Skip to content

Commit 555881e

Browse files
Zach Winterzachwinter
andauthored
ui: reduce per-token render cost when streaming (ggml-org#26053)
* performance harness - the empirical root Assisted-by: Claude Opus 4.8 * 210.36ms -> 2.67ms per streamed token Assisted-by: Claude Opus 4.8 * 11.58ms -> 0.62ms per streamed token Assisted-by: Claude Opus 4.8 * 22.02ms -> 3.33ms per streamed token Assisted-by: Claude Opus 4.8 * 3.07ms -> 1.36ms per streamed token at 40 messages Assisted-by: Claude Opus 4.8 --------- Co-authored-by: Zach Winter <dmtommy@icloud.com>
1 parent 96013c5 commit 555881e

18 files changed

Lines changed: 1038 additions & 36 deletions

tools/ui/src/lib/components/app/content/CollapsibleContentBlock.svelte

Lines changed: 9 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -89,10 +89,15 @@
8989
</Collapsible.Trigger>
9090

9191
<Collapsible.Content>
92-
<div class="pl-1.5 grid min-w-0" style="min-height: var(--min-message-height);">
93-
<div class="min-w-0 border-l border-muted-foreground/20 pl-4 pb-2 my-2">
94-
{@render children()}
92+
<!-- Collapsible.Content renders its children unconditionally and only sets
93+
`hidden`, so a closed block would keep re-rendering its whole body on
94+
every streamed token. Gate on `open` so collapsed content costs nothing. -->
95+
{#if open}
96+
<div class="pl-1.5 grid min-w-0" style="min-height: var(--min-message-height);">
97+
<div class="min-w-0 border-l border-muted-foreground/20 pl-4 pb-2 my-2">
98+
{@render children()}
99+
</div>
95100
</div>
96-
</div>
101+
{/if}
97102
</Collapsible.Content>
98103
</Collapsible.Root>

tools/ui/src/lib/components/app/content/CollapsibleTerminalBlock.svelte

Lines changed: 7 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -90,8 +90,12 @@
9090
</Collapsible.Trigger>
9191

9292
<Collapsible.Content>
93-
<div class="p-3 pt-1">
94-
{@render children()}
95-
</div>
93+
<!-- See CollapsibleContentBlock: bits-ui keeps closed content mounted, which
94+
makes a collapsed tool result re-render on every streamed token. -->
95+
{#if open}
96+
<div class="p-3 pt-1">
97+
{@render children()}
98+
</div>
99+
{/if}
96100
</Collapsible.Content>
97101
</Collapsible.Root>

tools/ui/src/lib/components/app/content/MarkdownContent/MarkdownContent.svelte

Lines changed: 10 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -107,6 +107,15 @@
107107
return null;
108108
});
109109
const liveSvgHtml = $derived(streamingSvgCode !== null ? sanitizeSvg(streamingSvgCode) : '');
110+
111+
// Derived rather than called inline in the template so it only recomputes when
112+
// the block actually changes. Auto-detection is disabled while streaming: it
113+
// costs ~38ms a call and re-guesses the language on every chunk.
114+
const streamingCodeHtml = $derived(
115+
incompleteCodeBlock
116+
? highlightCode(incompleteCodeBlock.code, incompleteCodeBlock.language || 'text', false)
117+
: ''
118+
);
110119
let previewDialogOpen = $state(false);
111120
let previewCode = $state('');
112121
let previewLanguage = $state('text');
@@ -903,10 +912,7 @@
903912
>
904913
<pre class="streaming-code-pre"><code
905914
class="hljs language-{incompleteCodeBlock.language || 'text'}"
906-
>{@html highlightCode(
907-
incompleteCodeBlock.code,
908-
incompleteCodeBlock.language || 'text'
909-
)}</code
915+
>{@html streamingCodeHtml}</code
910916
></pre>
911917
</div>
912918
</div>

tools/ui/src/lib/constants/latex-protection.ts

Lines changed: 8 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -28,6 +28,14 @@ export const LATEX_MATH_AND_CODE_PATTERN =
2828
/** Regex to capture the content of a $$...\\\\...$$ block (display-formula with line-break) */
2929
export const LATEX_LINEBREAK_REGEXP = /\$\$([\s\S]*?\\\\[\s\S]*?)\$\$/;
3030

31+
/**
32+
* Cheap gate for `preprocessLaTeX`. Every transformation it performs is triggered
33+
* by a `$` (inline/display math, currency escaping) or a backslash escape
34+
* (`\(`, `\[`, `\ce{`, `\pu{`). Text containing neither is returned untouched, so
35+
* this lets the caller skip the whole protect/restore pipeline.
36+
*/
37+
export const LATEX_TRIGGER_REGEXP = /[$\\]/;
38+
3139
/** map from mchem-regexp to replacement */
3240
export const MHCHEM_PATTERN_MAP: readonly [RegExp, string][] = [
3341
[/(\s)\$\\ce{/g, '$1$\\\\ce{'],

tools/ui/src/lib/stores/conversations.svelte.ts

Lines changed: 15 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -169,8 +169,21 @@ class ConversationsStore {
169169
* Updates a message at a specific index in active messages
170170
*/
171171
updateMessageAtIndex(index: number, updates: Partial<DatabaseMessage>): void {
172-
if (index !== -1 && this.activeMessages[index]) {
173-
this.activeMessages[index] = { ...this.activeMessages[index], ...updates };
172+
const message = index === -1 ? undefined : this.activeMessages[index];
173+
174+
if (!message) return;
175+
176+
// Assign field by field rather than replacing the object. Replacing it
177+
// changes the array slot, which invalidates every consumer that merely
178+
// walks the list - notably ChatMessages.displayMessages, which rebuilds
179+
// entries for every message in the conversation. Deep $state proxies make
180+
// per-field writes fine-grained, so only readers of the changed field wake.
181+
const target = message as unknown as Record<string, unknown>;
182+
183+
for (const [key, value] of Object.entries(updates)) {
184+
if (target[key] !== value) {
185+
target[key] = value;
186+
}
174187
}
175188
}
176189

tools/ui/src/lib/utils/code.ts

Lines changed: 13 additions & 6 deletions
Original file line numberDiff line numberDiff line change
@@ -30,13 +30,21 @@ function trimCodePadding(code: string): string {
3030
return code.replace(TRIM_LEADING_PADDING_REGEX, '').replace(TRIM_TRAILING_PADDING_REGEX, '');
3131
}
3232

33+
function escapeCode(code: string): string {
34+
return code.replace(AMPERSAND_REGEX, '&amp;').replace(LT_REGEX, '&lt;').replace(GT_REGEX, '&gt;');
35+
}
36+
3337
/**
3438
* Highlights code using highlight.js
3539
* @param code - The code to highlight
3640
* @param language - The programming language
41+
* @param autoDetect - Fall back to `highlightAuto` when `language` is unknown.
42+
* Callers rendering a still-streaming block should pass false: auto-detection
43+
* costs ~38ms per call and re-guesses on every chunk, so the language (and
44+
* therefore the whole highlight) flickers as the block grows.
3745
* @returns HTML string with syntax highlighting
3846
*/
39-
export function highlightCode(code: string, language: string): string {
47+
export function highlightCode(code: string, language: string, autoDetect = true): string {
4048
if (!code) return '';
4149

4250
const trimmed = trimCodePadding(code);
@@ -47,15 +55,14 @@ export function highlightCode(code: string, language: string): string {
4755

4856
if (isSupported) {
4957
return hljs.highlight(trimmed, { language: lang }).value;
50-
} else {
58+
} else if (autoDetect) {
5159
return hljs.highlightAuto(trimmed).value;
60+
} else {
61+
return escapeCode(trimmed);
5262
}
5363
} catch {
5464
// Fallback to escaped plain text
55-
return trimmed
56-
.replace(AMPERSAND_REGEX, '&amp;')
57-
.replace(LT_REGEX, '&lt;')
58-
.replace(GT_REGEX, '&gt;');
65+
return escapeCode(trimmed);
5966
}
6067
}
6168

tools/ui/src/lib/utils/latex-protection.ts

Lines changed: 34 additions & 17 deletions
Original file line numberDiff line numberDiff line change
@@ -2,6 +2,7 @@ import {
22
CODE_BLOCK_REGEXP,
33
LATEX_MATH_AND_CODE_PATTERN,
44
LATEX_LINEBREAK_REGEXP,
5+
LATEX_TRIGGER_REGEXP,
56
MHCHEM_PATTERN_MAP
67
} from '$lib/constants';
78

@@ -148,6 +149,15 @@ export function preprocessLaTeX(content: string): string {
148149
// See also:
149150
// https://github.com/danny-avila/LibreChat/blob/main/client/src/utils/latex.ts
150151

152+
// Every step below keys off a `$` or a backslash escape (\[ \] \( \) \ce{ \pu{).
153+
// With neither present the protect/restore passes round-trip the input
154+
// unchanged, so skip them: the step 2 scan is O(n^2) in line length and costs
155+
// ~90ms on a 26KB single-line message that contains no math at all. This
156+
// matters during streaming, where the whole message is reprocessed per frame.
157+
if (!LATEX_TRIGGER_REGEXP.test(content)) {
158+
return content;
159+
}
160+
151161
// Step 0: Temporarily remove blockquote markers (>) to process LaTeX correctly
152162
// Store the structure so we can restore it later
153163
const blockquoteMarkers: Map<number, string> = new Map();
@@ -175,24 +185,31 @@ export function preprocessLaTeX(content: string): string {
175185
const latexExpressions: string[] = [];
176186

177187
// Match \S...\[...\] and protect them and insert a line-break.
178-
content = content.replace(/([\S].*?)\\\[([\s\S]*?)\\\](.*)/g, (match, group1, group2, group3) => {
179-
// Check if there are characters following the formula (display-formula in a table-cell?)
180-
if (group1.endsWith('\\')) {
181-
return match; // Backslash before \[, do nothing.
182-
}
183-
const hasSuffix = /\S/.test(group3);
184-
let optBreak;
185-
186-
if (hasSuffix) {
187-
latexExpressions.push(`\\(${group2.trim()}\\)`); // Convert into inline.
188-
optBreak = '';
189-
} else {
190-
latexExpressions.push(`\\[${group2}\\]`);
191-
optBreak = '\n';
192-
}
188+
// Guarded: with no `\[` present this pattern still probes every start offset,
189+
// expanding `.*?` to the end of each line before failing - O(n^2) for nothing.
190+
if (content.includes('\\[')) {
191+
content = content.replace(
192+
/([\S].*?)\\\[([\s\S]*?)\\\](.*)/g,
193+
(match, group1, group2, group3) => {
194+
// Check if there are characters following the formula (display-formula in a table-cell?)
195+
if (group1.endsWith('\\')) {
196+
return match; // Backslash before \[, do nothing.
197+
}
198+
const hasSuffix = /\S/.test(group3);
199+
let optBreak;
200+
201+
if (hasSuffix) {
202+
latexExpressions.push(`\\(${group2.trim()}\\)`); // Convert into inline.
203+
optBreak = '';
204+
} else {
205+
latexExpressions.push(`\\[${group2}\\]`);
206+
optBreak = '\n';
207+
}
193208

194-
return `${group1}${optBreak}<<LATEX_${latexExpressions.length - 1}>>${optBreak}${group3}`;
195-
});
209+
return `${group1}${optBreak}<<LATEX_${latexExpressions.length - 1}>>${optBreak}${group3}`;
210+
}
211+
);
212+
}
196213

197214
// Match \(...\), \[...\], $$...$$ and protect them
198215
content = content.replace(
Lines changed: 58 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,58 @@
1+
# Agentic thread perf harness
2+
3+
Two tiers, both reusing the existing vitest projects (see `vite.config.ts`).
4+
5+
## Tier 1 - `agentic-stream.perf.svelte.test.ts` (project: `client`, real Chromium)
6+
7+
Mounts `ChatMessageAgenticContent` and replays a stream, replacing the message
8+
object on each chunk exactly as the real pipeline does:
9+
10+
- `chat.svelte.ts` `updateStreamingUI()` runs per SSE chunk
11+
- `conversations.svelte.ts` `updateMessageAtIndex` does `{ ...old, ...updates }`
12+
13+
That new object identity is the thing under test: it cascades through
14+
`deriveAgenticSections` (which returns fresh `AgenticSection` objects) into every
15+
tool-call block in the message, including completed ones.
16+
17+
```
18+
npx vitest --project=client --run tests/client/agentic-stream.perf.svelte.test.ts
19+
```
20+
21+
### Reading the output
22+
23+
- `mean` / `p95` / `max` - the synchronous window per token: prop write,
24+
`await tick()`, then a forced `offsetHeight` read so style and layout are
25+
included rather than deferred.
26+
- `sync` - sum of those windows. This is the number to optimize.
27+
- `wall` - the whole run including work `MarkdownContent` defers into its own
28+
`requestAnimationFrame`. It carries a ~16.7ms/token idle floor because the
29+
harness yields a frame each iteration, so compare `wall` **across fixtures**,
30+
never against `sync`.
31+
32+
### The knobs, and what each one discriminates
33+
34+
The point of the harness is the _scaling curve_, not any single number.
35+
36+
| Knob | Reads on |
37+
| --------------------------- | ---------------------------------------------------------------------------------------------------- |
38+
| `priorToolCalls` (0/1/5/20) | the reactive fan-out. Flat => no fan-out. Linear => confirmed. |
39+
| `toolResultBytes` | whole-blob string scans (`extractSearchResults`, `parseToolResultWithImages`, `classifyToolResult`). |
40+
| `editFileEdits` | `computeLineDiff`, the O(m\*n) LCS. |
41+
| `openCodeFence` | `hljs.highlightAuto` on partial code. |
42+
43+
Deliberately no hard assertions: CI timing is noisy and the value here is the
44+
before/after delta, not a gate.
45+
46+
### Caveat
47+
48+
This measures one message's subtree. In the real app `ChatMessages.svelte`
49+
rebuilds its whole `displayMessages` list per token, so multiply by the number
50+
of rendered messages to get the conversation-level cost.
51+
52+
## Tier 2 - `../unit/agentic-hotpath.bench.ts` (project: `unit`, node)
53+
54+
Per-call costs for the pure functions the curve implicates.
55+
56+
```
57+
npx vitest bench --project=unit --run tests/unit/agentic-hotpath.bench.ts
58+
```

0 commit comments

Comments
 (0)