Skip to content

fast: Keep retention and scroll layers working with real application patterns - #48

Merged
sunli829 merged 45 commits into
mainfrom
fast-robust-retention
Oct 12, 2026
Merged

sunli829 merged 45 commits into
mainfrom
fast-robust-retention

Conversation

@huacnlee

@huacnlee huacnlee commented Oct 9, 2026 •

Copy link
Copy Markdown
Member

Summary

Real applications (allsum-desktop, GPUI Kit apps) still used 40–50% CPU while scrolling, although gpui_perf's synthetic scenarios looked cheap. The gap came from ordinary application and component patterns that silently turned gpui-fast's retention and scroll layers off, so every scrolled frame redrew everything. This PR makes the framework tolerate those patterns instead of requiring every app to avoid them, and adds benchmarks that reproduce them.

All changes live in fast/ (plus one-line hooks); script/check-upstream reports 0 violations.

Framework changes

  • Animation frames are scoped to the content that asked for them (fast/layers/invalidate.rs, policy.rs). A frame requested from outside a scroll container's content (a GPUI Kit scrollbar fading in its prepaint, a spinner in an ancestor view) no longer keeps the container off its layer. If the owner itself animates outside the content, the container draws directly while it does and composites again as soon as it stops, without being demoted.
  • Cached views are drawn again only for notified changes (fast/retained.rs, dependencies.rs). A write without a notify, made while the window draws, such as Allsum's ChatApp::render calling chat_view.update(.. set_active ..) every frame, no longer rebuilds a .cached() view. This matches upstream's semantics. Writes made while a list is built count as the list's own, so its rows stay reusable.
  • ScrollHandle::scroll_to_bottom on a handle already at its bottom changes nothing (fast/layers/scroll_to_bottom.rs), as ListState::scroll_to_end already did on main. A view keeping a growing box at its bottom, as ai-chat's thinking box does while streaming, calls it on every render.
  • List rows that read an entity right after the row before them read it now record the read (fast/layers/lists.rs). The access log drops a repeated read, so a row could miss a dependency and stay stale when the entity changed; this also happens on main.
  • Retention and div scroll layers stay on while accessibility is active (fast/a11y.rs). Before this, any assistive-technology client (on macOS: window managers, password managers, IMEs) turned all of gpui-fast off for the window. A reused subtree now replays its accessibility nodes and focus, and the tree is identical to drawing from scratch. Virtual-list layers still draw directly while accessibility is active.
  • Layers whose content has paths now composite (fast/layers/overlay.rs). A GPUI Kit markdown table's rounded corners are paths. Paths cannot go into tiles, because shader derivatives depend on 2×2 quad parity, so they are drawn, with what covers them, over the tiles each frame. Each pixel is drawn by the same primitives in the same order as without layers.
  • A wheel event's extra notifications count as its scroll (fast/layers/wheel.rs). GPUI Kit's scrollbar notifies its owner again from its own wheel listener. With several trackpad events per frame, this used to count as a content change.
  • A scroll layer never costs more than drawing without it (fast/layers/policy.rs, lists.rs).
    • Repaints count as the 2–2.6× they really cost.
    • Each frame renders at most ¾ viewport of new rows, ahead of the scroll.
    • A content-change repaint paints only the rows shown.
    • Jumps past the cache drop the layer.
    • A layer that costs more than direct drawing over 6 frames falls back at once.
  • Accessibility test switch: GPUI_A11Y_ACTIVE=1 (test-support only) forces accessibility on for benchmarks.

Benchmarks

  • gpui_perf scenarios that mirror real code, citing file:line in ai-chat, allsum-desktop and gpui-kit:
    • GPUI Kit-style scrollbars: wheel, drag, fading, and several wheel events per frame;
    • chat-scroll-*, which reproduce ai-chat ChatView's patterns one at a time, plus chat-scroll-allsum, which combines them;
    • chat-app-sidebar-scroll and scroll-list-tables.
  • A real-component benchmark (kept outside the repo) that runs ai-chat's real ChatView and GPUI Kit's real List and DataTable headless.
    • It fakes the clock at 16.667 ms per frame and runs next-frame callbacks. Components that read Instant::now() directly had made earlier measurements nondeterministic.
    • Instruction counts are deterministic across runs and builds.

