Skip to content

Commit 45ea327

Browse files
committed
[ExecuTorch][WebGPU] f16-accumulate (pwdqf16acc) steel q4gsw prefill GEMM
Pull Request resolved: #20800 **+46-56% end-to-end prefill tok/s over the shipped f16 `steel` q4gsw GEMM (Apple M4 Pro / Chrome Canary), behind an opt-in runtime spec (default OFF); perplexity held (13.32 -> 13.37, +0.05).** **Problem:** the f16 `steel` prefill GEMM (and its packed-word-dequant variant `pwdq`) accumulates its 4x4 register tile in f32. WebLLM/MLC accumulate in f16, which halves the accumulator footprint and raises occupancy — the largest remaining prefill gap vs MLC on Apple. **Solution:** an f16-accumulate variant of the `pwdq` kernel — identical staging (dequant-once + hoisted scale) and 64x64/256-thread/BK=16 geometry, but the 4x4 accumulator is kept in f16 with `fma()` (mirrors MLC's `array<f16,16>` reduction) and cast to f32 in the epilogue for the f32 output/bias. Before (`pwdq`): f16 multiply, f32 accumulate. After (`pwdqf16acc`): f16 multiply, f16 accumulate, f32 epilogue. **Implementation:** - New `ACC=half` fork in the shared `q4gsw_linear_gemm_steel.wgsl` template (a `shader_variants` entry in `q4gsw_linear_gemm_steel.yaml` generates `q4gsw_linear_gemm_steel_half_pwdq_f16acc_wgsl.h`) — no standalone shader file. - Opt-in via the `enable_f16_accumulate_gemm` runtime spec (a load-time `BackendOption` read in `WebGPUBackend::init`, threaded through `WebGPUGraph::build(..., f16_accumulate_gemm)` -> `graph.f16_accumulate_gemm()`), default OFF — no CMake option or compile flag. - When the spec is set, overrides the f32-accumulate steel kernels for M>1 prefill whenever the device negotiated shader-f16 and `group_size % BK == 0`; else the f32-accumulate `pwdq` / `half` / f32 kernels run (fail-closed). **Constraints:** LOSSY — f16 accumulation over the full K (up to 8192) is not bit-exact, so it ships on a perplexity bar, not a bit-exact gate (as MLC does): measured Llama-3.2-1B int4 perplexity 13.32 -> 13.37 (+0.05) on the real prefill path. Default-OFF keeps upstream builds on the strict f32-accumulate golden; the runtime spec is opt-in for latency-sensitive deployments. Co-authored-with: Claude Code. ghstack-source-id: 401564900 @exported-using-ghexport Differential Revision: [D111163606](https://our.internmc.facebook.com/intern/diff/D111163606/)
1 parent 0690a89 commit 45ea327

6 files changed

Lines changed: 182 additions & 3 deletions

File tree

backends/webgpu/runtime/WebGPUBackend.cpp

Lines changed: 10 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -89,13 +89,22 @@ Result<DelegateHandle*> WebGPUBackend::init(
8989
enable_f16_kv_cache = spec.get();
9090
}
9191
}
92+
bool enable_f16_accumulate_gemm = false;
93+
{
94+
Result<bool> spec =
95+
context.get_runtime_spec<bool>("enable_f16_accumulate_gemm");
96+
if (spec.ok()) {
97+
enable_f16_accumulate_gemm = spec.get();
98+
}
99+
}
92100

