Skip to content

perf(flow-chat): cut per-flush cost on the streaming render path - #3545

Open
Raing5Days wants to merge 3 commits into
GCWing:mainfrom
Raing5Days:perf/flow-chat-streaming-hot-path
Open

Raing5Days wants to merge 3 commits into
GCWing:mainfrom
Raing5Days:perf/flow-chat-streaming-hot-path

Conversation

@Raing5Days

@Raing5Days Raing5Days commented Oct 24, 2026 •

Copy link
Copy Markdown
Contributor

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 on
    every chunk: (currentContent + text).replace(/\n{3,}/g, '\n\n'). That is O(N) per
    chunk 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 before log.trace(), and the
    logger 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 the immer middleware. The three setters only assign
    whole fields, never a draft, so produce() plus immer's deep freeze walked the entire
    session and virtual-item tree on every flush. Plain set() keeps the same reference
    semantics. clear() now also resets the needsUserAttention projection cache guard
    alongside the other cached fields.
  • useConversationTimeline — the prewarm effect depends on [sources], which changes on
    every flush, so it re-walked the whole timeline including items already queued or
    parsed. A WeakSet of seen items skips them.

Pass 2 — work that scales with the conversation

  • timeline/document — every projection rebuilt a state-sized revision string, embedded
    it 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 its
    attempts 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 (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 and the join and Set run only when the sequence
    really 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.

Pass 3 — the live round's own per-flush allocations

  • 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 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 to
    reuse the previous one (the data spread plus a member scan for isStreaming,
    projectedGroups, memberIds), so a quiet flush allocated payloads it immediately
    discarded. 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, the
    merged-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/main at e17e1803e:

  • 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 → clean
  • pnpm --dir src/web-ui run lint (changed files) → 0 errors

Focused 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:

  • The disclosure change is isolated and reproducible in one process: 24 groups x 48 members
    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.
  • End-to-end per-flush timings (whole project() / sessionToVirtualItems) move inside the
    run-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.
  • A WeakMap cache of the per-round grouping result was implemented and then dropped: on the
    streaming round it always misses and it measured slower than rebuilding. It is not in this PR.

TextChunkModule.test.ts did 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:

  1. modernFlowChatStore no longer freezes its state. Nothing reads frozen state or hands
    an updater to setState on this store, and useFlowChatActions selects actions through
    useShallow, so it is unaffected. canvasStore keeps its immer middleware.
  2. The newline fix relies on the invariant "the content buffer never holds a 3+ newline
    run". Both buffer writers go through the new helper, and recovered item content is
    cleaned once on reuse.
  3. The projection's view stamp is identity-based, matching the existing useMemo
    dependencies at the call site. Anything that mutates groupStates,
    expandedToolCapsules, or pendingPermissionToolCallIds in place would already be
    invisible to that memo today; these are all replaced immutably
    (useFlowGroupState, FlowChatContext).
  4. Pass 3's disclosure index reads the capsule key shape ([sessionId, turnId, itemId])
    instead of calling toolCapsuleStateKey per member. document.test.ts builds its keys
    with 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

  • This PR is focused and does not include secrets, temporary prompts, generated scratch files, or unrelated artifacts.
  • Relevant verification is recorded above, or skipped checks are explained.
  • User-facing strings, docs, and locales are updated where applicable. (No user-facing copy changed, so nothing to update.)

Generated with OpenBitFun

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
Raing5Days force-pushed the perf/flow-chat-streaming-hot-path branch from b53bbd7 to b4fc0d1 Compare October 24, 2026 16:34
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.
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