Performance

Instructions per frame on the main thread, release, retention on, Linux. Instruction counts are deterministic; CPU ms on this shared machine is not, so it is not reported.

Real components (ai-chat ChatView 670d8c0 with 150 Markdown messages, GPUI Kit 296fe2f, 240 frames)

Scene main this PR Composited frames (main → PR)
Chat transcript, mouse wheel (with tables) 13.95M 9.30M (−33%) 0 → 241
Chat transcript, trackpad (with tables) 13.11M 8.51M (−35%) 0 → 240
Chat transcript, mouse wheel, no tables 8.95M 8.79M (−2%) 245 → 242
Chat transcript, trackpad, no tables 13.11M 8.46M (−35%) 22 → 241
GPUI Kit List, wheel 14.17M 14.11M 0 → 0
GPUI Kit List, trackpad 13.29M 13.23M 0 → 0
GPUI Kit DataTable, wheel 18.95M 18.95M 0 → 0
GPUI Kit DataTable, trackpad 18.79M 18.76M 0 → 0

Allocations per frame for the chat transcript drop from about 6,900 to 4,300. Views built per frame drop from 4.7 to 2.6.

GPUI Kit's List and DataTable use their own virtual list element, which never reaches scroll layers, and create about 390 layout nodes every frame. That needs separate work.

gpui_perf (headless, 300 frames)

Scenario main + scenarios this PR Layers off
chat-scroll-allsum-wheel (ChatView + ChatApp patterns) 7.48M 5.74M (−23%) 6.74M
chat-scroll-allsum (trackpad) 6.77M 5.75M (−15%) 6.75M
chat-scroll-animates 3.88M 2.50M (−36%) 3.86M
chat-scroll 2.83M 2.76M (−3%) 4.59M
scrollbar-drag-uniform-list 23.6M 5.78M (−75%) 5.78M
scroll-list-tables 6.26M¹ 1.64M (−74%) 5.58M
scroll-list-scrollbar-burst 5.03M¹ 1.75M (−65%) 4.97M
scroll-{list,uniform-list}-fading-scrollbar 4.2–4.6M¹ 3.1–3.3M 5.0–5.2M
Other scroll scenarios — within ±2% —

¹ Measured on the scenario's branch before its fix.

With accessibility active (GPUI_A11Y_ACTIVE=1):

Scenario old gate (retention off, emulated on this tree) this PR
scroll-child-view 9.15M 1.09M
workspace-hover 29.75M 26.06M
Real ChatView, views built per frame 11.8 4.7

