`p3-c2-bubble-attribution` 施策 #1 (期待 -3〜-5%、実装後の再見積りで -1.5〜-3%) の実装。`[backend] host_schedule = "forward_shadow" (default) | "backward_shadow"` を追加し、<code>RefineState::accumulate</code> (+ その入力の grad readback) だけを 1 iteration 遅らせて <code>B_it+1</code> の commit 直後 (<code>B_it</code> 自身の 6.38ms shadow の中) で flush できるようにした。実装・test・1000 iter smoke は完了していたが、<strong>12.5k n=3 A/B gate で Δ +5.63% (on が off より遅い、期待と逆符号、閾値 max(2σ,1%)=1% を大きく外れて不通過) — 棄却確定、default (ForwardShadow) は変更しない</strong> (§8)。
`p3-c2-bubble-attribution` §6 施策 #1 は「post-backward の host 仕事 (grad readback 後の `RefineState::accumulate`、SSIM host 総和、opacity decay 準備等) を forward CB の 2.00ms shadow ではなく backward CB の 6.38ms shadow 側にエンコード順序を変更する」ことを提案し、期待 Δ を -3〜-5% とブラケットした。本 finding は **その実装** (`[backend] host_schedule` flag、`Depth2Pipeline` の 1-iteration lag)、実装段階で判明した制約 (loss staleness を守るスコープ限定、既存 2 世代 scratch の再利用による安全性論証)、そして **12.5k n=3 A/B gate の実測 (Δ +5.63%、期待と逆符号で棄却確定、§8)** を記録する。
| 対象 | 動かしたか | 理由 |
|---|---|---|
| grad readback (`chain.grad_buf` の `to_vec()`/slice 読み) | ○ (backward_shadow のとき) | `RefineState::accumulate` の入力のみに使われ、他の値を消費しない — 遅延させても副作用がない |
| `RefineState::accumulate` | ○ (backward_shadow のとき) | 施策 #1 の本体。`grad_xy_accum`/`visible_count` は refine tick でしか読まれず、tick は常に `debug_assert_drained` 経由で flush 済みを要求する drain 点 |
| `read_loss_mean` (l1) | × (常に同期) | `StepOutcome.loss` → `history` に直接効く。1 個の float 読みでコストも無視できる |
| `read_mean_ssim` (SSIM host 総和、1.92M 要素) | × (常に同期) | `loss = "l1_ssim"` のとき `StepOutcome.loss` の一部。動かすと `loss_history` が 1-step stale になり、`crate::pipeline` module doc の逸脱項目 2 (「表示のみ 1-step stale」は不要と明記済み) を破る。**最大の未着手候補** (下記 §5 で follow-up として明記) |
| scale_reg/MCMC L1 RMW・adam (out-of-cb)・MCMC noise | × (対象外) | これらが有効な iteration は `grad_rmw_pending`/`mcmc_noise` により `prefetch_next` が構造的に false — 施策の対象になる「prefetched した iteration」には元々出現しない (実装時に確認、下記 §2 参照) |
初版実装は `pipelining_depth` と同じパターン (`Trainer::host_schedule_backward_shadow: Cell
配線バグ修正後、6-iteration の steady-state test (`forward_shadow` vs `backward_shadow`、両方 depth2、tick 無しで連続 prefetch) を 8 回走らせたところ **6/8 で `assert_params_close` の tolerance (rel<1e-4) を超えて fail** した。原因は `host_schedule` ではなく、`per_kernel_single_texel_run_to_run_deterministic_1iter` の doc が既に明記していた「iter 2 以降は同一設定を 2 回走らせても atomic 加算順の非決定性で ulp 差が出る」という **fixture 固有の既知の性質** — resync 点 (refine tick 等) が無い長い連続 iteration では、この ulp 差が iteration を追うごとに複利的に効いて、たまたま tolerance を超える。既存の複数 iteration test (`across_refine_ticks` 系) が安定して pass するのは `every=2` 等で頻繁に host 側の同期点が挟まるためで、drift そのものを消しているわけではない。**対応**: steady-state test の iteration 数を 3 に絞り (defer→flush を 2 回連続で踏むには十分)、8/8 pass を確認して確定させた。次にこの fixture で複数 iteration の tight tolerance test を追加する人向けに、この性質を明記しておく。
| 検証 | 結果 |
|---|---|
| `cargo test --workspace --release` | 全 pass (splat-metal 91、splat-train-v1 88 [新規 3 本含む]、他クレート含め failure 0)。`cargo test -p splat-train-v1` (debug profile、`debug_assert!` 有効) も全 pass — steady-state chain test が `Depth2Pipeline::step`/`drain` の deferred-flush 不変条件を debug assert 込みで通過することを確認 |
| 1000 iter smoke (Lego、`loss=l1_ssim`、`refine.every=100 start=500` → tick 6 回) | off/on とも `peak_in_flight=2` / `sync_iters=6` / crash なし。PSNR (19.5〜19.6 dB, n=1) と `final splats` (6833〜6896) は off 同士の 2 run 間 (6886 vs 6896) と on 込み (6886 vs 6833) で同程度のばらつき — **atomic 非決定性の範囲内、backward_shadow による correctness regression の兆候なし** |
| 1000 iter smoke の `host_lead`/`cb_gap` p50 | off/on で有意差なし (例: overall `cb_gap` p50 0.242ms [off] vs 0.246ms [on]、`host_lead` p50 -0.187ms [off] vs -0.188ms [on])。**予想通り** — このスケール (splats ~6.9k) では移動した仕事 (grad copy ~248KB + accumulate 6.9k 要素) が 12.5k 規模 (400k splats, 移動量 ~14.4MB + 407µs) の ~1/60 で、shadow への fill 率の変化が計測ノイズに埋もれる。**この smoke は「correctness + plumbing (flag が実際に効いている、drain 不変条件が壊れない)」の確認であり、性能効果の検証ではない** (advisor レビュー指摘どおり、性能検証は 12.5k gate に委ねる) |
| 実行順 | arm | run | train elapsed (s) | PSNR (dB) | run 窓内 load1 平均/最大 | 備考 |
|---|---|---|---|---|---|---|
| 1 | off (A) | s4-12500-off-r1 | 129.23 | 36.159 | 2.72 / 7.91 | **CONTAMINATED、破棄** (load1 max 7.91 が LOAD_LIMIT 6.0 超、自 arm 内 3σ 超も該当 — (i)(ii) 双方成立) |
| 2 | on (B) | s4-12500-on-r1 | 128.53 | 36.176 | 4.47 / 8.90 | **CONTAMINATED、破棄** (同一 pair、load1 max 8.90 も LOAD_LIMIT 超。ペア単位で retry) |
| 3 | off (A) | s4-12500-off-r2 | 125.41 | 36.151 | 1.89 / 2.89 | 採用 |
| 4 | on (B) | s4-12500-on-r2 | 126.39 | 36.163 | 2.39 / 3.64 | 採用 |
| 5 | off (A) | s4-12500-off-r3 | 124.62 | 36.239 | 1.51 / 1.84 | 採用 |
| 6 | on (B) | s4-12500-on-r3 | 128.19 | 36.215 | 2.63 / 4.47 | 採用 |
| 7 (retry) | off (A) | s4-12500-off-r4 | 125.19 | 36.131 | 1.81 / 2.35 | r1 の代替として採用、load 終始 clean |
| 8 (retry) | on (B) | s4-12500-on-r4 | 141.77 | 36.225 | 1.64 / 3.19 | r1 の代替として採用。**大外れ値 (on 他 2値から見て統計的に大きく外れる) だが load は終始 clean (max 3.19) — 事前登録条件 (i) 不成立のため追加 retry せず、そのまま採用**。ms/iter trace は iter 2000 以降一貫して on-r3 より高い (discrete spike ではなく持続的、A7 §7 off-r3 と同型の GPU thermal 蓄積の可能性、host_schedule 由来と特定する根拠はない) |
| **mean (n=3、r1→r4 置換)** | **off** | — | **125.073** (σ 0.326%) | **36.174** | series 独立 load sampler 最大値 8.90 (r1 pair、破棄分)。採用 4 run のみなら load 終始 6.0 未満 | — |
| **mean (n=3、r1→r4 置換)** | **on** | — | **132.117** (σ 6.36%) | **36.201** | — | — |
| **Δ** | — | — | **+5.63%** | **+0.027** | — | 閾値 max(2σ_off,1%)=1% を大きく外れて不通過 (符号も逆) |
wallclock: 採用セット (r1→r4 置換) の Δ は **+5.63%** (on が off より遅い) で「Δ ≤ -1%」の採用条件と符号が逆。ただしこの数字を効果量の代表値として扱ってはいけない — on-r4=141.77s という単一の大外れ値 (on 他 2 値 126.39/128.19 から大きく外れる) がほぼ全てを支配しており、on-r4 を除くと Δ は +1.8%、retry 前の r1 込み n=3 でも +1.02% と、どの切り出し方でも符号は一貫して不通過だが、大きさは ~1〜2% (σ_on 6.36% の分解能付近) と見るのが妥当 — `p3-tier1-bit-exact` v1 が「off arm 汚染で -4.99% が見かけ上膨らんでいた (真の効果量は ~1%)」と自己反証した構図の符号違い版である。3/3 pair (採用セット) とも on>off で一貫 (r2 +0.78%、r3 +2.86%、r4 +13.24%) — 一貫性はあるが「施策が効いている」ではなく「施策が一貫してわずかに悪化させている」ことを示す一貫性。破棄した r1 pair は逆に on<off (on が速かった) であり、4 pair 全体では 3/4 が on>off ― 採用セットの「3/3 一貫」を全体の一貫性と誤読しないこと。PSNR guard (+0.027dB) は形式的に pass するが wallclock が既に不通過なので AND 判定全体としては不通過。採用しない。`[backend] host_schedule` の default (`ForwardShadow`) は変更せず、`.rs` は一切変更していない。
on-r4 (141.77s) は on 系列の他 2 値 (126.39s, 128.19s) から見て統計的に大きく外れている。しかし独立 load sampler は on-r4 の run 窓内で終始 clean (mean 1.64, max 3.19、LOAD_LIMIT 6.0 を一度も超えない) であり、事前登録した retry 条件の (i) (load 証拠) を満たさない。`p3-tier1-bit-exact` §5-6 が明記した教訓 (「自 arm 内 3σ超」単独でのretryは停止条件のない除外連鎖になり得る) をそのまま適用し、追加 retry はしない。ms/iter trace を on-r3 (同一 config、対照的に正常) と比較すると、on-r4 は iter 2000 (5.4 vs 4.7 ms/iter) から一貫して高く、iter 12500 時点でも 13.1 vs 12.2 ms/iter — discrete spike ではなく持続的な劣化パターンで、`p3-refine-3pass` §7 の off-r3 (GPU thermal throttling 疑い) と同型。この session では合計 8 本の 12.5k full run (r1×2 + r2×2 + r3×2 + r4×2) を連続実行しており、終盤の run ほど GPU 熱蓄積の影響を受けやすい一般的リスクはあるが、host_schedule=backward_shadow 由来と特定する直接証拠はなく、むしろ off 側にも同型の劣化が既に他 gate (`p3-refine-3pass` off-r3) で観測されているため、config family 共通のノイズ源として扱う。仮に on-r4 を外れ値として除外し on 系列を r2/r3 の 2 値だけで評価しても mean 127.29s (Δ +1.82%) となお逆符号で不通過であり、on-r4 を含めるか除外するかで結論 (棄却) は変わらない。
事前登録した条件 (i) 「load1 が series 中央値の 1.5倍超」は mean/max のどちらで評価するかを predecision log 上で明記していなかった。実際の判定は **run 窓内 load1 の max** で行った (off-r1 max 7.91 / on-r1 max 8.90、いずれも series 全 run の max の中央値 4.055 の 1.5倍=6.08 を超過)。**mean ベースで再評価すると off-r1 (mean 2.72) は series 全 run の mean の中央値 2.51 の 1.5倍=3.765 未満で非該当**になり、pair 破棄を単独で licenseするのは on-r1 (mean 4.47 > 3.765、こちらは mean でも該当) 側だけになる。ペア単位retryのため実務上の判断 (r1 pair 全体を破棄) は変わらないが、次回の predecision log では「(i) は mean/max のどちらで評価するか」を事前に明記すべき、という運用上の教訓として記録する。
6 pair とも off→on の順で実行しており (`gate-ab-n.sh` の固定 ARM_SEQ)、ABBA 的なカウンターバランスはしていない。そのため on arm は常に同一 pair 内で「後に (=機体がより温まった状態で)」走る構造的バイアスの可能性を排除できない — 最も遅い run (on-r4) が系列終盤かつ on 側であることはこの仮説と整合する。ただし同一 config family・同じ ABAB 順で行った `p3-refine-3pass` gate (a7v2-12500) では on 側 (改善案) が一貫して off 側より速く出ており、このバイアスが支配的でないことも示唆される。次回の gate 設計では ABBA 順の採用を検討する価値がある、という申し送り。
採否を判断する過程で `host_schedule` の default を `BackwardShadow` に flip する実装可否を検討したところ、単純な `#[default]` 属性移動 + `BackendConfig::default()` 変更では済まないことが判明した。(1) `host_schedule` field には field-level `#[serde(default)]` が付いている (`splat-train-v1/src/config.rs`) — struct-level `#[serde(deny_unknown_fields, default)]` と併存する場合、field-level 側が優先され、TOML で省略された値は `BackendConfig::default()` の literal ではなく `HostScheduleKind::default()` (enum 側の `#[default]` 属性、現在は `ForwardShadow`) から解決される。これは `refine_scratch_persist`/`target_cache` の bool field が field-level `#[serde(default)]` を意図的に付けていない理由 (`bool::default()`=false で struct 側の true を上書きしてしまう) と同型の罠で、enum でも同じ罠が成立する — flip するなら enum の `#[default]` 属性そのものを移動する必要があり、`BackendConfig::default()` の literal だけ変えると TOML 経由の default と Rust 構築の default が silently 分岐する。(2) enum の `#[default]` を移動してもなお、`pipelining = "off"` を明示し `host_schedule` を省略する既存 config が `splat/configs/` に **37 本**確認できる (`grep -rln 'pipelining\s*=\s*"off"' splat/configs/`) — これらは flip 後、省略された `host_schedule` が新 default (`BackwardShadow`) に解決され、`BackendConfig::validate()` の hard error (`host_schedule = "backward_shadow" (C2/S4) requires pipelining = "depth2"`, config.rs:652 付近) で **一括して壊れる**。container-level default は他 field の値に条件分岐できない (`BackendConfig::default()` は静的な struct 一つ) ため、field 自体の default 値だけでこの依存関係を解決することはできない。(3) 解決の方向性として、`splat-cli/src/cmd/bench.rs` の `demote_unspecified_pipelining` (raw TOML 文字列を line scan して `pipelining` が明示されているかを判定し、未指定なら `Off` に demote する) と同型のイディオムが既に存在する — これを `host_schedule` 版として実装し `pipelining != Depth2` かつ raw TOML に `host_schedule` の明示行が無い場合だけ `ForwardShadow` に demote する仕組みを、bench 経路だけでなく **train 側の config load path (`splat-cli/src/config.rs::FullConfig::from_toml_str`)** にも追加する必要がある (bench 側の関数は bench 経路にしか適用されない)。この demote は「明示された `backward_shadow` + `pipelining="off"`」の組合せは引き続き hard error にする (silent override 回避の規約を保つ) 設計にすること。以上は次に flip を判断する人が同じ調査をやり直さずに済むよう記録するもので、本 finding の棄却判定そのものには影響しない。