Skip to content

Commit 0253746

Browse files
feich-msclaudegithub-code-quality[bot]CopilotCopilot
authored
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>
1 parent 6d4d138 commit 0253746

8 files changed

Lines changed: 552 additions & 20 deletions

File tree

src/mobius/_configs/_base.py

Lines changed: 5 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1488,6 +1488,11 @@ class Gemma4Config(VisionLanguageConfig):
14881488
causal attention (only ``None`` and ``"vision"`` are accepted).
14891489
"""
14901490

1491+
# Set to True by Gemma4Task.build() when the target EP's max_buffer_size
1492+
# is too small for the fused [V, L*D] per-layer embedding table, to split
1493+
# it into L separate [V, D] tables that each fit within the EP's buffer limit.
1494+
split_per_layer_embedding: bool = False
1495+
14911496
@classmethod
14921497
def from_transformers(cls, config, parent_config=None) -> Gemma4Config:
14931498
base = ArchitectureConfig.from_transformers(config, parent_config)

src/mobius/_execution_providers.py

Lines changed: 19 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -97,6 +97,20 @@ class EpCapabilities:
9797
devices. ``True`` only for WebGPU (consumer GPU); ``False`` for
9898
CUDA / CPU / DML / TRT-RTX where the runtime can handle large
9999
pre-allocations.
100+
max_buffer_size: Maximum allowed size in bytes for a single model
101+
weight buffer on this EP. ``None`` means no limit. When non-zero,
102+
large weight tensors (e.g. fused per-layer embedding tables) must
103+
be split into chunks that each fit within this bound. WebGPU's
104+
W3C spec default ``maxBufferSize`` is 268,435,456 bytes (256 MiB).
105+
requires_graph_capture_rewrite: Whether this EP requires rewrite rules
106+
to make models compatible with graph capture (e.g. replacing
107+
``Shape`` / ``ConstantOfShape`` with static alternatives for
108+
shared-KV layer models like Gemma4). Not all EPs with
109+
``enable_graph_capture`` need this — e.g. CUDA EP's ``Shape``
110+
kernel is already registered inside the CUDA partition and is
111+
graph-capture-safe. Set ``True`` only for EPs that cannot execute
112+
``Shape`` / ``ConstantOfShape`` under graph capture (currently
113+
WebGPU).
100114
"""
101115

102116
name: str
@@ -112,6 +126,8 @@ class EpCapabilities:
112126
enable_graph_capture: bool = False
113127
supports_past_present_share_buffer: bool = False
114128
cap_kv_buffer_max_length: bool = False
129+
max_buffer_size: int | None = None
130+
requires_graph_capture_rewrite: bool = False
115131

116132
def __post_init__(self) -> None:
117133
if not self.supports_fused_rope and self.qkv_pack_dtypes:
@@ -281,6 +297,9 @@ def _register_builtins() -> None:
281297
enable_graph_capture=True,
282298
supports_past_present_share_buffer=True,
283299
cap_kv_buffer_max_length=True,
300+
# W3C WebGPU spec default maxBufferSize (https://www.w3.org/TR/webgpu/)
301+
max_buffer_size=268_435_456, # 256 MiB
302+
requires_graph_capture_rewrite=True,
284303
),
285304
EpCapabilities(
286305
name="trt-rtx",

src/mobius/_optimizations.py

Lines changed: 11 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -68,6 +68,7 @@
6868
separate_rope_rules,
6969
skip_layer_norm_rules,
7070
skip_norm_rules,
71+
static_empty_kv_rules,
7172
unpack_qkv_rules,
7273
)
7374

