[Frontend][Core] Add release_kv_cache_memory() API (#44890)
Signed-off-by: Dakai An <dakaian108@gmail.com> Signed-off-by: AlanFokCo <alanfok2868@gmail.com> Signed-off-by: aoshen02 <aoshen@inferact.ai> Signed-off-by: Dakai An <77474977+andakai@users.noreply.github.com> Co-authored-by: AlanFokCo <alanfok2868@gmail.com> Co-authored-by: aoshen02 <aoshen@inferact.ai> Co-authored-by: OpenAI Codex <noreply@openai.com>
Dakai An committed
Sep 18, 2026 at 07:12 UTC
092bdd6d57ac7c1cd5272339c80372053fd51bbe
24 files changed
+572
-42
docs/features/sleep_mode.md
+21
@@ -74,6 +74,18 @@ llm.wake_up(tags=["weights"])
74
llm.wake_up(tags=["kv_cache"])
75
```
76
77
+#### Release only KV cache memory
78
+
79
+`LLM.release_kv_cache_memory()` discards KV cache physical memory while keeping model weights resident. It requires a completed pause and all executor memory to be resident: full sleep, partial wake-up, and repeated release without restoring memory are rejected. Requests retained with `mode="keep"` are recomputed after wake-up.
80
+
81
+Use the default `cumem` backend with managed allocations: `enable_cumem_allocator=True` on CUDA/ROCm (also enabled by `enable_sleep_mode=True`), or `enable_sleep_mode=True` on XPU. Other backends must implement selective discard; CPU and unmanaged allocations are unsupported.
82
+
83
+```python
84
+llm.sleep(level=0, mode="keep") # Wait for the pause to complete.
85
+llm.release_kv_cache_memory()
86
+llm.wake_up(tags=["kv_cache"]) # Reallocate KV cache and resume scheduling.
87
+```
88
+
89
### Online Serving
90
91
To enable sleep mode in a vLLM server you need to initialize it with the flag `VLLM_SERVER_DEV_MODE=1` and pass `--enable-sleep-mode` to the vLLM server.
@@ -107,9 +119,18 @@ curl -X POST 'http://localhost:8000/collective_rpc' -H 'Content-Type: applicatio
119
curl -X POST 'http://localhost:8000/wake_up?tags=kv_cache'
120
```
121
122
+To release only KV cache memory, wait for the level 0 pause to complete before calling the release endpoint. The same resident-memory and backend requirements as the Python API apply.
123
+
124
+```bash
125
+curl -X POST 'http://localhost:8000/sleep?level=0&mode=keep'
126
+curl -X POST 'http://localhost:8000/release_kv_cache_memory'
127
+curl -X POST 'http://localhost:8000/wake_up?tags=kv_cache'
128
+```
129
+
130
#### HTTP endpoints
131
132
- `POST /sleep?level=1` — Put the model to sleep (`level=1`).
133
+- `POST /release_kv_cache_memory` — Discard KV cache memory after a completed pause, while all executor memory is resident.
134
- `POST /wake_up` — Wake up the model. Supports optional `tags` query parameters for partial wake-up (e.g., `?tags=weights`).
135
- `POST /collective_rpc` — Perform a collective remote procedure call (RPC).
136
- `GET /is_sleeping` — Check if the model is sleeping.
rust/src/engine-core-client/src/client.rs
+13
-4
@@ -911,13 +911,22 @@ impl EngineCoreClient {
911
Ok(())
912
}
913
914
- /// Wake the engine from sleep, optionally limiting the wake-up to specific
915
- /// tags.
916
- pub async fn wake_up(&self, tags: Option<Vec<String>>) -> Result<()> {
917
- self.call_utility::<(), _>("wake_up", (tags,)).await?;
914
+ /// Release KV cache memory while keeping model weights resident.
915
+ pub async fn release_kv_cache_memory(&self) -> Result<()> {
916
+ self.call_utility::<(), _>("release_kv_cache_memory", ()).await?;
917
Ok(())
918
}
919
920
+ /// Wake the engine from sleep, optionally limiting the wake-up to specific
921
+ /// tags, and return whether every engine is fully awake.
922
+ pub async fn wake_up(&self, tags: Option<Vec<String>>) -> Result<bool> {
923
+ Ok(self
924
+ .call_utility::<bool, _>("wake_up", (tags,))
925
+ .await?
926
+ .into_iter()
927
+ .all(|fully_awake| fully_awake))
928
+ }
929
+
930
/// Pause the scheduler so generation can be halted
931
pub async fn pause_scheduler(&self, mode: PauseMode, clear_cache: bool) -> Result<()> {
932
self.call_utility::<(), _>("pause_scheduler", (mode, clear_cache)).await?;
rust/src/mock-engine/src/engine.rs
+4
-6
@@ -95,12 +95,10 @@ fn utility_response(
95
"get_supported_tasks" => utility_envelope(vec!["generate"]),
96
"is_sleeping" => utility_envelope(false),
97
"reset_prefix_cache" => utility_envelope(true),
98
- "reset_mm_cache"
99
- | "reset_encoder_cache"
100
- | "profile"
101
- | "sleep"
102
- | "wake_up"
103
- | "execute_dummy_batch" => utility_envelope(()),
98
+ "wake_up" => utility_envelope(true),
99
+ "reset_mm_cache" | "reset_encoder_cache" | "profile" | "sleep" | "execute_dummy_batch" => {
100
+ utility_envelope(())
101
+ }
102
_ => utility_envelope(Value::Nil),
103
}?;
104
rust/src/mock-engine/src/tests.rs
+1
@@ -195,6 +195,7 @@ async fn utility_requests_return_minimal_success_responses() {
195
assert!(client.reset_prefix_cache(false, false).await.expect("reset prefix cache"));
196
client.reset_mm_cache().await.expect("reset mm cache");
197
client.reset_encoder_cache().await.expect("reset encoder cache");
198
+ assert!(client.wake_up(Some(vec!["kv_cache".to_string()])).await.expect("wake up"));
199
200
shutdown_mock(client, shutdown, task).await;
201
}
rust/src/server/src/middleware/metrics.rs
+1
@@ -26,6 +26,7 @@ const EXCLUDED_HANDLERS: &[&str] = &[
26
"/reset_encoder_cache",
27
"/collective_rpc",
28
"/sleep",
29
+ "/release_kv_cache_memory",
30
"/wake_up",
31
"/is_sleeping",
32
];
rust/src/server/src/routes.rs
+4
@@ -142,6 +142,10 @@ fn build_router_with_options(
142
.route("/weight_info", get(weight_transfer::weight_info))
143
.route("/abort_requests", post(abort_requests::abort_requests))
144
.route("/sleep", post(sleep::sleep))
145
+ .route(
146
+ "/release_kv_cache_memory",
147
+ post(sleep::release_kv_cache_memory),
148
+ )
149
.route("/wake_up", post(sleep::wake_up))
150
.route("/is_sleeping", get(sleep::is_sleeping))
151
.route("/pause", post(pause::pause))
rust/src/server/src/routes/sleep.rs
+24
-9
@@ -5,7 +5,7 @@ use std::sync::Arc;
5
6
use axum::Json;
7
use axum::extract::rejection::QueryRejection;
8
-use axum::extract::{Query, State};
8
+use axum::extract::{Query, RawQuery, State};
9
use axum::http::StatusCode;
10
use serde::{Deserialize, Serialize};
11
use vllm_engine_core_client::protocol::utility::PauseMode;
@@ -27,12 +27,6 @@ pub(crate) struct SleepParams {
27
mode: PauseMode,
28
}
29
30
-#[derive(Debug, Default, Deserialize)]
31
-pub(crate) struct WakeUpParams {
32
- #[serde(default)]
33
- tags: Option<Vec<String>>,
34
-}
35
-
30
const fn default_sleep_level() -> u32 {
31
1
32
}
@@ -57,14 +51,35 @@ pub async fn sleep(
51
Ok(StatusCode::OK)
52
}
53
54
+/// Release KV cache memory while keeping model weights resident.
55
+pub async fn release_kv_cache_memory(
56
+ State(state): State<Arc<AppState>>,
57
+) -> Result<StatusCode, ApiError> {
58
+ state
59
+ .engine_core_client()
60
+ .release_kv_cache_memory()
61
+ .await
62
+ .map_err(|error| utility_call_error("release_kv_cache_memory", error))?;
63
+
64
+ Ok(StatusCode::OK)
65
+}
66
+
67
/// Wake the engine from sleep mode.
68
pub async fn wake_up(
69
State(state): State<Arc<AppState>>,
63
- Query(params): Query<WakeUpParams>,
70
+ RawQuery(query): RawQuery,
71
) -> Result<StatusCode, ApiError> {
72
+ let tags = query.and_then(|query| {
73
+ let tags = url::form_urlencoded::parse(query.as_bytes())
74
+ .filter(|(key, _)| key == "tags")
75
+ .map(|(_, value)| value.into_owned())
76
+ .collect::<Vec<_>>();
77
+ (!tags.is_empty()).then_some(tags)
78
+ });
79
+
80
state
81
.engine_core_client()
67
- .wake_up(params.tags)
82
+ .wake_up(tags)
83
.await
84
.map_err(|error| utility_call_error("wake_up", error))?;
85
rust/src/server/src/routes/tests.rs
+87
-2
@@ -5805,7 +5805,46 @@ async fn sleep_route_uses_python_compatible_default_query_values() {
5805
5806
#[tokio::test(flavor = "multi_thread", worker_threads = 2)]
5807
#[serial]
5808
-async fn wake_up_route_without_tags_sends_none() {
5808
+async fn release_kv_cache_memory_route_sends_expected_utility_call() {
5809
+ let (app, engine_task) = test_admin_app_with_engine_script(|dealer, push| {
5810
+ boxed_test_future(async move {
5811
+ let utility = recv_engine_message(dealer).await;
5812
+ assert_eq!(utility[0].as_ref(), &[0x03]);
5813
+
5814
+ let payload = decode_value(&utility[1]).expect("decode utility payload");
5815
+ let array = payload.as_array().expect("utility payload array");
5816
+ let call_id = array[1].as_u64().expect("call id");
5817
+
5818
+ assert_eq!(array[2], Value::from("release_kv_cache_memory"));
5819
+ assert_eq!(array[3], Value::Array(Vec::new()));
5820
+
5821
+ send_outputs(push, utility_outputs(call_id, utility_none_result())).await;
5822
+ })
5823
+ })
5824
+ .await;
5825
+
5826
+ let response = app
5827
+ .clone()
5828
+ .call(
5829
+ Request::builder()
5830
+ .method("POST")
5831
+ .uri("/release_kv_cache_memory")
5832
+ .body(Body::empty())
5833
+ .expect("build request"),
5834
+ )
5835
+ .await
5836
+ .expect("call app");
5837
+
5838
+ let status = response.status();
5839
+ let body = to_bytes(response.into_body(), usize::MAX).await.expect("read body");
5840
+ assert_eq!(status, StatusCode::OK, "{}", String::from_utf8_lossy(&body));
5841
+ assert!(body.is_empty());
5842
+ engine_task.await.expect("mock engine task");
5843
+}
5844
+
5845
+#[tokio::test(flavor = "multi_thread", worker_threads = 2)]
5846
+#[serial]
5847
+async fn wake_up_route_without_tags_accepts_bool_result() {
5848
let (app, engine_task) = test_admin_app_with_engine_script(|dealer, push| {
5849
boxed_test_future(async move {
5850
let utility = recv_engine_message(dealer).await;
@@ -5818,7 +5857,7 @@ async fn wake_up_route_without_tags_sends_none() {
5857
assert_eq!(array[2], Value::from("wake_up"));
5858
assert_eq!(array[3], Value::Array(vec![Value::Nil]));
5859
5821
- send_outputs(push, utility_outputs(call_id, utility_none_result())).await;
5860
+ send_outputs(push, utility_outputs(call_id, utility_result_value(true))).await;
5861
})
5862
})
5863
.await;
@@ -5842,6 +5881,51 @@ async fn wake_up_route_without_tags_sends_none() {
5881
engine_task.await.expect("mock engine task");
5882
}
5883
5884
+#[tokio::test(flavor = "multi_thread", worker_threads = 2)]
5885
+#[serial]
5886
+async fn wake_up_route_accepts_repeated_tags() {
5887
+ let (app, engine_task) = test_admin_app_with_engine_script(|dealer, push| {
5888
+ boxed_test_future(async move {
5889
+ let utility = recv_engine_message(dealer).await;
5890
+ assert_eq!(utility[0].as_ref(), &[0x03]);
5891
+
5892
+ let payload = decode_value(&utility[1]).expect("decode utility payload");
5893
+ let array = payload.as_array().expect("utility payload array");
5894
+ let call_id = array[1].as_u64().expect("call id");
5895
+
5896
+ assert_eq!(array[2], Value::from("wake_up"));
5897
+ assert_eq!(
5898
+ array[3],
5899
+ Value::Array(vec![Value::Array(vec![
5900
+ Value::from("weights"),
5901
+ Value::from("kv_cache"),
5902
+ ])])
5903
+ );
5904
+
5905
+ send_outputs(push, utility_outputs(call_id, utility_result_value(true))).await;
5906
+ })
5907
+ })
5908
+ .await;
5909
+
5910
+ let response = app
5911
+ .clone()
5912
+ .call(
5913
+ Request::builder()
5914
+ .method("POST")
5915
+ .uri("/wake_up?tags=weights&tags%5B%5D=ignored&tags=kv_cache")
5916
+ .body(Body::empty())
5917
+ .expect("build request"),
5918
+ )
5919
+ .await
5920
+ .expect("call app");
5921
+
5922
+ let status = response.status();
5923
+ let body = to_bytes(response.into_body(), usize::MAX).await.expect("read body");
5924
+ assert_eq!(status, StatusCode::OK, "{}", String::from_utf8_lossy(&body));
5925
+ assert!(body.is_empty());
5926
+ engine_task.await.expect("mock engine task");
5927
+}
5928
+
5929
#[tokio::test(flavor = "multi_thread", worker_threads = 2)]
5930
#[serial]
5931
async fn is_sleeping_route_returns_json_payload() {
@@ -6160,6 +6244,7 @@ async fn admin_routes_are_hidden_when_dev_mode_is_disabled() {
6244
for (method, uri) in [
6245
("GET", "/is_sleeping"),
6246
("POST", "/sleep"),
6247
+ ("POST", "/release_kv_cache_memory"),
6248
("POST", "/wake_up"),
6249
("GET", "/is_paused"),
6250
("POST", "/pause"),
tests/basic_correctness/test_mem.py
+85
@@ -109,6 +109,91 @@ def test_basic_cumem():
109
assert torch.allclose(output, torch.ones_like(output) * 3)
110
111
112
+@pytest.mark.parametrize("full_sleep", [0, 1, 2], ids=["kv-only", "sleep-1", "sleep-2"])
113
+@create_new_process_for_each_test("fork" if current_platform.is_cuda() else "spawn")
114
+def test_release_kv_cache_memory_preserves_generation(full_sleep, monkeypatch):
115
+ """Rejected release and KV-only/full sleep cycles preserve greedy tokens."""
116
+ monkeypatch.setenv("VLLM_ALLOW_INSECURE_SERIALIZATION", "1")
117
+ kv_cache_memory_bytes = 256 * 1024 * 1024
118
+ llm = LLM(
119
+ "Qwen/Qwen3-0.6B",
120
+ enable_sleep_mode=True,
121
+ enforce_eager=True,
122
+ kv_cache_memory_bytes=kv_cache_memory_bytes,
123
+ max_model_len=1024,
124
+ max_num_seqs=4,
125
+ gpu_memory_utilization=0.05,
126
+ )
127
+ prompt = "How are you?"
128
+ sampling_params = SamplingParams(temperature=0, max_tokens=10)
129
+ expected = llm.generate(prompt, sampling_params)[0].outputs[0].token_ids
130
+
131
+ def get_mapped_bytes(worker):
132
+ return mapped_usage(get_mem_allocator_instance())
133
+
134
+ mapped_before = llm.collective_rpc(get_mapped_bytes)[0]
135
+ # Utility RPC transports the engine error as a plain Exception.
136
+ with pytest.raises(Exception, match="requires a completed pause"):
137
+ llm.release_kv_cache_memory()
138
+ assert llm.collective_rpc(get_mapped_bytes)[0] == mapped_before
139
+ assert not llm.llm_engine.is_sleeping()
140
+ assert llm.generate(prompt, sampling_params)[0].outputs[0].token_ids == expected
141
+
142
+ llm.sleep(level=0)
143
+ llm.release_kv_cache_memory()
144
+ mapped_after = llm.collective_rpc(get_mapped_bytes)[0]
145
+ assert mapped_before - mapped_after >= kv_cache_memory_bytes * 0.99
146
+ assert mapped_after > 0
147
+ assert llm.llm_engine.is_sleeping()
148
+
149
+ if full_sleep:
150
+ llm.sleep(level=full_sleep)
151
+ assert llm.collective_rpc(get_mapped_bytes)[0] == 0
152
+ llm.wake_up(tags=None if full_sleep else ["kv_cache"])
153
+ if full_sleep == 2:
154
+ llm.collective_rpc("reload_weights")
155
+ assert llm.collective_rpc(get_mapped_bytes)[0] == mapped_before
156
+ assert not llm.llm_engine.is_sleeping()
157
+ actual = llm.generate(prompt, sampling_params)[0].outputs[0].token_ids
158
+ assert actual == expected
159
+
160
+
161
+@pytest.mark.parametrize("first_level", [1, 2])
162
+@pytest.mark.parametrize("second_level", [1, 2])
163
+@create_new_process_for_each_test("fork" if current_platform.is_cuda() else "spawn")
164
+def test_sleep_with_only_weights_asleep(first_level, second_level, monkeypatch):
165
+ """Repeated sleep after KV-only wake preserves mappings and recoverability."""
166
+ monkeypatch.setenv("VLLM_ALLOW_INSECURE_SERIALIZATION", "1")
167
+ llm = LLM(
168
+ "Qwen/Qwen3-0.6B",
169
+ enable_sleep_mode=True,
170
+ enforce_eager=True,
171
+ kv_cache_memory_bytes=256 * 1024 * 1024,
172
+ max_model_len=1024,
173
+ max_num_seqs=4,
174
+ gpu_memory_utilization=0.05,
175
+ )
176
+ prompt = "How are you?"
177
+ sampling_params = SamplingParams(temperature=0, max_tokens=10)
178
+ expected = llm.generate(prompt, sampling_params)[0].outputs[0].token_ids
179
+
180
+ def get_mapped_bytes(worker):
181
+ return mapped_usage(get_mem_allocator_instance())
182
+
183
+ llm.sleep(level=first_level)
184
+ llm.wake_up(tags=["kv_cache"])
185
+ mapped_before = llm.collective_rpc(get_mapped_bytes)[0]
186
+ assert mapped_before > 0
187
+ llm.sleep(level=second_level)
188
+ assert llm.collective_rpc(get_mapped_bytes)[0] == mapped_before
189
+ llm.wake_up(tags=["weights"])
190
+ if first_level == 2:
191
+ llm.collective_rpc("reload_weights")
192
+ assert not llm.llm_engine.is_sleeping()
193
+ actual = llm.generate(prompt, sampling_params)[0].outputs[0].token_ids
194
+ assert actual == expected
195
+
196
+
197
@create_new_process_for_each_test("fork" if current_platform.is_cuda() else "spawn")
198
def test_discard_tags():
199
"""Test that discard(tags) selectively frees GPU memory for specific
tests/entrypoints/serve/dev/test_sleep.py
+24
-1
@@ -1,14 +1,37 @@
1
# SPDX-License-Identifier: Apache-2.0
2
# SPDX-FileCopyrightText: Copyright contributors to the vLLM project
3
4
+from unittest.mock import AsyncMock
5
+
6
+import pytest
7
import requests
8
+from fastapi import FastAPI
9
+from fastapi.testclient import TestClient
10
from prometheus_client.parser import text_string_to_metric_families
11
12
from tests.utils import RemoteOpenAIServer
13
+from vllm.entrypoints.serve.dev.sleep.api_router import attach_router
14
15
MODEL_NAME = "meta-llama/Llama-3.2-1B"
16
17
18
+@pytest.mark.cpu_test
19
+@pytest.mark.parametrize("fails", [False, True])
20
+def test_release_kv_cache_memory_route(fails):
21
+ app = FastAPI()
22
+ app.state.engine_client = AsyncMock()
23
+ release = app.state.engine_client.release_kv_cache_memory
24
+ if fails:
25
+ release.side_effect = RuntimeError("requires a completed pause first")
26
+ attach_router(app)
27
+
28
+ with TestClient(app, raise_server_exceptions=False) as client:
29
+ response = client.post("/release_kv_cache_memory")
30
+
31
+ assert response.status_code == (500 if fails else 200)
32
+ release.assert_awaited_once_with()
33
+
34
+
35
def test_sleep_mode():
36
# dtype, max-len etc set so that this can run in CI
37
args = [
@@ -63,7 +86,7 @@ def test_sleep_mode():
86
)
87
assert response.status_code == 200
88
66
- # is sleeping should be false after waking up any part of the engine
89
+ # Partial wake keeps the engine sleeping.
90
response = requests.get(remote_server.url_for("is_sleeping"))
91
assert response.status_code == 200
92
assert response.json().get("is_sleeping") is True
tests/multimodal/test_cache.py
+75
@@ -1,7 +1,10 @@
1
# SPDX-License-Identifier: Apache-2.0
2
# SPDX-FileCopyrightText: Copyright contributors to the vLLM project
3
import multiprocessing as mp
4
+from concurrent.futures import ThreadPoolExecutor
5
+from functools import partial
6
from types import SimpleNamespace
7
+from unittest.mock import Mock
8
9
import numpy as np
10
import pytest
@@ -34,6 +37,8 @@ from vllm.multimodal.inputs import (
37
PlaceholderRange,
38
)
39
from vllm.multimodal.processing import PromptInsertion
40
+from vllm.renderers.base import BaseRenderer
41
+from vllm.utils.async_utils import make_async
42
from vllm.utils.mem_constants import GiB_bytes, MiB_bytes
43
44
from ..utils import create_new_process_for_each_test
@@ -755,6 +760,76 @@ def test_processor_cache_shared_across_loras():
760
assert feature_lora_b.data == item_data
761
762
763
+@pytest.mark.parametrize("use_async", [False, True])
764
+@pytest.mark.parametrize(
765
+ "release_error",
766
+ [
767
+ None,
768
+ "requires a completed pause first",
769
+ "requires all executor memory to be resident",
770
+ ],
771
+ ids=["released", "not-paused", "nonresident-memory"],
772
+)
773
+@pytest.mark.asyncio
774
+async def test_release_kv_cache_resends_mm_payload(use_async, release_error):
775
+ """Release must not leave a sender hit pointing at a cleared receiver."""
776
+ from vllm.v1.engine.async_llm import AsyncLLM
777
+ from vllm.v1.engine.llm_engine import LLMEngine
778
+
779
+ model_config = SimpleNamespace(
780
+ get_multimodal_config=lambda: MultiModalConfig(mm_processor_cache_gb=1)
781
+ )
782
+ sender = MultiModalProcessorSenderCache(model_config)
783
+ receiver = MultiModalReceiverCache(model_config)
784
+ item = _dummy_item({"pixel_values": 16})
785
+ mm_hash = "image_A"
786
+ payload, _ = sender.get_and_update_item((item, []), mm_hash)
787
+ assert receiver.get_and_update_item(payload, mm_hash) is item
788
+ assert sender.get_and_update_item(None, mm_hash)[0] is None
789
+
790
+ renderer = SimpleNamespace(mm_processor_cache=sender, _mm_cache_stats=None)
791
+ renderer.clear_mm_cache = partial(BaseRenderer.clear_mm_cache, renderer)
792
+
793
+ def release():
794
+ if release_error:
795
+ raise RuntimeError(release_error)
796
+ receiver.clear_cache()
797
+
798
+ with ThreadPoolExecutor(max_workers=1) as executor:
799
+ renderer._clear_mm_cache_async = make_async(
800
+ renderer.clear_mm_cache, executor=executor
801
+ )
802
+ renderer.clear_mm_cache_async = partial(
803
+ BaseRenderer.clear_mm_cache_async, renderer
804
+ )
805
+ engine = SimpleNamespace(
806
+ renderer=renderer,
807
+ engine_core=SimpleNamespace(
808
+ release_kv_cache_memory=release,
809
+ release_kv_cache_memory_async=make_async(release, executor=executor),
810
+ ),
811
+ logger_manager=Mock(),
812
+ )
813
+
814
+ async def call_release():
815
+ if use_async:
816
+ await AsyncLLM.release_kv_cache_memory(engine)
817
+ else:
818
+ LLMEngine.release_kv_cache_memory(engine)
819
+
820
+ if release_error:
821
+ with pytest.raises(RuntimeError, match=release_error):
822
+ await call_release()
823
+ engine.logger_manager.record_sleep_state.assert_not_called()
824
+ else:
825
+ await call_release()
826
+ engine.logger_manager.record_sleep_state.assert_called_once_with(1, 0)
827
+
828
+ payload, _ = sender.get_and_update_item((item, []), mm_hash)
829
+ assert payload is item
830
+ assert receiver.get_and_update_item(payload, mm_hash) is item
831
+
832
+
833
_SLEEP_VISION_PROMPT = (
834
"<|im_start|>system\nYou are a helpful assistant.<|im_end|>"
835
"\n<|im_start|>user\n<|vision_start|><|image_pad|><|vision_end|>"
tests/v1/core/test_scheduler.py
+76
@@ -36,10 +36,13 @@ from vllm.utils.hashing import sha256
36
from vllm.v1.core.encoder_cache_manager import EncoderCacheManager
37
from vllm.v1.core.kv_cache_coordinator import HybridKVCacheCoordinator
38
from vllm.v1.core.kv_cache_utils import get_request_block_hasher, init_none_hash
39
+from vllm.v1.core.sched.interface import PauseState
40
from vllm.v1.core.sched.output import CachedRequestData, SchedulerOutput
41
from vllm.v1.core.sched.scheduler import Scheduler
42
from vllm.v1.core.single_type_kv_cache_manager import register_all_kvcache_specs
43
from vllm.v1.engine import FinishReason
44
+from vllm.v1.engine.core import EngineCore
45
+from vllm.v1.executor.uniproc_executor import UniProcExecutor
46
from vllm.v1.hisparse.coordinator import get_hisparse_coordinator
47
from vllm.v1.kv_cache_interface import (
48
FullAttentionSpec,
@@ -1487,6 +1490,79 @@ def test_scheduler_reset_prefix_cache():
1490
assert scheduler.waiting[i] == request
1491
1492
1493
+def test_kv_cache_release_after_keep_pause_preserves_requests():
1494
+ """A completed keep pause permits release and recomputation of live requests."""
1495
+ scheduler = create_scheduler(max_num_seqs=1, enable_prefix_caching=True)
1496
+ running, waiting = create_requests(num_requests=2)
1497
+ for request in (running, waiting):
1498
+ scheduler.add_request(request)
1499
+ scheduler_output = scheduler.schedule()
1500
+ scheduler.update_from_output(
1501
+ scheduler_output,
1502
+ ModelRunnerOutput(
1503
+ req_ids=[running.request_id],
1504
+ req_id_to_index={running.request_id: 0},
1505
+ sampled_token_ids=[[1]],
1506
+ ),
1507
+ )
1508
+ assert scheduler.kv_cache_manager.get_block_ids(running.request_id)[0]
1509
+
1510
+ core = object.__new__(EngineCore)
1511
+ core.scheduler = scheduler
1512
+ core.model_executor = Mock(is_sleeping=False)
1513
+ core.mm_receiver_cache = None
1514
+ core.batch_queue = None
1515
+
1516
+ assert core.pause_scheduler(mode="keep", clear_cache=False) is None
1517
+ assert scheduler.running == [running]
1518
+ assert list(scheduler.waiting) == [waiting]
1519
+ assert not scheduler.has_requests()
1520
+
1521
+ core.release_kv_cache_memory()
1522
+
1523
+ core.model_executor.discard.assert_called_once_with(("kv_cache",))
1524
+ assert scheduler.requests == {
1525
+ request.request_id: request for request in (running, waiting)
1526
+ }
1527
+ assert not scheduler.running
1528
+ assert list(scheduler.waiting) == [running, waiting]
1529
+ assert running.status == RequestStatus.PREEMPTED
1530
+ assert running.num_computed_tokens == 0
1531
+ assert list(running.output_token_ids) == [1]
1532
+ assert not scheduler.kv_cache_manager.get_block_ids(running.request_id)[0]
1533
+ assert scheduler.schedule().total_num_scheduled_tokens == 0
1534
+
1535
+ assert core.wake_up(tags=["kv_cache"])
1536
+ resumed = scheduler.schedule()
1537
+ assert resumed.num_scheduled_tokens == {running.request_id: running.num_tokens}
1538
+
1539
+
1540
+@pytest.mark.parametrize(
1541
+ "sleeping_tags",
1542
+ [{"weights", "kv_cache"}, {"weights"}, {"kv_cache"}],
1543
+ ids=["full-sleep", "weights-only", "kv-only-or-repeated-release"],
1544
+)
1545
+def test_kv_cache_release_rejects_nonresident_memory(sleeping_tags):
1546
+ """Reject release without resetting caches or discarding more memory."""
1547
+ core = object.__new__(EngineCore)
1548
+ core.scheduler = create_scheduler()
1549
+ core.scheduler.set_pause_state(PauseState.PAUSED_ALL)
1550
+ core.batch_queue = None
1551
+ core._reset_caches = Mock()
1552
+ core.model_executor = object.__new__(UniProcExecutor)
1553
+ core.model_executor.sleeping_tags = sleeping_tags.copy()
1554
+ core.model_executor.collective_rpc = Mock()
1555
+
1556
+ with pytest.raises(
1557
+ RuntimeError, match="requires all executor memory to be resident"
1558
+ ):
1559
+ core.release_kv_cache_memory()
1560
+
1561
+ core._reset_caches.assert_not_called()
1562
+ core.model_executor.collective_rpc.assert_not_called()
1563
+ assert core.model_executor.sleeping_tags == sleeping_tags
1564
+
1565
+
1566
def test_reset_connector_cache_no_connector_is_no_op_success():
1567
"""``reset_connector_cache`` must return True when no connector is
1568
configured.
tests/v1/engine/test_engine_core.py
+24
@@ -23,6 +23,7 @@ from vllm.config import (
23
from vllm.engine.arg_utils import EngineArgs
24
from vllm.platforms import current_platform
25
from vllm.utils.torch_utils import set_default_torch_num_threads
26
+from vllm.v1.core.sched.interface import PauseState
27
from vllm.v1.engine import EngineCoreRequest
28
from vllm.v1.engine.core import DPEngineCoreProc, EngineCore, EngineCoreProc
29
from vllm.v1.executor.abstract import Executor
@@ -667,6 +668,29 @@ def _pausable_engine_core_proc() -> EngineCoreProc:
668
return core
669
670
671
+@pytest.mark.parametrize(
672
+ "pause_state,has_requests,has_batches",
673
+ [
674
+ pytest.param(PauseState.UNPAUSED, False, False, id="not-paused"),
675
+ pytest.param(PauseState.PAUSED_ALL, True, False, id="pending-requests"),
676
+ pytest.param(PauseState.PAUSED_ALL, False, True, id="pending-batches"),
677
+ ],
678
+)
679
+def test_kv_cache_release_rejects_unsafe_state(pause_state, has_requests, has_batches):
680
+ """Reject release before touching caches or memory if work can still use KV."""
681
+ core = _pausable_engine_core_proc()
682
+ core.scheduler.pause_state = pause_state
683
+ core.scheduler.has_requests.return_value = has_requests
684
+ core.batch_queue = [object()] if has_batches else None
685
+ core._reset_caches = MagicMock()
686
+
687
+ with pytest.raises(RuntimeError, match="requires a completed pause"):
688
+ core.release_kv_cache_memory()
689
+
690
+ core._reset_caches.assert_not_called()
691
+ core.model_executor.discard.assert_not_called()
692
+
693
+
694
@pytest.mark.parametrize("deferred", [False, True])
695
def test_pause_synchronizes_device_before_cache_reset(deferred: bool):
696
"""A resolved pause promises an idle device: the barrier must run before
vllm/device_allocator/sleep_mode_backend.py
+14
@@ -73,6 +73,13 @@ class SleepModeBackend(ABC):
73
(suspended) engine from a healthy-serving one (see RFC #34303)."""
74
return self._state
75
76
+ def discard(self, tags: tuple[str, ...]) -> None:
77
+ """Discard selected tagged allocations without preserving contents."""
78
+ raise RuntimeError(
79
+ f"Sleep-mode backend {type(self).__name__!r} does not support "
80
+ "selective discard."
81
+ )
82
+
83
# -- Capability introspection (no instance required) --
84
85
@classmethod
@@ -132,6 +139,13 @@ class CuMemBackend(SleepModeBackend):
139
allocator.wake_up(tags)
140
self._state = "RUNNING"
141
142
+ def discard(self, tags: tuple[str, ...]) -> None:
143
+ from vllm.device_allocator import get_mem_allocator_instance
144
+
145
+ allocator = get_mem_allocator_instance()
146
+ allocator.discard(tags)
147
+ self._state = "SUSPENDED"
148
+
149
@classmethod
150
def preserves_communicators(cls) -> bool:
151
# Comm identity/topology survive memory suspension; no reinit needed.
vllm/engine/protocol.py
+5
@@ -186,6 +186,11 @@ class EngineClient(ABC):
186
"""Sleep the engine."""
187
...
188
189
+ @abstractmethod
190
+ async def release_kv_cache_memory(self) -> None:
191
+ """Discard KV cache physical GPU memory. Requires a completed pause."""
192
+ ...
193
+
194
@abstractmethod
195
async def wake_up(self, tags: list[str] | None = None) -> None:
196
"""Wake up the engine."""
vllm/entrypoints/llm.py
+8
@@ -830,6 +830,14 @@ class LLM(BeamSearchOfflineMixin, PoolingOfflineMixin, OfflineInferenceMixin):
830
"""
831
self.llm_engine.sleep(level=level, mode=mode)
832
833
+ def release_kv_cache_memory(self) -> None:
834
+ """Release the GPU physical memory backing the KV cache.
835
+
836
+ Requires a completed ``sleep(level=0)`` and resident executor memory.
837
+ Restore with ``wake_up(tags=["kv_cache"])``; kept requests are recomputed.
838
+ """
839
+ self.llm_engine.release_kv_cache_memory()
840
+
841
def wake_up(self, tags: list[str] | None = None):
842
"""Wake up the engine from sleep mode. See the [sleep][vllm.LLM.sleep]
843
method for more details.
vllm/entrypoints/serve/dev/sleep/api_router.py
+6
@@ -27,6 +27,12 @@ async def sleep(raw_request: Request):
27
return Response(status_code=200)
28
29
30
+@router.post("/release_kv_cache_memory")
31
+async def release_kv_cache_memory(raw_request: Request):
32
+ await engine_client(raw_request).release_kv_cache_memory()
33
+ return Response(status_code=200)
34
+
35
+
36
@router.post("/wake_up")
37
async def wake_up(raw_request: Request):
38
tags = raw_request.query_params.getlist("tags")
vllm/v1/engine/async_llm.py
+9
-2
@@ -1079,10 +1079,17 @@ class AsyncLLM(EngineClient):
1079
if self.logger_manager is not None:
1080
self.logger_manager.record_sleep_state(1, level)
1081
1082
- async def wake_up(self, tags: list[str] | None = None) -> None:
1083
- await self.engine_core.wake_up_async(tags)
1082
+ async def release_kv_cache_memory(self) -> None:
1083
+ await self.renderer.clear_mm_cache_async()
1084
+ await self.engine_core.release_kv_cache_memory_async()
1085
1086
if self.logger_manager is not None:
1087
+ self.logger_manager.record_sleep_state(1, 0)
1088
+
1089
+ async def wake_up(self, tags: list[str] | None = None) -> None:
1090
+ fully_awake = await self.engine_core.wake_up_async(tags)
1091
+
1092
+ if self.logger_manager is not None and fully_awake:
1093
self.logger_manager.record_sleep_state(0, 0)
1094
1095
async def checkpoint_prepare(self) -> None:
vllm/v1/engine/core.py
+27
-2
@@ -957,12 +957,15 @@ class EngineCore:
957
pause_future.add_done_callback(pause_complete)
958
return future
959
960
- def wake_up(self, tags: list[str] | None = None):
960
+ def wake_up(self, tags: list[str] | None = None) -> bool:
961
"""Wake up the engine from sleep.
962
963
Args:
964
tags: Tags to wake up. Use ["scheduling"] for level 0 wake up.
965
966
+ Returns:
967
+ Whether all executor memory is resident again (fully awake).
968
+
969
"""
970
if tags is not None and "scheduling" in tags:
971
# Remove "scheduling" from tags if there are other tags to process.
@@ -973,8 +976,30 @@ class EngineCore:
976
977
# Partial wakes intentionally keep the remaining allocations asleep.
978
# Resume scheduling only once all executor memory is resident again.
976
- if not self.model_executor.is_sleeping:
979
+ fully_awake = not self.model_executor.is_sleeping
980
+ if fully_awake:
981
self.resume_scheduler()
982
+ return fully_awake
983
+
984
+ def release_kv_cache_memory(self) -> None:
985
+ """Discard KV cache physical memory. Requires a completed pause
986
+ and all executor memory to be resident. Kept requests are recomputed
987
+ after wake-up.
988
+ """
989
+ if not (
990
+ self.is_scheduler_paused()
991
+ and not self.scheduler.has_requests()
992
+ and not self.batch_queue
993
+ ):
994
+ raise RuntimeError(
995
+ "release_kv_cache_memory() requires a completed pause first"
996
+ )
997
+ if self.model_executor.is_sleeping:
998
+ raise RuntimeError(
999
+ "release_kv_cache_memory() requires all executor memory to be resident"
1000
+ )
1001
+ self._reset_caches()
1002
+ self.model_executor.discard(("kv_cache",))
1003
1004
def is_sleeping(self) -> bool:
1005
"""Check if engine is sleeping at any level."""
vllm/v1/engine/core_client.py
+23
-8
@@ -193,7 +193,10 @@ class EngineCoreClient(ABC):
193
def sleep(self, level: int = 1, mode: PauseMode = "abort") -> None:
194
raise NotImplementedError
195
196
- def wake_up(self, tags: list[str] | None = None) -> None:
196
+ def release_kv_cache_memory(self) -> None:
197
+ raise NotImplementedError
198
+
199
+ def wake_up(self, tags: list[str] | None = None) -> bool:
200
raise NotImplementedError
201
202
def is_sleeping(self) -> bool:
@@ -285,7 +288,10 @@ class EngineCoreClient(ABC):
288
async def sleep_async(self, level: int = 1, mode: PauseMode = "abort") -> None:
289
raise NotImplementedError
290
288
- async def wake_up_async(self, tags: list[str] | None = None) -> None:
291
+ async def release_kv_cache_memory_async(self) -> None:
292
+ raise NotImplementedError
293
+
294
+ async def wake_up_async(self, tags: list[str] | None = None) -> bool:
295
raise NotImplementedError
296
297
async def is_sleeping_async(self) -> bool:
@@ -397,8 +403,11 @@ class InprocClient(EngineCoreClient):
403
result = self.engine_core.sleep(level, mode)
404
assert result is None
405
400
- def wake_up(self, tags: list[str] | None = None) -> None:
401
- self.engine_core.wake_up(tags)
406
+ def release_kv_cache_memory(self) -> None:
407
+ self.engine_core.release_kv_cache_memory()
408
+
409
+ def wake_up(self, tags: list[str] | None = None) -> bool:
410
+ return self.engine_core.wake_up(tags)
411
412
def is_sleeping(self) -> bool:
413
return self.engine_core.is_sleeping()
@@ -1021,8 +1030,11 @@ class SyncMPClient(MPClient):
1030
def sleep(self, level: int = 1, mode: PauseMode = "abort") -> None:
1031
self.call_utility("sleep", level, mode)
1032
1024
- def wake_up(self, tags: list[str] | None = None) -> None:
1025
- self.call_utility("wake_up", tags)
1033
+ def release_kv_cache_memory(self) -> None:
1034
+ self.call_utility("release_kv_cache_memory")
1035
+
1036
+ def wake_up(self, tags: list[str] | None = None) -> bool:
1037
+ return self.call_utility("wake_up", tags)
1038
1039
def is_sleeping(self) -> bool:
1040
return self.call_utility("is_sleeping")
@@ -1265,8 +1277,11 @@ class AsyncMPClient(MPClient):
1277
async def sleep_async(self, level: int = 1, mode: PauseMode = "abort") -> None:
1278
await self.call_utility_async("sleep", level, mode)
1279
1268
- async def wake_up_async(self, tags: list[str] | None = None) -> None:
1269
- await self.call_utility_async("wake_up", tags)
1280
+ async def release_kv_cache_memory_async(self) -> None:
1281
+ await self.call_utility_async("release_kv_cache_memory")
1282
+
1283
+ async def wake_up_async(self, tags: list[str] | None = None) -> bool:
1284
+ return await self.call_utility_async("wake_up", tags)
1285
1286
async def is_sleeping_async(self) -> bool:
1287
return await self.call_utility_async("is_sleeping")
vllm/v1/engine/llm_engine.py
+9
-2
@@ -375,10 +375,17 @@ class LLMEngine:
375
if self.logger_manager is not None:
376
self.logger_manager.record_sleep_state(1, level)
377
378
- def wake_up(self, tags: list[str] | None = None):
379
- self.engine_core.wake_up(tags)
378
+ def release_kv_cache_memory(self) -> None:
379
+ self.renderer.clear_mm_cache()
380
+ self.engine_core.release_kv_cache_memory()
381
382
if self.logger_manager is not None:
383
+ self.logger_manager.record_sleep_state(1, 0)
384
+
385
+ def wake_up(self, tags: list[str] | None = None):
386
+ fully_awake = self.engine_core.wake_up(tags)
387
+
388
+ if self.logger_manager is not None and fully_awake:
389
self.logger_manager.record_sleep_state(0, 0)
390
391
def is_sleeping(self) -> bool:
vllm/v1/executor/abstract.py
+26
-6
@@ -32,6 +32,9 @@ logger = init_logger(__name__)
32
33
_R = TypeVar("_R")
34
35
+# Memory-pool tags managed by sleep/wake_up/discard.
36
+SLEEP_TAGS = frozenset(("weights", "kv_cache"))
37
+
38
FailureCallback = Callable[[], None]
39
40
@@ -108,7 +111,6 @@ class Executor(ABC):
111
self.speculative_config = vllm_config.speculative_config
112
self.observability_config = vllm_config.observability_config
113
self._init_executor()
111
- self.is_sleeping = False
114
self.sleeping_tags: set[str] = set()
115
self.kv_output_aggregator: KVOutputAggregator | None = None
116
self.ec_output_aggregator: ECOutputAggregator | None = None
@@ -338,15 +340,18 @@ class Executor(ABC):
340
"""Reset the encoder cache in each worker to clear cached encoder outputs."""
341
self.collective_rpc("reset_encoder_cache")
342
343
+ @property
344
+ def is_sleeping(self) -> bool:
345
+ return bool(self.sleeping_tags)
346
+
347
def sleep(self, level: int = 1):
342
- if self.is_sleeping:
348
+ if "weights" in self.sleeping_tags:
349
logger.warning("Executor is already sleeping.")
350
return
351
time_before_sleep = time.perf_counter()
352
self.collective_rpc("sleep", kwargs=dict(level=level))
353
time_after_sleep = time.perf_counter()
348
- self.sleeping_tags = {"weights", "kv_cache"}
349
- self.is_sleeping = True
354
+ self.sleeping_tags |= SLEEP_TAGS
355
logger.info(
356
"It took %.6f seconds to fall asleep.", time_after_sleep - time_before_sleep
357
)
@@ -375,8 +380,23 @@ class Executor(ABC):
380
self.sleeping_tags.remove(tag)
381
else:
382
self.sleeping_tags.clear()
378
- if not self.sleeping_tags:
379
- self.is_sleeping = False
383
+
384
+ def discard(self, tags: tuple[str, ...]) -> None:
385
+ tags_to_discard = set(tags) - self.sleeping_tags
386
+ if not tags_to_discard:
387
+ logger.warning("Tags %s are already sleeping.", tags)
388
+ return
389
+ time_before_discard = time.perf_counter()
390
+ try:
391
+ self.collective_rpc("discard", args=(tuple(tags_to_discard),))
392
+ finally:
393
+ self.sleeping_tags |= tags_to_discard
394
+ time_after_discard = time.perf_counter()
395
+ logger.info(
396
+ "It took %.6f seconds to discard tags %s.",
397
+ time_after_discard - time_before_discard,
398
+ tags_to_discard,
399
+ )
400
401
def reinitialize_distributed(
402
self, reconfig_request: ReconfigureDistributedRequest
vllm/v1/worker/cpu_worker.py
+3
@@ -198,6 +198,9 @@ class CPUWorker(Worker):
198
logger.warning("sleep mode is not supported on CPU, ignore it.")
199
pass
200
201
+ def discard(self, tags: tuple[str, ...]) -> None:
202
+ raise RuntimeError("Selective discard is not supported on CPU.")
203
+
204
def _should_warm_up_model(self) -> bool:
205
# VLLM_CPU_CI_ENV always skips warmup to save CI time.
206
if envs.VLLM_CPU_CI_ENV:
vllm/v1/worker/gpu_worker.py
+3
@@ -304,6 +304,9 @@ class Worker(WorkerBase):
304
305
self.synchronize_device()
306
307
+ def discard(self, tags: tuple[str, ...]) -> None:
308
+ self.sleep_mode_backend.discard(tags)
309
+
310
def checkpoint_prepare(self) -> None:
311
checkpoint_prepare_distributed_state()
312