Studio: stop the streamed reply being flattened on every arrival by danielhanchen · Pull Request #9049 · unslothai/unsloth · GitHub
Skip to content

Studio: stop the streamed reply being flattened on every arrival - #9049

Merged
danielhanchen merged 3 commits into
mainfrom
perf-stream-flatten-floor
Aug 17, 2026
Merged

Studio: stop the streamed reply being flattened on every arrival#9049
danielhanchen merged 3 commits into
mainfrom
perf-stream-flatten-floor

Conversation

@danielhanchen

@danielhanchen danielhanchen commented Aug 17, 2026

Copy link
Copy Markdown
Member

Stacked on #9012 (perf-chat-adapter-scans). Review that one first; this branch is one commit on top of it.

What is left after #9012

#9012 removed two whole-buffer scans from the chat stream adapter and reported about 16 ms per side still remaining. That remainder is not a scan, and it cannot be removed by bounding one.

Every cumulativeText += delta leaves a V8 cons string. The first thing that reads it copies the whole reply flat. So the amount a step inspects is irrelevant: one charCodeAt costs the same as a full scan. Measured over a 220,000 character reply of 55,005 arrivals of 4, medians of 7, paired and interleaved:

A  flat parent, slice(0, c)            0.28 ms
B  flat parent, slice(c - 13, c)       0.14 ms
C  rope, slice(0, whole length)        0.09 ms
D  rope, slice(length - 13)          271.62 ms
E  rope, one charCodeAt              282.57 ms

Three things are free on the accumulating buffer: appending, .length, and the degenerate slice(0, length) that V8 answers with the rope itself. Exactly one thing costs, at about 1000x, and it is forcing the rope flat.

That the remainder is the flatten and not the reads is directly checkable. Same reads, same buffer contents, answers asserted identical, only the representation differs. 55,000 characters, 13,755 arrivals, medians of 9:

buffer built by += (rope, flattened per arrival)   17.23 ms
same reads over an already flat buffer              1.10 ms    16x

Three steps ran per arrival and all three read the buffer, so all three paid it. Each now takes what the arrival added instead.

What changed

appendCumulative becomes the single place the reply grows, so everything derived from it sees the same characters in the same order.

  • The think-tag tracker takes the delta and keeps the seven characters in front of it itself, which is the most a tag split across arrivals can hide behind.
  • The trailing ${...} strip cannot avoid touching the end of the reply when it fires, so a watch decides from the deltas whether it could fire at all, from the last non-whitespace character, the last ${, and the two most recent }. It never says no when the strip would cut, so nothing that used to be stripped survives, and a reply that never ends in a brace never wakes the strip.
  • parseAssistantContent over the whole reply, which liveAssistantContent ran on every arrival, becomes an incremental parse that keeps the parts it has produced and extends them with the delta. It holds back the trailing characters that could still turn out to be a tag, so <thi is text until nk> arrives and reasoning after. Runs are cut at the tool-call cursors exactly as before, including that think state resets at a boundary.

The retained state describes an append-only reply. A rewritten prefix, a removed suffix, or a tool call landing behind the end shows up as a length or boundary mismatch and reparses from the buffer, which is what this replaces, so those paths are no slower than before. An external continuation whose prefix joinContinuation may repair never uses the incremental path at all.

Before and after

Whole per-arrival path, paired and interleaved, medians of 5, answers cross-checked identical between the two sides at every size:

reply arrivals before after
55,000 13,755 43.23 ms 5.10 ms 8x
110,000 27,505 188.18 ms 10.91 ms 17x
220,000 55,005 3389.88 ms 24.53 ms 138x
400,000 100,005 12838.32 ms 37.41 ms 343x

The factor grows because the old cost was quadratic. It steepens again past 131,072 characters, where a flattened one-byte string stops fitting in a regular heap object and every flatten allocates in large-object space instead. Bisecting the transition, medians of 9:

131,000 chars    96.18 ms    22303 MB/s        132,000 chars   117.72 ms   18501 MB/s
131,060 chars    95.12 ms    22572 MB/s        134,000 chars   187.70 ms   11958 MB/s
                                               140,000 chars   339.02 ms    7227 MB/s

