Skip to content

Commit 2f12a15

Browse files
feat(cuda): scoped device-memory arena (withCudaArena) + use-after-free detector
A long *pure* eager loop — a `foldl` of `Buffer → Buffer` ops with no IO sequencing — keeps every intermediate `Buffer` GC-reachable until the final readback, so the reference-counting finalizers that would free the device memory never run and the working set grows with the loop length. Explicit `release` cannot reach those buffers: a pure carrier has no IO point at which to call it. This adds a scoped arena that sidesteps GC reachability. `Buffer.arenaEnter` opens an allocation epoch; every device buffer allocated while it is open is registered to it. `Buffer.arenaExit keep` frees the device data of *all* of them — reachable or not — except the `keep` results, which are promoted to an enclosing arena (or left to ordinary finalization at the outermost level). `Buffer.withCudaArena keep body` is the bracketed form (arena still closed if `body` raises). This matches a training loop's natural phase boundary (one step / one fold). Implementation: - `csrc/cuda/common/torchlean_cuda_arena.h`: a mutex-guarded epoch-stack registry. Each tracked buffer points at a heap `arena_reg` (and back); a buffer freed mid-scope flips its reg's `alive` flag so the exit walk skips it (no dangling/double-free). No-arena and untracked paths take no lock. - Two `arena_reg`/`arena_freed_depth` fields on the buffer struct; register in `buffer_alloc`, unlink in `finalize`/`drop_unboxed`; `arena_enter`/`arena_exit` IO exports — mirrored in the CUDA `.cu` and the portable stub `.c`. It also adds an opt-in use-after-free detector (`TORCHLEAN_ARENA_DEBUG=1`): a reclaimed buffer records the epoch that freed it in `arena_freed_depth`, and the `require_same_size2/3` choke point (every binary/ternary op) asserts liveness before the size compare — turning a stale operand into a panic that names the freeing epoch instead of a silent launch on freed memory, and catching the both-operands-freed case the bare `0 == 0` size check misses. When the flag is off it is one predicted branch on a cached int. Tests (`NN/Tests/Runtime/Cuda/Stress.lean`, run by `nn_tests_suite`): - `runArenaStress`: k buffers held live (never released) across a scope exit are reclaimed anyway (the case `release` cannot reach), and a promoted buffer survives and stays usable. - `runArenaDetectorDeathTest`: forks the suite via `/proc/self/exe` — positive (detector on + planted UAF ⇒ aborts naming the hazard), negative (detector on + a valid promotion reused in a binary op ⇒ clean, no false positive), control (detector off ⇒ the UAF slips through).
1 parent 0c9a8b8 commit 2f12a15

7 files changed

Lines changed: 531 additions & 11 deletions

File tree

NN/Runtime/Autograd/Engine/Cuda/Buffer.lean

Lines changed: 43 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -325,6 +325,49 @@ opaque collectAllocatorRaw (force : UInt32) : UInt32
325325
def collectAllocator (force : Bool := true) : UInt32 :=
326326
collectAllocatorRaw (if force then 1 else 0)
327327

