Skip to content

Commit d79e7a0

Browse files
authored
[ExecuTorch][WebGPU] et_vk.embedding_q4gsw test suite (export + native golden)
Differential Revision: D108668383 Pull Request resolved: #20289
1 parent ac7a5ab commit d79e7a0

4 files changed

Lines changed: 324 additions & 1 deletion

File tree

backends/webgpu/scripts/test_webgpu_native_ci.sh

Lines changed: 18 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -45,12 +45,24 @@ DISPATCH_ORDER_DIR="/tmp/dispatch_order"
4545
DISPATCH_ORDER_OK=1
4646
UPDATE_CACHE_DIR="/tmp/update_cache"
4747
UPDATE_CACHE_OK=1
48+
EMBEDDING_MODEL="/tmp/webgpu_embedding_q4gsw.pte"
49+
EMBEDDING_INDICES="/tmp/webgpu_embedding_q4gsw_indices.bin"
50+
EMBEDDING_GOLDEN="/tmp/webgpu_embedding_q4gsw_golden.bin"
51+
EMBEDDING_LLAMA1B_MODEL="/tmp/webgpu_embedding_q4gsw_llama1b.pte"
52+
EMBEDDING_LLAMA1B_INDICES="/tmp/webgpu_embedding_q4gsw_llama1b_indices.bin"
53+
EMBEDDING_LLAMA1B_GOLDEN="/tmp/webgpu_embedding_q4gsw_llama1b_golden.bin"
4854

4955
$PYTHON_EXECUTABLE -c "
5056
from executorch.backends.webgpu.test.ops.quantized_linear.test_quantized_linear import export_all_quantized_linear_models
5157
export_all_quantized_linear_models('/tmp')
5258
" || echo "WARN: q4gsw export failed; required configs will FAIL in webgpu_native_test"
5359

