Skip to content

fix(ui): O(N²)→O(N) text/reasoning accumulation in processUIMessageStream + DefaultStreamTextResult - #15669

Closed
meitalbensinai wants to merge 6 commits into
vercel:mainfrom
meitalbensinai:fix/processuistream-quadratic-textaccum
Closed

fix(ui): O(N²)→O(N) text/reasoning accumulation in processUIMessageStream + DefaultStreamTextResult#15669
meitalbensinai wants to merge 6 commits into
vercel:mainfrom
meitalbensinai:fix/processuistream-quadratic-textaccum

Conversation

@meitalbensinai

@meitalbensinai meitalbensinai commented May 28, 2026

Copy link
Copy Markdown

Fixes #15670

Summary

processUIMessageStream and DefaultStreamTextResult accumulate streaming text/reasoning deltas with part.text += chunk.delta. On long thinking-mode streams (Anthropic extended-thinking, MiniMax M2, GPT-o-series, Qwen3-thinking, etc.) this becomes O(N²) in cumulative text length: V8/JSC try to keep the rope lazy, but any read of .text between writes (UI render loop, NDJSON serializer, anything that flattens) forces re-allocation + memcpy of the prior content on every subsequent +=.

Same shape as the bug fixed in #14619 for @ai-sdk/mcp. See #15670 for the full bug write-up with reproducer + CPU profile.

Effect on real workloads

opencode batch run on SWE-bench-Pro, MiniMax-M2.7 via Tabnine proxy, same instances both runs:

Instance Before After (this PR) Δ
flipt-io/flipt-21a935 1666 s (47 steps, 35.4 s/step) 800 s (47 steps, 17.0 s/step) 2.1× faster
future-architect/vuls-7e91f5 7200 s — HARD TIMEOUT steady ~21 s/step, completes escapes timeout

CPU profile evidence

perf record -F 99 -g -p <bun_pid> -- sleep 60 on a live opencode stream:

Metric Before After
__memmove_avx_unaligned_erms in libc.so.6 30.27% of samples <1.4%
HeapHelper GC threads 4.0–4.3% CPU each (pinned) 1.3–1.6% each (normal idle)
Main JS thread 85% CPU state R (pegged) 52.7% (active, not pegged)

Fix

Chunks-array + lazy getter on .text. Public API surface preserved — textPart.text / reasoningPart.text returns the cumulative string just like before. Internal storage is now an array of chunks; the getter materializes via join('') on read with a one-shot collapse (when _chunks.length > 1, join + replace with single-element array so subsequent reads are O(1) until next delta).

Accumulator stays O(N) total for writes. Reads are O(N) per call but cached between deltas, so consumers that debounce (e.g. UI render at requestAnimationFrame) pay O(N) total.

Files changed

  • packages/ai/src/ui/process-ui-message-stream.ts — text-start, text-delta, reasoning-start, reasoning-delta
  • packages/ai/src/generate-text/stream-text.ts — activeText delta + activeReasoning delta in DefaultStreamTextResult

Behavior change

None to the public API. The .text property is now a defined getter/setter (was a plain data property), which:

  • ✅ JSON.stringify / structuredClone invoke the getter and serialize the current accumulated text correctly
  • ✅ Reading part.text mid-stream returns the cumulative text-so-far (progressive UI rendering keeps working)
  • ✅ Writing part.text = '...' still works (handled by the setter)
  • ⚠ Adds a property descriptor at text-start; we benchmarked this against the baseline and saw no measurable V8 deopt in practice

Out of scope (follow-up)

processUIMessageStream has a third site at partialToolCall.text += chunk.inputTextDelta followed by await parsePartialJson(partialToolCall.text). That one reads the cumulative text on every delta, so the same fix doesn't apply cleanly — needs an incremental partial-JSON parser. Left for a follow-up PR; manifests as residual memmove on workloads that stream very large tool inputs (large write/edit JSON args).

Reproducer

import { processUIMessageStream, createStreamingUIMessageState } from 'ai';

const N = 10000;
const CHUNK = 'x'.repeat(200);

const stream = new ReadableStream({
  start(c) {
    c.enqueue({ type: 'text-start', id: '1' });
    for (let i = 0; i < N; i++) c.enqueue({ type: 'text-delta', id: '1', delta: CHUNK });
    c.enqueue({ type: 'text-end', id: '1' });
    c.close();
  }
});

const state = createStreamingUIMessageState({ lastMessage: undefined, messageId: 'm1' });

const t0 = performance.now();
const out = processUIMessageStream({
  stream,
  runUpdateMessageJob: (job) => job({ state, write: () => {} }),
  onError: (e) => { throw e; },
});
const reader = out.getReader();
while ((await reader.read()).done === false) {}
console.log(`elapsed: ${(performance.now() - t0).toFixed(0)} ms`);
// Before fix: ~25 s (quadratic at N=10000)
// After this PR: ~1 s (linear)

Test plan

  • Existing UI message stream tests still pass
  • Reproducer above runs in <2 s wall on CI (was ~25 s on ai@6.0.168)
  • Manual: chat-UI demo app (progressive text rendering) still renders incrementally

