Skip to content
Merged
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
15 changes: 15 additions & 0 deletions src/config.cpp
Original file line number Diff line number Diff line change
Expand Up @@ -365,6 +365,10 @@ struct DecoderInputs_Element : JSON::Element {
v_.past_conv_names = JSON::Get<std::string_view>(value);
} else if (name == "past_recurrent_names") {
v_.past_recurrent_names = JSON::Get<std::string_view>(value);
} else if (name == "state_update_capture_count") {
v_.state_update_capture_count = JSON::Get<std::string_view>(value);
} else if (name == "state_update_active") {
v_.state_update_active = JSON::Get<std::string_view>(value);
} else if (name == "hidden_states") {
v_.hidden_states = JSON::Get<std::string_view>(value);
} else if (name == "targets") {
Expand Down Expand Up @@ -406,6 +410,10 @@ struct DecoderOutputs_Element : JSON::Element {
v_.present_conv_names = JSON::Get<std::string_view>(value);
} else if (name == "present_recurrent_names") {
v_.present_recurrent_names = JSON::Get<std::string_view>(value);
} else if (name == "state_update_conv_value_names") {
v_.state_update_conv_value_names = JSON::Get<std::string_view>(value);
} else if (name == "state_update_recurrent_capsule_names") {
v_.state_update_recurrent_capsule_names = JSON::Get<std::string_view>(value);
} else if (name == "hidden_states") {
v_.hidden_states = JSON::Get<std::string_view>(value);
} else if (name == "outputs") {
Expand Down Expand Up @@ -747,6 +755,13 @@ struct Decoder_Element : JSON::Element {
v_.num_key_value_heads = SafeDoubleToInt(JSON::Get<double>(value), name);
} else if (name == "head_size") {
v_.head_size = SafeDoubleToInt(JSON::Get<double>(value), name);
} else if (name == "state_update_capacity") {
// The kernel packs every captured transition for a layer into one fixed-width capsule output.
// Keep this limit synchronized with check_extra_options in builder.py and the model-builder README.
constexpr int kMaxStateUpdateCapacity = 8;
v_.state_update_capacity = SafeDoubleToInt(JSON::Get<double>(value), name);
if (v_.state_update_capacity < 0 || v_.state_update_capacity > kMaxStateUpdateCapacity)
throw std::runtime_error("state_update_capacity must be between 0 and " + std::to_string(kMaxStateUpdateCapacity));
} else if (name == "conv_cache_size") {
v_.conv_cache_size = SafeDoubleToInt(JSON::Get<double>(value), name);
} else {
Expand Down
11 changes: 11 additions & 0 deletions src/config.h
Original file line number Diff line number Diff line change
Expand Up @@ -30,6 +30,10 @@ struct Config {
static constexpr std::string_view PresentValueName = "present.%d.value";
static constexpr std::string_view PresentConvName = "present.%d.conv";
static constexpr std::string_view PresentRecurrentName = "present.%d.recurrent";
static constexpr std::string_view StateUpdateCaptureCountName = "state_update_capture_count";
static constexpr std::string_view StateUpdateActiveName = "state_update_active";
static constexpr std::string_view StateUpdateConvValueName = "state_update.%d.conv_value";
static constexpr std::string_view StateUpdateRecurrentCapsuleName = "state_update.%d.recurrent_capsule";
static constexpr std::string_view HiddenStatesName = "hidden_states";
static constexpr std::string_view RnnStatesName = "rnn_states";
static constexpr std::string_view RnnStatesPrevName = "rnn_states_prev";
Expand Down Expand Up @@ -360,6 +364,9 @@ struct Config {
int num_key_value_heads{};
int num_hidden_layers{};
int head_size{};
// Compact per-token state transitions a forward captures so a partial accept can be replayed
// without rerunning the model. 0 means the model does not export the state-update bindings.
int state_update_capacity{};

// Hybrid SSM+Attention (LFM2) parameters
std::vector<std::string> layer_types; // Per-layer type: "conv" or "full_attention"
Expand Down Expand Up @@ -423,6 +430,8 @@ struct Config {
std::string attention_metadata{Defaults::AttentionMetadataName};
std::string past_conv_names{Defaults::PastConvName}; // Conv cache input name template (LFM2)
std::string past_recurrent_names{Defaults::PastRecurrentName};
std::string state_update_capture_count{Defaults::StateUpdateCaptureCountName}; // Per-sequence capture count
std::string state_update_active{Defaults::StateUpdateActiveName}; // Capture enable flag

// Last hidden-state input (e.g. the MTP head consumes the main model's hidden state).
// Empty unless the model graph takes a hidden_states input.
Expand All @@ -449,6 +458,8 @@ struct Config {
std::string rnn_states{Defaults::RnnStatesName};
std::string present_conv_names{Defaults::PresentConvName}; // Conv cache output name template (LFM2)
std::string present_recurrent_names{Defaults::PresentRecurrentName};
std::string state_update_conv_value_names{Defaults::StateUpdateConvValueName};
std::string state_update_recurrent_capsule_names{Defaults::StateUpdateRecurrentCapsuleName};
std::string hidden_states; // Last hidden state output (when exported with include_hidden_states; e.g. fed to the MTP head)

// RNNT decoder outputs
Expand Down
30 changes: 28 additions & 2 deletions src/python/py/models/README.md
Original file line number Diff line number Diff line change
Expand Up @@ -29,6 +29,8 @@ This folder contains the model builder for quickly creating optimized and quanti
- [Enable CUDA Graph Capture](#enable-cuda-graph-capture)
- [Export a ModelOpt or compressed-tensors NVFP4/FP8 Checkpoint](#export-a-modelopt-or-compressed-tensors-nvfp4fp8-checkpoint)
- [MTP Head (Qwen3.6)](#mtp-head-qwen36)
- [Compact State Updates (Qwen3.5/3.8)](#compact-state-updates-qwen3538)
- [Select the Qwen3.5/3.8 Recurrent Operator](#select-the-qwen3538-recurrent-operator)
- [Enable WebGPU Graph Capture](#enable-webgpu-graph-capture)
- [Disable QKV Projections Fusion](#disable-qkv-projections-fusion)
- [Disable QK Norm GQA Fusion in CUDA or WebGPU](#disable-qk-norm-gqa-fusion-in-cuda-or-webgpu)
Expand Down Expand Up @@ -293,11 +295,11 @@ python builder.py -i path_to_local_folder_on_disk -o path_to_output_folder -p pr

#### Build with Paged Attention

This scenario is for when you want to build a model that uses the `PagedAttention` operator so it can be served by ONNX Runtime GenAI's continuous-batching engine. When enabled, the builder replaces `GroupQueryAttention` with `PagedAttention`, packs all sequences of the batch into a single flattened token axis (`input_ids` becomes 1D), stores the KV-cache in paged `[num_blocks, block_size, num_key_value_heads, head_size]` buffers, and removes the `attention_mask` and `position_ids` inputs in favor of the `block_table`, `cumulative_sequence_lengths`, and `past_sequence_lengths` metadata inputs. Set `prune_lm_head=true` to select the final packed hidden state for each sequence before the LM head and output `[batch_size, vocab_size]` logits. By default, it projects every packed hidden state and outputs `[num_tokens, vocab_size]` logits.
This scenario is for when you want to build a model that uses the `PagedAttention` operator so it can be served by ONNX Runtime GenAI's continuous-batching engine. When enabled, the builder replaces `GroupQueryAttention` with `PagedAttention`, packs all sequences of the batch into a single flattened token axis (`input_ids` becomes 1D), stores the KV-cache in paged `[num_blocks, block_size, num_key_value_heads, head_size]` buffers, and removes the `attention_mask` input in favor of the `block_table`, `cumulative_sequence_lengths`, and `past_sequence_lengths` metadata inputs. It also removes `position_ids` when RoPE is fused into attention; architectures that require an external MRoPE op retain packed position IDs (for example, Qwen3.5/3.8 uses `[3, num_tokens]`). Set `prune_lm_head=true` to select the final packed hidden state for each sequence before the LM head and output `[batch_size, vocab_size]` logits. By default, it projects every packed hidden state and outputs `[num_tokens, vocab_size]` logits.

Paged attention supports CUDA with `fp16` or `bf16` precision and WebGPU with `fp16` precision. Paged exports include the CPU `attention_metadata` input used by the runtime to provide stable query and KV bounds without downloading device sequence lengths in every attention layer. Paged attention cannot be combined with `exclude_embeds` or `exclude_lm_head`. `paged_block_size` defaults to `256` and must be a positive multiple of `256`; for models with short and long rotary caches, it must evenly divide `original_max_position_embeddings`. `gpu_utilization_factor` defaults to `0.6` and must be greater than `0` and at most `1`. `max_batch_size` defaults to `100` and must be a positive integer no greater than `256`. `paged_chunk_size` defaults to `paged_block_size`, must be a positive integer, and is written to `search.chunk_size`; it applies only to models whose sliding-window layers are served from a ring of blocks, which hold `paged_chunk_size + window_size - 1` positions and therefore require chunked prefill.

Paged builds can describe non-legacy decoder state in `model.decoder.state_groups`. The Qwen hybrid builder emits exact logical layer IDs for sparse paged KV, fixed convolution state, and fixed recurrent state. Tensor name templates are emitted once under the decoder's `inputs` and `outputs`. Legacy models whose every decoder layer uses paged KV omit the manifest and preserve the existing implicit contract. Manifest metadata describes the graph contract; Engine support for a state kind still depends on the runtime implementation.
Paged builds can describe non-legacy decoder state in `model.decoder.state_groups`. The Qwen hybrid builder emits exact logical layer IDs for sparse paged KV, fixed convolution state, and fixed recurrent state. Tensor name templates are emitted once under the decoder's `inputs` and `outputs`. Legacy models whose every decoder layer uses paged KV omit the manifest and preserve the existing implicit contract. The hybrid state manifest is experimental and its schema is not yet stable. It requires coordinated Engine runtime work beyond the current onnxruntime-genai#2454 head and is not compatible with the merged runtime on its own. In particular, the runtime must supply packed multimodal position IDs with shape `[3, num_tokens]`; the current `VarlenDecoderIO` does not create that input.

```bash
# From wheel:
Expand Down Expand Up @@ -412,6 +414,30 @@ The head always exports `hidden_states_out` (its own post-final-norm hidden stat

A multi-token verify forward can additionally carry a window of recurrent/conv states so a partial accept can be handled by cropping instead of replaying the main model. Pass `state_window=W` (with `W >= num_speculative_tokens + 1`) to widen `past/present_key_values.%d.{conv,recurrent}_state` to `[W, B, ...]` and emit the matching attribute on `CausalConvWithState` / `LinearAttention`. This requires ONNX Runtime kernels that understand the attribute; leave it at the default `0` otherwise.

#### Compact State Updates (Qwen3.5/3.8)

Paged Qwen3.5/3.8 exports can capture compact convolution and GatedDeltaNet transitions for speculative tokens instead of returning full recurrent-state checkpoints. Set `state_update_capacity=N` to reserve updates for up to `N` tokens. The capacity defaults to `0` (disabled) and requires `use_paged_attention=true`. It must be an integer from `0` through `8`, matching the kernel and runtime-parser bound, because the kernel packs every captured transition for a layer into a single fixed-width capsule output. Paged Qwen3.5/3.8 exports use GatedDeltaNet regardless of `linear_attn_op` and support CUDA with `fp16` or `bf16` model I/O. When enabled, `genai_config.json` records the capacity, the `state_update_capture_count` and `state_update_active` input bindings, and the per-layer convolution-value and recurrent-capsule output templates. All compact state-update inputs and outputs are omitted when `state_update_capacity=0`.

```bash
# From wheel:
python -m onnxruntime_genai.models.builder -m model_name -o path_to_output_folder -p bf16 -e cuda -c cache_dir_for_hf_files --extra_options use_paged_attention=true state_update_capacity=3

# From source:
python builder.py -m model_name -o path_to_output_folder -p bf16 -e cuda -c cache_dir_for_hf_files --extra_options use_paged_attention=true state_update_capacity=3
```

#### Select the Qwen3.5/3.8 Recurrent Operator

This scenario is for when you want to choose which contrib operator implements the linear-attention layers of a non-paged Qwen3.5/3.8 export. `linear_attn_op` accepts `linear_attention` (the default), which emits `CausalConvWithState` + `LinearAttention`, or `gated_delta_net`, which emits `CausalConvWithState` + `GatedDeltaNet` with an FP32 V-major recurrent state and native Qwen gate arithmetic from the raw `A_log`/`dt_bias` initializers. `gated_delta_net` is CUDA-only, requires `state_window=0`, and supports `fp16` or `bf16` model I/O. Paged exports (`use_paged_attention=true`) always use GatedDeltaNet and therefore also require CUDA; they ignore this option. The default `linear_attention` path requires an ONNX Runtime kernel that implements the selected contrib operator.

```bash
# From wheel:
python -m onnxruntime_genai.models.builder -m model_name -o path_to_output_folder -p bf16 -e cuda -c cache_dir_for_hf_files --extra_options linear_attn_op=gated_delta_net

# From source:
python builder.py -m model_name -o path_to_output_folder -p bf16 -e cuda -c cache_dir_for_hf_files --extra_options linear_attn_op=gated_delta_net
```

#### Enable WebGPU Graph Capture

This scenario is for when you want to enable WebGPU graph capture for your ONNX model.
Expand Down
41 changes: 34 additions & 7 deletions src/python/py/models/builder.py
Original file line number Diff line number Diff line change
Expand Up @@ -174,6 +174,22 @@ def check_extra_options(
raise ValueError("state_window must be a non-negative integer.")
extra_options["state_window"] = state_window

if "state_update_capacity" in extra_options:
# The kernel packs every captured transition into one fixed-width capsule per layer, so the
# number of tokens a single forward can record is bounded by that capsule. Keep this limit
# synchronized with kMaxStateUpdateCapacity in src/config.cpp and the model-builder README.
max_state_update_capacity = 8
message = f"state_update_capacity must be an integer from 0 through {max_state_update_capacity}."
try:
state_update_capacity = int(extra_options["state_update_capacity"])
except (TypeError, ValueError) as e:
raise ValueError(message) from e
if not 0 <= state_update_capacity <= max_state_update_capacity:
raise ValueError(message)
if state_update_capacity and not extra_options.get("use_paged_attention", False):
raise ValueError("state_update_capacity requires use_paged_attention=true.")
extra_options["state_update_capacity"] = state_update_capacity

if "mtp_quant_config" in extra_options:
mtp_quant_config = extra_options["mtp_quant_config"]
if not isinstance(mtp_quant_config, QuantConfig):
Expand Down Expand Up @@ -737,18 +753,29 @@ def get_args():
layer i, so indices must lie in [1, num_hidden_layers). Default is empty (disabled).
mtp_quant_config = JSON object/file: Configure MTP I/O, dense weights, MoE, and runtime using the
structured QuantConfig schema independently from the main model.
state_window = Widen Qwen3.6 recurrent/conv state I/O to [W, B, ...]. Default is 0 (disabled).
linear_attn_op = linear_attention/gated_delta_net: Select the recurrent operator for non-paged
Qwen3.5/3.8 exports. Default is linear_attention. Paged exports always use GatedDeltaNet and
ignore this option. gated_delta_net is CUDA-only, requires state_window=0, and supports fp16
or bf16 I/O.
state_update_capacity = Number of compact Qwen3.5/3.8 state updates to capture. Default is 0 (disabled).
Must be an integer from 0 through 8 and requires use_paged_attention=true. This experimental
contract requires Engine runtime work beyond the current onnxruntime-genai#2454 head.
state_window = Configure hybrid Qwen recurrent/conv state history. Default is 0 (disabled).
Must be a non-negative integer. For MTP verification, W must be at least num_speculative_tokens + 1.
Qwen3.5/3.8 exports using GatedDeltaNet (paged, or linear_attn_op=gated_delta_net) require
state_window=0.
Requires ONNX Runtime kernels that implement this attribute.
use_paged_attention = Build the model with PagedAttention for the continuous-batching engine. Default is false.
Replaces GroupQueryAttention with the PagedAttention contrib op, packs all sequences into a single
flattened token axis (`input_ids` becomes 1D), stores the KV-cache in paged
[num_blocks, block_size, num_kv_heads, head_size] buffers, and removes the `attention_mask` and
`position_ids` inputs in favor of the `block_table`, `cumulative_sequence_lengths`, and
`past_sequence_lengths` metadata inputs. With prune_lm_head=true, selects the final packed hidden
state for each sequence so the model outputs [batch_size, vocab_size] logits. By default, the model
outputs [num_tokens, vocab_size] logits. Currently only supported for the CUDA execution provider
with fp16 or bf16 precision. Cannot be combined with exclude_embeds or exclude_lm_head.
[num_blocks, block_size, num_kv_heads, head_size] buffers, and removes the `attention_mask` input.
It also removes `position_ids` when RoPE is fused; architectures with external MRoPE retain packed
position IDs (for example, Qwen3.5/3.8 uses [3, num_tokens]). The block_table,
cumulative_sequence_lengths, and past_sequence_lengths metadata inputs are added. With
prune_lm_head=true, selects the final packed hidden state for each sequence so the model outputs
[batch_size, vocab_size] logits. By default, the model outputs [num_tokens, vocab_size] logits.
Currently only supported for the CUDA execution provider with fp16 or bf16 precision. Cannot be
combined with exclude_embeds or exclude_lm_head.
paged_block_size = 256/512/768/...: Paged KV-cache block size used when use_paged_attention is set.
Must be a positive multiple of 256 (required by the ONNX Runtime PagedAttention CUDA kernel).
Default is 256. Also written to the `engine.dynamic_batching` section of genai_config.json.
Expand Down
Loading
Loading