Skip to content

fix(cuda): route all 39 kernel launches through one checked helper - #154

Merged
balbasty merged 4 commits into
mainfrom
fix/cuda-launch-error-checking
Aug 20, 2026
Merged

fix(cuda): route all 39 kernel launches through one checked helper#154
balbasty merged 4 commits into
mainfrom
fix/cuda-launch-error-checking

Conversation

@balbasty

@balbastybalbasty commented Aug 20, 2026

Copy link
Copy Markdown
Collaborator

Fixes#152.

The defect

A CUDA kernel launch is asynchronous and does not throw. It reports failure by
setting the runtime's error state, and that state has to be read by somebody.
Nothing here read it: 39 <<< sites, zero cudaGetLastError, zero
cudaPeekAtLastError
in the whole repository. A kernel that never ran was
therefore indistinguishable from one that ran correctly — the launcher returned
normally and the caller read whatever was already in the output buffer.

The shape of the fix

Not a check macro at 39 call sites. <<< now appears in exactly one place in
the tree
include/fastfields/impl/cuda/launch.h — and all 39 sites reach it
through FF_CUDA_LAUNCH:

- kernel<NB, ndim, scalar_t, offset_t, reduce_t, IX, BX, IY, BY, IZ, BZ>
- <<<blocks, threads, 0, s>>>(out, inp, shift, d_scale, ...);
+ FF_CUDA_LAUNCH(
+ (kernel<NB, ndim, scalar_t, offset_t, reduce_t, IX, BX, IY, BY, IZ, BZ>),
+ blocks, threads, 0, s,
+ out, inp, shift, d_scale, ...);

ff::cuda::launchKernel launches on the caller's stream — unchanged, no
regression to the default stream — then calls cudaGetLastError() and throws
with the kernel name and the grid/block configuration.

The macro exists only because #KERNEL records the kernel's spelling for the
report; the work is a function template, per this tree's "prefer an inline
function to a macro" rule. nvcc lowers ptr<<<…>>>(args) through the same host
stub as a direct launch, so the indirection is free and the argument list is
still type-checked against the kernel signature at the call.

This costs no synchronisation — please read this before worrying that it does

cudaGetLastError() is a host-side read of a thread-local error word that
the driver wrote while enqueueing the launch. It does not touch the device, does
not wait for the kernel, and does not change stream ordering or host/device
overlap. Two such reads per launch (one peek, one get) against a launch that
costs microseconds.

The price of that is what it cannot see: a fault raised while the kernel
executes (illegal address, misaligned access, device-side assert). Observing
those requires a synchronisation point, which would serialise every launcher and
destroy the asynchrony the design depends on. So that is opt-in and off by
default
:

FF_CUDA_LAUNCH_SYNC=1 ./your-program # environment variable
nvcc -DFF_CUDA_LAUNCH_SYNC=1 ... # or a build flag

The environment wins over the compile-time default in both directions, so a
release build can be asked for the diagnosis once without a rebuild. It is this
project's CUDA_LAUNCH_BLOCKING, and it is never on by default.

Misattribution: peek first, and say so

cudaGetLastError() returns and clears the last error from any preceding
CUDA call — not just from a launch. So a stale, unrelated failure would
otherwise be blamed on whichever launch happened to look next.

What I chose:cudaPeekAtLastError() before the launch (it does not clear).
If the state was already dirty with the same code, the report says so in as many
words rather than asserting this kernel failed.

What I rejected, and why: draining at the dispatch boundary. fastfields is a
library inside somebody else's process — PyTorch, CuPy — and that error belongs
to them; they are entitled to see it on their own next check. Draining would
discard it. Peeking takes nothing away. The failure path does clear, but only
because it immediately converts the error into an exception: escalated, never
swallowed.

This is not theoretical, and it is already exercised — see the sample output
below, where an earlier cudaMalloc left the state dirty and the helper
correctly declined to blame the kernel.

Sticky vs non-sticky, in the message

The distinction is invisible in the error string and matters enormously:

  • a configuration error is raised before the kernel starts — the context is
    untouched, the fix is on the caller's side;
  • an execution error poisons the CUDA context — every later CUDA call in the
    process fails with the same code. That is "this process is dead", not "retry
    with less".

