Skip to content

fix(kernels): utils.h helpers must be host+device, not device-only (#150) - #155

Merged
balbasty merged 1 commit into
mainfrom
fix/kernels-utils-host-device
Aug 20, 2026
Merged

fix(kernels): utils.h helpers must be host+device, not device-only (#150)#155
balbasty merged 1 commit into
mainfrom
fix/kernels-utils-host-device

Conversation

@balbasty

@balbastybalbasty commented Aug 20, 2026

Copy link
Copy Markdown
Collaborator

Fixes#150. The report is right about the defect and wrong about three of its
details — one of them the part that decides whether the shipped library works.

Everything below is measured on nvcc 12.0.140, the version build-cuda
installs.

The mechanism — what nvcc actually does

nvcc does reject a __host____device__ call. But only when the
calling function is not itself a template.
That is the whole hazard, and it is
not documented as a limitation anywhere in the CUDA guide.

callercalleenvcc 12.0.140
plain __host__ function__device__ functionerror
plain __host__ function__device__ function templateerror
__host__ function template__device__ functionaccepted, no diagnostic
__host__ function template__device__ function templateaccepted, no diagnostic

Rows 1–2 give

matrix.cu(6): error: calling a __device__ function("d_plain(const int *, int)")
from a __host__ function("h") is not allowed

Rows 3–4 give rc=0 and empty stderr. --Werror all-warnings and
-Xcudafe --display_error_number add nothing: there is no diagnostic to
promote.

In the silent rows cudafe++ writes this into the host object in place of
the callee's real body — the real one is kept beside it under #if 0:

# 11 "repro.cu"
template<classOT, classIT>__attribute__((unused)) inlineOTmy_prod(constIT*p, int32_tn)
{intvolatile___=1;(void)p;(void)n;::exit(___);}
#if0
{ OTr= (1); for (int32_ti=0; i<n; ++i) { r *= ((OT)(p[i])); } returnr; }
#endif

Everything follows from that. It is a call to exit, not an error, and exit
is noreturn, so from -O1 up the host compiler deletes every statement after
the call. The buggy entry point at -O1 is five instructions:

0000000000000019 <entry(Tensor&, int)>:
19: endbr64
1d: sub $0x18,%rsp
21: movl $0x1,0xc(%rsp)
29: mov 0xc(%rsp),%edi
2d: call 32 <...> 2e: R_X86_64_PLT32 exit-0x4

Correction 1 — -O0 is not the safe end of the range

The issue reads refs: 3 at -O0 as "not affected". It is affected
identically; only the object-code symptom is optimisation-dependent. Linked
and run (no GPU needed — nothing is launched):

-O0-O1
host+device prodf1 reached / returned normally, exit 0same
__device__-only prodno output, exit 1no output, exit 1

At -O0 the dispatch is still in the object and still never reached: the
process is gone before it. This is a process-terminating defect at every
optimisation level, not dead code at -O1+. For a library loaded into a Python
process, that is exit(1) under the user's interpreter.

Correction 2 — FF_INDEX32=0 is not a workaround, and index64 is not correct

The follow-up comment concludes that "index64 produces a functionally correct
library and index32 … does not". That holds for splinc and does not
generalise. canUse32BitIndexMath is only one of three host callers:

  • canUse32BitIndexMathtyped_prod, behind every FF_CANUSE32BITS;
  • every FF_CUHOST launcher in impl/cuda/{reg_field,reg_flow, distance_euclidean,distance_l1,distance_mesh}.hprod(size, n) on its
    first line
    , to size the grid;
  • impl/kernels/distance/mesh.h's FF_CUHOST build_treemax.

Only the first is behind FF_INDEX32. Measured on unmodified b0463bc,
distance.cpp, -O1, FF_INDEX32=0:

distance.o exported entry points = 6 reaching exit(1) = 3
ff::cuda::dt_euclidean(...)
ff::cuda::dt_l1(...)
ff::cuda::dt_mesh(...)

Exactly the three whose launchers live in the three headers that call prod
from host code. The spline entry points, whose launchers do not, are fine.
Turning the axis off moves the truncation from the entry point down into the
launcher; it does not remove it. Neither build-cuda leg produced a working
library.

CI agrees: in the index64 leg, the only module whose peak RSS moves on this
branch is distance, +15.2% (564,184 → 650,204 kB) — the work that was
being discarded there, now being done.

Correction 3 — the blast radius is 100%, and "78 sites" is not the unit

"78" counts FF_CANUSE32BITSlines; there are 123 occurrences in
src/lib-cuda. Neither is the blast radius, because one entry point can contain
several and because the launcher edge is not counted at all. The unit that
matters is exported entry points whose body is truncated, measured per
object by walking each object's call graph to the exit stub:

moduleexported ff::cuda:: entry pointsreaching exit(1) on mainon this branch
distance660
posdef880
pushpull440
pushpull_backward440
reg_field13130
reg_field_rls330
reg_flow13130
reg_flow_rls330
resize110
restrict110
splinc110
total57570

Every entry point of the public CUDA API. Not "every dispatch" — the whole
surface.

And the compiler had already said so

tests/impl-cuda/compile_probe_mesh.cu has carried this since the mesh port:

An explicit instantiation also instantiates the body in nvcc's device pass,
which reports a spurious error: calling a __device__ function ("ff::cuda::prod") from a __host__ function("sdt") is not allowed because
prod is FF_CUDEV. … it is an artifact of the probe technique, not a defect.

It was not an artifact. That is #150, reported correctly by nvcc and dismissed;
an explicit instantiation was the one thing in the tree that made the check
fire. Corrected in place, and the technique is now used deliberately.

The fix

impl/kernels/utils.h's entire first half — swap, square, sqrt, pow,
min, max, abs, sign, mod, typed_prod ×2, prod ×2, fillfrom ×4,
fill ×2 — was inline FF_CUDEV. All 28 become FF_CUHOSTDEV. Nothing in the
header is device-specific, and its second half (StaticValue and its 60-odd
operators) was already FF_CUHOSTDEV, so the header is now uniform. Device
codegen is unchanged; the host side gains one inline function per used
instantiation.

How the class was found, three ways that agree: (a) nm -u | grep -w exit over
every built object — an undefined exit has exactly one source here, since
nothing in the tree calls ::exit; (b) grep for unqualified calls to each
utils.h name from FF_CUHOST context, which is what turned up the launchers
and build_tree; (c) FF_CUHOST appears 129 times in include/, few enough to
read.

The guards — worth more than the fix

  1. tests/impl-cuda/compile_probe_hostdev.cu — calls every helper from a
    plain, non-template__host__ function, i.e. the shape nvcc does check.
    Against the pre-fix header it produces 22 errors; against this branch it
    compiles in 2.5 s. It also explicitly instantiates one launcher per
    affected header, which re-checks the whole launcher body in the device pass.
    Picked up automatically by compile-probe-cuda (PROBESRC is a wildcard).
  2. tools/check-cuda-host-stubs.sh — new build-cuda step over
    build/obj/lib-cuda/*.o, fails on any undefined exit. Runs in 2 s and is
    the catch-all for edges the probe does not enumerate.

Nothing that exists today could have gone red: the damage is intra-TU, so
--no-undefined and ldd -r both pass; front-end instantiation is unaffected,
so the FFMEM budget does not move; and the CPU suite cannot see it because
both macros are empty without nvcc.

Cost — measured, and the memory gate does not trip

build-cuda (index32), this branch vs run 32388984092 (main at b0463bc),
same runner image, same flags:

modulepeak RSS beforeafterΔnvcc s beforeafter
reg_flow13,604,176 kB13,605,772 kB+0.01%1043.81069.3
reg_field8,523,6248,630,240+1.25%781.4848.9
reg_field_rls7,417,2447,416,756−0.01%699.2745.0
resize2,100,6962,100,088−0.03%194.9210.3
reg_flow_rls1,992,6921,991,952−0.04%163.7159.5
pushpull_backward1,580,6201,580,5960.00%162.9191.8
pushpull1,555,5961,553,024−0.17%158.0177.2
restrict1,361,4081,361,584+0.01%117.8136.1
distance843,7281,041,956+23.5%130.1146.6
posdef407,556453,184+11.2%44.147.6
splinc439,776439,176−0.14%68.668.4
.so link439,164541,332+23.3%1.51.5

Compile step wall: 34m24s → 35m45s (+3.9%). reg_flow stays at 12.98 GiB
against the 14 GiB FF_MEM_BUDGET_KB, with the same ~1.02 GiB of headroom.

Why the peaks barely move, and why that is not evidence the fix does nothing.
FFMEM is ru_maxrss — a maximum over the nvcc process tree. Restoring the
deleted code adds work to the host pass (cc1plus). For the small modules
the host pass is the peak, so it shows: distance +23.5%, posdef +11.2%, the
.so link +23.3%. For the regularisers the peak is cicc/ptxas in the
device pass, which this change does not touch at all, so a real increase in
host work is invisible to that metric — it surfaces instead in compile time
(reg_field +8.6%, pushpull +12.1%, restrict +15.5%, pushpull_backward +17.7%)
and in object size (locally, at identical flags: distance 7.20 → 12.30 MB,
resize 25.14 → 29.79 MB).

The uncomfortable corollary: FF_MEM_BUDGET_KB could never have caught this
defect and cannot confirm its repair.
It measures the pass that was always
working. Worth noting next to the #147 discussion — the split is still the right
call, but not because of anything here.

The same across all 11 modules on a local rig (all-Dynamic policy, -O1,
FF_INDEX32=1, so absolute values differ from CI; the ratios are the point):
peaks move ≤0.03% for resize/restrict/splinc/reg_field/reg_field_rls/reg_flow/
reg_flow_rls, and +20% to +52% for distance/posdef/pushpull/pushpull_backward —
exactly the modules whose peak is the host pass.

CPU path — unchanged, proved not assumed

FF_CUDEV and FF_CUHOSTDEV both expand to nothing outside nvcc, so all ten
src/lib-cpu/*.cpp preprocess byte-for-byte identically before vs after
(g++ -E -P, ten of ten). And the gate itself:

tools/test-baseline.sh --tree . --legs default,lib
default leg: 13 suites, 59,886 checks, 0 failures
+ 2 hub suites (14 checks)
15 rows, diffed row-for-row against tools/test-baseline.expected -> identical

(--check insists on --legs all, so the comparison is done by diffing the
default/lib rows, as #147 also did.)

Green

build-cuda (index32), build-cuda (index64), compile-probe-cuda, all six
test-cpu legs, test-hub, codespell. Both new gates ran and passed.
lint (clang-format) is continue-on-error and unreadable (#89) — not chased.

No GPU in CI

Nothing here is a runtime claim. What still needs real hardware: that the
restored dispatch selects the right instantiation for each dtype/dim/bound, that
the launchers launch and the kernels compute correctly, the CUDA stream
plumbing, the atomics, and the mesh sdt path. This PR establishes only that
the code is present and reachable — which it was not, anywhere, for any entry
point.

Adjacent

#154 also edits .github/workflows/ci.yml and CLAUDE.md; the two touch
different regions but will want ordering. #147 is gated on this fix and is
unaffected by it in its own measurements.

`impl/kernels/utils.h` declared its whole first half -- swap, square, sqrt,
pow, min, max, abs, sign, mod, typed_prod, prod, fillfrom, fill -- as
`inline FF_CUDEV`, i.e. `__device__` only. Host code calls them:
* `canUse32BitIndexMath`, in this same header, calls `typed_prod`. That is
the edge behind every `FF_CANUSE32BITS` in `src/lib-cuda`.
* Every `FF_CUHOST` launcher in `impl/cuda/{reg_field,reg_flow,
distance_euclidean,distance_l1,distance_mesh}.h` calls `prod(size, n)` on
its first line, to size the grid.
* `impl/kernels/distance/mesh.h`'s `FF_CUHOST build_tree` calls `max`.
nvcc rejects a host->device call only when the calling function is not itself
a template. All of the above are templates, so nvcc emitted no diagnostic and
cudafe++ wrote `{int volatile ___ = 1; ...; ::exit(___);}` into the HOST
object in place of each callee's body. The result links cleanly, passes
`-Wl,--no-undefined` and `ldd -r` (the damage is intra-TU), terminates the
process with status 1 on the first call, and -- `exit` being `noreturn` --
loses every statement after that call at -O1 and above.
Make the whole header `FF_CUHOSTDEV`. Nothing in it is device-specific;
device codegen is unchanged and the host side gains one inline function per
used instantiation. Without nvcc both macros expand to nothing, so the CPU
layers preprocess byte-for-byte identically (verified over all ten
`src/lib-cpu/*.cpp`).
Two gates, because the fix alone leaves the trap set:
* `tests/impl-cuda/compile_probe_hostdev.cu` calls each helper from a
plain, non-template `__host__` function -- the shape nvcc does check --
so a re-qualified helper is a compile error again. It produces 22 errors
against the pre-fix header and compiles in ~2 s. It also explicitly
instantiates one launcher per affected header, which type-checks the
launcher body in the device pass where the call edge is reported.
* `tools/check-cuda-host-stubs.sh`, run in `build-cuda`, fails on any
undefined `exit` in `build/obj/lib-cuda/*.o`. Nothing here calls
`::exit`, so that symbol has exactly one source.
`compile_probe_mesh.cu` documented this exact nvcc error as "a spurious
artifact of the probe technique, not a defect". It was neither; that comment
is corrected in place.
Fixes#150.
@balbasty
balbasty merged commit 247714e into mainAug 20, 2026
13 of 14 checks passed
@balbasty
balbasty deleted the fix/kernels-utils-host-device branch August 20, 2026 18:10
balbasty pushed a commit that referenced this pull request Aug 20, 2026
#155 fixed nvcc writing `::exit(1)` into the host object in place of every
impl/kernels/utils.h helper, which is the state the first measurement of this
split was taken in. Both sides of the table are now post-#155 runs of the
same job, so nothing quoted here comes from a library that could not run.
The finding is that #155 did not move the compile at all: unsplit reg_flow
peaks at 12.98 GiB before and after the fix, because the code #155 restored
is host code and the peak belongs to a device-side process. The split's case
is unchanged, but it is now stated from runs that measure a working library.
Re-measured, index32 (BEFORE run 32397332659 / AFTER run 32418602148):
heaviest reg_flow TU 12.98 -> 1.94 GiB (-85.0%)
reg_flow elapsed, sum 1069.30 -> 803.32 s (-24.9%)
`make cuda -j2` wall 2145 -> 1861 s (-13.2%)
max peak, any module 12.98 (reg_flow) -> 8.35 (reg_field)
and the index64 leg besides, where the elapsed saving is much smaller (-5.7%)
because collapsing the offset axis had already removed half the
instantiations -- while memory still falls by four fifths.
Two corrections to how the previous numbers were framed. The elapsed and
wall-clock savings are smaller than first reported (-24.9% and -13.2%, not
-34.6% and -25.5%); reg_field, which nothing here touches, moved 848.87 ->
861.05 s between the same two runs, so wall-clock on this job carries several
percent of noise and is quoted with that caveat. Peak RSS does not: all
thirteen per-slice peaks reproduce the earlier run to within 0.01 GiB, and
reproduce off-runner on a different machine to 0.1-0.7%.
Also annotates the #80 table above, whose reg_flow row no longer describes a
translation unit that exists, with post-#155 figures for the modules this
change does not touch.
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.

libfastfields-cuda.so: every dispatch after FF_CANUSE32BITS is silently deleted by nvcc (__host__ calling a __device__-only typed_prod)

1 participant

@balbasty