Praxis · M3 · ticket #381 · PR #400

Waiting on the GPU Explained

Every CPU stage of town, the dense Reference scene, read 20 to 40% slower once the GPU became the longer half of the frame, stages that touch no acceleration structure alike. The dynamic TLAS's CPU line read over its 1.1 ms ceiling and the CPU frame over M3's 6 ms. This volume follows ticket #381's search for the cause: where the main thread waits, how Apple silicon sets a core's clock, how to read that clock without counters, what nine runs ruled in and out, and what PR #400 changed. Two live demos show a frame's timeline under each way of waiting, and the nine runs' stages against the clock.

Provenance. The code is main at 958ed4e9, PR #400, which closes ticket #381. The measurements are docs/performance.md's section of 2026-10-30, taken on an Apple M4 Pro (8 performance and 4 efficiency cores), macOS 27.0.1, wgpu 30.0.1 on Metal, from a release build of 102b22c3 with a measurement patch that was not committed. Where the guide quotes Apple or kernel source, it names and links it.

Chapter 1 · src/capture/mod.rs · render_frames

Where the CPU waits

Key idea. With two frames in flight and a GPU-bound frame, the main thread waits for the oldest frame in every frame; it can wait by blocking, asleep until the GPU signals, or by spinning, polling on its core.

The CPU records a frame and hands it to the GPU, then starts the next. It may run ahead by a fixed number of frames. When the GPU is the slower of the two, the CPU reaches that limit every frame and has to wait.

Praxis lets two frames be in flight. FRAMES_IN_FLIGHT is 2, in src/render/mod.rs: "Frames submitted but not finished on the GPU when the next one starts, at most: the window's swapchain latency and capture's queue." While the GPU runs frame N, the CPU records frame N+1 beside it (Frame Timing Explained). Before it starts N+2, frame N must be done.

On town at 2560×1440 with 4x MSAA, the GPU frame is now 20.1 ms, since volume 26's sun shadow edge rays (Shadow Edge Rays Explained). Spin-waiting, the CPU frame reads 4,977 µs. So the main thread spends most of each frame waiting.

Two places, two ways

The window and the bench wait in different places. The window waits in the surface's get_current_texture, in src/lib.rs, for a swapchain texture to come free. praxis bench, praxis capture and the Goldens all render through render_frames in src/capture/mod.rs, which keeps a queue of submissions and, when two are in flight, waits for the oldest with device.poll:

// src/capture/mod.rs at 958ed4e9, render_frames.
if in_flight.len() == FRAMES_IN_FLIGHT {
    let oldest = in_flight.pop_front();
    let timeout = match wait {
        FrameWait::Block => None,
        FrameWait::Spin => Some(Duration::ZERO),
    };
    loop {
        match context.device.poll(wgpu::PollType::Wait {
            submission_index: oldest.clone(),
            timeout,
        }) {
            Ok(_) => break,
            Err(wgpu::PollError::Timeout) => std::hint::spin_loop(),
            Err(error) => return Err(error.into()),
        }
    }
}
  • Blocking, timeout: None. On Metal, wgpu-hal 30's fence wait is a parking_lot condition variable that the command buffer's completion handler signals. The thread sleeps until the frame is done: no sleep loop, no polling. Its core is free for other work, or to go idle.
  • Spinning, timeout: Some(Duration::ZERO). Each poll returns at once with PollError::Timeout until the frame is done, and the loop polls again, with std::hint::spin_loop() between. The thread never leaves its core.

Before PR #400, render_frames always blocked, as the window does. Neither way changes what the GPU does: it gets the same frames in the same order. What the measurements in chapter 4 found is that the choice changes how fast the CPU's next frame runs.

CPU work waiting, blocked: the thread asleep waiting, spinning: the core busy GPU frame
Wait
CPU frame, as the bench reads it
Waiting each frame
Frame to frame
GPU idle each frame

