松田研究室
FindingID: p3-refine-3pass
Status: stable / task docs/strategy/2026-08-next-phase-tasklist.md Tier2 A7 (refine の 3-pass 削減)
← Findings Index
docs/strategy/2026-08-next-phase-tasklist.md · Tier2 A7 · refine 3-pass 削減

A7 refine 3-pass 削減 — 採用確定 (2026-08-30 gate)

refine tick (100 iter に 1 回) の CPU compact 実装が、毎 tick 30 本の scratch <code>Vec</code> (15 read snapshot + 15 write staging、Lego 407k splats で ~576MB/tick) を <code>Vec::with_capacity</code>/<code>vec![0.0; ..]</code> で新規確保し、最後に <code>write_vec</code> で Metal buffer へ一括コピーする「pass 4」を経由していた。<code>[backend] refine_scratch_persist</code> (scratch を <code>RefineState</code> に永続化) と <code>refine_direct_write</code> (compact loop が Metal buffer に直接書き、pass 4 を削除) の 2 flag で実装した。(b) の安全性は「tick 時点で in-flight command buffer が 0」に依存し、depth2 pipelining はこの前提を構造的に満たす (<code>param_mutated_after_step()</code> が refine tick で先行 forward encode を skip し、<code>step()</code> は戻る前に <code>B_it</code> を <code>wait_until_completed()</code> する)。書き込みロジック自体は 1 つも変えていないため、新設した parity test <code>refine_flag_combinations_produce_identical_output</code> は 4 通りの flag 組合せが bit-exact に一致することを確認する。<strong>12.5k n=3 gate (2 回目 series、1 回目は load 汚染で棄却) で Δ -2.149% (閾値 1.612%)、PSNR +0.0197dB guard 内、3/3 pair 一貫 — 採用確定、default を true に flip</strong>。

positiverank: midadopted第 1 軸第 3 軸p3-refine-3passa7refinescratch-persistdirect-writealloc-churnin-flight-invariantlegogate-adopteddefault-flipcontaminated-series
Δ PSNR
+0.0197 dB (12.5k n=3、guard 内。書き込みロジック自体は不変なので理論上の期待通りノイズレベル)
Δ Wallclock
**-2.149%** (12.5k n=3、閾値 max(2σ_off,1%)=1.612%、3/3 pair 一貫)。tasklist 期待帯 -1.3〜-2.6% にちょうど収まる
Impact
A7 (refine の 3-pass 削減) を実装・gate した。(a) refine_scratch_persist: compact pass の scratch Vec 30 本を RefineState (新設 RefineScratch) に永続化し std::mem::take で借り出し・tick 末尾で書き戻す。allocation の出所が変わるだけで計算は 1 つも変えない。(b) refine_direct_write: compact 書き込み先の抽象 Dst (Vec か Metal buffer 生ポインタ) を導入し、direct_write = true のとき新規 Vec を一切作らず compact loop が param.*/adam_state.* に直接書く。実装中に見つけた subtle な罠: Clone の複製先 / Respawn / Split の子スロットは旧実装では「新規 0 埋め Vec」から無料で Adam m/v = 0 を得ていたが、direct 経路は stale データが残っている可能性のある live buffer に書くため、この暗黙の 0 を前提にできない — zero_adam_at ヘルパーで明示的に 0 を書くよう修正した (無修正だと A7(b) 単体で MCMC 論文の「新規 slot は Adam fresh start」不変条件が壊れていた)。不変条件 I ([num_splats, capacity) は常に 0) は direct 経路でも zero_tail による [n_new, n_write) の明示 0 埋めで維持する。gate: 12.5k n=3 交互 A/B (2回目 series、a7v2-12500) で Δ -2.149% (閾値 max(2σ_off,1%)=1.612%)、PSNR +0.0197dB (guard 内)、3/3 pair 一貫 (on<off)。1回目 series は load 汚染 (6 run 中 3 run に load1 スパイク) で棄却。default flip 実施: BackendConfig::default() の両 flag を true に変更。field 単独の #[serde(default)] は付けない (bool 型に付けると bool::default()=false に固定され struct 側の true を上書きするため — target_cache/shader_fast_math と同じ規約)、これは 1 iter smoke で config 省略時に refine_scratch_persist: true, refine_direct_write: true が実際に解決されることを確認済み。cargo test --workspace --release 全 pass (0 failed)。
01

