-
Notifications
You must be signed in to change notification settings - Fork 0
Memory Management
Memory management decides where every tensor lives and for how long. In LLM inference this is not bookkeeping — it is the primary determinant of how many requests can be served concurrently and how long a context can grow before the system fails.
Decode is memory bound, so memory management is throughput management.
- Fit — the model, activations, and cache must fit in available memory
- Maximize concurrency — memory freed is a request served
- Avoid fragmentation — unusable free space is wasted capacity
- Keep allocation cheap — allocation on the critical path costs latency
- Degrade gracefully — running out of memory should throttle, not crash
flowchart LR
A[Graph Compiler] --> B[Memory Management]
B --> C[Runtime]
D[Batching and Scheduling] <--> B
style B fill:#2d6a9f,color:#fff
The two-way arrow matters: the scheduler can only admit a request if memory exists for it, and memory availability changes as requests finish. These two subsystems are tightly coupled.
| Consumer | Size | Lifetime |
|---|---|---|
| Model weights | Fixed, large | Entire process |
| KV cache | Grows per token, per request | Duration of a request |
| Activations | Transient, per step | Within one forward pass |
| Workspace | Kernel scratch space | Within one kernel |
Weights are a one-time cost. Activations are small in decode, since only one token is processed at a time. The KV cache is the variable that determines system capacity, and nearly all interesting memory management is about it.
For each token processed, attention produces a key and a value vector per layer, which must be retained so future tokens can attend to them. Cache size scales as:
cache bytes ≈ 2 × layers × kv_heads × head_dim × seq_len × batch × bytes_per_element
Two properties make this hard:
- It grows unboundedly with generated length, and the final length is unknown when the request starts.
- It is read in full every decode step. Cache size doesn't just consume capacity — it directly sets decode speed.
The naive approach reserves a contiguous block per request sized to the maximum possible length. This wastes catastrophically: a request that generates 100 tokens holds an allocation sized for thousands.
The standard solution is paged allocation — divide cache memory into fixed-size blocks, and give each request a block table mapping logical positions to physical blocks. Requests grow by acquiring blocks on demand.
| Property | Contiguous | Paged |
|---|---|---|
| Internal waste | Very high | One partial block per request |
| External fragmentation | Severe | None — blocks are interchangeable |
| Sharing between requests | Impossible | Natural, via shared block references |
| Kernel complexity | Simple addressing | Requires indirection through a block table |
The cost is real: kernels must gather through a block table rather than reading a contiguous span. The benefit — often several times more concurrent requests — is almost always worth it.
Because paged blocks are referenced rather than owned, identical prefixes can share physical storage. When many requests share a common system prompt, this eliminates redundant copies entirely. Copy-on-write handles divergence: shared blocks stay shared until a request writes, at which point it gets its own copy.
| Technique | Mechanism | Cost |
|---|---|---|
| Grouped-query attention | Several query heads share one KV head | Architectural; must be trained in |
| Cache quantization | Store keys and values in fewer bits | Small accuracy cost — see Quantization |
| Eviction | Drop tokens judged unimportant | Accuracy risk if the wrong tokens go |
| Windowing | Retain only a recent window | Loses long-range attention |
| Offloading | Move cold cache to slower memory | Bandwidth cost on recall |
Eviction deserves care. The observation that attention concentrates on a small subset of tokens — a few early tokens plus recent ones — motivates dropping the rest. It works well on many workloads and fails badly on tasks needing specific mid-context recall. Treat any eviction policy as an accuracy-affecting change requiring measurement, not a free win.
Within a forward pass, intermediate tensors are allocated and freed constantly. Doing this through a general allocator on the critical path is too slow.
Standard approach: the Graph Compiler computes a static allocation plan offline. Knowing every tensor's lifetime, it assigns offsets in a pre-allocated arena so that non-overlapping lifetimes reuse the same space. Runtime allocation becomes a pointer offset — free.
This is a classic interval-packing problem: minimize peak memory subject to lifetime constraints. Fusion helps here too, since fused intermediates never need an allocation at all.
| Metric | Why it matters |
|---|---|
| Memory utilization | Fraction actually holding useful data |
| Fragmentation | Free but unusable space |
| Max concurrent requests | The capacity number that matters commercially |
| Cache hit rate on shared prefixes | How much sharing is being exploited |
| Peak activation memory | Determines headroom for cache |
| Allocation latency | Should be negligible on the critical path |
| Preemption rate | How often requests are evicted under pressure |
| Problem | Root cause | Fix |
|---|---|---|
| Low concurrency despite free memory | Fragmentation from contiguous allocation | Paged allocation |
| Out of memory mid-generation | Cache grew past the reservation | Admission control; preemption policy |
| Throughput collapses under load | Thrashing between admission and eviction | Hysteresis; reserve headroom |
| Shared prefixes duplicated | No sharing mechanism | Reference-counted blocks with copy-on-write |
| Allocation shows in the profile | Dynamic allocation on the hot path | Static plan; pre-allocated arena |
| Accuracy drops on long context | Eviction discarding needed tokens | Re-examine the policy; measure on recall tasks |
| Wasted space per request | Block size too large | Tune block size against typical lengths |
- Page the KV cache. The complexity cost is real and worth paying.
- Pre-allocate everything at startup. Steady-state allocation should be near zero.
- Plan activations statically. The compiler knows all lifetimes; use that.
- Reserve headroom. Running at 100% capacity means the next request fails.
- Make admission memory-aware. Accepting a request you can't finish wastes all the work done on it.
- Share aggressively, copy lazily. Common prefixes are extremely common in practice.
- Instrument capacity. Utilization, fragmentation, and preemption rate should be visible in production, not discovered during an incident.
- Treat eviction as an accuracy change. Measure it like one.
See also: Batching and Scheduling · Quantization · Graph Compiler · Runtime · Inference Software