v8.getHeapSpaceStatistics() confirms it: large_object_space grows 0.0 MB at 131,000 characters and 1.2 MB at 140,000.

This is V8's kMaxRegularHeapObjectSize, half a 256 KB page, and Heap::AllocateRaw dispatches on it with a bare size_in_bytes > kMaxRegularHeapObjectSize. The cliff is a known V8 issue rather than something specific to us: v8:13085 reports a 7x discontinuity at exactly this boundary, and a V8 developer's reply there notes that the 128 KB limit "is not outlandishly big", so ordinary short-lived objects reach it. Large objects skip young space entirely, which is why the cost characteristics change so sharply. See also the write-up in danbev/learning-v8 notes/heap.md.

This is the regime a long code-heavy reply actually lands in.

The after column is linear in the reply, which is the check that no flatten is left.

These are node measurements of the adapter loop. The browser harnesses do not reach this code: PR 9016's heavy-thread harness has no streaming action, and PR 8969's stream harness drives a stub ChatModelAdapter that never calls createOpenAIStreamAdapter. Measuring it there would mean standing up a backend SSE endpoint, which was not proportionate, so the node measurement is the evidence and this states its limits.

Tests

A differential fuzz test drives random arrival streams, with tags split at every offset, tool boundaries, unclosed tags, empty arrivals and truncations, and requires the incremental parse to deep-equal a full reparse after every single arrival. The placeholder watch gains a soundness sweep: for every state of every stream, if the strip would cut, the watch admitted it.

Two source pins from #9012 are repointed rather than dropped. The one that asserted the exact spelling of the tracker call now asserts what that spelling protected, that the reply grows through one call, which is what keeps the delta-fed pieces in step. A new pin enumerates the forms in which the loop may mention the buffer at all, because character counting cannot see this defect: a charCodeAt and a full scan read the same characters and cost the same, so only a rule about touching the buffer catches a regression.

Every new test was run against a deliberately broken tree. One does not earn its place and is called out rather than quietly kept: a think block split by a tool boundary parses as the adapter parses it passes under all 14 breaks tried. It is a characterisation pin on existing behaviour and is documented as such.

Full frontend suite 2973 pass, 0 fail. Typecheck clean.

Not done here

assistant-stream, already a dependency, exposes an append-based appendText(textDelta), and migrating the adapter onto AssistantStream would be the upstream-aligned form of this fix. That is a rewrite of a 6,000 line generator that would still need cumulativeText for the finalizers, the token estimate and continuation repair, so it is not attempted here.

countReasoningGroups and lastReasoningGroupTextLength are deliberately left alone. They take the parts array rather than the text, and measure 0.87 to 1.76 ms over the same 55,000 character stream, about 5% of the flattening floor and about 1% of the parse this removes. They are O(1) per arrival and not worth touching.

danielhanchen added a commit to Datta0/unsloth-staging-3 that referenced this pull request Aug 17, 2026
Base automatically changed from perf-chat-adapter-scans to main August 17, 2026 10:41
PR 9012 removed two whole-buffer scans from the chat stream adapter and
left about 16 ms per side behind. That remainder is not a scan. Every
`cumulativeText += delta` leaves a cons string, and the first thing that
reads it copies the whole reply flat, so a single `charCodeAt` costs the
same as a full scan. Measured over a 220,000 character reply of 55,005
arrivals, medians of 5 to 7 repetitions, paired and interleaved:

  append only, nothing reads the buffer          0.84 ms
  one bounded scan of the tail per arrival    1715.98 ms

Three steps ran per arrival and all three read the buffer, so all three
paid that. Each now takes what the arrival added instead.

The think-tag tracker takes the delta rather than slicing it back out of
the buffer, and keeps the seven characters in front of it itself, which
is the most a tag split across arrivals can hide behind. 1743.92 ms to
4.19 ms at 220K.

The trailing `${...}` strip cannot avoid touching the end of the reply
when it fires, so a watch decides whether it could fire at all, from the
deltas: the last non-whitespace character, the last `${`, and the two
most recent `}`. It never says no when the strip would cut, so nothing
that used to be stripped survives, and a reply that never ends in a
brace never wakes the strip. 1715.98 ms to 6.74 ms at 220K.