60+
$PYTHON_EXECUTABLE -c "
61+
from executorch.backends.webgpu.test.ops.embedding_q4gsw.test_embedding_q4gsw import export_embedding_q4gsw_model
62+
export_embedding_q4gsw_model('${EMBEDDING_MODEL}', '${EMBEDDING_GOLDEN}', '${EMBEDDING_INDICES}')
63+
export_embedding_q4gsw_model('${EMBEDDING_LLAMA1B_MODEL}', '${EMBEDDING_LLAMA1B_GOLDEN}', '${EMBEDDING_LLAMA1B_INDICES}', 'llama1b')
64+
" || echo "WARN: embedding_q4gsw export failed; embedding configs will FAIL in webgpu_native_test"
65+
5466
$PYTHON_EXECUTABLE -c "
5567
from executorch.backends.webgpu.test.ops.dispatch_order.test_dispatch_order import export_dispatch_order_cases
5668
export_dispatch_order_cases('${DISPATCH_ORDER_DIR}')
@@ -136,6 +148,12 @@ if [[ -x "${BIN_DIR}/webgpu_native_test" ]] &&
136148
"${PYTHON_EXECUTABLE}" -c "import executorch" 2>/dev/null; then
137149
env WEBGPU_TEST_SDPA_DIR=/tmp/ \
138150
WEBGPU_TEST_QUANTIZED_LINEAR_DIR=/tmp/ \
151+
WEBGPU_TEST_EMBEDDING_Q4GSW_MODEL="${EMBEDDING_MODEL}" \
152+
WEBGPU_TEST_EMBEDDING_Q4GSW_INDICES="${EMBEDDING_INDICES}" \
153+
WEBGPU_TEST_EMBEDDING_Q4GSW_GOLDEN="${EMBEDDING_GOLDEN}" \
154+
WEBGPU_TEST_EMBEDDING_Q4GSW_LLAMA1B_MODEL="${EMBEDDING_LLAMA1B_MODEL}" \
155+
WEBGPU_TEST_EMBEDDING_Q4GSW_LLAMA1B_INDICES="${EMBEDDING_LLAMA1B_INDICES}" \
156+
WEBGPU_TEST_EMBEDDING_Q4GSW_LLAMA1B_GOLDEN="${EMBEDDING_LLAMA1B_GOLDEN}" \
139157
"${BIN_DIR}/webgpu_native_test"
140158
else
141159
echo "(skipping webgpu_native_test: executorch wheel absent — exports did not run)"
Lines changed: 5 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,5 @@
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.
Lines changed: 161 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,161 @@
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+
"""4-bit groupwise-symmetric quantized embedding (`et_vk.embedding_q4gsw`) export
8+
+ golden for the WebGPU backend.
9+
10+
Quantizes an nn.Embedding with the Llama EmbeddingQuantHandler recipe (int4
11+
groupwise-symmetric, packed) which lowers to `quantized_decomposed.embedding_4bit`
12+
and fuses under VulkanPartitioner into `et_vk.embedding_q4gsw.default`
13+
(is_linear_weight=False). Writes a torch-computed golden (the native binary has no
14+
ATen) via the registered et_vk reference op + the raw int32 indices the native
15+
test loads and compares.
16+
17+
Two shapes are exercised: a tiny one and a Llama-3.2-1B-scale one (EMBED=2048,
18+
GROUP=64) so the per-group scale indexing (32 groups/row) + dequant are validated
19+
at the real embedding dim, not just a single 64-wide row.
20+
"""
21+
22+
import unittest
23+
from collections import namedtuple
24+
25+
import executorch.backends.vulkan.custom_ops_lib # noqa: F401
26+
27+
import torch
28+
from executorch.backends.vulkan import VulkanPartitioner
29+
from executorch.examples.models.llama.source_transformation.quantize import (
30+
EmbeddingQuantHandler,
31+
)
32+
from executorch.exir import to_edge_transform_and_lower
33+
34+
# vocab rows, embed columns (embed % 32 == 0), group-wise scales, gather indices.
35+
Shape = namedtuple("Shape", ["name", "vocab", "embed", "group", "indices"])
36+
SHAPES = [
37+
Shape("small", 64, 64, 32, [1, 5, 63, 0]),
38+
# Llama-3.2-1B embedding dim + group (small vocab keeps the export light).
39+
Shape("llama1b", 512, 2048, 64, [1, 5, 511, 0]),
40+
]
41+
42+
43+
class _EmbeddingModel(torch.nn.Module):
44+
def __init__(self, vocab: int, embed: int) -> None:
45+
super().__init__()
46+
self.emb = torch.nn.Embedding(vocab, embed)
47+
48+
def forward(self, idx: torch.Tensor) -> torch.Tensor:
49+
return self.emb(idx)
50+
51+
52+
def _make_quantized_model(shape: Shape) -> torch.nn.Module:
53+
torch.manual_seed(0)
54+
return (
55+
EmbeddingQuantHandler(
56+
_EmbeddingModel(shape.vocab, shape.embed).eval(),
57+
device="cpu",
58+
bitwidth=4,
59+
group_size=shape.group,
60+
packed=True,
61+
quantize_with_hqq=False,
62+
)
63+
.quantized_model()
64+
.eval()
65+
)
66+
67+
68+
def _indices(shape: Shape) -> torch.Tensor:
69+
return torch.tensor(shape.indices, dtype=torch.long)
70+
71+
72+
def _quant_params(qm: torch.nn.Module) -> tuple[torch.Tensor, torch.Tensor, int]:
73+
sd = qm.state_dict()
74+
weight = next(
75+
v for k, v in sd.items() if k.endswith("weight") and v.dtype == torch.uint8
76+
)
77+
scales = next(v for k, v in sd.items() if k.endswith("scales"))
78+
if scales.ndim == 1:
79+
scales = scales.unsqueeze(1)
80+
embed = weight.shape[1] * 2
81+
group_size = embed // scales.shape[1]
82+
return weight, scales, group_size
83+
84+
85+
def _golden(qm: torch.nn.Module, idx: torch.Tensor) -> torch.Tensor:
86+
# Reference = the registered et_vk dequant+gather op (non-linear branch).
87+
weight, scales, group_size = _quant_params(qm)
88+
return torch.ops.et_vk.embedding_q4gsw.default(
89+
weight, scales, group_size, idx, False
90+
)
91+
92+
93+
def _export(qm: torch.nn.Module, idx: torch.Tensor):
94+
ep = torch.export.export(qm, (idx,))
95+
return to_edge_transform_and_lower(
96+
ep, partitioner=[VulkanPartitioner()]
97+
).to_executorch()
98+
99+
100+
class TestEmbeddingQ4gsw(unittest.TestCase):
101+
def test_export_delegates(self) -> None:
102+
for shape in SHAPES:
103+
with self.subTest(shape=shape.name):
104+
et = _export(_make_quantized_model(shape), _indices(shape))
105+
found = any(
106+
d.id == "VulkanBackend"
107+
for plan in et.executorch_program.execution_plan
108+
for d in plan.delegates
109+
)
110+
self.assertTrue(
111+
found, "Expected a VulkanBackend delegate (embedding_q4gsw fusion)"
112+
)
113+
114+
def test_golden_matches_eager(self) -> None:
115+
# The torch golden (et_vk reference) must equal torch dequant+gather, so a
116+
# buggy golden can't fake-pass the native kernel. Run at both shapes so the
117+
# Llama-scale per-group scale indexing (32 groups/row) is covered.
118+
for shape in SHAPES:
119+
with self.subTest(shape=shape.name):
120+
qm = _make_quantized_model(shape)
121+
idx = _indices(shape)
122+
weight, scales, group_size = _quant_params(qm)
123+
vocab = weight.shape[0]
124+
embed = weight.shape[1] * 2
125+
# fp64 reference dequant, vectorized (no fp32 rounding in oracle).
126+
w = weight.to(torch.int64)
127+
nib = torch.empty((vocab, embed), dtype=torch.int64)
128+
nib[:, 0::2] = (w >> 4) & 0xF # even dim -> high nibble
129+
nib[:, 1::2] = w & 0xF # odd dim -> low nibble
130+
scale_exp = scales.to(torch.float64).repeat_interleave(
131+
group_size, dim=1
132+
)
133+
deq = (nib - 8).to(torch.float64) * scale_exp
134+
eager = torch.nn.functional.embedding(idx, deq)
135+
golden = _golden(qm, idx)
136+
torch.testing.assert_close(golden.double(), eager, atol=1e-5, rtol=1e-5)
137+
138+
139+
def export_embedding_q4gsw_model(
140+
pte_path: str, golden_path: str, indices_path: str, shape_name: str = "small"
141+
) -> None:
142+
"""Write the embedding_q4gsw .pte + torch golden (raw LE fp32) + raw LE int32
143+
indices (downcast from int64 for the int32-typed GPU buffer). `shape_name`
144+
selects an entry from SHAPES (default the tiny shape; "llama1b" = EMBED=2048)."""
145+
shape = next(s for s in SHAPES if s.name == shape_name)
146+
qm = _make_quantized_model(shape)
147+
idx = _indices(shape)
148+
golden = _golden(qm, idx).detach().numpy().astype("<f4")
149+
et = _export(qm, idx)
150+
with open(pte_path, "wb") as f:
151+
f.write(et.buffer)
152+
golden.tofile(golden_path)
153+
idx.to(torch.int32).numpy().astype("<i4").tofile(indices_path)
154+
print(
155+
f"Exported {pte_path} (shape={shape_name}); golden {golden_path} "
156+
f"({golden.size} floats); indices {indices_path} ({idx.numel()} int32)"
157+
)
158+
159+
160+
if __name__ == "__main__":
161+
unittest.main()

