feat(cuda): scope eager device-memory reclamation to the tape (withStep/withForward)#9
Conversation
|
Thanks, Nicholas. The memory-pressure problem here is real, but I don't think we can merge the arena API in its current form. It can free storage while the corresponding Lean With the new runtime refactor, I think this should instead be implemented inside the eager session/tape ownership model. Temporary buffers should belong to a step or tape node, workspace should be released through If you're interested in revising it in that direction, we can keep discussing the design here. |
…es the arena) Long eager loops accrete device memory because each op wraps its result in a GC-managed external object whose finalizer frees the device data only when the value becomes unreachable -- which, in a loop that keeps intermediates reachable until a final readback, can lag arbitrarily. The training path already bounds this by releasing the tape at each optimizer step; this generalizes that into a reusable, exception-safe scope and drops the previously proposed device arena. `EagerSession.withStep` brackets one training step: `resetTape` opens it, the body runs forward/backward/optimizer, and on exit every tape-owned temporary is released (`releaseCudaTapeAfterOptimizerStep`) and the snapshot retired, so the working set stays flat across steps. On the error path -- before the optimizer has re-mirrored the parameters -- it falls back to the conservative release that preserves every parameter value, so it never frees a live mirror. `EagerSession.withForward` is the inference/eval analog: it transfers the body's result off the tape (e.g. `getValue`, which downloads to host) before releasing the forward temporaries, so the caller leaves the scope holding host tensors, not device buffers. Unlike the rejected `withCudaArena`, reclamation is keyed on tape membership, not allocation epoch: the only buffers freed are tape node values and workspace (`cleanup`), which are runtime-private -- callers hold `TensorRef`/`Param`, never a raw `Buffer` -- so a scope can never invalidate a buffer the caller still holds. This removes the use-after-free hazard that blocked the public arena API. The three open-coded CUDA optimizer-step reclamation sites in `Trainer` now use `withStep`. Tests (NN/Tests/Runtime/Cuda/Stress.lean, nn_tests_suite): - runLeakBoundStress asserts a flat device working set across step count under the explicit release discipline (Buffer-level; runs on the CPU stub); - runEagerScopeStress checks withForward end-to-end on a CPU session (every build) and, on a real CUDA session, that the device working set is flat across forward scopes and returns to baseline (skipped on the CPU stub, which rejects .cuda sessions). CPU-stub `lake build && lake exe nn_tests_suite`: green.
eed66d6 to
4c6b389
Compare
Summary
Reworked in response to the review here: instead of a public device arena that frees buffers by
allocation epoch — which can free a device buffer while its Lean
Bufferis still reachable(use-after-free if a value or alias is missing from
keep) — scoped device-memory reclamation nowlives inside the eager session/tape ownership model, keyed on tape membership.
Two exception-safe combinators on
EagerSessionbracket an eager phase and reclaim the tape at itsboundary:
withStep— one training step.resetTapeopens it; the body runs forward/backward/optimizer;on exit every tape-owned temporary is released and the tape snapshot retired, so the device working
set stays flat across steps.
withForward— one inference/eval forward pass. It transfers the body's result off the tape(e.g.
getValue, which downloads to host) before releasing the forward temporaries.The three open-coded CUDA optimizer-step reclamation sites in
Trainernow usewithStep.Why this is safe (addresses the arena's use-after-free)
Reclamation is keyed on tape membership, not allocation epoch. The only buffers a scope frees are
tape node
values and workspace (cleanup) — which are runtime-private: callers holdTensorRef/Paramhandles, never a rawBuffer. So a scope can never invalidate a buffer the callerstill holds. Persistent parameter mirrors are preserved by the
paramsByLeafrole check.withStepis also correct on the error path: if the body raises before the optimizer re-mirrorsthe parameters, the trainable leaf buffers may still be the live mirrors, so it falls back to the
conservative release (
releaseCudaTapeNonParamValues, which preserves every parameter value) beforere-raising — it never frees a live mirror.
This removes the class of hazard that blocked the public
withCudaArenaAPI; the arena implementation(
torchlean_cuda_arena.h, the epoch registry, the bufferarena_regfield, the UAF detector) isdropped.
Scope vs. the original problem
The refactored
mainalready bounds the training path (each optimizer step releases the tape);this generalizes that into a reusable, exception-safe scope and gives the inference/eval path the
same guarantee. The one case the arena uniquely served — a raw
foldlofBuffer → Bufferwith notape and no IO — is intentionally out of scope: real models go through the tape, and for a genuine
in-op reduction the existing
releaseThen/releaseManyThen(threaded through the carried result) isthe safe tool.
Tests
NN/Tests/Runtime/Cuda/Stress.lean(innn_tests_suite, wired into the stress suite):runLeakBoundStress(folded from the closed leak-bound regression): drives a small eager Bufferworkload through the explicit release discipline for two equal blocks of steps and asserts, via
Buffer.allocatorStats, that live allocations and live bytes are flat across step count and returnto baseline. Runs on the CPU stub.
runEagerScopeStress: checkswithForwardend-to-end on a CPU session (every build), and — on areal CUDA session — that the device working set is flat across forward scopes and returns to
baseline. The CUDA loop is skipped on the CPU-stub build, which rejects
.cudasessions.Verification
CPU-stub
lake build && lake exe nn_tests_suite && lake lint: green. The CUDA-specific assertions(
runEagerScopeStressPart 2) require a GPU — runscripts/checks/check.sh --cuda.