Skip to content

Commit 6c56efa

Browse files
authored
[ExecuTorch][WebGPU] Tests for the softmax WebGPU ops
Differential Revision: D111755117 Pull Request resolved: #20920
1 parent 728c830 commit 6c56efa

2 files changed

Lines changed: 213 additions & 0 deletions

File tree

Lines changed: 97 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,97 @@
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._log_softmax.default` export + golden for the WebGPU backend.
8+
9+
Exports single-op log-softmax graphs through VulkanPartitioner and writes a
10+
torch-computed golden (the native binary has no ATen) + the raw fp32 input the
11+
native test loads and compares. log_softmax is on the training critical path:
12+
the cross-entropy / decomposed-backward lowers to `_log_softmax`, computed in
13+
kernel as `x - (max + log(sum exp(x - max)))`. `dim=-1` gives inner=1; a middle
14+
dim exercises the inner>1 reduction path.
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+
27+
class LogSoftmaxModule(torch.nn.Module):
28+
def __init__(self, dim: int) -> None:
29+
super().__init__()
30+
self.dim = dim
31+
32+
def forward(self, x: torch.Tensor) -> torch.Tensor:
33+
return torch.log_softmax(x, dim=self.dim)
34+
35+
36+
def _det_input() -> torch.Tensor:
37+
"""Deterministic fp32 spanning large +/- magnitudes (exercises the
38+
max-subtraction: a naive exp(x) would overflow on the +40 entries)."""
39+
return torch.linspace(-40.0, 40.0, 4 * 8 * 16, dtype=torch.float32).reshape(
40+
4, 8, 16
41+
)
42+
43+
44+
def _export(m: torch.nn.Module, x: torch.Tensor):
45+
ep = torch.export.export(m, (x,))
46+
return to_edge_transform_and_lower(
47+
ep, partitioner=[VulkanPartitioner()]
48+
).to_executorch()
49+
50+
51+
def _delegates(et) -> bool:
52+
return any(
53+
d.id == "VulkanBackend"
54+
for plan in et.executorch_program.execution_plan
55+
for d in plan.delegates
56+
)
57+
58+
59+
class TestLogSoftmax(unittest.TestCase):
60+
def test_export_delegates_last_dim(self) -> None:
61+
et = _export(LogSoftmaxModule(-1).eval(), _det_input())
62+
self.assertTrue(
63+
_delegates(et), "Expected a VulkanBackend delegate (log_softmax dim=-1)"
64+
)
65+
66+
def test_export_delegates_middle_dim(self) -> None:
67+
# dim=1 => inner>1: the non-unit-stride reduction path in the kernel.
68+
et = _export(LogSoftmaxModule(1).eval(), _det_input())
69+
self.assertTrue(
70+
_delegates(et), "Expected a VulkanBackend delegate (log_softmax dim=1)"
71+
)
72+
73+
def test_golden_matches_eager(self) -> None:
74+
x = _det_input()
75+
torch.testing.assert_close(
76+
LogSoftmaxModule(-1)(x), torch.log_softmax(x, dim=-1)
77+
)
78+
79+
80+
def export_log_softmax_model(pte_path: str, golden_path: str, input_path: str) -> None:
81+
"""Write log_softmax(dim=-1) .pte + torch golden (raw LE fp32) + raw LE fp32 input."""
82+
m = LogSoftmaxModule(-1).eval()
83+
x = _det_input()
84+
golden = m(x).detach().numpy().astype("<f4")
85+
et = _export(m, x)
86+
with open(pte_path, "wb") as f:
87+
f.write(et.buffer)
88+
golden.tofile(golden_path)
89+
x.numpy().astype("<f4").tofile(input_path)
90+
print(
91+
f"Exported {pte_path}; golden {golden_path} ({golden.size} floats); "
92+
f"input {input_path} ({x.numel()} floats)"
93+
)
94+
95+
96+
if __name__ == "__main__":
97+
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._softmax.default` export + golden for the WebGPU backend.
8+
9+
Exports single-op softmax graphs through VulkanPartitioner and writes a
10+
torch-computed golden (the native binary has no ATen) + the raw fp32 input the
11+
native test loads and compares. Softmax is on the training critical path: the
12+
decomposed attention lowers to `matmul -> softmax(dim=-1) -> matmul`, whereas the
13+
fused inference `sdpa` computes softmax internally, so a standalone `_softmax`
14+
op is only exercised by the decomposed backward. `dim=-1` gives inner=1 (the
15+
attention case); a middle dim exercises the inner>1 reduction path.
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, dim). dim=-1 => inner=1 (attention); a middle dim => inner>1.
28+
CONFIGS = {
29+
"last_dim_3d": ((4, 8, 16), -1),
30+
"middle_dim_3d": ((4, 8, 16), 1),
31+
"last_dim_2d": ((32, 64), -1),
32+
}
33+
34+
35+
class SoftmaxModule(torch.nn.Module):
36+
def __init__(self, dim: int) -> None:
37+
super().__init__()
38+
self.dim = dim
39+
40+
def forward(self, x: torch.Tensor) -> torch.Tensor:
41+
return torch.softmax(x, dim=self.dim)
42+
43+
44+
def _det_input(shape) -> torch.Tensor:
45+
"""Deterministic fp32 spanning large +/- magnitudes (exercises the
46+
max-subtraction: a naive exp(x) would overflow on the +40 entries)."""
47+
numel = 1
48+
for d in shape:
49+
numel *= d
50+
return torch.linspace(-40.0, 40.0, numel, dtype=torch.float32).reshape(shape)
51+
52+
53+
def _fp64_golden(x: torch.Tensor, dim: int) -> torch.Tensor:
54+
"""Numerically-stable softmax in fp64, independent of torch.softmax:
55+
exp(x - max) / sum(exp(x - max)) along `dim`."""
56+
xd = x.double()
57+
e = torch.exp(xd - xd.amax(dim=dim, keepdim=True))
58+
return (e / e.sum(dim=dim, keepdim=True)).to(torch.float32)
59+
60+
61+
def _export(m: torch.nn.Module, x: torch.Tensor):
62+
ep = torch.export.export(m, (x,))
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 TestSoftmax(unittest.TestCase):
77+
def test_export_delegates(self) -> None:
78+
for name, (shape, dim) in CONFIGS.items():
79+
with self.subTest(config=name):
80+
et = _export(SoftmaxModule(dim).eval(), _det_input(shape))
81+
self.assertTrue(
82+
_delegates(et),
83+
f"Expected a VulkanBackend delegate (softmax {name})",
84+
)
85+
86+
def test_golden_matches_fp64(self) -> None:
87+
for name, (shape, dim) in CONFIGS.items():
88+
with self.subTest(config=name):
89+
x = _det_input(shape)
90+
torch.testing.assert_close(
91+
torch.softmax(x, dim=dim),
92+
_fp64_golden(x, dim),
93+
atol=1e-6,
94+
rtol=1e-5,
95+
)
96+
97+
98+
def export_softmax_model(pte_path: str, golden_path: str, input_path: str) -> None:
99+
"""Write the softmax(dim=-1) .pte + fp64 golden (raw LE fp32) + raw LE fp32 input."""
100+
shape = (4, 8, 16)
101+
m = SoftmaxModule(-1).eval()
102+
x = _det_input(shape)
103+
golden = _fp64_golden(x, -1).numpy().astype("<f4")
104+
et = _export(m, x)
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} floats); "
111+
f"input {input_path} ({x.numel()} floats)"
112+
)
113+
114+
115+
if __name__ == "__main__":
116+
unittest.main()

0 commit comments

Comments
 (0)