Skip to content

Commit 4085ff7

Browse files
njhillclaude
andauthored
[Core] Add kvcache watermark to reduce preemptions (#44594)
Signed-off-by: Nick Hill <nickhill123@gmail.com> Co-authored-by: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
1 parent 23eb7c8 commit 4085ff7

7 files changed

Lines changed: 291 additions & 8 deletions

File tree

benchmarks/kv_cache_watermark.sh

Lines changed: 248 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,248 @@
1+
#!/bin/bash
2+
# SPDX-License-Identifier: Apache-2.0
3+
# SPDX-FileCopyrightText: Copyright contributors to the vLLM project
4+
#
5+
# Reproducible demonstration of the KV cache watermark (`--watermark`) for
6+
# reducing preemption thrashing.
7+
#
8+
# The watermark is the fraction of total KV cache blocks the scheduler keeps
9+
# free when admitting a waiting/preempted request into the running queue.
10+
#
11+
# Why this workload triggers thrashing:
12+
# Requests are admitted based on the KV cache they need *at admission time*.
13+
# With `--scheduler-reserve-full-isl` (default) the input length is reserved up
14+
# front, but the *output* length is unknown and unreserved. A decode-heavy
15+
# workload (output >> input) at high concurrency therefore over-admits while
16+
# requests are short, then runs out of KV cache as they all grow during decode
17+
# -> the scheduler preempts (recompute) recently-admitted requests, re-prefills
18+
# them later, and repeats. The watermark keeps a block of KV cache free so
19+
# running requests can grow into it instead of triggering this churn.
20+
#
21+
# This script launches `vllm serve` under a deliberately KV-constrained config
22+
# and a decode-heavy workload, sweeping the watermark across several values, and
23+
# reports the preemption count (scraped from /metrics), throughput, and latency
24+
# percentiles for each. It then plots the results.
25+
#
26+
# Default workload: concurrency 200, input ~300 tokens, output ~4000 tokens
27+
# (+/- 20% variance), sized to run each config for ~5 minutes.
28+
#
29+
# Usage:
30+
# benchmarks/kv_cache_watermark.sh
31+
# MODEL=Qwen/Qwen2.5-14B-Instruct TP=2 benchmarks/kv_cache_watermark.sh
32+
#
33+
# Run inside the vLLM virtualenv (so `vllm` and `python` resolve to it).
34+
set -euo pipefail
35+
36+
# ---- Config (override via environment) -------------------------------------
37+
MODEL=${MODEL:-Qwen/Qwen2.5-7B-Instruct}
38+
TP=${TP:-1}
39+
PORT=${PORT:-8000}
40+
URL="http://127.0.0.1:${PORT}"
41+
# Constrain the KV cache to a *near-critical* size: large enough that the engine
42+
# can run stably, but small enough that greedy over-admission tips it into
43+
# preemption thrashing. (Independent of GPU size, so the demo is reproducible.)
44+
# At the default workload this fits ~1.5x the mean concurrent KV demand.
45+
KV_CACHE_MEMORY_GB=${KV_CACHE_MEMORY_GB:-16}
46+
MAX_MODEL_LEN=${MAX_MODEL_LEN:-8192}
47+
MAX_NUM_SEQS=${MAX_NUM_SEQS:-256}
48+
# Optional weight loader (e.g. fastsafetensors on the GCP cluster).
49+
LOAD_FORMAT=${LOAD_FORMAT:-auto}
50+
# Decode-heavy workload: moderate input, long output, with length variance. The
51+
# long output means preempted requests have generated a lot before eviction, so
52+
# resuming them re-prefills a long sequence (high recomputation cost).
53+
INPUT_LEN=${INPUT_LEN:-1000}
54+
OUTPUT_LEN=${OUTPUT_LEN:-5000}
55+
RANGE_RATIO=${RANGE_RATIO:-0.2}
56+
CONCURRENCY=${CONCURRENCY:-128}
57+
# Enough prompts to keep each config saturated for ~5+ minutes.
58+
NUM_PROMPTS=${NUM_PROMPTS:-450}
59+
OUTDIR=${OUTDIR:-./watermark_bench_results}
60+
# Watermark fractions compared. "label value" per line; value=0 disables it.
61+
CONFIGS=${CONFIGS:-"off 0
62+
w0.02 0.02
63+
w0.05 0.05
64+
w0.10 0.10
65+
w0.15 0.15"}
66+
67+
KV_CACHE_MEMORY_BYTES=$((KV_CACHE_MEMORY_GB * 1024 * 1024 * 1024))
68+
mkdir -p "$OUTDIR"
69+
70+
SERVER_PID=""
71+
cleanup() { [[ -n "$SERVER_PID" ]] && kill "$SERVER_PID" 2>/dev/null || true; }
72+
trap cleanup EXIT
73+
74+
scrape_preemptions() {
75+
# Sum the vllm:num_preemptions_total counter across engines.
76+
python - "${URL}/metrics" <<'PY'
77+
import sys, urllib.request
78+
total = 0.0
79+
try:
80+
body = urllib.request.urlopen(sys.argv[1], timeout=10).read().decode("utf-8", "replace")
81+
for line in body.splitlines():
82+
if line.startswith("vllm:num_preemptions_total"):
83+
total += float(line.rsplit(" ", 1)[-1])
84+
except Exception as e: # noqa: BLE001
85+
print(f"scrape error: {e}", file=sys.stderr)
86+
print(int(total))
87+
PY
88+
}
89+
90+
wait_for_server() {
91+
for _ in $(seq 1 300); do
92+
if curl -s "${URL}/health" >/dev/null 2>&1; then return 0; fi
93+
if ! kill -0 "$SERVER_PID" 2>/dev/null; then
94+
echo "ERROR: server process exited during startup" >&2; return 1
95+
fi
96+
sleep 5
97+
done
98+
echo "ERROR: server did not become ready" >&2; return 1
99+
}
100+
101+
run_one() {
102+
local label=$1 watermark=$2
103+
echo
104+
echo "==================== watermark: ${label} (${watermark}) ===================="
105+
vllm serve "$MODEL" \
106+
--tensor-parallel-size "$TP" \
107+
--load-format "$LOAD_FORMAT" \
108+
--kv-cache-memory-bytes "$KV_CACHE_MEMORY_BYTES" \
109+
--max-model-len "$MAX_MODEL_LEN" \
110+
--max-num-seqs "$MAX_NUM_SEQS" \
111+
--no-enable-prefix-caching \
112+
--watermark "$watermark" \
113+
--port "$PORT" >"${OUTDIR}/serve_${label}.log" 2>&1 &
114+
SERVER_PID=$!
115+
wait_for_server
116+
sleep 5
117+
118+
local pre post
119+
pre=$(scrape_preemptions)
120+
vllm bench serve \
121+
--backend vllm \
122+
--base-url "$URL" \
123+
--model "$MODEL" \
124+
--dataset-name random \
125+
--random-input-len "$INPUT_LEN" \
126+
--random-output-len "$OUTPUT_LEN" \
127+
--random-range-ratio "$RANGE_RATIO" \
128+
--ignore-eos \
129+
--num-prompts "$NUM_PROMPTS" \
130+
--max-concurrency "$CONCURRENCY" \
131+
--percentile-metrics "ttft,tpot,itl,e2el" \
132+
--metric-percentiles "50,90,99" \
133+
--save-result \
134+
--result-dir "$OUTDIR" \
135+
--result-filename "bench_${label}.json"
136+
post=$(scrape_preemptions)
137+
echo "${label} ${watermark} $((post - pre))" >>"${OUTDIR}/preemptions.txt"
138+
139+
kill "$SERVER_PID" 2>/dev/null || true
140+
for _ in $(seq 1 60); do curl -s "${URL}/health" >/dev/null 2>&1 || break; sleep 2; done
141+
SERVER_PID=""
142+
sleep 10
143+
}
144+
145+
: >"${OUTDIR}/preemptions.txt"
146+
while read -r label watermark; do
147+
[[ -z "${label:-}" ]] && continue
148+
run_one "$label" "$watermark"
149+
done <<<"$CONFIGS"
150+
151+
echo
152+
echo "==================== summary ===================="
153+
python - "$OUTDIR" <<'PY'
154+
import json, os, sys
155+
outdir = sys.argv[1]
156+
pre = {}
157+
order = []
158+
for line in open(os.path.join(outdir, "preemptions.txt")):
159+
label, watermark, n = line.split()
160+
pre[label] = (float(watermark), int(n))
161+
order.append(label)
162+
163+
def g(d, *names):
164+
for n in names:
165+
if d.get(n) is not None:
166+
return d[n]
167+
return float("nan")
168+
169+
cols = ["watermark", "frac", "preempt", "out_tok/s", "req/s",
170+
"TTFT_p50", "TTFT_p99", "ITL_p99", "E2EL_p50"]
171+
print(" ".join(f"{c:>10}" for c in cols))
172+
rows = []
173+
for label in order:
174+
watermark, n = pre[label]
175+
d = json.load(open(os.path.join(outdir, f"bench_{label}.json")))
176+
rows.append(dict(
177+
label=label, watermark=watermark, preempt=n,
178+
out_tok_s=g(d, "output_throughput"),
179+
req_s=g(d, "request_throughput"),
180+
ttft_p50=g(d, "p50_ttft_ms", "median_ttft_ms"),
181+
ttft_p99=g(d, "p99_ttft_ms"),
182+
itl_p99=g(d, "p99_itl_ms"),
183+
e2el_p50=g(d, "p50_e2el_ms", "median_e2el_ms"),
184+
))
185+
print(" ".join(f"{str(v):>10}" for v in [
186+
label, watermark, n,
187+
f"{rows[-1]['out_tok_s']:.0f}",
188+
f"{rows[-1]['req_s']:.3f}",
189+
f"{rows[-1]['ttft_p50']/1000:.2f}",
190+
f"{rows[-1]['ttft_p99']/1000:.2f}",
191+
f"{rows[-1]['itl_p99']:.2f}",
192+
f"{rows[-1]['e2el_p50']/1000:.1f}",
193+
]))
194+
print("\n(TTFT/E2EL in seconds; ITL in ms. Lower preempt is better.)")
195+
196+
# ---- Plot -------------------------------------------------------------------
197+
try:
198+
import matplotlib
199+
matplotlib.use("Agg")
200+
import matplotlib.pyplot as plt
201+
except Exception as e: # noqa: BLE001
202+
print(f"\n(skip plot: matplotlib unavailable: {e})")
203+
sys.exit(0)
204+
205+
x = [r["watermark"] for r in rows]
206+
xt = [f"{r['watermark']:g}\n({r['label']})" for r in rows]
207+
idx = list(range(len(rows)))
208+
209+
fig, axes = plt.subplots(2, 2, figsize=(12, 8))
210+
fig.suptitle(
211+
f"KV cache watermark sweep — {os.path.basename(os.path.abspath(outdir))}",
212+
fontsize=12,
213+
)
214+
215+
ax = axes[0][0]
216+
ax.bar(idx, [r["preempt"] for r in rows], color="tab:red")
217+
ax.set_title("Preemptions (lower is better)")
218+
ax.set_ylabel("preemptions")
219+
ax.set_xticks(idx); ax.set_xticklabels(xt)
220+
221+
ax = axes[0][1]
222+
ax.plot(idx, [r["out_tok_s"] for r in rows], "o-", color="tab:green")
223+
ax.set_title("Output throughput (higher is better)")
224+
ax.set_ylabel("tokens/s")
225+
ax.set_xticks(idx); ax.set_xticklabels(xt)
226+
227+
ax = axes[1][0]
228+
ax.plot(idx, [r["itl_p99"] for r in rows], "o-", color="tab:blue")
229+
ax.set_title("Inter-token latency p99 (lower is better)")
230+
ax.set_ylabel("ITL p99 (ms)")
231+
ax.set_xlabel("watermark fraction")
232+
ax.set_xticks(idx); ax.set_xticklabels(xt)
233+
234+
ax = axes[1][1]
235+
ax.plot(idx, [r["ttft_p50"] / 1000 for r in rows], "o-", label="TTFT p50")
236+
ax.plot(idx, [r["ttft_p99"] / 1000 for r in rows], "o-", label="TTFT p99")
237+
ax.plot(idx, [r["e2el_p50"] / 1000 for r in rows], "o-", label="E2EL p50")
238+
ax.set_title("Latency (lower is better)")
239+
ax.set_ylabel("seconds")
240+
ax.set_xlabel("watermark fraction")
241+
ax.set_xticks(idx); ax.set_xticklabels(xt)
242+
ax.legend()
243+
244+
fig.tight_layout(rect=(0, 0, 1, 0.95))
245+
out_png = os.path.join(outdir, "watermark_results.png")
246+
fig.savefig(out_png, dpi=120)
247+
print(f"\nWrote plot: {out_png}")
248+
PY

tests/v1/core/test_scheduler.py

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1849,6 +1849,8 @@ def create_scheduler_with_priority(
18491849
enable_chunked_prefill=True,
18501850
is_encoder_decoder=model_config.is_encoder_decoder,
18511851
policy="priority", # Enable priority scheduling
1852+
# Ensure admission/preemption mechanics are deterministic
1853+
watermark=0.0,
18521854
)
18531855
# Cache config, optionally force APC
18541856
cache_config = CacheConfig(

tests/v1/core/utils.py

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -90,6 +90,8 @@ def create_scheduler(
9090
enable_chunked_prefill=enable_chunked_prefill,
9191
async_scheduling=async_scheduling,
9292
is_encoder_decoder=model_config.is_encoder_decoder,
93+
# Ensure admission/preemption mechanics are deterministic
94+
watermark=0.0,
9395
)
9496
# Cache config, optionally force APC
9597
cache_config = CacheConfig(

vllm/config/scheduler.py

Lines changed: 7 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -143,6 +143,13 @@ class SchedulerConfig:
143143
checking the first chunk. Prevents over-admission and KV cache thrashing
144144
with chunked prefill."""
145145

146+
watermark: float = Field(default=0.0, ge=0.0, lt=1.0)
147+
"""Fraction of total KV cache blocks to keep free (the watermark) when
148+
admitting waiting or preempted requests into the running queue. This headroom
149+
helps avoid frequent KV cache eviction and the resulting repeated preemption
150+
of requests when GPU memory is scarce. Must be in the range [0.0, 1.0); 0.0
151+
(the default) disables the watermark."""
152+
146153
async_scheduling: bool | None = None
147154
"""If set to False, disable async scheduling. Async scheduling helps to
148155
avoid gaps in GPU utilization, leading to better latency and throughput.

vllm/engine/arg_utils.py

Lines changed: 4 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -600,6 +600,8 @@ class EngineArgs:
600600

601601
scheduler_reserve_full_isl: bool = SchedulerConfig.scheduler_reserve_full_isl
602602

603+
watermark: float = SchedulerConfig.watermark
604+
603605
disable_hybrid_kv_cache_manager: bool | None = (
604606
SchedulerConfig.disable_hybrid_kv_cache_manager
605607
)
@@ -1408,6 +1410,7 @@ def add_cli_args(parser: FlexibleArgumentParser) -> FlexibleArgumentParser:
14081410
"--scheduler-reserve-full-isl",
14091411
**scheduler_kwargs["scheduler_reserve_full_isl"],
14101412
)
1413+
scheduler_group.add_argument("--watermark", **scheduler_kwargs["watermark"])
14111414
scheduler_group.add_argument(
14121415
"--disable-hybrid-kv-cache-manager",
14131416
**scheduler_kwargs["disable_hybrid_kv_cache_manager"],
@@ -2045,6 +2048,7 @@ def create_engine_config(
20452048
max_long_partial_prefills=self.max_long_partial_prefills,
20462049
long_prefill_token_threshold=self.long_prefill_token_threshold,
20472050
scheduler_reserve_full_isl=self.scheduler_reserve_full_isl,
2051+
watermark=self.watermark,
20482052
disable_hybrid_kv_cache_manager=self.disable_hybrid_kv_cache_manager,
20492053
async_scheduling=self.async_scheduling,
20502054
stream_interval=self.stream_interval,

vllm/v1/core/kv_cache_manager.py

Lines changed: 25 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -17,7 +17,7 @@
1717
get_kv_cache_spec_sliding_window,
1818
)
1919
from vllm.v1.metrics.stats import PrefixCacheStats
20-
from vllm.v1.request import Request
20+
from vllm.v1.request import Request, RequestStatus
2121

2222
logger = init_logger(__name__)
2323

@@ -122,6 +122,7 @@ def __init__(
122122
dcp_world_size: int = 1,
123123
pcp_world_size: int = 1,
124124
metrics_collector: KVCacheMetricsCollector | None = None,
125+
watermark: float = 0.0,
125126
) -> None:
126127
self.max_model_len = max_model_len
127128
# When unset, fall back to `max_model_len` so the recycling-aware cap
@@ -155,6 +156,11 @@ def __init__(
155156
self.num_kv_cache_groups = len(kv_cache_config.kv_cache_groups)
156157
self.block_pool = self.coordinator.block_pool
157158
self.kv_cache_config = kv_cache_config
159+
160+
# Watermark: minimum number of KV cache blocks to keep free when
161+
# admitting waiting/preempted requests, to avoid frequent preemptions.
162+
assert watermark >= 0.0, "watermark must be non-negative"
163+
self.watermark_blocks = int(watermark * kv_cache_config.num_blocks)
158164
self.kv_cache_event_metadata = tuple(
159165
(
160166
get_kv_cache_spec_kind(group.kv_cache_spec).value,
@@ -247,6 +253,7 @@ def allocate_slots(
247253
num_encoder_tokens: int = 0,
248254
full_sequence_must_fit: bool = False,
249255
reserved_blocks: int = 0,
256+
has_scheduled_reqs: bool = True,
250257
) -> KVCacheBlocks | None:
251258
"""Add slots for a request with new tokens to append.
252259
@@ -277,6 +284,8 @@ def allocate_slots(
277284
made if it fits within (free blocks - reserved_blocks). Used to gate
278285
async KV-connector loads so their initial allocation cannot consume
279286
blocks an already in-flight (prefilling) sequence is relying on.
287+
has_scheduled_reqs: Whether any requests are already scheduled to run
288+
this step, controls whether watermark is applied.
280289
281290
Blocks layout:
282291
```
@@ -351,6 +360,15 @@ def allocate_slots(
351360
self.max_model_len,
352361
)
353362

363+
watermark_blocks = 0
364+
# The watermark is applied to waiting/preempted requests only, and only
365+
# when there's at least one request already scheduled.
366+
if has_scheduled_reqs and request.status in (
367+
RequestStatus.WAITING,
368+
RequestStatus.PREEMPTED,
369+
):
370+
watermark_blocks = self.watermark_blocks
371+
354372
if full_sequence_must_fit:
355373
# First check and fail if the full request sequence won't fit.
356374
full_num_tokens = min(request.num_tokens, self.max_model_len)
@@ -364,7 +382,8 @@ def allocate_slots(
364382
num_tokens_main_model=full_num_tokens,
365383
apply_admission_cap=True,
366384
)
367-
if num_blocks_to_allocate > self.block_pool.get_num_free_blocks():
385+
required_blocks = num_blocks_to_allocate + watermark_blocks
386+
if required_blocks > self.block_pool.get_num_free_blocks():
368387
return None
369388

370389
num_tokens_main_model = total_computed_tokens + num_new_tokens
@@ -392,8 +411,11 @@ def allocate_slots(
392411
num_tokens_main_model=num_tokens_main_model,
393412
)
394413

414+
# Keep `reserved_blocks` free for other in-flight sequences, and an
415+
# additional watermark of headroom for waiting/preempted admissions.
395416
available_blocks = self.block_pool.get_num_free_blocks() - reserved_blocks
396-
if num_blocks_to_allocate > available_blocks:
417+
required_blocks = num_blocks_to_allocate + watermark_blocks
418+
if required_blocks > available_blocks:
397419
# Cannot allocate new blocks
398420
return None
399421

0 commit comments

Comments
 (0)