Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
1 change: 1 addition & 0 deletions docs/source/openvino/models.mdx
Original file line number Diff line number Diff line change
Expand Up @@ -116,6 +116,7 @@ Here is the list of the supported architectures :
- MobileNet v1
- MobileNet v2
- MobileViT
- Nemotron-H (NVIDIA-Nemotron-3-Nano)
- Nystromformer
- OLMo
- OLMo 2
Expand Down
155 changes: 155 additions & 0 deletions optimum/exporters/openvino/_ov_ops.py
Original file line number Diff line number Diff line change
Expand Up @@ -111,3 +111,158 @@ def convert_recurrent_attention_cell(context):
final_output = ops.concat([core_attn_out_new, last_recurrent_state_new], 0)

return [final_output.output(0)]


# Conversion rule for the `Mamba2RecurrentCellOp` operation in a Torch graph.
#
# This generalizes the recurrent-cell-to-`ov::Loop` approach (originally introduced for the
# GatedDeltaNet block, see `convert_recurrent_attention_cell` above) to the Mamba2 selective
# state-space recurrence used by hybrid Mamba2 models such as NemotronH.
#
# The Mamba2 single-step recurrence follows the standard Mamba-2 discretization:
# dA_t = exp(A * dt_t) # [B, H] (broadcast)
# dBx_t = dt_t * B_t outer x_t # [B, H, P, N]
# state_t = state_{t-1} * dA_t + dBx_t # [B, H, P, N]
# y_t = reduce_sum(state_t * C_t, axis=N) # [B, H, P]
#
# The raw parameters A (log-decay), dt (time steps), B, x, C are passed directly into the
# loop; discretization (exp, outer product) happens per timestep inside the body.
# Inputs are in [B, T, H, ...] layout; the loop slices along dim 1 (T).
# The skip connection `x_t * D` does not depend on the recurrent state and is added outside.
#
# The `Mamba2RecurrentCellOp` appears in the Torch graph as a result of replacing the
# `Mamba2RecurrentCell` `torch.nn.Module` via a registered `ModuleExtension` in the OpenVINO
# PyTorch frontend; OpenVINO then applies this conversion rule to the resulting operation.
def convert_recurrent_mamba2_cell(context):
# Inputs match the forward signature of `Mamba2RecurrentCell`:
# A [H] — negative log-decay rates
# dt [B, T, H] — time steps
# B [B, T, G, N] — input matrix (G groups, expanded to H inside the loop)
# x [B, T, H, P] — input hidden states
# C [B, T, G, N] — output matrix (G groups, expanded to H inside the loop)
# last_state [B, H, P, N] — initial recurrent state
A = context.get_input(0) # [H]
dt = context.get_input(1) # [B, T, H]
B = context.get_input(2) # [B, T, G, N]
x = context.get_input(3) # [B, T, H, P]
C = context.get_input(4) # [B, T, G, N]
last_state_old = context.get_input(5) # [B, H, P, N]

const_zero_axis = ops.constant(0, dtype=np.int32)
const_one = ops.constant(1, dtype=np.int32)
const_minus_one = ops.constant(-1, dtype=np.int32)
const_minus_two = ops.constant(-2, dtype=np.int32)

# Compute heads_per_group = H / G from the shapes of dt and B.
dt_shape = ops.shape_of(dt)
B_shape = ops.shape_of(B)
const_two = ops.constant(2, dtype=np.int32)
num_heads = ops.gather(dt_shape, const_two, const_zero_axis) # H
num_groups = ops.gather(B_shape, const_two, const_zero_axis) # G
heads_per_group = ops.convert(ops.divide(num_heads, num_groups), "i64")

# Expand B/C from [B, T, G, N] → [B, T, H, N] by repeating each group.
# reshape to [B, T, G, 1, N] → tile [1, 1, 1, heads_per_group, 1] → reshape [B, T, H, N]
B_5d = ops.unsqueeze(B, ops.constant(3, dtype=np.int32)) # [B, T, G, 1, N]
C_5d = ops.unsqueeze(C, ops.constant(3, dtype=np.int32)) # [B, T, G, 1, N]
tile_shape = ops.concat(
[ops.constant([1, 1, 1], dtype=np.int64), ops.unsqueeze(heads_per_group, const_zero_axis),
ops.constant([1], dtype=np.int64)], 0
)
B_tiled = ops.tile(B_5d, tile_shape) # [B, T, G, H/G, N]
C_tiled = ops.tile(C_5d, tile_shape) # [B, T, G, H/G, N]

