render: repaint and upload only the rows a frame changed #424

Merged
natsukium merged 6 commits from render/incremental-instances into main 2026-09-25 10:31:45 +09:00
Owner

Closes #403

Per keystroke the client rebuilt every cell instance and re-uploaded the whole bg/fg/deco instance buffers, so one echoed character cost a full-grid walk plus a full-buffer copy.

The renderer now keeps cell instances across frames in fixed-stride row slots (crates/felis-render-wgpu/src/row_cache.rs), repaints only the rows the client's shadow ScreenBuffer marked dirty plus the cursor's old/new rows, and uploads only the changed byte ranges:

  • staged path (discrete GPU): Queue::write_buffer per changed run;
  • unified-memory mapped ring (#396): ranged mapped views into the ring slot, no blit. Each ring slot tracks the (epoch, version) it last took.

Every input that reaches all rows falls back to a full repaint (geometry, cell metrics, viewport, selection, chrome rows, pre-edit, DECSCNM, shape frame, glyph atlas recycle, theme/palette, font reload, atlas thrash, slot overflow). If padded slots would exceed half the device buffer limit, rows stay packed and every frame repaints them all, the same as before. The property test incremental_frames_match_a_full_rebuild drives random shadow writes, scrolls, resizes, cursor/paint-state changes, sized cells, and both padded and packed limits, and after every frame it compares the result with a from-scratch walk. ring_slots_track_the_source_through_ranged_writes covers the ring.

Measurements

Linux, niri 60 Hz, NVIDIA (adapter confirmed), release builds, interleaved with base a53cd63e, wl-latency harness, 154x61:

run frame build+upload CPU per keystroke, median (p95) wl-latency p50 / mean / p95 ms load start/end
base 1 272 (391) us 5.09 / 5.29 / 6.78 6.39 / 5.42
this 1 51 (106) us 5.11 / 5.21 / 6.56 4.59 / 1.98
base 2 387 (1156) us 7.17 / 7.50 / 11.63 5.09 / 8.17
this 2 52 (141) us 5.10 / 5.28 / 6.84 6.29 / 2.49
base 3 372 (548) us 6.01 / 6.39 / 8.49 2.07 / 3.83
this 3 51 (82) us 5.12 / 5.18 / 6.54 3.23 / 3.09

(foot control ~2.2 ms p50.) End-to-end p50 does not move because vsync and compositor time dominate it. A 212x93 scroll flood, where every row repaints, costs ~5% more per frame (601/604 -> 628/647 us). That cost and the whole-grid glyph populate walk are split into #422.

macOS (M4 Max): the felis-render-wgpu suite passes with the mapped upload mode. A GUI check could not run because the screen was locked.

Closes #403 Per keystroke the client rebuilt every cell instance and re-uploaded the whole bg/fg/deco instance buffers, so one echoed character cost a full-grid walk plus a full-buffer copy. The renderer now keeps cell instances across frames in fixed-stride row slots (`crates/felis-render-wgpu/src/row_cache.rs`), repaints only the rows the client's shadow `ScreenBuffer` marked dirty plus the cursor's old/new rows, and uploads only the changed byte ranges: - staged path (discrete GPU): `Queue::write_buffer` per changed run; - unified-memory mapped ring (#396): ranged mapped views into the ring slot, no blit. Each ring slot tracks the `(epoch, version)` it last took. Every input that reaches all rows falls back to a full repaint (geometry, cell metrics, viewport, selection, chrome rows, pre-edit, DECSCNM, shape frame, glyph atlas recycle, theme/palette, font reload, atlas thrash, slot overflow). If padded slots would exceed half the device buffer limit, rows stay packed and every frame repaints them all, the same as before. The property test `incremental_frames_match_a_full_rebuild` drives random shadow writes, scrolls, resizes, cursor/paint-state changes, sized cells, and both padded and packed limits, and after every frame it compares the result with a from-scratch walk. `ring_slots_track_the_source_through_ranged_writes` covers the ring. ## Measurements Linux, niri 60 Hz, NVIDIA (adapter confirmed), release builds, interleaved with base a53cd63e, wl-latency harness, 154x61: | run | frame build+upload CPU per keystroke, median (p95) | wl-latency p50 / mean / p95 ms | load start/end | |---|---|---|---| | base 1 | 272 (391) us | 5.09 / 5.29 / 6.78 | 6.39 / 5.42 | | this 1 | 51 (106) us | 5.11 / 5.21 / 6.56 | 4.59 / 1.98 | | base 2 | 387 (1156) us | 7.17 / 7.50 / 11.63 | 5.09 / 8.17 | | this 2 | 52 (141) us | 5.10 / 5.28 / 6.84 | 6.29 / 2.49 | | base 3 | 372 (548) us | 6.01 / 6.39 / 8.49 | 2.07 / 3.83 | | this 3 | 51 (82) us | 5.12 / 5.18 / 6.54 | 3.23 / 3.09 | (foot control ~2.2 ms p50.) End-to-end p50 does not move because vsync and compositor time dominate it. A 212x93 scroll flood, where every row repaints, costs ~5% more per frame (601/604 -> 628/647 us). That cost and the whole-grid glyph populate walk are split into #422. macOS (M4 Max): the felis-render-wgpu suite passes with the mapped upload mode. A GUI check could not run because the screen was locked.
Every keystroke rebuilt the instances of every cell and re-uploaded
the whole instance buffer, so one echoed character cost a full-grid
walk plus a full-buffer copy, scaling with window area rather than
with the change.

The client's shadow already marks the rows each apply writes (it shares
the daemon's ScreenBuffer), and nothing read those marks. The renderer
now keeps its cell instances across frames in fixed-stride row slots,
repaints the rows the shadow marked plus the rows the cursor left and
entered, and the client clears the marks after each paint. Unused slot
entries are zero-sized quads, which rasterize nothing, so the draw
calls and passes are unchanged. Packed rows were rejected: an echoed
glyph changes its row's instance count, which would shift and
re-upload every later row. Every input that reaches all rows repaints
them all: geometry, cell metrics, viewport, selection, chrome rows,
pre-edit span, DECSCNM, the shape frame being built, a glyph atlas
recycle (a new counter), a theme or palette change and a font reload
(explicit invalidation), a frame that ran out of atlas, and a row
outgrowing its slot (which doubles that slot, so a line being typed
does not relayout every few keys). A property test drives random
shadow writes, scrolls, resizes, cursor moves and paint-state changes
and requires the cached instances to equal a from-scratch walk after
every frame.

Uploads follow the rows: the staged path writes each changed run with
`Queue::write_buffer` at its offset, and the unified-memory ring writes
the changed runs into its mapped slot, so no blit returns on that path.
Each ring slot remembers the version it last took, since it misses the
frames written into the other two slots.

Measured on Linux (niri 60 Hz, NVIDIA, release builds, interleaved
with the base a53cd63e, wl-latency harness, 154x61 grid), CPU time of
the frame's instance build and upload per keystroke frame, median
(p95):

  base: 272 (391), 387 (1156), 372 (548) us  load 6.4/5.1/2.1
  this:  51 (106),  52 (141),  51 (82)  us   load 4.6/6.3/3.2

wl-latency p50 stays at 5.1 ms (base 5.09/7.17/6.01, this
5.11/5.10/5.12; the slower base runs had the higher load): the saved
0.2-0.3 ms sits below the vsync and compositor time that dominates the
figure. A scroll flood at 212x93, where every frame repaints every row,
costs 628/647 us against the base's 601/604 us per frame, the price of
the per-row copy and the slot padding.

Assisted-by: Claude Code
A slot is as wide as the widest row, so one wide row pads every other
row to its width, and the first layout also doubled every slot. At the
largest admitted grid (2048x1025) filled with glyphs the foreground
layer then needed more than wgpu's default 256 MiB buffer limit, which
the packed layout stays under, and the buffer allocation fails.

The first layout now sizes slots at the widest row plus an eighth plus
eight, and only a slot a row outgrew doubles. When a padded layer
would exceed half the device's buffer limit (the ring rounds capacity
up to a power of two), or four times its packed size past a 16 MiB
allowance, the frame keeps the rows packed and repaints them all, as
every frame did before, until the rows even out. The property test now
runs under a limit that forces packed rows as well as one that does
not, and adds OSC 66 sized cells.