chat-app-sidebar-scroll (scrolling Allsum's sidebar beside a cached transcript) goes from 3.12M to 2.79M per frame, and from 2 views built per frame to 1.

Correctness

  • cargo test -p gpui --features test-support --lib: 669 passed.
  • cargo test -p gpui_wgpu: 52 passed, including new covered-path tile tests at odd and even offsets.
  • Metal and Direct3D pixel tests for covered paths are new and run in CI.
  • New tests:
    • layer-oracle runs with paths, offset reads and animations;
    • an accessibility-tree oracle, comparing trees frame by frame against drawing from scratch;
    • cached-view write rules;
    • wheel-notify counting;
    • WorkBudget units.
  • gpui_perf --headless --verify: painted frames identical with layers on and off, in all 31 scroll and chat scenarios, with accessibility off and on.
  • Clippy -D warnings passes for gpui and gpui_perf, and for gpui_windows and gpui_apple cross-target. cargo fmt --check and script/check-upstream (0 violations) pass.

Review cleanup

After review, parts with no measured value were removed: a viewport-answers mechanism (fast/layers/answers.rs, ~1,300 lines with tests) that recorded item_is_above/below_viewport and ScrollHandle::top_item answers. It measured no gain in any scene, and no real code calls the ScrollHandle queries; removing it costs +0.9% instructions on the real ChatView (9.22M → 9.30M). Diagnostic-only gpui_perf scenarios and a duplicate test were also dropped, and list.rs and uniform_list.rs are main's again.

Not done

  • window.refresh(): it still redraws every view. It is the API for state the framework cannot see, so nothing can tell which views read that state, and narrowing it soundly is not possible. A test checks that frames after a refresh never show tiles painted before it.

  • GPUI Kit List / DataTable layers: the custom virtual list needs a framework path of its own. That work is in progress on a separate branch.

  • Owner reads of a list's raw offset in render (logical_scroll_top, scroll_px_offset_for_scrollbar): compositing them soundly would mean rendering the shown rows again every frame, which costs about 2× direct drawing. The app should compute these outside render. An attempt is on branch fast-render-offset-reads; it is not merged, because it had no measurable gain.

  • Application-side fixes the framework cannot make soundly:

    • GPUI Kit TextView rewrites its state on every render, which rebuilds the Markdown;
    • the input blink cursor is relayed to the host view every 500 ms;
    • set_placeholder is called on every render;
    • resizable panels notify unconditionally.

    These are listed with file:line in the analysis.

🤖 Generated with Claude Code

huacnlee and others added 30 commits October 8, 2026 17:17
Real lists carry a scrollbar that reads the scroll offset while it is
prepainted, notifies its view from its own wheel listener, and drags its
thumb with a mouse-move listener (ListState's through
set_offset_from_scrollbar). The scroll scenarios had none. Each content
kind now also runs as `<name>-scrollbar`, and the div, uniform_list and
list kinds as `scrollbar-drag-*`, the thumb dragged a few pixels a frame.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
…reads

A view asking where a scroll container is scrolled as it renders
(ListState::is_scrolled_to_end, item_is_above/below_viewport,
ScrollHandle::top_item/bottom_item, UniformListScrollHandle::
is_scrolled_to_end) now records the question and its answer
(fast::layers::answers). When its record is judged the question is asked
again of the live state (of a list being prepainted, as its prepaint
began), and only another answer is a change, so a scroll that leaves the
answers as they were neither rebuilds the view nor keeps the list off its
layer. This generalizes the AtEnd read, which was only judged while the
list's layer was decided on.

ScrollHandle::scroll_to_bottom on a handle already at its bottom no longer
bumps its version; the request is kept for content that grew, and dropped
as the frame ends if nothing prepainted the container.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
… Allsum's ChatApp

Each chat-scroll-* scenario in the new scenarios::chat_patterns adds one
pattern of the real chat page that keeps a list off its scroll layer,
mirroring and citing the code it stands for (ai-chat 670d8c0, allsum-desktop
4f975246, gpui-kit 40ad5270): the outline's visible turns, the at-end read
behind the back-to-bottom fade, ScrollBounce's edge notifications, an
outline jump's highlight, scroll_to_end while following, ChatApp writing its
cached chat view, ChatView's chrome (composer write, scrollbar, measuring
canvas) and trackpad input. chat-scroll-allsum combines them all.

The sweep scrolls back down until the list is at its end, and the wheel
test compares the content composited tiles stand for, so frames that only
moved a layer count as moving.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
… Allsum's ChatApp

Each chat-scroll-* scenario in the new scenarios::chat_patterns adds one
pattern of the real chat page that keeps a list off its scroll layer,
mirroring and citing the code it stands for (ai-chat 670d8c0, allsum-desktop
4f975246, gpui-kit 40ad5270): the outline's visible turns, the at-end read
behind the back-to-bottom fade, ScrollBounce's edge notifications, an
outline jump's highlight, scroll_to_end while following, ChatApp writing its
cached chat view, ChatView's chrome (composer write, scrollbar, measuring
canvas) and trackpad input. chat-scroll-allsum combines them all.

The sweep scrolls back down until the list is at its end, and the wheel
test compares the content composited tiles stand for, so frames that only
moved a layer count as moving.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
… Allsum's ChatApp

