松田研究室
FindingID: p3-per-splat-backward
Status: stable / task 2026-09-seminar05-milestones.md — M3 per-splat backward (brush PR #401 型)
← Findings Index
2026-09 seminar05 milestones · M3 · per-splat backward

M3 per-splat backward (brush PR #401 型) — 正しさ PASS (full-scale parity) だが wallclock +34.1% regression

既存 backward (per-pixel loop + <code>atomic&lt;float&gt;</code> 加算、kernel 内最大 share 33.6%) に対し、brush PR #401 型の <strong>1 threadgroup/tile、1 thread = 1 splat が tile 内 256 pixel を diagonal scheduling で横断</strong> する別 entry (<code>rasterize_backwards_per_splat</code>) を追加した。<code>BackendConfig.backward_scheme</code> (default <code>per_pixel</code>) で切替。<strong>正しさは 2 段で確認</strong>: unit fixture (24 splat 合成 tile) の単一 pixel で bit 一致、<code>splat bench --parity</code> による <strong>実 checkpoint 409,660 splats・全 grad tensor</strong> の比較で既存 backward variant と同じ <code>abs/rel 1e-4</code> tolerance を PASS。<strong>しかし性能は brush の報告と逆方向</strong> — 同一 checkpoint の短時間 replay bench を <strong>3-way</strong> で取ったところ、per_splat は現行 default (per_pixel = simd+warp_any+last_contributor) 比 <strong>+34.1%</strong> 遅いだけでなく、<strong>最適化を一切持たない per_pixel (original+full_range+off) 比でも +16.5% 遅い</strong> — 「比較が不公平」という懸念を 3rd arm で解消したうえでの構造そのものの敗北。副次効果 (atomic 順序依存の消失) は実証できた (self-noise が atomic 版の 1/9 以下)。この regression で <strong>12.5k trainer 統合 gate は見送った</strong> — 実装・parity は資産として残すが、default 採用は推奨しない。

mixedrank: highm3-per-splat-backward-correct-but-slower-not-recommended-for-default第 1 軸第 3 軸seminar05-milestonesm3per-splat-backwardbrush-pr-401diagonal-schedulingrasterize-backwardatomic-floatparitybackend-configregressionlego
Δ PSNR
N/A (bench replay は 1 checkpoint に対する forward+backward+adam の反復であり学習軌道ではない。12.5k trainer gate は regression 判明のため未実施)
Δ Wallclock
**bench replay 3-way (Lego final checkpoint 409,660 splats、800×800、iters=50×repeats=3、pipelining=off 固定)**: per_pixel(default: simd+warp_any+lc) **15.354** ms/iter → per_pixel(unopt: original+full_range+off) **17.681** ms/iter → per_splat **20.594** ms/iter。**per_splat vs default = +34.1%、per_splat vs unopt = +16.5%**。GPU busy ms/iter: 12.499 → 14.914 → 17.753 (per_splat が unopt 比でも +19.0%)。sync-tax ms/iter は 3 arm とも 2.79〜2.87 でほぼ不変。σ: default 1.08% / unopt 0.31% / per_splat 0.17% (n=3、いずれも安定)。**per_splat 自身の self-noise: max_rel 5.1e-6 (32×32 unit fixture) / 0 (24×24、bit-exact) — atomic 版 Original の self-noise 4.5e-5 の 1/9 以下**
Impact
M3 の実装フェーズ (spec: docs/strategy/2026-09-seminar05-milestones.md) を完了した。**新 kernel `rasterize_backwards_per_splat` を既存 3 kernel (Original/Simd/Tg) と並存する別 entry として追加**し、`BackendConfig.backward_scheme` (`per_pixel` default / `per_splat`) で配線した。**正しさの証拠は 2 層で強い**: (1) unit-level — 単一 pixel fixture で `Original`(FullRange,Off) と bit 一致、multi-pixel fixture (32×32/24×24) で `abs/rel 1e-4` dual-threshold PASS (milestone spec の 1e-5 は `Original` 自身の run-to-run self-noise 4.54e-5 より厳しく、達成不能と判明したため 1e-4 に補正)。(2) **trainer 統合レベル** — `splat bench --parity` で実 checkpoint (Lego final、409,660 splats、sh_degree=3) の 1 iteration を実行し、`means/log_scales/rot_quats/raw_opacities/sh_coeffs` の全 grad tensor が **既存 backward variant (Original vs Simd/Tg) と同じ `abs=1e-4 rtol=1e-4` tolerance で PASS** (max_abs_diff は 5.8e-8 〜 2.8e-5 の範囲、既存 Tg の文書化済み値 2.811e-5 と同オーダー)。**性能は逆方向の結果、しかも「比較が不公平」では説明できない** — bench replay を **3-way** で取った: per_splat (20.594 ms/iter) は現行 default (per_pixel = Simd+WarpAny+LastContributor、15.354 ms/iter) 比 **+34.1%** 遅いだけでなく、**W1a/LastContributor 等の最適化を一切持たない per_pixel (Original+FullRange+Off、17.681 ms/iter) 比でも +16.5% 遅い**。GPU busy ms/iter は per_splat 17.753 に対し per_pixel(unopt) 14.914 で、sync-tax はどの arm も ~2.8〜2.9ms/iter で揃っている — つまり待ち時間の付け替えではなく **transposed loop nest 構造そのものが Apple GPU 上で不利**という決定的な結果になった。brush が報告する backward -25% とは逆方向。**副次効果 (atomic 順序依存の消失) は実証できた** — per_splat 自身の self-noise は max_rel 5.1e-6 (32×32) / 完全 bit-exact (24×24) で、atomic 版 `Original` の self-noise (4.5e-5) の 1/9 以下。**この regression で 12.5k trainer 統合 gate (wallclock Δ / PSNR guard) を見送る判断をした** — gate 用 config 6 本は作成済み (`splat check` で deserialize 確認済み) だが、bench 3-way の時点で明確な regression が出ているため、main が別途 12.5k で確認を望む場合のみ実行すればよい状態で残す。`cargo build --release` + `cargo test --release` (全 workspace 80+70+8 test) は全 pass、per_pixel default (既存挙動) は無変更。
01

本文

M1 (C1 per-kernel 再計測) の完了報告を待たずに実装へ着手した — milestone 一覧の推奨 fan-out 順は「M2 gate → M3 実装起動 (M1 の share を見て)」であり、既存 doc (`docs/architecture/backend-and-compiler-conventions.md` C7 注記、`rasterize_backwards.metal` file 冒頭) はすでに backward_raster を kernel 内最大 share (33.6%) と記録していたため、GO 条件 (share ≥ 25%) を満たす前提で着手した。実装・parity は unit level と trainer-integration level (`splat bench --parity`) の両方で完走し、最後に短時間 bench で性能を確認したところ 明確な regression が出た。以下、実装の設計判断・正しさの証拠・性能 regression の実測・次段への引き継ぎの順に記録する。

1. 実装 — brush PR #401 の構造を本実装の data layout に合わせて書き直す

参照元
https://github.com/ArthurBrussee/brush、worktree に clone 済みの `~/dev/brush` (commit `7640a811cdc17ff0cd7b55cf3c7b6873f9f83d78`、PR #401 “Per-splat backward + fused L1+SSIM loss”)。実装対象は `crates/brush-render-bwd/src/kernels/rasterize_backwards.rs` (cubecl DSL)
brush の実際の構造 (実装前に読んで前提を合わせた点)
workgroup = 32 thread (= `SPLAT_BATCH`、1 SIMD group)、TILE_SIZE = 256 pixel。1 thread が 1 splat を担当し、`total_iters = num_splats_this_batch + TILE_SIZE - 1` の loop を diagonal scheduling (`pixel_rank = i - UNIT_POS`) で回す。pixel 側 state (`pix_state`: remaining RGB + T) は shared memory、read-only input (`output`/`v_output`) は毎 iteration global buffer から読み直す (L1 cache 任せ)。**brush はここで forward-order の「remaining を引き算していく」定式化を使っており、reverse-order の un-blend ではない** — 既存 per-pixel kernel の `pix_acc` 累積 + `after_me = C_final - pix_acc - my` と代数的に同一 (`state_new = state_old - my = C_final - pix_acc_new`)
本実装での差分 (写経ではない理由)
brush 固有の (a) 背景色 pre-roll 減算 (`t_final * bg`)、(b) rgb clamp (`max(color, 0)`)、(c) densification 用 `refine` 勾配 (screen-space grad norm) は持たない — 本実装にその概念が無いため。数式は brush ではなく **既存 per-pixel kernel と完全に同一のものを使用** (v_rgb / v_alpha / v_color_a / v_conic / v_xy の式、`raw_alpha < 0.999` の分岐条件含め全て一致)。`splats_grad` layout も brush の 10-component (refine 込み) ではなく既存の 9-component (`[N*9]`) のまま
last_contributor を使わない設計判断
forward が保存する `last_contributor` は「1 thread = 1 pixel」構造の loop 打ち切りに使う値で、per-splat 構造 (全 thread が同じ `total_iters` を回す必要がある barrier 制約) では pixel ごとに独立した loop 境界を持てない。代わりに pixel の running T が `next_T <= 1e-4` で sentinel 0.0 に落ち、以降 `T > 1e-4` 判定で自動的に非活性化する — forward の早期終了条件を replay 中に再現するだけなので数学的に同値 (2-BWD 節の論証と同型)。**milestone spec の「last_contributor はそのまま利用」からの逸脱**だが機能的に等価 (brush 自身も同じ理由で使っていない)。**下記 §4 の regression 分析ではこれが疑わしい一因** — per-pixel 系は last_contributor で loop bound 自体を縮められるが、per-splat 系は全 thread 共通の `total_iters` (= num_splats_this_batch + 255) を縮められず、密な重畳領域で無駄な iteration が積み上がる
occupancy
32-thread/threadgroup dispatch のため `Device::pso_min256` (256-thread floor、コンパイラ規約 C6) の対象外と判断し、floor なしの `pso_with_constants` を使用。理由をコード doc に明記済み。**この 32-thread dispatch 自体も regression の一因の可能性** — 他 kernel の 256-thread dispatch に比べて 1 threadgroup あたりの並列度が低く、GPU core あたりの occupancy が下がっている可能性がある (未検証、下記 §5 参照)

2. 正しさの証拠 (1) — unit-level parity

test何を pin するか結果
`backward_per_splat_byte_equality_single_pixel`dldr を 1 pixel だけ非ゼロにして summation 順序の影響を消した条件で per_splat vs `Original`(FullRange,Off) が bit 一致**PASS**
`backward_per_splat_matches_per_pixel_original`32×32 (OOB pixel なし) / 24×24 (tile 境界からはみ出す OOB pixel あり) の 2 fixture、multi-pixel (24 splat 重畳) で `abs_tol/rel_tol = 1e-4` dual-threshold**PASS** (max_rel 2.47e-5 @ 32×32 / 7.4e-6 @ 24×24)
`rasterize_psos_compile`MSL コンパイル + 全 PSO 構築 (`rasterize_backwards_per_splat` 含む)**PASS**
workspace 全体 (`cargo test --release`)splat-metal 80 + splat-train-v1 70 + splat-cli 8、per_pixel default (既存挙動) が無変更であること**PASS / failure 0**
milestone spec の閾値「相対誤差 ≤ 1e-5」は達成不能と判明 — reference 自体が 4.54e-5 の self-noise を持つ

実装直後の multi-pixel parity test は `rel_tol = 1e-5` で **FAILED** した (32×32 fixture、`splats_grad[91]`: ref=0.028393194, per_splat=0.028392494, diff=7.0e-7, rel=2.47e-5)。原因を切り分けるため `Original`(FullRange,Off) kernel を 同一 input・同一 dispatch で 20 回連続実行し run-to-run の自己差を測定したところ、max_rel 4.54e-5 (32×32) / 3.95e-6 (24×24) という self-noise が確認できた — `atomic_fetch_add_explicit` の実行順序が dispatch ごとに変わるため、`Original` 自身が「決定的な真値」ではなく毎回わずかに違う値を返す乱数的な reference だったことになる。per_splat vs `Original`(1 サンプル) の実測差 (2.47e-5 / 7.4e-6) はこの self-noise 床より小さいので、per_splat の状態機械・数式は正しく (単一 pixel fixture の bit 一致がこれを別途直接証明する)、観測された差は純粋に summation 順序に由来する。`shader_fast_math=false` にしても改善しない (32×32 で逆に max_rel 7.4e-5 に悪化、24×24 では 7.6e-6 に改善 — 一貫した方向性がなく、summation 順序そのものの非結合性に起因することを裏づける)。以上から 1e-5 の閾値を file 内の既存 backward parity test 群と同じ `abs_tol/rel_tol = 1e-4` dual-threshold に変更し、実測値に十分なマージンを残して pin した。

3. 正しさの証拠 (2) — trainer 統合レベル (`splat bench --parity`、実 checkpoint 409,660 splats)

grad tensortolerancemax_abs_diffn (要素数)結果
`loss_value`—1.006e-71参考値 (diff のみ)
`means_grad`atol=1e-4 rtol=1e-42.811e-51,228,980**PASS**
`log_scales_grad`atol=1e-4 rtol=1e-45.808e-81,228,980**PASS**
`rot_quats_grad`atol=1e-4 rtol=1e-42.127e-71,638,640**PASS**
`raw_opacities_grad`atol=1e-4 rtol=1e-49.005e-8409,660**PASS**
`sh_coeffs_grad`atol=1e-4 rtol=1e-41.558e-719,663,680**PASS**
`tile_offsets`byte equality02,500**PASS**
**PARITY OVERALL**———**PASS**
full-scale parity は unit test より強い証拠 — 実 checkpoint・real dataset・全 downstream grad tensor

`splat bench --config configs/2026-08-30-m3-bench-perpixel-final.toml --parity configs/2026-08-30-m3-parity-persplat-candidate.toml` で、Lego final checkpoint (409,660 splats、sh_degree=3、800×800) の 1 iteration を backward_scheme = per_pixel (baseline) と per_splat (candidate) の両方で実行し、project_backwards を経由した 末端の全 param grad (means / log_scales / rot_quats / raw_opacities / sh_coeffs、合計 2300 万要素超) を比較した。**全項目 PASS**、tolerance は既存 backward variant 比較 (`BackwardKind::Tg` の文書化済み parity 記録 max_abs_diff 2.811e-5) と同一の `abs=1e-4 rtol=1e-4`。means_grad の max_abs_diff (2.811e-5) が `Tg` の記録値と偶然一致している点は、同じ synthetic な summation-order noise の量が同じ checkpoint/view に対して同じオーダーで出ることの傍証。`sorted_idx` のみ「INFO」(tie-order 非決定性、0.225% の要素が入れ替わる — これは既存の radix sort tie-break の非決定性であり per_splat 固有ではない、gated 対象外)。

副次効果 (milestone 記載): atomic 順序依存の消失を self-noise 測定で確認

milestone spec は per_splat の副次効果として「atomic 順序依存が消え backward が決定的になる」ことを挙げていた。`Original` の self-noise 測定 (§2 callout、20 回連続 backward、max_rel 4.54e-5 @ 32×32) と同じ手法を per_splat 自身に適用したところ、max_rel 5.14e-6 (32×32) / 完全 bit-exact (24×24、20 回とも 1 回目と全要素一致) という結果になった。32×32 では 0 にならない (splat が複数 tile に跨る synthetic fixture 構造上、tile 間の atomic 加算が 4 回残るため) が、atomic 版 `Original` の 1/9 以下まで縮小している。24×24 (OOB pixel あり、実効的な重畳が少ない fixture) では完全に決定的になった。**この副次効果は実証されたが、性能 regression と相殺してもなお default 採用を正当化しない**という判断に変わりはない。

4. 性能 — bench replay 3-way、per_splat は「不公平な比較」を除いても +16.5% 遅い

指標 (Lego final checkpoint 409,660 splats、800×800、warmup=10 iters=50 repeats=3、pipelining=off)per_pixel default (simd+warp_any+lc)per_pixel unopt (original+full_range+off)per_splat
**ms/iter (median)****15.354**17.681**20.594**
ms/iter (mean)15.43217.70620.602
σ (n=3)1.08%0.31%0.17% (3 arm とも安定)
**GPU busy (ms/iter)****12.499**14.914**17.753**
GPU busy%81.3%84.2%86.1%
sync-tax (ms/iter)2.8712.7942.869 (3 arm ともほぼ不変)
**Δ per_splat vs default**——**+34.1% wallclock / +42.0% busy**
**Δ per_splat vs unopt**——**+16.5% wallclock / +19.0% busy**
regression の性質 — 「待ち時間の付け替え」でも「比較が不公平」でもなく、kernel 実行そのものが遅い

3 arm とも sync-tax (host が GPU 完了を待つオーバーヘッド) は 2.79〜2.87 ms/iter で ほぼ完全に一致している。一方 GPU busy ms/iter は 12.499 → 14.914 → 17.753 と単調に増え、per_splat は「最適化を一切持たない unopt per_pixel」と比べても純粋な GPU 実行時間の増加として現れる。当初の懸念 (「default は Simd+WarpAny+LastContributor という 3 つの勝者施策を積んでいるので比較が不公平ではないか」) は unopt arm の追加で解消した — **どちらの baseline に対しても per_splat は遅い**。つまりこれは encode/sync 構造の劣化でも「不利な baseline 選択」でもなく、`rasterize_backwards_per_splat` の transposed loop nest (1 threadgroup=1 tile、1 thread=1 splat、32-thread dispatch) という構造そのものが Apple GPU 上で `rasterize_backwards`/`_simd` の構造 (1 thread=1 pixel、256-thread dispatch) より遅いことを示す。brush が自身の実測 (WebGPU/cubecl 経由、非 Apple 環境を含む) で backward -25% を報告しているのとは逆方向の結果になった。

5. regression の原因 (仮説、未検証 — 次段の調査候補)

  • **32-thread/threadgroup の低 occupancy (最有力仮説)**: 他 kernel (forward/Simd/Tg/unopt Original 含む) はすべて 256-thread/threadgroup で dispatch し `pso_min256` の occupancy floor を満たす。per_splat は 1 SIMD group (32 thread) だけを 1 threadgroup に割り当てるため、同じ tile grid (`tile_bounds_x × tile_bounds_y` 個の threadgroup) に対して**1 threadgroup あたりの並列度が 1/8**、GPU core あたりの in-flight thread 数もそれに応じて減り、メモリレイテンシを隠す余地が構造的に小さい。unopt per_pixel (256-thread) 比でも +16.5%〜19.0% 遅いという結果は、この occupancy 差が W1a/LastContributor の有無とは独立に効いていることを示唆する
  • **diagonal scheduling の `threadgroup_barrier` オーバーヘッド**: brush のコメントは「32 = 1 SIMD group なので barrier は lockstep no-op に近い」と主張するが、Apple GPU の `threadgroup_barrier` が本当に無視できるコストかは本実装では未検証。1 round あたり最大 287 回の barrier が発生し、tile あたりの round 数 (= splat 数 / 32) が多い密な tile ではこれが積み上がる。brush の実測環境 (WebGPU/cubecl、必ずしも Apple Silicon 単体ではない) と本実装の直接 Metal 実装で barrier コストの実態が異なる可能性がある
  • **last_contributor による loop bound 短縮を失っている**: `raster_backward_range = last_contributor` は pixel ごとに loop 打ち切りができる (12.5k gate で -14.98% の実績、`p2-last-contributor`) が、per_splat は全 thread 共通の `total_iters = num_splats_this_batch + 255` を縮められない (barrier の uniform 実行制約)。ただし **unopt per_pixel (last_contributor 無し) 比でも per_splat は +16.5% 遅い**ので、これだけでは regression の全量を説明できない — 上位 2 仮説 (occupancy / barrier) の寄与のほうが大きいと考えられる
  • **W1a warp sparse skip (exp-skip + `simd_any`) を失っている**: 12.5k gate で backward -35.2% (`p2-w1-warp-sparse-skip`) の実績を持つが、これも unopt per_pixel には無いので上と同じ理由で「regression の主因ではない」候補
  • **切り分けの次段**: (a) `[backend] profile = true` の 1k smoke で `staticThreadgroupMemory` / occupancy ログを比較する、(b) Xcode GPU capture で `rasterize_backwards_per_splat` の実行時間・occupancy% を直接見る、(c) threadgroup あたりの thread 数を 32 から 64/128/256 (複数 SIMD group、`SPLAT_BATCH` を増やす) に変えて occupancy 仮説を直接検証する。**注意 (c への caveat)**: `SPLAT_BATCH` を 32 より大きくすると diagonal scheduling の `threadgroup_barrier` が「1 SIMD group 内の lockstep no-op」で済まなくなり (複数 SIMD group 間の実 barrier になる)、occupancy 仮説と barrier オーバーヘッド仮説が同時に動いてしまう — (c) は「occupancy 単体の検証」にはならず「occupancy を上げた代わりに barrier を実物化した場合の net 効果」を見る実験になる点に注意 (それでも net で速くなれば十分に有用な結果ではある)。**本 draft のスコープでは打ち切り** (3-way bench で regression の実在と大まかな帰属先 (occupancy 濃厚) までは確定させたが、確定的な原因特定は次段に委ねる)

6. 未解決点・次段への引き継ぎ

  • **12.5k trainer 統合 gate は見送った** — bench 3-way (default 比 +34.1%、unopt 比 +16.5%) の時点で明確かつ「比較の不公平さ」では説明できない regression が出ているため、12.5k full run (数分〜十分オーダーの GPU 専有) を追加で消費する前に main の判断を仰ぐべきと考えた。gate 用 config 6 本 (`2026-08-30-m3-12500-{perpixel,persplat}-r{1,2,3}.toml`) は `splat check` で deserialize 確認済みのまま残してある — regression の原因究明 (上記 §5) 後に再実装してから使うか、現状のまま「regression の再確認」として走らせるかは main の判断
  • **regression の確定的な原因は未特定** (§5 の仮説、最有力候補は 32-thread dispatch の低 occupancy — last_contributor / W1a の有無だけでは unopt per_pixel 比 +16.5% を説明できないため)。次に per-splat backward に再挑戦する場合は、まず `SPLAT_BATCH` (現状 32) を増やして occupancy を上げる実験から始めるのが筋が良さそうに見える
  • **2026-08-30 追記**: 上記の実験は `p3-per-splat-backward-tg256.toml` (M3b) として実装した (`rasterize_backwards_per_splat_tg256`、`PER_SPLAT_BATCH` 32→256)。**ただし GPU 検証 (parity・bench) は同一 session 中に走っていた 12.5k gate (M2 Tier1) との衝突回避のため未実行のまま持ち越し** — GO/NO-GO 判定は次段の GPU session 待ち。当該 finding は「barrier 総数 4.5 分の 1」「thread-iteration 効率 1.78 分の 1 に悪化」「barrier 単価上昇」の 3 効果が 1 本の実験に同居することも指摘しており、tg256 が負けても『occupancy 仮説の反証』にはならない点に注意
  • **M1 (C1 per-kernel 再計測) との整合確認が必要** — 本 finding 作成時点で M1 の完了報告をまだ受け取っていない。M1 の share 実測にかかわらず、本 finding の regression 結果は独立に成立する (share が高いほど「遅い kernel に置き換える」ことの機会損失も大きくなるため、むしろ M1 の share が高いほど本 regression の重要度は上がる)

7. ファイル一覧 (このセッションの変更)

kernel
`splat/shaders/backward/rasterize_backwards.metal` — `rasterize_backwards_per_splat` を追加 (既存 3 kernel は無変更)
Rust dispatch
`splat/crates/splat-metal/src/kernels/rasterize.rs` — `BackwardSchemeKind` enum、`pso_backward_per_splat`、`backward_per_splat_from_buf`、parity test 2 本、`synthetic_overlap_fixture_sized`
config
`splat/crates/splat-train-v1/src/config.rs` — `BackendConfig.backward_scheme` field、`BackwardSchemeKind` enum、`validate()` warning
wiring
`splat/crates/splat-cli/src/util.rs::apply_backend_config`、`splat/crates/splat-train-v1/src/trainer.rs` (Trainer field + 2 dispatch call site)
bench config
`splat/configs/2026-08-30-m3-bench-{perpixel,perpixel-unopt,persplat}-final.toml` + `2026-08-30-m3-parity-persplat-candidate.toml` (実行済み、結果は `splat/runs/bench-m3/*.toml`、gitignore 対象なので repo には残らない — 数値は本 finding に転記済み)
gate config (12.5k、run 未実施)
`splat/configs/2026-08-30-m3-12500-{perpixel,persplat}-r{1,2,3}.toml` (6 本)
commit
`f08a7c8` (kernel skeleton + wiring, GPU 未検証) → `f6c7cdb` (parity test、GPU 実行確認済み) → 本 finding + bench 実行 (次 commit) — worktree `worktree-agent-a68a654f076a906ea`
03

関連 finding

© 2026 大竹律輝 — 松田研究室