`parseAssistantContent` over the whole reply, which `liveAssistantContent`
runs on every arrival, becomes an incremental parse that keeps the parts
it has already produced and extends them with the delta. It holds back
the trailing characters that could still turn out to be a tag, so `<thi`
is text until `nk>` arrives and reasoning after, matching a full reparse
at every state. Runs are cut at the tool-call cursors exactly as before,
including that think state resets at a boundary. 1978.78 ms to 7.37 ms
at 220K.

The retained state describes an append-only reply. A rewritten prefix, a
removed suffix or a tool call landing behind the end shows up as a length
or boundary mismatch and reparses from the buffer, which is what this
replaces, so those paths are no slower than before. An external
continuation whose prefix `joinContinuation` may repair never uses the
incremental path at all.

Whole per-arrival path, before to after, medians of 5:

   55,000 chars   13,755 arrivals    236.89 ms ->  4.06 ms    58x
  110,000 chars   27,505 arrivals    795.09 ms ->  5.73 ms   139x
  220,000 chars   55,005 arrivals   4882.98 ms -> 13.06 ms   374x
  400,000 chars  100,005 arrivals  17060.48 ms -> 21.57 ms   791x

The factor grows with the reply because the old cost was quadratic and
steepens again past 131,072 characters, where a flattened string stops
fitting in a regular heap object.

Tests. A differential fuzz test drives random arrival streams, with tags
split at every offset, tool boundaries, unclosed tags, empty arrivals and
truncations, and requires the incremental parse to deep-equal a full
reparse after every single arrival. The think tracker and the placeholder
watch keep their existing suites, driven through the new delta API, and
the watch gains a soundness sweep: for every state of every stream, if
the strip would cut, the watch admitted it.

Two source pins from 9012 are repointed rather than dropped. The one that
asserted the exact spelling of the tracker call now asserts what that
spelling protected: the reply grows through one call, which is what keeps
all three delta-fed pieces in step. A new pin lists the forms in which
the loop may mention the buffer at all, because character counting cannot
see this defect: a `charCodeAt` and a full scan read the same characters
and cost the same, so only a rule about touching the buffer catches it.
No conflict: 9085 and 9088 land in the assistant-ui render schedule and its
tests, which this branch does not touch.
@danielhanchen

Copy link
Copy Markdown
Member Author

A coverage gap worth closing before this merges, plus a branch-state note.

The gap

The watch lets the adapter skip the strip on almost every arrival. That is only sound while one invariant holds:

isCandidate() is never false on an arrival where stripTrailingTemplatePlaceholder would cut.

If it is ever false at such a moment, the reply keeps a ${...} fragment that the previous build removed. That is a visible difference in the user's text, not a slower path.

trailing-placeholder-watch.test.ts works on short buffers, so the reseed after a strip is never put under pressure: it looks back a bounded window and forgets everything older, and only a buffer longer than that window can catch it forgetting something it still needs.

Measured, by mutating the source rather than by reading it. Setting RESEED_WINDOW = 8:

suite result on that mutation
trailing-placeholder-watch.test.ts 4 of 4 passing
a sweep across the reseed window fails, with cases like "the strip would cut 708 characters but isCandidate() said no"

So the mutation is a real defect the current tests do not see.

The test

trailing-placeholder-reseed.test.ts drives the watch exactly as the adapter does (append, then strip and retract when the gate opens) and checks the invariant at every arrival, including immediately after each retract, since a second fragment can already be sitting at the end. It covers two fragments back to back, a fragment followed by whitespace only, the opener landing on each offset either side of the reseed edge, resumed turns, and seeded random streams that cross the window repeatedly. 118,021 states with 13,905 real strips, in 0.7 s.

It also asserts a strip actually fired, so it cannot pass by never exercising the thing it is about.

Confirmed both ways: it fails on RESEED_WINDOW = 8 and passes on the restored source.

Branch state

The commit adding it is prepared but deliberately not pushed yet, to avoid racing another rebase in flight on this branch. It applies on top of 79a38ecd0; frontend suite 3605 passing with it.

Two other results from the same round, for the record: RESEED_WINDOW aside, the differential found no behavioural difference between this branch and its merge base across recorded real streams, and the two surviving mutations on Math.max over the tag indices and on the heldBackLength bound are equivalent mutations rather than gaps, so they are deliberately not covered.