Assisted-by: Claude Code
The rendering pages said a frame always uploads only the rows it
rebuilt, but when padded row slots would not fit the buffer limit the
cell layers stay packed and every frame rebuilds and uploads all rows,
so the claim held only for the slotted layout. The testing reference
also claimed the row-cache property test covers every frame-wide paint
input, while it never builds a non-empty shape frame (that needs a real
font stack and shaper); it does cover both slotted and packed rows.

Assisted-by: Claude Code
Merge remote-tracking branch 'origin/main' into render/incremental-instances
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) Has been cancelled
darwin / build felis (aarch64-darwin) (pull_request) Has been cancelled
darwin / publish felis (aarch64-darwin) (pull_request) Has been cancelled
darwin / darwin docs-only no-op (pull_request) Has been cancelled
fuzz / cargo fuzz smoke (per target) (pull_request) Has been cancelled
pr / nix flake check (pull_request) Has been cancelled
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
bench / Criterion regression gate (pull_request) Has been cancelled
7202896267
Merge remote-tracking branch 'origin/main' into render/incremental-instances
Some checks failed
bench / Criterion full-suite snapshot (pull_request) Has been skipped
fuzz / cargo fuzz nightly long-run (pull_request) Has been skipped
bench / Criterion regression gate (pull_request) Failing after 3m56s
darwin / detect relevant changes (pull_request) Successful in 4s
darwin / darwin docs-only no-op (pull_request) Has been skipped
darwin / build felis (aarch64-darwin) (pull_request) Successful in 54s
darwin / publish felis (aarch64-darwin) (pull_request) Has been skipped
fuzz / cargo fuzz smoke (per target) (pull_request) Failing after 1m4s
pr / nix flake check (pull_request) Successful in 38s
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
pr / cargo build / clippy / test / deny (pull_request) Failing after 31s
pr / wire schema is compatible with the base (pull_request) Successful in 16s
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
pr / frontend smoke (x86_64-linux) (pull_request) Has been cancelled
b355f4e3e8
# Conflicts:
#	CHANGELOG.md
Merge remote-tracking branch 'origin/main' into render/incremental-instances
All checks were successful
bench / Criterion full-suite snapshot (pull_request) Has been skipped
fuzz / cargo fuzz nightly long-run (pull_request) Has been skipped
bench / Criterion regression gate (pull_request) Successful in 2m18s
darwin / detect relevant changes (pull_request) Successful in 4s
darwin / darwin docs-only no-op (pull_request) Has been skipped
pr / nix flake check (pull_request) Successful in 34s
pr / frontend smoke (x86_64-linux) (pull_request) Successful in 1m21s
pr / publish felis (x86_64-linux) (pull_request) Has been skipped
darwin / build felis (aarch64-darwin) (pull_request) Successful in 54s
darwin / publish felis (aarch64-darwin) (pull_request) Has been skipped
fuzz / cargo fuzz smoke (per target) (pull_request) Successful in 1m4s
pr / cargo build / clippy / test / deny (pull_request) Successful in 1m42s
pr / wire schema is compatible with the base (pull_request) Successful in 12s
windows / detect relevant changes (pull_request) Successful in 4s
windows / windows docs-only no-op (pull_request) Has been skipped
windows / cargo clippy (Windows cross) (pull_request) Successful in 12s
windows / cargo nextest (Windows) (pull_request) Successful in 7m38s
windows / frontend smoke (Windows) (pull_request) Successful in 2m9s
windows / package felis (x86_64-pc-windows-msvc) (pull_request) Has been skipped
a5ce909f48
natsukium deleted branch render/incremental-instances 2026-09-25 10:31:46 +09:00
Commenting is not possible because the repository is archived.
No description provided.