Files
club-3090/scripts/lmcache-retention-test.sh
Alex b03a134925 Local: L1=30 vs L1=60 A/B — the RAM IS working (~1.6-2x faster warm reads)
Reconciles the earlier L2=0 finding. With L2 on (production shape), a 60 GB
L1 serves warm reads ~1.6x faster on average (~2x on the evicted half) than
a 30 GB L1 for a ~50 GB working set: older sessions that spill past a 30 GB
L1 fall to L2 disk (~3-5s) but stay in RAM at L1=60 (~1.3-2.4s).

Two-part model: L2 disk decides *whether* a read is warm (retention); L1 RAM
decides *how fast* (speed). L1 is a fast write-through front for L2, not a
standalone store — hence L2=0 gave 0/8 yet L2=1+L1=60 is fastest. Decision:
keep L1=60, it is not reclaimable headroom.

- Add SALT env to lmcache-retention-test.sh (fresh keys per A/B leg, no L2 collision)
- Add _local-lmcache-tuning/l1-ab-run.sh (two-leg orchestrator + comparison)
- Record result + reconciliation in HANDOFF.md

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-07-13 21:44:15 +02:00

205 lines
9.5 KiB
Bash
Executable File

#!/usr/bin/env bash
# ===========================================================================
# lmcache-retention-test.sh — prove L1 *retention*, not just warm-cache-works.
# ---------------------------------------------------------------------------
# scripts/bench.sh's PREFILL_PROBE salts every request FRESH, so it measures
# COLD prefill and (by re-sending) the immediate warm hit — but it never fills
# past L1 capacity, so it can't show what a BIGGER L1 buys. That "bigger L1"
# question is the whole reason to spend host RAM on LMCACHE_L1_GB.
#
# This wrapper closes that gap. It inserts N distinct large prefixes whose
# combined KV exceeds a small L1 but fits a large one, then re-reads them:
#
# Round 1 (cold insert): send N distinct ~SESSION_TOKENS prefixes once each,
# small max_tokens so each request COMPLETES (LMCache
# only banks a session's blocks on completion — see
# lmcache.yml "run to COMPLETION" note). Fills L1.
# Round 2 (retention read): re-send the SAME N prefixes in the SAME order.
# Session 1 is the LRU-oldest, so Round-2 TTFT per
# session IS the retention curve:
# - L1 big enough -> every session WARM (retained)
# - L1 too small -> earliest sessions COLD (evicted)
#
# PASS = all N warm in Round 2. On THIS rig (LMCACHE_L1_GB=60) a set sized
# between the 30 GB and 60 GB capacity lines should be 8/8 warm — and would
# have evicted its earliest sessions at the default L1=30.
#
# Prereq: the LMCache compose is serving (vllm/qwen-27b-dual-lmcache). Bring it
# up with scripts/switch.sh first; confirm the served id at /v1/models.
#
# Env vars (all optional):
# PORT Endpoint port. Default: 8017 (the slug's default_port)
# URL Full base URL. Default: http://localhost:${PORT}
# MODEL Served model id. Default: qwen3.6-27b
# (a WRONG id is a silent 404 — confirm via /v1/models)
# NUM_SESSIONS Distinct prefixes to insert. Default: 8
# SESSION_TOKENS Approx prompt tokens per prefix. Default: 48000
# (8 x 48K ~= 384K tok ~= 50 GB @ 131 KB/tok: inside a
# 60 GB L1, past a 30 GB L1 — the discriminating window)
# WARM_THRESHOLD_S TTFT at/below this = warm hit. Default: 8.0
# (L1 rehydrate is sub-second..~few s per #423; cold
# re-prefill is ~35-45 s — 8 s cleanly separates them)
# KV_KB_PER_TOKEN LMCache cache rate for the GB math. Default: 131 (measured)
# PROBE_MAX_TOKENS Output cap per request (just enough to complete). Default: 8
# SALT Mixes into every prefix's seed + anchor so a re-run inserts
# BYTE-DIFFERENT prefixes -> fresh cache keys that cannot hit
# KV left on L2 disk by a prior run. Default: "" (the original
# deterministic prefixes). Give each A/B leg a distinct SALT so
# the two legs never read each other's (or old) cached blocks.
#
# Exit 0 = all sessions retained (PASS). Exit 1 = one or more evicted.
# ===========================================================================
set -euo pipefail
PORT="${PORT:-8017}"
URL="${URL:-http://localhost:${PORT}}"
MODEL="${MODEL:-qwen3.6-27b}"
NUM_SESSIONS="${NUM_SESSIONS:-8}"
SESSION_TOKENS="${SESSION_TOKENS:-48000}"
WARM_THRESHOLD_S="${WARM_THRESHOLD_S:-8.0}"
KV_KB_PER_TOKEN="${KV_KB_PER_TOKEN:-131}"
PROBE_MAX_TOKENS="${PROBE_MAX_TOKENS:-8}"
SALT="${SALT:-}"
command -v python3 >/dev/null || { echo "Fix: python3 not found on PATH." >&2; exit 2; }
python3 - "$URL" "$MODEL" "$NUM_SESSIONS" "$SESSION_TOKENS" \
"$WARM_THRESHOLD_S" "$KV_KB_PER_TOKEN" "$PROBE_MAX_TOKENS" "$SALT" <<'PY'
import json, random, string, sys, time, urllib.request, zlib
# Community rigs run non-UTF-8 locales; a piped stdout defaults to ASCII (repo
# convention: pin utf-8 on both read and write).
sys.stdout.reconfigure(encoding="utf-8")
URL, MODEL, N, STOK, WARM, KVKB, PMAX, SALT = sys.argv[1:]
N = int(N); STOK = int(STOK); WARM = float(WARM); KVKB = float(KVKB); PMAX = int(PMAX)
# Stable integer offset derived from SALT (crc32 is process-independent, unlike
# builtin hash() under PYTHONHASHSEED). Empty SALT -> 0 -> original prefixes.
SALT_OFF = (zlib.crc32(SALT.encode()) % 1_000_000) if SALT else 0
def tokenize_count(text):
"""Exact prompt-token count via vLLM's /tokenize; None if unavailable."""
for path in ("/tokenize", "/v1/tokenize"):
try:
body = json.dumps({"model": MODEL, "prompt": text}).encode()
req = urllib.request.Request(URL + path, data=body,
headers={"Content-Type": "application/json"})
with urllib.request.urlopen(req, timeout=120) as r:
return json.load(r).get("count")
except Exception:
continue
return None
def rand_words(rng, n):
# Seeded random words tokenize near ~1 token/word and do NOT BPE-compress
# the way repeated filler does — so the prefix actually fills L1.
return [''.join(rng.choice(string.ascii_lowercase)
for _ in range(rng.randint(3, 9))) for _ in range(n)]
def make_prefix(idx, target_tokens):
"""Deterministic per (idx, SALT) -> byte-identical across rounds within a run
(real cache hit) but byte-DIFFERENT across salts (fresh key vs prior runs).
Calibrated against the live tokenizer so token count is ~on target."""
key = idx + SALT_OFF
rng = random.Random(key * 7919 + 13)
sample = " ".join(rand_words(random.Random(key), 400))
c = tokenize_count(sample)
tpw = (c / 400.0) if c else 1.4 # tokens-per-word for this stream
n_words = max(64, int(target_tokens / tpw))
header = f"SESSION-{idx:03d}-salt-{SALT_OFF}-anchor-{key * 7919}\n"
body = header + " ".join(rand_words(rng, n_words))
return body + "\n\nReply with only: OK"
def run_once(prompt):
"""Stream a completion; return (ttft_seconds, prompt_tokens). Mirrors
bench.sh's streaming-TTFT method (first content delta = TTFT)."""
body = json.dumps({
"model": MODEL,
"messages": [{"role": "user", "content": prompt}],
"max_tokens": PMAX,
"temperature": 0.0,
"stream": True,
"stream_options": {"include_usage": True},
"chat_template_kwargs": {"enable_thinking": False},
}).encode()
req = urllib.request.Request(f"{URL}/v1/chat/completions", data=body,
headers={"Content-Type": "application/json"})
t = time.time(); ttft = None; ptok = 0
with urllib.request.urlopen(req, timeout=600) as r:
for line in r:
line = line.decode("utf-8", errors="ignore").rstrip()
if not line.startswith("data: "):
continue
payload = line[6:]
if payload == "[DONE]":
break
try:
chunk = json.loads(payload)
except json.JSONDecodeError:
continue
choices = chunk.get("choices") or []
if choices:
delta = choices[0].get("delta", {})
content = delta.get("content") or delta.get("reasoning_content")
if content and ttft is None:
ttft = time.time() - t
usage = chunk.get("usage")
if usage:
ptok = usage.get("prompt_tokens", ptok)
if ttft is None:
ttft = time.time() - t
return ttft, ptok
print(f"LMCache L1 retention probe -> {URL} model={MODEL}")
print(f" sessions={N} target_tokens/session~={STOK} "
f"warm_threshold={WARM:.1f}s kv={KVKB:.0f}KB/tok\n")
# Build all prefixes ONCE (identical objects reused across both rounds).
print("Building calibrated haystacks (one /tokenize probe each)...")
prefixes = [make_prefix(i, STOK) for i in range(1, N + 1)]
print("\nRound 1 - cold insert (fills L1):")
actual = []
for i, pfx in enumerate(prefixes, 1):
ttft, ptok = run_once(pfx)
actual.append(ptok)
print(f" session {i:2d}: cold TTFT {ttft:7.2f}s (prompt_tokens={ptok})")
tot = sum(actual)
gb = tot * KVKB / 1e6
cap30 = 30e9 / (KVKB * 1e3)
cap60 = 60e9 / (KVKB * 1e3)
print(f"\n inserted ~{tot} tokens ~= {gb:.1f} GB of L1")
print(f" capacity lines: L1=30 -> ~{cap30/1000:.0f}K tok (~30 GB), "
f"L1=60 -> ~{cap60/1000:.0f}K tok (~60 GB)")
if gb < 32:
print(" NOTE: inserted set < ~32 GB — too small to distinguish L1=30 from 60."
" Raise NUM_SESSIONS/SESSION_TOKENS.")
elif gb > 58:
print(" NOTE: inserted set > ~58 GB — may exceed even L1=60. Lower it a touch.")
print("\nRound 2 - retention read (same prefixes; warm = retained):")
warm = 0
for i, pfx in enumerate(prefixes, 1):
ttft, _ = run_once(pfx)
hit = ttft <= WARM
warm += 1 if hit else 0
tag = "WARM retained" if hit else "COLD evicted / re-prefill"
print(f" session {i:2d}: TTFT {ttft:7.2f}s {tag}")
print(f"\n retained warm: {warm}/{N}")
if warm == N:
print("\n PASS - all sessions retained. L1 held the full working set;")
print(" this set would have evicted its earliest sessions at L1=30.")
sys.exit(0)
else:
print(f"\n FAIL - {N - warm} session(s) evicted. Working set exceeded the live L1,")
print(" or L1 is smaller than expected. Check LMCACHE_L1_GB and that")
print(" shm_size >= L1 (else the MP connector falls back to slow pickle).")
sys.exit(1)
PY