Praxis · M3 · ticket #244 · PR #267

How a ray missed a triangle that was there

A test of the Mesh pool traced a ray straight down onto a triangle and, now and then, found nothing. The triangle was there. The TLAS that the ray searched had been built before the GPU finished writing the triangle's BLAS, and on Metal nothing in wgpu 30.0.1 told the GPU to wait. This guide follows that race from the symptom to the fix: why a GPU queue promises the order in which work starts but not the order in which it finishes, how graphics APIs order work that reads what other work wrote, why Metal's automatic tracking cannot see a TLAS build read its BLASes, how the work on #244 found the race by changing one step at a time, and what the fix costs. Two live demos model the race and replay the measured cuts.

Provenance. Ticket #244 (M3) is closed. PR #267 fixed it and squash-merged on 2026-10-23 as main 470221e, from two commits: 072c41f, the fix, and 2d7c57e, a review commit that changed only the tests. PR #270 then corrected two of its comments, as main 3b07d5f. Praxis's code is quoted from 3b07d5f, and from 5a5f075 where it shows the code before the fix. The measurements are from the work on #244, on the M4 Pro with Metal, on 2026-10-23. wgpu's code is quoted from wgpu-core and wgpu-hal 30.0.1, the versions Praxis pins. Upstream, gfx-rs/wgpu#9215 is open and its two fence fixes are unmerged.

Chapter 1 · ticket #244

A ray that sometimes misses

The test loads a Mesh of one triangle into the Mesh pool, which builds the Mesh's BLAS and compacts it. It then builds a TLAS with one entry for that BLAS, tagged 7, and traces three rays straight down: one onto the triangle and two beside it. It expects [Some(7), None, None]. On the M4 Pro it sometimes got [None, None, None]: the ray aimed at the triangle hit nothing.

The test is a_meshs_blas_holds_its_triangle_from_its_arena_ranges_and_is_compacted, in src/assets/pools.rs. Before the triangle it loads a Mesh whose only triangle is degenerate, so that the real triangle's ranges of the vertex and index Arenas start past their first elements. The TLAS places the BLAS with the identity transform and tags the entry with custom data 7, which a hit reports. Each ray starts 1 m above its point and looks 2 m down.

The test's triangle seen from above. Ray 1 goes down onto it, at (0.8, 0); rays 2 and 3 go down beside it, at (0, 0) and (0.8, 0.5). x y the triangle, at z 0 1 2 3 1 m
RayDown toExpectedA failing run
1(0.8, 0, 0), on the triangle7none
2(0, 0, 0), beside itnonenone
3(0.8, 0.5, 0), past its tipnonenone
The test's three rays, seen from above and drawn to scale. The triangle's corners are (0.6, −0.2), (1.0, −0.2) and (0.8, 0.2), at z 0. A hit reports the entry's custom data, 7; a miss reports none. A failing run read [None, None, None], so only ray 1 went wrong.

#244 records how often. The test first failed on 2026-10-22, during scripts/verify.sh on a branch that changed only a shader and its oracle tests, and passed when rerun alone. That day #244 counted 5 failures in about 16 runs of cargo test --lib and scripts/verify.sh, on branches that touched no BLAS code, and then recorded three more; the next day it failed once in 4 runs. Some failures came while another process was loading the same GPU, and at least one while nothing else was. That left the library's own tests, 115 to 120 of them, which cargo test runs in parallel, as many at a time as the machine has hardware threads, all on one adapter.

On 2026-10-23 the project's owner placed the ticket in milestone M3, blocking #211, the milestone's exit, for two reasons recorded on it. Verify runs are not reliable evidence while a test is flaky. And the BLAS the test traces comes from MeshPool::compact_blases, the engine's own load path, so "the race may be the engine's and not the test's; then shadow rays could miss a Mesh in real scenes." Chapter 5 shows that it was the engine's. The ticket closed the same day, when PR #267 merged.

Chapter 2 · Metal's command queue

A queue is an order, not a timeline

The CPU records GPU work into command buffers and submits them to a queue. The queue keeps them in the order they were submitted, and that order is its whole promise: the GPU starts each command buffer after the ones ahead of it have started. It does not wait for them to finish. Work that does not depend on earlier work, as far as the GPU knows, can run alongside it.

Apple's documentation says it in two places. A command queue "maintains an ordered list of command buffers", and of commit(), which submits one: "The GPU starts the command buffer after it starts any command buffers that are ahead of it in the same command queue." Starts, not finishes. Apple's overview of resource synchronization opens with "By design, GPUs can run multiple commands in parallel." And wgpu's Metal backend says it as plainly in a doc comment: "Metal allows independent command buffers in a queue to overlap on the GPU" (wgpu-hal 30.0.1, metal/mod.rs).

