Skip to content

Commit f909e82

Browse files
committed
[integration_test] add mutation meta-test and four vllm e2e scenarios
* test_mutation (B4): runs the basic scenario with MutatedConnector (slot -1 in _attn_token_indices) and asserts KV verification FAILS -- proof the capture-based harness catches symmetric translation bugs and is not vacuous. * test_full_hit (C2): prompt trimmed to an exact multiple of the manager block size, resent after being fully saved. Regression for the synchronous full-hit crash (vllm 0.26.0 scheduler.py 'assert num_new_tokens > 0'); asserts the engine survives and 0 < matched < prompt tokens. * test_partial_hit (C1): staged A / A+B / A+B+C prompts with prefix caching on; exercises the non-zero-offset incremental manager query and the incremental save extension, asserts a logged query with offset > 0 and verifies the blocks saved through the incremental path. * test_load_failure (C3): deletes the tail half of the per-block storage files between save and load (key_count_per_file=1, block_per_load_task=1, kv_load_failure_policy=recompute). Full-attn: failures reported to vLLM, surviving head blocks verify bit-exact, mismatches confined to deleted blocks. Hybrid: failure swallowed by design (vLLM invalid-block recovery is single-group only), asserts no hang/crash and the failure log. * test_multi_turn (C4): turn 1 decodes past a manager block boundary (ignore_eos + return_token_ids), asserts the manager committed more blocks than the prompt covers; turn 2 embeds turn 1 prompt+output as token ids and must externally match beyond prompt-only coverage with verified KV. All scenarios pass for both Qwen2.5-7B-Instruct (full-attn) and Qwen3.5-4B (hybrid) alongside the original basic/concurrent/tp regressions.
1 parent 645e58a commit f909e82

6 files changed

Lines changed: 568 additions & 0 deletions

File tree

integration_test/vllm_e2e/BUILD

