Benchmarking Issues & Idempotence
Benchmarking Issues and Idempotence in WebGPU Compute Primitives
When evaluating the performance of highly optimized WebGPU compute primitives (like OneSweepSort or DLDFScan), running multiple consecutive trials is standard practice to amortize dispatch overhead and warm up pipeline caches. However, WebGPU’s execution model and the specific mechanics of decoupled lookback introduce unique challenges for benchmark idempotence.
1. The GPU-Side Benchmarking Loop & Atomics
To avoid CPU-GPU round-trip latency, Gridwise records a loop of N dispatches directly inside a single command buffer when trials > 1 (configured via args.trials).
The Problem
Any buffer allocation and initialization commands (such as AllocateBuffer with clearBufferOnReuse: true) are recorded and executed on the CPU before the command buffer is submitted.
- Between consecutive dispatches on the GPU, no CPU-driven clearing commands run.
- If a buffer accumulates state across dispatches (e.g. via
atomicAdd), trial 1 will accumulate into the buffer, and trial 2 will start with the populated buffer, yielding incorrect results or timing.
2. Decoupled Lookback Spine Corruption
Both OneSweepSort (passHist buffer) and DLDFScan (spine buffer) use lookback spines containing workgroup status flags (FLAG_READY, FLAG_INCLUSIVE) and intermediate values to synchronize scanning progress across workgroups.
Even though each workgroup in a new trial will eventually overwrite its slot in the spine (using atomicStore), the lookback synchronization loop relies on spin-waiting for preceding slots to change from 0 (FLAG_NOT_READY) to a ready state.
If the lookback spine is not reset between trials:
- Trial 0 completes: All slots in the spine are left at
FLAG_INCLUSIVE. - Trial 1 starts:
- Workgroup 1 computes its local sum and looks back at
spine[0]to see if Workgroup 0 is finished. - Instead of finding
0(not ready), it immediately readsFLAG_INCLUSIVEleft over from Trial 0. - Workgroup 1 assumes Workgroup 0 has already finished, reads the stale prefix sum from Trial 0, and completes.
- Workgroup 1 computes its local sum and looks back at
- The Consequences:
- Timing skew: The workgroups skip the lookback spin-waiting loop entirely, leading to artificially fast and inaccurate benchmarking times.
- Correctness corruption: If the input dataset changes, the primitive will return corrupted results because it reads stale prefix values from the previous execution.
3. Buffer Reset Strategies & Overhead
To resolve the lookback spine corruption without skewing performance timings, we must carefully evaluate the cost of buffer resetting:
Self-Cleaning Buffers
Some atomic buffers, such as sortBump and scanBump, do not need external resets because the compute shader itself resets the atomic counter back to 0 using atomicStore on the final workgroup dispatch.
Dynamic Reset of Lookback Spines
Because the lookback spines (passHist and spine) are small (e.g., 15.6 KB for a 1,000,000 element scan), they can be cleared on the GPU between dispatches using a lightweight GPU shader (clear_buffer) with zero measurable overhead.
- Sort Solution: Include
"passHist"inresetBuffersForBenchmarkingOnlyinside onesweep.mjs. - Scan Solution: Set
clearBufferOnReuse: trueand addresetBuffersForBenchmarkingOnly: ["spine"]inside scandldf.mjs.
Restoring Input Data
For in-place algorithms like sorting, the input buffer is modified by the sort itself. Restoring the input data via full memory copies (GPU-to-GPU) would double the memory traffic and artificially deflate performance for bandwidth-bound primitives.
- Radix Sort Data Independence: Radix sort execution time is data-independent (always performing the same number of bit-level scatters). Sorting already-sorted data has negligible impact on performance.
- Dynamic Offsets: If different random inputs are required on each trial without memory copy overhead, we can allocate a single large input buffer containing multiple random datasets and update the dynamic binding offset (
setBindGroup) between trials.