Studio: stop the streaming prefix checks scanning the whole reply by danielhanchen · Pull Request #9038 · unslothai/unsloth · GitHub
Skip to content

Studio: stop the streaming prefix checks scanning the whole reply - #9038

Merged
danielhanchen merged 3 commits into
mainfrom
perf-prefix-compare
Aug 17, 2026
Merged

Studio: stop the streaming prefix checks scanning the whole reply#9038
danielhanchen merged 3 commits into
mainfrom
perf-prefix-compare

Conversation

@danielhanchen

Copy link
Copy Markdown
Member

Four checks on the streaming path ask whether the new text still begins with what was already handled. All four used startsWith, which scans. Slicing to the prefix length and comparing lets V8 reject on length and then compare natively.

Stacked on #9017, which restructures the same file and added one of these call sites.

Measured

60,000 character stream, 1,052 comparisons, median of 5 interleaved runs, Node:

strings startsWith slice compare
sharing a parent 79.8 ms 1.1 ms 72x
not sharing one 74.3 ms 1.6 ms 48x

Both cases are given because a shared-parent microbenchmark can exaggerate this. Here it does not: the effect survives flattening, so it is not an artifact of rope structure.

Call sites: three in streaming-render-schedule.ts, one of them added by #9017, and the coalescer in markdown-text.tsx whose own comment already put its scan at 59 ms across a 175,000 character stream.

The substitution is exact

slice clamps, so a prefix longer than the string yields a short slice that cannot equal it. There is no input on which the two disagree, which is the whole point and also the thing worth testing.

A contract test asserted the spelling, not the behaviour

markdown-streaming-scheduling.test.ts asserted the literal string text.startsWith(displayed.text), so it failed on a change that keeps the behaviour identical. It now asserts both halves of the gate and accepts either spelling:

  • the length rejection has to be there, since the compare alone would run on every arrival;
  • the prefix compare has to be there, since the length check alone would pass a comparison against the wrong string.

Testing

An equivalence fuzz: 20,000 randomised cases over an alphabet including newlines, backslashes, backticks, $, an astral character and a combining mark, plus a fixed corpus and every cut point of a string whose characters straddle UTF-16 code-unit boundaries. It requires at least 4,000 of the cases to be real prefixes, so it cannot pass by rejecting everything on length.

Mutation results, per mutation rather than in aggregate:

mutation result
off-by-one on the slice caught, 3 of 3 tests fail
comparison in the wrong order caught, 3 of 3 tests fail
length guard removed not caught, and cannot be

The third is a genuine zero, not a coverage gap. Because slice clamps, removing the length guard is semantically identical and only loses the fast path, so no behavioural test can detect it. Reporting it rather than dropping it, since a mutation table with only kills in it invites the reader to assume the rest were tried.

npm test 2,946 passed, 0 failed. npm run typecheck clean. biome check adds no errors on any changed file.

danielhanchen and others added 2 commits August 17, 2026 12:13
Four checks on the streaming path ask whether the new text still begins
with what was already handled. All four used startsWith, which scans.
Slicing to the prefix length and comparing lets V8 reject on length and
then compare natively.

Measured over a 60,000 character stream, 1,052 comparisons, median of 5
interleaved runs:

  strings sharing a parent   79.8 ms -> 1.1 ms
  strings not sharing one    74.3 ms -> 1.6 ms

Both cases are given because a shared-parent microbenchmark can
exaggerate this; here it does not, the effect survives flattening.

Call sites: three in streaming-render-schedule.ts, one of them added by
the retained-prefix work, and the coalescer in markdown-text.tsx whose
own comment already put its scan at 59 ms across a 175,000 character
stream.

The substitution is exact. slice clamps, so a prefix longer than the
string yields a short slice that cannot equal it.

markdown-streaming-scheduling.test.ts asserted the literal spelling
"text.startsWith(displayed.text)", so it failed on a change that keeps
the behaviour identical. It now asserts both halves of the gate and
accepts either spelling: the length rejection has to be there, since the
compare alone would run on every arrival, and the prefix compare has to
be there, since the length check alone would pass a comparison against
the wrong string.

Testing: an equivalence fuzz, 20,000 randomised cases plus a fixed
corpus and every cut of astral text, asserting agreement with startsWith
throughout. It requires at least 4,000 of the cases to be real prefixes,
so it cannot pass by rejecting everything on length.

