Skip to content

Commit 789a587

Browse files
committed
[ExecuTorch][WebGPU] Add et_vk.prepack (constant-tensor packing) for E2E weight loading
Pull Request resolved: #20265 Adds the WebGPU backend handler for `et_vk.prepack.default`, the node the VulkanPartitioner wraps around every constant feeding a delegated op so the constant is materialized into its dedicated GPU buffer before inference. For the WebGPU backend's buffer-flat/fp32 model, prepack is an identity layout (same dims, dtype, and bytes), so the handler runs no compute shader: it validates that `src` and `out` match (dims, `elem_size`, `nbytes`, non-null buffers; every check throws fail-loud) and records a one-time `src`->`out` buffer-to-buffer copy via the new `WebGPUGraph::add_prepack_copy`. The recorded copies run once in a new `build()` Phase 4 (after the op-dispatch chain is recorded), mirroring the Vulkan delegate's separate `prepack()` init phase (distinct from per-inference `execute()`). Ordering is guaranteed by the WebGPU queue -- the prepack submit precedes the first `execute()` submit on the same queue, so the copied data is visible without an explicit device poll (Dawn has no `wgpuDevicePoll`, and the backend relies on queue ordering plus the output-map wait elsewhere). `src.elem_size` is the `WebGPUTensor` field added by the embedding op lower in this stack, so prepack stacks above it. ghstack-source-id: 393506814 @exported-using-ghexport Differential Revision: [D108428754](https://our.internmc.facebook.com/intern/diff/D108428754/)
1 parent 49d2499 commit 789a587

8 files changed

Lines changed: 306 additions & 0 deletions

File tree

backends/webgpu/CMakeLists.txt

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -40,6 +40,7 @@ set(WEBGPU_SRCS
4040
runtime/ops/quantized_linear/QuantizedLinear.cpp
4141
runtime/ops/embedding_q4gsw/EmbeddingQ4gsw.cpp
4242
runtime/ops/rope/RotaryEmbedding.cpp
43+
runtime/ops/prepack/Prepack.cpp
4344
)
4445

4546
add_library(webgpu_backend ${WEBGPU_SRCS})

backends/webgpu/runtime/WebGPUGraph.cpp

Lines changed: 17 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -454,6 +454,23 @@ void WebGPUGraph::build(
454454
webgpu_operator_registry().get_op_fn(op_name)(*this, args);
455455
}
456456
}
457+
458+
// Phase 4: one-time constant-prepack copies (mirrors Vulkan prepack phase).
459+
// No poll (Dawn lacks wgpuDevicePoll); queue order syncs it before execute().
460+
if (!prepack_copies_.empty()) {
461+
WGPUCommandEncoderDescriptor enc_desc = {};
462+
WGPUCommandEncoder encoder =
463+
wgpuDeviceCreateCommandEncoder(device_, &enc_desc);
464+
for (const auto& c : prepack_copies_) {
465+
wgpuCommandEncoderCopyBufferToBuffer(
466+
encoder, c.src, 0, c.dst, 0, c.nbytes);
467+
}
468+
WGPUCommandBufferDescriptor cmd_desc = {};
469+
WGPUCommandBuffer cmd = wgpuCommandEncoderFinish(encoder, &cmd_desc);
470+
wgpuQueueSubmit(queue_, 1, &cmd);
471+
wgpuCommandBufferRelease(cmd);
472+
wgpuCommandEncoderRelease(encoder);
473+
}
457474
}
458475