93101
try {
94102
graph->build(
95103
flatbuffer_data,
96104
constant_data,
97105
context.get_named_data_map(),
98-
enable_f16_kv_cache);
106+
enable_f16_kv_cache,
107+
enable_f16_accumulate_gemm);
99108
} catch (const std::exception& e) {
100109
ET_LOG(Error, "WebGPU graph build failed: %s", e.what());
101110
graph->~WebGPUGraph();

backends/webgpu/runtime/WebGPUGraph.cpp

Lines changed: 6 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -359,7 +359,8 @@ void WebGPUGraph::build(
359359
const void* flatbuffer_data,
360360
const uint8_t* constant_data,
361361
const executorch::runtime::NamedDataMap* named_data_map,
362-
bool f16_kv_cache) {
362+
bool f16_kv_cache,
363+
bool f16_accumulate_gemm) {
363364
if (!device_) {
364365
auto* ctx = get_default_webgpu_context();
365366
if (ctx) {
@@ -385,6 +386,10 @@ void WebGPUGraph::build(
385386
const WebGPUContext* kv_ctx = get_default_webgpu_context();
386387
kv_f16_ = f16_kv_cache && (kv_ctx != nullptr && kv_ctx->shader_f16_supported);
387388

389+
// f16-accumulate q4gsw steel prefill GEMM (runtime opt-in). QuantizedLinear
390+
// additionally gates the kernel on the negotiated shader-f16 feature.
391+
f16_accumulate_gemm_ = f16_accumulate_gemm;
392+
388393
// Phase 1: Create all values
389394
const auto* values = graph->values();
390395
const int num_vals = values ? values->size() : 0;

backends/webgpu/runtime/WebGPUGraph.h

Lines changed: 9 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -105,7 +105,8 @@ class WebGPUGraph {
105105
const void* flatbuffer_data,
106106
const uint8_t* constant_data,
107107
const executorch::runtime::NamedDataMap* named_data_map = nullptr,
108-
bool f16_kv_cache = false);
108+
bool f16_kv_cache = false,
109+
bool f16_accumulate_gemm = false);
109110

110111
// Copy input tensor data from host pointers into GPU buffers.
111112
void copy_inputs(const std::vector<InputData>& inputs);
@@ -350,9 +351,16 @@ class WebGPUGraph {
350351
return kv_f16_;
351352
}
352353

354+
// True when the q4gsw steel prefill GEMM uses the lossy f16-accumulate kernel
355+
// (runtime opt-in; perplexity-gated, not bit-exact).
356+
bool f16_accumulate_gemm() const {
357+
return f16_accumulate_gemm_;
358+
}
359+
353360
private:
354361
bool kv_f16_ = false;
355362
std::unordered_set<int> kv_cache_ids_;
363+
bool f16_accumulate_gemm_ = false;
356364

357365
private:
358366
WGPUInstance instance_ = nullptr;

backends/webgpu/runtime/ops/quantized_linear/QuantizedLinear.cpp

Lines changed: 12 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -12,6 +12,7 @@
1212
#include <executorch/backends/webgpu/runtime/ops/OperatorRegistry.h>
1313
#include <executorch/backends/webgpu/runtime/ops/quantized_linear/q4gsw_linear_coop4_bicol_wgsl.h>
1414
#include <executorch/backends/webgpu/runtime/ops/quantized_linear/q4gsw_linear_gemm_shmem_wgsl.h>
15+
#include <executorch/backends/webgpu/runtime/ops/quantized_linear/q4gsw_linear_gemm_steel_half_pwdq_f16acc_wgsl.h>
1516
#include <executorch/backends/webgpu/runtime/ops/quantized_linear/q4gsw_linear_gemm_steel_half_pwdq_wgsl.h>
1617
#include <executorch/backends/webgpu/runtime/ops/quantized_linear/q4gsw_linear_gemm_steel_half_wgsl.h>
1718
#include <executorch/backends/webgpu/runtime/ops/quantized_linear/q4gsw_linear_gemm_steel_wgsl.h>
@@ -280,6 +281,17 @@ void q4gsw_linear_impl(WebGPUGraph& graph, const std::vector<int>& args) {
280281
: kQ4gswLinearGemmSteelHalfWGSL;
281282
}
282283
}
284+
// f16-accumulate: pwdq staging with an f16 register accumulator.
285+
// Lossy (f16 accumulate over K) -> opt-in via the enable_f16_accumulate_gemm
286+
// runtime spec (default off), gated on the negotiated shader-f16 feature and
287+
// group_size % BK == 0 (same hoisted-scale requirement as pwdq). Overrides
288+
// the f32-accumulate steel kernels.
289+
if (use_steel && graph.f16_accumulate_gemm() && (gs % kQ4gswSteelBK == 0u)) {
290+
const WebGPUContext* ctx = get_default_webgpu_context();
291+
if (ctx != nullptr && ctx->shader_f16_supported) {
292+
shader_src = kQ4gswLinearGemmSteelHalfPwdqF16accWGSL;
293+
}
294+
}
283295
const uint32_t workgroup_count = compute_q4gsw_workgroup_count(
284296
device,
285297
use_gemv,

backends/webgpu/runtime/ops/quantized_linear/q4gsw_linear_gemm_steel.yaml

Lines changed: 4 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -16,3 +16,7 @@ q4gsw_linear_gemm_steel:
1616
DTYPE: half
1717
PWDQ: true
1818
ACC: float
19+
- NAME: q4gsw_linear_gemm_steel_half_pwdq_f16acc
20+
DTYPE: half
21+
PWDQ: true
22+
ACC: half
Lines changed: 141 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,141 @@
1+
/*
2+
* Copyright (c) Meta Platforms, Inc. and affiliates.
3+
* All rights reserved.
4+
*
5+
* This source code is licensed under the BSD-style license found in the
6+
* LICENSE file in the root directory of this source tree.
7+
*/
8+
9+
#pragma once
10+
11+
#include <cstdint>
12+
13+
namespace executorch::backends::webgpu {
14+
15+
// @generated from q4gsw_linear_gemm_steel.wgsl - DO NOT EDIT.
16+
// wgsl-sha256: 36b3d3f9dd08a529909c13ec7d66cd0cf392c347ca047a4d38453b3c295f72ce
17+
inline constexpr const char* kQ4gswLinearGemmSteelHalfPwdqF16accWGSL = R"(
18+
enable f16;
19+
@group(0) @binding(0) var<storage, read_write> t_out: array<f32>;
20+
@group(0) @binding(1) var<storage, read> t_input: array<f32>;
21+
@group(0) @binding(2) var<storage, read> t_weight: array<u32>;
22+
@group(0) @binding(3) var<storage, read> t_scales: array<f32>;
23+
@group(0) @binding(4) var<storage, read> t_bias: array<f32>;
24+
25+
struct Params {
26+
M: u32,
27+
N: u32,
28+
K: u32,
29+
K_packed: u32,
30+
group_size: u32,
31+
padded_N: u32,
32+
has_bias: u32,
33+
_pad: u32,
34+
}
35+
@group(0) @binding(5) var<uniform> params: Params;
36+
37+
// "steel" prefill GEMM (M>1): 64x64 tile, 256 threads; K%16==0 host-guarded.
38+
// The "steel" name + register-tiled dequant-to-shared GEMM structure are
39+
// inspired by MLX's steel GEMM kernels (github.com/ml-explore/mlx,
40+
// mlx/backend/metal/kernels/steel). One template, four variants:
41+
// DTYPE=float f32 storage/multiply, per-nibble weight staging.
42+
// DTYPE=half f16 storage/multiply, per-nibble weight staging.
43+
// PWDQ (half only) packed-word dequant: load each u32 weight word ONCE,
44+
// unpack all 16 nibbles of a column + hoist the per-column scale to one read
45+
// (the per-nibble path re-reads each word ~8x). Requires K%BK==0 (steel
46+
// route guarantees it) and group_size%BK==0 (hoisted scale across the tile).
47+
// ACC=half (PWDQ only) f16 accumulate with fma(), cast to f32 in the epilogue
48+
// -- LOSSY, perplexity-gated, opt-in via a runtime spec. ACC=float is f32
49+
// accumulate -- BIT-EXACT to the per-nibble half kernel.
50+
const BM: u32 = 64u; const BN: u32 = 64u; const BK: u32 = 16u;
51+
var<workgroup> As: array<f16, 1024>; // BM*BK
52+
var<workgroup> Bs: array<f16, 1024>; // BK*BN
53+
@compute @workgroup_size(16, 16)
54+
fn main(@builtin(workgroup_id) wid: vec3<u32>,
55+
@builtin(local_invocation_id) lid: vec3<u32>) {
56+
let nbN = (params.N + BN - 1u) / BN;
57+
let bx = wid.x % nbN; // decode 2D tile id from 1D dispatch
58+
let by = wid.x / nbN;
59+
let row0 = by * BM;
60+
let col0 = bx * BN;
61+
let tid = lid.y * 16u + lid.x;
62+
var acc: array<array<f16, 4>, 4>;
63+
for (var m: u32 = 0u; m < 4u; m = m + 1u) {
64+
for (var n: u32 = 0u; n < 4u; n = n + 1u) { acc[m][n] = 0.0h; }
65+
}
66+
// A staging coords: 256 threads load 64x16 = 1024 f32 -> 4 rows each (4 contiguous K).
67+
let ar = tid / 4u; // 0..63 (row in tile)
68+
let ac = (tid % 4u) * 4u; // 0,4,8,12 (K offset, 4 contiguous)
69+
70+
var k0: u32 = 0u;
71+
loop {
72+
if (k0 >= params.K) { break; }
73+
// stage activations (edge-masked on M; K is a multiple of BK for our shapes)
74+
let arow = row0 + ar;
75+
if (arow < params.M) {
76+
let base = arow * params.K + k0 + ac;
77+
As[ar * BK + ac + 0u] = f16(t_input[base]);
78+
As[ar * BK + ac + 1u] = f16(t_input[base + 1u]);
79+
As[ar * BK + ac + 2u] = f16(t_input[base + 2u]);
80+
As[ar * BK + ac + 3u] = f16(t_input[base + 3u]);
81+
} else {
82+
As[ar * BK + ac + 0u] = 0.0h; As[ar * BK + ac + 1u] = 0.0h;
83+
As[ar * BK + ac + 2u] = 0.0h; As[ar * BK + ac + 3u] = 0.0h;
84+
}
85+
// Packed-word dequant: threads [0,BN) each stage one full BK-column of Bs.
86+
if (tid < BN) {
87+
let c = tid; // Bs column within this tile
88+
let n = col0 + c; // global output column
89+
if (n < params.N) {
90+
// Scale is constant across the BK tile (group_size % BK == 0 for all real
91+
// group sizes; K%BK==0 on the steel route), so hoist it to one read.
92+
let scale_row = (k0 / params.group_size) * params.padded_N;
93+
let scale = f16(t_scales[scale_row + n]);
94+
// Column n's 16-nibble K-slice for this tile = two consecutive words.
95+
// K_packed multiple of 8 => base_word stays inside column n's own region.
96+
let base_word = n * (params.K_packed >> 2u) + (k0 >> 3u);
97+
let w0 = t_weight[base_word];
98+
let w1 = t_weight[base_word + 1u];
99+
for (var br: u32 = 0u; br < BK; br = br + 1u) {
100+
let word = select(w1, w0, br < 8u); // word0 holds K-slice [0,8)
101+
let nib = (word >> ((br & 7u) * 4u)) & 0x0Fu;
102+
Bs[br * BN + c] = f16(i32(nib) - 8) * scale;
103+
}
104+
} else {
105+
for (var br: u32 = 0u; br < BK; br = br + 1u) { Bs[br * BN + c] = 0.0h; }
106+
}
107+
}
108+
workgroupBarrier();
109+
for (var k: u32 = 0u; k < BK; k = k + 1u) {
110+
var a: array<f16, 4>;
111+
var bvec: array<f16, 4>;
112+
for (var m: u32 = 0u; m < 4u; m = m + 1u) { a[m] = As[(lid.y * 4u + m) * BK + k]; }
113+
for (var n: u32 = 0u; n < 4u; n = n + 1u) { bvec[n] = Bs[k * BN + lid.x * 4u + n]; }
114+
for (var m: u32 = 0u; m < 4u; m = m + 1u) {
115+
for (var n: u32 = 0u; n < 4u; n = n + 1u) { acc[m][n] = fma(a[m], bvec[n], acc[m][n]); }
116+
}
117+
}
118+
workgroupBarrier();
119+
k0 = k0 + BK;
120+
}
121+
for (var m: u32 = 0u; m < 4u; m = m + 1u) {
122+
for (var n: u32 = 0u; n < 4u; n = n + 1u) {
123+
let r = row0 + lid.y * 4u + m;
124+
let c = col0 + lid.x * 4u + n;
125+
if (r < params.M && c < params.N) {
126+
var v = f32(acc[m][n]);
127+
if (params.has_bias != 0u) { v = v + t_bias[c]; }
128+
t_out[r * params.N + c] = v;
129+
}
130+
}
131+
}
132+
}
133+
)";
134+
135+
inline constexpr uint32_t kQ4gswLinearGemmSteelHalfPwdqF16accWorkgroupSizeX =
136+
16;
137+
inline constexpr uint32_t kQ4gswLinearGemmSteelHalfPwdqF16accWorkgroupSizeY =
138+
16;
139+
inline constexpr uint32_t kQ4gswLinearGemmSteelHalfPwdqF16accWorkgroupSizeZ = 1;
140+
141+
} // namespace executorch::backends::webgpu

0 commit comments

Comments
 (0)