Each chat-scroll-* scenario in the new scenarios::chat_patterns adds one
pattern of the real chat page that keeps a list off its scroll layer,
mirroring and citing the code it stands for (ai-chat 670d8c0, allsum-desktop
4f975246, gpui-kit 40ad5270): the outline's visible turns, the at-end read
behind the back-to-bottom fade, ScrollBounce's edge notifications, an
outline jump's highlight, scroll_to_end while following, ChatApp writing its
cached chat view, ChatView's chrome (composer write, scrollbar, measuring
canvas) and trackpad input. chat-scroll-allsum combines them all.

The sweep scrolls back down until the list is at its end, and the wheel
test compares the content composited tiles stand for, so frames that only
moved a layer count as moving.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
A clickable div's press (the click it starts, its active style) and an
InteractiveText's press called window.refresh(), which turns retention
off for the whole window: every click rebuilt every view, twice. That
state is the element's own, read only by the element inside the view
that painted it, so its listeners now mark only that view dirty
(fast::element_refresh). A refresh the application asks for still
draws everything again: nothing tells which views read the state
outside entities it signals.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
GPUI Kit's scrollbar notifies its view again from its own wheel listener
when the offset moved since its last prepaint. With several wheel events
a frame its notifications outnumbered the scrolls, and the layer was
never composited. A notification of a view sent while a wheel event that
scrolled a container it painted is dispatched now counts for the scroll,
as long as no entity was updated or written and no global changed during
the dispatch (fast::layers::wheel).

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
…rame too

Each content kind also runs as `scroll-<kind>-scrollbar-burst`, its
wheel turned in three events a frame as a trackpad sends them, which
makes GPUI Kit's scrollbar notify its view between scrolls.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
An animation frame asked for from inside a scroll container's content (an
element in it, or a view drawn in it) still keeps the container off its
layer. One asked for from outside it no longer does: each request records
the element id path and the layer being painted, and only requests inside
the content count against it. A view around the one holding the container
animating leaves the layer composited.

When the view holding the container asks (a GPUI Kit scrollbar fading
beside a list), it is rendered again on every frame the animation runs,
and re-rendering the content costs more than drawing it directly, so the
container bypasses its layer while it animates, without demotion and
without the 8-frame retry wait, and repaints it as soon as it stops.

gpui_perf gets scroll-*-animated-scrollbar and scroll-*-fading-scrollbar
scenarios and a "layer frames repainted" row.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
…ites building a list as the list's

A view rendering again and writing into a cached view it draws, without
notifying it, as a window hands its panes whether they show on every
render, had the cached view and everything in it built again on every
render of the window. Upstream draws a cached view again only when it,
or a view in it, is notified: a cached view, and the views drawn in it,
are now reused despite entities they read being updated without a
notification, outside drawing or while the window draws.

The rows a list's layer holds were all taken for changed when the view
holding the list wrote, as it rendered, a model they read, and when the
rows rendered on one frame wrote what the rows kept read. What the view's
render writes is judged with the render, by what it read; what the list
and its rows write while the list is built is part of building it.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
A chat client's window scrolling its sidebar, writing into the cached
transcript beside it and into the model its rows read on every render.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
… Allsum's ChatApp

Each chat-scroll-* scenario in the new scenarios::chat_patterns adds one
pattern of the real chat page that keeps a list off its scroll layer,
mirroring and citing the code it stands for (ai-chat 670d8c0, allsum-desktop
4f975246, gpui-kit 40ad5270): the outline's visible turns, the at-end read
behind the back-to-bottom fade, ScrollBounce's edge notifications, an
outline jump's highlight, scroll_to_end while following, ChatApp writing its
cached chat view, ChatView's chrome (composer write, scrollbar, measuring
canvas) and trackpad input. chat-scroll-allsum combines them all.

The sweep scrolls back down until the list is at its end, and the wheel
test compares the content composited tiles stand for, so frames that only
moved a layer count as moving.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
…tive

