WebGPU: enable INT64 for Equal/Sub/Where/ReduceSum under enable_int64 flag to support Gemma4 - #29392
Conversation
There was a problem hiding this comment.
Pull request overview
This PR updates the WebGPU EP kernel registration for Equal, Sub, Where, and ReduceSum so that INT64 type constraints are included when enable_int64=true, avoiding unintended CPU fallback (notably in graph-capture scenarios).
Changes:
- Converted
Equal/Sub/Where/ReduceSumfrom static macro/table-based registration to factory-function registration, enabling conditional INT64 type constraints. - Wired the new factory registrations into
RegisterKernels(enable_graph_capture, enable_int64)alongside existing conditional-int64 kernels (e.g., Cast/Unsqueeze/Expand/Range). - Preserved existing special-casing (e.g.,
ReduceSumv13+ axes input remainsOrtMemTypeCPUInput).
Reviewed changes
Copilot reviewed 7 out of 7 changed files in this pull request and generated 8 comments.
Show a summary per file
| File | Description |
|---|---|
| onnxruntime/core/providers/webgpu/webgpu_execution_provider.cc | Removes static table entries for the 4 ops and registers them via RegisterKernels() with enable_int64 propagation. |
| onnxruntime/core/providers/webgpu/tensor/where.h | Declares Where kernel factory functions to support conditional INT64 registration. |
| onnxruntime/core/providers/webgpu/tensor/where.cc | Implements Where kernel factory functions and conditional type constraints. |
| onnxruntime/core/providers/webgpu/reduction/reduction_ops.h | Declares ReduceSum kernel factory functions to support conditional INT64 registration. |
| onnxruntime/core/providers/webgpu/reduction/reduction_ops.cc | Implements ReduceSum kernel factory functions with conditional INT64 type constraints and preserves CPU axes input for v13+. |
| onnxruntime/core/providers/webgpu/math/binary_elementwise_ops.h | Declares Equal/Sub kernel factory functions to support conditional INT64 registration. |
| onnxruntime/core/providers/webgpu/math/binary_elementwise_ops.cc | Implements Equal/Sub kernel factory functions with conditional INT64 type constraints. |
cc3a5a2 to
a6a73a4
Compare
Review: PR #29392 — WebGPU: enable INT64 for Equal/Sub/Where/ReduceSum under enable_int64 flag to support Gemma4LGTM overall. The What's good
Non-blocking items
|
BinaryElementwise: disable vec4 vectorization for INT64 inputs (stored as vec2<u32>), use component=1 for correct buffer binding, and compute vec_size as element count rather than rounded-up vec4 count for INT64 outputs. Fix scalar input path to not apply .x dereference on top of GetByOffset which already extracts the i32 element for INT64. Where: fix non-broadcast INT64 path to use the correct element-per-thread shader instead of the vec4 fast path that truncates to i32. Add is_int64 to the cache hint to prevent float and INT64 shaders sharing a cache entry. Register shape indices for non-broadcast INT64 so BroadcastedIndicesToOffset has the required helpers available. Co-Authored-By: Claude <noreply@anthropic.com>
Equal with INT64 inputs and bool output was dispatching vec_size=(N+3)/4
threads but only reading element 0 of each input and broadcasting it as
a vec4 comparison. Fix: in the element-wise path, when is_int64_ and
output is Boolx4, explicitly read 4 consecutive elements per thread and
pack them into vec4<bool> before SetByOffset.
ReduceSum_int64_webgpu test declared output shape {} (scalar) but
ReduceSum opset-13 defaults to keepdims=1, so the output is {1}.
Co-Authored-By: Claude <noreply@anthropic.com>
…nds checks and update test to size=5 - Add `element_count` uniform to BinaryElementwiseProgram - Guard INT64+bool output lanes 1-3 with `if (base+Xu < n)` to avoid OOB reads when tensor size is not a multiple of 4 - Update Equal_int64_webgpu test from size=4 to size=5 to exercise tail case
95d6187 to
4b96d3b
Compare
…lementwise - Fix Equal INT64 broadcast bug: remove erroneous `is_int64 ||` from the element-wise condition in ComputeInternal so non-scalar INT64 broadcast correctly routes to the broadcast path (vec4<i32>==vec4<i32> works fine) - Extract `can_use_element_wise_mode` to replace duplicated scalar/shape condition, shared by both the INT64+bool fast path and the general path - Add ORT_ENFORCE in ReduceSum to guard against future INT64 vectorization - Add comment in where.cc explaining why INT64 is excluded from the fast path - Add Equal tests: size%4==0, lhs_scalar, rhs_scalar, broadcast, broadcast_size%4==0 - Add ReduceSum test: size%4==0 - Add Where broadcast test Co-Authored-By: Claude <noreply@anthropic.com>
The fast path uses global_idx as a vec4 index (vec_size=output_size/4), so c_input.GetByOffset reads a packed-bool u32 as vec4<bool> directly. For INT64, vec_size=output_size (element index), so the same GetByOffset call would read the wrong condition word — hence the is_int64_ branch uses explicit bit extraction via offset_c/4u and byte masking. Co-Authored-By: Claude <noreply@anthropic.com>
- where.cc: use c_input.GetByOffset helper instead of raw c_data access - reduction_ops.cc: replace tautological ORT_ENFORCE with meaningful framework capability check (ToProgramVariableDataType(INT64,4)==InvalidType) - binary_elementwise_ops.cc: clarify that INT64 broadcast uses this path correctly with component=1 Co-Authored-By: Claude <noreply@anthropic.com>
…ec4 structure Instead of a dedicated code path for INT64+bool output (Equal), integrate INT64 handling into the existing element-wise and broadcast structure: - Read 4 component-1 elements into vec4 (splat for scalar inputs) - Reuse the generic expression_ for all types - Split output write: bool output uses packed SetByOffset, INT64 output writes element-by-element with bounds guards - Remove ORT_ENFORCE from ReduceSum (reviewer feedback: unnecessary guard) - Add Sub_webgpu_int64_broadcast test case Co-Authored-By: Claude <noreply@anthropic.com>
- Rename is_int64_ to is_int64_input_ and pass is_int64_output to the program instead of computing it locally from output var_type - Rename is_int64/is_output_int64 to is_int64_input/is_int64_output in ComputeInternal for consistency - Add per-lane bounds guards for INT64 input reads in element-wise path (matching cast.cc pattern) to avoid OOB reads when size % 4 != 0 Co-Authored-By: Claude <noreply@anthropic.com>
…se in output Move `base` and `element_count` declarations to a shared location before the element-wise/broadcast branch so both paths can reference them in the INT64 output block. Use `base` instead of `global_idx * 4u` for clarity. Co-Authored-By: Claude <noreply@anthropic.com>
b9741c3 to
0371cb5
Compare
## 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>
### Description Extends the WebGPU EP `Add` operator to accept `tensor(int64)` inputs by converting its registration from a static macro to the same conditional factory-function pattern already used by `Sub` and `Equal` (#29392) ### Motivation and Context Int64 `Add` nodes (e.g. token-position arithmetic in LLMs, [mobileclip_s0 text model](https://huggingface.co/Xenova/mobileclip_s0/blob/main/onnx/text_model_fp16.onnx)) fail kernel lookup in the WebGPU EP. The shader already handles int64 generically for all binary ops; only the kernel registration was missing the int64 type constraint for `Add`. --------- Co-authored-by: copilot-swe-agent[bot] <198982749+Copilot@users.noreply.github.com>
Summary
These four ops were forcing CPU fallback when inputs were INT64, preventing WebGPU graph capture. Converted from static macro registration to the factory function pattern (same as Cast/Unsqueeze/Expand/Range) so INT64 type constraints are included when
enable_int64=true.enable_int64is alwaystruewhenenable_graph_capture=true—GetKernelRegistrycallsRegisterKernels(true, true)unconditionally in that path — so this fix has no effect on the default (non-graph-capture) execution path.Changed ops
EqualSubWhereReduceSumApproach
Each op follows the established factory function pattern:
CreateXVersionedKernelInfo<Start, End>(bool enable_int64)/CreateXKernelInfo<Since>(bool enable_int64)build_kernel_create_info_function_table[]RegisterKernels()alongside Cast/Unsqueeze/Expand/RangeReduceSumversion 13+ retainsInputMemoryType(OrtMemTypeCPUInput, 1)(axes input must be on CPU) — unchanged from the original registration.Wherefactory functions delegate toGetOpTypeConstraints(enable_int64, /*enable_bool=*/true)rather than a local duplicate type list. All three files (where.cc,binary_elementwise_ops.cc,reduction_ops.cc) use stack-allocatedKernelDefBuilder()consistent with other WebGPU factory functions, and drop the unusedwebgpu_execution_provider.hinclude.Runtime fixes for INT64 inference
BinaryElementwise(Sub,Equal) andWhererequired additional shader-level fixes to correctly handle INT64 tensors at inference time:vec2<u32>(8 bytes/element) and has no vec4 representation. Vectorization is now disabled when either input is INT64, andcomponent=1is used for correct buffer binding.vec_sizeis computed as element count (not rounded-up vec4 count) for INT64 outputs..xdereference on top ofGetByOffset, which already extracts thei32element for INT64.Wherenow uses the element-per-thread shader path instead of the vec4 fast path that would truncate values to i32.is_int64is included in theWherecache hint, preventing float and INT64 shaders from incorrectly sharing a cached entry.WheresoBroadcastedIndicesToOffsethas the required helpers available.vec4<bool>before writing to the bool output buffer. Previously, only element 0 was read and broadcast across all 4 positions of the comparison, giving wrong results.Testing
WebGpuExecutionProvider(previously ~20 nodes fell back to CPU due to INT64 Equal/Sub/Where/ReduceSum)USE_WEBGPU-guarded unit tests added for all four ops withkEnableInt64=1:Sub_int64_webgpu,Equal_int64_webgpu,Where_int64_webgpu,ReduceSum_int64_webgpuRelated