# Reshape [B, T, G, H/G, N] → [B, T, H, N]
x_shape = ops.shape_of(x)
target_4d = ops.gather(x_shape, ops.constant([0, 1], dtype=np.int32), const_zero_axis)
N_dim = ops.gather(B_shape, ops.constant(3, dtype=np.int32), const_zero_axis)
BC_shape = ops.concat([target_4d, ops.unsqueeze(num_heads, const_zero_axis),
ops.unsqueeze(N_dim, const_zero_axis)], 0)
B_expanded = ops.reshape(B_tiled, BC_shape, False) # [B, T, H, N]
C_expanded = ops.reshape(C_tiled, BC_shape, False) # [B, T, H, N]

# Build the zero-initialized output accumulator with shape [B, T, H, P].
core_shape = ops.gather(x_shape, ops.constant([0, 1, 2, 3], dtype=np.int32), const_zero_axis)
const_zero_f32 = ops.constant(0, dtype=np.float32)
core_out = ops.broadcast(const_zero_f32, core_shape)

# Trip count for the loop equals the sequence length (dim 1 of x).
seq_len = ops.gather(x_shape, const_one, const_zero_axis)
seq_len = ops.convert(seq_len, "i32")

# Body parameters (one timestep slice each along dim 1).
timestep_param = ops.parameter([], np.int32, "timestep")
dt_t_param = ops.parameter([-1, 1, -1], np.float32, "dt_t") # [B, 1, H]
B_t_param = ops.parameter([-1, 1, -1, -1], np.float32, "B_t") # [B, 1, H, N]
x_t_param = ops.parameter([-1, 1, -1, -1], np.float32, "x_t") # [B, 1, H, P]
C_t_param = ops.parameter([-1, 1, -1, -1], np.float32, "C_t") # [B, 1, H, N]
last_state_t = ops.parameter([-1, -1, -1, -1], np.float32, "last_state_t") # [B, H, P, N]
core_out_t = ops.parameter([-1, -1, -1, -1], np.float32, "core_out_t") # [B, T, H, P]

# Drop the singleton sequence dimension introduced by slicing.
dt_t = ops.squeeze(dt_t_param, const_one) # [B, H]
B_t = ops.squeeze(B_t_param, const_one) # [B, H, N]
x_t = ops.squeeze(x_t_param, const_one) # [B, H, P]
C_t = ops.squeeze(C_t_param, const_one) # [B, H, N]

# Discretization inside the loop body:
# dA_t = exp(A * dt_t) — A is [H], dt_t is [B, H] → broadcast to [B, H]
A_unsqueeze = ops.unsqueeze(A, const_zero_axis) # [1, H]
dA_t = ops.exp(ops.multiply(A_unsqueeze, dt_t)) # [B, H]
dA_t_4d = ops.unsqueeze(ops.unsqueeze(dA_t, const_minus_one), const_minus_one) # [B, H, 1, 1]

# dBx_t = (dt_t * B_t)[:,None,:] * x_t[:,:,None] → [B, H, P, N]
dt_B_t = ops.multiply(ops.unsqueeze(dt_t, const_minus_one), B_t) # [B, H, N]
dBx_t = ops.multiply(
ops.unsqueeze(dt_B_t, const_minus_two), # [B, H, 1, N]
ops.unsqueeze(x_t, const_minus_one), # [B, H, P, 1]
) # [B, H, P, N]

# state_t = state_{t-1} * dA_t + dBx_t
last_state_new = ops.add(ops.multiply(last_state_t, dA_t_4d), dBx_t) # [B, H, P, N]

# y_t = reduce_sum(state_t * C_t, axis=N) → [B, H, P]
y_t = ops.multiply(last_state_new, ops.unsqueeze(C_t, const_minus_two)) # [B, H, P, N]
y_t = ops.reduce_sum(y_t, const_minus_one, False) # [B, H, P]
y_t = ops.unsqueeze(y_t, const_one) # [B, 1, H, P]

timestep = ops.unsqueeze(timestep_param, const_zero_axis)
core_out_res = ops.scatter_update(core_out_t, timestep, y_t, const_one)
last_state_res = last_state_new

body_cond = ops.constant([True], dtype=bool)
body_model = ov.Model(
[body_cond, last_state_res, core_out_res],
[
timestep_param,
dt_t_param,
B_t_param,
x_t_param,
C_t_param,
last_state_t,
core_out_t,
],
"mamba2_body_model",
)

loop = ops.loop(seq_len, ops.constant(True, dtype="bool"))
loop.set_function(body_model)

