|
| 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