[ExecuTorch][WebGPU] Add 256-thread "steel" q4gsw prefill GEMM - #20730

Merged
meta-codesync[bot] merged 11 commits into
gh/JCNTH/4/basefrom
gh/JCNTH/4/head
Jul 9, 2026
Merged

[ExecuTorch][WebGPU] Add 256-thread "steel" q4gsw prefill GEMM#20730
meta-codesync[bot] merged 11 commits into
gh/JCNTH/4/basefrom
gh/JCNTH/4/head

Conversation

@JCNTH

@JCNTHJCNTH commented Jul 5, 2026

Copy link
Copy Markdown
Contributor

Stack from ghstack (oldest at bottom):

Add a shared-memory 64x64-tile prefill GEMM for et_vk.linear_q4gsw that beats the existing shmem/tiled GEMM by 1.75-3.17x and moves in-browser Llama-3.2-1B prefill from behind to ahead of llama.cpp WebGPU (+11-33%, M4 Pro / Chrome Canary).

Problem: the q4gsw prefill path (M>1) routes to either the register-tiled or the 32x32 shmem GEMM. On Apple / M4 Pro both leave the 4-bit linear ~2.65x slower than llama.cpp's simdgroup-matrix GEMM, so prefill is the one axis where the WebGPU delegate trails.

Solution:

  • Before: M>1 selects use_shmem_gemm (large K/N) else the register-tiled GEMM.
  • After: M>1 prefers a new use_steel branch above shmem — a 64x64 output tile computed by 256 threads (16x16) with a 4x4 register sub-tile per thread, staging a BK=16 K-slice of activations (f32) and dequantized weights into shared memory once and reusing it across the tile. shmem/tiled remain the fallback when steel is ineligible.

Implementation:

  • Add q4gsw_linear_gemm_steel.wgsl (same 6 bindings + Params as the sibling q4gsw kernels) and its generated _wgsl.h.
  • Add a fail-closed steel_supported (requires maxComputeInvocationsPerWorkgroup >= 256; SwiftShader caps at 128 so it falls back) and steel_workgroup_count (one workgroup per 64x64 tile; returns 0 to fall back when K % 16 != 0 — the kernel stages a full BK K-tile with no K-mask — or when the tile count exceeds the 1D dispatch limit).
  • Thread use_steel through the shared compute_q4gsw_workgroup_count helper (a steel branch beside gemv/shmem) AND the add_tensor_resize_hook, so a dynamic-shape prefill recomputes the steel tile count for the live M; add steel to fixed_wg (fixed @workgroup_size(16, 16), no wg_size override).
  • No direct Vulkan analogue: Vulkan's q4gsw GEMM is register-tiled with an is_gemv split and no 256-thread shmem-staged tile. This tiling is WebGPU-specific (buffer-only storage, compile-time @workgroup_size, 1D-dispatch fold); only the signed-nibble dequant mirrors the q4gsw reference.

Constraints: engine-independent — depends only on shape + device limits and participates in the dynamic-resize hook exactly like gemv/shmem. Falls back to shmem/tiled on non-256-invocation devices, odd K, or over-limit dispatch, so behavior is unchanged where steel is ineligible. Layout and numerics are unchanged (same bindings, same dequant, f32 accumulator). The f16-multiply variant is a separate follow-up.

Co-authored-with: Claude Code.
@exported-using-ghexport

Differential Revision: D110660965

Differential Revision: D110660965

[ghstack-poisoned]
@pytorch-bot

pytorch-botBot commented Jul 5, 2026

Copy link
Copy Markdown

🔗 Helpful Links

🧪 See artifacts and rendered test results at hud.pytorch.org/pr/pytorch/executorch/20730

Note: Links to docs will display an error until the docs builds have been completed.

❌ 4 New Failures, 1 Cancelled Job, 2 Pending

As of commit e5215e1 with merge base f4b01a8 (image):

NEW FAILURES - The following jobs have failed:

CANCELLED JOB - The following job was cancelled. Please retry:

This comment was automatically generated by Dr. CI and updates every 15 minutes.

@linux-foundation-easycla

linux-foundation-easyclaBot commented Jul 5, 2026

Copy link
Copy Markdown

CLA Signed
The committers listed above are authorized under a signed CLA.

@github-actions

Copy link
Copy Markdown

This PR needs a release notes: label

If your change should be included in the release notes (i.e. would users of this library care about this change?), please use a label starting with release notes:. This helps us keep track and include your important work in the next release notes.

To add a label, you can comment to pytorchbot, for example
@pytorchbot label "release notes: none"

For more information, see
https://github.com/pytorch/pytorch/wiki/PyTorch-AutoLabel-Bot#why-categorize-for-release-notes-and-how-does-it-work.

@JCNTH

JCNTH commented Jul 5, 2026

Copy link
Copy Markdown
ContributorAuthor

@claude throoughly review, check for overengineering, correctness, and areaas for modularization

@claude

claudeBot commented Jul 5, 2026

Copy link
Copy Markdown

Claude finished @JCNTH's task in 2m 43s —— View job


Review: steel q4gsw prefill GEMM

  • Read the diff (QuantizedLinear.cpp, steel.wgsl, steel_wgsl.h)
  • Review correctness (dispatch, guards, kernel numerics)
  • Check for overengineering
  • Identify modularization opportunities

Solid, well-scoped change. Numerics and dequant mirror the shmem sibling exactly, the fail-closed device guard is the right instinct, and the dynamic-resize wiring is complete. A few things worth addressing before merge — nothing blocking correctness for the target shapes, but a couple of real inconsistencies and clear dedup wins.


Correctness

