char-fork/llama.cpp
git clone https://git.t4t.associates/char-fork/llama.cpp
521a64cd0
char/aliceai-t5-moe
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. */ 27priorToolCalls :number ; 28/** Size of each tool result blob. */ 29toolResultBytes :number ; 30/** Number of edit_file calls (each a 400x400-line diff). */ 31editFileEdits :number ; 32/** Leave an unclosed ``` fence at the end of the streamed content. */ 33openCodeFence :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*/ 39paragraphEvery :number ; 40} 41 42const DEFAULTS :FixtureOpts = { 43editFileEdits :0 , 44openCodeFence :false , 45paragraphEvery :0 , 46priorToolCalls :0 , 47toolResultBytes :1024 48}; 49 50function blob ( bytes :number , seed :string ) :string { 51const line = ` ${ seed } output line with some representative width to it` ; 52const n = Math . max ( 1 , Math . ceil ( bytes / ( line . length + 1 ))); 53const out :string [] = []; 54 55for ( let i = 0 ; i < n ; i ++ ) out . push ( ` ${ line } ${ i } ` ); 56 57return out . join ( '\n' ); 58} 59 60function diffLines ( n :number , seed :string ) :string { 61const out :string [] = []; 62 63for ( let i = 0 ; i < n ; i ++ ) out . push ( ` ${ seed } line ${ i } const value_ ${ i } = compute( ${ i } );` ); 64 65return out . join ( '\n' ); 66} 67 68let msgSeq = 0 ; 69 70function baseMessage ( overrides :Partial < DatabaseMessage >) :DatabaseMessage { 71return { 72children :[], 73content :'' , 74convId :'perf-conv' , 75id :`m ${ msgSeq ++ } ` , 76parent :null , 77role :MessageRole . ASSISTANT , 78timestamp :0 , 79type :'text' , 80 ...overrides 81} as DatabaseMessage ; 82} 83 84function buildFixture ( opts :FixtureOpts ) :{ 85message :DatabaseMessage ; 86toolMessages :DatabaseMessage []; 87} { 88const toolCalls :unknown [] = []; 89const toolMessages :DatabaseMessage [] = []; 90 91for ( let i = 0 ; i < opts . priorToolCalls ; i ++ ) { 92const id = `call_ ${ i } ` ; 93 94toolCalls . push ({ 95function :{ 96arguments :JSON . stringify ({ command :`grep -rn "thing_ ${ i } " src/` }), 97name :'exec_shell_command' 98}, 99 id, 100type :'function' 101}); 102toolMessages . push ( 103baseMessage ({ 104content :` ${ blob ( opts . toolResultBytes , `t ${ i } ` )} \n[exit code: 0]` , 105role :MessageRole . TOOL , 106toolCallId :id 107}) 108); 109} 110 111for ( let i = 0 ; i < opts . editFileEdits ; i ++ ) { 112const id = `edit_ ${ i } ` ; 113 114toolCalls . push ({ 115function :{ 116arguments :JSON . stringify ({ 117edits :[{ new_text :diffLines ( 400 , 'new' ), old_text :diffLines ( 400 , 'old' ) }], 118path :`/src/file_ ${ i } .ts` 119}), 120name :'edit_file' 121}, 122 id, 123type :'function' 124}); 125toolMessages . push ( 126baseMessage ({ 127content :JSON . stringify ({ edits_applied :1 , result :'ok' }), 128role :MessageRole . TOOL , 129toolCallId :id 130}) 131); 132} 133 134const message = baseMessage ({ 135content :'' , 136toolCalls :toolCalls . length > 0 ?JSON . stringify ( toolCalls ) :undefined 137}); 138 139return { message, toolMessages}; 140} 141 142// --- the driver ----------------------------------------------------------- 143 144interface Sample { 145label :string ; 146tokens :number ; 147mean :number ; 148p95 :number ; 149max :number ; 150/** Sum of the synchronous per-token windows. */ 151total :number ; 152/** Wall-clock for the whole run incl. deferred rAF work, then settle. */ 153wall :number ; 154} 155 156function nextFrame () :Promise < void > { 157return 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 ) { 168const opts = { ...DEFAULTS , ...partial }; 169const { message, toolMessages} = buildFixture ( opts ); 170 171perfState . message = message ; 172perfState . toolMessages = toolMessages ; 173perfState . 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. 178const { unmount} = render ( AgenticPerfWrapper ); 179 180await tick (); 181 182const CHUNK = 'The quick brown fox jumps over the lazy dog. ' ; 183 184let accumulated = opts . openCodeFence ?'```notalanguage\n' :'' ; 185 186const durations :number [] = []; 187const wallStart = performance . now (); 188 189for ( let i = 0 ; i < tokens ; i ++ ) { 190accumulated += CHUNK ; 191 192if ( opts . paragraphEvery > 0 && ( i + 1 ) % opts . paragraphEvery === 0 ) { 193accumulated += '\n\n' ; 194} 195 196const t0 = performance . now (); 197 198// Mirrors updateMessageAtIndex: a brand-new object identity per chunk. 199perfState . message = { ...perfState . message ! , content :accumulated }; 200await tick (); 201void document . body . offsetHeight ; // force style + layout 202durations . 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. 208await nextFrame (); 209} 210 211// Let any trailing coalesced work drain before stopping the clock. 212await nextFrame (); 213await nextFrame (); 214const wall = performance . now () - wallStart ; 215 216await unmount (); 217 218durations . sort (( a , b ) => a - b ); 219const total = durations . reduce (( a , b ) => a + b , 0 ); 220 221results . push ({ 222 label, 223max :durations [ durations . length - 1 ], 224mean :total / durations . length , 225p95 :durations [ Math . floor ( durations . length * 0.95 )], 226 tokens, 227 total, 228 wall 229}); 230} 231 232function report () { 233const pad = ( s :string , n :number ) => s . padEnd ( n ); 234const num = ( n :number ) => n . toFixed ( 2 ). padStart ( 8 ); 235const header = ` ${ pad ( 'fixture' , 40 )} ${ pad ( 'tok' , 5 )} ${ 'mean' . padStart ( 8 )} ${ 'p95' . padStart ( 8 )} ${ 'max' . padStart ( 8 )} ${ 'sync' . padStart ( 9 )} ${ 'wall' . padStart ( 9 )} ` ; 236const 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'' , 247header , 248'-' . repeat ( header . length ) 249]; 250 251for ( const r of results ) { 252lines . 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 257lines . push ( '' ); 258console . 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 ( 268label :string , 269priorMessages :number , 270tokens = 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*/ 276agentic = false 277) { 278const history :DatabaseMessage [] = []; 279 280for ( let i = 0 ; i < priorMessages ; i ++ ) { 281const isAssistant = i % 2 !== 0 ; 282 283if ( isAssistant && agentic ) { 284const id = `prior_call_ ${ i } ` ; 285 286history . push ( 287baseMessage ({ 288content :`Message ${ i } ` , 289role :MessageRole . ASSISTANT , 290toolCalls :JSON . stringify ([ 291{ 292function :{ 293arguments :JSON . stringify ({ command :`grep -rn "thing_ ${ i } " src/` }), 294name :'exec_shell_command' 295}, 296 id, 297type :'function' 298} 299]) 300}) 301); 302history . push ( 303baseMessage ({ 304content :` ${ blob ( 1024 , `r ${ i } ` )} \n[exit code: 0]` , 305role :MessageRole . TOOL , 306toolCallId :id 307}) 308); 309 310continue ; 311} 312 313history . push ( 314baseMessage ({ 315content :`Message ${ i } : ${ blob ( 512 , `m ${ i } ` )} ` , 316role :isAssistant ?MessageRole . ASSISTANT :MessageRole . USER 317}) 318); 319} 320 321const streaming = baseMessage ({ content :'' , role :MessageRole . ASSISTANT }); 322 323history . push ( streaming ); 324 325conversationsStore . activeMessages = history ; 326 327const { unmount} = render ( ChatMessagesPerfWrapper ); 328 329await tick (); 330 331const idx = conversationsStore . findMessageIndex ( streaming . id ); 332const CHUNK = 'The quick brown fox jumps over the lazy dog. ' ; 333 334let accumulated = '' ; 335 336const durations :number [] = []; 337const wallStart = performance . now (); 338 339for ( let i = 0 ; i < tokens ; i ++ ) { 340accumulated += CHUNK ; 341 342const t0 = performance . now (); 343 344// The real path: chat.svelte.ts -> conversations.svelte.ts. 345conversationsStore . updateMessageAtIndex ( idx , { content :accumulated }); 346await tick (); 347void document . body . offsetHeight ; 348durations . push ( performance . now () - t0 ); 349 350await nextFrame (); 351} 352 353await nextFrame (); 354await nextFrame (); 355const wall = performance . now () - wallStart ; 356 357await unmount (); 358conversationsStore . activeMessages = []; 359 360durations . sort (( a , b ) => a - b ); 361const total = durations . reduce (( a , b ) => a + b , 0 ); 362 363results . push ({ 364 label, 365max :durations [ durations . length - 1 ], 366mean :total / durations . length , 367p95 :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' , () => { 379it ( 'scales' , { timeout :600_000 }, async () => { 380// Baseline: plain text streaming, nothing agentic. 381await measure ( 'baseline: no tool calls' , {}); 382 383// Knob: priorToolCalls. Flat => no fan-out. Linear => fan-out confirmed. 384await measure ( 'priorToolCalls=1 (1KB results)' , { priorToolCalls :1 }); 385await measure ( 'priorToolCalls=5 (1KB results)' , { priorToolCalls :5 }); 386await measure ( 'priorToolCalls=20 (1KB results)' , { priorToolCalls :20 }); 387 388// Knob: toolResultBytes, at fixed section count. 389await measure ( '5 calls x 200KB results' , { 390priorToolCalls :5 , 391toolResultBytes :200 * 1024 392}); 393 394// Knob: editFileEdits (computeLineDiff, 400x400 LCS). 395await measure ( '3 edit_file calls (400x400 diff)' , { editFileEdits :3 }); 396 397// Knob: openCodeFence (hljs highlightAuto on partial code). 398await 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. 402await measureConversation ( 'convo: 1 prior message' , 1 ); 403await measureConversation ( 'convo: 10 prior messages' , 10 ); 404await measureConversation ( 'convo: 40 prior messages' , 40 ); 405 406// Same, but each prior assistant turn carries a resolved tool call. 407await measureConversation ( 'convo: 10 prior, agentic' , 10 , 60 , true ); 408await 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. 414await measure ( 'len 60tok 1-para (worst case)' , {}, 60 ); 415await measure ( 'len 250tok 1-para (worst case)' , {}, 250 ); 416await 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. 420await measure ( 'len 60tok paras (typical)' , { paragraphEvery :8 }, 60 ); 421await measure ( 'len 250tok paras (typical)' , { paragraphEvery :8 }, 250 ); 422await measure ( 'len 600tok paras (typical)' , { paragraphEvery :8 }, 600 ); 423 424report (); 425}); 426});