Two command buffers submitted in order, A then B. A writes something that B reads. On the GPU, B starts after A starts and runs while A is still running. queue A B submitted in this order GPU A writes B reads A starts B starts B runs while A still writes time; schematic, not to scale
Submitted in order, run side by side. A writes something that B reads. The GPU starts B after A starts; unless it knows that B reads what A writes, nothing holds B back until A finishes.

In wgpu, a command buffer is what CommandEncoder::finish returns, and Queue::submit sends a batch of them as one submission. wgpu-core adds command buffers of its own, among them one of resource transitions ahead of each of yours and one of copies that need no encoder of yours, such as those of Queue::write_buffer. That one is its pending writes, which it puts ahead of them all in the next submission (device/queue.rs). On Metal, wgpu-hal then commits the batch's command buffers one after another, so their order holds, and only their order.

The CPU learns that work has finished by asking. Device::poll with PollType::wait_indefinitely() blocks the calling thread until the most recent submission has finished (wgpu-types 30.0.1). That is a CPU wait: it is correct, and the thread does nothing else meanwhile.

The test's submissions

The flaky test makes three submissions. The first sends the load staging, with the copies of the Meshes' data and the BLAS builds that read them. compact_blases then polls until the builds have finished and records each compaction copy, which wgpu-core keeps in its pending writes. The second submission is the TLAS build's command buffer, with the pending writes just ahead of it. The third traces the rays. Nothing in the second submission says that the TLAS build reads what the copy writes.

Chapter 3 · hazards and barriers

Read after write

When one piece of GPU work writes memory that a later piece reads, the reader has to wait until the writer is done, or it may read old or half-written data. That is a read-after-write hazard. Vulkan and Direct3D 12 leave it to the application, which records a barrier between the two. Metal can handle it for you: for the resources it tracks, it holds back work that reads what earlier work is still writing. Either way, something has to know that the second piece reads what the first one writes.

Hazards are named by the order of two accesses to the same memory. Read after write is the one here. Write after read is a write that must wait until earlier reads are done, and write after write is two writes that must land in order. Two reads never conflict. In Apple's words: "It's okay for multiple commands to load data from the same resource memory at the same time because they all read from memory without modifying it." Without synchronization, the same page warns, "a load operation from one command can run before, during, or after a store operation."

VulkanDirect3D 12Metal
Who orders a read after a writeThe application, with a pipeline barrierThe application, with a resource barrierMetal itself, for tracked resources that the work binds directly; the application for anything else, with a barrier, a fence between passes or an event between command buffers
At an acceleration-structure barrier, in wgpu 30.0.1wgpu-core asks for a barrier; wgpu-hal records vkCmdPipelineBarrierwgpu-core asks for a barrier; wgpu-hal records a UAV barrierwgpu-core asks for a barrier; wgpu-hal's function is empty (chapter 4)
Who orders a read after a write, by API, and what wgpu does at an acceleration-structure barrier. From Apple's Resource synchronization and wgpu-hal 30.0.1's place_acceleration_structure_barrier in vulkan/command.rs, dx12/command.rs and metal/command.rs.

Metal's hazard tracking

Every Metal resource is tracked or untracked. Resources made from the device are tracked by default and resources made from a heap are not, and an acceleration structure is a resource like any other; wgpu-hal makes its acceleration structures from the device (metal/device.rs, create_acceleration_structure). When a command writes a tracked resource, Apple writes, Metal will "Delay write operations until all previous read operations finish" and "Prevent subsequent commands from running until write operations finish".

Hazard tracking has an edge, and this whole race sits on it. Before Metal 4, Metal synchronizes access conflicts "only for the resources that" are tracked and that the application binds directly: "You directly bind that resource to an encoder type that adopts the MTLCommandEncoder protocol". A resource that the work reaches any other way, such as through an ID stored in a buffer, is outside it, unless a render or compute pass declares it with useResource; wgpu-hal declares no BLAS that way. Metal 4's own queues track nothing at all; wgpu-hal 30.0.1 still uses the older MTLCommandQueue, where tracking applies.

Barriers, fences and events

Where tracking does not reach, the application orders the work itself. Vulkan and Direct3D 12 do this with barriers recorded between commands. Metal has three tools. A barrier makes later work wait for earlier work: within a render or compute pass, and, since Metal's 26 releases, between passes on one queue, acceleration-structure passes included. A fence orders passes: one pass updates it after its stores, and a later pass waits for it before running. An event orders command buffers: one signals a value once its earlier work is done, and another, on the same queue or a different one, waits for that value before its next pass. A shared event also reaches the CPU and other devices.

Chapter 4 · wgpu-hal 30.0.1, Metal