Each frame logs what building its accessibility tree did, indexed like
its hitboxes; a stretch drawn again from the last frame does it again
through the same builder, so its nodes hang off the node open where it
lands, finished nodes get this frame's children and the focus is asked
again. Accessibility action listeners move with the paint. A div's scroll
layer keeps its content's nodes and adds them moved by the scroll when it
is composited. Virtual list layers stay off while accessibility is active.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
…ounded frames drawn with paths

Every third row of the list holds a table in a rounded frame drawn as GPUI
Kit draws markdown tables: paths fill the frame's corner notches, and the
frame's border, painted after its children, draws over them.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Test-support only: every window builds its accessibility tree as though
assistive technology had asked for it, so gpui_perf can measure frames
drawn with it.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
…ths and their covers over the tiles

A scroll layer whose content painted a path that anything drew over was
never composited, and a list's layer was kept off whenever any row painted
a path: one GPUI Kit markdown table, whose rounded frame fills its corner
notches with paths under the frame's border, kept a whole chat transcript
off its layer.

Paths still never go into tiles: the path shaders antialias with
derivatives taken within 2x2 pixel quads, so a tile composited at an odd
translation can come out a level apart on edge pixels. Instead the content
is split (fast::layers::overlay): every path, and everything drawn after a
member of that set that overlaps it, is the layer's overlay, kept out of
the tiles and drawn over them in the frame, in drawing order, wherever the
layer is composited. Every pixel is then drawn by the same primitives in
the same order as without a layer. A list splits each row; a row drawing
over the overlay of a row before it keeps the list off its layer.

Paths' content masks go on the path grid with their vertices, and the
overlay clips paths to the viewport as Window::paint_path takes it, so the
overlay draws what painting the content into the frame draws.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
…is built again

A view inside a cached view that read an entity updated without being
notified was no longer marked dirty, so a cached view built again for
another reason built the views around it again instead of drawing them
from last frame around it. Such a view is now marked when the innermost
cached view around it is dirty: one drawn from last frame draws it from
last frame too.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
be997d4 recorded one answer per question and deduplicated them pairwise, so
an outline asking of a hundred turns whether each is above the viewport
cost more than the offset reads it replaced (gpui_perf
chat-scroll-reads-offset +20%).

- The questions whether an item before the row at the top of the list is
  above the viewport, as an outline asks of every turn above it, are
  merged into the last one as they are logged, and kept as that one: they
  stay answered so while it still comes before the row at the top.
- A record keeps the answers it read of each state as one set, without an
  allocation for a single answer; answers of a state whose offset was read
  too are dropped, as the offset read covers them.
- A recording's own reads are the same set as all of its reads when
  nothing nested read any.
- Lists are copied as their prepaint began only while answers about lists
  live.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
# Conflicts:
#	crates/gpui_perf/src/scenarios/chat.rs
# Conflicts:
#	crates/gpui/src/fast/layers/invalidate.rs
#	crates/gpui_perf/src/scenarios/chat.rs
# Conflicts:
#	crates/gpui/src/fast/layers/invalidate.rs
#	crates/gpui/src/fast/tests/mod.rs
#	crates/gpui_perf/src/runner.rs
#	crates/gpui_perf/src/scenarios/chat.rs
#	crates/gpui_perf/src/scenarios/scroll.rs
A list's layer rendered five viewports of rows whenever a frame's scroll
left what it held behind: a scrollbar's thumb dragged over a uniform list
cost four times drawing it directly (scrollbar-drag-uniform-list, 23.6M
instructions a frame against 5.8M), and a uniform list painted afresh for
a change of its content painted the whole overscan again. The work
budget took repaints for as cheap as drawing directly, though they cost
about twice that, and waited 32 frames before falling back.

- A frame keeping a list's rows renders at most a viewport of rows, those
  it shows first, the rest growing the overscan, mostly on the side the
  list scrolls toward. A uniform list's layer painted afresh for a change
  paints only the rows shown, as a `list`'s did, and grows back.
- A scroll past everything a layer holds drops it, and a layer is not
  promoted while the scroll moves by a viewport or more a frame.