Mutation results, stated per mutation:
  off-by-one on the slice        caught, 3 of 3 fail
  comparison in the wrong order  caught, 3 of 3 fail
  length guard removed           NOT caught, and cannot be: slice clamps,
                                 so that mutation is semantically
                                 identical and only loses the fast path

npm test 2,946 passed, 0 failed. typecheck clean. biome adds no errors.
@danielhanchen

Copy link
Copy Markdown
Member Author

Branch state note, to avoid a duplicate rebase.

This branch is already rebased onto current main (34c9d9831) at 1084a921e, and GitHub reports it MERGEABLE. git merge-base --is-ancestor origin/main HEAD is true, so it contains main rather than trailing it.

The conflict with #9088 was real and is resolved. normalizeLineEndings and hasPrefix are adjacent insertions at the same point in streaming-render-schedule.ts, not competing edits, so both are kept, main's first. After the resolution: both symbols present, typecheck clean, frontend suite 3597 passing, and the diff against the merge base is still only the four intended files.

The branch also now carries two follow-ups from verification:

  • A, aB and AAAAAAAAAA added to the prefix corpus. Nothing else in it varied case, so a compare that folded case agreed with startsWith on every entry. Confirmed the addition fails against exactly that mutation and passes otherwise.
  • The hasPrefix doc comment corrected. It attributed the win to whether the two strings share a parent; a sweep does not support that. The win is in the prefix LENGTH: at 8 characters startsWith is faster (0.4x), at 1,024 it is 105x slower, at 60,000 it is 250x slower, and a cons receiver behaves the same as a flat one (250x against 254x). The comment now says that, and warns the helper off short prefixes since it is exported.

@danielhanchen

Copy link
Copy Markdown
Member Author

@codex review

danielhanchen added a commit to shimmyshimmer/unsloth-staging-4 that referenced this pull request Aug 17, 2026
danielhanchen added a commit to shimmyshimmer/unsloth-staging-4 that referenced this pull request Aug 17, 2026
@chatgpt-codex-connector

Copy link
Copy Markdown

Codex Review: Didn't find any major issues. Can't wait for the next one!

Reviewed commit: 1084a921e9

ℹ️ 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".

The scheduling test accepted either spelling of the coalescer gate, and the
previous code satisfies the startsWith half, so it passed unchanged on a tree
with this PR fully backed out. Pin the slice form and reject the scanning one;
verified by reverting markdown-text.tsx to the old spelling, which now fails
where it passed 5 of 5 before.

Nothing covered the three call sites in the cache at all. The two spellings
return the same answer, so no output test can separate them and reverting the
call sites was free. Assert instead that no one-argument startsWith survives on
that path; the one call the helper cannot express takes a start position and
compares a fixed block, so it does not grow with the reply and is excluded by
the same rule rather than by an exception. Verified by reverting two call sites,
which now fails.

Drop the duplicated measurement at the coalescer and point at the helper.
@danielhanchen

Copy link
Copy Markdown
Member Author

@codex review

@chatgpt-codex-connector

Copy link
Copy Markdown

Codex Review: Didn't find any major issues. Already looking forward to the next diff.

Reviewed commit: 70e333c06b

ℹ️ 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 added a commit to shimmyshimmer/unsloth-staging-4 that referenced this pull request Aug 17, 2026
@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. 9088 matters here more than anywhere else, because it edits this same file and rewrites update(markdown) into update(rawMarkdown) with a normalise on the way in, so every number below was retaken on a tree that has it. The branch is rebased onto 34c9d98 and normalizeLineEndings and hasPrefix now sit side by side; updateTail is private and only ever reached from update, so every hasPrefix call still compares normalised text against normalised source.

Is it the same function

hasPrefix(a, b) and a.startsWith(b) were compared on the real module served by each tree's own build: 4,400 comparisons per engine on V8, JavaScriptCore and SpiderMonkey, including astral pairs, combining marks, Arabic and CJK. Zero disagreements.

Is the rendered output the same

Recorded SSE from unsloth/Qwen3.5-2B-MTP-GGUF:UD-Q4_K_XL at 4096 context, replayed byte for byte to two production builds, merge base against head, comparing .aui-assistant-message-content textContent and normalised innerHTML.