Live demo. This page's model, in measured numbers where it has them: the CPU's work at full clock is spin-waiting's CPU frame, 4,977 µs, and a blocking wait with two frames in flight stretches it by 1.40, the chain's 2.75 µs against 1.96 (chapter 4). The GPU frame is yours to set; town's is 20.1 ms. Simplified: the GPU starts a frame when the CPU's frame ends, so with one frame in flight the model's wait is the whole GPU frame, where the measured runs waited about 15 ms a frame; with one frame in flight the model runs the CPU at full clock, where the measured runs were slow at the frame's start and fast by the end of update; and the slider stops at 8 ms, so the frame stays GPU-bound, the only case measured.
Chapter 2 · Apple silicon

Cores, clusters and clocks

Key idea. This M4 Pro's cores sit in clusters of four that share an L2 cache, and the kernel's scheduler may adjust each cluster's frequency depending on its workload, its thermal pressure and other factors; how it weighs a thread that sleeps for most of each frame is not published.

This M4 Pro has 12 cores of two kinds. sysctl reports them by performance level:

hw.perflevel0.name: Performance
hw.perflevel0.logicalcpu: 8
hw.perflevel0.cpusperl2: 4
hw.perflevel1.name: Efficiency
hw.perflevel1.logicalcpu: 4
hw.perflevel1.cpusperl2: 4

Eight performance cores in two clusters of four, and four efficiency cores in one; each cluster shares one L2. The core numbers below are the ones the measurement patch read (chapter 3).

Efficiency

0123
shared L2

Performance

4567
shared L2

Performance

891011
shared L2
The M4 Pro's clusters, numbered as the measurement patch's core numbers came out on this machine: cores 0 to 3 ran the chain slower, so they are the efficiency cluster; 4 to 7 and 8 to 11 are the two performance clusters.

Who sets the clock

Apple's Tech Talk Tune CPU job scheduling for Apple silicon games says: "Each cluster may be independently activated or have its frequency adjusted by the kernel's scheduler -- depending on the current workload, the current thermal pressure for that cluster, and other factors." A core's speed is therefore not fixed: the same code can run faster or slower depending, in Apple's words, on the workload, the thermal pressure and other factors.

The same talk names two costs of a thread that waits. "[W]hen a CPU core has nothing to do for quite some time, it goes idle to save energy. And reactivating an idle core takes a little bit of time. That is our first cost, the core wake-up cost." And: "When you signal and wait for threads, that generally means some will be scheduled on the CPU core and some will be blocked and moved off core. Doing that many times is a performance pitfall."

What the talk does not say, and what no Apple source the ticket found says, is how the scheduler weighs a thread that blocks for most of each frame and runs for the rest. This guide does not guess. It reports what the chain's timing shows the core's speed did, and leaves the rule that set it unexplained.

QoS and priority

A thread on macOS also carries a quality-of-service (QoS) class and a scheduler priority. Either could, in principle, keep a thread off a slow core or a slow clock. The ticket named the QoS class as a suspect, with frequency scaling and migration to an efficiency core. Chapter 4 tests QoS, priority and migration.

Chapter 3 · the measurement patch

Measuring a clock without counters

Key idea. Without access to the clock, time a fixed piece of work: if the work never changes, a change in its time is a change in the core's speed.

macOS's powermetrics reports each cluster's frequency, but it needs root, which the measurement did not have. So the patch read the clock through work. It timed a fixed chain of 2,000 dependent multiply-adds on the main thread:

// The measurement patch, not committed.
fn work() -> f64 {
    let started = std::time::Instant::now();
    let mut value = 0x1234_5678u64;
    for _ in 0..2_000 {
        value = value.wrapping_mul(6364136223846793005).wrapping_add(1442695040888963407);
    }
    std::hint::black_box(value);
    started.elapsed().as_secs_f64() * 1e6
}
  • Dependent. Each step needs the value the step before produced, so the core cannot run steps side by side; black_box keeps the compiler from removing a result nothing reads. The chain does the same work every time it runs.
  • Three points a frame. The patch ran it at each frame's start, after the wait; after update; and after render. A clock that changes within a frame shows as a difference between the three.
  • About 2.0 µs at full clock. Spin-waiting, the chain's medians read 1.92 to 2.04 µs at all three points.

Which core