What Metal cannot see

A TLAS is built from its BLASes, so its build has to come after whatever writes them. On Metal, wgpu builds every TLAS from a buffer of entries that name their BLASes by resource ID. The build's descriptor names the buffer, not the BLASes, so Metal's tracking never learns that the build reads them. wgpu-core does know, and asks the backend for a barrier at that point. On Metal, the function it calls is empty.

Apple's page on the build command shows the ordering it expects: "you can use a single encoder to build multiple geometry acceleration structures and then build the instanced structure that uses them", because "By default, Metal automatically synchronizes GPU access to the acceleration structure and any resources contained in the acceleration descriptor." The words that matter are the last ones: contained in the descriptor.

Two ways to name a BLAS

Metal's descriptor for a TLAS, an instance acceleration structure, takes its entries in one of several layouts. In the default layout, the descriptor carries the BLASes themselves, in instancedAccelerationStructures, and "Each instance in the instance descriptor buffer has an index into this array". In the indirect layout, "An option that enables an instance descriptor memory layout the GPU can populate", each entry holds its BLAS's accelerationStructureID, a resource ID, and the descriptor lists no BLAS at all.

The default layout

  • the build's target: the TLAS, which the build writes
  • in the descriptor: the buffer of entries; entry 0 holds index 0
  • in the descriptor: instancedAccelerationStructures, whose element 0 is the BLAS

Metal sees the BLAS, so it orders the build after the work that writes it.

The indirect layout, which wgpu-hal uses

  • the build's target: the TLAS, which the build writes
  • in the descriptor: the buffer of entries; entry 0 holds the BLAS's accelerationStructureID
  • nowhere in the descriptor: the BLAS itself, which the build reads by that ID

Metal sees the buffer, not the BLAS, so nothing orders the build after the work that writes it.

What a TLAS build shows Metal, in each layout. In both, the build reads each entry's BLAS; only in the default layout is the BLAS among the resources Metal synchronizes. Schematic: one entry, one BLAS.
// metal/conv.rs, map_acceleration_structure_descriptor: every TLAS
// takes the indirect layout, and a buffer of entries.
crate::AccelerationStructureEntries::Instances(instances) => {
    let descriptor = MTLInstanceAccelerationStructureDescriptor::new();
    descriptor.setInstanceDescriptorType(
        MTLAccelerationStructureInstanceDescriptorType::Indirect,
    );
    descriptor.setInstanceCount(instances.count as usize);
    descriptor.setInstanceDescriptorBuffer(Some(&instances.buffer.unwrap().raw));
    // … and the buffer's offset
}

// metal/device.rs: what wgpu-core keeps as a BLAS's address is its GPU
// resource ID ...
acceleration_structure.raw.gpuResourceID().to_raw()

// ... and each TLAS entry carries it, beside its custom data.
userID: instance.custom_data,
accelerationStructureID: unsafe { MTLResourceID::from_raw(instance.blas_address) },
How wgpu-hal 30.0.1 describes a TLAS on Metal, abridged from metal/conv.rs (map_acceleration_structure_descriptor) and metal/device.rs (get_acceleration_structure_device_address, tlas_instance_to_bytes). Each entry names its BLAS by the BLAS's GPU resource ID, and the descriptor names no BLAS.

Where wgpu-core asks for a barrier

wgpu is built in layers. Your calls go to wgpu-core, which validates them, tracks resources and decides where ordering is needed, the same way for every backend. wgpu-hal turns each of its requests into one backend's commands. For acceleration structures, wgpu-core calls the hal's place_acceleration_structure_barrier before it builds BLASes, after the BLAS builds of a call and before its TLAS builds, and after a TLAS build, before shaders read the TLAS (wgpu-core 30.0.1, command/ray_tracing.rs). What that call becomes depends on the backend:

Backend:
// metal/command.rs, quoted whole.
unsafe fn place_acceleration_structure_barrier(
    &mut self,
    _barriers: crate::AccelerationStructureBarrier,
) {
}

Metal: nothing. The request arrives and no command is recorded, so ordering is left to Metal's tracking, which cannot see the BLASes.

One request, three backends. wgpu-core makes the same call for each; choose a backend to see what wgpu-hal 30.0.1 does with it. The Vulkan and Direct3D 12 bodies are paraphrased with full-word names; Metal's is quoted whole.

So on Metal, nothing orders a TLAS build after the work that writes its BLASes, beyond whatever the GPU happens to do. At submit, wgpu-hal does one more thing for TLAS users: set_acceleration_structure_dependencies puts the BLASes that each command buffer's TLASes refer to into a residency set (metal/command.rs). That keeps them in GPU memory and orders nothing. Apple's page on residency sets says so: "residency sets don't track hazards, so you need to account for hazards with fences and events."

