Praxis · M3 · ticket #190 · PR #220

Why the Instance buffer stopped growing

A field guide to the Instance buffer: how every Renderable keeps a slot on the GPU, why that buffer is now created once at 524,288 slots, what happens to Renderable 524,289, and how a frame's bytes reach the GPU. It also works as a map of the frame, because the path from the ECS World to a draw call runs through every part of this PR.

Provenance. PR #220 merged on 2026-10-19 as cd8c611, closing #190; the guide describes the code at that commit. The fixed capacity and the refusal rules still hold on main at 270c5e4 (2026-10-26), but the frame around them has changed. PR #223 (b172252) moved the Instance buffer to bind group 2. PR #228 (67bf3c2) put each Instance's Mesh in its 4 bytes of padding, and a freed slot now takes an Instance with no Mesh, which the GPU skips, and is handed out again only from the next frame. PR #230 (926fcc1) took the draws out of Frame data: a compute pass builds the frame's draw, the main pass issues it with draw_indexed_indirect, and the vertex shader finds the slot through a Meshlet record, not instance_index; Instance Culling Explained follows that frame. Byte counts are cd8c611's too: at 270c5e4, a pbr-sweep frame that writes no Instance uploads 384 bytes, not 288.

Chapter 1

The frame, end to end

Praxis renders by pulling. Each frame, State::update runs the fixed-rate simulation, then hands the ECS World to FramePreparer::prepare. Prepare reads the camera, the lights and every Renderable, and fills an owned value called Frame data. The Renderer never reads the World. It copies Frame data into GPU buffers, records the draws and submits them (ADR 0004).

The Instance buffer sits in the middle of that path. Prepare decides which slot each Renderable owns and which slots need new bytes. Upload copies those bytes in. Every draw names a slot, and the vertex shader reads it. PR #220 changed the slot table, the buffer, the staging that feeds it, and Frame stats.

CPU · MAIN THREAD GPU ECS World Transform Mesh Handle Material Handle src/state.rs prepare FramePreparer slot table change detection render/prepare.rs Frame data camera, light Instance writes draws render/prepare.rs upload Renderer FrameStaging 3 slots, bounded render/staging.rs Frame stats renderables_refused GPU buffers camera 96 B light 192 B Instances 64 MiB render/renderer.rs Render pass bind groups 0 to 3 one draw_indexed per Primitive render/renderer.rs Vertex shader reads its slot: instances[i] i = instance_index shaders/main.wgsl changed in PR #220 reads fills bytes GPU copy draws bound runs
One frame. The top row runs on the CPU's main thread, the bottom row on the GPU. Blue outlines mark what PR #220 changed; Frame data also lost instance_slot_count, which only the growth path read. Boxes name the file that owns them, under src/.

Where to look in the code

FileWhat lives there
render/prepare.rsFramePreparer, the slot table (InstanceSlots), Frame data, and the tests for both
render/instance.rsINSTANCE_CAPACITY, the 128-byte InstanceData, the anchor and offset split
render/renderer.rsRenderer::new creates the buffers; upload and render run every frame
render/staging.rsFrameStaging for per-frame uploads, LoadStaging for loads
render/shaders/main.wgslthe main pass's shaders, including instances[instance_index]
stats/mod.rsFrameStats, its counts line, and how GPU memory is added up
state.rsState::update: Ticks, systems, prepare, upload, in that order
Chapter 2

Instances and their slots

A Renderable is an entity with a Transform and a Mesh Handle, and optionally a Material Handle that overrides its Mesh's Materials. Its Instance is its data on the GPU: 128 bytes in one slot of the Instance buffer.

04896108112124128 xyz xyz i32 ×3u32f32 ×3 rotation_scale · mat3 · 48 B normal_matrix · mat3 · 48 B anchor · 12 B offset · 12 B material_id · 4 B pad · 4 B
What one slot holds (InstanceData). WGSL lays out each vec3 column of a mat3x3 in 16 bytes, so each matrix carries 12 bytes of padding (hatched), and the struct is padded to a multiple of 16. The two tinted fields are the position, split into an integer anchor and a float offset.

The position is split into an integer anchor, a cell of a 64 m grid, and an f32 offset from that cell's corner (ADR 0014). The camera's position travels separately, in the camera uniform, and the shader subtracts the two. A camera that moves therefore changes no Instance, and a frame where only the camera moves writes no Instances at all.

Slots persist