本文

旧実装は 1 tick ごとに 30 本の scratch Vec (15 read snapshot + 15 write staging) を新規確保し、compact 結果をローカル Vec に書いてから write_vec で Metal buffer へ丸ごとコピーする「pass 4」を経由していた。A7 はこの 2 点をそれぞれ独立の [backend] flag で除去する。12.5k n=3 gate で採用確定、default を true に flip した — §7/§8 に gate 実測 (1 回目の棄却と 2 回目の採用根拠) を記す。

1. (a) scratch 永続化 — RefineState::scratch + mem::take パターン

field 追加
`RefineState` に非 pub `scratch: RefineScratch` を追加。`RefineScratch` は old_* 15 本 + new_* 15 本の `Vec<f32>` (計 30)。`Vec::new()` は allocation しないので `scratch_persist = false` のコストは 0
借用パターン
`take_snapshot(slot, persist, buf, n)` / `take_zeroed(slot, persist, n)` が `persist=true` のとき `std::mem::take(slot)` で永続 `Vec` を借り出し `clear()` してから再利用、`persist=false` のとき毎回 `Vec::new()`。各呼び出しは `refine_state.scratch.X` を一瞬だけ借りるだけなので、`&mut RefineState` の他フィールド (`grad_xy_accum` 等) との同時借用は起きない
書き戻し
tick 末尾 (`refine_state.reset()` の直前) で `persist=true` のときだけ `refine_state.scratch.X = x;` と代入し、確保済み capacity を次 tick に持ち越す。old_* は常に、new_* は `direct_write=false` のときだけ書き戻す (`direct_write=true` のとき new_* は使わないので空のまま)
値への影響
**ゼロ**。allocation の出所が変わるだけで、`refine()` 内の計算・書き込み値は 1 バイトも変わらない

2. (b) compact 直書き — Dst 抽象 + pass 4 削除 + 見つかった罠

`Dst` 抽象
`enum Dst<'a> { Vec(&'a mut Vec<f32>), Ptr(*mut f32) }` に `set`/`add` メソッド。`copy_splat`/`copy_adam` を `&mut [f32]` から `&mut Dst` 受け取りに書き換え、compact loop 本体は 1 つのまま両経路 (Vec 経由 / Metal buffer 直書き) を共有する — 経路ごとにロジックを複製すると「2 つの実装が将来ズレる」リスクがあるため、単一ロジック + 実行時 dispatch (enum match) を選んだ (refine は 100 iter に 1 回しか呼ばないので match のコストは無視できる)
pass 4 削除
`direct_write = true` のとき 15 本の `new_*` `Vec` を一切作らず、`Dst::Ptr(param.means.contents() as *mut f32)` 等で compact loop が直接 Metal buffer に書く。最後の `write_vec` × 15 (旧 pass 4) を丸ごとスキップ
**実装中に見つけた罠 (修正済み)**
旧実装の Clone 複製先 / Respawn / Split 子スロットは「compact 先 `Vec` が `vec![0.0; ..]` で事前 0 埋め済み」に暗黙依存して Adam m/v への明示 0 書き込みを省略していた。direct 経路は書き込み先が **live Metal buffer (前回 tick までの stale データが残っている)** なので、この暗黙の 0 が成立しない。放置すると MCMC 論文 Algorithm 1 の「新規 slot は Adam fresh start」が (a) と組み合わせなくても (b) 単体で壊れる不変条件バグになっていた。`zero_adam_at` ヘルパーを追加し、該当 3 ケースで明示的に 0 を書くよう修正した (`direct_write = false` でも呼ぶが 0→0 の no-op)
不変条件 I の維持
`[n_new, n_write)` の 0 埋めは旧実装では「新規確保 `Vec` の初期値」から free に得ていたが、direct 経路は書き込み後に `zero_tail` (15 buffer × 該当範囲) で明示的に 0 埋めする。範囲は `[0, n_new)` (compact loop が書く範囲) と素で disjoint なので、compact loop の書き込みを踏みつぶす心配はない
**安全性の前提: in-flight CB = 0**
unified memory 上の buffer を GPU が読み書き中に host が直接上書きすると race になる。depth2 pipelining はこの前提を構造的に満たす — `param_mutated_after_step()` が true になる iteration (refine tick を含む) は `Depth2Pipeline::step` の手順 4 (先行 F_{it+1} encode) を skip し、手順 5 で B_it を `wait_until_completed()` してから return する。`pipelining = off` の逐次経路も `train_step` が毎 iteration wait するので同じく in-flight は常に 0

