render: drop per-frame blits on unified-memory GPUs #396

Merged
natsukium merged 2 commits from render/uma-direct-write into main 2026-09-25 01:15:20 +09:00
Owner

On Apple Silicon the felis client's physical footprint sat at 424 MB for a focused window with a blinking cursor, and every throughput benchmark peaked at 430–460 MB (#382). Almost none of it is felis's own: MTLDevice.currentAllocatedSize stays near 21 MB. The rest is Apple's GPU driver charging per-encoder-type working sets to the process, about 240 MB once a render encoder runs and about 160 MB more once any blit or compute encoder does. Each is released about a second after that encoder type last ran. A bare Metal program with no wgpu reproduces both numbers.

felis woke the blit set on every repaint (Queue::write_buffer for instance data and uniforms) and on every new glyph or image (Queue::write_texture, plus wgpu's buffer-copy zero-init of the glyph atlas). On a unified-memory adapter (integrated GPU with MAPPABLE_PRIMARY_BUFFERS) this branch removes all of those blits:

  • Instance buffers and uniforms (viewport, post shader) are a ring of three MAP_WRITE buffers written in place. The slot being reused was last read three submissions back, so re-mapping it waits on that submission, not on the newest.
  • Atlas uploads (mono and color glyphs, Kitty images) are drawn into the atlas by a render pass at the head of the frame. Rows are repacked into a mapped storage buffer and a fragment shader fetches the texels. RGBA goes through a linear view of the sRGB atlas, so bytes land unconverted. The atlases gain RENDER_ATTACHMENT, which also moves wgpu's lazy zero-init to a render-pass clear.
  • Discrete GPUs keep the staged path unchanged, since a mappable buffer there lives in host memory the GPU reads across the bus.

Measured on an M4 (release, same config, 0.25 s sampling like the benchmark harness):

main this branch
focused, blinking, otherwise idle 424 MB 264 MB
peak during a sustained flood 455–460 MB 302–304 MB
peak with the trail post shader 472 MB 316 MB
150 MB cat wall-clock 0.46–0.50 s 0.46–0.50 s

Kept honest:

  • For about a second after launch the footprint still passes ~430 MB, because wgpu fills its own zero buffer with a blit when the device is created. Removing that needs a wgpu change.
  • The mapped path is exercised only on macOS/Metal here. Linux (Vulkan) and Windows (DX12) integrated GPUs select it too, but are not run yet.
  • docs/reference/benchmarks.md still shows the pre-change felis figures; republishing them is a separate run.

Verified:

  • Window pixels are identical to the staged path outside the title bar, with text, color emoji and a Kitty image on screen, both without and with the trail post shader.
  • just fmt-check lint check-portable prose-check skill-check docs-links deny pass, and felis-render-wgpu passes 274/274 tests.
  • The workspace run's only failures are in felis-cli and felis-daemon, which do not depend on felis-render-wgpu. One of them is the known cli_bridge hang.
  • The design and both commits passed an independent review.
On Apple Silicon the felis client's physical footprint sat at 424 MB for a focused window with a blinking cursor, and every throughput benchmark peaked at 430–460 MB (#382). Almost none of it is felis's own: `MTLDevice.currentAllocatedSize` stays near 21 MB. The rest is Apple's GPU driver charging per-encoder-type working sets to the process, about 240 MB once a render encoder runs and about 160 MB more once any blit or compute encoder does. Each is released about a second after that encoder type last ran. A bare Metal program with no wgpu reproduces both numbers. felis woke the blit set on every repaint (`Queue::write_buffer` for instance data and uniforms) and on every new glyph or image (`Queue::write_texture`, plus wgpu's buffer-copy zero-init of the glyph atlas). On a unified-memory adapter (integrated GPU with `MAPPABLE_PRIMARY_BUFFERS`) this branch removes all of those blits: - **Instance buffers and uniforms** (viewport, post shader) are a ring of three `MAP_WRITE` buffers written in place. The slot being reused was last read three submissions back, so re-mapping it waits on that submission, not on the newest. - **Atlas uploads** (mono and color glyphs, Kitty images) are drawn into the atlas by a render pass at the head of the frame. Rows are repacked into a mapped storage buffer and a fragment shader fetches the texels. RGBA goes through a linear view of the sRGB atlas, so bytes land unconverted. The atlases gain `RENDER_ATTACHMENT`, which also moves wgpu's lazy zero-init to a render-pass clear. - **Discrete GPUs** keep the staged path unchanged, since a mappable buffer there lives in host memory the GPU reads across the bus. Measured on an M4 (release, same config, 0.25 s sampling like the benchmark harness): | | main | this branch | |---|---|---| | focused, blinking, otherwise idle | 424 MB | 264 MB | | peak during a sustained flood | 455–460 MB | 302–304 MB | | peak with the trail post shader | 472 MB | 316 MB | | 150 MB `cat` wall-clock | 0.46–0.50 s | 0.46–0.50 s | Kept honest: - For about a second after launch the footprint still passes ~430 MB, because wgpu fills its own zero buffer with a blit when the device is created. Removing that needs a wgpu change. - The mapped path is exercised only on macOS/Metal here. Linux (Vulkan) and Windows (DX12) integrated GPUs select it too, but are not run yet. - `docs/reference/benchmarks.md` still shows the pre-change felis figures; republishing them is a separate run. Verified: - Window pixels are identical to the staged path outside the title bar, with text, color emoji and a Kitty image on screen, both without and with the trail post shader. - `just fmt-check lint check-portable prose-check skill-check docs-links deny` pass, and felis-render-wgpu passes 274/274 tests. - The workspace run's only failures are in felis-cli and felis-daemon, which do not depend on felis-render-wgpu. One of them is the known cli_bridge hang. - The design and both commits passed an independent review.
A focused felis window with a blinking cursor held ~424 MB of physical
footprint on an M4, and every throughput benchmark peaked at 430-460 MB.
None of it is felis's own resources (MTLDevice.currentAllocatedSize stays
~21 MB): it is Apple's GPU driver charging per-encoder-type working sets
to the process, ~240 MB once a render encoder runs and ~160 MB more once
any blit or compute encoder does, each released about a second after its
encoder type last ran. A bare Metal window reproduces both numbers.