A slow chain could mean a slow clock on a performance core or a run on an efficiency core, so the patch also recorded the core. It read the low 12 bits of the arm64 register TPIDR_EL0, which gave 0 to 11 on this machine, and mapped the numbers to clusters by timing the same chain on each core: 0 to 3 were slower, the efficiency cluster; 4 to 7 and 8 to 11 the two performance clusters. Apple documents no such use of the register. Read it as what the patch did on this machine, an observation, not an interface to build on.

The patch also logged each CPU stage's median, and switched by environment variable the frames in flight, a spin-wait (device.poll with a zero timeout, in a loop) and a priority of 45 set with pthread_setschedparam under the default time-sharing policy. Its core counts span every frame after the first 100: 1,948 a run.

Chapter 4 · docs/performance.md, 2026-10-30

What the runs showed

Key idea. prepare_frame and propagate_world_transforms slowed by the chain's own ratio when the thread blocked beside a busy GPU, on the same performance cluster and at priority 45 too: the clock fell; the work did not grow.

Nine runs of praxis bench <dir> --skip-aliasing town at 2560×1440 with 4x MSAA, 1,800 measured frames each, no other GPU work. Medians in µs, from the patch's log rather than praxis report, whose tables hold neither stages nor the chain. The chain's medians are with rays on, at the frame's start, after update and after render. Runs in the order they ran; the default's first run was the only one with a fast clock.

RunCPU frameDynamic TLAS, CPUGPU frameprepare_framepropagate_world_transformsencodesubmitChain: start, update, render
Default (2 in flight)5,985.81,136.220,155.01,324.5362.2283.22,710.12.04, 1.96, 2.04
1 in flight4,913.6911.520,630.51,285.7344.4166.72,129.02.46, 1.92, 2.00
Default7,278.51,325.520,150.91,804.0481.7242.13,311.22.67, 2.58, 2.92
Spin-wait4,977.5937.220,119.11,307.2351.5201.22,072.52.00, 1.92, 2.00
Priority 456,904.21,301.920,135.01,811.1488.2244.72,935.32.75, 2.75, 3.17
Default6,877.11,301.720,136.31,812.3483.4244.92,918.02.75, 2.75, 3.04
1 in flight5,347.5963.320,638.91,259.7310.1165.22,568.92.25, 1.96, 2.04
Spin-wait4,976.1931.020,140.81,306.5353.2204.72,066.92.00, 1.96, 2.04
Default6,907.41,307.820,140.11,812.4482.7245.22,928.02.75, 2.75, 3.00

Copied from docs/performance.md; the default runs with a slow clock are shaded. The Frame stats give the CPU frame and the stages; the chain is the patch's.

default, blocking spin-wait 1 in flight priority 45 same work as spin-waiting, at the chain's speed
Chain at
Stage or system
RunChain ÷ spinMedian ÷ spin

Live demo. The nine runs of the table above, each a point: the chain's median at the chosen point of the frame across, the chosen stage's or system's median up. The dashed line runs from the origin through the two spin-wait runs' means: a run on it does spin-waiting's work at the speed its chain shows. The ratios divide each run's medians by the spin-wait runs' mean. All numbers are the table's; the ratios and the line are this page's arithmetic on them.

