Skip to content

fast: Keep the path shapes in a cache capped by weight - #88

Open
dtateks wants to merge 2 commits into
longbridge:mainfrom
dtateks:fast-path-cache-byte-bound
Open

dtateks wants to merge 2 commits into
longbridge:mainfrom
dtateks:fast-path-cache-byte-bound

Conversation

@dtateks

@dtateks dtateks commented Oct 26, 2026

Copy link
Copy Markdown

Problem

fast/path_cache.rs keeps tessellated paths in two generations bounded only by count (2048 shapes). A window-sized path whose shape changes every frame never hits, but each build is still kept until all 2048 entries are full. In the new paths-moving-holes scenario (a 1440×900 fill with 200 moving holes), each path weighs about 140 KiB, and the process holds 289.8 MiB more after the scenario than before it, nearly all of it kept shapes.

Change

  • The kept shapes are a least-recently-drawn list (slots linked by index plus an index map, no new dependency), capped at 2048 shapes and 16 MiB of weight per thread. A hit moves the shape to the front. Adding a shape lets go of the least recently drawn shapes until it fits.
  • Weight is what a shape's key and triangles own.
  • A shape heavier than 16 MiB is kept in one slot beside the list until the next such shape replaces it. So one huge shape redrawn every frame still hits, and it does not push out the rest.
  • Tessellation is untouched, so drawn output is identical.

Why a list, not generations capped by bytes

Adding a byte cap to the two generations breaks reuse. A hit in the older generation moves the shape into the newer one, which can fill it and drop the rest of the older one. A set of shapes redrawn every frame that weighs between one and two generations would then miss every frame, where main hits. The list keeps the 2048 most recently drawn distinct shapes. That includes every shape main's generations keep, so apart from the case below, only the weight cap lets a shape go earlier than main does. every_shape_two_generations_keep_is_kept checks this against a model of main's generations.

Costs

  • About 1% on frames that only reuse paths (paths-still-holes, 401 hits a frame), inside the run-to-run spread. A hit now relinks its shape.
  • Two shapes over 16 MiB drawn in turn replace each other in the slot and are tessellated on every draw, where main kept both. two_shapes_over_the_limit_drawn_in_turn_are_tessellated_every_time pins this.

Measurement

gpui_perf --headless --retention off, release build, base (main's cache logic) and branch runs alternated:

scenario main this PR
paths-moving-holes, 3000 frames live heap growth 289.8 MiB 18.9 MiB
cache held at end 286.5 MiB, 2037 shapes 16.0 MiB, 118 shapes
CPU per frame 0.176–0.181 ms 0.168–0.169 ms
paths-still-holes, 2000 frames, 4 runs CPU per frame 0.530–0.542 ms (mean 0.536) 0.516–0.560 ms (mean 0.542)
instructions per frame 9.73–9.79 M 9.76–9.87 M

Across the 11 workspace-* scenarios, hits are identical in 10, and misses are never above main's.

Tests

cargo test -p gpui --lib --features test-support path_cache

  • Bounds: unique_shapes_never_weigh_more_than_the_limit; unique_shapes_never_outnumber_the_count; unique_shapes_over_the_limit_are_held_one_at_a_time; unique_window_sized_paths_are_kept_within_a_bounded_number_of_bytes.
  • Reuse:
    • a_set_drawn_every_frame_between_half_the_limit_and_the_limit_is_kept
    • a_set_drawn_every_frame_is_kept_among_unique_shapes
    • a_shape_drawn_again_stays_kept_among_unique_large_ones
    • a_set_of_window_sized_paths_between_half_and_all_the_bytes_is_drawn_from_kept_ones
    • every_shape_two_generations_keep_is_kept
  • Over-limit slot: a_shape_over_the_limit_is_kept_alone_while_it_is_drawn_again; a_frame_with_a_shape_over_the_limit_keeps_the_set_drawn_every_frame; two_shapes_over_the_limit_drawn_in_turn_are_tessellated_every_time.
  • Output and integrity: shapes_built_again_after_being_let_go_are_tessellated_again_the_same; a random sequence that checks the links, index and weights stay consistent.

Reproduce

cargo run -p gpui_perf --release -- --headless --scenario paths-moving-holes --retention off --frames 3000 --warmup 60

The path … rows and the live heap growth row report the cache.

Scope

Outside fast/, the PR touches four lines in gpui.rs that re-export PathCacheStats and path_cache_stats behind test-support, one paragraph in docs/architecture.md, and the new gpui_perf scenarios and rows. The path cache exists only in gpui-fast, so this is a memory-growth fix to a gpui-fast feature, not a GPUI upstream matter.

Tessellated paths are kept least recently drawn last, up to 2048 shapes and 16 MiB of weight (the memory their keys and triangles own) per thread. A shape heavier than 16 MiB is kept in a slot of its own beside the rest until the next such shape takes it: one drawn every frame lets go of none of the rest, but two drawn in turn are each tessellated every time. Tessellation is untouched and drawn output identical.

This replaces two generations of 1024 shapes bounded by count alone, which kept a window-sized path in a new shape every frame, never drawn again, in all 2048 entries and held hundreds of megabytes. Generations capped by bytes were tried and left: drawing a reused set again moved its shapes from the older generation into the newer, which could turn over and drop the rest of the set, and a shape over the cap did the same. The list keeps the 2048 most recent distinct shapes, so every shape the generations kept stays kept except as the weight cap forces and in the slot case above.

Also counts hits, misses, evictions and the weight tessellated and kept per thread, behind test-support as PathCacheStats, so tests and gpui_perf can observe what the cache does.
Adds paths-moving-holes (a window-sized fill with 200 holes in a new shape every frame) and paths-still-holes (the same fill and 400 sparklines, drawn unchanged every frame), and reports per scenario the live heap growth and the kept path shapes' hits, misses, evictions, weight per tessellated path and weight held at the end.

The heap row counts bytes live at the scenario's end less those live before it was built, so earlier scenarios in a run do not show in it.
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