A slot belongs to its Renderable (ADR 0003) until prepare stops finding it: the entity is despawned, or loses its Mesh Handle or its WorldTransform, the component prepare reads in place of the Transform. Removing only the Transform leaves the WorldTransform that came with it, so the slot stays. Each frame, prepare writes only the slots whose contents changed. That means Renderables that are new, whose World transform or Material changed, or that move between simulation Ticks and are blended every frame (ADR 0022). Every other slot keeps what an earlier frame wrote.

Prepare finds those changes with bevy_ecs change detection. Every component remembers the tick it last changed. A Ref<T> in a query answers is_changed(), and RemovedComponents lists entities that lost a component. bevy_ecs used on its own, outside the full Bevy engine, has to close each change window itself with World::clear_trackers(). Prepare does that as its last step, which is why it must be the frame's last read of the World.

The free list

When a Renderable goes away, prepare pushes its slot onto a free list. A new Renderable takes the most recently freed slot first. It takes a fresh slot, one past the highest ever used, only when the free list is empty. Slot numbers stay dense, and a Renderable's slot never moves while it lives.

Chapter 3

Why stop growing

Before this PR the Instance buffer started with 16 slots (2 KiB) and doubled whenever a frame needed a slot past its end. A GPU buffer cannot grow in place, so growing meant replacing it: create a bigger buffer, copy the old one into it on the GPU, and rebuild every bind group that named the old one. A bind group is a snapshot of specific resources. Replace a buffer and every bind group made from it still points at the old one, which is why the CLAUDE.md review checklist has a line for it.

Before: double when full 16 slots · 2 KiB GPU copy 32 slots · 4 KiB bind group 3 stale rebuilt Both buffers exist while the copy runs, and the copy needs the old buffer to allow COPY_SRC. After: created once, refuses when full 524,288 slots · 64 MiB · made in Renderer::new bind group 3 never rebuilt Renderable 524,289 refused Warned about once, counted every frame, retried every frame until a slot frees.
The difference. Growth replaced the buffer and everything that pointed at it, mid-frame. The fixed buffer is made once, and the only new behaviour is at the edge: a Renderable that finds no slot.

Now Renderer::new creates the buffer once, with 524,288 slots: 64 MiB at 128 bytes each. The growth function, its copy, its bind-group rebuild and the COPY_SRC usage the copy needed are gone.