3. 不変条件の確認箇所 (コード上のエビデンス)

確認内容file (commit 4f843e59bf9b 時点)備考
refine tick で先行 encode を skip する条件splat-train-v1/src/pipeline.rs:340 `param_mutated_after_step()` (373-376 が refine_tick の分岐)`train_loop.rs:655-664` の `prefetch_next` 計算がこの関数の戻り値を直接使う
skip 時に B_it を wait してから return するsplat-train-v1/src/pipeline.rs:593-603 (`Depth2Pipeline::step()` 手順 4〜5、`prefetched` 分岐 + `chain.cmd.wait_until_completed()`)手順 4 が false 分岐 (596 行目 `self.sync_iters += 1`) のとき `self.pending` は None のまま、手順 5 の wait (603 行目) は手順 4 の分岐に関わらず必ず実行される
呼び出し側で明示的に検査する APIsplat-train-v1/src/pipeline.rs:457 `Depth2Pipeline::debug_assert_drained()` (新設)`self.pending.is_none()` と `self.limiter.live() == 0` を debug_assert。`train_loop.rs:764` (refine tick 直前) で呼ぶ。**`debug_assert!` は release ビルドでは生成されないため、12.5k gate run (release) 自体にはこの実行時チェックは乗らない** — 保護は「構造的な論証 (drain 点の設計)」+「debug ビルドの regression test 群」の組合せであり、gate run 中に何かを検出する仕組みではない点に注意
既存の tolerance 比較 regression (前提の pin、direct_write 自体は未使用)splat-train-v1/src/train_loop.rs:1394 `pipelining_depth2_matches_off_across_refine_ticks``BackendConfig::default()` (両 A7 flag は false) で Off/Depth2 が refine tick を跨いで一致する (`assert_history_matches`/`assert_params_close`、tolerance 比較 — atomic float 由来の ulp 差を許容する既存方針。厳密な byte-exact ではない) ことを pin する — **`refine_direct_write` の安全性が依存する「refine tick で in-flight = 0」という前提条件そのものを検査する test**であって、(b) の直書きコードパスは通っていない
A7 flag 有効時の tolerance 比較 regression (direct_write を実際に通す)splat-train-v1/src/train_loop.rs:1432 `pipelining_depth2_matches_off_across_refine_ticks_with_a7_direct_write` (新設)上と同じシナリオを `refine_scratch_persist = true` / `refine_direct_write = true` で実行し Off/Depth2 が一致することを確認 (同じく tolerance 比較)。in-flight ≠ 0 の状態で refine が走れば GPU 書き込みと host 直書きが競合し、この test が不一致で fail するはず — (b) の安全性論証を実際に運動させる唯一の test
不変条件 I (`[num_splats, capacity)` は常に 0) の direct 経路 pinsplat-train-v1/src/refine.rs `zero_tail()` (633-649 の呼び出し箇所) + test `refine_flag_combinations_produce_identical_output` (1423 行目付近) の `assert_zero_tail`4 通りの flag 組合せ全てで確認

4. test 計画 — 全て GPU 実行済み・全 PASS

