Skip to content

CubeCL GPU backend: kernels as Rust, evaluated on CUDA/Metal/RADV - #99

Merged
wasabipesto merged 37 commits into
wasabipesto:mainfrom
wasabipesto-bot:cubecl-backend
Aug 19, 2026
Merged

CubeCL GPU backend: kernels as Rust, evaluated on CUDA/Metal/RADV#99
wasabipesto merged 37 commits into
wasabipesto:mainfrom
wasabipesto-bot:cubecl-backend

Conversation

@wasabipesto-bot

@wasabipesto-bot wasabipesto-bot commented Aug 17, 2026

Copy link
Copy Markdown
Contributor

Note

Stacked on #96 — the first 11 commits here are #96's (vulkan-backend); only the last 6 are new. Review from Add a CubeCL detailed-mode backend for evaluation against the hand-written kernels onward.

What this is

An evaluation of CubeCL as the GPU kernel layer, motivated by auditability: the detailed-mode kernel is one #[cube] Rust function replacing ~500 lines of WGSL string generation (and, potentially, the CUDA C codegen too). Same per-base constants from gpu_config, same 4-copy workgroup histogram, same near-miss records, same FieldResults. Per-base specialization is #[comptime] — CubeCL JITs one kernel per base at first launch, exactly like the NVRTC/naga paths.

Two runtime targets behind one kernel source:

  • --gpu-backend cubecl — wgpu (Vulkan on Linux, Metal on macOS)
  • --gpu-backend cubecl-cuda — native CUDA runtime

The port also implements the per-device chunk-scan width #96 lists as future work: a comptime bool selects wide u64 chunks on CUDA vs split16 on wgpu (c1ae5e5).

Correctness

CPU parity tests (distribution + near-miss, bases 10/40/62/80) pass exactly on every device and runtime tested:

device runtime result
lavapipe (CI-style software Vulkan) wgpu exact
RTX 4060 native CUDA exact, 3/3 repeat runs
Apple M4 wgpu → Metal exact
RX 9070 XT (RADV, Mesa 26.0.4) wgpu → Vulkan exact

The hand-written backends' own device suites (detailed, niceonly, prefilter mirror) also pass on all of the above, so both arms of each benchmark were parity-checked on the same silicon.

Benchmarks

nice_client detailed --benchmark --benchmark-secs 60 --gpu, 3 interleaved rounds per arm (A,B,A,B,A,B), median-of-3. Spreads ≤0.8% everywhere.

device hand backend (n/s, b40 / b50) CubeCL (n/s, b40 / b50) ratio
RTX 4060 (vs hand-CUDA) 5.42e9 / 3.92e9 4.10e9 / 2.92e9 0.75–0.76
Apple M4 (vs hand-WGSL via MoltenVK) 6.68e8 / 3.91e8 5.75e8 / 3.36e8 0.86
RX 9070 XT (vs hand-WGSL, RADV) 3.89e9 / 2.40e9 5.56e9 / 3.31e9 1.38–1.43

