Skip to content

v1.1.4: bound depth-chunk K_max by the per-tile cap - #7

Merged
rayanht merged 3 commits into
mainfrom
perf/chunked-rast-kmax
Aug 25, 2026
Merged

v1.1.4: bound depth-chunk K_max by the per-tile cap#7
rayanht merged 3 commits into
mainfrom
perf/chunked-rast-kmax

Conversation

@rayanht

@rayanht rayanht commented Aug 25, 2026

Copy link
Copy Markdown
Owner

What

K_max, the number of 512-gaussian depth chunks a tile is split into, was derived
from capacity (num_points * 16): a buffer size, not an intersection count.
scatter_to_prealloc_bins_kernel clamps every tile counter to MAX_TILE_ELEMS,
so 4 chunks cover any tile. It was dispatching ~190, writing hundreds of MB of
chunk buffers and near-empty threadgroups every iteration.

Chunking only engages below 400 tiles, so this affects downscaled steps only.

Numbers

garden/bicycle/counter/room 7K, --num-downscales 2 (the CLI default), M4 Max,
mean of 2 interleaved reps:

Scene v1.1.3 v1.1.4 Speedup
bicycle 46s 33s 1.42x
counter 40s 41s 0.98x
garden 44s 36s 1.22x
room 38s 39s 0.98x

PSNR/SSIM unchanged on all four (bicycle 23.38/0.605, counter 27.35/0.871,
garden 24.93/0.716, room 29.9/0.891). bicycle gains most: most gaussians, fewest
tiles, so K_max was most inflated. counter and room sit at 425 tiles at 1/4
resolution and never chunk.

--num-downscales 0 is unaffected — measured on garden 7K, 79.0s → 79.6s.

Also fixed

  • ensure_chunks never resized on a resolution change. It compared ih/iw
    against img_height/img_width, which ensure_forward updates earlier in the
    same step, so the guard was tautological. Chunk buffers kept the previous
    resolution's size. Reachable whenever chunking is active at more than one
    resolution; garden dodges it because its 1/2-resolution phase has 1107 tiles and
    takes the monolithic path.
  • Packed buffers sized by a provable bound. num_tiles * MAX_TILE_ELEMS
    replaces num_points * 16, which bounded nothing — a gaussian's tile footprint
    is unclamped, so the sort could write past the end of gaussian_ids /
    packed_xy_opac / packed_conic / packed_rgb.
  • Backward transmittance for capped gaussians. Both backward rasterizers
    guarded the whole gradient block on alpha < 0.999f, skipping T *= 1/(1-alpha)
    but the forward multiplied T by (1 - alpha) using that same capped alpha, leaving T 1000x too small for every nearer gaussian on the pixel. The cap now zeroes only the sigma-routed gradients (v_conic, v_xy, v_opacity). Quality-neutral on garden 7K.
  • PROFILE_STAGES reported real times. It applied the mach timebase to
    MTLCounterResultTimestamp values that are already nanoseconds, scaling every
    stage by 41.67x. Stage totals now reconcile with command-buffer GPU time.

Tooling

  • Shaders build with -frecord-sources -gline-tables-only, so a .gputrace
    carries shader source and per-line cost in Xcode. Verified with
    xcrun metal-source. Neither flag changes codegen; measured free (30.5s vs
    30.8s on garden).
  • msplat_begin_gpu_capture / msplat_end_gpu_capture write a .gputrace
    document. CLI exposes them via MSPLAT_GPUTRACE, MSPLAT_GPUTRACE_STEP,
    MSPLAT_GPUTRACE_ITERS; needs METAL_CAPTURE_ENABLED=1. Both edges sync so the
    window holds whole command buffers under commitAndContinue.
  • BENCHMARK=1 reports per-resolution-phase timings, refine/densify share, and
    intersection counts against the per-tile cap.

Known issues

  • counter and room are ~2.5% slower, consistent across both reps on both
    scenes. Suspected cause is the capacity bound, not K_max: for counter at full
    resolution num_tiles * 2048 = 13M against num_points * 16 = 6.8M at ~424K
    gaussians, so the packed buffers roughly double during the 1x phase. garden and
    bicycle have enough gaussians that the old term dominated and nothing changed.
    Sizing from what the GPU last reported needing would be sound and tight, but
    that would be an additional readback + capacity clamp in the sort kernel.

Not addressed

  • The MAX_TILE_ELEMS = 2048 per-tile cap saturates (max=2048 from ~step 2500)
    at both --num-downscales 0 and 2, so gaussians are being dropped. The dropped
    set is chosen in atomic arrival order before the depth sort, and a dropped
    gaussian still increments vis_counts while contributing nothing to
    xys_grad_norm, biasing avg_grad down in exactly the tiles that are starved.
  • saturate() on the forward output has no matching mask in the backward, so
    gradients are nonzero where the composite clips.

h/t @frs0n for finding two of these bugs in frs0n/msplat-ios:

  1. The packed-buffer overflow faulting the GPU and killing the Metal
    context (iPhone 15 Pro, 20852 gaussians, 906292 slots needed against 333136
    allocated)
  2. The ensure_chunks resize bug worth PSNR 20.56 → 21.68 on a
    4-downscale-level config.

K_max, the number of 512-gaussian depth chunks a tile is split into, was
derived from capacity (num_points * 16) — a buffer size, not an intersection
count. scatter_to_prealloc_bins clamps every tile counter to MAX_TILE_ELEMS,
so 4 chunks cover any tile; it was dispatching ~190, writing hundreds of MB
of chunk buffers and near-empty threadgroups per iteration.

garden 7K at the default 2 downscales: 44s -> 36s. Full-resolution runs never
chunk (num_tiles >= 400), so --num-downscales 0 is unchanged.

Also fixed:
- ensure_chunks compared against img_height/img_width, which ensure_forward
  had already updated, so chunk buffers never resized on a resolution change.
- Packed buffers sized by num_tiles * MAX_TILE_ELEMS, a provable bound;
  num_points * 16 bounded nothing, since tile footprint is unclamped.
- Backward rasterizers skipped T *= 1/(1-alpha) when alpha hit the 0.999 cap,
  leaving T too small for every nearer gaussian on the pixel. The cap now
  zeroes only the sigma-routed gradients, which are genuinely zero there.
- PROFILE_STAGES applied the mach timebase to counter timestamps already in
  nanoseconds, scaling every stage by 41.67x.

Tooling: shaders build with -frecord-sources -gline-tables-only so a
.gputrace carries shader source and per-line cost; msplat_begin/end_gpu_capture
plus MSPLAT_GPUTRACE* write one from the CLI; BENCHMARK=1 reports
per-resolution-phase timings and intersection counts against the per-tile cap.
@rayanht
rayanht merged commit b254d4d into main Aug 25, 2026
10 checks passed
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

1 participant