Single-pass immediate-mode rendering with no virtualization strategy, against a 5000-message benchmark scene
Источник:
dev/fera-research/docs/oc1/023-no-virtualization-strategy-for-single-pass-list.md(read-only; сгенерировано scripts/sync-docs.mjs)
Single-pass immediate-mode rendering with no virtualization strategy, against a 5000-message benchmark scene
Section titled “Single-pass immediate-mode rendering with no virtualization strategy, against a 5000-message benchmark scene”- kind: problem
- area: ui / perf
- evidence:
ADR.md:1013-1016(ADR-030) — “Hit-testing: single-pass (immediate-mode style, à la egui). The visual tree and the hit-test tree are built in the same frame pass; interaction state (.clicked(), hover) returns inline in the HoF view code. No separate hit-test pass, no added input latency.”ADR.md:1016-1020— the justification: after ADR-028/023, “our own hit-testing covers only lists/messages/scroll (native fields and menus hit-test themselves in the OS); overlays and complex z-order cases are native — the known weaknesses of single-pass do not apply to our domain.”ADR.md:980-982(ADR-028) — “Only lists, scrolling, and message rendering are implemented on the project’s own engine.”TECHSPEC.md:260-263— still describes the pre-decision shape: “component functions compose into a render tree from&State; decide single-pass … vs. two-pass before significant implementation investment” (resolved by ADR-030; see my finding 003).- The benchmark scene every perf gate uses:
OQ.md:143— “Chat: 5000 messages”;OQ.md:144— “100,000 total messages”;TECHSPEC.md:446-447— “strawman: chat with 5000 messages, 50 concurrently-animating stickers”;OQ.md:146— 500 chats, 30 unread. - No virtualization / windowing / recycling strategy exists anywhere.
Verified: grep for
virtuali|retained|recycl|windowingacross the corpus returns only prose uses —ADR.md:468(secret chats “should not be durably retained”),ADR.md:427/ADR.md:450(“a lazy check at startup”),ADR.md:997(“winit + windowing”),FUTURE-CHECKS.md:72(“sanakirja/milli retained where KV+FTS already fits well”),OQ.md:40/OQ.md:60(“more conventional for retained-mode”), andOQ.md:467(“virtualization” appears only in the argument for rejecting GPUI). Zero design decisions, zero code-level notes. TECHSPEC.md:289-292sets ~24 fps for chrome and ~45 fps for message-list updates (see finding 008) — i.e. the message list is budgeted at ~22 ms/frame, every frame, while scrolling.- ADR-005’s batching premise is the other half of the cost story:
storage returns ranges (
ADR.md:127-129), which bounds data transfer but not the rendering of 5000 rows. - Repo reality:
ui-contract823 LOC,ui-elm549,ui-engine-vello464 — and per oc2’s bus measurement (seq 8),lower.rs:77-81silently dropsText/Image/Spacerandpaint()returnsOk(())unconditionally, so no frame has ever been rendered end to end.
- severity: high
Суть / Substance
Section titled “Суть / Substance”The corpus decided (correctly, for good reasons) that the message list — the one genuinely large, high-frequency, GPU-rendered surface in a chat client — is rendered by our own engine via single-pass immediate-mode composition, and then never decided how a list of 5000 messages is rendered at 45 fps.
Single-pass means the render tree is rebuilt every frame. That is fine for a settings pane and expensive for a scrollback: absent windowing, every frame does layout + hit-test + paint for every message in the chat, not for the visible ones. The corpus’s own benchmark parameters make this concrete: 5000 messages at ~22 ms/frame budget. Nothing anywhere says “render the 30 visible ones and recycle the rest”.
This is not a detail that can be deferred to implementation, because several already-made decisions constrain the answer and were made without it:
- Single-pass (ADR-030) is cheapest when the frame’s input is a small
visible set; it is most expensive when the frame’s input is the whole
history. Both statements are true, and ADR-030’s own rationale
(
ADR.md:1016-1020) reasons only about hit-testing, never about volume. - Parley/Vello own text shaping and layout (
ADR.md:971), so shaping 5000 paragraphs per frame is inside the measured budget unless something prevents it. Shaping is the expensive part and it is per-glyph. - The Elm loop (
ADR.md:200-204) is a pureupdate(&mut State, Msg) -> Vec<Effect>— so scrolling produces aMsgper frame that must run the whole reducer, then the whole tree. The cost is O(history) per frame. - Frame budgets are per-path and measured (finding 008), so a missing windowing strategy is guaranteed to show up as a budget miss — and the corpus has no design to fall back on when it does.
ADR-028:980-982’s “only lists, scrolling, and message rendering” means the team knows this is the surface that matters, and it is the one with no plan.
The absence is also invisible because the corpus’s perf gates are all about pixels-per-second on a synthetic scene, and nothing measures “cost per frame as a function of history length”. A 5000-message scene measured with a naive full rebuild looks like “45 fps, budget met” right up until a user has 50,000 messages — at which point the app is unusable and the corpus has no answer.
Варианты решения (tradeoffs)
Section titled “Варианты решения (tradeoffs)”- (A) Virtualize the message list explicitly. Keep a window of visible + overscan messages, each holding a recycled layout/shaping result; rebuild only the window per frame. The history is a data structure, not a render structure. This is the standard answer and it composes with single-pass (egui does exactly this). Costs: scroll-anchoring discipline (jump-to-message, “load more” at the top, RTL, variable heights, sticky date separators), an explicit “unloaded region” visual state, and a decision about whether shaping results are cached across window movement (memory vs re-shaping cost).
- (B) Keep full-tree rebuild but cap chat size in the view layer
(render at most N messages, with an explicit “scroll to load more”
boundary). Cheaper to build, worse UX, and it silently breaks
TECHSPEC.md:56-59’spaginate_historycontract’s intent and find-in-page. - (C) Reconsider ADR-030’s single-pass for the message list only — two-pass or retained-mode for the list, single-pass for the rest. Gets per-row layout reuse, but reintroduces the latency ADR-030 removed, and complicates ADR-008’s single Elm loop.
- (D) Do nothing and let the benchmark find it. Reject: the benchmark is not measuring the axis that fails, and by the time a user has 50k messages the fix is a rewrite of the list.
Предлагаемое решение
Section titled “Предлагаемое решение”(A), specified as an ADR before the renderer is built further — the
cheapest moment is now, and oc2/oc4 have both measured that the render
path is not yet exercised end to end, so the decision costs nothing
today.
- New ADR “Message-list virtualization” stating: the render tree is
built over a window (
visible + overscan), row layout/shaping results are recycled (an LRU of row layout objects keyed by(message_id, width, style-hash)), and history depth is a data concern owned by the storage layer (get_messages(chat_id, from, limit)already supports it —TECHSPEC.md:137-139). Explicitly state which measurements are per-frame and which are amortised. - Add the missing benchmark axis (this is the part that would have
caught it): per-frame cost as a function of history length —
10 / 500 / 5,000 / 50,000 messages — with a gate that cost-per-visible-
row is flat in history length. Without that axis, every existing
criterion benchmark (
TECHSPEC.md:445-447) passes and the app still dies at 50k. - Reconcile ADR-030 with it explicitly: single-pass stays (it is cheap per row), but “single-pass” is stated as applying to the windowed tree, not the whole history. This also removes the ambiguity in ADR-030’s rationale, which argued only about hit-testing.
- Name the hard cases in the ADR so they are designed rather than discovered: variable-height rows (images, code blocks), jump-to-message across an unloaded region, “load older” at the scroll top, scroll-anchoring during prepend, and what the user sees in the not-yet-laid-out region.
- Fix
TECHSPEC.md:260-263to state the resolved single-pass shape (finding 003), so the HoF layer is written against the windowed tree rather than a whole-chat rebuild.