459476
WGPUShaderModule WebGPUGraph::get_or_create_shader(

backends/webgpu/runtime/WebGPUGraph.h

Lines changed: 15 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -50,6 +50,13 @@ struct OutputCopy {
5050
size_t nbytes = 0;
5151
};
5252

53+
// One-time constant-prepack buffer->buffer copy, run at the end of build().
54+
struct PrepackCopy {
55+
WGPUBuffer src = nullptr; // non-owning: owned by tensors_[], freed in dtor
56+
WGPUBuffer dst = nullptr; // non-owning: owned by tensors_[], freed in dtor
57+
size_t nbytes = 0;
58+
};
59+
5360
struct ExecuteConfig {
5461
size_t chunk_size = 0;
5562
size_t initial_chunk_size = 0;
@@ -180,6 +187,11 @@ class WebGPUGraph {
180187
dispatches_.push_back(dispatch);
181188
}
182189

190+
// Record a constant-prepack copy, executed once at the end of build().
191+
void add_prepack_copy(WGPUBuffer src, WGPUBuffer dst, size_t nbytes) {
192+
prepack_copies_.push_back({src, dst, nbytes});
193+
}
194+
183195
void add_uniform_buffer_bytes(size_t bytes) {
184196
uniform_buffer_bytes_ += bytes;
185197
}
@@ -286,6 +298,9 @@ class WebGPUGraph {
286298

287299
std::vector<WebGPUDispatch> dispatches_;
288300

301+
// Constant-prepack copies, executed once at the end of build().
302+
std::vector<PrepackCopy> prepack_copies_;
303+
289304
ExecuteConfig execute_config_;
290305

291306
// Caches for reusing GPU objects across dispatches.
Lines changed: 50 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,50 @@
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/WebGPUGraph.h>
10+
#include <executorch/backends/webgpu/runtime/ops/OperatorRegistry.h>
11+
12+
#include <stdexcept>
13+
14+
namespace executorch::backends::webgpu {
15+
16+
namespace {
17+
18+
// Materialize a constant to its GPU buffer: a dtype-agnostic byte copy.
19+
void prepack_impl(WebGPUGraph& graph, const std::vector<int>& args) {
20+
// et_vk.prepack.default args: [src (constant), out].
21+
if (args.size() != 2) {
22+
throw std::runtime_error("WebGPU prepack: expected 2 args (src, out)");
23+
}
24+
const auto& src = graph.get_tensor(args.at(0));
25+
const auto& out = graph.get_tensor(args.at(1));
26+
27+
if (src.dims != out.dims) {
28+
throw std::runtime_error("WebGPU prepack: src/out shape mismatch");
29+
}
30+
if (src.elem_size != out.elem_size) {
31+
throw std::runtime_error(
32+
"WebGPU prepack: src/out dtype mismatch (cast unsupported)");
33+
}
34+
if (src.nbytes != out.nbytes) {
35+
throw std::runtime_error("WebGPU prepack: src/out byte-size mismatch");
36+
}
37+
if (src.buffer == nullptr || out.buffer == nullptr) {
38+
throw std::runtime_error("WebGPU prepack: null buffer binding");
39+
}
40+
41+
graph.add_prepack_copy(src.buffer, out.buffer, out.nbytes);
42+
}
43+
44+
} // namespace
45+
46+
WEBGPU_REGISTER_OPERATORS {
47+
WEBGPU_REGISTER_OP(et_vk.prepack.default, prepack_impl);
48+
}
49+
50+
} // namespace executorch::backends::webgpu

backends/webgpu/scripts/test_webgpu_native_ci.sh

Lines changed: 14 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -53,6 +53,10 @@ EMBEDDING_GOLDEN="/tmp/webgpu_embedding_q4gsw_golden.bin"
5353
ROPE_MODEL="/tmp/webgpu_rope.pte"
5454
ROPE_XQ_GOLDEN="/tmp/webgpu_rope_xq_golden.bin"
5555
ROPE_XK_GOLDEN="/tmp/webgpu_rope_xk_golden.bin"
56+
PREPACK_MODEL="/tmp/webgpu_prepack.pte"
57+
PREPACK_GOLDEN="/tmp/webgpu_prepack_golden.bin"
58+
PREPACK2_MODEL="/tmp/webgpu_prepack_two_const.pte"
59+
PREPACK2_GOLDEN="/tmp/webgpu_prepack_two_const_golden.bin"
5660

5761
$PYTHON_EXECUTABLE -c "
5862
from executorch.backends.webgpu.test.ops.add.test_add import export_add_model, export_chained_add_model
@@ -75,6 +79,12 @@ from executorch.backends.webgpu.test.ops.rope.test_rope import export_rope_model
7579
export_rope_model('${ROPE_MODEL}', '${ROPE_XQ_GOLDEN}', '${ROPE_XK_GOLDEN}')
7680
" || echo "WARN: rope export failed; webgpu_native_test apply_rotary_emb case self-skips"
7781

82+
$PYTHON_EXECUTABLE -c "
83+
from executorch.backends.webgpu.test.ops.prepack.test_prepack import export_prepack_model, export_prepack_two_const_model
84+
export_prepack_model('${PREPACK_MODEL}', '${PREPACK_GOLDEN}')
85+
export_prepack_two_const_model('${PREPACK2_MODEL}', '${PREPACK2_GOLDEN}')
86+
" || echo "WARN: prepack export failed; webgpu_native_test prepack cases self-skip"
87+
7888
$PYTHON_EXECUTABLE -c "
7989
from executorch.backends.webgpu.test.ops.rms_norm.test_rms_norm import export_rms_norm_cases
8090
export_rms_norm_cases('${RMS_NORM_DIR}')
@@ -171,6 +181,10 @@ if [[ -x "${BIN_DIR}/webgpu_native_test" && -f "${PTE_MODEL}" ]]; then
171181
WEBGPU_TEST_ROPE_MODEL="${ROPE_MODEL}" \
172182
WEBGPU_TEST_ROPE_XQ_GOLDEN="${ROPE_XQ_GOLDEN}" \
173183
WEBGPU_TEST_ROPE_XK_GOLDEN="${ROPE_XK_GOLDEN}" \
184+
WEBGPU_TEST_PREPACK_MODEL="${PREPACK_MODEL}" \
185+
WEBGPU_TEST_PREPACK_GOLDEN="${PREPACK_GOLDEN}" \
186+
WEBGPU_TEST_PREPACK2_MODEL="${PREPACK2_MODEL}" \
187+
WEBGPU_TEST_PREPACK2_GOLDEN="${PREPACK2_GOLDEN}" \
174188
"${BIN_DIR}/webgpu_native_test"
175189
else
176190
echo "(skipping webgpu_native_test: no exported .pte — needs the executorch python wheel)"
Lines changed: 5 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,5 @@
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.
Lines changed: 108 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,108 @@
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+
"""Constant-tensor prepack (`et_vk.prepack`) export + golden for the WebGPU
8+
backend.
9+
10+
The VulkanPartitioner wraps every constant feeding a delegated op in an
11+
`et_vk.prepack.default` node that materializes the constant into a GPU buffer at
12+
init. Model `M(x) = x + w` (w a constant) routes `w` through prepack, so the
13+
delegate must run the prepack copy for the output to equal `x + w` rather than
14+
`x + 0 = x`. The input is a deterministic /16 ramp so the native binary
15+
reconstructs it bit-for-bit; the torch-computed golden is written for the native
16+
binary to compare (it has no ATen).
17+
"""
18+
19+
import unittest
20+
21+
import executorch.backends.vulkan.custom_ops_lib # noqa: F401
22+
23+
import torch
24+
from executorch.backends.vulkan import VulkanPartitioner
25+
from executorch.exir import to_edge_transform_and_lower
26+
27+
# 4x4 constant weight, small enough to dump and reason about by hand.
28+
N = 4
29+
30+
31+
class _AddConst(torch.nn.Module):
32+
def __init__(self) -> None:
33+
super().__init__()
34+
# arange weight: non-zero everywhere so an unrun prepack (out = x + 0 = x)
35+
# is unambiguously distinguishable from a correct one (out = x + w).
36+
self.w = torch.nn.Parameter(
37+
torch.arange(N * N, dtype=torch.float32).reshape(N, N)
38+
)
39+
40+
def forward(self, x: torch.Tensor) -> torch.Tensor:
41+
return x + self.w
42+
43+
44+
class _AddTwoConst(torch.nn.Module):
45+
# Two constants => two prepack nodes (the multi-copy path E2E Llama needs);
46+
# add-only so it stays delegated with just this stack's registered ops.
47+
def __init__(self) -> None:
48+
super().__init__()
49+
self.w1 = torch.nn.Parameter(
50+
torch.arange(N * N, dtype=torch.float32).reshape(N, N)
51+
)
52+
self.w2 = torch.nn.Parameter(
53+
torch.arange(N * N, dtype=torch.float32).reshape(N, N) * 0.5 - 3.0
54+
)
55+
56+
def forward(self, x: torch.Tensor) -> torch.Tensor:
57+
return x + self.w1 + self.w2
58+
59+
60+
def _inputs() -> tuple[torch.Tensor]:
61+
# ((i % 13) - 6) / 16: exact in fp32, matches test_webgpu_native.cpp.
62+
idx = torch.arange(N * N, dtype=torch.int64)
63+
x = (((idx % 13) - 6).to(torch.float32) / 16.0).reshape(N, N)
64+
return (x,)
65+
66+
67+
def _export(model, inputs):
68+
ep = torch.export.export(model.eval(), inputs)
69+
return to_edge_transform_and_lower(
70+
ep, partitioner=[VulkanPartitioner()]
71+
).to_executorch()
72+
73+
74+
class TestPrepack(unittest.TestCase):
75+
def test_export_delegates(self) -> None:
76+
et = _export(_AddConst(), _inputs())
77+
found = any(
78+
d.id == "VulkanBackend"
79+
for plan in et.executorch_program.execution_plan
80+
for d in plan.delegates
81+
)
82+
self.assertTrue(found, "Expected a VulkanBackend delegate (x + w fusion)")
83+
84+
85+
def _write(model, pte_path: str, golden_path: str) -> None:
86+
(x,) = _inputs()
87+
golden = model.eval()(x)
88+
et = _export(model, (x,))
89+
with open(pte_path, "wb") as f:
90+
f.write(et.buffer)
91+
golden.detach().numpy().astype("<f4").tofile(golden_path)
92+
print(f"Exported {pte_path}; golden {golden_path} ({golden.numel()} floats)")
93+
94+
95+
def export_prepack_model(pte_path: str, golden_path: str) -> None:
96+
"""Write the x + w .pte + torch golden (raw LE fp32). One prepacked constant.
97+
The input is a /16 ramp reconstructed in the native test."""
98+
_write(_AddConst(), pte_path, golden_path)
99+
100+
101+
def export_prepack_two_const_model(pte_path: str, golden_path: str) -> None:
102+
"""Write the x + w1 + w2 .pte + golden. Two prepacked constants, exercising
103+
the multi-copy path."""
104+
_write(_AddTwoConst(), pte_path, golden_path)
105+
106+
107+
if __name__ == "__main__":
108+
unittest.main()

backends/webgpu/test/test_webgpu_native.cpp

Lines changed: 96 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -633,6 +633,76 @@ static bool test_rope(
633633
return true;
634634
}
635635

636+
static bool test_prepack(
637+
const std::string& model_path,
638+
const std::string& golden_path,
639+
const std::string& label = "x + const w") {
640+
// et_vk.prepack copy vs golden; unrun copy leaves zeros. See test_prepack.py.
641+
constexpr int n = 4;
642+
constexpr int numel = n * n;
643+
printf("\n--- Test: prepack (%s, %dx%d) ---\n", label.c_str(), n, n);
644+
645+
Module module(model_path);
646+
auto err = module.load_forward();
647+
if (err != Error::Ok) {
648+
printf("FAIL: could not load forward method (error %d)\n", (int)err);
649+
return false;
650+
}
651+
printf("Model loaded: %s\n", model_path.c_str());
652+
653+
std::vector<float> golden = load_golden(golden_path, numel);
654+
if (golden.empty()) {
655+
printf("FAIL: could not load golden %s\n", golden_path.c_str());
656+
return false;
657+
}
658+
659+
// ((i % 13) - 6) / 16: exact in fp32, matches test_prepack.py::_inputs.
660+
std::vector<float> x_data(numel);
661+
for (int i = 0; i < numel; i++) {
662+
x_data[i] = static_cast<float>((i % 13) - 6) / 16.0f;
663+
}
664+
auto x = make_tensor_ptr({n, n}, std::vector<float>(x_data));
665+
666+
auto result = module.forward({EValue(x)});
667+
if (!result.ok()) {
668+
printf("FAIL: forward failed (error %d)\n", (int)result.error());
669+
return false;
670+
}
671+
const auto& outputs = result.get();
672+
if (outputs.empty() || !outputs[0].isTensor()) {
673+
printf("FAIL: no tensor output\n");
674+
return false;
675+
}
676+
const auto& out_tensor = outputs[0].toTensor();
677+
if (out_tensor.numel() != numel) {
678+
printf(
679+
"FAIL: output numel %zu != expected %d\n",
680+
(size_t)out_tensor.numel(),
681+
numel);
682+
return false;
683+
}
684+
const float* out_data = out_tensor.const_data_ptr<float>();
685+
686+
float max_abs_err = 0.0f, max_rel_err = 0.0f;
687+
for (int i = 0; i < numel; i++) {
688+
const float ae = std::abs(out_data[i] - golden[i]);
689+
max_abs_err = std::max(max_abs_err, ae);
690+
max_rel_err =
691+
std::max(max_rel_err, ae / std::max(std::abs(golden[i]), 1e-6f));
692+
}
693+
printf(
694+
"Max abs error: %e Max rel error: %e (checked %d elements)\n",
695+
max_abs_err,
696+
max_rel_err,
697+
numel);
698+
if (max_abs_err > 1e-3f || max_rel_err > 1e-3f) {
699+
printf("FAIL: prepack exceeds tolerance 1e-3\n");
700+
return false;
701+
}
702+
printf("PASS: prepack test\n");
703+
return true;
704+
}
705+
636706
// Reconstruct _ramp_input bit-for-bit, run the op, compare to the fp64 golden.
637707
static bool test_q4gsw_config(
638708
const Q4gswConfig& cfg,
@@ -1681,6 +1751,22 @@ int main(int argc, char** argv) {
16811751
rope_xk_golden_path = env;
16821752
}
16831753

1754+
std::string prepack_model_path, prepack_golden_path;
1755+
if (const char* env = std::getenv("WEBGPU_TEST_PREPACK_MODEL")) {
1756+
prepack_model_path = env;
1757+
}
1758+
if (const char* env = std::getenv("WEBGPU_TEST_PREPACK_GOLDEN")) {
1759+
prepack_golden_path = env;
1760+
}
1761+
1762+
std::string prepack2_model_path, prepack2_golden_path;
1763+
if (const char* env = std::getenv("WEBGPU_TEST_PREPACK2_MODEL")) {
1764+
prepack2_model_path = env;
1765+
}
1766+
if (const char* env = std::getenv("WEBGPU_TEST_PREPACK2_GOLDEN")) {
1767+
prepack2_golden_path = env;
1768+
}
1769+
16841770
// SDPA sweep: configs self-discover their sdpa_<name>.pte/.golden.bin under
16851771
// this directory (default "" = the embedded-file root / cwd). Set
16861772
// WEBGPU_TEST_SDPA_DIR to point at the exported .pte directory (e.g. /tmp/).
@@ -1747,6 +1833,16 @@ int main(int argc, char** argv) {
17471833
ok;
17481834
}
17491835

1836+
if (!prepack_model_path.empty() && !prepack_golden_path.empty()) {
1837+
ok = test_prepack(prepack_model_path, prepack_golden_path) && ok;
1838+
}
1839+
1840+
if (!prepack2_model_path.empty() && !prepack2_golden_path.empty()) {
1841+
ok = test_prepack(
1842+
prepack2_model_path, prepack2_golden_path, "x + w1 + w2") &&
1843+
ok;
1844+
}
1845+
17501846
bool sdpa_ran = false;
17511847
bool sdpa_ok = test_sdpa_sweep(sdpa_dir, &sdpa_ran);
17521848
if (sdpa_ran) {

0 commit comments

Comments
 (0)