The issue upstream, gfx-rs/wgpu#9215, "Metal BLAS -> TLAS builds need synchronisation", was opened in March 2026 and is still open. It says the same from wgpu's side: "On metal, acceleration structure barriers are not inserted (due to metal not having barriers during development of this) however it appears that metal does not serialize them as I would expect an API with little synchronisation to have." Its repro sometimes reads zero where it should read one; #244's test read nothing where it should read 7.

The newer of its two fixes, PR #10292, still an open draft, adds regression tests, and a comment in them puts the mechanism in one sentence: "On Metal, acceleration structure builds are not ordered against each other within an encoder, nor across command buffers committed back to back, so a TLAS build could consume a BLAS that is still building." One of those tests, COMPACTED_BLAS_IN_PENDING_WRITES, is #244's case exactly: a BLAS compacted through the pending writes, which are "committed ahead of the command buffers of the next submission", and a TLAS built in that next submission, which "must be ordered after the copy".

Chapter 5 · Renderer::upload

The same race at load

The test did not invent the race; it follows the engine's own order. In a frame that follows new loads, Renderer::upload submits the load staging, which builds the new Meshes' BLASes; compacts them; and records the static TLAS's build into the frame's encoder. compact_blases waited on the CPU for the builds, but not for the compaction copies. wgpu-core held those in its pending writes until the next submission, and sent them in a command buffer at its head. That submission held the dynamic TLAS's build when the frame built one, or else the frame's own command buffer, and nothing ordered either TLAS build after the copies.

// Loads staged since the last frame land before its copies and
// draws, and their BLASes are compacted before the static TLAS
// takes them.
self.load_staging.submit(&self.queue);
self.mesh_pool.compact_blases(&self.queue);
let mut encoder = create_frame_encoder(&self.device);
self.static_tlas
    .update(frame, &self.mesh_pool, &mut encoder);
The load path, from Renderer::upload in src/render/renderer.rs; the fix changes only its doc comment. render later submits the frame's encoder, with the static TLAS's build in it.
pub fn compact_blases(&mut self, queue: &wgpu::Queue) {
    if self.awaiting_compaction.is_empty() {
        return;
    }
    for &(handle, _) in &self.awaiting_compaction {
        self.blases[handle.0 as usize].prepare_compaction_async(|_| {});
    }
    if let Err(error) = self.device.poll(wgpu::PollType::wait_indefinitely()) {
        tracing::error!(%error, "waiting for BLAS builds failed");
    }
    for (handle, triangles) in self.awaiting_compaction.drain(..) {
        let blas = &mut self.blases[handle.0 as usize];
        // … a BLAS that is not ready keeps its build, as logged
        *blas = queue.compact_blas(blas);
        // … its triangles now count as compacted
    }
}
MeshPool::compact_blases before the fix, abridged from src/assets/pools.rs at 5a5f075. prepare_compaction_async asks wgpu for each BLAS's compacted size, and the poll waits for the builds and those answers. Queue::compact_blas records the copy into wgpu-core's pending writes and returns the compacted BLAS at once, before the GPU has written it (wgpu-core 30.0.1, device/queue.rs).

A model of the race

The demo below models the submission that carried the race in a frame that builds no dynamic TLAS: the pending writes, holding one compaction copy, and the frame's command buffer, whose TLAS build reads the copy's BLAS. The slider adds other devices' work to the same GPU, and the two switches are the two ways to order the pair. It is a model to watch the mechanism in, not a measurement.

7

The same settings, 100 runs of the model:

the compaction copy, writing the BLAS the TLAS build where the build reads the BLAS a wait read too early: the ray misses
Live demo: an illustrative simulation, not a measurement. The model is this page's own. The frame's command buffer starts a little after the pending writes start, as Metal allows, and its TLAS build reads the BLAS early in its run; other devices' work stretches the copy by a random amount, so the more devices, the more often the copy outlasts the read. Its time units are arbitrary, and its counts of misses are the model's, not measured ones: chapter 6 has those. "CPU wait" submits the pending writes on their own and polls until they finish before submitting the frame, as the fix does. "GPU wait" makes the TLAS build wait on the GPU until the copy is done, as a barrier that works would within one submission, or an event through wgpu-hal 30.0.1 would with the copy in a submission of its own and strict mode on (chapter 7). In the strip, a red square is a run that misses, and the ringed one is the run drawn above; the dashed line in the copy's lane marks when the build reads.

What it would have cost