328+
/-!
329+
### Scoped device-memory arena (`withCudaArena`)
330+
331+
A long *pure* eager loop — a `foldl` of `Buffer → Buffer` ops with no IO sequencing — keeps every
332+
intermediate `Buffer` GC-*reachable* until the final readback, so the finalizers that would free the
333+
device memory never run and the working set grows with the loop length. Explicit `release` cannot
334+
reach those buffers: a pure carrier has no IO point at which to call it.
335+
336+
A scoped arena fixes this. `arenaEnter` opens an allocation epoch; every device buffer allocated while
337+
it is open is tracked. `arenaExit keep` frees the device data of *all* of them — reachable or not —
338+
except the `keep` results, which survive: promoted to an enclosing arena if there is one, or left to
339+
ordinary reference-counted finalization at the outermost level. This matches a training loop's natural
340+
phase boundary (one LM step / one fold).
341+
342+
**Contract.** Every buffer that must outlive the scope MUST appear in `keep` (or be reachable only
343+
through a kept buffer). Touching any other in-scope buffer after `arenaExit` is a use-after-free, the
344+
same hazard as reading a buffer after `release`.
345+
-/
346+
347+
@[extern "torchlean_cuda_arena_enter"]
348+
opaque arenaEnter : IO Unit
349+
350+
@[extern "torchlean_cuda_arena_exit"]
351+
opaque arenaExit (keep : @& Array Buffer) : IO Unit
352+
353+
/--
354+
Run `body` inside a fresh device-memory arena, then reclaim every buffer it allocated except those
355+
named by `keep result`.
356+
357+
`keep` extracts the buffers that must survive the scope (typically the step's result parameters). If
358+
`body` raises, the arena is still closed and every in-scope buffer is reclaimed before the exception
359+
propagates.
360+
-/
361+
def withCudaArena {α : Type} (keep : α → Array Buffer) (body : IO α) : IO α := do
362+
arenaEnter
363+
try
364+
let r ← body
365+
arenaExit (keep r)
366+
return r
367+
catch e =>
368+
arenaExit #[]
369+
throw e
370+
328371
/-- Allocate a length-`n` buffer filled with zeros. -/
329372
@[extern "torchlean_cuda_buffer_zeros"]
330373
opaque zeros (n : UInt32) : Buffer

NN/Tests/Runtime/Cuda/Stress.lean

Lines changed: 177 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -142,6 +142,91 @@ def runReleaseStress : IO Unit := do
142142
if Buffer.size b != 0 then
143143
throw <| IO.userError s!"release size reset: expected 0, got {Buffer.size b}"
144144

145+
/--
146+
Build `k` distinct length-`n` buffers and force their allocation immediately.
147+
148+
The fill value varies per `(salt, index)` for two reasons: within a call it stops the compiler from
149+
hoisting one shared `full` out of the inner loop, and across calls a distinct `salt` keeps the whole
150+
expression from being treated as loop-invariant (and hoisted out of the *caller's* loop) or CSE'd with
151+
another call site — either of which would allocate the buffers once, outside the arena under test. The
152+
returned element-count total is a forcing witness the caller checks.
153+
-/
154+
def buildArenaScratch (n : UInt32) (k : Nat) (salt : Nat) : Array Buffer × Nat :=
155+
Id.run do
156+
let mut held : Array Buffer := Array.mkEmpty k
157+
for i in [0:k] do
158+
held := held.push (Buffer.full n (1.0 + Float.ofNat (salt * k + i)))
159+
let mut touched : Nat := 0
160+
for b in held do
161+
touched := touched + (Buffer.size b).toNat
162+
return (held, touched)
163+
164+
def runArenaStress : IO Unit := do
165+
IO.println "== cuda arena scope stress =="
166+
167+
let n : UInt32 := 4096
168+
let k : Nat := 64
169+
let blocks : Nat := 4
170+
let expectTouched := k * n.toNat
171+
let base ← Buffer.allocatorStats
172+
IO.println s!" baseline: {base.format}"
173+
174+
-- Reclaim path: `k` buffers are built and held live (never released) inside an arena, kept alive
175+
-- across the scope exit, and reclaimed anyway. This is the case explicit `release` cannot reach: the
176+
-- buffers stay GC-reachable for the whole scope, so only the arena can free them.
177+
for blockIdx in [0:blocks] do
178+
let before ← Buffer.allocatorStatsWithToken (UInt32.ofNat blockIdx)
179+
Buffer.arenaEnter
180+
let (held, touched) := buildArenaScratch n k blockIdx
181+
if touched != expectTouched then
182+
throw <| IO.userError s!"arena: scratch build under-allocated ({touched} vs {expectTouched})"
183+
let inside ← Buffer.allocatorStatsWithToken (UInt32.ofNat (blockIdx + 100))
184+
if inside.allocCount != before.allocCount + UInt64.ofNat k then
185+
throw <| IO.userError
186+
s!"arena: expected {k} in-scope allocations ({before.allocCount}{inside.allocCount})"
187+
if inside.freeCount != before.freeCount then
188+
throw <| IO.userError
189+
s!"arena: buffers freed before scope exit ({before.freeCount}{inside.freeCount})"
190+
-- Keep nothing: every in-scope buffer is reclaimed even though `held` still references them all.
191+
Buffer.arenaExit #[]
192+
-- Touch `held` *after* the exit so it stays live across it: this proves the ARENA did the freeing,
193+
-- not reference-counted finalization (which cannot run while `held` is still referenced).
194+
let heldGuard := held.size
195+
let after ← Buffer.allocatorStatsWithToken (UInt32.ofNat (blockIdx + 200))
196+
if heldGuard != k then
197+
throw <| IO.userError s!"arena: held guard mismatch ({heldGuard} vs {k})"
198+
if after.freeCount != before.freeCount + UInt64.ofNat k then
199+
throw <| IO.userError
200+
s!"arena: scope exit freed {after.freeCount - before.freeCount}, expected {k} live buffers"
201+
if after.liveBytes != before.liveBytes then
202+
throw <| IO.userError
203+
s!"arena: live bytes not restored at scope exit ({before.liveBytes}{after.liveBytes})"
204+
IO.println s!" reclaimed {k} live in-scope buffers across {blocks} arenas"
205+
206+
-- Promotion path: a kept buffer survives the scope and stays usable; the rest are reclaimed.
207+
let before ← Buffer.allocatorStats
208+
Buffer.arenaEnter
209+
let keeper := Buffer.full n 2.0
210+
-- Force `keeper`'s allocation inside the scope (so it is registered and then promoted on exit).
211+
if Buffer.size keeper != n then
212+
throw <| IO.userError "arena: keep-path keeper not allocated"
213+
let (scratch, scratchTouched) := buildArenaScratch n k blocks
214+
if scratchTouched != expectTouched then
215+
throw <| IO.userError "arena: keep-path scratch build under-allocated"
216+
Buffer.arenaExit #[keeper]
217+
let scratchGuard := scratch.size
218+
let after ← Buffer.allocatorStats
219+
if scratchGuard != k then
220+
throw <| IO.userError "arena: keep-path scratch guard mismatch"
221+
if after.freeCount != before.freeCount + UInt64.ofNat k then
222+
throw <| IO.userError
223+
s!"arena keep: expected {k} frees, got {after.freeCount - before.freeCount}"
224+
-- `keeper` was promoted out of the scope, so its data is intact: reducing it still sees `2.0`.
225+
let keptSum := (Buffer.toFloatArray (Buffer.reduceSum keeper)).get! 0
226+
let expectedSum := 2.0 * Float.ofNat n.toNat
227+
Utils.assertApprox "arena kept-buffer survives scope" keptSum expectedSum (tol := 1e-1)
228+
IO.println " promoted buffer survived its arena"
229+
145230
@[noinline] def runWrapperLifetimeIteration (i : Nat) : IO Unit := do
146231
let host := FloatArray.mk #[i.toFloat, 2.0, -3.0, 4.0]
147232
let a ← Buffer.ofFloatArrayIO host
@@ -407,10 +492,102 @@ def runMatmulStress : IO Unit := do
407492
Utils.assertTensorApprox (s := sY2) "matmul stress case2 fp32" yFp322 yRef2 (tol := 7e-3)
408493
Utils.assertTensorApprox (s := sY2) "matmul stress case2 fp64" yFp642 yRef2 (tol := 1e-9)
409494

495+
/--
496+
Planted use-after-free, the subject of `runArenaDetectorDeathTest`. Allocates two buffers inside an
497+
arena, reclaims **both** at `arenaExit`, then uses them in an op — the same hazard as touching a
498+
`release`d buffer. Because reclaimed buffers have `size == 0`, the bare size check (`0 == 0`) lets this
499+
slip through to a launch on freed memory; only the detector (`TORCHLEAN_ARENA_DEBUG=1`) catches it,
500+
naming the epoch. Run only in a forked child (selected by `TORCHLEAN_ARENA_UAF_PROBE=uaf`): under the
501+
detector it `panic`s (which is why it must be forked, not asserted in-process); with the detector off it
502+
returns a silently-wrong size-0 result — exactly the silent corruption the detector closes. -/
503+
def runArenaUseAfterFreeProbe : IO Unit := do
504+
IO.println "== cuda arena use-after-free probe =="
505+
let n : UInt32 := 16
506+
Buffer.arenaEnter
507+
let a := Buffer.full n 3.0
508+
let b := Buffer.full n 5.0
509+
let forced := Buffer.size a + Buffer.size b -- force both allocations inside the epoch
510+
if forced != 2 * n then
511+
throw <| IO.userError "uaf probe: operands not allocated"
512+
Buffer.arenaExit #[] -- reclaim BOTH (keep nothing)
513+
-- `a` and `b` are now reclaimed (size 0). The detector asserts liveness before the size check and
514+
-- panics here; with the detector off, `add` slips past `0 == 0` and yields a silently-empty buffer.
515+
let bad := Buffer.add a b
516+
IO.println s!" detector OFF: use-after-free slipped through, result size = {Buffer.size bad} (expected 16)"
517+
518+
/--
519+
Valid arena promotion, the negative-case subject of `runArenaDetectorDeathTest`. Promotes one buffer
520+
past the scope and reclaims another, then uses the *promoted* buffer in a **binary** op — which flows
521+
through the same `require_same_size2` choke point the detector guards. A promoted buffer is live
522+
(`arena_freed_depth == 0`), so the detector must not fire and the result must be correct. Selected in a
523+
forked child by `TORCHLEAN_ARENA_UAF_PROBE=valid`. -/
524+
def runArenaValidPromotionProbe : IO Unit := do
525+
IO.println "== cuda arena valid-promotion probe =="
526+
let n : UInt32 := 16
527+
Buffer.arenaEnter
528+
let keep := Buffer.full n 2.0
529+
let scratch := Buffer.full n 7.0
530+
let forced := Buffer.size keep + Buffer.size scratch -- force both allocations inside the epoch
531+
if forced != 2 * n then
532+
throw <| IO.userError "valid-promotion probe: operands not allocated"
533+
Buffer.arenaExit #[keep] -- promote `keep`; reclaim `scratch`
534+
-- `keep` is promoted (live). A binary op on it exercises the detector's choke point and must pass.
535+
let ok := Buffer.add keep keep
536+
let s := (Buffer.toFloatArray (Buffer.reduceSum ok)).get! 0
537+
if s != 4.0 * Float.ofNat n.toNat then
538+
throw <| IO.userError s!"valid-promotion probe: wrong result {s} (expected {4.0 * Float.ofNat n.toNat})"
539+
IO.println s!" promoted buffer reused in a binary op, result sum = {s}"
540+
541+
/--
542+
Positive + negative regression test for the arena use-after-free detector. A detected UAF must `panic`
543+
(it cannot be caught in-process), so the suite binary is forked in each configuration via
544+
`/proc/self/exe` and its outcome inspected:
545+
546+
* **positive** — `TORCHLEAN_ARENA_DEBUG=1` + the planted UAF ⇒ the child aborts with a
547+
`use-after-arena-free` panic;
548+
* **negative (no false positive)** — detector on + a *valid* arena promotion
549+
(`runArenaValidPromotionProbe`, which reuses a promoted buffer in a binary op through the detector's
550+
own choke point) ⇒ the child exits cleanly, so the detector never fires on a kept buffer;
551+
* **control** — detector off + the planted UAF ⇒ the child exits cleanly (the UAF slips through, the
552+
silent corruption the detector closes).
553+
554+
The forked children re-enter the suite with `TORCHLEAN_ARENA_UAF_PROBE` set (see `NN.Tests.run`) and so
555+
run only the relevant fragment. Linux-only (uses `/proc/self/exe`); skipped with a note elsewhere. -/
556+
def runArenaDetectorDeathTest : IO Unit := do
557+
IO.println "== cuda arena use-after-free detector (fork death test) =="
558+
let self : System.FilePath := "/proc/self/exe"
559+
if !(← self.pathExists) then
560+
IO.println " skipped: no /proc/self/exe (fork death test is Linux-only)"
561+
return
562+
let contains (hay needle : String) : Bool := (hay.splitOn needle).length ≥ 2
563+
let fork (debug : Bool) (mode : String) : IO IO.Process.Output := do
564+
let env := #[("TORCHLEAN_ARENA_UAF_PROBE", some mode)]
565+
let env := if debug then env.push ("TORCHLEAN_ARENA_DEBUG", some "1") else env
566+
IO.Process.output { cmd := self.toString, args := #[], env := env }
567+
-- positive: the detector aborts the planted use-after-free, naming the hazard.
568+
let pos ← fork true "uaf"
569+
if pos.exitCode == 0 then
570+
throw <| IO.userError "arena UAF detector: detector ON did NOT abort the planted use-after-free"
571+
if !(contains (pos.stderr ++ pos.stdout) "use-after-arena-free") then
572+
throw <| IO.userError s!"arena UAF detector: detector ON aborted without the expected message; stderr:\n{pos.stderr}"
573+
IO.println " positive: detector ON aborts the planted use-after-free ✓"
574+
-- negative: a valid promotion under the detector is left untouched (no false positive).
575+
let neg ← fork true "valid"
576+
if neg.exitCode != 0 then
577+
throw <| IO.userError s!"arena UAF detector: false positive on a valid promotion (exit {neg.exitCode}); stderr:\n{neg.stderr}"
578+
IO.println " negative: detector ON leaves a valid arena promotion untouched ✓"
579+
-- control: with the detector off the same use-after-free slips through and the child exits cleanly.
580+
let off ← fork false "uaf"
581+
if off.exitCode != 0 then
582+
throw <| IO.userError s!"arena UAF detector: with the detector off the probe should exit cleanly (exit {off.exitCode})"
583+
IO.println " control: detector OFF leaves the use-after-free undetected, as designed ✓"
584+
410585
def run : IO Unit := do
411586
IO.println "=== CUDA runtime stress suite ==="
412587
runRngStress
413588
runReleaseStress
589+
runArenaStress
590+
runArenaDetectorDeathTest
414591
runWrapperLifetimeStress
415592
runGradientAliasingStress
416593
runMalformedBufferValidationStress

NN/Tests/Suite.lean

Lines changed: 17 additions & 9 deletions
Original file line numberDiff line numberDiff line change
@@ -46,15 +46,23 @@ def usage : String :=
4646
]
4747

4848
def run : IO Unit := do
49-
IO.println "== TorchLean: curated tests =="
50-
NN.Tests.API.SelfSupervised.BlockMask.run
51-
NN.Tests.API.GradientAccumulation.run
52-
NN.Tests.Backend.Profile.run
53-
NN.Tests.MLTheory.CROWNOperators.run
54-
Tests.Floats.run
55-
Tests.Rationals.Suite.run
56-
Tests.Cuda.run
57-
IO.println "== TorchLean: all curated tests passed =="
49+
-- Death-test child modes for the arena use-after-free detector. A forked child (see
50+
-- `Tests.Cuda.Stress.runArenaDetectorDeathTest`) re-enters here with `TORCHLEAN_ARENA_UAF_PROBE` set
51+
-- and runs only the planted UAF (`uaf` — expected to panic under `TORCHLEAN_ARENA_DEBUG=1`) or a
52+
-- valid promotion (`valid` — expected to be left alone), then exits, so the parent can inspect it.
53+
match ← IO.getEnv "TORCHLEAN_ARENA_UAF_PROBE" with
54+
| some "uaf" => Tests.Cuda.Stress.runArenaUseAfterFreeProbe
55+
| some "valid" => Tests.Cuda.Stress.runArenaValidPromotionProbe
56+
| _ =>
57+
IO.println "== TorchLean: curated tests =="
58+
NN.Tests.API.SelfSupervised.BlockMask.run
59+
NN.Tests.API.GradientAccumulation.run
60+
NN.Tests.Backend.Profile.run
61+
NN.Tests.MLTheory.CROWNOperators.run
62+
Tests.Floats.run
63+
Tests.Rationals.Suite.run
64+
Tests.Cuda.run
65+
IO.println "== TorchLean: all curated tests passed =="
5866

5967
def main (args : List String) : IO Unit := do
6068
match args with

0 commit comments

Comments
 (0)