What the measurements say about the cause

  • The clock, not the work. The chain does the same work in every frame, so its time is the core's speed. Where it reads 2.75 µs, 1.38 to 1.42 times the spin-wait runs' mean at the same point, the prepare_frame stage and the propagate_world_transforms system read 1.39 and 1.37 times their spin-wait medians: 1,812 against 1,307 µs, and 483 against 352.
  • Blocking, not the GPU's load. Spin-waiting leaves the GPU as busy, two frames in flight and the GPU frame within 40 µs, 20,119 to 20,141 against the default's 20,136 to 20,155, yet the chain stays at 1.92 to 2.04 µs.
  • The wait's length and timing. With one frame in flight the thread blocks for longer, about 15 ms a frame, and the chain is slow at the frame's start, 2.25 to 2.46 µs, but fast again by the end of update, 1.92 to 1.96. The frame then runs while the GPU is idle. With two in flight it runs beside the GPU and stays slow throughout.
  • Not migration. In every run, at most 1 of 1,948 frames started on an efficiency core, and at most 14 changed performance cluster between one frame's render and the next frame's start. With one exception, a few hundred changed core within a cluster, which shares its L2, so the L2 stays warm.
  • Not QoS or priority. The main thread of a process started from a shell reports QOS_CLASS_USER_INTERACTIVE (0x21) from qos_class_self(), but ps -M shows it at priority 31, the default class's, whichever class a QoS call asks for. xnu's task policy has a rule that would produce the gap. For an app-type task, its switch on the task's role ends in a default case whose comment reads "Apps that don't have an application role get USER_INTERACTIVE and USER_INITIATED squashed to LEGACY" (osfmk/kern/task_policy.c, lines 879 to 883, inside the condition at 833 to 834). The patch did not read the process's app type or role, so this fits what was measured without being shown to be the cause. Then the patch tried part of what Apple recommends for a game's render thread. At WWDC18, in Metal Game Performance Optimization, Apple said: "We recommend the render set priority to be fixed to 45" (the transcript's "render set" is the render thread), and "we need to opt out of the scheduler's quality of service in order to prevent priority decay". The patch set priority 45 with pthread_setschedparam under the default time-sharing policy, SCHED_OTHER, which Apple's Tech Talk calls "compatible with QoS classes" and of which it says: "Threads using it may be subject to priority decay." It did not try the round-robin policy, SCHED_RR, whose priority is fixed. Priority 45 took effect and left the stages as slow as the default: CPU frame 6,904.2 µs, chain 2.75, 2.75 and 3.17.
  • Not all of it. The first default run kept a fast clock, its chain 1.96 to 2.04 µs, yet its encode and submit read 283 and 2,710 µs against spin-waiting's 201 to 205 and 2,067 to 2,073, and its CPU frame 5,986. Blocking costs about 1 ms there that the clock does not explain. The measurements do not find what; nor does this guide.

Shipped code takes other routes. SDL gives a high-priority thread on Apple platforms the round-robin policy and priority 45; Godot and Chromium map their thread priorities and types to QoS classes (links in the reading list). On this machine a QoS call left the thread at priority 31, and priority 45 under the time-sharing policy left the clock as slow; the round-robin route was not measured.

A clock that changes run to run

The clock was fast in one run and slow in the next three, with the same binary: the first default run kept it, the three after it did not. The machine also read slower an hour later: when town's Baseline was re-recorded, the blocking build's CPU frame read 8,180 µs where it had read 6,877 to 7,279. No chain ran then, so that drift is the CPU frame's, not a measured clock. With rays off, the GPU frame is shorter but still longer than the CPU frame, and the default runs' chain reads 2.54 µs at the start, the fast first run's included. A CPU line read behind a GPU-bound frame, then, carries a clock that the work does not set, and that does not hold still.

Chapter 5 · PR #400 · FrameWait · bench::run

What the bench does now

Key idea. M3's CPU ceilings price the main thread's work when it is on the critical path, so the bench spin-waits to measure that work at full clock, and the window keeps blocking, leaving the core, its energy and its heat to the frame.

M3's exit criteria hold the CPU to two ceilings: the CPU frame under 6 ms at the median and the p95 (criterion 1), and the dynamic TLAS's CPU line under 1.1 ms at the median (criterion 6), the Budgets beside them in praxis report's tables. They are about the main thread's cost when it is on the critical path, when the frame waits on the CPU. Blocked behind a GPU-bound frame it is not, and its clock falls because it has the time.

The options the owner chose from

OptionWhat it costs
1. The bench spin-waits; the window keeps blocking (chosen)No extra run; the busy-wait burns a P-core for the bench's few minutes only. The CPU lines read the work at full clock and the GPU lines are unchanged. The bench then differs from the window in how it waits.
2. The CPU lines come from a run with one frame in flightThe thread blocks, as Apple asks, and the lines read 4,914 to 5,348 µs and 912 to 963 µs, but the clock's ramp after each wait stays in them, and each scene runs a third time.
3. The engine spin-waits everywhereThe window's CPU frame would read the bench's, against Apple's advice: a busy-wait holds a P-core and spends the thermal headroom a GPU-bound frame needs.
4. No changeThe CPU lines keep reading the slowed clock whenever town is GPU-bound, over both ceilings, for a cost the frame does not pay.