1. Build-time falls back gracefully, resize hard-throws — asymmetric.steel_workgroup_count returns 0 (→ fall back to shmem/tiled) when the tile count exceeds maxComputeWorkgroupsPerDimension, so at build time an over-limit shape silently picks a different kernel. But use_steel is baked into the pipeline at build. On a dynamic-shape resize, compute_q4gsw_workgroup_count re-runs with the live M and, for the same over-limit condition, throws (QuantizedLinear.cpp:125-129) instead of falling back — the kernel is already fixed, so it can't. That's defensible (you can't swap pipelines mid-resize), but the two paths reason about the same limit in opposite ways. Worth a one-line comment on the throw explaining why resize can't fall back the way the build path does, so a future reader doesn't "fix" the asymmetry.

2. maxComputeWorkgroupsPerDimension is queried three times per dispatch. On the build path for a steel dispatch: once in steel_supported (invocations), once in steel_workgroup_count (dim limit), and a third time inside compute_q4gsw_workgroup_count's steel branch. All redundant. Minor, but see modularization below — folding them removes it for free.

3. The steel branch throw is unreachable on the build path (only reachable via resize). Since use_steel is only ever true when steel_workgroup_count(...) > 0 — i.e. total <= max_count — the identical check in compute_q4gsw_workgroup_count can never fire at build time. That's fine (it exists for resize), but it means the limit is validated twice with two separate code copies that must be kept in sync.

4. Kernel numerics — verified correct. The A/B staging (ar/ac, br/bc), the K%BK==0 host guard removing the need for a K-mask, the signed-nibble dequant (f32(i32(nib) - 8) * scale), scale_row computed per shmem-K-row, the 1D→2D tile decode (bx = wid.x % nbN), and the bounds-guarded write all line up with the shmem sibling. No issues found.


Overengineering

5. Steel now shadows the shmem GEMM for essentially all M>1 shapes. Previously shmem was gated on K >= 4096 || N >= 2048. Now use_shmem_gemm = !use_gemv && !use_steel && (...), and steel wins for any M>1 with K%16==0 on a ≥256-invocation device. So shmem is only reachable on odd-K shapes or sub-256-invocation devices (SwiftShader) that also hit the large-K/N gate — a narrow slice. That's an intended consequence, but if the plan is for steel to be the prefill path going forward, it's worth a comment noting shmem is now a fallback-only path (and confirming it's still exercised in CI on SwiftShader, which caps at 128 and will route here). Not a request to delete it — just flag the intent so it isn't mistaken for dead code later.

Beyond that, I don't see gratuitous complexity — the kernel is a straightforward 64×64/256-thread tile and the host guards are minimal.


Modularization

6. Three copies of the "max workgroups per dimension" query. The block

WGPULimits limits = {};
constuint32_t max_wgs =
wgpuDeviceGetLimits(device, &limits) == WGPUStatus_Success &&
limits.maxComputeWorkgroupsPerDimension > 0
? limits.maxComputeWorkgroupsPerDimension : 65535u;

appears in steel_workgroup_count and twice in compute_q4gsw_workgroup_count (steel + shmem branches), all identical including the 65535u fallback. Extract one helper:

uint32_tmax_workgroups_per_dim(WGPUDevice device) {
WGPULimits limits = {};
return (wgpuDeviceGetLimits(device, &limits) == WGPUStatus_Success &&
limits.maxComputeWorkgroupsPerDimension > 0)
? limits.maxComputeWorkgroupsPerDimension : 65535u;
}

Fix this →

7. The steel and shmem branches of compute_q4gsw_workgroup_count are byte-identical except for the tile constants and the error string. Both are "one workgroup per tile, throw over dim-limit." Collapse into a small helper:

uint32_ttiled_wg_count(WGPUDevice device, uint32_t m, uint32_t n,
uint32_t tile_m, uint32_t tile_n,
constchar* op_name, constchar* kind) {
constint64_t total = utils::div_up<int64_t>(m, tile_m) *
utils::div_up<int64_t>(n, tile_n);
if (total > static_cast<int64_t>(max_workgroups_per_dim(device))) {
throwstd::runtime_error(std::string("WebGPU ") + op_name + ": " + kind +
" tile count exceeds the 1D dispatch limit");
}
returnstatic_cast<uint32_t>(total);
}

Then both branches are one call each, and combined with #6 the routing function drops ~25 lines.

8. Consider deriving steel_workgroup_count's dim-limit check from the same helper so the "return 0 to fall back" and the "throw over limit" logic share their notion of the max, rather than each re-deriving max_count/max_wgs independently (the bug surface in #1/#3).


Minor / nits

  • steel_workgroup_count guards K % 16 != 0 and over-limit but the total == 0u case (m==0 or n==0) also returns 0 — correct, just undocumented in the comment which only mentions K and dispatch limit.
  • The generated _wgsl.h correctly carries a wgsl-sha256; confirm it was regenerated by the codegen tool rather than hand-edited (the source and embedded copy match, so this looks fine).

Overall: correct for the target shapes, good guard discipline, and the perf win is well-motivated. The main asks are the max-limit dedup (#6/#7) and a comment clarifying the build-vs-resize fallback asymmetry (#1).

[ghstack-poisoned]
[ghstack-poisoned]
[ghstack-poisoned]
[ghstack-poisoned]

@SS-JIASS-JIA 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.

Review automatically exported from Phabricator review in Meta.

[ghstack-poisoned]
[ghstack-poisoned]
[ghstack-poisoned]
[ghstack-poisoned]
@meta-codesync
meta-codesyncBot merged commit 5f7b84a into gh/JCNTH/4/baseJul 9, 2026
177 of 183 checks passed
@meta-codesync
meta-codesyncBot deleted the gh/JCNTH/4/head branch July 9, 2026 21:39
JCNTH added a commit that referenced this pull request Jul 9, 2026
Pull Request resolved: #20730
**Add a shared-memory 64x64-tile prefill GEMM for `et_vk.linear_q4gsw` that beats the existing shmem/tiled GEMM by 1.75-3.17x and moves in-browser Llama-3.2-1B prefill from behind to ahead of llama.cpp WebGPU (+11-33%, M4 Pro / Chrome Canary).**
**Problem:** the q4gsw prefill path (M>1) routes to either the register-tiled or the 32x32 shmem GEMM. On Apple / M4 Pro both leave the 4-bit linear ~2.65x slower than llama.cpp's simdgroup-matrix GEMM, so prefill is the one axis where the WebGPU delegate trails.
**Solution:**
- **Before:** M>1 selects `use_shmem_gemm` (large K/N) else the register-tiled GEMM.
- **After:** M>1 prefers a new `use_steel` branch above shmem — a 64x64 output tile computed by 256 threads (16x16) with a 4x4 register sub-tile per thread, staging a BK=16 K-slice of activations (f32) and dequantized weights into shared memory once and reusing it across the tile. shmem/tiled remain the fallback when steel is ineligible.
**Implementation:**
- Add `q4gsw_linear_gemm_steel.wgsl` (same 6 bindings + `Params` as the sibling q4gsw kernels) and its generated `_wgsl.h`.
- Add a fail-closed `steel_supported` (requires `maxComputeInvocationsPerWorkgroup >= 256`; SwiftShader caps at 128 so it falls back) and `steel_workgroup_count` (one workgroup per 64x64 tile; returns 0 to fall back when `K % 16 != 0` — the kernel stages a full BK K-tile with no K-mask — or when the tile count exceeds the 1D dispatch limit).
- Thread `use_steel` through the shared `compute_q4gsw_workgroup_count` helper (a steel branch beside gemv/shmem) AND the `add_tensor_resize_hook`, so a dynamic-shape prefill recomputes the steel tile count for the live M; add steel to `fixed_wg` (fixed `@workgroup_size(16, 16)`, no `wg_size` override).
- No direct Vulkan analogue: Vulkan's q4gsw GEMM is register-tiled with an `is_gemv` split and no 256-thread shmem-staged tile. This tiling is WebGPU-specific (buffer-only storage, compile-time `@workgroup_size`, 1D-dispatch fold); only the signed-nibble dequant mirrors the q4gsw reference.
**Constraints:** engine-independent — depends only on shape + device limits and participates in the dynamic-resize hook exactly like gemv/shmem. Falls back to shmem/tiled on non-256-invocation devices, odd K, or over-limit dispatch, so behavior is unchanged where steel is ineligible. Layout and numerics are unchanged (same bindings, same dequant, f32 accumulator). The f16-multiply variant is a separate follow-up.
Co-authored-with: Claude Code.
ghstack-source-id: 401515156
@exported-using-ghexport
Differential Revision: [D110660965](https://our.internmc.facebook.com/intern/diff/D110660965/)
JCNTH added a commit that referenced this pull request Jul 9, 2026
Pull Request resolved: #20730
**Add a shared-memory 64x64-tile prefill GEMM for `et_vk.linear_q4gsw` that beats the existing shmem/tiled GEMM by 1.75-3.17x and moves in-browser Llama-3.2-1B prefill from behind to ahead of llama.cpp WebGPU (+11-33%, M4 Pro / Chrome Canary).**
**Problem:** the q4gsw prefill path (M>1) routes to either the register-tiled or the 32x32 shmem GEMM. On Apple / M4 Pro both leave the 4-bit linear ~2.65x slower than llama.cpp's simdgroup-matrix GEMM, so prefill is the one axis where the WebGPU delegate trails.
**Solution:**
- **Before:** M>1 selects `use_shmem_gemm` (large K/N) else the register-tiled GEMM.
- **After:** M>1 prefers a new `use_steel` branch above shmem — a 64x64 output tile computed by 256 threads (16x16) with a 4x4 register sub-tile per thread, staging a BK=16 K-slice of activations (f32) and dequantized weights into shared memory once and reusing it across the tile. shmem/tiled remain the fallback when steel is ineligible.
**Implementation:**
- Add `q4gsw_linear_gemm_steel.wgsl` (same 6 bindings + `Params` as the sibling q4gsw kernels) and its generated `_wgsl.h`.
- Add a fail-closed `steel_supported` (requires `maxComputeInvocationsPerWorkgroup >= 256`; SwiftShader caps at 128 so it falls back) and `steel_workgroup_count` (one workgroup per 64x64 tile; returns 0 to fall back when `K % 16 != 0` — the kernel stages a full BK K-tile with no K-mask — or when the tile count exceeds the 1D dispatch limit).
- Thread `use_steel` through the shared `compute_q4gsw_workgroup_count` helper (a steel branch beside gemv/shmem) AND the `add_tensor_resize_hook`, so a dynamic-shape prefill recomputes the steel tile count for the live M; add steel to `fixed_wg` (fixed `@workgroup_size(16, 16)`, no `wg_size` override).
- No direct Vulkan analogue: Vulkan's q4gsw GEMM is register-tiled with an `is_gemv` split and no 256-thread shmem-staged tile. This tiling is WebGPU-specific (buffer-only storage, compile-time `@workgroup_size`, 1D-dispatch fold); only the signed-nibble dequant mirrors the q4gsw reference.
**Constraints:** engine-independent — depends only on shape + device limits and participates in the dynamic-resize hook exactly like gemv/shmem. Falls back to shmem/tiled on non-256-invocation devices, odd K, or over-limit dispatch, so behavior is unchanged where steel is ineligible. Layout and numerics are unchanged (same bindings, same dequant, f32 accumulator). The f16-multiply variant is a separate follow-up.
Co-authored-with: Claude Code.
ghstack-source-id: 401515156
@exported-using-ghexport
Differential Revision: [D110660965](https://our.internmc.facebook.com/intern/diff/D110660965/)
JCNTH added a commit that referenced this pull request Jul 9, 2026
Pull Request resolved: #20730
**Add a shared-memory 64x64-tile prefill GEMM for `et_vk.linear_q4gsw` that beats the existing shmem/tiled GEMM by 1.75-3.17x and moves in-browser Llama-3.2-1B prefill from behind to ahead of llama.cpp WebGPU (+11-33%, M4 Pro / Chrome Canary).**
**Problem:** the q4gsw prefill path (M>1) routes to either the register-tiled or the 32x32 shmem GEMM. On Apple / M4 Pro both leave the 4-bit linear ~2.65x slower than llama.cpp's simdgroup-matrix GEMM, so prefill is the one axis where the WebGPU delegate trails.
**Solution:**
- **Before:** M>1 selects `use_shmem_gemm` (large K/N) else the register-tiled GEMM.
- **After:** M>1 prefers a new `use_steel` branch above shmem — a 64x64 output tile computed by 256 threads (16x16) with a 4x4 register sub-tile per thread, staging a BK=16 K-slice of activations (f32) and dequantized weights into shared memory once and reusing it across the tile. shmem/tiled remain the fallback when steel is ineligible.
**Implementation:**
- Add `q4gsw_linear_gemm_steel.wgsl` (same 6 bindings + `Params` as the sibling q4gsw kernels) and its generated `_wgsl.h`.
- Add a fail-closed `steel_supported` (requires `maxComputeInvocationsPerWorkgroup >= 256`; SwiftShader caps at 128 so it falls back) and `steel_workgroup_count` (one workgroup per 64x64 tile; returns 0 to fall back when `K % 16 != 0` — the kernel stages a full BK K-tile with no K-mask — or when the tile count exceeds the 1D dispatch limit).
- Thread `use_steel` through the shared `compute_q4gsw_workgroup_count` helper (a steel branch beside gemv/shmem) AND the `add_tensor_resize_hook`, so a dynamic-shape prefill recomputes the steel tile count for the live M; add steel to `fixed_wg` (fixed `@workgroup_size(16, 16)`, no `wg_size` override).
- No direct Vulkan analogue: Vulkan's q4gsw GEMM is register-tiled with an `is_gemv` split and no 256-thread shmem-staged tile. This tiling is WebGPU-specific (buffer-only storage, compile-time `@workgroup_size`, 1D-dispatch fold); only the signed-nibble dequant mirrors the q4gsw reference.
**Constraints:** engine-independent — depends only on shape + device limits and participates in the dynamic-resize hook exactly like gemv/shmem. Falls back to shmem/tiled on non-256-invocation devices, odd K, or over-limit dispatch, so behavior is unchanged where steel is ineligible. Layout and numerics are unchanged (same bindings, same dequant, f32 accumulator). The f16-multiply variant is a separate follow-up.
Co-authored-with: Claude Code.
ghstack-source-id: 401515156
@exported-using-ghexport
Differential Revision: [D110660965](https://our.internmc.facebook.com/intern/diff/D110660965/)
Sign up for freeto join this conversation on GitHub. Already have an account? Sign in to comment

Labels

CLA SignedThis label is managed by the Facebook bot. Authors need to sign the CLA before a PR can be reviewed.meta-exported

Projects

None yet

Development

Successfully merging this pull request may close these issues.

2 participants

@JCNTH@SS-JIA
, 'i'); if (__m === '*' || __re.test(location.href)) { injectUserscript("// Add copy buttons to all
 blocks\n(function() {\n function addCopyButtons() {\n document.querySelectorAll('pre code').forEach(function(codeBlock) {\n if (codeBlock.parentElement.hasAttribute('data-copy-added')) return;\n codeBlock.parentElement.setAttribute('data-copy-added', 'true');\n \n var btn = document.createElement('button');\n btn.textContent = 'Copy';\n btn.style.cssText = 'position:absolute;top:4px;right:4px;padding:2px 8px;font-size:11px;background:#4ecdc4;border:none;border-radius:4px;color:#1a1a2e;cursor:pointer;opacity:0.7;transition:opacity 0.2s;';\n btn.onmouseover = function() { this.style.opacity = '1'; };\n btn.onmouseout = function() { this.style.opacity = '0.7'; };\n btn.onclick = function() {\n navigator.clipboard.writeText(codeBlock.textContent).then(function() {\n btn.textContent = 'Copied!';\n setTimeout(function() { btn.textContent = 'Copy'; }, 1500);\n });\n };\n codeBlock.parentElement.style.position = 'relative';\n codeBlock.parentElement.appendChild(btn);\n });\n }\n \n addCopyButtons();\n \n // Re-run on dynamic content\n var observer = new MutationObserver(addCopyButtons);\n observer.observe(document.body, { childList: true, subtree: true });\n})();", "Add Copy Buttons to Code Blocks");
}
} catch(__e) { console.warn('[Userscript:Add Copy Buttons to Code Blocks]', __e); }
})();
(function(){
try {
var __m = "github.com";
var __re = new RegExp('^' + "github\\.com" + '
Skip to content

[ExecuTorch][WebGPU] Add 256-thread "steel" q4gsw prefill GEMM - #20730

Merged
meta-codesync[bot] merged 11 commits into
gh/JCNTH/4/basefrom
gh/JCNTH/4/head
Jul 9, 2026
Merged

[ExecuTorch][WebGPU] Add 256-thread "steel" q4gsw prefill GEMM#20730
meta-codesync[bot] merged 11 commits into
gh/JCNTH/4/basefrom
gh/JCNTH/4/head

Conversation

@JCNTH

@JCNTHJCNTH commented Jul 5, 2026

Copy link
Copy Markdown
Contributor

Stack from ghstack (oldest at bottom):

Add a shared-memory 64x64-tile prefill GEMM for et_vk.linear_q4gsw that beats the existing shmem/tiled GEMM by 1.75-3.17x and moves in-browser Llama-3.2-1B prefill from behind to ahead of llama.cpp WebGPU (+11-33%, M4 Pro / Chrome Canary).

Problem: the q4gsw prefill path (M>1) routes to either the register-tiled or the 32x32 shmem GEMM. On Apple / M4 Pro both leave the 4-bit linear ~2.65x slower than llama.cpp's simdgroup-matrix GEMM, so prefill is the one axis where the WebGPU delegate trails.

Solution:

  • Before: M>1 selects use_shmem_gemm (large K/N) else the register-tiled GEMM.
  • After: M>1 prefers a new use_steel branch above shmem — a 64x64 output tile computed by 256 threads (16x16) with a 4x4 register sub-tile per thread, staging a BK=16 K-slice of activations (f32) and dequantized weights into shared memory once and reusing it across the tile. shmem/tiled remain the fallback when steel is ineligible.

Implementation:

  • Add q4gsw_linear_gemm_steel.wgsl (same 6 bindings + Params as the sibling q4gsw kernels) and its generated _wgsl.h.
  • Add a fail-closed steel_supported (requires maxComputeInvocationsPerWorkgroup >= 256; SwiftShader caps at 128 so it falls back) and steel_workgroup_count (one workgroup per 64x64 tile; returns 0 to fall back when K % 16 != 0 — the kernel stages a full BK K-tile with no K-mask — or when the tile count exceeds the 1D dispatch limit).
  • Thread use_steel through the shared compute_q4gsw_workgroup_count helper (a steel branch beside gemv/shmem) AND the add_tensor_resize_hook, so a dynamic-shape prefill recomputes the steel tile count for the live M; add steel to fixed_wg (fixed @workgroup_size(16, 16), no wg_size override).
  • No direct Vulkan analogue: Vulkan's q4gsw GEMM is register-tiled with an is_gemv split and no 256-thread shmem-staged tile. This tiling is WebGPU-specific (buffer-only storage, compile-time @workgroup_size, 1D-dispatch fold); only the signed-nibble dequant mirrors the q4gsw reference.

Constraints: engine-independent — depends only on shape + device limits and participates in the dynamic-resize hook exactly like gemv/shmem. Falls back to shmem/tiled on non-256-invocation devices, odd K, or over-limit dispatch, so behavior is unchanged where steel is ineligible. Layout and numerics are unchanged (same bindings, same dequant, f32 accumulator). The f16-multiply variant is a separate follow-up.

Co-authored-with: Claude Code.
@exported-using-ghexport

Differential Revision: D110660965

Differential Revision: D110660965

[ghstack-poisoned]
@pytorch-bot

pytorch-botBot commented Jul 5, 2026

Copy link
Copy Markdown

🔗 Helpful Links

🧪 See artifacts and rendered test results at hud.pytorch.org/pr/pytorch/executorch/20730

Note: Links to docs will display an error until the docs builds have been completed.

❌ 4 New Failures, 1 Cancelled Job, 2 Pending

As of commit e5215e1 with merge base f4b01a8 (image):

NEW FAILURES - The following jobs have failed:

CANCELLED JOB - The following job was cancelled. Please retry:

This comment was automatically generated by Dr. CI and updates every 15 minutes.

@linux-foundation-easycla

linux-foundation-easyclaBot commented Jul 5, 2026

Copy link
Copy Markdown

CLA Signed
The committers listed above are authorized under a signed CLA.

@github-actions

Copy link
Copy Markdown

This PR needs a release notes: label

If your change should be included in the release notes (i.e. would users of this library care about this change?), please use a label starting with release notes:. This helps us keep track and include your important work in the next release notes.

To add a label, you can comment to pytorchbot, for example
@pytorchbot label "release notes: none"

For more information, see
https://github.com/pytorch/pytorch/wiki/PyTorch-AutoLabel-Bot#why-categorize-for-release-notes-and-how-does-it-work.

@JCNTH

JCNTH commented Jul 5, 2026

Copy link
Copy Markdown
ContributorAuthor

@claude throoughly review, check for overengineering, correctness, and areaas for modularization

@claude

claudeBot commented Jul 5, 2026

Copy link
Copy Markdown

Claude finished @JCNTH's task in 2m 43s —— View job


Review: steel q4gsw prefill GEMM

  • Read the diff (QuantizedLinear.cpp, steel.wgsl, steel_wgsl.h)
  • Review correctness (dispatch, guards, kernel numerics)
  • Check for overengineering
  • Identify modularization opportunities

Solid, well-scoped change. Numerics and dequant mirror the shmem sibling exactly, the fail-closed device guard is the right instinct, and the dynamic-resize wiring is complete. A few things worth addressing before merge — nothing blocking correctness for the target shapes, but a couple of real inconsistencies and clear dedup wins.


Correctness

1. Build-time falls back gracefully, resize hard-throws — asymmetric.steel_workgroup_count returns 0 (→ fall back to shmem/tiled) when the tile count exceeds maxComputeWorkgroupsPerDimension, so at build time an over-limit shape silently picks a different kernel. But use_steel is baked into the pipeline at build. On a dynamic-shape resize, compute_q4gsw_workgroup_count re-runs with the live M and, for the same over-limit condition, throws (QuantizedLinear.cpp:125-129) instead of falling back — the kernel is already fixed, so it can't. That's defensible (you can't swap pipelines mid-resize), but the two paths reason about the same limit in opposite ways. Worth a one-line comment on the throw explaining why resize can't fall back the way the build path does, so a future reader doesn't "fix" the asymmetry.

2. maxComputeWorkgroupsPerDimension is queried three times per dispatch. On the build path for a steel dispatch: once in steel_supported (invocations), once in steel_workgroup_count (dim limit), and a third time inside compute_q4gsw_workgroup_count's steel branch. All redundant. Minor, but see modularization below — folding them removes it for free.

3. The steel branch throw is unreachable on the build path (only reachable via resize). Since use_steel is only ever true when steel_workgroup_count(...) > 0 — i.e. total <= max_count — the identical check in compute_q4gsw_workgroup_count can never fire at build time. That's fine (it exists for resize), but it means the limit is validated twice with two separate code copies that must be kept in sync.

4. Kernel numerics — verified correct. The A/B staging (ar/ac, br/bc), the K%BK==0 host guard removing the need for a K-mask, the signed-nibble dequant (f32(i32(nib) - 8) * scale), scale_row computed per shmem-K-row, the 1D→2D tile decode (bx = wid.x % nbN), and the bounds-guarded write all line up with the shmem sibling. No issues found.


Overengineering

5. Steel now shadows the shmem GEMM for essentially all M>1 shapes. Previously shmem was gated on K >= 4096 || N >= 2048. Now use_shmem_gemm = !use_gemv && !use_steel && (...), and steel wins for any M>1 with K%16==0 on a ≥256-invocation device. So shmem is only reachable on odd-K shapes or sub-256-invocation devices (SwiftShader) that also hit the large-K/N gate — a narrow slice. That's an intended consequence, but if the plan is for steel to be the prefill path going forward, it's worth a comment noting shmem is now a fallback-only path (and confirming it's still exercised in CI on SwiftShader, which caps at 128 and will route here). Not a request to delete it — just flag the intent so it isn't mistaken for dead code later.

Beyond that, I don't see gratuitous complexity — the kernel is a straightforward 64×64/256-thread tile and the host guards are minimal.


Modularization

6. Three copies of the "max workgroups per dimension" query. The block

WGPULimits limits = {};
constuint32_t max_wgs =
wgpuDeviceGetLimits(device, &limits) == WGPUStatus_Success &&
limits.maxComputeWorkgroupsPerDimension > 0
? limits.maxComputeWorkgroupsPerDimension : 65535u;

appears in steel_workgroup_count and twice in compute_q4gsw_workgroup_count (steel + shmem branches), all identical including the 65535u fallback. Extract one helper:

uint32_tmax_workgroups_per_dim(WGPUDevice device) {
WGPULimits limits = {};
return (wgpuDeviceGetLimits(device, &limits) == WGPUStatus_Success &&
limits.maxComputeWorkgroupsPerDimension > 0)
? limits.maxComputeWorkgroupsPerDimension : 65535u;
}

Fix this →

7. The steel and shmem branches of compute_q4gsw_workgroup_count are byte-identical except for the tile constants and the error string. Both are "one workgroup per tile, throw over dim-limit." Collapse into a small helper:

uint32_ttiled_wg_count(WGPUDevice device, uint32_t m, uint32_t n,
uint32_t tile_m, uint32_t tile_n,
constchar* op_name, constchar* kind) {
constint64_t total = utils::div_up<int64_t>(m, tile_m) *
utils::div_up<int64_t>(n, tile_n);
if (total > static_cast<int64_t>(max_workgroups_per_dim(device))) {
throwstd::runtime_error(std::string("WebGPU ") + op_name + ": " + kind +
" tile count exceeds the 1D dispatch limit");
}
returnstatic_cast<uint32_t>(total);
}

Then both branches are one call each, and combined with #6 the routing function drops ~25 lines.

8. Consider deriving steel_workgroup_count's dim-limit check from the same helper so the "return 0 to fall back" and the "throw over limit" logic share their notion of the max, rather than each re-deriving max_count/max_wgs independently (the bug surface in #1/#3).


Minor / nits

  • steel_workgroup_count guards K % 16 != 0 and over-limit but the total == 0u case (m==0 or n==0) also returns 0 — correct, just undocumented in the comment which only mentions K and dispatch limit.
  • The generated _wgsl.h correctly carries a wgsl-sha256; confirm it was regenerated by the codegen tool rather than hand-edited (the source and embedded copy match, so this looks fine).

Overall: correct for the target shapes, good guard discipline, and the perf win is well-motivated. The main asks are the max-limit dedup (#6/#7) and a comment clarifying the build-vs-resize fallback asymmetry (#1).

[ghstack-poisoned]
[ghstack-poisoned]
[ghstack-poisoned]
[ghstack-poisoned]

@SS-JIASS-JIA 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.

Review automatically exported from Phabricator review in Meta.

[ghstack-poisoned]
[ghstack-poisoned]
[ghstack-poisoned]
[ghstack-poisoned]
@meta-codesync
meta-codesyncBot merged commit 5f7b84a into gh/JCNTH/4/baseJul 9, 2026
177 of 183 checks passed
@meta-codesync
meta-codesyncBot deleted the gh/JCNTH/4/head branch July 9, 2026 21:39
JCNTH added a commit that referenced this pull request Jul 9, 2026
Pull Request resolved: #20730
**Add a shared-memory 64x64-tile prefill GEMM for `et_vk.linear_q4gsw` that beats the existing shmem/tiled GEMM by 1.75-3.17x and moves in-browser Llama-3.2-1B prefill from behind to ahead of llama.cpp WebGPU (+11-33%, M4 Pro / Chrome Canary).**
**Problem:** the q4gsw prefill path (M>1) routes to either the register-tiled or the 32x32 shmem GEMM. On Apple / M4 Pro both leave the 4-bit linear ~2.65x slower than llama.cpp's simdgroup-matrix GEMM, so prefill is the one axis where the WebGPU delegate trails.
**Solution:**
- **Before:** M>1 selects `use_shmem_gemm` (large K/N) else the register-tiled GEMM.
- **After:** M>1 prefers a new `use_steel` branch above shmem — a 64x64 output tile computed by 256 threads (16x16) with a 4x4 register sub-tile per thread, staging a BK=16 K-slice of activations (f32) and dequantized weights into shared memory once and reusing it across the tile. shmem/tiled remain the fallback when steel is ineligible.
**Implementation:**
- Add `q4gsw_linear_gemm_steel.wgsl` (same 6 bindings + `Params` as the sibling q4gsw kernels) and its generated `_wgsl.h`.
- Add a fail-closed `steel_supported` (requires `maxComputeInvocationsPerWorkgroup >= 256`; SwiftShader caps at 128 so it falls back) and `steel_workgroup_count` (one workgroup per 64x64 tile; returns 0 to fall back when `K % 16 != 0` — the kernel stages a full BK K-tile with no K-mask — or when the tile count exceeds the 1D dispatch limit).
- Thread `use_steel` through the shared `compute_q4gsw_workgroup_count` helper (a steel branch beside gemv/shmem) AND the `add_tensor_resize_hook`, so a dynamic-shape prefill recomputes the steel tile count for the live M; add steel to `fixed_wg` (fixed `@workgroup_size(16, 16)`, no `wg_size` override).
- No direct Vulkan analogue: Vulkan's q4gsw GEMM is register-tiled with an `is_gemv` split and no 256-thread shmem-staged tile. This tiling is WebGPU-specific (buffer-only storage, compile-time `@workgroup_size`, 1D-dispatch fold); only the signed-nibble dequant mirrors the q4gsw reference.
**Constraints:** engine-independent — depends only on shape + device limits and participates in the dynamic-resize hook exactly like gemv/shmem. Falls back to shmem/tiled on non-256-invocation devices, odd K, or over-limit dispatch, so behavior is unchanged where steel is ineligible. Layout and numerics are unchanged (same bindings, same dequant, f32 accumulator). The f16-multiply variant is a separate follow-up.
Co-authored-with: Claude Code.
ghstack-source-id: 401515156
@exported-using-ghexport
Differential Revision: [D110660965](https://our.internmc.facebook.com/intern/diff/D110660965/)
JCNTH added a commit that referenced this pull request Jul 9, 2026
Pull Request resolved: #20730
**Add a shared-memory 64x64-tile prefill GEMM for `et_vk.linear_q4gsw` that beats the existing shmem/tiled GEMM by 1.75-3.17x and moves in-browser Llama-3.2-1B prefill from behind to ahead of llama.cpp WebGPU (+11-33%, M4 Pro / Chrome Canary).**
**Problem:** the q4gsw prefill path (M>1) routes to either the register-tiled or the 32x32 shmem GEMM. On Apple / M4 Pro both leave the 4-bit linear ~2.65x slower than llama.cpp's simdgroup-matrix GEMM, so prefill is the one axis where the WebGPU delegate trails.
**Solution:**
- **Before:** M>1 selects `use_shmem_gemm` (large K/N) else the register-tiled GEMM.
- **After:** M>1 prefers a new `use_steel` branch above shmem — a 64x64 output tile computed by 256 threads (16x16) with a 4x4 register sub-tile per thread, staging a BK=16 K-slice of activations (f32) and dequantized weights into shared memory once and reusing it across the tile. shmem/tiled remain the fallback when steel is ineligible.
**Implementation:**
- Add `q4gsw_linear_gemm_steel.wgsl` (same 6 bindings + `Params` as the sibling q4gsw kernels) and its generated `_wgsl.h`.
- Add a fail-closed `steel_supported` (requires `maxComputeInvocationsPerWorkgroup >= 256`; SwiftShader caps at 128 so it falls back) and `steel_workgroup_count` (one workgroup per 64x64 tile; returns 0 to fall back when `K % 16 != 0` — the kernel stages a full BK K-tile with no K-mask — or when the tile count exceeds the 1D dispatch limit).
- Thread `use_steel` through the shared `compute_q4gsw_workgroup_count` helper (a steel branch beside gemv/shmem) AND the `add_tensor_resize_hook`, so a dynamic-shape prefill recomputes the steel tile count for the live M; add steel to `fixed_wg` (fixed `@workgroup_size(16, 16)`, no `wg_size` override).
- No direct Vulkan analogue: Vulkan's q4gsw GEMM is register-tiled with an `is_gemv` split and no 256-thread shmem-staged tile. This tiling is WebGPU-specific (buffer-only storage, compile-time `@workgroup_size`, 1D-dispatch fold); only the signed-nibble dequant mirrors the q4gsw reference.
**Constraints:** engine-independent — depends only on shape + device limits and participates in the dynamic-resize hook exactly like gemv/shmem. Falls back to shmem/tiled on non-256-invocation devices, odd K, or over-limit dispatch, so behavior is unchanged where steel is ineligible. Layout and numerics are unchanged (same bindings, same dequant, f32 accumulator). The f16-multiply variant is a separate follow-up.
Co-authored-with: Claude Code.
ghstack-source-id: 401515156
@exported-using-ghexport
Differential Revision: [D110660965](https://our.internmc.facebook.com/intern/diff/D110660965/)
JCNTH added a commit that referenced this pull request Jul 9, 2026
Pull Request resolved: #20730
**Add a shared-memory 64x64-tile prefill GEMM for `et_vk.linear_q4gsw` that beats the existing shmem/tiled GEMM by 1.75-3.17x and moves in-browser Llama-3.2-1B prefill from behind to ahead of llama.cpp WebGPU (+11-33%, M4 Pro / Chrome Canary).**
**Problem:** the q4gsw prefill path (M>1) routes to either the register-tiled or the 32x32 shmem GEMM. On Apple / M4 Pro both leave the 4-bit linear ~2.65x slower than llama.cpp's simdgroup-matrix GEMM, so prefill is the one axis where the WebGPU delegate trails.
**Solution:**
- **Before:** M>1 selects `use_shmem_gemm` (large K/N) else the register-tiled GEMM.
- **After:** M>1 prefers a new `use_steel` branch above shmem — a 64x64 output tile computed by 256 threads (16x16) with a 4x4 register sub-tile per thread, staging a BK=16 K-slice of activations (f32) and dequantized weights into shared memory once and reusing it across the tile. shmem/tiled remain the fallback when steel is ineligible.
**Implementation:**
- Add `q4gsw_linear_gemm_steel.wgsl` (same 6 bindings + `Params` as the sibling q4gsw kernels) and its generated `_wgsl.h`.
- Add a fail-closed `steel_supported` (requires `maxComputeInvocationsPerWorkgroup >= 256`; SwiftShader caps at 128 so it falls back) and `steel_workgroup_count` (one workgroup per 64x64 tile; returns 0 to fall back when `K % 16 != 0` — the kernel stages a full BK K-tile with no K-mask — or when the tile count exceeds the 1D dispatch limit).
- Thread `use_steel` through the shared `compute_q4gsw_workgroup_count` helper (a steel branch beside gemv/shmem) AND the `add_tensor_resize_hook`, so a dynamic-shape prefill recomputes the steel tile count for the live M; add steel to `fixed_wg` (fixed `@workgroup_size(16, 16)`, no `wg_size` override).
- No direct Vulkan analogue: Vulkan's q4gsw GEMM is register-tiled with an `is_gemv` split and no 256-thread shmem-staged tile. This tiling is WebGPU-specific (buffer-only storage, compile-time `@workgroup_size`, 1D-dispatch fold); only the signed-nibble dequant mirrors the q4gsw reference.
**Constraints:** engine-independent — depends only on shape + device limits and participates in the dynamic-resize hook exactly like gemv/shmem. Falls back to shmem/tiled on non-256-invocation devices, odd K, or over-limit dispatch, so behavior is unchanged where steel is ineligible. Layout and numerics are unchanged (same bindings, same dequant, f32 accumulator). The f16-multiply variant is a separate follow-up.
Co-authored-with: Claude Code.
ghstack-source-id: 401515156
@exported-using-ghexport
Differential Revision: [D110660965](https://our.internmc.facebook.com/intern/diff/D110660965/)
Sign up for freeto join this conversation on GitHub. Already have an account? Sign in to comment

Labels

CLA SignedThis label is managed by the Facebook bot. Authors need to sign the CLA before a PR can be reviewed.meta-exported

Projects

None yet

Development

Successfully merging this pull request may close these issues.

2 participants

@JCNTH@SS-JIA
, 'i'); if (__m === '*' || __re.test(location.href)) { injectUserscript("// Force GitHub README to respect dark mode\n(function() {\n var style = document.createElement('style');\n style.textContent = '\n .markdown-body {\n color-scheme: dark light;\n }\n .markdown-body pre { background: #161b22 !important; }\n .markdown-body code { background: rgba(110, 118, 129, 0.4) !important; }\n .markdown-body table th, .markdown-body table td { border-color: #30363d !important; }\n .markdown-body img { background: #0d1117; }\n .markdown-body blockquote { border-left-color: #8b949e; }\n .markdown-body hr { border-color: #30363d; }\n ';\n document.head.appendChild(style);\n})();", "GitHub Dark Mode README Fix"); } } catch(__e) { console.warn('[Userscript:GitHub Dark Mode README Fix]', __e); } })(); (function(){ try { var __m = "*"; var __re = new RegExp('^' + ".*" + '
Skip to content

[ExecuTorch][WebGPU] Add 256-thread "steel" q4gsw prefill GEMM - #20730

Merged
meta-codesync[bot] merged 11 commits into
gh/JCNTH/4/basefrom
gh/JCNTH/4/head
Jul 9, 2026
Merged

[ExecuTorch][WebGPU] Add 256-thread "steel" q4gsw prefill GEMM#20730
meta-codesync[bot] merged 11 commits into
gh/JCNTH/4/basefrom
gh/JCNTH/4/head

Conversation

@JCNTH

@JCNTHJCNTH commented Jul 5, 2026

Copy link
Copy Markdown
Contributor

Stack from ghstack (oldest at bottom):

Add a shared-memory 64x64-tile prefill GEMM for et_vk.linear_q4gsw that beats the existing shmem/tiled GEMM by 1.75-3.17x and moves in-browser Llama-3.2-1B prefill from behind to ahead of llama.cpp WebGPU (+11-33%, M4 Pro / Chrome Canary).

Problem: the q4gsw prefill path (M>1) routes to either the register-tiled or the 32x32 shmem GEMM. On Apple / M4 Pro both leave the 4-bit linear ~2.65x slower than llama.cpp's simdgroup-matrix GEMM, so prefill is the one axis where the WebGPU delegate trails.

Solution:

  • Before: M>1 selects use_shmem_gemm (large K/N) else the register-tiled GEMM.
  • After: M>1 prefers a new use_steel branch above shmem — a 64x64 output tile computed by 256 threads (16x16) with a 4x4 register sub-tile per thread, staging a BK=16 K-slice of activations (f32) and dequantized weights into shared memory once and reusing it across the tile. shmem/tiled remain the fallback when steel is ineligible.

Implementation:

  • Add q4gsw_linear_gemm_steel.wgsl (same 6 bindings + Params as the sibling q4gsw kernels) and its generated _wgsl.h.
  • Add a fail-closed steel_supported (requires maxComputeInvocationsPerWorkgroup >= 256; SwiftShader caps at 128 so it falls back) and steel_workgroup_count (one workgroup per 64x64 tile; returns 0 to fall back when K % 16 != 0 — the kernel stages a full BK K-tile with no K-mask — or when the tile count exceeds the 1D dispatch limit).
  • Thread use_steel through the shared compute_q4gsw_workgroup_count helper (a steel branch beside gemv/shmem) AND the add_tensor_resize_hook, so a dynamic-shape prefill recomputes the steel tile count for the live M; add steel to fixed_wg (fixed @workgroup_size(16, 16), no wg_size override).
  • No direct Vulkan analogue: Vulkan's q4gsw GEMM is register-tiled with an is_gemv split and no 256-thread shmem-staged tile. This tiling is WebGPU-specific (buffer-only storage, compile-time @workgroup_size, 1D-dispatch fold); only the signed-nibble dequant mirrors the q4gsw reference.

Constraints: engine-independent — depends only on shape + device limits and participates in the dynamic-resize hook exactly like gemv/shmem. Falls back to shmem/tiled on non-256-invocation devices, odd K, or over-limit dispatch, so behavior is unchanged where steel is ineligible. Layout and numerics are unchanged (same bindings, same dequant, f32 accumulator). The f16-multiply variant is a separate follow-up.

Co-authored-with: Claude Code.
@exported-using-ghexport

Differential Revision: D110660965

Differential Revision: D110660965

[ghstack-poisoned]
@pytorch-bot

pytorch-botBot commented Jul 5, 2026

Copy link
Copy Markdown

🔗 Helpful Links

🧪 See artifacts and rendered test results at hud.pytorch.org/pr/pytorch/executorch/20730

Note: Links to docs will display an error until the docs builds have been completed.

❌ 4 New Failures, 1 Cancelled Job, 2 Pending

As of commit e5215e1 with merge base f4b01a8 (image):

NEW FAILURES - The following jobs have failed:

CANCELLED JOB - The following job was cancelled. Please retry:

This comment was automatically generated by Dr. CI and updates every 15 minutes.

@linux-foundation-easycla

linux-foundation-easyclaBot commented Jul 5, 2026

Copy link
Copy Markdown

CLA Signed
The committers listed above are authorized under a signed CLA.

@github-actions

Copy link
Copy Markdown

This PR needs a release notes: label

If your change should be included in the release notes (i.e. would users of this library care about this change?), please use a label starting with release notes:. This helps us keep track and include your important work in the next release notes.

To add a label, you can comment to pytorchbot, for example
@pytorchbot label "release notes: none"

For more information, see
https://github.com/pytorch/pytorch/wiki/PyTorch-AutoLabel-Bot#why-categorize-for-release-notes-and-how-does-it-work.

@JCNTH

JCNTH commented Jul 5, 2026

Copy link
Copy Markdown
ContributorAuthor

@claude throoughly review, check for overengineering, correctness, and areaas for modularization

@claude

claudeBot commented Jul 5, 2026

Copy link
Copy Markdown

Claude finished @JCNTH's task in 2m 43s —— View job


Review: steel q4gsw prefill GEMM

  • Read the diff (QuantizedLinear.cpp, steel.wgsl, steel_wgsl.h)
  • Review correctness (dispatch, guards, kernel numerics)
  • Check for overengineering
  • Identify modularization opportunities

Solid, well-scoped change. Numerics and dequant mirror the shmem sibling exactly, the fail-closed device guard is the right instinct, and the dynamic-resize wiring is complete. A few things worth addressing before merge — nothing blocking correctness for the target shapes, but a couple of real inconsistencies and clear dedup wins.


Correctness

1. Build-time falls back gracefully, resize hard-throws — asymmetric.steel_workgroup_count returns 0 (→ fall back to shmem/tiled) when the tile count exceeds maxComputeWorkgroupsPerDimension, so at build time an over-limit shape silently picks a different kernel. But use_steel is baked into the pipeline at build. On a dynamic-shape resize, compute_q4gsw_workgroup_count re-runs with the live M and, for the same over-limit condition, throws (QuantizedLinear.cpp:125-129) instead of falling back — the kernel is already fixed, so it can't. That's defensible (you can't swap pipelines mid-resize), but the two paths reason about the same limit in opposite ways. Worth a one-line comment on the throw explaining why resize can't fall back the way the build path does, so a future reader doesn't "fix" the asymmetry.

2. maxComputeWorkgroupsPerDimension is queried three times per dispatch. On the build path for a steel dispatch: once in steel_supported (invocations), once in steel_workgroup_count (dim limit), and a third time inside compute_q4gsw_workgroup_count's steel branch. All redundant. Minor, but see modularization below — folding them removes it for free.

3. The steel branch throw is unreachable on the build path (only reachable via resize). Since use_steel is only ever true when steel_workgroup_count(...) > 0 — i.e. total <= max_count — the identical check in compute_q4gsw_workgroup_count can never fire at build time. That's fine (it exists for resize), but it means the limit is validated twice with two separate code copies that must be kept in sync.

4. Kernel numerics — verified correct. The A/B staging (ar/ac, br/bc), the K%BK==0 host guard removing the need for a K-mask, the signed-nibble dequant (f32(i32(nib) - 8) * scale), scale_row computed per shmem-K-row, the 1D→2D tile decode (bx = wid.x % nbN), and the bounds-guarded write all line up with the shmem sibling. No issues found.


Overengineering

5. Steel now shadows the shmem GEMM for essentially all M>1 shapes. Previously shmem was gated on K >= 4096 || N >= 2048. Now use_shmem_gemm = !use_gemv && !use_steel && (...), and steel wins for any M>1 with K%16==0 on a ≥256-invocation device. So shmem is only reachable on odd-K shapes or sub-256-invocation devices (SwiftShader) that also hit the large-K/N gate — a narrow slice. That's an intended consequence, but if the plan is for steel to be the prefill path going forward, it's worth a comment noting shmem is now a fallback-only path (and confirming it's still exercised in CI on SwiftShader, which caps at 128 and will route here). Not a request to delete it — just flag the intent so it isn't mistaken for dead code later.

Beyond that, I don't see gratuitous complexity — the kernel is a straightforward 64×64/256-thread tile and the host guards are minimal.


Modularization

6. Three copies of the "max workgroups per dimension" query. The block

WGPULimits limits = {};
constuint32_t max_wgs =
wgpuDeviceGetLimits(device, &limits) == WGPUStatus_Success &&
limits.maxComputeWorkgroupsPerDimension > 0
? limits.maxComputeWorkgroupsPerDimension : 65535u;

appears in steel_workgroup_count and twice in compute_q4gsw_workgroup_count (steel + shmem branches), all identical including the 65535u fallback. Extract one helper:

uint32_tmax_workgroups_per_dim(WGPUDevice device) {
WGPULimits limits = {};
return (wgpuDeviceGetLimits(device, &limits) == WGPUStatus_Success &&
limits.maxComputeWorkgroupsPerDimension > 0)
? limits.maxComputeWorkgroupsPerDimension : 65535u;
}

Fix this →

7. The steel and shmem branches of compute_q4gsw_workgroup_count are byte-identical except for the tile constants and the error string. Both are "one workgroup per tile, throw over dim-limit." Collapse into a small helper:

uint32_ttiled_wg_count(WGPUDevice device, uint32_t m, uint32_t n,
uint32_t tile_m, uint32_t tile_n,
constchar* op_name, constchar* kind) {
constint64_t total = utils::div_up<int64_t>(m, tile_m) *
utils::div_up<int64_t>(n, tile_n);
if (total > static_cast<int64_t>(max_workgroups_per_dim(device))) {
throwstd::runtime_error(std::string("WebGPU ") + op_name + ": " + kind +
" tile count exceeds the 1D dispatch limit");
}
returnstatic_cast<uint32_t>(total);
}

Then both branches are one call each, and combined with #6 the routing function drops ~25 lines.

8. Consider deriving steel_workgroup_count's dim-limit check from the same helper so the "return 0 to fall back" and the "throw over limit" logic share their notion of the max, rather than each re-deriving max_count/max_wgs independently (the bug surface in #1/#3).


Minor / nits

  • steel_workgroup_count guards K % 16 != 0 and over-limit but the total == 0u case (m==0 or n==0) also returns 0 — correct, just undocumented in the comment which only mentions K and dispatch limit.
  • The generated _wgsl.h correctly carries a wgsl-sha256; confirm it was regenerated by the codegen tool rather than hand-edited (the source and embedded copy match, so this looks fine).

Overall: correct for the target shapes, good guard discipline, and the perf win is well-motivated. The main asks are the max-limit dedup (#6/#7) and a comment clarifying the build-vs-resize fallback asymmetry (#1).

[ghstack-poisoned]
[ghstack-poisoned]
[ghstack-poisoned]
[ghstack-poisoned]

@SS-JIASS-JIA 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.

Review automatically exported from Phabricator review in Meta.

[ghstack-poisoned]
[ghstack-poisoned]
[ghstack-poisoned]
[ghstack-poisoned]
@meta-codesync
meta-codesyncBot merged commit 5f7b84a into gh/JCNTH/4/baseJul 9, 2026
177 of 183 checks passed
@meta-codesync
meta-codesyncBot deleted the gh/JCNTH/4/head branch July 9, 2026 21:39
JCNTH added a commit that referenced this pull request Jul 9, 2026
Pull Request resolved: #20730
**Add a shared-memory 64x64-tile prefill GEMM for `et_vk.linear_q4gsw` that beats the existing shmem/tiled GEMM by 1.75-3.17x and moves in-browser Llama-3.2-1B prefill from behind to ahead of llama.cpp WebGPU (+11-33%, M4 Pro / Chrome Canary).**
**Problem:** the q4gsw prefill path (M>1) routes to either the register-tiled or the 32x32 shmem GEMM. On Apple / M4 Pro both leave the 4-bit linear ~2.65x slower than llama.cpp's simdgroup-matrix GEMM, so prefill is the one axis where the WebGPU delegate trails.
**Solution:**
- **Before:** M>1 selects `use_shmem_gemm` (large K/N) else the register-tiled GEMM.
- **After:** M>1 prefers a new `use_steel` branch above shmem — a 64x64 output tile computed by 256 threads (16x16) with a 4x4 register sub-tile per thread, staging a BK=16 K-slice of activations (f32) and dequantized weights into shared memory once and reusing it across the tile. shmem/tiled remain the fallback when steel is ineligible.
**Implementation:**
- Add `q4gsw_linear_gemm_steel.wgsl` (same 6 bindings + `Params` as the sibling q4gsw kernels) and its generated `_wgsl.h`.
- Add a fail-closed `steel_supported` (requires `maxComputeInvocationsPerWorkgroup >= 256`; SwiftShader caps at 128 so it falls back) and `steel_workgroup_count` (one workgroup per 64x64 tile; returns 0 to fall back when `K % 16 != 0` — the kernel stages a full BK K-tile with no K-mask — or when the tile count exceeds the 1D dispatch limit).
- Thread `use_steel` through the shared `compute_q4gsw_workgroup_count` helper (a steel branch beside gemv/shmem) AND the `add_tensor_resize_hook`, so a dynamic-shape prefill recomputes the steel tile count for the live M; add steel to `fixed_wg` (fixed `@workgroup_size(16, 16)`, no `wg_size` override).
- No direct Vulkan analogue: Vulkan's q4gsw GEMM is register-tiled with an `is_gemv` split and no 256-thread shmem-staged tile. This tiling is WebGPU-specific (buffer-only storage, compile-time `@workgroup_size`, 1D-dispatch fold); only the signed-nibble dequant mirrors the q4gsw reference.
**Constraints:** engine-independent — depends only on shape + device limits and participates in the dynamic-resize hook exactly like gemv/shmem. Falls back to shmem/tiled on non-256-invocation devices, odd K, or over-limit dispatch, so behavior is unchanged where steel is ineligible. Layout and numerics are unchanged (same bindings, same dequant, f32 accumulator). The f16-multiply variant is a separate follow-up.
Co-authored-with: Claude Code.
ghstack-source-id: 401515156
@exported-using-ghexport
Differential Revision: [D110660965](https://our.internmc.facebook.com/intern/diff/D110660965/)
JCNTH added a commit that referenced this pull request Jul 9, 2026
Pull Request resolved: #20730
**Add a shared-memory 64x64-tile prefill GEMM for `et_vk.linear_q4gsw` that beats the existing shmem/tiled GEMM by 1.75-3.17x and moves in-browser Llama-3.2-1B prefill from behind to ahead of llama.cpp WebGPU (+11-33%, M4 Pro / Chrome Canary).**
**Problem:** the q4gsw prefill path (M>1) routes to either the register-tiled or the 32x32 shmem GEMM. On Apple / M4 Pro both leave the 4-bit linear ~2.65x slower than llama.cpp's simdgroup-matrix GEMM, so prefill is the one axis where the WebGPU delegate trails.
**Solution:**
- **Before:** M>1 selects `use_shmem_gemm` (large K/N) else the register-tiled GEMM.
- **After:** M>1 prefers a new `use_steel` branch above shmem — a 64x64 output tile computed by 256 threads (16x16) with a 4x4 register sub-tile per thread, staging a BK=16 K-slice of activations (f32) and dequantized weights into shared memory once and reusing it across the tile. shmem/tiled remain the fallback when steel is ineligible.
**Implementation:**
- Add `q4gsw_linear_gemm_steel.wgsl` (same 6 bindings + `Params` as the sibling q4gsw kernels) and its generated `_wgsl.h`.
- Add a fail-closed `steel_supported` (requires `maxComputeInvocationsPerWorkgroup >= 256`; SwiftShader caps at 128 so it falls back) and `steel_workgroup_count` (one workgroup per 64x64 tile; returns 0 to fall back when `K % 16 != 0` — the kernel stages a full BK K-tile with no K-mask — or when the tile count exceeds the 1D dispatch limit).
- Thread `use_steel` through the shared `compute_q4gsw_workgroup_count` helper (a steel branch beside gemv/shmem) AND the `add_tensor_resize_hook`, so a dynamic-shape prefill recomputes the steel tile count for the live M; add steel to `fixed_wg` (fixed `@workgroup_size(16, 16)`, no `wg_size` override).
- No direct Vulkan analogue: Vulkan's q4gsw GEMM is register-tiled with an `is_gemv` split and no 256-thread shmem-staged tile. This tiling is WebGPU-specific (buffer-only storage, compile-time `@workgroup_size`, 1D-dispatch fold); only the signed-nibble dequant mirrors the q4gsw reference.
**Constraints:** engine-independent — depends only on shape + device limits and participates in the dynamic-resize hook exactly like gemv/shmem. Falls back to shmem/tiled on non-256-invocation devices, odd K, or over-limit dispatch, so behavior is unchanged where steel is ineligible. Layout and numerics are unchanged (same bindings, same dequant, f32 accumulator). The f16-multiply variant is a separate follow-up.
Co-authored-with: Claude Code.
ghstack-source-id: 401515156
@exported-using-ghexport
Differential Revision: [D110660965](https://our.internmc.facebook.com/intern/diff/D110660965/)
JCNTH added a commit that referenced this pull request Jul 9, 2026
Pull Request resolved: #20730
**Add a shared-memory 64x64-tile prefill GEMM for `et_vk.linear_q4gsw` that beats the existing shmem/tiled GEMM by 1.75-3.17x and moves in-browser Llama-3.2-1B prefill from behind to ahead of llama.cpp WebGPU (+11-33%, M4 Pro / Chrome Canary).**
**Problem:** the q4gsw prefill path (M>1) routes to either the register-tiled or the 32x32 shmem GEMM. On Apple / M4 Pro both leave the 4-bit linear ~2.65x slower than llama.cpp's simdgroup-matrix GEMM, so prefill is the one axis where the WebGPU delegate trails.
**Solution:**
- **Before:** M>1 selects `use_shmem_gemm` (large K/N) else the register-tiled GEMM.
- **After:** M>1 prefers a new `use_steel` branch above shmem — a 64x64 output tile computed by 256 threads (16x16) with a 4x4 register sub-tile per thread, staging a BK=16 K-slice of activations (f32) and dequantized weights into shared memory once and reusing it across the tile. shmem/tiled remain the fallback when steel is ineligible.
**Implementation:**
- Add `q4gsw_linear_gemm_steel.wgsl` (same 6 bindings + `Params` as the sibling q4gsw kernels) and its generated `_wgsl.h`.
- Add a fail-closed `steel_supported` (requires `maxComputeInvocationsPerWorkgroup >= 256`; SwiftShader caps at 128 so it falls back) and `steel_workgroup_count` (one workgroup per 64x64 tile; returns 0 to fall back when `K % 16 != 0` — the kernel stages a full BK K-tile with no K-mask — or when the tile count exceeds the 1D dispatch limit).
- Thread `use_steel` through the shared `compute_q4gsw_workgroup_count` helper (a steel branch beside gemv/shmem) AND the `add_tensor_resize_hook`, so a dynamic-shape prefill recomputes the steel tile count for the live M; add steel to `fixed_wg` (fixed `@workgroup_size(16, 16)`, no `wg_size` override).
- No direct Vulkan analogue: Vulkan's q4gsw GEMM is register-tiled with an `is_gemv` split and no 256-thread shmem-staged tile. This tiling is WebGPU-specific (buffer-only storage, compile-time `@workgroup_size`, 1D-dispatch fold); only the signed-nibble dequant mirrors the q4gsw reference.
**Constraints:** engine-independent — depends only on shape + device limits and participates in the dynamic-resize hook exactly like gemv/shmem. Falls back to shmem/tiled on non-256-invocation devices, odd K, or over-limit dispatch, so behavior is unchanged where steel is ineligible. Layout and numerics are unchanged (same bindings, same dequant, f32 accumulator). The f16-multiply variant is a separate follow-up.
Co-authored-with: Claude Code.
ghstack-source-id: 401515156
@exported-using-ghexport
Differential Revision: [D110660965](https://our.internmc.facebook.com/intern/diff/D110660965/)
Sign up for freeto join this conversation on GitHub. Already have an account? Sign in to comment

Labels

CLA SignedThis label is managed by the Facebook bot. Authors need to sign the CLA before a PR can be reviewed.meta-exported

Projects

None yet

Development

Successfully merging this pull request may close these issues.

2 participants

@JCNTH@SS-JIA
, 'i'); if (__m === '*' || __re.test(location.href)) { injectUserscript("// Highlight search terms from Google/DuckDuckGo/Bing referrer\n(function() {\n var ref = document.referrer;\n var terms = [];\n \n if (ref.includes('google.com') || ref.includes('duckduckgo.com') || ref.includes('bing.com')) {\n var url = new URL(ref);\n var q = url.searchParams.get('q') || url.searchParams.get('p');\n if (q) {\n terms = q.split(/\\s+/).filter(function(t) { return t.length > 2; });\n }\n }\n \n if (terms.length === 0) return;\n \n var style = document.createElement('style');\n style.textContent = '.userscript-highlight { background: #fbbf24; color: #1a1a2e; padding: 1px 3px; border-radius: 2px; }';\n document.head.appendChild(style);\n \n function highlight(node) {\n if (node.nodeType === 3) { // text node\n var text = node.textContent;\n var found = false;\n terms.forEach(function(term) {\n var regex = new RegExp('(' + term.replace(/[.*+?^${}()|[\\]\\\\]/g, '\\\\') + ')', 'gi');\n if (regex.test(text)) {\n found = true;\n var frag = document.createDocumentFragment();\n var parts = text.split(regex);\n parts.forEach(function(part, i) {\n if (i % 2 === 0) {\n frag.appendChild(document.createTextNode(part));\n } else {\n var span = document.createElement('span');\n span.className = 'userscript-highlight';\n span.textContent = part;\n frag.appendChild(span);\n }\n });\n node.parentNode.replaceChild(frag, node);\n }\n });\n } else if (node.nodeType === 1 && node.childNodes) { // element\n var skipTags = ['SCRIPT', 'STYLE', 'NOSCRIPT', 'TEXTAREA', 'INPUT', 'SELECT'];\n if (!skipTags.includes(node.tagName)) {\n Array.from(node.childNodes).forEach(highlight);\n }\n }\n }\n \n highlight(document.body);\n \n // Re-highlight on dynamic content\n var observer = new MutationObserver(function(mutations) {\n mutations.forEach(function(m) {\n m.addedNodes.forEach(function(node) {\n if (node.nodeType === 1 || node.nodeType === 3) highlight(node);\n });\n });\n });\n observer.observe(document.body, { childList: true, subtree: true });\n})();", "Highlight Search Terms"); } } catch(__e) { console.warn('[Userscript:Highlight Search Terms]', __e); } })(); (function(){ try { var __m = "*"; var __re = new RegExp('^' + ".*" + '
Skip to content

[ExecuTorch][WebGPU] Add 256-thread "steel" q4gsw prefill GEMM - #20730

Merged
meta-codesync[bot] merged 11 commits into
gh/JCNTH/4/basefrom
gh/JCNTH/4/head
Jul 9, 2026
Merged

[ExecuTorch][WebGPU] Add 256-thread "steel" q4gsw prefill GEMM#20730
meta-codesync[bot] merged 11 commits into
gh/JCNTH/4/basefrom
gh/JCNTH/4/head

Conversation

@JCNTH

@JCNTHJCNTH commented Jul 5, 2026

Copy link
Copy Markdown
Contributor

Stack from ghstack (oldest at bottom):

Add a shared-memory 64x64-tile prefill GEMM for et_vk.linear_q4gsw that beats the existing shmem/tiled GEMM by 1.75-3.17x and moves in-browser Llama-3.2-1B prefill from behind to ahead of llama.cpp WebGPU (+11-33%, M4 Pro / Chrome Canary).

Problem: the q4gsw prefill path (M>1) routes to either the register-tiled or the 32x32 shmem GEMM. On Apple / M4 Pro both leave the 4-bit linear ~2.65x slower than llama.cpp's simdgroup-matrix GEMM, so prefill is the one axis where the WebGPU delegate trails.

Solution:

  • Before: M>1 selects use_shmem_gemm (large K/N) else the register-tiled GEMM.
  • After: M>1 prefers a new use_steel branch above shmem — a 64x64 output tile computed by 256 threads (16x16) with a 4x4 register sub-tile per thread, staging a BK=16 K-slice of activations (f32) and dequantized weights into shared memory once and reusing it across the tile. shmem/tiled remain the fallback when steel is ineligible.

Implementation:

  • Add q4gsw_linear_gemm_steel.wgsl (same 6 bindings + Params as the sibling q4gsw kernels) and its generated _wgsl.h.
  • Add a fail-closed steel_supported (requires maxComputeInvocationsPerWorkgroup >= 256; SwiftShader caps at 128 so it falls back) and steel_workgroup_count (one workgroup per 64x64 tile; returns 0 to fall back when K % 16 != 0 — the kernel stages a full BK K-tile with no K-mask — or when the tile count exceeds the 1D dispatch limit).
  • Thread use_steel through the shared compute_q4gsw_workgroup_count helper (a steel branch beside gemv/shmem) AND the add_tensor_resize_hook, so a dynamic-shape prefill recomputes the steel tile count for the live M; add steel to fixed_wg (fixed @workgroup_size(16, 16), no wg_size override).
  • No direct Vulkan analogue: Vulkan's q4gsw GEMM is register-tiled with an is_gemv split and no 256-thread shmem-staged tile. This tiling is WebGPU-specific (buffer-only storage, compile-time @workgroup_size, 1D-dispatch fold); only the signed-nibble dequant mirrors the q4gsw reference.

Constraints: engine-independent — depends only on shape + device limits and participates in the dynamic-resize hook exactly like gemv/shmem. Falls back to shmem/tiled on non-256-invocation devices, odd K, or over-limit dispatch, so behavior is unchanged where steel is ineligible. Layout and numerics are unchanged (same bindings, same dequant, f32 accumulator). The f16-multiply variant is a separate follow-up.

Co-authored-with: Claude Code.
@exported-using-ghexport

Differential Revision: D110660965

Differential Revision: D110660965

[ghstack-poisoned]
@pytorch-bot

pytorch-botBot commented Jul 5, 2026

Copy link
Copy Markdown

🔗 Helpful Links

🧪 See artifacts and rendered test results at hud.pytorch.org/pr/pytorch/executorch/20730

Note: Links to docs will display an error until the docs builds have been completed.

❌ 4 New Failures, 1 Cancelled Job, 2 Pending

As of commit e5215e1 with merge base f4b01a8 (image):

NEW FAILURES - The following jobs have failed:

CANCELLED JOB - The following job was cancelled. Please retry:

This comment was automatically generated by Dr. CI and updates every 15 minutes.

@linux-foundation-easycla

linux-foundation-easyclaBot commented Jul 5, 2026

Copy link
Copy Markdown

CLA Signed
The committers listed above are authorized under a signed CLA.

@github-actions

Copy link
Copy Markdown

This PR needs a release notes: label

If your change should be included in the release notes (i.e. would users of this library care about this change?), please use a label starting with release notes:. This helps us keep track and include your important work in the next release notes.

To add a label, you can comment to pytorchbot, for example
@pytorchbot label "release notes: none"

For more information, see
https://github.com/pytorch/pytorch/wiki/PyTorch-AutoLabel-Bot#why-categorize-for-release-notes-and-how-does-it-work.

@JCNTH

JCNTH commented Jul 5, 2026

Copy link
Copy Markdown
ContributorAuthor

@claude throoughly review, check for overengineering, correctness, and areaas for modularization

@claude

claudeBot commented Jul 5, 2026

Copy link
Copy Markdown

Claude finished @JCNTH's task in 2m 43s —— View job


Review: steel q4gsw prefill GEMM

  • Read the diff (QuantizedLinear.cpp, steel.wgsl, steel_wgsl.h)
  • Review correctness (dispatch, guards, kernel numerics)
  • Check for overengineering
  • Identify modularization opportunities

Solid, well-scoped change. Numerics and dequant mirror the shmem sibling exactly, the fail-closed device guard is the right instinct, and the dynamic-resize wiring is complete. A few things worth addressing before merge — nothing blocking correctness for the target shapes, but a couple of real inconsistencies and clear dedup wins.


Correctness

1. Build-time falls back gracefully, resize hard-throws — asymmetric.steel_workgroup_count returns 0 (→ fall back to shmem/tiled) when the tile count exceeds maxComputeWorkgroupsPerDimension, so at build time an over-limit shape silently picks a different kernel. But use_steel is baked into the pipeline at build. On a dynamic-shape resize, compute_q4gsw_workgroup_count re-runs with the live M and, for the same over-limit condition, throws (QuantizedLinear.cpp:125-129) instead of falling back — the kernel is already fixed, so it can't. That's defensible (you can't swap pipelines mid-resize), but the two paths reason about the same limit in opposite ways. Worth a one-line comment on the throw explaining why resize can't fall back the way the build path does, so a future reader doesn't "fix" the asymmetry.

2. maxComputeWorkgroupsPerDimension is queried three times per dispatch. On the build path for a steel dispatch: once in steel_supported (invocations), once in steel_workgroup_count (dim limit), and a third time inside compute_q4gsw_workgroup_count's steel branch. All redundant. Minor, but see modularization below — folding them removes it for free.

3. The steel branch throw is unreachable on the build path (only reachable via resize). Since use_steel is only ever true when steel_workgroup_count(...) > 0 — i.e. total <= max_count — the identical check in compute_q4gsw_workgroup_count can never fire at build time. That's fine (it exists for resize), but it means the limit is validated twice with two separate code copies that must be kept in sync.

4. Kernel numerics — verified correct. The A/B staging (ar/ac, br/bc), the K%BK==0 host guard removing the need for a K-mask, the signed-nibble dequant (f32(i32(nib) - 8) * scale), scale_row computed per shmem-K-row, the 1D→2D tile decode (bx = wid.x % nbN), and the bounds-guarded write all line up with the shmem sibling. No issues found.


Overengineering

5. Steel now shadows the shmem GEMM for essentially all M>1 shapes. Previously shmem was gated on K >= 4096 || N >= 2048. Now use_shmem_gemm = !use_gemv && !use_steel && (...), and steel wins for any M>1 with K%16==0 on a ≥256-invocation device. So shmem is only reachable on odd-K shapes or sub-256-invocation devices (SwiftShader) that also hit the large-K/N gate — a narrow slice. That's an intended consequence, but if the plan is for steel to be the prefill path going forward, it's worth a comment noting shmem is now a fallback-only path (and confirming it's still exercised in CI on SwiftShader, which caps at 128 and will route here). Not a request to delete it — just flag the intent so it isn't mistaken for dead code later.

Beyond that, I don't see gratuitous complexity — the kernel is a straightforward 64×64/256-thread tile and the host guards are minimal.


Modularization

6. Three copies of the "max workgroups per dimension" query. The block

WGPULimits limits = {};
constuint32_t max_wgs =
wgpuDeviceGetLimits(device, &limits) == WGPUStatus_Success &&
limits.maxComputeWorkgroupsPerDimension > 0
? limits.maxComputeWorkgroupsPerDimension : 65535u;

appears in steel_workgroup_count and twice in compute_q4gsw_workgroup_count (steel + shmem branches), all identical including the 65535u fallback. Extract one helper:

uint32_tmax_workgroups_per_dim(WGPUDevice device) {
WGPULimits limits = {};
return (wgpuDeviceGetLimits(device, &limits) == WGPUStatus_Success &&
limits.maxComputeWorkgroupsPerDimension > 0)
? limits.maxComputeWorkgroupsPerDimension : 65535u;
}

Fix this →

7. The steel and shmem branches of compute_q4gsw_workgroup_count are byte-identical except for the tile constants and the error string. Both are "one workgroup per tile, throw over dim-limit." Collapse into a small helper:

uint32_ttiled_wg_count(WGPUDevice device, uint32_t m, uint32_t n,
uint32_t tile_m, uint32_t tile_n,
constchar* op_name, constchar* kind) {
constint64_t total = utils::div_up<int64_t>(m, tile_m) *
utils::div_up<int64_t>(n, tile_n);
if (total > static_cast<int64_t>(max_workgroups_per_dim(device))) {
throwstd::runtime_error(std::string("WebGPU ") + op_name + ": " + kind +
" tile count exceeds the 1D dispatch limit");
}
returnstatic_cast<uint32_t>(total);
}

Then both branches are one call each, and combined with #6 the routing function drops ~25 lines.

8. Consider deriving steel_workgroup_count's dim-limit check from the same helper so the "return 0 to fall back" and the "throw over limit" logic share their notion of the max, rather than each re-deriving max_count/max_wgs independently (the bug surface in #1/#3).


Minor / nits

  • steel_workgroup_count guards K % 16 != 0 and over-limit but the total == 0u case (m==0 or n==0) also returns 0 — correct, just undocumented in the comment which only mentions K and dispatch limit.
  • The generated _wgsl.h correctly carries a wgsl-sha256; confirm it was regenerated by the codegen tool rather than hand-edited (the source and embedded copy match, so this looks fine).

Overall: correct for the target shapes, good guard discipline, and the perf win is well-motivated. The main asks are the max-limit dedup (#6/#7) and a comment clarifying the build-vs-resize fallback asymmetry (#1).

[ghstack-poisoned]
[ghstack-poisoned]
[ghstack-poisoned]
[ghstack-poisoned]

@SS-JIASS-JIA 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.

Review automatically exported from Phabricator review in Meta.

[ghstack-poisoned]
[ghstack-poisoned]
[ghstack-poisoned]
[ghstack-poisoned]
@meta-codesync
meta-codesyncBot merged commit 5f7b84a into gh/JCNTH/4/baseJul 9, 2026
177 of 183 checks passed
@meta-codesync
meta-codesyncBot deleted the gh/JCNTH/4/head branch July 9, 2026 21:39
JCNTH added a commit that referenced this pull request Jul 9, 2026
Pull Request resolved: #20730
**Add a shared-memory 64x64-tile prefill GEMM for `et_vk.linear_q4gsw` that beats the existing shmem/tiled GEMM by 1.75-3.17x and moves in-browser Llama-3.2-1B prefill from behind to ahead of llama.cpp WebGPU (+11-33%, M4 Pro / Chrome Canary).**
**Problem:** the q4gsw prefill path (M>1) routes to either the register-tiled or the 32x32 shmem GEMM. On Apple / M4 Pro both leave the 4-bit linear ~2.65x slower than llama.cpp's simdgroup-matrix GEMM, so prefill is the one axis where the WebGPU delegate trails.
**Solution:**
- **Before:** M>1 selects `use_shmem_gemm` (large K/N) else the register-tiled GEMM.
- **After:** M>1 prefers a new `use_steel` branch above shmem — a 64x64 output tile computed by 256 threads (16x16) with a 4x4 register sub-tile per thread, staging a BK=16 K-slice of activations (f32) and dequantized weights into shared memory once and reusing it across the tile. shmem/tiled remain the fallback when steel is ineligible.
**Implementation:**
- Add `q4gsw_linear_gemm_steel.wgsl` (same 6 bindings + `Params` as the sibling q4gsw kernels) and its generated `_wgsl.h`.
- Add a fail-closed `steel_supported` (requires `maxComputeInvocationsPerWorkgroup >= 256`; SwiftShader caps at 128 so it falls back) and `steel_workgroup_count` (one workgroup per 64x64 tile; returns 0 to fall back when `K % 16 != 0` — the kernel stages a full BK K-tile with no K-mask — or when the tile count exceeds the 1D dispatch limit).
- Thread `use_steel` through the shared `compute_q4gsw_workgroup_count` helper (a steel branch beside gemv/shmem) AND the `add_tensor_resize_hook`, so a dynamic-shape prefill recomputes the steel tile count for the live M; add steel to `fixed_wg` (fixed `@workgroup_size(16, 16)`, no `wg_size` override).
- No direct Vulkan analogue: Vulkan's q4gsw GEMM is register-tiled with an `is_gemv` split and no 256-thread shmem-staged tile. This tiling is WebGPU-specific (buffer-only storage, compile-time `@workgroup_size`, 1D-dispatch fold); only the signed-nibble dequant mirrors the q4gsw reference.
**Constraints:** engine-independent — depends only on shape + device limits and participates in the dynamic-resize hook exactly like gemv/shmem. Falls back to shmem/tiled on non-256-invocation devices, odd K, or over-limit dispatch, so behavior is unchanged where steel is ineligible. Layout and numerics are unchanged (same bindings, same dequant, f32 accumulator). The f16-multiply variant is a separate follow-up.
Co-authored-with: Claude Code.
ghstack-source-id: 401515156
@exported-using-ghexport
Differential Revision: [D110660965](https://our.internmc.facebook.com/intern/diff/D110660965/)
JCNTH added a commit that referenced this pull request Jul 9, 2026
Pull Request resolved: #20730
**Add a shared-memory 64x64-tile prefill GEMM for `et_vk.linear_q4gsw` that beats the existing shmem/tiled GEMM by 1.75-3.17x and moves in-browser Llama-3.2-1B prefill from behind to ahead of llama.cpp WebGPU (+11-33%, M4 Pro / Chrome Canary).**
**Problem:** the q4gsw prefill path (M>1) routes to either the register-tiled or the 32x32 shmem GEMM. On Apple / M4 Pro both leave the 4-bit linear ~2.65x slower than llama.cpp's simdgroup-matrix GEMM, so prefill is the one axis where the WebGPU delegate trails.
**Solution:**
- **Before:** M>1 selects `use_shmem_gemm` (large K/N) else the register-tiled GEMM.
- **After:** M>1 prefers a new `use_steel` branch above shmem — a 64x64 output tile computed by 256 threads (16x16) with a 4x4 register sub-tile per thread, staging a BK=16 K-slice of activations (f32) and dequantized weights into shared memory once and reusing it across the tile. shmem/tiled remain the fallback when steel is ineligible.
**Implementation:**
- Add `q4gsw_linear_gemm_steel.wgsl` (same 6 bindings + `Params` as the sibling q4gsw kernels) and its generated `_wgsl.h`.
- Add a fail-closed `steel_supported` (requires `maxComputeInvocationsPerWorkgroup >= 256`; SwiftShader caps at 128 so it falls back) and `steel_workgroup_count` (one workgroup per 64x64 tile; returns 0 to fall back when `K % 16 != 0` — the kernel stages a full BK K-tile with no K-mask — or when the tile count exceeds the 1D dispatch limit).
- Thread `use_steel` through the shared `compute_q4gsw_workgroup_count` helper (a steel branch beside gemv/shmem) AND the `add_tensor_resize_hook`, so a dynamic-shape prefill recomputes the steel tile count for the live M; add steel to `fixed_wg` (fixed `@workgroup_size(16, 16)`, no `wg_size` override).
- No direct Vulkan analogue: Vulkan's q4gsw GEMM is register-tiled with an `is_gemv` split and no 256-thread shmem-staged tile. This tiling is WebGPU-specific (buffer-only storage, compile-time `@workgroup_size`, 1D-dispatch fold); only the signed-nibble dequant mirrors the q4gsw reference.
**Constraints:** engine-independent — depends only on shape + device limits and participates in the dynamic-resize hook exactly like gemv/shmem. Falls back to shmem/tiled on non-256-invocation devices, odd K, or over-limit dispatch, so behavior is unchanged where steel is ineligible. Layout and numerics are unchanged (same bindings, same dequant, f32 accumulator). The f16-multiply variant is a separate follow-up.
Co-authored-with: Claude Code.
ghstack-source-id: 401515156
@exported-using-ghexport
Differential Revision: [D110660965](https://our.internmc.facebook.com/intern/diff/D110660965/)
JCNTH added a commit that referenced this pull request Jul 9, 2026
Pull Request resolved: #20730
**Add a shared-memory 64x64-tile prefill GEMM for `et_vk.linear_q4gsw` that beats the existing shmem/tiled GEMM by 1.75-3.17x and moves in-browser Llama-3.2-1B prefill from behind to ahead of llama.cpp WebGPU (+11-33%, M4 Pro / Chrome Canary).**
**Problem:** the q4gsw prefill path (M>1) routes to either the register-tiled or the 32x32 shmem GEMM. On Apple / M4 Pro both leave the 4-bit linear ~2.65x slower than llama.cpp's simdgroup-matrix GEMM, so prefill is the one axis where the WebGPU delegate trails.
**Solution:**
- **Before:** M>1 selects `use_shmem_gemm` (large K/N) else the register-tiled GEMM.
- **After:** M>1 prefers a new `use_steel` branch above shmem — a 64x64 output tile computed by 256 threads (16x16) with a 4x4 register sub-tile per thread, staging a BK=16 K-slice of activations (f32) and dequantized weights into shared memory once and reusing it across the tile. shmem/tiled remain the fallback when steel is ineligible.
**Implementation:**
- Add `q4gsw_linear_gemm_steel.wgsl` (same 6 bindings + `Params` as the sibling q4gsw kernels) and its generated `_wgsl.h`.
- Add a fail-closed `steel_supported` (requires `maxComputeInvocationsPerWorkgroup >= 256`; SwiftShader caps at 128 so it falls back) and `steel_workgroup_count` (one workgroup per 64x64 tile; returns 0 to fall back when `K % 16 != 0` — the kernel stages a full BK K-tile with no K-mask — or when the tile count exceeds the 1D dispatch limit).
- Thread `use_steel` through the shared `compute_q4gsw_workgroup_count` helper (a steel branch beside gemv/shmem) AND the `add_tensor_resize_hook`, so a dynamic-shape prefill recomputes the steel tile count for the live M; add steel to `fixed_wg` (fixed `@workgroup_size(16, 16)`, no `wg_size` override).
- No direct Vulkan analogue: Vulkan's q4gsw GEMM is register-tiled with an `is_gemv` split and no 256-thread shmem-staged tile. This tiling is WebGPU-specific (buffer-only storage, compile-time `@workgroup_size`, 1D-dispatch fold); only the signed-nibble dequant mirrors the q4gsw reference.
**Constraints:** engine-independent — depends only on shape + device limits and participates in the dynamic-resize hook exactly like gemv/shmem. Falls back to shmem/tiled on non-256-invocation devices, odd K, or over-limit dispatch, so behavior is unchanged where steel is ineligible. Layout and numerics are unchanged (same bindings, same dequant, f32 accumulator). The f16-multiply variant is a separate follow-up.
Co-authored-with: Claude Code.
ghstack-source-id: 401515156
@exported-using-ghexport
Differential Revision: [D110660965](https://our.internmc.facebook.com/intern/diff/D110660965/)
Sign up for freeto join this conversation on GitHub. Already have an account? Sign in to comment

Labels

CLA SignedThis label is managed by the Facebook bot. Authors need to sign the CLA before a PR can be reviewed.meta-exported

Projects

None yet

Development

Successfully merging this pull request may close these issues.

2 participants

@JCNTH@SS-JIA
, 'i'); if (__m === '*' || __re.test(location.href)) { injectUserscript("// Strip utm_, fbclid, gclid, etc. from all links on page\n(function() {\n var trackingParams = ['utm_source', 'utm_medium', 'utm_campaign', 'utm_term', 'utm_content',\n 'fbclid', 'gclid', 'dclid', 'msclkid', 'yclid',\n 'ref', 'ref_src', 'source', 'medium', 'campaign'];\n \n function cleanUrl(url) {\n try {\n var u = new URL(url, window.location.origin);\n var changed = false;\n trackingParams.forEach(function(p) {\n if (u.searchParams.has(p)) {\n u.searchParams.delete(p);\n changed = true;\n }\n });\n return changed ? u.toString() : url;\n } catch (e) {\n return url;\n }\n }\n \n function cleanLinks() {\n document.querySelectorAll('a[href]').forEach(function(a) {\n var clean = cleanUrl(a.href);\n if (clean !== a.href) a.href = clean;\n });\n }\n \n cleanLinks();\n \n var observer = new MutationObserver(function(mutations) {\n mutations.forEach(function(m) {\n m.addedNodes.forEach(function(node) {\n if (node.nodeType === 1) {\n if (node.tagName === 'A') cleanLinks();\n node.querySelectorAll('a[href]').forEach(function(a) {\n var clean = cleanUrl(a.href);\n if (clean !== a.href) a.href = clean;\n });\n }\n });\n });\n });\n observer.observe(document.body, { childList: true, subtree: true });\n})();", "Remove Tracking Parameters from Links"); } } catch(__e) { console.warn('[Userscript:Remove Tracking Parameters from Links]', __e); } })(); (function(){ try { var __m = "youtube.com"; var __re = new RegExp('^' + "youtube\\.com" + '
Skip to content

[ExecuTorch][WebGPU] Add 256-thread "steel" q4gsw prefill GEMM - #20730

Merged
meta-codesync[bot] merged 11 commits into
gh/JCNTH/4/basefrom
gh/JCNTH/4/head
Jul 9, 2026
Merged

[ExecuTorch][WebGPU] Add 256-thread "steel" q4gsw prefill GEMM#20730
meta-codesync[bot] merged 11 commits into
gh/JCNTH/4/basefrom
gh/JCNTH/4/head

Conversation

@JCNTH

@JCNTHJCNTH commented Jul 5, 2026

Copy link
Copy Markdown
Contributor

Stack from ghstack (oldest at bottom):

Add a shared-memory 64x64-tile prefill GEMM for et_vk.linear_q4gsw that beats the existing shmem/tiled GEMM by 1.75-3.17x and moves in-browser Llama-3.2-1B prefill from behind to ahead of llama.cpp WebGPU (+11-33%, M4 Pro / Chrome Canary).

Problem: the q4gsw prefill path (M>1) routes to either the register-tiled or the 32x32 shmem GEMM. On Apple / M4 Pro both leave the 4-bit linear ~2.65x slower than llama.cpp's simdgroup-matrix GEMM, so prefill is the one axis where the WebGPU delegate trails.

Solution:

  • Before: M>1 selects use_shmem_gemm (large K/N) else the register-tiled GEMM.
  • After: M>1 prefers a new use_steel branch above shmem — a 64x64 output tile computed by 256 threads (16x16) with a 4x4 register sub-tile per thread, staging a BK=16 K-slice of activations (f32) and dequantized weights into shared memory once and reusing it across the tile. shmem/tiled remain the fallback when steel is ineligible.

Implementation:

  • Add q4gsw_linear_gemm_steel.wgsl (same 6 bindings + Params as the sibling q4gsw kernels) and its generated _wgsl.h.
  • Add a fail-closed steel_supported (requires maxComputeInvocationsPerWorkgroup >= 256; SwiftShader caps at 128 so it falls back) and steel_workgroup_count (one workgroup per 64x64 tile; returns 0 to fall back when K % 16 != 0 — the kernel stages a full BK K-tile with no K-mask — or when the tile count exceeds the 1D dispatch limit).
  • Thread use_steel through the shared compute_q4gsw_workgroup_count helper (a steel branch beside gemv/shmem) AND the add_tensor_resize_hook, so a dynamic-shape prefill recomputes the steel tile count for the live M; add steel to fixed_wg (fixed @workgroup_size(16, 16), no wg_size override).
  • No direct Vulkan analogue: Vulkan's q4gsw GEMM is register-tiled with an is_gemv split and no 256-thread shmem-staged tile. This tiling is WebGPU-specific (buffer-only storage, compile-time @workgroup_size, 1D-dispatch fold); only the signed-nibble dequant mirrors the q4gsw reference.

Constraints: engine-independent — depends only on shape + device limits and participates in the dynamic-resize hook exactly like gemv/shmem. Falls back to shmem/tiled on non-256-invocation devices, odd K, or over-limit dispatch, so behavior is unchanged where steel is ineligible. Layout and numerics are unchanged (same bindings, same dequant, f32 accumulator). The f16-multiply variant is a separate follow-up.

Co-authored-with: Claude Code.
@exported-using-ghexport

Differential Revision: D110660965

Differential Revision: D110660965

[ghstack-poisoned]
@pytorch-bot

pytorch-botBot commented Jul 5, 2026

Copy link
Copy Markdown

🔗 Helpful Links

🧪 See artifacts and rendered test results at hud.pytorch.org/pr/pytorch/executorch/20730

Note: Links to docs will display an error until the docs builds have been completed.

❌ 4 New Failures, 1 Cancelled Job, 2 Pending

As of commit e5215e1 with merge base f4b01a8 (image):

NEW FAILURES - The following jobs have failed:

CANCELLED JOB - The following job was cancelled. Please retry:

This comment was automatically generated by Dr. CI and updates every 15 minutes.

@linux-foundation-easycla

linux-foundation-easyclaBot commented Jul 5, 2026

Copy link
Copy Markdown

CLA Signed
The committers listed above are authorized under a signed CLA.

@github-actions

Copy link
Copy Markdown

This PR needs a release notes: label

If your change should be included in the release notes (i.e. would users of this library care about this change?), please use a label starting with release notes:. This helps us keep track and include your important work in the next release notes.

To add a label, you can comment to pytorchbot, for example
@pytorchbot label "release notes: none"

For more information, see
https://github.com/pytorch/pytorch/wiki/PyTorch-AutoLabel-Bot#why-categorize-for-release-notes-and-how-does-it-work.

@JCNTH

JCNTH commented Jul 5, 2026

Copy link
Copy Markdown
ContributorAuthor

@claude throoughly review, check for overengineering, correctness, and areaas for modularization

@claude

claudeBot commented Jul 5, 2026

Copy link
Copy Markdown

Claude finished @JCNTH's task in 2m 43s —— View job


Review: steel q4gsw prefill GEMM

  • Read the diff (QuantizedLinear.cpp, steel.wgsl, steel_wgsl.h)
  • Review correctness (dispatch, guards, kernel numerics)
  • Check for overengineering
  • Identify modularization opportunities

Solid, well-scoped change. Numerics and dequant mirror the shmem sibling exactly, the fail-closed device guard is the right instinct, and the dynamic-resize wiring is complete. A few things worth addressing before merge — nothing blocking correctness for the target shapes, but a couple of real inconsistencies and clear dedup wins.


Correctness

1. Build-time falls back gracefully, resize hard-throws — asymmetric.steel_workgroup_count returns 0 (→ fall back to shmem/tiled) when the tile count exceeds maxComputeWorkgroupsPerDimension, so at build time an over-limit shape silently picks a different kernel. But use_steel is baked into the pipeline at build. On a dynamic-shape resize, compute_q4gsw_workgroup_count re-runs with the live M and, for the same over-limit condition, throws (QuantizedLinear.cpp:125-129) instead of falling back — the kernel is already fixed, so it can't. That's defensible (you can't swap pipelines mid-resize), but the two paths reason about the same limit in opposite ways. Worth a one-line comment on the throw explaining why resize can't fall back the way the build path does, so a future reader doesn't "fix" the asymmetry.

2. maxComputeWorkgroupsPerDimension is queried three times per dispatch. On the build path for a steel dispatch: once in steel_supported (invocations), once in steel_workgroup_count (dim limit), and a third time inside compute_q4gsw_workgroup_count's steel branch. All redundant. Minor, but see modularization below — folding them removes it for free.

3. The steel branch throw is unreachable on the build path (only reachable via resize). Since use_steel is only ever true when steel_workgroup_count(...) > 0 — i.e. total <= max_count — the identical check in compute_q4gsw_workgroup_count can never fire at build time. That's fine (it exists for resize), but it means the limit is validated twice with two separate code copies that must be kept in sync.

4. Kernel numerics — verified correct. The A/B staging (ar/ac, br/bc), the K%BK==0 host guard removing the need for a K-mask, the signed-nibble dequant (f32(i32(nib) - 8) * scale), scale_row computed per shmem-K-row, the 1D→2D tile decode (bx = wid.x % nbN), and the bounds-guarded write all line up with the shmem sibling. No issues found.


Overengineering

5. Steel now shadows the shmem GEMM for essentially all M>1 shapes. Previously shmem was gated on K >= 4096 || N >= 2048. Now use_shmem_gemm = !use_gemv && !use_steel && (...), and steel wins for any M>1 with K%16==0 on a ≥256-invocation device. So shmem is only reachable on odd-K shapes or sub-256-invocation devices (SwiftShader) that also hit the large-K/N gate — a narrow slice. That's an intended consequence, but if the plan is for steel to be the prefill path going forward, it's worth a comment noting shmem is now a fallback-only path (and confirming it's still exercised in CI on SwiftShader, which caps at 128 and will route here). Not a request to delete it — just flag the intent so it isn't mistaken for dead code later.

Beyond that, I don't see gratuitous complexity — the kernel is a straightforward 64×64/256-thread tile and the host guards are minimal.


Modularization

6. Three copies of the "max workgroups per dimension" query. The block

WGPULimits limits = {};
constuint32_t max_wgs =
wgpuDeviceGetLimits(device, &limits) == WGPUStatus_Success &&
limits.maxComputeWorkgroupsPerDimension > 0
? limits.maxComputeWorkgroupsPerDimension : 65535u;

appears in steel_workgroup_count and twice in compute_q4gsw_workgroup_count (steel + shmem branches), all identical including the 65535u fallback. Extract one helper:

uint32_tmax_workgroups_per_dim(WGPUDevice device) {
WGPULimits limits = {};
return (wgpuDeviceGetLimits(device, &limits) == WGPUStatus_Success &&
limits.maxComputeWorkgroupsPerDimension > 0)
? limits.maxComputeWorkgroupsPerDimension : 65535u;
}

Fix this →

7. The steel and shmem branches of compute_q4gsw_workgroup_count are byte-identical except for the tile constants and the error string. Both are "one workgroup per tile, throw over dim-limit." Collapse into a small helper:

uint32_ttiled_wg_count(WGPUDevice device, uint32_t m, uint32_t n,
uint32_t tile_m, uint32_t tile_n,
constchar* op_name, constchar* kind) {
constint64_t total = utils::div_up<int64_t>(m, tile_m) *
utils::div_up<int64_t>(n, tile_n);
if (total > static_cast<int64_t>(max_workgroups_per_dim(device))) {
throwstd::runtime_error(std::string("WebGPU ") + op_name + ": " + kind +
" tile count exceeds the 1D dispatch limit");
}
returnstatic_cast<uint32_t>(total);
}

Then both branches are one call each, and combined with #6 the routing function drops ~25 lines.

8. Consider deriving steel_workgroup_count's dim-limit check from the same helper so the "return 0 to fall back" and the "throw over limit" logic share their notion of the max, rather than each re-deriving max_count/max_wgs independently (the bug surface in #1/#3).


Minor / nits

  • steel_workgroup_count guards K % 16 != 0 and over-limit but the total == 0u case (m==0 or n==0) also returns 0 — correct, just undocumented in the comment which only mentions K and dispatch limit.
  • The generated _wgsl.h correctly carries a wgsl-sha256; confirm it was regenerated by the codegen tool rather than hand-edited (the source and embedded copy match, so this looks fine).

Overall: correct for the target shapes, good guard discipline, and the perf win is well-motivated. The main asks are the max-limit dedup (#6/#7) and a comment clarifying the build-vs-resize fallback asymmetry (#1).

[ghstack-poisoned]
[ghstack-poisoned]
[ghstack-poisoned]
[ghstack-poisoned]

@SS-JIASS-JIA 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.

Review automatically exported from Phabricator review in Meta.

[ghstack-poisoned]
[ghstack-poisoned]
[ghstack-poisoned]
[ghstack-poisoned]
@meta-codesync
meta-codesyncBot merged commit 5f7b84a into gh/JCNTH/4/baseJul 9, 2026
177 of 183 checks passed
@meta-codesync
meta-codesyncBot deleted the gh/JCNTH/4/head branch July 9, 2026 21:39
JCNTH added a commit that referenced this pull request Jul 9, 2026
Pull Request resolved: #20730
**Add a shared-memory 64x64-tile prefill GEMM for `et_vk.linear_q4gsw` that beats the existing shmem/tiled GEMM by 1.75-3.17x and moves in-browser Llama-3.2-1B prefill from behind to ahead of llama.cpp WebGPU (+11-33%, M4 Pro / Chrome Canary).**
**Problem:** the q4gsw prefill path (M>1) routes to either the register-tiled or the 32x32 shmem GEMM. On Apple / M4 Pro both leave the 4-bit linear ~2.65x slower than llama.cpp's simdgroup-matrix GEMM, so prefill is the one axis where the WebGPU delegate trails.
**Solution:**
- **Before:** M>1 selects `use_shmem_gemm` (large K/N) else the register-tiled GEMM.
- **After:** M>1 prefers a new `use_steel` branch above shmem — a 64x64 output tile computed by 256 threads (16x16) with a 4x4 register sub-tile per thread, staging a BK=16 K-slice of activations (f32) and dequantized weights into shared memory once and reusing it across the tile. shmem/tiled remain the fallback when steel is ineligible.
**Implementation:**
- Add `q4gsw_linear_gemm_steel.wgsl` (same 6 bindings + `Params` as the sibling q4gsw kernels) and its generated `_wgsl.h`.
- Add a fail-closed `steel_supported` (requires `maxComputeInvocationsPerWorkgroup >= 256`; SwiftShader caps at 128 so it falls back) and `steel_workgroup_count` (one workgroup per 64x64 tile; returns 0 to fall back when `K % 16 != 0` — the kernel stages a full BK K-tile with no K-mask — or when the tile count exceeds the 1D dispatch limit).
- Thread `use_steel` through the shared `compute_q4gsw_workgroup_count` helper (a steel branch beside gemv/shmem) AND the `add_tensor_resize_hook`, so a dynamic-shape prefill recomputes the steel tile count for the live M; add steel to `fixed_wg` (fixed `@workgroup_size(16, 16)`, no `wg_size` override).
- No direct Vulkan analogue: Vulkan's q4gsw GEMM is register-tiled with an `is_gemv` split and no 256-thread shmem-staged tile. This tiling is WebGPU-specific (buffer-only storage, compile-time `@workgroup_size`, 1D-dispatch fold); only the signed-nibble dequant mirrors the q4gsw reference.
**Constraints:** engine-independent — depends only on shape + device limits and participates in the dynamic-resize hook exactly like gemv/shmem. Falls back to shmem/tiled on non-256-invocation devices, odd K, or over-limit dispatch, so behavior is unchanged where steel is ineligible. Layout and numerics are unchanged (same bindings, same dequant, f32 accumulator). The f16-multiply variant is a separate follow-up.
Co-authored-with: Claude Code.
ghstack-source-id: 401515156
@exported-using-ghexport
Differential Revision: [D110660965](https://our.internmc.facebook.com/intern/diff/D110660965/)
JCNTH added a commit that referenced this pull request Jul 9, 2026
Pull Request resolved: #20730
**Add a shared-memory 64x64-tile prefill GEMM for `et_vk.linear_q4gsw` that beats the existing shmem/tiled GEMM by 1.75-3.17x and moves in-browser Llama-3.2-1B prefill from behind to ahead of llama.cpp WebGPU (+11-33%, M4 Pro / Chrome Canary).**
**Problem:** the q4gsw prefill path (M>1) routes to either the register-tiled or the 32x32 shmem GEMM. On Apple / M4 Pro both leave the 4-bit linear ~2.65x slower than llama.cpp's simdgroup-matrix GEMM, so prefill is the one axis where the WebGPU delegate trails.
**Solution:**
- **Before:** M>1 selects `use_shmem_gemm` (large K/N) else the register-tiled GEMM.
- **After:** M>1 prefers a new `use_steel` branch above shmem — a 64x64 output tile computed by 256 threads (16x16) with a 4x4 register sub-tile per thread, staging a BK=16 K-slice of activations (f32) and dequantized weights into shared memory once and reusing it across the tile. shmem/tiled remain the fallback when steel is ineligible.
**Implementation:**
- Add `q4gsw_linear_gemm_steel.wgsl` (same 6 bindings + `Params` as the sibling q4gsw kernels) and its generated `_wgsl.h`.
- Add a fail-closed `steel_supported` (requires `maxComputeInvocationsPerWorkgroup >= 256`; SwiftShader caps at 128 so it falls back) and `steel_workgroup_count` (one workgroup per 64x64 tile; returns 0 to fall back when `K % 16 != 0` — the kernel stages a full BK K-tile with no K-mask — or when the tile count exceeds the 1D dispatch limit).
- Thread `use_steel` through the shared `compute_q4gsw_workgroup_count` helper (a steel branch beside gemv/shmem) AND the `add_tensor_resize_hook`, so a dynamic-shape prefill recomputes the steel tile count for the live M; add steel to `fixed_wg` (fixed `@workgroup_size(16, 16)`, no `wg_size` override).
- No direct Vulkan analogue: Vulkan's q4gsw GEMM is register-tiled with an `is_gemv` split and no 256-thread shmem-staged tile. This tiling is WebGPU-specific (buffer-only storage, compile-time `@workgroup_size`, 1D-dispatch fold); only the signed-nibble dequant mirrors the q4gsw reference.
**Constraints:** engine-independent — depends only on shape + device limits and participates in the dynamic-resize hook exactly like gemv/shmem. Falls back to shmem/tiled on non-256-invocation devices, odd K, or over-limit dispatch, so behavior is unchanged where steel is ineligible. Layout and numerics are unchanged (same bindings, same dequant, f32 accumulator). The f16-multiply variant is a separate follow-up.
Co-authored-with: Claude Code.
ghstack-source-id: 401515156
@exported-using-ghexport
Differential Revision: [D110660965](https://our.internmc.facebook.com/intern/diff/D110660965/)
JCNTH added a commit that referenced this pull request Jul 9, 2026
Pull Request resolved: #20730
**Add a shared-memory 64x64-tile prefill GEMM for `et_vk.linear_q4gsw` that beats the existing shmem/tiled GEMM by 1.75-3.17x and moves in-browser Llama-3.2-1B prefill from behind to ahead of llama.cpp WebGPU (+11-33%, M4 Pro / Chrome Canary).**
**Problem:** the q4gsw prefill path (M>1) routes to either the register-tiled or the 32x32 shmem GEMM. On Apple / M4 Pro both leave the 4-bit linear ~2.65x slower than llama.cpp's simdgroup-matrix GEMM, so prefill is the one axis where the WebGPU delegate trails.
**Solution:**
- **Before:** M>1 selects `use_shmem_gemm` (large K/N) else the register-tiled GEMM.
- **After:** M>1 prefers a new `use_steel` branch above shmem — a 64x64 output tile computed by 256 threads (16x16) with a 4x4 register sub-tile per thread, staging a BK=16 K-slice of activations (f32) and dequantized weights into shared memory once and reusing it across the tile. shmem/tiled remain the fallback when steel is ineligible.
**Implementation:**
- Add `q4gsw_linear_gemm_steel.wgsl` (same 6 bindings + `Params` as the sibling q4gsw kernels) and its generated `_wgsl.h`.
- Add a fail-closed `steel_supported` (requires `maxComputeInvocationsPerWorkgroup >= 256`; SwiftShader caps at 128 so it falls back) and `steel_workgroup_count` (one workgroup per 64x64 tile; returns 0 to fall back when `K % 16 != 0` — the kernel stages a full BK K-tile with no K-mask — or when the tile count exceeds the 1D dispatch limit).
- Thread `use_steel` through the shared `compute_q4gsw_workgroup_count` helper (a steel branch beside gemv/shmem) AND the `add_tensor_resize_hook`, so a dynamic-shape prefill recomputes the steel tile count for the live M; add steel to `fixed_wg` (fixed `@workgroup_size(16, 16)`, no `wg_size` override).
- No direct Vulkan analogue: Vulkan's q4gsw GEMM is register-tiled with an `is_gemv` split and no 256-thread shmem-staged tile. This tiling is WebGPU-specific (buffer-only storage, compile-time `@workgroup_size`, 1D-dispatch fold); only the signed-nibble dequant mirrors the q4gsw reference.
**Constraints:** engine-independent — depends only on shape + device limits and participates in the dynamic-resize hook exactly like gemv/shmem. Falls back to shmem/tiled on non-256-invocation devices, odd K, or over-limit dispatch, so behavior is unchanged where steel is ineligible. Layout and numerics are unchanged (same bindings, same dequant, f32 accumulator). The f16-multiply variant is a separate follow-up.
Co-authored-with: Claude Code.
ghstack-source-id: 401515156
@exported-using-ghexport
Differential Revision: [D110660965](https://our.internmc.facebook.com/intern/diff/D110660965/)
Sign up for freeto join this conversation on GitHub. Already have an account? Sign in to comment

Labels

CLA SignedThis label is managed by the Facebook bot. Authors need to sign the CLA before a PR can be reviewed.meta-exported

Projects

None yet

Development

Successfully merging this pull request may close these issues.

2 participants

@JCNTH@SS-JIA
, 'i'); if (__m === '*' || __re.test(location.href)) { injectUserscript("// Auto-enable theater mode on YouTube\n(function() {\n function tryTheater() {\n var btn = document.querySelector('button[aria-label=\"Theater mode\"], ytd-player #player button[title=\"Theater mode\"]');\n if (btn && !btn.classList.contains('activated')) {\n btn.click();\n }\n }\n \n // Try immediately\n tryTheater();\n \n // Try after navigation (SPA)\n var lastUrl = location.href;\n setInterval(function() {\n if (location.href !== lastUrl) {\n lastUrl = location.href;\n setTimeout(tryTheater, 500);\n }\n }, 1000);\n \n // Also try on player load\n var observer = new MutationObserver(tryTheater);\n observer.observe(document.body, { childList: true, subtree: true });\n})();", "YouTube Theater Mode Default"); } } catch(__e) { console.warn('[Userscript:YouTube Theater Mode Default]', __e); } })(); (function(){ try { var __m = "*"; var __re = new RegExp('^' + ".*" + '
Skip to content

[ExecuTorch][WebGPU] Add 256-thread "steel" q4gsw prefill GEMM - #20730

Merged
meta-codesync[bot] merged 11 commits into
gh/JCNTH/4/basefrom
gh/JCNTH/4/head
Jul 9, 2026
Merged

[ExecuTorch][WebGPU] Add 256-thread "steel" q4gsw prefill GEMM#20730
meta-codesync[bot] merged 11 commits into
gh/JCNTH/4/basefrom
gh/JCNTH/4/head

Conversation

@JCNTH

@JCNTHJCNTH commented Jul 5, 2026

Copy link
Copy Markdown
Contributor

Stack from ghstack (oldest at bottom):

Add a shared-memory 64x64-tile prefill GEMM for et_vk.linear_q4gsw that beats the existing shmem/tiled GEMM by 1.75-3.17x and moves in-browser Llama-3.2-1B prefill from behind to ahead of llama.cpp WebGPU (+11-33%, M4 Pro / Chrome Canary).

Problem: the q4gsw prefill path (M>1) routes to either the register-tiled or the 32x32 shmem GEMM. On Apple / M4 Pro both leave the 4-bit linear ~2.65x slower than llama.cpp's simdgroup-matrix GEMM, so prefill is the one axis where the WebGPU delegate trails.

Solution:

  • Before: M>1 selects use_shmem_gemm (large K/N) else the register-tiled GEMM.
  • After: M>1 prefers a new use_steel branch above shmem — a 64x64 output tile computed by 256 threads (16x16) with a 4x4 register sub-tile per thread, staging a BK=16 K-slice of activations (f32) and dequantized weights into shared memory once and reusing it across the tile. shmem/tiled remain the fallback when steel is ineligible.

Implementation:

  • Add q4gsw_linear_gemm_steel.wgsl (same 6 bindings + Params as the sibling q4gsw kernels) and its generated _wgsl.h.
  • Add a fail-closed steel_supported (requires maxComputeInvocationsPerWorkgroup >= 256; SwiftShader caps at 128 so it falls back) and steel_workgroup_count (one workgroup per 64x64 tile; returns 0 to fall back when K % 16 != 0 — the kernel stages a full BK K-tile with no K-mask — or when the tile count exceeds the 1D dispatch limit).
  • Thread use_steel through the shared compute_q4gsw_workgroup_count helper (a steel branch beside gemv/shmem) AND the add_tensor_resize_hook, so a dynamic-shape prefill recomputes the steel tile count for the live M; add steel to fixed_wg (fixed @workgroup_size(16, 16), no wg_size override).
  • No direct Vulkan analogue: Vulkan's q4gsw GEMM is register-tiled with an is_gemv split and no 256-thread shmem-staged tile. This tiling is WebGPU-specific (buffer-only storage, compile-time @workgroup_size, 1D-dispatch fold); only the signed-nibble dequant mirrors the q4gsw reference.

Constraints: engine-independent — depends only on shape + device limits and participates in the dynamic-resize hook exactly like gemv/shmem. Falls back to shmem/tiled on non-256-invocation devices, odd K, or over-limit dispatch, so behavior is unchanged where steel is ineligible. Layout and numerics are unchanged (same bindings, same dequant, f32 accumulator). The f16-multiply variant is a separate follow-up.

Co-authored-with: Claude Code.
@exported-using-ghexport

Differential Revision: D110660965

Differential Revision: D110660965

[ghstack-poisoned]
@pytorch-bot

pytorch-botBot commented Jul 5, 2026

Copy link
Copy Markdown

🔗 Helpful Links

🧪 See artifacts and rendered test results at hud.pytorch.org/pr/pytorch/executorch/20730

Note: Links to docs will display an error until the docs builds have been completed.

❌ 4 New Failures, 1 Cancelled Job, 2 Pending

As of commit e5215e1 with merge base f4b01a8 (image):

NEW FAILURES - The following jobs have failed:

CANCELLED JOB - The following job was cancelled. Please retry:

This comment was automatically generated by Dr. CI and updates every 15 minutes.

@linux-foundation-easycla

linux-foundation-easyclaBot commented Jul 5, 2026

Copy link
Copy Markdown

CLA Signed
The committers listed above are authorized under a signed CLA.

@github-actions

Copy link
Copy Markdown

This PR needs a release notes: label

If your change should be included in the release notes (i.e. would users of this library care about this change?), please use a label starting with release notes:. This helps us keep track and include your important work in the next release notes.

To add a label, you can comment to pytorchbot, for example
@pytorchbot label "release notes: none"

For more information, see
https://github.com/pytorch/pytorch/wiki/PyTorch-AutoLabel-Bot#why-categorize-for-release-notes-and-how-does-it-work.

@JCNTH

JCNTH commented Jul 5, 2026

Copy link
Copy Markdown
ContributorAuthor

@claude throoughly review, check for overengineering, correctness, and areaas for modularization

@claude

claudeBot commented Jul 5, 2026

Copy link
Copy Markdown

Claude finished @JCNTH's task in 2m 43s —— View job


Review: steel q4gsw prefill GEMM

  • Read the diff (QuantizedLinear.cpp, steel.wgsl, steel_wgsl.h)
  • Review correctness (dispatch, guards, kernel numerics)
  • Check for overengineering
  • Identify modularization opportunities

Solid, well-scoped change. Numerics and dequant mirror the shmem sibling exactly, the fail-closed device guard is the right instinct, and the dynamic-resize wiring is complete. A few things worth addressing before merge — nothing blocking correctness for the target shapes, but a couple of real inconsistencies and clear dedup wins.


Correctness

1. Build-time falls back gracefully, resize hard-throws — asymmetric.steel_workgroup_count returns 0 (→ fall back to shmem/tiled) when the tile count exceeds maxComputeWorkgroupsPerDimension, so at build time an over-limit shape silently picks a different kernel. But use_steel is baked into the pipeline at build. On a dynamic-shape resize, compute_q4gsw_workgroup_count re-runs with the live M and, for the same over-limit condition, throws (QuantizedLinear.cpp:125-129) instead of falling back — the kernel is already fixed, so it can't. That's defensible (you can't swap pipelines mid-resize), but the two paths reason about the same limit in opposite ways. Worth a one-line comment on the throw explaining why resize can't fall back the way the build path does, so a future reader doesn't "fix" the asymmetry.

2. maxComputeWorkgroupsPerDimension is queried three times per dispatch. On the build path for a steel dispatch: once in steel_supported (invocations), once in steel_workgroup_count (dim limit), and a third time inside compute_q4gsw_workgroup_count's steel branch. All redundant. Minor, but see modularization below — folding them removes it for free.

3. The steel branch throw is unreachable on the build path (only reachable via resize). Since use_steel is only ever true when steel_workgroup_count(...) > 0 — i.e. total <= max_count — the identical check in compute_q4gsw_workgroup_count can never fire at build time. That's fine (it exists for resize), but it means the limit is validated twice with two separate code copies that must be kept in sync.

4. Kernel numerics — verified correct. The A/B staging (ar/ac, br/bc), the K%BK==0 host guard removing the need for a K-mask, the signed-nibble dequant (f32(i32(nib) - 8) * scale), scale_row computed per shmem-K-row, the 1D→2D tile decode (bx = wid.x % nbN), and the bounds-guarded write all line up with the shmem sibling. No issues found.


Overengineering

5. Steel now shadows the shmem GEMM for essentially all M>1 shapes. Previously shmem was gated on K >= 4096 || N >= 2048. Now use_shmem_gemm = !use_gemv && !use_steel && (...), and steel wins for any M>1 with K%16==0 on a ≥256-invocation device. So shmem is only reachable on odd-K shapes or sub-256-invocation devices (SwiftShader) that also hit the large-K/N gate — a narrow slice. That's an intended consequence, but if the plan is for steel to be the prefill path going forward, it's worth a comment noting shmem is now a fallback-only path (and confirming it's still exercised in CI on SwiftShader, which caps at 128 and will route here). Not a request to delete it — just flag the intent so it isn't mistaken for dead code later.

Beyond that, I don't see gratuitous complexity — the kernel is a straightforward 64×64/256-thread tile and the host guards are minimal.


Modularization

6. Three copies of the "max workgroups per dimension" query. The block

WGPULimits limits = {};
constuint32_t max_wgs =
wgpuDeviceGetLimits(device, &limits) == WGPUStatus_Success &&
limits.maxComputeWorkgroupsPerDimension > 0
? limits.maxComputeWorkgroupsPerDimension : 65535u;

appears in steel_workgroup_count and twice in compute_q4gsw_workgroup_count (steel + shmem branches), all identical including the 65535u fallback. Extract one helper:

uint32_tmax_workgroups_per_dim(WGPUDevice device) {
WGPULimits limits = {};
return (wgpuDeviceGetLimits(device, &limits) == WGPUStatus_Success &&
limits.maxComputeWorkgroupsPerDimension > 0)
? limits.maxComputeWorkgroupsPerDimension : 65535u;
}

Fix this →

7. The steel and shmem branches of compute_q4gsw_workgroup_count are byte-identical except for the tile constants and the error string. Both are "one workgroup per tile, throw over dim-limit." Collapse into a small helper:

uint32_ttiled_wg_count(WGPUDevice device, uint32_t m, uint32_t n,
uint32_t tile_m, uint32_t tile_n,
constchar* op_name, constchar* kind) {
constint64_t total = utils::div_up<int64_t>(m, tile_m) *
utils::div_up<int64_t>(n, tile_n);
if (total > static_cast<int64_t>(max_workgroups_per_dim(device))) {
throwstd::runtime_error(std::string("WebGPU ") + op_name + ": " + kind +
" tile count exceeds the 1D dispatch limit");
}
returnstatic_cast<uint32_t>(total);
}

Then both branches are one call each, and combined with #6 the routing function drops ~25 lines.

8. Consider deriving steel_workgroup_count's dim-limit check from the same helper so the "return 0 to fall back" and the "throw over limit" logic share their notion of the max, rather than each re-deriving max_count/max_wgs independently (the bug surface in #1/#3).


Minor / nits

  • steel_workgroup_count guards K % 16 != 0 and over-limit but the total == 0u case (m==0 or n==0) also returns 0 — correct, just undocumented in the comment which only mentions K and dispatch limit.
  • The generated _wgsl.h correctly carries a wgsl-sha256; confirm it was regenerated by the codegen tool rather than hand-edited (the source and embedded copy match, so this looks fine).

Overall: correct for the target shapes, good guard discipline, and the perf win is well-motivated. The main asks are the max-limit dedup (#6/#7) and a comment clarifying the build-vs-resize fallback asymmetry (#1).

[ghstack-poisoned]
[ghstack-poisoned]
[ghstack-poisoned]
[ghstack-poisoned]

@SS-JIASS-JIA 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.

Review automatically exported from Phabricator review in Meta.

[ghstack-poisoned]
[ghstack-poisoned]
[ghstack-poisoned]
[ghstack-poisoned]
@meta-codesync
meta-codesyncBot merged commit 5f7b84a into gh/JCNTH/4/baseJul 9, 2026
177 of 183 checks passed
@meta-codesync
meta-codesyncBot deleted the gh/JCNTH/4/head branch July 9, 2026 21:39
JCNTH added a commit that referenced this pull request Jul 9, 2026
Pull Request resolved: #20730
**Add a shared-memory 64x64-tile prefill GEMM for `et_vk.linear_q4gsw` that beats the existing shmem/tiled GEMM by 1.75-3.17x and moves in-browser Llama-3.2-1B prefill from behind to ahead of llama.cpp WebGPU (+11-33%, M4 Pro / Chrome Canary).**
**Problem:** the q4gsw prefill path (M>1) routes to either the register-tiled or the 32x32 shmem GEMM. On Apple / M4 Pro both leave the 4-bit linear ~2.65x slower than llama.cpp's simdgroup-matrix GEMM, so prefill is the one axis where the WebGPU delegate trails.
**Solution:**
- **Before:** M>1 selects `use_shmem_gemm` (large K/N) else the register-tiled GEMM.
- **After:** M>1 prefers a new `use_steel` branch above shmem — a 64x64 output tile computed by 256 threads (16x16) with a 4x4 register sub-tile per thread, staging a BK=16 K-slice of activations (f32) and dequantized weights into shared memory once and reusing it across the tile. shmem/tiled remain the fallback when steel is ineligible.
**Implementation:**
- Add `q4gsw_linear_gemm_steel.wgsl` (same 6 bindings + `Params` as the sibling q4gsw kernels) and its generated `_wgsl.h`.
- Add a fail-closed `steel_supported` (requires `maxComputeInvocationsPerWorkgroup >= 256`; SwiftShader caps at 128 so it falls back) and `steel_workgroup_count` (one workgroup per 64x64 tile; returns 0 to fall back when `K % 16 != 0` — the kernel stages a full BK K-tile with no K-mask — or when the tile count exceeds the 1D dispatch limit).
- Thread `use_steel` through the shared `compute_q4gsw_workgroup_count` helper (a steel branch beside gemv/shmem) AND the `add_tensor_resize_hook`, so a dynamic-shape prefill recomputes the steel tile count for the live M; add steel to `fixed_wg` (fixed `@workgroup_size(16, 16)`, no `wg_size` override).
- No direct Vulkan analogue: Vulkan's q4gsw GEMM is register-tiled with an `is_gemv` split and no 256-thread shmem-staged tile. This tiling is WebGPU-specific (buffer-only storage, compile-time `@workgroup_size`, 1D-dispatch fold); only the signed-nibble dequant mirrors the q4gsw reference.
**Constraints:** engine-independent — depends only on shape + device limits and participates in the dynamic-resize hook exactly like gemv/shmem. Falls back to shmem/tiled on non-256-invocation devices, odd K, or over-limit dispatch, so behavior is unchanged where steel is ineligible. Layout and numerics are unchanged (same bindings, same dequant, f32 accumulator). The f16-multiply variant is a separate follow-up.
Co-authored-with: Claude Code.
ghstack-source-id: 401515156
@exported-using-ghexport
Differential Revision: [D110660965](https://our.internmc.facebook.com/intern/diff/D110660965/)
JCNTH added a commit that referenced this pull request Jul 9, 2026
Pull Request resolved: #20730
**Add a shared-memory 64x64-tile prefill GEMM for `et_vk.linear_q4gsw` that beats the existing shmem/tiled GEMM by 1.75-3.17x and moves in-browser Llama-3.2-1B prefill from behind to ahead of llama.cpp WebGPU (+11-33%, M4 Pro / Chrome Canary).**
**Problem:** the q4gsw prefill path (M>1) routes to either the register-tiled or the 32x32 shmem GEMM. On Apple / M4 Pro both leave the 4-bit linear ~2.65x slower than llama.cpp's simdgroup-matrix GEMM, so prefill is the one axis where the WebGPU delegate trails.
**Solution:**
- **Before:** M>1 selects `use_shmem_gemm` (large K/N) else the register-tiled GEMM.
- **After:** M>1 prefers a new `use_steel` branch above shmem — a 64x64 output tile computed by 256 threads (16x16) with a 4x4 register sub-tile per thread, staging a BK=16 K-slice of activations (f32) and dequantized weights into shared memory once and reusing it across the tile. shmem/tiled remain the fallback when steel is ineligible.
**Implementation:**
- Add `q4gsw_linear_gemm_steel.wgsl` (same 6 bindings + `Params` as the sibling q4gsw kernels) and its generated `_wgsl.h`.
- Add a fail-closed `steel_supported` (requires `maxComputeInvocationsPerWorkgroup >= 256`; SwiftShader caps at 128 so it falls back) and `steel_workgroup_count` (one workgroup per 64x64 tile; returns 0 to fall back when `K % 16 != 0` — the kernel stages a full BK K-tile with no K-mask — or when the tile count exceeds the 1D dispatch limit).
- Thread `use_steel` through the shared `compute_q4gsw_workgroup_count` helper (a steel branch beside gemv/shmem) AND the `add_tensor_resize_hook`, so a dynamic-shape prefill recomputes the steel tile count for the live M; add steel to `fixed_wg` (fixed `@workgroup_size(16, 16)`, no `wg_size` override).
- No direct Vulkan analogue: Vulkan's q4gsw GEMM is register-tiled with an `is_gemv` split and no 256-thread shmem-staged tile. This tiling is WebGPU-specific (buffer-only storage, compile-time `@workgroup_size`, 1D-dispatch fold); only the signed-nibble dequant mirrors the q4gsw reference.
**Constraints:** engine-independent — depends only on shape + device limits and participates in the dynamic-resize hook exactly like gemv/shmem. Falls back to shmem/tiled on non-256-invocation devices, odd K, or over-limit dispatch, so behavior is unchanged where steel is ineligible. Layout and numerics are unchanged (same bindings, same dequant, f32 accumulator). The f16-multiply variant is a separate follow-up.
Co-authored-with: Claude Code.
ghstack-source-id: 401515156
@exported-using-ghexport
Differential Revision: [D110660965](https://our.internmc.facebook.com/intern/diff/D110660965/)
JCNTH added a commit that referenced this pull request Jul 9, 2026
Pull Request resolved: #20730
**Add a shared-memory 64x64-tile prefill GEMM for `et_vk.linear_q4gsw` that beats the existing shmem/tiled GEMM by 1.75-3.17x and moves in-browser Llama-3.2-1B prefill from behind to ahead of llama.cpp WebGPU (+11-33%, M4 Pro / Chrome Canary).**
**Problem:** the q4gsw prefill path (M>1) routes to either the register-tiled or the 32x32 shmem GEMM. On Apple / M4 Pro both leave the 4-bit linear ~2.65x slower than llama.cpp's simdgroup-matrix GEMM, so prefill is the one axis where the WebGPU delegate trails.
**Solution:**
- **Before:** M>1 selects `use_shmem_gemm` (large K/N) else the register-tiled GEMM.
- **After:** M>1 prefers a new `use_steel` branch above shmem — a 64x64 output tile computed by 256 threads (16x16) with a 4x4 register sub-tile per thread, staging a BK=16 K-slice of activations (f32) and dequantized weights into shared memory once and reusing it across the tile. shmem/tiled remain the fallback when steel is ineligible.
**Implementation:**
- Add `q4gsw_linear_gemm_steel.wgsl` (same 6 bindings + `Params` as the sibling q4gsw kernels) and its generated `_wgsl.h`.
- Add a fail-closed `steel_supported` (requires `maxComputeInvocationsPerWorkgroup >= 256`; SwiftShader caps at 128 so it falls back) and `steel_workgroup_count` (one workgroup per 64x64 tile; returns 0 to fall back when `K % 16 != 0` — the kernel stages a full BK K-tile with no K-mask — or when the tile count exceeds the 1D dispatch limit).
- Thread `use_steel` through the shared `compute_q4gsw_workgroup_count` helper (a steel branch beside gemv/shmem) AND the `add_tensor_resize_hook`, so a dynamic-shape prefill recomputes the steel tile count for the live M; add steel to `fixed_wg` (fixed `@workgroup_size(16, 16)`, no `wg_size` override).
- No direct Vulkan analogue: Vulkan's q4gsw GEMM is register-tiled with an `is_gemv` split and no 256-thread shmem-staged tile. This tiling is WebGPU-specific (buffer-only storage, compile-time `@workgroup_size`, 1D-dispatch fold); only the signed-nibble dequant mirrors the q4gsw reference.
**Constraints:** engine-independent — depends only on shape + device limits and participates in the dynamic-resize hook exactly like gemv/shmem. Falls back to shmem/tiled on non-256-invocation devices, odd K, or over-limit dispatch, so behavior is unchanged where steel is ineligible. Layout and numerics are unchanged (same bindings, same dequant, f32 accumulator). The f16-multiply variant is a separate follow-up.
Co-authored-with: Claude Code.
ghstack-source-id: 401515156
@exported-using-ghexport
Differential Revision: [D110660965](https://our.internmc.facebook.com/intern/diff/D110660965/)
Sign up for freeto join this conversation on GitHub. Already have an account? Sign in to comment

Labels

CLA SignedThis label is managed by the Facebook bot. Authors need to sign the CLA before a PR can be reviewed.meta-exported

Projects

None yet

Development

Successfully merging this pull request may close these issues.

2 participants

@JCNTH@SS-JIA
, 'i'); if (__m === '*' || __re.test(location.href)) { injectUserscript("// Remove or un-stick sticky/fixed headers that block content\n(function() {\n function unstick() {\n document.querySelectorAll('header, nav, [role=\"banner\"], .header, .navbar, .sticky, .fixed-top, [style*=\"position: fixed\"], [style*=\"position:sticky\"]').forEach(function(el) {\n if (el.style.position === 'fixed' || el.style.position === 'sticky' || \n getComputedStyle(el).position === 'fixed' || getComputedStyle(el).position === 'sticky') {\n el.style.position = 'static';\n el.style.top = 'auto';\n el.style.zIndex = 'auto';\n }\n });\n }\n \n unstick();\n \n var observer = new MutationObserver(unstick);\n observer.observe(document.body, { childList: true, subtree: true, attributes: true, attributeFilter: ['style', 'class'] });\n})();", "Kill Sticky Headers"); } } catch(__e) { console.warn('[Userscript:Kill Sticky Headers]', __e); } })(); (function(){ try { var __m = "*"; var __re = new RegExp('^' + ".*" + '
Skip to content

[ExecuTorch][WebGPU] Add 256-thread "steel" q4gsw prefill GEMM - #20730

Merged
meta-codesync[bot] merged 11 commits into
gh/JCNTH/4/basefrom
gh/JCNTH/4/head
Jul 9, 2026
Merged

[ExecuTorch][WebGPU] Add 256-thread "steel" q4gsw prefill GEMM#20730
meta-codesync[bot] merged 11 commits into
gh/JCNTH/4/basefrom
gh/JCNTH/4/head

Conversation

@JCNTH

@JCNTHJCNTH commented Jul 5, 2026

Copy link
Copy Markdown
Contributor

Stack from ghstack (oldest at bottom):

Add a shared-memory 64x64-tile prefill GEMM for et_vk.linear_q4gsw that beats the existing shmem/tiled GEMM by 1.75-3.17x and moves in-browser Llama-3.2-1B prefill from behind to ahead of llama.cpp WebGPU (+11-33%, M4 Pro / Chrome Canary).

Problem: the q4gsw prefill path (M>1) routes to either the register-tiled or the 32x32 shmem GEMM. On Apple / M4 Pro both leave the 4-bit linear ~2.65x slower than llama.cpp's simdgroup-matrix GEMM, so prefill is the one axis where the WebGPU delegate trails.

Solution:

  • Before: M>1 selects use_shmem_gemm (large K/N) else the register-tiled GEMM.
  • After: M>1 prefers a new use_steel branch above shmem — a 64x64 output tile computed by 256 threads (16x16) with a 4x4 register sub-tile per thread, staging a BK=16 K-slice of activations (f32) and dequantized weights into shared memory once and reusing it across the tile. shmem/tiled remain the fallback when steel is ineligible.

Implementation:

  • Add q4gsw_linear_gemm_steel.wgsl (same 6 bindings + Params as the sibling q4gsw kernels) and its generated _wgsl.h.
  • Add a fail-closed steel_supported (requires maxComputeInvocationsPerWorkgroup >= 256; SwiftShader caps at 128 so it falls back) and steel_workgroup_count (one workgroup per 64x64 tile; returns 0 to fall back when K % 16 != 0 — the kernel stages a full BK K-tile with no K-mask — or when the tile count exceeds the 1D dispatch limit).
  • Thread use_steel through the shared compute_q4gsw_workgroup_count helper (a steel branch beside gemv/shmem) AND the add_tensor_resize_hook, so a dynamic-shape prefill recomputes the steel tile count for the live M; add steel to fixed_wg (fixed @workgroup_size(16, 16), no wg_size override).
  • No direct Vulkan analogue: Vulkan's q4gsw GEMM is register-tiled with an is_gemv split and no 256-thread shmem-staged tile. This tiling is WebGPU-specific (buffer-only storage, compile-time @workgroup_size, 1D-dispatch fold); only the signed-nibble dequant mirrors the q4gsw reference.

Constraints: engine-independent — depends only on shape + device limits and participates in the dynamic-resize hook exactly like gemv/shmem. Falls back to shmem/tiled on non-256-invocation devices, odd K, or over-limit dispatch, so behavior is unchanged where steel is ineligible. Layout and numerics are unchanged (same bindings, same dequant, f32 accumulator). The f16-multiply variant is a separate follow-up.

Co-authored-with: Claude Code.
@exported-using-ghexport

Differential Revision: D110660965

Differential Revision: D110660965

[ghstack-poisoned]
@pytorch-bot

pytorch-botBot commented Jul 5, 2026

Copy link
Copy Markdown

🔗 Helpful Links

🧪 See artifacts and rendered test results at hud.pytorch.org/pr/pytorch/executorch/20730

Note: Links to docs will display an error until the docs builds have been completed.

❌ 4 New Failures, 1 Cancelled Job, 2 Pending

As of commit e5215e1 with merge base f4b01a8 (image):

NEW FAILURES - The following jobs have failed:

CANCELLED JOB - The following job was cancelled. Please retry:

This comment was automatically generated by Dr. CI and updates every 15 minutes.

@linux-foundation-easycla

linux-foundation-easyclaBot commented Jul 5, 2026

Copy link
Copy Markdown

CLA Signed
The committers listed above are authorized under a signed CLA.

@github-actions

Copy link
Copy Markdown

This PR needs a release notes: label

If your change should be included in the release notes (i.e. would users of this library care about this change?), please use a label starting with release notes:. This helps us keep track and include your important work in the next release notes.

To add a label, you can comment to pytorchbot, for example
@pytorchbot label "release notes: none"

For more information, see
https://github.com/pytorch/pytorch/wiki/PyTorch-AutoLabel-Bot#why-categorize-for-release-notes-and-how-does-it-work.

@JCNTH

JCNTH commented Jul 5, 2026

Copy link
Copy Markdown
ContributorAuthor

@claude throoughly review, check for overengineering, correctness, and areaas for modularization

@claude

claudeBot commented Jul 5, 2026

Copy link
Copy Markdown

Claude finished @JCNTH's task in 2m 43s —— View job


Review: steel q4gsw prefill GEMM

  • Read the diff (QuantizedLinear.cpp, steel.wgsl, steel_wgsl.h)
  • Review correctness (dispatch, guards, kernel numerics)
  • Check for overengineering
  • Identify modularization opportunities

Solid, well-scoped change. Numerics and dequant mirror the shmem sibling exactly, the fail-closed device guard is the right instinct, and the dynamic-resize wiring is complete. A few things worth addressing before merge — nothing blocking correctness for the target shapes, but a couple of real inconsistencies and clear dedup wins.


Correctness

1. Build-time falls back gracefully, resize hard-throws — asymmetric.steel_workgroup_count returns 0 (→ fall back to shmem/tiled) when the tile count exceeds maxComputeWorkgroupsPerDimension, so at build time an over-limit shape silently picks a different kernel. But use_steel is baked into the pipeline at build. On a dynamic-shape resize, compute_q4gsw_workgroup_count re-runs with the live M and, for the same over-limit condition, throws (QuantizedLinear.cpp:125-129) instead of falling back — the kernel is already fixed, so it can't. That's defensible (you can't swap pipelines mid-resize), but the two paths reason about the same limit in opposite ways. Worth a one-line comment on the throw explaining why resize can't fall back the way the build path does, so a future reader doesn't "fix" the asymmetry.

2. maxComputeWorkgroupsPerDimension is queried three times per dispatch. On the build path for a steel dispatch: once in steel_supported (invocations), once in steel_workgroup_count (dim limit), and a third time inside compute_q4gsw_workgroup_count's steel branch. All redundant. Minor, but see modularization below — folding them removes it for free.

3. The steel branch throw is unreachable on the build path (only reachable via resize). Since use_steel is only ever true when steel_workgroup_count(...) > 0 — i.e. total <= max_count — the identical check in compute_q4gsw_workgroup_count can never fire at build time. That's fine (it exists for resize), but it means the limit is validated twice with two separate code copies that must be kept in sync.

4. Kernel numerics — verified correct. The A/B staging (ar/ac, br/bc), the K%BK==0 host guard removing the need for a K-mask, the signed-nibble dequant (f32(i32(nib) - 8) * scale), scale_row computed per shmem-K-row, the 1D→2D tile decode (bx = wid.x % nbN), and the bounds-guarded write all line up with the shmem sibling. No issues found.


Overengineering

5. Steel now shadows the shmem GEMM for essentially all M>1 shapes. Previously shmem was gated on K >= 4096 || N >= 2048. Now use_shmem_gemm = !use_gemv && !use_steel && (...), and steel wins for any M>1 with K%16==0 on a ≥256-invocation device. So shmem is only reachable on odd-K shapes or sub-256-invocation devices (SwiftShader) that also hit the large-K/N gate — a narrow slice. That's an intended consequence, but if the plan is for steel to be the prefill path going forward, it's worth a comment noting shmem is now a fallback-only path (and confirming it's still exercised in CI on SwiftShader, which caps at 128 and will route here). Not a request to delete it — just flag the intent so it isn't mistaken for dead code later.

Beyond that, I don't see gratuitous complexity — the kernel is a straightforward 64×64/256-thread tile and the host guards are minimal.


Modularization

6. Three copies of the "max workgroups per dimension" query. The block

WGPULimits limits = {};
constuint32_t max_wgs =
wgpuDeviceGetLimits(device, &limits) == WGPUStatus_Success &&
limits.maxComputeWorkgroupsPerDimension > 0
? limits.maxComputeWorkgroupsPerDimension : 65535u;

appears in steel_workgroup_count and twice in compute_q4gsw_workgroup_count (steel + shmem branches), all identical including the 65535u fallback. Extract one helper:

uint32_tmax_workgroups_per_dim(WGPUDevice device) {
WGPULimits limits = {};
return (wgpuDeviceGetLimits(device, &limits) == WGPUStatus_Success &&
limits.maxComputeWorkgroupsPerDimension > 0)
? limits.maxComputeWorkgroupsPerDimension : 65535u;
}

Fix this →

7. The steel and shmem branches of compute_q4gsw_workgroup_count are byte-identical except for the tile constants and the error string. Both are "one workgroup per tile, throw over dim-limit." Collapse into a small helper:

uint32_ttiled_wg_count(WGPUDevice device, uint32_t m, uint32_t n,
uint32_t tile_m, uint32_t tile_n,
constchar* op_name, constchar* kind) {
constint64_t total = utils::div_up<int64_t>(m, tile_m) *
utils::div_up<int64_t>(n, tile_n);
if (total > static_cast<int64_t>(max_workgroups_per_dim(device))) {
throwstd::runtime_error(std::string("WebGPU ") + op_name + ": " + kind +
" tile count exceeds the 1D dispatch limit");
}
returnstatic_cast<uint32_t>(total);
}

Then both branches are one call each, and combined with #6 the routing function drops ~25 lines.

8. Consider deriving steel_workgroup_count's dim-limit check from the same helper so the "return 0 to fall back" and the "throw over limit" logic share their notion of the max, rather than each re-deriving max_count/max_wgs independently (the bug surface in #1/#3).


Minor / nits

  • steel_workgroup_count guards K % 16 != 0 and over-limit but the total == 0u case (m==0 or n==0) also returns 0 — correct, just undocumented in the comment which only mentions K and dispatch limit.
  • The generated _wgsl.h correctly carries a wgsl-sha256; confirm it was regenerated by the codegen tool rather than hand-edited (the source and embedded copy match, so this looks fine).

Overall: correct for the target shapes, good guard discipline, and the perf win is well-motivated. The main asks are the max-limit dedup (#6/#7) and a comment clarifying the build-vs-resize fallback asymmetry (#1).

[ghstack-poisoned]
[ghstack-poisoned]
[ghstack-poisoned]
[ghstack-poisoned]

@SS-JIASS-JIA 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.

Review automatically exported from Phabricator review in Meta.

[ghstack-poisoned]
[ghstack-poisoned]
[ghstack-poisoned]
[ghstack-poisoned]
@meta-codesync
meta-codesyncBot merged commit 5f7b84a into gh/JCNTH/4/baseJul 9, 2026
177 of 183 checks passed
@meta-codesync
meta-codesyncBot deleted the gh/JCNTH/4/head branch July 9, 2026 21:39
JCNTH added a commit that referenced this pull request Jul 9, 2026
Pull Request resolved: #20730
**Add a shared-memory 64x64-tile prefill GEMM for `et_vk.linear_q4gsw` that beats the existing shmem/tiled GEMM by 1.75-3.17x and moves in-browser Llama-3.2-1B prefill from behind to ahead of llama.cpp WebGPU (+11-33%, M4 Pro / Chrome Canary).**
**Problem:** the q4gsw prefill path (M>1) routes to either the register-tiled or the 32x32 shmem GEMM. On Apple / M4 Pro both leave the 4-bit linear ~2.65x slower than llama.cpp's simdgroup-matrix GEMM, so prefill is the one axis where the WebGPU delegate trails.
**Solution:**
- **Before:** M>1 selects `use_shmem_gemm` (large K/N) else the register-tiled GEMM.
- **After:** M>1 prefers a new `use_steel` branch above shmem — a 64x64 output tile computed by 256 threads (16x16) with a 4x4 register sub-tile per thread, staging a BK=16 K-slice of activations (f32) and dequantized weights into shared memory once and reusing it across the tile. shmem/tiled remain the fallback when steel is ineligible.
**Implementation:**
- Add `q4gsw_linear_gemm_steel.wgsl` (same 6 bindings + `Params` as the sibling q4gsw kernels) and its generated `_wgsl.h`.
- Add a fail-closed `steel_supported` (requires `maxComputeInvocationsPerWorkgroup >= 256`; SwiftShader caps at 128 so it falls back) and `steel_workgroup_count` (one workgroup per 64x64 tile; returns 0 to fall back when `K % 16 != 0` — the kernel stages a full BK K-tile with no K-mask — or when the tile count exceeds the 1D dispatch limit).
- Thread `use_steel` through the shared `compute_q4gsw_workgroup_count` helper (a steel branch beside gemv/shmem) AND the `add_tensor_resize_hook`, so a dynamic-shape prefill recomputes the steel tile count for the live M; add steel to `fixed_wg` (fixed `@workgroup_size(16, 16)`, no `wg_size` override).
- No direct Vulkan analogue: Vulkan's q4gsw GEMM is register-tiled with an `is_gemv` split and no 256-thread shmem-staged tile. This tiling is WebGPU-specific (buffer-only storage, compile-time `@workgroup_size`, 1D-dispatch fold); only the signed-nibble dequant mirrors the q4gsw reference.
**Constraints:** engine-independent — depends only on shape + device limits and participates in the dynamic-resize hook exactly like gemv/shmem. Falls back to shmem/tiled on non-256-invocation devices, odd K, or over-limit dispatch, so behavior is unchanged where steel is ineligible. Layout and numerics are unchanged (same bindings, same dequant, f32 accumulator). The f16-multiply variant is a separate follow-up.
Co-authored-with: Claude Code.
ghstack-source-id: 401515156
@exported-using-ghexport
Differential Revision: [D110660965](https://our.internmc.facebook.com/intern/diff/D110660965/)
JCNTH added a commit that referenced this pull request Jul 9, 2026
Pull Request resolved: #20730
**Add a shared-memory 64x64-tile prefill GEMM for `et_vk.linear_q4gsw` that beats the existing shmem/tiled GEMM by 1.75-3.17x and moves in-browser Llama-3.2-1B prefill from behind to ahead of llama.cpp WebGPU (+11-33%, M4 Pro / Chrome Canary).**
**Problem:** the q4gsw prefill path (M>1) routes to either the register-tiled or the 32x32 shmem GEMM. On Apple / M4 Pro both leave the 4-bit linear ~2.65x slower than llama.cpp's simdgroup-matrix GEMM, so prefill is the one axis where the WebGPU delegate trails.
**Solution:**
- **Before:** M>1 selects `use_shmem_gemm` (large K/N) else the register-tiled GEMM.
- **After:** M>1 prefers a new `use_steel` branch above shmem — a 64x64 output tile computed by 256 threads (16x16) with a 4x4 register sub-tile per thread, staging a BK=16 K-slice of activations (f32) and dequantized weights into shared memory once and reusing it across the tile. shmem/tiled remain the fallback when steel is ineligible.
**Implementation:**
- Add `q4gsw_linear_gemm_steel.wgsl` (same 6 bindings + `Params` as the sibling q4gsw kernels) and its generated `_wgsl.h`.
- Add a fail-closed `steel_supported` (requires `maxComputeInvocationsPerWorkgroup >= 256`; SwiftShader caps at 128 so it falls back) and `steel_workgroup_count` (one workgroup per 64x64 tile; returns 0 to fall back when `K % 16 != 0` — the kernel stages a full BK K-tile with no K-mask — or when the tile count exceeds the 1D dispatch limit).
- Thread `use_steel` through the shared `compute_q4gsw_workgroup_count` helper (a steel branch beside gemv/shmem) AND the `add_tensor_resize_hook`, so a dynamic-shape prefill recomputes the steel tile count for the live M; add steel to `fixed_wg` (fixed `@workgroup_size(16, 16)`, no `wg_size` override).
- No direct Vulkan analogue: Vulkan's q4gsw GEMM is register-tiled with an `is_gemv` split and no 256-thread shmem-staged tile. This tiling is WebGPU-specific (buffer-only storage, compile-time `@workgroup_size`, 1D-dispatch fold); only the signed-nibble dequant mirrors the q4gsw reference.
**Constraints:** engine-independent — depends only on shape + device limits and participates in the dynamic-resize hook exactly like gemv/shmem. Falls back to shmem/tiled on non-256-invocation devices, odd K, or over-limit dispatch, so behavior is unchanged where steel is ineligible. Layout and numerics are unchanged (same bindings, same dequant, f32 accumulator). The f16-multiply variant is a separate follow-up.
Co-authored-with: Claude Code.
ghstack-source-id: 401515156
@exported-using-ghexport
Differential Revision: [D110660965](https://our.internmc.facebook.com/intern/diff/D110660965/)
JCNTH added a commit that referenced this pull request Jul 9, 2026
Pull Request resolved: #20730
**Add a shared-memory 64x64-tile prefill GEMM for `et_vk.linear_q4gsw` that beats the existing shmem/tiled GEMM by 1.75-3.17x and moves in-browser Llama-3.2-1B prefill from behind to ahead of llama.cpp WebGPU (+11-33%, M4 Pro / Chrome Canary).**
**Problem:** the q4gsw prefill path (M>1) routes to either the register-tiled or the 32x32 shmem GEMM. On Apple / M4 Pro both leave the 4-bit linear ~2.65x slower than llama.cpp's simdgroup-matrix GEMM, so prefill is the one axis where the WebGPU delegate trails.
**Solution:**
- **Before:** M>1 selects `use_shmem_gemm` (large K/N) else the register-tiled GEMM.
- **After:** M>1 prefers a new `use_steel` branch above shmem — a 64x64 output tile computed by 256 threads (16x16) with a 4x4 register sub-tile per thread, staging a BK=16 K-slice of activations (f32) and dequantized weights into shared memory once and reusing it across the tile. shmem/tiled remain the fallback when steel is ineligible.
**Implementation:**
- Add `q4gsw_linear_gemm_steel.wgsl` (same 6 bindings + `Params` as the sibling q4gsw kernels) and its generated `_wgsl.h`.
- Add a fail-closed `steel_supported` (requires `maxComputeInvocationsPerWorkgroup >= 256`; SwiftShader caps at 128 so it falls back) and `steel_workgroup_count` (one workgroup per 64x64 tile; returns 0 to fall back when `K % 16 != 0` — the kernel stages a full BK K-tile with no K-mask — or when the tile count exceeds the 1D dispatch limit).
- Thread `use_steel` through the shared `compute_q4gsw_workgroup_count` helper (a steel branch beside gemv/shmem) AND the `add_tensor_resize_hook`, so a dynamic-shape prefill recomputes the steel tile count for the live M; add steel to `fixed_wg` (fixed `@workgroup_size(16, 16)`, no `wg_size` override).
- No direct Vulkan analogue: Vulkan's q4gsw GEMM is register-tiled with an `is_gemv` split and no 256-thread shmem-staged tile. This tiling is WebGPU-specific (buffer-only storage, compile-time `@workgroup_size`, 1D-dispatch fold); only the signed-nibble dequant mirrors the q4gsw reference.
**Constraints:** engine-independent — depends only on shape + device limits and participates in the dynamic-resize hook exactly like gemv/shmem. Falls back to shmem/tiled on non-256-invocation devices, odd K, or over-limit dispatch, so behavior is unchanged where steel is ineligible. Layout and numerics are unchanged (same bindings, same dequant, f32 accumulator). The f16-multiply variant is a separate follow-up.
Co-authored-with: Claude Code.
ghstack-source-id: 401515156
@exported-using-ghexport
Differential Revision: [D110660965](https://our.internmc.facebook.com/intern/diff/D110660965/)
Sign up for freeto join this conversation on GitHub. Already have an account? Sign in to comment

Labels

CLA SignedThis label is managed by the Facebook bot. Authors need to sign the CLA before a PR can be reviewed.meta-exported

Projects

None yet

Development

Successfully merging this pull request may close these issues.

2 participants

@JCNTH@SS-JIA
, 'i'); if (__m === '*' || __re.test(location.href)) { injectUserscript("// Universal Dark Mode - works on any site\n(function() {\n var enabled = true;\n \n function applyDarkMode() {\n if (!enabled) return;\n \n // Create style element if it doesn't exist\n var style = document.getElementById('universal-dark-mode-style');\n if (!style) {\n style = document.createElement('style');\n style.id = 'universal-dark-mode-style';\n document.head.appendChild(style);\n }\n \n // Dark mode CSS - inverts colors but preserves images/video\n style.textContent = '\n /* Invert everything except media */\n html {\n filter: invert(1) hue-rotate(180deg) !important;\n background: #1a1a2e !important;\n }\n \n /* Restore images, videos, iframes, canvas */\n img, video, iframe, canvas, svg, picture, [style*=\"background-image\"] {\n filter: invert(1) hue-rotate(180deg) !important;\n }\n \n /* Preserve specific elements that should not be inverted */\n .no-dark-mode, .no-dark-mode *,\n [data-theme=\"light\"], [data-theme=\"light\"],\n .ace_editor, .ace_editor *,\n .CodeMirror, .CodeMirror *,\n .monaco-editor, .monaco-editor *,\n .markdown-body pre, .markdown-body pre *,\n .highlight, .highlight *,\n pre code, pre code * {\n filter: none !important;\n }\n \n /* Fix common UI elements */\n .modal, .popup, .dropdown-menu, .tooltip, .popover {\n filter: invert(1) hue-rotate(180deg) !important;\n background: #2d2d44 !important;\n border-color: #444 !important;\n }\n \n /* Scrollbars */\n ::-webkit-scrollbar { background: #1a1a2e !important; }\n ::-webkit-scrollbar-thumb { background: #444 !important; }\n ::-webkit-scrollbar-thumb:hover { background: #555 !important; }\n \n /* Selection */\n ::selection { background: #4ecdc4 !important; color: #1a1a2e !important; }\n ::-moz-selection { background: #4ecdc4 !important; color: #1a1a2e !important; }\n ';\n }\n \n function removeDarkMode() {\n var style = document.getElementById('universal-dark-mode-style');\n if (style) style.remove();\n }\n \n // Toggle with Alt+Shift+D\n document.addEventListener('keydown', function(e) {\n if (e.altKey && e.shiftKey && e.key === 'D') {\n e.preventDefault();\n enabled = !enabled;\n if (enabled) {\n applyDarkMode();\n console.log('[Universal Dark Mode] Enabled');\n } else {\n removeDarkMode();\n console.log('[Universal Dark Mode] Disabled');\n }\n }\n });\n \n // Apply on load\n applyDarkMode();\n \n // Re-apply on dynamic content\n var observer = new MutationObserver(function(mutations) {\n if (enabled && !document.getElementById('universal-dark-mode-style')) {\n applyDarkMode();\n }\n });\n observer.observe(document.head, { childList: true });\n \n console.log('[Universal Dark Mode] Loaded - Press Alt+Shift+D to toggle');\n})();", "Universal Dark Mode"); } } catch(__e) { console.warn('[Userscript:Universal Dark Mode]', __e); } })(); })();
Skip to content

[ExecuTorch][WebGPU] Add 256-thread "steel" q4gsw prefill GEMM - #20730

Merged
meta-codesync[bot] merged 11 commits into
gh/JCNTH/4/basefrom
gh/JCNTH/4/head
Jul 9, 2026
Merged

[ExecuTorch][WebGPU] Add 256-thread "steel" q4gsw prefill GEMM#20730
meta-codesync[bot] merged 11 commits into
gh/JCNTH/4/basefrom
gh/JCNTH/4/head

Conversation

@JCNTH

@JCNTHJCNTH commented Jul 5, 2026

Copy link
Copy Markdown
Contributor

Stack from ghstack (oldest at bottom):

Add a shared-memory 64x64-tile prefill GEMM for et_vk.linear_q4gsw that beats the existing shmem/tiled GEMM by 1.75-3.17x and moves in-browser Llama-3.2-1B prefill from behind to ahead of llama.cpp WebGPU (+11-33%, M4 Pro / Chrome Canary).

Problem: the q4gsw prefill path (M>1) routes to either the register-tiled or the 32x32 shmem GEMM. On Apple / M4 Pro both leave the 4-bit linear ~2.65x slower than llama.cpp's simdgroup-matrix GEMM, so prefill is the one axis where the WebGPU delegate trails.

Solution:

  • Before: M>1 selects use_shmem_gemm (large K/N) else the register-tiled GEMM.
  • After: M>1 prefers a new use_steel branch above shmem — a 64x64 output tile computed by 256 threads (16x16) with a 4x4 register sub-tile per thread, staging a BK=16 K-slice of activations (f32) and dequantized weights into shared memory once and reusing it across the tile. shmem/tiled remain the fallback when steel is ineligible.

Implementation:

  • Add q4gsw_linear_gemm_steel.wgsl (same 6 bindings + Params as the sibling q4gsw kernels) and its generated _wgsl.h.
  • Add a fail-closed steel_supported (requires maxComputeInvocationsPerWorkgroup >= 256; SwiftShader caps at 128 so it falls back) and steel_workgroup_count (one workgroup per 64x64 tile; returns 0 to fall back when K % 16 != 0 — the kernel stages a full BK K-tile with no K-mask — or when the tile count exceeds the 1D dispatch limit).
  • Thread use_steel through the shared compute_q4gsw_workgroup_count helper (a steel branch beside gemv/shmem) AND the add_tensor_resize_hook, so a dynamic-shape prefill recomputes the steel tile count for the live M; add steel to fixed_wg (fixed @workgroup_size(16, 16), no wg_size override).
  • No direct Vulkan analogue: Vulkan's q4gsw GEMM is register-tiled with an is_gemv split and no 256-thread shmem-staged tile. This tiling is WebGPU-specific (buffer-only storage, compile-time @workgroup_size, 1D-dispatch fold); only the signed-nibble dequant mirrors the q4gsw reference.

Constraints: engine-independent — depends only on shape + device limits and participates in the dynamic-resize hook exactly like gemv/shmem. Falls back to shmem/tiled on non-256-invocation devices, odd K, or over-limit dispatch, so behavior is unchanged where steel is ineligible. Layout and numerics are unchanged (same bindings, same dequant, f32 accumulator). The f16-multiply variant is a separate follow-up.

Co-authored-with: Claude Code.
@exported-using-ghexport

Differential Revision: D110660965

Differential Revision: D110660965

[ghstack-poisoned]
@pytorch-bot

pytorch-botBot commented Jul 5, 2026

Copy link
Copy Markdown

🔗 Helpful Links

🧪 See artifacts and rendered test results at hud.pytorch.org/pr/pytorch/executorch/20730

Note: Links to docs will display an error until the docs builds have been completed.

❌ 4 New Failures, 1 Cancelled Job, 2 Pending

As of commit e5215e1 with merge base f4b01a8 (image):

NEW FAILURES - The following jobs have failed:

CANCELLED JOB - The following job was cancelled. Please retry:

This comment was automatically generated by Dr. CI and updates every 15 minutes.

@linux-foundation-easycla

linux-foundation-easyclaBot commented Jul 5, 2026

Copy link
Copy Markdown

CLA Signed
The committers listed above are authorized under a signed CLA.

@github-actions

Copy link
Copy Markdown

This PR needs a release notes: label

If your change should be included in the release notes (i.e. would users of this library care about this change?), please use a label starting with release notes:. This helps us keep track and include your important work in the next release notes.

To add a label, you can comment to pytorchbot, for example
@pytorchbot label "release notes: none"

For more information, see
https://github.com/pytorch/pytorch/wiki/PyTorch-AutoLabel-Bot#why-categorize-for-release-notes-and-how-does-it-work.

@JCNTH

JCNTH commented Jul 5, 2026

Copy link
Copy Markdown
ContributorAuthor

@claude throoughly review, check for overengineering, correctness, and areaas for modularization

@claude

claudeBot commented Jul 5, 2026

Copy link
Copy Markdown

Claude finished @JCNTH's task in 2m 43s —— View job


Review: steel q4gsw prefill GEMM

  • Read the diff (QuantizedLinear.cpp, steel.wgsl, steel_wgsl.h)
  • Review correctness (dispatch, guards, kernel numerics)
  • Check for overengineering
  • Identify modularization opportunities

Solid, well-scoped change. Numerics and dequant mirror the shmem sibling exactly, the fail-closed device guard is the right instinct, and the dynamic-resize wiring is complete. A few things worth addressing before merge — nothing blocking correctness for the target shapes, but a couple of real inconsistencies and clear dedup wins.


Correctness

1. Build-time falls back gracefully, resize hard-throws — asymmetric.steel_workgroup_count returns 0 (→ fall back to shmem/tiled) when the tile count exceeds maxComputeWorkgroupsPerDimension, so at build time an over-limit shape silently picks a different kernel. But use_steel is baked into the pipeline at build. On a dynamic-shape resize, compute_q4gsw_workgroup_count re-runs with the live M and, for the same over-limit condition, throws (QuantizedLinear.cpp:125-129) instead of falling back — the kernel is already fixed, so it can't. That's defensible (you can't swap pipelines mid-resize), but the two paths reason about the same limit in opposite ways. Worth a one-line comment on the throw explaining why resize can't fall back the way the build path does, so a future reader doesn't "fix" the asymmetry.

2. maxComputeWorkgroupsPerDimension is queried three times per dispatch. On the build path for a steel dispatch: once in steel_supported (invocations), once in steel_workgroup_count (dim limit), and a third time inside compute_q4gsw_workgroup_count's steel branch. All redundant. Minor, but see modularization below — folding them removes it for free.

3. The steel branch throw is unreachable on the build path (only reachable via resize). Since use_steel is only ever true when steel_workgroup_count(...) > 0 — i.e. total <= max_count — the identical check in compute_q4gsw_workgroup_count can never fire at build time. That's fine (it exists for resize), but it means the limit is validated twice with two separate code copies that must be kept in sync.

4. Kernel numerics — verified correct. The A/B staging (ar/ac, br/bc), the K%BK==0 host guard removing the need for a K-mask, the signed-nibble dequant (f32(i32(nib) - 8) * scale), scale_row computed per shmem-K-row, the 1D→2D tile decode (bx = wid.x % nbN), and the bounds-guarded write all line up with the shmem sibling. No issues found.


Overengineering

5. Steel now shadows the shmem GEMM for essentially all M>1 shapes. Previously shmem was gated on K >= 4096 || N >= 2048. Now use_shmem_gemm = !use_gemv && !use_steel && (...), and steel wins for any M>1 with K%16==0 on a ≥256-invocation device. So shmem is only reachable on odd-K shapes or sub-256-invocation devices (SwiftShader) that also hit the large-K/N gate — a narrow slice. That's an intended consequence, but if the plan is for steel to be the prefill path going forward, it's worth a comment noting shmem is now a fallback-only path (and confirming it's still exercised in CI on SwiftShader, which caps at 128 and will route here). Not a request to delete it — just flag the intent so it isn't mistaken for dead code later.

Beyond that, I don't see gratuitous complexity — the kernel is a straightforward 64×64/256-thread tile and the host guards are minimal.


Modularization

6. Three copies of the "max workgroups per dimension" query. The block

WGPULimits limits = {};
constuint32_t max_wgs =
wgpuDeviceGetLimits(device, &limits) == WGPUStatus_Success &&
limits.maxComputeWorkgroupsPerDimension > 0
? limits.maxComputeWorkgroupsPerDimension : 65535u;

appears in steel_workgroup_count and twice in compute_q4gsw_workgroup_count (steel + shmem branches), all identical including the 65535u fallback. Extract one helper:

uint32_tmax_workgroups_per_dim(WGPUDevice device) {
WGPULimits limits = {};
return (wgpuDeviceGetLimits(device, &limits) == WGPUStatus_Success &&
limits.maxComputeWorkgroupsPerDimension > 0)
? limits.maxComputeWorkgroupsPerDimension : 65535u;
}

Fix this →

7. The steel and shmem branches of compute_q4gsw_workgroup_count are byte-identical except for the tile constants and the error string. Both are "one workgroup per tile, throw over dim-limit." Collapse into a small helper:

uint32_ttiled_wg_count(WGPUDevice device, uint32_t m, uint32_t n,
uint32_t tile_m, uint32_t tile_n,
constchar* op_name, constchar* kind) {
constint64_t total = utils::div_up<int64_t>(m, tile_m) *
utils::div_up<int64_t>(n, tile_n);
if (total > static_cast<int64_t>(max_workgroups_per_dim(device))) {
throwstd::runtime_error(std::string("WebGPU ") + op_name + ": " + kind +
" tile count exceeds the 1D dispatch limit");
}
returnstatic_cast<uint32_t>(total);
}

Then both branches are one call each, and combined with #6 the routing function drops ~25 lines.

8. Consider deriving steel_workgroup_count's dim-limit check from the same helper so the "return 0 to fall back" and the "throw over limit" logic share their notion of the max, rather than each re-deriving max_count/max_wgs independently (the bug surface in #1/#3).


Minor / nits

  • steel_workgroup_count guards K % 16 != 0 and over-limit but the total == 0u case (m==0 or n==0) also returns 0 — correct, just undocumented in the comment which only mentions K and dispatch limit.
  • The generated _wgsl.h correctly carries a wgsl-sha256; confirm it was regenerated by the codegen tool rather than hand-edited (the source and embedded copy match, so this looks fine).

Overall: correct for the target shapes, good guard discipline, and the perf win is well-motivated. The main asks are the max-limit dedup (#6/#7) and a comment clarifying the build-vs-resize fallback asymmetry (#1).

[ghstack-poisoned]
[ghstack-poisoned]
[ghstack-poisoned]
[ghstack-poisoned]

@SS-JIASS-JIA 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.

Review automatically exported from Phabricator review in Meta.

[ghstack-poisoned]
[ghstack-poisoned]
[ghstack-poisoned]
[ghstack-poisoned]
@meta-codesync
meta-codesyncBot merged commit 5f7b84a into gh/JCNTH/4/baseJul 9, 2026
177 of 183 checks passed
@meta-codesync
meta-codesyncBot deleted the gh/JCNTH/4/head branch July 9, 2026 21:39
JCNTH added a commit that referenced this pull request Jul 9, 2026
Pull Request resolved: #20730
**Add a shared-memory 64x64-tile prefill GEMM for `et_vk.linear_q4gsw` that beats the existing shmem/tiled GEMM by 1.75-3.17x and moves in-browser Llama-3.2-1B prefill from behind to ahead of llama.cpp WebGPU (+11-33%, M4 Pro / Chrome Canary).**
**Problem:** the q4gsw prefill path (M>1) routes to either the register-tiled or the 32x32 shmem GEMM. On Apple / M4 Pro both leave the 4-bit linear ~2.65x slower than llama.cpp's simdgroup-matrix GEMM, so prefill is the one axis where the WebGPU delegate trails.
**Solution:**
- **Before:** M>1 selects `use_shmem_gemm` (large K/N) else the register-tiled GEMM.
- **After:** M>1 prefers a new `use_steel` branch above shmem — a 64x64 output tile computed by 256 threads (16x16) with a 4x4 register sub-tile per thread, staging a BK=16 K-slice of activations (f32) and dequantized weights into shared memory once and reusing it across the tile. shmem/tiled remain the fallback when steel is ineligible.
**Implementation:**
- Add `q4gsw_linear_gemm_steel.wgsl` (same 6 bindings + `Params` as the sibling q4gsw kernels) and its generated `_wgsl.h`.
- Add a fail-closed `steel_supported` (requires `maxComputeInvocationsPerWorkgroup >= 256`; SwiftShader caps at 128 so it falls back) and `steel_workgroup_count` (one workgroup per 64x64 tile; returns 0 to fall back when `K % 16 != 0` — the kernel stages a full BK K-tile with no K-mask — or when the tile count exceeds the 1D dispatch limit).
- Thread `use_steel` through the shared `compute_q4gsw_workgroup_count` helper (a steel branch beside gemv/shmem) AND the `add_tensor_resize_hook`, so a dynamic-shape prefill recomputes the steel tile count for the live M; add steel to `fixed_wg` (fixed `@workgroup_size(16, 16)`, no `wg_size` override).
- No direct Vulkan analogue: Vulkan's q4gsw GEMM is register-tiled with an `is_gemv` split and no 256-thread shmem-staged tile. This tiling is WebGPU-specific (buffer-only storage, compile-time `@workgroup_size`, 1D-dispatch fold); only the signed-nibble dequant mirrors the q4gsw reference.
**Constraints:** engine-independent — depends only on shape + device limits and participates in the dynamic-resize hook exactly like gemv/shmem. Falls back to shmem/tiled on non-256-invocation devices, odd K, or over-limit dispatch, so behavior is unchanged where steel is ineligible. Layout and numerics are unchanged (same bindings, same dequant, f32 accumulator). The f16-multiply variant is a separate follow-up.
Co-authored-with: Claude Code.
ghstack-source-id: 401515156
@exported-using-ghexport
Differential Revision: [D110660965](https://our.internmc.facebook.com/intern/diff/D110660965/)
JCNTH added a commit that referenced this pull request Jul 9, 2026
Pull Request resolved: #20730
**Add a shared-memory 64x64-tile prefill GEMM for `et_vk.linear_q4gsw` that beats the existing shmem/tiled GEMM by 1.75-3.17x and moves in-browser Llama-3.2-1B prefill from behind to ahead of llama.cpp WebGPU (+11-33%, M4 Pro / Chrome Canary).**
**Problem:** the q4gsw prefill path (M>1) routes to either the register-tiled or the 32x32 shmem GEMM. On Apple / M4 Pro both leave the 4-bit linear ~2.65x slower than llama.cpp's simdgroup-matrix GEMM, so prefill is the one axis where the WebGPU delegate trails.
**Solution:**
- **Before:** M>1 selects `use_shmem_gemm` (large K/N) else the register-tiled GEMM.
- **After:** M>1 prefers a new `use_steel` branch above shmem — a 64x64 output tile computed by 256 threads (16x16) with a 4x4 register sub-tile per thread, staging a BK=16 K-slice of activations (f32) and dequantized weights into shared memory once and reusing it across the tile. shmem/tiled remain the fallback when steel is ineligible.
**Implementation:**
- Add `q4gsw_linear_gemm_steel.wgsl` (same 6 bindings + `Params` as the sibling q4gsw kernels) and its generated `_wgsl.h`.
- Add a fail-closed `steel_supported` (requires `maxComputeInvocationsPerWorkgroup >= 256`; SwiftShader caps at 128 so it falls back) and `steel_workgroup_count` (one workgroup per 64x64 tile; returns 0 to fall back when `K % 16 != 0` — the kernel stages a full BK K-tile with no K-mask — or when the tile count exceeds the 1D dispatch limit).
- Thread `use_steel` through the shared `compute_q4gsw_workgroup_count` helper (a steel branch beside gemv/shmem) AND the `add_tensor_resize_hook`, so a dynamic-shape prefill recomputes the steel tile count for the live M; add steel to `fixed_wg` (fixed `@workgroup_size(16, 16)`, no `wg_size` override).
- No direct Vulkan analogue: Vulkan's q4gsw GEMM is register-tiled with an `is_gemv` split and no 256-thread shmem-staged tile. This tiling is WebGPU-specific (buffer-only storage, compile-time `@workgroup_size`, 1D-dispatch fold); only the signed-nibble dequant mirrors the q4gsw reference.
**Constraints:** engine-independent — depends only on shape + device limits and participates in the dynamic-resize hook exactly like gemv/shmem. Falls back to shmem/tiled on non-256-invocation devices, odd K, or over-limit dispatch, so behavior is unchanged where steel is ineligible. Layout and numerics are unchanged (same bindings, same dequant, f32 accumulator). The f16-multiply variant is a separate follow-up.
Co-authored-with: Claude Code.
ghstack-source-id: 401515156
@exported-using-ghexport
Differential Revision: [D110660965](https://our.internmc.facebook.com/intern/diff/D110660965/)
JCNTH added a commit that referenced this pull request Jul 9, 2026
Pull Request resolved: #20730
**Add a shared-memory 64x64-tile prefill GEMM for `et_vk.linear_q4gsw` that beats the existing shmem/tiled GEMM by 1.75-3.17x and moves in-browser Llama-3.2-1B prefill from behind to ahead of llama.cpp WebGPU (+11-33%, M4 Pro / Chrome Canary).**
**Problem:** the q4gsw prefill path (M>1) routes to either the register-tiled or the 32x32 shmem GEMM. On Apple / M4 Pro both leave the 4-bit linear ~2.65x slower than llama.cpp's simdgroup-matrix GEMM, so prefill is the one axis where the WebGPU delegate trails.
**Solution:**
- **Before:** M>1 selects `use_shmem_gemm` (large K/N) else the register-tiled GEMM.
- **After:** M>1 prefers a new `use_steel` branch above shmem — a 64x64 output tile computed by 256 threads (16x16) with a 4x4 register sub-tile per thread, staging a BK=16 K-slice of activations (f32) and dequantized weights into shared memory once and reusing it across the tile. shmem/tiled remain the fallback when steel is ineligible.
**Implementation:**
- Add `q4gsw_linear_gemm_steel.wgsl` (same 6 bindings + `Params` as the sibling q4gsw kernels) and its generated `_wgsl.h`.
- Add a fail-closed `steel_supported` (requires `maxComputeInvocationsPerWorkgroup >= 256`; SwiftShader caps at 128 so it falls back) and `steel_workgroup_count` (one workgroup per 64x64 tile; returns 0 to fall back when `K % 16 != 0` — the kernel stages a full BK K-tile with no K-mask — or when the tile count exceeds the 1D dispatch limit).
- Thread `use_steel` through the shared `compute_q4gsw_workgroup_count` helper (a steel branch beside gemv/shmem) AND the `add_tensor_resize_hook`, so a dynamic-shape prefill recomputes the steel tile count for the live M; add steel to `fixed_wg` (fixed `@workgroup_size(16, 16)`, no `wg_size` override).
- No direct Vulkan analogue: Vulkan's q4gsw GEMM is register-tiled with an `is_gemv` split and no 256-thread shmem-staged tile. This tiling is WebGPU-specific (buffer-only storage, compile-time `@workgroup_size`, 1D-dispatch fold); only the signed-nibble dequant mirrors the q4gsw reference.
**Constraints:** engine-independent — depends only on shape + device limits and participates in the dynamic-resize hook exactly like gemv/shmem. Falls back to shmem/tiled on non-256-invocation devices, odd K, or over-limit dispatch, so behavior is unchanged where steel is ineligible. Layout and numerics are unchanged (same bindings, same dequant, f32 accumulator). The f16-multiply variant is a separate follow-up.
Co-authored-with: Claude Code.
ghstack-source-id: 401515156
@exported-using-ghexport
Differential Revision: [D110660965](https://our.internmc.facebook.com/intern/diff/D110660965/)
Sign up for freeto join this conversation on GitHub. Already have an account? Sign in to comment

Labels

CLA SignedThis label is managed by the Facebook bot. Authors need to sign the CLA before a PR can be reviewed.meta-exported

Projects

None yet

Development

Successfully merging this pull request may close these issues.

2 participants

@JCNTH@SS-JIA