ML Interview Notes
39 min read15 sections
Part 2 · Memory and the KV cache · 02-06

Offload, hierarchical cache, and budgeting VRAM

Status
SOURCE PINNED
Primary sources
  • vllm/v1/kv_offload/
  • python/sglang/srt/mem_cache/hiradix_cache.py
  • python/sglang/srt/mem_cache/hicache_storage.py
Edition pins
vllm a556f3f · sglang 7d89325

Two questions close out Part 2. First: of the 80 GB on the card, how many are actually yours for KV, and who measured each of the other terms? Second: when the GPU pool is full, is it ever cheaper to fetch a prefix back over PCIe than to recompute it? Both have arithmetic answers, and both engines have code that gets them wrong in instructive ways.

§1

The problem

A Llama-3-8B server has been up for eleven minutes on a single H100. It started fine — the log said Available KV cache memory: 54.4 GiB, the health check passed, traffic ramped. Then:

representative CUDA OOM during steady-state serving shell
torch.OutOfMemoryError: CUDA out of memory. Tried to allocate 1.16 GiB.
GPU 0 has a total capacity of 79.65 GiB of which 812.00 MiB is free.
Of the allocated memory 76.12 GiB is allocated by PyTorch, and 1.94 GiB
is reserved by PyTorch but unallocated.

Nothing in the workload changed, no request was larger than the ones flowing for ten minutes, and the engine had explicitly profiled its own peak at startup. Yet it died — not at startup, where a wrong budget is caught, but deep into a run, where it costs you a page.

Whoever set this up had raised gpu_memory_utilization to 0.98 because the startup log showed 6 GiB apparently idle. That 6 GiB was the margin between what the profiler could measure and what the process would eventually touch. This chapter is about what lives in that margin, how to read it out of the engine's own logs instead of guessing, and what to do with a tier of memory 50× slower than HBM and 20× larger.

§2

Mental model

The KV pool is a residual, not an allocation. You do not ask for it; you get whatever survives a subtraction. Every engine performs the same subtraction, and the only real difference between vLLM and SGLang is which terms they measure and which they estimate. Terms that are measured shrink to fit reality. Terms that are estimated must be over-estimated, or the process dies later.

Figure 1 — the 80 GB H100 budget for Llama-3-8B in bf16, drawn to scale. Bar widths are proportional. Weights and the utilisation cap are derived exactly; the activation, non-torch and graph-pool terms are illustrative magnitudes — the engine measures yours at startup and prints them. Read your own log, not this figure.

Stacked VRAM budget for an 80 GB H100 A horizontal bar of 79.6 gibibytes divided into: model weights 15.0, non-torch CUDA context and NCCL 0.8, peak transient activation 1.5, CUDA graph pool 1.5, KV cache pool 54.4, and 6.4 gibibytes left unrequested above the gpu_memory_utilization cap of 0.92. total_memory = 79.6 GiB reported by get_memory_info() weights 15.0 KV cache pool — 54.4 GiB — 27,852 blocks of 16 — 445,632 tokens unrequested non-torch 0.8 GiB — CUDA context plus NCCL buffers, snapshotted after init_worker_distributed_environment transient peak headroom 1.5 GiB — torch_peak minus torch_allocated during the dummy forward CUDA graph pool 1.5 GiB — profile_cudagraph_memory, captured into a throwaway pool 6.4 GiB above the 0.92 cap requested_memory = total_memory x gpu_memory_utilization = 73.2 GiB

Four of those five non-KV terms are measured by a real forward pass. The fifth — the slack above the cap — is the only thing standing between you and the OOM in §1, and it is the one people delete.

§3

First principles: the subtraction

Start from FORMULAS:

$$\text{KV}_{\text{pool}} = C_{\text{total}} \cdot u \;-\; W \;-\; A_{\text{peak}} \;-\; O_{\text{non-torch}} \;-\; G_{\text{graphs}}$$

with $C_{\text{total}}$ the device capacity, $u$ the utilisation fraction, $W$ the weight bytes, $A_{\text{peak}}$ the transient activation peak, $O$ the non-torch allocations (CUDA context, NCCL communication buffers, attention-backend workspaces allocated outside the caching allocator), and $G$ the CUDA-graph memory pool. Work it for Llama-3-8B in bf16 on one H100 SXM 80 GB.

$C_{\text{total}}$. The device reports 79.6 GiB, not 80 — get_memory_info returns what the driver will hand out, which excludes ECC and driver reservations. Use the reported number.

$u$. As of vLLM a556f3f the default is 0.92, not the 0.90 that most blog posts quote:

vllm/config/cache.py:L80-L87 vLLM
    gpu_memory_utilization: float = Field(default=0.92, gt=0, le=1)
    """The fraction of GPU memory to be used for the model executor, which can
    range from 0 to 1. For example, a value of 0.5 would imply 50% GPU memory
    utilization. If unspecified, will use the default value of 0.92. This is a
    per-instance limit, and only applies to the current vLLM instance. It does
    not matter if you have another vLLM instance running on the same GPU. For
    example, if you have two vLLM instances running on the same GPU, you can
    set the GPU memory utilization to 0.5 for each instance."""

Note the semantics carefully: $u$ multiplies total_memory, not free memory. It is a planning target, not an enforced allocator ceiling or a guarantee that two instances at 0.5 coexist safely. So $73.2 = 79.6 \times 0.92$ GiB is the whole budget for weights, activations, overhead and KV together.