loop.set_sliced_input(dt_t_param, dt, 0, 1, 1, -1, 1)
loop.set_sliced_input(B_t_param, B_expanded.output(0), 0, 1, 1, -1, 1)
loop.set_sliced_input(x_t_param, x, 0, 1, 1, -1, 1)
loop.set_sliced_input(C_t_param, C_expanded.output(0), 0, 1, 1, -1, 1)
loop.set_merged_input(last_state_t, last_state_old, last_state_res.output(0))
loop.set_merged_input(core_out_t, core_out.output(0), core_out_res.output(0))
loop.set_special_body_ports([0, 0])

core_out_new = loop.get_iter_value(core_out_res.output(0), -1)
last_state_new = loop.get_iter_value(last_state_res.output(0), -1)

flatten_shape = ops.constant([-1], dtype=np.int32)
core_out_new = ops.reshape(core_out_new, flatten_shape, False)
last_state_new = ops.reshape(last_state_new, flatten_shape, False)

final_output = ops.concat([core_out_new, last_state_new], 0)

return [final_output.output(0)]
69 changes: 69 additions & 0 deletions optimum/exporters/openvino/input_generators.py
Original file line number Diff line number Diff line change
Expand Up @@ -1705,6 +1705,75 @@ def generate(self, input_name: str, framework: str = "pt", int_dtype: str = "int
return cache_params


class NemotronHDummyPastKeyValuesGenerator(DummyPastKeyValuesGenerator):
"""
Generates dummy cache_params inputs for NemotronH (hybrid Mamba2 + Attention + MoE) architectures.

The cache is a flat list of tensors ordered as:
* for each mamba layer: conv_state, recurrent_state
* for each attention layer: key cache, value cache
"""

SUPPORTED_INPUT_NAMES = ("cache_params",)

def __init__(
self,
task: str,
normalized_config,
batch_size: int = DEFAULT_DUMMY_SHAPES["batch_size"],
sequence_length: int = DEFAULT_DUMMY_SHAPES["sequence_length"],
**kwargs,
):
super().__init__(
task=task,
normalized_config=normalized_config,
batch_size=batch_size,
sequence_length=sequence_length,
**kwargs,
)

config = normalized_config.config
self.layers_block_type = config.layers_block_type
self.num_mamba_layers = config.layers_block_type.count("mamba")
self.num_attn_layers = config.layers_block_type.count("attention")

self.conv_kernel_size = config.conv_kernel
self.ssm_state_size = config.ssm_state_size
self.n_groups = config.n_groups
self.mamba_num_heads = config.mamba_num_heads
self.mamba_head_dim = config.mamba_head_dim
self.intermediate_size = config.mamba_num_heads * config.mamba_head_dim
self.conv_dim = self.intermediate_size + 2 * self.n_groups * self.ssm_state_size

self.num_key_value_heads = config.num_key_value_heads
self.head_dim = getattr(config, "head_dim", config.hidden_size // config.num_attention_heads)

def generate(self, input_name: str, framework: str = "pt", int_dtype: str = "int64", float_dtype: str = "fp32"):
cache_params = []

for _ in range(self.num_mamba_layers):
conv_state_shape = (self.batch_size, self.conv_dim, self.conv_kernel_size)
conv_state = self.random_float_tensor(conv_state_shape, framework=framework, dtype=float_dtype)
cache_params.append(conv_state)
recurrent_state_shape = (
self.batch_size,
self.mamba_num_heads,
self.mamba_head_dim,
self.ssm_state_size,
)
recurrent_state = self.random_float_tensor(recurrent_state_shape, framework=framework, dtype=float_dtype)
cache_params.append(recurrent_state)

for _ in range(self.num_attn_layers):
kv_shape = (self.batch_size, self.num_key_value_heads, self.sequence_length, self.head_dim)
k = self.random_float_tensor(kv_shape, framework=framework, dtype=float_dtype)
v = self.random_float_tensor(kv_shape, framework=framework, dtype=float_dtype)
cache_params.append(k)
cache_params.append(v)

return cache_params


class Qwen3_5DummyPastKeyValuesGenerator(DummyPastKeyValuesGenerator):
"""
Generates dummy cache_params inputs for Qwen3.5 architectures.
Expand Down
84 changes: 80 additions & 4 deletions optimum/exporters/openvino/model_configs.py
Original file line number Diff line number Diff line change
Expand Up @@ -81,6 +81,7 @@
LTXTransformerDummyInputGenerator,
LTXVaeDummyInputGenerator,
MambaCacheDummyInputGenerator,
NemotronHDummyPastKeyValuesGenerator,
OVFalconDummyPastKeyValuesGenerator,
OVMiniCPM3DummyPastKeyValuesGenerator,
PooledProjectionsDummyInputGenerator,
Expand Down Expand Up @@ -145,6 +146,7 @@
MixtralModelPatcher,
ModelPatcher,
MPTModelPatcher,
NemotronHModelPatcher,
OVDecoderModelPatcher,
OVSeq2SeqModelPatcher,
Phi3ModelPatcher,
Expand Down Expand Up @@ -5413,10 +5415,6 @@ class GraniteMoeHybridOpenVINOConfig(MambaOpenVINOConfig):
NORMALIZED_CONFIG_CLASS = NormalizedTextConfig
_MODEL_PATCHER = GraniteMoeHybridModelPatcher

def __init__(self, *args, **kwargs):
super().__init__(*args, **kwargs)
_warn_potential_accuracy_issue_ov_2026_1("granitemoehybrid")

def add_past_key_values(self, inputs_or_outputs: Dict[str, Dict[int, str]], direction: str):
if direction not in ["inputs", "outputs"]:
raise ValueError(f'direction must either be "inputs" or "outputs", but {direction} was given')
Expand Down Expand Up @@ -6388,6 +6386,84 @@ def generate_dummy_inputs(self, framework: str = "pt", **kwargs):
return dummy_inputs


@register_in_tasks_manager(
"nemotron_h",
*["text-generation", "text-generation-with-past"],
library_name="transformers",
)
class NemotronHOpenVINOConfig(TextDecoderOpenVINOConfig):
DUMMY_INPUT_GENERATOR_CLASSES = (DummyTextInputGenerator, NemotronHDummyPastKeyValuesGenerator)
DUMMY_PKV_GENERATOR_CLASS = NemotronHDummyPastKeyValuesGenerator
NORMALIZED_CONFIG_CLASS = NormalizedTextConfig
MIN_TRANSFORMERS_VERSION = "5.0.0"
_MODEL_PATCHER = NemotronHModelPatcher

def add_past_key_values(self, inputs_or_outputs: Dict[str, Dict[int, str]], direction: str):
if direction not in ["inputs", "outputs"]:
raise ValueError(f'direction must either be "inputs" or "outputs", but {direction} was given')

if direction == "inputs":
decoder_sequence_name = "past_sequence_length"
cache_name_prefix = "cache_params.past"
else:
decoder_sequence_name = "past_sequence_length + sequence_length"
cache_name_prefix = "cache_params.present"

layers_block_type = self._normalized_config.layers_block_type
self.num_mamba_layers = layers_block_type.count("mamba")
self.num_attn_layers = layers_block_type.count("attention")

for i in range(self.num_mamba_layers):
# [batch_size, conv_dim, conv_kernel_size]
inputs_or_outputs[f"{cache_name_prefix}.conv.{i}"] = {0: "batch_size"}
# [batch_size, mamba_num_heads, mamba_head_dim, ssm_state_size]
inputs_or_outputs[f"{cache_name_prefix}.ssm.{i}"] = {0: "batch_size"}

for i in range(self.num_attn_layers):
inputs_or_outputs[f"{cache_name_prefix}.key.{i}"] = {0: "batch_size", 2: decoder_sequence_name}
inputs_or_outputs[f"{cache_name_prefix}.value.{i}"] = {0: "batch_size", 2: decoder_sequence_name}

@property
def inputs(self) -> Dict[str, Dict[int, str]]:
common_inputs = {
"input_ids": {0: "batch_size", 1: "sequence_length"},
"attention_mask": {0: "batch_size", 1: "sequence_length"},
}
if self.use_past_in_inputs:
self.add_past_key_values(common_inputs, direction="inputs")
return common_inputs

def generate_dummy_inputs(self, framework: str = "pt", **kwargs):
# need to override `generate_dummy_inputs` since the hybrid Mamba2 model exposes
# conv/recurrent states (for mamba layers) and key/value caches (for attention layers)
# bundled together under a single flat `cache_params` input.
dummy_inputs_generators = self._create_dummy_input_generator_classes(**kwargs)

dummy_inputs = {}
input_names = [key for key in self.inputs.keys() if not key.startswith("cache_params")]
if self.use_past_in_inputs:
input_names.extend(["cache_params"])

for input_name in input_names:
input_was_inserted = False
for dummy_input_gen in dummy_inputs_generators:
if dummy_input_gen.supports_input(input_name):
dummy_inputs[input_name] = self.overwrite_shape_and_generate_input(
dummy_input_gen,
input_name,
framework,
input_shapes=kwargs,
)
input_was_inserted = True
break
if not input_was_inserted:
raise RuntimeError(
f'Could not generate dummy input for "{input_name}". Try adding a proper dummy input generator to the model OpenVINO config.'
)

return dummy_inputs


@register_in_tasks_manager(
"lfm2_moe",
*[
Expand Down
Loading