Files
club-3090/scripts/bench-agentic.sh
noonghunna 7df200163e feat(bench-agentic): decouple context ramp from tool-call success (#255) (#396)
bench-agentic.sh drove the ramp with tool_choice='required' and RAISED if a
turn returned no parseable tool call → the main loop caught it and broke,
aborting at the first miss. Reachable depth was bounded by tool-call
reliability at depth, not the TURNS/context budget — so on flaky parsers the
ramp stopped below the ~35K DeltaNet degrade zone the producer exists to
characterize (field-observed abort at turn 11 / ~22K).

Fix: on a no-parseable-tool-call turn, synthesize a tool call so the prompt
keeps growing by the same fixed ~chars (the fixture tool_result is injected
regardless and dominates the growth), flag tool_call_missed, and continue.
Genuine transport errors (HTTP/timeout) still propagate and stop the ramp.
Per-turn rows mark misses; a summary line reports "tool-call misses: N/M".

The optional non-tool RAMP_MODE the issue floats is largely subsumed — the
synthesize-on-miss path already lets the ramp reach configured depth on any
engine regardless of tool-call reliability.

Test: scripts/tests/test-bench-agentic-ramp.sh — mock SSE endpoint; asserts the
ramp reaches turn 3 under 100% tool-call misses (counted) AND the success path
is unchanged (no false misses). Offline, no GPU.

Caveat: guarantees reaching configured TURNS; whether the 15-turn fixture
exceeds 35K is separate (extend the fixture if not). Sibling bench-agentic fix
#498 stays separate.

Co-authored-by: noonghunna <10742901+noonghunna@users.noreply.github.com>
Co-authored-by: Claude Opus 4.8 <noreply@anthropic.com>
2026-06-13 18:53:54 +05:00

431 lines
20 KiB
Bash
Executable File
Raw Blame History

This file contains ambiguous Unicode characters

This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.

#!/usr/bin/env bash
#
# Agentic prefill stress benchmark.
#
# Simulates a multi-turn coding-agent session and measures TTFT + decode TPS
# as context accumulates over N turns of tool calls. This is the relevant
# workload for Cline / Cursor / Claude Code — NOT single large prompts.
#
# Why this matters:
# bench.sh uses single-prompt, single-turn requests. It measures decode
# throughput but does NOT stress incremental prefill — the cost that
# dominates every round >5 in a real coding session.
#
# On standard-attention engines, TTFT may stay roughly flat after warmup
# when cached context can be reused. On DeltaNet/SSM hybrid models served
# through vLLM, the recurrent SSM state is not prefix-cacheable, so TTFT can
# grow O(n) with accumulated context even when attention KV caching works.
# Treat this as a per-(engine, arch_class, config) curve-shape producer, not
# a universal cache verdict.
#
# Fixture:
# scripts/fixtures/agentic-bench-fixture.json — 15 turns of real tool
# call results extracted from an actual Claude Code session against this
# repo (filesystem paths redacted). The payload is opaque context used only
# to grow prompt depth; this script does not parse fixture paths or commands
# as live config. Sizes range from 300 chars (ls output) to 35K chars (large
# file reads), reaching ~53K accumulated prompt tokens by turn 15.
#
# Cliff 2 context (vLLM DeltaNet observation — llama.cpp is not affected):
# Qwen3.6-27B is a DeltaNet/Mamba hybrid. The DeltaNet SSM recurrent
# state CANNOT be prefix-cached — it must be recomputed from the full
# sequence on every turn. On one measured vLLM single-card 24 GB
# Qwen3-Next cell, TTFT degraded noticeably above ~35K accumulated tokens
# and requests timed out around ~74K. Treat those as informational
# per-arch_class observations, not universal thresholds.
#
# Ramp robustness (#255):
# The context ramp is driven by tool_choice='required' turns but does NOT
# depend on tool-call success. If the model fails to emit a parseable tool
# call at depth (intermittent on some parsers/configs), the turn synthesizes a
# tool call so the prompt keeps growing by the same fixed ~chars (the fixture
# tool_result is injected regardless) — the miss is logged + counted, but the
# ramp reaches the configured TURNS so the ~35K degrade zone is observed
# regardless of tool-call reliability. Only genuine transport errors
# (HTTP / timeout) stop the ramp. See the per-turn `tool_call_missed` flag and
# the "tool-call misses" summary line.
#
# Output:
# Per-turn table (turn, prompt_tokens, ttft_ms, decode_tps)
# TTFT growth analysis: flat = low incremental prefill; linear = O(n)
#
# Usage:
# bash scripts/bench-agentic.sh
# SESSIONS=3 bash scripts/bench-agentic.sh # 3 sessions for stats
# TURNS=10 bash scripts/bench-agentic.sh # stop at turn 10
# QUIET=1 bash scripts/bench-agentic.sh # suppress per-req lines
#
# Env vars:
# URL Endpoint. Default: auto-detect running service, else bench.sh fallback
# MODEL Served model name. Default: auto-detected from /v1/models
# CONTAINER For GPU + spec-decode log scrape. Default: auto-detect
# SESSIONS Sessions to run (for per-turn TTFT statistics). Default: 2
# TURNS Turns per session (1-15). Default: 12
# QUIET Set to 1 to suppress per-request status lines. Default: 0
set -euo pipefail
ROOT_DIR="$(cd -- "$(dirname -- "${BASH_SOURCE[0]}")/.." && pwd)"
if [[ -f "${ROOT_DIR}/scripts/preflight.sh" ]]; then
# shellcheck source=preflight.sh
source "${ROOT_DIR}/scripts/preflight.sh"
preflight_autodetect_endpoint || true
fi
URL="${URL:-http://localhost:8020}"
MODEL="${MODEL:-}"
CONTAINER="${CONTAINER:-}"
SESSIONS="${SESSIONS:-2}"
TURNS="${TURNS:-12}"
QUIET="${QUIET:-0}"
FIXTURE="${ROOT_DIR}/scripts/fixtures/agentic-bench-fixture.json"
need() { command -v "$1" >/dev/null 2>&1 || { echo "ERROR: '$1' not in PATH." >&2; exit 1; }; }
need curl
need python3
if [[ ! -f "$FIXTURE" ]]; then
echo "ERROR: fixture not found: $FIXTURE" >&2
exit 1
fi
if ! curl -sf "${URL}/v1/models" >/dev/null; then
echo "ERROR: service not reachable at ${URL}/v1/models" >&2
echo " Start with: bash scripts/launch.sh (or bash scripts/switch.sh <variant>)" >&2
exit 1
fi
# Auto-detect model name from API if not explicitly provided
if [[ -z "$MODEL" ]]; then
MODEL=$(curl -sf "${URL}/v1/models" | python3 -c \
"import json,sys; d=json.load(sys.stdin).get('data',[]); print(d[0]['id'] if d else '')" 2>/dev/null || true)
fi
if [[ -z "$MODEL" ]]; then
echo "ERROR: could not detect model name from ${URL}/v1/models — set MODEL=<name>" >&2
exit 1
fi
python3 - "$URL" "$MODEL" "$SESSIONS" "$TURNS" "$QUIET" "$FIXTURE" << 'PYEOF'
import json, sys, time, urllib.request, statistics as s, pathlib
sys.stdout.reconfigure(line_buffering=True) # flush after every \n
URL, MODEL, SESSIONS, TURNS, QUIET, FIXTURE_PATH = sys.argv[1:7]
SESSIONS = int(SESSIONS); TURNS = int(TURNS); QUIET = int(QUIET) == 1
# Load real-session fixtures (tool results from an actual Claude Code session)
FIXTURE = json.loads(pathlib.Path(FIXTURE_PATH).read_text())
# Cap to requested TURNS
FIXTURE = FIXTURE[:TURNS]
# ---------------------------------------------------------------------------
# System prompt + tool schemas (fixed across all turns/sessions so prefix
# caching can warm up after the first turn of the first session).
# ---------------------------------------------------------------------------
SYSTEM = (
"You are an autonomous coding assistant working inside a Python repository. "
"The user is investigating a performance regression. When file contents, "
"search results, or command output would materially change your answer, "
"call the appropriate tool — don't speculate. After each tool call, "
"briefly state what you learned and what your next planned step is. "
"Keep responses concise (under 100 words); defer to tools for raw data.\n\n"
"Repository layout:\n"
" scripts/ — bench, verify, soak, launch helper scripts\n"
" models/ — per-model compose configs + patches\n"
" docs/ — architecture and cliff notes\n"
" BENCHMARKS.md — measured performance numbers\n"
" CHANGELOG.md — version history\n"
)
TOOLS = [
{"type": "function", "function": {
"name": n, "description": d,
"parameters": {"type": "object", "properties": {
"path": {"type": "string"},
"command": {"type": "string"},
"pattern": {"type": "string"},
"recursive": {"type": "boolean"},
}, "required": []}}}
for n, d in [
("Read", "Read a UTF-8 file from the repository."),
("Bash", "Execute a shell command and return stdout+stderr."),
("Edit", "Apply a string replacement edit to a file."),
("Write", "Write or overwrite a file."),
("Grep", "Search for a regex pattern across the codebase."),
("LS", "List files in a directory."),
("TodoRead", "Read the current task/todo list."),
("TodoWrite", "Create or update a task/todo list."),
("WebSearch", "Search the web for information."),
("WebFetch", "Fetch a URL and return the HTML/text."),
]
]
def run_turn(messages, fixture_turn, session_id, turn_idx):
user_msg = fixture_turn["user_msg"]
tool_result_content = fixture_turn["tool_result"]
messages.append({"role": "user", "content": user_msg})
body = json.dumps({
"model": MODEL,
"messages": messages,
"tools": TOOLS,
"tool_choice": "required", # guarantee a tool call every turn
"max_tokens": 150,
"temperature": 0.3,
"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_send = time.time()
ttft = None
completion_tokens = 0
prompt_tokens = 0
content_parts = []
tool_calls_acc = {}
with urllib.request.urlopen(req, timeout=600) as r:
for line in r:
line = line.decode("utf-8", errors="replace").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", {})
if ttft is None and (delta.get("content") or delta.get("tool_calls")):
ttft = time.time() - t_send
if delta.get("content"):
content_parts.append(delta["content"])
for tc in (delta.get("tool_calls") or []):
idx = tc.get("index", 0)
slot = tool_calls_acc.setdefault(idx, {"id": "", "name": "", "args": ""})
if tc.get("id"): slot["id"] = tc["id"]
fn = tc.get("function") or {}
if fn.get("name"): slot["name"] = fn["name"]
if fn.get("arguments"): slot["args"] += fn["arguments"]
usage = chunk.get("usage")
if usage:
completion_tokens = usage.get("completion_tokens", completion_tokens)
prompt_tokens = usage.get("prompt_tokens", prompt_tokens)
t_end = time.time()
wall = t_end - t_send
if ttft is None:
ttft = wall
# Reconstruct assistant message from real tool calls.
# tool_choice=required guarantees at least one; treat empty as a server bug.
tool_calls_response = [
{"id": s["id"] or f"call_t{turn_idx}_s{session_id}_{i}",
"type": "function",
"function": {"name": s["name"], "arguments": s["args"] or "{}"}}
for i, s in sorted(tool_calls_acc.items()) if s["name"]
]
# #255: decouple the context ramp from tool-call success. A turn that fails
# to emit a parseable tool call (intermittent parser flakiness at depth) used
# to abort the whole ramp via RuntimeError — capping reachable depth below
# the ~35K zone this producer exists to characterize. Instead, synthesize a
# tool call so the prompt keeps growing by the same fixed ~chars (the fixture
# tool_result below is injected regardless and is what dominates the growth),
# log + count the miss, and keep going. Genuine transport errors (HTTP /
# timeout) still propagate from urlopen and stop the ramp — you can't grow
# context off a dead request.
tool_call_missed = not tool_calls_response
if tool_call_missed:
tool_calls_response = [{
"id": f"call_t{turn_idx}_s{session_id}_synthetic",
"type": "function",
"function": {"name": TOOLS[0]["function"]["name"], "arguments": "{}"},
}]
# Sanitize: strip lone surrogates that json.dumps would emit as
# invalid \uD800-\uDFFF sequences, causing server-side 400s.
def _clean(s):
return s.encode("utf-8", errors="replace").decode("utf-8")
content = _clean("".join(content_parts)) or None
assistant_msg = {"role": "assistant", "tool_calls": tool_calls_response}
if content:
assistant_msg["content"] = content
messages.append(assistant_msg)
# Inject the fixture tool result onto the model's real tool call IDs.
# The model may have called a different tool than the original session;
# that's intentional — fixed results make TTFT measurements reproducible
# across runs and engines. Only the first call gets the full result; any
# additional calls (rare with max_tokens=150) get a placeholder so the
# context size matches the single-tool-call case.
messages.append({
"role": "tool",
"tool_call_id": tool_calls_response[0]["id"],
"content": tool_result_content,
})
for tc in tool_calls_response[1:]:
messages.append({"role": "tool", "tool_call_id": tc["id"], "content": "(done)"})
decode_s = max(wall - ttft, 1e-6)
decode_tps = completion_tokens / decode_s if completion_tokens > 0 else 0
return {
"ttft_ms": ttft * 1000,
"wall_ms": wall * 1000,
"decode_tps": decode_tps,
"completion_tokens": completion_tokens,
"prompt_tokens": prompt_tokens,
"tool_calls": len(tool_calls_response),
"result_chars": len(tool_result_content),
"tool_call_missed": tool_call_missed,
}
# ---------------------------------------------------------------------------
# Run sessions and collect per-turn metrics
# ---------------------------------------------------------------------------
per_turn_metrics = [[] for _ in range(TURNS)]
tool_call_misses = 0 # #255: turns where the model emitted no parseable tool call
for session in range(1, SESSIONS + 1):
print(f"\n{'='*72}")
print(f"SESSION {session}/{SESSIONS} — {TURNS} turns, context grows to ~{sum(f['chars'] for f in FIXTURE)//4:,} tokens")
print(f"{'='*72}")
print(f" {'Turn':<5} {'Prompt tok':>10} {'TTFT ms':>9} {'Decode TPS':>11} {'Result chars':>13}")
print(f" {'-'*5} {'-'*10} {'-'*9} {'-'*11} {'-'*13}")
messages = [{"role": "system", "content": SYSTEM}]
for turn_idx in range(TURNS):
fixture_turn = FIXTURE[turn_idx]
try:
m = run_turn(messages, fixture_turn, session, turn_idx)
per_turn_metrics[turn_idx].append(m)
if m.get("tool_call_missed"):
tool_call_misses += 1
if not QUIET:
miss = " ⚠ tool-call miss (synthetic result injected)" if m.get("tool_call_missed") else ""
print(f" {turn_idx+1:<5} {m['prompt_tokens']:>10,} {m['ttft_ms']:>9.0f} "
f"{m['decode_tps']:>11.1f} {m['result_chars']:>13,}{miss}", flush=True)
except Exception as e:
print(f" turn {turn_idx+1}: FAIL — {e}", flush=True)
break
# ---------------------------------------------------------------------------
# Summary table: per-turn means across sessions
# ---------------------------------------------------------------------------
print(f"\n\n{'='*72}")
print(f"SUMMARY — multi-turn prefill stress ({SESSIONS} session(s) × {TURNS} turns)")
print(f"{'='*72}")
if tool_call_misses:
turns_run = sum(len(x) for x in per_turn_metrics)
print(f" tool-call misses: {tool_call_misses}/{turns_run} turns — ramp continued via "
f"synthetic results (#255); depth/curve unaffected, but tool-call reliability is "
f"degraded at depth on this config.")
print(f" {'Turn':<5} {'Prompt tok':>10} {'TTFT ms':>9} {'σ ms':>6} {'Decode TPS':>11} Notes")
print(f" {'-'*5} {'-'*10} {'-'*9} {'-'*6} {'-'*11} {'─'*35}")
# Warm baseline: turn 1's TTFT includes cold-start (engine compile / cudagraph
# capture / first-token warmup) and is NOT a steady-state datapoint, so the
# growth analysis anchors to the first WARM turn (turn 2) when >=3 turns ran —
# matching the repo's warm-up-then-measure bench protocol. With <3 turns we
# cannot exclude warm-up and fall back to turn 1.
contiguous = []
for turn_idx in range(TURNS):
if per_turn_metrics[turn_idx]:
contiguous.append(turn_idx)
else:
break
active_turns = len(contiguous)
anchor_pos = 1 if active_turns >= 3 else 0
baseline_idx = contiguous[anchor_pos] if contiguous else None
baseline_ttft = (s.mean([m["ttft_ms"] for m in per_turn_metrics[baseline_idx]])
if baseline_idx is not None else None)
cold_idx = contiguous[0] if (contiguous and anchor_pos > 0) else None
for turn_idx in contiguous:
ms_list = per_turn_metrics[turn_idx]
ttfts = [m["ttft_ms"] for m in ms_list]
tpss = [m["decode_tps"] for m in ms_list if m["decode_tps"] > 0]
ptoks = [m["prompt_tokens"] for m in ms_list]
mean_ttft = s.mean(ttfts)
std_ttft = s.stdev(ttfts) if len(ttfts) > 1 else 0
mean_tps = s.mean(tpss) if tpss else 0
mean_ptok = s.mean(ptoks)
note = ""
if turn_idx == cold_idx:
note = "cold-start (compile/warmup — excluded from growth)"
elif turn_idx == baseline_idx:
note = "warm baseline"
elif baseline_idx is not None and turn_idx > baseline_idx and baseline_ttft and mean_ttft > 0:
ratio = mean_ttft / baseline_ttft
if ratio > 4.0:
note = f"⚠ TTFT {ratio:.1f}× warm-baseline (O(n)-like growth for this arch_class)"
elif ratio > 2.0:
note = f"↑ TTFT {ratio:.1f}× warm-baseline"
elif ratio > 1.4:
note = f"~ TTFT {ratio:.1f}× warm-baseline"
print(f" {turn_idx+1:<5} {mean_ptok:>10,.0f} {mean_ttft:>9.0f} {std_ttft:>6.0f} {mean_tps:>11.1f} {note}")
# TTFT growth analysis — anchored to the first warm turn (cold-start excluded)
if baseline_idx is not None and contiguous[-1] != baseline_idx:
last_idx = contiguous[-1]
first_ttft = baseline_ttft
last_ttft = s.mean([m["ttft_ms"] for m in per_turn_metrics[last_idx]])
first_ptok = s.mean([m["prompt_tokens"] for m in per_turn_metrics[baseline_idx]])
last_ptok = s.mean([m["prompt_tokens"] for m in per_turn_metrics[last_idx]])
cold_ttft = (s.mean([m["ttft_ms"] for m in per_turn_metrics[cold_idx]])
if cold_idx is not None else None)
ttft_growth = last_ttft / first_ttft if first_ttft > 0 else 0
token_growth = last_ptok / first_ptok if first_ptok > 0 else 0
print(f"\n{'─'*72}")
print(f" TTFT growth by accumulated context ({active_turns} turns, {SESSIONS} sessions):")
if cold_ttft is not None:
print(f" Turn 1 (cold): {cold_ttft:>8.0f} ms TTFT — compile/warmup, excluded from growth")
print(f" Turn {baseline_idx+1} (warm base): {first_ttft:>8.0f} ms TTFT @ {first_ptok:,.0f} prompt tokens")
print(f" Turn {last_idx+1}: {last_ttft:>8.0f} ms TTFT @ {last_ptok:,.0f} prompt tokens")
print(f" Context grew {token_growth:.1f}×, TTFT grew {ttft_growth:.1f}× (warm baseline → last turn)")
if ttft_growth <= 1.5:
print(" ✓ TTFT stable across the measured range for this engine/arch/config cell.")
elif ttft_growth <= token_growth * 0.5:
print(f" ~ TTFT sub-linear for this cell ({ttft_growth:.1f}× vs {token_growth:.1f}× context).")
elif ttft_growth <= 2.5:
print(f" ↑ TTFT grew {ttft_growth:.1f}× (vs {token_growth:.1f}× context) for this cell.")
else:
print(f" ⚠ TTFT grew near-linearly — O(n)-like accumulated-context cost for this cell.")
print(f" (Full-context O(n) growth would approach {token_growth:.1f}× with context)")
print(f"")
print(f" Note — DeltaNet/SSM state is NOT prefix-cacheable on vLLM Qwen3-Next cells.")
print(f" Attention KV caching can still work, but recurrent-state recomputation scales")
print(f" O(n) with sequence length. Prior single-card 24 GB vLLM Qwen3-Next observations")
print(f" saw degradation above ~35K tokens and timeouts around ~74K; treat those as")
print(f" informational per-arch_class guideposts. llama.cpp is not affected.")
PYEOF
# GPU state
if command -v nvidia-smi >/dev/null 2>&1; then
echo ""
echo "=== GPU state ==="
nvidia-smi --query-gpu=index,utilization.gpu,memory.used,memory.total,power.draw,temperature.gpu \
--format=csv,noheader
fi
# MTP / spec-decode stats
if command -v docker >/dev/null 2>&1 && docker inspect "${CONTAINER}" >/dev/null 2>&1; then
echo ""
echo "=== Last 3 SpecDecoding metrics ==="
docker logs "${CONTAINER}" 2>&1 | grep "SpecDecoding metrics" | tail -3 || true
fi