Queue::write_buffer stages each upload and copies it with a blit, so
every repaint woke the blit pool, and a 0.5 s blink kept it resident. On
a unified-memory adapter the six instance buffers are now a ring of
three MAP_WRITE|VERTEX buffers (MAPPABLE_PRIMARY_BUFFERS) written through
their mapping; the slot being reused was last read three submissions
back, so re-mapping it waits on that submission, not the newest. The
viewport uniform is written only when it changes. Discrete GPUs keep the
staged path, where a mappable vertex buffer would sit in host memory.

Measured against main on the same M4 (release, same config):
- blinking, otherwise idle: 424 -> 264 MB
- sustained 10 s flood: 460 -> 295-300 MB
- 150 MB cat wall-clock: 0.46-0.49 s vs 0.46-0.47 s (unchanged)

Glyph and image atlas uploads still go through the queue, so the first
second of a workload that introduces new glyphs still peaks near 460 MB.

Assisted-by: Claude Code:Opus 5.5
render: upload atlases and uniforms without blits on unified-memory GPUs
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 2m25s
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 53s
darwin / publish felis (aarch64-darwin) (pull_request) Has been skipped
fuzz / cargo fuzz smoke (per target) (pull_request) Successful in 1m4s
pr / nix flake check (pull_request) Successful in 35s
pr / cargo build / clippy / test / deny (pull_request) Successful in 1m40s
pr / wire schema is compatible with the base (pull_request) Successful in 12s
pr / frontend smoke (x86_64-linux) (pull_request) Successful in 1m21s
pr / publish felis (x86_64-linux) (pull_request) Has been skipped
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 11s
windows / cargo nextest (Windows) (pull_request) Successful in 3m25s
windows / frontend smoke (Windows) (pull_request) Successful in 1m23s
windows / package felis (x86_64-pc-windows-msvc) (pull_request) Has been skipped
7220a54eb3
The mapped instance ring cut the steady state, but every workload that
met new glyphs still peaked near 460 MB: glyph and image uploads went
through Queue::write_texture, wgpu cleared the glyph atlas on first use
with buffer-to-texture copies from its zero buffer, and the viewport and
post-shader uniforms used Queue::write_buffer. Each is a blit, and one
blit is enough for Apple's driver to charge its ~160 MB blit working set
to the process for the next second.

In mapped mode the atlas writes are now recorded and drawn into the atlas
at the head of the frame's encoder: rows are repacked to a 4-byte stride
into a mapped-at-creation storage buffer, and one instanced quad per
write fetches its texels in the fragment shader (R8 through the atlas's
own format, RGBA through a linear view of the sRGB atlas, so bytes land
unconverted). The atlases gain RENDER_ATTACHMENT, which also moves
wgpu's lazy zero-init to a render-pass clear. Staging is split into
batches that fit one storage binding, splitting a write by rows rather
than falling back to a copy that would run ahead of earlier deferred
writes. The uniform blocks share the instance buffers' ring.

Measured against the previous commit on the same M4 (release, same
config, 0.25 s sampling like the benchmark harness):
- peak during a sustained flood: 455-460 -> 302-304 MB
- with the trail post shader: 472 -> 316 MB peak, 440 -> 280 MB idle
- 150 MB cat wall-clock unchanged (0.46-0.50 s both)
- window pixels identical outside the title bar, with text, color
  emoji and a Kitty image on screen

Startup still passes ~430 MB for about a second: wgpu fills its own zero
buffer with a blit when the device is created.

Assisted-by: Claude Code:Opus 5.5
natsukium scheduled this pull request to auto merge when all checks succeed 2026-09-25 01:04:16 +09:00
natsukium deleted branch render/uma-direct-write 2026-09-25 01:15:20 +09:00
Commenting is not possible because the repository is archived.
No description provided.