Context:

  • On NVIDIA, CubeCL-CUDA at 0.75× of hand-CUDA still beats the hand-WGSL Vulkan backend on the same card by ~2.5× (Add Vulkan GPU backend #96 measured hand-Vulkan at 0.29–0.34× of CUDA on the 4060).
  • On the 9070 XT, CubeCL is ~40% faster than the hand-WGSL backend. CubeCL b40 on the 9070 XT (5.56e9) edges out hand-CUDA on the 4060 (5.42e9).
  • The chunk-width choice matters: with split16 (wrong flavor) on CUDA the ratio was 0.46–0.58; the comptime wide-chunk fix closed half the gap. Prime suspect for the remaining ~25% on CUDA: CubeCL local Arrays landing in local memory where the hand kernel forces scalar registers (checkable via CUBECL_DEBUG_LOG=1).

Findings along the way

  • MoltenVK fix for the hand backend (988a902): Add Vulkan GPU backend #96's ash instance creation didn't opt into VK_KHR_portability_enumeration, so the hand-Vulkan backend found no device on macOS at all. Fixed with runtime detection (no-op on Linux/Windows); all 6 device tests then pass through MoltenVK 1.4.2 on an M4, including shaderInt64.
  • Negative result: the wide-u64 chunk flavor does not compile on Metal even though wgpu advertises SHADER_INT64 — naga (wgpu 29) emits an ambiguous metal::select(ulong, uint, bool) in its u64 checked-division guard. Split16 stays the wgpu flavor for a concrete reason; recheck on future naga releases.
  • Alpha texture: cubecl 0.10 needed an OnceLock guard around non-idempotent init, host API drifted from docs in a few places, and the wasm build needs three dependency-feature unifications. All solvable in minutes, none blocking, but worth knowing.

Update 2026-08-17 — feature restructure, auto order, CI

  • Features restructured (a845244): per-backend flags cuda / vulkan / cubecl / cubecl-cuda under a new gpu umbrella that enables all of them. Release recipe is two builds: default features = lightweight CPU-only binary, --features gpu = one binary that runs on CPU or any supported GPU. Verified the full umbrella builds on a machine with no CUDA toolkit. CUDA-specific _gpu names renamed to _cuda (client_process_cuda, CudaContext, process_*_cuda, …); backend-neutral names (gpu_config, gpu_niceonly, --gpu-backend) keep theirs.
  • auto now ranks by measured speed: CUDA → CubeCL → Vulkan. In niceonly mode auto skips CubeCL (no niceonly kernel yet) and falls through to Vulkan; explicitly named backends still fail fatally rather than falling back.
  • CI device parity (01d4763): new gpu-parity.yml runs the device suites on every backend a hosted runner can drive — lavapipe on Linux (vulkan + cubecl) and macOS arm64 (cubecl via Metal, hand-WGSL via MoltenVK using the new portability path). The workflow header notes inline that cuda/cubecl-cuda cannot run in CI (NVIDIA silicon required); their NVRTC compile-tests still run in the normal suite.

Update — niceonly ported

0c9665a ports niceonly: the kernel does the same on-device candidate reconstruction, offset reduction, lane tiling, and low-digit modular prefilter as the generated WGSL, with the stride modulus/residue count as comptime parameters. The backend-neutral tiling helpers move from vulkan/codegen.rs into gpu_niceonly (the Vulkan backend re-imports them). Device tests mirror the Vulkan suite — CPU parity at bases 10/12/25/40/45/62/80, 69-in-base-10 through the whole pipeline, a CUDA-runtime variant, and a prefilter probe against an independent u128 host mirror at every lane width (the v3.2.14 over-rejection class is untestable any other way). All pass on lavapipe; prefilter survivor counts match the hand-WGSL filter exactly. auto drops its niceonly carve-out.

Update — round 2 benchmarks: detailed + niceonly on all three devices

Same interleaved 3-round methodology at head 16779af, parity suites first (all pass everywhere they can run; hardware.gpu_backend confirmed in reports).

Detailed reproduces round 1 on every device: 1.43/1.38 (9070 XT, vs hand-WGSL), 0.86 (M4, vs MoltenVK), 0.75 (4060 cubecl-cuda vs hand-CUDA).

Niceonly: the fresh port loses to the hand backends everywhere (cubecl/hand medians):

scenario 9070 XT M4 4060 (vs hand-CUDA)
b40_msd_strong 0.67 0.53 0.57
b40_msd_weak 0.70 0.45 0.56
b50_msd_weak 0.73 0.50 0.27
b50_residue_dense 0.73 0.58 0.26
b52_msd_weak 0.74 0.51 0.24

The 4060 gap is strongly base-dependent while hand-CUDA holds its rate; ranked suspects: per-dispatch client.create() of the descriptor buffers in the CubeCL RangeSink (both hand backends reuse persistent buffers), the flag-guarded early exit vs the hand kernels' true early return, and the lane tiling vs hand-CUDA's fixed warp. Correctness unaffected — parity exact everywhere. This folds into the perf-chase item below, which is now clearly load-bearing for the niceonly half of the backend-lineup decision.

Update — perf chase closed the gap; review blocker fixed

Four A/B-verified root causes on an RTX 4060, parity-checked at every step (fee54a8b825b2e): the per-field stride-table rebuild (now a per-base plan cache), the pass-loop copies in the full check, div+mod pairs (nvcc lowers each u64 constant division independently — remainders are now mul-sub like the hand kernels), and ptxas register-promoting the scan scratch into compare/select chains (now shared memory). Record run, 3 interleaved rounds:

scenario cubecl-cuda / hand-CUDA
b40 / b50 detailed 1.03 / 1.07
b40 msd_strong / msd_weak 1.16 / 1.22
b50 msd_weak / residue_dense 0.97 / 0.96
b52 msd_weak 0.91

Review response (b825b2e): the driver-without-toolkit blocker is fixed — new_cuda proves the runtime with a smoke kernel + verified readback before reporting success (verified both ways by stashing libnvrtc on the test instance), and detailed mode checks histogram conservation so silently-dropped device work can never report results on any runtime. Also: standalone --features cubecl-cuda compiles, and the wgpu adapter log/telemetry includes the resolved graphics API (AutoGraphicsApi may pick DX12 on Windows).

Note for the wgpu columns: the mul-sub and shared-memory changes affect WGSL codegen too and postdate the review's RADV/Intel runs — those numbers need a re-measure.

Remaining before this is mergeable

  • Port niceonly mode to CubeCL (0c9665a) — RangeSink pipeline, prefilter included, probe-tested; auto carve-out removed
  • Backend lineup decided (64c14a7, e493c90): auto picks the measured-fastest order per mode — detailed cubecl-cuda → cubecl → cuda, niceonly cuda → cubecl — and hand-Vulkan is now an experimental opt-in feature outside the gpu umbrella (every platform it serves is covered by cubecl; build with --features gpu,vulkan while its promote-or-remove fate is decided). Hand-CUDA stays as the niceonly-b50+ leader and independent oracle
  • Resolve the cudarc bump (1f6260c): cuda-12080 selects the compiled binding surface, not a driver floor — dynamic loading resolves symbols lazily, the hand-CUDA path calls nothing newer than 12.0, and cubecl-cuda's 12.8 declarations are never launched by our kernels (parity-verified on silicon). Rationale now lives in the Cargo.toml comments
  • Add Vulkan GPU backend #96 coordination: resolved — Add Vulkan GPU backend #96 is closed; this PR carries the final form of that work
  • Pin the cubecl version (1f6260c): =0.10.0 with the bump policy (deliberate, parity-suite-gated) in the manifest comment
  • CI: device parity jobs for every CI-drivable backend (01d4763)
  • Docs: README feature/--gpu-backend documentation (a845244)
  • ? Robust GPU model names when the driver reports a generic string (e.g. append PCI vendor:device IDs, optionally mapped through pci.ids) — RADV reported the true "AMD Radeon RX 9070 XT" name in testing, so this is a hedge for older Mesa / other drivers
  • Perf chase (CUDA): cubecl-cuda now beats hand-CUDA in detailed (1.03–1.07) and b40 niceonly (1.16–1.22); b50+/b52 niceonly at 0.91–0.97
  • wgpu re-measure (round 3, head 06ddd5e): detailed cubecl/hand-vulkan 1.90 (RX 9070 XT) and 1.17–1.20 (M4); niceonly wins RADV b50+ (1.28–1.38), trails RADV b40 (0.85–0.91) and Metal (0.55–0.79). The M4 prefilter probe caught a second Metal-only naga-MSL miscompile (mul-sub in the prefilter sites; reverted there, 06ddd5e)
  • Watchdog fix from review applied with its regression test (ffe33a6), plus the two defects it found in b825b2e's conservation check (duplicated ensure in the drain branch; runtime-appropriate failure hint)
  • --gpu-device for wgpu (70d753e): new --gpu-wgpu-device / NICE_GPU_WGPU_DEVICE takes CubeCL's own device spelling (DiscreteGpu(0), IntegratedGpu(1), Cpu, …) — a separate selector because --gpu-device indexes per-backend namespaces and no flat ordinal is right for every backend on a multi-GPU box; the chosen adapter + graphics API are always logged
  • Browser-readiness pre-landed (8a132db, 70d753e): wasm32 dependency unification (the graph had regressed), new_default_async (browsers cannot init synchronously), async detailed path via read_async (sync entry wraps the same body, so native device tests cover it), web-time Instant. The coming wasm/WebGPU PR should only need its u32-only kernel variant (own module) and wasm-client plumbing
  • Docker tags: decided — one -gpu image with all backends, no CUDA-only variant (simplicity beats 8 MiB)
  • ? Browser/wasm follow-up PR: u32-only kernel variant (no SHADER_INT64 in WebGPU; the evaluation probe proved parity for that shape) + wasm-client integration — core prep above means it should not touch the backend files

Benchmark raw data (per-round stdout + JSON reports for all three devices) is archived outside the repo; happy to attach it here if useful.

Janzert and others added 29 commits August 5, 2026 15:01
The CUDA path is NVIDIA-only. This adds a second backend that runs on any
Vulkan 1.2 device with shaderInt64 -- AMD, Intel, NVIDIA, and llvmpipe --
selected at runtime with --gpu-backend {auto,cuda,vulkan}.

Both cudarc and ash dlopen their driver, so one binary can carry both
backends and require neither at build time. `auto` tries CUDA first, so
behaviour on an NVIDIA machine is unchanged; an explicitly named backend
that fails to initialize is fatal rather than silently falling back to a
much slower path.

Detailed mode only for now; niceonly falls back to the CPU. The detailed
kernel takes no input buffers (each thread derives its own n), so it needs
neither the MSD prefix pipeline nor the stride residue table on-device.

Kernels are generated as WGSL per base and compiled to SPIR-V by naga at
runtime -- the same JIT-per-base design NVRTC gives the CUDA path, which
matters because every divisor in the hot loop must be a compile-time
constant.

The one place the port could not be literal is the scan_digits chunk
split. nvcc strength-reduces `u64 / CHUNK_DIV` to a multiply-high; ACO
does that only for 32-bit divisors (NIR's nir_opt_idiv_const is width
limited), and expands the 64-bit form into a ~220-instruction restoring
shift-subtract loop. So CHUNK_DIV is kept under 2^16 and the split is done
as two 32-bit constant divisions over 16-bit halves, which is exact.
Measured on base 40 (Radeon 860M): 3.88e8 vs 1.36e8 candidates/s, i.e.
2.85x, despite needing ~1.7x more chunk iterations; shader code size
halves and the division expansion leaves the ISA entirely.

Per-base constants move to a new backend-neutral gpu_config module, since
client_process_gpu is cfg(feature = "gpu") and unreachable from a Vulkan
build. The CUDA path keeps every one of its existing tests.

Measured end to end, 1e9 numbers at base 40: 4.33e8/s on the integrated
Radeon 860M against 6.50e7/s for the 16-thread CPU on the same machine.
CPU/GPU parity is exact (distribution and near-miss list) at bases 10, 40,
62 and 80 -- and unlike the CUDA parity tests, it also passes against
lavapipe, so correctness can be checked with no GPU at all.

Assisted-by: Claude Code:claude-opus-5
`--gpu-backend auto` is meant to try CUDA and fall through to Vulkan, but
cudarc panics rather than returning an error when libcuda.so is absent
(`panic_no_lib_found` returns `!`). That is the ordinary case on a machine
with no NVIDIA driver -- precisely the case the fallback exists for -- so
`nice_client --gpu` died with cudarc's 20-line message instead of using
the Vulkan backend sitting right there in the same binary.

Wrap CUDA init in catch_unwind with the panic hook silenced for the
duration, so a routine fallback is quiet and the reason is still available
at debug level. Upstream catches the same panic for the same reason in
nvrtc_compiles_kernels_for_all_supported_bases.

An explicit --gpu-backend cuda still fails hard, now with the underlying
message and a pointer at --gpu-backend vulkan, rather than a panic.

This relies on unwinding; the workspace does not set panic = "abort".

Assisted-by: Claude Code:claude-opus-5
Bases with b % 4 == 3 have an empty residue set R_b. That means there are
provably no solutions -- but it also means StrideTable's
first_valid_at_or_after indexes valid_residues[idx] on an empty vec and
panics, so the answer has to be decided before any table is built.

process_range_niceonly_vulkan built one unconditionally. The CUDA path has
the same guard inside process_range_niceonly_gpu; this puts it ahead of the
CPU fallback rather than after it, so it also covers bases the GPU itself
cannot take.

The server never hands out such bases, so this was not reachable in
production -- but --benchmark and any direct call are.

Also document --gpu-backend in the README, note that the two backend
features are additive and need neither library at build time, and build
all three GPU feature combinations in `just test`.

Assisted-by: Claude Code:claude-opus-5
Ports niceonly_ranges_kernel to WGSL and lifts the host side of the
niceonly path out of client_process_gpu.rs into a new backend-neutral
module, so CUDA and Vulkan drive one pipeline instead of two copies.
This is the gpu_config.rs split applied to behaviour rather than
constants: backends supply a RangeSink (launch + sync), everything above
that -- the adaptive MSD floor, the worker/channel/batching loop, the
per-field stats -- is written once. The CUDA path was moved onto it; its
kernel source is unchanged and it keeps every one of its tests.

Correctness is exact against client_process::process_range_niceonly at
bases 10, 12, 25, 40, 45 and 62, on RADV and on lavapipe, and base 10
returns exactly [69] through the whole pipeline. That is the real claim:
the GPU checks a superset of the CPU's candidates (its MSD floor is
coarser), so the two only agree if the on-device candidate
reconstruction and the digit scan are both right.

One construct did not port, and it is the same one as last time. CUDA
computes n mod M with two 64-bit divisions by a constant, which RADV/ACO
expands into a ~220-instruction shift-subtract loop rather than
strength-reducing. The shader now takes field_start mod M as a push
constant and reduces only the 64-bit offset, byte by byte:
acc = (acc << 8 | byte) % M, eight 32-bit constant divisions. Exact
because acc < M <= 2^24, and real moduli (b-1)*b^2 top out near 2^21.
RADV_DEBUG=asm confirms it: zero s_brev_b64, one v_cmp_le_u64 (the loop
bound), no scratch, no spills.

Two things this turned up that are worth calling out.

AdaptiveFloor's metric was wrong on any backend whose launch blocks. It
inferred GPU time by subtracting MSD time from the total, which assumes
the two phases are disjoint -- true for an async CUDA launch, false for
a Vulkan dispatch that waits on a fence inside launch. The same 1e13
base-50 field read "msd 122.6s, gpu 0.15s" before and "msd 5.3s, gpu
133.0s" after: not slightly off, inverted. It would have driven the
floor to MSD_FLOOR_MAX every field, which measures >2000s against 129s
at the optimum. The pipeline now times its own calls into the sink and
subtracts them; CUDA's numbers barely move.

Bounding the descriptor channel (needed because a blocking consumer lets
the MSD workers queue over a gigabyte) introduced a deadlock on the
launch-error path: the consumer breaks, workers parked in send never
wake, and thread::scope waits on them forever. Fixed by dropping the
receiver inside the scope and breaking on send failure, and pinned by a
timeout-guarded test that hangs for 120s without the fix.

Not included, deliberately: the modular prefilter (base <= 40 only) and
async dispatch. See vulkan-nice-spec.md for both, and for the floor
sweep -- end to end this buys ~1.16x over the 16-thread CPU on an iGPU,
not detailed mode's 6.7x, because the floor that keeps the GPU fed also
multiplies the work it is fed.