vllm/v1/worker/utils.py:L444-L464 vLLM
def request_memory(init_snapshot: MemorySnapshot, cache_config: CacheConfig) -> int:
    """
    Calculate the amount of memory required by vLLM, then validate
    that the current amount of free memory is sufficient for that.
    """
    requested_memory = math.ceil(
        init_snapshot.total_memory * cache_config.gpu_memory_utilization
    )

    if init_snapshot.free_memory < requested_memory:
        raise ValueError(
            f"Free memory on device {init_snapshot.device_} "
            f"({format_gib(init_snapshot.free_memory)}/"
            f"{format_gib(init_snapshot.total_memory)} GiB) on startup "
            f"is less than desired GPU memory utilization "
            f"({cache_config.gpu_memory_utilization}, "
            f"{format_gib(requested_memory)} GiB). Decrease GPU memory "
            f"utilization or reduce GPU memory used by other processes."

$W$. 8.03 × 10⁹ parameters at 2 bytes = 16.06 × 10⁹ B = 14.96 GiB. This term is not estimated either — it is handed to the profiler as model_memory_usage, recorded by the loader.

$A_{\text{peak}}$. Derivable only to an order of magnitude. At max_num_batched_tokens = 8192 the resident layer working set is hidden states $8192 \times 4096 \times 2 = 67$ MB, MLP intermediate $8192 \times 14336 \times 2 = 235$ MB, and a handful of those alive concurrently — call it 1 GiB. But the true peak also includes the attention backend's scratch workspace, the sampler's logits buffer, and any all-gather staging, none of which follows from model shape. This is exactly why the engines profile instead of computing.

§4

How production systems do it: profile versus estimate

vLLM measures

Worker.determine_available_memory runs a synthetic forward pass at the largest shape the scheduler can ever produce, inside a context manager that brackets it with two memory snapshots:

vllm/v1/worker/gpu_worker.py:L513-L519 vLLM
        # Execute a forward pass with dummy inputs to profile the memory usage
        # of the model.
        with memory_profiling(
            self.init_snapshot,
            weights_memory=int(self.model_runner.model_memory_usage),
        ) as profile_result:
            self.model_runner.profile_run()

The dummy run is self._dummy_run(self.max_num_tokens, is_profile=True) at vllm/v1/worker/gpu_model_runner.py:L6612-L6615 plus a dummy sampler or pooler run — the is_profile flag exists specifically "to pre-allocate communication buffers" — and, for multimodal models, an encoder pass on maximum-feature-size items. The accounting is the interesting part: total_consumed comes from mem_get_info, not PyTorch's reserved counter.

vllm/utils/mem_utils.py:L314-L326 vLLM
    # Measure total consumption via mem_get_info() instead of
    # memory_reserved(), which goes negative when pluggable allocators
    # (e.g. cumem) bypass PyTorch's tracking.
    result.total_consumed = (
        result.before_create.free_memory - result.after_profile.free_memory
    )

    # total_consumed already covers persistent torch allocations; add only the
    # transient peak headroom to avoid double-counting.
    result.transient_peak_headroom = (
        result.after_profile.torch_peak - result.after_profile.torch_allocated
    )
    result.non_kv_cache_memory = result.total_consumed + result.transient_peak_headroom

total_consumed is everything the process took from the driver, torch and non-torch alike; transient_peak_headroom is the spike above what stayed resident, which the allocator needs again on every real forward pass. Their sum is what must be held back. CUDA graphs — replayable recordings of a whole decode step's kernel launches, which reserve their own memory pool (§8.1) — are profiled separately, by capturing every graph descriptor into a throwaway pool against a minimal KV cache (vllm/v1/worker/gpu_model_runner.py:L6775-L6797). The final subtraction:

vllm/v1/worker/gpu_worker.py:L559-L563 vLLM
        self.available_kv_cache_memory_bytes = (
            self.requested_memory
            - profile_result.non_kv_cache_memory
            - cudagraph_memory_estimate_applied
        )

Bytes become blocks by a plain division at vllm/v1/core/kv_cache_utils.py:L1023-L1041: num_blocks = int(available_memory // page_size // num_layers). With $b_{tok} = 131{,}072$ B for Llama-3-8B (from §2.1), 54.4 GiB is $5.841 \times 10^{10}$ bytes; the two floor divisions give $5.841\times10^{10} \mathbin{//} 65{,}536 \mathbin{//} 32 = \mathbf{27{,}852}$ blocks of 16, i.e. 445,632 token slots, or 54 concurrent sequences at 8k context. Derived.

Why this is not §2.1's 52.32 GiB

The three soft terms in Figure 1 sum to 3.8 GiB, and 73.2 − 15.0 − 3.8 = 54.4. §2.1 carries 6.0 GiB instead and lands on a 52.32 GiB pool, and every capacity figure elsewhere in the book uses that. Neither is wrong: 3.8 GiB is an illustrative non-KV allowance, not a measured or typical result, 13.1 is what SGLang reserves a priori at H100 defaults (§4 below), and 6.0 is the conservative working allowance the book standardises on so its capacity tables are comparable across chapters. The differing allowances in them is the subject of this chapter — it is roughly two concurrent 8k sequences, and it is exactly the margin the operator in §1 deleted. Read your own startup log rather than either number.

SGLang estimates, then measures what is left

SGLang's mem_fraction_static means something structurally different. It is not a ceiling on consumption; it is the complement of a reserve. The default is computed from a closed-form heuristic before anything runs:

python/sglang/srt/server_args.py:L4992-L5012 SGLang
                # Tokens the activation working set scales with (per serving mode).
                if self.disaggregation_mode == "decode":
                    running_requests = (
                        self.max_running_requests
                        or decode_cuda_graph_config.max_bs
                        or 1
                    )
                    draft_tokens = self.speculative_num_draft_tokens or 1
                    activation_tokens = max(running_requests * draft_tokens, 2048)
                elif self.chunked_prefill_size > 0:
                    activation_tokens = max(self.chunked_prefill_size, 2048)
                else:
                    activation_tokens = max(self.max_prefill_tokens, 2048)
                # Constant meta data (e.g., from attention backend) + activation slack.
                reserved_mem = 512
                reserved_mem += activation_tokens * 1.5
                # Some adjustments for large parallel size
                reserved_mem += self.tp_size * self.pp_size / 8 * 1024
                reserved_mem += self.reserve_for_graph_mb()
                if gpu_mem is not None and gpu_mem > 60 * 1024:
                    reserved_mem = max(reserved_mem, 10 * 1024)

The docstring is unusually candid about this: "The coefficient 1.5 is a heuristic value, in the future, we can do better estimation by looking at the model types, hidden sizes or even do a dummy run" (python/sglang/srt/server_args.py:L4839-L4862). On any GPU above 60 GB the reserve is floored at 10 GB. On our 80 GB card with chunked_prefill_size = 8192 the formula gives $512 + 12288 + 128 + G$ MB, already above the floor, so $u_{\text{static}} = (81920 - \text{reserved})/81920$.

Then, crucially, the actual pool size is not mem_fraction_static × capacity. It is measured free memory minus a slack proportional to the complement:

python/sglang/srt/mem_cache/kv_cache_configurator.py:L1764-L1775 SGLang
    def _profile_available_bytes(self, pre_model_load_memory: int) -> int:
        # KV pool budget = currently-free GPU memory minus the non-static runtime
        # slack (pre_model_load_memory * (1 - mem_fraction_static)). Whatever is
        # already resident (model weights, etc.) is thus charged against it.
        available_gpu_memory = get_available_gpu_memory(
            self.device,
            self.gpu_id,
            distributed=get_world_group().world_size > 1,
            cpu_group=get_world_group().cpu_group,
        )

        slack_gb = pre_model_load_memory * (1 - get_schedule().mem_fraction_static)

So SGLang measures capacity and weights but estimates activations and graphs; vLLM measures all four. The tradeoff runs both ways: vLLM's profile costs a max-shape forward pass plus a full graph capture at every startup and asserts that no other process on the card moves during it, while SGLang starts faster but hands you a formula to tune by hand for an unusual model. A newer opt-in path (SGLANG_ENABLE_POST_CAPTURE_KV_SIZING, gated at python/sglang/srt/server_args.py:L5043-L5067) sizes the pool after graph capture instead, dropping the reserve to a 1536 MB floor — but it excludes MLA, DCP, fp4 KV and memory-saver mode, so most DeepSeek deployments still use the heuristic.

§5

Why 0.98 kills you at minute eleven

The profile is a path-specific estimate of the peak, and the engine knows it — when suggesting an explicit --kv-cache-memory, vLLM subtracts an admitted fudge factor:

vllm/v1/worker/gpu_worker.py:L761-L770 vLLM
            # empirically observed that the memory profiling may
            # slightly underestimate the memory consumption.
            # So leave a small buffer (=150MiB) to avoid OOM.
            redundancy_buffer_memory = 150 * (1 << 20)

            non_kv_cache_memory = (
                self.total_consumed
                + self.peak_activation_memory
                + cuda_graph_memory_bytes
            )

Four things live in the gap between profile and reality, and none of them are visible at startup:

Fragmentation

The allocator's high-water mark drifts up

PyTorch's caching allocator serves the steady state from cached blocks, but a novel size class — an unusually long prompt, a batch shape the profile never produced — forces a fresh cudaMalloc against a heap already carved up. The OOM message in §1 shows the signature: 1.94 GiB reserved but unallocated, and the request still fails.

Unprofiled paths

The dummy run does not exercise everything

profile_run executes one forward, one sampler run, one encoder batch. Structured-output masks, LoRA adapter swaps, and beam-search bookkeeping allocate on paths the profile never enters.

Non-torch growth

NCCL and backend workspaces are not static

The snapshot is taken after init_worker_distributed_environment, deliberately, so NCCL buffers are counted. But collective buffers can grow on first use of a new message size, and that memory is invisible to memory_stats — it only shows up in non_torch_memory.

Neighbours

You are rarely alone on the card

A metrics exporter, a second process, a sidecar that opens a CUDA context — each takes 300–500 MiB of context that was free during the profile. vLLM asserts against the reverse case (a neighbour releasing memory mid-profile) but cannot defend against one arriving later.

At $u = 0.92$ you hold 6.4 GiB back; at $u = 0.98$, 1.6 GiB, buying 4.8 GiB of KV — a 9% larger pool, roughly 5 more concurrent 8k sequences. That is the trade: 9% more concurrency against every one of the four failure modes above. Take it on a dedicated card with a fixed workload and a tested maximum prompt length; never on shared hardware.

The better knob

If you want the last gigabyte, do not raise $u$. Run once, read the suggested --kv-cache-memory=<bytes> out of the startup log, and pin it. That path skips profiling entirely and gives you an absolute number that does not move when the machine's free memory does — at the cost of having to re-derive it whenever the model, parallelism or batch config changes. kv_cache_memory_bytes when set "ignores gpu_memory_utilization" (vllm/config/cache.py:L201-L208).

§6

Pitfalls: the error strings and what to change

Four distinct failures produce four distinct strings. Matching the string to the cause saves an hour.

Startup memory failures, with the source of each string. Quoted verbatim from the pinned SHAs.
StringSourceMeansChange
Free memory on device ... on startup is less than desired GPU memory utilization vllm/v1/worker/utils.py:L453-L461 Something else already holds the card. $u \cdot C_{\text{total}}$ exceeds what is free before vLLM allocates anything. Find the other process, or lower $u$ to below free/total.
No available memory for the cache blocks. vllm/v1/core/kv_cache_utils.py:L778-L784 The subtraction went negative: weights plus activations plus graphs exceed the budget. Raise $u$, shrink max_num_batched_tokens, or --enforce-eager to delete the graph term.
To serve at least one request with the model's max seq len (N), (X GiB KV cache is needed, which is larger than the available KV cache memory (Y GiB) vllm/v1/core/kv_cache_utils.py:L799-L807 The pool exists but cannot hold one maximum-length sequence. The message carries a binary-searched estimated maximum model length. Set --max-model-len to the estimate it prints, or quantize the KV (§2.5).
Error in memory profiling. ... This happens when other processes sharing the same container release GPU memory while vLLM is profiling vllm/v1/worker/gpu_worker.py:L550-L558 Free memory went up during the profile, so the delta is meaningless. Isolate the container. This is an assertion, not a heuristic — it will fail every time.
Loaded weights leave no GPU memory for the KV cache under --mem-fraction-static=... python/sglang/srt/mem_cache/kv_cache_configurator.py:L1800-L1809 SGLang: weights (target plus draft) exceeded the static budget. The message computes the minimum viable fraction for you. Use the printed suggested_mem_fraction_static. If you enabled speculative decoding, draft weights are now counted.
Not enough host memory available. Requesting X GB but only have Y GB free. python/sglang/srt/mem_cache/pool_host/base.py:L173-L180 SGLang HiCache: the host tier does not fit in RAM. This is a host failure from a GPU flag. Lower --hicache-ratio or set --hicache-size in GB explicitly.

A host tier smaller than the GPU tier reduces the potential cache window but is not necessarily useless. Selective backup, different eviction orders, and expensive infrequently reused prefixes can still produce useful hits. Measure useful-byte hit rate and avoided recomputation against transfer and host-memory cost.

§7

The hierarchy, and when a fetch beats a recompute

Now the second half. Below HBM sit three more tiers, each roughly an order of magnitude larger and slower. §1.4 derived the crossover for a preemption swap, which pays PCIe twice — out and back: $R^{*} = B_{\text{pcie}} / (2 b_{tok})$. A hierarchical cache read is different: the write happened minutes ago, off the critical path, and only the inbound leg blocks the request. So the threshold is twice as permissive:

$$R^{*}_{\text{read}} = \frac{B_{\text{tier}}}{b_{tok}}$$

Fetching beats recomputing whenever the engine's achieved prefill rate $R$ is below $R^{*}_{\text{read}}$. Using $R \approx 22{,}000$ tok/s for Llama-3-8B at 400 TFLOP/s achieved (§1.4, derived) and $b_{tok} = 131{,}072$ B:

Figure 2 — the memory hierarchy for Llama-3-8B bf16, with the recompute crossover marked. Bandwidths are cited vendor/standard figures; the tokens-per-second column is derived as $B_{\text{tier}} / b_{tok}$. Nothing is measured.

Memory tier ladder with recompute crossover Five tiers plotted by inbound token rate on a logarithmic axis: HBM3 at 25.6 million tokens per second, host DRAM over PCIe Gen5 at 488 thousand, local NVMe at 107 thousand, 100 gigabit Ethernet at 95 thousand, and 10 gigabit Ethernet at 9,500. A vertical line at 22,000 tokens per second marks the recompute rate; every tier except 10 gigabit Ethernet is to its right and therefore faster than recomputing. 10k tok/s 100k 1M 10M 100M recompute at R = 22,000 tok/s HBM3 on-package — 80 GB — 3.35 TB/s — 25.6M tok/s Host DRAM over PCIe Gen5 x16 — 1-2 TB — 64 GB/s — 488k tok/s Local NVMe Gen5 x4 — 4-30 TB — 14 GB/s — 107k tok/s Remote store over 100 GbE — PB — 12.5 GB/s — 95k tok/s Remote store over 10 GbE — PB — 1.25 GB/s — 9.5k tok/s Every tier right of the dashed line is cheaper to fetch from than to re-prefill. The 10 GbE tier is not.

Bandwidth provenance: HBM3 at 3.35 TB/s is the H100 SXM datasheet figure fixed in §0.4. PCIe Gen5 x16 at 64 GB/s per direction is the PCI-SIG link rate, as used in §1.4. 14 GB/s is the vendor sequential-read spec for a PCIe Gen5 x4 enterprise NVMe (e.g. Kioxia CM7-R class). 12.5 and 1.25 GB/s are 100 GbE and 10 GbE line rates. All are optimistic ceilings; real pinned-copy and filesystem throughput lands lower, which moves each tier left — toward the recompute line, never away from it.

For the assumed 8B model and recomputation rate, the bandwidth-only calculation favors PCIe over recomputation and disfavors 10 GbE. These are model-specific comparisons, not universal rules: link startup, CPU/NUMA contention, shared bandwidth, prefix size, and faster or slower prefill can reverse the result.

MLA changes the arithmetic

The DeepSeek-V3 illustrative bf16 latent is 61*576*2=70,272 bytes/token, about 68.6 KiB. That is about 1.87 times smaller than Llama-3-8B's 128 KiB, not more than an order of magnitude. Relative to a hypothetical uncompressed attention layout of the same architecture the reduction can be much larger; state the baseline before quoting a ratio.

§8

SGLang HiCache: a three-level cache

Most writing about SGLang's hierarchical cache describes a class called HiRadixCache. At 7d89325 that class is never constructed:

every construction site of HiRadixCache in the SGLang runtime shell
$ grep -rn "HiRadixCache(" python/sglang/srt/
python/sglang/srt/mem_cache/hiradix_cache.py:77:class HiRadixCache(RadixCache):

One hit, and it is the class statement. The live path runs through a factory instead. Every branch of default_radix_cache_factory returns a ChunkCache variant, RadixCacheCpp (behind SGLANG_EXPERIMENTAL_CPP_RADIX_TREE), PureSWARadixCache, LMCRadixCache or the FlexKV factory — and the fall-through, which is what a normal server gets, is _create_unified_radix_cache (python/sglang/srt/mem_cache/registry.py:L80-L143). HiCache is then bolted onto that object by a second call:

python/sglang/srt/mem_cache/registry.py:L187-L196 SGLang
    cache = UnifiedRadixCache(params)
    if (
        ctx.enable_hierarchical_cache
        or get_disagg().disaggregation_decode_retraction_backup == "host_pool"
    ):
        cache.init_hicache(server_args, params)
        ctx.tp_worker.register_hicache_layer_transfer_counter(
            cache.cache_controller.layer_done_counter
        )
    return cache

That is the shape of the whole subsystem now. UnifiedRadixCache is a plain prefix cache whose HiCache fields are declared inert at construction — "HiCache D↔H defaults (overridden by init_hicache)" and "Owns the storage backend lifecycle; built by init_hicache" (python/sglang/srt/mem_cache/unified_radix_cache.py:L230-L235) — and init_hicache attaches the host pools, controller and storage backend after the fact. Tiering is a layer, not a subclass.

The convergence is deliberate and documented: the per-model radix variants "(radix_cache.py, swa_radix_cache.py, mamba_radix_cache.py, hiradix_cache.py, chunk_cache.py) are converging onto the Unified Radix Cache" (python/sglang/srt/mem_cache/README.md:L38-L45). Two independently-grown prefix caches merged into one tree with a component model, and the hierarchy became something you attach to it. hiradix_cache.py is still in the repo and is often the clearest statement of an idea, so this chapter quotes it where that holds — always labelled. You cannot reach it with a flag.

Still live

Everything below the tree survived the merge unchanged and is cited here as current: pool_host/ and memory_pool_host.py (the host tier), hicache_storage.py and storage/backend_factory.py (L3), l2_transfer.py (the D↔H engine), and managers/cache_controller.py, whose HiCacheController is the base class of the HybridCacheController the unified cache builds (python/sglang/srt/mem_cache/hybrid_cache/hybrid_cache_controller.py:L96). Only the tree above them was replaced.

With that settled: the unified tree gives every node a second address — host_value (host-pool indices) alongside its device value, plus a hash_value used as the key in an external store. The code and metrics call these L1, L2 and L3.

Sizing the host tier

python/sglang/srt/mem_cache/pool_host/base.py:L149-L162 SGLang
        self.dtype = device_pool.store_dtype
        self.size_per_token = self.get_size_per_token()
        if host_size > 0:
            self.size = sync_fixed_hicache_size(
                int(host_size * 1e9 // self.size_per_token), host_size
            )
        else:
            self.size = int(device_pool.size * host_to_device_ratio)
        # Align up the host memory pool size to the page size
        self.page_num = self.size // self.page_size + 1
        self.size = self.page_num * self.page_size

--hicache-size in GB wins if set; otherwise --hicache-ratio multiplies the device pool. Defaults as of 7d89325: 2.0 in cache mode, 1.2 in buffer_only mode, and unset for a decode-role server, where kv_cache_builder resolves it against the retraction-backup backend (python/sglang/srt/server_args.py:L7426-L7440). With our 445,632-token GPU pool, the default ratio 2.0 buys 891,264 host tokens = 109 GiB of pinned host RAM — a number worth knowing before you enable the flag on a 128 GB box.

Write policy: through, selective, or back

Three policies, validated in the controller (python/sglang/srt/managers/cache_controller.py:L334-L339) and resolved into two pieces of tree state by init_hicache:

python/sglang/srt/mem_cache/unified_radix_cache.py:L443-L451 SGLang
        # State initialization
        self.write_through_threshold = (
            1 if server_args.hicache_write_policy == "write_through" else 2
        )
        self.is_write_back = (
            self.cache_controller is not None
            and self.cache_controller.write_policy == "write_back"
        )

Those two flow down to the tree core, where the whole policy reduces to one predicate evaluated on every prefix hit:

python/sglang/srt/mem_cache/unified_cache/unified_tree_core.py:L847-L860 SGLang
    def _inc_hit_count_and_check(
        self, node: UnifiedTreeNode, chunked: bool = False
    ) -> bool:
        """Increment hit count; check whether a write backup should be fired."""
        if node.evicted or chunked:
            return False
        if self.is_write_back:
            return False
        node.hit_count += 1
        return (
            self.enable_hicache
            and not node.backuped
            and node.hit_count >= self.write_through_threshold
        )

So: write_through (threshold 1) copies every node to host on its first prefix hit; write_through_selective (threshold 2) waits for a second hit, filtering out one-shot prefixes that would only pollute the host pool; write_back short-circuits the predicate entirely and instead stages nodes to host at eviction time, in evict_device_leaf, which evicts "one device leaf (demote if backuped, delete if write-through); for an unbacked write-back node, the result carries the BackupKV for the cache to execute and then demote" (python/sglang/srt/mem_cache/unified_cache/unified_tree_core.py:L1244-L1250). The default is write_through, and write-back is on its way out: the earlier implementation already carried "note this path will be deprecated in the future" over its write-back eviction (python/sglang/srt/mem_cache/hiradix_cache.py:L1231-L1233earlier implementation; it reads as foreshadowing). The reason is structural: write-back must do a host allocation and a D2H copy on the eviction path, inside the allocator's critical section.

The backup invariant, and where the merge improved it

Backed-up nodes must form a contiguous prefix from the root — a host tier with holes never serves anything, since a prefix match is only usable if it reaches the root. Both implementations enforce that and disagree about how, which is the clearest single example of what the convergence bought. The earlier HiRadixCache enforced it by refusing: if the parent is not backed up, return 0.

python/sglang/srt/mem_cache/hiradix_cache.py:L841-L848 — EARLIER IMPLEMENTATION, not on the live path SGLang
    def write_backup(self, node: TreeNode, write_back=False) -> int:
        # Backup invariant (for write-through mode): backed-up nodes must form a
        # contiguous prefix from root — no gaps.  Skip if parent isn't backed
        # up yet;
        if not write_back and (
            node.parent != self.root_node and not node.parent.backuped
        ):
            return 0

The unified tree enforces the same invariant by repairing: it walks up collecting unbacked ancestors and reverses the chain so they are written first.

python/sglang/srt/mem_cache/unified_cache/unified_tree_core.py:L1907-L1923 SGLang
    def _build_backup_kv_action(
        self, node: UnifiedTreeNode, write_back: bool = False
    ) -> BackupKV:
        """Build the backup action for a node and its unbacked ancestors."""
        chain = [node]
        if not write_back:
            ancestor = node.parent
            while (
                ancestor is not None
                and ancestor is not self.root_node
                and not ancestor.backuped
            ):
                chain.append(ancestor)
                ancestor = ancestor.parent
            # write_through: Ancestors first to preserve backup invariant
            chain.reverse()
        return BackupKV([target.id for target in chain])

Same invariant, opposite failure mode. Under the old rule a hot node reached only through a cold ancestor could stall indefinitely; under the new one, its first hit drags the whole unbacked ancestry to host in a single action. If you are reading numbers from a HiCache write-up aimed at the old class, this is one reason they may not reproduce.

The storage tier and its backends

L3 is behind one ABC, HiCacheStorage (python/sglang/srt/mem_cache/hicache_storage.py:L150-L155), whose interesting methods are the _v2 family: batch_exists_v2 performs a longest-prefix existence probe across multiple pools before any data moves, and batch_get_v2/batch_set_v2 move pages directly into and out of registered host-pool memory. Ten backends are registered at 7d89325:

python/sglang/srt/mem_cache/storage/backend_factory.py:L196-L251 SGLang
# Register built-in storage backends
StorageBackendFactory.register_backend(
    "file", "sglang.srt.mem_cache.hicache_storage", "HiCacheFile"
)
# ... sim, nixl, mooncake, hf3fs, aibrix, eic, simm ...
StorageBackendFactory.register_backend(
    "mori",
    "sglang.srt.mem_cache.storage.umbp.umbp_store",
    "UMBPStore",
)

StorageBackendFactory.register_backend(
    "shm",
    "sglang.srt.mem_cache.storage.shm",
    "HiCacheShm",
)

Mapped onto Figure 2's tiers: file and hf3fs are filesystem (NVMe class), shm and simm are shared host memory, mooncake, aibrix and eic are remote KV stores, nixl and mori are RDMA transports, and sim is a test simulator. A dynamic pseudo-backend loads an out-of-tree class by module path, so the list is a floor. The lmcache and flexkv directories under storage/ are not registered here — they integrate as alternative radix caches (lmc_radix_cache.py, flexkv_radix_cache.py), not L3 backends.

§9

Worked trace: an L3 prefetch

Follow one request whose 10,240-token prompt hits 2,048 tokens in the GPU tree and needs 8,192 more.

Figure 3 — the HiCache prefetch path, function by function. Ctrl/Cmd + wheel to zoom · drag to pan · double-click to fit

Loading…
  1. prefetch_from_storage (python/sglang/srt/mem_cache/unified_radix_cache.py:L1587-L1620) builds a page-aligned key and bails immediately if the tail is shorter than prefetch_threshold (256 tokens by default, overridable through hicache_storage_backend_extra_config), if the controller is rate-limited, or if a fetch for this request is already in flight — "overwriting would leak its staging slots". Nothing is allocated yet: the probe comes first.
  2. The existence probe. The storage thread calls batch_exists_v2, which returns a PoolTransferResult whose kv_hit_pages is the usable prefix length across every registered pool — the minimum, so a missing auxiliary page (Mamba state, or DeepSeek-V3.2's DSA sparse-attention indexer, §7.2) shrinks the KV prefix too (python/sglang/srt/mem_cache/hicache_storage.py:L165-L196).
  3. Second threshold check, then allocate. Back on the scheduler thread the drain revokes the prefetch when operation.storage_hit_count < self.prefetch_threshold — a "below-threshold hit … (not enough benefit)" (python/sglang/srt/mem_cache/unified_radix_cache.py:L2185-L2191) — and only then allocates host pages for exactly the hit length. Under host pressure it does not give up outright: it retries with "a shorter page-aligned prefix", and abandons only if even that falls below the threshold (python/sglang/srt/mem_cache/unified_radix_cache.py:L2135-L2154).
  4. Fetch and graft. check_prefetch_progress reduces the completed token count to a minimum across attention groups via _sync_and_check_hybrid_prefetch_result so every rank agrees on one usable prefix, then tree_core.insert_host grafts the fetched pages onto the tree as host_value (python/sglang/srt/mem_cache/unified_radix_cache.py:L1754-L1810). Anything beyond the agreed prefix goes on the host-release queue. The graft respects the same invariant as a write-through backup: a refill under an un-backed-up node is dropped rather than creating a hole, and says so — HiCache prefetch dropped %d-token refill under un-backed-up node %d (python/sglang/srt/mem_cache/unified_cache/unified_tree_core.py:L1786-L1794).
  5. Load back. load_back refuses runs shorter than load_back_threshold = 10 tokens or larger than the caller's memory quota (python/sglang/srt/mem_cache/unified_radix_cache.py:L1396-L1405), then hands the transfer to the L2 engine — which is unchanged from the earlier implementation. The H2D copy is issued per layer:
python/sglang/srt/mem_cache/l2_transfer.py:L85-L110 SGLang
        with device_module.stream(self.host_to_device_stream):
            start_event.wait(self.host_to_device_stream)
            ack_start.record()
            for layer_id in range(layer_num):
                for transfer in transfers:
                    local_layer_id = (
                        transfer.layer_mapper(layer_id)
                        if transfer.layer_mapper is not None
                        else layer_id
                    )
                    # ...
                    transfer.host_pool.load_to_device_per_layer(
                        transfer.device_pool,
                        transfer.host_indices,
                        transfer.device_indices,
                        local_layer_id,
                        self.io_backend,
                        is_draft=transfer.is_draft,
                    )
                if on_layer_done is not None:
                    on_layer_done(layer_id)

That loop, plus the LayerDoneCounter it drives, is what turns a blocking PCIe copy — the trace's 8,192 tokens are exactly 1 GiB of KV, 16.8 ms at Figure 2's 64 GB/s — into something the forward pass can consume incrementally: layer 0's KV lands while layer 0 is still computing. The whole point of the per-layer decomposition is to hide the transfer behind the model.

§10

vLLM: two offload stacks, one connector interface

First, the verification promised by §1.4. At a556f3f, grep -rn "swap_space" vllm/ returns zero hits — the field is gone from CacheConfig entirely. grep -rn "swap" vllm/v1/ returns 47 hits, and every one is either an unrelated identifier (swap_row in the block table, swap_states in logits processors) or belongs to vllm/v1/kv_offload/cpu/, where swap_blocks_batch is the name of a Triton kernel for GPU↔CPU block copies. There is no preemption-swap path in vLLM V1. Host memory is not a fallback for eviction; it is only ever a cache.

What exists instead is two separate offload stacks, reachable through the same KVConnectorBase_V1 factory that §1.6 describes for P/D:

Figure 4 — vLLM's two offload stacks at a556f3f. Ctrl/Cmd + wheel to zoom · drag to pan · double-click to fit

Loading…

vllm/v1/kv_offload/ — the general stack

An OffloadingManager runs in the scheduler and tracks addresses only; a separate OffloadingWorker moves data. Its primitive set is a cache protocol, not a swap protocol — lookup, prepare_load, touch, complete_load, prepare_store, complete_store (vllm/v1/kv_offload/base.py:L163-L184). Keys are make_offload_key(block_hash, group_idx) — the same content hashes §2.3 derives — so the host tier shares a namespace with the GPU prefix cache without sharing an allocator. Two specs are registered:

vllm/v1/kv_offload/factory.py:L61-L69 vLLM
# Register various specs here.
OffloadingSpecFactory.register_spec(
    "CPUOffloadingSpec", "vllm.v1.kv_offload.cpu.spec", "CPUOffloadingSpec"
)
OffloadingSpecFactory.register_spec(
    "TieringOffloadingSpec",
    "vllm.v1.kv_offload.tiering.spec",
    "TieringOffloadingSpec",
)

CPUOffloadingSpec is a single host tier backed by a SharedOffloadRegion: an mmap'd, page-aligned region sized by a mandatory cpu_bytes_to_use, with lru or arc eviction (vllm/v1/kv_offload/cpu/policies/factory.py:L84-L90). Under TP, ranks take disjoint slots — except for a pure-MLA model where every rank's bytes are identical, in which case all share slot 0, dividing host footprint by the TP degree (vllm/v1/kv_offload/cpu/spec.py:L90-L110).

TieringOffloadingSpec subclasses it and adds L3 under the constraint its docstring states: "The CPU primary tier has direct GPU access and serves as the gateway for all GPU↔offload operations. Secondary tiers cannot directly access GPU memory and must transfer data through the primary tier" (vllm/v1/kv_offload/tiering/spec.py:L84-L95). Four secondary tiers are registered at vllm/v1/kv_offload/tiering/factory.py:L103-L125example, fs (thread-pooled filesystem), p2p (NIXL data plane, ZMQ control plane) and obj (object store) — plus an out-of-tree escape hatch taking a module_path.

vllm/v1/simple_kv_offload/ — the specialised stack

The simple stack answers a different question: what if the host tier were the same data structure as the GPU pool? SimpleCPUOffloadScheduler derives a CPU KVCacheConfig from the GPU one and builds a second full KVCacheCoordinator and BlockPool over host memory (vllm/v1/simple_kv_offload/manager.py:L94-L142), so host prefix matching uses the same find_longest_cache_hit, hash table and LRU free queue as the GPU pool (§2.2). Backends are exactly two, ("cpu", "disk"), both moving blocks with cuMemcpyBatchAsync; the disk backend uses O_DIRECT against 4096-aligned pinned staging buffers with separate store and load threads so "loads (latency-critical) never block behind stores (background work)" (vllm/v1/simple_kv_offload/disk_backend.py:L3-L8).

Its most instructive feature is a write policy SGLang lacks: lazy offload, which writes blocks to host only when they are near eviction, by walking the GPU free queue with a persistent cursor.

vllm/v1/simple_kv_offload/manager.py:L474-L489 vLLM
    def _prepare_lazy_store_specs(
        self,
    ) -> tuple[list[int], list[int], list[str]]:
        """Single-pass cursor walk: offload cached GPU blocks near eviction.

        Walks the GPU free queue from the cursor, counting blocks that are
        free-or-offloaded (safe for the allocator to evict). Stops when
        target_free blocks are covered or CPU capacity is reached.
        """
        gpu_pool = self._gpu_block_pool
        if gpu_pool is None or self._target_free <= 0:
            return [], [], []

        free_queue = gpu_pool.free_block_queue
        cpu_pool = self.cpu_block_pool
        num_cpu_free = cpu_pool.get_num_free_blocks()

This is SGLang's write_back without the drawback that deprecated it: the walk is amortised across steps by the cursor and bounded by _target_free, so it never runs synchronously inside an eviction. The honest comparison: vLLM's general stack is the more extensible — pluggable specs, tiers and eviction policies, all loadable out-of-tree — while SGLang's HiCache is the more integrated, because the host tier is a second address on every radix node rather than a parallel key-value namespace, which is what makes the per-layer overlapped load-back in Figure 3 possible at all. Enabling vLLM's is one pair of flags: kv_offloading_size in GiB and kv_offloading_backend, "native" or "lmcache" (vllm/config/cache.py:L210-L219).

§11

When a host tier actually pays

Bandwidth says the host tier is 22× cheaper than recompute. That is necessary, not sufficient — the tier only earns its keep if requests actually hit in it, and hit rate is a residency question. Model a tier as LRU with capacity $C$ tokens fed by unique insertions at $\lambda$ tokens/s. An entry survives for roughly

$$T_{\text{tier}} \;\approx\; \frac{C_{\text{tier}}}{\lambda}$$

Under a simplified unique-insertion LRU approximation, a 445632-token GPU tier at 2000 tokens/s gives 222.8 s. A 2x-capacity inclusive write-through host tier contains copies of many GPU entries, so its unique window is about 891264/2000=445.6 s, not (445632+891264)/2000=668.4 s. The additive result applies to an ideal exclusive hierarchy with disjoint contents. Real reuse distances and pinned entries determine hit rates.

223 s
GPU-tier residency, derived
446 s
with a 2.0-ratio host tier
2.0×
combined window vs GPU alone

So the host tier pays for exactly one thing: inter-turn gaps between 3.7 and 7.4 minutes under the inclusive write-through approximation. Below 3.7 minutes the GPU cache already hits and the host tier is pure write amplification; above 7.4 minutes in that simplified model both miss and you need L3 or nothing. That window is the shape of human multi-turn chat — read a 400-word answer, think, type, come back four minutes later — which is why multi-turn chat is the canonical win. The workloads it does not help:

Loses

Single-pass batch

Every prefix is referenced once. The host tier absorbs 100% of writes and serves 0% of reads. With write_through that is a full D2H copy of every block on its first hit, competing with the model for PCIe. Use write_through_selective or turn it off.

Loses

Shared system prompt, high QPS

The hot prefix never leaves the GPU tier — $T_{\text{GPU}}$ is irrelevant because it is re-referenced continuously. The host tier holds a second copy of something that is never evicted.

Wins

Long-document Q&A

A 100k-token document, several questions minutes apart. One document is 12.2 GiB of KV at $b_{tok}$ — it can be evicted under sufficient insertion pressure but may remain resident indefinitely at low load, but it fits in a host tier and fetches in 0.2 s versus a 4.5 s re-prefill. Derived.

The prefetch threshold encodes exactly this: SGLang refuses to fetch fewer than 256 tokens because the round-trip's fixed cost exceeds re-prefilling them. Both bail-outs in Figure 3 are that idea.

Where this goes

Once L3 is a network store, the KV cache stops being a memory-management problem and becomes a distributed-systems problem: consistency across replicas, cache-aware request routing so a request lands where its prefix already is, eviction coordinated across nodes, and a failure model for a store that can return stale bytes. The p2p tier's split of NIXL for data and ZMQ for control is that problem in miniature. Routing is §9.4; the open problems are §13.3.

§12

Hands-on

Read your own budget instead of trusting Figure 1. Start vLLM at debug level and grep four lines:

read the budget out of the log shell
VLLM_LOGGING_LEVEL=DEBUG vllm serve meta-llama/Meta-Llama-3-8B-Instruct \
  --max-model-len 8192 --max-num-batched-tokens 8192 2>&1 \
  | grep -E "init memory snapshot|Memory profiling takes|Available KV cache|kv-cache-memory="

Line one gives $O_{\text{non-torch}}$ and $C_{\text{total}}$; line two is MemoryProfilingResult.__repr__ — weights, torch peak increase, total consumed; line three is the residual; line four is the suggested explicit byte count. Then flip one thing at a time and watch which term moves: --enforce-eager should zero the graph term, VLLM_MEMORY_PROFILER_ESTIMATE_CUDAGRAPHS=0 should leave the graph pool unaccounted (and inflate the KV pool by the same amount), and doubling --max-num-batched-tokens should move the activation peak and potentially graph/workspace/backend allocation terms too.

For SGLang, the equivalent is a single startup line — max_total_num_tokens=..., chunked_prefill_size=..., max_running_requests=... (python/sglang/srt/managers/scheduler.py:L1094-L1099) — plus, with --enable-hierarchical-cache, the host-pool allocation line Allocating kv hierarchical KV host pool: N tokens, X GB host memory. Compare max_total_num_tokens against vLLM's block count × 16 on the same model and card; a large gap is the estimate-versus-profile difference made visible.

§13

Exercises

  1. Read and answer. Open vllm/utils/mem_utils.py and read the docstring of memory_profiling (L233-L290). Its worked example says non-KV memory is 5 GiB, made of 2 (weights) + 2 (activations) + 1 (non-torch). Trace which of the three lines at L312-L326 produces each of those three numbers, and explain why total_consumed alone would double-count if transient_peak_headroom were defined as torch_peak rather than torch_peak - torch_allocated.
  2. Compute. Redo the Figure 1 subtraction for Llama-3-70B in bf16 on 8×H100 with TP=8. Per-GPU weights are $70.6\times10^{9} \times 2 / 8$; per-GPU $b_{tok}$ is $2 \cdot 80 \cdot (8/8) \cdot 128 \cdot 2$. Assume the same 0.8 GiB non-torch and 1.5 GiB graph terms and a 2.0 GiB activation peak. How many tokens does the pool hold, and how many 8k sequences?
  3. Predict, then verify. You set --hicache-ratio 4.0 on a 128 GB host serving Llama-3-8B with the 54.4 GiB GPU pool from §3. Predict the requested host bytes and whether SGLang starts. Then verify by reading get_size_per_token for MHATokenToKVPoolHost and the check at python/sglang/srt/mem_cache/pool_host/base.py:L173-L180.
  4. Predict, then verify. With hicache_write_policy=write_through, a request's prefix creates radix nodes A → B → C, and only C is ever hit. Predict whether C reaches the host tier under the earlier HiRadixCache.write_backup (python/sglang/srt/mem_cache/hiradix_cache.py:L841-L848) and under the live _build_backup_kv_action (python/sglang/srt/mem_cache/unified_cache/unified_tree_core.py:L1907-L1923). Then answer the question the difference raises: why is repairing the invariant safe here when refusing was the original choice?
  5. Design. Your L3 is an object store on 25 GbE (3.1 GB/s). Using $R^{*}_{\text{read}} = B_{\text{tier}} / b_{tok}$ and $R = 22{,}000$ tok/s, decide whether L3 pays for Llama-3-8B. Then decide it again for a model whose $b_{tok}$ is 8× smaller. What does that tell you about which architectures make distributed KV stores viable?
Answers

1. Weights come from the weights_memory argument, not from measurement at all — the loader reports it. The 1 GiB non-torch is inside total_consumed, since that is a mem_get_info delta and therefore driver-level. The 2 GiB activation is transient_peak_headroom. Double-counting: after the profile, the 2 GiB of weights are still allocated, so torch_peak = 4 GiB includes them; total_consumed = 3 GiB also includes them. Subtracting torch_allocated (2 GiB) from torch_peak isolates the transient 2 GiB alone.

2. Per-GPU weights $= 141.2\times10^{9}/8 = 16.44$ GiB, budget $73.2$ GiB, so KV $= 73.2 - 16.44 - 0.8 - 2.0 - 1.5 = 52.46$ GiB $= 5.633\times10^{10}$ B. Per-GPU $b_{tok} = 2 \cdot 80 \cdot 1 \cdot 128 \cdot 2 = 40{,}960$ B, giving $1{,}375{,}500$ tokens, i.e. 167 sequences at 8k — three times the 8B figure. GQA plus TP sharding makes the 70B model more concurrent per GPU than the 8B model, the most counter-intuitive number in Part 2.

3. Host tokens $= 445{,}632 \times 4 = 1{,}782{,}528$, at 131,072 B/token $= 2.34\times10^{11}$ B $= 234$ GB requested against a 128 GB host. It raises Not enough host memory available. Requesting 233.63 GB but only have ... GB free. — and note it does so from a flag that looks like a GPU knob.

4. Opposite answers. Under the earlier class: nowrite_through_threshold is 1 so C is eligible on its first hit, but write_backup returns 0 immediately because B is not backuped, and C stalls until something independently backs up A and B. Under the live tree: yes_build_backup_kv_action walks up from C collecting the unbacked ancestors, reverses the chain so A and B are written first, and returns one BackupKV covering all three. Repairing is safe because the walk is bounded by the depth of the unbacked suffix, it is built as a plan the cache executes rather than a synchronous write inside the tree step, and it runs on the hit path, not the eviction path — which is precisely the property write-back lacks and the reason write-back is being deprecated.

5. $R^{*}_{\text{read}} = 3.1\times10^{9}/131072 = 23{,}650$ tok/s, versus $R = 22{,}000$. It pays by 7% — inside the error bars of every assumption, so no, not worth the complexity. At $b_{tok}/8 = 16{,}384$ B, $R^{*}_{\text{read}} = 189{,}000$ tok/s, an 8.6× margin: comfortably worth it. MLA and aggressive KV quantization are what move a remote tier from marginal to obvious, which is why the distributed-KV-store ecosystem grew up alongside DeepSeek-V2.

§14

Key takeaways

  • gpu_memory_utilization multiplies total device memory, not free memory, and the KV pool is what survives subtracting measured weights, measured non-torch allocations, a measured transient activation peak and a measured CUDA-graph pool from that product. Every one of those terms is printed at startup — read them rather than guessing.
  • vLLM combines memory snapshots, a synthetic forward profile, and graph-specific estimation or capture accounting; SGLang estimates activations and graphs with a closed-form reserve (512 MB plus 1.5 MB per activation token, floored at 10 GB above 60 GB of VRAM) and measures only capacity and weights. That is the real difference between gpu_memory_utilization and mem_fraction_static, and it is why the two numbers are not interchangeable.
  • A profile is workload- and path-specific, not a mathematical lower bound on every deployment peak. Raising $u$ to 0.98 buys about 9% more concurrency and spends the entire margin that absorbs fragmentation, unprofiled code paths, NCCL growth and arriving neighbours — which is why the failure lands minutes into a run, not at startup. If you want the last gigabyte, pin --kv-cache-memory from the log instead.
  • vLLM V1 has no swap path at all: swap_space has zero occurrences in the tree at a556f3f, and the only swap_* symbols under vllm/v1/ are a Triton kernel inside the offload cache. Host memory in V1 is a cache, never a spill target.
  • A cache read pays PCIe once, not twice, so the crossover is $R^{*}_{\text{read}} = B_{\text{tier}}/b_{tok}$ — double §1.4's swap threshold. Host DRAM beats recompute by 22× for Llama-3-8B; a 10 GbE remote store loses in this particular 8B/22k-tokens-per-second example, not for every model or workload. Shrinking $b_{tok}$ via MLA or KV quantization moves every tier the same multiple to the right.
  • Bandwidth and residency are both relevant, but neither proves an end-to-end win: include fixed latency, hit rate, contention, and tail behavior. A tier of capacity $C$ under unique-insertion rate $\lambda$ holds an entry about $C/\lambda$ seconds; a host tier only pays for re-references that fall between the GPU and host windows. Multi-turn chat lands there; single-pass batch never does, and for it a write-through host tier is pure PCIe tax.
  • SGLang's hierarchical cache is no longer a subclass. HiRadixCache is never constructed at 7d89325; the live path is UnifiedRadixCache plus an init_hicache() call that attaches the host pools, controller and storage backend to an ordinary prefix tree. Tiering became a layer you bolt on, which is why the same tree now serves Full, SWA and Mamba components — and why the backup invariant moved from refusing writes to repairing the ancestry.
§15

Further reading

  • vLLM PR #11743, "Memory profiling rework" — the origin of the MemorySnapshot / memory_profiling split and the torch-versus-non-torch category model quoted in §4.
  • vLLM RFC #19854, "KV cache offloading framework" — the design discussion behind vllm/v1/kv_offload/'s manager/worker/spec split. Read it before writing an out-of-tree OffloadingSpec.
  • SGLang PR #2693, "Hierarchical caching for SGLang" — the original HiRadixCache, including the write-policy debate that produced write_through_selective. Read it as history, then read SGLang issue #20415, the Unified Radix Cache tracker named in python/sglang/srt/mem_cache/README.md:L38-L45, for the merge that absorbed it.
  • SGLang HF3FS backend notes and the Mooncake paper (arXiv:2407.00079) for what a production L3 actually has to solve — Mooncake is the reference design for a disaggregated KV store and is a registered backend in both engines.
  • LMCache (repo) — the third-party KV layer that both engines expose as a first-class backend (kv_offloading_backend="lmcache" in vLLM, lmc_radix_cache.py in SGLang). Useful as a contrast: it owns the whole hierarchy rather than plugging into the engine's.
  • NVIDIA H100 datasheet for the 3.35 TB/s HBM3 figure, and the PCI-SIG PCIe 5.0 base specification for the 64 GB/s-per-direction x16 link rate. Both are ceilings; treat any number derived from them as optimistic.

The Inference Engineering Course · code read at vllm@a556f3fccb and sglang@7d893255c3, pinned 2026-08-21. See SOURCES for the full provenance record.

Explore the library

Reading preferences

Appearance
18 px