Repository navigation
perf(flow-chat): cut per-flush cost on the streaming render path - #3545
Open
Raing5Days wants to merge 3 commits into
Open
Raing5Days wants to merge 3 commits into
Raing5Days wants to merge 3 commits into
Conversation
Four fixes found while profiling the Web UI streaming hot path. Each removes work that ran unconditionally on every batched flush (32ms while text streams). - TextChunkModule: newline coalescing re-scanned the whole accumulated response on every chunk, which is O(N^2) over a long answer. Both sides of an append are already clean, so only the two-character join boundary plus the incoming chunk need cleaning. Reused item content is cleaned once when recovered so the buffer invariant still holds. - EventBatcher: build the trace payload only when trace output can reach a sink. The logger drops the entry after its level check, so every flush previously allocated a full event map for a discarded log line. The slow-flush warning keeps its counts. - modernFlowChatStore: drop the immer middleware. The setters only assign whole fields, so produce() and its deep freeze walked the entire session and virtual item tree on every flush for no benefit. Also reset the needsUserAttention projection cache guard in clear(). - useConversationTimeline: remember prewarmed text items in a WeakSet so a streaming flush no longer re-walks the whole timeline. Verification: 133 vitest cases across the touched modules pass, tsc --noEmit is clean, eslint reports 0 errors. Web UI only; no remote-workspace, remote-control, peer-device, or dispatch protocol behavior changes.
Raing5Days
force-pushed
the
perf/flow-chat-streaming-hot-path
branch
from
October 24, 2026 16:34
b53bbd7 to
b4fc0d1
Compare
A second pass over the same streaming path, looking for work that grows with the conversation rather than with the token that arrived. - timeline/document: every projection rebuilt a state-sized revision string and embedded it into a fresh per-source revision, then matched the cache by comparing those strings. That is O(sources x |view state|) of string building and comparing per flush. The projection now stamps the reader/group inputs by identity and compares one number per source. Identity is what the caller's own memo already keys on, so the contract is unchanged. - FlowChatStore.updateModelRoundItem: every round ahead of the target re-derived its attempts from items and threw the result away; a streamed token always lands in the last round. Rounds that neither recorded attempts nor hold the item now return before the derivation. - useFlowChatVirtualizer: the key sequence was enumerated twice per flush (a join for the revision, a Set for retired measurements), allocating one string per row each time. Keys are now cached per row object and the sequence is compared by reference, so a quiet flush allocates nothing; the join and the Set only run when the sequence actually changed. The cache bypasses primitives because the hook stays generic. - VirtualMessageList: the estimate revision collator-sorted the disclosure map on every render. It is now memoized on that map's identity. Verification: 4311 vitest cases across src/flow_chat pass, tsc --noEmit clean, eslint 0 errors. Local Web UI only; no remote scenario behavior changes.
A third pass over the same streaming path, now looking at what the live round
re-projects on every batched flush.
- timeline/document, disclosure: readerExpanded asked whether any member of a
group is an expanded tool capsule by serializing a key per member --
toolCapsuleStateKey is a three-element JSON.stringify. That ran for every
member of every group even when nothing was expanded, which is the common
case. The expanded keys are now inverted once per projection (turn id to
member ids) and the member scan is skipped entirely when the set is empty. A
test pins the key format this now depends on.
- timeline/document, block writer: add() built the whole row before deciding to
reuse the previous one -- the data spread plus a member scan for isStreaming,
projectedGroups and memberIds -- so a quiet flush allocated payloads it
immediately discarded. The reuse predicate now reads cheap primitives first
and only the misses build a row.
- grouping/groupProjection: the threshold test copied the item list per group,
the merged-run finalizer flattened each part four times while building its
source ids, and the trailing pass re-mapped every row. The threshold is now a
counting scan, each part is flattened once, and rows whose groups already meet
the threshold keep their identity.
Verification: 499 vitest cases across flow_chat/{grouping,timeline,store}, 1090
across flow_chat/components/modern, tsc --noEmit clean, eslint 0 errors. Local
Web UI only; no remote scenario behavior changes.
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.
Summary
Profiling the Web UI streaming render path turned up work that runs unconditionally on
every batched flush (every 32ms while text streams). This PR removes it in three passes:
the first drops work that is simply wasted, the second drops work that grows with the
transcript instead of with the token that arrived, the third drops allocations the live
round pays on every flush.
Pass 1 — work that buys nothing
TextChunkModule— newline coalescing re-scanned the whole accumulated response onevery chunk:
(currentContent + text).replace(/\n{3,}/g, '\n\n'). That is O(N) perchunk and O(N^2) over a long answer, on the main thread. Both sides of an append are
already clean, so only the two-character join boundary plus the incoming chunk need
cleaning. Recovered item content is cleaned once when reused, so the buffer invariant
still holds.
EventBatcher—getBatchedEventsLogPayload()ran beforelog.trace(), and thelogger drops the entry after its level check. With the default level (WARN in
production, DEBUG in dev) every flush allocated a full per-event map for a log line
that was then thrown away. The payload is now built only when trace output can reach a
sink; the slow-flush warning keeps its counts.
modernFlowChatStore— dropped theimmermiddleware. The three setters only assignwhole fields, never a draft, so
produce()plus immer's deep freeze walked the entiresession and virtual-item tree on every flush. Plain
set()keeps the same referencesemantics.
clear()now also resets theneedsUserAttentionprojection cache guardalongside the other cached fields.
useConversationTimeline— the prewarm effect depends on[sources], which changes onevery flush, so it re-walked the whole timeline including items already queued or
parsed. A
WeakSetof seen items skips them.Pass 2 — work that scales with the conversation
timeline/document— every projection rebuilt a state-sized revision string, embeddedit into a fresh per-source revision, then matched its cache by comparing those
strings: O(sources x |view state|) of string building and comparing per flush. The
projection now stamps the reader and group inputs by identity and compares one number
per source. Identity is the contract the caller's own memo already uses, so nothing
about invalidation changes.
FlowChatStore.updateModelRoundItem— every round ahead of the target re-derived itsattempts from items and discarded the result; a streamed token always lands in the last
round. Rounds that neither recorded attempts nor hold the item now return before the
derivation.
useFlowChatVirtualizer— the key sequence was enumerated twice per flush (ajoinfor the revision, a
Setfor retired measurements), allocating one string per row eachtime. Keys are now cached per row object and the sequence is compared by reference, so
a quiet flush allocates nothing and the
joinandSetrun only when the sequencereally changed. The cache bypasses primitives, because the hook stays generic.
VirtualMessageList— the estimate revision collator-sorted the disclosure map on everyrender. It is now memoized on that map's identity.
Pass 3 — the live round's own per-flush allocations
timeline/document, disclosure —readerExpandedasked whether any member of a groupis an expanded tool capsule by serializing a key per member:
toolCapsuleStateKeyis athree-element
JSON.stringify. That ran for every member of every group even whennothing was expanded, which is the normal state. The expanded keys are now inverted
once per projection (turn id to member ids) and the member scan is skipped entirely
when the set is empty. A test pins the key format this now reads, so the coupling
cannot drift silently.
timeline/document, block writer —add()built the whole row before deciding toreuse the previous one (the data spread plus a member scan for
isStreaming,projectedGroups,memberIds), so a quiet flush allocated payloads it immediatelydiscarded. The reuse predicate now reads cheap primitives first and only misses build
a row. Returned identity is unchanged, so the memoized row contract still holds.
grouping/groupProjection— the threshold test copied the item list per group, themerged-run finalizer flattened each part four times while collecting its source ids,
and the trailing pass re-mapped every row. The threshold is now a counting scan, each
part is flattened once, and rows whose groups already meet the threshold keep their
identity.
Fixes #
Type and Areas
Type: performance / refactor
Areas: web UI (
src/web-ui/src/flow_chat)Motivation / Impact
Streaming a long answer got progressively more expensive per flush, and that cost bought
nothing: a redundant full-text rescan, a log payload that was discarded, a state-tree deep
freeze nothing depended on, a repeated timeline sweep, a per-source revision string, an
attempts derivation whose result was thrown away, duplicate key enumeration, a serialized
disclosure key per member, and row payloads built for blocks that were about to be reused.
User-facing effect: long streaming responses keep a steadier frame budget, and per-flush
cost stops growing with the length of the conversation. No behavior, API, or
persisted-shape change.
Verification
Run on this branch, based on
origin/mainate17e1803e:pnpm --dir src/web-ui run test:run src/flow_chat/→ 4311 passed across 391 files (final run, passes 1-2)pnpm --dir src/web-ui run test:run src/flow_chat/grouping src/flow_chat/timeline src/flow_chat/store→ 499 passed across 23 files (pass 3)pnpm --dir src/web-ui run test:run src/flow_chat/store src/flow_chat/components/modern→ 1090 passed across 78 files (pass 3)pnpm run type-check:web→ cleanpnpm --dir src/web-ui run lint(changed files) → 0 errorsFocused runs while iterating: 109 / 62 / 340 / 686 / 1099 passing for the touched modules.
Pass 3 measurement notes (honest limits)
The changed paths were measured with a local, uncommitted profiling harness on a synthetic
362-source transcript whose live round holds 58 items:
cost 14.5-15.6 us per pass with a serialized key per member when nothing is expanded, and
0.06 us once the empty-set scan is skipped (0.4 us index build + 0.4-0.5 us lookups when
one capsule is expanded). Extrapolated to the 121 groups in the fixture that is ~70 us per
flush, ~2 ms/s at the 30 Hz flush rate.
project()/sessionToVirtualItems) move inside therun-to-run noise band on this machine (+-10-25% between identical builds, including on code
paths this PR does not touch), so no wall-clock number is claimed for the other two items;
they remove allocations and repeated passes with unchanged output.
WeakMapcache of the per-round grouping result was implemented and then dropped: on thestreaming round it always misses and it measured slower than rebuilding. It is not in this PR.
TextChunkModule.test.tsdid not assert newline coalescing directly before this change;behavior equivalence there is covered by the existing streaming tests. A dedicated
"chunk boundary crosses 3+ newlines" case would be a good follow-up.
Reviewer Notes
Four points worth a careful look:
modernFlowChatStoreno longer freezes its state. Nothing reads frozen state or handsan updater to
setStateon this store, anduseFlowChatActionsselects actions throughuseShallow, so it is unaffected.canvasStorekeeps itsimmermiddleware.run". Both buffer writers go through the new helper, and recovered item content is
cleaned once on reuse.
useMemodependencies at the call site. Anything that mutates
groupStates,expandedToolCapsules, orpendingPermissionToolCallIdsin place would already beinvisible to that memo today; these are all replaced immutably
(
useFlowGroupState,FlowChatContext).[sessionId, turnId, itemId])instead of calling
toolCapsuleStateKeyper member.document.test.tsbuilds its keyswith that helper, so a format change fails the test rather than silently closing
expanded groups.
Remote scenarios: this touches the local Web UI render path only. No changes to remote
workspace transport, remote control, Peer Device Mode command handling, or the detached
dispatch protocol, and none of those paths consume these modules.
Checklist
Generated with OpenBitFun