Skip to content

text_selection: Stop writing selection state on every frame - #3407

Merged
huacnlee merged 1 commit into
mainfrom
perf/no-per-frame-entity-writes
Oct 8, 2026
Merged

huacnlee merged 1 commit into
mainfrom
perf/no-per-frame-entity-writes

Conversation

@huacnlee

@huacnlee huacnlee commented Oct 8, 2026

Copy link
Copy Markdown
Member

Problem

Scrolling a long chat session in Allsum used 35–45 % process CPU here (50–60 % in the original report), most of it laying out and shaping message rows that had only moved. Part of the cause was in gpui-kit: selectable text, the text selection layer, Root and inputs wrote entities and globals on every frame they drew, even when nothing about the selection or focus changed.

Under GPUI Fast's retained views a write is a change, so every view that read any of these (every message row with a TextView) was built again on the next frame, and the transcript list could not stay on its scroll layer.

Per-frame writes found:

Where Write every frame
TextSelectionHandle::register WindowSelectionState updated with the participant's geometry
TextView prepaint / paint GlobalState::global_mut to push/pop the text view stack, begin_selection_frame
update_runs / set_hit_test_runs participant entity updated with its painted runs
TextSelectionLayer registry global set again; touch frame shifted; finish-frame flag updated
selection publishing set_snapshot on every participant, changed or not
scroll / mouse-move handlers state updated on every event, selecting or not
Root / InputState focus registry re-synced

Changes

  • With no selection, gesture or touch selection active, a participant records its geometry without updating the state (register_quietly, finish_frame_quietly).
  • The text view stack and selection document order are interior state of GlobalState (RefCell/Cell), read through GlobalState::global. Painted runs and copy text are interior state of the participant.
  • Snapshots are published only to participants whose snapshot differs. The registry, active scope, focus sync and the mouse-move and scroll handlers write only when something changed.
  • Touch handles are recorded per participant and replaced when that participant paints them again. Before, they expired with frames the selection layer drew; once the layer's view is retained it may not draw, so stale handle bounds made a tap on the text ignored.
  • Test fix: a_flow_is_laid_out_again_at_another_width changed the view's width without cx.notify(). It passed only because the per-frame writes rebuilt the view anyway.

Before / after

Allsum release build (macOS, Apple silicon), long session scrolled by the wheel for 30 s, process CPU, together with longbridge/gpui-fast#44:

Scroll before after
slow 35.3–36.4 % 31.2–33.4 %
fast 43.0–43.6 % 37.0–37.7 %

Main thread over 12 s: List::prepaint 1374 → 451 ms, StateInner::layout_items 1218 → 84 ms, compute_layout 879 → 180 ms. With only the gpui-fast change, the list still fell off its layer, because these writes kept rebuilding the rows.

Testing

  • New test redrawing_selectable_text_leaves_its_readers_retained (GPUI Fast): a view reading the selection state beside selectable text is not rendered again when the text redraws. It fails without this change (13 renders instead of 5).
  • cargo test -p gpui-base --lib: 1317 passed. cargo test -p gpui-component --lib: 649 passed.
  • cargo test -p gpui-base --lib --features gpui-fast: the same 14 tests fail as on main, and no new ones. Before the touch-handle change, long_press_release_keeps_handles_which_drag_the_selection failed here.
  • cargo clippy -p gpui-base -p gpui-component --tests -- --deny warnings (with and without gpui-fast) and cargo fmt --check are clean.

🤖 Generated with Claude Code

Selectable text, the text selection layer and inputs wrote entities and
globals on every frame they drew, even when nothing about the selection or
focus changed: each participant registered its geometry through an update
of the window's selection state, `TextView` pushed itself onto the
`GlobalState` text view stack through `global_mut`, the selection registry
global was set again, `Root` re-synced focus, and the scroll and mouse-move
handlers updated the state on every event.

Under GPUI Fast's retained views, a write is a change: every view that read
any of these was built again on the next frame. In a chat transcript this
rebuilt every message row on every scrolled frame, and kept the list off its
scroll layer.

- Participants record their geometry without updating the state while
  there is no selection, gesture or touch selection for it to change.
