既存 backward (per-pixel loop + <code>atomic<float></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 採用は推奨しない。
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 の実測・次段への引き継ぎの順に記録する。
| 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** |
実装直後の 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 した。
| grad tensor | tolerance | max_abs_diff | n (要素数) | 結果 |
|---|---|---|---|---|
| `loss_value` | — | 1.006e-7 | 1 | 参考値 (diff のみ) |
| `means_grad` | atol=1e-4 rtol=1e-4 | 2.811e-5 | 1,228,980 | **PASS** |
| `log_scales_grad` | atol=1e-4 rtol=1e-4 | 5.808e-8 | 1,228,980 | **PASS** |
| `rot_quats_grad` | atol=1e-4 rtol=1e-4 | 2.127e-7 | 1,638,640 | **PASS** |
| `raw_opacities_grad` | atol=1e-4 rtol=1e-4 | 9.005e-8 | 409,660 | **PASS** |
| `sh_coeffs_grad` | atol=1e-4 rtol=1e-4 | 1.558e-7 | 19,663,680 | **PASS** |
| `tile_offsets` | byte equality | 0 | 2,500 | **PASS** |
| **PARITY OVERALL** | — | — | — | **PASS** |
`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 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 採用を正当化しない**という判断に変わりはない。
| 指標 (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.432 | 17.706 | 20.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.871 | 2.794 | 2.869 (3 arm ともほぼ不変) |
| **Δ per_splat vs default** | — | — | **+34.1% wallclock / +42.0% busy** |
| **Δ per_splat vs unopt** | — | — | **+16.5% wallclock / +19.0% busy** |
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% を報告しているのとは逆方向の結果になった。