You signed in with another tab or window. Reload to refresh your session.You signed out in another tab or window. Reload to refresh your session.You switched accounts on another tab or window. Reload to refresh your session.Dismiss alert
Enable WebGPU graph capture for Gemma 4 decoder (#380)
## Summary
Enable WebGPU graph capture for Gemma 4 decoder. All graph capture
compatibility is handled at the ONNX export level in Mobius. Requires
two ORT PRs: indirect dispatch support
([microsoft/onnxruntime#29236](microsoft/onnxruntime#29236))
and INT64 support for Equal/Sub/Where/ReduceSum
([microsoft/onnxruntime#29392](microsoft/onnxruntime#29392)).
**Note**: Graph capture is currently supported for the decoder only.
Vision, embedding, and audio encoders each have ops that fall back to
CPU under graph capture (investigated, to be addressed in a follow-up
PR). When Mobius builds a WebGPU model with graph capture enabled, the
generated `genai_config.json` will have `enableGraphCapture: "1"` set
for **all** sessions by default. Until the follow-up PR lands, you must
manually edit `genai_config.json` to disable `enableGraphCapture` (set
to `"0"`) for the `vision`, `embedding`, and `speech` sections, keeping
it `"1"` only for `decoder`.
## Changes
### New rewrite rule: `static_empty_kv_rules` (`_static_empty_kv.py`)
- **Pattern**: `Shape → Concat → ConstantOfShape → CastLike/Cast` — used
to build the empty `[batch, 0, kv_hidden]` KV tensor for Gemma4's
shared-KV layers (layers 15–34)
- **Why it breaks graph capture**: `Shape` outputs to CPU;
`ConstantOfShape` is unsupported by WebGPU EP
- **Fix**: replace with a static `Constant(zeros([1, 0, kv_hidden],
dtype))` — batch fixed to 1 (required by graph capture's static-shape
requirement), dtype inferred from the model's activation dtype
- Two variants cover the `CastLike` form (from onnxscript) and the
`Cast` form (post-quantization); BFLOAT16 handled by emitting a float32
constant followed by an explicit Cast (numpy has no native bfloat16)
- Rule applied automatically in `_optimizations.py` when
`enable_graph_capture=True`
- 6 unit tests in `_static_empty_kv_test.py`, built with `ir.Graph +
GraphBuilder` to match repo conventions
### `gemma4.py` changes
- **Split per-layer embedding tables**: the fused `[V, L*D]` table (~4.7
GB for Gemma4 E2B) exceeds WebGPU's 256 MiB `maxBufferSize` limit,
causing device loss. When `config.split_per_layer_embedding` is set, L
separate `[V, D]` tables (~128 MiB each) are used instead; shape
asserted before each `chunk()` call to catch layout drift early
- **Gather-based per-layer extraction**: replaced `Slice(combined,
starts=[i], ends=[i+1], axes=[2])` with `Gather(combined,
Constant(value_int=i), axis=2)` — `Slice` with INT64 axis inputs runs on
CPU under graph capture; `Gather` with a static scalar constant is
GPU-resident
- **`total_seq_len` scalar without `Shape`**: in the graph-capture
branch, `Shape(attention_mask)` outputs to CPU and breaks the capture
boundary. Fix: `total_seq_len = Cast(Gather(ReduceSum(attention_mask,
axis=1), 0), INT32)` — `ReduceSum` produces `[batch]` INT32,
`Gather(..., 0)` extracts the scalar (valid because graph capture
requires `batch=1`). The non-capture branch continues to use
`Gather(Shape(attention_mask), 1)` unchanged.
- `Gemma4TextModel.__init__` now stores `self.config = config` so
`_compute_per_layer_inputs` can access it
### `_gemma4.py` changes
- **Split table routing**: `Gemma4Task.build()` computes the fused
table's byte size using `config.dtype.itemsize` and sets
`config.split_per_layer_embedding = fused_bytes > caps.max_buffer_size`;
when set, `per_layer_inputs` is omitted from the decoder graph and
`input_ids` is added instead
### `base.py` changes
- Added the `split_per_layer_embedding` flag, set to True by
Gemma4Task.build() when the target EP's max_buffer_size is too small for
the fused [V, L*D] per-layer embedding table, to split it into L
separate [V, D] tables that each fit within the EP's buffer limit.
### `_execution_providers.py` changes
- **`EpCapabilities.max_buffer_size`**: new field (`0` = no limit). Set
to `268_435_456` (256 MiB) for WebGPU per the [W3C spec
default](https://www.w3.org/TR/webgpu/#typedefdef-gpusize64). Drives the
split-table decision in `Gemma4Task.build()` instead of a hardcoded EP
name check — any future EP with a tight buffer limit gets the split
automatically
### ORT WebGPU EP changes (separate PRs)
- **Indirect dispatch**
([microsoft/onnxruntime#29236](microsoft/onnxruntime#29236)):
prerequisite for graph capture correctness
- **INT64 for Equal/Sub/Where/ReduceSum**
([microsoft/onnxruntime#29392](microsoft/onnxruntime#29392)):
these ops were forcing CPU fallback when inputs were INT64, preventing
graph capture
## Test plan
- [x] 6 unit tests in `_static_empty_kv_test.py` pass
- [x] Exported INT4 Gemma4 WebGPU decoder: 0 `ConstantOfShape`, 0
`Shape`, 0 `Slice`, 0 `Squeeze`
- [x] All decoder nodes assigned to `WebGpuExecutionProvider`
- [x] End-to-end inference with graph capture ON: **90+ tok/s** (INT4
WebGPU) and OFF: **70+ tok/s**, coherent output
- [x] Exported a CPU INT4 decoder: coherent output vs WebGPU, but with
poor perf
- [x] Confirmed the mode applied the rewrite rules produce bit-identical
output compared to the original model that didn't apply the rewrite
rules on WebGPU ep.
---------
Signed-off-by: Fei Chen <feich@microsoft.com>
Signed-off-by: Copilot <copilot@github.com>
Co-authored-by: Claude Opus 4 (1M context) <noreply@anthropic.com>
Co-authored-by: Copilot Autofix powered by AI <223894421+github-code-quality[bot]@users.noreply.github.com>
Co-authored-by: copilot-swe-agent[bot] <198982749+Copilot@users.noreply.github.com>
Co-authored-by: Copilot Autofix powered by AI <175728472+Copilot@users.noreply.github.com>
0 commit comments