Skip to content

Commit f54cfbf

Browse files
committed
[ExecuTorch][WebGPU] Add 4-bit weight-only quantized linear (et_vk.linear_q4gsw)
Pull Request resolved: #20226 Adds the `et_vk.linear_q4gsw` operator (4-bit groupwise-symmetric weight-only linear) to the WebGPU backend: dequantize the packed int4 weight in WGSL (`(q-8)*scale`) and accumulate an fp32 matmul, consuming the serialized `[N, K/2]` uint8 weight directly (no prepack), one workgroup per output row. Mirrors the Vulkan reference (`backends/vulkan/.../impl/QuantizedLinear.cpp`). The dispatch carries a `linear_q4gsw` label for GPU-timestamp-query profiling (mirroring the SDPA kernels). The numerical test suite is in the stacked test diff. ghstack-source-id: 392908894 @exported-using-ghexport Differential Revision: [D108312283](https://our.internmc.facebook.com/intern/diff/D108312283/)
1 parent 6926726 commit f54cfbf

4 files changed

Lines changed: 397 additions & 0 deletions

File tree

backends/webgpu/CMakeLists.txt

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -37,6 +37,7 @@ set(WEBGPU_SRCS
3737
runtime/ops/update_cache/UpdateCache.cpp
3838
runtime/ops/sdpa/Sdpa.cpp
3939
runtime/ops/select_as_symint/SelectAsSymint.cpp
40+
runtime/ops/quantized_linear/QuantizedLinear.cpp
4041
)
4142

