Lab 01 — Caching GPU Allocator (Rust)
Phase: 05 — Runtime Systems | Difficulty: ⭐⭐⭐⭐☆ | Time: 5–8 hours Language: Rust (safe, stdlib only) | Hardware: none — a CPU-side model of the GPU original
Concept primer:
../WARMUP.mdCh. 2–5.
Run
cargo test # 12 correctness tests
cargo run --release --bin fragdemo # the two teaching demos
0. The mission
Model the two layers every framework stacks on the driver:
Device= the "driver": a fixed address space, first-fit allocation, holes coalesce on free, and it counts free calls (each is a device sync in real CUDA). This iscudaMalloc/cudaFree.CachingAllocator= the framework layer (the PyTorch design): size rounding into classes, binned free lists, block splitting, stream tagging, and flush-and-retry on OOM. This is what actually serves your tensors.
The tests prove the semantics; fragdemo proves why the design exists.
1. What fragdemo shows
== Demo 1: external fragmentation is a shape problem ==
interleaved (mixed) free=128 MB largest hole= 8 MB 16MB alloc: FAILS (fragmented!)
grouped (disciplined) free=128 MB largest hole=128 MB 16MB alloc: OK
== Demo 2: the caching allocator's reason to exist ==
raw driver : 5000 mallocs, 5000 frees (each free = device sync)
caching : 1 device mallocs, 4999 bin hits, 0 device frees
Demo 1 is the day-3 OOM in 30 deterministic lines: the same 128 MB is free in both rows; only the layout differs. Mixed shapes strand free memory in 8 MB holes between permanent blocks, so a 16 MB request fails at 50% utilization — and since HBM can't be compacted (live pointers), the only fix in production is a restart. Shape discipline (grouping lifetimes) keeps one big contiguous hole. This is exactly why Phase 07 pages the KV cache.
Demo 2 is why the caching layer exists at all: 5000 alloc/free cycles become 5000 synchronizing driver frees raw, versus 1 device malloc + 4999 ~100 ns bin hits cached. The driver never sees a steady-state free.
2. Reading order (src/lib.rs)
round_size+ theDevicemodel — the constraints (first-fit, coalescing, counted frees).CachingAllocator::alloc— the four-step path: bin hit → split larger → driver malloc → flush-and-retry. Each step maps to a WARMUP Ch. 4 bullet.free(goes to a bin, never the driver) andflush_cache(theempty_cache()semantics, including why a split block isn't returnable).- The stream-tag logic in
alloc— same-stream reuse is free; cross-stream reuse counts an event edge (cross_stream_syncs), the Ch. 7 safety rule.
Then trace a 1.2 MB allocation by hand and check yourself against
test_split_accounting.
3. The tests, and what each proves
| Test | The lesson it locks in |
|---|---|
test_rounding | size classes (Ch. 4) |
test_device_first_fit_and_coalesce | the driver model's behavior |
test_bin_reuse_fast_path | reuse never calls the driver (free_calls == 0) |
test_split_accounting | split math: 4MB → 1MB out + 3MB binned |
test_cross_stream_sync_counted | cross-stream reuse needs an event edge (Ch. 7) |
test_same_stream_preferred | same-stream blocks chosen first (no edge) |
test_reserved_vs_allocated | the nvidia-smi-vs-framework gap (Ch. 4, Q2) |
test_flush_and_retry_on_oom | the pre-OOM stall (Ch. 4) |
test_split_block_not_returnable | can't return half a device segment |
4. Extensions
- Coalescing (hard): when two adjacent free blocks exist, merge them back into one larger class — true defragmentation within the cache. Add a test that an alloc/free/alloc pattern which currently fails now succeeds.
empty_cachepolicy: return only segments idle for N cycles; measure the reserved-memory sawtooth.- Trace replayer: parse a CSV of
(op, size, stream)lines and replay it, emitting reserved/allocated/largest-block per step — turn the allocator into a measurable system you can feed real PyTorch traces. - Best-fit vs first-fit in the bins: measure fragmentation on the demo-1 workload; quantify the classic tradeoff.
- Prometheus metrics: export reserved/allocated/cached/largest-block — the Phase 10 fragmentation dashboard, fed from here.
5. Common pitfalls
- Returning a split remainder to the driver — you can't free half a segment
(
test_split_block_not_returnableenforces it; PyTorch has the same limit). - Forgetting the stream tag on reuse — silent cross-stream UAF in the real thing; here it's a counted edge so you can see it.
- Treating reserved as a leak — it's cache; the leak signal is allocated trending up, not reserved.
6. What this lab proves about you
You can implement, test, and reason about the allocator at the heart of every
ML framework — and translate its behavior into operational language (reserved
vs allocated, fragmentation early-warning, the restart confession). Reading
PyTorch's real CUDACachingAllocator.cpp is now a code-review, not an
expedition.