Skip to content

Commit c87347c

Browse files
Merge pull request #61 from off-grid-ai/fix/post-nightly-feedback
chore(bench): chat prompt-eval benchmark (used to root-cause Windows slowness)
2 parents df4e3b8 + 1ab50a4 commit c87347c

1 file changed

Lines changed: 147 additions & 0 deletions

File tree

scripts/bench-chat.mjs

Lines changed: 147 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,147 @@
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+
// A stalled connection or a never-ending stream must not hang the benchmark forever — bound each
20+
// request so a hang surfaces as a normal failure instead of a wedged process.
21+
const DEADLINE_MS = Number(arg('--deadline-ms', '120000'))
22+
const ENDPOINT = `http://127.0.0.1:${PORT}/v1/chat/completions`
23+
// Prompt-token targets standing in for a growing chat history. ~500 ≈ turn 1; 8k–32k ≈ a few
24+
// Auto-length answers deep. (Qwen3.5-2B trained ctx is 256K, so all fit.)
25+
const TARGETS = [500, 2000, 4000, 8000, 16000, 32000]
26+
27+
// ~4 chars/token of natural-ish filler; oversize then the model reports the real prompt_tokens.
28+
const SENTENCE =
29+
'The off-grid system balances solar capacity against battery storage while the controller logs each cycle. '
30+
const fillerForTokens = (tok) => SENTENCE.repeat(Math.ceil((tok * 4) / SENTENCE.length))
31+
32+
let nonce = 0
33+
async function ttft(promptText, { unique = true } = {}) {
34+
// A unique prefix per call defeats the server's prompt cache so we measure COLD prompt-eval
35+
// (the cache-miss cost a turn pays when its prefix changed). unique:false reuses the prefix to
36+
// measure the WARM (cache-hit) cost.
37+
const tag = unique ? `#${++nonce} ` : ''
38+
const body = JSON.stringify({
39+
messages: [
40+
{ role: 'system', content: 'You are concise.' },
41+
{ role: 'user', content: `${tag}${promptText}\n\nReply with exactly: OK` }
42+
],
43+
max_tokens: 16,
44+
temperature: 0,
45+
stream: true,
46+
stream_options: { include_usage: true },
47+
chat_template_kwargs: { enable_thinking: false }
48+
})
49+
const t0 = performance.now()
50+
// Bound the whole request (connect + stream) so a stalled server can't hang the benchmark; the
51+
// abort surfaces through the normal catch path in main().
52+
const ac = new AbortController()
53+
const deadline = setTimeout(() => ac.abort(), DEADLINE_MS)
54+
try {
55+
const res = await fetch(ENDPOINT, {
56+
method: 'POST',
57+
headers: { 'content-type': 'application/json' },
58+
body,
59+
signal: ac.signal
60+
})
61+
if (!res.ok) throw new Error(`HTTP ${res.status}`)
62+
const reader = res.body.getReader()
63+
const dec = new TextDecoder()
64+
let firstAt = 0
65+
let buf = ''
66+
let promptTokens = 0
67+
let done = false
68+
while (!done) {
69+
const { value, done: d } = await reader.read()
70+
if (d) break
71+
buf += dec.decode(value, { stream: true })
72+
let nl
73+
while ((nl = buf.indexOf('\n')) >= 0) {
74+
const line = buf.slice(0, nl).trim()
75+
buf = buf.slice(nl + 1)
76+
if (!line.startsWith('data:')) continue
77+
const payload = line.slice(5).trim()
78+
if (payload === '[DONE]') {
79+
done = true
80+
break
81+
}
82+
try {
83+
const j = JSON.parse(payload)
84+
const delta = j.choices?.[0]?.delta?.content
85+
if (delta && firstAt === 0) firstAt = performance.now()
86+
if (j.usage?.prompt_tokens) promptTokens = j.usage.prompt_tokens
87+
} catch {
88+
/* keepalive */
89+
}
90+
}
91+
}
92+
// Without prompt_tokens the ms/1k-token scaling is Infinity/NaN and the whole conclusion is
93+
// meaningless — fail loudly rather than print a garbage row.
94+
if (!promptTokens) {
95+
throw new Error('no usage.prompt_tokens in stream (endpoint ignored include_usage)')
96+
}
97+
const total = performance.now() - t0
98+
return { ttft: firstAt ? firstAt - t0 : total, total, promptTokens }
99+
} finally {
100+
clearTimeout(deadline)
101+
}
102+
}
103+
104+
const median = (xs) => [...xs].sort((a, b) => a - b)[Math.floor(xs.length / 2)]
105+
106+
async function main() {
107+
console.log(`\nChat prompt-eval benchmark · ${ENDPOINT} · ${REPEATS} repeats\n`)
108+
console.log('warming up…')
109+
await ttft('warmup').catch(() => {})
110+
console.log('done\n')
111+
console.log('target promptTok TTFT(med ms) ms/1k promptTok total(med ms)')
112+
const rows = []
113+
for (const target of TARGETS) {
114+
const filler = fillerForTokens(target)
115+
const runs = []
116+
for (let r = 0; r < REPEATS; r++) runs.push(await ttft(filler))
117+
const pt = runs[runs.length - 1].promptTokens
118+
const tt = median(runs.map((r) => r.ttft))
119+
const tot = median(runs.map((r) => r.total))
120+
rows.push({ target, pt, tt, tot })
121+
console.log(
122+
`${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)}`
123+
)
124+
}
125+
const first = rows[0]
126+
const last = rows[rows.length - 1]
127+
console.log(
128+
`\nCOLD TTFT grew ${(last.tt / first.tt).toFixed(1)}× from ${first.pt} to ${last.pt} prompt tokens ` +
129+
`(${(last.pt / first.pt).toFixed(1)}× the tokens). ~linear ⇒ a cache-MISS turn scales with history.`
130+
)
131+
// Decisive: does the SAME big prompt, sent twice, hit the cache the 2nd time? If warm << cold, the
132+
// engine reuses the KV prefix across turns → a growing history is NOT re-eval'd each turn (so the
133+
// slowdown must come from something that BUSTS the prefix, e.g. changing injected context).
134+
const bigFiller = fillerForTokens(16000)
135+
const cold = await ttft(bigFiller, { unique: true })
136+
const warm1 = await ttft(bigFiller, { unique: false })
137+
const warm2 = await ttft(bigFiller, { unique: false })
138+
console.log(
139+
`\nCache test @~${cold.promptTokens} promptTok: cold=${cold.ttft.toFixed(0)}ms ` +
140+
`warm=${Math.min(warm1.ttft, warm2.ttft).toFixed(0)}ms ` +
141+
`→ ${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)'}`
142+
)
143+
}
144+
main().catch((e) => {
145+
console.error('bench failed:', e.message)
146+
process.exit(1)
147+
})

0 commit comments

Comments
 (0)