@danielhanchen

Copy link
Copy Markdown
Member Author

@codex review

@danielhanchen

Copy link
Copy Markdown
Member Author

Re-verified after the main merge, on the merged tree rather than the pre-merge one.

The reseed test still discriminates: with RESEED_WINDOW = 8 it fails, and on the restored source it passes. Worth confirming rather than assuming, because #9088 made the line ending live and the corpus now carries carriage returns, either of which could have changed what the sweep reaches.

Two things found in the same round that belong outside this PR, noted here so they do not get pulled into it:

  • Chat: an external-provider reply containing a template literal loses text mid-stream #9098, a pre-existing data-loss bug. On an external provider a reply containing a template literal loses text permanently mid-stream: return + "" + Hi, ${name}! + "" + goes in at 21 characters and arrives at 13. The end-anchored strip is run against every prefix of the buffer and the result assigned back, so it fires on the single arrival where the buffer transiently ends at${name}. It reproduces on this branch's merge base and on main`, and this PR neither causes nor fixes it.

  • The two per-frame gates. stream-pacing.ts reopens a publish gate on a frame, and useCoalescedStreamingText in markdown-text.tsx schedules another before display. They sit in series: measured arrival-to-paint median 41.0 ms with both against 23.6 ms with the second disabled, roughly one frame, reproduced across three configurations, with paint count unchanged. Both predate this PR and its diff adds, moves and removes none of them, so this needs its own change against main. Prior art worth citing there: open-webui landed this shape in perf: batch history reactive updates to rAF in chatEventHandler open-webui/open-webui#22947 and reverted it the next day in #23016.

@chatgpt-codex-connector

Copy link
Copy Markdown

Codex Review: Didn't find any major issues. Swish!

Reviewed commit: 6992e372ee

ℹ️ About Codex in GitHub

Codex has been enabled to automatically review pull requests in this repo. Reviews are triggered when you

  • Open a pull request for review
  • Mark a draft as ready
  • Comment "@codex review".

If Codex has suggestions, it will comment; otherwise it will react with 👍.

When you sign up for Codex through ChatGPT, Codex can also answer questions or update the PR, like "@codex address that feedback".

@danielhanchen

Copy link
Copy Markdown
Member Author

Heads up on an adjacency, no action needed on this branch.

#9101 fixes #9098 by moving the trailing ${...} strip out of the SSE loop and running it once on the finished reply. This branch keeps it inside the loop behind placeholderWatch.isCandidate(), which is the right thing for a performance change to do, since it preserves the existing behaviour exactly. That existing behaviour is the data-loss bug: an end-anchored strip run against every prefix of the reply deletes ${name} out of return \Hi, ${name}!``.

So the two touch adjacent lines and will need a small merge whichever lands second. If #9101 lands first, placeholderWatch has nothing left to guard at that call site: the strip runs once per reply rather than once per arrival, so the scan it was avoiding is no longer on the hot path at all. The appendCumulative funnel and the think-tag delta tracking are unaffected either way.

Also worth knowing for the reseed and watch tests here: after #9101 the buffer inside the loop only ever grows. The tracker's shrink branch and the watch's retract stop being reachable from the adapter.

@danielhanchen

Copy link
Copy Markdown
Member Author

Verification against current main

Re-verified end to end after main moved: 9085 and 9088 merged at 12:06Z, and this branch now carries Merge origin/main on top of 34c9d98. 9088 rewrites IncrementalMarkdownCache.update to normalise line endings, which is the render path this PR feeds, so every number below was retaken rather than carried over.

The bar

The rendered output must be the same as before, character for character, not merely look the same. Deferred work is fine; missing content is not.

Live sampling cannot show that. Three runs of one prompt against unsloth/Qwen3.5-2B-MTP-GGUF:UD-Q4_K_XL at 4096 context, temperature 0, seed 1234, gave 1718, 1637 and 1590 characters and first diverged at character 477. So the SSE was recorded once and replayed byte for byte to both sides, and the only variable left is the frontend source.

Correctness

