What the 3DGS Literature Left on the Table

Five self-contained CUDA optimizations for 3D Gaussian Splatting

Since its introduction by Kerbl & Kopanas et al. (SIGGRAPH 2023) [1], 3D Gaussian Splatting (3DGS) has become one of the most heavily optimized GPU pipelines in computer graphics and vision. I still remember, even before we started working on the original 3DGS paper, writing my first differentiable software rasterizers: a single forward pass took 500 ms and could barely handle 1.5 million points. I was doing my best, and in every weekly meeting my supervisor, George Drettakis, would insist: “I don't know how to do it, that's for you to figure out, but there is no way this is the fastest a GPU can render 1.5 million points.” His intuition gave him the conviction that kept us going, and I only realized how invaluable that was later, when that early code was transformed with the help of Bernhard Kerbl into the published kernels of the Inria implementation. When we released those kernels, it was hard to imagine that within three years they could be made another ~7× faster, as we show in this post. Yet what surprised me even more is that even after combining all the optimizations proposed since then by students, academics, and engineers across the community, we are still not hitting the hardware ceiling—another 1.78× speedup on the full forward-backward training step remains on the table. In this post, we explore where that remaining headroom comes from and how to take advantage of it. I wrote this for graphics and machine-learning practitioners, whether you are working on 3DGS itself or want a concrete, end-to-end case study of how to optimize a non-trivial GPU pipeline from first principles. I assume basic prior knowledge of 3DGS and some familiarity with the technical details of the original CUDA implementation, while explaining the GPU hardware concepts we need right before we use them.

To establish a rigorous literature baseline, we consolidate five families of optimizations from recent publications and open-source repositories into a single package, s01_literature, drawing on DISTWAR [2], Taming 3DGS [3], gsplat [4], StopThePop [5], FlashGS [6], Speedy-Splat [7], Splatshop [8], Faster-GS [9], HiGS [10], Yang et al. [11], and LichtFeld Studio (fastgs) [12]. Together, they deliver a 4.09× speedup at 30k (4.53× at 7k) over Inria across the 9 Mip-NeRF 360 scenes. This consolidated baseline is already faster than any existing open-source implementation we could find, outperforming the standalone LichtFeld Studio (fastgs) [12] rasterizer by 1.67× at 30k (1.63× at 7k).

Where does the remaining 1.78× speedup come from? Once those literature techniques are in place, profiling reveals a second tier of bottlenecks rooted in how the kernels map onto the GPU's warp execution model, register file, L2 cache sectors, and arithmetic execution pipes. In the rest of this post, we will first recap and explain all the optimizations we incorporated from the literature (s01_literature), and then walk through five self-contained optimizations (s02–s06) that address these hardware bottlenecks one by one. Each step lives in its own standalone package with a direct code diff against the previous step, reaching our 7.30× end-to-end speedup at 30k (8.38× at 7k) over Inria [1] and gsplat [4] (and 2.97× over LichtFeld Studio [12]) with identical image quality.

Implementation — fwd (ms)Forward Rendering (A100) garden7k / 30k room7k / 30k all mean7k / 30k Speedup vs. Inria7k / 30k
gsplat [4] 11.52/12.01 18.88/15.50 15.06/13.97 0.32×/0.44×
Inria 3DGS [1] 5.85/7.42 4.87/6.08 4.89/6.15 1.00×/1.00×
LichtFeld Studio (fastgs) [12] 3.53/4.02 2.08/2.24 2.53/2.91 1.93×/2.12×
Inria + Literature (s01) 2.58/2.98 1.85/1.72 1.98/2.18 2.47×/2.82×
Ours (s06, float32 SH) 2.35/2.73 1.46/1.54 1.78/2.03 2.75×/3.03×
Ours (s06, bfloat16 SH) 2.29/2.77 1.50/1.53 1.74/1.98 2.82×/3.11×
Summary of headline results. Synchronous latency in milliseconds of the forward pass (fwd, top) and of the full training step (fwd-bwd, bottom) at the 7k / 30k checkpoints on one NVIDIA A100-SXM4-40GB, for garden, room, and the mean over all 9 Mip-NeRF 360 scenes. Each value is the median over 3 independent jobs of the per-job mean latency across all test views; speedups are ratios of the 9-scene means. Rows in each table are ordered by descending 30k mean latency and comprise gsplat [4], Inria 3DGS [1], LichtFeld Studio (fastgs) [12], the consolidated literature baseline (s01), and our final kernel (s06) with float32 and bfloat16 SH storage. All of our optimizations (s00–s06, in both float32 and bfloat16) reproduce the exact same PSNR as Inria down to 0.01 dB (27.17 dB at 7k, 28.90 dB at 30k).
Implementation — fwd-bwd (ms)Forward + Backward (A100) garden7k / 30k room7k / 30k all mean7k / 30k Speedup vs. Inria7k / 30k
gsplat [4] 25.05/27.90 26.27/22.86 24.13/24.33 1.00×/0.99×
Inria 3DGS [1] 24.84/25.90 28.69/26.09 24.18/24.04 1.00×/1.00×
LichtFeld Studio (fastgs) [12] 11.53/13.02 7.64/7.68 8.70/9.80 2.78×/2.45×
Inria + Literature (s01) 7.07/8.17 5.03/4.77 5.34/5.87 4.53×/4.09×
Ours (s06, float32 SH) 4.56/5.28 2.62/2.67 3.20/3.68 7.55×/6.54×
Ours (s06, bfloat16 SH) 4.05/4.61 2.45/2.47 2.89/3.29 8.38×/7.30×
Background: NVIDIA A100 (GA100) Hardware Model
click to expand ▾ click to collapse ▴

Collapsible section (click to expandcollapse) — SM sub-partitions, warp schedulers, FMA/ALU/MUFU/LSU execution pipes, L1 and shared memory, 32-byte L2 cache sectors, write-through stores, and ROP atomics.

40 GB HBM2 Off-Chip Memory (1,555 GB/s Peak · ~400–600 Cycles Latency · 32-Byte Sector Granularity) 5.83M Gaussians × 236 Bytes = 1.38 GB of scene parameters (~0.89 ms minimum just to read once at peak HBM2 bandwidth) 40 MB Shared L2 Cache (Split into 80 Parallel Slices · 128-Byte Cache Lines = 4 × 32-Byte Sectors) Global writes skip L1 and go straight to L2 in 32-byte sectors; atomicAdds to the same address queue up at one slice Inside One of the 108 Streaming Multiprocessors (SMs) · Up to 64 Resident Warps (2,048 Threads) Sub-Partition 0 1 warp scheduler issues 1 warp instruction / cycle 16,384 × 32-bit registers (64 KB) 16 FP32 · 16 INT32 · 8 LSU · 4 MUFU units Sub-Partition 1 1 warp scheduler issues 1 warp instruction / cycle 16,384 × 32-bit registers (64 KB) 16 FP32 · 16 INT32 · 8 LSU · 4 MUFU units Sub-Partition 2 1 warp scheduler issues 1 warp instruction / cycle 16,384 × 32-bit registers (64 KB) 16 FP32 · 16 INT32 · 8 LSU · 4 MUFU units Sub-Partition 3 1 warp scheduler issues 1 warp instruction / cycle 16,384 × 32-bit registers (64 KB) 16 FP32 · 16 INT32 · 8 LSU · 4 MUFU units SM-Wide Pipe Throughput (4 Sub-Partitions Combined) FMA Pipe FP32 add/mul/fma 64 threads / cycle = 2 warp instr / cycle ALU Pipe INT32, cmp, sel 64 threads / cycle = 2 warp instr / cycle MUFU Pipe ex2, rcp, rsqrt 16 threads / cycle = 0.5 warp instr / cycle LSU & Shuffle ld/st, __shfl_* 32 threads / cycle = 1 warp instr / cycle 192 KB On-Chip SRAM (~20–30 Cycles) L1 data cache hardware-managed caches global loads Shared memory programmer-managed __shared__, up to 164 KB one SRAM, split configurably · 32 banks × 4 B / cycle
Figure 1: The memory hierarchy and compute layout of the NVIDIA GA100 GPU in the A100-SXM4-40GB. Top: 40 GB of HBM2 feed a 40 MB L2 cache, split into 80 slices, that all 108 SMs share; global atomics execute inside the L2, and global writes travel to it in 32-byte sectors. Bottom: the inside of a single SM. Each of its four sub-partitions has its own warp scheduler, which issues one warp instruction per cycle, and its own 64 KB register file. The lower row adds the four sub-partitions together into the throughput of each pipe for the whole SM, next to the 192 KB of on-chip SRAM that the SM splits between L1 cache and shared memory. Grey boxes hold data, white boxes compute; the slate line marks the atomic serialization that dominates the baseline.

Every performance cliff we encounter in 3D Gaussian Splatting, and every speedup we achieve across Steps 1 through 6, traces directly back to how the NVIDIA GA100 (A100-SXM4-40GB) schedules instructions inside its 108 Streaming Multiprocessors (SMs) and moves data through its two-level cache hierarchy.

The Streaming Multiprocessor (SM): SIMT Warps, Register Files, and Disjoint Execution Pipes

An A100 GPU contains 108 SMs clocked at up to 1.41 GHz. Each SM contains four independent processing blocks (sub-partitions), each with its own warp scheduler and register file (65,536 32-bit registers per SM in total). The hardware partitions the threads of a thread block into 32-thread warps that execute a single instruction stream in Single-Instruction, Multiple-Thread (SIMT) lockstep. Throughout this post we denote the 32 threads of a warp by T0..T31. Because each SM can hold up to 64 resident warps (2,048 threads) but only 65,536 registers, the number of 32-bit registers allocated per thread directly governs how many warps can reside on an SM at once to hide memory and instruction latency: only a kernel using at most 32 registers per thread can sustain full occupancy (64 warps/SM), whereas holding large local arrays per thread forces the compiler (ptxas) either to allocate more registers, throttling warp residency, or to spill excess live values into slow local memory in L1/L2.

Each of the four sub-partitions has its own warp scheduler, and every cycle each scheduler can issue one warp instruction, that is, one operation for all 32 threads T0..T31. The SM as a whole therefore issues up to four warp instructions per cycle. Each instruction goes to one of several separate functional units, or pipes: the FMA pipe for floating-point math, the ALU pipe for integer arithmetic, comparisons, and predicate logic, the MUFU pipe for transcendentals such as exponentials and reciprocals, and the LSU pipe for memory accesses and cross-thread shuffles. How fast a pipe drains those instructions depends on how many threads it can execute per cycle. A sub-partition has 16 FP32 units, so one 32-thread FMA instruction occupies its FMA pipe for two cycles; summed over the four sub-partitions, the SM completes 64 FP32 thread operations, or two warp instructions, per cycle, and the ALU pipe has the same width. The LSU pipe is half as wide, one warp instruction per cycle per SM, and the MUFU pipe a quarter, half a warp instruction per cycle. Four instructions per cycle is thus a ceiling the SM reaches only when it spreads work across several pipes. Because the pipes are independent, a loop made mostly of floating-point math bottlenecks on the FMA pipe while the ALU pipe idles, and a loop made mostly of integer indexing, bitmask manipulation, and predicate tests bottlenecks on the ALU pipe no matter how much floating-point work we remove.

