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>。
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)。旧実装は 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 回目の採用根拠) を記す。
| 確認内容 | 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 の分岐に関わらず必ず実行される |
| 呼び出し側で明示的に検査する API | splat-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 経路 pin | splat-train-v1/src/refine.rs `zero_tail()` (633-649 の呼び出し箇所) + test `refine_flag_combinations_produce_identical_output` (1423 行目付近) の `assert_zero_tail` | 4 通りの flag 組合せ全てで確認 |
| 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` variant | capacity 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」を実際に運動させる唯一の test | PASS |
| `cargo test --workspace --release` (全 crate) | workspace 全体の regression | **全 pass (0 failed)**、12.5k gate と同時期の main HEAD 3fc6dea 上で実行 |
| arm | run | train elapsed (s) | PSNR (dB) | 備考 |
|---|---|---|---|---|
| off | off-r1 | 127.534 | 36.219 | load1 正常 (baseline 127.53s と一致) |
| on | on-r1 | 125.747 | 36.276 | load1 正常 |
| off | off-r2 | 138.022 | 36.125 | **load1 spike 17.86 (走行中〜終了直後)、discrete jump が iter 9000-10000 で ms/iter に出現** |
| on | on-r2 | 124.902 | 36.209 | load1 は spike 1 の decay tail で 2-3 に緩やかに上昇していたが結果自体は最速 (影響ほぼ無し) |
| off | off-r3 | 143.663 | 36.163 | **iter 2000 から一貫して ms/iter が高い (discrete spike ではなく持続的、GPU thermal throttling の疑い)。全 6 run 中最遅** |
| on | on-r3 | 131.281 | 36.238 | **load1 spike 8.64 (走行中、iter5000-6000 の ms/iter に discrete bump として出現、その後回復)** |
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 側処理の遅延に寄与した可能性がある)。
| arm | run | train elapsed (s) | PSNR (dB) | run 窓内 load1 |
|---|---|---|---|---|
| off | off-r1 | 128.315 | 36.286 | 1.24-3.37 (正常) |
| on | on-r1 | 125.176 | 36.261 | 0.87-2.10 (正常) |
| off | off-r2 | 126.379 | 36.228 | 1.24-1.24 (正常) |
| on | on-r2 | 124.616 | 36.247 | 0.81-0.87 (正常) |
| off | off-r3 | 127.947 | 36.150 | 0.81-0.95 (正常) |
| on | on-r3 | 124.628 | 36.215 | 0.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: Δ -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。
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 を組む際の申し送り事項として記録する。