backends/webgpu/test/test_webgpu_native.cpp

Lines changed: 140 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -295,7 +295,112 @@ static float q4gsw_ramp(int i) {
295295
return static_cast<float>((i % 17) - 8) / 16.0f;
296296
}
297297

298-
// Per-element dual tolerance (abs OR rel), parameterized like sdpa_within_tol.
298+
// Fwd decl of the per-element abs-OR-rel tolerance helper (defined below).
299+
static bool quant_within_tol(
300+
const float* out,
301+
const float* golden,
302+
int n,
303+
float atol,
304+
float rtol,
305+
float* ma,
306+
float* mr);
307+
308+
static std::vector<int32_t> load_indices(
309+
const std::string& path,
310+
size_t numel) {
311+
// Load raw little-endian int32 indices written by the export .py.
312+
std::vector<int32_t> g(numel);
313+
FILE* f = std::fopen(path.c_str(), "rb");
314+
if (!f) {
315+
return {};
316+
}
317+
size_t n = std::fread(g.data(), sizeof(int32_t), numel, f);
318+
std::fclose(f);
319+
if (n != numel) {
320+
return {};
321+
}
322+
return g;
323+
}
324+
325+
static bool test_embedding_q4gsw(
326+
const std::string& model_path,
327+
const std::string& indices_path,
328+
const std::string& golden_path,
329+
int num_indices,
330+
int embed,
331+
const char* label) {
332+
// q4gsw embedding-gather vs torch golden; shapes per test_embedding_q4gsw.py.
333+
const int out_numel = num_indices * embed;
334+
printf(
335+
"\n--- Test: embedding_q4gsw (%s: indices=%d, embed=%d) ---\n",
336+
label,
337+
num_indices,
338+
embed);
339+
340+
Module module(model_path);
341+
auto err = module.load_forward();
342+
if (err != Error::Ok) {
343+
printf("FAIL: could not load forward method (error %d)\n", (int)err);
344+
return false;
345+
}
346+
printf("Model loaded: %s\n", model_path.c_str());
347+
348+
std::vector<int32_t> idx32 = load_indices(indices_path, num_indices);
349+
std::vector<float> golden = load_golden(golden_path, out_numel);
350+
if (idx32.empty() || golden.empty()) {
351+
printf(
352+
"FAIL: could not load indices %s / golden %s\n",
353+
indices_path.c_str(),
354+
golden_path.c_str());
355+
return false;
356+
}
357+
358+
// int64 at the program boundary; copy_inputs narrows to the int32 buffer.
359+
std::vector<int64_t> idx64(idx32.begin(), idx32.end());
360+
auto idx = make_tensor_ptr({num_indices}, std::move(idx64));
361+
362+
auto result = module.forward({EValue(idx)});
363+
if (!result.ok()) {
364+
printf("FAIL: forward failed (error %d)\n", (int)result.error());
365+
return false;
366+
}
367+
const auto& outputs = result.get();
368+
if (outputs.empty() || !outputs[0].isTensor()) {
369+
printf("FAIL: no tensor output\n");
370+
return false;
371+
}
372+
const auto& out_tensor = outputs[0].toTensor();
373+
if (out_tensor.numel() != out_numel) {
374+
printf(
375+
"FAIL: output numel %zu != expected %d\n",
376+
(size_t)out_tensor.numel(),
377+
out_numel);
378+
return false;
379+
}
380+
const float* out_data = out_tensor.const_data_ptr<float>();
381+
382+
float max_abs_err = 0.0f, max_rel_err = 0.0f;
383+
const bool pass = quant_within_tol(
384+
out_data,
385+
golden.data(),
386+
out_numel,
387+
1e-3f,
388+
1e-3f,
389+
&max_abs_err,
390+
&max_rel_err);
391+
printf(
392+
"Max abs error: %e Max rel error: %e (checked %d elements)\n",
393+
max_abs_err,
394+
max_rel_err,
395+
out_numel);
396+
if (!pass) {
397+
printf("FAIL: embedding_q4gsw exceeds tolerance 1e-3 (abs AND rel)\n");
398+
return false;
399+
}
400+
printf("PASS: embedding_q4gsw test\n");
401+
return true;
402+
}
403+
299404
static bool quant_within_tol(
300405
const float* out,
301406
const float* golden,
@@ -1342,6 +1447,31 @@ int main(int argc, char** argv) {
13421447
}
13431448
}
13441449

