[CI] GSM8K eval integration test for KV offloading (#46893)
Signed-off-by: Tyler Michael Smith <tyler@tylermsmith.com> Signed-off-by: Tyler Michael Smith <tlrmchlsmth@gmail.com> Signed-off-by: Itay Etelis <itay.etelis@ibm.com> Signed-off-by: Itay Etelis <92247226+Etelis@users.noreply.github.com> Signed-off-by: Or Ozeri <oro@il.ibm.com> Co-authored-by: Claude Opus 4.6 (1M context) <noreply@anthropic.com> Co-authored-by: Itay Etelis <92247226+Etelis@users.noreply.github.com> Co-authored-by: Itay Etelis <itay.etelis@ibm.com> Co-authored-by: Or Ozeri <oro@il.ibm.com> Co-authored-by: mergify[bot] <37929162+mergify[bot]@users.noreply.github.com>
Tyler Michael Smith committed
Jul 8, 2026 at 17:59 UTC
dcdd756d75a9a2cdc9ff257c7d44c67b9b9fee68
5 files changed
+330
-10
.buildkite/test_areas/lm_eval.yaml
+41
@@ -298,6 +298,47 @@ steps:
298
- uv pip install --system 'gpt-oss[eval]==0.0.5'
299
- pytest -s -v evals/gpt_oss/test_gpqa_correctness.py --config-list-file=configs/models-spark.txt
300
301
+- label: LM Eval KV-Offload (1xH200)
302
+ key: kv-offload-small
303
+ timeout_in_minutes: 30
304
+ device: h200_35gb
305
+ source_file_dependencies:
306
+ - vllm/distributed/kv_transfer/kv_connector/v1/offloading/
307
+ - vllm/distributed/kv_transfer/kv_connector/v1/simple_cpu_offload_connector.py
308
+ - vllm/v1/kv_offload/
309
+ - vllm/v1/simple_kv_offload/
310
+ - tests/evals/gsm8k/test_gsm8k_offloading.py
311
+ commands:
312
+ - pytest -s -v evals/gsm8k/test_gsm8k_offloading.py -k "nemotron-h-8b or gemma-4-e4b-it"
313
+
314
+- label: LM Eval KV-Offload (2xH100)
315
+ key: kv-offload-medium
316
+ timeout_in_minutes: 60
317
+ device: h100
318
+ num_devices: 2
319
+ source_file_dependencies:
320
+ - vllm/distributed/kv_transfer/kv_connector/v1/offloading/
321
+ - vllm/distributed/kv_transfer/kv_connector/v1/simple_cpu_offload_connector.py
322
+ - vllm/v1/kv_offload/
323
+ - vllm/v1/simple_kv_offload/
324
+ - tests/evals/gsm8k/test_gsm8k_offloading.py
325
+ commands:
326
+ - pytest -s -v evals/gsm8k/test_gsm8k_offloading.py -k "qwen3.5-35b"
327
+
328
+- label: LM Eval KV-Offload (4xH100)
329
+ key: kv-offload-large
330
+ timeout_in_minutes: 60
331
+ device: h100
332
+ num_devices: 4
333
+ source_file_dependencies:
334
+ - vllm/distributed/kv_transfer/kv_connector/v1/offloading/
335
+ - vllm/distributed/kv_transfer/kv_connector/v1/simple_cpu_offload_connector.py
336
+ - vllm/v1/kv_offload/
337
+ - vllm/v1/simple_kv_offload/
338
+ - tests/evals/gsm8k/test_gsm8k_offloading.py
339
+ commands:
340
+ - pytest -s -v evals/gsm8k/test_gsm8k_offloading.py -k "deepseek-v4-flash"
341
+
342
- label: MRCR Eval Small Models
343
device: h200_35gb
344
timeout_in_minutes: 30
rust/src/server/src/routes/cache.rs
+10
-4
@@ -1,8 +1,9 @@
1
use std::sync::Arc;
2
3
+use axum::Json;
4
use axum::extract::{Query, State};
5
use axum::http::StatusCode;
5
-use serde::Deserialize;
6
+use serde::{Deserialize, Serialize};
7
8
use crate::error::ApiError;
9
use crate::state::AppState;
@@ -16,19 +17,24 @@ pub(crate) struct ResetPrefixCacheParams {
17
reset_external: bool,
18
}
19
20
+#[derive(Debug, Serialize)]
21
+pub(crate) struct ResetPrefixCacheResponse {
22
+ success: bool,
23
+}
24
+
25
/// Reset the local prefix cache and optionally the connector-managed external
26
/// cache.
27
pub async fn reset_prefix_cache(
28
State(state): State<Arc<AppState>>,
29
Query(params): Query<ResetPrefixCacheParams>,
24
-) -> Result<StatusCode, ApiError> {
25
- state
30
+) -> Result<Json<ResetPrefixCacheResponse>, ApiError> {
31
+ let success = state
32
.engine_core_client()
33
.reset_prefix_cache(params.reset_running_requests, params.reset_external)
34
.await
35
.map_err(|error| utility_call_error("reset_prefix_cache", error))?;
36
31
- Ok(StatusCode::OK)
37
+ Ok(Json(ResetPrefixCacheResponse { success }))
38
}
39
40
/// Reset the multi-modal cache.
rust/src/server/src/routes/tests.rs
+4
-1
@@ -4796,7 +4796,10 @@ async fn reset_prefix_cache_route_sends_expected_utility_call() {
4796
let status = response.status();
4797
let body = to_bytes(response.into_body(), usize::MAX).await.expect("read body");
4798
assert_eq!(status, StatusCode::OK, "{}", String::from_utf8_lossy(&body));
4799
- assert!(body.is_empty());
4799
+ assert_eq!(
4800
+ serde_json::from_slice::<serde_json::Value>(&body).expect("json body"),
4801
+ json!({"success": true})
4802
+ );
4803
engine_task.await.expect("mock engine task");
4804
}
4805
tests/evals/gsm8k/test_gsm8k_offloading.py
new
+269
@@ -0,0 +1,269 @@
1
+# SPDX-License-Identifier: Apache-2.0
2
+# SPDX-FileCopyrightText: Copyright contributors to the vLLM project
3
+"""
4
+GSM8K correctness test for CPU KV offloading connectors.
5
+
6
+Regression guard for stride computation bugs in the offloading worker
7
+(e.g. https://github.com/vllm-project/vllm/pull/46888) and silent KV
8
+cache data corruption during CPU offloading. Runs GSM8K twice, dropping
9
+the GPU prefix cache (but not the CPU cache) between runs so the second
10
+run reloads offloaded KV data from CPU. The reset only succeeds once
11
+in-flight offload transfers have released their GPU blocks, so retrying
12
+it until success also waits for offloading to complete.
13
+
14
+Correctness is enforced via GSM8K accuracy on both runs: corrupt reloaded
15
+KV drops accuracy below threshold. The reload itself is not asserted, so
16
+a silently skipped reload (e.g. offloading disabled) would not be flagged.
17
+
18
+Covers both KV offloading connectors (OffloadingConnector and
19
+SimpleCPUOffloadConnector) across four architecture families:
20
+ - Hybrid Mamba (NemotronH: attention + Mamba)
21
+ - Heterogeneous head dim (Gemma 4)
22
+ - Hybrid GDN (Qwen 3.5: attention + GatedDeltaNet)
23
+ - Compressed attention (DeepSeek-V4-Flash: CSA)
24
+
25
+Usage:
26
+ pytest -s -v evals/gsm8k/test_gsm8k_offloading.py
27
+"""
28
+
29
+import json
30
+import time
31
+from dataclasses import dataclass, field
32
+
33
+import pytest
34
+import requests
35
+
36
+from tests.utils import RemoteOpenAIServer
37
+from vllm.platforms import current_platform
38
+
39
+from .gsm8k_eval import evaluate_gsm8k
40
+
41
+if not current_platform.is_cuda_alike():
42
+ pytest.skip("Requires CUDA or ROCm", allow_module_level=True)
43
+
44
+NUM_QUESTIONS = 200
45
+NUM_FEWSHOT = 5
46
+
47
+_OFFLOAD_SYNC_TIMEOUT = 60
48
+
49
+
50
+def _kv_transfer_config(connector: str, cpu_gib: int = 4) -> str:
51
+ if connector == "OffloadingConnector":
52
+ return json.dumps(
53
+ {
54
+ "kv_connector": "OffloadingConnector",
55
+ "kv_role": "kv_both",
56
+ "kv_connector_extra_config": {
57
+ "spec_name": "CPUOffloadingSpec",
58
+ "cpu_bytes_to_use": cpu_gib << 30,
59
+ "eviction_policy": "lru",
60
+ },
61
+ }
62
+ )
63
+ elif connector == "SimpleCPUOffloadConnector":
64
+ return json.dumps(
65
+ {
66
+ "kv_connector": "SimpleCPUOffloadConnector",
67
+ "kv_role": "kv_both",
68
+ "kv_connector_extra_config": {
69
+ "cpu_bytes_to_use": cpu_gib << 30,
70
+ },
71
+ }
72
+ )
73
+ else:
74
+ raise ValueError(f"Unknown connector: {connector}")
75
+
76
+
77
+def _force_engine_step(base_url: str) -> None:
78
+ """Force an engine step so completed offload transfers get processed."""
79
+ requests.post(
80
+ f"{base_url}/v1/completions",
81
+ json={"prompt": "0", "max_tokens": 1, "temperature": 0},
82
+ timeout=60,
83
+ ).raise_for_status()
84
+
85
+
86
+def _reset_gpu_prefix_cache(base_url: str) -> None:
87
+ """Drop the GPU prefix cache while keeping the CPU (connector) cache, so
88
+ the next run must reload KV data through the connector.
89
+
90
+ The reset fails while asynchronous offload transfers still hold GPU
91
+ blocks, so retry until it succeeds. Requires VLLM_SERVER_DEV_MODE=1.
92
+ """
93
+ deadline = time.monotonic() + _OFFLOAD_SYNC_TIMEOUT
94
+ while True:
95
+ resp = requests.post(
96
+ f"{base_url}/reset_prefix_cache",
97
+ params={"reset_external": "false"},
98
+ timeout=30,
99
+ )
100
+ resp.raise_for_status()
101
+ if resp.json().get("success"):
102
+ return
103
+ assert time.monotonic() < deadline, (
104
+ f"prefix cache reset did not succeed within {_OFFLOAD_SYNC_TIMEOUT}s; "
105
+ "async offload may be stuck"
106
+ )
107
+ _force_engine_step(base_url)
108
+
109
+
110
+@dataclass
111
+class OffloadingModelConfig:
112
+ id: str
113
+ model: str
114
+ connector: str
115
+ accuracy_threshold: float
116
+ tolerance: float = 0.05
117
+ extra_server_args: list[str] = field(default_factory=list)
118
+ cpu_offload_gib: int = 4
119
+ startup_timeout: int = 600
120
+
121
+
122
+MODELS = [
123
+ # ── OffloadingConnector ──────────────────────────────────────────
124
+ OffloadingModelConfig(
125
+ id="offloading-nemotron-h-8b",
126
+ model="nvidia/Nemotron-H-8B-Base-8K",
127
+ connector="OffloadingConnector",
128
+ # Baseline ~0.49 on 200 questions (measured on GB200).
129
+ accuracy_threshold=0.39,
130
+ ),
131
+ OffloadingModelConfig(
132
+ id="offloading-gemma-4-e4b-it",
133
+ model="google/gemma-4-E4B-it",
134
+ connector="OffloadingConnector",
135
+ # Baseline ~0.64 on 200 questions (measured on GB200).
136
+ accuracy_threshold=0.55,
137
+ ),
138
+ OffloadingModelConfig(
139
+ id="offloading-qwen3.5-35b",
140
+ model="Qwen/Qwen3.5-35B-A3B",
141
+ connector="OffloadingConnector",
142
+ accuracy_threshold=0.75,
143
+ extra_server_args=[
144
+ "--tensor-parallel-size",
145
+ "2",
146
+ "--enable-expert-parallel",
147
+ ],
148
+ startup_timeout=1200,
149
+ ),
150
+ OffloadingModelConfig(
151
+ id="offloading-deepseek-v4-flash",
152
+ model="deepseek-ai/DeepSeek-V4-Flash",
153
+ connector="OffloadingConnector",
154
+ # Baseline ~0.97 on 200 questions (measured on GB200).
155
+ accuracy_threshold=0.90,
156
+ extra_server_args=[
157
+ "--tensor-parallel-size",
158
+ "4",
159
+ "--enable-expert-parallel",
160
+ "--kv-cache-dtype",
161
+ "fp8",
162
+ "--block-size",
163
+ "256",
164
+ ],
165
+ cpu_offload_gib=16,
166
+ startup_timeout=1200,
167
+ ),
168
+ # ── SimpleCPUOffloadConnector ────────────────────────────────────
169
+ OffloadingModelConfig(
170
+ id="simple-nemotron-h-8b",
171
+ model="nvidia/Nemotron-H-8B-Base-8K",
172
+ connector="SimpleCPUOffloadConnector",
173
+ accuracy_threshold=0.45,
174
+ ),
175
+ OffloadingModelConfig(
176
+ id="simple-gemma-4-e4b-it",
177
+ model="google/gemma-4-E4B-it",
178
+ connector="SimpleCPUOffloadConnector",
179
+ accuracy_threshold=0.55,
180
+ ),
181
+ OffloadingModelConfig(
182
+ id="simple-qwen3.5-35b",
183
+ model="Qwen/Qwen3.5-35B-A3B",
184
+ connector="SimpleCPUOffloadConnector",
185
+ accuracy_threshold=0.75,
186
+ extra_server_args=[
187
+ "--tensor-parallel-size",
188
+ "2",
189
+ "--enable-expert-parallel",
190
+ ],
191
+ startup_timeout=1200,
192
+ ),
193
+ OffloadingModelConfig(
194
+ id="simple-deepseek-v4-flash",
195
+ model="deepseek-ai/DeepSeek-V4-Flash",
196
+ connector="SimpleCPUOffloadConnector",
197
+ accuracy_threshold=0.90,
198
+ extra_server_args=[
199
+ "--tensor-parallel-size",
200
+ "4",
201
+ "--enable-expert-parallel",
202
+ "--kv-cache-dtype",
203
+ "fp8",
204
+ "--block-size",
205
+ "256",
206
+ ],
207
+ cpu_offload_gib=16,
208
+ startup_timeout=1200,
209
+ ),
210
+]
211
+
212
+
213
+@pytest.mark.parametrize("cfg", MODELS, ids=lambda c: c.id)
214
+def test_gsm8k_offloading_correctness(cfg: OffloadingModelConfig):
215
+ if "--tensor-parallel-size" in cfg.extra_server_args:
216
+ tp_size = int(
217
+ cfg.extra_server_args[
218
+ cfg.extra_server_args.index("--tensor-parallel-size") + 1
219
+ ]
220
+ )
221
+ if current_platform.device_count() < tp_size:
222
+ pytest.skip(f"Requires {tp_size} GPUs")
223
+
224
+ # Prefix caching must be explicitly enabled: SimpleCPUOffloadConnector requires it.
225
+ server_args = [
226
+ "--enforce-eager",
227
+ "--max-model-len",
228
+ "4096",
229
+ "--enable-prefix-caching",
230
+ "--no-disable-hybrid-kv-cache-manager",
231
+ "--kv-transfer-config",
232
+ _kv_transfer_config(cfg.connector, cfg.cpu_offload_gib),
233
+ "--trust-remote-code",
234
+ "--disable-uvicorn-access-log",
235
+ *cfg.extra_server_args,
236
+ ]
237
+
238
+ with RemoteOpenAIServer(
239
+ cfg.model,
240
+ server_args,
241
+ # /reset_prefix_cache requires dev mode.
242
+ env_dict={"VLLM_SERVER_DEV_MODE": "1"},
243
+ max_wait_seconds=cfg.startup_timeout,
244
+ ) as server:
245
+ base_url = f"http://{server.host}:{server.port}"
246
+
247
+ for run_idx in range(1, 3):
248
+ results = evaluate_gsm8k(
249
+ num_questions=NUM_QUESTIONS,
250
+ num_shots=NUM_FEWSHOT,
251
+ host=f"http://{server.host}",
252
+ port=server.port,
253
+ )
254
+
255
+ print(
256
+ f"GSM8K run {run_idx}/2 + {cfg.connector} ({cfg.id}): "
257
+ f"accuracy={results['accuracy']:.4f}, "
258
+ f"invalid_rate={results['invalid_rate']:.3f}, "
259
+ f"latency={results['latency']:.1f}s"
260
+ )
261
+
262
+ assert results["accuracy"] >= (cfg.accuracy_threshold - cfg.tolerance), (
263
+ f"GSM8K run {run_idx}/2 accuracy "
264
+ f"{results['accuracy']:.4f} below "
265
+ f"{cfg.accuracy_threshold - cfg.tolerance:.4f}"
266
+ )
267
+
268
+ if run_idx == 1:
269
+ _reset_gpu_prefix_cache(base_url)
vllm/entrypoints/serve/dev/cache/api_router.py
+6
-5
@@ -3,7 +3,7 @@
3
4
5
from fastapi import APIRouter, FastAPI, Query, Request
6
-from fastapi.responses import Response
6
+from fastapi.responses import JSONResponse, Response
7
8
from vllm.engine.protocol import EngineClient
9
from vllm.logger import init_logger
@@ -29,18 +29,19 @@ async def reset_prefix_cache(
29
Optionally, if the query parameter `reset_external=true`
30
also resets the external (connector-managed) prefix cache.
31
32
- Note that we currently do not check if the prefix cache
33
- is successfully reset in the API server.
32
+ Returns `{"success": bool}`. The reset fails (`success=false`) while
33
+ blocks are still held, e.g. by running requests or in-flight async KV
34
+ offload transfers; callers may retry.
35
36
Example:
37
POST /reset_prefix_cache?reset_external=true
38
"""
39
logger.info("Resetting prefix cache...")
40
40
- await engine_client(raw_request).reset_prefix_cache(
41
+ success = await engine_client(raw_request).reset_prefix_cache(
42
reset_running_requests, reset_external
43
)
43
- return Response(status_code=200)
44
+ return JSONResponse(content={"success": bool(success)})
45
46
47
@router.post("/reset_mm_cache")