render: glyph populate walk and scroll still cost the whole grid per frame #422

Closed
opened 2026-09-25 07:49:43 +09:00 by natsukium · 1 comment
Owner

Split from #403.

#403's change (branch render/incremental-instances) rebuilds and uploads the cell instances of only the rows the client's shadow marked. Two whole-grid costs are left on the per-frame path:

  • The glyph populate walk still reads every cell. Renderer::populate_glyphs (GlyphIndex::populate, or populate_grid_with_features when features or clusters are live) walks and, with features, shapes the whole grid each frame to prime the atlas and build the ShapeFrame. In the #403 profile (188x93, one key per 50 ms) it was about 6% of the main thread. Walking only the marked rows needs the ShapeFrame to keep the unmarked rows' entries across frames.
  • A scroll rebuilds every row. Instances carry absolute pixel positions, so a scroll directive marks the whole region and every row is rebuilt and uploaded. Under a scroll flood at 212x93 (NVIDIA, Linux) the frame's build and upload cost 628/647 us against 601/604 us before #403: the per-row copy into the slot and the slot padding. Positioning rows through a per-row offset (a small storage buffer the vertex shader indexes by slot) would make a scroll a rotation of offsets plus the rotated-in rows, which is the Alacritty / WezTerm row-rotation model applied to the instance buffers.

Done when both are measured before and after on a keystroke and on a scroll flood, and each either lands or is shown not to pay.

Split from #403. #403's change (branch `render/incremental-instances`) rebuilds and uploads the cell instances of only the rows the client's shadow marked. Two whole-grid costs are left on the per-frame path: - **The glyph populate walk still reads every cell.** `Renderer::populate_glyphs` (`GlyphIndex::populate`, or `populate_grid_with_features` when features or clusters are live) walks and, with features, shapes the whole grid each frame to prime the atlas and build the `ShapeFrame`. In the #403 profile (188x93, one key per 50 ms) it was about 6% of the main thread. Walking only the marked rows needs the `ShapeFrame` to keep the unmarked rows' entries across frames. - **A scroll rebuilds every row.** Instances carry absolute pixel positions, so a scroll directive marks the whole region and every row is rebuilt and uploaded. Under a scroll flood at 212x93 (NVIDIA, Linux) the frame's build and upload cost 628/647 us against 601/604 us before #403: the per-row copy into the slot and the slot padding. Positioning rows through a per-row offset (a small storage buffer the vertex shader indexes by slot) would make a scroll a rotation of offsets plus the rotated-in rows, which is the Alacritty / WezTerm row-rotation model applied to the instance buffers. Done when both are measured before and after on a keystroke and on a scroll flood, and each either lands or is shown not to pay.
Author
Owner

The populate walk half is #431. The scroll half (rotating per-row offsets so a scroll rebuilds only the rows it brings in) does not pay today, so I did not build it. It can only act on a Scrolled directive the client receives, and in every workload I measured the client got close to none. The rows arrive as RowDeltas, and each one marks its row, so no row is left to rotate.

Linux, NVIDIA, release, 212x93, origin/main (43ac9b14) instrumented with the frame cost, the count of shadow rows marked per frame, and a log line per Scrolled the shadow applies:

workload frames Scrolled received rows marked per frame (median) build+upload median
seq -w flood, 6 runs 366-480 each 0, 0, 2, 3, 14, 19 93 577-1671 us
a line every 33 ms (printf "\n..."), 3 runs 193-209 0 93 1207-2334 us
less, j every 30 ms 143 0 92 630 us
vim -u NONE, ^E every 30 ms, 2 runs 183-224 0 92 427-638 us

The daemon has three reasons for this:

  • A flood or a streaming printer prints into the region before the tick's line feed. scroll_region_up then finds the region dirty and ships the whole region as rows. That is the documented choice in editing.rs, which says a directive on top would double the per-frame cost.
  • less rewrites its prompt on the bottom row before it scrolls, so its region is dirty for the same reason.
  • vim scrolls with DECSTBM plus DL (ESC[1;28r ESC[H ESC[M ESC[r, captured from a pty). DL is not routed through scroll_region_up, so it never produces a directive.

Rotation on the client would pay only after the daemon changes. DL/IL at the top margin would have to ship as Scrolled, and a mixed tick would have to ship a directive plus the rows it touched. The second change reopens the doubled-cost trade-off in editing.rs. If either lands, the client design from the issue still applies: per-slot y offsets in a uniform, the shadow rotating its damage bits instead of marking the region, and the row cache and ShapeFrame rotating slots. The uniform is needed because storage buffers are not guaranteed in vertex shaders on downlevel adapters. The measurement above shows what it would save on those frames: roughly the whole 430-2300 us build+upload, against one or two rebuilt rows.

The populate walk half is #431. The scroll half (rotating per-row offsets so a scroll rebuilds only the rows it brings in) does not pay today, so I did not build it. It can only act on a `Scrolled` directive the client receives, and in every workload I measured the client got close to none. The rows arrive as `RowDelta`s, and each one marks its row, so no row is left to rotate. Linux, NVIDIA, release, 212x93, origin/main (43ac9b14) instrumented with the frame cost, the count of shadow rows marked per frame, and a log line per `Scrolled` the shadow applies: | workload | frames | `Scrolled` received | rows marked per frame (median) | build+upload median | |---|---|---|---|---| | `seq -w` flood, 6 runs | 366-480 each | 0, 0, 2, 3, 14, 19 | 93 | 577-1671 us | | a line every 33 ms (`printf "\n..."`), 3 runs | 193-209 | 0 | 93 | 1207-2334 us | | `less`, `j` every 30 ms | 143 | 0 | 92 | 630 us | | `vim -u NONE`, `^E` every 30 ms, 2 runs | 183-224 | 0 | 92 | 427-638 us | The daemon has three reasons for this: - A flood or a streaming printer prints into the region before the tick's line feed. `scroll_region_up` then finds the region dirty and ships the whole region as rows. That is the documented choice in `editing.rs`, which says a directive on top would double the per-frame cost. - `less` rewrites its prompt on the bottom row before it scrolls, so its region is dirty for the same reason. - vim scrolls with DECSTBM plus DL (`ESC[1;28r ESC[H ESC[M ESC[r`, captured from a pty). DL is not routed through `scroll_region_up`, so it never produces a directive. Rotation on the client would pay only after the daemon changes. DL/IL at the top margin would have to ship as `Scrolled`, and a mixed tick would have to ship a directive plus the rows it touched. The second change reopens the doubled-cost trade-off in `editing.rs`. If either lands, the client design from the issue still applies: per-slot y offsets in a uniform, the shadow rotating its damage bits instead of marking the region, and the row cache and `ShapeFrame` rotating slots. The uniform is needed because storage buffers are not guaranteed in vertex shaders on downlevel adapters. The measurement above shows what it would save on those frames: roughly the whole 430-2300 us build+upload, against one or two rebuilt rows.
Commenting is not possible because the repository is archived.
No description provided.