test何を確認するか実行状況
既存 refine test 7 本 (`refine_prunes_low_opacity` 等)`RefineFlags::default()` (両 flag true、旧 default false 挙動と bit-exact — parity test で確認済みなので値は不変) で旧実装と同じ挙動PASS (`cargo test --workspace --release`)
`relocation_growth_after_refine_sees_zero_adam_state` + 3 flag variant (`_scratch_persist` / `_direct_write` / `_persist_and_direct`)refine の write スコープ縮小 (n_old=8→n_new=6、n_write=8) + relocation growth の Adam fresh start 回帰。**`_direct_write` 系 2 本が `zero_tail` の shrink 経路 (`[n_new, n_write)` = `[6,8)` が非空) を運動させる唯一の test** — `refine_flag_combinations_produce_identical_output` の 2 tick は両方 growth (`n_write = n_new`) なのでこの経路を検出できないPASS
新設 `refine_flag_combinations_produce_identical_output`2 tick 構成 (tick1: prune→respawn/keep/clone/split 全種、tick2: tick1 出力を再 poison した上で growth) で `RefineFlags` の 4 通り (persist × direct_write) が **bit-exact に同一の全 15 buffer** を返すこと。**各 tick 直前に `poison_adam` で active slot を非 0 にする** — これが無いと `AdamState::new` の初期値がそもそも全 0 なので、`zero_adam_at` を丸ごと削除しても test が通ってしまう false-negative になる (実装レビューで実際に指摘された、下記「実装中に見つけた罠」参照)。tick2 は scratch_persist の cross-tick 再利用と、Clone/Split の 2 つ目の出力が tick1 由来の poison 値を持つ live buffer 位置に上書きされるケースの両方を運動させるPASS。**direct_write = true を踏む唯一の unit test** (既存 7 本は全て default flag のみ)
`refine_grows_capacity_on_overflow` + `_direct_write` variantcapacity overflow (4→8) 発生時、`resize_capacity` の呼び出しが `Dst::Ptr` (直書き先ポインタ) の取得より**前**であることの regression。`_direct_write` 版が唯一この順序に依存するコードパスを通す — 順序が壊れると (誤って resize 前に pointer を取ってしまうと) compact 結果 (m_means slot 0 の目印値保持) が壊れて fail するはずPASS
`assert_zero_tail`全 test で before/after 両方 pin (既存 15 buffer × 10 test + 新設 test × 4 combo)PASS
`pipelining_depth2_matches_off_across_refine_ticks_with_a7_direct_write`A7 flag 有効時の Off/Depth2 tolerance 比較 regression。`refine_direct_write` の安全性が依存する「refine tick で in-flight = 0」を実際に運動させる唯一の testPASS
`cargo test --workspace --release` (全 crate)workspace 全体の regression**全 pass (0 failed)**、12.5k gate と同時期の main HEAD 3fc6dea 上で実行

7. 1 回目 gate series (a7-12500) — load 汚染で棄却

armruntrain elapsed (s)PSNR (dB)備考
offoff-r1127.53436.219load1 正常 (baseline 127.53s と一致)
onon-r1125.74736.276load1 正常
offoff-r2138.02236.125**load1 spike 17.86 (走行中〜終了直後)、discrete jump が iter 9000-10000 で ms/iter に出現**
onon-r2124.90236.209load1 は spike 1 の decay tail で 2-3 に緩やかに上昇していたが結果自体は最速 (影響ほぼ無し)
offoff-r3143.66336.163**iter 2000 から一貫して ms/iter が高い (discrete spike ではなく持続的、GPU thermal throttling の疑い)。全 6 run 中最遅**
onon-r3131.28136.238**load1 spike 8.64 (走行中、iter5000-6000 の ms/iter に discrete bump として出現、その後回復)**
1 回目 series の棄却理由 — 事前登録 retry 条件が評価不能

