perf(chat): widen the streaming flush window with the reply it re-renders - #716
Open
Adam-Dalloul wants to merge 1 commit into
Open
perf(chat): widen the streaming flush window with the reply it re-renders#716Adam-Dalloul wants to merge 1 commit into
Adam-Dalloul wants to merge 1 commit into
Conversation
Adam-Dalloul
force-pushed
the
perf/high-rate-streaming
branch
from
September 10, 2026 15:48
ceb1598 to
69b088d
Compare
…ders At around 300 tokens a second the whole UI stops responding (xintaofei#589), on hardware with plenty of headroom. Measured where the time goes, per streaming batch, driving a realistic chunk stream through the real code. The store and timeline are not it: `setLiveMessage` plus `computeTimeline` costs 0.03 to 0.12 ms a batch, and running the message adapter on top brings that to 0.15 to 1.0 ms. Rendering the reply is three orders of magnitude above that. Each batch replaces the live message, so the prose run it appended to is handed to the markdown renderer again whole: normalized, re-lexed into blocks (marked, 0.7 ms at 4 KB rising to 6.9 ms at 64 KB), re-highlighted, re-rendered. In the test renderer that is 3.1 ms a batch at 4 KB and 27 ms at 64 KB. An unchanged string costs 0.075 ms, so the whole of it is the run having grown. The window those batches landed in was a flat 16 ms whatever the reply had grown to, so the work a turn costs rose with the square of its own output while the rate it arrived at stayed put. Replaying 300 tok/s and counting the characters re-rendered across the turn: 30 seconds of output cost 32.5M, 120 seconds cost 518.8M: sixteen times the work for four times the answer. The window now scales with the run being re-rendered. Under 8 KB, which is nearly every reply, it is the same 16 ms as today; past that each further 8 KB buys one more frame, up to 192 ms. Same replay: 30 s falls to 11.1M and 120 s to 59.9M, and the growth goes from quadratic to near linear. It is sized from the run rather than the whole message, so a reply that has already written 9 KB and then ran a tool is back to a single frame for the block it starts next. Nothing about what gets delivered changes. The queue merges and dispatches exactly as before, every chunk lands once and in order, and every non-streaming event still flushes it immediately, so a tool card, a permission prompt or the end of a turn never waits on this window. What is left is the per-batch cost itself: the run is still re-lexed and re-rendered whole each time. Splitting a streaming reply at block boundaries so only the tail is rebuilt would remove that, but not without changing how markdown spanning the split renders.
Adam-Dalloul
force-pushed
the
perf/high-rate-streaming
branch
from
September 10, 2026 16:16
69b088d to
8a25483
Compare
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
Sign up for free
to join this conversation on GitHub.
Already have an account?
Sign in to comment
Add this suggestion to a batch that can be applied as a single commit.This suggestion is invalid because no changes were made to the code.Suggestions cannot be applied while the pull request is closed.Suggestions cannot be applied while viewing a subset of changes.Only one suggestion per line can be applied in a batch.Add this suggestion to a batch that can be applied as a single commit.Applying suggestions on deleted lines is not supported.You must change the existing code in this line in order to create a valid suggestion.Outdated suggestions cannot be applied.This suggestion has been applied or marked resolved.Suggestions cannot be applied from pending reviews.Suggestions cannot be applied on multi-line comments.Suggestions cannot be applied while the pull request is queued to merge.Suggestion cannot be applied right now. Please check back later.
For #589: at around 300 tokens a second the whole UI stops responding, on a machine with plenty of headroom.
I measured where the per-batch time goes rather than guessing, driving a realistic chunk stream through the real code.
The store and the timeline are not it.
setLiveMessagepluscomputeTimelinecosts 0.03 to 0.12 ms a batch over a 2000-batch turn, and running the message adapter on top brings that to 0.15 to 1.0 ms. Rendering the reply is three orders of magnitude above that. Every batch replaces the live message, so the prose run it appended to goes to the markdown renderer again whole: normalized, re-lexed into blocks, re-highlighted, re-rendered. In the test renderer that is 3.1 ms a batch at 4 KB and 27 ms at 64 KB; the block splitter alone (marked) is 0.7 ms at 4 KB and 6.9 ms at 64 KB. Handing it an unchanged string costs 0.075 ms, so all of it is the run having grown.The window those batches landed in was a flat 16 ms no matter how long the reply had got. So the work a turn costs rose with the square of its own output while the rate it arrived at stayed put. Replaying 300 tok/s and counting the characters re-rendered across the turn:
Sixteen times the work for four times the answer, before. Near linear, after. End to end through the real markdown renderer, 30 seconds of output went from 1803 batches and 1.9 to 3.7 times realtime in render work, to 888 batches and 1.4 to 1.6 times. (Those two numbers move with machine load; the character counts above are deterministic, which is what the test asserts on.)
The window now scales with the run being re-rendered: under 8 KB, which is nearly every reply, it is the same 16 ms as today, and past that each further 8 KB buys one more frame, up to 192 ms. It is sized from the run and not from the whole message, so a reply that has already written 9 KB and then ran a tool is back to a single frame for the block it starts next.
Nothing about what gets delivered changes. The queue merges and dispatches exactly as before, every chunk lands once and in order, and every non-streaming event still flushes it immediately, so a tool card, a permission prompt or the end of a turn never waits on this window.
Still open, and deliberately not in here:
Tests: a new
streaming-flush-cadence.test.tsfor the window and for what a 300 tok/s turn costs under it, plus two inacp-connections-context.test.tsxdriving real events through the provider on fake timers, one checking a long run waits the extra frames and the text arrives exactly as sent, one checking a new run goes back to a single frame.Composes with the open PRs rather than colliding: #705 and #708 both change
computeTimeline/conversation-runtime-store.ts, and #703 is backend. This touches only the queue inacp-connections-context.tsx, which sits above all of them, and it makes their work run fewer times per turn rather than changing what it does.