- Repaints count at twice their work; a list's work is the height its
  rendered rows would fill and their paint operations, not their count;
  six recent frames costing more than drawing directly fall back at once.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
…ahead of the scroll

The real ai-chat transcript (real-bench) fell back off its layer: a row
taller than the viewport, rendered again, counted as several viewports of
work, though drawing the list without the layer renders it whole too.

- A list's work is the height of the rows it rendered against that of the
  rows shown (or of the viewport, if taller), and their paint operations
  against those of the rows shown.
- A frame keeping a list's rows renders at most three quarters of a
  viewport, three quarters of it ahead of the scroll and a quarter behind,
  so that growing the overscan stays cheaper than drawing directly; a
  `list` adds a row only if it fits.
- Six recent frames cost more than drawing directly, upkeep included, when
  they average more and half of them each do.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
huacnlee and others added 13 commits October 9, 2026 09:41
…wers

A refresh for state outside entities is drawn into the frames after it,
whether the layer composites them or not. A view asking whether a uniform
list is at its end is built again only when the answer changes; without
the hook in UniformListScrollHandle::is_scrolled_to_end it was built on
every scroll.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
The showcase's first page is now Allsum Desktop's chat window, built from
the headless chat-scroll-allsum scenario's views (ChatApp with its inline
session sidebar, PreviewSplit, the cached ChatView transcript with its
outline, composer, bottom fade, back-to-bottom button and GPUI Kit-style
scrollbar), scrolled only by real input. --demo waits while it shows. The
status bar's metrics show on it as on every page.

Headless chat-scroll* scenarios are unchanged (instructions/frame within
0.06%, --verify identical).

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
…read

Recording viewport questions with their answers (fast::layers::answers)
measured no gain on any benchmark; main already tells a read of only
ListState::is_scrolled_to_end apart from an offset read (Read::AtEnd).
The upstream hooks in list.rs, uniform_list.rs and div.rs go back to
main's.

ScrollHandle::scroll_to_bottom on a handle already at its bottom still
changes nothing, now in fast::layers::scroll_to_bottom.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
…e paths

Keep the four scroll kinds with and without a scrollbar, a fading
scrollbar and a three-event burst only for the two lists, the three thumb
drags and scroll-list-tables. Drop the single-pattern chat-scroll
variants that were only diagnostic; chat-scroll-allsum still covers every
pattern. Rename chat.rs's window view to SidebarChatApp so only
chat_patterns::ChatApp claims to be Allsum's. Delete a layers.rs test
that duplicated layers_lists.rs's composited_once_the_owner_stops_animating,
and revert this branch's edits to the historical scroll layers design spec.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Test a running union before scanning an overlay's reaches, reuse
background's draw_order, keep a list row rendered to learn its height
instead of rendering it again every frame, and update docs left stale by
paths in layers.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
A replayed stretch whose push or leaf fails logs fewer ops than the
original, shifting the indices after it: the next frame draws nothing
again from such a frame. Keep only GPUI_A11Y_ACTIVE as the switch for
benchmarks, and document it and the dependency on last_tree_update.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
A row the layer of a list keeps was marked up to date with every write
made while the list was built, the other rows' included: a row widening
a column the kept rows read, without a notification, left them drawn at
the old width. Only the owner's render writes are masked now
(changed_rows); gpui_perf is unchanged (chat-app-sidebar-scroll 2.83M
-> 2.82M instructions/frame).

A row reading the entity the row before it read last noted no read, as
the access log drops a repeat within one stretch: each row starts a
stretch of its own. The burst test's rows now read the model every
eighth row, as every row reading it changes them all.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
A view inside a cached view changed only by an update that was not
notified is no longer marked dirty when the cached view is built again:
it is skipped, as the reviewer proposed. gpui_perf is unchanged
(chat-app-sidebar-scroll, chat-scroll-parent-writes, workspace-*).

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
covered_paths_drawn_over_the_tiles_equal_direct_drawing draws paths into
the window frame, over the composited tiles, but the test harness drew
every frame through a Target without path textures, so the first path
batch hit the debug assertion that only layer tiles lack them.

