Skip to content

Commit 989e136

Browse files
committed
[ExecuTorch][WebGPU] Tests for the binary WebGPU ops
Pull Request resolved: #20922 Export-delegation + fp64 golden tests for the binary (`div`, `sub`) ops. Key changes: - `test/ops/{test_div,test_sub}.py` — fp64 goldens (incl. broadcast) + delegation checks — fp64 library-reference goldens + delegation checks (≥2 cases/op). Co-authored-with: Claude Code. ghstack-source-id: 405026054 @exported-using-ghexport Differential Revision: [D111755114](https://our.internmc.facebook.com/intern/diff/D111755114/)
1 parent e14431b commit 989e136

2 files changed

Lines changed: 208 additions & 0 deletions

File tree

Lines changed: 94 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,94 @@
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.div.Tensor` (broadcast) export + fp64 golden for the WebGPU backend.
8+
9+
Exports single-op divide graphs through VulkanPartitioner and asserts they
10+
delegate to the Vulkan backend (div is absent from the top-level portable ops),
11+
then locks the golden math against an fp64 torch reference (`a / b` with PyTorch
12+
broadcasting). Configs span the same-shape fast path, a last-dim broadcast at
13+
LLM width, and a mixed-rank left-pad case. Divisors are bounded away from zero
14+
so the fp32-vs-fp64 comparison stays well-conditioned.
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 -> (shape_a, shape_b). Output shape is the broadcast of the two.
27+
CONFIGS = {
28+
"same": ((8, 32), (8, 32)), # fast path (same-shape elementwise)
29+
"bcast_lastdim": ((1, 1, 7, 896), (1, 1, 7, 1)), # last-dim broadcast, LLM
30+
"mixedrank": ((4,), (3, 4)), # right-aligned left-pad (in.ndim < out.ndim)
31+
}
32+
33+
34+
class DivModule(torch.nn.Module):
35+
def forward(self, a: torch.Tensor, b: torch.Tensor) -> torch.Tensor:
36+
return a / b
37+
38+
39+
def _det_inputs(shape_a, shape_b):
40+
"""Deterministic fp32 inputs (fixed seed); divisor bounded away from zero."""
41+
g = torch.Generator().manual_seed(0)
42+
a = torch.randn(*shape_a, generator=g, dtype=torch.float32)
43+
b = torch.randn(*shape_b, generator=g, dtype=torch.float32).abs() + 0.5
44+
return a, b
45+
46+
47+
def _export(a: torch.Tensor, b: torch.Tensor):
48+
ep = torch.export.export(DivModule().eval(), (a, b))
49+
return to_edge_transform_and_lower(
50+
ep, partitioner=[VulkanPartitioner()]
51+
).to_executorch()
52+
53+
54+
def _delegated(et) -> bool:
55+
return any(
56+
d.id == "VulkanBackend"
57+
for plan in et.executorch_program.execution_plan
58+
for d in plan.delegates
59+
)
60+
61+
62+
def _top_level_op_names(et) -> set[str]:
63+
return {
64+
op.name
65+
for plan in et.executorch_program.execution_plan
66+
for op in plan.operators
67+
}
68+
69+
70+
class TestDiv(unittest.TestCase):
71+
def test_export_delegates(self) -> None:
72+
for name, (sa, sb) in CONFIGS.items():
73+
with self.subTest(name=name):
74+
a, b = _det_inputs(sa, sb)
75+
et = _export(a, b)
76+
self.assertTrue(
77+
_delegated(et), f"Expected a VulkanBackend delegate (div {name})"
78+
)
79+
self.assertFalse(
80+
any("div" in n for n in _top_level_op_names(et)),
81+
f"div should be delegated, not a top-level portable op (div {name})",
82+
)
83+
84+
def test_op_matches_fp64_golden(self) -> None:
85+
for name, (sa, sb) in CONFIGS.items():
86+
with self.subTest(name=name):
87+
a, b = _det_inputs(sa, sb)
88+
got = DivModule()(a, b)
89+
golden = (a.double() / b.double()).to(torch.float32)
90+
torch.testing.assert_close(got, golden, atol=5e-4, rtol=1e-3)
91+
92+
93+
if __name__ == "__main__":
94+
unittest.main()
Lines changed: 114 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,114 @@
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.sub.Tensor` export + golden for the WebGPU backend.
8+
9+
Exports single-op subtraction graphs through VulkanPartitioner (the WebGPU runtime
10+
consumes the Vulkan VK00 delegate directly) and checks an fp64 torch golden
11+
(`out = in1 - alpha * in2`). The native/etvk numeric oracle compares the GPU kernel
12+
against this same reference; this suite locks delegation + the reference math.
13+
Configs span same-shape 2D/3D, trailing- and leading-dim broadcast, and alpha != 1.
14+
"""
15+
16+
from __future__ import annotations
17+
18+
import unittest
19+
20+
import torch
21+
22+
from executorch.backends.vulkan.partitioner.vulkan_partitioner import VulkanPartitioner
23+
from executorch.exir import to_edge_transform_and_lower
24+
25+
26+
# name -> (shape_a, shape_b); b broadcasts into a.
27+
CONFIGS = {
28+
"2d": ((4, 4), (4, 4)),
29+
"3d": ((2, 3, 4), (2, 3, 4)),
30+
"bcast_last": ((4, 4), (4, 1)),
31+
"bcast_row": ((4, 4), (1, 4)),
32+
}
33+
34+
35+
class SubModule(torch.nn.Module):
36+
def forward(self, a: torch.Tensor, b: torch.Tensor) -> torch.Tensor:
37+
return torch.sub(a, b)
38+
39+
40+
class SubAlphaModule(torch.nn.Module):
41+
def __init__(self, alpha: float) -> None:
42+
super().__init__()
43+
self.alpha = alpha
44+
45+
def forward(self, a: torch.Tensor, b: torch.Tensor) -> torch.Tensor:
46+
return torch.sub(a, b, alpha=self.alpha)
47+
48+
49+
def _det_inputs(shape_a, shape_b):
50+
"""Deterministic fp32 inputs (fixed seed) for a config."""
51+
g = torch.Generator().manual_seed(0)
52+
a = torch.randn(*shape_a, generator=g, dtype=torch.float32)
53+
b = torch.randn(*shape_b, generator=g, dtype=torch.float32)
54+
return a, b
55+
56+
57+
def _export(m: torch.nn.Module, a: torch.Tensor, b: torch.Tensor):
58+
ep = torch.export.export(m.eval(), (a, b))
59+
return to_edge_transform_and_lower(
60+
ep, partitioner=[VulkanPartitioner()]
61+
).to_executorch()
62+
63+
64+
def _delegates(et) -> bool:
65+
return any(
66+
d.id == "VulkanBackend"
67+
for plan in et.executorch_program.execution_plan
68+
for d in plan.delegates
69+
)
70+
71+
72+
class TestSub(unittest.TestCase):
73+
def test_export_delegates(self) -> None:
74+
# Delegation => no aten.sub.Tensor left in the top-level portable graph.
75+
for name, (sa, sb) in CONFIGS.items():
76+
with self.subTest(name=name):
77+
a, b = _det_inputs(sa, sb)
78+
et = _export(SubModule(), a, b)
79+
self.assertTrue(
80+
_delegates(et),
81+
f"Expected a VulkanBackend delegate (sub {name})",
82+
)
83+
84+
def test_golden_matches_fp64(self) -> None:
85+
for name, (sa, sb) in CONFIGS.items():
86+
with self.subTest(name=name):
87+
a, b = _det_inputs(sa, sb)
88+
ref = (a.double() - b.double()).to(torch.float32)
89+
torch.testing.assert_close(SubModule()(a, b), ref)
90+
91+
def test_golden_matches_fp64_alpha(self) -> None:
92+
# Locks the handler's out = in1 - alpha * in2 path (alpha != 1).
93+
alpha = 2.5
94+
a, b = _det_inputs((4, 4), (4, 4))
95+
ref = (a.double() - alpha * b.double()).to(torch.float32)
96+
torch.testing.assert_close(SubAlphaModule(alpha)(a, b), ref)
97+
98+
99+
def export_sub_model(pte_path: str, golden_path: str, input_path: str) -> None:
100+
"""Write sub(a, b) .pte + fp64-computed torch golden + raw LE fp32 inputs (in1, in2)."""
101+
a, b = _det_inputs((1024, 1024), (1024, 1024))
102+
golden = (a.double() - b.double()).to(torch.float32).numpy().astype("<f4")
103+
et = _export(SubModule(), a, b)
104+
with open(pte_path, "wb") as f:
105+
f.write(et.buffer)
106+
golden.tofile(golden_path)
107+
with open(input_path, "wb") as f:
108+
a.numpy().astype("<f4").tofile(f)
109+
b.numpy().astype("<f4").tofile(f)
110+
print(f"Exported {pte_path}; golden {golden_path} ({golden.size} floats)")
111+
112+
113+
if __name__ == "__main__":
114+
unittest.main()

0 commit comments

Comments
 (0)