Assisted-by: Claude:claude-opus-5 [RADV_DEBUG] [clippy]
The niceonly kernel's uniform pre-check — are the lowest p base-b digits of
n^2 and n^3 all distinct? — rejects ~99% of candidates in fixed, branch-free
work, so whole lanes skip the full check. It was the last piece of
niceonly_ranges_kernel not ported.

__umul64hi, the difficulty the port expected here, is not needed at all,
because the construct it appears in does not survive for a different reason.
reduce_pre ends in `lo % PRE_MOD`, and the digit peel is `sq % BASE` /
`sq /= BASE` on a u64: three more 64-bit divisions by a compile-time
constant, which is exactly the expansion NIR's width-limited
nir_opt_idiv_const leaves behind (~220 instructions), in the one place that
runs on every candidate.

So carry the residue as three chunks of base^chunk_digits < 2^16 — the same
chunk the digit scan already uses — instead of in a u64. `n mod b^p` is three
rounds of the existing split16 step, the multiply is truncated schoolbook over
those chunks (a*b + acc + carry <= chunk_div^2 - 1 < 2^32, so every
intermediate stays a u32), and the digits fall out of each chunk with 32-bit
divisions. Nothing 64-bit is ever divided. Three chunks also lands on exactly
CUDA's digit count at every base that enables either filter.

Measured on an AMD Radeon 860M, 1e12 numbers at base 40, MSD floor pinned at
4000: the device phase drops from 31.8-34.7s to 19.0-20.7s (1.68x on the
kernel, 1.30e9 vs 7.7e8 candidates/s) and the field from 38.3-41.3s to
25.4-27.2s, against 67.7-71.0s for the same field on the 16-thread CPU.
RADV_DEBUG=asm,shaderstats confirms the expansion never appeared: 0
s_brev_b64, scratch 0, 56 VGPRs and 18 subgroups/SIMD with the filter and
without, for 362 extra instructions.

Correctness needed its own test. The niceonly parity tests cannot see this
filter — the only nice number any of them finds is 69, in base 10, where the
prefilter is disabled — so a filter that rejected every candidate would pass
all of them, which is the bug the CUDA kernel shipped in v3.2.14. A probe
build of the shader reports the prefilter's survivors directly and they must
match a Rust mirror of the chunk arithmetic candidate for candidate, on RADV
and on lavapipe. It agrees at bases 30/34/40, and the 1.12% survival it
measures at b40 matches the CUDA crossover study's 1.1% on other hardware.

Also adds the massive-low-base benchmark (1e12 @ base 40): every range large
enough to measure niceonly was base 50, where the prefilter is compiled out
on profitability grounds, so nothing exercised it. NICE_VULKAN_PREFILTER=0
compiles it out, which is how the A/B above was taken.

Assisted-by: Claude:claude-opus-5
…patch

Phase 3, both changes identified and measured for by earlier phases.

The lane tiling was CUDA's warp carried over as a constant 32. Nothing here is
a warp — the lanes stride a range's candidates by index and never communicate
— but they do all repeat the range's setup, the residue reduction and a
~12-iteration binary search. At MSD floor 250 a base-40 range holds ~39
candidates, so 32 lanes bought 32 copies of that setup to share out 1.2
candidates each. The shader now takes log2(lanes) as a push constant and the
host sizes it per dispatch from the batch's mean range length. Device time at
floor 250 by pinned width: 30.0s at 32 lanes, 24.6 at 16, 22.8 at 8, 21.6 at
4, 21.1 at 2 and 1 — monotone where ranges are short, flat within noise
everywhere else, which is the whole shape of the result.

Dispatch is now asynchronous over a ring of command buffers and fences, with
per-slot range descriptor buffers so the host fills batch i+1 while the device
reads batch i. The reservation is a guard that holds the submitter lock across
the caller's fill: without it two threads could be handed the same slot, since
nothing marks a slot taken until its submit. Holding it costs no device time —
it blocks other hosts, not the queue.

Measured on an AMD Radeon 860M, totals at the floor each configuration is best
at. Base 50, 1e13: 129.4s -> 86.5s, against 149.7-180.5s for the 16-thread
CPU, so this base goes from 1.16x the CPU to 1.7-2.1x. Base 40, 1e12: 25.4s ->
22.5s pinned, ~23.6s adaptive. The prefilter does not apply at base 50, so
that base's whole gain is this phase.

It also repairs the control loop rather than the constant. AdaptiveFloor aims
at msd == gpu, which minimizes a maximum — right only when the phases overlap.
They now do: the same base-40 field that used to walk the floor from 24.6s
down to 57s settles into a limit cycle at 23.5-25.0s against 22.3s for the
best fixed floor, ~5% off where it was 2.3x off. The residue is the hard
+/-1.5x step, not the target.

Two constants flagged as never swept are now swept and both are flat:
HIST_COPIES (1/2/4/8: 2.66/2.58/2.62/2.65s) and MAX_WORKGROUPS (1024..16384:
22.6-22.8s). Left at 4 and 4096.

One caveat the async path introduces: device_secs is time the host spent
blocked in launch/sync, not the device's work, and an asynchronous backend
drives it to nearly zero on a field the device keeps up with. Compare totals.

Assisted-by: Claude:claude-opus-5
The bound was quoted as LAUNCH_BATCH_RANGES * PIPELINE_DEPTH descriptors,
which names the wrong quantity. A channel item is the whole output of one
PROCESSING_CHUNK_SIZE chunk; LAUNCH_BATCH_RANGES is the consumer's flush
threshold and never bounds what is queued.

The real cap is PIPELINE_DEPTH times the most ranges one chunk can yield.
get_valid_ranges_recursive returns a range whole once it is at or below
the floor, so that is about PROCESSING_CHUNK_SIZE / floor -- ~4000 at
MSD_FLOOR_MIN, fewer at any coarser floor, and the depth-22 subdivision
limit never binds first. Roughly 3 MB rather than the 50 MB the old
arithmetic implied; the stated bound was conservative, so nothing was
unsafe, but this constant is one of the two things that changed for CUDA
users (the channel used to be unbounded) and it should say what it does.

Comment only; no behaviour change.

Assisted-by: Claude:claude-opus-5
…nt LSD bitmap

Brings wasabipesto/nice 0ed8652 onto vulkan-backend. Two upstream filter
fixes, both of which the Vulkan backend inherits without shader changes:

- has_duplicate_msd_prefix loses its cross MSD-LSD collision check, which
  was unsound (it treated the range start's low square/cube digits as
  fixed across a range whose n mod b^k varies). Ranges containing nice
  numbers were being skipped, e.g. [68, 70) in base 10 around 69. The
  Vulkan niceonly path calls get_valid_ranges_recursive, so it was
  skipping the same ranges the CPU was.
- get_valid_multi_lsd_bitmap now checks all 2k fixed low digits of n^2
  and n^3 for pairwise distinctness at fixed width. The GPU uploads this
  host-built stride table verbatim, so the tighter bitmap reaches the
  device with no kernel or codegen change.

Expect niceonly to slow down where the old filter was wrongly aggressive
(upstream measured ~1.6x at b40, ~2.6x at b50); that cost is the fix.
…2 storage

Brings wasabipesto/nice up to a066ab3 (origin/main). wasabipesto#87 came in on the
previous merge; this one is wasabipesto#88 plus the version/changelog commits.

Conflict: wasabipesto#88 edited GPU_LSD_K and the MSD-floor machinery in place in
client_process_gpu.rs, but this branch had already moved both into
gpu_niceonly.rs so the Vulkan backend could share them. Resolved by
keeping the shared module and carrying wasabipesto#88's semantic change to it —
GPU_LSD_K 2 -> 3. The duplicated AdaptiveFloor block upstream re-added
was dropped; it is identical to the one already in gpu_niceonly.rs.

The k=3 table needed one real fix on the Vulkan side. The niceonly
shader reduces a range offset with a Horner loop, acc = (acc << c | chunk)
% M, which requires M <= 2^(32-c); c was a fixed 8, sound only because
M = (b-1)*b^k stayed under 2^24 at k=2. At k=3 every modulus gains a
factor of b: base 65 hits 17 576 000 and base 128 hits 266 338 304, so
NiceonlyConfig::new would have refused every base >= 65 — base 80, which
is in production here, among them. stride_chunk_bits now picks c per base
(8 while M <= 2^24, else 4, bound 2^28), and the emitted shader and the
host mirror derive it from the same modulus. It costs nothing measurable:
this reduction runs once per range descriptor, not per candidate.

Also adapted this branch's stride-table test mirrors to the u32 residue
and gap types, the same way wasabipesto#88 did for the CUDA ones, and added base 80
to the device parity list — every base below 65 keeps the byte chunk, so
nothing else here executes the 4-bit path on hardware.

Verified: 120 unit tests plus all 6 device tests pass on the AMD iGPU;
niceonly and detailed both match the CPU at bases 10/12/25/40/45/62/80.

Assisted-by: Claude:claude-opus-5
…ark sweep, telemetry

