What Irregularity Costs CUDA C++, Rust, and Triton on a Hash-Blocked GPU Workload

Petr Korolev
Spacial Intelligence Labs
arXiv:2608.08287 · cs.CV · 2026

The result

GPU language comparisons are almost always run on tiled dense linear algebra, where every toolchain is good and the differences are small. We implement the same hash-blocked TSDF fusion kernel five times and measure it on a workload with the opposite character: an open-addressed hash table with compare-exchange insertion, data-dependent per-lane probe depth, and contended scatter.

2026-08-10T21:40:39.786251 image/svg+xml Matplotlib v3.8.3, https://matplotlib.org/
The same two implementations, priced separately on each stage, as a range over all 19 workloads on both GPUs with the median marked. Log scale; CUDA C++ is the reference at 1×. Rust is 0.96–1.18× on the regular stage and 1.02–3.33× on the irregular one; Triton is 1.1–2.5× against 11.2–31.7×.
Language choice is nearly free on the work that is usually benchmarked, and expensive on the work that is not.

The workloads

These are the volumes the timings come from, rendered from the fused result: one lit point per occupied voxel, shaded by the normal the TSDF supplies. Not illustrations of the workloads, but the workloads.

Two of these explain numbers that look strange in the tables. The 20k-point cell is the sweep's outlier everywhere, and the reason is visible: at that density the volume barely reaches its weight threshold, so there is almost no work to do and a fixed per-launch cost dominates whatever the language contributes. And the load-factor cell shows what Triton's bounded probe costs in correctness rather than time. The loss is not scattered noise. It arrives as whole contiguous patches, because the unit that fails to be allocated is a block of 8³ voxels, and a block that loses its probe takes its entire surface with it.


Full integrate path

WorkloadGPU CUDA C++Rust× Triton×
TartanAir, warm volumeRTX 5070 Ti 0.3880.396 1.02× 1.134 2.92×
TartanAir, warm volumeRTX 5060 0.7630.768 1.01× 2.229 2.92×
TartanAir, cold volumeRTX 5070 Ti 0.4720.487 1.03× 1.214 2.57×
TartanAir, cold volumeRTX 5060 0.9040.915 1.01× 2.334 2.58×
Plane, 320k pointsRTX 5070 Ti 0.3630.380 1.05× 0.876 2.41×
Plane, 320k pointsRTX 5060 0.6730.686 1.02× 1.665 2.47×
Sphere, 1.28M pointsRTX 5070 Ti 0.9751.024 1.05× 2.986 3.06×
Sphere, 1.28M pointsRTX 5060 1.7121.735 1.01× 5.706 3.33×
Sphere, 320k pointsRTX 5070 Ti 0.2780.308 1.11× 0.828 2.98×
Sphere, 320k pointsRTX 5060 0.5110.539 1.05× 1.526 2.99×
Allocate plus update, milliseconds, median of three passes on each of two GPUs. Allocate is roughly a tenth of the work, so the totals are dominated by the regular stage. On real depth data Rust is within 1–3% of hand-written CUDA C++.

Why, not just how much

Triton: two costs that compound

There is no per-lane early exit, so a probe loop runs to a compile-time bound and every lane pays worst-case depth. And tl.atomic_cas takes no mask, unlike tl.atomic_add, so lanes that have already resolved must be aimed at a scratch address. That scratch structure has no counterpart in the CUDA implementation, and sizing it badly costs a factor of twenty, while also silently destroying the kernel's ability to use additional streaming multiprocessors, a failure invisible on a single GPU.

Stronger than any ratio: Triton cannot express an unbounded probe at all. The bound must be a compile-time constant, and a bound small enough to compile is small enough to silently lose data at load factors CUDA C++ handles exactly.

Rust: the safe construct is the expensive one

Rust's gap was invisible to every instruction count we could take. The Rust kernel issues fewer branches, fewer memory operations and 12% fewer dynamic compare-exchanges, from a smaller register budget, at identical occupancy. And it was still slower. It does less work, more slowly.

Hardware counters found it in L1 residency: identical requests, but 1.70× the sectors pushed to L2 and an L1 hit rate of 28.9% against 55.9%.

A GPU-scope atomic load must be coherent across SMs. No NVIDIA L1 is coherent across SMs. So the type-correct way to read a shared location bypasses the cache on every access: relaxed removes ordering, not coherence.

Replacing two such reads with plain loads, correct because the algorithm rather than the memory model provides the guarantee, restores every counter to parity. Nothing in the source, the PTX, the machine code or the occupancy reveals this; it is visible only in where the data is permitted to live.

