`B_it` 統合 CB の Adam encoder が param/adam 全 15 buffer を書くため、<code>wait(B_it)</code> 自体は depth3 化 (buffer 2 世代化、実装コスト大) なしには消せない — refine tick drain の <strong>構造的な必要性</strong>を確認した。縮められるのは drain の中身: `refine()` の CPU snapshot read (15 buffer中 <code>sh_coeffs</code>/<code>m_sh_coeffs</code>/<code>v_sh_coeffs</code> が支配的、407k splats で ~250MB/tick) を host memcpy から <code>B_it</code> CB 末尾 (Adam encoder 直後、同一 CB 内で hazard tracking により直列化) への GPU blit に置き換える <code>[backend] refine_gpu_snapshot</code> を実装した。<code>raw_opacities</code> は brush 互換 opacity decay が blit 後に書き換えるため対象外、grad RMW / MCMC noise・relocation が同 tick で発火する場合は <code>refine_tick_wants_gpu_snapshot</code> が安全側にフォールバックする。bit-exact 性は「iter tag が一致したときだけ消費、それ以外は無条件 host snapshot」という <code>Option::take()</code> ベースの機構で保証 (誤った iteration の値を使うことは構造的に起きない)。unit test 3 本 (advisor 指摘の 2 trap + capacity overflow 生存性)・integration test 2 本 を追加、<code>cargo test --workspace --release</code> 全 pass。<strong>2026-08-30 実施の 12.5k n=3 交互 A/B gate では Δ -0.2365% (閾値 max(2σ_off,1%)=1.000%) で実質ノイズ水準の null 結果、pair 一貫性も 2/3 に留まり、advisor 試算 (-1.2〜-1.9%) の pessimistic 側にすら届かなかった。PSNR guard (+0.0553dB) は pass するが wallclock が閾値の 24% にしか届かず棄却、`refine_gpu_snapshot` の default は false のまま維持。</strong>
task は refine tick drain (3.0〜3.8s、wall の 2.4〜3.1%) の non-draining 化を求めた。実装に入る前に advisor と行った構造分析で、drain (`wait(B_it)`) 自体は depth3 化なしには除去できないことが判明し、task が提示した 3 候補はいずれも staleness を受け入れるか低い ceiling (6.4ms の backward shadow に押し込めるだけ) にとどまることが分かった。本 finding は (1) drain が構造的に必要な理由、(2) 3 候補 + advisor 提案の第 4 候補の比較、(3) 採用した設計 (`refine_gpu_snapshot`) の bit-exact 性・安全性、(4) test/smoke 結果、(5) gate config と期待値、の順で記録する。
| # | 案 | bit-exact 性 | ceiling | 採否 |
|---|---|---|---|---|
| (a) | tick を `B_it` commit 直後・`F_{it+1}` encode 前に置き、`B_it` の GPU 実行と host densify を重ねる | **意味論変更** — `B_it` はこの時点でまだ実行中 (Adam 未完了) なので、host が読む grad/param は前 iteration (`it-1`) までの値になる。1 tick 分 stale | 6.379ms (`B_it` shadow) しかなく、advisor 試算で refine bucket (~24ms/tick) の ~27% (≈0.6s、0.5%程度) が上限。stale の代償に見合わない | 不採用 |
| (b) | densify の host 計算 (候補選定・index 計算) だけ先に走らせ、buffer 書き換えは drain 後に短時間で行う 2 段化 | **(a) と同じ race を踏む** — 候補選定は `old_raw_opacities`/`old_log_scales` 等を読む必要があり、これらも Adam 未完了の値になる。真に安全にするには (a) と同じ staleness を受け入れる必要があり、2 段化しても race 条件は変わらない | (a) と同じ 6.379ms shadow 制約 | 不採用 (机上で (a) に帰着すると判明) |
| (c) | 再充填コストの削減 (tick 直後の `F` を通常 depth2 で即 overlap) | bit-exact (staleness なし) | **ceiling ≈ 0** — `F_{it+1}` は post-refine の param/num_splats に依存するため refine() 完了前には encode できず、host は refine() 完了直後に `F_{it+1}` を encode するしかない (現状の実装と同じ)。オーバーラップできる相手が構造的に存在しない | 不採用 (効果なしと判明) |
| (4, advisor 提案) | host memcpy による snapshot read を `B_it` 統合 CB 末尾 (Adam 直後) への GPU blit に置き換える (`B_it` の CB 自体を延長する — 何かと重ねるのではない) | **bit-exact** — blit は Adam の直後・同一 CB 内 (hazard tracking で直列化) から同じバイトをコピーするだけ。staleness ゼロ | 6.4ms shadow の制約を受けない (「重ねる」設計ではないため)。ceiling は snapshot read が refine tick drain bucket に占める割合 (advisor 試算 ~半分) | **採用** |
(a)/(b) の狙いは「host の CPU 作業を `B_it` の GPU 実行時間に重ねる」ことだが、`B_it` の Adam encoder が `refine()` の読み書き対象と**同じ 15 buffer**を書いているため、「重ねる」こと自体が race になる。狙いを達成するには「重ねる」のではなく「対象を `B_it` 完了前の世代に限定する」しかなく、それは即ち staleness の受容に等しい。(4) が (a)/(b) と質的に違うのは、`B_it` と並行して host が読み書きするのではなく、`B_it` 自身の CB に blit を追加して GPU 側で完結させる点 — 「重ねる」候補ではなく「host 作業を GPU に移す」候補であり、この違いが 6.4ms shadow の制約を受けない理由になっている。
| 確認内容 | file:symbol | 備考 |
|---|---|---|
| blit 対象 14 buffer の定義・確保 | `splat-train-v1/src/refine.rs` `RefineGpuSnapshot` struct + `RefineGpuSnapshot::new` | `raw_opacities` (値そのもの) のみ除外。`m_raw_opacities`/`v_raw_opacities` は opacity decay が触らないので対象に含む |
| blit の encode (B_it CB 末尾) | `refine.rs` `RefineState::encode_gpu_snapshot_blit` — Adam encoder 直後に呼ぶ | 呼び出し側 (`Depth2Pipeline::step`) が commit **前**に呼ぶ制約を守る (`pipeline.rs` の呼び出し箇所コメント参照) |
| 安全条件の判定 (encode してよいか) | `pipeline.rs` `refine_tick_wants_gpu_snapshot` — grad RMW / MCMC noise / 同 it の MCMC relocation を除外 | `param_mutated_after_step` の姉妹関数、「`train_loop` の実際の分岐条件と 1:1 対応させること」という同じ規約を継承 |
| host memcpy を実際に消す (**修正済み**) | `refine.rs` `refine()` 冒頭、`Cow<[f32]>` で `Cow::Borrowed`(GPU mirror への zero-copy `&[f32]`) / `Cow::Owned`(`take_snapshot` の host memcpy Vec) を分岐 | **gate 前に advisor レビューで発覚した実装ミスの修正箇所**。初版は `take_snapshot` の引数 (読み出し元 buffer) を差し替えただけで memcpy 自体を呼び続けており、GPU blit を「追加」しただけで host コピーを「置換」できていなかった (net で純増、下記コールアウト参照) |
| bit-exact な消費 (iteration tag) | `refine.rs` `refine()` 冒頭 `refine_state.gpu_snapshot_ready_for.take() == Some(it)` | `Option::take()` で毎回無条件にクリアするため、iteration 不一致時に stale な値を誤って使うことは構造的に起きない (最適化されないだけに縮退) |
| `[backend]` flag / validate | `config.rs` `BackendConfig::refine_gpu_snapshot` (default false) + `validate()` の `pipelining != Depth2` hard error | `refine_scratch_persist`/`refine_direct_write` と同じ配線パターン (`apply_backend_config` の exhaustive destructure に追加) |
初版実装は `old_means = take_snapshot(&mut ..., persist, means_buf, n)` のように、`means_buf` を `¶m.means` から `&snap.means` (GPU mirror) に差し替えるだけだった。しかし `take_snapshot` 自体は `buf.contents()` から `Vec::extend_from_slice` で **host CPU memcpy** する実装のままで、読み出し元アドレスが変わっただけで memcpy は消えていなかった — つまり tick ごとに host memcpy (~250MB) + GPU blit (~250MB) の両方を行っており、net で純増になっていた。1000 iter smoke (7k splats 規模、advisor 指摘どおりこの規模では効果は測れないが方向性は検出できる) で `ts_refine` per-call を比較したところ、初版は off (365.6µs/call) より on (618.9µs/call) の方が **遅い** — 実際に純増していたことを既存ログのみで確認した (advisor 助言「30 秒で確認できる」の通り、新規 GPU run 不要)。修正 (`Cow<[f32]>` 化、host memcpy を `Cow::Borrowed` 分岐で完全にスキップ) 後、同条件で on 370.5µs/call ≈ off 365.6µs/call まで復帰 (この小規模 regime では snapshot read 自体が `ts_refine` の小部分のため、劇的な差は出ない — 12.5k/407k splats 規模で初めて有意な差が出る想定、advisor 試算 -1.2〜-1.9%)。この bug を含む版で 12.5k gate を実施していたら、「効果なし〜わずかに悪化」という誤った結論に達していたはず — 実装完了後・gate 実施前の advisor レビューが機能した事例として記録する。
| test | 何を確認するか | 結果 |
|---|---|---|
| `refine_gpu_snapshot_matches_host_snapshot_when_blitted` (unit) | blit した GPU snapshot を消費した `refine()` が、host snapshot 経路と全 15 buffer bit-exact に一致すること | PASS |
| `refine_gpu_snapshot_wrong_iter_falls_back_to_host_snapshot` (unit) | `gpu_snapshot_ready_for` の iteration が呼び出し時の `it` と食い違うとき、stale な blit 値ではなく live buffer (blit 後に意図的に変更した値) を読むこと — フォールバックが実際に機能する証拠 | PASS |
| `refine_grows_capacity_on_overflow_gpu_snapshot` (unit、advisor 指摘で追加) | capacity overflow (`param.resize_capacity`/`adam_state.resize_capacity` が live buffer を新しい Metal buffer に差し替える) を GPU snapshot 経由で発生させ、mirror buffer への `Cow::Borrowed` スライスが resize を跨いでも有効であり続け、host snapshot 経路と bit-exact に一致すること | PASS |
| `pipelining_depth2_refine_gpu_snapshot_matches_off_across_refine_ticks` (integration) | advisor 指摘の trap 1 (opacity_decay_rate > 0 が実際に発火するシナリオ) を通しつつ Off と Depth2+flag が一致すること | PASS |
| `pipelining_depth2_refine_gpu_snapshot_falls_back_with_grad_rmw` (integration) | advisor 指摘の trap 2 (`scale_reg.weight > 0` で grad RMW が有効、Adam が CB 外で走る) で `refine_gpu_snapshot=true` でも安全にフォールバックし Off と一致すること | PASS |
| `cargo test --workspace --release` (全 crate) | 既存 test (refine 22 本、pipelining/train_loop 系含む) の regression 無し | 全 pass (0 failed)、新規 5 test 込み |
| 1000 iter smoke (`2026-08-30-refine-nondrain-smoke-1000-gpu-snapshot.toml`、修正版で再実行) | 実バイナリで crash なし、`[backend] BackendConfig {...}` line で `refine_gpu_snapshot: true` が実際に解決、`ts_refine_snapshot_blit` の呼び出し回数が refine tick 数と一致、`ts_refine` per-call が off と同水準に復帰 (純増バグが直った証拠) | PASS — `ts_refine_snapshot_blit` 6 回 (= `ts_refine`/`ts_opacity_decay` と同数、tick 500/600/700/800/900/1000)、49.8µs/call、`sync_iters=6/1000`、`ts_refine` 370.5µs/call (off 365.6µs/call とほぼ同水準) |
1000 iter smoke を on (`refine_gpu_snapshot=true`) / off で走らせたところ final splats (6816 vs 6766) / PSNR (19.551 vs 19.611 dB) が一致しなかった。これは実装のバグではなく、フル Lego シーン (複数カメラ view) の GPU atomic float 累積順が run ごとに非決定的という既知の特性 (`per_kernel_single_texel_run_to_run_deterministic_1iter` の doc 参照 — 決定性が保証されるのは single-texel fixture の 1 iter のみ) — 同じ off config を 2 回走らせただけでも final splats が 6766→6948、PSNR が 19.611→19.548 と同程度ばらつくことを確認して裏付けた。bit-exact 性は unit test (決定的な single-texel 相当の fixture、固定 seed) で確認済みであり、smoke の役割は crash-freedom と計装確認に限定される。
| 実行順 | arm | run | train elapsed (s) | PSNR (dB) | final splats | run 窓内 load1 mean/max | 備考 |
|---|---|---|---|---|---|---|---|
| 1 | off (A) | rnd-12500-off-r1 | 123.08 | 36.113 | 408,798 | 2.41 / 3.49 | 採用 |
| 2 | on (B) | rnd-12500-on-r1 | 122.11 | 36.179 | 405,242 | 1.10 / 1.59 | 採用 |
| 3 | off (A) | rnd-12500-off-r2 | 122.28 | 36.187 | 405,976 | 0.80 / 1.04 | 採用 |
| 4 | on (B) | rnd-12500-on-r2 | 122.18 | 36.203 | 406,688 | 1.38 / 2.05 | 採用 |
| 5 | off (A) | rnd-12500-off-r3 | 122.58 | 36.115 | 407,562 | 0.87 / 1.17 | 採用 |
| 6 | on (B) | rnd-12500-on-r3 | 122.78 | 36.199 | 409,949 | 0.96 / 1.15 | 採用 |
| **mean (n=3)** | **off** | — | **122.6467** (σ 0.330%) | **36.1383** | 407,445 | — | — |
| **mean (n=3)** | **on** | — | **122.3567** (σ 0.301%) | **36.1937** | 407,293 | — | — |
| **Δ** | — | — | **-0.2365%** | **+0.0553** | -0.037% | — | 閾値 max(2σ_off,1%)=1.000% を大きく下回り不通過 |
wallclock: Δ **-0.2365%** は有意閾値 max(2σ_off,1%)=1.000% の **24%** にしか届かず、明確に不通過。pair は r1 (-0.788%, on 優位) / r2 (-0.082%, on 優位だがごく小) / r3 (+0.163%, on が逆に遅い) で 3 組中 2 組のみ on 優位、系統的な一貫性はない。PSNR: Δ **+0.0553dB** で guard (-0.05dB 以内) は pass するが、`refine_gpu_snapshot` は snapshot read の読み出し元を変えるだけで gradient には一切触れない bit-exact 設計のため、この値は改善の証拠ではなく §2b (`p3-s4b-ssim-reduce`) と同型の背景 atomic noise と解釈すべきもの。wallclock が閾値に遠く及ばない以上、PSNR guard の pass に関わらず棄却。
advisor 試算は「refine tick drain bucket (2.4〜3.1%) のうち snapshot read が占める割合を概算半分」という比例配分だったが、`p3-c2-bubble-attribution` のブラケットは drain bucket 全体 (snapshot read + action 決定 + compact write + zero_tail) の合算であり、snapshot read 単体の絶対コストは当時計装 (`ts_refine` のサブラベル分割) されていなかった (§4 未解決点 (2) が明記していた計装ギャップ)。**§9 の instrumentation-only 実測 (n=1、12.5k 規模) がこれを直接測定した**: 12500 iter 中の refine tick は `start_iter=500, every=100` により **121 回** (500,600,…,12500)、`ts_refine` per-call が off 20.744ms → on 16.032ms (削減 4.712ms/tick) なので、121 tick × 4.712ms/tick = **0.570s** — `ts_refine` の合計値 (2.510s − 1.940s = 0.570s) と独立に一致する。~123s の wall に対し **0.46%** であり、これが本施策の実測上限 (ceiling)。12.5k n=3 gate の Δ -0.2365% はこの 0.46% ceiling とノイズ帯の範囲で整合する — 逆算や頻度論は不要で、**advisor 試算の「drain bucket の約半分」という配分そのものが過大評価だった**、という単純な説明で足りる。
§7 の 6 run はいずれも `profile = false` (他の全 gate と同じ設定) で `ts_*` 計装ログが出力されないため、`[backend] BackendConfig { refine_gpu_snapshot: true }` は flag が parse されたことしか示さず、`refine_tick_wants_gpu_snapshot` が実際に true を返し `refine()` が `Cow::Borrowed` 分岐 (GPU blit 経路) を取ったことの証拠にはならない。§3 コールアウトが記録した過去の実装ミス (host memcpy が消えていないまま blit だけ追加していた) は `ts_refine` の on/off 比較でのみ検出できた前例であり、本 gate も同じ確認を経ないと「効果なし」と「計測していない」が区別できない。この懸念に対応するため、gate 6 run とは別に `profile = true` の n=1 pair (`2026-08-30-rnd-12500-prof-{off,on}.toml`、name/run_dir を gate run と衝突しないよう変更、統計には含めない instrumentation-only run) を追加で実行した。
| 計装 | off (rnd-12500-prof-off) | on (rnd-12500-prof-on) | 備考 |
|---|---|---|---|
| `ts_refine_snapshot_blit` 発火回数 | (該当なし) | **121 / 121** | `start_iter=500, every=100, stop_iter=12500` → refine tick 数 (500,600,…,12500) と完全一致。blit が全 refine tick で実際に発火したことの直接証拠 |
| `ts_refine` (per-call) | 20.744ms/call (合計 2.510s, 121 call) | 16.032ms/call (合計 1.940s, 121 call) | **-22.7%/call** — snapshot read の host memcpy 除去による `refine()` 自体の短縮は実際に起きている |
| `ts_refine_snapshot_blit` (per-call) | — | 12.009µs/call (合計 1.453ms) | blit 自体の GPU encode コストは極小 (1000 iter smoke の 49.8µs/call より小さい — 407k splats 規模でも支配的コストにならない) |
| `ts_chain_commit_wait` (`B_it` 完了待ち、drain 本体) | 82.855s (57.1%) | 87.983s (57.2%) | **+5.128s** だが下記コールアウト参照 — blit のコストではなく run 単体のノイズと判断 |
| `ts_forward` (参考、flag が一切触れないはずの経路) | 26.300s | 28.309s | **+2.009s** — `refine_gpu_snapshot` は forward path に一切配線されていないにもかかわらず増加しており、on-arm instrumentation run 全体が (profiling オーバーヘッドか偶発的な system jitter で) 一般的に遅かったことを示す独立した証拠 |
| `training done in` | 123.18s | 129.68s | n=1・instrumentation-only。on 側は gate の on 3 run (122.11-122.78s) から明確に外れており、下記コールアウトの通り run 単体のノイズと判断、統計には含めない |
`ts_chain_commit_wait` の +5.128s は blit の GPU コストではない、と判断する根拠は 3 点: (1) 大きさが合わない — 121 回の blit に均せば 42ms/blit 相当になるが、同じ log の `ts_refine_snapshot_blit` encode コストは 12.009µs/call、~250MB の unified memory `copy_from_buffer` の実行コストも概算 1ms 未満のオーダーで、42ms/tick は 2 桁近く合わない。(2) 同じ log 内に独立した反証がある — `refine_gpu_snapshot` は一切配線されていない `ts_forward` (count 12500) も +2.009s 増えており、`ts_chain_commit_wait` (同じく count 12500、wall の 57%) は system 全体が遅いときにそのまま増える場所である。(3) `training done in` 自体が on 側で gate の on 3 run から明確に外れている (129.68s vs 122.11-122.78s) — これは on-arm instrumentation run 単体が (profiling オーバーヘッドか偶発的な負荷で) 全体的に遅かったことの直接証拠であり、その run のバケット差分を『blit の構造的コスト』として解釈するのは誤り。したがって本施策の実測上限は `ts_refine` の削減分のみ: -0.570s ≈ wall (123s) の 0.46%。これは 12.5k n=3 gate の Δ -0.2365% (ノイズ帯) とほぼ整合する — §8 で「host memcpy コスト自体が想定より小さかった」と推測した読みが正しく、advisor 試算 (-1.2〜-1.9%、drain bucket の約半分という配分) の方が過大評価だった、という単純な説明で足りる。blit 自体の追加コストが `wait(B_it)` を有意に伸ばしているという証拠は無い (§8 のまま維持)。
(1) ~~snapshot read が refine tick drain bucket に占める正確な割合は計装していない~~ → **§9 で解消**: 12.5k 規模 instrumentation-only run (n=1) で `ts_refine_snapshot_blit` 121/121 発火・`ts_refine` per-call -22.7% (20.744ms→16.032ms、合計 -0.570s ≈ wall の 0.46%) を実測。これが本施策の実測上限であり advisor 試算 (-1.2〜-1.9%) は過大評価だったと判明した (`ts_chain_commit_wait` の +5.128s は同 run の `ts_forward` +2.009s [flag が触れない経路] と合わせて run 単体のノイズと判定、§9 参照)。`ts_refine` のさらに細かいサブラベル分割 (`snapshot`/`action`/`compact`/`zero_tail`) は依然未実施。(2) MCMC 有効時 (grad RMW / SGLD noise / relocation) の safety fallback は integration test 1 本 (`scale_reg` 経由) のみで確認しており、`mean_noise_weight`/relocation 経由の fallback は明示的な test を追加していない。(3) 4 flag 一括ではなく単独 flag の gate だったため他施策との相互作用は未検証だが、効果量が既にノイズ水準のため優先度は低い。(4) `config.rs:511` (`refine_gpu_snapshot` field doc)・`config.rs:558` (`BackendConfig::default()` のコメント)・`refine.rs:64` (module doc) にはいずれも「default = false (gate 待ち)」の文言が残っている — 棄却が確定した以上、`p3-tier1-bit-exact.toml` §6 が指摘した規約 (「verdict が変わったら doc を同じ commit で更新する」) に照らすと doc-only で「棄却確定 (2026-08-30 gate、`p3-refine-nondrain` 参照)」に追随させるべきだが、本 task の指示 (「棄却なら code 不変」) に従い `.rs` は一切変更していない。`p3-tier1-bit-exact` 系の未処理の同種 doc (`ssim_clear`/`raster_grad_clear`/`backward_uniform` の「gate 待ち」文言、`config.rs:406/412/420/428/433`) も含め、main の判断で doc-only commit として一括処理することを推奨する。
この finding が観測された / 言及している実験 run。