launchErrorClass() classifies both lists explicitly and reports anything on
neither as unclassified rather than guessing. A wrong claim about stickiness
is worse than no claim.

The trigger (CUDA_NUM_THREADS = 1024) — reported now, fixed separately

1024 is the architectural maximum, not a safe default. On failure the report
carries the kernel's cudaFuncGetAttributes().maxThreadsPerBlock and register
count and says outright when the block size exceeded it, so the first person to
hit cudaErrorLaunchOutOfResources gets the number to clamp to instead of a
mystery.

Making the block size fit automatically is deliberately not in this PR, and
the reasons are recorded above CUDA_NUM_THREADS in utils.h:

  • Two launchers couple the launch geometry to a host-side allocation.
    distance_euclidean::dt and distance_mesh::sdt size a per-lane scratch
    buffer as num_blocks * CUDA_NUM_THREADS on the host, while the kernel
    derives its lane stride from blockDim.x * gridDim.x on the device. Shrinking
    the block alone is safe (the loops are grid-stride, and the buffer is merely
    over-allocated); raising the grid to compensate — which is what
    cudaOccupancyMaxPotentialBlockSize hands you — is an out-of-bounds device
    write.
    Untangling that coupling is its own change with its own review.
  • It is a behaviour change to launch geometry, not a reporting change.
  • It cannot be exercised here at all. cudaFuncGetAttributes needs a device, so
    a clamp would ship completely unvalidated, whereas the reporting path is
    validated by compile+link and by the message being exercisable on a
    device-less host (below).

Per the issue's own ordering: reporting the failure is worth more than avoiding
it, and it is the half that can be done without hardware.

Exceptions at the call sites

The launchers can now throw where they previously could not, so I checked every
one.

  • No launcher is reachable from a destructor or a noexcept function. The
    only noexcept in the tree is on AbstractVector's accessors and the only
    destructors are mesh_utils.h's virtual ones; none launches anything.
  • distance_mesh::copy_faces had no cleanup handler at all, because before
    this nothing between its allocDevice and its return could fail. It has one
    now, or the device buffer leaks on the new throw path.
  • Twelve catch (const std::exception &exc) { …; throw exc; } sites were
    copy-initialising a std::exception from the reference and throwing that —
    dynamic type and message both discarded. Four sit directly on a launcher's
    unwind path, where they would have replaced the entire diagnostic below with
    "std::exception". All twelve are now throw;, including the one in
    impl/cpu/distance_euclidean.h that has nothing to do with CUDA but is the
    same defect.

The lint — the part that makes this stick

There is no GPU in CI, so nothing can execute a launch and no test can notice
the check being dropped: an unchecked launch compiles, links and passes every
gate this project has. That is exactly how #152 survived. But it is a textual
property, so tools/check-cuda-launches.py fails if:

  1. <<< appears in code anywhere outside launch.h;
  2. launch.h does not contain exactly one <<<;
  3. launch.h stops calling cudaGetLastError — the funnel is still a funnel
    but no longer checks anything, which is No CUDA launch error checking anywhere: 39 launches, zero cudaGetLastError — failures are silently discarded #152 again with more steps;
  4. cudaLaunchKernel / cudaLaunchCooperativeKernel is called outside the
    helper, which is how you launch without writing <<<.