From docs/performance.md's options, shortened.

Apple's advice against option 3 is plain, in the same Tech Talk: "Avoid busy waits. They potentially lock a P core, instead of doing something useful with it. They also prevents the scheduler from promoting a thread from and E to a P core. You are also wasting energy and producing unnecessary heat, eating away your thermal headroom." This guide infers, beyond Apple's words, that a GPU-bound game needs that headroom for its GPU. The bench is not a game: it runs for minutes, and what it must report is the work.

The code

render_frames now takes a FrameWait:

// src/capture/mod.rs at 958ed4e9.
/// How `render_frames` waits for the oldest frame in flight.
#[derive(Clone, Copy)]
pub(crate) enum FrameWait {
    /// Blocks, as a window does in `get_current_texture`.
    Block,
    /// Polls until the frame is done. […]
    Spin,
}

bench::run picks Spin, except on a software adapter:

// src/bench/mod.rs at 958ed4e9, run.
// A software rasterizer's threads would share the CPU with the spin.
let wait = match context.adapter.get_info().device_type {
    wgpu::DeviceType::Cpu => FrameWait::Block,
    _ => FrameWait::Spin,
};
  • CI's lavapipe blocks. Its rasterizer runs on the CPU, where a spinning main thread would take a core from it.
  • Everything else blocks too. capture and capture_frames, and so the Goldens, pass FrameWait::Block; the window never goes through render_frames and waits in get_current_texture as before.
  • One Baseline moved. Only town's M4 Pro Baseline was re-recorded, the only one whose CPU lines moved: every other scene's compare passes on its CPU lines. The RTX 2070 SUPER's Baselines were recorded blocking.

After the change

The re-recorded run against the previous Baseline, from praxis report, in µs:

TimeMedianp95Baseline medianRatioBudget
CPU frame5,597.86,149.28,223.80.6816,000.0
Dynamic TLAS, CPU1,018.91,210.11,566.10.6511,100.0
GPU frame20,153.020,775.020,489.10.98412,000.0

From docs/performance.md's "After the change", rows trimmed, the Baseline's p95 and the Δ columns left out.

The Baseline comes from a full run; two full runs read the CPU frame at 5,529 to 5,598 µs spin-waiting. An interleaved pair from one build, run with --skip-aliasing and switched between the two waits by a patch, reads it at 4,977 to 5,127. Blocking: CPU frame 8,180.0 µs, dynamic TLAS 1,567.8, GPU frame 20,473.1. Spin-waiting: 4,977.2, 943.5 and 20,759.1. That pair's GPU frame is 1.4% longer spin-waiting, where the earlier runs' were within 40 µs. docs/performance.md notes that a busy P-core may take power the GPU would otherwise have, and that judging GPU lines needs that in mind.

What is still over, and still open

  • The CPU frame's p95. Criterion 1 judges the CPU frame at the median and the p95: 5,597.8 µs is under 6 ms, 6,149.2 is not. The dynamic TLAS's ceiling is judged at the median, 1,018.9, under 1.1 ms; its p95 reads 1,210.1.
  • The ticket's 5%. Ticket #381 asked for stages and systems within 5% of their medians when the GPU is idle at the frame's start. Against the runs with one frame in flight, spin-waiting's prepare_frame reads 1,307 against 1,260 to 1,286 µs, within 4%, but the propagate_world_transforms system is up to 14% over and encode 21 to 24% over; submit, 2,067 to 2,073 against 2,129 to 2,569, is under. The ticket's 5% holds for prepare_frame and submit, not for propagate_world_transforms or encode.
  • The remainder. The first default run's ~1 ms that its fast clock does not explain is still unexplained.
Review

Check yourself

Two runs of one build on town read prepare_frame at 1,812 and 1,307 µs, with the GPU frame at 20.1 ms in both. Did prepare_frame get slower in the first?

