Skip to content

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

Description

@balbasty

Found while measuring the reg_flow TU split (#147). It is not caused by that change — it is pre-existing on main and affects the shipped CUDA library.

Claim

In libfastfields-cuda.so, every public entry point that computes
use_32bits = FF_CANUSE32BITS(...) and then dispatches has had the dispatch removed by the compiler. The function validates its arguments and returns without launching anything.

That is all 11 modules in src/lib-cuda/, 78 FF_CANUSE32BITS sites:

distance 19 posdef 10 pushpull 4 pushpull_backward 11 reg_field 11
reg_field_rls 4 reg_flow 12 reg_flow_rls 4 resize 1 restrict 1 splinc 1

src/lib-cpu uses the same macro and is unaffected: under the host compiler FF_CUDEV expands to nothing. That is precisely why the 59,886-check CPU gate has never seen this, and why no CUDA job has either — build-cuda is compile+link only and there is no GPU in CI.

Root cause

include/fastfields/impl/kernels/utils.h:

  • line 192-194 — the dynamic-size typed_prod is inline FF_CUDEV, i.e. __device__ only:
template <typenameOT, typenameIT, typenamesize_t>
inlineFF_CUDEVOTtyped_prod(constIT * x, size_t size)
  • line 667-668 — canUse32BitIndexMath is FF_CUHOST inline (__host__) and calls it:
FF_CUHOSTinlineboolcanUse32BitIndexMath(...)
{
...
int64_t numel = typed_prod<int64_t>(size, ndim);

A __host__ function calling a __device__-only function template. nvcc does not reject it, and at -O1/-O2/-O3 the host object silently loses everything in the caller after that call.

prod(const T * x, size_t size) (~line 217) is FF_CUDEV too and forwards to the same typed_prod, so it is in the same class.

Minimal reproducer

Self-contained, ~40 lines, no project headers needed beyond the qualifier macros. refs counts undefined references to the three dispatch arms; 3 is correct, 0 means the switch was deleted.

#include<stdexcept>
#include<cstdint>
#include<limits>
#ifdef BUG
# definePROD_QUAL __device__ // as typed_prod is today
#else
# definePROD_QUAL __host__ __device__ // the fix
#endiftemplate <typenameOT, typenameIT>
PROD_QUALinlineOTmy_prod(constIT * p, int32_t n)
{ OT r = 1; for (int32_t i = 0; i < n; ++i) r *= (OT)p[i]; return r; }
template <classN, classS, classT>
__host__ inlineboolmy_canUse(N ndim, const S * size, const T * stride)
{
int64_t max32 = std::numeric_limits<int32_t>::max();
int64_t numel = my_prod<int64_t>(size, ndim);
if (numel >= max32) returnfalse;
if (numel == 0) return max32 > 0;
if (stride == nullptr) returntrue;
int64_t offset = 0, lin = numel - 1;
for (N i = ndim - 1; i >= 0; --i) { offset += (lin % size[i]) * stride[i]; lin /= size[i]; }
return offset < max32;
}
structTensor { int32_t ndim; int64_t * shape; int64_t * strides; };
namespacens {
__attribute__((visibility("hidden"))) voidf1(Tensor&, bool);
__attribute__((visibility("hidden"))) voidf2(Tensor&, bool);
__attribute__((visibility("hidden"))) voidf3(Tensor&, bool);
}
voidentry(Tensor & t, int ndim)
{
constbool u = my_canUse(t.ndim, t.shape, t.strides);
switch (ndim) {
case1: returnns::f1(t, u);
case2: returnns::f2(t, u);
case3: returnns::f3(t, u);
default: throwstd::invalid_argument("Only 1D, 2D and 3D");
}
}
$ for O in 0 1 2 3; do for D in "" "-DBUG"; do
printf "%-6s -O%s refs: " "${D:-ok}" $O
nvcc -std=c++14 -O$O $D -x cu -Xcompiler -fPIC -c -o /tmp/r.o repro.cu 2>/dev/null
nm -u /tmp/r.o | c++filt | grep -c "ns::f"
done; done
ok -O0 refs: 3
-DBUG -O0 refs: 3
ok -O1 refs: 3
-DBUG -O1 refs: 0 <-- dispatch deleted
ok -O2 refs: 3
-DBUG -O2 refs: 0
ok -O3 refs: 3
-DBUG -O3 refs: 0

g++ compiling the same source directly keeps all 3 at every -O. It is the -x cu path.

nvcc 12.0.140 (nvidia-cuda-toolkit from Ubuntu apt — the exact package build-cuda installs).

Confirmation on real, unmodified code

src/lib-cuda/splinc.cpp at 85fdac7, compiled with the shipping flags and CI's -O1. DISPATCH_SPLINC computes use_32bits = FF_CANUSE32BITS(inp_out) and then switches on dtype/npoles/bound. Checking for the string literals of the throws on each side of that line:

string literalpositionshippeduse_32bits = falsetyped_prod fixed
Unsupported spline orderbefore111
Unsupported npolesafter011
only floating point ...after011

Everything after the FF_CANUSE32BITS line is gone from splinc.o, and comes back when either that one expression is neutralised or typed_prod is made host+device. The 360 ff::cuda::splinc:: template instantiations are still in the object — front-end instantiation happens regardless — so the object stays ~8 MB and the build looks entirely healthy.

Why nothing caught it

  • --no-undefined and the ldd -r step both pass: the deleted calls were intra-TU, so nothing became undefined.
  • build-cuda is compile+link only.
  • The per-module FFMEM budget is unaffected — the instantiations are still there.
  • The CPU suite cannot see it: FF_CUDEV is empty under the host compiler.

Fix

One word, verified on the reproducer and on real splinc.cpp:

 template <typename OT, typename IT, typename size_t>
-inline FF_CUDEV+inline FF_CUHOSTDEV
OT typed_prod(const IT * x, size_t size)

prod(const T *, size_t) should very likely go with it, and the rest of impl/kernels/ is worth auditing for other FF_CUHOSTFF_CUDEV call edges — that direction is the dangerous one, since nvcc does not diagnose it here.

Two things worth deciding alongside the fix, because the fix alone leaves the same trap set:

  1. A gate. The failure is invisible to every current job. The cheapest real check is a compile-time one: assert that a chosen ff::cuda:: entry point still references its dispatch (e.g. nm -u on one object, or a linker check against a stub). Anything that would have gone red here.
  2. -Werror-style handling for nvcc's host/device call diagnostics, if it can be made to emit one for this case at all.

Scope note

I have deliberately not included this in #147 — it is a correctness fix in impl/kernels/, which triggers the whole CI matrix and overlaps #145/#146, and it deserves its own review rather than riding along with a compile-time perf change. #147's measurements are unaffected either way: the slices instantiate exactly what they are meant to instantiate, and the peak-RSS and CPU figures are the same with or without the fix.

Activity

Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Metadata

Metadata

Assignees

No one assigned

    Labels

    No labels
    No labels

    Type

    No type

    Projects

    No projects

      Milestone

      No milestone

      Relationships

      None yet

      Development

      No branches or pull requests

      Issue actions