Branches interact with this lockstep execution in two ways, and Step 5 relies on both. First, all 32 threads T0..T31 of a warp share a single program counter, so a branch only saves work when all 32 threads take the same side. When 8 pixels of a 32-pixel warp fall inside a small Gaussian ellipse and 24 fall outside, an early-exit branch such as if (!hit) continue; does not make the warp any faster: the 8 active threads still step through every instruction of the loop body while the other 24 wait. Second, the branch costs time even when no thread diverges. Its mere presence forces the compiler to wrap the loop body in extra control-flow instructions: BSSY before the branch to record where diverged threads will later rejoin, BRA for the conditional jump itself, and BSYNC at the end of the loop body to re-synchronize the warp. Every warp executes these instructions on every iteration, whether its threads split or not. They also cut one straight-line loop body into several short basic blocks, and the compiler cannot move independent loads and math across those block boundaries to hide their latency.

The Memory Hierarchy: Shared Memory, 32-Byte L2 Sectors, Write-Through Stores, and ROP Atomics

Next to its registers, each SM has 192 KB of on-chip SRAM (~20–30 cycles away) split between the hardware-managed L1 data cache and up to 164 KB of programmer-managed shared memory (__shared__, organized into 32 four-byte banks delivering 128 B/cycle). Shared memory is what makes tile-based rasterization fast: instead of every pixel thread fetching each Gaussian from L2, the 256 threads of a 16 × 16 tile cooperatively stage a batch of Gaussians into shared memory once, and when all 32 threads of a warp read the same Gaussian, the hardware broadcasts the shared-memory word in a single conflict-free access.

Outside the SMs sits a 40 MB L2 cache (~200 cycles away, backed by 1,555 GB/s HBM2) partitioned into 80 parallel hardware slices. Each slice stores 128-byte cache lines subdivided into four 32-byte sectors (8 floats)—the minimum unit transferred between an SM and L2. While global loads cache in L1, global stores are write-through: every warp store immediately sends each modified 32-byte sector to L2. When T0..T31 write 32 adjacent floats (128 B), they fill four complete sectors at 100% efficiency; when they write one float per Gaussian at a 192-byte stride, each 4-byte store touches a separate 32-byte sector, sending 8× more L2 traffic than payload.

Finally, global floating-point atomicAdd instructions execute inside the Raster Operation (ROP) unit of each L2 slice. Throughput depends on whether atomics hit the same 32-bit address: atomics to different cache lines spread across the 80 slices, and atomics to distinct floats within the same cache line pipeline without dependency stalls (as in Step 2, where 18 threads update 18 distinct gradient floats at once). Only atomics to the exact same 32-bit address serialize strictly one after another—which is why the baseline rasterizer stalls for hundreds of cycles when up to 256 pixel threads atomically add into the same Gaussian's gradients, and why Step 1 first reduces across the warp in registers.

Pipeline Architecture & Benchmark Setup

The Five-Stage Differentiable 3DGS Pipeline

In 3D Gaussian Splatting, a scene is represented by a set of N anisotropic 3D Gaussians rendered into a W × H image partitioned into a grid of 16 × 16 pixel tiles. Each Gaussian i is defined by 59 trainable parameters: a world-space mean μi ∈ ℝ3, log-scale si ∈ ℝ3, rotation quaternion qi ∈ ℝ4, logit-opacity oi ∈ ℝ, and 16 degree-3 Spherical Harmonics (SH) coefficients per RGB channel (ci ∈ ℝ16×3). Every package in our progression executes a five-stage differentiable pipeline:

  1. Stage 1 — Forward Preprocess (preprocess): Launches one thread per 3D Gaussian across all N primitives. Each thread transforms its Gaussian into camera coordinates ti = Rcamμi + tcam, projects its mean to a 2D pixel center μ′i and its 3D covariance into a 2D screen-space EWA covariance Σ′i = JiWΣiW⊤Ji⊤ + 0.3I with inverse covariance (conic) Λi = (Σ′i)−1 = [a b; b c], shades its view-dependent RGB color ci via its degree-3 SH polynomial, and counts how many 16 × 16 screen tiles its 𝛼 ≥ 1/255 footprint intersects.
  2. Stage 2 — Tile Binning & Depth Sorting (binning): Because each visible Gaussian overlaps a variable number of screen tiles, building per-tile splat lists in parallel requires packing all P (tile_id, gaussian_id) intersection pairs into a contiguous buffer. This stage runs a parallel prefix sum over the per-Gaussian tile counts from Stage 1 to assign each Gaussian its starting write offset in that buffer, emits one pair per intersected tile, and radix-sorts the buffer by tile ID and depth so that every 16 × 16 tile receives a contiguous, front-to-back list of Gaussians.
  3. Stage 3 — Forward Tile Rasterization (raster_fwd): Launches one thread block per 16 × 16 tile, loads batches of 2D splats into shared memory, and blends them front-to-back at each pixel. For each splat k, the pixel evaluates the splat's 2D Gaussian weight αk, accumulates its color C += αk Tk−1 ck, and updates its transmittance Tk = (1 − αk) Tk−1 starting from T0 = 1. Splats with αk < 1/255 are skipped, and the pixel stops once Tk drops below 10−4.
  4. Stage 4 — Backward Tile Rasterization (raster_bwd): Walks each tile's splat list in exact reverse order from the last splat that contributed in Stage 3 down to the first, recovers each splat's incoming transmittance via exact division Tk−1 = Tk / (1 − αk), and accumulates partial derivatives into the 9 screen-space attributes per splat: 2D center (μ′x, μ′y), conic (a, b, c), opacity o, and RGB color (r, g, b).
  5. Stage 5 — Backward Preprocess VJP (preprocess_bwd): Launches one thread per 3D Gaussian across all N primitives to read its 9 accumulated screen-space gradients and apply the chain rule (vector-Jacobian product) through the 2D projection, covariance, and degree-3 SH polynomial, writing all 59 parameter gradients per Gaussian.

Setting Up the Benchmark

To isolate the hardware impact of each transformation, we structured our CUDA implementation as seven self-contained, progressive packages, s00_baseline through s06_multirow_and_pipe_math. The first, s00_baseline, is a faithful re-implementation of Inria's CUDA rasterizer [1] in our benchmark harness. It matches Inria's rendered PSNR to 0.01 dB and closely tracks its latency, and it is structured so that each later package adds one step of optimizations on top of the previous one, which lets us measure every step in isolation. All speedups against Inria in this post are nevertheless computed against Inria's own measured latency. We verify every package against a dense brute-force reference (max_abs ≤ 2e-5 on forward rendered images and rel_l2 ≤ 1e-4 on all 59 parameter gradients) and benchmark all 9 Mip-NeRF 360 scenes at both the 7,000- and 30,000-iteration checkpoints on a single NVIDIA A100-SXM4-40GB GPU.

We report end-to-end latencies for the forward pass (fwd) and the full forward + backward training step (fwd-bwd), as well as per-stage CUDA-event latencies. All latencies are synchronous, where the host waits for the GPU to finish after every step. Before each profiled step we run 20 unprofiled fwd-bwd warm-up steps, so that the SM clock stays at its boost clock of 1410 MHz during the per-stage measurements. Each end-to-end latency is the mean over 3 passes through all test views of a scene within one job (after a single warm-up step), and we report the median of 3 independent jobs. Per-stage times are the median of 5 in-process repeats on test view 0, taken from the fastest of the 3 jobs (the GPU clock governor can only slow a profiled step down). We additionally benchmark three external references (Inria [1], gsplat [4], and LichtFeld Studio (fastgs) [12]), which we measure with the same harness and protocol.

Overview of the Progressive Packages

Figure 2 summarizes the measured progression across all packages on room 30k (N = 1,593,376 Gaussians at 1557 × 1038 resolution), and the scorecard at the top of each step section reports the per-stage CUDA-event latencies for that step on a single NVIDIA A100-SXM4-40GB GPU. To anchor per-stage timings and line-by-line code diffs, s00_baseline (diff vs. Inria ↗) re-implements Kerbl & Kopanas et al.'s reference CUDA rasterizer [1] inside our harness. Step 1 (s01_literature) then consolidates five techniques from the published literature—combined together for the first time in an open-source package—and Steps 2–6 isolate the five novel hardware-level optimizations of this post.

Progressive Stage Breakdown on room 30k (ms) preprocess binning raster_fwd raster_bwd preprocess_bwd s00_baseline 17.9 ms 25.06 ms (1.0×) s01_literature 4.86 ms (5.2×) s02_reduce_scatter 4.04 ms (6.2×) s03_sh_streaming 3.96 ms (6.3×) s04_raster_pruning 3.71 ms (6.8×) s05_branchless_pred 3.15 ms (8.0×) s06_multirow (f32) 2.57 ms (9.8×) s06_multirow (bf16) 2.39 ms (10.5×) 0 ms 1 ms 2 ms 3 ms 4 ms 5 ms 6 ms
Figure 2: Stacked per-stage CUDA-event breakdown on test view 0 of room 30k (1557 × 1038, N = 1.59M) across all packages. Every stage across all rows is drawn to the same 0–6 ms linear scale except s00_baseline's 17.93 ms raster_bwd segment, which is broken with a double slash so all rows fit on one chart (25.06 → 2.39 ms total stage time, 10.5×). Here and in the per-step scorecards, speedups are ratios of per-stage totals relative to s00_baseline on the same view; end-to-end speedups vs. Inria are reported separately in the text and in Tables 2–3.

Step 1: Consolidating What the Literature Already Knowss01_literature·diff vs. s00_baseline ↗

Before claiming any new optimization, we want to establish an uncompromising baseline that consolidates every major algorithmic and systems improvement already known in the 3DGS literature into a single, cohesive CUDA implementation. While this step does not introduce any new technique of its own, it is the first open-source implementation to combine every trick from the literature under one roof, achieving state-of-the-art performance that is already 1.67× faster than LichtFeld Studio (fastgs) [12] at 30k (and 1.63× faster at 7k).

(a) Warp-butterfly backward gradient reduction (DISTWAR [2], Taming 3DGS [3], gsplat [4], LichtFeld Studio [12]). Because all 32 threads T0..T31 of a warp step through the same shared-memory splat simultaneously and hold their 9 per-pixel screen-space derivatives gr[0..8] in registers (2D center μ′x, μ′y, conic a, b, c, opacity o, and RGB color r, g, b), we sum each splat's gradients across the 32 threads of the warp in registers before touching global memory. In CUDA, threads in the same warp can exchange a 32-bit register directly with a partner thread in a single hardware instruction—a warp shuffle (__shfl_xor_sync)—without touching shared or global memory. Using a 5-step butterfly reductionWarp Butterfly Reduction (__shfl_xor_sync)Sums a value across all 32 threads of a warp in log2(32) = 5 register steps. At each step offset ∈ {16, 8, 4, 2, 1}, every thread swaps its running sum with partner thread ^ offset and adds the two:T₀T₁T₂T₃ΣΣΣΣstep 1 (xor 2): pair (T₀,T₂), (T₁,T₃)step 2 (xor 1): pair (T₀,T₁), (T₂,T₃)all threads hold full sum ΣNamed after the crossing "butterfly" edges of an FFT network. (5 shuffles per float with offset = 16, 8, 4, 2, 1, or 9 × 5 = 45 shuffles per splat), thread 0 accumulates the warp's 32-pixel subtotal and issues 9 global atomicAdds, reducing L2 atomic traffic by up to 32×.

