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.
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.
| Workload | GPU | CUDA C++ | Rust | × | Triton | × |
|---|---|---|---|---|---|---|
| TartanAir, warm volume | RTX 5070 Ti | 0.388 | 0.396 | 1.02× | 1.134 | 2.92× |
| TartanAir, warm volume | RTX 5060 | 0.763 | 0.768 | 1.01× | 2.229 | 2.92× |
| TartanAir, cold volume | RTX 5070 Ti | 0.472 | 0.487 | 1.03× | 1.214 | 2.57× |
| TartanAir, cold volume | RTX 5060 | 0.904 | 0.915 | 1.01× | 2.334 | 2.58× |
| Plane, 320k points | RTX 5070 Ti | 0.363 | 0.380 | 1.05× | 0.876 | 2.41× |
| Plane, 320k points | RTX 5060 | 0.673 | 0.686 | 1.02× | 1.665 | 2.47× |
| Sphere, 1.28M points | RTX 5070 Ti | 0.975 | 1.024 | 1.05× | 2.986 | 3.06× |
| Sphere, 1.28M points | RTX 5060 | 1.712 | 1.735 | 1.01× | 5.706 | 3.33× |
| Sphere, 320k points | RTX 5070 Ti | 0.278 | 0.308 | 1.11× | 0.828 | 2.98× |
| Sphere, 320k points | RTX 5060 | 0.511 | 0.539 | 1.05× | 1.526 | 2.99× |
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'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%.
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.
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.
| Cell | Axis | Points | Load | Rust | Triton |
|---|---|---|---|---|---|
base | baseline | 320,000 | 0.035 | 1.31 | 17.9 |
pts-20k | points | 20,000 | 0.034 | 3.33 | 17.2 |
pts-80k | points | 80,000 | 0.035 | 1.70 | 14.4 |
pts-720k | points | 720,000 | 0.035 | 1.21 | 21.8 |
pts-1280k | points | 1,280,000 | 0.035 | 1.21 | 24.6 |
r-0.25 | extent | 320,000 | 0.009 | 1.50 | 21.2 |
r-1.0 | extent | 320,000 | 0.036 | 1.26 | 15.5 |
r-2.0 | extent | 320,000 | 0.069 | 1.16 | 11.3 |
plane-320k | shape | 320,356 | 0.032 | 1.22 | 17.0 |
lf-sparse | loadfactor | 320,000 | 0.018 | 1.48 | 17.8 |
tartan-p001 | real | 374,692 | 0.043 | 1.33 | 31.7 |
diner-p000 | real | 393,624 | 0.094 | 1.33 | 26.4 |
tartan-p004 | real | 400,248 | 0.066 | 1.30 | 29.5 |
tartan-p005 | real | 401,678 | 0.108 | 1.30 | 24.8 |
tartan-p003 | real | 405,269 | 0.114 | 1.25 | 28.5 |
tartan-cold | real | 407,444 | 0.078 | 1.23 | 17.8 |
tartan-warm | real | 407,444 | 0.109 | 1.24 | 28.5 |
diner-p002 | real | 409,600 | 0.021 | 1.41 | 30.5 |
diner-p003 | real | 409,600 | 0.117 | 1.33 | 28.6 |
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.
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.
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.
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.
@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},
}