Skip to content

Commit 3cc0ca1

Browse files
committed
[ExecuTorch][WebGPU] Tests for the gather WebGPU ops
Pull Request resolved: #20928 Export-delegation + fp64 golden tests for the gather (`gather`, `embedding`) ops. Key changes: - `test/ops/{test_gather,test_embedding}.py` — fp64 goldens + delegation checks — fp64 library-reference goldens + delegation checks (≥2 cases/op). Co-authored-with: Claude Code. ghstack-source-id: 405026067 @exported-using-ghexport Differential Revision: [D111755129](https://our.internmc.facebook.com/intern/diff/D111755129/)
1 parent 1a07c3f commit 3cc0ca1

2 files changed

Lines changed: 226 additions & 0 deletions

File tree

Lines changed: 103 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,103 @@
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+
"""`aten.embedding.default` (fp32 row-gather) export + golden for the WebGPU backend.
8+
9+
Exports single-op embedding graphs through VulkanPartitioner and checks a torch
10+
golden. embedding is a pure row-gather -- out[i, :] = weight[idx[i], :] -- on the
11+
token-embedding path that feeds the transformer (and the fine-tuning training
12+
window). 1D indices exercise the [S, D] output; 2D indices the batched [B, S, D]
13+
path. The indices span the full vocab (incl. the first/last rows) so a wrong
14+
row-stride would miss the golden.
15+
"""
16+
17+
from __future__ import annotations
18+
19+
import unittest
20+
21+
import torch
22+
23+
from executorch.backends.vulkan.partitioner.vulkan_partitioner import VulkanPartitioner
24+
from executorch.exir import to_edge_transform_and_lower
25+
26+
# name -> (num_embeddings, embedding_dim, indices_shape).
27+
CONFIGS = {
28+
"rows_1d": (32, 16, (8,)),
29+
"batched_2d": (128, 8, (2, 4)),
30+
}
31+
32+
33+
class EmbeddingModule(torch.nn.Module):
34+
def __init__(self, weight: torch.Tensor) -> None:
35+
super().__init__()
36+
self.weight = torch.nn.Parameter(weight, requires_grad=False)
37+
38+
def forward(self, idx: torch.Tensor) -> torch.Tensor:
39+
return torch.nn.functional.embedding(idx, self.weight)
40+
41+
42+
def _det_weight(num_embeddings: int, dim: int) -> torch.Tensor:
43+
"""Deterministic fp32 [num_embeddings, dim] table (distinct per-row values)."""
44+
return torch.linspace(-1.0, 1.0, num_embeddings * dim, dtype=torch.float32).reshape(
45+
num_embeddings, dim
46+
)
47+
48+
49+
def _det_indices(num_embeddings: int, shape: tuple[int, ...]) -> torch.Tensor:
50+
"""Deterministic int64 indices spread across the vocab, forced to hit row 0
51+
and the last row so an off-by-one row-stride shows up in the golden."""
52+
n = 1
53+
for s in shape:
54+
n *= s
55+
flat = (torch.arange(n, dtype=torch.int64) * 7 + 3) % num_embeddings
56+
flat[0] = 0
57+
flat[-1] = num_embeddings - 1
58+
return flat.reshape(shape)
59+
60+
61+
def _export(m: torch.nn.Module, idx: torch.Tensor):
62+
ep = torch.export.export(m, (idx,))
63+
return to_edge_transform_and_lower(
64+
ep, partitioner=[VulkanPartitioner()]
65+
).to_executorch()
66+
67+
68+
def _delegates(et) -> bool:
69+
return any(
70+
d.id == "VulkanBackend"
71+
for plan in et.executorch_program.execution_plan
72+
for d in plan.delegates
73+
)
74+
75+
76+
class TestEmbedding(unittest.TestCase):
77+
def test_export_delegates(self) -> None:
78+
# aten.embedding must be absorbed into the VulkanBackend delegate.
79+
for name, (num_embeddings, dim, shape) in CONFIGS.items():
80+
with self.subTest(name=name):
81+
weight = _det_weight(num_embeddings, dim)
82+
idx = _det_indices(num_embeddings, shape)
83+
et = _export(EmbeddingModule(weight).eval(), idx)
84+
self.assertTrue(
85+
_delegates(et),
86+
f"Expected a VulkanBackend delegate (embedding {name})",
87+
)
88+
89+
def test_golden_matches_eager(self) -> None:
90+
# fp64 gather golden: out[i,:] == weight[idx[i],:], bit-exact.
91+
for name, (num_embeddings, dim, shape) in CONFIGS.items():
92+
with self.subTest(name=name):
93+
weight = _det_weight(num_embeddings, dim)
94+
idx = _det_indices(num_embeddings, shape)
95+
got = EmbeddingModule(weight)(idx)
96+
golden = torch.nn.functional.embedding(idx, weight.double()).to(
97+
torch.float32
98+
)
99+
torch.testing.assert_close(got, golden)
100+
101+
102+
if __name__ == "__main__":
103+
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+
"""`aten.gather.default` export + fp64 golden for the WebGPU backend.
8+
9+
Exports single-op gather graphs through VulkanPartitioner and writes a torch-computed
10+
golden (the native binary has no ATen). gather(self, dim, index) copies self along
11+
`dim` at the positions named by index (out has index's shape). Configs cover the last
12+
dim, dim 0, and a rank-3 negative dim (exercises the handler's dim-normalization). The
13+
native test reconstructs the deterministic inputs bit-for-bit.
14+
"""
15+
16+
from __future__ import annotations
17+
18+
import os
19+
import unittest
20+
21+
import torch
22+
23+
from executorch.backends.vulkan.partitioner.vulkan_partitioner import VulkanPartitioner
24+
from executorch.exir import to_edge_transform_and_lower
25+
26+
# name -> (self_shape, dim, index_shape). Ranks stay <= 4 (TensorMeta MAX_NDIM).
27+
CONFIGS = {
28+
"cols": ((4, 8), 1, (4, 3)),
29+
"rows": ((5, 6), 0, (3, 6)),
30+
"rank3_neg": ((2, 3, 4), -1, (2, 3, 2)),
31+
}
32+
33+
34+
class GatherModule(torch.nn.Module):
35+
def __init__(self, dim: int) -> None:
36+
super().__init__()
37+
self.dim = dim
38+
39+
def forward(self, x: torch.Tensor, index: torch.Tensor) -> torch.Tensor:
40+
return torch.gather(x, self.dim, index)
41+
42+
43+
def _det_inputs(self_shape, dim: int, index_shape):
44+
"""Distinct fp32 source (a wrong pick is visible) + in-range int64 index."""
45+
n = 1
46+
for s in self_shape:
47+
n *= s
48+
x = torch.arange(n, dtype=torch.float32).reshape(self_shape)
49+
bound = self_shape[dim]
50+
m = 1
51+
for s in index_shape:
52+
m *= s
53+
index = (torch.arange(m, dtype=torch.int64) % bound).reshape(index_shape)
54+
return x, index
55+
56+
57+
def _lower(m: torch.nn.Module, x: torch.Tensor, index: torch.Tensor):
58+
ep = torch.export.export(m, (x, index))
59+
return to_edge_transform_and_lower(ep, partitioner=[VulkanPartitioner()])
60+
61+
62+
def _delegated(et) -> bool:
63+
return any(
64+
d.id == "VulkanBackend"
65+
for plan in et.executorch_program.execution_plan
66+
for d in plan.delegates
67+
)
68+
69+
70+
def _op_delegated(edge) -> bool:
71+
# gather must be absorbed into the delegate, not a top-level CPU node.
72+
gm = edge.exported_program().graph_module
73+
return all("gather" not in str(getattr(n, "target", "")) for n in gm.graph.nodes)
74+
75+
76+
class TestGather(unittest.TestCase):
77+
def test_export_delegates(self) -> None:
78+
for name, (self_shape, dim, index_shape) in CONFIGS.items():
79+
with self.subTest(name=name):
80+
x, index = _det_inputs(self_shape, dim, index_shape)
81+
edge = _lower(GatherModule(dim).eval(), x, index)
82+
et = edge.to_executorch()
83+
self.assertTrue(
84+
_delegated(et),
85+
f"Expected a VulkanBackend delegate (gather {name})",
86+
)
87+
self.assertTrue(
88+
_op_delegated(edge),
89+
f"gather not delegated (fell back to CPU) for {name}",
90+
)
91+
92+
def test_op_matches_fp64_golden(self) -> None:
93+
for name, (self_shape, dim, index_shape) in CONFIGS.items():
94+
with self.subTest(name=name):
95+
x, index = _det_inputs(self_shape, dim, index_shape)
96+
got = GatherModule(dim)(x, index)
97+
golden = torch.gather(x.double(), dim, index).to(torch.float32)
98+
torch.testing.assert_close(got, golden)
99+
100+
101+
def export_gather_model(name: str, pte_path: str, golden_path: str) -> None:
102+
"""Write one config's gather .pte + fp64 torch golden (raw LE fp32)."""
103+
self_shape, dim, index_shape = CONFIGS[name]
104+
x, index = _det_inputs(self_shape, dim, index_shape)
105+
et = _lower(GatherModule(dim).eval(), x, index).to_executorch()
106+
golden = torch.gather(x.double(), dim, index).to(torch.float32)
107+
with open(pte_path, "wb") as f:
108+
f.write(et.buffer)
109+
golden.numpy().astype("<f4").tofile(golden_path)
110+
print(f"Exported {pte_path}; golden {golden_path} ({golden.numel()} floats)")
111+
112+
113+
def export_all_gather_models(out_dir: str) -> None:
114+
for name in CONFIGS:
115+
export_gather_model(
116+
name,
117+
os.path.join(out_dir, f"gather_{name}.pte"),
118+
os.path.join(out_dir, f"gather_{name}.golden.bin"),
119+
)
120+
121+
122+
if __name__ == "__main__":
123+
unittest.main()

0 commit comments

Comments
 (0)