33 cases across Chromium, WebKit and Firefox: all rendered text identical. Cases: ordinary reply, unterminated fence, 13,655 character reply, no fences, only fences, CJK, RTL, mixed RTL with CJK and code, reasoning, one token, empty, plus reload.

Driven through the real IncrementalMarkdownCache with 9088 in place, the cache commits exactly the same thing on both trees: 106,998 characters retained and 4,480 blocks, in the LF arm and the CRLF arm alike.

What the screenshots show

Two isolated installs, merge base 34c9d98 against this head, one scene, the same recorded reply truncated mid-reply at SSE event 883 so the settled state ends on an unterminated python fence.

settled on an unterminated fence

Both halves end on a fence the model never closed, which Studio has still rendered as a labelled, highlighted code block, with the interrupted notice, Continue and Retry. Read from the DOM, equal on both sides:

  • settled_chars_dom 4249 and settled_sha256_12 901a38cfcab2
  • 2 rendered code blocks, both labelled python
  • headings: Mastering CSV File Reading in Python, Understanding the pandas Library for Data Import, Navigating File Paths and Encoding Issues
  • received_sse_bytes 160555 on both, from the same recording, sha 85d13c7f78e0

The two settled screenshots are byte-identical PNG files. Only the clocks moved: 8.5s against 8.47s.

The pair was captured at 1084a92. The one source change since is comment-only in markdown-text.tsx, AST-verified, so it still describes this head.

Cost, and a correction to what the comment used to claim

The published attempt at this substitution, bytes.zone, failed to beat startsWith. That is not a contradiction, it is a different prefix length. Swept on V8 at a fixed haystack:

receiver prefix startsWith hasPrefix factor
cons 8 0.01 ms 0.03 ms 0.4x, startsWith wins
cons 1,024 4.73 ms 0.04 ms 105x
cons 60,000 271.30 ms 1.09 ms 250x
flat 60,000 265.76 ms 1.05 ms 254x

The effect tracks the prefix length, not whether the strings share a parent: cons and flat receivers differ by 250x against 254x, which is nothing. The doc comment used to attribute it to representation and now says this instead, including the warning not to reach for the helper on short prefixes.

End to end through the real cache, 7 repetitions each, median:

arm reply frames merge base head
LF 106,560 chars 4,441 1138 ms 443 ms
CRLF 112,320 chars 4,681 2225 ms 1054 ms

The CRLF arm only exists because 9088 landed. Before it, a CRLF reply never committed anything and took the full document path every frame, so this prefix check was not on the critical path at all. Now it is, and this halves it.

Tests, and a test that was measuring nothing

markdown-streaming-scheduling.test.ts asserted the coalescer gate as slice form || startsWith form. The previous code satisfies the second half, and the length assertion above it also passes on the previous code, so the file passed 5 of 5 on a tree with this PR fully backed out. I checked that by reverting markdown-text.tsx rather than by reading it.

Fixed in 70e333c: the slice form is pinned and the scanning form is asserted absent, verified to fail on the reverted tree. The three call sites inside the cache had no coverage at all, and since the two spellings return the same answer no output test can reach them, so there is now a source assertion that no one-argument startsWith survives on this path. The one call the helper cannot express, tail.startsWith(block, exactLength), takes a start position and compares a fixed block, so it does not grow with the reply and falls outside the rule rather than needing an exception. Verified by reverting two call sites, which fails.

Mutation tested with 8 planted bugs: 8 caught, none survive.

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 and is reported as INCONCLUSIVE rather than as agreement.
  • 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 trees.

Cross-platform

Staging CI on shimmyshimmer/unsloth-staging-4, since the org queue is carrying 34 pending checks per PR. Green on ubuntu-latest, macos-14, windows-latest, frontend ubuntu-latest and frontend macos-14. frontend windows-latest fails 13 tests, all of them clipboard, Tauri version and permission tests with nothing in the streaming path, and the same set fails on other branches, so it is pre-existing rather than caused here.

danielhanchen added a commit to shimmyshimmer/unsloth-staging-4 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 bf60ff8 into main Aug 17, 2026
34 of 37 checks passed
@danielhanchen
danielhanchen deleted the perf-prefix-compare branch August 17, 2026 13:38
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