Repository navigation
Metal training: per-step host waits leave the GPU idle (planning fixes) #3905
Copy link
Copy link
Open
Description
I've been training with LichtFeld Studio on a Mac Studio (M3 Ultra, macOS 27.0.1, Metal 4). The GPU isn't fully used during training, and we'd like to work on that. This issue describes what we measured and what we plan to change, so you can say if any of it clashes with your plans.
Measurements (dev build of 2026-10-28; a 126-image 5698×4128 drone tile; MRNF with depth and normal losses, masks, a crop box and --exposure-correction):
- GPU busy (
ioreg"Device Utilization", 1 s samples): 92.6% in steps 0-6,780 (111 ms/step), 96.5% at a 48.4M cap (300-330 ms/step), about 97% at 100M+ splats (650 ms/step). - Image loading isn't the limit:
--perf-benchreports 0.06 ms of data-loader wait per step. - A
sampleof the trainer at the cap shows the training thread waiting on the GPU at four places:- FastGS instance count in the forward (51% of its time, every step). The deferred count from Resolve the Metal FastGS instance count at the commit instead of the forward #2867 is off here because the bilateral grid and PPISP are on (
!bilateral_grid_ && !ppisp_wheredeferred_fast_countis set). - Crop box view damping (24%, every step).
install_cropbox_view_damping→roi_reject_maskuploads four tiny constants withTensor::from_vector/.to(GPU). On Metal, a synchronous host-to-device copy waits for the last GPU use of the recycled destination buffer. - Loss readback (11%, every 10th step).
submitLossReadbackjoins two scalars withTensor::cat, which copies withsynchronous = true. - Thin-structure denominator (7%, every step).
structure_base_denominatorreadssum().item<float>(), which waits for the whole device.
- FastGS instance count in the forward (51% of its time, every step). The deferred count from Resolve the Metal FastGS instance count at the commit instead of the forward #2867 is off here because the bilateral grid and PPISP are on (
These are shares of CPU waiting, not of GPU idle time. The GPU idles in the gaps after each wait.
Plan:
- Measure the GPU idle time per step and attribute it to the waits, using each batch's
GPUStartTime/GPUEndTimefrom the MTL4 commit feedback. We'll test on garden with theeval/scripts and on our tile, alternating before/after runs. - Then one PR per fix:
- Use the deferred count with the bilateral grid and PPISP on: guard their persistent updates with the GPU-side count check the way Resolve the Metal FastGS instance count at the commit instead of the forward #2867 guards the other consumers, or encode those updates after the count is resolved.
- Pass the crop-box constants to
roi_masks.slangas kernel parameters instead of uploading them. - Read back the loss without
cat. - Cache the denominator per camera, or pass it to the kernel on the GPU.
We expect a few percent on our tile, and probably more on scenes with smaller images, like garden.
Questions:
- For @MrNeRF (author of Resolve the Metal FastGS instance count at the commit instead of the forward #2867): why were the bilateral grid and PPISP left out of the deferred count, and which approach would you prefer?
- Benchmark settings: which garden settings gave the 59.3 → 55.7 s in Resolve the Metal FastGS instance count at the commit instead of the forward #2867, so our numbers can be compared directly?
- Duplicate work: is anyone already working on any of these?
🤖 Generated with Claude Code
Metadata
Metadata
Assignees
Labels
No labels
Activity
MrNeRF commentedon Oct 29, 2026
Thanks, this is a careful write-up. I checked the four waits against dev (50a1c5e), and they all match.
One requirement for all four fixes: they have to stay behind the Tensor facade, so they work on every backend (Metal, Vulkan, CUDA and HIP). Fix each wait where the facade causes it, not with Metal-only paths in the trainer.
We're also evaluating #3844, which reworks the upload, readback, completion and queue services that all four waits go through. Please build and measure on top of it, or at least check each fix against it, so the same wait isn't fixed twice.
1. Deferred count with the bilateral grid and PPISP. When #2867 landed, exposure correction was still opt-in. #2963 made it an MRNF default a day later, so default Metal training has waited on the count every step since. That explains your 51%.
#2867 kept those iterations synchronous, but most of what's needed is already in place:
step_image, PPISP'soptimizer_stepandscheduler_step) run after the resolve, and aRetryreturns before them.PPISP::backward_in_placeaccumulates intoexposure_grad_and its other gradient buffers before the count is resolved, so a replay would add the failed attempt's gradients again.So I'd go with your second option: keep the updates after the resolve, zero the PPISP gradients on replay, and leave controller-phase iterations synchronous. Please extend #2867's forced-overflow test (capacity 1) with exposure correction on, and check that the replay matches a large-capacity run.
The deferral is opt-in through the FastGS ops table, and only Metal provides it today. Keep the trainer side backend-neutral, so other backends can opt in the same way.
2. Crop-box constants. Passing the matrix and the camera centre in the
roi_masksparameter block is fine, since that kernel is single-source Slang. The underlying problem, a small synchronous upload waiting on a recycled buffer, is in the facade's upload path and will hit other callers too. Please fix it there.3. Loss readback. The same goes for
Tensor::cat, which waits on the host once per input on Metal. Please fix that in the Tensor library instead of avoidingcatin the trainer.4. Denominator. No per-step
item(). Either cache it per camera, keyed on the camera, its mask and the crop box (the crop box can change while training runs in the GUI), or keep it on the device.Benchmark: the 59.3 → 55.7 s was GUI training on garden
images_4, 7,000 iterations, median of 3, on an M5 Max, before #2963. Today's MRNF defaults include exposure correction, so please measure with it both on and off. Alternate the before and after runs: thermals moved the same headless run by about a third on my machine.Duplicate work: nobody else is working on these. #3893 also touches
trainer.cpp, so expect some rebasing.One PR per fix into dev works for me. Please run
tools/check_backend_neutrality.pyand the error-debt census before each one.oli-har commentedon Oct 30, 2026
Thanks for the guidance. We followed it, and found one more wait on the way. Each fix is its own branch from dev
2849c644and will be its own PR:MetalBackendOps::copy_host_to_device.Tensor::catcopies without a host wait per input (item 3).Alone, each one hardly changes the step time on the Mac, because the next wait in the same step takes over. Together, on our tile (M3 Ultra, steps 0-3,000, mean of two runs): 89.64 → 72.06 ms per step (-19.6%), with the GPU going from about 89% to 97% busy. At the 48M cap: 297.46 → 281.91 (-5.2%). Garden at 7,000 steps with exposure correction on: 8.670 → 8.642 (-0.3%), since its GPU is already 97-99% busy. Losses and PSNR stay within the run-to-run spread. Most of what is left on the tile is the trainer resolving the deferred count at the end of the backward, while the GPU is still busy.
#3926 (split from #3844) and #3930 are on dev now. They don't touch the code the five change, and all five branches merge cleanly with today's dev (
20d82adf).The deferred count and the upload staging only change what Metal runs, so their PRs come first. The other three also change what CUDA and Vulkan run, so they follow once our PC (RTX 5090) has run both suites and the same benchmarks on them.
Stage synchronous Metal uploads into storage the GPU still uses (#3934)
Defer the FastGS instance count with exposure correction on (#3933)