Repository navigation
make_buffer_copy leaves an unfoldable gap after skipped extent-1 dimensions #9563
Description
Description
make_buffer_copy leaves an unfoldable gap after skipped extent-1 dimensions ... this breaks WebGPU 8/16-bit copies
Environment: Halide 22.0.0.dev444 (pip wheel), WebGPU backend via Dawn 74c5edcc (ABI-matched to mini_webgpu.h), macOS 15 arm64 (Dawn on Metal). The bug is in shared runtime code and affects every GPU backend; it is only incorrect on WebGPU (elsewhere it is a performance bug).
Summary
make_buffer_copy() in src/runtime/device_buffer_utils.h inserts non-trivial dimensions into the device_copy sorted by destination stride, but bounds the insertion search by the source dimension index i instead of by the number of dimensions inserted so far. When an extent-1 dimension is skipped before a later dimension, the later dimension is inserted after an unused slot (extent = 1, stride = 0). That slot sits at position 0, so the fold loop (chunk_size == src_stride_bytes[0]) never fires and a dense buffer is copied one element (or one row) at a time instead of as a single chunk.
Root cause
for (int i = 0; i < dst->dimensions; i++) {
if (dst->dim[i].extent == 1) continue; // skipped -> slot i stays {1, 0, 0}
...
for (insert = 0; insert < i; insert++) { // BUG: should be < number already inserted
if (dst_stride_bytes < c.dst_stride_bytes[insert] && dst_stride_bytes != 0) break;
}
// empty slots have stride 0, so the comparison is false and insert == iExample: a width-1 single-channel image {x: extent 1, stride 1}, {y: extent H, stride 1}. Dim 0 is skipped, dim 1 is inserted at slot 1, slot 0 is {extent 1, stride 0}. chunk_size (1 byte) != src_stride_bytes[0] (0), so nothing folds and the copy issues H separate 1-byte copies. Any dense buffer whose extent-1 dimension precedes a non-trivial one (e.g. a single-row {x: W}, {y: 1}, {c: 3} planar buffer) hits the same path.
Why it is a correctness bug on WebGPU
WebGPU requires buffer-to-buffer copy offsets and sizes to be multiples of 4 bytes. The WebGPU runtime rounds the copy size up to 4, so each per-element copy of a u8/s8/u16/s16 value either fails validation or writes the neighbouring 1–3 bytes, corrupting the output. A single folded chunk would be aligned (or at least only have one tail).
Separately, the WebGPU backend should handle genuinely non-dense 8/16-bit copies (e.g. crops) by staging through an aligned span rather than issuing unaligned per-element copies.
Reproducing case
## Reproducer
Any pipeline on the `webgpu` target with an 8- or 16-bit output, copied back with `copy_to_host()`/`halide_copy_to_host`, where the buffer has an extent-1 dimension that precedes a non-trivial one, e.g. a `Buffer<uint8_t>(1, 64)` (width 1) or a planar `Buffer<uint8_t>(64, 1, 3)` (one row). Output bytes differ from the CPU target / Dawn reports `Copy size ... is not a multiple of 4`.
## Expected vs actual
- Expected: dense buffer is copied as one contiguous chunk regardless of extent-1 dimensions.
- Actual: one copy per element/row; on WebGPU, misaligned copies that fail or corrupt adjacent bytes.How did you get Halide?
pip (halide)
Halide version
22.0.0.dev444
Halide commit (if known)
No response
Target
arm-64-macos-arm_dot_prod-arm_fp16-webgpu
Operating system
MacOS Tahoe 26.6.2
Additional context
Suggested fix
Track the number of inserted dimensions and bound the search by it:
int inserted = 0;
for (int i = 0; i < dst->dimensions; i++) {
if (dst->dim[i].extent == 1) continue;
...
int insert;
for (insert = 0; insert < inserted; insert++) { ... }
for (int j = inserted; j > insert; j--) { /* shift */ }
... ; inserted++;
}
Activity