Repository navigation
fast: Keep retention and scroll layers working with real application patterns - #48
Merged
Merged
Conversation
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: # docs/scroll-layers.md
# 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>
…le window" This reverts commit 7de6a1b.
…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>
This was referenced Oct 10, 2026
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
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>
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
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-upstreamreports 0 violations.Framework changes
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.fast/retained.rs,dependencies.rs). A write without a notify, made while the window draws, such as Allsum'sChatApp::rendercallingchat_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_bottomon a handle already at its bottom changes nothing (fast/layers/scroll_to_bottom.rs), asListState::scroll_to_endalready 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.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.divscroll 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.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.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.fast/layers/policy.rs,lists.rs).GPUI_A11Y_ACTIVE=1(test-support only) forces accessibility on for benchmarks.Benchmarks
chat-scroll-*, which reproduce ai-chat ChatView's patterns one at a time, pluschat-scroll-allsum, which combines them;chat-app-sidebar-scrollandscroll-list-tables.ChatViewand GPUI Kit's realListandDataTableheadless.Instant::now()directly had made earlier measurements nondeterministic.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
ChatView670d8c0 with 150 Markdown messages, GPUI Kit 296fe2f, 240 frames)List, wheelList, trackpadDataTable, wheelDataTable, trackpadAllocations 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
ListandDataTableuse 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)
chat-scroll-allsum-wheel(ChatView + ChatApp patterns)chat-scroll-allsum(trackpad)chat-scroll-animateschat-scrollscrollbar-drag-uniform-listscroll-list-tablesscroll-list-scrollbar-burstscroll-{list,uniform-list}-fading-scrollbar¹ Measured on the scenario's branch before its fix.
With accessibility active (
GPUI_A11Y_ACTIVE=1):scroll-child-viewworkspace-hoverchat-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.WorkBudgetunits.gpui_perf --headless --verify: painted frames identical with layers on and off, in all 31 scroll and chat scenarios, with accessibility off and on.-D warningspasses for gpui and gpui_perf, and for gpui_windows and gpui_apple cross-target.cargo fmt --checkandscript/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 recordeditem_is_above/below_viewportandScrollHandle::top_itemanswers. It measured no gain in any scene, and no real code calls theScrollHandlequeries; 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, andlist.rsanduniform_list.rsare 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/DataTablelayers: 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 branchfast-render-offset-reads; it is not merged, because it had no measurable gain.Application-side fixes the framework cannot make soundly:
TextViewrewrites its state on every render, which rebuilds the Markdown;set_placeholderis called on every render;These are listed with file:line in the analysis.
🤖 Generated with Claude Code