what scale result
Per-arrival differential, merge base against head, comparing cumulativeText, endsInsideThink and the assembled content parts at every arrival 30,000 streams, 267,488 arrivals, 15,558 of them with a real strip identical, no divergence
Recorded replies from the model replayed through both trees 81 cases, final text and every intermediate frame 81 of 81 identical
Gate safety invariant: isCandidate() is never false when the strip would cut 118,021 states, 13,905 real strips holds
Production builds in three engines, .aui-assistant-message-content textContent plus normalised innerHTML 33 cases across Chromium, WebKit and Firefox all rendered text identical

The differential corpus now includes carriage returns, added because 9088 made the line ending a live variable below this code. A lone \r appears on its own so a CRLF pair can land split across two arrivals.

Cases covered: ordinary reply, unterminated code fence, 13,655 character reply, no fences, only fences, CJK, RTL, mixed RTL with CJK and code, reasoning, one token, empty, plus interrupt, reload and two tabs.

What the screenshots show

Two isolated installs, merge base 34c9d98 against this head, one scene driven against both, the same recorded reply served to each.

Mid-stream, both sides part way through the same reply:

mid-stream

Settled:

settled

The halves are identical by design, so the evidence is the fact list, not the picture. Read from the DOM on each side, equal on both:

  • settled_chars_dom 7270 and settled_sha256_12 25ff91a3919c
  • 3 rendered code blocks, all labelled python
  • headings: Mastering CSV File Reading in Python, Understanding the pandas Library for Data Import, Navigating File Paths and Encoding Issues, Handling Special Characters and Quoting, Conclusion, Practical Tips for Effective CSV Processing
  • received_sse_bytes 278028 on both, from the same recording, sha 85d13c7f78e0

Only the clocks moved: 11.9s against 12.77s to settle, 61 ms against 78 ms longest paint gap.

Cost

Both claims reproduce. Per-arrival bookkeeping, paired in one process:

reply merge base head
5,000 chars 1.0 ms 1.3 ms
60,000 chars 24.5 ms 3.9 ms
240,000 chars 2672 ms 24.2 ms

Stated plainly: below about 10,000 characters this is at parity or marginally slower. The win is on long replies.

Tests

Mutation tested by planting 21 plausible bugs. 19 are caught. The two that survive are equivalent mutants with no observable effect: the Math.max on lastOpen cannot move the index backwards because any tag inside the window is at or after the recorded index, and heldBackLength's tag.length - 1 bound cannot matter because the loop has already consumed every complete tag, so rest cannot end with one.

Two of the four that survived on the first pass were real gaps, both in createTrailingPlaceholderWatch.retract's reseed, on buffers longer than the window it looks back over. tests/trailing-placeholder-reseed.test.ts closes them, and it was checked against a deliberately broken tree rather than trusted: with RESEED_WINDOW = 8 it fails with "the strip would cut 59 characters but isCandidate() said no", while trailing-placeholder-watch.test.ts still passes 4 of 4 on that same mutant.

What was not tested

  • Playwright WebKit stands in for the webviews Desktop embeds. WKWebView and WebKitGTK themselves were not run.
  • two_tabs renders zero characters on both sides, so it measures nothing. The harness now reports that as INCONCLUSIVE instead of IDENTICAL; it used to report agreement about nothing.
  • The interrupt case disagrees on one 8 character token some of the time. Driving the same tree against itself disagrees more often, 4 runs in 8 and in both directions, against 2 in 8 for base against head, so that case cannot separate the two trees and is not evidence either way.

Unrelated, found while measuring

Streaming a JavaScript template literal through an external provider permanently deletes text: return `Hi, ${name}!` becomes return `Hi,!`, 215 characters in and 207 out. It reproduces identically on the merge base, so this PR neither causes nor fixes it. Filed as #9098.

Staging CI on Datta0/unsloth-staging-3, since the org queue is carrying 34 pending checks per PR.

danielhanchen added a commit to Datta0/unsloth-staging-3 that referenced this pull request Aug 17, 2026
@danielhanchen

Copy link
Copy Markdown
Member Author

Follow-up: the interrupt case, settled properly

The earlier note said the interrupt scenario cannot separate the two trees. Here is the full study rather than the summary, because a case that disagrees at all deserves the numbers.

