Skip to content

Commit 58f64f8

Browse files
committed
[ExecuTorch][WebGPU] Add q4gsw training kernels (backward, weight-grad, requant) to the WebGPU backend
Pull Request resolved: #20931 Add the 4-bit fine-tuning weight-path custom ops — `et_vk.linear_q4gsw_backward` (input grad dX), `et_vk.linear_dW` (weight grad dW — a generic fp32 weight-gradient matmul, not q4gsw-specific), and `et_vk.q4gsw_requant` (STE re-quant of fp32 latents → 4-bit codes) — that close the on-device training loop through a frozen 4-bit base. Key changes: - `runtime/ops/quantized_linear/{QuantizedLinearBackward,QuantizedLinearRequant}.cpp` + `LinearDw.cpp` + `q4gsw_{backward,requant}.wgsl` + `linear_dW.wgsl` — the three training kernels - `custom_ops_lib.py` — 3 custom-op defs (`linear_q4gsw_backward` + `register_autograd` on `linear_q4gsw`, `linear_dW`, `q4gsw_requant`) - `op_registry.py` — 3 `OpFeatures` - `CMakeLists.txt` `WEBGPU_SRCS` — wire the sources Training-only, WebGPU-only custom ops registered under the shared Vulkan partitioner; Vulkan has no kernels for them yet (a separate Vulkan-kernel track covers that). Guards mirror the sibling `Dw`/`Requant` ops (scales-rank + `K==0`). Co-authored-with: Claude Code. ghstack-source-id: 405026076 @exported-using-ghexport Differential Revision: [D111755130](https://our.internmc.facebook.com/intern/diff/D111755130/)
1 parent 371e5fb commit 58f64f8

12 files changed

Lines changed: 1215 additions & 0 deletions

backends/vulkan/custom_ops_lib.py

