Skip to content

Commit d448fd7

Browse files
committed
[ExecuTorch][WebGPU] aten.index.Tensor test suite (export + native golden)
Pull Request resolved: #20465 Adds the test suite for the aten.index.Tensor op (stacked on the op diff): - test/ops/index/test_index.py: exports a module computing x[idx] through VulkanPartitioner for four configs (reorder/repeat indices over distinct self values, so a wrong-gather is visible), asserts a VulkanBackend delegate with index.Tensor absorbed (not a CPU fallback), and writes per-config .pte + .self/.idx/.golden.bin. - test/native/test_index.cpp: a standalone Dawn binary that loads each .pte, feeds self (fp32) + index (int64 at the program boundary, narrowed to the int32 buffer) and compares the gather against the torch golden at 1e-3, with a single-output shape guard. - Wired into CMake (webgpu_index_test), test/TARGETS (python_unittest test_index), and the Dawn native CI script. ghstack-source-id: 397756255 @exported-using-ghexport @diff-train-skip-merge Differential Revision: [D109479000](https://our.internmc.facebook.com/intern/diff/D109479000/)
1 parent 89ef6fa commit d448fd7

6 files changed

Lines changed: 310 additions & 1 deletion

File tree

backends/webgpu/CMakeLists.txt

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -194,4 +194,5 @@ if(EXECUTORCH_BUILD_WEBGPU_TEST)
194194
target_compile_options(webgpu_op_test_util_test PRIVATE -fexceptions)
195195
set_property(TARGET webgpu_op_test_util_test PROPERTY CXX_STANDARD 17)
196196
endif()
197+
add_webgpu_native_test(webgpu_index_test test/native/test_index.cpp)
197198
endif()

backends/webgpu/scripts/test_webgpu_native_ci.sh

Lines changed: 11 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -45,6 +45,8 @@ DISPATCH_ORDER_DIR="/tmp/dispatch_order"
4545
DISPATCH_ORDER_OK=1
4646
UPDATE_CACHE_DIR="/tmp/update_cache"
4747
UPDATE_CACHE_OK=1
48+
INDEX_DIR="/tmp/index"
49+
INDEX_OK=1
4850
EMBEDDING_MODEL="/tmp/webgpu_embedding_q4gsw.pte"
4951
EMBEDDING_INDICES="/tmp/webgpu_embedding_q4gsw_indices.bin"
5052
EMBEDDING_GOLDEN="/tmp/webgpu_embedding_q4gsw_golden.bin"
@@ -104,6 +106,11 @@ export_update_cache_replay('${UPDATE_CACHE_DIR}')
104106
export_update_cache_negative('${UPDATE_CACHE_DIR}')
105107
" || { echo "WARN: update_cache export failed; skipping update_cache native test"; UPDATE_CACHE_OK=0; }
106108

109+
$PYTHON_EXECUTABLE -c "
110+
from executorch.backends.webgpu.test.ops.index.test_index import export_all_index_models
111+
export_all_index_models('${INDEX_DIR}')
112+
" || { echo "WARN: index export failed; skipping index native test"; INDEX_OK=0; }
113+
107114
# Non-fatal: a failed sdpa export makes the required 4k/8k configs hard-fail in
108115
# webgpu_native_test below (precise per-config error), so don't exit/mask here.
109116
$PYTHON_EXECUTABLE -c "
@@ -136,7 +143,7 @@ cmake \
136143
"${EXECUTORCH_ROOT}"
137144

138145
# ── Build + run every native test target that exists in this tree ────────────
139-
TARGETS=(webgpu_native_test webgpu_dispatch_order_test webgpu_scratch_buffer_test webgpu_update_cache_test)
146+
TARGETS=(webgpu_native_test webgpu_dispatch_order_test webgpu_scratch_buffer_test webgpu_update_cache_test webgpu_index_test)
140147
BIN_DIR="${BUILD_DIR}/backends/webgpu"
141148