A Target now carries the views its paths are drawn through
(fast::frame::PathTargets) instead of the window's DirectXResources,
whose swap chain a test cannot make. The renderer borrows them from its
resources as before; the harness makes them for its frame with
upstream's own constructors, at the frame's size, and rasterizes tiles
through a Target without them, as the renderer does.
@sunli829
sunli829 merged commit 4c088de into main Oct 12, 2026
4 checks passed
@sunli829
sunli829 deleted the fast-robust-retention branch October 12, 2026 03:20
sunli829 added a commit that referenced this pull request Oct 12, 2026
… again (#55)

A view in a list row that moved is laid out again as the list prepaints
the row, outside the row's own layout. Claiming its nodes there cleared
the flag that keeps a row's nodes for 240 frames after the row leaves.
Once the row scrolled out its nodes were released, so scrolling back
over it laid its views out, and shaped their text, from scratch. The
flag now stays set once a node has been claimed laying out a list row
(`fast/layout.rs`, `claim()`).

Adds `gpui_perf`'s `chat-drag` scenario, which drags a chat transcript's
scrollbar from the end to the top and back, as a user does through a
long session. With retained views it took 37 % longer per frame than
drawing from scratch and shaped twice as many lines; it now does the
same work.

Found while investigating high CPU when dragging the transcript
scrollbar in Allsum. In Allsum itself this change alone is a small part
of the gain; the larger fixes are in a separate draft PR on top of #48.

- Test:
`a_moved_view_in_a_list_row_keeps_its_nodes_while_the_row_is_out_of_view`
(`fast/tests/layout.rs`).
- `docs/scroll-layers.md`: one sentence on the lingering nodes.

`script/check-upstream`: 0 violations. `cargo test -p gpui --features
test-support --lib fast::` and `cargo test -p gpui_perf` pass.

🤖 Generated with [Claude Code](https://claude.com/claude-code)
trancong12102 added a commit to aislopware/gpui-fast that referenced this pull request Oct 12, 2026
Upstream's new tests build ScrollWheelEvent without the momentum phase our fork's events carry.
trancong12102 added a commit to aislopware/gpui-fast that referenced this pull request Oct 12, 2026
longbridge#48 drew a cached view again only for what notified it, leaving out an
entity updated outside drawing without a notification, which the fork had
always counted under a notified view. A listener that hands a cached view
something and notifies only the view around it, as Slopty's workspace
hands a dropped file to a tile's composer, then showed it only once the
cached view was next notified: the composer's chip stayed missing.

Cached views count such updates again when inside a notified view, as
every other retained view does. What longbridge#48 was for stays: a write made while
the window draws (a host handing its pane whether it shows, every render)
still does not draw a cached view again.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
sunli829 added a commit that referenced this pull request Oct 12, 2026
#56)

Users of Allsum reported high CPU when dragging the scrollbar of a long
conversation. Profiling Allsum (release build, a long "Greeting" session
with charts, tables and markdown) showed the transcript list's scroll
layer bought almost nothing: rows were rendered again on every frame,
and the layer was demoted or kept off on most frames. Three fixes here,
one commit each.

**Stacked on #48.** The base branch `fast-robust-retention-on-main` is
#48 merged into current `main` (conflict in `fast/tests/mod.rs`
resolved), so this diff shows only these changes.

### 1. A list row's writes while it is drawn are its own
Writes made while a row was laid out, prepainted or painted counted as
someone else's. A chart filling its own path cache as it paints made its
row (and every row like it) render again on every scrolled frame. Those
writes are now the row's own, as a view's writes while it renders
already are. The write generation is shared with the thread like the
read logs, for the list to read without an app at hand.

Test: `rows_writing_what_they_read_as_they_are_drawn_keep_the_layer`
(fails without the fix: every shown row re-renders per scroll).

### 2. Replay a list's layer instead of compositing tiles
A list's layer keeps its rows' paint operations and replays those
reaching the viewport into the frame, moved by the scroll. No tiles are
rasterized for lists, so:
- rows painting paths, or drawing over earlier rows' paths, are replayed
in order (no overlay, no keep-off);
- a background that cannot be baked (translucent window) no longer
matters;
- a list's layer is not demoted: repainting costs what drawing without
the layer costs.

Overscan and the per-frame render budget are unchanged. `OverlayReach`
(lists only) is removed.

Tests that encoded tile, overlay or demotion behaviour for lists were
rewritten to assert the new behaviour; every `matches_layers_off`
comparison passes unchanged. `composites()` in the test helpers now also
counts replayed frames.

This is the change most worth discussing: it trades GPU-side tile
compositing for CPU-side replay of display lists for lists. In Allsum it
was the largest single win for scrollbar drag.

### 3. A list's scrollbar drag is a scroll
`ListState::set_offset_from_scrollbar` bumped the list's state version,
which counts content changes too, so every drag frame repainted the
layer. It now notes the offset like other code-set offsets
(`ScrollLog::take_offsets_set`, matched to the painted list by its
state), which counts as a scroll. Without layers the version still
moves.

Test: `a_list_dragged_by_its_scrollbar_is_composited` (fails without the
fix).

### Measured in Allsum (experimental build: these fixes, the hover
freeze of the stacked PR and the app-side fixes; the zero-overscan
variant of replay. Without hover freeze, wheel CPU was ~37 %; drag was
unchanged)
| | wheel CPU | drag CPU |
|---|---|---|
| main | 54 % | 36 % |
| with fixes | ~22 % | ~19 % |