(b) Exact 2D conic-tile intersection & opacity-aware cutoffs (StopThePop [5], FlashGS [6], Speedy-Splat [7], LichtFeld Studio [12]). Kerbl & Kopanas et al.'s 3𝜎 circle radius ignores both the anisotropy and the opacity o of a splat, emitting every tile in a conservative square around the Gaussian (Figure 3, left). Since a splat's contribution at pixel offset d is 𝛼 = o e−q(d)/2 with q(d) = d⊤Λd, it can only exceed the 1/255 blending cutoff inside the opacity-aware ellipse q(d) ≤ 2 ln(255 o). A first improvement (StopThePop [5], FlashGS [6]) shrinks the search region to the tight axis-aligned bounding box (AABB) of that ellipse and tests each tile in the AABB in a 2D loop, though tilted ellipses still waste loop iterations evaluating and rejecting empty corner tiles inside the AABB (Figure 3, middle). Following Speedy-Splat [7] and LichtFeld Studio [12], s01_literature instead solves the quadratic boundary Δx(Δy) in closed form for each covered tile row ty to obtain that row's exact horizontal tile span [tx0, tx1) directly, evaluating zero rejected tiles (Figure 3, right). On room 30k view 0, this cuts emitted tile-Gaussian pairs by 73.8% (3.82× fewer pairs, 7,004,474 → 1,832,356).

Blue: emitted tile Red: evaluated & rejected Gray: completely skipped Green: solved row span 3σ Circle Square (s00) r = 3√λmax circle → all 25 tiles r = 3√λmax circle 25 emitted · 0 rejected 0 skipped (14 miss ellipse) Tight AABB + 2D Loop 5 × 3 AABB → 4 corners rejected 11 emitted · 4 rejected 10 skipped (4 wasted tile tests) Analytic 1D Row Spans (s01) Rows ty₁→ty₃, solve [tx₀, tx₁) → 0 rejected row loop ty₁ ty₂ ty₃ 11 emitted · 0 rejected 14 skipped (0 wasted tile tests)
Figure 3: Screen-space tile binning on a 5 × 5 tile grid. Left (s00_baseline [1]): the 3𝜎 circle's bounding square emits all 25 tiles. Middle (tight AABB + 2D loop [5, 6]): the tight AABB skips 10 tiles (gray) but still tests and rejects 4 empty corners (red). Right (s01_literature [7, 12]): solving each row's horizontal span [tx0, tx1) in closed form (green) emits only the 11 intersecting tiles with zero rejected tests.

(c) Per-warp 8×4 sub-tile culling bitmask & compact 48-byte splat layout (HiGS [10], Yang et al. [11], FlashGS [6]). Each 16 × 16 pixel tile is processed by 8 warps (W0..W7), where each warp is responsible for an 8×4 rectangular sub-tile of 32 pixels (Figure 4, left). Many splats that intersect the 16 × 16 tile only touch one or two of those eight sub-tiles; if all 8 warps blindly loop through every splat in the tile's list, warps whose 8×4 pixels lie completely outside a splat waste cycles evaluating it. To avoid this, whenever a batch of splats is staged into shared memory, we apply the same analytic 1D row-span algorithm from (b) at 8×4 sub-tile granularity (WarpMask<1>) to compute an 8-bit mask (batch_mask) for each splat (one bit per warp) recording which of the eight 8×4 sub-tiles the splat's ellipse overlaps. Before blending a 32-splat chunk, each warp uses a single hardware warp-vote instruction (__ballot_sync) to gather its sub-tile bit across all 32 staged splats into a 32-bit integer worklist, and then uses find-first-set (__ffs) to jump directly from one overlapping splat to the next (Figure 4, right), skipping splats that miss its sub-tile in zero loop iterations. In addition, all 2D attributes of each visible Gaussian (center, conic, RGB color, and opacity) are packed contiguously into a single 16-byte-aligned 48-byte struct holding three float4 fields, allowing threads to stage each splat into shared memory with three wide 16-byte float4 reads instead of scattered scalar loads.

16×16 Tile: 8 Warps of 8×4 Pixels Splat S₃ touches only W₀ & W₂ → 8-bit mask = 00000101₂ S₃ W₀ (bit 0=1) W₁ (bit 1=0) W₂ (bit 2=1) W₃ (bit 3=0) W₄ (bit 4=0) W₅ (bit 5=0) W₆ (bit 6=0) W₇ (bit 7=0) batch_mask[3] (8 bits in shared mem): W₇0 W₆0 W₅0 W₄0 W₃0 W₂1 W₁0 W₀1 Warp W₀ Hardware Ballot (__ballot_sync) & Set-Bit Loop (__ffs) Threads T₀..T₃₁ of Warp W₀ test 32 staged splats in parallel (thread Tᵢ tests splat Sᵢ) 32-splat chunk: (bit W₀ of mask) Thread Tᵢ vote: S₀W₀=1 T₀: 1 S₁W₀=0 T₁: 0 S₂W₀=0 T₂: 0 S₃W₀=1 T₃: 1 S₄W₀=0 T₄: 0 S₅W₀=0 T₅: 0 S₆W₀=1 T₆: 1 S₇W₀=0 T₇: 0 … S₃₁ … T₃₁ __ballot_sync: 1 instruction packs 32 votes → 32-bit int sub_batch reg: (all 32 threads) bit 01 bit 10 bit 20 bit 31 bit 40 bit 50 bit 61 bit 70 8..31… __ffs loop: (jump 1-bits) skip S₁, S₂ skip S₄, S₅ 1st: S₀ 2nd: S₃ 3rd: S₆ Result: Warp W₀ blends only {S₀, S₃, S₆} (3 iterations instead of 8; missed splats take 0 loop iterations)
Figure 4: Sub-tile skipping in s01_literature [6, 10, 11]. Left: a 16 × 16 tile is split among 8 warps (W0..W7), each covering an 8 × 4 pixel sub-tile. When staging a splat into shared memory, we record an 8-bit mask showing which warps it overlaps (here S3 touches only W0 and W2). Right: before blending a batch of 32 splats, the 32 threads of warp W0 each check one splat's W0 bit in parallel. A single warp-vote instruction (__ballot_sync) packs those 32 answers into one 32-bit integer, and find-first-set (__ffs) jumps straight from one set bit to the next (S0 → S3 → S6), skipping non-overlapping splats completely.

(d) Two-stage visibility-indexed 32-bit / 16-bit radix sorting (Splatshop [8], Faster-GS [9]). Instead of running an 8-pass 64-bit CUB radix sort over all I (tile | depth) keys, s01_literature implements the two-stage sorting pipeline of Splatshop [8] and Faster-GS [9] (Figure 5): first, filter the M visible Gaussians (M ≪ N) and sort them front-to-back once by their 32-bit float depth key; second, emit 16-bit tile keys in that depth-sorted order and run a narrow 2-pass stable radix sort over the 16-bit tile IDs. On room 30k view 0, sorting time drops by 79% (0.97 ms → 0.20 ms across the two radix sorts).

Filter Visible Drop culled Gaussians (cnt = 0); keep only M = 3 visible splats G₀ (culled) 0 tiles G₁ · d=0.8 tiles {τ₂, τ₅} G₂ (culled) 0 tiles G₃ · d=0.2 tiles {τ₅} G₄ · d=0.5 tiles {τ₂, τ₅} G₅ (culled) 0 tiles depth_keys[M], ids[M]: [d=0.8 | id=G₁] [d=0.2 | id=G₃] [d=0.5 | id=G₄] 32-Bit Depth Sort + Scan 4-pass sort on M 32-bit depths + prefix scan on M tile counts 1st: G₃ (d=0.2) cnt=1 · off=0 · {τ₅} 2nd: G₄ (d=0.5) cnt=2 · off=1 · {τ₂,τ₅} 3rd: G₁ (d=0.8) cnt=2 · off=3 · {τ₂,τ₅} Emit in Depth Order Write 16-bit tile_id at prefix offsets (already depth-sorted!) key=τ₅, G₃ slot 0 (d=0.2) key=τ₂, G₄ slot 1 (d=0.5) key=τ₅, G₄ slot 2 (d=0.5) key=τ₂, G₁ slot 3 (d=0.8) key=τ₅, G₁ slot 4 (d=0.8) Stable 16-Bit Tile Sort 2-pass stable sort on tile_id groups tiles, keeps depth order Tile τ₂ range [0, 2): d = 0.5 ≤ 0.8 τ₂ · G₄ d = 0.5 (1st) τ₂ · G₁ d = 0.8 (2nd) Tile τ₅ range [2, 5): d = 0.2 ≤ 0.5 ≤ 0.8 τ₅ · G₃ d = 0.2 (1st) τ₅ · G₄ d = 0.5 (2nd) τ₅ · G₁ d = 0.8 (3rd)
Figure 5: Two-stage sorting in s01_literature [8, 9] on 6 Gaussians (G0..G5). First, culled Gaussians are dropped, leaving M = 3 visible splats. Second, a 32-bit radix sort orders the visible splats front-to-back by depth (G3 → G4 → G1), and a prefix scan computes their write offsets. Third, each splat emits its 16-bit tile IDs in that depth-sorted order. Finally, a 2-pass stable radix sort on the 16-bit tile IDs groups pairs by tile while preserving front-to-back depth order.