Its work did not. The chain, a fixed piece of work, read 2.75 µs after update in the first and 1.96 in the second, a ratio of 1.40; prepare_frame's ratio is 1.39. The same work ran on a slower clock: the first run blocked for the oldest frame, the second spin-waited. A CPU line measured behind a GPU-bound frame needs the clock checked before the code is blamed.

Spin-waiting kept the clock up. Why does the window not spin-wait too?

Behind a GPU-bound frame the CPU is not on the critical path, so a slower clock costs the frame nothing, while a busy-wait holds a P-core, can keep the scheduler from promoting a thread from an E to a P core, and wastes energy and thermal headroom, as Apple's Tech Talk warns; headroom that, this guide infers, a GPU-bound frame needs. The ceilings price the work, which the bench measures by spinning; the frame the player sees is set by the GPU either way.

With one frame in flight the thread still blocks, as Apple asks, and its CPU frame read 4,914 to 5,348 µs, close to spin-waiting's. Why not read the CPU lines from such a run?

That was option 2. The clock is slow at the frame's start, the chain at 2.25 to 2.46 µs, and recovers only by the end of update, so the ramp stays in the lines. The frame also runs while the GPU is idle, which is not how the engine runs, and each scene would need a third run.

Reference

Reading list

Grouped by source, each with what it is good for here. Code links are pinned to 958ed4e9.

This ticket

In the repo

  • Ticket #381 and PR #400 the slowed stages, the options, the owner's choice and the change
  • docs/performance.md the section of 2026-10-30: every measurement on this page, the nine runs and the tables after the change
  • Ticket #378 the split of submit that left the CPU outrunning the GPU on town

The code, at 958ed4e9

Earlier volumes

Apple, the kernel and other engines

Books

  • Jason Gregory, Game Engine Architecture, 3rd ed., chapter 4, Parallelism and Concurrent Programming, §4.4 Operating System Fundamentals and §4.6 Thread Synchronization Primitives threads, the scheduler, and how a thread waits
  • The same book, chapter 8, The Game Loop and Real-Time Simulation, §8.6 Multiprocessor Game Loops a game loop split across threads and processors. Chapters and sections from the book's contents.
Reference

Glossary

Reference scene and Baseline are CONTEXT.md's definitions; Budget and the Frame stats tooltip are its definitions, shortened; frames in flight quotes the code. The rest are this guide's own.

Frames in flight
Frames submitted but not finished on the GPU when the next one starts, at most: the window's swapchain latency and capture's queue. Two in Praxis.
Blocking wait
A wait in which the thread sleeps until it is signalled, leaving its core; the window's, capture's and so the Goldens', and the bench's before PR #400.
Spin-wait
A wait in which the thread polls in a loop on its core until the frame is done; a busy-wait. The bench's on a hardware adapter since PR #400.
Cluster
A group of cores of one kind that share an L2 cache and a frequency; this M4 Pro has two clusters of four performance cores and one of four efficiency cores.
Fixed chain
A piece of work whose steps each need the one before, run unchanged at chosen points, so its time reads the core's speed.
QoS class
A thread's quality-of-service class on Apple's systems, such as user-interactive; the kernel can cap it below what the thread asks for.
Critical path
The work that sets the frame's length; behind a GPU-bound frame, the GPU's, not the main thread's.
Reference scene
A Scene paired with a fixed camera path and injected time, so that every run produces the same frames. Used for Goldens and Baselines.
Baseline
The recorded measurements of a Reference scene on one adapter and backend: the reference machine, where Budgets are judged against it; CI's lavapipe, where only its counters are judged; or the RTX 2070 SUPER, the hardware floor benched for comparison, on Vulkan and on DX12.
Budget
A numeric limit on one metric of a Reference scene (CPU ms, GPU ms, GPU memory, Input latency) that a change must not regress. (Shortened.)
Built from ticket #381, PR #400 at 958ed4e9, docs/performance.md, CONTEXT.md and the sources in the reading list. Every measurement is docs/performance.md's own, on one Apple M4 Pro. The timeline demo is this page's model; the runs demo plots the measured table.