### Measured again with the code of these PRs
Allsum release build against this branch stack (#56, #57 and the
lingering-nodes fix of #55), GPUI Kit's draft fix and
longbridge/ai-chat#438, same session, quiet machine:

| | wheel CPU | drag CPU |
|---|---|---|
| main | 47.2 % | 32.4 % |
| these PRs | 21.9 % | 19.7 % (~2.2 ms/frame) |

Keeping the overscan unchanged (unlike the experimental build) lost
nothing.

The app-side fixes are in separate PRs (GPUI Kit, ai-chat). Hover
freezing while the wheel scrolls is a separate draft stacked on this
one.

`script/check-upstream`: 0 violations. `cargo test -p gpui --features
test-support --lib` (681 passed), `cargo test -p gpui_perf`, `cargo
clippy -p gpui --features test-support --lib --tests` pass.

🤖 Generated with [Claude Code](https://claude.com/claude-code)
trancong12102 added a commit to aislopware/gpui-fast that referenced this pull request Oct 12, 2026
…d nothing

RenderDependencies::union returned self unchanged when the other side read
nothing, dropping the writes the subtree made while the other side was
recorded. A view holding a list reads nothing of its own while it is
prepainted (its rows' reads are theirs), so its prepaint stretch, where
the rows write, fell out of the record's own writes: on the next frame
note_rebuild took the rows' writes for writes from outside the view, and
the view's layer counted every rebuild as a change, rendering every shown
row again on each scrolled frame
(a_view_and_rows_writing_what_the_rows_read_keep_the_layer, from longbridge#48).

The fast path now merges the writes too, and Writes::union coalesces
stretches that meet, so a layer record extended frame after frame keeps a
few stretches rather than one a frame.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
trancong12102 added a commit to aislopware/gpui-fast that referenced this pull request Oct 12, 2026
)

longbridge#61 and longbridge#62 add `gpui_linux/src/fast/xim.rs` and `x11_scale.rs`, declared
in `gpui_linux/src/fast/mod.rs`, which this fork had removed with
longbridge's window composition. The module comes back with those two
only, still without `composition`, and `gpui_linux.rs` declares it again.

Also: gpui_perf's chat scenarios (longbridge#48) build their wheel events with this
fork's `momentum_phase`, and the showcase, which builds against upstream
GPUI too, fills it from `Default`, so the fork's CI compiles gpui_perf
again; its compat check covers the gpui-fast stand-ins.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
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.

2 participants