(e) Visibility-gated SH evaluation, wide float4 SH loads & base-2 MUFU exponential (Taming 3DGS [3], gsplat [4], FlashGS [6], Faster-GS [9], LichtFeld Studio [12]). Finally, s01_literature gates forward Spherical Harmonics evaluation behind visibility so off-screen Gaussians never touch their 192-byte SH table, replaces scalar 4-byte SH reads with wide 16-byte float4 vector loads, pre-scales conic coefficients by −½ log2(e) so the inner rasterization loop evaluates 1-cycle hardware base-2 exponentials (ex2.approx.ftz.f32 on the SM's MUFU pipe), and replaces the 3-component running color accumulator in raster_bwd with a single scalar projection accum = cbehind · ∇I onto the pixel's upstream color gradient (saving 4 FMAs per pixel–splat evaluation) [3, 4, 12].

Comparison against LichtFeld Studio (fastgs) [12]. Combining all five literature pillars cuts the stage total on room 30k view 0 from 25.06 ms down to 4.86 ms (5.2×), and the end-to-end synchronous step from 25.18 ms to 4.77 ms, a 5.5× speedup over Inria's 26.09 ms. Across all 9 Mip-NeRF 360 scenes at 30k, s01_literature averages 5.87 ms (4.09× vs. Inria). Notably, s01_literature is already 1.67× faster at 30k and 1.63× faster at 7k than the standalone open-source LichtFeld Studio (fastgs) rasterizer [12], which shares much of the same algorithmic foundations ([3, 5, 7, 9]) but leaves per-tile batch_mask bitmask filtering in forward from HiGS [10] and Yang et al. [11] (c), the single packed 48-byte Splat struct from FlashGS [6] (c) (it stages conic/opacity and color from separate float4 arrays), and the two-stage depth / 16-bit tile sort from Splatshop [8] and Faster-GS [9] (d) unexploited, sorting a single packed (tile | depth) key instead. Everything from Step 2 (s02) through Step 6 (s06) is therefore strictly beyond the published literature.

Step 2: Matching the Reduction to the Gradient Width — Warp Reduce-Scatter & Two-Splat Foldings02_reduce_scatter·diff vs. s01_literature ↗

After Step 1, raster_bwd is still the largest stage on every scene (2.90 ms on room 30k, or 60% of the step; 3.98 ms on garden 30k, or 45% of the step). Although warp-level aggregation in s01_literature (following DISTWAR [2], Taming 3DGS [3], and LichtFeld Studio [12]) eliminated per-pixel L2 atomic collisions within each warp, inspect how every published warp reduction computes that sum (Figure 6, left): each thread holds 9 gradient floats for the current splat, and the kernel reduces them one component at a time using 9 full 32-thread butterfly reductionsWarp Butterfly Reduction (__shfl_xor_sync)Sums a value across all 32 threads of a warp in log2(32) = 5 register steps. At each step offset ∈ {16, 8, 4, 2, 1}, every thread swaps its running sum with partner thread ^ offset and adds the two:T₀T₁T₂T₃ΣΣΣΣstep 1 (xor 2): pair (T₀,T₂), (T₁,T₃)step 2 (xor 1): pair (T₀,T₁), (T₂,T₃)all threads hold full sum ΣNamed after the crossing "butterfly" edges of an FFT network.. Each step of a butterfly reduction is a warp shuffle (__shfl_xor_sync)—a hardware instruction in which every thread exchanges one 32-bit float register directly with a partner thread in the same warp—so reducing 9 floats across 5 steps takes 9 × 5 = 45 shuffles per splat (or 30 shuffles when 6 intermediate moments are reduced before reconstructing the 9 outputs).

Standard Butterfly (4 threads × 4 components) Start Step 1 xor 2 Step 2 xor 1 Global atomicAdd T₀ T₁ T₂ T₃ g0 g1 g2 g3 g0 g1 g2 g3 g0 g1 g2 g3 g0 g1 g2 g3 shuffle all 4 components g0 g1 g2 g3 g0 g1 g2 g3 g0 g1 g2 g3 g0 g1 g2 g3 shuffle all 4 components Σ0 Σ1 Σ2 Σ3 Σ0 Σ1 Σ2 Σ3 Σ0 Σ1 Σ2 Σ3 Σ0 Σ1 Σ2 Σ3 T₁..T₃ discard their sums (idle) T₀ issues 4 serial atomicAdds (1 thread active) For 32 threads × 9 grads: 45 shuffles + 9 serial atomicAdds Halving Reduce-Scatter (active array halves each step) T₀ T₁ T₂ T₃ g0 g1 g2 g3 g0 g1 g2 g3 g0 g1 g2 g3 g0 g1 g2 g3 split in half: 2 shuffles (not 4) g0 g1 g0 g1 g2 g3 g2 g3 split in half: 1 shuffle (not 4) Σ0 Σ1 Σ2 Σ3 All 4 threads issue atomicAdd in ONE instruction For 32 threads × 9 grads: 5+3+2+1+1 = 12 shuffles + 1 atomicAdd instr
Figure 6: Visual comparison on a 4-thread, 4-gradient toy example. On the left, the standard butterfly reduction in s01_literature shuffles and carries all 4 components on every thread at every step; at the end, T1..T3 discard their sums while T0 issues 4 serial atomicAdds. On the right, halving reduce-scatter in s02_reduce_scatter splits the remaining components in half between paired threads at each step, from 4 down to 2 and then 1, cutting the shuffle count and finishing with one component sum per thread so all atomicAdds fire simultaneously in a single warp instruction.

That naive loop (Figure 6, left) wastes work in two ways. First, at the end of a 5-step butterfly, all 32 threads hold a duplicate copy of the sum even though 31 of the 32 threads immediately throw their copy away, so repeating that across all 9 components costs 9 × 5 = 45 shuffle instructions per splat. Second, a warp only sums 32 pixels, whereas a 16 × 16 tile contains 8 warps and a splat can span multiple tiles running on different SMs, so each warp must still atomically add its 32-pixel subtotal into the splat's global gradient array in L2. Because s01_literature leaves all 9 warp subtotals on thread T0, T0 has to issue 9 global atomicAdd instructions one after another while threads T1..T31 sit idle.

Halving Warp Reduce-Scatter (45 → 12 Shuffles). As Figure 6 (right) illustrates, instead of keeping all 9 gradient components alive on every thread through all 5 steps, we can split the 9 components across the threads as we sum them. At each step, paired threads divide their remaining components in half—one partner receives and adds the lower half from its partner while the other receives and adds the upper half. Because the number of active components per thread halves at every step (9 → 5 → 3 → 2 → 1 → 1), each thread issues only 5 + 3 + 2 + 1 + 1 = 12 register shuffles (__shfl_xor_sync exchanges) instead of 45. Moreover, at the end of Step 5, 9 distinct threads in the warp each hold the 32-pixel warp sum for one of the 9 gradient components. Instead of thread T0 looping over 9 sequential global atomicAdds, all 9 threads write their warp subtotal into the splat's global gradient array in L2 at the same time in a single warp instruction.

Single-Splat Halving on a Wider Warp (2 grads on 4 threads) Active components per thread: 2 → 1 → 1 (hits 1 early, can't split 1 in Step 2) Start Step 1 xor 2 Step 2 xor 1 Global atomicAdd T₀ T₁ T₂ T₃ a0 a1 a0 a1 a0 a1 a0 a1 split 2 → 1: 1 shuffle a0 a0 a1 a1 can't split 1 → 1: still costs 1 shuffle! Σa0 idle Σa1 idle 2 shuffles/splat (4 for 2 splats) · half the warp idle 32 threads × 9 grads: 12 shuffles/splat (24 for 2) · 23 of 32 threads idle Two-Splat Half-Warp Folding (Splat A + Splat B together) Half-warp 0 → Splat A Half-warp 1 → Splat B T₀ T₁ T₂ T₃ a0 a1 b0 b1 a0 a1 b0 b1 a0 a1 b0 b1 a0 a1 b0 b1 fold A → T₀,T₁ and B → T₂,T₃: 2 shuffles a0 a1 a0 a1 b0 b1 b0 b1 both half-warps split 2 → 1 in parallel: 1 shuffle Σa0 Σa1 Σb0 Σb1 3 shuffles for 2 splats (1.5/splat) · all 4 threads write at once 32 threads × 9 grads: 9 + 11 = 20 shuffles (10/splat) · 18 atomicAdds
Figure 6b: Halving reduction on a 4-thread toy warp with 2 gradients per splat (analogous to 9 gradients on a 32-thread warp). Left (single splat): Step 1 reaches 1 component per thread (2 → 1), so Step 2 cannot split further (1 → 1) and leaves half the warp idle (2 shuffles/splat). Right (two-splat folding): Step 1 folds splats A and B into opposite half-warps, and Step 2 splits both in parallel (2 + 1 = 3 shuffles for 2 splats, 1.5/splat; all 4 threads fire atomicAdd at once).

Reducing Two Splats Together in Half-Warps (12 → 10 Shuffles per Splat). In Figure 6 (right), our toy example had 4 gradient components on 4 threads, so the active count halved cleanly all the way down (4 → 2 → 1) and every thread ended up with one sum. When the warp has more threads than gradient components, however—as in 3DGS, where a warp has 32 threads but a splat has only 9 gradients (so even a 16-thread half-warp is wide enough to hold all 9 sums)—the active count per thread hits 1 before the last step (9 → 5 → 3 → 2 → 1 → 1) and cannot be split any further. Figure 6b (left) shows this on our 4-thread toy warp with a 2-component splat (a0, a1): Step 1 splits 2 → 1 (1 shuffle), and then Step 2 still has to spend 1 more shuffle to sum that single component across pairs (1 → 1), leaving half the threads idle (2 shuffles per splat, or 4 shuffles for two splats). Instead, we can evaluate two consecutive splats (A and B) before reducing and use Step 1 to fold splat A into the lower half-warp and splat B into the upper half-warp, so both half-warps then run the halving reduce-scatter in parallel without wasted tail steps (Figure 6b, right).

Inspecting the compiled SM80 SASS of RasterBackwardKernel with cuobjdump --dump-sass confirms the structural change: static SHFL.BFLY instructions drop from 366 to 230 (−37%) and global atomic reduction instructions (RED.E.ADD.F32.FTZ.RN) drop from 72 to 16 (−78%). Because the halving reduction keeps more partial sums live per thread, we also give RasterBackwardKernel a spill-free register budget (__launch_bounds__(256, 4), up to 64 registers instead of 40), and, as in s01_literature, still skip the reduction entirely when no pixel of the warp blends the splat pair. On room 30k, raster_bwd drops from 2.90 ms to 2.08 ms (−28%), and on garden 30k from 3.98 ms to 3.46 ms (−13%).

Step 3: Eliminating SH Register Pressure & Bulk Memsets — 4-Group Register-Window Streaming & Cooperative Warp Zeroings03_sh_streaming·diff vs. s02_reduce_scatter ↗

The runtime of preprocess_bwd scales with the total number of Gaussians N in the scene rather than with the number of pixels: on room, with 1.59M Gaussians, it takes 0.54 ms, whereas on garden, with 5.83M Gaussians, it takes 2.18 ms—about a quarter of the entire step and the second-largest stage after raster_bwd. For this reason Step 3 reports garden 30k numbers. At first sight, 2.18 ms is surprising: preprocess_bwd launches only one thread per Gaussian and performs simple closed-form polynomial math. Why is such a simple kernel so slow?

The bottleneck is memory bandwidth and register pressure, not arithmetic. In 3DGS, each Gaussian stores 16 RGB Spherical Harmonics (SH) coefficients—16 × 3 = 48 contiguous floats (192 bytes per Gaussian)—laid out back-to-back in global memory so that Gaussian G0 occupies bytes 0..191, Gaussian G1 occupies bytes 192..383, and so on. Even after s01_literature gated forward SH evaluation behind visibility and widened per-thread SH loads to 16-byte float4 vectors, two bottlenecks still remain in preprocess_bwd.

96 live SH registers per thread. In preprocess_bwd, each active thread loads all 48 forward SH floats into a local array coeffs[48] and accumulates all 48 SH gradients into a second array dsh[48] before writing any of them out—tying up 96 registers per thread for SH alone, which totals 152 registers per thread and caps SM occupancy on A100 at 18.75% (12 warps per SM with 128-thread blocks).

To cut register pressure, we exploit the fact that the 16 SH basis functions Yk(d) are additive and independent across basis index k. In the forward pass, the view-dependent RGB color ∑k Yk(d) sk only requires a 3-float running sum. In the backward pass, each coefficient's gradient ∂L/∂sk = Yk(d) (∂L/∂c) depends only on the incoming RGB gradient ∂L/∂c and the viewing direction d, while the viewing-direction derivative ∂L/∂d = ∑k (∂L/∂c · sk) ∇dYk(d) is likewise a 3-float running sum. We therefore never need all 48 forward coefficients or all 48 gradients in registers at the same time. Instead, we rearrange the SH evaluation and VJP into four passes of 4 SH basis functions (4 × 3 = 12 floats) each, streaming one 12-float group at a time through a single register buffer v[12]—the smallest multiple of 3 RGB channels that packs evenly into 4-wide float4 (16-byte) loads, the widest load instruction a single GPU thread can issue. Streaming SH through a single 12-float register window cuts PreprocessBackwardKernel register usage by 32% (from 152 to 104 registers/thread in float32, and down to 94 when combined with bfloat16 SH in Step 6), raising resident warp occupancy on A100 from 18.75% to 25% (12 → 16 warps per SM).

Redundant 1.38 GB bulk cudaMemsetAsync & Cooperative Warp-Coalesced Zeroing of Untouched SH Rows. Before the backward pass runs, a bulk cudaMemsetAsync call zeroes all 59 gradient floats of all N = 5.83M Gaussians (1.38 GB on garden 30k) so that culled or untouched Gaussians end up with the correct zero gradients. That writes memory twice for every visible Gaussian: first zeroed by cudaMemsetAsync, then overwritten by preprocess_bwd. Yet preprocess_bwd already launches a thread for every Gaussian 0..N−1: if the kernel itself writes zeros for the inactive Gaussians while writing the computed gradients for the active ones, the separate 1.38 GB cudaMemsetAsync pass can be removed entirely.

Zeroing Untouched SH Rows in Memory: Strided Per-Thread vs. Cooperative Warp Sweep Naive (1 thread per Gaussian) — T₀, T₁, T₂… write 192 B apart in the same step (scattered across 32 L2 sectors): T₀:0 G₀ (12 × 16B chunks = 192B) T₁:0 G₁ (12 × 16B chunks = 192B) T₂:0 G₂ (12 × 16B chunks = 192B) 192B stride between T₀, T₁, T₂… → 32 separate L2 sectors touched s03 Cooperative Warp Sweep — T₀..T₃₁ march side-by-side across adjacent 16 B chunks (skipping active Gaussians): T₀:0T₁:0 T₂:0T₃:0 T₄:0T₅:0 G₀: untouched → write 0 T₆:–T₇:– T₈:–T₉:– T₁₀:–T₁₁:– G₁: active → skip T₁₂:0T₁₃:0 T₁₄:0T₁₅:0 T₁₆:0T₁₇:0 G₂: untouched → write 0 Contiguous 512B / step → 100% coalesced · 0B smem
Figure 7: Zeroing untouched SH rows in s03_sh_streaming. Top: if each thread zeroes its own untouched Gaussian at a 192 B stride, threads T0..T31 write 192 B apart across 32 separate 32 B L2 sectors. Bottom: in the cooperative warp sweep, threads T0..T31 march side-by-side across contiguous 16-byte chunks, skipping active Gaussians.

Because one thread handles one Gaussian, if each inactive thread simply zeroed its own 192-byte SH row in a per-thread loop, adjacent threads in a warp would write 192 bytes apart in global memory (Figure 7, top). Recall from the hardware background section that the A100 L1/L2 caches transfer memory in 32-byte sectors: because adjacent threads are 192 bytes apart instead of 16 bytes apart, no two threads share a sector—every thread touches a separate 32-byte L2 sector. Instead, because preprocess_bwd assigns consecutive threads T0..T31 of a warp to 32 consecutive Gaussians G0..G31, and the SH tensor stores those Gaussians back-to-back in global memory, the warp's 32 SH rows form one contiguous 6,144-byte block. The warp first votes on activity with a single __ballot_sync instruction so every thread knows which of the warp's 32 Gaussians were untouched, and then the 32 threads march side-by-side across that contiguous block 32 chunks at a time (Figure 7, bottom), writing 16 bytes of zeros whenever a chunk belongs to an untouched Gaussian and skipping active Gaussians.

Because adjacent threads T0..T31 always address adjacent 16-byte chunks, every zeroing step coalesces cleanly into complete 32-byte L2 sectors without using a single byte of shared memory. Together, these two changes cut preprocess_bwd on garden 30k from 2.18 ms to 1.81 ms, and because removing the 5 bulk host-launched cudaMemsetAsync passes also eliminates inter-kernel launch overhead, the end-to-end synchronous fwd-bwd step on garden 30k drops from 7.62 ms to 7.38 ms.

Step 4: Pruning the Backward Pass — In-Place Contributor Compaction, Upper-Byte cmask Packing & splat_touched Gatings04_raster_pruning·diff vs. s03_sh_streaming ↗

Even with exact ellipse-tile intersection and 8 × 4 sub-tile bitmasks (Figure 4) in s01–s03, a large fraction of the splats assigned to a tile never actually blend into a single pixel because front-to-back transmittance T saturates below 10−4 on the pixels underneath the splat before the forward pass reaches its depth. Yet in s01–s03, as in prior tile rasterizers [1, 2, 3, 4, 6, 12], raster_bwd walks the tile's unpruned splat list in reverse from the last contributing splat back to the front, staging every occluded splat in that prefix into shared memory and recomputing its sub-tile mask, while preprocess_bwd runs on every frustum-visible Gaussian even if it was completely occluded across all tiles. Step 4 eliminates this wasted backward work through three co-designed mechanisms illustrated in Figure 8:

In-Place Forward Contributor Compaction & Upper-Byte cmask Packing (s04_raster_pruning) Forward Input (point_list[range.x..range.y)): 6 binned splats in tile τ; S₁, S₄, S₅ are occluded (T < 10⁻⁴ on covered pixels) & hit 0 pixels S₀ (contrib) α ≥ 1/255 ✓ S₁ (occluded) T < 10⁻⁴ ✗ S₂ (contrib) α ≥ 1/255 ✓ S₃ (contrib) α ≥ 1/255 ✓ S₄ (occluded) T < 10⁻⁴ ✗ S₅ (occluded) T < 10⁻⁴ ✗ In-Place Compacted Slice [range.x..+max_last): Forward compacts contributors & remaps n_contrib[p]; backward unpacks cmask & sets splat_touched[id]=1 cmask₀ | S₀ touched[S₀]=1 cmask₂ | S₂ touched[S₂]=1 cmask₃ | S₃ touched[S₃]=1 pruned tail — never visited by raster_bwd! 32-Bit Compacted Word Format: raster_bwd unpacks batch_mask = packed >> 24 bits 31..24: cmask (8 bits) which warps blended this splat bits 23..0: Gaussian ID (24 bits, up to 16.7M splats) index into splats[id] and splat_grads[id]
Figure 8: In-place forward contributor compaction in s04_raster_pruning. During raster_fwd, each block compacts only the splats that actually blended (𝛼 ≥ 1/255) into the front of the tile's splat list and packs the 8-bit warp contributor mask cmask into the upper byte of each 32-bit entry alongside the 24-bit Gaussian ID, so raster_bwd visits only contributing splats and warps.

1. In-Place Forward Contributor Compaction. As each warp steps through a 32-splat chunk during raster_fwd (Figure 8, top row), it records a 32-bit bitmask of the splats that actually blended with 𝛼 ≥ 1/255 into at least one pixel of that warp and writes the mask to shared memory. At the end of each 256-splat batch, taking the bitwise OR of those masks across all warps identifies every splat that contributed anywhere in the tile (such as S0, S2, and S3 in Figure 8, while dropping occluded splats like S1, S4, and S5). A short prefix sum over the population counts of those masks gives each contributing splat its dense destination index, allowing the thread block to overwrite the front of the tile's sorted splat list in place with only the true contributors (Figure 8, middle row) and remap each pixel's stopping index into the compacted stream. On room 30k view 0, this prunes 20.7% of all tile-Gaussian pairs before the backward pass begins, shrinking the tile splat lists from 1.83M to 1.45M entries.

2. Upper-Byte cmask Packing. Because Mip-NeRF 360 Gaussian indices fit comfortably within 24 bits for scenes up to 16.7M Gaussians, forward compaction also gathers the 8 per-warp contribution bits for each surviving splat into an 8-bit contributor mask cmask and packs it into the upper byte of the compacted 32-bit splat-list entry above the 24-bit Gaussian ID (Figure 8, bottom row). When raster_bwd walks the compacted prefix in reverse, each staging thread unpacks the 24-bit Gaussian ID and the 8-bit warp mask directly from that 32-bit word—eliminating sub-tile bounding-box evaluation from the backward pass altogether and skipping warps where a splat overlapped the sub-tile ellipse in geometry but was occluded on all pixels of that warp.

3. 1-Byte splat_touched Backward Preprocess Gating. As raster_bwd stages each compacted contributing entry (Figure 8, middle row, touched[Si]=1), it writes a single byte flag marking that Gaussian ID as having contributed to the rendered image. In preprocess_bwd, threads check this flag instead of frustum visibility, skipping both the 9-float intermediate gradient load and the 48-coefficient Spherical Harmonics backward pass for every frustum-visible Gaussian that was occluded across all tiles. On room 30k view 0, spending an extra 0.16 ms in raster_fwd to compact the splat list cuts raster_bwd by 18.2%, from 2.09 ms to 1.71 ms, and preprocess_bwd from 0.46 ms to 0.42 ms, reducing total stage time from 3.96 ms to 3.71 ms and bringing the end-to-end synchronous step to 3.70 ms.

Step 5: Eliminating SIMT Warp Divergence — Branchless Predication & Exact Quadratic Roundings05_branchless_predication·diff vs. s04_raster_pruning ↗

After Step 4's pruning, the inner per-splat loops of raster_fwd (0.98 ms) and raster_bwd (1.71 ms) account for 73% of the total step time (3.71 ms) on room 30k. Crucially, the per-warp cmask sub-tile bitmask and Step 4's contributor compaction guarantee an important structural invariant: every splat that a warp's inner loop visits already intersects at least one pixel in that warp's sub-tile. Yet in s01–s04, the per-splat loop inside both rasterizers still contains divergent per-thread early-exit branches that test whether the pixel is still active and whether the evaluated opacity satisfies 𝛼 ≥ 1/255.

Divergent Early-Exit Loop (s04_raster_pruning) BSSY reconverge_label // push SIMT token if (!active) continue; // @P0 BRA power = col.Row(c2, dy); if (power > 0) continue; // @P1 BRA raw_a = min(0.99f, o * ex2(power)); if (raw_a < 1/255) continue; // @P2 BRA C += (T*raw_a)*c; T *= (1 - raw_a); BSYNC reconverge_label // pop & sync warp Branchless Straight-Line Loop (s05_branchless) // Single basic block — zero BSSY / BSYNC power = col.Row(c2, dy); raw_a = min(0.99f, o * ex2(power)); hit = active && (power <= 0) && (raw_a >= 1/255); alpha = hit ? raw_a : 0.0f; // SEL (α = 0 on miss) C += (T*alpha)*c; T *= (1 - alpha);
Figure 9: Replacing the three divergent per-thread continue branches (left) with branchless 𝛼 = 0 predication (right) in s05_branchless_predication. Evaluating power and raw_a unconditionally and merging the three guards into a single hardware select (SEL) collapses the inner loop into one straight-line basic block with zero BSSY/BSYNC overhead.

Why are these branches counterproductive once sub-tile culling is in place? Because all 32 threads of a warp share a single hardware instruction pointer (SIMT), a branch can only skip the rest of the loop body if all 32 threads take the early exit together. Because the warp's sub-tile bitmask already skipped every splat that missed the whole warp, at least one thread in the warp hits the current splat and stays in the loop body. Consequently, the warp must execute every blending and gradient instruction of the loop body anyway, while threads that took the early exit sit idle with their predicate masks disabled. Worse, as shown in Figure 9 (left), the compiler surrounds those divergent branches with hardware branch-reconvergence instructions (BSSY before the branch and BSYNC where threads rejoin) that chop the inner loop into multiple basic blocks and stall instruction issue.

Branchless 𝛼 = 0 Predication. Instead of branching out of the loop when a pixel misses a splat, s05_branchless_predication keeps all 32 threads converged through the entire inner loop and uses a single-cycle hardware select instruction (SEL) to set 𝛼 = 0 on missed pixels (Figure 9, right). Because a zero-opacity splat adds zero color (𝛼Tc = 0) and multiplies transmittance by 1 − 𝛼 = 1, missed threads can execute the exact same forward blending and backward gradient instructions as active threads without changing their pixel's running color or transmittance, and their zero derivatives contribute nothing to the warp reduction.

Exact Round-to-Nearest Quadratic Evaluation. Running the backward update unconditionally on every thread exposes a subtle numerical requirement: the forward and backward kernels must agree bit-for-bit on whether a pixel crossed the 𝛼 ≥ 1/255 threshold. If the compiler fuses floating-point multiply-adds differently between the two kernels, a pixel sitting right on the boundary can evaluate as a hit in the forward pass and a miss in the backward pass, leaving its transmittance T un-divided by 1 − 𝛼. To prevent this mismatch, we evaluate the 2D quadratic form in both kernels using explicit IEEE round-to-nearest-even instructions, guaranteeing bit-identical exponents and opacity values in forward and backward. Inspecting the compiled SM80 assembly confirms that branch-reconvergence instruction pairs in raster_bwd drop from 66 to 42. On room 30k, raster_fwd drops from 0.98 ms to 0.78 ms and raster_bwd drops from 1.71 ms to 1.35 ms, cutting the stage total from 3.71 ms to 3.15 ms.

Step 6: Balancing SM Execution Pipes — Multi-Row Warp Mapping & Pipe-Balanced Maths06_multirow_and_pipe_math·diff vs. s05_branchless_predication ↗

With branchless predication in place, both raster_fwd (0.78 ms) and raster_bwd (1.35 ms) execute straight-line inner loops without L2 atomic stalls or SIMT reconvergence penalties. Profiling s05_branchless_predication with Nsight Compute (on a synthetic 6M-Gaussian scene calibrated to garden 30k; all hardware counters below come from this scene) reveals the final two hardware bottlenecks inside the SM sub-partitions. First, in raster_bwd, warps spend 14.9% of their cycles stalled at block-wide __syncthreads() barriers, waiting for the slowest warp in the 16 × 16 tile to finish its batch before the block can stage the next shared-memory batch, while inside the loop body itself, the FMA pipe (floating-point multiply-add) is the busiest execution unit, running at 68.9% of its peak throughput (compared to 40.0% for the integer/comparison ALU pipe and 38.9% for the load/store LSU pipe). Second, in raster_fwd, no single execution pipe is saturated, yet the warp scheduler is: how can the scheduler be saturated when every execution pipe is well below 100%? Recall from Figure 1 that each of the four SM sub-partitions has a single warp scheduler that can issue at most 1 instruction per cycle in total across all of its functional units combined (FMA, ALU, MUFU, and LSU). Because the forward loop alternates almost evenly between integer/predicate logic (ALU, 201.9M instructions/frame), floating-point math (FMA, 205.4M instructions/frame), and fast transcendentals (XU/MUFU ex2/rcp, 54.4M instructions/frame), the warp scheduler hits its 1-instruction-per-cycle issue ceiling first—keeping issue slots busy 81.2% of active cycles—while the ALU, FMA, and XU pipes each sit at 60–65% of their individual peak throughputs. The forward loop is therefore instruction-issue bound: removing floating-point work alone cannot speed it up; the total number of issued instructions per pixel–splat evaluation, across ALU, FMA, and XU, has to come down. Step 6 (s06_multirow_and_pipe_math) resolves both bottlenecks through five co-designed transformations.

1 Pixel/Thread: 8 Warps W₀..W₇ of 8×4 (s00..s05) W₀ (8×4) W₁ (8×4) W₂ (8×4) W₃ (8×4) W₄ (8×4) W₅ (8×4) W₆ (8×4) W₇ (8×4) 1 thread = 1 pixel (1×1) • 256 threads / tile (8 warps) • Shared splat setup repeated 8× • 8 warp atomics / splat in bwd • Block-wide __syncthreads() stalls fast warps on slow warps 2 Vertical Pixels/Thread: 4 Square 8×8 Warps W₀..W₃ (s06) W₀ (8×8) W₁ (8×8) W₂ (8×8) W₃ (8×8) 1 thread = 2 vertical pixels (1×2) top (x, y₀) + bottom (x, y₀+1) share Δx • 128 threads / tile (4 warps) • Δx, A·Δx², log₂o shared by 2 rows • Vertical sum before reduce-scatter halves L2 atomics (4 warps/tile) • Barrier-free __syncwarp() in bwd Each 32-thread warp tiles an 8×8 quadrant as 8 columns × 4 vertical 1×2 pixel pairs, halving per-splat warp work.
Figure 10: Moving from 1 pixel per thread (8 warps W0..W7 of 8 × 4 pixels, left) to 2 vertical pixels per thread (4 square warps W0..W3 of 8 × 8 pixels tiled by 32 vertical 1 × 2 thread pairs, right) in s06_multirow_and_pipe_math.

1. Square 8 × 8 Multi-Row Thread Mapping (2 Vertical Pixels per Thread). In s00–s05, a 16 × 16 tile block launched 256 threads—8 warps of 8 × 4 pixels (Figure 10, left)—so every shared-memory load, sub-tile bitmask scan, horizontal Δx setup, and backward warp reduction ran 8 times per tile. In s06_multirow_and_pipe_math, each thread instead processes two vertically adjacent pixels in the same column, so a 32-thread warp covers a square 8 × 8 quadrant (64 pixels) and the entire 16 × 16 tile needs only 128 threads = 4 warps (Figure 10, right). Because both pixels in a thread share the same horizontal coordinate, the horizontal offset Δx = px − μ′x and the horizontal quadratic term depend only on the column and are computed once per thread rather than twice. Furthermore, in raster_bwd, each thread sums the partial derivatives of its two vertical pixels in registers before the warp reduction begins. Summing the two rows in registers first reduces the 256 pixels of the tile down to 128 thread totals across 4 warps instead of 8, cutting the number of warp reductions and global atomicAdd writes per tile in half (4 warps per tile instead of 8).

2. Barrier-Free Per-Warp Backward Traversal (__syncwarp()). What about the 14.9% of cycles that raster_bwd spent waiting at block-wide __syncthreads() barriers? In s00–s05, all warps in the 16 × 16 tile read splats from a single shared-memory buffer, so no warp could load the next batch until every warp in the tile had finished the current batch; fast warps whose quadrant of the tile was mostly empty had to sit idle waiting for the busiest warp. Instead, we partition shared memory in raster_bwd into 4 independent 32-splat staging slices—one private slice for each of the 4 warps—and synchronize each warp only with its own 32 threads via __syncwarp(). Using the 8-bit cmask packed into the upper byte of each entry in Step 4, each warp loads and processes only the splats that contributed to its own 8 × 8 quadrant, marching backward from its last contributor to the front completely independently of the other three warps in the tile and reducing barrier stalls from 14.9% to 0.0%.

3. Folding log2(opacity) into the Column Constant in Forward & Backward. Because both vertical pixels in a thread share Δx, we can also fold the splat's opacity o directly into the base-2 exponential using the identity o · 2p = 2p + log2 o. We precompute log2 o once per visible Gaussian during preprocess; inside the tile rasterizers, each thread adds log2 o into the horizontal A Δx2 column constant once per column so that a single MUFU.EX2 instruction on each row yields the unclamped alpha 𝛼raw = o · 2p directly—eliminating both the per-pixel o · g multiplication and the per-row power <= 0 guard (replaced by a single per-column Δx2 ≤ Δxmax2 check in forward). In raster_bwd, folding o into 𝛼raw has a second payoff: each row's log-falloff gradient gk = 𝛼raw T (c · ∇I − accum) already carries the factor of o required by the conic and mean derivatives, and only the single lane that outputs ∂L/∂o at the end of the warp reduction divides o back out once via 2−log2 o.

4. Zero-Multiply Row Derivative Combination & Deferred Δx Multiplication Across Vertical Shuffles. Even more FMA instructions disappear when a thread combines the conic and mean derivatives across its two vertical pixels, which sit at vertical offsets Δy0 and Δy0 − 1 from the splat center. Let g0 and g1 be the scalar log-alpha derivatives of the two rows. Because the row index shift k ∈ {0, 1} is zero on the top row and satisfies 1 = 12 = 1 on the bottom row, summing the Δy-weighted and Δy2-weighted derivatives across both rows requires zero extra multiplications by the row index: m0 = g0 + g1,
Sy = g0 Δy0 + g1(Δy0 − 1) = m0 Δy0 − g1,
Syy = m0 Δy02 − g1(2Δy0 − 1).
Better still, inside an 8 × 8 quadrant warp, threads that exchange values during the first two vertical shuffle steps (stride 16 and stride 8) lie in the exact same pixel column and therefore share the exact same horizontal offset Δx. Instead of multiplying m0 and Sy by Δx and Δx2 on every thread before the reduction, we sum the vertical terms down the column across the first two shuffle steps first and multiply by Δx only once per column before the horizontal shuffle steps.

5. Relieving the ALU Pipe in Forward via Sign-Bit Transmittance Termination. Finally, to relieve the integer/comparison ALU pipe in raster_fwd, we eliminate the separate boolean flag that tracked whether each pixel row had saturated. Instead, we encode saturation directly in the sign bit of the floating-point transmittance register T: when a pixel's updated transmittance crosses below the 10−4 threshold, we negate T so that a negative value marks the pixel as finished while preserving its magnitude for the final output image. A single floating-point sign check then tests whether the pixel is still active, and each thread accumulates its blended-splat bits in a register across the 32-splat chunk before combining them with a single hardware warp-wide OR reduction at the end of the chunk.

Together, these transformations cut raster_fwd on room 30k from 0.78 ms to 0.62 ms and raster_bwd from 1.35 ms to 0.93 ms (−31.1%), bringing the stage total from 3.15 ms down to 2.57 ms (9.8× below s00; 2.39 ms and 10.5× with bfloat16 SH; end-to-end, the room 30k step is 9.8× faster than Inria). Across all 9 Mip-NeRF 360 scenes, the mean training step reaches 3.29 ms at 30k (7.30× vs. Inria) and 2.89 ms at 7k (8.38× vs. Inria).

What Didn't Work: 17 Negative Results & Hardware Traps
click to expand ▾ click to collapse ▴

Collapsible section (click to expandcollapse) — Occupancy & register pressure cliffs, SM execution-pipe & warp-geometry traps, shared-memory & barrier overhead, L2 locality pitfalls, and sorting trade-offs tested and reverted on A100.

An optimization journey is incomplete without the ideas that sounded compelling on paper, many of them standard recommendations in GPU programming guides, yet regressed or failed to improve wall-clock time on the A100 when we benchmarked them, so we reverted them. Across our experimental log, we implemented, profiled, and rejected 17 distinct hypotheses. Every one of them teaches a concrete hardware lesson about register pressure, L1/L2 cache line behavior, or SM execution-pipe contention on GA100 (Table 1).

Table 1: Summary of 17 negative optimization hypotheses we tested and reverted during our A100 campaign, grouped by the hardware mechanism that caused them to fail.
Attempted Optimization Δ latency*How Δ latency was measuredWe measured all deltas on garden at the 30k checkpoint, as the latency change of the affected stage(s) relative to the variant we kept. We recorded them during development on intermediate versions of the code that we did not preserve, so we cannot reproduce them exactly. Why It Failed on A100
Occupancy & Register Pressure
kRows = 4 (64 threads / 2 warps per 16×16 tile) in raster_fwd & raster_bwd +0.45 ms 64-splat batches doubled barrier frequency; registers rose from 58 to 72/thread, cutting occupancy on heavy tiles.
Caching the 3×3 color Jacobian in registers across the 4 streamed SH groups in preprocess_bwd +0.57 ms 9 extra live floats pushed register usage across the next occupancy cliff (16 → 12 warps/SM in float32, 20 → 16 in bfloat16); lost latency hiding outweighed 36 saved FMAs.
Forcing __launch_bounds__(128, 8) on RasterBackwardKernel +0.89 ms The 64-register cap made ptxas spill 24 B of loop state to local memory, adding LSU traffic to the inner loop.
SM Pipe Contention & Warp Geometry
Cutting FP multiplies in raster_fwd (folding log2_o) before relieving the ALU pipe ±0.00 ms No measurable change on its own: the forward loop is issue-bound with ALU/FMA/MUFU evenly loaded; it only helped once combined with sign-bit termination (kept in s06).
Incremental linear row recurrence (p1 = p0 + dp) across the 2 rows in raster_fwd +0.01 ms Saved 1 FMA/splat on a non-bottleneck pipe and added live registers across rows.
16-thread half-warp sub-tile traversal (two 8×4 sub-tiles per warp) in raster_fwd +0.21 ms Extracting two bit positions per thread from the contributor mask added shift/mask ALU work that outweighed the tighter culling.
__nv_bfloat162 packed math inside the raster_fwd and raster_bwd loops +0.18 ms float32 ↔ bfloat16 conversions around MUFU ops added ALU/FMA work and degraded T / (1 − 𝛼) precision (1.2e-2 gradient error).
Shared-Memory & Barrier Overhead
Accumulating per-splat gradients in shared memory across all warps before 1 block-level atomicAdd +0.38 ms 9 KB of shared memory per block halved resident blocks (6 → 3) and added a block barrier per batch; warp atomics were already uncontended.
Direct per-thread global atomicAdd fast-path when ≤2 threads hit a splat in raster_bwd +0.14 ms An extra ballot, popc, and divergent branch on every splat cost more than unconditional shuffles.
Per-warp asynchronous shared-memory staging in raster_fwd (forward) without contributor compaction +0.31 ms Without compaction, all 4 warps scan the full raw tile range, duplicating global loads and shared-memory writes 4×.
Warp-staged shared-memory buffer in EmitPairsKernel for coalesced pair writes +0.09 ms Binning: most visible Gaussians cover only 1–2 tiles, so staging and warp syncs cost more than direct global writes.
Memory Hierarchy & Caching
Indexing splats[] and splat_grads[] by compact visible index vis_idx ∈ [0, M) +0.07 ms PreprocessKernel wrote splats in atomic-arrival order instead of thread order, destroying L2 locality between neighbours.
Lazy Spherical Harmonics evaluation inside raster_fwd instead of preprocess +1.40 ms Repeated the 48-float SH read and evaluation for every covered tile (1.8 tiles/splat on garden, 4.7 on room).
Compressing the 48-byte 2D Splat record into 24-byte bfloat16 attributes — Rejected for accuracy, not speed: 7 mantissa bits give 0.5-px quantization at x = 1024 (4.5e-3 pixel error), violating the 2e-5 forward contract.
Work Scheduling & Sorting
Fast-path binning: warp-ballot visibility compaction, a 32-bit packed tile-rectangle word that lets EmitPairsKernel skip re-solving the ellipse, and integer pixel bounds for sub-tile masks +0.02 ms Cut pair emission by 35% (0.23 → 0.15 ms), but its conservative single-row/column bounds admit ~0.5% more tile pairs and preprocess does slightly more work, so end-to-end it was neutral to slightly slower (+1.6% on the 9-scene mean at 30k); an exact variant only broke even. Measured on the current code, unlike the other rows.
Scheduling heaviest tiles first via sorted tile worklist and atomicAdd tile counter +0.11 ms The extra per-tile count sort in binning cost more than it saved in the rasterizers; ~10 waves of 4,346 tiles over 108 SMs already balance the tail.
Single 64-bit radix sort over compacted visible pairs instead of two-level (32b depth + 16b tile) sort +0.26 ms Binning: 8 passes over 3.91M 64-bit keys (with 32-bit values) move ~3× more data than 4 passes over 2.22M 32-bit depths plus 2 over 13-bit tile keys.

Full 9-Scene Mip-NeRF 360 Benchmark & Roofline Analysis

Stage-by-Stage Roofline Analysis on NVIDIA A100

How close is s06_multirow_and_pipe_math to the physical limits of the NVIDIA A100-SXM4-40GB (~1,350 GB/s sustained achievable HBM2 bandwidth, 19.5 TFLOPS FP32 FMA)? On Mip-NeRF 360 garden at 30k (N = 5.83M total Gaussians, M = 2.22M visible in test view 0, I = 3.91M tile pairs), the pipeline's three stage types sit close to their respective hardware roofs:

Per-Gaussian Preprocess & Backward (preprocess: 0.65 ms bf16 / 0.80 ms f32; preprocess_bwd: 1.04 ms bf16 / 1.63 ms f32). Both stages are HBM-bandwidth-bound: reading all N geometries plus visible SH coefficients and writing 2D splat attributes in forward (~0.60 GB bf16 / ~0.82 GB f32) reaches 69–76% of sustained A100 HBM bandwidth, while writing all 59 parameter gradients across N Gaussians in backward (~0.82 GB bf16 / ~1.38 GB f32 of gradient writes, plus up to ~0.39 GB / ~0.60 GB of reads if all M visible Gaussians are touched) reaches between 58–63% (writes alone) and 86–90% (with all visible reads) of sustained HBM bandwidth. Going faster here requires reducing the bytes moved per step—for example via coarse spatial frustum culling (octree/BVH), adaptive per-splat SH degree, or sparsity-inducing pruning [3].
Tile Binning & Two-Level Sort (binning: 0.74 ms bf16 / 0.75 ms f32). Moving ~740 MB across the 32-bit visible depth sort, prefix scan, pair emission, and 16-bit tile sort achieves ~1,000 GB/s effective throughput (74% of sustained HBM bandwidth).
Tile Rasterization (raster_fwd: 0.99 ms bf16 / 0.98 ms f32; raster_bwd: 1.51 ms bf16 / 1.50 ms f32). Both rasterizers are SM-execution-pipe bound, with shared-memory batching broadcasting each 48-byte splat across the tile and s06's 8 × 8 multi-row warps driving FMA pipe utilization to 70.3% of peak with 0.0% barrier stalls (Nsight Compute, same synthetic profiling scene as Step 6).

Full 9-Scene Mip-NeRF 360 Benchmark (7,000 & 30,000 Iterations)

We benchmark our progression alongside Inria [1], gsplat [4], and LichtFeld Studio [12] across all nine Mip-NeRF 360 scenes (5 outdoor and 4 indoor) at both the 7,000-iteration (Table 2) and 30,000-iteration (Table 3) checkpoints on 1× NVIDIA A100-SXM4-40GB.

Table 2: Per-scene latency (ms) at the 7,000-iteration checkpoint across all 9 Mip-NeRF 360 scenes on 1× NVIDIA A100-SXM4-40GB (synchronous execution; per-job mean over all test views, median of 3 independent jobs). Mean is the arithmetic mean over the 9 scenes; vs. Inria is the ratio of means. Legend: shaded rows are reference implementations (Inria [1], gsplat [4], LichtFeld Studio [12]); bold slate marks the fastest value in each column of a block (highest for vs. Inria); the emphasized row is our final kernel (s06, bfloat16 SH), and the italic vs. Inria row below it gives its per-scene speedup over Inria.
7k checkpoint Outdoor Indoor Mean vs.
Inria
bicy. flow. gard. stump tree. room coun. kitc. bons.
fwd-bwd (training step)
Inria [1] 23.24 17.17 24.84 18.94 19.45 28.69 28.50 33.83 22.94 24.18 1.00×
gsplat [4] 23.95 21.01 25.05 21.86 20.36 26.27 25.46 30.76 22.43 24.13 1.00×
LichtFeld (fastgs) [12] 10.05 7.37 11.53 8.83 7.71 7.64 8.06 10.39 6.69 8.70 2.78×
s00 baseline 22.53 16.88 24.22 18.21 18.78 27.92 27.75 33.15 22.11 23.51 1.03×
s01 literature 5.89 4.09 7.07 5.23 4.46 5.03 5.15 6.91 4.23 5.34 4.53×
s02 reduce-scatter 5.29 3.60 6.51 4.53 3.92 4.00 4.14 5.90 3.51 4.60 5.26×
s03 SH streaming 5.16 3.54 6.36 4.54 3.94 3.99 4.14 5.73 3.40 4.53 5.33×
s04 raster pruning 4.68 3.28 5.85 4.22 3.49 3.76 3.87 5.44 3.20 4.20 5.76×
s05 branchless 4.12 2.94 5.09 3.75 3.06 3.27 3.33 4.55 2.86 3.66 6.60×
s06 f32 SH 3.76 2.76 4.56 3.41 2.86 2.62 2.71 3.83 2.33 3.20 7.55×
s06 bf16 SH 3.31 2.32 4.05 2.99 2.58 2.45 2.57 3.53 2.18 2.89 8.38×
vs. Inria (s06 bf16) 7.02× 7.40× 6.13× 6.33× 7.54× 11.71× 11.09× 9.58× 10.52× 8.38× —
fwd (rendering only)
Inria [1] 5.94 3.52 5.85 4.55 4.06 4.87 5.21 6.44 3.57 4.89 1.00×
gsplat [4] 14.06 13.43 11.52 13.65 13.18 18.88 17.07 18.07 15.68 15.06 0.32×
LichtFeld (fastgs) [12] 2.85 2.21 3.53 2.59 2.33 2.08 2.33 3.03 1.85 2.53 1.93×
s00 baseline 6.20 3.91 5.96 4.75 4.39 5.04 5.37 6.75 3.78 5.13 0.95×
s01 literature 2.25 1.61 2.58 1.96 1.78 1.85 1.86 2.38 1.57 1.98 2.47×
s02 reduce-scatter 2.27 1.59 2.47 1.89 1.75 1.73 1.86 2.29 1.55 1.93 2.53×
s03 SH streaming 2.22 1.64 2.62 1.90 1.75 1.79 1.92 2.37 1.55 1.97 2.48×
s04 raster pruning 2.34 1.73 2.68 2.08 1.89 1.98 2.11 2.81 1.74 2.15 2.27×
s05 branchless 2.14 1.53 2.51 1.87 1.64 1.72 1.82 2.34 1.51 1.90 2.58×
s06 f32 SH 2.14 1.51 2.35 1.77 1.66 1.46 1.62 2.14 1.34 1.78 2.75×
s06 bf16 SH 2.07 1.46 2.29 1.76 1.60 1.50 1.53 2.04 1.38 1.74 2.82×
vs. Inria (s06 bf16) 2.87× 2.41× 2.55× 2.59× 2.54× 3.25× 3.41× 3.16× 2.59× 2.82× —
Table 3: Per-scene latency (ms) at the 30,000-iteration checkpoint across all 9 Mip-NeRF 360 scenes on 1× NVIDIA A100-SXM4-40GB (synchronous execution; per-job mean over all test views, median of 3 independent jobs). Mean is the arithmetic mean over the 9 scenes; vs. Inria is the ratio of means. Legend: shaded rows are reference implementations (Inria [1], gsplat [4], LichtFeld Studio [12]); bold slate marks the fastest value in each column of a block (highest for vs. Inria); the emphasized row is our final kernel (s06, bfloat16 SH), and the italic vs. Inria row below it gives its per-scene speedup over Inria.
30k checkpoint Outdoor Indoor Mean vs.
Inria
bicy. flow. gard. stump tree. room coun. kitc. bons.
fwd-bwd (training step)
Inria [1] 28.52 18.07 25.90 20.40 19.89 26.09 25.51 29.86 22.11 24.04 1.00×
gsplat [4] 28.75 22.60 27.90 23.32 20.85 22.86 23.25 27.38 22.06 24.33 0.99×
LichtFeld (fastgs) [12] 14.13 9.13 13.02 10.40 9.38 7.68 7.92 9.98 6.52 9.80 2.45×
s00 baseline 27.79 17.50 25.34 19.43 18.84 25.18 24.70 29.25 20.98 23.22 1.04×
s01 literature 8.21 5.05 8.17 6.06 5.43 4.77 4.79 6.43 3.93 5.87 4.09×
s02 reduce-scatter 7.57 4.55 7.62 5.55 4.88 3.94 3.98 5.64 3.27 5.22 4.60×
s03 SH streaming 7.35 4.46 7.38 5.41 4.78 3.93 3.97 5.48 3.19 5.11 4.71×
s04 raster pruning 6.69 4.05 6.76 5.07 4.36 3.70 3.72 5.22 3.05 4.74 5.08×
s05 branchless 5.81 3.65 5.91 4.46 3.86 3.18 3.21 4.31 2.68 4.12 5.84×
s06 f32 SH 5.48 3.39 5.28 4.08 3.56 2.67 2.69 3.67 2.27 3.68 6.54×
s06 bf16 SH 4.78 2.90 4.61 3.59 3.16 2.47 2.51 3.44 2.19 3.29 7.30×
vs. Inria (s06 bf16) 5.97× 6.23× 5.62× 5.68× 6.29× 10.56× 10.16× 8.68× 10.10× 7.30× —
fwd (rendering only)
Inria [1] 9.17 4.75 7.42 5.63 5.25 6.08 6.03 6.89 4.14 6.15 1.00×
gsplat [4] 14.57 13.19 12.01 13.10 11.85 15.50 15.05 14.76 15.74 13.97 0.44×
LichtFeld (fastgs) [12] 4.19 2.73 4.02 3.13 2.91 2.24 2.45 2.66 1.82 2.91 2.12×
s00 baseline 9.30 4.88 7.33 5.40 5.43 6.35 6.06 7.10 4.37 6.25 0.98×
s01 literature 2.98 1.97 2.98 2.21 2.13 1.72 1.82 2.28 1.52 2.18 2.82×
s02 reduce-scatter 3.03 1.96 2.99 2.30 2.10 1.74 1.80 2.25 1.49 2.18 2.82×
s03 SH streaming 3.13 1.98 2.84 2.26 2.12 1.73 1.87 2.25 1.48 2.18 2.82×
s04 raster pruning 3.31 2.09 3.11 2.39 2.28 1.93 2.00 2.75 1.67 2.39 2.57×
s05 branchless 2.97 1.87 2.80 2.19 2.02 1.71 1.81 2.23 1.46 2.12 2.90×
s06 f32 SH 2.86 1.89 2.73 2.11 1.99 1.54 1.63 2.13 1.37 2.03 3.03×
s06 bf16 SH 2.83 1.77 2.77 2.10 1.96 1.53 1.56 1.97 1.32 1.98 3.11×
vs. Inria (s06 bf16) 3.24× 2.68× 2.68× 2.68× 2.68× 3.97× 3.87× 3.50× 3.14× 3.11× —

Why do the four indoor scenes (room, counter, kitchen, bonsai) speed up by 8.68× to 10.56× at 30k (9.58× to 11.71× at 7k), whereas the five outdoor scenes (bicycle, flowers, garden, stump, treehill) speed up by 5.62× to 6.29× at 30k (6.13× to 7.54× at 7k)? Because indoor and outdoor scenes stress completely different parts of the pipeline. Indoor scenes have relatively few Gaussians (N ≈ 1.2M–1.8M at 30k), so per-Gaussian memory traffic is small, but the camera sits close to walls, tables, and counters, producing large 2D splats that overlap dozens of tiles and cause severe per-tile overdraw and L2 atomic contention. Outdoor scenes have the opposite profile: to represent distant foliage and unbounded background detail, N grows 3–4× larger (3.6M–6.1M Gaussians at 30k), and each Gaussian projects to only 1–2 screen tiles. Consequently, a much larger share of the step time on outdoor scenes is spent simply streaming N Gaussians through HBM in preprocess and preprocess_bwd, where physical HBM bandwidth caps the achievable speedup even after SH streaming, bfloat16 storage, and visible compaction.

Why is gsplat 2.3×–3.1× slower than Inria in fwd yet matches Inria in fwd-bwd? In Tables 2 and 3, gsplat [4] is 2.3×–3.1× slower than Inria [1] in the forward pass (15.06 ms vs. 4.89 ms at 7k; 13.97 ms vs. 6.15 ms at 30k), yet matches Inria in combined fwd-bwd time (~24 ms). Whereas Inria fuses 3D projection, SH evaluation, and clamping into a single kernel, gsplat's default packed pipeline splits preprocessing into multiple PyTorch and CUDA dispatches: it runs projection twice over all N Gaussians with a blocking GPU-to-CPU count readback to allocate packed buffers, gathers the 48 SH coefficients of visible splats into a temporary HBM tensor before launching separate SH and clamp kernels, and reads RGB colors directly from global memory inside the rasterizer loop. In the backward pass, however, that up-front compaction and warp-level shuffle reductions make gsplat's backward pass ~1.7×–2.1× faster than Inria's (~9.1 ms vs. ~19.3 ms at 7k; ~10.4 ms vs. ~17.9 ms at 30k), canceling out its ~8–10 ms slower forward pass in combined fwd-bwd timing.

End-to-End 3DGS Training Verification

End-to-End 30,000-Iteration Training from COLMAP SfM

Single-step kernel microbenchmarks can hide subtle gradient biases that only manifest over tens of thousands of Adam updates and adaptive Gaussian densification/pruning cycles. To verify that our optimizations, including pre-scaled base-2 conics with inline PTX MUFU instructions (ex2.approx.ftz, rcp.approx.ftz) and branchless predication, preserve end-to-end reconstruction quality across both outdoor and indoor regimes, we trained Mip-NeRF 360 garden (outdoor, 1297 × 840) and room (indoor, 1557 × 1038) for the full 30,000 iterations from the initial COLMAP Structure-from-Motion point cloud across all three configurations (Table 4).

Table 4: End-to-end 30,000-iteration 3DGS training from COLMAP SfM on Mip-NeRF 360 garden (outdoor, 1297 × 840) and room (indoor, 1557 × 1038) on 1× NVIDIA A100-SXM4-40GB, including adaptive densification, opacity resets, SSIM + L1 loss, and Adam updates across all 59 parameters in a shared JAX training harness. Training-loop time excludes JIT compilation, periodic test-set evaluation, and checkpoint I/O. Single seed per configuration; our row trains with cuda_impl, the production build of our final kernel.
Configuration (30,000 iterations) Test PSNR (dB) ↑ Test SSIM ↑ Final Gaussians N Training-loop time ↓
garden — outdoor (1297 × 840)
Inria [1] 27.42 0.8673 5,923,445 23.0 min (1.00×)
Inria + Literature (s01, float32 SH) 27.38 0.8672 5,915,536 8.1 min (2.83×)
Ours s06 (float32 SH) 27.35 0.8669 5,893,074 6.7 min (3.42×)
room — indoor (1557 × 1038)
Inria [1] 31.46 0.9200 1,573,221 17.5 min (1.00×)
Inria + Literature (s01, float32 SH) 31.37 0.9199 1,575,077 4.3 min (4.03×)
Ours s06 (float32 SH) 31.45 0.9204 1,560,779 3.1 min (5.59×)

On both scenes, all three configurations converge to virtually identical held-out test quality (27.35 to 27.42 dB PSNR and 0.8669 to 0.8673 SSIM on garden, and 31.37 to 31.46 dB PSNR and 0.9199 to 0.9204 SSIM on room), with final Gaussian counts within 1% of one another. This confirms that pre-scaled base-2 conics with inline PTX ex2.approx.ftz / rcp.approx.ftz and branchless predication introduce zero optimization drift across 30,000 Adam steps and adaptive densification cycles. In training-loop time (excluding JIT compilation, evaluation, and checkpoint I/O), s01_literature reduces garden from 23.0 min to 8.1 min (2.83×) and s06_multirow_and_pipe_math cuts it further to 6.7 min (3.42× vs. Inria, 1.21× vs. s01); on room, s01_literature reduces training from 17.5 min to 4.3 min (4.03×) and s06_multirow_and_pipe_math brings it down to 3.1 min (5.59× vs. Inria, 1.38× vs. s01). End-to-end training speeds up less than the standalone rasterizer because each iteration also computes the SSIM and L1 image loss, updates all 59 parameters of every Gaussian with Adam, and periodically densifies and prunes the scene. Once s06 reduces rasterization to just a few milliseconds per step, the optimizer and image loss dominate training-loop time—especially on garden, where Adam must read and update nearly four times as many Gaussians as on room.

Note

This article and the kernels behind it were heavily brainstormed, coded, and tested by autonomous agents, and more specifically Gemini 4 Argon.

References

  1. B. Kerbl, G. Kopanas, T. Leimkühler, and G. Drettakis, “3D Gaussian Splatting for Real-Time Radiance Field Rendering,” ACM Transactions on Graphics (SIGGRAPH), vol. 42, no. 4, article 139, 2023.
  2. S. Durvasula, A. Zhao, F. Chen, R. Liang, P. K. Sanjaya, and N. Vijaykumar, “DISTWAR: Fast Differentiable Rendering on Raster-based Rendering Pipelines,” arXiv preprint arXiv:2401.05345, 2024.
  3. S. S. Mallick, R. Goel, B. Kerbl, M. Steinberger, F. V. Carrasco, and F. De la Torre, “Taming 3DGS: High-Quality Radiance Fields with Limited Resources,” in SIGGRAPH Asia Conference Papers, 2024.
  4. V. Ye, R. Li, J. Kerr, M. Turkulainen, B. Yi, Z. Pan, O. Seiskari, J. Ye, J. Hu, M. Tancik, and A. Kanazawa, “gsplat: An Open-Source Library for Gaussian Splatting,” Journal of Machine Learning Research (JMLR), vol. 26, no. 34, pp. 1–17, 2025.
  5. L. Radl, M. Steiner, M. Parger, A. Weinrauch, B. Kerbl, and M. Steinberger, “StopThePop: Sorted Gaussian Splatting for View-Consistent Real-time Rendering,” ACM Transactions on Graphics (SIGGRAPH), vol. 43, no. 4, article 64, 2024.
  6. G. Feng, S. Chen, R. Fu, Z. Liao, Y. Wang, T. Liu, B. Hu, L. Xu, Z. Pei, H. Li, X. Li, X. Zhang, and B. Dai, “FlashGS: Efficient 3D Gaussian Splatting for Large-scale and High-resolution Rendering,” in Proceedings of the IEEE/CVF Conference on Computer Vision and Pattern Recognition (CVPR), 2025.
  7. A. Hanson, A. Tu, G. Lin, V. Singla, M. Zwicker, and T. Goldstein, “Speedy-Splat: Fast 3D Gaussian Splatting with Sparse Pixels and Sparse Primitives,” in Proceedings of the IEEE/CVF Conference on Computer Vision and Pattern Recognition (CVPR), 2025.
  8. M. Schütz, L. Radl, M. Wimmer, and B. Kerbl, “Splatshop: Efficiently Editing Large Gaussian Splat Models,” Computer Graphics Forum (Proceedings of High-Performance Graphics / HPG), vol. 44, no. 8, 2025.
  9. F. Hahlbohm, L. Franke, M. Eisemann, and M. Magnor, “Faster-GS: Analyzing and Improving Gaussian Splatting Optimization,” in Proceedings of the IEEE/CVF Conference on Computer Vision and Pattern Recognition (CVPR), 2026.
  10. D. Pająk, M. Bisson, and R. Lima, “HiGS: A Hierarchical Rendering Architecture for Real-Time 3D Gaussian Splatting,” arXiv preprint arXiv:2606.00352, 2026.
  11. K. J. Yang, G. Drettakis, and G. Bernstein, “Warp-Level Culling for Efficient Blending in 3D Gaussian Splatting,” Proceedings of the ACM on Computer Graphics and Interactive Techniques (PACMCGIT / HPG), 2026.
  12. F. Hahlbohm and MrNeRF, “LichtFeld Studio (fastgs CUDA Rasterizer),” Open-Source Software Repository, 2025.