Repository navigation
[Feature] Offload KV-init runtime state on sleep - #59158
Conversation
2206bcf to
a48d422
Compare
|
This pull request has merge conflicts that must be resolved before it can be |
b0a91fd to
0354f3d
Compare
|
Request for an XPU check (nested memory pools) Could someone with an Intel XPU run the snippet below? This PR allocates the KV-init state under the with allocator.use_memory_pool("runtime"):
with allocator.use_memory_pool("kv_cache"): # inside initialize_kv_cache
kv = torch.empty(...)
block_table = torch.empty(...)On CUDA, torch allocates from the innermost active pool, so the KV cache and the runtime state stay in separate pools. The XPU caching allocator picks the outermost one instead.
As a result:
Repro, run on this branch (stacked on #59156) on a single XPU: import torch
from vllm.device_allocator.xpumem import XpuMemAllocator
a = XpuMemAllocator.get_instance()
with a.use_memory_pool(tag="runtime"):
with a.use_memory_pool(tag="kv_cache"):
kv = torch.empty(3 << 20, dtype=torch.uint8, device="xpu")
rt = torch.empty(3 << 19, dtype=torch.uint8, device="xpu")
for name, t in (("kv", kv), ("rt", rt)):
for tag, entries in a.allocator_and_pools.items():
for pool, _ in entries:
if any(s["address"] <= t.data_ptr() < s["address"] + s["total_size"]
for s in pool.snapshot()):
print(f"{name} is in the {tag!r} pool")
for d in a.pointer_to_data.values():
base, size = d.handle[2], d.handle[1]
if base <= t.data_ptr() < base + size:
print(f"{name} is in a segment tagged {d.tag!r}")Expected (what CUDA prints): Please post the output, so we can confirm this on hardware. Unless it shows otherwise, I'll skip the 🤖 Generated with Claude Code |
verified on XPU and torch 2.14, claude is correct. cc @yma11 please work with Intel torch team. |
c758b9c to
3fe6f0b
Compare
72e4284 to
a598a66
Compare
|
This pull request has merge conflicts that must be resolved before it can be |
a598a66 to
74e48b9
Compare
74e48b9 to
153bc44
Compare
92cd53e to
153bc44
Compare
Block tables and attention metadata built in initialize_from_config were allocated outside the sleep-mode pools, so /sleep kept them (about 4.6 GiB per GPU for DeepSeek-V4-Flash TP4). Allocate them under the cumem tag "runtime" and offload that tag at every sleep level; any wake, selective or not, copies it back to the same addresses. - initialize_from_config runs initialize_kv_cache inside the "runtime" pool, except on XPU, whose caching allocator picks the outermost active pool and would put KV cache and runtime state in one pool. KV connectors need no exception: what they allocate in register_kv_caches is scratch or pointer tables, and the memory they register with a transport is the KV cache, which keeps its own pool. - V1: _cleanup_profiling_kv_cache() also resets _init_block_sizes, so the real initialize_kv_cache rebuilds the InputBatch that the CUDA graph profiling pass may have built outside any pool. Signed-off-by: aoshen02 <aoshen@inferact.ai> Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
153bc44 to
658bdd4
Compare
|
/ci run |
|
✅ Triggered Buildkite CI #92580 for commit |
Based on main, which now includes #59156 (merged). The runner-construction part (former #59162) has moved to a separate follow-up; this PR covers only the state built in
initialize_kv_cache().Purpose
/sleepreleases the weights and the KV cache, but not the other persistent GPU state built during KV-cache initialization. On DeepSeek-V4-Flash with TP4 on GB300 that state is about 4.6 GiB per GPU:These buffers are allocated in
Worker.initialize_from_config, but only the KV cache inside that call uses a sleep-mode pool.This PR:
model_runner.initialize_kv_cache(...)inside a new cumem tag,runtime. That is where block tables and attention metadata are built. The KV cache keeps its own nestedkv_cachepool.register_kv_cachesis scratch or pointer tables (e.g. the HF3FS staging buffers), which the offload restores at the same address with the same contents. The memory connectors register with a transport (NIXL, Mooncake, MoRIIO, LMCache-MP IPC) is the KV cache, which keeps its ownkv_cache(or connector) pool. The aux-output connector'sRoutedExpertsCapturer.device_bufferis likewise a plain tensor. XPU is the one exception, below.CuMemBackend.suspendoffloadruntimeto host memory at every sleep level. These buffers hold live state, so they are copied rather than discarded. Wake copies them back to the same addresses, so captured CUDA graphs stay valid.weightsandkv_cache, soruntimecomes back automatically, for examplewake_up(["weights"])→reload_weights→wake_up(["kv_cache"]).initialize_kv_cache()outside any pool, and for MLA, hybrid and multi-group models it rebuilds theInputBatchthere. The realinitialize_kv_cache()then saw matching sizes and kept thatInputBatch, so the live block tables stayed in the default pool._cleanup_profiling_kv_cache(), which already undoes the rest of the profiling KV init, now also resets_init_block_sizes, so the real call rebuilds theInputBatchunderruntime, as it already does without CUDA graphs. Model Runner V2 needs no change: itsinitialize_kv_cache()always builds newBlockTables, inside theruntimepool for the real call.runtime(see the XPU note below); CPU (no sleep mode) is unchanged.Cost: sleep and wake copy the
runtimebytes over the host link, about 4.6 GiB in the case above.#59156 (merged) covers the workspace scratch.
Test Plan
test_runtime_state_survives_sleep[sleep-1|sleep-2]:runtimewith a nestedkv_cachepool keeps the tags separate;wake_up(["weights"])restores the runtime contents;wake_up(["kv_cache"])restores the KV cache./sleep;Test Result
Current head
658bdd4b52(on main with #59156 merged), H200, CI imagedeb148e730:test_cumem.py19,test_sleep_mode_backend.py9,test_gpu_worker.py20 passed. Model Runner V1, DeepSeek-V2-Lite with FlashMLA (block size 64, so the profiling pass rebuilds theInputBatch): with the_cleanup_profiling_kv_cache()reset the live block table is inruntime, without it the block table, slot mapping and sampling tensors of theInputBatchare not. Model Runner V2: the block tables are built ininitialize_kv_cache()and are inruntime. opt-125m +ExampleConnector(kv_both), V1 and V2: outputs equal the awake run after level-1 and level-2 (+reload_weights) wakes. An allocation audit of everythinginitialize_kv_cache()allocates underruntime(V1 and V2, FlashMLA, Triton MLA, FA3, FlashInfer, draft-model and ngram speculation, LoRA, a GDN hybrid) found no memory that is registered with a transport or exported, and no allocation or free inruntimeafter startup.Earlier measurements. The re-validation sections from head
b0a91fd8f8on were measured with heads that also built the model runner underruntime, which this PR no longer does: with a speculator, those asleep numbers are lower than this PR's by the MTP hidden-states buffer (512 MiB on DeepSeek-V4-Flash, 192 MiB on GLM-5.3); without one the difference is negligible (DeepSeek-V4.1: 18583 MiB either way).TP4 + EP4, auto MoE. Memory per GPU after
/sleep:runtimetag (offloaded at level 1 and level 2)runtimebytes back.Re-validation of the combined PR (head
b0a91fd8f8), DeepSeek-V4-Flash TP4 + EP4 + MTP, 4× GB300. Gated bit-exact harness (553 requests per round, batch composition fixed by/pausegating; tokens, output logprobs and prompt logprobs compared with the first awake round):weights, thenkv_cache).test_cumem.pyandtest_sleep_mode_backend.py: 24 passed.Re-validation stacked on #59156 (head
0354f3d8a9, #59156 at21c7a0be4c), DeepSeek-V4-Flash TP4 + EP4 + MTP, 4× GB300, same image, another rack. Gated bit-exact harness, 553 requests per round:VLLM_USE_V2_MODEL_RUNNER=0): asleep 7507 MiB with the V1InputBatchfix vs 9187 MiB without it (−1680 MiB: the block tables), same KV cache (12.26 M tokens). Both arms give the same 552/553 in every round, including the awake-only rounds, so the one differing request is V1 run-to-run variation, not sleep.runtime; KV blocks equal unpatched (34471); full and selective wakes exact. Qwen3-8B (geometry matches the placeholder): identical with and without the fix, block tables already inruntime.test_cumem.py15,test_sleep_mode_backend.py11,test_workspace.py11 passed.Large model accuracy (GLM-5.3, TP8 over 2 GB300 nodes,
mpbackend, MTP on). GSM8K, 1319 questions, 5-shot, temperature 0. Each cell is accuracy, and every run had 0 invalid answers. The unpatched numbers come from the same nodes and image (see #59156).reload_weightsreload_weights.Large model accuracy (DeepSeek-V4.1, TP8 over 2 GB300 nodes,
mpbackend). GSM8K, 1319 questions, 5-shot, temperature 0. Each cell is accuracy / invalid rate.reload_weightsafter a level-2 sleep produces garbage for this model with or without this PR: 172 "Failed to load weights" warnings fromlayerwise.pyforDeepseekV4MegaAttnAttention,DeepseekV4ScalingRotaryEmbeddingandNgramHashState. It is a separate, existing layerwise-reload gap, so level-2 accuracy is not compared here.Other model families (with #59156, TP4, 4× GB300). In every phase, every request matches the first awake run exactly, text and all logprobs. The phases are level-1 full wake, level-1 selective wake, and level 2 +
reload_weights. Level 2 + reload being exact confirms the runtime state comes back.Lifecycle edge cases (with #59156, DeepSeek-V4-Flash TP4): all pass, bit-exact after each.
Configuration variants (with #59156, DeepSeek-V4-Flash TP4). Every post-wake round is bit-identical to the awake reference: 553 requests, tokens plus output and prompt logprobs.
runtimetag--enforce-eager--max-num-batched-tokens 2048--max-num-seqs 8reload_weights)runtimepool with a connector): Qwen3-8B TP2 +ExampleConnector, asleep memory equal to unpatched. The current head drops that exception; see the current-head results above. DeepSeek-V4-Flash +ExampleConnectorfails to start on unpatched vLLM too (MLAAttentionSpec ... 'bounded_replay'), so it is not covered.max_num_seqs×max_model_len, as expected for block tables.Review: three rounds of adversarial review (Codex, Kimi K3). The final round found no blocker or major issue.
This PR contains AI-assisted code (Claude Code); commits carry a
Co-authored-bytrailer.AMD MI355X (ROCm)
Works on ROCm unchanged, stacked on #59156 (vLLM ROCm nightly
e975732152). Nested pools behave as on CUDA: the innermost pool wins, so the KV cache and the runtime state land in separate tags. Additional saving over #59156, memory per GPU while asleep (level 1): DeepSeek-V2-Lite TP1 −104, TP2 −110, DP2 −92 MiB; DeepSeek-V4-Flash TP4 −143 MiB. Outputs are bit-identical after full and selective wake.AMD 50-cycle leak test (DeepSeek-V2-Lite TP1 / TP2 / DP2, DeepSeek-V4-Flash TP4; with #59156): device-memory slope 0.00 MiB/cycle, 50/50 cycles bit-exact, no host-RSS growth over level-1 cycles, in-process memory after wake equal to before sleep. GSM8K on V4-Flash TP4 (1319 questions, 5-shot, temperature 0), awake R1 / R2 → full wake / selective wake / +10 cycles: unpatched 0.949 / 0.945 → 0.948 / 0.944 / 0.945; with #59156 0.947 / 0.948 → 0.949 / 0.948 / 0.947.
XPU: the XPU caching allocator allocates from the outermost active pool (
get_pool()walks the active pools FIFO; CUDA uses LIFO since pytorch/pytorch#168382), so the nestedruntime/kv_cachepools would share one pool there. On XPU the KV-init state therefore stays outside theruntimepool, as on main. A hardware check is requested in the comments.🤖 Generated with Claude Code