The static TLAS is built from the first Frame data and rebuilt only in a frame that changes a static Instance or frees one's slot (StaticTlas, src/render/acceleration_structures.rs). A Mesh whose BLAS lost the race would have cast no ray-traced shadow until the static TLAS's next rebuild, which, in a scene where nothing static changes, never comes. The dynamic TLAS, built in render in that same frame, had the same exposure for a Moving Instance whose Mesh had just loaded. It is rebuilt in every frame (#204), though, so its next build would have read the BLAS again. The fix covers both.

The fix changes no Golden: no Reference scene renders differently with it, by PR #267's verify run. The race needed the GPU busy with other work to show, as in #244's failing runs, where that work came from other processes or from the library's own parallel tests.

Chapter 6 · the work on #244

Make it fail, then cut

A race that shows once in a few runs is hard to study, since every experiment needs dozens of runs to say anything. So the work on #244 first made it fail often, by running the test's GPU steps on eight threads at once, each with its own device. Then it changed one step at a time and counted the misses. The change that stopped them named the race; the change that did not showed where a wait comes too late.

A loop that fails every run

The loop was a temporary test, ignored by default and never committed. It ran the flaky test's GPU steps, from loading the two Meshes to the trace, on 8 threads, 10 times each, so 80 traces a run, in about 0.7 s. Each iteration made its own headless context, so each thread drove its own wgpu device on the one GPU. Every run of it missed:

Threads × iterationsMisses in each run of 80 traces
8 × 1015, 23, 11, 20, 12, 17
2 × 405, 6, 5
1 × 800, 0, 0
The stress loop before the fix, M4 Pro, Metal, 2026-10-23: traces onto the triangle that found nothing, in each run of 80. One thread alone missed none of these 240 traces; in three later runs of 80 on one thread, timed for chapter 7 while other work on the machine loaded the GPU, it missed once.

So the race needs other devices loading the GPU at the same moment, and the more of them, the more often it loses. The loop was also a test that turned red within a second, every time, which the next step needs.

One step at a time

With a red loop in hand, each variant below changed one thing about the test's steps and ran three times, 80 traces each, on 8 threads. Choose one to see its steps, where the CPU waits, and which write is left unordered with the TLAS build.

VariantRun 1Run 2Run 3
7113
247
71620
000
1099
000
111015

Its steps, in order

    writes the BLAS reads it: the TLAS build the CPU waits unordered with the TLAS build ordered after every write
    Live demo: the cuts, M4 Pro, Metal, 2026-10-23. The counts are measured: misses in each of three runs of 80 traces, on 8 threads with a device each. Choose a variant to see its steps; the steps are this page's reading of each variant's name.

    Read together, the cuts name the race. The TLAS build reads its BLAS before the last GPU write to that BLAS has finished: the BLAS build itself when nothing waits after it, the compaction copy when the build is waited for. A CPU wait after that last write ends the misses. A wait after the TLAS build, just before the trace, changes nothing, because by then the TLAS is already built. And the degenerate first Mesh played no part.

    The regression test

    The loop became a test that stays, a_tlas_built_after_compaction_holds_its_blas_while_other_devices_load_the_gpu, in src/assets/pools.rs. Eight threads, each with its own device and a Mesh pool whose Arenas hold 4 KiB each, load a triangle Mesh, compact its BLAS and trace it, 25 times over. Without the fix it failed every run: 20 of 20 with full-size Arenas, then 10 of 10 once the review had shrunk them, since eight pools of full Arenas took 660 MiB. With the fix it passed 20 of 20 both times, and it takes 0.18 s on the M4 Pro. The ticket asked for a reproducer that fails at least one run in four before the fix and passes 20 consecutive runs after it. The stress loop, after the fix, missed 0 of 1,600 traces in 20 runs.

    The review also made both tests read the BLAS through MeshPool::blas, as the renderer does, rather than from the pool's own list. On CI's lavapipe the library's tests now take 38.4 s against main's 24.3 s, about 14 s more. PR #267 first read the new test as a guard for Metal alone, saying Vulkan places the barrier. That holds after a BLAS build, where wgpu-core asks for a barrier (chapter 4), but not after the compaction copy. wgpu-core 30.0.1 records no barrier after it, and the barrier before a TLAS-only build has shader reads as its source. The barriers wgpu-core records around a TLAS's buffer of entries cover only that buffer. So on hardware Vulkan no barrier makes the copy's writes visible to a TLAS build either, and the wait does that on every backend. The PR's text was corrected after merge, and PR #270 corrected the code comment.

    /// A TLAS built once `compact_blases` returns holds the compacted BLAS
    /// while other devices load the GPU, as parallel tests do: no barrier
    /// orders a TLAS build after the copies that write its BLASes, so
    /// `compact_blases` waits for them (#244). Eight threads, each with its
    /// own device, load and trace a triangle 25 times.
    #[test]
    fn a_tlas_built_after_compaction_holds_its_blas_while_other_devices_load_the_gpu() {
        if gpu_context().is_none() {
            return;
        }
        let load_and_trace = || {
            let context = gpu_context().expect("a GPU adapter, as found above");
            let mut staging = LoadStaging::new(context.device.clone());
            // Room for its 25 triangles; eight pools of full Arenas would
            // take 660 MiB.
            let mut pool = MeshPool::new(&context.device, 4 << 10);
            (0..25)
                .map(|_| {
                    let handle = pool
                        .load(&mut staging, &triangle_about_x(), &[None])
                        .expect("a triangle fits");
                    staging.submit(&context.queue);
                    pool.compact_blases(&context.queue);
                    let blas = pool.blas(handle).expect("the triangle's BLAS");
                    trace_triangle_about_x(&context, blas)
                })
                .collect::<Vec<_>>()
        };
        std::thread::scope(|scope| {
            let threads: Vec<_> = (0..8).map(|_| scope.spawn(load_and_trace)).collect();
            for thread in threads {
                for traced in thread.join().expect("a tracing thread finishes") {
                    assert_eq!(traced, [Some(7), None, None]);
                }
            }
        });
    }
    The regression test, from src/assets/pools.rs at 3b07d5f. trace_triangle_about_x builds a TLAS of the one BLAS, tagged 7, and traces chapter 1's three rays; the original test now calls it too, with its assertions unchanged.

    What it does not show

    The original scenario did not come back on the day of the fix. With the fix reverted and the new test skipped, 12 runs of cargo test --lib all passed, as did 12 with the fix in. So the evidence that the fix ends #244's flaky failures is the mechanism, the cuts and the stress loop. The loop stands in for the flaky run because it matches it twice over: the same path, in which Meshes load, the loads are submitted, the BLASes compacted, and a TLAS built in the submission that flushes the pending writes; and the same symptom, [None, None, None]. It is not a before and after of the original failure.

    Chapter 7 · PR #267

    Wait for the copies, for now

    compact_blases already waited for the BLAS builds. Now it also submits the pending writes, where the copies are, and waits for those. It is a CPU wait, so the thread that loads stops until the GPU has finished the copies. It runs only in frames that load Meshes; in three runs of 80 calls on one thread, it added about 0.3 ms to the median call's time. Work that has to be ordered in every frame could not pay that way, and #209 brought such work, which it orders on the GPU instead (below).

        // … the copies are recorded, as before
        // The copies wait in the queue's pending writes until a submit.
        queue.submit(std::iter::empty());
        if let Err(error) = self.device.poll(wgpu::PollType::wait_indefinitely()) {
            tracing::error!(%error, "waiting for BLAS compaction failed");
        }
    }
    The fix, at the end of MeshPool::compact_blases in src/assets/pools.rs, at 3b07d5f; the review left it as 072c41f wrote it. Its doc comment, as PR #270 corrected it, says why the wait is there: "Nothing else orders a TLAS build after the copies: wgpu-core 30.0.1 records no barrier after compact_blas's copy on any backend, and Metal has no acceleration-structure barrier at all (gfx-rs/wgpu#9215)". The last clause is true of wgpu-hal 30.0.1, which records none on Metal, but not of Metal itself, which has had a barrier between passes since its 26 releases (chapter 3). So the wait can go only once wgpu orders the copy on every backend.

    The empty submit is what makes the poll useful. The copies sit in wgpu-core's pending writes until something submits, and a poll waits only for work already submitted, so a poll alone would return without waiting for them. queue.submit(std::iter::empty()) sends the pending writes as a submission of their own, and the poll then waits for it.

    What it costs

    Timed on one thread, 80 calls a run and three runs, compact_blases took a median of 4.0 to 4.6 ms before the fix and 4.5 to 4.7 ms after, about 0.3 ms more; other work on the machine was loading the GPU at the time. It runs only in frames that load Meshes, which today means frame 0, and on every backend. No bench measures such a frame: every run counts its counters from frame 8, and the runs that record Baselines time their frames after 248 warm-up frames. So no Baseline moves, and every counter matches.

    Other ways to order the pair

    A CPU wait is the bluntest tool. It suits work that happens once, at load, and it does not suit work in every frame. The pair can also be ordered on the GPU, which lets the CPU go on:

    RouteWho waitsIn wgpu 30.0.1The catch
    A CPU wait after the last write: the fixThe thread that polls; in Praxis, the main thread, which records and submits each frame (ADR 0016)Works now, on every backendThe thread does nothing until the GPU has finished the submission it waits for
    A fence between the acceleration-structure passes: upstream's fixThe GPUNot in a release: PR #9645 is open, and #10292, an open draft, supersedes itNeeds a wgpu release with it, and a version bump is the owner's call
    An event: signal after the write, wait before the build, through wgpu-hal's add_signal_event and add_wait_eventThe GPUAvailable, through Queue::as_halStaging is queue-wide, and by default the wait is "best-effort under cross-CB GPU concurrency"
    Three ways to order a write to a BLAS before the TLAS build that reads it, on Metal with wgpu 30.0.1. #9215's first comment lists one more, the barrier command Metal added in its 26 releases, which "loses most intel macs"; the fence methods go back to macOS 11 and iOS 14, the releases that brought acceleration structures.

    Why the event route is delicate

    wgpu-hal 30.0.1's Metal queue can stage a wait or a signal on an MTLSharedEvent for the next submit. Its doc comments carry two warnings. Staging is queue-wide: "any add_wait_event call is consumed by whichever Queue::submit runs next on this queue". And by default the wait is encoded on a command buffer of its own, which "does not strictly gate subsequent user command buffers when those CBs share no Metal-tracked resources with it": this race exactly. Strict ordering needs enable_strict_event_sync, at "one extra encodeWaitForEvent per command buffer plus one extra internal command buffer per submit".

    #9215's last comment, from 2026-10-13, reports another application's experience. It had built this event workaround and then removed it: a worker thread could submit between staging the signal and submitting the BLAS's command buffer, take the signal, and leave the TLAS waiting for unrelated work. A lock around its own submits could not cover the submits that wgpu-core makes by itself, for example when mapping a buffer with pending writes. It fell back to a CPU poll on Metal after each frame that rebuilds a BLAS, and accepted that the wait "stalls the render thread through the producer and earlier queued GPU work".

    What #209 has to solve

    #209 refits each crowd Mesh's BLAS in every frame through wgpu-hal on Metal, and the dynamic TLAS, rebuilt in every frame, is then built from those BLASes. A refit writes the BLAS and the TLAS build reads it by ID: the same unordered pair, in every frame instead of at load. A CPU wait there would stall the main thread, which records and submits every frame (ADR 0016), in every frame. So ordering on the GPU is the likely route: the upstream fence fix, if a wgpu release ships it in time, or events through wgpu-hal.

    For events, Praxis starts with one thing in its favour: only the main thread submits, presents or polls (ADR 0016), so no worker can take a staged signal. The submits that wgpu-core makes by itself remain, so #209 would have to stage and submit with nothing in between that could submit, and choose whether it needs strict mode. (It took events, in strict mode. Since PR #289, merged on 2026-10-26 as 66b5b57, the submission that holds the refits signals a Metal shared event as it completes, and the dynamic TLAS's build waits for that event on the GPU; the frame waits on the CPU only where no shared event can be made. The owner then chose the crowd's per-bone shadow proxies for milestone M7, so the refit stays behind its Render switch. BLAS Refit Explained tells that story.)

    The project's roadmap issue, #17, keeps the rest as a one-line note for later work, "Metal BLAS-to-TLAS ordering": drop the wait only when a wgpu release orders a TLAS build after compaction copies on every backend. #9215 is Metal's alone, and wgpu-core 30.0.1 records no barrier after the copy on any backend. That is a version bump and so the owner's call. Revisit when milestone M4 streams Meshes during play, "where each load frame stalls on the GPU twice". It ends where this chapter does: #209's per-frame refit "needs an order of its own", which PR #289 has since given it.

    Reference

    Reading list

    Grouped by source, each with what it is good for here. Links into the repo are pinned to main at 3b07d5f, where PR #270 merged after PR #267, except the one to the code before the fix, at 5a5f075. Links into wgpu are pinned to its v30.0.1 tag, whose sources match the crates Praxis builds. Apple's pages were read on 2026-10-23, and the article on consumer barriers on 2026-10-26.

    This ticket

    In the repo

    • Ticket #244 the failures, their rate, the triage and the bar for done
    • PR #267 the fix, the cuts, the regression test's runs, the cost and the review
    • PR #270 the corrected comments: the wait covers every backend
    • MeshPool::compact_blases, and before the fix the wait for the builds, and now for the copies
    • The regression test eight devices at once, in 4 KiB Arenas
    • Renderer::upload the load path: submit the loads, compact, record the static TLAS's build
    • StaticTlas when the static TLAS is built and rebuilt
    • ADR 0016 the main thread records, submits and polls; only it submits, presents or polls
    • ADR 0020 a compacted BLAS per Mesh, the static TLAS built at load, the dynamic TLAS rebuilt each frame
    • Roadmap issue #17 its note "Metal BLAS-to-TLAS ordering": when the wait goes, and when to revisit it
    • Ticket #209 the crowd's BLAS refit through wgpu-hal, every frame: where this race came back, and PR #289, which orders it on the GPU

    wgpu, upstream

    Apple's Metal documentation

    Books, and in this series

    • Gregory, Game Engine Architecture, 3rd ed., chapter 11, The Rendering Engine the engine's shape and its rendering pipeline: where submitting GPU work sits. Architecture, not API synchronization
    • Acceleration Structures Explained BLAS, TLAS and compaction in Praxis (#202)
    • Dynamic TLAS Explained the TLAS rebuilt every frame, which #209's refitted BLASes feed in the crowd's refit form
    • BLAS Refit Explained #209's refit, and the shared event that orders the dynamic TLAS's build after it
    Reference

    Glossary

    Queue
    Where command buffers are submitted; it keeps their order of submission and does not wait for one to finish before the next starts.
    Command buffer
    A batch of GPU commands recorded on the CPU and submitted in one piece.
    Submission
    One call to Queue::submit, with wgpu-core's pending writes ahead of its command buffers.
    Pass
    A run of commands of one kind, recorded through one encoder.
    Pending writes
    wgpu-core's own command buffer of copies, sent ahead of yours in the next submission.
    Poll
    Device::poll; with a wait, it blocks the CPU until the most recent submission has finished.
    Hazard
    Two accesses to the same memory, at least one a write, with nothing to order them.
    Read after write
    The hazard in which a read must wait for an earlier write to finish.
    Barrier
    A recorded command that makes later GPU work wait for earlier work's writes.
    Hazard tracking
    Metal's automatic ordering, for tracked resources that work binds directly.
    Tracked resource
    A Metal resource whose hazards Metal tracks; device-made resources are tracked by default.
    Fence
    MTLFence: one pass updates it after its stores, a later pass waits for it.
    Event
    MTLEvent: a counter one command buffer signals and another waits for; a shared event also reaches the CPU.
    Residency set
    Metal's list of resources a command buffer may touch; it tracks no hazards.
    Resource ID
    A number naming a Metal resource from inside a buffer, out of tracking's sight.
    Indirect instance descriptor
    Metal's TLAS entry layout that names its BLAS by resource ID; wgpu-hal uses it for every TLAS.
    wgpu-core
    wgpu's layer that validates calls, tracks resources and asks for barriers, for every backend alike.
    wgpu-hal
    wgpu's layer that turns wgpu-core's requests into one backend's commands.
    BLAS
    Bottom-level acceleration structure: the GPU's search structure over one Mesh's triangles.
    TLAS
    Top-level acceleration structure: the GPU's search structure over Instances, each placing a BLAS with its transform.
    Static TLAS
    The TLAS of every static Instance whose Mesh rays trace, built at load and rebuilt only in a frame that changes one (#202).
    Dynamic TLAS
    The TLAS of at most 10,000 Moving Instances, rebuilt every frame (#204).
    Compaction
    Copying a built BLAS into a smaller one that replaces it; wgpu-core records the copy into its pending writes.
    Refit
    Recomputing an acceleration structure's bounds in place, keeping its tree; not in wgpu-core 30.
    Load staging
    Praxis's encoder of load-time copies and BLAS builds, submitted ahead of the next frame's work.
    Custom data
    A number a TLAS entry carries and a hit reports; Praxis stores the Instance's slot.
    Device
    A wgpu connection to one GPU, with its own queue.
    Flaky test
    A test that passes on some runs and fails on others, with the same code.
    Stress loop
    A test run many times at once to raise a race's rate until every run fails.
    Cut
    One step of the test changed or removed, to see whether the failures stop.
    Mesh
    An ordered list of Primitives that are rendered together.
    Instance
    One Renderable's data on the GPU, in a slot of the Instance buffer that the Renderable keeps while it is one.
    Golden
    A committed image that a Reference scene must reproduce within tolerance.
    Baseline
    The recorded measurements of a Reference scene on one adapter.
    Built from ticket #244 and PR #267, merged as main 470221e, and PR #270, merged as main 3b07d5f, as the work on #244 verified them on 2026-10-23, with wgpu-core and wgpu-hal 30.0.1's sources, gfx-rs/wgpu#9215 and its two fence PRs, Apple's Metal documentation, the note on roadmap issue #17, and ADRs 0016 and 0020; the note on #209's order comes from PRs #289 and #290. Quotes from wgpu, Apple, Khronos and Microsoft are verbatim; Praxis's code is quoted from 3b07d5f, or from 5a5f075 where marked; wgpu's Vulkan and Direct3D 12 barriers are paraphrased with full-word names. The demos are this page's models: the race demo's timings and counts are illustrative, and the cuts demo replays the measured counts.