1450+
// embedding_q4gsw on-GPU configs: small + llama1b (env-gated,
1451+
// run-if-present).
1452+
struct EmbConfig {
1453+
const char* name;
1454+
const char* model_env;
1455+
const char* indices_env;
1456+
const char* golden_env;
1457+
int num_indices;
1458+
int embed;
1459+
};
1460+
const EmbConfig emb_configs[] = {
1461+
{"small",
1462+
"WEBGPU_TEST_EMBEDDING_Q4GSW_MODEL",
1463+
"WEBGPU_TEST_EMBEDDING_Q4GSW_INDICES",
1464+
"WEBGPU_TEST_EMBEDDING_Q4GSW_GOLDEN",
1465+
4,
1466+
64},
1467+
{"llama1b",
1468+
"WEBGPU_TEST_EMBEDDING_Q4GSW_LLAMA1B_MODEL",
1469+
"WEBGPU_TEST_EMBEDDING_Q4GSW_LLAMA1B_INDICES",
1470+
"WEBGPU_TEST_EMBEDDING_Q4GSW_LLAMA1B_GOLDEN",
1471+
4,
1472+
2048},
1473+
};
1474+
13451475
// SDPA sweep: configs self-discover their sdpa_<name>.pte/.golden.bin under
13461476
// this directory (default "" = the embedded-file root / cwd). Set
13471477
// WEBGPU_TEST_SDPA_DIR to point at the exported .pte directory (e.g. /tmp/).
@@ -1389,6 +1519,15 @@ int main(int argc, char** argv) {
13891519
ok = false;
13901520
}
13911521

1522+
for (const auto& c : emb_configs) {
1523+
const char* m = std::getenv(c.model_env);
1524+
const char* ip = std::getenv(c.indices_env);
1525+
const char* g = std::getenv(c.golden_env);
1526+
if (m && ip && g && *m && *ip && *g) {
1527+
ok = test_embedding_q4gsw(m, ip, g, c.num_indices, c.embed, c.name) && ok;
1528+
}
1529+
}
1530+
13921531
bool sdpa_ran = false;
13931532
bool sdpa_ok = test_sdpa_sweep(sdpa_dir, &sdpa_ran);
13941533
if (sdpa_ran) {

0 commit comments

Comments
 (0)