feat(cuda): bound the device block reuse cache with an env byte cap - #10
Conversation
62fe748 to
50e2001
Compare
|
Hey Nicolas, thanks! This one still addresses a real gap: low-level or custom CUDA paths can retain an unbounded amount of released memory in the reuse cache. We just merged a major Lean 4.32 refactor, so could you first rebase this onto the latest
Once that is updated, we'll run the full CUDA stress suite and review it for merge. Thanks! |
50e2001 to
3ea69d2
Compare
|
Thanks! Rebased onto the latest
CPU-stub |
3ea69d2 to
81782ae
Compare
|
Heads-up on ordering: on glibc ≥ 2.38 this PR does not link on its own — the new |
81782ae to
f1adc61
Compare
|
Thanks, Nicolas — the cache bound, overflow-safe accounting, and subprocess test all look good. We’d like to merge this. Since #18 is needed for the |
…dojo#10) # Conflicts: # NN/Tests/Runtime/Cuda/Stress.lean
f1adc61 to
8243c95
Compare
8243c95 to
8524883
Compare
|
Thanks, Nicolas. The cache accounting and cap logic look good. I think we should avoid making this depend on the global __isoc23_strto* shim in #18, though. Could you replace the strtoull call with a small strict decimal parser here, rejecting malformed and overflowing values? That keeps this fix self-contained and avoids adding a broad glibc compatibility layer. With that change and the CUDA stress test green, I’d be happy to merge this. |
The reuse cache (return/take of exact-size device blocks) grew without bound: a dropped buffer is returned to the cache, and the cache is only emptied on a cudaMalloc-failure flush or at process exit. A loop over many distinct sizes therefore accretes device memory that `liveBytes` does not see -- a returned block is accounted as freed before it is cached. Add an opt-in byte cap, `TORCHLEAN_CUDA_CACHE_CAP_BYTES` (0 = unbounded, the prior behaviour exactly): a returned block that would grow the cache past the cap is freed immediately -- after waiting on its completion event, exactly as the flush path does, so an in-flight kernel never reads freed memory -- instead of being cached. A running `cache_bytes` total under the cache mutex backs the decision and is surfaced as a new `AllocatorStats.cacheBytes` telemetry field (0 in the CPU stub, which keeps no cache and so has nothing to cap). Regression test `runCacheCapTest` (nn_tests_suite, runs on both the CUDA build and the CPU stub): the cap is read once natively, so it forks the suite binary per configuration. With a 1 MiB cap an 8 MiB return workload is bounded to a 1 MiB cache; with no cap (control) the same workload caches the full 8 MiB. On CUDA the test also checks the cap is the binding constraint (the cache fills to within one block of it). On the stub cacheBytes stays 0, so the cap bound holds trivially. The test's scratch helper (`buildCacheScratch`) is self-contained, so this change stands alone on the updated main rather than stacking on the arena PR. CPU-stub `lake build && lake exe nn_tests_suite`: green.
Replace the strtoull call with a hand-rolled strict parser: only a non-empty digit string that fits in size_t is accepted; malformed or overflowing values are rejected with a warning and leave the cache unbounded instead of being silently misread. This also removes the only strto* reference in the CUDA objects, so the branch links against the Lean toolchain's glibc without any isoc23 compatibility layer. The fork test gains malformed and overflow children pinning the rejection semantics: both behave exactly like the uncapped control, whereas a prefix-parsing reader would misread '1MiB' as a one-byte cap.
8524883 to
e02fcf8
Compare
|
Thanks, Nicolas! This is merged now. The bounded CUDA cache and telemetry fit the direction of the runtime well, and I’m doing a final local cleanup pass so the behavior and configuration are documented clearly. |
Summary
Rebased onto the latest
main(the Lean 4.32 refactor), as a single commit. Adds an opt-in byte capon the CUDA buffer reuse cache (the exact-size device-block free list behind
take_cached_block/return_cached_block).The cache grows without bound: a dropped buffer is returned to it, and it is only emptied on a
cudaMalloc-failure flush or at process exit. A loop over many distinct buffer sizes thereforeaccretes device memory that
liveBytesdoes not even see — a returned block is accounted as freedbefore it is cached.
TORCHLEAN_CUDA_CACHE_CAP_BYTES(0 = unbounded, the prior behaviour exactly) bounds it: a returnedblock that would grow the cache past the cap is freed immediately — after waiting on its completion
event, exactly as the flush path does, so an in-flight kernel never reads freed memory — instead of
being cached.
What's in it
cache_bytestotal under the cache mutex backs the cap decision.AllocatorStats.cacheBytes(externtorchlean_cuda_allocator_cache_bytes; theCPU stub returns
0);AllocatorStats.formatgainscache=<MiB>.Review changes addressed
strtoull) — the cap is parsed by a smallhand-rolled loop that accepts exactly a non-empty digit string fitting in
size_t; malformed oroverflowing values are rejected with a warning and leave the cache unbounded, instead of being
silently misread (
strtoullwould take1MiBas a 1-byte cap and saturate overflow toULLONG_MAX). This also removes the onlystrto*reference in the CUDA objects, so the branchlinks against the Lean toolchain's glibc with no compatibility layer — no dependency on feat(cuda): bundle + auto-link the glibc≥2.38 isoc23 link shim #18.
pthread_once(
torchlean_cuda_cache_byte_cap_init), so concurrent first callers cannot race on the parse and thevalue is published before any caller observes it. (The prior function-local
staticassigned afterits declaration was a data race and depended on
-fthreadsafe-statics.)cache_bytes + incoming > capis reformulated asincoming > cap || cache_bytes > cap - incoming, so the sum is never formed and cannot wrapsize_t(andcap - incomingis only evaluated whenincoming <= cap).0— the fork helper always setsTORCHLEAN_CUDA_CACHE_CAP_BYTESin the child's environment (capped =
1048576, control =0), so neither child inherits a straycap from the parent and the control run genuinely exercises unbounded growth.
Test
runCacheCapTest(innn_tests_suite, runs on both the CUDA build and the CPU stub). The cap is readonce natively, so the test forks the suite binary per configuration:
0the same workload caches the full 8 MiB;1MiBis rejected and the cache stays unbounded (a prefix-parsing reader wouldcache nothing under a 1-byte cap);
On CUDA it also checks the cap is the binding constraint (the cache fills to within one block of it).
On the stub
cacheBytesstays 0, so the cap bound holds trivially.Verification
CPU-stub
lake build && lake exe nn_tests_suite: green, all four fork cases pass. Full CUDA build +suite re-run on an RTX A4500 (CUDA 13.0), built deliberately without any glibc compatibility
object: links clean and all curated tests pass, with all four fork cases confirming the cap and the
rejection semantics on the real allocator.