A reusable benchmark harness for measuring inference-cache performance and iterating on cache-policy tuning. Built on top of sgl-project/genai-bench.
genai-bench is great at measuring LLM serving performance — TTFT, throughput, latency distributions — and it natively supports prefix-cache-friendly workloads via --prefix-len. But it has no visibility into the inference-cache routing decision or server-side state (hit rate, eviction, index utilization).
This harness adds a thin layer that:
- Routes traffic through inference-cache's
LookupRoutegRPC when measuringlookupmode via a minimal client (lib/dumb_gateway_client.py, CAC-152) that mirrors what a production gateway does — tokenize, hash, ask, route. genai-bench alone can't call gRPC services. - Scrapes the inference-cache server's
/metricsin parallel with the run - Merges genai-bench's per-request data with the server-side state into a single comparison report
- Supports the tuning loop: apply a CRD change → re-run → compare two labeled runs
| Use case | Tool |
|---|---|
Day-to-day tuning of CachePolicy / CacheBackend / PromptTemplate CRs |
this harness |
| Comparing two CRD configurations side-by-side | this harness |
| Reproducing a customer's reported TTFT issue | this harness |
| Multi-model sweep with consistent methodology | this harness |
| Pure LLM throughput / latency measurement (no cache plane involved) | genai-bench directly |
.
├── README.md # this file
├── run_tuning_bench.sh # entry point — run / compare / list-scenarios / clean
├── lib/
│ ├── dumb_gateway_client.py # production-shaped HTTP gateway (default; CAC-152)
│ ├── lookup_proxy_legacy.py # deprecated chain-walking proxy (kept one release for A/B)
│ ├── collect_ic_metrics.py # scrapes inference-cache /metrics over time
│ └── correlate.py # merges genai-bench + ic-metrics → markdown report
├── scenarios/
│ ├── rag-headline.yaml # RAG-style workload with long shared prefix
│ ├── tuning-loop.yaml # short, cheap iteration scenario
│ └── chat-multi-turn.yaml # chat workload — shorter prefix, more concurrency
└── scripts/
├── oci-session-refresher.sh # keeps OCI session + kubectl PFs alive
└── oci-session-refresher.conf.example
The benchmark client (lib/dumb_gateway_client.py) is intentionally minimal:
tokenize the prompt, hash blocks content-addressably, ask the server, route.
The server makes routing decisions; the gateway follows. No chain table,
no ZMQ subscriptions, no per-replica state — exactly what a production gateway
would do.
This is the "we decide routing; the gateway follows" principle (see the main
inference-cache project's design docs). The deprecated lookup_proxy_legacy.py
re-implemented chain-walking inside the harness; that drift hid server-side
ranking bugs (CAC-150) and skewed Phase 2 results. The new client makes the
benchmark a faithful proxy for what production will do.
# 1. Python deps
make install
# (or: pip install genai-bench grpcio grpcio-tools aiohttp pandas pyyaml requests)Plus on the runner host: kubectl, yq, jq, make.
You'll also need:
-
A Kubernetes cluster with inference-cache installed and a running vLLM workload wired into a
CacheBackend. See the main repo's getting-started guide for setup. -
Generated proto stubs for inference-cache's gRPC API. From this directory, with the main inference-cache repo checked out as a sibling:
make proto # → regenerates proto/ from ../inference-cache/proto/Override the source path if it lives elsewhere:
make proto INFERENCE_CACHE_REPO=/abs/path/to/inference-cache
-
Port-forwards. The harness needs reachability to the inference-cache server and to vLLM. Read the gotcha below before choosing PF targets.
For the inference-cache server (single replica —
svc/...is fine):kubectl -n inference-cache port-forward svc/inference-cache-server 38001:8080 & # /metrics kubectl -n inference-cache port-forward svc/inference-cache-server 38002:9090 & # gRPC
For vLLM, always use per-pod port-forwards when the deployment has more than one replica — see the
lookupmode setup below. That covers--mode lookupand--mode no-hint(the client round-robins fallback traffic across all pods).For
--mode baselineagainst a single-replica vanilla vLLM,svc/...is acceptable:kubectl -n <baseline-ns> port-forward svc/<vanilla-vllm-svc> 38005:8000 &
Override the defaults via env vars (
IC_SERVER_METRICS,IC_SERVER_GRPC,VLLM_ENGINE_URL,VLLM_BASELINE_URL) — seerun_tuning_bench.shheader.Verify everything is reachable before a real run:
make check-paths
⚠️ kubectl port-forward svc/<name>does NOT load-balance. The apiserver picks one endpoint at PF-establish time and pins every connection on that port to that endpoint until the PF dies. Across multiple PF sessions over 36 h on theic-smokedeployment (CAC-163), one pod accumulated 0 requests while siblings served millions — the kube-proxy/iptables layer was fine; the imbalance came entirely from PF pinning. Any measurement that depends on traffic spreading across replicas (cache distribution, per-pod hit rate, replica-balance studies) must use per-pod PFs throughdumb_gateway_client.py's explicit round-robin (LOOKUP_PROXY_REPLICAS, set up in the lookup-mode section below). Usesvc/...PF only for single-replica targets, or for local connectivity smoke tests where you don't care which pod answers.The harness includes an automatic post-run check (
lib/check_pod_distribution.py) that scrapes per-podvllm:prefix_cache_queries_totalbefore and after the run and warns loudly if any pod received zero traffic while siblings got some — pointing at exactly this misconfiguration.
kubectl port-forward dies silently for many reasons — server-side connection timeouts, OCI VPN blips, OKE API restarts. On OCI specifically, the OCI session token also expires on its own cadence, and once it does every kubectl (including any subsequent port-forward) starts failing with an auth error. A run that takes 4 hours in real time will routinely hit one of these.
scripts/oci-session-refresher.sh is a long-running watchdog that handles both:
- refreshes the OCI session token (
oci session refresh --profile $PROFILE) every$INTERVALseconds, - checks each declared local port with
lsof -ti tcp:$port -sTCP:LISTEN, and - for any port that's no longer LISTEN, kills the stale
kubectlprocess holding that port (if any) and re-runskubectl port-forwardfor it.
Each tick logs one summary line — tick OK — PFs alive: 12/12 in steady state, or tick OK — PFs alive: 12/12 — restored: 38010 15001 after a restore. Per-port kubectl output goes to $PF_LOG_DIR/pf-<port>.log so you can dig into why a particular PF won't stay up.
# 1. Copy the example config and edit PROFILE / KUBECONFIG / PF_SPECS for your cluster:
cp scripts/oci-session-refresher.conf.example ~/.oci-session-refresher.conf
$EDITOR ~/.oci-session-refresher.conf
# 2. Launch detached. Output goes to wherever you redirect; logs are per-tick.
nohup scripts/oci-session-refresher.sh > /tmp/oci-session-refresher.log 2>&1 &
disown
# 3. Tail the log to watch tick state:
tail -f /tmp/oci-session-refresher.logFor pod-targeted PFs whose pod names can change after a restart (e.g. the per-replica HTTP/ZMQ ports used by lookup mode), the config can define a resolve_pf_specs shell function that's called each tick to rebuild PF_SPECS from kubectl get pod output. See the commented-out block at the bottom of the example config for the pattern.
The benchmark orchestrator also uses the refresher directly after rollout restarts:
FORCE_RESTART_PFS=1 scripts/oci-session-refresher.sh --once ~/.oci-session-refresher.confThat one-shot mode tears down and recreates every configured kubectl port-forward immediately, so per-pod forwards reconnect to the new pods before the next measured run.
To validate restore behavior end-to-end without waiting on a real PF to die: launch the refresher with INTERVAL=10, find a kubectl PF process with pgrep -fl 'kubectl.*port-forward', kill -9 it, and watch the next tick log a — restored: <port> entry.
./run_tuning_bench.sh run \
--scenario rag-headline \
--label "current" \
--mode lookupProduces results/current-<timestamp>/:
genai-bench/— raw genai-bench experiment dir (Excel, plots, per-request JSON)ic-metrics.csv— inference-cache metrics scraped every Nsreport.md— correlated comparison vs. acceptance criteriacrd-snapshot.yaml— CRDs at run time (so futurecompareruns can diff)scenario.yaml— the scenario YAML used (verbatim copy)dataset.txt— dataset fingerprint (path, SHA-256, line count, first-prompt head, generator config) — only written when the scenario usesdataset_pathcluster-state.yaml— pod + node + event snapshot for the run window (see below)kubectl-top-pods-start-<namespace>.txt/kubectl-top-pods-end-<namespace>.txt— best-effortkubectl top podsnapshots for the configuredCLUSTER_STATE_TARGETSnamespacesvllm-metrics-before.csv/vllm-metrics-after.csv— one-shot pre/post vLLM metric scrapes for exact T1/T2 counter deltas when vLLM endpoints are configured
When a pod restarts mid-run (lm-smoke OOM, vllm-engine rolling restart, kubelet
eviction, etc.), the bench result on its own gives no signal about why things
went sideways — by the time you go looking, the cluster state that explained it
is gone. cluster-state.yaml is the smallest artifact that lets you do
post-mortem days later without a live cluster.
Per run, the harness captures:
pods_start/pods_end— name, namespace, pod_uid, node, age, andrestart_countfor the configured targets at the run boundaries. Two comparisons matter:pod_uidchanges mean the pod was deleted+recreated;restart_countdeltas mean a container restarted in place.pod_changes— a denormalised summary of those deltas so the headline ("lm-smoke-yyy was restarted") is visible without diffing the two lists.events— pod-level events (OOMKilled,BackOff,FailedScheduling, …) from the configured namespaces that fall inside the run window. Pulled viakubectl get events --field-selector involvedObject.namespace=<ns>.nodes— capacity, allocatable, and pressure conditions for every node hosting one of the captured pods.
The kubectl-top-pods-* files pair with the restart-count snapshot: restart
deltas show pod churn, while kubectl top pod gives a quick signal for
run-window CPU/memory throttling or memory pressure.
Which pods to capture is controlled by CLUSTER_STATE_TARGETS, a
comma-separated list of <namespace>:<name-prefix> pairs. The default targets
the Phase 2 setup:
CLUSTER_STATE_TARGETS="ic-smoke:vllm-engine,ic-smoke:lm-smoke,gpu-baseline:vllm-baseline"
Override it for a different workload layout — e.g. a Qwen sweep:
export CLUSTER_STATE_TARGETS="ic-smoke:qwen-vllm,gpu-baseline:qwen-baseline"
./run_tuning_bench.sh run --scenario rag-headline --label qwen-trial --mode lookupEvents are pulled from the unique namespaces in CLUSTER_STATE_TARGETS
unless you set CLUSTER_STATE_EVENTS_NS explicitly (also comma-separated).
All kubectl calls are best-effort: any failure (no cluster context, missing
namespace, RBAC denial) is logged in yellow and the run continues. The merged
yaml will contain an error: field instead of pod data.
# 1. Baseline measurement with current CRDs
./run_tuning_bench.sh run --scenario tuning-loop --label "ttl-60s" --mode lookup
# 2. Change a CRD field
kubectl -n <ns> patch cachepolicy <name> --type=merge -p '{"spec":{"evictionTTL":"5m"}}'
# 3. Wait for steady state, re-measure
sleep 60
./run_tuning_bench.sh run --scenario tuning-loop --label "ttl-5m" --mode lookup
# 4. Compare
./run_tuning_bench.sh compare ttl-60s ttl-5m
# → results/compare-ttl-60s-ttl-5m-<timestamp>.mdfor mode in baseline no-hint lookup; do
./run_tuning_bench.sh run --scenario rag-headline --label "v1-$mode" --mode "$mode"
done
./run_tuning_bench.sh compare v1-baseline v1-no-hint v1-lookupFor the brown-bag headline benchmark, three new scenarios live alongside
rag-headline (full details in scenarios/README.md):
| Scenario | Working set | What it shows |
|---|---|---|
rag-multi-context |
1.1M tokens (200 × 5500) | Moderate WS: baseline thrashes T1, 3-pod spread fits — routing affinity wins |
cache-stress-extreme |
7M tokens (1000 × 7000) | T1+T2 hierarchy under stress; graceful degradation |
perfect-storm-rag |
1.2M tokens (150 × 8000) | Maximum-delta showcase — steady-state hit rate ≈ 99% in lookup mode |
Each scenario specifies four concurrency points ([4, 8, 16, 32]); the
scripts/phase3-sweep.sh orchestrator runs all three across the three modes
(baseline / no-hint / lookup) and four iterations (cold, warm-1,
warm-2, warm-3). Before each scenario/mode group it does a mode-boundary
reset: restart the relevant deployment(s), force configured port-forwards to
reconnect through scripts/oci-session-refresher.sh --once, and run a tiny
warm-start (MODE_WARMUP_REQUESTS, default 5) before the first measured
iteration. Warm iterations within the same mode do not restart pods; they
reuse the previous iteration's state to capture warmup → steady-state
transitions.
# Generate datasets first (one-time):
python scenarios/datasets/gen_rag_multi_context.py
python scenarios/datasets/gen_cache_stress.py \
--num-prefixes 1000 --questions-per-prefix 3 --words-per-prefix 5500 \
--output scenarios/datasets/cache_stress_extreme.txt
python scenarios/datasets/gen_perfect_storm_rag.py
# Run the full 36-bench matrix (3 scenarios × 3 modes × 4 iters):
scripts/phase3-sweep.sh
# Or a subset — useful for rerunning after a failure:
scripts/phase3-sweep.sh --scenarios "perfect-storm-rag" --iters "cold warm-1"
# Dry-run (echo commands without executing):
scripts/phase3-sweep.sh --dry-run
# Disable mode-boundary restarts/PF rebuilds/warmup for a targeted rerun:
scripts/phase3-sweep.sh --skip-mode-reset --scenarios "perfect-storm-rag" --iters "warm-2"
# Build comparison reports only (skips re-running):
scripts/phase3-sweep.sh --compare-onlyInterpreting the output:
- Each iteration writes a
results/phase3-<scenario>-<mode>-<iter>-<ts>/directory with the usualreport.md,ic-metrics.csv,vllm-metrics.csv(Phase 3), one-shotvllm-metrics-before/after.csv,kubectl-top-pods-*,cluster-state.yaml, etc. - The orchestrator builds a three-way comparison per scenario at the end
(
results/compare-phase3-<scenario>-{baseline,no-hint,lookup}-cold-<ts>.md), using thecolditers as the headline numbers. - The headline is the delta between modes within a single scenario, not absolute numbers across scenarios.
- Warm iters live alongside the cold runs for tail-analysis; load them
manually with
./run_tuning_bench.sh compare <label1> <label2>if you want a warmup-vs-steady-state view.
Per-pod vLLM metrics (vllm-metrics.csv) are scraped automatically when
VLLM_METRICS_ENDPOINTS is set or derivable from LOOKUP_PROXY_REPLICAS —
baseline mode auto-derives one endpoint from VLLM_BASELINE_URL. Each
report.md now includes a "Throughput (per concurrency)" section and a
"vLLM aggregate throughput" section computed from those CSVs.
| Mode | What it points genai-bench at | What it tests |
|---|---|---|
baseline |
Vanilla vLLM, no cache plane at all (VLLM_BASELINE_URL) |
Reference TTFT — single pod, no cache plane |
no-hint |
dumb_gateway_client.py --routing-mode=round-robin in front of cache-enabled vLLM |
Cache plane up, routing layer disabled — every request round-robins across all configured LOOKUP_PROXY_REPLICAS. Isolates the LMCache wrapper's contribution from any LookupRoute-driven routing benefit. (CAC-153) |
lookup |
dumb_gateway_client.py --routing-mode=lookup in front of cache-enabled vLLM |
Full system — the client calls LookupRoute and routes to the hinted replica on PREFIX_MATCH, otherwise round-robins across LOOKUP_PROXY_REPLICAS (CAC-154) |
no-hint requires LOOKUP_PROXY_REPLICAS to be set so the client has multiple upstreams to round-robin across — with the env var unset both modes fail to start (the warning prints in that case). See Setting up lookup mode below for how to populate LOOKUP_PROXY_REPLICAS. The same env var serves both modes; no-hint only uses the HTTP-URL field of each entry (the ZMQ field is parsed but ignored, and the tokenizer / --ic-server are not loaded).
The client is the bit that mediates between genai-bench's "single endpoint URL" assumption and inference-cache's "list of replicas + a routing hint" model. Without the client, genai-bench can't exercise the routing-decision path and can't spread traffic across replicas — which is why no-hint also routes through the client (just with the LookupRoute RPC short-circuited).
The new client (lib/dumb_gateway_client.py) follows the production gateway
contract exactly — no chain table, no ZMQ, no per-replica state:
incoming HTTP request
↓
tokenize prompt (HF AutoTokenizer; Llama-3.1 chat template for chat-completions)
↓
chunk into block-size-token blocks (default 16) and emit content-addressed
sha256-truncated block hashes (parent_hash || token_bytes → 8-byte digest)
↓
send LookupRoute(tenant, model, block_hashes, block_token_counts)
↓
PREFIX_MATCH → route to replica_scores[0].replica_id's HTTP URL
NO_HINT / TIMEOUT / TENANT_HOT / unknown replica id → round-robin across
--replica pool (CAC-154)
Two key consequences:
-
Hashes are content-addressed and reproducible across processes. Same prompt on any pod (or in the client) yields the same chain. No need to subscribe to engine events to reconstruct the index — the server matches the client-computed chain directly against its
(tenant, model, hash_scheme)partition. This is the whole reason the chain-walking proxy went away. -
The server makes the routing decision. The client doesn't pick "the longest match" or "the highest-scoring replica" — it forwards the server's pick verbatim. CAC-149's chat-template matched-tokens floor and CAC-151's distinguishing-power scoring both live server-side and now drive routing directly, without the harness filtering them through chain-walk logic.
--replica repeated: the replica pool — id=url (preferred) or legacy
id|zmq|http_url[|router] (zmq + router fields parsed for backward compat
with LOOKUP_PROXY_REPLICAS and ignored). Per-process round-robin pointer:
30 fallback picks across 3 URLs produces an exact 10/10/10 split (CAC-154).
--routing-mode={lookup,round-robin}: clean lever for both bench modes
from one binary (CAC-153). lookup is the production path; round-robin
skips the RPC entirely — tokenizer not loaded, gRPC channel not opened.
Cold-start behavior: until the server's index has seen this (tenant, model, hash_scheme) partition populated with matching chains, requests get
NO_HINT and are routed round-robin. With CAC-149's Llama chat-template
floor active, the framing tokens alone produce PREFIX_MATCH responses even
on a cold index, so warmup looks smoother than under the old chain-walking
proxy. This mirrors how the real production gateway will behave.
You need a port-forward to the HTTP serve port of each replica:
| Per-replica need | What it's for |
|---|---|
HTTP serve port (:8000 on the pod) |
Where the client forwards requests (PREFIX_MATCH hint or round-robin fallback) |
No ZMQ port-forward is needed — the dumb client doesn't subscribe to engine
events. The tcp://... field in LOOKUP_PROXY_REPLICAS is parsed for
backward compat and ignored. (If you also run USE_LEGACY_PROXY=1 to A/B
against the deprecated chain-walking proxy, the ZMQ PFs are required for
that mode — see the Legacy proxy comparison section.)
For a 3-replica deployment in namespace ic-smoke:
# List the pod names
PODS=($(kubectl -n ic-smoke get pod -l app=vllm-engine -o jsonpath='{.items[*].metadata.name}'))
# HTTP port-forwards (38010, 38011, 38012)
kubectl -n ic-smoke port-forward pod/${PODS[0]} 38010:8000 &
kubectl -n ic-smoke port-forward pod/${PODS[1]} 38011:8000 &
kubectl -n ic-smoke port-forward pod/${PODS[2]} 38012:8000 &
# Tell the harness about them. The ZMQ field (`tcp://...`) is kept in the
# env var format for backward compat with the legacy proxy; the dumb client
# parses and ignores it.
export LOOKUP_PROXY_REPLICAS="r0|tcp://unused|http://localhost:38010,r1|tcp://unused|http://localhost:38011,r2|tcp://unused|http://localhost:38012"
# Pick the tokenizer matching what the served model expects (default:
# unsloth/Meta-Llama-3.1-8B-Instruct). MUST match the engine's tokenizer
# or the block hashes won't agree with what the engine commits to its
# index — see lib/dumb_gateway_client.py docstring.
export LOOKUP_PROXY_TOKENIZER="unsloth/Meta-Llama-3.1-8B-Instruct"Then run normally:
./run_tuning_bench.sh run --scenario rag-headline --label test-dgw --mode lookupPer-request routing decisions are logged to <results_dir>/dumb_gateway_client.log. Each response carries X-Cache-Lookup-Reason and X-Cache-Route-Reason headers for visibility.
Each route_decision log line includes a match_quality field bucketed from the chain length the client computed:
| Bucket | Chain blocks | Tokens (approx.) | Meaning |
|---|---|---|---|
trivial |
1 | ~16 | chat-template framing only — server's CAC-149 floor returns PREFIX_MATCH here regardless of replica state; routing benefit is minimal |
weak |
2 – 7 | 32 – 112 | small shared prefix |
strong |
8+ | 128+ | meaningful prefix reuse — the routing benefit lands here |
Reading the log distribution by bucket tells you what fraction of PREFIX_MATCH responses actually drove routing benefit. Pairs with the server-side inferencecache_lookup_route_floor_match_total counter from CAC-149.
For runtime stats (per-mode counters, per-replica round-robin counts):
curl -s http://localhost:18100/proxy/metrics | jqThe previous chain-walking proxy lives on as lib/lookup_proxy_legacy.py for
one release to let operators A/B compare its routing decisions against the
dumb client's. To run a bench with it instead of the new client:
USE_LEGACY_PROXY=1 ./run_tuning_bench.sh run \
--scenario rag-headline --label legacy-comparison --mode lookupThe legacy mode requires ZMQ port-forwards (:5557 per pod) in addition to
the HTTP ones — see git history for the original setup. It will be removed
in the next release. New work should rely on the dumb client; the legacy
proxy is retained only for cross-checks during the transition.
- Cold start: until the server's index has matching chains for this
(tenant, model, hash_scheme)partition, requests get NO_HINT and round-robin. CAC-149's chat-template floor returns PREFIX_MATCH on framing tokens alone, so warmup is smoother than under the legacy proxy. - One LookupRoute per request: 500ms default client-side timeout. Failing
open on timeout falls back to round-robin (no retry). Tune via
--lookup-timeoutif you need a different budget. - Hash-scheme agreement: the engine's kvevent-subscriber must populate the
index under the same
--hash-schemetag the client sends (defaultvllm) AND use the same content-addressed block-hashing algorithm. If they disagree, the server returns NO_HINT for non-trivial prefixes and falls through to the CAC-149 chat-template floor — you'll see PREFIX_MATCH for framing only.
Every run that uses a dataset_path scenario gets a dataset.txt in its
result dir capturing exactly which dataset bytes were used. To re-run a
historical comparison from scratch:
- Read
results/<label>-<ts>/dataset.txt. It lists the dataset's SHA-256, line count, and thegenerator_config:block (num_prefixes,questions_per_prefix,words_per_prefix, RNG seeds). - Regenerate the dataset with those exact knobs:
Generators are seeded, so the output is byte-identical across runs with the same args. Confirm by re-hashing:
python scenarios/datasets/gen_cache_stress.py \ --num-prefixes 600 \ --questions-per-prefix 5 \ --words-per-prefix 5000
shasum -a 256 scenarios/datasets/cache_stress.txt # → must match the sha256: field in dataset.txt - Re-run the harness with the same scenario and the rest of the harness
config (CRDs, replicas, etc.) restored to what
crd-snapshot.yamlrecords.
If a result dir is missing dataset.txt it predates CAC-159; use
tools/backfill_dataset_meta.sh to write the fingerprint based on the
current dataset (the script flags this with a NOTE: line since the
on-disk dataset may differ from what the run actually used).
| CLI flag | Default | Meaning |
|---|---|---|
--num-prefixes |
600 | Distinct prefix blocks |
--questions-per-prefix |
5 | Questions appended per prefix (capped at 10) |
--words-per-prefix |
5000 | ≈ 1.4× tokens for Llama tokenizer |
--prefix-seed |
cache_stress_prefix |
Per-block RNG seed prefix |
--shuffle-seed |
cache_stress_shuffle |
Final shuffle seed |
--output |
scenarios/datasets/cache_stress.txt |
Output path |
The generator also writes <output>.meta.yaml alongside the dataset; the
harness embeds this in each run's dataset.txt so the knobs travel with the
result.
Historical sizings used in Phase 2 (recorded in CAC-159 for context):
600 × 5 × 5000— current default, CAC-139 forced-eviction sizing (~100 MB, 3.3M prefix tokens)200 × 5 × 5000— reverted 5-iter rerun sizing (~34 MB, 1.4M prefix tokens)50 × 10 × 1100— original phase-1 sizing (~3 MB, 75K prefix tokens)
name: rag-headline
description: |
RAG-style workload with a 1500-token shared prefix and a ~500-token unique
suffix. The launch-claim measurement.
genai_bench:
task: text-to-text
api_backend: openai
model: vllm-model
tokenizer: meta-llama/Llama-3.1-8B-Instruct
traffic_scenarios:
- "D(2000,200)" # input,output tokens (deterministic)
prefix_len: 1500 # 75% prefix sharing
num_concurrency: [1, 4]
max_requests_per_run: 200
max_time_per_run: 5 # minutes
ic_metrics:
scrape_interval_s: 10
capture:
- inferencecache_lookup_route_calls_total
- inferencecache_lookup_route_latency_seconds
- inferencecache_index_entries
- inferencecache_index_evictions_total
acceptance:
ttft_p50_max_ms: 80
lookup_hit_rate_pct_min: 70
cold_path_overhead_max_ms: 5Copy rag-headline.yaml as the template for new scenarios. See docs.sglang.io/genai-bench/user-guide/scenario-definition for the full set of traffic_scenarios syntax.
Single-run report.md:
- Header: scenario, label, timestamp, CRD snapshot reference
- TTFT distribution: p50/p95/p99 (from genai-bench)
- Throughput: tokens/sec, requests/sec
- inference-cache view: hit rate, eviction rate, server-side lookup latency p99, index entries
- Acceptance gate: each criterion PASS/FAIL with the measured value
- Sanity checks: errors, index population
compare-<A>-<B>.md for comparisons:
- Side-by-side table with delta column
- CRD diff (
diff -u) between the two runs - Honest "deciding if delta beats noise floor is your call" verdict
- Multi-knob changes between runs. If you
kubectl applytwo CRD changes at once, the comparison can't tell you which won. The harness can't enforce this — discipline lives in your head. - Comparing single-run results. Run each label 3× and take the median if anything close to noise floor. genai-bench's
--max-time-per-runmakes this cheap. - Ignoring the acceptance gate. If acceptance fails, the report says so. Don't paper over it.
- Cherry-picking concurrency levels. Report all sweep points; never just the one that looks good.
- Tune CRDs for you. That's future work (controller-side tuning recommendations).
- Generate per-customer workloads. Use real customer datasets via the genai-bench
--dataset-pathoption; configure in the YAML. - Run in CI. This is a manual tuning instrument. Use lower-level test scripts for regression gates.
- Test failure modes. That's a separate operational-validation effort.
PRs welcome. New scenarios should follow the YAML schema above and include a short description block explaining when to use them. The proxy's hash-scheme is configurable via --hash-scheme — match it to your engine's kvevent-subscriber configuration.
Apache License 2.0. See LICENSE.