Lines changed: 82 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -59,3 +59,85 @@ py_test(
5959
timeout = "eternal",
6060
deps = [":e2e_lib"],
6161
)
62+
63+
py_test(
64+
name = "test_full_hit",
65+
srcs = ["test_full_hit.py"],
66+
data = [
67+
"//kv_cache_manager:kv_cache_manager_bin",
68+
],
69+
imports = ["."],
70+
tags = [
71+
"no-remote-exec",
72+
"gpu", # requires 1+ GPU
73+
"exclusive", # GPU tests must run serially to avoid CUDA OOM contention
74+
],
75+
timeout = "eternal",
76+
deps = [":e2e_lib"],
77+
)
78+
79+
py_test(
80+
name = "test_partial_hit",
81+
srcs = ["test_partial_hit.py"],
82+
data = [
83+
"//kv_cache_manager:kv_cache_manager_bin",
84+
],
85+
imports = ["."],
86+
tags = [
87+
"no-remote-exec",
88+
"gpu", # requires 1+ GPU
89+
"exclusive", # GPU tests must run serially to avoid CUDA OOM contention
90+
],
91+
timeout = "eternal",
92+
deps = [":e2e_lib"],
93+
)
94+
95+
py_test(
96+
name = "test_load_failure",
97+
srcs = ["test_load_failure.py"],
98+
data = [
99+
"//kv_cache_manager:kv_cache_manager_bin",
100+
],
101+
imports = ["."],
102+
tags = [
103+
"no-remote-exec",
104+
"gpu", # requires 1+ GPU
105+
"exclusive", # GPU tests must run serially to avoid CUDA OOM contention
106+
],
107+
timeout = "eternal",
108+
deps = [":e2e_lib"],
109+
)
110+
111+
py_test(
112+
name = "test_multi_turn",
113+
srcs = ["test_multi_turn.py"],
114+
data = [
115+
"//kv_cache_manager:kv_cache_manager_bin",
116+
],
117+
imports = ["."],
118+
tags = [
119+
"no-remote-exec",
120+
"gpu", # requires 1+ GPU
121+
"exclusive", # GPU tests must run serially to avoid CUDA OOM contention
122+
],
123+
timeout = "eternal",
124+
deps = [":e2e_lib"],
125+
)
126+
127+
# Meta-test: injects an off-by-one into the connector's token translation and
128+
# asserts the KV verification FAILS -- proof the harness is not vacuous.
129+
py_test(
130+
name = "test_mutation",
131+
srcs = ["test_mutation.py"],
132+
data = [
133+
"//kv_cache_manager:kv_cache_manager_bin",
134+
],
135+
imports = ["."],
136+
tags = [
137+
"no-remote-exec",
138+
"gpu", # requires 1+ GPU
139+
"exclusive", # GPU tests must run serially to avoid CUDA OOM contention
140+
],
141+
timeout = "eternal",
142+
deps = [":e2e_lib"],
143+
)
Lines changed: 82 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,82 @@
1+
"""test_full_hit: full-prompt external hit must not crash the engine.
2+
3+
Regression test for the synchronous-load full-hit bug: this connector reports
4+
external matches with load_kv_async=False, so vLLM schedules
5+
``num_tokens - num_computed_tokens`` new tokens and asserts that count is > 0
6+
(vllm/v1/core/sched/scheduler.py, waiting-queue loop: ``assert num_new_tokens
7+
> 0``). Without capping, a prompt whose token count is an exact multiple of the
8+
manager block size and whose blocks are all externally cached would make the
9+
count 0 and kill the engine.
10+
11+
Phase 1 saves a prompt of exactly N manager blocks; phase 2 resends the very
12+
same prompt (as explicit token ids, so tokenization cannot shift the length).
13+
Asserts: the engine survives, the completion is well-formed, and the connector
14+
reports 0 < matched < prompt tokens (the cap dropped at least the last block).
15+
16+
Runs against both full-attention and hybrid models via $KVCM_E2E_MODEL.
17+
"""
18+
19+
import logging
20+
import unittest
21+
22+
from e2e_lib import (
23+
ScenarioEnv, make_base_prompts, send_completions, tokenize,
24+
wait_for_prefix_cached,
25+
)
26+
27+
logger = logging.getLogger("vllm_e2e")
28+
29+
30+
class TestFullHit(unittest.TestCase):
31+
def test_full_hit(self):
32+
env = ScenarioEnv("full_hit")
33+
try:
34+
env.start_manager()
35+
vllm = env.start_vllm(log_suffix="_p1" if env.hybrid else "")
36+
37+
mbs = env.manager_block_size()
38+
# Trim a long-enough prompt's token ids to an exact multiple of the
39+
# manager block size (>= 2 blocks so the cap has room to drop one).
40+
toks = tokenize(vllm.base_url(), make_base_prompts(1, env.hybrid)[0])
41+
num_blocks = len(toks) // mbs
42+
self.assertGreaterEqual(
43+
num_blocks, 2, f"prompt too short: {len(toks)} tokens, mbs={mbs}")
44+
prompt_ids = toks[:num_blocks * mbs]
45+
logger.info("full-hit prompt: %d tokens = %d x %d",
46+
len(prompt_ids), num_blocks, mbs)
47+
48+
# Phase 1: fresh prefill -> all blocks saved.
49+
resp1 = send_completions(vllm.base_url(), [prompt_ids])[0]
50+
self.assertTrue(resp1["choices"][0]["text"])
51+
self.assertTrue(wait_for_prefix_cached(
52+
env.manager.manager_uri(), env.instance_id, prompt_ids,
53+
min_blocks=num_blocks))
54+
55+
if env.hybrid:
56+
# Clear the local prefix cache so phase 2 goes external.
57+
vllm = env.restart_vllm(log_suffix="_p2")
58+
59+
# Phase 2: the exact same prompt -> full external hit. Without the
60+
# cap this crashes the engine (assert num_new_tokens > 0).
61+
resp2 = send_completions(vllm.base_url(), [prompt_ids])[0]
62+
self.assertTrue(resp2["choices"][0]["text"])
63+
64+
# The engine must still be alive and serving.
65+
resp3 = send_completions(vllm.base_url(), ["sanity check prompt"])[0]
66+
self.assertTrue(resp3["choices"][0]["text"])
67+
68+
# Connector-side evidence: matched > 0 (external hit happened) and
69+
# matched < prompt tokens (the cap left tokens to recompute).
70+
matched = [int(g[0]) for g in
71+
env.scan_connector_logs(r"matched (\d+) external tokens")]
72+
self.assertTrue(matched, "no 'matched N external tokens' log found")
73+
hit = [m for m in matched if m > 0]
74+
self.assertTrue(hit, f"no positive external match in {matched}")
75+
self.assertTrue(all(m < len(prompt_ids) for m in hit),
76+
f"match not capped below prompt len: {matched}")
77+
finally:
78+
env.stop()
79+
80+
81+
if __name__ == "__main__":
82+
unittest.main()
Lines changed: 161 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,161 @@
1+
"""test_load_failure: storage loss between save and load must degrade, not kill.
2+
3+
Phase 1 saves a long prompt; the test then deletes the storage files of the
4+
*tail half* of the manager blocks (resolved through the manager's ordered
5+
getCacheLocation response, one file per block via key_count_per_file=1).
6+
Phase 2 reloads the same prefix with ``block_per_load_task=1`` so every block
7+
fails or succeeds independently.
8+
9+
Full-attention models (single group, report_failures=True): the connector
10+
reports the failed blocks' vLLM block ids; with
11+
``kv_load_failure_policy="recompute"`` (vLLM 0.26.0 defaults to "fail", which
12+
turns any load failure into a 500) vLLM truncates the computed-token count at
13+
the first invalid block and recomputes from there
14+
(vllm/v1/core/sched/scheduler.py::_handle_invalid_blocks /
15+
_update_requests_with_invalid_blocks). Asserts: the request returns a normal
16+
completion, the failures were logged and reported, every *surviving* head
17+
block's loaded KV is bit-exact, and any mismatching capture belongs to a
18+
deleted (recomputed) block. Recomputed blocks are not held to bit-exactness:
19+
they contain freshly recomputed KV whose numerics depend on prefill chunking,
20+
which is vLLM's business, not the connector's.
21+
22+
Hybrid models (multiple groups, report_failures=False): vLLM's invalid-block
23+
recovery only supports single-group block tables, so the connector only logs
24+
the failure. Asserts: the request still returns (no hang, no crash) and the
25+
failure was logged. KV content is NOT verified: with the failure swallowed
26+
the affected blocks keep garbage by design.
27+
28+
This scenario also regression-tests the fail-reschedule loop fix: a request
29+
whose external load failed must not re-match external blocks on requeue
30+
(v1_connector.get_num_new_matched_tokens retry guard), otherwise the engine
31+
loops load-fail-reschedule forever and the request hangs.
32+
"""
33+
34+
import logging
35+
import os
36+
import unittest
37+
from urllib.parse import urlparse
38+
39+
import requests
40+
41+
from e2e_lib import (
42+
ScenarioEnv, compare_captures, full_block_hashes, make_base_prompts,
43+
send_completions, tokenize, wait_for_captures, wait_for_prefix_cached,
44+
)
45+
46+
logger = logging.getLogger("vllm_e2e")
47+
48+
49+
def get_block_files(manager_uri: str, instance_id: str, token_ids: list[int],
50+
spec_name: str = "tp0_g0") -> list[str]:
51+
"""Per-manager-block storage file paths, in block order, from the manager's
52+
getCacheLocation response."""
53+
r = requests.post(f"{manager_uri}/api/getCacheLocation", json={
54+
"trace_id": "e2e_block_files",
55+
"token_ids": token_ids,
56+
"instance_id": instance_id,
57+
"query_type": "QT_PREFIX_MATCH",
58+
"block_mask": {"offset": 0},
59+
}, timeout=10)
60+
r.raise_for_status()
61+
files = []
62+
for location in r.json().get("locations", []):
63+
for spec in location.get("location_specs", []):
64+
if spec["name"] == spec_name:
65+
# uri: file://<path>?size=...
66+
files.append(urlparse(spec["uri"]).path)
67+
return files
68+
69+
70+
class TestLoadFailure(unittest.TestCase):
71+
def test_load_failure(self):
72+
env = ScenarioEnv(
73+
"load_failure",
74+
extra_config_overrides={"block_per_load_task": 1},
75+
key_count_per_file=1, # one file per block -> per-block failures
76+
kv_load_failure_policy="recompute",
77+
)
78+
try:
79+
env.start_manager()
80+
vllm = env.start_vllm(log_suffix="_p1" if env.hybrid else "")
81+
mbs = env.manager_block_size()
82+
83+
prompt = make_base_prompts(1, env.hybrid)[0]
84+
suffix = " Now answer: what is 2+2?"
85+
toks = tokenize(vllm.base_url(), prompt)
86+
save_blocks = len(toks) // mbs
87+
self.assertGreaterEqual(save_blocks, 2)
88+
89+
# ---- Phase 1: save everything.
90+
send_completions(vllm.base_url(), [prompt])
91+
wait_for_captures(env.capture_dir, "ref", expected=save_blocks,
92+
timeout=180)
93+
self.assertTrue(wait_for_prefix_cached(
94+
env.manager.manager_uri(), env.instance_id, toks,
95+
min_blocks=save_blocks))
96+
97+
# ---- Sabotage: delete the tail half of the blocks' files. The
98+
# head blocks stay loadable, so vLLM truncates at the first deleted
99+
# block and the surviving loads remain verifiable.
100+
files = get_block_files(env.manager.manager_uri(), env.instance_id,
101+
toks)
102+
self.assertEqual(len(files), save_blocks)
103+
keep = save_blocks // 2
104+
for path in files[keep:]:
105+
os.remove(path)
106+
logger.info("deleted %d/%d block files (kept blocks 0..%d)",
107+
save_blocks - keep, save_blocks, keep - 1)
108+
109+
if env.hybrid:
110+
vllm = env.restart_vllm(log_suffix="_p2")
111+
112+
# ---- Phase 2: load with holes. The request must return normally.
113+
resp = send_completions(vllm.base_url(), [prompt + suffix])[0]
114+
self.assertTrue(resp["choices"][0]["text"])
115+
116+
# The engine must survive and keep serving.
117+
resp2 = send_completions(vllm.base_url(), ["engine alive?"])[0]
118+
self.assertTrue(resp2["choices"][0]["text"])
119+
120+
failed_tasks = env.scan_connector_logs(r"load task failed")
121+
self.assertTrue(failed_tasks, "no load failure was logged; the "
122+
"sabotage did not break any loaded block")
123+
124+
if env.hybrid:
125+
# report_failures=False path: swallowed but logged.
126+
swallowed = env.scan_connector_logs(
127+
r"load failed for \d+/\d+ blocks .*hybrid")
128+
self.assertTrue(swallowed,
129+
"hybrid load failure was not logged")
130+
return
131+
132+
# Full-attention: vLLM was told about the invalid blocks...
133+
reported = env.scan_connector_logs(r"block_ids_with_load_errors")
134+
self.assertTrue(reported, "failed loads were not reported to vLLM")
135+
136+
# ...and every surviving head block's loaded KV is bit-exact,
137+
# while any mismatch belongs to a deleted (recomputed) block.
138+
wait_for_captures(env.capture_dir, "loaded", expected=keep,
139+
timeout=180)
140+
report = compare_captures(env.capture_dir, tp_size=1)
141+
hashes = full_block_hashes(toks, mbs)
142+
kept_keys = {("tp0", h) for h in hashes[:keep]}
143+
deleted_keys = {("tp0", h) for h in hashes[keep:]}
144+
failed_keys = {f["key"] for f in report["failures"]}
145+
self.assertFalse(
146+
failed_keys & kept_keys,
147+
f"surviving loaded blocks mismatched: {failed_keys & kept_keys}")
148+
self.assertTrue(
149+
failed_keys <= deleted_keys,
150+
f"mismatches outside the deleted blocks: "
151+
f"{failed_keys - deleted_keys}")
152+
matched_kept = kept_keys & set(report["matched_keys"])
153+
self.assertEqual(
154+
len(matched_kept), keep,
155+
f"only {len(matched_kept)}/{keep} surviving blocks verified")
156+
finally:
157+
env.stop()
158+
159+
160+
if __name__ == "__main__":
161+
unittest.main()

0 commit comments

Comments
 (0)