Skip to content

Commit 5b99c07

Browse files
Exporters: ONNX Runtime fusions at export time (OnnxConfig(ort_fusions=...)) (#50309)
* Exporters: feed SDPA's boolean mask to ONNX as an additive one torchlib guards its boolean-mask softmax with an IsNaN/Where over the whole (query, key) weight matrix; the patch already zeroes fully masked rows, so the guard only costs time (2x on a 2520-patch vision attention on ORT CPU). Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com> * Exporters: fuse ONNX exports into ONNX Runtime's ops at export time `OnnxConfig(ort_fusions="cpu" | "cuda")` fuses the exported graph into ONNX Runtime's transformer ops before it is quantized and saved: RMS / layer / skip norms, rotary embeddings, MultiHeadAttention, GroupQueryAttention over the key-value cache (rotating itself), MoE (CUDA), and packed sibling projections. onnxscript's `optimize_for_ort` does the core fusions; `onnxruntime_fusions.py` prepares the graph for them, repairs the ones ONNX Runtime rejects or miscomputes, and adds the rest. Off by default (`None`): the fused ops aren't standard ONNX. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com> * Exporters: explain the ORT fusion tests, plainer docs Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com> * Exporters: grouped_mm without Slices; fuse float16 MoE blocks The grouped_mm translation takes each group's end and weights from one Split and gathers its rows from a Range: onnxscript's SlicesSplit rule tries every pair of Slices in the graph, and a Slice per expert and layer made it most of an MoE export's time (granite 1B-A400M: export 23 min -> 5 min, an ORT fusion pass 13 min -> 2 min). The MoE fusion steps over the casts a float16 model's router and expert sum compute through in float32, so real checkpoints fuse, not only float32 ones. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com> * Exporters: on CPU, unfuse the attentions GroupQueryAttention didn't take ONNX Runtime's CPU MultiHeadAttention with an attention bias runs slower than the MatMuls and Softmax it fuses (2.5x on Qwen3-0.6B's decode step), so for `ort_fusions="cpu"` the ones left after the GroupQueryAttention fusion (encoders, attention without a cache) go back to plain ops. EmbeddingGemma2 on CPU: 0.98x -> 1.17x. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com> * Exporters: ORT fusions on ONNX Runtime 1.29 - Graphs fused for "cuda" need onnxruntime>=1.27 (fp32 accumulation in the CUDA skip norms, microsoft/onnxruntime#28682): the fp16 skip-norm unfusing is dropped, with a warning when an older version is installed. - GroupQueryAttention: a known total length goes in as an int32 constant (1.29's shape inference reads one computed from shapes as int64), and a decode taking any number of tokens is fused only for a batch of one (1.29 rejects several new tokens after a past for a larger batch). It's a CPU-only step: CUDA's rejects the bias every masked attention has. - MoE: SiLU-gated experts only; 1.29's CUDA MoE miscomputes gated GELU. - The pipeline is a `(name, step, providers)` table. - The export tests fuse for the provider they run on (`torch_device`). Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com> * Exporters: trace MoE experts as batched_mm for CUDA fusions With `ort_fusions="cuda"`, the ONNX exporter traces mixture-of-experts layers with `batched_mm` (restoring the model's own implementation after), and the `MoE` fusion matches that form: each routed row's expert weight picked by `GatherND`, a batched `MatMul`, the SiLU gate, the weighted sum per token. Its graph is a fraction of `grouped_mm`'s per-expert one, so Granite 3.0 1B-A400M exports in 69 s instead of 302 s and fuses in 15 s instead of 130 s, to the same `MoE` graph. `"cpu"` and unfused exports keep `grouped_mm`, whose prefill stays fast unfused. A warning says when a block doesn't fuse. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com> * Exporters: GroupQueryAttention on CUDA for decode with a dynamic cache On ONNX Runtime 1.29, CUDA's `GroupQueryAttention` takes the attention bias and is exact. It's a large win for decode (Qwen3-0.6B: 7.48 -> 4.71 ms, 3.71x with a CUDA graph) but slower than `MultiHeadAttention` for a prefill, and a static cache's total length, computed on the GPU, is read as 0 (microsoft/onnxruntime#33867). So "cuda" fuses decode steps over a dynamic cache only; "cpu" fuses every case as before. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com> * Exporters: CUDA GroupQueryAttention for static caches too A static cache attends over its whole buffer: `total_sequence_length` is the buffer's length as an int32 constant, the bias spans the buffer (the cache's mask already masks the slots not written), and `seqlens_k` carries each sequence's written length. A constant never lives on the GPU, which CUDA's kernel reads as 0 (microsoft/onnxruntime#33867), so static caches fuse on CUDA too. CUDA's step stays decode-only: with a bias, its prefill runs an unfused kernel, slower than `MultiHeadAttention`'s. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com> * Exporters: GroupQueryAttention over transformers' folded decode attention Since #49743, a single-token SDPA step without `enable_gqa` folds the query heads onto their key-value heads (`[batch, kv_heads, groups, head_dim]`) instead of repeating the keys and values, which onnxscript fuses into a `MultiHeadAttention` over the key-value heads. The `GroupQueryAttention` fusion reads that form too: the query heads behind the fold, the bias behind its expand over the group rows, `num_heads` the key-value heads times the groups, its output reshaped back to the folded layout. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com> * Exporters: pad GroupQueryAttention's rotary caches with a Concat ONNX Runtime's CUDA provider has no bfloat16 `Pad`, so a bfloat16 graph whose `GroupQueryAttention` rotates with a per-token cache failed to load with the CUDA provider alone. The zero rows are appended with a `Concat` instead. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com> * Exporters: fuse GroupQueryAttention into dynamic decode graphs - The SDPA patch checks that the key, value and mask lengths are equal, which SDPA requires but doesn't check under export. A dynamic export then gives all caches one length symbol, so onnxscript's attention fusion matches every layer. - A rule turns torchlib's dynamic key transpose (Reshape, Transpose, Reshape with Shape-derived targets) into one Transpose. - Shape nodes reading the same dims are merged before CSE, so each layer's GroupQueryAttention lengths are computed once instead of 28 times with a host-to-device copy each. - The integer Equal rewrite is dropped: the CUDA provider has Equal kernels at every opset. - Comments cite microsoft/onnxruntime#33873-#33876. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com> * Exporters: drop layout transposes around attention, fold q/k norms into GQA - A new step moves the transposes between [batch, length, heads, head_dim] and [batch, heads, length, head_dim] past rotary embeddings (run on the 3D layout), the key-value head repeat and the zeroing of unattended rows, until they cancel: MultiHeadAttention reads and writes the first layout. Qwen3's prefill keeps 2 transposes per layer instead of 8, about 1 ms faster on an A100. - On CUDA, decode GroupQueryAttention takes Qwen3's per-head query and key RMS norms as q_norm_weight / k_norm_weight, in half precision (CUDA's kernel is the one with them). The "cuda" minimum becomes onnxruntime 1.28, the first release with these inputs. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com> * Exporters: hand GroupQueryAttention its packed q/k/v projection When a GroupQueryAttention's query, key and value are the three outputs of the split after a packed projection, it now reads that projection packed, with key and value left empty: no Split, no reshapes. Outputs are bitwise identical; Qwen3 decode on an A100 goes from 4.71 to 4.53 ms (3.20 to 3.14 ms with a CUDA graph). Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com> * Exporters: simplify the ONNX Runtime fusions; tests in test_fusions.py - One `_add_node` helper builds and inserts the fusions' new nodes, and one `_merged_heads` helper matches the attention's merged heads. - The repeated key-value heads broadcast to a constant shape, instead of one built from the states' shape at run time. - GroupQueryAttention looks for per-head norms only on a key in its own layout. - The SDPA patch returns early without a mask; the grouped_mm translation splits its per-group inputs with one helper. - The module docstring, docs and version warning describe the current steps. - The ONNX Runtime fusion tests move to tests/exporters/test_fusions.py, one class per backend, and check that GroupQueryAttention reads the packed projection. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com> * Docs: drop the onnxruntime-gpu installation details from the fusions section Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com> * Exporters: inline three single-use fusion helpers `_unshare_key_transposes`, `_same_dims` and `_merge_equal_shapes` each had one caller, a few lines long; their bodies now sit there. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com> * Exporters: test the ORT fusions per layer on each family's tiny model; fuse float16 MoE - tests/exporters/test_fusions.py exports the tiny model of each decoder family's own tests (Llama, Qwen2, Qwen3, Phi-3, OLMo2, Granite, Mistral, Gemma 3, Mixtral, Qwen3-MoE), the same table as the OpenVINO fusion tests, and checks that every layer of the prefill and the decode runs ONNX Runtime's attention and, on CUDA, its `MoE`, with no rotary, attention or RMS norm left decomposed, for both providers. - Those tests found float16 mixture-of-experts blocks that didn't fuse: the experts' outputs weighted in float32 (Mixtral) and a router taking its top-k in float32 (Qwen3-MoE). The `MoE` fusion now steps over both casts. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com> * Exporters: give sliding-window cache layers a full cache during export The same patch as the OpenVINO exporter's (#50311): a `DynamicSlidingWindowLayer` gets a `DynamicLayer`'s update and mask sizes during the trace, and the sliding mask still applies the window. Temporary, until #49542 keeps sliding-window decode on one graph. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com> * Exporters: name fuse_for_onnxruntime's target `provider`, not `device` It's the ONNX Runtime execution provider the graph is fused for (each step's `providers`), not the device the export runs on. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com> * Exporters: call fuse_for_onnxruntime's target `ort_fusions`, as in the config Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com> * Exporters: prepare the model for ORT fusions like ExecuTorch's backends, and warn about unfused MoE in the fusion `prepare_for_ort_fusions` sets the experts to `batched_mm` for "cuda", under the same condition as fusing (which also skips FX-graph quantizers). `fuse_for_onnxruntime` takes the original model to warn about mixture-of-experts blocks left unfused. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com> * Exporters: add ort_fusions="webgpu" for ONNX Runtime's WebGPU provider WebGPU's GroupQueryAttention ignores position_ids when it rotates, so rotary stays a separate RotaryEmbedding there, and it has no FusedMatMul, so those go back to MatMul. MoE stays unfused (not in onnxruntime-webgpu 1.27). The docs list what each provider fuses in a table. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com> * Exporters: say the ORT fusion table is what each provider supports, with the two slowdowns as temporary Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com> * Exporters: cite the ORT issues behind the two fusions skipped for speed Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com> * Exporters: shorter sliding-window patch docstring; build the fusion tests' models in their dtype Matches #50311 after its review: `from_config(dtype=...)` keeps the float32 buffers a model builds (rotary `inv_freq`) float32, which casting after didn't. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com> * Tests: free the ORT fusion tests' memory with MemoryCleanupMixin; docs: the full sliding-window cache Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com> * Exporters: one `ort_fusions` for the ONNX export's fusion target, and apply the backend patches first Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com> * Exporters: simplify the ONNX Runtime fusions; restore the experts implementation after export - fuse_for_onnxruntime(model, ort_fusions): drop the steps / original_model params and the MoE-count warning - use_experts_implementation restores the model's experts implementation after a "cuda" export - OnnxConfig raises on ort_fusions without optimize; is_onnxruntime_available(min_version) - GroupQueryAttention fusion split into helpers; heads-second flag instead of a wrapper class - export tests fuse only for cpu / cuda with optimize on - ORT fusion tests laid out like the OpenVINO ones (expected-count tables, export helper, assert_fused) Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com> * Exporters: repair onnxscript's ORT fusions where ONNX Runtime rejects them; simplify the fusion steps - undo or fix what optimize_for_ort gets wrong: group norms (onnxscript#3182), position embeddings packed as attention biases (#3181), leftover SDPA ops (#3184), partial rotary without num_heads (#3185), loop-carried sequences typed as tensors (onnx/ir-py#539) - skip GroupQueryAttention when the bias reads the updated cache (Doge) - _patch_sdpa: leave a data-dependent mask width unchecked (SAM 3) - steps no longer return counts; _STEPS holds (step, providers); shared helpers for node loops, node replacement and the attention decomposition Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com> * Exporters: mark the ORT fusion tests slow; small fusion-step cleanups Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com> * Exporters: lay out the ORT fusion tests like the OpenVINO ones Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com> * Exporters: import numpy and torch directly in the fusion tests Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com> * Exporters: cite the onnxscript issues behind two ORT fusion repairs Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com> * Exporters: ort_fusions="dml" for DirectML DirectML gets the shared fusions with MultiHeadAttention kept: its GroupQueryAttention ignores rotary and attention_bias and needs unpacked q/k/v. No MoE kernel. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com> --------- Co-authored-by: Claude Opus 5.5 <noreply@anthropic.com>
1 parent c527bbf commit 5b99c07

8 files changed

Lines changed: 1729 additions & 35 deletions

File tree

‎docs/source/en/exporters.md‎

Lines changed: 35 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -852,6 +852,41 @@ for (int64_t position = prompt_len; position < max_cache_len; ++position) {
852852
853853
</details>
854854
855+
## ONNX Runtime fusions
856+
857+
Set `ort_fusions` to rewrite an ONNX export into ONNX Runtime's fused transformer ops before it's saved. ONNX Runtime
858+
runs the fused graph much faster than the plain one. The fusions cover RMS and layer norms, attention
859+
(`MultiHeadAttention`, or `GroupQueryAttention` when there's a key-value cache), rotary embeddings, GELU, and
860+
projections that share an input. Each fusion gives the same result as the ops it replaces.
861+
862+
Set `ort_fusions` to the execution provider the graph will run on. Each provider gets the fused ops it supports.
863+
864+
```python
865+
config = OnnxConfig(ort_fusions="cpu")
866+
config = OnnxConfig(ort_fusions="cuda")
867+
config = OnnxConfig(ort_fusions="webgpu")
868+
config = OnnxConfig(ort_fusions="dml")
869+
```
870+
871+
| | `"cpu"` | `"cuda"` | `"webgpu"` | `"dml"` |
872+
|---|---|---|---|---|
873+
| Attention with a key-value cache | `GroupQueryAttention`, rotary included | `GroupQueryAttention` with its query and key norms; the prefill keeps `MultiHeadAttention` for now ([slower](https://github.com/microsoft/onnxruntime/issues/33869)) | `GroupQueryAttention`, rotary as its own `RotaryEmbedding` | `MultiHeadAttention`, rotary as its own `RotaryEmbedding` |
874+
| Other attention | plain ops for now ([slower](https://github.com/microsoft/onnxruntime/issues/33873)) | `MultiHeadAttention` | `MultiHeadAttention` | `MultiHeadAttention` |
875+
| Mixture of experts | unfused | `MoE` | unfused | unfused |
876+
| CUDA graphs | — | static exports can replay | — | — |
877+
878+
A `"cuda"` graph with `MoE` doesn't run on CPU, since ONNX Runtime's `MoE` only exists for CUDA. A `"webgpu"` graph runs
879+
in the browser with onnxruntime-web, or natively with onnxruntime-webgpu. A `"dml"` graph runs on Windows with
880+
onnxruntime-directml.
881+
882+
The fused ops aren't standard ONNX, so other ONNX consumers such as TensorRT or OpenVINO can't read them. The default
883+
is `ort_fusions=None`, which keeps the graph standard. If you need the model on several runtimes, export it once for
884+
each.
885+
886+
The fusions run before [`~exporters.OnnxRuntimeQuantizer`] quantizes the graph, as ONNX Runtime recommends. They're
887+
skipped when the model is quantized before export, and they require `optimize=True` (the default). The `"cuda"` fusions
888+
need onnxruntime 1.28 or later.
889+
855890
## Quantization
856891

857892
Every export config takes a `quantizer` that quantizes the model during the export, on the `torch.export` FX graph

‎src/transformers/exporters/configs.py‎

Lines changed: 11 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -18,7 +18,7 @@
1818
import json
1919
from dataclasses import dataclass
2020
from enum import StrEnum
21-
from typing import TYPE_CHECKING, Any
21+
from typing import TYPE_CHECKING, Any, Literal
2222

2323
from ..utils import logging
2424

@@ -147,12 +147,22 @@ class OnnxConfig(DynamoConfig):
147147
Run `onnxscript` optimisation passes (constant folding, dead-code
148148
elimination, …) on the exported graph. Disable for models that
149149
hit upstream `onnxscript` optimiser bugs.
150+
ort_fusions (`str`, *optional*):
151+
Fuse the graph into ONNX Runtime's ops for the execution provider it runs on: `"cpu"`, `"cuda"`,
152+
`"webgpu"` or `"dml"`. The fused graph only runs on ONNX Runtime. Requires `optimize=True`.
150153
"""
151154

152155
export_format: ExportFormat = ExportFormat.ONNX
153156

154157
opset_version: int | None = None
155158
optimize: bool = True
159+
ort_fusions: Literal["cpu", "cuda", "webgpu", "dml"] | None = None
160+
161+
def __post_init__(self):
162+
if self.ort_fusions not in (None, "cpu", "cuda", "webgpu", "dml"):
163+
raise ValueError(f"`ort_fusions` must be None, 'cpu', 'cuda', 'webgpu' or 'dml', got {self.ort_fusions!r}")
164+
if self.ort_fusions is not None and not self.optimize:
165+
raise ValueError("`ort_fusions` requires `optimize=True`.")
156166

157167

158168
@dataclass

‎src/transformers/exporters/exporter_onnx.py‎

Lines changed: 64 additions & 22 deletions
Original file line numberDiff line numberDiff line change
@@ -68,6 +68,7 @@
6868

6969
if is_torch_available():
7070
import torch
71+
from torch.fx.experimental.symbolic_shapes import guard_or_false
7172
from torch.onnx import ONNXProgram
7273

7374

@@ -76,6 +77,8 @@
7677
from onnxscript.function_libs.torch_lib.ops.core import aten_index_put
7778
from onnxscript.onnx_opset import opset18 as op
7879

80+
from .onnxruntime_fusions import fuse_for_onnxruntime
81+
7982
# torch.dtype -> onnx_ir.DataType, mirroring torch.onnx's private _TORCH_DTYPE_TO_ONNX so we
8083
# don't depend on that path. Only the dtypes a `Cast` target can realistically be; exotic
8184
# float8/float4 variants (never emitted as an ``out_dtype``) are omitted.
@@ -134,7 +137,17 @@ def export_program(
134137
sample_inputs: MutableMapping[str, Any],
135138
config: OnnxConfig,
136139
) -> tuple[onnx_ir.Model, ExportMetadata]:
137-
with patch_model_outputs(model) as (inputs_names, outputs_names), apply_patches("onnx"):
140+
quantizer = config.quantizer
141+
# A model quantized before export has no ops the fusions match.
142+
ort_fusions = config.ort_fusions if quantizer is None or not quantizer.quantizes_fx_graph else None
143+
# `batched_mm` experts fuse into ONNX Runtime's CUDA `MoE`.
144+
experts_implementation = "batched_mm" if ort_fusions == "cuda" else None
145+
146+
with (
147+
apply_patches("onnx"),
148+
use_experts_implementation(model, experts_implementation),
149+
patch_model_outputs(model) as (inputs_names, outputs_names),
150+
):
138151
exported_program, metadata = super().export_program(model, sample_inputs, config=config)
139152
inputs_names, outputs_names = disambiguate_io_names(inputs_names, outputs_names)
140153
apply_fx_node_fixes("onnx", exported_program.graph_module)
@@ -151,7 +164,9 @@ def export_program(
151164

152165
apply_onnx_ir_fixes(onnx_program)
153166

154-
quantizer = config.quantizer
167+
if ort_fusions:
168+
fuse_for_onnxruntime(onnx_program.model, ort_fusions)
169+
155170
if quantizer is not None and not quantizer.quantizes_fx_graph:
156171
onnx_program.model = quantizer.quantize(onnx_program.model, sample_inputs, config.export_format)
157172

@@ -203,6 +218,20 @@ def _update_onnx_metadata(metadata: ExportMetadata, onnx_model: onnx_ir.Model) -
203218
metadata.output_names = [value.name for value in onnx_model.graph.outputs]
204219

205220

221+
@contextmanager
222+
def use_experts_implementation(model, experts_implementation: str | None):
223+
"""Run the model's experts with `experts_implementation` inside the block, then restore its own."""
224+
if experts_implementation is None or not getattr(model, "_can_set_experts_implementation", lambda: False)():
225+
yield
226+
return
227+
original = model.get_experts_implementation()
228+
model.set_experts_implementation(experts_implementation)
229+
try:
230+
yield
231+
finally:
232+
model.set_experts_implementation(original)
233+
234+
206235
@contextmanager
207236
def patch_model_outputs(model):
208237
"""Wrap `model.forward` to return a flat `dict[str, Tensor]` with duplicated outputs,
@@ -290,16 +319,28 @@ def _patch_sdpa(original):
290319
zeros; ONNX Runtime evaluates the softmax literally and, when the mask is `-inf` (parakeet's
291320
relative-position bias masks that way), returns `NaN` — which a later BatchNorm then spreads over
292321
the whole batch. Rows like these are routine: any padded frame under a padding mask has one.
322+
323+
A boolean mask goes in as an additive one (torchlib's boolean softmax adds a redundant `IsNaN`/`Where`), and
324+
unattended rows are counted with a sum (a bool `ReduceMax` runs on the CPU in ONNX Runtime's CUDA provider,
325+
microsoft/onnxruntime#33876). The key, value and mask lengths are checked equal, so a dynamic export gives them
326+
one symbol, which onnxscript's attention fusion needs.
293327
"""
294328

295329
def patch(query, key, value, attn_mask=None, *args, **kwargs):
296-
attn_output = original(query, key, value, attn_mask, *args, **kwargs)
330+
torch._check(key.shape[-2] == value.shape[-2])
297331
if attn_mask is None:
298-
return attn_output
332+
return original(query, key, value, attn_mask, *args, **kwargs)
333+
# A mask width that's data-dependent (SAM 3's) can't be compared to 1: left unchecked.
334+
if guard_or_false(attn_mask.shape[-1] != 1):
335+
torch._check(attn_mask.shape[-1] == key.shape[-2])
336+
# Built from scalars only, so every layer's copy of this, reading the same mask, is one after CSE.
299337
if attn_mask.dtype == torch.bool:
300-
unattended = ~attn_mask.any(dim=-1, keepdim=True)
338+
attended = attn_mask.to(query.dtype)
339+
attn_mask = (~attn_mask).to(query.dtype) * torch.finfo(query.dtype).min
301340
else:
302-
unattended = attn_mask.amax(dim=-1, keepdim=True) <= torch.finfo(attn_mask.dtype).min
341+
attended = (attn_mask > torch.finfo(attn_mask.dtype).min).to(query.dtype)
342+
unattended = attended.sum(dim=-1, keepdim=True) < 1
343+
attn_output = original(query, key, value, attn_mask, *args, **kwargs)
303344
zero = torch.zeros((), dtype=attn_output.dtype, device=attn_output.device)
304345
return torch.where(unattended, zero, attn_output)
305346

@@ -1061,33 +1102,34 @@ def _aten_grouped_mm(mat_a: TReal, mat_b: TReal, offs: INT64, bias=None, out_dty
10611102
mat_a[r] @ mat_b[group(r)]` where rows are sorted by group and `offs` holds the
10621103
cumulative end index per group.
10631104
1064-
Per-group `Slice + MatMul + Concat`. `G` (number of experts) is static for any
1065-
concrete model, so unroll at translation time: emit one `Slice + MatMul` triple
1066-
per group and a final `Concat`. Avoids the `(M, K, N)` materialisation a naive
1067-
`weight[group_idx]` gather would emit — peak memory is `O(M·N + max(n_g)·K + K·N)`.
1105+
`G` (number of experts) is static, so it unrolls into one `MatMul` per group and a final
1106+
`Concat`, avoiding the `(M, K, N)` materialisation of a `weight[group_idx]` gather. The group
1107+
ends and weights come from one `Split` each, not a `Slice` per group: onnxscript's
1108+
`SlicesSplit` rule tries every pair of `Slice`s, which dominated the export time.
10681109
"""
10691110
G = mat_b.shape[0]
10701111
if not isinstance(G, int):
10711112
raise ValueError("_aten_grouped_mm: number of experts (mat_b.shape[0]) must be static at translation time")
10721113

1073-
offs_i64 = op.Cast(offs, to=7)
1114+
def per_group(x):
1115+
return op.Split(x, axis=0, num_outputs=G) if G > 1 else [x]
1116+
10741117
axes_0 = op.Constant(value_ints=[0])
1075-
zero_1d = op.Constant(value_ints=[0])
1118+
ends, weights = per_group(op.Cast(offs, to=7)), per_group(mat_b)
1119+
biases = per_group(bias) if bias is not None else None
10761120

10771121
outputs = []
1078-
prev_end = zero_1d
1122+
prev_end = op.Constant(value_ints=[0])
1123+
one = op.Constant(value_int=1)
10791124
for g in range(G):
1080-
g_lo = op.Constant(value_ints=[g])
1081-
g_hi = op.Constant(value_ints=[g + 1])
1082-
end = op.Slice(offs_i64, g_lo, g_hi, axes_0) # (1,) — offs[g]
1083-
a_g = op.Slice(mat_a, prev_end, end, axes_0) # (n_g, K)
1084-
w_g = op.Squeeze(op.Slice(mat_b, g_lo, g_hi, axes_0), axes_0) # (K, N)
1085-
out_g = op.MatMul(a_g, w_g) # (n_g, N)
1086-
if bias is not None:
1125+
rows_g = op.Range(op.Squeeze(prev_end, axes_0), op.Squeeze(ends[g], axes_0), one)
1126+
a_g = op.Gather(mat_a, rows_g, axis=0) # (n_g, K)
1127+
out_g = op.MatMul(a_g, op.Squeeze(weights[g], axes_0)) # (n_g, N)
1128+
if biases is not None:
10871129
# per-group bias ``(G, N)`` → ``(N,)`` broadcasts over the group's rows
1088-
out_g = op.Add(out_g, op.Squeeze(op.Slice(bias, g_lo, g_hi, axes_0), axes_0))
1130+
out_g = op.Add(out_g, op.Squeeze(biases[g], axes_0))
10891131
outputs.append(out_g)
1090-
prev_end = end
1132+
prev_end = ends[g]
10911133

10921134
result = op.Concat(*outputs, axis=0) # (M, N)
10931135
if out_dtype is not None:

0 commit comments

Comments
 (0)