@@ -317,6 +318,16 @@ def _get_optimization_passes(
317318
if not caps.supports_rank4_rmsnorm:
318319
lower.append(("HtpRank4RMSNorm", list(htp_rank4_rmsnorm_rules())))
319320

321+
# --- Graph-capture rewrite ---
322+
# Supports graph capture for shared-KV layer models on WebGPU EP
323+
# (currently Gemma4). Pattern-based: only fires on models that emit the
324+
# dynamic Shape → ConstantOfShape → Cast empty-KV pattern. Gated by
325+
# requires_graph_capture_rewrite rather than enable_graph_capture because
326+
# not all graph-capture EPs need it — e.g. CUDA EP's Shape kernel is
327+
# already graph-capture-safe.
328+
if caps.requires_graph_capture_rewrite:
329+
lower.append(("StaticEmptyKV", list(static_empty_kv_rules())))
330+
320331
return fuse, lower
321332

322333

src/mobius/models/gemma4.py

Lines changed: 101 additions & 17 deletions
Original file line numberDiff line numberDiff line change
@@ -1478,6 +1478,7 @@ class Gemma4TextModel(nn.Module):
14781478

14791479
def __init__(self, config: Gemma4Config):
14801480
super().__init__()
1481+
self.config = config
14811482
self._dtype = config.dtype
14821483

14831484
embed_scale = math.sqrt(config.hidden_size)
@@ -1559,14 +1560,30 @@ def __init__(self, config: Gemma4Config):
15591560
if self._per_layer_dim:
15601561
self._num_layers = config.num_hidden_layers
15611562
vocab_per_layer = getattr(config, "vocab_size_per_layer_input", 0)
1562-
# Single fused [V, L*D] table. Requires ORT >= 1.27 for CUDA
1563-
# Gather int64 index support (onnxruntime#28107).
1563+
# Fused [V, L*D] table — used when split_per_layer_embedding is False.
1564+
# Requires ORT >= 1.27 for CUDA Gather int64 index support (onnxruntime#28107).
15641565
self.embed_tokens_per_layer = Gemma3TextScaledWordEmbedding(
15651566
vocab_per_layer,
15661567
self._num_layers * self._per_layer_dim,
15671568
config.pad_token_id,
15681569
embed_scale=float(self._per_layer_dim**0.5),
15691570
)
1571+
# Split [V, D] tables — used when split_per_layer_embedding is True
1572+
# (i.e. the fused table exceeds the EP's max_buffer_size, e.g. WebGPU's
1573+
# 256 MiB limit; ~128 MiB each vs ~4.7 GB fused).
1574+
# Only the table actually called in forward() is realized as an
1575+
# ONNX initializer, so the unused one adds no graph weight.
1576+
self.embed_tokens_per_layer_split = nn.ModuleList(
1577+
[
1578+
Gemma3TextScaledWordEmbedding(
1579+
vocab_per_layer,
1580+
self._per_layer_dim,
1581+
config.pad_token_id,
1582+
embed_scale=float(self._per_layer_dim**0.5),
1583+
)
1584+
for _ in range(self._num_layers)
1585+
]
1586+
)
15701587
self.per_layer_model_projection = Linear(
15711588
config.hidden_size,
15721589
config.num_hidden_layers * self._per_layer_dim,
@@ -1605,17 +1622,26 @@ def _compute_per_layer_inputs(
16051622
masked_ids,
16061623
)
16071624

1608-
fused_emb = self.embed_tokens_per_layer(op, masked_ids)
1609-
fused_emb = op.Reshape(
1610-
fused_emb,
1611-
op.Constant(value_ints=[0, 0, self._num_layers, self._per_layer_dim]),
1612-
)
1625+
if self.config.split_per_layer_embedding:
1626+
# L separate Gathers on [V, D] tables — each fits within the EP's
1627+
# max_buffer_size (e.g. WebGPU's 256 MiB limit).
1628+
per_layer_embs = [
1629+
op.Unsqueeze(self.embed_tokens_per_layer_split[i](op, masked_ids), [2])
1630+
for i in range(self._num_layers)
1631+
]
1632+
fused_emb = op.Concat(*per_layer_embs, axis=2) # [B, S, L, D]
1633+
else:
1634+
fused_emb = self.embed_tokens_per_layer(op, masked_ids)
1635+
fused_emb = op.Reshape(
1636+
fused_emb,
1637+
op.Constant(value_ints=[0, 0, self._num_layers, self._per_layer_dim]),
1638+
)
16131639

16141640
combined = op.Add(proj, fused_emb)
16151641
combined = op.Mul(combined, float(0.5**0.5))
16161642

16171643
return [
1618-
op.Squeeze(op.Slice(combined, starts=[i], ends=[i + 1], axes=[2]), [2])
1644+
op.Gather(combined, op.Constant(value_int=i), axis=2)
16191645
for i in range(self._num_layers)
16201646
]
16211647

@@ -1721,17 +1747,25 @@ def forward(
17211747
# seqlens_k[b] = sum(attention_mask[b]) - 1 (last valid KV idx)
17221748
# total_seq_len = attention_mask.shape[1] (past + current)
17231749
one_i32 = op.Constant(value_int=1)
1750+
reduce_sum = op.ReduceSum(attention_mask, [1], keepdims=0)
17241751
seqlens_k = op.Cast(
1725-
op.Sub(
1726-
op.ReduceSum(attention_mask, [1], keepdims=0),
1727-
one_i32,
1728-
),
1729-
to=ir.DataType.INT32,
1730-
)
1731-
total_seq_len = op.Cast(
1732-
op.Gather(op.Shape(attention_mask), 1),
1752+
op.Sub(reduce_sum, one_i32),
17331753
to=ir.DataType.INT32,
17341754
)
1755+
if caps.requires_graph_capture_rewrite:
1756+
# Support graph capture for shared-KV layer models on WebGPU EP.
1757+
# Derive total_seq_len from reduce_sum (already computed) as a
1758+
# scalar INT32 via Gather index 0 (valid because graph capture
1759+
# requires batch=1).
1760+
total_seq_len = op.Gather(
1761+
op.Cast(reduce_sum, to=ir.DataType.INT32),
1762+
op.Constant(value_int=0),
1763+
)
1764+
else:
1765+
total_seq_len = op.Cast(
1766+
op.Gather(op.Shape(attention_mask), 1),
1767+
to=ir.DataType.INT32,
1768+
)
17351769

17361770
# Per-layer-type GQA contexts with appropriate cos/sin caches
17371771
# and local_window_size for sliding layers.
@@ -1903,6 +1937,7 @@ def preprocess_weights(
19031937
state_dict.pop(key, None)
19041938
# HF's model.embed_tokens_per_layer.weight [V, L*D] maps directly
19051939
# to our fused embedding table — no splitting needed.
1940+
# (For WebGPU, splitting is handled by _Gemma4DecoderModel.preprocess_weights.)
19061941
# Map HF expert weight names and fold router scale
19071942
_remap_moe_expert_weights(state_dict, self.config)
19081943
return super().preprocess_weights(state_dict)
@@ -1966,7 +2001,23 @@ def forward(
19662001
def preprocess_weights(
19672002
self, state_dict: dict[str, torch.Tensor]
19682003
) -> dict[str, torch.Tensor]:
1969-
return vlm_decoder_weights(state_dict, tie=self.config.tie_word_embeddings)
2004+
state_dict = vlm_decoder_weights(state_dict, tie=self.config.tie_word_embeddings)
2005+
# For WebGPU: split the fused [V, L*D] per-layer embedding into L separate [V, D] tables.
2006+
per_layer_dim = self.config.hidden_size_per_layer_input
2007+
if per_layer_dim and self.config.split_per_layer_embedding:
2008+
fused_key = "model.embed_tokens_per_layer.weight"
2009+
if fused_key in state_dict:
2010+
num_layers = self.config.num_hidden_layers
2011+
fused = state_dict.pop(fused_key)
2012+
assert fused.shape[1] == num_layers * per_layer_dim, (
2013+
f"{fused_key} dim 1 expected {num_layers * per_layer_dim} "
2014+
f"({num_layers} layers x {per_layer_dim} per_layer_dim), "
2015+
f"got {fused.shape[1]}"
2016+
)
2017+
chunks = fused.chunk(num_layers, dim=1)
2018+
for i, chunk in enumerate(chunks):
2019+
state_dict[f"model.embed_tokens_per_layer_split.{i}.weight"] = chunk
2020+
return state_dict
19702021

19712022

19722023
class _Gemma4VisionEncoderModel(nn.Module):
@@ -2183,6 +2234,12 @@ def forward(
21832234
if not self._per_layer_dim:
21842235
return outputs
21852236

2237+
# When split_per_layer_embedding is set, the per-layer computation runs
2238+
# inside the decoder using split [V, D] tables. The embedding model only
2239+
# emits inputs_embeds in that case.
2240+
if self.config.split_per_layer_embedding:
2241+
return outputs
2242+
21862243
# Compute per-layer input embeddings (moved from the decoder).
21872244
# 1. Project hidden states → [B, S, L*D] and scale by hidden_size**-0.5
21882245
proj = self.per_layer_model_projection(op, hidden)
@@ -2737,6 +2794,33 @@ def preprocess_weights(
27372794
# Map HF expert weight names and fold router scale
27382795
_remap_moe_expert_weights(renamed, self.config)
27392796

2797+
# For WebGPU: the fused [V, L*D] embed_tokens_per_layer exceeds the 256 MiB
2798+
# per-buffer limit. Split it into L separate [V, D] tables in the decoder.
2799+
# The per_layer_projection weights also live in the decoder (not embedding).
2800+
if self.config.split_per_layer_embedding:
2801+
fused_key = "embedding.embed_tokens_per_layer.weight"
2802+
if fused_key in renamed:
2803+
num_layers = self.config.num_hidden_layers
2804+
per_layer_dim = self.config.hidden_size_per_layer_input
2805+
fused = renamed.pop(fused_key)
2806+
assert fused.shape[1] == num_layers * per_layer_dim, (
2807+
f"{fused_key} dim 1 expected {num_layers * per_layer_dim} "
2808+
f"({num_layers} layers x {per_layer_dim} per_layer_dim), "
2809+
f"got {fused.shape[1]}"
2810+
)
2811+
chunks = fused.chunk(num_layers, dim=1)
2812+
for i, chunk in enumerate(chunks):
2813+
renamed[f"decoder.model.embed_tokens_per_layer_split.{i}.weight"] = chunk
2814+
# Re-route the projection weights from embedding.* → decoder.model.*
2815+
for k in list(renamed.keys()):
2816+
if k.startswith(
2817+
(
2818+
"embedding.per_layer_model_projection.",
2819+
"embedding.per_layer_projection_norm.",
2820+
)
2821+
):
2822+
renamed[k.replace("embedding.", "decoder.model.", 1)] = renamed.pop(k)
2823+
27402824
return renamed
27412825

27422826

src/mobius/rewrite_rules/__init__.py

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -39,6 +39,7 @@
3939
"separate_rope_rules",
4040
"skip_layer_norm_rules",
4141
"skip_norm_rules",
42+
"static_empty_kv_rules",
4243
"unpack_qkv_rules",
4344
]
4445

@@ -56,4 +57,5 @@
5657
from mobius.rewrite_rules._separate_rope import separate_rope_rules
5758
from mobius.rewrite_rules._skip_layer_norm import skip_layer_norm_rules
5859
from mobius.rewrite_rules._skip_norm import skip_norm_rules
60+
from mobius.rewrite_rules._static_empty_kv import static_empty_kv_rules
5961
from mobius.rewrite_rules._unpack_qkv import unpack_qkv_rules

0 commit comments

Comments
 (0)