Lines changed: 127 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -289,6 +289,67 @@ def linear_dq8ca_q4gsw(
289289
lib.impl(name, linear_q4gsw, "CompositeExplicitAutograd")
290290
linear_qc4w_op = getattr(getattr(torch.ops, namespace), name)
291291

292+
293+
# Backward of linear_q4gsw wrt input (for on-device LoRA training through a frozen
294+
# 4-bit base): d_x = d_out @ dequant(W). Reference impl extracts dequant(W) via the
295+
# forward on an identity so it is layout-agnostic; the runtime dispatches this op to
296+
# the tiled q4gsw_backward WGSL kernel (contracts over N).
297+
def linear_q4gsw_backward_impl(
298+
d_out: torch.Tensor,
299+
weights: torch.Tensor,
300+
weight_scales: torch.Tensor,
301+
group_size: int,
302+
) -> torch.Tensor:
303+
in_features = int(weights.shape[1]) * 2
304+
eye = torch.eye(in_features, dtype=d_out.dtype, device=d_out.device)
305+
w_t = linear_q4gsw(eye, weights, weight_scales, group_size) # [in, out]
306+
return d_out @ w_t.t() # [M, out] @ [out, in] = [M, in]
307+
308+
309+
def linear_q4gsw_backward_meta(
310+
d_out: torch.Tensor,
311+
weights: torch.Tensor,
312+
weight_scales: torch.Tensor,
313+
group_size: int,
314+
) -> torch.Tensor:
315+
return d_out.new_empty(d_out.shape[:-1] + (int(weights.shape[1]) * 2,))
316+
317+
318+
name = "linear_q4gsw_backward"
319+
lib.define(
320+
f"{name}(Tensor d_out, Tensor weights, Tensor weight_scales, int group_size) -> Tensor"
321+
)
322+
lib.impl(name, linear_q4gsw_backward_impl, "CompositeExplicitAutograd")
323+
lib.impl(name, linear_q4gsw_backward_meta, "Meta")
324+
linear_q4gsw_backward_op = getattr(getattr(torch.ops, namespace), name)
325+
326+
327+
def linear_q4gsw_setup_context(ctx, inputs, output) -> None:
328+
_x, weights, weight_scales, group_size, _bias = inputs
329+
ctx.save_for_backward(weights, weight_scales)
330+
ctx.group_size = group_size
331+
332+
333+
def linear_q4gsw_backward(ctx, grad_out):
334+
weights, weight_scales = ctx.saved_tensors
335+
d_x = torch.ops.et_vk.linear_q4gsw_backward(
336+
grad_out, weights, weight_scales, ctx.group_size
337+
)
338+
return (
339+
d_x,
340+
None,
341+
None,
342+
None,
343+
None,
344+
) # grads for (x, weights, scales, group_size, bias)
345+
346+
347+
torch.library.register_autograd(
348+
f"{namespace}::linear_q4gsw",
349+
linear_q4gsw_backward,
350+
setup_context=linear_q4gsw_setup_context,
351+
)
352+
292353
name = "linear_dq8ca_q4gsw"
293354
lib.define(
294355
f"""
@@ -1090,3 +1151,69 @@ def rms_norm_impl(
10901151
lib.define(f"{name}(Tensor x, Tensor weight, float eps) -> Tensor")
10911152
lib.impl(name, rms_norm_impl, "CompositeExplicitAutograd")
10921153
rms_norm_op = getattr(getattr(torch.ops, namespace), name)
1154+
1155+
1156+
# STE weight gradient d_out^T @ x through the frozen 4-bit linear_q4gsw base.
1157+
def linear_dW_impl(
1158+
d_out: torch.Tensor,
1159+
x: torch.Tensor,
1160+
) -> torch.Tensor:
1161+
return d_out.reshape(-1, d_out.shape[-1]).t() @ x.reshape(-1, x.shape[-1])
1162+
1163+
1164+
def linear_dW_meta(
1165+
d_out: torch.Tensor,
1166+
x: torch.Tensor,
1167+
) -> torch.Tensor:
1168+
return d_out.new_empty((d_out.shape[-1], x.shape[-1]))
1169+
1170+
1171+
name = "linear_dW"
1172+
lib.define(f"{name}(Tensor d_out, Tensor x) -> Tensor")
1173+
lib.impl(name, linear_dW_impl, "CompositeExplicitAutograd")
1174+
lib.impl(name, linear_dW_meta, "Meta")
1175+
linear_dW_op = getattr(getattr(torch.ops, namespace), name)
1176+
1177+
1178+
##################
1179+
## q4gsw_requant ##
1180+
##################
1181+
1182+
1183+
# STE re-quant of fp32 latent weights into the frozen-scale 4-bit codes.
1184+
def q4gsw_requant_impl(
1185+
latent: torch.Tensor,
1186+
scales: torch.Tensor,
1187+
group_size: int,
1188+
) -> torch.Tensor:
1189+
n, k = latent.shape
1190+
group_idx = torch.arange(k, device=latent.device) // group_size
1191+
scale_full = scales.t()[:, group_idx] # [N, K]: scales[k // group_size, n]
1192+
nonzero = scale_full != 0
1193+
safe = torch.where(nonzero, scale_full, torch.ones_like(scale_full))
1194+
q = torch.round(latent / safe)
1195+
q = torch.where(nonzero, q, torch.zeros_like(q))
1196+
codes = (torch.clamp(q, -8, 7).to(torch.int32) + 8) & 0xF # [N, K] in 0..15
1197+
k_packed = (k + 1) // 2
1198+
packed = torch.zeros((n, k_packed), dtype=torch.uint8, device=latent.device)
1199+
packed[:, :] = codes[:, 0::2].to(torch.uint8)
1200+
if k > 1:
1201+
high = codes[:, 1::2].to(torch.uint8)
1202+
packed[:, : high.shape[1]] |= high << 4
1203+
return packed
1204+
1205+
1206+
def q4gsw_requant_meta(
1207+
latent: torch.Tensor,
1208+
scales: torch.Tensor,
1209+
group_size: int,
1210+
) -> torch.Tensor:
1211+
n, k = latent.shape
1212+
return latent.new_empty((n, (k + 1) // 2), dtype=torch.uint8)
1213+
1214+
1215+
name = "q4gsw_requant"
1216+
lib.define(f"{name}(Tensor latent, Tensor scales, int group_size) -> Tensor")
1217+
lib.impl(name, q4gsw_requant_impl, "CompositeExplicitAutograd")
1218+
lib.impl(name, q4gsw_requant_meta, "Meta")
1219+
q4gsw_requant_op = getattr(getattr(torch.ops, namespace), name)

backends/vulkan/op_registry.py

Lines changed: 26 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -462,6 +462,15 @@ def register_quantizedlinear_cpp_ops():
462462
)
463463

464464

465+
@update_features(exir_ops.edge.et_vk.linear_q4gsw_backward.default)
466+
def register_linear_q4gsw_backward():
467+
return OpFeatures(
468+
inputs_storage=utils.CONTIGUOUS_ANY,
469+
inputs_dtypes=utils.FP_T,
470+
supports_prepacking=True,
471+
)
472+
473+
465474
@update_features(exir_ops.edge.et_vk.linear_dq8ca_q4gsw.default)
466475
def register_linear_dq8ca_q4gsw():
467476
return OpFeatures(
@@ -1775,6 +1784,23 @@ def register_logical_not():
17751784
)
17761785

17771786

1787+
@update_features(exir_ops.edge.et_vk.linear_dW.default)
1788+
def register_linear_dW():
1789+
return OpFeatures(
1790+
inputs_storage=utils.CONTIGUOUS_ANY,
1791+
inputs_dtypes=utils.FP_T,
1792+
supports_prepacking=True,
1793+
)
1794+
1795+
1796+
@update_features(exir_ops.edge.et_vk.q4gsw_requant.default)
1797+
def register_q4gsw_requant():
1798+
return OpFeatures(
1799+
inputs_storage=utils.CONTIGUOUS_ANY,
1800+
inputs_dtypes=utils.FP_T,
1801+
)
1802+
1803+
17781804
#######################
17791805
## Utility functions ##
17801806
#######################

backends/webgpu/CMakeLists.txt

Lines changed: 3 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -38,6 +38,7 @@ set(WEBGPU_SRCS
3838
runtime/ops/sdpa/Sdpa.cpp
3939
runtime/ops/select_as_symint/SelectAsSymint.cpp
4040
runtime/ops/quantized_linear/QuantizedLinear.cpp
41+
runtime/ops/quantized_linear/QuantizedLinearBackward.cpp
4142
runtime/ops/mul/BinaryOp.cpp
4243
runtime/ops/embedding_q4gsw/EmbeddingQ4gsw.cpp
4344
runtime/ops/rope/RotaryEmbedding.cpp
@@ -67,6 +68,8 @@ set(WEBGPU_SRCS
6768
runtime/ops/dim_order/DimOrder.cpp
6869
runtime/ops/linear/Linear.cpp
6970
runtime/ops/embedding/Embedding.cpp
71+
runtime/ops/quantized_linear/LinearDw.cpp
72+
runtime/ops/quantized_linear/QuantizedLinearRequant.cpp
7073
)
7174

7275
add_library(webgpu_backend ${WEBGPU_SRCS})
Lines changed: 173 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,173 @@
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/linear_dW_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+
struct LinearDwParams {
25+
uint32_t M;
26+
uint32_t N;
27+
uint32_t K;
28+
uint32_t _pad;
29+
};
30+
static_assert(sizeof(LinearDwParams) == 16, "params must be 16 bytes");
31+
32+
// STE weight gradient d_W[N,K] = d_out^T @ x.
33+
void linear_dW_impl(WebGPUGraph& graph, const std::vector<int>& args) {
34+
const int dout_id = args.at(0);
35+
const int x_id = args.at(1);
36+
const int dw_id = args.at(2);
37+
38+
WGPUDevice device = graph.device();
39+
const auto& dout = graph.get_tensor(dout_id);
40+
const auto& x = graph.get_tensor(x_id);
41+
const auto& dw = graph.get_tensor(dw_id);
42+
43+
if (dw.dims.size() != 2 || dout.dims.empty() || x.dims.empty()) {
44+
throw std::runtime_error("linear_dW: bad tensor ranks");
45+
}
46+
const uint32_t N = static_cast<uint32_t>(dw.dims[0]);
47+
const uint32_t K = static_cast<uint32_t>(dw.dims[1]);
48+
if (N == 0 || K == 0) {
49+
throw std::runtime_error("linear_dW: N or K == 0");
50+
}
51+
52+
uint64_t dout_numel = 1;
53+
for (int64_t d : dout.dims) {
54+
dout_numel *= static_cast<uint64_t>(d);
55+
}
56+
uint64_t x_numel = 1;
57+
for (int64_t d : x.dims) {
58+
x_numel *= static_cast<uint64_t>(d);
59+
}
60+
if (static_cast<uint32_t>(dout.dims.back()) != N ||
61+
static_cast<uint32_t>(x.dims.back()) != K) {
62+
throw std::runtime_error("linear_dW: d_out/x last dim mismatch");
63+
}
64+
const uint32_t M = static_cast<uint32_t>(dout_numel / N);
65+
if (dout_numel % N != 0 || x_numel % K != 0 || x_numel / K != M) {
66+
throw std::runtime_error("linear_dW: M mismatch across d_out/x");
67+
}
68+
69+
// fp32-only byte-size guards (mirror the forward's byte checks).
70+
if (dw.nbytes != static_cast<uint64_t>(N) * K * sizeof(float) ||
71+
dout.nbytes != dout_numel * sizeof(float) ||
72+
x.nbytes != x_numel * sizeof(float)) {
73+
throw std::runtime_error("linear_dW: fp32-only (byte-size mismatch)");
74+
}
75+
76+
LinearDwParams params = {};
77+
params.M = M;
78+
params.N = N;
79+
params.K = K;
80+
81+
const uint32_t wg_size =
82+
utils::clamp_workgroup_size(device, kLinearDwWorkgroupSizeX);
83+
const uint64_t tiles =
84+
utils::div_up<uint64_t>(N, 4u) * utils::div_up<uint64_t>(K, 4u);
85+
if (tiles > UINT32_MAX) {
86+
throw std::runtime_error("linear_dW: tile count exceeds u32");
87+
}
88+
const uint32_t workgroup_count = utils::compute_1d_workgroup_count(
89+
device, static_cast<uint32_t>(tiles), wg_size, "linear_dW");
90+
91+
WGPUBuffer uniform_buffer =
92+
utils::make_uniform(device, &params, sizeof(params));
93+
graph.add_uniform_buffer_bytes(sizeof(params));
94+
95+
WGPUShaderSourceWGSL wgsl_desc = {};
96+
wgsl_desc.chain.sType = WGPUSType_ShaderSourceWGSL;
97+
wgsl_desc.code = {kLinearDwWGSL, WGPU_STRLEN};
98+
WGPUShaderModuleDescriptor shader_desc = {};
99+
shader_desc.nextInChain = &wgsl_desc.chain;
100+
WGPUShaderModule shader = wgpuDeviceCreateShaderModule(device, &shader_desc);
101+
102+
WGPUBindGroupLayoutEntry entries[4] = {};
103+
entries[0].binding = 0;
104+
entries[0].visibility = WGPUShaderStage_Compute;
105+
entries[0].buffer.type = WGPUBufferBindingType_Storage;
106+
for (uint32_t i = 1; i <= 2; i++) {
107+
entries[i].binding = i;
108+
entries[i].visibility = WGPUShaderStage_Compute;
109+
entries[i].buffer.type = WGPUBufferBindingType_ReadOnlyStorage;
110+
}
111+
entries[3].binding = 3;
112+
entries[3].visibility = WGPUShaderStage_Compute;
113+
entries[3].buffer.type = WGPUBufferBindingType_Uniform;
114+
115+
WGPUBindGroupLayoutDescriptor bgl_desc = {};
116+
bgl_desc.entryCount = 4;
117+
bgl_desc.entries = entries;
118+
WGPUBindGroupLayout bgl = wgpuDeviceCreateBindGroupLayout(device, &bgl_desc);
119+
120+
WGPUPipelineLayoutDescriptor pl_desc = {};
121+
pl_desc.bindGroupLayoutCount = 1;
122+
pl_desc.bindGroupLayouts = &bgl;
123+
WGPUPipelineLayout pipeline_layout =
124+
wgpuDeviceCreatePipelineLayout(device, &pl_desc);
125+
126+
WGPUConstantEntry wg_size_constant = {};
127+
wg_size_constant.key = {"wg_size", WGPU_STRLEN};
128+
wg_size_constant.value = static_cast<double>(wg_size);
129+
130+
WGPUComputePipelineDescriptor pipeline_desc = {};
131+
pipeline_desc.layout = pipeline_layout;
132+
pipeline_desc.compute.module = shader;
133+
pipeline_desc.compute.entryPoint = {"main", WGPU_STRLEN};
134+
pipeline_desc.compute.constantCount = 1;
135+
pipeline_desc.compute.constants = &wg_size_constant;
136+
WGPUComputePipeline pipeline =
137+
wgpuDeviceCreateComputePipeline(device, &pipeline_desc);
138+
139+
WGPUBindGroupEntry bg_entries[4] = {};
140+
bg_entries[0].binding = 0;
141+
bg_entries[0].buffer = dw.buffer;
142+
bg_entries[0].size = dw.nbytes;
143+
bg_entries[1].binding = 1;
144+
bg_entries[1].buffer = dout.buffer;
145+
bg_entries[1].size = dout.nbytes;
146+
bg_entries[2].binding = 2;
147+
bg_entries[2].buffer = x.buffer;
148+
bg_entries[2].size = x.nbytes;
149+
bg_entries[3].binding = 3;
150+
bg_entries[3].buffer = uniform_buffer;
151+
bg_entries[3].size = sizeof(params);
152+
153+
WGPUBindGroupDescriptor bg_desc = {};
154+
bg_desc.layout = bgl;
155+
bg_desc.entryCount = 4;
156+
bg_desc.entries = bg_entries;
157+
WGPUBindGroup bind_group = wgpuDeviceCreateBindGroup(device, &bg_desc);
158+
159+
graph.add_dispatch({pipeline, bind_group, workgroup_count, "linear_dW"});
160+
161+
wgpuShaderModuleRelease(shader);
162+
wgpuBindGroupLayoutRelease(bgl);
163+
wgpuPipelineLayoutRelease(pipeline_layout);
164+
graph.own_uniform_buffer(uniform_buffer);
165+
}
166+
167+
} // namespace
168+
169+
WEBGPU_REGISTER_OPERATORS {
170+
WEBGPU_REGISTER_OP(et_vk.linear_dW.default, linear_dW_impl);
171+
}
172+
173+
} // namespace executorch::backends::webgpu

0 commit comments

Comments
 (0)