- The text view stack and the selection document order are interior state
  of `GlobalState`, read through `GlobalState::global`.
- Painted runs and copy text are interior state of the participant.
- Snapshots are published to a participant only when they differ; the
  registry, the active scope, focus sync and the mouse-move and scroll
  handlers write only when something changed.
- Touch handles are recorded per participant and replaced when it paints
  them again, instead of expiring with frames the selection layer may not
  draw when its view is retained.

A test checks that redrawing selectable text leaves a view reading the
selection state retained under GPUI Fast.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
@huacnlee
huacnlee merged commit ab7b54f into main Oct 8, 2026
16 checks passed
@huacnlee
huacnlee deleted the perf/no-per-frame-entity-writes branch October 8, 2026 03:49
huacnlee added a commit to longbridge/gpui-fast that referenced this pull request Oct 8, 2026
## Problem

A profile of scrolling a long session in Allsum (about 50–60 % process
CPU on the reporter's machine) put most of the time in `List::prepaint →
StateInner::layout_items → layout_as_list_item → Taffy → InlineFlow →
shape_line`, even though gpui-fast's retained views and list scroll
layers were on. The transcript list's scroll layer was almost never
composited: every scrolled frame laid out and shaped the rows it showed
again.

Reproduced with `gpui_perf` scenarios shaped like a chat view
(`chat-scroll`, no dependency on the app), then checked against Allsum
itself with temporary per-frame counters (not committed) until the list
stayed composited.

## Causes and changes

**1. Reads the layer took for content changes** (first commits)
- `is_scrolled_to_end()` read while rendering (a "back to bottom"
button) counted as reading the list's offset. It is now recorded as a
read of its answer, which changes only when the list reaches or leaves
its end.
- `scroll_to_end()` every render bumped the list state even at its end.
It no longer does.
- What a view writes into a model as it renders (an input writing its
options) counted as an outside change.
- What a view reads of its list *after* building it (an outline marker
reading the first row shown) no longer counts as shaping the rows; only
what its `render` read before the list was built does.

**2. One row changing repainted the whole layer** (last commit)
- The layer now keeps what each row read (rendering, layout, prepaint,
paint) and the views drawn in it, apart from what the rest of the list
read. A change read by one row — its Markdown parsed after it first
rendered, its view notified — renders that row again, alone, on a
composited frame.
- A row the list only measured, without prepainting it, is not the
layer's; nothing it read is kept.

**3. The view holding the list rendered again for something unrelated**
- A fade animation of the back-to-bottom button, or a task notifying the
view every two seconds, repainted and then demoted the layer. Now the
list renders the rows it shows again and marks the other rows it holds
to be rendered before they show. A list whose item count changed is
still painted afresh.
- A change during the demotion cooldown restarted the whole (doubling)
cooldown, so a view notified every two seconds kept its list off its
layer for good. It now asks for 60 more stable frames. A repaint that
left every row as the layer held it does not count as a change.
- Broad refreshes demote only when two come within 120 frames of each
other.

**4. Rows laid out from scratch**
- A `list` lays out every row it shows before prepainting the first, so
the first row took every row's layout keys and, when carried, claimed
their Taffy nodes: the other rows got throwaway nodes each frame. Each
row now keeps only its own keys.
- A list row's nodes outlive it for 240 frames, so a row scrolled out
and back keeps its measurements instead of shaping its text again.
- A leaf measured through `request_measured_layout`, given a new closure
every frame, is measured again before layout and left clean if it
measures as before, instead of being dirtied with every node above it.

Upstream files change only by hooks (`list.rs`, `taffy.rs`);
`script/check-upstream` passes. `docs/scroll-layers.md` is updated.

## Results

**Allsum** (macOS, Apple silicon, release; a long session scrolled by
the wheel for 30 s; process CPU from `ps`, two runs each, against the
build the report was measured on):

| Scroll | before | after |
|---|---|---|
| slow (40 px events) | 35.3–36.4 % | **31.2–33.4 %** |
| fast (120 px events) | 43.0–43.6 % | **37.0–37.7 %** |

Main thread over 12 s of slow scrolling (samply):

| | before | after |
|---|---|---|
| `List::prepaint` | 1374 ms | **451 ms** |
| `StateInner::layout_items` | 1218 ms | **84 ms** |
| `TaffyLayoutEngine::compute_layout` | 879 ms | **180 ms** |
| main thread total | 3395 ms | **2699 ms** |
| list layer decisions | mostly demoted (bypass) | **composited on ~99 %
of list frames** |

What remains on the main thread is laying out and painting rows as they
enter the overscan, and presenting the frame.

**gpui_perf `chat-scroll`** (headless, 1200 frames; the scenario now
also parses message bodies the frame after they first render, notifies
its view every two seconds and reads the outline offset after building
the list):

| | layers off | layers on |
|---|---|---|
| frame mean | 0.266 ms | **0.139 ms** |
| instructions | 3.77M | **1.93M** |
| composited | – | **93 %** |

On `main`, the first version of this scenario (without the parsing,
notifications and outline read) composited no frame: 0.262 ms with
layers on, as with them off.

## Testing

- `cargo test -p gpui --lib`: 617 passed. `cargo test -p gpui_perf`:
passed.
- New tests in `fast/tests/layers_lists.rs`, each checked to fail
without its fix:
- a change read by one row (a model its view reads, a model the row
renderer reads, the row's view itself) renders that row alone,
composited, drawing as without layers;
- a view rendering again renders the rows shown and the rest as they
show, drawing as without layers;
- a view animating beside its list keeps the layer; a demoted list
notified every few seconds gets its layer back;
- each held row keeps only its own layout keys; scrolling back and forth
lays no row out afresh;
- plus the at-end / offset-read / own-write tests from the earlier
commits.
- New test in `fast/tests/layout.rs`: a rebuilt measured leaf measuring
the same leaves its layout alone, and one measuring differently moves
what follows, both matching a from-scratch frame.
- `gpui_perf --headless --verify`: every scenario paints identical
frames with layers on and off.
- `cargo clippy -p gpui -p gpui_perf --lib --tests` clean.

Companion gpui-kit change, needed for the Allsum numbers above (no
per-frame entity/global writes from text selection, `Root` and inputs):
longbridge/gpui-kit#3407.

🤖 Generated with [Claude Code](https://claude.com/claude-code)

---------

Co-authored-by: Claude Opus 5.5 <noreply@anthropic.com>
huacnlee added a commit that referenced this pull request Oct 8, 2026
## Description

Follow-up to #3407. Two more places update an entity even though nothing
in it changed. Under GPUI Fast, a view that read that entity is then
counted as changed and built again, even when it is retained.

- **`TextViewState`**: a markdown text that is parsed synchronously when
it is set still receives an acknowledgement (`baseline_ack`) from the
background parser. `commit_parsed_update` discarded it, but only after
`weak_self.update`, which already marked the state as updated. The
receive task now checks first, with `read_with` and the new
`TextViewState::commits`, and skips results that commit nothing.
`commit_parsed_update` uses the same check.
- **`ResizablePanelGroup` / `ResizablePanel`**: both report their bounds
in `on_prepaint` on every frame they are drawn, and updated
`ResizableState` every time, even when the bounds were the same. Every
view that read the state was rebuilt on every frame. They now return
early when nothing would change: same group bounds, or
`ResizableState::panel_size_changes` (crate-private) is false.

Behaviour is unchanged: the skipped updates were no-ops.

## Before / After

Measured by the new tests (`gpui-fast` feature), counting renders of a
view that reads the state:

| Case | Before | After |
| --- | --- | --- |
| Reader of a `TextViewState` after the parser acknowledges a
synchronous parse | 2 renders | 1 render |
| Reader of a `ResizableState` while its panels are redrawn at the same
bounds (3 extra frames) | 11 renders | 5 renders |

## Tests

-
`text::state::tests::parse_ack::acknowledging_a_parse_leaves_its_readers_retained`
-
`resizable::tests::redrawing_panels_where_they_were_leaves_their_readers_retained`

Both fail without the fix and pass with it. `cargo fmt --check` and
`cargo clippy -p gpui-base -- --deny warnings` (with and without
`gpui-fast`) are clean. `cargo test -p gpui-base --lib` passes, 1323
tests. With `--features gpui-fast` the same 14 tests fail on `main` as
on this branch, so this change neither causes nor fixes them.

🤖 Generated with [Claude Code](https://claude.com/claude-code)

Co-authored-by: Claude Opus 5.5 <noreply@anthropic.com>
trancong12102 added a commit to aislopware/gpui-kit that referenced this pull request Oct 9, 2026
… fork's own

longbridge#3407 stops selectable text, the selection layer and TextView
writing entities and globals on every frame. The fork's a5aaeef and the
commits after it (7f9d593 among them) do the same and more: TextView's
reveal and selection frame run inside the state's own view, and the
selection's document order holds across frames that paint only some
views. Both rewrote the same state, so the fork's commits are replayed
over upstream with longbridge#3407's text side taken back; its Root and InputState
changes stay.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
trancong12102 added a commit to aislopware/gpui-kit that referenced this pull request Oct 9, 2026
…e one is made

longbridge#3407 stops the pointer's moves and the wheel updating the window's
selection state when no selection is being made, and the touch edit menu
when no touch selection is active: every update counted as a change for
the views reading the state. The fork took back longbridge#3407's text selection
side for its own (fa37e9d), which lacked these two guards.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
trancong12102 added a commit to aislopware/gpui-kit that referenced this pull request Oct 9, 2026
…e selection state

Every participant registers its geometry on every frame it paints, and the
fork's register did so through an update of the window's selection state.
Under GPUI Fast's retained views an update is a change, so every view that
read the state was built again on the next frame however idle the text:
a reader beside selectable text rendered 7 times where 3 suffice.

Port longbridge#3407's register_quietly to the fork's own selection state:
with no selection, gesture or touch selection active, and none held by the
participant, its registration goes into the participant ledger without an
update. The ledger (participants and the automatic document order) moves
into RefCells so recording it, pruning dead participants and numbering the
automatic order work from a read; register_participant takes the same path
after its selection checks. The participant's painted runs and copy text are
interior too, as in longbridge#3407, so update_runs and set_hit_test_runs no longer
update it per frame. finish_painted_frame already wrote nothing on an idle
frame. Selection behaviour is unchanged: an active selection still registers
through register_participant, and finish_painted_frame delivers any snapshot
that changed.

Tests: redrawing_selectable_text_leaves_its_readers_retained (from longbridge#3407,
unconditional on the fork, which always retains), and
redrawing_selectable_text_with_a_selection_keeps_it. longbridge#3407's test fix to
a_flow_is_laid_out_again_at_another_width (notify on a width change) is
taken, since the per-frame writes no longer rebuild that view.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
linruohan pushed a commit to linruohan/gpui-component that referenced this pull request Oct 9, 2026
…ge#3407)

## Problem

Scrolling a long chat session in Allsum used 35–45 % process CPU here
(50–60 % in the original report), most of it laying out and shaping
message rows that had only moved. Part of the cause was in gpui-kit:
selectable text, the text selection layer, `Root` and inputs **wrote**
entities and globals on every frame they drew, even when nothing about
the selection or focus changed.

Under GPUI Fast's retained views a write is a change, so every view that
read any of these (every message row with a `TextView`) was built again
on the next frame, and the transcript list could not stay on its scroll
layer.

Per-frame writes found:

| Where | Write every frame |
|---|---|
| `TextSelectionHandle::register` | `WindowSelectionState` updated with
the participant's geometry |
| `TextView` prepaint / paint | `GlobalState::global_mut` to push/pop
the text view stack, `begin_selection_frame` |
| `update_runs` / `set_hit_test_runs` | participant entity updated with
its painted runs |
| `TextSelectionLayer` | registry global set again; touch frame shifted;
finish-frame flag updated |
| selection publishing | `set_snapshot` on every participant, changed or
not |
| scroll / mouse-move handlers | state updated on every event, selecting
or not |
| `Root` / `InputState` | focus registry re-synced |

## Changes

- With no selection, gesture or touch selection active, a participant
records its geometry without updating the state (`register_quietly`,
`finish_frame_quietly`).
- The text view stack and selection document order are interior state of
`GlobalState` (`RefCell`/`Cell`), read through `GlobalState::global`.
Painted runs and copy text are interior state of the participant.
- Snapshots are published only to participants whose snapshot differs.
The registry, active scope, focus sync and the mouse-move and scroll
handlers write only when something changed.
- Touch handles are recorded per participant and replaced when that
participant paints them again. Before, they expired with frames the
selection layer drew; once the layer's view is retained it may not draw,
so stale handle bounds made a tap on the text ignored.
- Test fix: `a_flow_is_laid_out_again_at_another_width` changed the
view's width without `cx.notify()`. It passed only because the per-frame
writes rebuilt the view anyway.

## Before / after

Allsum release build (macOS, Apple silicon), long session scrolled by
the wheel for 30 s, process CPU, together with longbridge/gpui-fast#44:

| Scroll | before | after |
|---|---|---|
| slow | 35.3–36.4 % | **31.2–33.4 %** |
| fast | 43.0–43.6 % | **37.0–37.7 %** |

Main thread over 12 s: `List::prepaint` 1374 → 451 ms,
`StateInner::layout_items` 1218 → 84 ms, `compute_layout` 879 → 180 ms.
With only the gpui-fast change, the list still fell off its layer,
because these writes kept rebuilding the rows.

## Testing

- New test `redrawing_selectable_text_leaves_its_readers_retained` (GPUI
Fast): a view reading the selection state beside selectable text is not
rendered again when the text redraws. It fails without this change (13
renders instead of 5).
- `cargo test -p gpui-base --lib`: 1317 passed. `cargo test -p
gpui-component --lib`: 649 passed.
- `cargo test -p gpui-base --lib --features gpui-fast`: the same 14
tests fail as on `main`, and no new ones. Before the touch-handle
change, `long_press_release_keeps_handles_which_drag_the_selection`
failed here.
- `cargo clippy -p gpui-base -p gpui-component --tests -- --deny
warnings` (with and without `gpui-fast`) and `cargo fmt --check` are
clean.

🤖 Generated with [Claude Code](https://claude.com/claude-code)

Co-authored-by: Claude Opus 5.5 <noreply@anthropic.com>
linruohan pushed a commit to linruohan/gpui-component that referenced this pull request Oct 9, 2026
…#3418)

## Description

Follow-up to longbridge#3407. Two more places update an entity even though nothing
in it changed. Under GPUI Fast, a view that read that entity is then
counted as changed and built again, even when it is retained.

- **`TextViewState`**: a markdown text that is parsed synchronously when
it is set still receives an acknowledgement (`baseline_ack`) from the
background parser. `commit_parsed_update` discarded it, but only after
`weak_self.update`, which already marked the state as updated. The
receive task now checks first, with `read_with` and the new
`TextViewState::commits`, and skips results that commit nothing.
`commit_parsed_update` uses the same check.
- **`ResizablePanelGroup` / `ResizablePanel`**: both report their bounds
in `on_prepaint` on every frame they are drawn, and updated
`ResizableState` every time, even when the bounds were the same. Every
view that read the state was rebuilt on every frame. They now return
early when nothing would change: same group bounds, or
`ResizableState::panel_size_changes` (crate-private) is false.

Behaviour is unchanged: the skipped updates were no-ops.

## Before / After

Measured by the new tests (`gpui-fast` feature), counting renders of a
view that reads the state:

| Case | Before | After |
| --- | --- | --- |
| Reader of a `TextViewState` after the parser acknowledges a
synchronous parse | 2 renders | 1 render |
| Reader of a `ResizableState` while its panels are redrawn at the same
bounds (3 extra frames) | 11 renders | 5 renders |

## Tests

-
`text::state::tests::parse_ack::acknowledging_a_parse_leaves_its_readers_retained`
-
`resizable::tests::redrawing_panels_where_they_were_leaves_their_readers_retained`

Both fail without the fix and pass with it. `cargo fmt --check` and
`cargo clippy -p gpui-base -- --deny warnings` (with and without
`gpui-fast`) are clean. `cargo test -p gpui-base --lib` passes, 1323
tests. With `--features gpui-fast` the same 14 tests fail on `main` as
on this branch, so this change neither causes nor fixes them.

🤖 Generated with [Claude Code](https://claude.com/claude-code)

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