🤖 Generated with Claude Code

processUIMessageStream and DefaultStreamTextResult accumulate streaming
text and reasoning deltas with `part.text += chunk.delta`. On long
thinking-mode streams (Anthropic extended-thinking, MiniMax M2,
GPT-o-series, Qwen3-thinking, etc.) this is quadratic in cumulative
text length: V8/JSC try to keep the rope lazy, but any read of
`.text` between writes (e.g., from a UI render loop or NDJSON
serializer) forces flatten, and the next `+=` allocates a fresh
buffer and memcpy's the prior content.

Effect on real workloads (opencode batch run on SWE-bench-Pro,
MiniMax-M2.7 via Tabnine proxy, same instance):

  flipt-21a935: 1666 s (47 steps, 35.4 s/step)
                → 800 s  (47 steps, 17.0 s/step)  — 2.1× faster
  vuls-7e91f5:  7200 s HARD TIMEOUT
                → ~21 s/step steady, completes — escapes timeout

CPU profile (60 s perf record -F 99 -p <bun_pid> -g):
  Before: 30.27% __memmove_avx_unaligned_erms, 7× HeapHelper GC
          threads pinned at 4.0–4.3% CPU each, main JS thread at
          85% CPU state R.
  After:  <1.4% memmove, HeapHelper threads at 1.3–1.6% CPU each
          (normal idle), main JS thread at 52.7% CPU.

Same bug shape as vercel#14619 (already fixed for @ai-sdk/mcp).

Fix: chunks-array + lazy getter on `.text`. The getter materializes
on demand and uses one-shot collapse (when _chunks.length > 1,
join + replace with single-element array). The public API surface
(`.text` returns the cumulative string) is preserved, so chat UIs
that render progressively continue to work. The accumulator stays
O(N) total for writes; reads are O(N) per call but cached between
deltas (cheap after the first read in a quiet window).

Sites patched:
  packages/ai/src/ui/process-ui-message-stream.ts:
    - text-start / text-delta
    - reasoning-start / reasoning-delta
  packages/ai/src/generate-text/stream-text.ts:
    - activeText delta (in DefaultStreamTextResult)
    - activeReasoning delta

Out of scope:
  - partialToolCall.text += chunk.inputTextDelta — the next line
    is parsePartialJson(partialToolCall.text) which needs the
    cumulative text on every delta. Fixing needs an incremental
    partial-JSON parser. Left for a follow-up.

🤖 Generated with [Claude Code](https://claude.com/claude-code)

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
Comment thread packages/ai/src/ui/process-ui-message-stream.ts Outdated
Address review feedback from vercel#15669: the previous patch assigned
_chunks via plain `=`, which created an enumerable own property.
That leaked _chunks through JSON.stringify(), structuredClone(),
and spread (...obj) into all consumers — implementation detail
escapes the API surface.

Switch to Object.defineProperty with enumerable: false. Subsequent
reassignments inside the getter/setter preserve the descriptor
(ES spec: assignment to an existing data property only updates
[[Value]], not [[Enumerable]]/[[Writable]]/[[Configurable]]).

Verified with a bench:
- JSON.stringify(part) no longer contains "_chunks"
- structuredClone(part) returns {type, text: <string>, ...} (no _chunks)
- {...part} spread same
- Object.keys(part) no longer lists _chunks
- final text accumulated correctly (N × delta_size chars)
- progressive .text reads still monotonic
- per-delta cost still O(1) (no perf regression)

🤖 Generated with [Claude Code](https://claude.com/claude-code)

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
@meitalbensinai

Copy link
Copy Markdown
Author

Good catch — fixed in 6374c81.

Swapped the _chunks initial assignment from obj._chunks = [...] to Object.defineProperty(obj, '_chunks', { value, writable: true, enumerable: false, configurable: true }). Subsequent reassignments inside the getter/setter preserve the descriptor (ES spec: obj.prop = X on an existing data property only updates [[Value]], not [[Enumerable]]/[[Writable]]/[[Configurable]]).

Verified the leak is closed with a small bench against readUIMessageStream:

"leaked_in_JSON_stringify":      false      (was true)
"leaked_in_structuredClone":     false      (was true)
"leaked_in_spread":              false      (was true)
"_chunks_in_Object_keys":        false      (was true)

"final_text_correct":            true       (correctness preserved)
"progressive_reads_monotonic":   true       (chat-UI reads still work)
"ms_per_delta":                  0.797      (still O(1) per write — no quadratic regression)

A nice side effect: structuredClone(state.message) now hands consumers a clean {type, text: <string>, providerMetadata, state} snapshot — the implementation detail (_chunks array) stays inside the SDK.

🤖 Generated with Claude Code

@aayush-kapoor aayush-kapoor left a comment

Copy link
Copy Markdown
Collaborator

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

there's some code duplication + certain things to be changed.

so marking this as closed in favor of #15897

Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

perf: O(N²) quadratic memmove in processUIMessageStream + DefaultStreamTextResult on long thinking-mode streams

2 participants