Skip to content

gpui: keep a long chat transcript on its scroll layer while it scrolls - #56

Merged
sunli829 merged 3 commits into
mainfrom
fast-chat-scroll-cpu
Oct 12, 2026
Merged

sunli829 merged 3 commits into
mainfrom
fast-chat-scroll-cpu

Conversation

@sunli829

@sunli829 sunli829 commented Oct 10, 2026 •

Copy link
Copy Markdown
Collaborator

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

A list row on a scroll layer was found changed when it read an entity
written since it was rendered, unless the write was made while the row
rendered. Writes made while the row was laid out, prepainted or painted
counted as someone else's: a chart filling its path cache as it paints
made its row, and every row like it, render again on every scrolled
frame, so the layer bought nothing. Those writes are now the row's own,
as a view's writes while it renders are.

The write generation is shared with the thread, as the read logs are, for
the list to know where it stands without an app at hand.
A list's layer now keeps its rows' paint operations and replays those of
the rows that reach the viewport into the frame, moved by the scroll,
rather than rasterizing them into tiles. A composited frame costs what
painting the rows shown costs, without rendering, laying out or
prepainting them again.

With nothing to rasterize, a list's layer is no longer kept off for what
tiles cannot do: rows painting paths, or drawing over the paths of rows
before them, are replayed in order with the rest, and a background that
cannot be baked does not matter, as the rows are drawn over whatever is
under them. Nor is it demoted: painting its rows afresh costs what drawing
them without the layer does, and the frames between two changes composite
them. In a chat transcript, whose tall rows paint charts and rounded
tables over a translucent window, the tiled layer was demoted or kept off
on most frames; replayed, it is composited on nearly all of them.

OverlayReach, which only lists used, goes.
ListState::set_offset_from_scrollbar moved the list's state version,
which counts changes of the list's content as well as of its offset, so
every frame of a scrollbar drag painted the list's layer afresh, rendering
every row it shows. The offset it sets is now noted as offsets code sets
are (ScrollLog::take_offsets_set), matched to the painted list by its
state, and taken for a scroll, as a wheel's is: the frame composites the
layer and renders only the rows the drag brings. Without layers the
version still moves.
@sunli829
sunli829 force-pushed the fast-chat-scroll-cpu branch from 35cd970 to 7c61fc5 Compare October 12, 2026 03:47
@sunli829
sunli829 changed the base branch from fast-robust-retention-on-main to main October 12, 2026 03:47
@sunli829
sunli829 merged commit 12def03 into main Oct 12, 2026
4 checks passed
@sunli829
sunli829 deleted the fast-chat-scroll-cpu branch October 12, 2026 04:37
sunli829 added a commit that referenced this pull request Oct 12, 2026
…57)

**Experimental, stacked on #56.** Changes hover semantics for every app,
so it is a separate PR for discussion.

A wheel moves what is under the pointer without the pointer moving:
every frame of a wheel scroll hovered another row, rendering the row it
left and the row it entered again. A hover could also start an animation
(a hover card fading in, a toolbar showing) that kept the list off its
layer. In Allsum's long transcript, wheel scrolling cost about what it
costs without layers.

For 100 ms after a wheel event, while the pointer stays where the event
was, nothing is hovered: the frame's hit test, and the one a list
foretells for its rows, are empty. When the wheel stops the window is
drawn again (a timer refreshes it) and hover follows the pointer; a
pointer that moves ends it at once.

- `fast/layers/hover_freeze.rs`: the state (per window, in
`WindowLayers`), `note_wheel` (from `input::before_dispatch`),
`begin_draw` (from `retained::begin_frame`), `hit_test`.
- `window.rs`: `mouse_hit_test` made `pub(crate)`; the post-prepaint hit
test goes through `hover_freeze::hit_test`. `script/upstream-allowlist`
raises window.rs's added budget from 80 to 82 for these two lines.
- gpui's own unit tests scroll under a still pointer to test hover as
rows move, so the freeze is off under `cfg(test)`;
`hover_held_still_while_the_wheel_scrolls_renders_no_row_for_it` turns
it on (and fails with it off).

Measured in Allsum (experimental build, same binary, toggled at
runtime): wheel CPU 37 % without, 21 % with; drag unchanged (~20 %).

Open questions:
- Clearing hover (rather than keeping the last hovered element) makes a
hovered row lose its hover style for the duration of the scroll. Hitbox
ids are per frame, so keeping the old hit test is not safe as is.
- Part of the gain may come from hover-started animations rather than
hover itself; an investigation of which requests drop the layer during
wheel scrolling (GPUI Kit's scrollbar fade, ai-chat's outline, a chart's
presence animation) is ongoing.

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

🤖 Generated with [Claude Code](https://claude.com/claude-code)
sunli829 added a commit to longbridge/gpui-kit that referenced this pull request Oct 12, 2026
…a closed popup (#3431)

## Background

Allsum users reported high CPU when dragging the scrollbar of a longer
AI chat conversation. Under gpui-fast's retained views, whatever did not
change since the last frame is drawn from it; two things in `gpui-base`
kept a long list of rendered markdown from ever being unchanged. Both
changes are in `gpui-base` (AGENTS.md asks for an explicit request
before changing Base; this was requested as part of the Allsum
investigation).

## Changes

- **`text/inline.rs`: read the pointer only under hovered text.**
`Inline::paint` read `window.mouse_position()` on every paint to find
the link under the pointer. Reading the pointer makes the text depend on
every pointer move, so a scrollbar drag, or any mouse move, drew every
paragraph of the transcript again. It now reads the pointer only when
its own hitbox is hovered; text not under the pointer has no link under
it, so the result is the same.
- **`popup.rs`: a closed popup asks for no frame.** A `Popup` asked for
an animation frame the first time it measured its trigger, so that its
content could be placed on the next frame. A closed popup has no content
to place, yet every list row coming into view with one asked for a
frame, and a list whose rows ask for frames is kept off its scroll
layer. It now asks only when it has content; opened later, it places its
content at once from the trigger bounds it already noted.

## Measurements

In Allsum, with the gpui-fast fixes (longbridge/gpui-fast#56) and
longbridge/ai-chat#438: turning off the inline-text change raised
scrollbar-drag CPU from ~19 % to ~38 %; turning off the popup change
raised it to ~27.5 %. With everything, wheel 47 % → 22 % and drag 32 % →
20 % against main.

## Tests

- New `a_closed_popup_asks_for_no_frame_and_opens_at_once` (a closed
popup renders its view once; opened, its content is placed on that
frame).
- **Not yet run locally**: resolving the workspace needs the `quickjs`
git dependency, whose fetch is still in progress here. The changed code
compiles (built into Allsum on top of 02daace). I will update this once
the tests have run.

🤖 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
Since longbridge#56 a scrollbar setting a list's offset leaves the list state's
version alone, for the list to be drawn from its layer, and counts as a
scroll only once a scroll container decides on its layer. A frame after
code set the offset without notifying the view holding the list (a
bounce, a test drawing) then drew that view from the last frame: the
list stayed where it was, and its wheel listener scrolled on from the
old offset (gpui-kit's ScrollBounce list tests: -50 where -1000).

Offsets set since the last frame are taken in as the frame begins, and
the view that laid out a list scrolled so is built again, as if notified;
its list composites from its layer as for a wheel.

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.

1 participant