Uh oh!
There was an error while loading. Please reload this page.
Scope the narrowed index arrays, and stop leaking them on the throwing path - #159
Conversation
`copy_if_needed` / `free_if_needed` is a manual acquire/release pair, and every dispatch wrapper in the tree puts code that can throw between the two: const offset_t * _size = copy_if_needed<offset_t*>(size, n); // allocates ... as_weights(), new reduce_t[], the impl call ... // can throw free_if_needed<int64_t*>(_size); // skipped On the CUDA side the middle section throws by design -- every FF_CUDA_LAUNCH does, and so does every copyToDevice. Reproduced with ASan/LSan against this header on the reg_field wrapper shape: 32 bytes in 2 allocations escape per throwing call on the narrow (int32_t) arm. It is a per-call leak on a path user code is expected to hit -- an invalid argument -- not a once-per-process one. IndexArray<offset_t> is the RAII form of that pair, for the one job it is actually used for: handing a `const int64_t*` shape or stride array to a kernel templated on offset_t. * The throwing path is covered by construction. * The elements live inside the object for the sizes that occur. These arrays are `nbatch + ndim + 1` long -- single digits in every caller -- so the narrow arm no longer allocates at all. Only a genuinely large rank falls back to hostNew. That matters most under FF_AUTOCAST_PINNED_HOST, where the fallback is cudaMallocHost: a page-locking syscall on the per-call path, three to five times per launch. * The wide arm (offset_t == int64_t) is specialised to borrow the caller's array verbatim, exactly as _copy_if_needed<T*, const T*> already does. So under FF_INDEX32=0, where both arms name int64_t, the whole class is a pointer copy. * A null source yields a null array and copies nothing, which is what the four `wgt ? copy_if_needed(...) : nullptr` sites in posdef needed. Converts the eight non-regulariser surfaces on both backends -- distance, posdef, pushpull, pushpull_backward, resize, restrict, splinc, solve_field -- 228 call sites across 15 translation units. Two sites in distance.cpp call `.get()` explicitly because distance_e::dt / distance_l1::dt *deduce* offset_t from the argument, and a deduced parameter cannot fire a user-defined conversion. The regulariser surface is deliberately left for a follow-up: its six TUs are being edited by the dispatch-header change and by the per-(family, ndim) TU split, and converting them here would collide with both. copy_if_needed and free_if_needed are untouched and still used there. Also fixes a latent bug in the same header, found by compiling it standalone under nvcc: hostNew/hostDelete throw std::runtime_error on the pinned-host path, and the header never included <stdexcept>. It compiles today only because every translation unit that reaches it happens to include <stdexcept> first; a .cu that includes just this header does not. C++11. Clean under clang++ and g++ with -fsanitize=address,undefined, and under nvcc -std=c++14 in the pinned-host configuration.
balbasty
commented
Aug 20, 2026
Correcting myself on one sentence in the description, because I checked how I wrote that it is "a path user code is expected to hit — an invalid argument". voidfield_matvec(...)
{
ContiguousStrides _out(out_), _inp(inp_);
FF_CHECK_NO_LANES (out) // <-- throws here …FF_CHECK_SAME_DTYPE(out, inp)
...
FF_DISPATCH_REG(_field_matvec, ...) // <-- … before anything is allocated here
}So a bad argument does not leak. Nothing is allocated yet. The accurate
That also answers the obvious reviewer question, "if this is real, why is CI
So this sits precisely in the blind spot between the two, which is why it The Generated by Claude Code |
balbasty
commented
Aug 20, 2026
Local gate, plus one thing this change does cost that I should put on the Gate13 suites, 59,886 checks, 0 failures, plus the 2 hub suites (14 checks), The cost: object text grows, and that growth is the fixInstantiation counts do not move — but the emitted code does.
That is not accidental overhead, and it is worth being clear about what it is: Does that reach ptxas?I do not think so, and it is checkable rather than a matter of opinion. The The Those seven are the modules this PR touches. If any of them has moved Generated by Claude Code |
Deleting a `free_if_needed` block left the blank line that used to separate it from the impl call sitting immediately before the closing brace: posdef::sym_matvec<...>(...); <-- this } 51 of them across the 15 converted translation units. Whitespace only, and scoped to the files this branch already converts -- the regulariser TUs are left alone, since this branch does not touch them.
balbasty
commented
Aug 20, 2026
Both CUDA legs are green on Those legs are the real check on this change — they compile all seven converted For reference, that timing is normal, not something this PR caused — One follow-up commit pushed
posdef::sym_matvec<...>(...);
// <-- this
}51 of them across the 15 converted TUs. It is provably content-neutral, which matters because the CUDA legs above ran Whitespace-normalised, the files are byte-identical. I held that commit until On the remaining |
balbasty
commented
Aug 20, 2026
CI has finished on the tip ( 16 checks, 0 pending, and the only red is the non-blocking So both CUDA legs are now green on the branch tip, not just on its parent — Nothing further from me on this one. Ready for review; not merging. Generated by Claude Code |
Uh oh!
There was an error while loading. Please reload this page.
Answers point 2 of the review on #94 — "should the autocast changes be their own
PR?" — with yes. This is the autocast half of that PR, on its own, and it is not
a refactor: it fixes a leak.
What this is worth on its own
A per-call memory leak on the throwing path, reproduced under LSan. That is
true regardless of what happens to the dispatch question, and this PR does not
depend on it.
copy_if_needed/free_if_neededis a manual acquire/release pair, and everydispatch wrapper in the tree puts code that can throw between the two:
On the CUDA side the middle section throws by design — every
FF_CUDA_LAUNCHdoes since #154, and so does everycopyToDevice. Measuredwith ASan/LSan against the real
core/autocast.h, on thereg_fieldwrappershape:
per throwing call on the narrow (
int32_t) arm. This is a path user code isexpected to hit — an invalid argument — so it is per call, not once per process.
What
IndexArrayisThe RAII form of that pair, for the one job it is actually used for: handing a
const int64_t *shape or stride array to a kernel templated onoffset_t.nbatch + ndim + 1long — single digits in every caller — so the elements live inside the object
(
FF_INDEX_ARRAY_INLINE, 8) and only a genuinely large rank falls back tohostNew. That matters most underFF_AUTOCAST_PINNED_HOST, where thefallback is
cudaMallocHost: a page-locking syscall on the per-call path,three to five times per launch.
IndexArray<int64_t>is specialised to hold thecaller's pointer verbatim — no copy, no storage — exactly as
_copy_if_needed<T*, const T*>already does for that case. So underFF_INDEX32=0, where both arms nameint64_t, the whole class is a pointercopy.
four
wgt ? copy_if_needed(…) : nullptrsites inposdefneeded.layer writes through a shape or stride array, and the wide arm aliases the
caller's memory.
Also: a latent bug in the same header
hostNew/hostDeletethrowstd::runtime_erroron the pinned-host path, andcore/autocast.hnever included<stdexcept>. It compiles today onlybecause every translation unit that reaches it happens to include
<stdexcept>first; a
.cuthat includes just that header does not:One line. Folded in here because it is the same file and this work is what
found it — I had nvcc available locally this time, which #94's run did not.
Scope
Converts the eight non-regulariser surfaces on both backends —
distance,posdef,pushpull,pushpull_backward,resize,restrict,splinc,solve_field— 228 call sites across 15 translation units. Every one ofthose TUs is fully converted; there are no half-converted files.
Two sites in
distance.cppcall.get()explicitly rather than relying on theimplicit conversion, because
distance_e::dt/distance_l1::dtdeduceoffset_tfrom the argument, and a deduced parameter cannot fire a user-definedconversion. Those are the only two of the 228 that are not a pure declaration
swap.
The regulariser surface is deliberately left out. Its six TUs are being
edited by #158 and by #147's per-(family, ndim) split, and converting them here
would collide with both.
copy_if_neededandfree_if_neededare untouched andstill used there, so nothing is stranded — the reg conversion is a mechanical
follow-up once those land.
Verification
clang++andg++,-std=c++11,-fsanitize=address,undefined— valuescorrect on the inline, heap and borrow paths; no leak on the throwing
path, both compilers.
nvcc -std=c++14, in the pinned-host configuration the CUDA library buildswith — compiles.
normalise-header-guards.py,normalise-include-delimiters.py,rename-macros.py,check-cuda-launches.py,codespell— all--checkclean.
tools/test-baseline.sh --legs default,lib --checkrun is still inprogress on a heavily contended machine here; CI's
test-cpuis the samesuite on a dedicated runner and is the check to believe. I will post the
local rows as a comment when it finishes.
What is not verified: anything about runtime behaviour on a GPU. There is no
GPU here, so whether taking
cudaMallocHostoff the per-call path changesupload throughput is unmeasured, and I am not claiming it does. The argument
for that part is that a page-locking syscall per call is worth avoiding on
principle, not a benchmark.
This touches
core/, so it correctly triggers the full CI set including bothbuild-cudalegs. Do not merge until those are green.Generated by Claude Code