TUI long-session render costs — shared step-timing scan and card line caches
TUI 长会话渲染开销:共享步骤耗时扫描与卡片行缓存
On a long resumed session (196k events, 2.2k steps, 1.8k tool cards) the TUI took ~12 s to render the transcript and ~800 ms to echo one keystroke. Profiling attributed both to the render path, not to session load (zstd + parse + surface seed is ~1.7 s): - Every step's timing footer called `stepTimingAt`, which replayed the whole event log from index 0 per footer — O(steps × events) on the initial render, ~6 s of CPU
English
Problem
On a long resumed session (196k events, 2.2k steps, 1.8k tool cards) the TUI took ~12 s to render the transcript and ~800 ms to echo one keystroke. Profiling attributed both to the render path, not to session load (zstd + parse + surface seed is ~1.7 s):
- Every step's timing footer called
stepTimingAt, which replayed the whole event log from index 0 per footer — O(steps × events) on the initial render, ~6 s of CPU. - pi-tui re-renders every component each frame and relies on per-component line caches (its own
Text/Markdowncache by(text, width)).ToolCardComponent.render()andContextCardComponent.render()built throwawaynew Text(...)/new Markdown(...)instances insiderender(width), so every frame — every keystroke — re-wrapped every settled card's output.
Decision
packages/ui/tui/src/chat/timing.ts replaces stepTimingAt with StepTimingTracker: one accumulator per chat mount, created in createTuiChat and threaded through StreamingAssistantComponent into each StepTimingComponent. A query advances a cursor over events appended since the previous query and keeps per-step bucket state in a map, so all footers together cost O(events). The open bucket is accumulated to the query clock at lookup, and a step is pinned at its step/end. The tracker requires the append-only session log (the seq = log length contract).
ToolCardComponent and ContextCardComponent cache their rendered rows keyed by width. The cache drops on every state mutator (updateResult, setVisibility, setExpanded) and on invalidate() (pi-tui's tree-wide cascade), so a state change always re-renders; everything else — including every keystroke frame — returns the cached rows. This restores upstream pi's own component convention (persistent child components plus explicit cachedWidth/cachedLines where rendering is custom, e.g. pi coding-agent bash.ts), which the imperative render(width) bodies here had silently defeated.
Measured on the 196k-event session (tmux, 200×50): resume prompt-ready 12.2 s → 7.2 s; per-keystroke echo 796 ms median → 17 ms (fresh-session parity).
Alternatives considered
- Index
step/startoffsets, keep per-footer replay — removes thefindIndexbut each footer still scans its step's span from a shared array; the tracker's single shared pass is the same complexity win with less bookkeeping. - Restructure the cards into persistent pi-tui child components (upstream pi's primary style) — equivalent steady-state cost, but a larger diff across card state handling for no additional win over the width-keyed cache.
- Cache inside pi-tui's
Container.render— wrong layer: the vendored patch surface would grow, and the contract (components own their caches) already exists upstream.
Consequences
- Typing latency no longer scales with total tool output; the residual per-frame cost is pi-tui's tree traversal and row concatenation, linear in rendered rows. Resume render cost is now dominated by pi-tui's one-time initial layout (~4 s at 196k events) plus load (~1.7 s), both linear.
- The tracker consumes event times as logged and drops the removed implementation's mid-scan
time > atcutoff, which per-footeratvalues make impossible in a shared scan; under a backward wall-clock step each bucket clamps at zero, which can differ from the old cutoff's totals. - Card
render()is no longer a pure function of(state, width)per call — mutators must droplinesCache. A new mutator that forgets to do so shows stale rows; the cache tests inpackages/ui/tui/tests/transcript-card-cache.spec.tspin the contract for the existing mutators. StepTimingTrackerassumes step coordinates are not reused afterstep/end; a duplicatestep/startfor a closed step is ignored rather than restarting the step.
中文
问题
在一个恢复后的长会话(196k 条事件、2.2k 个步骤、1.8k 张工具卡片)中,TUI 渲染 transcript(文本记录)耗时约 12 秒,回显一次按键耗时约 800 毫秒。性能剖析表明,两项耗时都来自渲染路径,而非会话加载(zstd + 解析 + 表层播种约为 1.7 秒):
- 每个步骤的耗时页脚都会调用
stepTimingAt,而它会针对每个页脚从索引 0 起回放整个事件日志,因此初次渲染的复杂度为 O(步骤数 × 事件数),占用约 6 秒 CPU 时间。 - pi-tui 每一帧都会重新渲染所有组件,并依赖各组件自己的行缓存(它的
Text/Markdown会按(text, width)缓存)。ToolCardComponent.render()和ContextCardComponent.render()构造用后即弃的new Text(...)/new Markdown(...)实例,且构造发生在render(width)内,因此每一帧,也就是每次按键,都会重新对每张已结算卡片的输出进行折行。
决策
packages/ui/tui/src/chat/timing.ts 不再使用 stepTimingAt,改用 StepTimingTracker:每次挂载聊天界面时在 createTuiChat 中创建一个累加器,再经 StreamingAssistantComponent 传入每个 StepTimingComponent。每次查询都会推进游标,扫描上次查询后追加的事件,并在一个映射表中保存各步骤的 bucket 状态,因此所有页脚合计只需 O(事件数)。查询时,系统把未闭合 bucket 累加到查询时刻;步骤在其 step/end 处固定。该跟踪器要求会话日志仅追加,即遵守 seq = log length 契约。
ToolCardComponent 和 ContextCardComponent 按宽度键控缓存渲染行。调用任一状态修改方法(updateResult、setVisibility、setExpanded)或 invalidate()(pi-tui 的全树级联)时会清空缓存,因此状态变化一定会重新渲染;其他情况,包括每一次按键帧,都会返回缓存行。这恢复了上游 pi 自身的组件惯例:使用常驻子组件;自定义渲染时显式使用 cachedWidth/cachedLines,例如 pi coding-agent 的 bash.ts。而这里命令式的 render(width) 函数体此前让这套惯例失效。
在该 196k 条事件的会话上测得(tmux,200×50):恢复后提示符就绪耗时从 12.2 秒降至 7.2 秒;每次按键的回显耗时中位数从 796 毫秒降至 17 毫秒(与新会话持平)。
曾考虑的替代方案
- 索引
step/start偏移量,保留逐页脚回放:这会消除findIndex,但每个页脚仍要从共享数组扫描所属步骤的区间;跟踪器的一次共享遍历以更少的额外状态记录取得相同的复杂度改进。 - 把卡片重构为常驻 pi-tui 子组件(上游 pi 的主要风格):稳定状态下成本相同,但卡片状态处理所需改动更大,相较按宽度键控的缓存并无额外收益。
- 在 pi-tui 的
Container.render内缓存:层级不对:对第三方内嵌代码的补丁范围会扩大,而上游已经约定由组件拥有各自的缓存。
后果
- 输入延迟不再随工具输出总量增长;剩余的每帧成本是 pi-tui 的树遍历与行拼接,与渲染行数呈线性关系。恢复时的渲染成本现由 pi-tui 的一次性初始布局(196k 条事件时约 4 秒)与加载(约 1.7 秒)主导,两者均为线性。
- 该跟踪器直接采用日志记录的事件时间,不再像已移除的实现那样,在扫描中途遇到
time > at时截断;由于每个页脚的at值不同,共享扫描无法采用这种截断;挂钟时间倒退时,每个 bucket 都以零为下限,所得总计值可能与旧截断下的总计值不同。 - 卡片的
render()不再是每次调用时(state, width)的纯函数,状态修改方法必须清空linesCache。若新增状态修改方法时忘记清空,界面会显示陈旧行;packages/ui/tui/tests/transcript-card-cache.spec.ts中的缓存测试固定了现有状态修改方法的契约。 StepTimingTracker假定步骤坐标在step/end后不会复用;对已关闭步骤重复出现的step/start会被忽略,不会重新启动该步骤。