Skip to content

Commit 28c4316

Browse files
committed
[ExecuTorch][WebGPU] Tests for the shape_copy WebGPU ops
Pull Request resolved: #20930 Export-delegation + fp64 golden tests for the shape/copy (`dim_order`, `expand_copy`, `fill`) ops. Key changes: - `test/ops/{test_dim_order,test_expand_copy,test_fill}.py` — fp64 goldens + delegation checks — fp64 library-reference goldens + delegation checks (≥2 cases/op). Co-authored-with: Claude Code. ghstack-source-id: 405026074 @exported-using-ghexport Differential Revision: [D111755126](https://our.internmc.facebook.com/intern/diff/D111755126/)
1 parent 9cbe455 commit 28c4316

3 files changed

Lines changed: 306 additions & 0 deletions

File tree

Lines changed: 95 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,95 @@
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+
"""`dim_order_ops._clone_dim_order.default` export delegation for the WebGPU backend.
8+
9+
`torch.clone(x)` lowers to `dim_order_ops._clone_dim_order.default` (the edge
10+
dialect's dim-order-aware clone). On the buffer-only WebGPU backend it is a
11+
numel-preserving flat copy handled by the shared `add_flat_copy` DMA helper (no
12+
WGSL). The partitioner tags it (single-node partitions are allowed), so a
13+
`VulkanBackend` delegate is formed; `RemoveRedundantOpsTransform` then folds the
14+
identity clone out of the delegate in preprocess, so it never reaches the native
15+
runtime (hence no `.golden.bin` / native sweep — like `clone`).
16+
17+
`test_export_delegates` locks the contract that the op is absorbed into the
18+
delegate and never survives as a top-level portable (CPU-fallback) node;
19+
`test_golden_matches_eager` locks the fp64 `x.clone()` reference. Configs cover
20+
1D, 3D, and 4D contiguous inputs.
21+
"""
22+
23+
from __future__ import annotations
24+
25+
import unittest
26+
27+
import torch
28+
29+
from executorch.backends.vulkan.partitioner.vulkan_partitioner import VulkanPartitioner
30+
from executorch.exir import to_edge_transform_and_lower
31+
32+
# name -> input_shape
33+
CONFIGS = {
34+
"flat": (16,),
35+
"3d": (2, 3, 4),
36+
"4d": (1, 3, 4, 5),
37+
}
38+
39+
40+
class CloneDimOrderModule(torch.nn.Module):
41+
def forward(self, x: torch.Tensor) -> torch.Tensor:
42+
return torch.clone(x)
43+
44+
45+
def _det_input(shape):
46+
g = torch.Generator().manual_seed(0)
47+
return torch.randn(*shape, generator=g, dtype=torch.float32)
48+
49+
50+
def _lower(x: torch.Tensor):
51+
ep = torch.export.export(CloneDimOrderModule().eval(), (x,))
52+
return to_edge_transform_and_lower(ep, partitioner=[VulkanPartitioner()])
53+
54+
55+
def _delegates(et) -> bool:
56+
return any(
57+
d.id == "VulkanBackend"
58+
for plan in et.executorch_program.execution_plan
59+
for d in plan.delegates
60+
)
61+
62+
63+
def _op_absent_from_toplevel(edge, op_substr: str) -> bool:
64+
# Delegated/folded ops are absorbed; none may survive as a top-level node.
65+
gm = edge.exported_program().graph_module
66+
return all(op_substr not in str(getattr(n, "target", "")) for n in gm.graph.nodes)
67+
68+
69+
class TestCloneDimOrder(unittest.TestCase):
70+
def test_export_delegates(self) -> None:
71+
for name, shape in CONFIGS.items():
72+
with self.subTest(name=name):
73+
edge = _lower(_det_input(shape))
74+
et = edge.to_executorch()
75+
self.assertTrue(
76+
_delegates(et),
77+
f"Expected a VulkanBackend delegate (clone_dim_order {name})",
78+
)
79+
self.assertTrue(
80+
_op_absent_from_toplevel(edge, "_clone_dim_order"),
81+
f"_clone_dim_order left as a top-level portable op for {name}",
82+
)
83+
84+
def test_golden_matches_eager(self) -> None:
85+
for name, shape in CONFIGS.items():
86+
with self.subTest(name=name):
87+
x = _det_input(shape)
88+
ref = x.to(torch.float64).clone()
89+
torch.testing.assert_close(
90+
CloneDimOrderModule()(x).to(torch.float64), ref
91+
)
92+
93+
94+
if __name__ == "__main__":
95+
unittest.main()
Lines changed: 120 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,120 @@
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.expand_copy.default` export + golden for the WebGPU backend.
8+
9+
Exports single-op expand-copy graphs through VulkanPartitioner and checks an fp64
10+
torch golden. expand_copy materializes a broadcasted view (size-1 input dims, and
11+
rank-increasing leading dims) into the target shape via a pure gather -- no
12+
arithmetic, so fp64 and fp32 agree exactly. Configs cover a broadcast leading
13+
dim, a broadcast middle dim, and a rank increase (input rank < output rank) that
14+
exercises the kernel's right-alignment of input dims into the output rank.
15+
"""
16+
17+
from __future__ import annotations
18+
19+
import math
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 -> (input shape, expanded shape).
28+
CONFIGS = {
29+
"broadcast_leading": ((1, 4), (3, 4)),
30+
"broadcast_middle": ((2, 1, 5), (2, 4, 5)),
31+
"rank_increase": ((4,), (3, 4)),
32+
}
33+
34+
35+
class ExpandCopyModule(torch.nn.Module):
36+
def __init__(self, shape: tuple[int, ...]) -> None:
37+
super().__init__()
38+
self.shape = shape
39+
40+
def forward(self, x: torch.Tensor) -> torch.Tensor:
41+
return x.expand(self.shape).clone()
42+
43+
44+
def _det_input(shape: tuple[int, ...]) -> torch.Tensor:
45+
"""Deterministic fp32 ramp; a broadcast dim repeats it so the copy is visible."""
46+
return torch.arange(math.prod(shape), dtype=torch.float32).reshape(shape)
47+
48+
49+
def _export(m: torch.nn.Module, x: torch.Tensor):
50+
ep = torch.export.export(m, (x,))
51+
return to_edge_transform_and_lower(
52+
ep, partitioner=[VulkanPartitioner()]
53+
).to_executorch()
54+
55+
56+
def _delegates(et) -> bool:
57+
return any(
58+
d.id == "VulkanBackend"
59+
for plan in et.executorch_program.execution_plan
60+
for d in plan.delegates
61+
)
62+
63+
64+
def _top_level_op_names(et) -> set[str]:
65+
return {
66+
op.name
67+
for plan in et.executorch_program.execution_plan
68+
for op in plan.operators
69+
}
70+
71+
72+
class TestExpandCopy(unittest.TestCase):
73+
def test_export_delegates(self) -> None:
74+
for name, (in_shape, out_shape) in CONFIGS.items():
75+
with self.subTest(name=name):
76+
x = _det_input(in_shape)
77+
et = _export(ExpandCopyModule(out_shape).eval(), x)
78+
self.assertTrue(
79+
_delegates(et),
80+
f"Expected a VulkanBackend delegate (expand_copy {name})",
81+
)
82+
# Delegated => expand_copy absent from top-level portable ops.
83+
self.assertFalse(
84+
any("expand_copy" in n for n in _top_level_op_names(et)),
85+
f"expand_copy leaked into top-level ops ({name})",
86+
)
87+
88+
def test_golden_matches_torch(self) -> None:
89+
for name, (in_shape, out_shape) in CONFIGS.items():
90+
with self.subTest(name=name):
91+
x = _det_input(in_shape)
92+
golden = x.double().expand(out_shape).clone()
93+
got = ExpandCopyModule(out_shape)(x)
94+
torch.testing.assert_close(got.double(), golden)
95+
96+
97+
def export_expand_copy_model(
98+
out_shape: tuple[int, ...],
99+
in_shape: tuple[int, ...],
100+
pte_path: str,
101+
golden_path: str,
102+
input_path: str,
103+
) -> None:
104+
"""Write an expand_copy .pte + torch golden (raw LE fp32) + raw LE fp32 input."""
105+
m = ExpandCopyModule(out_shape).eval()
106+
x = _det_input(in_shape)
107+
golden = m(x).detach().numpy().astype("<f4")
108+
et = _export(m, x)
109+
with open(pte_path, "wb") as f:
110+
f.write(et.buffer)
111+
golden.tofile(golden_path)
112+
x.numpy().astype("<f4").tofile(input_path)
113+
print(
114+
f"Exported {pte_path}; golden {golden_path} ({golden.size} floats); "
115+
f"input {input_path} ({x.numel()} floats)"
116+
)
117+
118+
119+
if __name__ == "__main__":
120+
unittest.main()
Lines changed: 91 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,91 @@
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.full` / `aten.full_like` export + fp64 golden for the WebGPU backend.
8+
9+
Both lower to the `fill` handler, which writes a constant scalar into the
10+
pre-allocated output buffer. `full` and `full_like` are on the training-backward
11+
critical path (gradient seeds). To keep the op in the graph (a constant-shaped
12+
`full` would fold away), the fill size is taken from a live input: `full` uses
13+
`x.shape`, `full_like` uses `x`. Configs span 1D/2D/4D and a non-multiple-of-256
14+
shape that exercises the dispatch bounds guard.
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 -> (kind, shape, fill_value)
27+
CONFIGS = {
28+
"full_1d": ("full", (37,), 0.0), # non-multiple of 256: bounds-guard path
29+
"full_2d": ("full", (4, 8), 3.0),
30+
"full_4d": ("full", (2, 3, 4, 5), -1.5),
31+
"full_like_2d": ("full_like", (16, 16), 7.25),
32+
}
33+
34+
35+
class FullModule(torch.nn.Module):
36+
def __init__(self, val: float) -> None:
37+
super().__init__()
38+
self.val = val
39+
40+
def forward(self, x: torch.Tensor) -> torch.Tensor:
41+
return torch.full(x.shape, self.val)
42+
43+
44+
class FullLikeModule(torch.nn.Module):
45+
def __init__(self, val: float) -> None:
46+
super().__init__()
47+
self.val = val
48+
49+
def forward(self, x: torch.Tensor) -> torch.Tensor:
50+
return torch.full_like(x, self.val)
51+
52+
53+
def _module(kind: str, val: float) -> torch.nn.Module:
54+
return (FullModule(val) if kind == "full" else FullLikeModule(val)).eval()
55+
56+
57+
def _export(m: torch.nn.Module, x: torch.Tensor):
58+
ep = torch.export.export(m, (x,))
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 TestFill(unittest.TestCase):
73+
def test_export_delegates(self) -> None:
74+
for name, (kind, shape, val) in CONFIGS.items():
75+
with self.subTest(name=name):
76+
x = torch.zeros(shape, dtype=torch.float32)
77+
et = _export(_module(kind, val), x)
78+
self.assertTrue(
79+
_delegates(et), f"Expected a VulkanBackend delegate (fill {name})"
80+
)
81+
82+
def test_golden_matches_eager(self) -> None:
83+
for name, (kind, shape, val) in CONFIGS.items():
84+
with self.subTest(name=name):
85+
x = torch.zeros(shape, dtype=torch.float32)
86+
golden = torch.full(shape, val, dtype=torch.float64)
87+
torch.testing.assert_close(_module(kind, val)(x).double(), golden)
88+
89+
90+
if __name__ == "__main__":
91+
unittest.main()

0 commit comments

Comments
 (0)