Skip to content

Commit 0545abb

Browse files
committed
[ExecuTorch][WebGPU] Tests for the q4gsw_train WebGPU ops
Pull Request resolved: #20932 Export-delegation + fp64 golden tests for the q4gsw training (`backward`, `dw`, `requant`) ops. Key changes: - `test/ops/{test_quantized_linear_backward,test_linear_q4gsw_dw,test_q4gsw_requant}.py` — fp64 dequant-matmul / weight-grad / re-quant goldens + a round-trip + delegation checks — fp64 library-reference goldens + delegation checks (≥2 cases/op). Co-authored-with: Claude Code. ghstack-source-id: 405026079 @exported-using-ghexport Differential Revision: [D111755121](https://our.internmc.facebook.com/intern/diff/D111755121/)
1 parent ac24fab commit 0545abb

3 files changed

Lines changed: 368 additions & 0 deletions

File tree

Lines changed: 88 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,88 @@
1+
# Copyright (c) Meta Platforms, Inc. and affiliates.
2+
# All rights reserved.
3+
#
4+
# This source code is licensed under the BSD-style license found in the
5+
# LICENSE file in the root directory of this source tree.
6+
7+
"""`et_vk.linear_dW` (STE weight gradient) export + fp64 golden.
8+
9+
The weight gradient of a frozen 4-bit linear: `d_W[N, K] = d_out^T @ x`, the grad
10+
wrt the dequantized weight (both operands are fp32; no int4 unpack). Reached by a
11+
direct op call in the on-device training graph. CONFIGS reuse Llama-3.2-1B linear
12+
shapes plus a non-tile-aligned shape that exercises the kernel's boundary clamp.
13+
"""
14+
15+
from __future__ import annotations
16+
17+
import unittest
18+
19+
import torch
20+
21+
from executorch.backends.vulkan.partitioner.vulkan_partitioner import VulkanPartitioner
22+
from executorch.exir import to_edge_transform_and_lower
23+
24+
# name -> (m tokens, k in_features, n out_features).
25+
CONFIGS = {
26+
"q_proj_112": (112, 2048, 2048),
27+
"kv_proj_112": (112, 2048, 512),
28+
"boundary": (13, 18, 10), # non-multiple-of-4: exercises the min()-clamp
29+
}
30+
31+
32+
class LinearDwModule(torch.nn.Module):
33+
def forward(self, d_out: torch.Tensor, x: torch.Tensor) -> torch.Tensor:
34+
return torch.ops.et_vk.linear_dW(d_out, x)
35+
36+
37+
def _det_inputs(m: int, k: int, n: int):
38+
"""Deterministic fp32 d_out [m, n] + x [m, k] (fixed seed)."""
39+
g = torch.Generator().manual_seed(0)
40+
d_out = torch.randn(m, n, generator=g, dtype=torch.float32)
41+
x = torch.randn(m, k, generator=g, dtype=torch.float32)
42+
return d_out, x
43+
44+
45+
def _fp64_golden(d_out: torch.Tensor, x: torch.Tensor) -> torch.Tensor:
46+
"""fp64 truth: d_W = d_out^T @ x, [N, M] @ [M, K] = [N, K]."""
47+
return (d_out.double().t() @ x.double()).to(torch.float32)
48+
49+
50+
def _export(d_out: torch.Tensor, x: torch.Tensor):
51+
ep = torch.export.export(LinearDwModule().eval(), (d_out, x))
52+
return to_edge_transform_and_lower(
53+
ep, partitioner=[VulkanPartitioner()]
54+
).to_executorch()
55+
56+
57+
def _delegated(et) -> bool:
58+
return any(
59+
d.id == "VulkanBackend"
60+
for plan in et.executorch_program.execution_plan
61+
for d in plan.delegates
62+
)
63+
64+
65+
class TestLinearDw(unittest.TestCase):
66+
def test_export_delegates(self) -> None:
67+
for name, (m, k, n) in CONFIGS.items():
68+
with self.subTest(config=name):
69+
d_out, x = _det_inputs(m, k, n)
70+
et = _export(d_out, x)
71+
self.assertTrue(
72+
_delegated(et),
73+
f"Expected a VulkanBackend delegate (linear_dW {name})",
74+
)
75+
76+
def test_op_matches_fp64_golden(self) -> None:
77+
# Op (d_out^T @ x) vs fp64 matmul truth: guards the formula.
78+
for name, (m, k, n) in CONFIGS.items():
79+
with self.subTest(config=name):
80+
d_out, x = _det_inputs(m, k, n)
81+
got = torch.ops.et_vk.linear_dW(d_out, x)
82+
golden = _fp64_golden(d_out, x)
83+
self.assertEqual(tuple(got.shape), (n, k))
84+
torch.testing.assert_close(got, golden, atol=5e-4, rtol=1e-3)
85+
86+
87+
if __name__ == "__main__":
88+
unittest.main()
Lines changed: 123 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,123 @@
1+
# Copyright (c) Meta Platforms, Inc. and affiliates.
2+
# All rights reserved.
3+
#
4+
# This source code is licensed under the BSD-style license found in the
5+
# LICENSE file in the root directory of this source tree.
6+
7+
"""`et_vk.q4gsw_requant` (STE re-quant + int4 pack) export + fp32 code golden.
8+
9+
Writes updated fp32 latent weights back to the 4-bit group-symmetric packed codes
10+
`et_vk.linear_q4gsw` reads (only the codes move; the per-group scale is frozen).
11+
Reached by a direct op call in the on-device training graph after the optimizer
12+
step. The golden is computed in fp32 (not fp64) on purpose: the kernel rounds
13+
`round(latent / scale)` in IEEE-754 fp32, so a bit-exact contract must round in
14+
fp32 too -- an fp64 reference would flip codes at half-way ties. CONFIGS reuse
15+
Llama-3.2-1B linear shapes plus an odd-K shape that exercises the final-nibble
16+
tail guard.
17+
"""
18+
19+
from __future__ import annotations
20+
21+
import unittest
22+
23+
import torch
24+
25+
from executorch.backends.vulkan.partitioner.vulkan_partitioner import VulkanPartitioner
26+
from executorch.exir import to_edge_transform_and_lower
27+
28+
# name -> (n out_features, k in_features, group_size).
29+
CONFIGS = {
30+
"kv_proj": (512, 2048, 64),
31+
"q_proj_g32": (2048, 2048, 32),
32+
"small_odd_k": (6, 129, 64), # odd K -> trailing low-nibble-only byte
33+
}
34+
35+
36+
class Q4gswRequantModule(torch.nn.Module):
37+
def __init__(self, group_size: int) -> None:
38+
super().__init__()
39+
self.group_size = group_size
40+
41+
def forward(self, latent: torch.Tensor, scales: torch.Tensor) -> torch.Tensor:
42+
return torch.ops.et_vk.q4gsw_requant(latent, scales, self.group_size)
43+
44+
45+
def _det_inputs(n: int, k: int, gs: int):
46+
"""Deterministic fp32 latent [N, K] + frozen scales [num_groups, N] (fixed seed).
47+
48+
Scales are small relative to the latent so `latent / scale` spans well beyond
49+
[-8, 7], exercising the clamp on both ends.
50+
"""
51+
num_groups = (k + gs - 1) // gs
52+
g = torch.Generator().manual_seed(0)
53+
latent = torch.randn(n, k, generator=g, dtype=torch.float32)
54+
scales = torch.rand(num_groups, n, generator=g, dtype=torch.float32) * 0.1 + 0.05
55+
return latent, scales
56+
57+
58+
def _reference_codes(
59+
latent: torch.Tensor, scales: torch.Tensor, gs: int
60+
) -> torch.Tensor:
61+
"""fp32 truth for the int4 codes: clamp(round(latent / scale), -8, 7), [N, K]."""
62+
n, k = latent.shape
63+
group_idx = torch.arange(k) // gs # [K]
64+
scale_full = scales.t()[:, group_idx] # [N, K]: scales[k // gs, n]
65+
q = torch.round(latent / scale_full)
66+
return torch.clamp(q, -8, 7).to(torch.int64)
67+
68+
69+
def _unpack(packed: torch.Tensor, k: int) -> torch.Tensor:
70+
"""Undo the nibble packing: even k -> low nibble, odd k -> high nibble, code - 8."""
71+
n = packed.shape[0]
72+
p = packed.to(torch.int64)
73+
low = p & 0xF
74+
high = (p >> 4) & 0xF
75+
codes = torch.zeros(n, k, dtype=torch.int64)
76+
n_low = codes[:, 0::2].shape[1]
77+
n_high = codes[:, 1::2].shape[1]
78+
codes[:, 0::2] = low[:, :n_low] - 8
79+
codes[:, 1::2] = high[:, :n_high] - 8
80+
return codes
81+
82+
83+
def _export(latent: torch.Tensor, scales: torch.Tensor, gs: int):
84+
ep = torch.export.export(Q4gswRequantModule(gs).eval(), (latent, scales))
85+
return to_edge_transform_and_lower(
86+
ep, partitioner=[VulkanPartitioner()]
87+
).to_executorch()
88+
89+
90+
def _delegated(et) -> bool:
91+
return any(
92+
d.id == "VulkanBackend"
93+
for plan in et.executorch_program.execution_plan
94+
for d in plan.delegates
95+
)
96+
97+
98+
class TestQ4gswRequant(unittest.TestCase):
99+
def test_export_delegates(self) -> None:
100+
for name, (n, k, gs) in CONFIGS.items():
101+
with self.subTest(config=name):
102+
latent, scales = _det_inputs(n, k, gs)
103+
et = _export(latent, scales, gs)
104+
self.assertTrue(
105+
_delegated(et),
106+
f"Expected a VulkanBackend delegate (q4gsw_requant {name})",
107+
)
108+
109+
def test_op_matches_fp32_golden(self) -> None:
110+
# Op codes vs fp32 quant truth: guards formula+layout, bit-exact.
111+
for name, (n, k, gs) in CONFIGS.items():
112+
with self.subTest(config=name):
113+
latent, scales = _det_inputs(n, k, gs)
114+
packed = torch.ops.et_vk.q4gsw_requant(latent, scales, gs)
115+
self.assertEqual(packed.dtype, torch.uint8)
116+
self.assertEqual(tuple(packed.shape), (n, (k + 1) // 2))
117+
got = _unpack(packed, k)
118+
golden = _reference_codes(latent, scales, gs)
119+
torch.testing.assert_close(got, golden, atol=0, rtol=0)
120+
121+
122+
if __name__ == "__main__":
123+
unittest.main()
Lines changed: 157 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,157 @@
1+
# Copyright (c) Meta Platforms, Inc. and affiliates.
2+
# All rights reserved.
3+
#
4+
# This source code is licensed under the BSD-style license found in the
5+
# LICENSE file in the root directory of this source tree.
6+
7+
"""Backward of 4-bit quantized linear (`et_vk.linear_q4gsw_backward`) export + fp64 golden.
8+
9+
Mirrors test_quantized_linear.py. The backward computes `d_x = d_out @ dequant(W)` so
10+
gradients flow through a frozen 4-bit base into a LoRA/DiReFT adapter. CONFIGS reuse the
11+
real Llama-3.2-1B linear shapes (the backward's d_out is [M, N] and its output d_x is
12+
[M, K]). The golden is the fp64 dequant-matmul truth; the native test
13+
(test_webgpu_native.cpp) reconstructs the identical deterministic ramp bit-for-bit.
14+
"""
15+
16+
import os
17+
import unittest
18+
from dataclasses import dataclass
19+
20+
import numpy as np
21+
import torch
22+
23+
from executorch.backends.vulkan import VulkanPartitioner
24+
from executorch.exir import to_edge_transform_and_lower
25+
from torchao.quantization.granularity import PerGroup
26+
from torchao.quantization.quant_api import IntxWeightOnlyConfig, quantize_
27+
28+
29+
@dataclass(frozen=True)
30+
class BwdConfig:
31+
name: str
32+
m: int # rows (tokens)
33+
k: int # in_features (== d_x cols)
34+
n: int # out_features (== d_out cols)
35+
group_size: int = 32 # K % group_size == 0, K % 8 == 0, N % 8 == 0
36+
heavy: bool = False
37+
38+
39+
# Mirrored by the C++ kQ4gswBackwardConfigs table (Llama-3.2-1B shapes).
40+
CONFIGS = [
41+
BwdConfig("q_proj", 1, 2048, 2048), # also o_proj
42+
BwdConfig("kv_proj", 1, 2048, 512),
43+
BwdConfig("gate_proj", 1, 2048, 8192),
44+
BwdConfig("down_proj", 1, 8192, 2048),
45+
BwdConfig("q_proj_112", 112, 2048, 2048), # S=112 multi-row training window
46+
]
47+
48+
49+
def _make_quantized_model(k: int, n: int, group_size: int) -> torch.nn.Module:
50+
torch.manual_seed(0) # load-bearing: fixes the weights the golden uses
51+
m = torch.nn.Linear(k, n, bias=False).eval()
52+
quantize_(
53+
m,
54+
IntxWeightOnlyConfig(weight_dtype=torch.int4, granularity=PerGroup(group_size)),
55+
)
56+
return m
57+
58+
59+
def _ramp(m_rows: int, cols: int) -> torch.Tensor:
60+
"""Deterministic fp32 [rows, cols]; the C++ side reconstructs it bit-for-bit.
61+
62+
v[flat] = ((flat % 17) - 8) / 16 -- exact in fp32 (small modulus, po2 denominator).
63+
"""
64+
flat = np.arange(m_rows * cols, dtype=np.int64)
65+
v = ((flat % 17) - 8).astype(np.float32) / np.float32(16.0)
66+
return torch.from_numpy(v).reshape(m_rows, cols)
67+
68+
69+
def _packed_qweights(m: torch.nn.Module):
70+
"""The int4 packed weights + per-group scales `et_vk.linear_q4gsw` consumes.
71+
72+
Recover them the same way the forward op does: `dequant(W)` is [N, K]; the backward op
73+
takes the same (weights, weight_scales, group_size) triple the partitioner extracts.
74+
"""
75+
aqt = m.weight # AffineQuantizedTensor
76+
return aqt
77+
78+
79+
def _fp64_golden(m: torch.nn.Module, d_out: torch.Tensor) -> np.ndarray:
80+
"""fp64 truth: d_x = d_out @ dequant(W), dequant(W) is [N, K] -> [M, N]@[N, K] = [M, K]."""
81+
wq = m.weight.dequantize() # [N, K]
82+
d_x = d_out.double() @ wq.double() # [M, K] in fp64
83+
return d_x.to(torch.float32).numpy().astype("<f4")
84+
85+
86+
class _BackwardModule(torch.nn.Module):
87+
"""Wraps the linear so autograd through it exercises the registered backward op."""
88+
89+
def __init__(self, lin: torch.nn.Module) -> None:
90+
super().__init__()
91+
self.lin = lin
92+
93+
def forward(self, x: torch.Tensor) -> torch.Tensor:
94+
return self.lin(x)
95+
96+
97+
def _export_backward(m: torch.nn.Module, x: torch.Tensor):
98+
# Training-style export: forward + backward makes the backward reachable.
99+
mod = _BackwardModule(m)
100+
ep = torch.export.export(mod, (x,))
101+
return to_edge_transform_and_lower(
102+
ep, partitioner=[VulkanPartitioner()]
103+
).to_executorch()
104+
105+
106+
class TestQuantizedLinearBackward(unittest.TestCase):
107+
def test_op_matches_fp64_golden(self) -> None:
108+
# Op impl (d_out @ dequant(W)) vs fp64 truth: guards backward formula.
109+
for cfg in CONFIGS:
110+
if cfg.heavy:
111+
continue
112+
with self.subTest(config=cfg.name):
113+
m = _make_quantized_model(cfg.k, cfg.n, cfg.group_size)
114+
d_out = _ramp(cfg.m, cfg.n)
115+
got = torch.ops.et_vk.linear_q4gsw_backward(
116+
d_out, _packed_qweights(m), None, cfg.group_size
117+
)
118+
golden = torch.from_numpy(_fp64_golden(m, d_out))
119+
torch.testing.assert_close(got, golden, atol=5e-4, rtol=1e-3)
120+
121+
def test_autograd_backward_matches_golden(self) -> None:
122+
# autograd through linear_q4gsw uses the registered backward op.
123+
for cfg in CONFIGS:
124+
if cfg.heavy:
125+
continue
126+
with self.subTest(config=cfg.name):
127+
m = _make_quantized_model(cfg.k, cfg.n, cfg.group_size)
128+
x = _ramp(cfg.m, cfg.k).requires_grad_(True)
129+
d_out = _ramp(cfg.m, cfg.n)
130+
y = m(x)
131+
y.backward(d_out)
132+
golden = torch.from_numpy(_fp64_golden(m, d_out))
133+
torch.testing.assert_close(x.grad, golden, atol=5e-4, rtol=1e-3)
134+
135+
136+
def export_backward_model(cfg: BwdConfig, pte_path: str, golden_path: str) -> None:
137+
"""Export one config's backward .pte + its fp64 golden (raw LE fp32)."""
138+
m = _make_quantized_model(cfg.k, cfg.n, cfg.group_size)
139+
x = _ramp(cfg.m, cfg.k)
140+
et = _export_backward(m, x)
141+
with open(pte_path, "wb") as f:
142+
f.write(et.buffer)
143+
_fp64_golden(m, _ramp(cfg.m, cfg.n)).tofile(golden_path)
144+
print(f"Exported {pte_path}; golden {golden_path} ({cfg.m * cfg.k} floats)")
145+
146+
147+
def export_all_backward_models(out_dir: str, include_heavy: bool = False) -> None:
148+
for cfg in CONFIGS:
149+
if cfg.heavy and not include_heavy:
150+
continue
151+
pte = os.path.join(out_dir, f"q4gsw_backward_{cfg.name}.pte")
152+
golden = os.path.join(out_dir, f"q4gsw_backward_{cfg.name}.golden.bin")
153+
export_backward_model(cfg, pte, golden)
154+
155+
156+
if __name__ == "__main__":
157+
unittest.main()

0 commit comments

Comments
 (0)