Skip to content

Feed decoder projections their weight untransposed on CUDA - #807

Open
y-chan wants to merge 1 commit into
onnxruntime:mainfrom
y-chan:transposed-matmul
Open

y-chan wants to merge 1 commit into
onnxruntime:mainfrom
y-chan:transposed-matmul

Conversation

@y-chan

@y-chan y-chan commented Oct 29, 2026

Copy link
Copy Markdown

Summary

  • --features transposed-matmul (build(..., transposed_matmul=True)) rewrites each decoder MatMul(x, Transpose(weight)) into com.microsoft::FusedMatMul(x, weight, transB=1)
  • the projections (the packed Q/K/V, o_proj, the MLP and the LM head) then keep the checkpoint's [out, in] weight instead of the [in, out] one FoldTransposedInitializerPass would store, and the transpose happens inside the GEMM; at batch 1 cuBLAS is faster on that layout
  • unlike Gemm, FusedMatMul takes the rank-3 activations a decoder layer feeds
  • CUDA only (EpCapabilities.supports_transposed_matmul); other EPs warn and ignore the request, as fp8-kv-cache does. Only decoder components are rewritten

Nothing changes for exports that do not pass transposed-matmul.

How the rewrite is placed

It is a graph pass rather than a different Linear, because the GQA and PackQKV rewrites match the plain MatMul. It runs after fusion and lowering, so they still fire, and before weights are loaded. A Transpose is only taken when it reads a weight: an initializer, or the axis-0 Concat of initializers that PackQKV emits, which FoldConcatInitializersPass still folds once weights are loaded. Transposed activations, transposes with another consumer, transposes that are graph outputs and other permutations are left alone, and the rewritten node keeps the output name (so logits stays logits).

Performance

Batch 1, fp16, --features static-cache with and without transposed-matmul, ORT 1.29 (CUDA EP), no other process on the GPU. Decode is one step under CUDA graph replay with an 8192-slot cache, the median of five blocks of 200 replays; prefill is one forward without a graph, the median of 20 runs. Each figure is the mean of rounds 2-3 of three rounds that alternate between the two exports.

ms, RTX PRO 6000 Blackwell Max-Q (300 W):

Model static-cache + transposed-matmul
Qwen3-8B decode, past 128 17.889 13.615 -23.9%
decode, past 4096 18.254 14.014 -23.2%
prefill 128 18.110 18.612 +2.8%
prefill 512 50.423 50.877 +0.9%
prefill 2048 196.553 203.024 +3.3%
Llama-3.1-8B-Instruct decode, past 128 17.466 13.198 -24.4%
decode, past 4096 17.768 13.585 -23.5%
prefill 128 16.943 17.507 +3.3%
prefill 512 48.740 49.340 +1.2%
prefill 2048 194.140 195.385 +0.6%

ms, A100 80GB PCIe:

Model static-cache + transposed-matmul
Qwen3-8B decode, past 128 12.692 12.354 -2.7%
decode, past 4096 14.405 14.145 -1.8%
prefill 128 17.162 17.788 +3.6%
prefill 512 46.882 47.297 +0.9%
prefill 2048 170.772 172.347 +0.9%
Llama-3.1-8B-Instruct decode, past 128 12.312 12.039 -2.2%
decode, past 4096 13.890 13.508 -2.7%
prefill 128 17.368 18.042 +3.9%
prefill 512 45.902 46.346 +1.0%
prefill 2048 164.469 165.847 +0.8%
  • Decode on Blackwell is where it pays: about 4.2 ms less per step, the same at 128 and 4096 past tokens, so it is the projections and not attention that change.
  • On the A100 decode gains 2-3%. The decode spread within a round is at most 0.12%, so that is not noise.
  • Prefill is 1-4% slower on both. Prefill also drifts upward from round to round on both cards, which blurs the size of that difference. At 128 and 512 tokens, and at 2048 on the A100, the transposed-matmul export is the slower one in every round; at 2048 on Blackwell the rounds disagree on the direction, so that figure is mostly drift.

It is opt-in because it only helps the batch-1 decode step and costs prefill a little.

Validation

  • the pass, on single and packed Q/K/V weights, matches numpy on ORT CPU; activations, shared transposes, graph-output transposes and other permutations are left untouched
  • a tiny fp16 Llama built for CUDA keeps GroupQueryAttention and packed Q/K/V, has no plain MatMul and, after weights are loaded, no Concat or Transpose left, and matches the MatMul export on ORT CPU
  • the CPU EP warns and ignores the request
  • the CLI passes --features transposed-matmul through to build

Not yet measured: batch sizes above 1, models other than the two above.

🤖 Generated with Claude Code

`--features transposed-matmul` (`build(..., transposed_matmul=True)`)
rewrites each decoder `MatMul(x, Transpose(weight))` into
`com.microsoft::FusedMatMul(x, weight, transB=1)`. The projections --
the packed Q/K/V, `o_proj`, the MLP and the LM head -- then keep the
checkpoint's `[out, in]` weight instead of the `[in, out]` one
FoldTransposedInitializerPass would store, and the transpose happens
inside the GEMM. At batch 1 that is the faster layout for cuBLAS. Unlike
`Gemm`, `FusedMatMul` takes the rank-3 activations a decoder layer feeds.

The rewrite is a graph pass rather than a different `Linear`, because the
GQA and PackQKV rewrites match the plain `MatMul`; it runs after fusion
and lowering, so they still fire, and before weights are loaded. A
`Transpose` is only taken when it reads a weight: an initializer, or the
axis-0 `Concat` of initializers that PackQKV emits, which
FoldConcatInitializersPass still folds once weights are loaded.
Transposed activations, shared transposes and other permutations are left
alone.

`EpCapabilities.supports_transposed_matmul` gates it and only CUDA sets
it; elsewhere the request warns and is ignored, as `fp8-kv-cache` does.
Only decoder components are rewritten.

Tested: the pass on single and packed weights against numpy, and a tiny
fp16 Llama built for CUDA keeps GQA and packed Q/K/V, has no plain
`MatMul` or weight `Transpose` left after loading, and matches the
`MatMul` export on ORT CPU.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
@y-chan
y-chan requested review from a team and a balanced review from Copilot October 29, 2026 17:48

Copilot AI left a comment

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Copilot was unable to review this pull request because the user who requested the review has insufficient remaining quota.

@justinchuby

justinchuby commented Oct 29, 2026 •

Copy link
Copy Markdown
Member

Please let us know the actual use case. Thanks. We don't want to blindly optimize the graph just for measured ORT perf.

@justinchuby justinchuby self-assigned this Oct 29, 2026

@justinchuby justinchuby left a comment

Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Blocking for now

@y-chan

y-chan commented Oct 29, 2026 •

Copy link
Copy Markdown
Author

Thanks for the comment. I’m running Qwen3-Omni, which mobius doesn’t support yet, at batch 1 on a single GPU. The tuning that took is too large to review as one change, so I’m splitting it into separate PRs; Qwen3-Omni itself will follow as its own PR. Profiling against vLLM on an RTX PRO 6000 showed the projections’ weight layout was the main gap. This change alone took about 2 ms off a decode step and brought the export level with vLLM’s int8 path on the same GPU (5.70 vs 5.71 ms per step).

@justinchuby

Copy link
Copy Markdown
Member

Thanks for the explanation!

@justinchuby

Copy link
Copy Markdown
Member

cc @gramalingam @titaiwangms another case where we may want to retain the old layout and not fold transpose.

This branch has not been deployed

No deployments
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

3 participants