The scenario holds the replayed stream after an exact SSE event count, so both sides receive identical bytes, then clicks Stop. The whole disagreement is one 8 character token, " because", at character 536: 561 characters against 569.

arm runs disagreements values seen on each side
9038 merge base against head 8 2 base 561 and 569, head 569
9038 merge base against ITSELF 8 5 561 and 569 on both sides
9038 head against ITSELF 8 5 561 and 569 on both sides
9049 merge base against head 8 4 561 and 569 on both sides

Each tree compared against itself disagrees more often than the two trees compared against each other, and every tree produces both values. So the split is where the Stop lands relative to the paint, not what either tree renders. The case is reported, and it is not evidence in either direction.

Two other cases were re-run rather than assumed:

  • webkit/reasoning came back once with the head at zero characters and the base at 150. Repeated three times it is 150 against 150 every time, so the single zero was a capture failure, not a regression.
  • two_tabs renders zero characters on both sides. That is agreement about nothing, and the harness now says INCONCLUSIVE where it used to say IDENTICAL. It remains an untested scenario rather than a passing one.

Staging CI

Both PRs are green on ubuntu-latest, macos-14, windows-latest, frontend ubuntu-latest, frontend macos-14 and studio-playwright. frontend windows-latest fails 13 tests on both, all clipboard, Tauri version and permission tests with nothing in the streaming path, and the same set fails on unrelated branches.

The first Playwright attempt failed with KeyError: 'STUDIO_OLD_PW' before opening a browser, which is a mismatch between the generated staging workflow's environment and what that script reads, not a defect here. Re-run against a script the workflow can actually drive, it exercises the streaming viewport for real: stream, idle, silent growth and scroll intent phases all reported, idle frame loop at zero per two seconds, and scrolling up stays detached through further streaming and re-attaches on scrolling back.

@danielhanchen
danielhanchen merged commit 4f0d691 into main Aug 17, 2026
36 of 37 checks passed
@danielhanchen
danielhanchen deleted the perf-stream-flatten-floor branch August 17, 2026 13:38
danielhanchen added a commit to shimmyshimmer/unsloth-staging-4 that referenced this pull request Aug 17, 2026
… reply

Fixes unslothai#9098.

The trailing `${...}` strip ran on every SSE arrival and assigned its
result back, so "ends with ${...}" was tested against every prefix of the
reply rather than the reply. The one arrival whose buffer ended at a
complete fragment was cut, and the reassignment made the cut permanent:

    in     return `Hi, ${name}!`      21 chars
    out    return `Hi,!`             13 chars

The strip now runs once, after the stream has finished. The fragment it
was added for in unslothai#4706 is still removed, because that one really is at
the end of a completed answer.

Rebased onto unslothai#9049. The watch it added still gates the scan, and now
saves the whole reply from being flattened rather than one arrival's
worth, so nothing on the arrival path can flatten the buffer at all.

Abort keeps the buffer whole: that tail is a prefix again, so stripping
it would be the same bug. `producedReplyText` is the same case one step
in, for a continuation that finishes without a text or reasoning delta
and so holds nothing but the partial it was seeded with.
birhantprkc pushed a commit to birhantprkc/unsloth that referenced this pull request Aug 17, 2026
… reply (unslothai#9101)

Fixes unslothai#9098.

The trailing `${...}` strip ran on every SSE arrival and assigned its
result back, so "ends with ${...}" was tested against every prefix of the
reply rather than the reply. The one arrival whose buffer ended at a
complete fragment was cut, and the reassignment made the cut permanent:

    in     return `Hi, ${name}!`      21 chars
    out    return `Hi,!`             13 chars

The strip now runs once, after the stream has finished. The fragment it
was added for in unslothai#4706 is still removed, because that one really is at
the end of a completed answer.

Rebased onto unslothai#9049. The watch it added still gates the scan, and now
saves the whole reply from being flattened rather than one arrival's
worth, so nothing on the arrival path can flatten the buffer at all.

Abort keeps the buffer whole: that tail is a prefix again, so stripping
it would be the same bug. `producedReplyText` is the same case one step
in, for a continuation that finishes without a text or reasoning delta
and so holds nothing but the partial it was seeded with.
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.

1 participant