Comments and string literals are blanked before scanning, so prose may discuss
<<< freely (launch.h's own header comment does, six times) — that is what
lets rule 2 be stated about the helper instead of waived for it.

--selftest runs first in CI and is the other half: a lint that has quietly
stopped matching anything prints "clean" forever. It asserts the analyser still
flags a bare launch, still ignores one in a // comment, a /* */ comment and
a string literal, is not fooled by an escaped quote or by // inside a string,
does not mistake a << b or cout << x << y for a launch, and still reports the
right line number.

Wired in as lint (cuda launch sites) — unconditional rather than path-filtered
(the rule is tree-wide and the cost is a Python startup), following
tools/rename-macros.py --check and tools/test-baseline.sh --check.

It also understands raw string literals, whose bodies may hold unescaped "
and \: a scanner that mishandled one would go out of phase and stop seeing
launches after it — a false pass, which is the only failure mode that
matters here. The tree has none today; that is the point of covering it now.

$ python3 tools/check-cuda-launches.py --selftest
check-cuda-launches: selftest passed (15 cases)
$ python3 tools/check-cuda-launches.py --check
check-cuda-launches: clean -- 166 source file(s) scanned, 1 launch site in
include/fastfields/impl/cuda/launch.h, 39 FF_CUDA_LAUNCH call site(s).

And with a launch reintroduced the old way:

include/fastfields/impl/cuda/splinc.h:113: kernel launch outside the helper
void rogue() { k<<<1, 2, 0, 0>>>(x); }
use FF_CUDA_LAUNCH((kernel<...>), grid, block, shmem, stream, args...)
from <fastfields/impl/cuda/launch.h>

What a failure looks like

Real output, from a binary built against this branch and run on a machine with
no GPU (so the launch fails with cudaErrorNoDevice). It also happens to
demonstrate the attribution logic: the preceding cudaMalloc had already failed
and left the error state dirty, and the helper says so instead of blaming the
kernel.

ff::cuda: CUDA kernel launch failed.
kernel : demo_kernel<3, float>
configuration : grid=(1,1,1) block=(1024,1,1) shared=0 bytes stream=(nil) -- 1024 threads total
error : cudaErrorNoDevice (100): no CUDA-capable device is detected
meaning : the launch was REJECTED before the kernel started, so no kernel has
poisoned the CUDA context. The fix is in the launch configuration, the
inputs, or the environment.
observed : immediately after the launch, without synchronising
ATTRIBUTION : the CUDA error state was ALREADY set with this same code before this
launch was issued, so it may have been raised by an earlier CUDA call
outside fastfields. It is reported here because this is the first place
in the process that inspects it.

Validation

CI on this branch (run 32391904003, at 25d6e1d; the later tools/-only commit re-runs the lint jobs only, by the path filter)

job
lint (cuda launch sites)✅ selftest + tree clean
lint (codespell)
test-cpu × 5 (clang-static, clang-dynamic, clang-cuda-default, clang-index64, gcc-static)
test-cpu (sanitize asan+ubsan), test-cpu (tsan, grain=1)
test-hub
build-cuda (index64), build-cuda (index32)✅ both, incl. the hub link with --no-undefined and ldd -r
compile-probe-cuda
lint (clang-format, changed lines)❌ — continue-on-error, and it cannot print its own findings (#89). Not chased.

The gate, row-for-row

tools/test-baseline.sh --tree . --legs default,lib --check against the
default and lib rows of tools/test-baseline.expected (the 13 default
rows sum to exactly 59,886 checks):

BASELINE MATCH: 15 rows identical to <the default+lib rows of tools/test-baseline.expected>

All 13 suites, 0 failures, every per-suite count unchanged. (--check compares
whole reports and refuses a leg subset outright — "BASELINE NOT COMPARABLE" —
so the recorded file was filtered to the two legs first rather than the counts
being summed by hand.)

nvcc peak RSS is unchanged

From this run's own build-cuda FFMEM tables, against the two columns recorded
above MODULES in src/lib-cuda/Makefile (#143). reg_flow — the module the
14 GiB budget exists for — lands on the recorded figure to 0.02%:

moduleindex32 recordedindex32 hereindex64 recordedindex64 here
reg_flow12.97 GiB12.973 GiB5.69 GiB5.694 GiB
reg_field8.09 GiB8.165 GiB3.76 GiB3.760 GiB
reg_field_rls7.07 GiB7.073 GiB3.42 GiB3.417 GiB
resize2.00 GiB2.004 GiB1.08 GiB1.079 GiB
reg_flow_rls1.90 GiB1.900 GiB1.01 GiB1.008 GiB
pushpull_backward1.51 GiB1.509 GiB0.80 GiB0.802 GiB
pushpull1.48 GiB1.482 GiB0.79 GiB0.789 GiB
restrict1.30 GiB1.298 GiB0.68 GiB0.684 GiB
distance0.80 GiB0.817 GiB0.54 GiB0.545 GiB
splinc0.42 GiB0.419 GiB0.27 GiB0.274 GiB
posdef0.37 GiB0.386 GiB0.35 GiB0.330 GiB

peak nvcc RSS within budget (14680064 kB). The helper is host code
instantiated once per launch-site signature, so it adds nothing to the
device-side instantiation matrix — which is what the 14 GiB FF_MEM_BUDGET_KB
gate protects. Reproduced locally on nvcc 12.0.140 (the version CI installs):
reg_flow 5,969,432 kB local vs 5,970,612 kB in CI, a 0.02% difference.

Explicitly not claimed: anything about runtime

Compile+link is the CUDA bar here. This change makes failures reportable; it
does not prove any launch succeeds. A GPU run would still be needed to
establish:

  • whether any current instantiation actually exceeds maxThreadsPerBlock at
    1024 threads. That is unknown, and it is the question No CUDA launch error checking anywhere: 39 launches, zero cudaGetLastError — failures are silently discarded #152 exists to make
    answerable rather than the one it answers;
  • that the maxThreadsPerBlock / register line of the report is produced for a
    real cudaErrorLaunchOutOfResourcescudaFuncGetAttributes needs a device,
    so that line is the one part of the message a device-less host cannot
    exercise;
  • that FF_CUDA_LAUNCH_SYNC=1 does surface an execution fault (illegal address,
    device assert) that the default path is documented as unable to see;
  • that throwing out of a launcher unwinds correctly with real device allocations
    in flight — i.e. that the freeDevice cleanup paths, including the new one in
    copy_faces, actually free what they claim.

One further gap, for the record: the fully-static pushpull order × bound matrix
(.github/workflows/nightly-pushpull.yml, which has a CUDA leg) is scheduled
and therefore never runs on a PR. It will first see pushpull.h's converted
launch sites after merge, at 03:17 UTC.

Coordination

Reported, not fixed

CLAUDE.md says tools/normalise-include-delimiters.py --check and
tools/normalise-header-guards.py --checkenforce their conventions, and
tools/rename-macros.py --check is quoted as a gate in PR descriptions — but
no workflow runs any of them. They all pass on main today, so wiring the
other three into the new lint job would be three lines; I left it out to keep
this PR to one subject. Worth doing.


Generated by Claude Code

A CUDA kernel launch is asynchronous and does not throw. It reports
failure by setting the runtime's error state, which somebody then has to
inspect. Nothing here did: 39 `<<<` sites, zero `cudaGetLastError` and
zero `cudaPeekAtLastError` in the whole repository (#152). A kernel that
never ran was therefore indistinguishable from one that ran correctly --
the launcher returned normally and the caller read whatever was already
in the output buffer.
The fix is structural rather than conventional. `<<<` now appears in
exactly one place in the tree, `impl/cuda/launch.h`, and all 39 sites go
through the `FF_CUDA_LAUNCH` macro that wraps it. A convention applied at
39 call sites is a convention that will be forgotten at the 40th; #152
exists precisely because this one was never applied at any of them. The
lint that makes the funnel unbypassable is the following commit.
`ff::cuda::launchKernel` launches on the CALLER'S STREAM -- unchanged, no
regression to the default stream -- then calls `cudaGetLastError()` and
throws with the kernel name and the grid/block configuration.
* No synchronisation. The post-launch check is a host-side read of a
thread-local error word; it does not touch the device and does not
wait for the kernel, so stream ordering and host/device overlap are
exactly as before.
* It therefore cannot see a fault raised while the kernel *executes*.
Observing those needs a synchronisation point, which would serialise
every launcher, so that is opt-in and off by default:
FF_CUDA_LAUNCH_SYNC, a build flag and an environment variable of the
same name (environment wins in both directions) -- this project's
CUDA_LAUNCH_BLOCKING.
* Misattribution is handled, not ignored. `cudaGetLastError` returns
AND CLEARS the last error from any preceding CUDA call, so a stale
unrelated failure would otherwise be blamed on this launch. The
helper peeks first (`cudaPeekAtLastError`, which does not clear); if
the state was already dirty with the same code the report says so
explicitly. Draining at the dispatch boundary instead was rejected:
that would discard an error belonging to the host application, which
is entitled to see it. Peeking takes nothing away, and the failure
path clears only because it immediately throws.
* Sticky and non-sticky errors are distinguished in the message.
A rejected launch leaves the context usable; an execution fault
poisons it for the rest of the process. Codes on neither list are
reported as unclassified rather than guessed at.
* On failure the report also carries the kernel's
`cudaFuncGetAttributes().maxThreadsPerBlock` and register count, and
says in as many words when the block size exceeded it. That is the
trigger #152 names -- CUDA_NUM_THREADS is 1024, the architectural
maximum rather than a safe default -- turned from a silent wrong
answer into a sentence naming the number to clamp to. Making the
block size fit automatically is deliberately not done here; the
reasons are recorded above CUDA_NUM_THREADS in utils.h.
Two consequences of the launch now being able to throw, both required
for the report to survive:
* `distance_mesh::copy_faces` had no cleanup handler, because before
this nothing between its `allocDevice` and its `return` could fail.
It has one now, or the device buffer leaks on the new throw path.
* Twelve `catch (const std::exception &exc) { ...; throw exc; }` sites
copy-initialised a *std::exception* from the reference and threw
that, discarding the dynamic type and the message. Four of them sit
directly on a launcher's unwind path, where they would have replaced
the whole diagnostic above with "std::exception". All twelve are now
`throw;`, including the one in impl/cpu that has nothing to do with
CUDA but is the same defect.
No behaviour change on the success path: same kernels, same
configurations, same stream, two host-side error-word reads per launch.
Refs #152
The previous commit funnelled all 39 launches through one helper. On its
own that is still a convention: nothing stops the fortieth launch being
written the old way, and nothing here could notice if it were. There is
no GPU in CI and there is not going to be one, so no test can execute a
launch, and an unchecked one compiles, links and passes every gate this
project has -- which is exactly how #152 lasted as long as it did.
But it is a textual property, so it is checkable without hardware.
`tools/check-cuda-launches.py` fails if:
1. `<<<` appears in code anywhere outside impl/cuda/launch.h;
2. launch.h does not contain exactly one `<<<`;
3. launch.h stops calling `cudaGetLastError` -- the funnel is still a
funnel but no longer checks anything, which is #152 again with more
steps;
4. `cudaLaunchKernel` / `cudaLaunchCooperativeKernel` is called outside
the helper, which is how you launch a kernel without writing `<<<`.
Comments and string literals are blanked before scanning, so prose may
discuss `<<<` freely -- launch.h's own header comment does, six times.
That is what lets rule 2 be stated about the helper rather than waived
for it.
`--selftest` is the other half and runs first in CI. A lint that has
quietly stopped matching anything prints "clean" forever, so it asserts
over synthetic inputs that the analyser still flags a bare launch, still
ignores one inside a `//` comment, a `/* */` comment and a string
literal, is not fooled by an escaped quote or by `//` inside a string,
does not mistake `a << b` or `cout << x << y` for a launch, and still
reports the right line number.
Wired in as `lint (cuda launch sites)`, unconditional rather than
path-filtered: the rule is about the whole tree and the cost is a Python
startup. Follows tools/rename-macros.py and tools/test-baseline.sh --
a committed script with a self-verifying check mode.
Refs #152
Where `<<<` is allowed to appear, what the helper does and does not
check, why the post-launch check costs nothing, and that
FF_CUDA_LAUNCH_SYNC is off by default and stays off. Also adds the new
lint job to the CI summary, which previously listed codespell as the only
unconditional gate.
Refs #152
A blind spot in the blanker, found by asking what would make it stop
matching. A raw string's body may contain unescaped `"` and `\`, so the
ordinary quote scanner would take the first `"` inside `R"(...)"` as the
literal's end and then be one quote out of phase for the rest of the file
-- which does not produce a false alarm, it produces a false *pass*, and
every launch after that point goes unseen. That is the one failure mode
this checker exists to prevent, so it should not have it.
The tree contains no raw strings today; this is about the day somebody
adds one. `R` preceded by an identifier character is not a prefix, so
`MYR("a(b")` is still an ordinary string.
Three more --selftest cases cover it: a launch inside a raw string (not
counted), code after a raw string whose body holds a quote and a
backslash (still counted), and the identifier-ending-in-R case. 15 cases
now.
Refs #152
Sign up for freeto 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.

No CUDA launch error checking anywhere: 39 launches, zero cudaGetLastError — failures are silently discarded

1 participant

@balbasty