2026-08-10T21:40:40.158602 image/svg+xml Matplotlib v3.8.3, https://matplotlib.org/
What the two-line change moved. Each counter is normalised to CUDA C++ so that quantities in different units share one axis; hollow markers are the Rust kernel before the fix, filled after. L1 hit rate is the one counter here where more is better, so its below-1 position is the bad direction.

The sweep

Two of the four axes were chosen as opposite levers, and they move the Triton ratio in opposite directions, which is the strongest evidence that the mechanism above is the right one. Raising point count over fixed geometry raises contention and Triton rises; spreading the same points over more blocks lowers contention and Triton falls.

2026-08-10T21:40:40.066614 image/svg+xml Matplotlib v3.8.3, https://matplotlib.org/
A cost driven by problem size would slope the same way in both panels. Triton does not, which is what makes contention rather than size the explanation. Rust falls in both, the shape of a fixed per-launch cost being amortised rather than a contention cost. RTX 5070 Ti, log–log.
CellAxisPointsLoad RustTriton
basebaseline 320,0000.035 1.31 17.9
pts-20kpoints 20,0000.034 3.33 17.2
pts-80kpoints 80,0000.035 1.70 14.4
pts-720kpoints 720,0000.035 1.21 21.8
pts-1280kpoints 1,280,0000.035 1.21 24.6
r-0.25extent 320,0000.009 1.50 21.2
r-1.0extent 320,0000.036 1.26 15.5
r-2.0extent 320,0000.069 1.16 11.3
plane-320kshape 320,3560.032 1.22 17.0
lf-sparseloadfactor 320,0000.018 1.48 17.8
tartan-p001real 374,6920.043 1.33 31.7
diner-p000real 393,6240.094 1.33 26.4
tartan-p004real 400,2480.066 1.30 29.5
tartan-p005real 401,6780.108 1.30 24.8
tartan-p003real 405,2690.114 1.25 28.5
tartan-coldreal 407,4440.078 1.23 17.8
tartan-warmreal 407,4440.109 1.24 28.5
diner-p002real 409,6000.021 1.41 30.5
diner-p003real 409,6000.117 1.33 28.6
Allocate ratio to CUDA C++ per cell, RTX 5070 Ti. The widest Rust value is the smallest cell, where the kernel is short enough that launch effects dominate. The highest Triton value is the warm real-data cell, the regime a live pipeline is in for every frame after the first.

What we got wrong

Roughly a dozen measurement errors were caught during this work. Three would have produced a reversed or null conclusion rather than a wrong magnitude, and two survived long enough to be written up:

All three were failures of comparability, not precision. None would have been caught by more repetitions or tighter error bars. Two published figures were also corrected: Triton's gap from 73× to 17.9×, mostly our own badly-sized workaround rather than the language.


A defect found along the way

In the version of cuda-oxide used here, the scoped atomic load and store could not be called at all. Both lowered to LLVM IR that libNVVM rejects, so every call failed the backend verifier in the build mode that produces real kernels with an error naming an LLVM basic block rather than the offending line of Rust. The project's own atomics example does not build in that mode.

The underlying PTX instructions have existed since sm_70, so this was a lowering gap rather than a hardware limit. The fix was contributed with tests, a regression example, and the CI registration whose absence let the defect survive, and is merged upstream. The API is callable in current cuda-oxide.

The maintainer amended it in two respects, both corrections rather than polish. We had made the assembler's memory clobber conditional, omitting it for relaxed ordering; it is unconditional in the merged version, because without it the compiler may reorder plain accesses to the same address across the inline assembly and break the single-thread coherence Rust guarantees whatever the ordering. That does not move the measurements here, which were taken with the clobber present, but the version submitted would have been wrong in a way this benchmark could not have caught.



What was actually built

Every number above comes from fusing one of these. The benchmark is abstract until you can see what a cell is: a sphere at four point densities is not the same workload as a room, and the load-factor cells that look identical in a table are the ones where an arm starts losing data. Pick a cell to load its fused surface.

Select a cell to load it.

Drag to orbit · scroll to zoom · arrow keys when focused. One lit point per occupied voxel cell, which is what a TSDF stores; the raw extracted surface is up to 11.4 million vertices and is reduced here for the page, not for the measurement.


BibTeX

@misc{korolev2026irregularity,
  author        = {Korolev, Petr},
  title         = {What Irregularity Costs: {CUDA} {C++}, {Rust}, and {Triton}
                   on a Hash-Blocked {GPU} Workload},
  year          = {2026},
  eprint        = {2608.08287},
  archivePrefix = {arXiv},
  primaryClass  = {cs.CV},
}