render: walk only the marked rows to prime and shape glyphs #431

Merged
natsukium merged 5 commits from render/incremental-populate into main 2026-09-25 12:29:27 +09:00
Owner

Closes #422

The glyph populate walk now visits only the rows the shadow marked, and keeps the shape results (ShapeFrame) of the other rows across frames. Row rotation for scrolls, the issue's second part, does not pay with the traffic the client receives today; the evidence is in a comment on #422.

What changed

  • GridWalker::walk (crates/felis-render-wgpu/src/glyphs.rs) walks screen.damage().dirty_rows() while the index's key from its last complete walk (rows, cols, whether the shaper runs) still holds, and every row otherwise. Every atlas recycle drops the key, including one caused by overlay priming between frames, and so does the retry after a mid-walk recycle. A font, size or feature reload rebuilds the index, which starts with no key.
  • The frame-wide inputs the row cache keys on (viewport, selection, pre-edit, chrome rows, reverse video) change pixels, not glyphs, so the walk stays incremental across them. The shadow has no scrollback: a viewport move arrives as composed RowDeltas, which mark their rows.
  • When a row is walked again, its old cluster glyphs stay in the frame's shared pool. The pool is rewritten once stale entries outnumber live ones two to one (floor 4096).
  • The walk now lives outside GlyphCache, with no GPU dependency, so tests can drive it against a bare GlyphIndex. The property test walking_the_marked_rows_matches_a_full_walk (glyphs/walk_tests.rs) applies random writes, clusters, sized cells, scroll directives, resizes, style sweeps and overlay priming. It runs with and without features, and under atlases small enough to recycle. After each frame, the kept frame must equal a from-scratch walk, and the atlas must already hold every glyph a full walk primes. Removing the recycle's key reset, the row clear, the compaction or the key check each makes it fail.
  • The walk harness forces its style sweep through TableGc::sweep (#432).

Measurements

Linux, NVIDIA, release builds, 212x93, interleaved with origin/main (43ac9b14), three runs each, load 6 to 19 on a shared host. Frame build+upload was timed as in #424. Populate is timed separately and covers the grid walk and overlay priming.

scenario populate median (us), main this branch build+upload median (us), main / branch
one key every 50 ms 63 / 80 / 101 3.8 / 4.1 / 5.0 66-75 / 70-75
seq scroll flood 94 / 94 / 116 112 / 98 / 101 all 93 rows dirty every frame, unchanged

A keystroke frame's populate plus build+upload goes from about 150 us to about 75 us. The flood does not change, because every frame marks every row.

just check passes locally. pi review (sol): PASS on the first round.

Closes #422 The glyph populate walk now visits only the rows the shadow marked, and keeps the shape results (`ShapeFrame`) of the other rows across frames. Row rotation for scrolls, the issue's second part, does not pay with the traffic the client receives today; the evidence is in a comment on #422. ## What changed - `GridWalker::walk` (`crates/felis-render-wgpu/src/glyphs.rs`) walks `screen.damage().dirty_rows()` while the index's key from its last complete walk (rows, cols, whether the shaper runs) still holds, and every row otherwise. Every atlas recycle drops the key, including one caused by overlay priming between frames, and so does the retry after a mid-walk recycle. A font, size or feature reload rebuilds the index, which starts with no key. - The frame-wide inputs the row cache keys on (viewport, selection, pre-edit, chrome rows, reverse video) change pixels, not glyphs, so the walk stays incremental across them. The shadow has no scrollback: a viewport move arrives as composed `RowDelta`s, which mark their rows. - When a row is walked again, its old cluster glyphs stay in the frame's shared pool. The pool is rewritten once stale entries outnumber live ones two to one (floor 4096). - The walk now lives outside `GlyphCache`, with no GPU dependency, so tests can drive it against a bare `GlyphIndex`. The property test `walking_the_marked_rows_matches_a_full_walk` (`glyphs/walk_tests.rs`) applies random writes, clusters, sized cells, scroll directives, resizes, style sweeps and overlay priming. It runs with and without features, and under atlases small enough to recycle. After each frame, the kept frame must equal a from-scratch walk, and the atlas must already hold every glyph a full walk primes. Removing the recycle's key reset, the row clear, the compaction or the key check each makes it fail. - The walk harness forces its style sweep through `TableGc::sweep` (#432). ## Measurements Linux, NVIDIA, release builds, 212x93, interleaved with origin/main (43ac9b14), three runs each, load 6 to 19 on a shared host. Frame build+upload was timed as in #424. Populate is timed separately and covers the grid walk and overlay priming. | scenario | populate median (us), main | this branch | build+upload median (us), main / branch | |---|---|---|---| | one key every 50 ms | 63 / 80 / 101 | 3.8 / 4.1 / 5.0 | 66-75 / 70-75 | | `seq` scroll flood | 94 / 94 / 116 | 112 / 98 / 101 | all 93 rows dirty every frame, unchanged | A keystroke frame's populate plus build+upload goes from about 150 us to about 75 us. The flood does not change, because every frame marks every row. `just check` passes locally. pi review (sol): PASS on the first round.
The incremental-rebuild property test called ScreenBuffer::gc_styles,
which the pen re-interning change made crate-private in felis-grid while
the row cache was in review, so felis-render-wgpu's tests stopped
compiling once both landed. The harness now interns past the sweep's
trigger point and runs TableGc::maybe_sweep, the path the client's
shadow takes, so the op still compacts the style table under the
cached rows.

Assisted-by: Claude Code
render: walk only the marked rows to prime and shape glyphs
Some checks failed
bench / Criterion full-suite snapshot (pull_request) Has been skipped
fuzz / cargo fuzz nightly long-run (pull_request) Has been skipped
darwin / detect relevant changes (pull_request) Successful in 4s
bench / Criterion regression gate (pull_request) Successful in 2m14s
darwin / darwin docs-only no-op (pull_request) Has been skipped
darwin / build felis (aarch64-darwin) (pull_request) Successful in 53s
darwin / publish felis (aarch64-darwin) (pull_request) Has been skipped
pr / nix flake check (pull_request) Successful in 43s
fuzz / cargo fuzz smoke (per target) (pull_request) Successful in 1m15s
pr / cargo build / clippy / test / deny (pull_request) Has been cancelled
pr / wire schema is compatible with the base (pull_request) Has been cancelled
pr / frontend smoke (x86_64-linux) (pull_request) Has been cancelled
pr / publish felis (x86_64-linux) (pull_request) Has been cancelled
windows / detect relevant changes (pull_request) Has been cancelled
windows / cargo clippy (Windows cross) (pull_request) Has been cancelled
windows / cargo nextest (Windows) (pull_request) Has been cancelled
windows / frontend smoke (Windows) (pull_request) Has been cancelled
windows / package felis (x86_64-pc-windows-msvc) (pull_request) Has been cancelled
windows / windows docs-only no-op (pull_request) Has been cancelled
b00639a03a
With the row cache in place, the glyph populate walk was the one
whole-grid cost left on a keystroke: it read, and with features or
clusters shaped, every cell to prime atlas slots and rebuild the shape
frame, although only the echoed row changed. At 212x93 it took more
time than building and uploading the frame's instances.

The walk now visits the rows the shadow marked and keeps the shape
frame's entries for the rest. That holds only while nothing moved the
slots or the frame's layout under them, so the index keeps the key of
its last complete walk (rows, cols, whether the shaper runs) and drops
it on every atlas recycle, including one an overlay's priming causes
between frames; a missing or different key walks every row, as does the
retry after a mid-walk recycle. A font, size or feature change rebuilds
the index and so starts from no key. The frame-wide paint inputs the row
cache also keys on (viewport, selection, pre-edit, chrome rows, reverse
video) change pixels, not glyphs, so they leave the walk incremental.

Clusters re-walked in place leave stale glyphs in the frame's shared
pool; the pool is rewritten once they outnumber the live ones two to
one (floor 4096), so it stays bounded without per-row pools, which
would have changed the offsets the painter and its tests use.

The walk moves into a GPU-free GridWalker so the property test can
drive it against a bare GlyphIndex: after random writes, clusters,
sized cells, scrolls, resizes, style sweeps and overlay priming, with
and without features and under atlases small enough to recycle, the
kept frame must equal a from-scratch walk and the atlas must already
hold every glyph a full walk primes.

Linux, NVIDIA, release, 212x93, interleaved with origin/main, three
runs each, load 6 to 19 (a shared host):

  one key every 50 ms   populate median 63/80/101 us -> 3.8/4.1/5.0 us
                        build+upload median 66-75 us on both
  seq scroll flood      every frame marks all 93 rows; populate
                        94-116 us on both, unchanged

so a keystroke frame's populate plus build+upload goes from about 150
to about 75 us.

Assisted-by: Claude Code
# Conflicts:
#	crates/felis-render-wgpu/src/row_cache.rs
render: force the walk harness's style sweep through TableGc::sweep
Some checks failed
darwin / publish felis (aarch64-darwin) (pull_request) Has been skipped
bench / Criterion full-suite snapshot (pull_request) Has been skipped
fuzz / cargo fuzz nightly long-run (pull_request) Has been skipped
darwin / detect relevant changes (pull_request) Successful in 13s
darwin / darwin docs-only no-op (pull_request) Has been skipped
darwin / build felis (aarch64-darwin) (pull_request) Successful in 53s
windows / windows docs-only no-op (pull_request) Has been skipped
windows / detect relevant changes (pull_request) Successful in 11s
windows / cargo nextest (Windows) (pull_request) Successful in 4m17s
bench / Criterion regression gate (pull_request) Failing after 2m11s
windows / frontend smoke (Windows) (pull_request) Successful in 3m17s
windows / package felis (x86_64-pc-windows-msvc) (pull_request) Has been skipped
fuzz / cargo fuzz smoke (per target) (pull_request) Successful in 1m32s
pr / nix flake check (pull_request) Successful in 49s
pr / cargo build / clippy / test / deny (pull_request) Failing after 1m4s
pr / wire schema is compatible with the base (pull_request) Successful in 13s
pr / frontend smoke (x86_64-linux) (pull_request) Successful in 1m53s
pr / publish felis (x86_64-linux) (pull_request) Has been skipped
windows / cargo clippy (Windows cross) (pull_request) Successful in 15s
a9f4f5c1e1
felis-grid now exposes a sweep that runs whatever the table size, so
the populate-walk property test drops its interning past the automatic
trigger point, which cost four thousand interns per op for the same
compaction.

Assisted-by: Claude Code
Merge remote-tracking branch 'origin/main' into render/incremental-populate
Some checks failed
darwin / build felis (aarch64-darwin) (pull_request) Successful in 53s
darwin / publish felis (aarch64-darwin) (pull_request) Has been skipped
windows / detect relevant changes (pull_request) Successful in 20s
windows / windows docs-only no-op (pull_request) Has been skipped
windows / frontend smoke (Windows) (pull_request) Has been skipped
windows / package felis (x86_64-pc-windows-msvc) (pull_request) Has been skipped
bench / Criterion regression gate (pull_request) Successful in 2m14s
fuzz / cargo fuzz smoke (per target) (pull_request) Successful in 1m8s
pr / frontend smoke (x86_64-linux) (pull_request) Successful in 1m22s
pr / nix flake check (pull_request) Successful in 27s
windows / cargo nextest (Windows) (pull_request) Failing after 4m1s
pr / cargo build / clippy / test / deny (pull_request) Successful in 1m44s
pr / wire schema is compatible with the base (pull_request) Successful in 12s
pr / publish felis (x86_64-linux) (pull_request) Has been skipped
windows / cargo clippy (Windows cross) (pull_request) Successful in 12s
bench / Criterion full-suite snapshot (pull_request) Has been skipped
fuzz / cargo fuzz nightly long-run (pull_request) Has been skipped
darwin / detect relevant changes (pull_request) Successful in 14s
darwin / darwin docs-only no-op (pull_request) Has been skipped
8c77c56bf7
natsukium deleted branch render/incremental-populate 2026-09-25 12:29:27 +09:00
Commenting is not possible because the repository is archived.
No description provided.