Skip to content

Commit e80dc59

Browse files
chore(bench): chat prompt-eval benchmark (bench-chat.mjs)
Isolates prompt-eval cost: tiny output, growing input, measures time-to-first-token + a cold-vs-warm cache test. Used to root-cause the Windows chat slowness — proved prompt-eval is ~1.3s/1k tokens and the KV cache gives 15x only on a stable prefix (which led to finding capture was hammering the single engine on Windows). Companion to scripts/bench-capture.mjs.
1 parent ef2dfcb commit e80dc59

1 file changed

Lines changed: 130 additions & 0 deletions

File tree

scripts/bench-chat.mjs

Lines changed: 130 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,130 @@
1+
#!/usr/bin/env node
2+
// Does a bigger conversation history actually slow each turn? Isolate PROMPT-eval cost: hold the
3+
// OUTPUT tiny (max_tokens=16) and grow the INPUT (a filler "history") across sizes, measuring
4+
// time-to-first-token (TTFT ≈ prompt eval + 1 token) and the reported prompt_tokens. If TTFT
5+
// scales with prompt tokens, the "2nd response onwards is slower" report is prompt-eval growth
6+
// from bloated history (which the Auto max-output default enlarges), not a fixed per-turn regression.
7+
//
8+
// node scripts/bench-chat.mjs # default sweep
9+
// node scripts/bench-chat.mjs --port 8439 --repeats 3
10+
11+
import { performance } from 'node:perf_hooks'
12+
13+
const arg = (f, d) => {
14+
const i = process.argv.indexOf(f)
15+
return i >= 0 && process.argv[i + 1] ? process.argv[i + 1] : d
16+
}
17+
const PORT = Number(arg('--port', '8439'))
18+
const REPEATS = Number(arg('--repeats', '3'))
19+
const ENDPOINT = `http://127.0.0.1:${PORT}/v1/chat/completions`
20+
// Prompt-token targets standing in for a growing chat history. ~500 ≈ turn 1; 8k–32k ≈ a few
21+
// Auto-length answers deep. (Qwen3.5-2B trained ctx is 256K, so all fit.)
22+
const TARGETS = [500, 2000, 4000, 8000, 16000, 32000]
23+
24+
// ~4 chars/token of natural-ish filler; oversize then the model reports the real prompt_tokens.
25+
const SENTENCE =
26+
'The off-grid system balances solar capacity against battery storage while the controller logs each cycle. '
27+
const fillerForTokens = (tok) => SENTENCE.repeat(Math.ceil((tok * 4) / SENTENCE.length))
28+
29+
let nonce = 0
30+
async function ttft(promptText, { unique = true } = {}) {
31+
// A unique prefix per call defeats the server's prompt cache so we measure COLD prompt-eval
32+
// (the cache-miss cost a turn pays when its prefix changed). unique:false reuses the prefix to
33+
// measure the WARM (cache-hit) cost.
34+
const tag = unique ? `#${++nonce} ` : ''
35+
const body = JSON.stringify({
36+
messages: [
37+
{ role: 'system', content: 'You are concise.' },
38+
{ role: 'user', content: `${tag}${promptText}\n\nReply with exactly: OK` }
39+
],
40+
max_tokens: 16,
41+
temperature: 0,
42+
stream: true,
43+
stream_options: { include_usage: true },
44+
chat_template_kwargs: { enable_thinking: false }
45+
})
46+
const t0 = performance.now()
47+
const res = await fetch(ENDPOINT, {
48+
method: 'POST',
49+
headers: { 'content-type': 'application/json' },
50+
body
51+
})
52+
if (!res.ok) throw new Error(`HTTP ${res.status}`)
53+
const reader = res.body.getReader()
54+
const dec = new TextDecoder()
55+
let firstAt = 0
56+
let buf = ''
57+
let promptTokens = 0
58+
let done = false
59+
while (!done) {
60+
const { value, done: d } = await reader.read()
61+
if (d) break
62+
buf += dec.decode(value, { stream: true })
63+
let nl
64+
while ((nl = buf.indexOf('\n')) >= 0) {
65+
const line = buf.slice(0, nl).trim()
66+
buf = buf.slice(nl + 1)
67+
if (!line.startsWith('data:')) continue
68+
const payload = line.slice(5).trim()
69+
if (payload === '[DONE]') {
70+
done = true
71+
break
72+
}
73+
try {
74+
const j = JSON.parse(payload)
75+
const delta = j.choices?.[0]?.delta?.content
76+
if (delta && firstAt === 0) firstAt = performance.now()
77+
if (j.usage?.prompt_tokens) promptTokens = j.usage.prompt_tokens
78+
} catch {
79+
/* keepalive */
80+
}
81+
}
82+
}
83+
const total = performance.now() - t0
84+
return { ttft: firstAt ? firstAt - t0 : total, total, promptTokens }
85+
}
86+
87+
const median = (xs) => [...xs].sort((a, b) => a - b)[Math.floor(xs.length / 2)]
88+
89+
async function main() {
90+
console.log(`\nChat prompt-eval benchmark · ${ENDPOINT} · ${REPEATS} repeats\n`)
91+
console.log('warming up…')
92+
await ttft('warmup').catch(() => {})
93+
console.log('done\n')
94+
console.log('target promptTok TTFT(med ms) ms/1k promptTok total(med ms)')
95+
const rows = []
96+
for (const target of TARGETS) {
97+
const filler = fillerForTokens(target)
98+
const runs = []
99+
for (let r = 0; r < REPEATS; r++) runs.push(await ttft(filler))
100+
const pt = runs[runs.length - 1].promptTokens
101+
const tt = median(runs.map((r) => r.ttft))
102+
const tot = median(runs.map((r) => r.total))
103+
rows.push({ target, pt, tt, tot })
104+
console.log(
105+
`${String(target).padStart(6)} ${String(pt).padStart(8)} ${tt.toFixed(0).padStart(11)} ${((tt / pt) * 1000).toFixed(1).padStart(15)} ${tot.toFixed(0).padStart(12)}`
106+
)
107+
}
108+
const first = rows[0]
109+
const last = rows[rows.length - 1]
110+
console.log(
111+
`\nCOLD TTFT grew ${(last.tt / first.tt).toFixed(1)}× from ${first.pt} to ${last.pt} prompt tokens ` +
112+
`(${(last.pt / first.pt).toFixed(1)}× the tokens). ~linear ⇒ a cache-MISS turn scales with history.`
113+
)
114+
// Decisive: does the SAME big prompt, sent twice, hit the cache the 2nd time? If warm << cold, the
115+
// engine reuses the KV prefix across turns → a growing history is NOT re-eval'd each turn (so the
116+
// slowdown must come from something that BUSTS the prefix, e.g. changing injected context).
117+
const bigFiller = fillerForTokens(16000)
118+
const cold = await ttft(bigFiller, { unique: true })
119+
const warm1 = await ttft(bigFiller, { unique: false })
120+
const warm2 = await ttft(bigFiller, { unique: false })
121+
console.log(
122+
`\nCache test @~${cold.promptTokens} promptTok: cold=${cold.ttft.toFixed(0)}ms ` +
123+
`warm=${Math.min(warm1.ttft, warm2.ttft).toFixed(0)}ms ` +
124+
`→ ${warm1.ttft < cold.ttft * 0.5 ? 'CACHE REUSED across identical prefixes (history growth is cheap when the prefix is stable)' : 'NO cache benefit (every turn re-evals — history growth is expensive)'}`
125+
)
126+
}
127+
main().catch((e) => {
128+
console.error('bench failed:', e.message)
129+
process.exit(1)
130+
})

0 commit comments

Comments
 (0)