142149
# Which targets are defined depends on which diffs are landed (native_test +
@@ -201,6 +208,9 @@ fi
201208
if [[ "${DISPATCH_ORDER_OK}" == "1" && -x "${BIN_DIR}/webgpu_dispatch_order_test" ]]; then
202209
"${BIN_DIR}/webgpu_dispatch_order_test" "${DISPATCH_ORDER_DIR}"
203210
fi
211+
if [[ "${INDEX_OK}" == "1" && -x "${BIN_DIR}/webgpu_index_test" ]]; then
212+
"${BIN_DIR}/webgpu_index_test" "${INDEX_DIR}"
213+
fi
204214
[[ -x "${BIN_DIR}/webgpu_scratch_buffer_test" ]] && "${BIN_DIR}/webgpu_scratch_buffer_test"
205215

206216
echo "=== WebGPU native tests on Dawn: all run targets passed ==="

backends/webgpu/test/TARGETS

Lines changed: 13 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -17,6 +17,19 @@ python_unittest(
1717
],
1818
)
1919

20+
python_unittest(
21+
name = "test_index",
22+
srcs = [
23+
"ops/index/test_index.py",
24+
],
25+
deps = [
26+
"//caffe2:torch",
27+
"//executorch/backends/vulkan/partitioner:vulkan_partitioner",
28+
"//executorch/backends/vulkan:vulkan_preprocess",
29+
"//executorch/exir:lib",
30+
],
31+
)
32+
2033
runtime.python_library(
2134
name = "tester",
2235
srcs = ["tester.py"],
Lines changed: 174 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,174 @@
1+
/*
2+
* Copyright (c) Meta Platforms, Inc. and affiliates.
3+
* All rights reserved.
4+
*
5+
* This source code is licensed under the BSD-style license found in the
6+
* LICENSE file in the root directory of this source tree.
7+
*/
8+
9+
#include <executorch/backends/webgpu/runtime/WebGPUDevice.h>
10+
#include <executorch/extension/module/module.h>
11+
#include <executorch/extension/tensor/tensor.h>
12+
13+
#include <algorithm>
14+
#include <cmath>
15+
#include <cstdio>
16+
#include <cstdlib>
17+
#include <fstream>
18+
#include <string>
19+
#include <vector>
20+
21+
using namespace executorch::backends::webgpu;
22+
using namespace executorch::extension;
23+
using namespace executorch::runtime;
24+
25+
namespace {
26+
27+
// Names mirror test_index.py CONFIGS (self/idx/golden bins written per case).
28+
constexpr const char* kIndexCases[] = {
29+
"index_n16_m5",
30+
"index_n8_rev",
31+
"index_n32_m3",
32+
"index_n4_rep",
33+
};
34+
35+
std::vector<float> read_f32_bin(const std::string& path) {
36+
std::ifstream f(path, std::ios::binary | std::ios::ate);
37+
if (!f) {
38+
return {};
39+
}
40+
const size_t bytes =
41+
static_cast<size_t>(f.tellg()) / sizeof(float) * sizeof(float);
42+
f.seekg(0);
43+
std::vector<float> data(bytes / sizeof(float));
44+
f.read(
45+
reinterpret_cast<char*>(data.data()),
46+
static_cast<std::streamsize>(bytes));
47+
return data;
48+
}
49+
50+
std::vector<int32_t> read_i32_bin(const std::string& path) {
51+
std::ifstream f(path, std::ios::binary | std::ios::ate);
52+
if (!f) {
53+
return {};
54+
}
55+
const size_t bytes =
56+
static_cast<size_t>(f.tellg()) / sizeof(int32_t) * sizeof(int32_t);
57+
f.seekg(0);
58+
std::vector<int32_t> data(bytes / sizeof(int32_t));
59+
f.read(
60+
reinterpret_cast<char*>(data.data()),
61+
static_cast<std::streamsize>(bytes));
62+
return data;
63+
}
64+
65+
bool run_case(const std::string& dir, const char* name) {
66+
printf("\n--- Test: %s ---\n", name);
67+
const std::string base = dir + "/" + name;
68+
std::vector<float> self_data = read_f32_bin(base + ".self.bin");
69+
std::vector<int32_t> idx32 = read_i32_bin(base + ".idx.bin");
70+
std::vector<float> golden = read_f32_bin(base + ".golden.bin");
71+
if (self_data.empty() || idx32.empty() || golden.empty()) {
72+
printf("FAIL: could not read self/idx/golden for %s\n", name);
73+
return false;
74+
}
75+
76+
Module module(base + ".pte");
77+
if (module.load_forward() != Error::Ok) {
78+
printf("FAIL: could not load %s.pte\n", name);
79+
return false;
80+
}
81+
82+
const int32_t n = static_cast<int32_t>(self_data.size());
83+
const int32_t m = static_cast<int32_t>(idx32.size());
84+
auto x = make_tensor_ptr({n}, std::vector<float>(self_data));
85+
// int64 at the program boundary; copy_inputs narrows to the int32 buffer.
86+
std::vector<int64_t> idx64(idx32.begin(), idx32.end());
87+
auto idx = make_tensor_ptr({m}, std::vector<int64_t>(idx64));
88+
89+
auto result = module.forward({EValue(x), EValue(idx)});
90+
if (!result.ok()) {
91+
printf("FAIL: forward failed (error %d)\n", (int)result.error());
92+
return false;
93+
}
94+
95+
const auto& outputs = result.get();
96+
// index.Tensor has exactly one output of shape [num_indices]; fail loud else.
97+
if (outputs.size() != 1 || !outputs[0].isTensor()) {
98+
printf("FAIL: expected exactly one tensor output\n");
99+
return false;
100+
}
101+
const auto& out_tensor = outputs[0].toTensor();
102+
if (out_tensor.dim() != 1 || out_tensor.size(0) != m) {
103+
printf(
104+
"FAIL: output shape mismatch (dim %d size0 %d, expected [%d])\n",
105+
(int)out_tensor.dim(),
106+
(int)(out_tensor.dim() == 1 ? out_tensor.size(0) : -1),
107+
m);
108+
return false;
109+
}
110+
if (static_cast<size_t>(out_tensor.numel()) != golden.size()) {
111+
printf(
112+
"FAIL: output numel %zu != golden %zu\n",
113+
(size_t)out_tensor.numel(),
114+
golden.size());
115+
return false;
116+
}
117+
const float* out_data = out_tensor.const_data_ptr<float>();
118+
119+
float max_abs_err = 0.0f;
120+
float max_rel_err = 0.0f;
121+
for (size_t i = 0; i < golden.size(); i++) {
122+
const float abs_err = std::abs(out_data[i] - golden[i]);
123+
max_abs_err = std::max(max_abs_err, abs_err);
124+
const float denom = std::max(std::abs(golden[i]), 1e-6f);
125+
max_rel_err = std::max(max_rel_err, abs_err / denom);
126+
}
127+
printf(
128+
"Max abs error: %e Max rel error: %e (%zu elements)\n",
129+
max_abs_err,
130+
max_rel_err,
131+
golden.size());
132+
if (max_abs_err > 1e-3f || max_rel_err > 1e-3f) {
133+
printf("FAIL: %s exceeds tolerance 1e-3\n", name);
134+
return false;
135+
}
136+
printf("PASS: %s\n", name);
137+
return true;
138+
}
139+
140+
} // namespace
141+
142+
int main(int argc, char** argv) {
143+
std::string dir = "/tmp/index";
144+
if (argc > 1) {
145+
dir = argv[1];
146+
}
147+
if (const char* env = std::getenv("WEBGPU_INDEX_DIR")) {
148+
dir = env;
149+
}
150+
151+
WebGPUContext ctx;
152+
try {
153+
ctx = create_webgpu_context();
154+
} catch (const std::exception& e) {
155+
printf("SKIP: %s\n", e.what());
156+
return 0;
157+
}
158+
set_default_webgpu_context(&ctx);
159+
printf("WebGPU device acquired (native); case dir: %s\n", dir.c_str());
160+
161+
bool ok = true;
162+
for (const char* name : kIndexCases) {
163+
ok = run_case(dir, name) && ok;
164+
}
165+
166+
set_default_webgpu_context(nullptr);
167+
destroy_webgpu_context(ctx);
168+
169+
if (!ok) {
170+
return 1;
171+
}
172+
printf("\nAll index tests passed\n");
173+
return 0;
174+
}

backends/webgpu/test/ops/index/__init__.py

Whitespace-only changes.
Lines changed: 111 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,111 @@
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.index.Tensor` export + goldens for the WebGPU backend.
8+
9+
Exports the 1D-self advanced-index form `self[idx]` through VulkanPartitioner --
10+
the only delegated index.Tensor (the 2D mask/freqs gathers are CPU fallbacks; see
11+
op_registry.py:1427). It is a flat gather out[i]=self[index[i]]; the int64 index
12+
serializes as int32 (downcast_64_bit). Distinct self values + reorder/repeat
13+
indices make a wrong-gather bug visible. Each config writes `index_<name>.pte`,
14+
`index_<name>.self.bin` (fp32 self), `index_<name>.idx.bin` (int32 index), and
15+
`index_<name>.golden.bin` so the native `test_index` self-discovers them.
16+
"""
17+
18+
import os
19+
import unittest
20+
21+
import torch
22+
23+
from executorch.backends.vulkan.partitioner.vulkan_partitioner import (
24+
VulkanPartitioner,
25+
)
26+
from executorch.exir import to_edge_transform_and_lower
27+
28+
# name -> (self_len, index_values)
29+
CONFIGS = {
30+
"n16_m5": (16, [0, 15, 7, 7, 2]),
31+
"n8_rev": (8, [7, 6, 5, 4, 3, 2, 1, 0]),
32+
"n32_m3": (32, [31, 0, 16]),
33+
"n4_rep": (4, [2, 2, 2, 2, 0, 1]),
34+
}
35+
36+
37+
class IndexModule(torch.nn.Module):
38+
def forward(self, x: torch.Tensor, idx: torch.Tensor) -> torch.Tensor:
39+
return x[idx]
40+
41+
42+
def _inputs(self_len, index_values):
43+
# Distinct self values so a wrong-index gather is visible.
44+
x = torch.arange(self_len, dtype=torch.float32) * 3.0 + 0.5
45+
idx = torch.tensor(index_values, dtype=torch.int64)
46+
return x, idx
47+
48+
49+
def _lower(x, idx):
50+
ep = torch.export.export(IndexModule().eval(), (x, idx))
51+
return to_edge_transform_and_lower(ep, partitioner=[VulkanPartitioner()])
52+
53+
54+
def _export(x, idx):
55+
return _lower(x, idx).to_executorch()
56+
57+
58+
def _delegated(et) -> bool:
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 _op_delegated(edge, op_substr: str) -> bool:
67+
# op must be absorbed into the delegate, not left as a top-level CPU-fallback node.
68+
gm = edge.exported_program().graph_module
69+
return all(op_substr not in str(getattr(n, "target", "")) for n in gm.graph.nodes)
70+
71+
72+
class TestIndex(unittest.TestCase):
73+
def test_export_delegates(self) -> None:
74+
for name, (n, iv) in CONFIGS.items():
75+
with self.subTest(name=name):
76+
edge = _lower(*_inputs(n, iv))
77+
et = edge.to_executorch()
78+
self.assertTrue(
79+
_delegated(et),
80+
f"Expected a VulkanBackend delegate (index {name})",
81+
)
82+
self.assertTrue(
83+
_op_delegated(edge, "index.Tensor"),
84+
f"index.Tensor not delegated (fell back to CPU) for {name}",
85+
)
86+
87+
def test_golden_matches_eager(self) -> None:
88+
for name, (n, iv) in CONFIGS.items():
89+
with self.subTest(name=name):
90+
x, idx = _inputs(n, iv)
91+
torch.testing.assert_close(IndexModule()(x, idx), x[idx])
92+
93+
94+
def export_all_index_models(out_dir: str) -> None:
95+
"""Write index_<name>.pte + .self/.idx/.golden.bin for every config."""
96+
os.makedirs(out_dir, exist_ok=True)
97+
for name, (n, iv) in CONFIGS.items():
98+
x, idx = _inputs(n, iv)
99+
golden = x[idx].contiguous().detach().numpy().astype("<f4")
100+
et = _export(x, idx)
101+
base = os.path.join(out_dir, f"index_{name}")
102+
with open(base + ".pte", "wb") as f:
103+
f.write(et.buffer)
104+
x.numpy().astype("<f4").tofile(base + ".self.bin")
105+
idx.numpy().astype("<i4").tofile(base + ".idx.bin")
106+
golden.tofile(base + ".golden.bin")
107+
print(f"Exported {base}.pte; self {n} -> golden {golden.size} floats")
108+
109+
110+
if __name__ == "__main__":
111+
unittest.main()

0 commit comments

Comments
 (0)