char-fork/llama.cpp

git clone https://git.t4t.associates/char-fork/llama.cpp

Aleksander Grygierui: Stores split refactor (#27240)521a64cd0

char/aliceai-t5-moe
12.8 KiB426 linesraw
1// Tier 1 perf harness for the agentic thread.
2//
3// Drives ChatMessageAgenticContent the way the real streaming pipeline does:
4// chat.svelte.ts -> conversations.svelte.ts:184 replaces the message object per
5// SSE chunk (`{ ...old, ...updates }`), which changes prop identity and cascades
6// through deriveAgenticSections into every tool-call block in the message.
7//
8// The fixture is parameterized so the *scaling curve* identifies the culprit -
9// a single number would not. See tests/client/README-perf.md.
10//
11// Run: npx vitest --project=client --run tests/client/agentic-stream.perf.svelte.test.ts
12
13import { perfState } from './components/agentic-perf-state.svelte';
14import AgenticPerfWrapper from './components/AgenticPerfWrapper.svelte';
15import ChatMessagesPerfWrapper from './components/ChatMessagesPerfWrapper.svelte';
16import { MessageRole } from '$lib/enums';
17import { conversationsStore } from '$lib/stores/conversations/index.svelte';
18import type { DatabaseMessage } from '$lib/types';
19import { tick } from 'svelte';
20import { describe, it } from 'vitest';
21import { render } from 'vitest-browser-svelte';
22
23// --- fixture construction -------------------------------------------------
24
25interface FixtureOpts {
26	/** Completed tool-call sections preceding the streaming text. */
27	priorToolCalls: number;
28	/** Size of each tool result blob. */
29	toolResultBytes: number;
30	/** Number of edit_file calls (each a 400x400-line diff). */
31	editFileEdits: number;
32	/** Leave an unclosed ``` fence at the end of the streamed content. */
33	openCodeFence: boolean;
34	/**
35	 * Emit a blank line every N chunks so the content forms real markdown
36	 * blocks. 0 = one unbroken paragraph, which defeats MarkdownContent's
37	 * stable-block cache entirely (worst case). Typical prose has breaks.
38	 */
39	paragraphEvery: number;
40}
41
42const DEFAULTS: FixtureOpts = {
43	editFileEdits: 0,
44	openCodeFence: false,
45	paragraphEvery: 0,
46	priorToolCalls: 0,
47	toolResultBytes: 1024
48};
49
50function blob(bytes: number, seed: string): string {
51	const line = `${seed} output line with some representative width to it`;
52	const n = Math.max(1, Math.ceil(bytes / (line.length + 1)));
53	const out: string[] = [];
54
55	for (let i = 0; i < n; i++) out.push(`${line} ${i}`);
56
57	return out.join('\n');
58}
59
60function diffLines(n: number, seed: string): string {
61	const out: string[] = [];
62
63	for (let i = 0; i < n; i++) out.push(`${seed} line ${i} const value_${i} = compute(${i});`);
64
65	return out.join('\n');
66}
67
68let msgSeq = 0;
69
70function baseMessage(overrides: Partial<DatabaseMessage>): DatabaseMessage {
71	return {
72		children: [],
73		content: '',
74		convId: 'perf-conv',
75		id: `m${msgSeq++}`,
76		parent: null,
77		role: MessageRole.ASSISTANT,
78		timestamp: 0,
79		type: 'text',
80		...overrides
81	} as DatabaseMessage;
82}
83
84function buildFixture(opts: FixtureOpts): {
85	message: DatabaseMessage;
86	toolMessages: DatabaseMessage[];
87} {
88	const toolCalls: unknown[] = [];
89	const toolMessages: DatabaseMessage[] = [];
90
91	for (let i = 0; i < opts.priorToolCalls; i++) {
92		const id = `call_${i}`;
93
94		toolCalls.push({
95			function: {
96				arguments: JSON.stringify({ command: `grep -rn "thing_${i}" src/` }),
97				name: 'exec_shell_command'
98			},
99			id,
100			type: 'function'
101		});
102		toolMessages.push(
103			baseMessage({
104				content: `${blob(opts.toolResultBytes, `t${i}`)}\n[exit code: 0]`,
105				role: MessageRole.TOOL,
106				toolCallId: id
107			})
108		);
109	}
110
111	for (let i = 0; i < opts.editFileEdits; i++) {
112		const id = `edit_${i}`;
113
114		toolCalls.push({
115			function: {
116				arguments: JSON.stringify({
117					edits: [{ new_text: diffLines(400, 'new'), old_text: diffLines(400, 'old') }],
118					path: `/src/file_${i}.ts`
119				}),
120				name: 'edit_file'
121			},
122			id,
123			type: 'function'
124		});
125		toolMessages.push(
126			baseMessage({
127				content: JSON.stringify({ edits_applied: 1, result: 'ok' }),
128				role: MessageRole.TOOL,
129				toolCallId: id
130			})
131		);
132	}
133
134	const message = baseMessage({
135		content: '',
136		toolCalls: toolCalls.length > 0 ? JSON.stringify(toolCalls) : undefined
137	});
138
139	return { message, toolMessages };
140}
141
142// --- the driver -----------------------------------------------------------
143
144interface Sample {
145	label: string;
146	tokens: number;
147	mean: number;
148	p95: number;
149	max: number;
150	/** Sum of the synchronous per-token windows. */
151	total: number;
152	/** Wall-clock for the whole run incl. deferred rAF work, then settle. */
153	wall: number;
154}
155
156function nextFrame(): Promise<void> {
157	return new Promise((resolve) => requestAnimationFrame(() => resolve()));
158}
159
160const results: Sample[] = [];
161
162/**
163 * Replays `tokens` streamed chunks, replacing the message object each time
164 * exactly as conversations.svelte.ts:184 does, flushing Svelte and forcing
165 * layout so the measurement includes render + style/layout, not just script.
166 */
167async function measure(label: string, partial: Partial<FixtureOpts>, tokens = 60) {
168	const opts = { ...DEFAULTS, ...partial };
169	const { message, toolMessages } = buildFixture(opts);
170
171	perfState.message = message;
172	perfState.toolMessages = toolMessages;
173	perfState.isStreaming = true;
174
175	// Every wrapper reads the same module state, so a leftover mount from a
176	// previous fixture would re-render on each mutation and fold its cost into
177	// this measurement. Tear down explicitly between fixtures.
178	const { unmount } = render(AgenticPerfWrapper);
179
180	await tick();
181
182	const CHUNK = 'The quick brown fox jumps over the lazy dog. ';
183
184	let accumulated = opts.openCodeFence ? '```notalanguage\n' : '';
185
186	const durations: number[] = [];
187	const wallStart = performance.now();
188
189	for (let i = 0; i < tokens; i++) {
190		accumulated += CHUNK;
191
192		if (opts.paragraphEvery > 0 && (i + 1) % opts.paragraphEvery === 0) {
193			accumulated += '\n\n';
194		}
195
196		const t0 = performance.now();
197
198		// Mirrors updateMessageAtIndex: a brand-new object identity per chunk.
199		perfState.message = { ...perfState.message!, content: accumulated };
200		await tick();
201		void document.body.offsetHeight; // force style + layout
202		durations.push(performance.now() - t0);
203
204		// MarkdownContent coalesces its parse into a rAF, so that work lands
205		// outside the window above. Yield a frame each iteration so it is
206		// captured in `wall` - the gap between `wall` and `total` is the
207		// deferred cost.
208		await nextFrame();
209	}
210
211	// Let any trailing coalesced work drain before stopping the clock.
212	await nextFrame();
213	await nextFrame();
214	const wall = performance.now() - wallStart;
215
216	await unmount();
217
218	durations.sort((a, b) => a - b);
219	const total = durations.reduce((a, b) => a + b, 0);
220
221	results.push({
222		label,
223		max: durations[durations.length - 1],
224		mean: total / durations.length,
225		p95: durations[Math.floor(durations.length * 0.95)],
226		tokens,
227		total,
228		wall
229	});
230}
231
232function report() {
233	const pad = (s: string, n: number) => s.padEnd(n);
234	const num = (n: number) => n.toFixed(2).padStart(8);
235	const header = `${pad('fixture', 40)}${pad('tok', 5)}${'mean'.padStart(8)}${'p95'.padStart(8)}${'max'.padStart(8)}${'sync'.padStart(9)}${'wall'.padStart(9)}`;
236	const lines = [
237		'',
238		'=== Tier 1: ms per streamed token (agentic content subtree) ===',
239		'mean/p95/max = synchronous window per token (script + style + layout).',
240		'sync  = sum of those windows.',
241		'wall  = whole run, incl. work MarkdownContent defers into its own rAF.',
242		'        NOTE: wall carries a ~16.7ms/token idle floor from the harness',
243		'        yielding a frame each iteration (60 tokens => ~1000ms floor).',
244		'        Compare wall ACROSS fixtures / against the baseline row,',
245		'        never against sync.',
246		'',
247		header,
248		'-'.repeat(header.length)
249	];
250
251	for (const r of results) {
252		lines.push(
253			`${pad(r.label, 40)}${pad(String(r.tokens), 5)}${num(r.mean)}${num(r.p95)}${num(r.max)}${num(r.total)}${num(r.wall)}`
254		);
255	}
256
257	lines.push('');
258	console.log(lines.join('\n'));
259}
260
261// --- conversation-level driver --------------------------------------------
262// The per-message driver above cannot see the fan-out in ChatMessages: a single
263// token mutation invalidates `displayMessages`, which rebuilds a fresh
264// toolMessages array for EVERY message in the conversation. Drive the real
265// store through the real list component to measure that.
266
267async function measureConversation(
268	label: string,
269	priorMessages: number,
270	tokens = 60,
271	/**
272	 * Give each prior assistant turn a resolved tool call, so the fixture pays
273	 * `hasAgenticContent`'s JSON.parse and the tool-message grouping walk that a
274	 * real agent thread would - plain prose messages skip both.
275	 */
276	agentic = false
277) {
278	const history: DatabaseMessage[] = [];
279
280	for (let i = 0; i < priorMessages; i++) {
281		const isAssistant = i % 2 !== 0;
282
283		if (isAssistant && agentic) {
284			const id = `prior_call_${i}`;
285
286			history.push(
287				baseMessage({
288					content: `Message ${i}`,
289					role: MessageRole.ASSISTANT,
290					toolCalls: JSON.stringify([
291						{
292							function: {
293								arguments: JSON.stringify({ command: `grep -rn "thing_${i}" src/` }),
294								name: 'exec_shell_command'
295							},
296							id,
297							type: 'function'
298						}
299					])
300				})
301			);
302			history.push(
303				baseMessage({
304					content: `${blob(1024, `r${i}`)}\n[exit code: 0]`,
305					role: MessageRole.TOOL,
306					toolCallId: id
307				})
308			);
309
310			continue;
311		}
312
313		history.push(
314			baseMessage({
315				content: `Message ${i}: ${blob(512, `m${i}`)}`,
316				role: isAssistant ? MessageRole.ASSISTANT : MessageRole.USER
317			})
318		);
319	}
320
321	const streaming = baseMessage({ content: '', role: MessageRole.ASSISTANT });
322
323	history.push(streaming);
324
325	conversationsStore.activeMessages = history;
326
327	const { unmount } = render(ChatMessagesPerfWrapper);
328
329	await tick();
330
331	const idx = conversationsStore.findMessageIndex(streaming.id);
332	const CHUNK = 'The quick brown fox jumps over the lazy dog. ';
333
334	let accumulated = '';
335
336	const durations: number[] = [];
337	const wallStart = performance.now();
338
339	for (let i = 0; i < tokens; i++) {
340		accumulated += CHUNK;
341
342		const t0 = performance.now();
343
344		// The real path: chat.svelte.ts -> conversations.svelte.ts.
345		conversationsStore.updateMessageAtIndex(idx, { content: accumulated });
346		await tick();
347		void document.body.offsetHeight;
348		durations.push(performance.now() - t0);
349
350		await nextFrame();
351	}
352
353	await nextFrame();
354	await nextFrame();
355	const wall = performance.now() - wallStart;
356
357	await unmount();
358	conversationsStore.activeMessages = [];
359
360	durations.sort((a, b) => a - b);
361	const total = durations.reduce((a, b) => a + b, 0);
362
363	results.push({
364		label,
365		max: durations[durations.length - 1],
366		mean: total / durations.length,
367		p95: durations[Math.floor(durations.length * 0.95)],
368		tokens,
369		total,
370		wall
371	});
372}
373
374// --- the matrix -----------------------------------------------------------
375// Sequential, in one test, so the table prints together and the samples do not
376// interleave with other suites competing for the main thread.
377
378describe('agentic streaming perf', () => {
379	it('scales', { timeout: 600_000 }, async () => {
380		// Baseline: plain text streaming, nothing agentic.
381		await measure('baseline: no tool calls', {});
382
383		// Knob: priorToolCalls. Flat => no fan-out. Linear => fan-out confirmed.
384		await measure('priorToolCalls=1  (1KB results)', { priorToolCalls: 1 });
385		await measure('priorToolCalls=5  (1KB results)', { priorToolCalls: 5 });
386		await measure('priorToolCalls=20 (1KB results)', { priorToolCalls: 20 });
387
388		// Knob: toolResultBytes, at fixed section count.
389		await measure('5 calls x 200KB results', {
390			priorToolCalls: 5,
391			toolResultBytes: 200 * 1024
392		});
393
394		// Knob: editFileEdits (computeLineDiff, 400x400 LCS).
395		await measure('3 edit_file calls (400x400 diff)', { editFileEdits: 3 });
396
397		// Knob: openCodeFence (hljs highlightAuto on partial code).
398		await measure('open code fence, unknown language', { openCodeFence: true });
399
400		// Conversation level: does streaming one message cost more as the
401		// conversation grows? Flat => no fan-out. Linear => confirmed.
402		await measureConversation('convo: 1 prior message', 1);
403		await measureConversation('convo: 10 prior messages', 10);
404		await measureConversation('convo: 40 prior messages', 40);
405
406		// Same, but each prior assistant turn carries a resolved tool call.
407		await measureConversation('convo: 10 prior, agentic', 10, 60, true);
408		await measureConversation('convo: 40 prior, agentic', 40, 60, true);
409
410		// Message length: MarkdownContent re-parses the whole accumulated string
411		// each frame, so per-token cost should climb as the response grows.
412		// A rising mean across these three rows means O(n^2) over the stream.
413		// One unbroken paragraph: worst case, stable-block cache never applies.
414		await measure('len 60tok  1-para (worst case)', {}, 60);
415		await measure('len 250tok 1-para (worst case)', {}, 250);
416		await measure('len 600tok 1-para (worst case)', {}, 600);
417
418		// Same lengths, broken into paragraphs every 8 chunks: the typical shape,
419		// where only the trailing paragraph should be unstable.
420		await measure('len 60tok  paras (typical)', { paragraphEvery: 8 }, 60);
421		await measure('len 250tok paras (typical)', { paragraphEvery: 8 }, 250);
422		await measure('len 600tok paras (typical)', { paragraphEvery: 8 }, 600);
423
424		report();
425	});
426});