What a fixed size buys

  • Memory is known at startup. ADR 0013 gives the GPU an 8 GB Budget. Milestone M4 splits it into a Budget per kind of memory and replaces every provisional capacity, this one included.
  • No frame pays for a resize. Growth allocated, copied and rebuilt in the middle of a frame, with both buffers alive during the copy.
  • Bind groups can be built once. The next groundwork ticket (#213) builds the bind group that holds the Instance buffer once, at startup, because every buffer in it has a fixed capacity.
  • A full buffer is visible. Running out is refused, warned about and counted, instead of quietly costing more memory.

The price is 64 MiB in every scene, even a scene with one helmet. That is 0.84% of the 8 GB Budget. M3's dense town scene aims at about 250,000 Instances, under half the capacity. (PRs #273 and #275 have since built it, with 254,256 Instances; Town Explained covers it.) 64 MiB also fits inside the 128 MiB that wgpu's default limits allow one storage-buffer binding, and the Capability floor keeps that default (ADR 0021), so no limit had to be raised.

Chapter 4

Renderable 524,289

When every slot is taken and a new Renderable arrives, prepare refuses it. The ticket and the owner's follow-up comment set four rules:

  • No slot, so no Instance is written and nothing draws it. The other 524,288 render as before.
  • Warned about once, in the first frame it is refused: WARN Renderable refused: the Instance buffer is full entity=… capacity=524288.
  • Counted every frame, in a new Frame stat: renderables_refused.
  • Retried every frame, so it takes the next slot another Renderable leaves.

The demo below runs the same rules on a buffer of 12 slots, with the same data structures as the Rust code. Entities appear in ECS table order, which is the order prepare visits them. Click an entity to despawn it or to make it start moving, then step a frame.

Still entities · table A, visited first
Moving entities · table B, carry PreviousWorldTransform, written every frame
Instance buffer · 12 slots (the real one has 524,288)
written this frame kept from an earlier frame on the free list never used refused entity moving entity
free list, next out first: empty
Clicking an entity:
After lost uploads:
    Live demo. It opens two frames in: 12 Renderables fill the buffer, and E13 and E14 arrive to find it full. Step frames and watch them stay refused without a second warning. Despawn one with a slot and a refused one takes it on the next frame. Replay the review bug plays the sequence below under whichever recovery is selected.

    The bug the review found

    If a frame's uploads fail, because a staging slot failed to map, the GPU never receives that frame's Instance writes. The recovery, rewrite_all_instances, makes the next prepare write every Instance again. The first draft of this PR did that by resetting the slot table, as the code before it had done.

    Resetting was harmless while the buffer could grow: every Renderable got a slot again, in whatever order. At a fixed capacity it is wrong, because prepare hands out slots in table order, and table order changes. bevy removes an entity from a table by moving the table's last entity into its row. An entity that gains a component, as a moving one gains PreviousWorldTransform, moves to another table that is visited later. After a reset, a refused Renderable that now sits earlier in table order takes a slot, a Renderable that held one is refused and vanishes, and the warnings fire again.

    The fix keeps the table and sets a flag, so the next prepare writes every Renderable into the slot it already owns. The test caught the first draft with left: 2, right: 1: two warnings where one was allowed.

    Chapter 5

    Getting bytes to the GPU

    The CPU writes Instances into memory it can map, and the GPU then copies them into the Instance buffer. That in-between memory is staging. wgpu's Queue::write_buffer provides it for you, but its own documentation says that on native platforms "the staging memory will be a new allocation", released after the next submission. It suggests a StagingBelt, or "buffers you explicitly create, map, and unmap yourself", to avoid short-lived allocations.

    Praxis does the second. FrameStaging keeps three staging slots and cycles through them: the window lets up to two frames be in flight on the GPU while the CPU writes the next. Each frame writes the camera and light uniforms (96 and 192 bytes) and the Instances it changed into the next slot, records one GPU copy per run of consecutive Instance slots, unmaps it, and asks for it to be mapped again once the GPU has finished that frame, its draws included. If the CPU laps the GPU, it waits.

    CPU WRITES GPU RUNS frame 1 · slot 0 frame 2 · slot 1 frame 3 · slot 2 frame 4 · slot 0 frame 5 · slot 1 frame 6 · slot 2 frame 1 frame 2 frame 3 frame 4 time →
    Three slots, two frames in flight. While the CPU writes frame 3, the GPU still holds frames 1 and 2. Frame 4 can reuse slot 0 only once the GPU has finished frame 1; if it has not, the CPU waits for it.

    Bounded, on demand

    A staging slot grows when a frame needs more bytes than it holds, to the next power of two, and it never shrinks. Before this PR nothing bounded it. Now the bound is the most one frame can write: every buffer upload writes, in full. That is the Instance buffer plus the two uniforms, 67,109,152 bytes. FrameStaging::write asserts that no frame goes past it, and growth stops there instead of rounding up to 128 MiB.

    bytes this frame
    one slot, before PR #220
    one slot, now

    Live demo. Bars are to scale, full width = 384 MiB. The two only differ near the top: a frame that writes every slot needs 64 MiB and 288 bytes, which the old growth rounded up to a 128 MiB slot, three times over.

    Most frames write almost nothing: a frame where only the camera moves uploads 288 bytes. The large frames are a scene's first, when every Renderable is new, and the recovery after lost uploads.

    Loads take a different path. Meshes and Textures go through LoadStaging, a wgpu StagingBelt that lives only until that load's copies are submitted, so load staging never stays resident (ADR 0017).

    Chapter 6

    Counting GPU memory

    gpu_memory_bytes is the engine's own ledger. It adds up the size of every buffer and texture the State creates, the same way on every backend, because wgpu has no allocator report on Metal (ADR 0013). For pbr-sweep at the bench's 2560 × 1440 it adds up to the byte:

    ResourceWhat sets its sizeBeforeAfterAfter, to scale
    pbr-sweep's GPU memory, row by row. The page adds these rows itself and compares the sums with the committed M4 Pro Baselines before and after the PR. The Instance buffer is now the largest item: 58% of the scene's GPU memory.

    Every scene but texture-wall gains 67,106,816 bytes: 64 MiB, less the 2 KiB that the 16 starting slots held. texture-wall gains 67,092,480 bytes, 14 KiB less, because its 100 quads had already grown the old buffer to 128 slots, 16 KiB. CI's software Vulkan runs the bench at 640 × 360, so its depth texture is 13,824,000 bytes smaller, and its totals are lower by exactly that.

    SceneM4 Pro beforeM4 Pro afterlavapipe beforelavapipe afterChange
    Every Baseline's gpu_memory_bytes, in bytes. CI does not bench the helmet, whose model is fetched rather than committed.
    Chapter 7

    Measuring without fooling yourself

    Every Reference scene has a Baseline per adapter: a committed result of praxis bench, rendered at 2560 × 1440 for 248 warm-up and 1,800 measured frames on the M4 Pro. The helmet has a Baseline on the M4 Pro only. praxis compare judges a new run against the Baseline and against the Budgets of ADR 0013: 6 ms of CPU and 12 ms of GPU per frame, and 8 GB of GPU memory.

    • Counters (draw calls, uploaded bytes, GPU memory, Arena bytes in use, heap allocations) repeat exactly from run to run, so any increase fails.
    • Times vary, so they are judged by the median frame; the p95, the slowest frame in twenty, is recorded beside it. A median 5% over its Baseline warns and 10% over fails, and a CPU median must also be more than 10 µs over to fail.

    Times need care. Other work on the machine slows a run, so an A/B comparison alternates the two binaries in rounds and compares the quietest, and Baselines are recorded only while no other work loads the machine. CI cannot time the M4 Pro, but it records counter Baselines on lavapipe, Mesa's software Vulkan driver, which renders on the CPU and repeats exactly.

    A trap found while recording

    uv-grid's CPU frame took 38.8 to 48.3 µs, about 39 µs at the median, in every run that benched it right after texture-wall in the same process, and 20.0 to 23.2 µs, about 22 µs at the median, when it ran alone or before texture-wall. Both binaries moved together, so the PR was not the cause. texture-wall binds 10,000 Textures, and something it leaves behind slows the frames after it, now noted on #183. Listing the scenes alphabetically, as scripts/verify.sh does, puts texture-wall just before uv-grid; praxis bench without a scene list runs texture-wall last, and the Baselines were recorded that way.

    main (28294f4) PR #220
    All 31 runs as a table
    RunBinaryWhere uv-grid ranCPU median (µs)
    uv-grid's CPU frame, median of 1,800 frames per run, on the M4 Pro. Each dot is one run; hover or focus a dot for its value. Both binaries shift by the same amount: the cause is the scene order. The per-run values come from the work on #190; the note on #183 records their ranges and the 11 runs per binary after texture-wall.

    Bias like this is a known hazard. Mytkowicz and colleagues showed that changing only the link order, or the size of the environment variables, can move measured performance enough to reverse a conclusion (ASPLOS 2009).

    CPU frame time, main against this PR

    Quietest of four roundsBaseline round
    ScenemainPR #220mainPR #220
    Medians in µs. Left: four interleaved rounds of main (28294f4) and this change on the same base, load 3 to 7, scenes listed alphabetically, so uv-grid carries the texture-wall penalty. Right: one round each in the default scene order at load 3.3, main (28294f4) against this change on fde4ac5, which also carries #216 and #218; the change's round became the new M4 Pro Baselines. The two binaries agree within noise in every scene: main's orrery took 33.0 µs in that one round but 24.0 µs in its quietest round.
    Chapter 8

    Proving the tests can fail

    Tests here sit at agreed seams: public boundaries where behaviour can be observed without reaching inside. For this ticket the seam is FramePreparer::prepare. A World of Renderables goes in, and slots, Instance writes or a refusal come out. The test uses the real capacity, one constant for the app, tests, bench and CI, so it spawns 524,288 Renderables and then one more. It takes 2.8 s in a debug build.

    It checks the refused Renderable over three frames: one warning that names it, a count of 1 each frame, nothing written, nothing drawn. Then it calls rewrite_all_instances and checks that every slot stayed where it was. Finally it despawns the Renderable in slot 7 and checks that the refused one takes it. To read the warning, the test installs a tracing subscriber that writes into a buffer, the logged helper the Mesh pool's tests already used.

    A test is only evidence if it can fail. So each guarantee was broken on purpose and the test run against the broken code:

    main's growth path (the test's first version, on main plus the constant):
      past capacity, wrote slots [524288]
    this branch, slots handed out past capacity:
      past capacity, wrote slots [524288]
    this branch, rewrite_all_instances resetting the slot table:
      assertion `left == right` failed (warnings), left: 2, right: 1

    Breaking code on purpose to check that a test notices is what mutation testing automates. Tools such as cargo-mutants make many small changes to the code and report each one that no test catches.

    Chapter 9

    What builds on fixed slots

    • #213 defines bind groups once, in Rust, and builds the ones that hold fixed-capacity buffers, the Instance buffer among them, a single time at startup.
    • #194 replaces the per-Primitive draw loop with a compute pass that reads every Instance slot in use and writes one indirect draw. A freed slot keeps whatever it last held, so freed slots will carry a sentinel Mesh reference that the pass skips.
    • #204 adds a dynamic TLAS of Moving Instances for ray-traced shadows, fixed at 10,000. Past it, Moving Instances are left out, counted in Frame stats and warned about: the pattern this PR set.
    • Milestone M4 splits the 8 GB Budget per kind of memory and replaces every provisional capacity, 524,288 included.

    Try it yourself

    • cargo test --lib render::prepareThe slot-table tests, including the one that fills all 524,288 slots.
    • RUST_LOG=praxis=debug cargo run --releaseOpens the window. G spawns a helmet three metres in front of the camera and H despawns the last one: each is a Renderable taking or freeing a slot, logged as "helmet spawned" and "helmet despawned".
    • cargo run --release -- bench target/bench
      cargo run --release -- compare target/benchBenches every Reference scene in the default order and compares it with the M4 Pro Baselines. Close other heavy work first.
    • cargo run --release -- capture pbr-sweep target/pbr-sweep.pngRenders one scene headless to a PNG.
    • scripts/verify.shThe full gate: format, lint, build, tests with Goldens, and bench counters against the Baselines.
    Reference

    Reading list

    Grouped by concept, in three kinds: what the repo already decided and measured, primary sources outside it, and chapters of books. Book chapter numbers are for the editions named.

    Persistent Instances and GPU-driven rendering

    In the repo

    Primary sources

    Books

    Slots, free lists and handles

    In the repo

    Primary sources

    Books

    • Game Engine Architecture, 3rd ed.: ch. 6 on memory management (pool allocators, fragmentation); ch. 16 on object references and handles

    Change detection in bevy_ecs

    In the repo

    • ADR 0009 bevy_ecs as a standalone crate
    • ADR 0022 why blended Renderables are written every frame

    Primary sources

    Books

    • Game Engine Architecture, 3rd ed.: ch. 8 The Game Loop and Real-Time Simulation

    Buffers, bind groups, staging, frames in flight

    In the repo

    Primary sources

    Books

    • Real-Time Rendering, 4th ed.: ch. 3 The Graphics Processing Unit, ch. 23 Graphics Hardware
    Reference

    Glossary

    Renderable
    An entity with a Transform and a Mesh Handle, optionally a Material Handle.
    Instance
    A Renderable's 128 bytes on the GPU, in its slot of the Instance buffer.
    Slot
    An index into the Instance buffer; draws pass it as instance_index.
    Free list
    Slots given back by Renderables that left; the latest is reused first.
    Capacity
    The fixed number of slots: 524,288 (219) until milestone M4 sizes it.
    Refused
    A Renderable that found no free slot: not drawn, warned about once, retried.
    Frame data
    The owned per-frame snapshot that prepare fills and the Renderer consumes.
    Frame stats
    What one frame cost, measured by the engine, now including Renderables refused.
    Change detection
    bevy_ecs's per-component change ticks, read through Ref and removals.
    Bind group
    A fixed set of resources handed to shaders; a snapshot, never updated.
    Storage buffer
    A GPU buffer shaders index like an array, as instances[i].
    Staging
    CPU-writable memory the GPU copies from into GPU buffers.
    Frames in flight
    Frames submitted to the GPU but not yet finished; here at most two.
    Arena
    A fixed-size GPU buffer of vertices or indices that Meshes are packed into.
    Baseline
    A committed bench result per scene and adapter that new runs are compared with.
    Budget
    A limit from ADR 0013: 6 ms CPU, 12 ms GPU, 8 GB of GPU memory.
    Median, p95
    The typical frame, and the slowest frame in twenty.
    lavapipe
    Mesa's software Vulkan driver; CI renders Goldens and counts with it.
    Seam
    A public boundary agreed for testing, where behaviour can be observed.
    Mutation testing
    Breaking code on purpose, automatically, to check that tests notice.
    Built from PR #220, merged as cd8c611, and ticket #190 with the owner's added rules. Numbers come from the committed Baselines and the PR's A/B runs on the M4 Pro; code facts were read at that commit.