Skip to content

Commit eda0991

Browse files
committed
[ExecuTorch][WebGPU] Tests for the select_bool WebGPU ops
Pull Request resolved: #20926 Export-delegation + fp64 golden tests for the select/bool (`where`, scalar compares, `logical_not`) ops. Key changes: - `test/ops/{test_where,test_compare,test_logical_not}.py` — fp64 goldens + delegation checks — fp64 library-reference goldens + delegation checks (≥2 cases/op). Co-authored-with: Claude Code. ghstack-source-id: 405026072 @exported-using-ghexport Differential Revision: [D111755124](https://our.internmc.facebook.com/intern/diff/D111755124/)
1 parent 1d4bf9e commit eda0991

3 files changed

Lines changed: 311 additions & 0 deletions

File tree

Lines changed: 109 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,109 @@
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.{eq,ne,le,ge,lt,gt}.Scalar` export + golden for the WebGPU backend.
8+
9+
Each scalar comparison lowers to a single `aten.<op>.Scalar` node that the
10+
kernel computes as `cmp(self[i], scalar)` and writes as a byte-packed bool. The
11+
delegation test locks that every variant partitions to `VulkanBackend`; the
12+
golden test locks the fp32 module output against the fp64 torch truth. The
13+
deterministic ramp is exact in fp32 and straddles (and hits) the scalar, so both
14+
precisions agree bit-for-bit and every op has mixed True/False cases; a
15+
non-multiple-of-4 numel exercises the kernel tail (4 elems per u32 word).
16+
"""
17+
18+
from __future__ import annotations
19+
20+
import unittest
21+
22+
import torch
23+
24+
from executorch.backends.vulkan.partitioner.vulkan_partitioner import VulkanPartitioner
25+
from executorch.exir import to_edge_transform_and_lower
26+
27+
SCALAR = 0.0
28+
OPS = ("eq", "ne", "le", "ge", "lt", "gt")
29+
SHAPES = {"tail": (3, 5), "3d": (2, 4, 8)}
30+
31+
_TORCH_OP = {
32+
"eq": torch.eq,
33+
"ne": torch.ne,
34+
"le": torch.le,
35+
"ge": torch.ge,
36+
"lt": torch.lt,
37+
"gt": torch.gt,
38+
}
39+
40+
41+
class CompareModule(torch.nn.Module):
42+
def __init__(self, op: str, scalar: float) -> None:
43+
super().__init__()
44+
self.op = op
45+
self.scalar = scalar
46+
47+
def forward(self, x: torch.Tensor) -> torch.Tensor:
48+
if self.op == "eq":
49+
return x == self.scalar
50+
if self.op == "ne":
51+
return x != self.scalar
52+
if self.op == "le":
53+
return x <= self.scalar
54+
if self.op == "ge":
55+
return x >= self.scalar
56+
if self.op == "lt":
57+
return x < self.scalar
58+
return x > self.scalar
59+
60+
61+
def _det_input(shape: tuple[int, ...]) -> torch.Tensor:
62+
"""Deterministic fp32 spanning [-0.5, 0.5] in 1/16 steps (exact in fp32,
63+
hits and straddles 0.0)."""
64+
n = 1
65+
for d in shape:
66+
n *= d
67+
flat = torch.arange(n, dtype=torch.float32)
68+
return (((flat % 17) - 8) / 16.0).reshape(shape)
69+
70+
71+
def _export(m: torch.nn.Module, x: torch.Tensor):
72+
ep = torch.export.export(m, (x,))
73+
return to_edge_transform_and_lower(
74+
ep, partitioner=[VulkanPartitioner()]
75+
).to_executorch()
76+
77+
78+
def _delegated(et) -> bool:
79+
return any(
80+
d.id == "VulkanBackend"
81+
for plan in et.executorch_program.execution_plan
82+
for d in plan.delegates
83+
)
84+
85+
86+
class TestCompare(unittest.TestCase):
87+
def test_export_delegates(self) -> None:
88+
for op in OPS:
89+
for name, shape in SHAPES.items():
90+
with self.subTest(op=op, shape=name):
91+
x = _det_input(shape)
92+
et = _export(CompareModule(op, SCALAR).eval(), x)
93+
self.assertTrue(
94+
_delegated(et),
95+
f"Expected a VulkanBackend delegate ({op}.Scalar {name})",
96+
)
97+
98+
def test_module_matches_fp64_golden(self) -> None:
99+
for op in OPS:
100+
for name, shape in SHAPES.items():
101+
with self.subTest(op=op, shape=name):
102+
x = _det_input(shape)
103+
got = CompareModule(op, SCALAR)(x)
104+
golden = _TORCH_OP[op](x.double(), float(SCALAR))
105+
torch.testing.assert_close(got, golden)
106+
107+
108+
if __name__ == "__main__":
109+
unittest.main()
Lines changed: 116 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,116 @@
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.logical_not.default` export + golden for the WebGPU backend.
8+
9+
Exports single-op logical_not graphs through VulkanPartitioner and writes a
10+
torch-computed golden. logical_not lowers to a byte-packed bool kernel (1 byte /
11+
elem, one u32 word / thread), so the golden is an EXACT bool match -- the native
12+
test compares the raw uint8 output byte-for-byte. The bool input is produced by a
13+
delegated `x >= threshold` compare; the golden uses the De Morgan inverse
14+
`x < threshold` as an independent reference. A non-multiple-of-4 numel exercises
15+
the shader's partial-final-word masking (the `i < num_elements` branch).
16+
"""
17+
18+
from __future__ import annotations
19+
20+
import unittest
21+
22+
import torch
23+
24+
from executorch.backends.vulkan.partitioner.vulkan_partitioner import VulkanPartitioner
25+
from executorch.exir import to_edge_transform_and_lower
26+
27+
# name -> shape. "tail3x7" has numel 21 (not a multiple of 4) => partial word.
28+
CONFIGS = {
29+
"vec1d": (16,),
30+
"mat2d": (4, 8),
31+
"tail3x7": (3, 7),
32+
"cube3d": (2, 4, 8),
33+
}
34+
35+
36+
class LogicalNotModule(torch.nn.Module):
37+
def __init__(self, threshold: float) -> None:
38+
super().__init__()
39+
self.threshold = threshold
40+
41+
def forward(self, x: torch.Tensor) -> torch.Tensor:
42+
return torch.logical_not(x >= self.threshold)
43+
44+
45+
def _det_input(shape: tuple[int, ...]) -> torch.Tensor:
46+
"""Deterministic fp32 spanning negatives, zero, and positives so the bool
47+
input to logical_not carries both True and False values."""
48+
g = torch.Generator().manual_seed(0)
49+
return torch.randn(*shape, generator=g, dtype=torch.float32)
50+
51+
52+
def _lower(m: torch.nn.Module, x: torch.Tensor):
53+
ep = torch.export.export(m, (x,))
54+
return to_edge_transform_and_lower(ep, partitioner=[VulkanPartitioner()])
55+
56+
57+
def _delegates(edge) -> bool:
58+
et = edge.to_executorch()
59+
return any(
60+
d.id == "VulkanBackend"
61+
for plan in et.executorch_program.execution_plan
62+
for d in plan.delegates
63+
)
64+
65+
66+
def _top_level_targets(edge) -> list[str]:
67+
return [
68+
str(n.target)
69+
for n in edge.exported_program().graph_module.graph.nodes
70+
if n.op == "call_function"
71+
]
72+
73+
74+
class TestLogicalNot(unittest.TestCase):
75+
def test_export_delegates(self) -> None:
76+
for name, shape in CONFIGS.items():
77+
with self.subTest(config=name):
78+
edge = _lower(LogicalNotModule(0.0).eval(), _det_input(shape))
79+
targets = _top_level_targets(edge)
80+
self.assertFalse(
81+
any("logical_not" in t for t in targets),
82+
f"logical_not must be delegated, not portable ({name}): {targets}",
83+
)
84+
self.assertTrue(
85+
_delegates(edge),
86+
f"Expected a VulkanBackend delegate (logical_not {name})",
87+
)
88+
89+
def test_golden_matches_eager(self) -> None:
90+
for name, shape in CONFIGS.items():
91+
with self.subTest(config=name):
92+
x = _det_input(shape)
93+
got = LogicalNotModule(0.0)(x)
94+
golden = x < 0.0 # De Morgan inverse of logical_not(x >= 0)
95+
self.assertEqual(got.dtype, torch.bool)
96+
torch.testing.assert_close(got, golden)
97+
98+
99+
def export_logical_not_model(pte_path: str, golden_path: str, input_path: str) -> None:
100+
"""Write logical_not .pte + torch golden (raw uint8) + raw LE fp32 input."""
101+
m = LogicalNotModule(0.0).eval()
102+
x = _det_input(CONFIGS["mat2d"])
103+
golden = m(x).detach().numpy().astype("<u1")
104+
et = _lower(m, x).to_executorch()
105+
with open(pte_path, "wb") as f:
106+
f.write(et.buffer)
107+
golden.tofile(golden_path)
108+
x.numpy().astype("<f4").tofile(input_path)
109+
print(
110+
f"Exported {pte_path}; golden {golden_path} ({golden.size} bytes); "
111+
f"input {input_path} ({x.numel()} floats)"
112+
)
113+
114+
115+
if __name__ == "__main__":
116+
unittest.main()
Lines changed: 86 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,86 @@
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.where.self` export + fp64 golden for the WebGPU backend.
8+
9+
`where(cond, a, b) -> cond ? a : b`, with cond a 1-byte bool and a/b fp32
10+
(broadcast across all three operands). The kernel reads cond byte-packed as
11+
`array<u32>` and relinearizes each out coord onto every operand. Configs cover
12+
the equal-shape path plus broadcasts that exercise the size-1 clamp on cond, a,
13+
and b. The native binary has no ATen, so the golden is computed with torch here
14+
and checked in etvk CI.
15+
"""
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 -> (cond_shape, a_shape, b_shape). Output is the broadcast of all three.
25+
CONFIGS = {
26+
"equal": ((4, 8), (4, 8), (4, 8)),
27+
"broadcast": ((4, 1), (4, 8), (1, 8)),
28+
"cond_row": ((8,), (4, 8), (4, 8)),
29+
}
30+
31+
32+
class WhereModule(torch.nn.Module):
33+
def forward(
34+
self, cond: torch.Tensor, a: torch.Tensor, b: torch.Tensor
35+
) -> torch.Tensor:
36+
return torch.where(cond, a, b)
37+
38+
39+
def _det_inputs(cond_shape, a_shape, b_shape):
40+
"""Deterministic (bool cond, fp32 a, fp32 b) for a config."""
41+
g = torch.Generator().manual_seed(0)
42+
cond = torch.rand(cond_shape, generator=g) > 0.5
43+
a = torch.randn(*a_shape, generator=g, dtype=torch.float32)
44+
b = torch.randn(*b_shape, generator=g, dtype=torch.float32)
45+
return cond, a, b
46+
47+
48+
def _fp64_golden(cond, a, b):
49+
return torch.where(cond, a.double(), b.double()).to(torch.float32)
50+
51+
52+
def _export(cond, a, b):
53+
ep = torch.export.export(WhereModule().eval(), (cond, a, b))
54+
return to_edge_transform_and_lower(
55+
ep, partitioner=[VulkanPartitioner()]
56+
).to_executorch()
57+
58+
59+
def _delegates(et) -> bool:
60+
return any(
61+
d.id == "VulkanBackend"
62+
for plan in et.executorch_program.execution_plan
63+
for d in plan.delegates
64+
)
65+
66+
67+
class TestWhere(unittest.TestCase):
68+
def test_export_delegates(self) -> None:
69+
for name, (cs, as_, bs) in CONFIGS.items():
70+
with self.subTest(config=name):
71+
cond, a, b = _det_inputs(cs, as_, bs)
72+
et = _export(cond, a, b)
73+
self.assertTrue(
74+
_delegates(et), f"Expected a VulkanBackend delegate (where {name})"
75+
)
76+
77+
def test_op_matches_fp64_golden(self) -> None:
78+
for name, (cs, as_, bs) in CONFIGS.items():
79+
with self.subTest(config=name):
80+
cond, a, b = _det_inputs(cs, as_, bs)
81+
out = WhereModule()(cond, a, b)
82+
torch.testing.assert_close(out, _fp64_golden(cond, a, b))
83+
84+
85+
if __name__ == "__main__":
86+
unittest.main()

0 commit comments

Comments
 (0)