Skip to content

Commit 21ecd8f

Browse files
authored
WebGPU: implement aten.arange.start_step (pytorch#22947)
## Problem The Vulkan partitioner claims `aten.arange.start_step`, but the WebGPU runtime has no implementation for it. Any model using `arange` therefore fails at graph build rather than falling back to CPU: ``` E WebGPUBackend.cpp:154] WebGPU graph build failed: WebGPU backend: unsupported op: aten.arange.start_step E method.cpp:132] Init failed for backend VulkanBackend: 0x30 ``` Hit while exporting DINOv2 (`dinov2_vits14`) for WebGPU — its positional-encoding path emits two `arange` nodes. ## Change Adds `runtime/ops/arange/` with fp32 and int32 shader variants writing `start + i * step`. Two details worth calling out: - The element count is taken from the **pre-allocated output tensor**, not recomputed as `(end - start) / step`, so it stays consistent with memory planning. A resize hook recomputes it from the live dims for dynamic shapes. - The **int32 variant** is needed because index arithmetic produces an integer `arange` — DINOv2's is `is_int=1, elem_size=4`. WGSL fixes the storage type at shader-compile time, so this is a separate shader rather than a branch. `start`/`step` are read as static `Int`/`Double` scalars; a SymInt is rejected, since a dynamic bound would make the output length dynamic in a way the fixed dispatch can't honor. ## Testing Verified against eager through a native Dawn/Metal build (`executor_runner` with `EXECUTORCH_BUILD_WEBGPU=ON`), feeding a fixed input and diffing the output: ``` arange: match=True maxdiff=0.000e+00 ``` Export-path tests added in `test/ops/test_arange.py` covering fp32, int, and non-unit step, matching the style of the neighbouring op suites. Full WebGPU op-test suite shows no new failures — the 6 that fail also fail on a clean tree. Authored with assistance from Claude Code.
1 parent e21a7d6 commit 21ecd8f

12 files changed

Lines changed: 610 additions & 7 deletions

File tree

‎backends/vulkan/partitioner/vulkan_partitioner.py‎

Lines changed: 14 additions & 5 deletions
Original file line numberDiff line numberDiff line change
@@ -71,8 +71,14 @@ def __init__(
7171
nn_module_allowlist: Optional[Set[str]] = None,
7272
downcast_64_bit: bool = True,
7373
constant_nodes: Optional[Set[torch.fx.Node]] = None,
74+
op_features_overrides: Optional[Dict[OpKey, OpFeatures]] = None,
7475
) -> None:
7576
super().__init__()
77+
# Backends that consume the Vulkan serialization but implement less of an
78+
# op than the Vulkan registry advertises supply their own features here.
79+
self.op_features_overrides: Dict[OpKey, OpFeatures] = (
80+
op_features_overrides if op_features_overrides is not None else {}
81+
)
7682
self.texture_limits: utils.ImageExtents = texture_limits
7783
self.buffer_limit = buffer_limit
7884
self.require_dynamic_shapes = require_dynamic_shape
@@ -304,17 +310,17 @@ def _check_node_support(self, node: torch.fx.Node) -> bool: # noqa: C901
304310
self.log_skip(node, "permute node of non compatible linear node")
305311
return False
306312

307-
features = None
308-
if target not in vulkan_supported_ops:
313+
features = self.op_features_overrides.get(target)
314+
if features is None:
315+
if target in vulkan_supported_ops:
316+
features = vulkan_supported_ops[target]
309317
# For some ops, i.e. custom ops the name is registered instead of the
310318
# OpOverload object.
311-
if hasattr(target, "name") and target.name() in vulkan_supported_ops:
319+
elif hasattr(target, "name") and target.name() in vulkan_supported_ops:
312320
features = vulkan_supported_ops[target.name()]
313321
else:
314322
self.log_skip(node, "no operator implementation")
315323
return False
316-
else:
317-
features = vulkan_supported_ops[target]
318324

319325
assert features is not None
320326

@@ -401,7 +407,9 @@ def __init__(
401407
operator_allowlist: Optional[List[OpKey]] = None,
402408
nn_module_blocklist: Optional[List[str]] = None,
403409
nn_module_allowlist: Optional[List[str]] = None,
410+
op_features_overrides: Optional[Dict[OpKey, OpFeatures]] = None,
404411
) -> None:
412+
self.op_features_overrides = op_features_overrides
405413
self.options: Dict[str, Any] = {}
406414
if compile_options is not None:
407415
self.options = compile_options
@@ -476,6 +484,7 @@ def partition(self, exported_program: ExportedProgram) -> PartitionResult:
476484
nn_module_blocklist=self.nn_module_blocklist,
477485
nn_module_allowlist=self.nn_module_allowlist,
478486
downcast_64_bit=self.options.get("downcast_64_bit", True),
487+
op_features_overrides=self.op_features_overrides,
479488
constant_nodes={
480489
node
481490
for node in exported_program.graph.nodes

‎backends/webgpu/partitioner/partitioner.py‎

Lines changed: 23 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -8,13 +8,34 @@
88

99
from typing import Any, Callable, Dict, final, List, Optional, Tuple
1010

11+
import executorch.backends.vulkan.utils as vk_utils
12+
1113
import torch
12-
from executorch.backends.vulkan.op_registry import OpKey
14+
from executorch.backends.vulkan.op_registry import OpFeatures, OpKey
1315
from executorch.backends.vulkan.partitioner.vulkan_partitioner import VulkanPartitioner
1416
from executorch.exir.backend.partitioner import Partitioner, PartitionResult
17+
from executorch.exir.dialects._ops import ops as exir_ops
1518
from torch.export import ExportedProgram
1619

1720

21+
def _arange_bounds_are_static(node: torch.fx.Node) -> bool:
22+
# A SymInt start/end/step makes the length dynamic, which the WebGPU kernel
23+
# does not resize for.
24+
return not any(isinstance(arg, torch.fx.Node) for arg in node.args[:3])
25+
26+
27+
# Ops whose WebGPU kernels cover less than the Vulkan registry advertises, so the
28+
# unsupported forms stay on CPU instead of failing at delegate init.
29+
_WEBGPU_OP_FEATURES: Dict[OpKey, OpFeatures] = {
30+
# fp32 and int32 kernels only; int64 is downcast to int32 on device.
31+
exir_ops.edge.aten.arange.start_step: OpFeatures(
32+
inputs_storage=vk_utils.ANY_STORAGE,
33+
inputs_dtypes={torch.float32, torch.int32, torch.int64},
34+
are_node_inputs_supported_fn=_arange_bounds_are_static,
35+
),
36+
}
37+
38+
1839
@final
1940
class WebGPUPartitioner(Partitioner):
2041
"""WebGPU frontend for the Vulkan partitioning and serialization path."""
@@ -33,6 +54,7 @@ def __init__(
3354
operator_allowlist=operator_allowlist,
3455
nn_module_blocklist=nn_module_blocklist,
3556
nn_module_allowlist=nn_module_allowlist,
57+
op_features_overrides=_WEBGPU_OP_FEATURES,
3658
)
3759

3860
def ops_to_not_decompose(

‎backends/webgpu/runtime/WebGPUShaderRegistry.cpp‎

Lines changed: 17 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -13,6 +13,8 @@
1313
#include <executorch/backends/webgpu/runtime/ops/adamw/adamw_step_wgsl.h>
1414
#include <executorch/backends/webgpu/runtime/ops/add/binary_add_wgsl.h>
1515
#include <executorch/backends/webgpu/runtime/ops/addmm/addmm_tiled_wgsl.h>
16+
#include <executorch/backends/webgpu/runtime/ops/arange/arange_int_wgsl.h>
17+
#include <executorch/backends/webgpu/runtime/ops/arange/arange_wgsl.h>
1618
#include <executorch/backends/webgpu/runtime/ops/argmax/arg_reduce_wgsl.h>
1719
#include <executorch/backends/webgpu/runtime/ops/avg_pool2d/avg_pool2d_wgsl.h>
1820
#include <executorch/backends/webgpu/runtime/ops/batch_norm/batch_norm_wgsl.h>
@@ -153,7 +155,7 @@
153155
namespace executorch::backends::webgpu {
154156
namespace {
155157

156-
constexpr std::array<WebGPUShaderInfo, 135> kShaderRegistry = {{
158+
constexpr std::array<WebGPUShaderInfo, 137> kShaderRegistry = {{
157159
{
158160
"abs",
159161
kAbsWGSL,
@@ -196,6 +198,20 @@ constexpr std::array<WebGPUShaderInfo, 135> kShaderRegistry = {{
196198
kApplyRotaryEmbInterleavedWorkgroupSizeY,
197199
kApplyRotaryEmbInterleavedWorkgroupSizeZ,
198200
},
201+
{
202+
"arange",
203+
kArangeWGSL,
204+
kArangeWorkgroupSizeX,
205+
kArangeWorkgroupSizeY,
206+
kArangeWorkgroupSizeZ,
207+
},
208+
{
209+
"arange_int",
210+
kArangeIntWGSL,
211+
kArangeIntWorkgroupSizeX,
212+
kArangeIntWorkgroupSizeY,
213+
kArangeIntWorkgroupSizeZ,
214+
},
199215
{
200216
"arg_reduce",
201217
kArgReduceWGSL,
Lines changed: 192 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,192 @@
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/WebGPUUtils.h>
11+
#include <executorch/backends/webgpu/runtime/ops/OperatorRegistry.h>
12+
#include <executorch/backends/webgpu/runtime/ops/arange/arange_int_wgsl.h>
13+
#include <executorch/backends/webgpu/runtime/ops/arange/arange_wgsl.h>
14+
15+
#include <webgpu/webgpu.h>
16+
17+
#include <cstdint>
18+
#include <stdexcept>
19+
#include <string>
20+
#include <vector>
21+
22+
namespace executorch::backends::webgpu {
23+
24+
namespace {
25+
26+
// Uniform layout shared by both shaders (16 bytes). start/step are
27+
// reinterpreted as i32 by the integer variant, so keep the slots the same
28+
// width.
29+
struct ArangeParams {
30+
uint32_t num_elements;
31+
union {
32+
float f;
33+
int32_t i;
34+
} start;
35+
union {
36+
float f;
37+
int32_t i;
38+
} step;
39+
uint32_t _pad;
40+
};
41+
static_assert(
42+
sizeof(ArangeParams) % 16u == 0u,
43+
"WebGPU parameter blocks must have a 16-byte-aligned size");
44+
45+
// start/end/step arrive as static Int or Double scalars, or as Null when the
46+
// caller omitted them, in which case aten's defaults apply. A SymInt would make
47+
// the output length dynamic, which the fixed dispatch below cannot honor.
48+
// if_omitted == nullptr marks an argument that has no default and must be
49+
// present.
50+
double read_scalar(
51+
WebGPUGraph& graph,
52+
int id,
53+
const char* what,
54+
const double* if_omitted) {
55+
switch (graph.get_value_type(id)) {
56+
case WebGPUGraph::ValueType::Int:
57+
return static_cast<double>(graph.get_int(id));
58+
case WebGPUGraph::ValueType::Double:
59+
return graph.get_double(id);
60+
case WebGPUGraph::ValueType::Null:
61+
if (if_omitted == nullptr) {
62+
throw std::runtime_error(
63+
std::string("arange: ") + what + " is required");
64+
}
65+
return *if_omitted;
66+
default:
67+
throw std::runtime_error(
68+
std::string("arange: dynamic/unsupported ") + what);
69+
}
70+
}
71+
72+
// aten.arange.start_step(start, end, step, *, dtype, layout, device,
73+
// pin_memory) args: [start, end, step, ..., out]. Writes start + i * step over
74+
// the pre-allocated output; the element count comes from the output tensor
75+
// rather than from (end - start) / step so it stays consistent with memory
76+
// planning.
77+
void arange_impl(WebGPUGraph& graph, const std::vector<int>& args) {
78+
if (args.size() < 4) {
79+
throw std::runtime_error("arange: expected at least 4 args");
80+
}
81+
const int out_id = args.at(args.size() - 1);
82+
83+
WGPUDevice device = graph.device();
84+
const auto& out_tensor = graph.get_tensor(out_id);
85+
// Two 4-byte variants: fp32 and i32. A bool output is 1-byte packed and has
86+
// no meaningful arange, so reject it rather than mis-stride the buffer.
87+
if (out_tensor.is_bool || out_tensor.elem_size != 4u) {
88+
throw std::runtime_error("arange: only 4-byte fp32 or int32 output");
89+
}
90+
const bool is_int = out_tensor.is_int;
91+
92+
constexpr double kStartDefault = 0.0;
93+
constexpr double kStepDefault = 1.0;
94+
const double start = read_scalar(graph, args.at(0), "start", &kStartDefault);
95+
const double step = read_scalar(graph, args.at(2), "step", &kStepDefault);
96+
// end does not feed the dispatch (the length comes from the output tensor),
97+
// but it must still be static: a SymInt end changes the live length, and
98+
// nothing here recomputes the output dims the way Vulkan's resize does, so a
99+
// dynamic end would silently keep emitting the max-sized output.
100+
(void)read_scalar(graph, args.at(1), "end", nullptr);
101+
if (step == 0.0) {
102+
throw std::runtime_error("arange: step must be non-zero");
103+
}
104+
105+
// An empty range (e.g. arange(0, 0)) is valid and has nothing to write.
106+
const uint64_t numel = out_tensor.nbytes / 4u;
107+
if (numel == 0) {
108+
return;
109+
}
110+
if (numel > UINT32_MAX) {
111+
throw std::runtime_error("arange: output numel exceeds u32");
112+
}
113+
if (out_tensor.buffer == nullptr) {
114+
throw std::runtime_error("arange: null output buffer");
115+
}
116+
const uint32_t num_elements = static_cast<uint32_t>(numel);
117+
118+
uint32_t wg_size = utils::clamp_workgroup_size(device, kArangeWorkgroupSizeX);
119+
utils::WgCount workgroup_count = utils::compute_2d_workgroup_count(
120+
device, num_elements, wg_size, "arange");
121+
122+
WGPUConstantEntry wg_size_constant = {};
123+
wg_size_constant.key = {"wg_size", WGPU_STRLEN};
124+
wg_size_constant.value = static_cast<double>(wg_size);
125+
126+
ArangeParams params = {};
127+
params.num_elements = num_elements;
128+
if (is_int) {
129+
// Truncating start/step before iterating is torch's integer semantics, not
130+
// an approximation: arange(0.5, 10.0, 1.5, dtype=int32) is [0, 1, ..., 6]
131+
// in eager, and a step that truncates to 0 yields all zeros there too.
132+
// Casting start + i * step per element would give [0, 2, 3, 5, 6, 8, 9]
133+
// instead.
134+
params.start.i = static_cast<int32_t>(start);
135+
params.step.i = static_cast<int32_t>(step);
136+
} else {
137+
params.start.f = static_cast<float>(start);
138+
params.step.f = static_cast<float>(step);
139+
}
140+
WGPUBuffer uniform_buffer =
141+
utils::make_uniform(device, &params, sizeof(ArangeParams));
142+
graph.add_uniform_buffer_bytes(sizeof(ArangeParams));
143+
144+
utils::ComputePipelineBundle bundle = utils::make_compute_pipeline(
145+
device,
146+
is_int ? kArangeIntWGSL : kArangeWGSL,
147+
{
148+
{0,
149+
WGPUBufferBindingType_Storage,
150+
out_tensor.buffer,
151+
out_tensor.nbytes},
152+
{1,
153+
WGPUBufferBindingType_Uniform,
154+
uniform_buffer,
155+
sizeof(ArangeParams)},
156+
},
157+
&wg_size_constant,
158+
1);
159+
160+
const size_t dispatch_idx = graph.add_dispatch(
161+
{bundle.pipeline,
162+
bundle.bind_group,
163+
workgroup_count.x,
164+
"",
165+
workgroup_count.y});
166+
167+
// Dynamic shapes: recompute num_elements/dispatch from the live output dims.
168+
WGPUBuffer params_buf = uniform_buffer;
169+
graph.add_tensor_resize_hook(
170+
out_id,
171+
[out_id, params, wg_size, dispatch_idx, params_buf](WebGPUGraph& g) {
172+
const auto& d = g.cur_dims(out_id);
173+
const uint64_t n = utils::numel_of(d);
174+
ArangeParams p = params;
175+
p.num_elements = static_cast<uint32_t>(n);
176+
wgpuQueueWriteBuffer(g.queue(), params_buf, 0, &p, sizeof(p));
177+
const utils::WgCount wgc = utils::compute_2d_workgroup_count(
178+
g.device(), static_cast<uint32_t>(n), wg_size, "arange(resize)");
179+
g.dispatch_at(dispatch_idx).workgroup_count_x = wgc.x;
180+
g.dispatch_at(dispatch_idx).workgroup_count_y = wgc.y;
181+
});
182+
183+
graph.own_uniform_buffer(uniform_buffer);
184+
}
185+
186+
} // namespace
187+
188+
WEBGPU_REGISTER_OPERATORS {
189+
WEBGPU_REGISTER_OP(aten.arange.start_step, arange_impl);
190+
}
191+
192+
} // namespace executorch::backends::webgpu
Lines changed: 22 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,22 @@
1+
@group(0) @binding(0) var<storage, read_write> output: array<f32>;
2+
3+
struct Params {
4+
num_elements: u32,
5+
start: f32,
6+
step: f32,
7+
_pad: u32,
8+
}
9+
@group(0) @binding(1) var<uniform> params: Params;
10+
11+
override wg_size: u32 = 256;
12+
13+
@compute @workgroup_size(wg_size)
14+
fn main(
15+
@builtin(global_invocation_id) gid: vec3<u32>,
16+
@builtin(num_workgroups) num_workgroups: vec3<u32>) {
17+
let idx = gid.x + gid.y * (num_workgroups.x * wg_size);
18+
if (idx >= params.num_elements) {
19+
return;
20+
}
21+
output[idx] = params.start + f32(idx) * params.step;
22+
}
Lines changed: 22 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,22 @@
1+
@group(0) @binding(0) var<storage, read_write> output: array<i32>;
2+
3+
struct Params {
4+
num_elements: u32,
5+
start: i32,
6+
step: i32,
7+
_pad: u32,
8+
}
9+
@group(0) @binding(1) var<uniform> params: Params;
10+
11+
override wg_size: u32 = 256;
12+
13+
@compute @workgroup_size(wg_size)
14+
fn main(
15+
@builtin(global_invocation_id) gid: vec3<u32>,
16+
@builtin(num_workgroups) num_workgroups: vec3<u32>) {
17+
let idx = gid.x + gid.y * (num_workgroups.x * wg_size);
18+
if (idx >= params.num_elements) {
19+
return;
20+
}
21+
output[idx] = params.start + i32(idx) * params.step;
22+
}

0 commit comments

Comments
 (0)