4243
add_library(webgpu_backend ${WEBGPU_SRCS})
Lines changed: 244 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,244 @@
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+
#include <executorch/backends/webgpu/runtime/WebGPUGraph.h>
10+
#include <executorch/backends/webgpu/runtime/WebGPUUtils.h>
11+
#include <executorch/backends/webgpu/runtime/ops/OperatorRegistry.h>
12+
#include <executorch/backends/webgpu/runtime/ops/quantized_linear/q4gsw_linear_wgsl.h>
13+
14+
#include <webgpu/webgpu.h>
15+
16+
#include <cstdint>
17+
#include <cstring>
18+
#include <stdexcept>
19+
20+
namespace executorch::backends::webgpu {
21+
22+
namespace {
23+
24+
// Uniform layout matching the WGSL Params struct (16-byte aligned, 32 bytes).
25+
struct Q4gswParams {
26+
uint32_t M;
27+
uint32_t N;
28+
uint32_t K;
29+
uint32_t K_packed;
30+
uint32_t group_size;
31+
uint32_t padded_N;
32+
uint32_t has_bias;
33+
uint32_t _pad;
34+
};
35+
static_assert(sizeof(Q4gswParams) == 32, "Q4gswParams must be 32 bytes");
36+
37+
// et_vk.linear_q4gsw args: [in, weight, scales, group_size, bias, out].
38+
void q4gsw_linear_impl(WebGPUGraph& graph, const std::vector<int>& args) {
39+
const int in_id = args.at(0);
40+
const int weight_id = args.at(1);
41+
const int scales_id = args.at(2);
42+
const int group_size_id = args.at(3);
43+
const int bias_id = args.at(4);
44+
const int out_id = args.at(5);
45+
46+
WGPUDevice device = graph.device();
47+
48+
const auto& in = graph.get_tensor(in_id);
49+
const auto& weight = graph.get_tensor(weight_id);
50+
const auto& scales = graph.get_tensor(scales_id);
51+
const auto& out = graph.get_tensor(out_id);
52+
53+
if (in.dims.empty() || weight.dims.size() < 2 || scales.dims.size() < 2) {
54+
throw std::runtime_error("WebGPU linear_q4gsw: malformed input dims");
55+
}
56+
57+
// Shapes from the tensors' own dims (no dtype field at runtime).
58+
const uint32_t K = static_cast<uint32_t>(in.dims.back());
59+
if (K == 0) {
60+
throw std::runtime_error("WebGPU linear_q4gsw: K == 0");
61+
}
62+
uint64_t in_numel = 1;
63+
for (int64_t d : in.dims) {
64+
in_numel *= static_cast<uint64_t>(d);
65+
}
66+
const uint32_t M = static_cast<uint32_t>(in_numel / K);
67+
if (in_numel % K != 0) {
68+
throw std::runtime_error(
69+
"WebGPU linear_q4gsw: input numel not a multiple of K");
70+
}
71+
const uint32_t N = static_cast<uint32_t>(weight.dims[0]);
72+
const uint32_t K_packed = static_cast<uint32_t>(weight.dims[1]);
73+
const uint32_t num_groups = static_cast<uint32_t>(scales.dims[0]);
74+
const uint32_t padded_N = static_cast<uint32_t>(scales.dims[1]);
75+
if (M == 0 || N == 0) {
76+
throw std::runtime_error("WebGPU linear_q4gsw: M or N == 0");
77+
}
78+
// int4 packing is 2 nibbles/byte, so K_packed must be ceil(K/2) (guards OOB).
79+
if (K_packed != (K + 1) / 2) {
80+
throw std::runtime_error("WebGPU linear_q4gsw: K_packed must be ceil(K/2)");
81+
}
82+
// Weight is read as array<u32>; a non-multiple-of-4 byte count over-reads.
83+
if ((static_cast<uint64_t>(N) * K_packed) % 4u != 0u) {
84+
throw std::runtime_error(
85+
"WebGPU linear_q4gsw: N*K_packed must be a multiple of 4 (u32-packed)");
86+
}
87+
88+
// One workgroup per output row (M); validate dispatch before any alloc.
89+
const uint32_t workgroup_count =
90+
utils::compute_1d_workgroup_count(device, M, 1, "linear_q4gsw");
91+
92+
// fp32-only byte-size guards (no runtime dtype); fp16 scales -> bail.
93+
const uint64_t scales_numel =
94+
static_cast<uint64_t>(num_groups) * static_cast<uint64_t>(padded_N);
95+
const uint64_t weight_numel =
96+
static_cast<uint64_t>(N) * static_cast<uint64_t>(K_packed);
97+
if (in.nbytes != in_numel * sizeof(float) ||
98+
out.nbytes != static_cast<uint64_t>(M) * N * sizeof(float) ||
99+
scales.nbytes != scales_numel * sizeof(float) ||
100+
weight.nbytes != weight_numel) {
101+
throw std::runtime_error(
102+
"WebGPU linear_q4gsw: fp32-only (byte-size mismatch)");
103+
}
104+
105+
int64_t group_size = 0;
106+
if (graph.get_value_type(group_size_id) == WebGPUGraph::ValueType::Int) {
107+
group_size = graph.get_int(group_size_id);
108+
}
109+
if (group_size <= 0) {
110+
throw std::runtime_error("WebGPU linear_q4gsw: group_size <= 0");
111+
}
112+
// scales is indexed [(k/group_size)*padded_N + n]; guard the table bounds.
113+
const uint32_t gs = static_cast<uint32_t>(group_size);
114+
if (num_groups < (K + gs - 1u) / gs || padded_N < N) {
115+
throw std::runtime_error(
116+
"WebGPU linear_q4gsw: scales dims too small for K/N");
117+
}
118+
119+
// Optional bias: real buffer if present, else a dummy for the fixed layout.
120+
uint32_t has_bias = 0;
121+
WGPUBuffer bias_buffer = nullptr;
122+
uint64_t bias_size = 4;
123+
if (graph.get_value_type(bias_id) == WebGPUGraph::ValueType::Tensor) {
124+
const auto& bias = graph.get_tensor(bias_id);
125+
if (bias.buffer == nullptr || bias.nbytes < N * sizeof(float)) {
126+
throw std::runtime_error(
127+
"WebGPU linear_q4gsw: bias present but null/undersized");
128+
}
129+
has_bias = 1;
130+
bias_buffer = bias.buffer;
131+
bias_size = bias.nbytes;
132+
}
133+
if (bias_buffer == nullptr) {
134+
bias_buffer = graph.create_scratch_buffer(4);
135+
}
136+
137+
Q4gswParams params = {};
138+
params.M = M;
139+
params.N = N;
140+
params.K = K;
141+
params.K_packed = K_packed;
142+
params.group_size = gs;
143+
params.padded_N = padded_N;
144+
params.has_bias = has_bias;
145+
146+
WGPUBufferDescriptor uniform_desc = {};
147+
uniform_desc.size = sizeof(Q4gswParams);
148+
uniform_desc.usage = WGPUBufferUsage_Uniform | WGPUBufferUsage_CopyDst;
149+
uniform_desc.mappedAtCreation = true;
150+
WGPUBuffer uniform_buffer = wgpuDeviceCreateBuffer(device, &uniform_desc);
151+
void* mapped =
152+
wgpuBufferGetMappedRange(uniform_buffer, 0, sizeof(Q4gswParams));
153+
std::memcpy(mapped, &params, sizeof(Q4gswParams));
154+
wgpuBufferUnmap(uniform_buffer);
155+
graph.add_uniform_buffer_bytes(sizeof(Q4gswParams));
156+
157+
WGPUShaderSourceWGSL wgsl_desc = {};
158+
wgsl_desc.chain.sType = WGPUSType_ShaderSourceWGSL;
159+
wgsl_desc.code = {kQ4gswLinearWGSL, WGPU_STRLEN};
160+
WGPUShaderModuleDescriptor shader_desc = {};
161+
shader_desc.nextInChain = &wgsl_desc.chain;
162+
WGPUShaderModule shader = wgpuDeviceCreateShaderModule(device, &shader_desc);
163+
164+
// Bind group layout: out (rw) + in/weight/scales/bias (ro storage) + uniform.
165+
WGPUBindGroupLayoutEntry entries[6] = {};
166+
entries[0].binding = 0;
167+
entries[0].visibility = WGPUShaderStage_Compute;
168+
entries[0].buffer.type = WGPUBufferBindingType_Storage;
169+
for (uint32_t i = 1; i <= 4; i++) {
170+
entries[i].binding = i;
171+
entries[i].visibility = WGPUShaderStage_Compute;
172+
entries[i].buffer.type = WGPUBufferBindingType_ReadOnlyStorage;
173+
}
174+
entries[5].binding = 5;
175+
entries[5].visibility = WGPUShaderStage_Compute;
176+
entries[5].buffer.type = WGPUBufferBindingType_Uniform;
177+
178+
WGPUBindGroupLayoutDescriptor bgl_desc = {};
179+
bgl_desc.entryCount = 6;
180+
bgl_desc.entries = entries;
181+
WGPUBindGroupLayout bgl = wgpuDeviceCreateBindGroupLayout(device, &bgl_desc);
182+
183+
WGPUPipelineLayoutDescriptor pl_desc = {};
184+
pl_desc.bindGroupLayoutCount = 1;
185+
pl_desc.bindGroupLayouts = &bgl;
186+
WGPUPipelineLayout pipeline_layout =
187+
wgpuDeviceCreatePipelineLayout(device, &pl_desc);
188+
189+
const uint32_t wg_size =
190+
utils::clamp_workgroup_size(device, kQ4gswLinearWorkgroupSizeX);
191+
WGPUConstantEntry wg_size_constant = {};
192+
wg_size_constant.key = {"wg_size", WGPU_STRLEN};
193+
wg_size_constant.value = static_cast<double>(wg_size);
194+
195+
WGPUComputePipelineDescriptor pipeline_desc = {};
196+
pipeline_desc.layout = pipeline_layout;
197+
pipeline_desc.compute.module = shader;
198+
pipeline_desc.compute.entryPoint = {"main", WGPU_STRLEN};
199+
pipeline_desc.compute.constantCount = 1;
200+
pipeline_desc.compute.constants = &wg_size_constant;
201+
WGPUComputePipeline pipeline =
202+
wgpuDeviceCreateComputePipeline(device, &pipeline_desc);
203+
204+
WGPUBindGroupEntry bg_entries[6] = {};
205+
bg_entries[0].binding = 0;
206+
bg_entries[0].buffer = out.buffer;
207+
bg_entries[0].size = out.nbytes;
208+
bg_entries[1].binding = 1;
209+
bg_entries[1].buffer = in.buffer;
210+
bg_entries[1].size = in.nbytes;
211+
bg_entries[2].binding = 2;
212+
bg_entries[2].buffer = weight.buffer;
213+
bg_entries[2].size = weight.nbytes;
214+
bg_entries[3].binding = 3;
215+
bg_entries[3].buffer = scales.buffer;
216+
bg_entries[3].size = scales.nbytes;
217+
bg_entries[4].binding = 4;
218+
bg_entries[4].buffer = bias_buffer;
219+
bg_entries[4].size = bias_size;
220+
bg_entries[5].binding = 5;
221+
bg_entries[5].buffer = uniform_buffer;
222+
bg_entries[5].size = sizeof(Q4gswParams);
223+
224+
WGPUBindGroupDescriptor bg_desc = {};
225+
bg_desc.layout = bgl;
226+
bg_desc.entryCount = 6;
227+
bg_desc.entries = bg_entries;
228+
WGPUBindGroup bind_group = wgpuDeviceCreateBindGroup(device, &bg_desc);
229+
230+
graph.add_dispatch({pipeline, bind_group, workgroup_count, "linear_q4gsw"});
231+
232+
wgpuShaderModuleRelease(shader);
233+
wgpuBindGroupLayoutRelease(bgl);
234+
wgpuPipelineLayoutRelease(pipeline_layout);
235+
wgpuBufferRelease(uniform_buffer);
236+
}
237+
238+
} // namespace
239+
240+
WEBGPU_REGISTER_OPERATORS {
241+
WEBGPU_REGISTER_OP(et_vk.linear_q4gsw.default, q4gsw_linear_impl);
242+
}
243+
244+
} // namespace executorch::backends::webgpu
Lines changed: 64 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,64 @@
1+
@group(0) @binding(0) var<storage, read_write> t_out: array<f32>;
2+
@group(0) @binding(1) var<storage, read> t_input: array<f32>;
3+
@group(0) @binding(2) var<storage, read> t_weight: array<u32>;
4+
@group(0) @binding(3) var<storage, read> t_scales: array<f32>;
5+
@group(0) @binding(4) var<storage, read> t_bias: array<f32>;
6+
7+
struct Params {
8+
M: u32,
9+
N: u32,
10+
K: u32,
11+
K_packed: u32,
12+
group_size: u32,
13+
padded_N: u32,
14+
has_bias: u32,
15+
_pad: u32,
16+
}
17+
@group(0) @binding(5) var<uniform> params: Params;
18+
19+
override wg_size: u32 = 64u;
20+
21+
// One workgroup per row m, threads stride N; loop logical K only (in-bounds).
22+
@compute @workgroup_size(wg_size, 1, 1)
23+
fn main(
24+
@builtin(workgroup_id) wid: vec3<u32>,
25+
@builtin(local_invocation_id) lid: vec3<u32>) {
26+
let m = wid.x;
27+
if (m >= params.M) {
28+
return;
29+
}
30+
let in_base = m * params.K;
31+
32+
var n: u32 = lid.x;
33+
loop {
34+
if (n >= params.N) {
35+
break;
36+
}
37+
var acc: f32 = 0.0;
38+
var k: u32 = 0u;
39+
loop {
40+
if (k >= params.K) {
41+
break;
42+
}
43+
// Packed weight byte for (n, k): row stride K_packed bytes, byte k/2.
44+
let byte_idx = n * params.K_packed + (k >> 1u);
45+
let word = t_weight[byte_idx >> 2u];
46+
let b = (word >> ((byte_idx & 3u) * 8u)) & 0xFFu;
47+
var nib: u32;
48+
if ((k & 1u) == 0u) {
49+
nib = b & 0x0Fu; // even k -> low nibble
50+
} else {
51+
nib = (b >> 4u) & 0x0Fu; // odd k -> high nibble
52+
}
53+
let q = f32(i32(nib) - 8); // +8-shifted on pack; recover signed [-8,7]
54+
let scale = t_scales[(k / params.group_size) * params.padded_N + n];
55+
acc = acc + t_input[in_base + k] * q * scale;
56+
k = k + 1u;
57+
}
58+
if (params.has_bias != 0u) {
59+
acc = acc + t_bias[n];
60+
}
61+
t_out[m * params.N + n] = acc;
62+
n = n + wg_size;
63+
}
64+
}

0 commit comments

Comments
 (0)