off arm 3値中 2値 (off-r2, off-r3) に load 汚染の証拠があり、事前登録した retry 条件 (i) load evidence AND (ii) 自 arm 他値から 3σ超 の (ii) を計算する基準点 (「汚染されていない他の値」) が off arm には 1 個 (off-r1) しか残らない。単一 pair の置換を想定した retry プロトコルはこの状況に適用できない (`p3-tier1-bit-exact.toml` §5 の r6 10.3σ 自己言及的失敗と同種の罠)。ms/iter trace の discriminator 分析で off-r2/on-r3 は discrete transient (再実行で解消見込み) だが off-r3 は iter 2000 から一貫して高く transient パターンと一致しない — 6 run 連続実行による GPU thermal 蓄積の可能性を排除できない。単純計算の Δ (naive) は -6.67% だが、off arm σ が 6.00% と巨大なため閾値 max(2σ,1%)=12.0% を大きく下回り不通過。加えて A7 の期待帯 (refine drain 予算 4.1% 以内) を明らかに超える Δ であり、A7 由来の測定ではないと判断。**series 全体を破棄し、削除せず `runs-logs/a7-12500-*` として保存、run_prefix を変えて再実行した**。ambient の原因として、同機上で稼働する他 claude セッション (`dangerously-skip-permissions`) の CPU 負荷が疑われる (GPU は占有していないため train run への直接干渉ではないが、host 側処理の遅延に寄与した可能性がある)。

8. 2 回目 gate series (a7v2-12500) — クリーン、採用根拠

armruntrain elapsed (s)PSNR (dB)run 窓内 load1
offoff-r1128.31536.2861.24-3.37 (正常)
onon-r1125.17636.2610.87-2.10 (正常)
offoff-r2126.37936.2281.24-1.24 (正常)
onon-r2124.61636.2470.81-0.87 (正常)
offoff-r3127.94736.1500.81-0.95 (正常)
onon-r3124.62836.2150.81-1.71 (正常)
**mean (n=3)****off****127.547** (σ 0.806%)**36.2213**series 独立 load sampler 最大値 4.74、series 全体を通じ LOAD_LIMIT=6.0 未満
**mean (n=3)****on****124.807** (σ 0.256%)**36.2410**—
**Δ**—**-2.149%****+0.0197**閾値 max(2σ_off,1%)=1.612% を有意にクリア
判定 — 採用 (wallclock AND PSNR guard 両方 pass)

wallclock: Δ -2.149% ≤ -閾値 1.612% で pass。3/3 pair とも on<off で一貫 (r1: 125.176<128.315、r2: 124.616<126.379、r3: 124.628<127.947)。PSNR: Δ +0.0197dB ≥ -0.05dB guard で pass。tasklist の期待帯 -1.3〜-2.6% にちょうど収まっており、1 回目 series の -6.67% (汚染由来で過大) とは対照的に物理的に妥当な範囲。採用確定、`BackendConfig::default()` の `refine_scratch_persist`/`refine_direct_write` を true に flip、`cargo test --workspace --release` 全 pass。

σ 非対称パターンの再確認 (このコンフィグ族で 3 例目)

1 回目 series では off arm が異常に不安定 (σ 6.00% vs on 2.72%、ただし load 汚染由来と特定済み)、2 回目のクリーンな series でも off arm σ (0.806%) が on arm σ (0.256%) の 3.1 倍と、より小さいスケールながら同じ方向の非対称が残る。`p3-tier1-bit-exact.toml` の分散構造の非対称性 callout (v1: off 不安定 4.66×、v2: on 不安定 1.81〜5.40×) に続き、この 12.5k Lego / depth2 pipelining config family では **off 側 (=変更前の baseline stack) が on 側より不安定な傾向が複数の gate で観測されている** (tier1 v1, A7 v1(汚染), A7 v2(クリーン) の 3/4 例が off 不安定方向、tier1 v2 のみ逆)。原因は本 finding の範囲では特定できず、次に同系列の gate を組む際の申し送り事項として記録する。

9. 未解決点 / 次の判断

  • (a)(b) は互いに独立な flag として実装したが、gate では 2×2 の全組合せではなく `off/off` vs `on/on` の 2 arm だけを比較した (tasklist の見積りが 2 施策合算のため)。個別の寄与分解が必要なら追加 gate (off/off, a-only, b-only, on/on の 4 arm) を検討
  • A7(b) の `Dst` 抽象は enum match による実行時 dispatch — 100 iter に 1 回のみの呼び出しなのでコスト影響は無視できる想定だが、per-tick `ts_refine` 内訳での確認は未実施
  • off 側不安定パターン (§8 callout) の原因調査は次の gate 系列への申し送り
03

関連 finding

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