Upstream replaced the fixed named benchmark fields (common/src/benchmark.rs)
with a structured adaptive sweep in client/src/bench.rs (wasabipesto#90), added benchmark
uploads and opt-in submission telemetry (wasabipesto#91), a POST /estimate endpoint with
GPU model canonicalization (wasabipesto#93, wasabipesto#95), and swapped the MSD prefix checks for an
interval digit-domain (Hall) analysis (wasabipesto#89).

One conflict and two integration fixes:

* common/src/benchmark.rs was deleted upstream and carried our MassiveLowBase
  mode, which existed only because every other niceonly benchmark was base 50,
  where the modular prefilter is compiled out (GPU_PREFILTER_MAX_BASE). Took
  the deletion: the new sweep's niceonly scenarios include two base-40 windows
  (b40_msd_strong, b40_msd_weak), so the prefilter is exercised again.

* DataToServer gained a telemetry field; client_process_vulkan.rs builds two of
  them and needed the same `telemetry: None` wasabipesto#91 gave the CUDA path.

* gpu_name() was CUDA-only, so a Vulkan run reported gpu:true with a null
  gpu_model, and on a box with both backends a --gpu-backend vulkan run would
  have reported the CUDA device's name. Both feed the new estimator's matching.
  It now asks the live GpuHandle, which is the only thing that knows which
  backend actually initialized; Vulkan already recorded device_name at init.

wasabipesto#89 needed no change on our side: the Vulkan pipeline calls
get_valid_ranges_recursive rather than reimplementing the filter, so it
inherits the stronger prune. Device parity re-verified — all 6 device tests
and 131 unit tests pass, including vulkan_matches_cpu_niceonly at base 80.

No GPU performance number was re-measured. wasabipesto#89 trades -2 to -12% must-process
work for +0 to +8% CPU wall clock, which shifts the host/device balance in the
niceonly pipeline and invalidates the pre-merge Vulkan speedups again.

Assisted-by: Claude:claude-opus-5
…eardown

`DetailedRun::drop` and `NiceonlyRun::drop` called `vkDeviceWaitIdle`, which
is specified as a `vkQueueWaitIdle` on *every* queue of the device and so
requires external synchronization against all of them. That is a precondition
neither `Drop` is in a position to guarantee, and violating it is undefined
behaviour whose observed cost in nice-count was not a crash but whole
dispatches silently doing nothing -- the host fenced, read zeroed counters,
and folded the work away.

No such violation is reachable today: one field is processed at a time, and
only `run_range_pipeline`'s consumer thread submits -- the MSD workers just
produce descriptors down a channel. So this is not a bug fix, it is the
removal of an unstated invariant. Nothing in either `Drop` records that the
call is safe only because no second thread submits, and a future overlapping
field or second context would break it silently rather than loudly.

`wait_all` needs no such invariant: it takes the submitter lock and waits the
fences of the slots actually in flight, which is a superset of the dropping
run's own dispatches and the only work that can still be reading the
descriptor pools and buffers freed immediately below.

`VulkanContext::drop` keeps `device_wait_idle`. It owns the queue, and the
`'a` borrow both run types take makes "every run is already dropped" a
compile-time guarantee rather than a convention.

Teardown-only, so there is nothing to measure. All 131 unit tests and all 6
device tests pass on the AMD iGPU, including `vulkan_matches_cpu_detailed`
and `vulkan_matches_cpu_niceonly` through base 80, both of which exercise
each changed `Drop`.

Assisted-by: Claude:claude-opus-5
…itten kernels

The vulkan/codegen.rs detailed kernel expressed as a #[cube] Rust function:
same per-base constants, same split16 chunk scan (every division 32-bit by a
comptime-constant divisor), same 4-copy workgroup histogram, same MISS_STRIDE
near-miss records. Per-base WGSL string generation becomes #[comptime]
parameters; CubeCL JIT-specializes one kernel per base at first launch.

Selected with --gpu-backend cubecl (feature "cubecl"; detailed mode only).
The benchmark sweep runs through it unchanged, so rates are directly
comparable with the cuda and vulkan backends on the same scenarios.

Parity: cubecl_matches_cpu_detailed checks exact distribution + near-miss
agreement with the CPU at bases 10/40/62/80, passing on lavapipe (same
harness as vulkan_matches_cpu_detailed).

This is a benchmark-grade evaluation port, not a merge candidate: niceonly
is not ported, and device selection takes wgpu's default adapter.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
The kernel was already runtime-generic; this makes the host body generic
too and adds a CUDA context variant (feature cubecl-cuda), so the NVIDIA
comparison can race CubeCL's CUDA codegen against the hand-written kernels
directly, without depending on container Vulkan.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
…lity

cubecl-cuda references CUDA 12.8 tensor-map symbols unconditionally; with
the workspace's cuda-12000 floor unioned in, cudarc compiles the 12.0
surface and the build fails. The hand-CUDA path calls none of the new
APIs and symbol lookup is lazy under dynamic loading. Evaluation branch
only; revisit the floor before any upstreaming.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
CUDA gets the wide flavor (one u64 constant division per limb, 5 digits
per chunk at base 40, matching the hand kernel); wgpu keeps split16 for
drivers that cannot strength-reduce 64-bit constant division. One kernel
source, one comptime bool — this is the per-device choice PR wasabipesto#96 lists as
future work for the WGSL generator.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
Loaders >= 1.3.207 hide portability (non-conformant) drivers unless the
instance enables VK_KHR_portability_enumeration, and the spec requires
VK_KHR_portability_subset on devices that advertise it. Both are detected
at runtime, so this is a no-op on conformant Linux/Windows stacks.

Verified on an Apple M4 mini through MoltenVK 1.4.2: all six device
parity tests pass (detailed, niceonly, prefilter mirror), and the
detailed benchmark runs at 6.7e8 n/s (b40) / 3.9e8 n/s (b50) --
CubeCL-on-Metal measures 0.86x of this hand-WGSL backend on the same
device.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
The CUDA backend's feature and symbols lose their generic names: feature
gpu -> cuda, client_process_gpu -> client_process_cuda, GpuContext ->
CudaContext, GPU_BATCH_SIZE -> CUDA_BATCH_SIZE, process_*_gpu ->
process_*_cuda. (cudarc's own CudaContext is imported as DriverContext
where the two would collide.) Names that are genuinely backend-neutral —
gpu_config, gpu_niceonly, --gpu-backend, the gpu CLI flag — keep them.

A new umbrella feature takes the gpu name: it enables every backend
(cuda, vulkan, cubecl, cubecl-cuda), so the release recipe is two
builds — default features for a lightweight CPU-only binary, --features
gpu for one binary that runs on the CPU or any supported GPU. No backend
needs its GPU libraries at build time; verified by building the full
umbrella on a machine with no CUDA toolkit.

--gpu-backend auto now ranks backends by measured speed: CUDA, then
CubeCL, then Vulkan (CubeCL measured 1.4x the hand-WGSL backend on
RDNA4/RADV and 2.5x on NVIDIA). In niceonly mode auto skips CubeCL,
which has no niceonly kernel yet, and falls through to Vulkan; an
explicitly named backend that fails to init stays fatal.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Two jobs: lavapipe on Linux (vulkan + cubecl through software Vulkan,
the same device the suites were developed against) and macOS arm64
(cubecl through wgpu->Metal on the runner's paravirtualized Apple GPU,
and the hand-WGSL backend through MoltenVK, exercising the new
portability-enumeration path). The workflow header notes the backends
CI cannot cover: cuda and cubecl-cuda need NVIDIA silicon, so their
result parity stays a manual step on real hardware — NVRTC
compile-tests of every base's kernel still run in the normal suite.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
The niceonly kernel joins detailed as a #[cube] Rust function: the same
on-device candidate reconstruction from the residue table, offset
reduction (chunked u32 Horner, every divisor comptime), lane tiling, and
low-digit modular prefilter as the generated WGSL, reviewable side by
side with it. The stride modulus and residue count ride along as
comptime parameters, so CubeCL specializes one kernel per base exactly
as the string generators bake literals.

The host end is a RangeSink like the other backends', driving the shared
gpu_niceonly pipeline; the tiling helpers (lane_shift_for,
stride_chunk_bits, MAX_STRIDE_MODULUS, MAX_LANES_PER_RANGE) and the
residue-empty guard move from vulkan/codegen.rs and
client_process_vulkan.rs into gpu_niceonly, where the Vulkan backend
re-imports them — they were never Vulkan-specific, and the CubeCL
backend must build without the vulkan feature.

Device tests mirror the Vulkan suite: CPU parity at bases
10/12/25/40/45/62/80 (the GPU checks a superset of the CPU's candidates;
the sets must still be identical), 69-in-base-10 through the whole
pipeline, a CUDA-runtime variant, and a prefilter probe that reports
survivors at every lane width against an independent u128 host mirror —
an over-rejecting prefilter passes every parity test (the v3.2.14 bug
class), so the filter's own output has to be observable. All pass on
lavapipe; survivor counts match the hand-WGSL filter's exactly
(396/159058 b30, 2099/350205 b34, 4472/288591 b40).

With both modes served, --gpu-backend auto drops its niceonly carve-out:
the order is CUDA, CubeCL, Vulkan in every mode.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
=0.10.0: CubeCL is pre-1.0 and its host API drifts between releases;
bumps should be deliberate, gated on the device parity suite.

cuda-12080 stays, now with the argument written down: it selects which
bindings are compiled, not which driver is required — cudarc's dynamic
loading resolves symbols lazily, the hand-CUDA path calls nothing newer
than 12.0, and cubecl-cuda's 12.8 declarations (tensor-map) are never
launched by our kernels. Parity-verified on real silicon.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Ubuntu ships the lavapipe manifest as lvp_icd.json; upstream Mesa
builds name it lvp_icd.<arch>.json. The job's glob only matched the
latter, so VK_ICD_FILENAMES exported empty and the loader found no
drivers. Glob both, and fail the step loudly when no ICD is found
instead of exporting an empty loader list. Verified in an ubuntu:24.04
container.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Three backends at very different throughputs can share one device name
(the 9070 XT differs 1.4x between vulkan and cubecl), so hardware rows
without the backend cannot be compared. hardware.gpu_backend carries the
--gpu-backend value of the backend that actually won init — read from
the live handle, since auto decides at init among the compiled backends.
Additive JSON; absent on CPU runs; no schema bump.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
CubeclNiceonlyRun rebuilt the stride table and re-uploaded the residue
buffer on every field. StrideTable::new walks the whole modulus on one
CPU thread — 8.6e6 steps at base 52 — and at benchmark-window field
sizes that walk dominated the pipeline: on an RTX 4060 the per-field
logs showed the device phase at 0.65-1.0x of hand-CUDA while the
scenario rates cratered to 0.24-0.57x, the difference being wall time
spent between fields on table rebuilds the hand backends never do (both
cache their per-base plans in the context; now this backend does too).
Explains the base-dependence (modulus grows with base) and the device
spread (the slower the box's CPU, the worse the hit).

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Same shape of knob as the hand backend's NICE_VULKAN_LANES: a power of
two up to 32 pins every dispatch's lane width; unset keeps the adaptive
per-dispatch sizing.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
The pass-loop structure copied sq into a zero-padded cu_limbs window,
built cu in its own array and copied it into the scratch, and branched
on a runtime pass selector — per candidate. At bases with the low-digit
prefilter (<= 40) almost nothing reaches the full check and none of
that mattered; above it every candidate pays, which is where the
remaining device-phase gap against hand-CUDA lived. Now the sq scan
reads a sq_limbs window and the cube is built directly in the scratch
and destroyed by its scan — no cu array, no copies, no pass branch,
matching check_is_nice in nice_kernels.cu shape for shape.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Batches launch asynchronously; the blocking histogram read is the only
stream sync, and doing it per 50M batch put a GPU-idle bubble after
every ~12ms of kernel work — measured as most of the hand-CUDA
backend's remaining detailed-mode lead on an RTX 4060. Each candidate
increments exactly one u32 bin, so a drain per 64 batches keeps every
bin under 64 * 50e6 < u32::MAX (const-asserted). The hand-CUDA kernel
sidesteps the bound with u64 bins; WGSL has no u64 atomics, and this
host loop serves both runtimes.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
The kernels paired every constant division with a matching %. nvcc
lowers each u64 constant division independently — a full multiply-high
correction sequence per operator — so every div+mod pair cost double,
and the digit peel pays the pair per digit. SASS for the b50 detailed
kernel showed +42% instructions and ~100 extra ISETP correction
compares vs the hand kernel, whose source computes every remainder as
cur - q * DIV for exactly this reason. All sixteen div+mod sites in
the detailed scan, niceonly scans, and prefilter now do the same.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
ptxas register-promotes the generated kernels' scratch array, turning
every runtime-indexed access in the split loop into a compare/select
chain over all words — ~100 extra SASS compares per kernel at b50,
where the hand kernels' local-memory array pays one LDL/STL. Shared
memory gives the same one-instruction indexed access without the local
round trip: one word per thread per limb, padded to an odd stride so a
warp's accesses spread across banks.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
@Janzert

Janzert commented Aug 17, 2026

Copy link
Copy Markdown
Contributor

Tested this on four machines: an AMD RDNA3.5 iGPU (Linux/RADV), an RTX 4060, an
RTX A1000 laptop, and an Intel iGPU (the last three on Windows 11).

Short version: it builds, device parity passes everywhere hardware allowed it,
and CubeCL is the fastest backend measured in detailed mode on every vendor —
including beating the hand-written CUDA kernel by 8-10% on NVIDIA.

There is one bug I'd call blocking, described first because it is a
silent-wrong-results failure rather than a performance issue.

Blocking: cubecl-cuda reports success while doing no work

On a machine with NVIDIA drivers but no CUDA toolkit, CubeclContext::new_cuda()
returns Ok, the client logs GPU initialized: CubeCL CUDA device 0, and the
run completes and reports numbers. Meanwhile every worker thread panics:

thread 'DSD-0-0' panicked at cudarc-0.19.4/src/lib.rs:182:5:
Unable to dynamically load the "nvrtc" shared library - searched for library
names: ["nvrtc.dll", "nvrtc64.dll", ...]

4120 panics in one 30-second detailed run, 1576 in a niceonly one. The reported
rates are physically impossible — it measured the cost of failing fast:

A1000 (broken) RTX 4060 (working) ratio
b40_detailed 1.16e10/s (206x CPU) 5.84e9/s 1.98
b50_detailed 1.03e10/s (280x CPU) 4.32e9/s 2.38

An A1000 laptop GPU is not 2x an RTX 4060. Against the same box's wgpu backend
on the same card, the CUDA runtime claimed 3.4-6.5x in niceonly.

The hand-written CUDA path gets this right on the identical machine: it
detects the missing NVRTC, logs an error with troubleshooting steps, and exits

  1. Only the CubeCL CUDA path swallows the per-thread panics.

Reproduction: NVIDIA driver installed, CUDA toolkit not installed,
--gpu --gpu-backend cubecl-cuda --gpu-device 0. With no NVIDIA card at all it
fails cleanly instead (init errors, exit 101), so the driver-without-toolkit
combination is what triggers it — a very common configuration.

Build

All four backends build into one binary with no GPU libraries present at build
time, as advertised. Stripped sizes (lto = true, codegen-units = 1):

build stripped vs cpu-only
cpu-only 6.3 MiB
cuda 6.7 MiB +0.3
vulkan 8.2 MiB +1.9
cubecl 13.1 MiB +6.7
cubecl-cuda 14.8 MiB +8.4
gpu (all four) 16.4 MiB +10.1

CubeCL roughly doubles a CPU-only binary. Worth knowing for
docker-build-push.yml, which publishes with --features gpu: the image grows
~8 MiB, and on the nvidia/cuda runtime base (no Vulkan ICD) the wgpu half is
inert. Not an objection — a separate CUDA-only tag would avoid it.

Correctness

Device parity passes on every backend each machine could run:

backend RTX 4060 A1000 Intel RADV
cpu pass (157) pass (157) pass (157) pass (157)
cuda pass (3) no toolkit no device no device
vulkan pass (5) pass (5) pass (5) pass (5)
cubecl pass (5) pass (5) pass (5) pass (5)
cubecl-cuda pass (2) no toolkit no device no device

Benchmarks

nice_client --benchmark, 30 s budget, 3 interleaved repeats, medians. Fixed
windows, so every arm grinds identical work. Implicit Vulkan layers disabled.

Ratios are per machine — absolute rates are not comparable across boxes
(different CPUs, OSes, rustc 1.94.1 vs 1.97.1).

CubeCL-over-wgpu vs the hand-written Vulkan kernel

scenario RTX 4060 A1000 Intel RADV
b40_detailed 2.47 2.27 2.44 1.76
b50_detailed 2.46 2.18 2.57 1.81
b40_msd_strong 0.97 1.42 (noisy) 0.82
b40_msd_weak 1.11 1.50 (noisy) 0.61
b50_residue_dense 1.38 1.41 (noisy) 0.97
b50_msd_weak 1.34 1.43 (noisy) 0.92
b52_msd_weak 1.42 1.57 (noisy) 0.96

Detailed mode: CubeCL wins on all four vendors, by 1.8-2.6x. That is the
clearest result in the set.

Niceonly splits by vendor: CubeCL wins on both NVIDIA cards (1.11-1.57) and
loses slightly on RADV (0.61-0.97). The Intel box's niceonly figures are
withheld deliberately — its Vulkan arm varied by up to 2.6x between repeats
of identical work, so its medians are not a measurement. Its detailed figures
repeat to 0.5% and are quoted above.

I do not have an explanation for the vendor split, and the obvious one is wrong:
c1ae5e5 picks the chunk width per runtime, but wgpu keeps split16 on both
vendors, so it cannot be the cause (it is the right explanation for the
cubecl-cuda column below). One thing that would help diagnose it: the client
logs CubeCL wgpu device (name) without recording which graphics API
AutoGraphicsApi resolved to, so on Windows this may be DX12 against the hand
kernel's Vulkan. Happy to re-run with WGPU_BACKEND pinned.

CubeCL-over-CUDA vs the hand-written CUDA kernel (RTX 4060)

mode scenario cubecl-cuda / cuda
detailed b40_detailed 1.08
detailed b50_detailed 1.10
niceonly b40_msd_weak 0.97
niceonly b40_msd_strong 0.86
niceonly b50_residue_dense 0.86
niceonly b50_msd_weak 0.85
niceonly b52_msd_weak 0.85

A portable Rust kernel beating hand-written CUDA in detailed mode is the
headline. Niceonly still trails by ~15%.

Sanity check on the setup: the vulkan/cuda ratios on the 4060 reproduce
figures measured on that card before this PR (0.51/0.49/0.48 niceonly, 0.35/0.30
detailed, against 0.51/0.50/0.47 and 0.34/0.29 recorded earlier).

What the last six commits bought (RADV)

I benchmarked 16779af first. The two runs are in different sessions and the
second came in 8-11% slower across the board — including the CPU and Vulkan
arms, whose code did not change — so absolute rates across them are not
comparable. Since the commits touch only common/src/cubecl_backend.rs, Vulkan
is a control, and its speedup-over-CPU is flat within noise (niceonly 6.51 ->
6.46, 6.96 -> 6.77, 6.96 -> 6.86, 2.73 -> 2.64; detailed 7.53 -> 7.61, 9.42 ->
9.53). Against that control, CubeCL/Vulkan moved:

mode scenario 16779af 30994e2
niceonly b40_msd_weak 0.51 0.61
niceonly b50_residue_dense 0.61 0.97
niceonly b50_msd_weak 0.69 0.92
niceonly b52_msd_weak 0.62 0.96
detailed b40_detailed 1.37 1.76
detailed b50_detailed 1.33 1.81

The niceonly work did what it set out to do.

Three smaller items

1. --features nice_client/cubecl-cuda alone does not compile.

error[E0433]: failed to resolve: use of undeclared type `CubeclContext`
   --> client/src/main.rs:437:15
error[E0425]: cannot find value `CUBECL_BATCH_SIZE` in this scope
error[E0599]: no variant or associated item named `Cubecl` found for enum `GpuHandle`

client/Cargo.toml has cubecl-cuda = ["nice_common/cubecl-cuda"], which
forwards to nice_common's cubecl feature and so never sets
cfg(feature = "cubecl") on nice_client — the gate on the CubeclContext
import, while the new_cuda call site is gated on cubecl-cuda. The gpu
umbrella enables both, which is why the combined build hides it.

cubecl-cuda = ["cubecl", "nice_common/cubecl-cuda"]

Same shape as the umbrella caveat already in the tree: cargo feature cfgs do not
propagate upward.

2. cubecl-cuda is unreachable from --gpu-backend auto. The cuda, cubecl
and vulkan blocks in init_gpu all match Auto | <backend>, but the
cubecl-cuda block is if want == GpuBackend::CubeclCuda. So the fastest
detailed-mode backend on the 4060 (59x/91x against CUDA's 55x/83x) is only
reachable by naming it. Possibly deliberate — it trails CUDA by ~15% in
niceonly, so the best pick is mode-dependent — but Auto's doc comment ("CUDA
if available, then CubeCL, then Vulkan") does not mention the exclusion.

3. --gpu-device is a per-backend namespace, and wgpu ignores it entirely.
On the A1000 laptop the A1000 is Vulkan ordinal 1 (0 is the Intel iGPU) but CUDA
ordinal 0, so no single value is right for all backends — passing 1 gave
CUDA_ERROR_INVALID_DEVICE. And CubeclContext::new_default() takes no ordinal
at all, so the wgpu backend picks its own adapter with no override. On a
multi-GPU box that silently benchmarks two backends on two different GPUs (it
did here, until caught). Worth a --gpu-device equivalent for wgpu, or at least
logging the chosen adapter distinctly.


Machines

# GPU OS / rustc backends exercised
A Radeon 860M (RADV GFX1152) Linux, 1.94.1 cpu, vulkan, cubecl
B RTX 4060 Windows 11, 1.97.1 all five
C RTX A1000 6GB Laptop Windows 11, 1.97.1 cpu, vulkan, cubecl (no CUDA toolkit)
D Intel(R) Graphics Windows 11, 1.97.1 cpu, vulkan, cubecl

@Janzert Janzert mentioned this pull request Aug 17, 2026
@Janzert

Janzert commented Aug 17, 2026

Copy link
Copy Markdown
Contributor

Of course right after posting that I found another bug when trying to run a real field from the server.

[INFO nice_client] GPU initialized: CubeCL wgpu device (AMD Radeon 860M Graphics (RADV GFX1152))
[INFO nice_client] Acquired claim:  216632786, Base 49
radv/amdgpu: The CS has been cancelled because the context is lost. This context is innocent.

thread 'tokio-rt-worker' panicked at cubecl-wgpu-0.10.0/src/compute/stream.rs:205:22:
Failed to map buffer: BufferAsyncError
thread '<unnamed>' panicked at wgpu-29.0.4/src/backend/wgpu_core.rs:1911:30:
Error in Device::poll: Validation Error
Caused by:
  Parent device is lost
thread 'main' panicked at client/src/main.rs:601:6:
Processing task panicked: JoinError::Panic(..., "Failed to map buffer: BufferAsyncError")

Dead ~1 second after the claim, every time. The kernel log shows the GPU being
reset underneath it:

amdgpu 0000:03:00.0: ring gfx_0.0.0 timeout, signaled seq=3328517, emitted seq=3328519
amdgpu 0000:03:00.0:  Process nice_client pid 262227 thread DSD-4-0 pid 262246
amdgpu 0000:03:00.0: Starting gfx_0.0.0 ring reset
amdgpu 0000:03:00.0: Ring gfx_0.0.0 reset succeeded
amdgpu 0000:03:00.0: [drm] device wedged, but recovered through reset

What it actually depends on

It is the number of sequential dispatches on one stream, and nothing else I
could find. Bisected with a standalone single-threaded harness calling
process_range_detailed_cubecl directly (no client, no server, one stream):

dispatches (batch = 5e7) candidates result
8 4.0e8 OK (0.945 s)
32 1.6e9 OK (2.069 s)
36 1.8e9 device lost
40 / 48 / 56 / 63 / 64 / 80 device lost

Ruled out:

  • Not base-specific. Bases 40, 49 and 50 all fail at 40 dispatches.
  • Not accumulated GPU time. Base 40 does ~1.7x less work per candidate than
    base 50 and fails at the same count, not the same elapsed time.
  • Not dispatch size. A single 5e7 dispatch completes in 0.127 s.
  • Not concurrency. Reproduces with --threads 1, and in a harness with one
    stream. (DSD-4-0 in the kernel log is a CubeCL-internal thread name, not
    client thread 4 — it appears at --threads 1 too.)

Minimal reproduction:

let ctx = CubeclContext::new_default()?;
let r = get_base_range_u128(50)?.unwrap();
let start = r.start() + (r.end() - r.start()) / 2;
let range = FieldSize::new(start, start + 1_800_000_000);   // 36 batches
process_range_detailed_cubecl(&ctx, &range, 50)?;           // device lost

I have not identified the mechanism. It looks like per-dispatch resources
accumulating on a stream that is not flushed or reclaimed until some sync point,
but I have not confirmed that in CubeCL's stream code, so treat that as a guess
rather than a diagnosis. Only the detailed path was bisected; niceonly is
untested.

Why --benchmark cannot catch this

The largest detailed window_gpu in bench.rs is 200_000_0004
dispatches
, about eight times below the threshold. So the benchmark sweep
never approaches the failing path at any base, thread count or budget, which is
why four machines of benchmarking came back clean while the first real field
died. Worth considering a long-running scenario in the sweep independently of
this bug.

Platform scope — and a confound I cannot remove

  • Fails: Linux, Radeon 860M (RADV GFX1152), Mesa 25.2.8-0ubuntu0.24.04.2,
    kernel 7.0.0-29.
  • Works (full fields, live): RTX 4060, RTX A1000, Intel(R) Graphics — all
    Windows 11.

Every failing run is Linux and every working run is Windows, so OS and driver
are confounded
in my sample: this could be RADV-specific or Linux-wgpu-specific
and I have only one Linux box to test with. Not universal to wgpu, at least.

Two reasons it may matter more than one machine suggests:

  1. docker-build-push.yml publishes a Linux image and now carries CubeCL
    under --features gpu. A Linux/AMD user running --gpu gets auto -> CUDA
    unavailable -> CubeCL, i.e. the failing path is the default there.
  2. auto prefers CubeCL over Vulkan, so on that machine it selects the backend
    that cannot finish a field over the one that can.

Workaround, verified on the failing machine

--gpu-backend vulkan completes fields normally on the same GPU, same driver,
same build. So this is not "an iGPU cannot take this workload" — the
hand-written Vulkan backend runs it fine on identical hardware, which also makes
it a useful A/B for whoever debugs this.

Versions: cubecl-wgpu 0.10.0, wgpu 29.0.4, rustc 1.94.1

Claude and others added 2 commits August 17, 2026 22:09
Review found the silent-wrong-results case: with an NVIDIA driver but
no CUDA toolkit, CubeCL compiles lazily at first launch, a missing
NVRTC panics its server thread, and launches keep reporting success —
the client completes and reports numbers measured from zeroed buffers.
new_cuda now proves the runtime end-to-end with a one-thread smoke
kernel and a verified readback before reporting success (the hand
backends' init-time compile checks, ported), and detailed_impl checks
histogram conservation — every candidate lands in exactly one bin — so
any future silently-dropped work refuses to produce results on any
runtime.

Also from review: the client's cubecl-cuda feature now enables its own
cubecl gate (feature cfgs do not propagate upward, so the standalone
feature did not compile); the wgpu adapter log and telemetry name now
include the resolved graphics API (AutoGraphicsApi may pick DX12 on
Windows, and backend comparisons need to know); Auto's docs state that
cubecl-cuda is deliberately explicit-only.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
The M4's prefilter probe caught survivors diverging from the host
mirror after the mul-sub commit — Metal only; the CUDA and SPIR-V
paths agree with the mirror, and the digit scan's mul-sub sites pass
full parity on Metal, so the miscompile is specific to these four
prefilter sites under naga's MSL backend (or Apple's MSL compiler).
The prefilter runs once per candidate, so pairing / with % here costs
nothing measurable; the scan keeps its mul-sub form and its measured
gains. One more entry for the naga-on-Metal ledger next to the
select(ulong, uint, bool) division-guard bug.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
@Janzert

Janzert commented Aug 17, 2026

Copy link
Copy Markdown
Contributor

Found it, and I owe you a correction: my previous comment's diagnosis was
wrong in every particular that mattered.
It is not a leak, it is not the
dispatch count, and it is not a mystery in CubeCL's stream code. It is a plain
GPU watchdog timeout, and the patch is one line.

The mechanism

WgpuStream accumulates dispatches into a single command encoder and submits
only when it reaches CUBECL_WGPU_MAX_TASKS tasks (default 32 — which is
why my bisection landed near 34) or when a read forces a flush. That bound is
tuned for ML kernels that run in microseconds. detailed_kernel is a persistent
grid-stride kernel over a whole CUBECL_BATCH_SIZE, so on this iGPU one
dispatch is ~0.12 s at base 50 and a submission of them is seconds of
uninterruptible GPU work.

I measured the driver's tolerance directly. The process aborts 2.06 s in:
amdgpu resets a gfx job at ~2 s here, not the 10 s I had assumed.
/sys/module/amdgpu/parameters/sched_hw_submission is 2, which is exactly the
emitted seq = signaled + 2 in the log — at most two jobs are on the ring, and
the watchdog covers the one executing.

Time is the discriminator, not count. Same dispatch count, different bases:

configuration GPU work per submission result
base 50, 16 per submission 1.87 s OK
base 40, 24 per submission 1.68 s OK
base 40, 32 per submission 2.24 s device lost
base 50, 32 per submission 3.75 s device lost
base 50, 80 dispatches at 1 per submission 0.12 s OK, 9.58 s total

That last row is the whole proof: 80 dispatches — more than twice what "fails at
~36" would allow — complete fine when each is its own submission, at full speed.

Three corrections to what I posted

1. My threshold table is not reproducible. I reported 32 dispatches as
OK (2.069 s). It lost the device on my first attempt this session. It is a
duration boundary that drifts with GPU clocks, so it will look like a count
threshold and then move on you.

2. The task cap is not the bound that matters — the field is. This is the
part I most want to correct, because it changes the severity.
DETAILED_SEARCH_MAX_FIELD_SIZE is 1_000_000_000 = 20 batches, which is
under the 32-task cap. So an unpatched real field is never split at all — it
is one submission end to end:

base one 1e9 field on the 860M unpatched
40 1.36 s survives
50 2.41 s device lost
62 3.74 s device lost
80 6.26 s device lost

The times are measured; the last column is measured at base 50 and inferred from
the ~2 s threshold for the other three. I confirmed the pre-patch build loses the
device on a single ordinary base-50 field. So this was not "long runs eventually die" — every detailed field past
base ~46 was killing the GPU on the first claim
, which matches the live
symptom exactly. Base 40 squeaking under 2 s is why my bisection needed 40
dispatches to make it fail, and why I wrongly concluded base did not matter.

3. OS and driver are probably not confounded after all. Windows' default TDR
delay is also 2 s. The Windows machines survive because their GPUs run a whole
field inside it — detailed mode measured 4-6e9 candidates/s on the 4060 in my
earlier benchmark table, which puts a 1e9 field at ~0.2 s, two orders of
magnitude inside the watchdog. On the 860M the same field is 2.4 s. This is a
slow-device bug, not a Linux one
, and any GPU roughly ~20x slower than a 4060
should reproduce it on Windows too. That also means it is not something to hand
to CubeCL: their aggregation heuristic is reasonable for the workloads it was
written for, and this kernel is simply nothing like those.

The patch

Submit each batch on its own. flush submits without waiting, so it does not
reintroduce the GPU-idle bubble 5ec19a4 removed.

--- a/common/src/cubecl_backend.rs
+++ b/common/src/cubecl_backend.rs
@@ -1117,6 +1117,30 @@ fn detailed_impl<R: cubecl::prelude::Runtime>(
             );
         }

+        // Submit this batch on its own, because a GPU driver kills a
+        // submission that runs too long. CubeCL aggregates dispatches into one
+        // command buffer and submits only at `CUBECL_WGPU_MAX_TASKS` tasks (32
+        // by default) or at the next read — a bound tuned for ML kernels that
+        // run in microseconds. This one is a persistent grid-stride kernel over
+        // a whole `CUBECL_BATCH_SIZE`, ~0.12 s per dispatch at base 50 on an
+        // AMD 860M, so without this flush a single submission holds the *whole
+        // field*: `DETAILED_SEARCH_MAX_FIELD_SIZE` is 20 batches, which is
+        // 2.4 s at base 50 and 6.3 s at base 80. Measured on Linux/RADV,
+        // amdgpu resets a gfx job at ~2 s ("ring gfx_0.0.0 timeout ... device
+        // wedged") and the device is lost for the rest of the process; Windows
+        // has the same 2 s TDR, and only survives because the GPUs it was
+        // tested on run a field inside it. So the task cap is not the bound
+        // that matters — the field is — and raising or lowering it does not
+        // help.
+        //
+        // Flushing per batch is the granularity the hand Vulkan backend has
+        // always used, and it is free: `flush` submits without waiting, and one
+        // batch already saturates the device (measured interleaved, 80 batches
+        // at base 50: 9.76 s median against 10.00 s for the aggregated form).
+        client
+            .flush()
+            .map_err(|e| anyhow::anyhow!("stream flush failed: {e:?}"))?;
+
         undrained += 1;
         if undrained == DRAIN_INTERVAL {
             drain(hist_handle, &mut histogram)?;

Cost: none. Interleaved, three pairs, base 50, 80 batches — new
9.757/9.771/9.892 s against old 9.604/10.042/9.997 s. The ranges overlap; the
dispatches are already large enough to keep the device saturated, so submission
granularity does not matter to throughput.

It is also robust against the env override: with CUBECL_WGPU_MAX_TASKS=100000
the patched build still completes 80 batches.

niceonly was never affected, and the reason is worth knowing

I flagged it as untested. It is fine — I ran 5e11-candidate fields both at the
default and at CUBECL_WGPU_MAX_TASKS=100000 and neither wedged.

The reason is incidental rather than designed: CubeclNiceonlyRun::launch
calls client.create twice per launch for the offsets and lens buffers, and a
ScheduleTask::Write flushes the stream before writing. So niceonly has always
submitted roughly one dispatch at a time as a side effect of allocating its
per-dispatch inputs. If those buffers were ever hoisted or pooled to save the
allocation, niceonly would inherit this bug — worth a comment there if you take
the patch.

Regression test

The fix is invisible to every existing test; here is one that catches it.

--- a/common/src/cubecl_backend.rs
+++ b/common/src/cubecl_backend.rs
@@ -1583,6 +1607,52 @@ mod tests {
         }
     }

+    /// Many batches in one field must not lose the device. Without the
+    /// per-batch flush in `detailed_impl`, `CubeCL` packs persistent
+    /// grid-stride dispatches into one submission until it reaches
+    /// `CUBECL_WGPU_MAX_TASKS` (32), and the driver's watchdog resets the GPU
+    /// long before that — ~2 s on Linux/RADV, the same 2 s TDR on Windows.
+    ///
+    /// 40 batches is deliberately over that cap so the test also covers the
+    /// aggregated path, but it is not the interesting size: a real field is
+    /// `DETAILED_SEARCH_MAX_FIELD_SIZE` = 20 batches, under the cap and still
+    /// over the watchdog at every base past ~46.
+    ///
+    /// Skipped on software rasterizers: CI runs the ignored tests on lavapipe,
+    /// where 2e9 candidates would take hours and no watchdog is involved.
+    #[test]
+    #[ignore = "requires a wgpu device"]
+    fn cubecl_detailed_survives_more_batches_than_the_task_cap() {
+        let ctx = CubeclContext::new_default().expect("CubeCL init");
+        let device = ctx.device_name();
+        let squashed = device.to_lowercase().replace(' ', "");
+        let software = ["llvmpipe", "lavapipe", "swiftshader", "softwarerasterizer"];
+        if software.iter().any(|s| squashed.contains(s)) {
+            println!("skipping on software rasterizer: {device}");
+            return;
+        }
+
+        let base = 40u32;
+        let batches = 40u128;
+        let count = batches * CUBECL_BATCH_SIZE;
+        let start = crate::base_range::get_base_range_u128(base)
+            .unwrap()
+            .unwrap()
+            .range_start;
+        let range = FieldSize::new(start, start + count);
+        let results = process_range_detailed_cubecl(&ctx, &range, base).expect("cubecl run");
+
+        // Non-vacuous: every candidate lands in exactly one bin, so the
+        // histogram must account for the whole field. A submission the driver
+        // killed would take its batch's counts with it.
+        let counted: u128 = results.distribution.iter().map(|d| d.count).sum();
+        assert_eq!(
+            counted, count,
+            "{batches} batches on {device}: histogram covers {counted} of {count} candidates"
+        );
+        println!("{batches} batches ({count} candidates) survived on {device}");
+    }
+
     /// CPU/CubeCL parity through the native CUDA runtime, on real silicon.
     #[test]
     #[cfg(feature = "cubecl-cuda")]

Mutation-checked: with the flush removed it fails with the exact reported
symptom (Failed to map buffer: BufferAsyncError, Parent device is lost). The
histogram assertion is what keeps it non-vacuous rather than merely "did not
crash" — it also catches a single dispatch silently going missing, which is the
failure mode that would not announce itself.

The software-rasterizer skip is aimed at your CI: gpu-parity.yml runs the
ignored tests on lavapipe, where 2e9 candidates would take hours and no watchdog
is involved.

Rebased onto b825b2e and 06ddd5e

Re-tested on the two commits you pushed since. The patch applies cleanly, all
six device tests pass on top of them, and the device loss is unchanged — one
ordinary 1e9 base-50 field still resets the ring on the unpatched head.

Two things worth knowing, both from b825b2e.

Its new histogram check intercepts this bug and misdiagnoses it. On the
unpatched head the failure is no longer a BufferAsyncError panic, it is:

GPU histogram counted 0 of 1000000000 candidates; the device dropped work
(is the CUDA toolkit, including NVRTC, installed?)

— on an AMD iGPU running Vulkan, with ring gfx_0.0.0 timeout still in dmesg
underneath it. The check is doing its job; it caught a genuinely dropped
dispatch. But the NVRTC hint is specific to one runtime and this failure belongs
to another, so on the machines most likely to hit it the message points the
wrong way. Worth making the hint conditional on the CUDA runtime.

And the check is duplicated into the drain branch, where it cannot hold.

        undrained += 1;
        if undrained == DRAIN_INTERVAL {
            drain(hist_handle, &mut histogram)?;

    // Every candidate lands in exactly one bin, ...
    let counted: u128 = histogram.iter().sum();
    ensure!(counted == range.size(), ...);
            hist_handle = client.create(...);

Mid-loop the histogram holds only the batches drained so far, and it is being
compared against the whole field, so any field over DRAIN_INTERVAL batches
fails. 64 batches passes; 65 gives:

GPU histogram counted 3200000000 of 3250000000 candidates; the device dropped work ...

This is latent in production only because DETAILED_SEARCH_MAX_FIELD_SIZE is 20
batches, so undrained never reaches 64 — but it fires immediately for anyone
calling process_range_detailed_cubecl directly or raising the field size.
Confirmed independent of my patch: it reproduces on the unpatched head under
CUBECL_WGPU_MAX_TASKS=1, which keeps the watchdog out of the picture. Deleting
the inner copy is the fix; the one after the loop is correctly placed.

Incidentally, that check is the same invariant my regression test asserts, which
I take as a good sign for both.

Verification

The one that matters is a live run against the server — --gpu, so auto ->
CubeCL, the configuration that died on the first claim last time. 107
consecutive fields at bases 45/47/49 across two runs, zero panics, zero device
losses, nothing in the kernel log.

[INFO nice_client] GPU initialized: CubeCL wgpu device (AMD Radeon 860M Graphics (RADV GFX1152)), batch size 50000000
[INFO nice_client] Acquired claim:  216942185, Base 49
[INFO nice_client] ✓ Processed 1.00e9 numbers in 2.52s (3.97e8 numbers/sec)
[INFO nice_client] Acquired claim:  216942390, Base 49
[INFO nice_client] ✓ Processed 1.00e9 numbers in 2.42s (4.13e8 numbers/sec)
...

Base 49 is the base from my original report, and the per-field times are the
whole story: 2.10-2.57 s, a hair over the ~2 s watchdog. Unpatched, each of
those was one submission. The only warnings in either run were a handful of
server-side claim and submit timeouts, all retried and recovered.

Also on the same 860M / RADV / Mesa 25.2.8 / kernel 7.0.0-29 box, post-patch:

  • 6/6 device tests pass, 113/113 unit tests pass, clippy clean.
  • Ten consecutive 1e9 base-50 fields, 2.41 s each, no resets.
  • Bases 40, 50, 62 and 80 at production field size.
  • 200 batches (1e10 candidates) in one field, crossing the histogram drain
    interval three times. (Measured at 30994e2; on the new head this run hits
    the misplaced ensure! above rather than anything to do with the watchdog.)
  • 5e11-candidate niceonly fields.

The --gpu-backend vulkan workaround from my last comment is no longer needed
on this machine; auto -> CubeCL now completes fields normally.

Versions unchanged: cubecl-wgpu 0.10.0, wgpu 29.0.4, rustc 1.94.1.


🦉 This diagnosis, patch and message were in the main produced by Claude (Opus 5).

Claude and others added 3 commits August 17, 2026 23:23
…eview)

Applied from Janzert's diagnosis on wasabipesto#99, with the regression test:
CubeCL aggregates dispatches into one command buffer and submits at
CUBECL_WGPU_MAX_TASKS (32) or the next read — so one submission held a
whole field, and GPU watchdogs (amdgpu ~2s, Windows TDR 2s) reset the
device on any GPU slow enough that a field outruns them: every detailed
field past base ~46 wedged an AMD 860M on its first claim. flush()
submits without waiting, so the drain pipelining is unchanged; measured
free on the reporting machine. Niceonly is safe incidentally (its
per-dispatch buffer writes flush the stream) — now documented at that
site so pooling those buffers doesn't quietly inherit the bug.

Also from the same review pass, two defects in b825b2e's conservation
check: a stray duplicate of the ensure inside the drain branch compared
a partially-drained histogram against the whole field (fired at 65+
batches; a replace-all editing accident), and the failure hint said
'install the CUDA toolkit' regardless of runtime — it now names the
watchdog on wgpu.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Co-Authored-By: Janzert <janzert@janzert.com>
Detailed: cubecl-cuda -> cubecl -> cuda -> vulkan. The CubeCL family
wins detailed on every vendor tested (1.03-1.07x over hand-CUDA on
NVIDIA, 1.2-2.6x over hand-Vulkan elsewhere), and since b825b2e a
toolkit-less cubecl-cuda init fails cleanly, so NVIDIA-without-toolkit
falls through to wgpu speed instead of the hand-WGSL fallback.

Niceonly: cuda -> cubecl -> vulkan. Hand-CUDA keeps a slim edge at the
b50+ bases where long-run wall time concentrates (0.91-0.97 for
cubecl-cuda); everywhere without a toolkit CubeCL is the best
available: it wins RADV b50+ (1.28-1.38), wins NVIDIA-over-wgpu
outright, and runs out of the box on Apple. The known concession is
Apple niceonly via MoltenVK (0.55-0.79), which needs optional brew
packages and stays reachable with --gpu-backend vulkan.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
…gain

The evaluation's wasm build fixes lived only in the throwaway probe
crate; at head, cargo check -p nice_common --features cubecl --target
wasm32-unknown-unknown failed in getrandom (no entropy backend named).
Target-gated dependencies activate getrandom's wasm_js backend and
cubecl-common's serde feature on wasm builds, tied to the cubecl
feature; native builds are unaffected. First step of readying the core
for the browser/WebGPU client.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
@wasabipesto

Copy link
Copy Markdown
Owner

@Janzert thanks for the comments, feel free to test again and benchmark this version. I may remove the vulkan backend before merge based on performance and auditablity, unless you have hardware where it performs better.

Claude and others added 2 commits August 17, 2026 23:59
…ming

Three changes so the coming wasm/WebGPU PR does not have to touch this
backend again (its remaining work is the u32-only kernel variant, which
can live in its own module, plus wasm-client plumbing):

- CubeclContext::new_default_async over init_setup_async — the only
  form a browser permits (the sync init_setup panics on wasm by
  design). Both constructors share one process-wide client.
- process_range_detailed_cubecl_async: the detailed path is now an
  async body using read_async (a wasm future cannot block); the sync
  entry is that future under block_on, so native device tests exercise
  the same code. Niceonly stays sync — its MSD thread pipeline is not
  browser scope.
- Instant now comes from web-time, which re-exports std on native and
  works on wasm32, where std::time::Instant panics at runtime.

Also --gpu-wgpu-device (env NICE_GPU_WGPU_DEVICE), from review item 3:
--gpu-device indexes per-backend namespaces (CUDA ordinals != Vulkan
ordinals != wgpu adapters), so on a multi-GPU box no single number is
right for every backend. The flag takes CubeCL's own device spelling
(DiscreteGpu(0), IntegratedGpu(1), Cpu, ...) and is translated to the
CUBECL_WGPU_DEFAULT_DEVICE env var before init, keeping one parser.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
One place covering the GPU path: the four backends and what each needs
at runtime per platform, auto's per-mode selection order and its
explicit-is-fatal rule, multi-GPU selection (--gpu-device's per-backend
namespaces and the typed --gpu-wgpu-device), and the troubleshooting
answers for the failure modes we have actually seen — missing NVRTC,
watchdog resets, MoltenVK setup, wrong laptop adapter, CPU fallback
bases. The embedded --help output was three backends out of date;
regenerated from the umbrella build.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
@Janzert

Janzert commented Aug 18, 2026

Copy link
Copy Markdown
Contributor

Looking good here, below are the latest numbers.

My Claude is rather fixated on niceonly processing and really doesn't want to give it up to drop the vulkan backend. I'm not really sure it's worth keeping around for the relatively small AMD and Apple gains (and possibly Intel).

All four boxes at 0efb010, cubecl ÷ vulkan:

box detailed niceonly b50+
RADV 860M (AMD) 1.74–1.77 0.87–0.91
Intel UHD 2.44–2.57 0.68–1.01 (results too noisy to draw conclusions)
RTX A1000 2.19–2.31 1.42–1.55
RTX 4060 2.48–2.50 1.34–1.43

Detailed: CubeCL wins everywhere, 1.74–2.57. Niceonly: a split — both NVIDIA boxes have CubeCL ahead by 1.34–1.55, AMD has it behind, Intel can't resolve it.

The Vulkan backend still performs better somewhere — AMD niceonly. And the specific claim in 64c14a7 that CubeCL "wins RADV b50+ (1.28-1.38)" is not found on this RADV machine; measured 0.87–0.91, and unreachable even on the most cubecl-favourable repeat pairing.

P.S. Part of Claude's response after reviewing the above:

On the opening line — your call, but "my Claude is rather fixated and really doesn't want to give it up" tells the maintainer to discount the part of your message that's least about preference and most about measurement. The niceonly weighting isn't a quirk: it's the mode carrying the bulk of production wall time. If you like the self-deprecating tone, it lands better after the table, once the numbers have spoken.

Like I said it really likes niceonly. It also disputes my characterization that the Apple (and potentially the Intel) gains are relatively small.

@wasabipesto
wasabipesto marked this pull request as ready for review August 18, 2026 05:21
@wasabipesto

Copy link
Copy Markdown
Owner

That's pretty funny. I'm still leaning towards removing the Vulkan backend, mainly because most of the compute I'm buying right now is from cuda-only markets (Vast and my local HPC). These advances are super cool and will allow some people with non-nvidia cards to contribute but I'm skeptical that we'll see more than two or three users per month using the non-cuda backends (just based on historic patterns). If you knew of a market where I could rent a dozen or so cheap preemptible AMD cards or mac minis it may be worth it to maintain a moderately-faster backend, but since most of the wall-time is going to hit cuda anyways I'm not so concerned about these cases.

The gpu umbrella no longer includes vulkan: every platform it serves
is covered by cubecl, which beats it in detailed mode on all vendors
measured, so the standard GPU binary and the -gpu docker image drop the
hand-WGSL backend and its ash/naga dependencies. The feature stays in
the tree, marked experimental, for --features gpu,vulkan builds — to be
promoted or removed once its remaining niceonly niches (Metal via
MoltenVK, RADV at prefilter-heavy bases) stop mattering or get closed.
No user-facing change otherwise: auto's order already ends with vulkan
only in builds that include it, and CI's device-parity jobs name their
features explicitly and still cover it.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
@wasabipesto
wasabipesto merged commit d4f302f into wasabipesto:main Aug 19, 2026
21 checks passed
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

3 participants