Skip to content

Metal training: per-step host waits leave the GPU idle (planning fixes) #3905

Open

Description

@oli-har

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-bench reports 0.06 ms of data-loader wait per step.
  • A sample of the trainer at the cap shows the training thread waiting on the GPU at four places:
    1. 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_ where deferred_fast_count is set).
    2. Crop box view damping (24%, every step). install_cropbox_view_damping → roi_reject_mask uploads four tiny constants with Tensor::from_vector/.to(GPU). On Metal, a synchronous host-to-device copy waits for the last GPU use of the recycled destination buffer.
    3. Loss readback (11%, every 10th step). submitLossReadback joins two scalars with Tensor::cat, which copies with synchronous = true.
    4. Thin-structure denominator (7%, every step). structure_base_denominator reads sum().item<float>(), which waits for the whole device.

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/GPUEndTime from the MTL4 commit feedback. We'll test on garden with the eval/ scripts and on our tile, alternating before/after runs.
  • Then one PR per fix:
    1. 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.
    2. Pass the crop-box constants to roi_masks.slang as kernel parameters instead of uploading them.
    3. Read back the loss without cat.
    4. 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:


🤖 Generated with Claude Code

Activity

  1. MrNeRF commented on Oct 29, 2026

    @MrNeRF
    Owner

    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:

    • The persistent updates (the grid's step_image, PPISP's optimizer_step and scheduler_step) run after the resolve, and a Retry returns before them.
    • The bilateral grid's backward zeroes its slice gradient on every call.
    • The gap is PPISP: PPISP::backward_in_place accumulates into exposure_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_masks parameter 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 avoiding cat in 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.py and the error-debt census before each one.

  2. oli-har commented on Oct 30, 2026

    @oli-har
    Author

    Thanks for the guidance. We followed it, and found one more wait on the way. Each fix is its own branch from dev 2849c644 and will be its own PR:

    • Deferred count with exposure correction on (item 1, option b). The grid's and PPISP's updates already run after the resolve, so a replay only has to zero PPISP's gradients first; controller-phase steps stay synchronous. Resolve the Metal FastGS instance count at the commit instead of the forward #2867's forced-overflow test also runs with both on.
    • Each bilateral grid slot gets its own storage (new). With both slots in one tensor, writing back the slot being replaced waited on Metal for the step still using the other one.
    • Synchronous Metal uploads stage instead of waiting (item 2), in MetalBackendOps::copy_host_to_device.
    • The thin-structure denominator is kept per camera (item 4), keyed on the mask and its settings and, for a camera outside the crop box, the crop box and its loss weight.
    • Tensor::cat copies 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.

Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Metadata

Metadata

Assignees

No one assigned

    Labels

    No labels
    No labels

    Projects

    No projects

      Milestone

      No milestone

      Relationships

      None yet

      Development

      No branches or pull requests

      Issue actions