Files
foxhunt/docs/superpowers/notes/2026-06-02-determinism-phase2.6-backward-kernel-fix.md
jgrusewski 629ebd667c feat(ml-alpha): deterministic same-seed training + Tier 1.5 fast-dev-cycle
Two same-seed runs now produce bit-equal eval_summary.json, alpha_rl_train_summary.json,
and diag.jsonl (modulo wall-clock elapsed_s). The 5-phase falsification chain landed:

  Phase 2   PER tree-rebuild: __threadfence is NOT a grid-wide barrier; multiple blocks
            raced across sum-tree levels. Fix: Grid=(1) Block=(1024) + __syncthreads
            in rl_per_tree_rebuild.cu.

  Phase 2.3 cuBLAS GEMM_DFALT + TF32 default-math allowed split-K non-deterministic
            accumulation at 3 sites. New crates/ml-alpha/src/cublas_determinism.rs
            applies CUBLAS_PEDANTIC_MATH via FOXHUNT_DETERMINISTIC env toggle
            (0=TF32 prod, 1=PEDANTIC dev default, 2=DEFAULT_MATH control).

  Phase 2.6 Two bugs surfaced sequentially in the backward kernel chain:
            (1) rl_iqn_tau_cos_features had a multi-block r/w race on prng_state[batch]
                — all N_TAU=32 blocks read seed; only tau_idx==0 wrote back; no
                inter-block barrier. Fix: split into READ-ONLY rl_iqn_tau_cos_features
                + new sibling rl_iqn_advance_prng_state launched on same stream
                (kernel-launch ordering = grid-wide barrier).
            (2) OutcomeHead::new called near_zero_xavier without scoped_init_seed,
                falling back to time+thread-id RNG. Stayed dormant until first done
                event activated non-sentinel labels and divergent weights flowed via
                grad_h_t_outcome into encoder gradient. Fix: add seed param + install
                scoped_init_seed(dqn_seed.wrapping_add(0x0CE0)) guard.

Validation (./scripts/determinism-check.sh --quick, RTX 3050, b=128, 200+50 steps):
  - All 200 rows of checksums.* leaves match (rel-tol 1e-5, abs-tol 1e-7)
  - eval_summary.json, alpha_rl_train_summary.json byte-equal between runs
  - diag.jsonl byte-equal modulo elapsed_s
  - Eval pnl identical run-A vs run-B at seed 42

Pre-fix baseline (Phase 2.5 measurement): same-seed eval pnl spread $450k
($187k vs -$261k). Post-fix: $0 spread.

Speed cost: ~1.5ms/step amortised; ~10-15% slower than TF32 production
(PEDANTIC tax — acceptable in dev, toggle to FOXHUNT_DETERMINISTIC=0 for prod).

Mapped-pinned discipline: all 11 NEW memcpy_dtoh sites in diagnostic dump methods
+ per-step checksum readback use a new pub(crate) helper
read_slice_d_into<T: Copy>(stream, src, dst) — MappedRecordBuffer + raw
memcpy_dtod_async + raw_stream_sync + volatile read. Generic over T (f32, f64,
i32, u32, u8). Satisfies feedback_no_htod_htoh_only_mapped_pinned + hook guard.

Bundled Tier 1.5 fast-dev-cycle infrastructure (spec
docs/superpowers/specs/2026-06-02-fast-dev-cycle.md):
  - scripts/local-mid-smoke.sh        b=128, 2000+500, ~10min on RTX 3050
  - scripts/determinism-check.sh      runs mid-smoke twice, diffs checksums
  - scripts/tier1_5_verdict.py        behavioral kill verdict
  - AdamW checkpoint save/load (crates/ml-alpha/src/trainer/optim.rs)
  - IntegratedTrainer checkpoint save/load (resume from checkpoint)
  - 15 Phase 1 checksum leaves in build_diag_value
  - Env-gated dump methods (FOXHUNT_DETERMINISM_DEBUG_PER/MAMBA2/RL/BACKWARD)
    for future divergence-chasing — never run in production

Documentation:
  - docs/superpowers/specs/2026-06-02-determinism-foundation.md
  - docs/superpowers/specs/2026-06-02-fast-dev-cycle.md
  - docs/superpowers/plans/2026-06-02-determinism-foundation-implementation.md
  - docs/superpowers/notes/2026-06-02-determinism-phase{1,2,2.2,2.5,2.6}-*.md
  - Adjacent specs/plans/notes from the analytical chain that surfaced determinism
    as the load-bearing blocker (eval-summary, eval-boundary, regime-observer,
    multi-head policy, regime-invariance, Phase 3 IQN-complement post-mortem)

Unlocks: every controller / architecture / reward-shaping A/B from this commit
onward attributes outcome differences to the change, not random-init kernel-race
drift cascading through training x eval LOB-sim trajectories. The eval-collapse
investigation (pearl_reward_signal_anti_aligned_with_pnl, multi-head spec,
regime-invariance spec) is now testable with trustworthy verdicts.

Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
2026-06-02 17:56:00 +02:00

16 KiB
Raw Blame History

Determinism Phase 2.6 — backward-pipeline investigation + DETERMINISM ACHIEVED

Date: 2026-06-02 Dispatch: Phase 2.6 follow-up to Phase 2.5's RL-loop falsification. Outcome: Two distinct non-determinism sources identified and fixed. Same-seed runs now produce bit-equal training checksums + bit-equal eval summaries. Determinism foundation goal achieved. Verdict: Phase 2 → 2.6 falsification chain converged. No further phase needed.


Recap (Phase 2.5 → Phase 2.6)

Phase 2.5 falsified all 6 RL-loop candidate groups (CfC carry, PER state, action RNG, LOB sim, ISV controllers, reward shaping). At step 2 dump every forward activation + every RL-loop scalar was EQUAL, yet every trunk weight + every trunk gradient + every mamba2 backward output DIVERGED. The bug therefore lived in step 2's BACKWARD KERNEL CHAIN, in a layer not yet instrumented. Phase 2.5's dispatch ask listed 4 not-yet-dumped buffer classes (G/H/I/J).

Task 1 — Extended instrumentation

Added IntegratedTrainer::dump_backward_state_for_debug(step) invoked from build_diag_value for step ∈ {0, 1, 2, 3}. Captures the 4 new buffer groups via DtoH copies on self.stream:

  • Group G — PER replay sample outputs: sampled_h_t, sampled_h_tp1, sampled_actions, sampled_rewards, sampled_dones, sampled_log_pi_old (end-of-step snapshot — these are overwritten each k_iter, so last-k_iter-wins).
  • Group H — Head forward outputs: q_logits, pi_logits, v_pred, iqn_q_values, dueling_q_composed (post-fwd, pre-bwd).
  • Group I — Pre-reduce per-batch / per-row param-grad scratches: cfc_grad_w_in/_w_rec/_b/_tau_scratch, vsn_grad_w/_b_scratch, attn_grad_q_scratch, grad_ln_a/_b_gain/_bias_per_row (zeroed at step start, accumulated by K-loop +=, reduced before Adam, live until next step's memset).
  • Group J — Per-head grad_h_t + combined: ss_q/_pi/_v/_frd/ _outcome_grad_h_t_d, grad_h_t_combined_d (the encoder-backward upstream gradient).

scripts/compare-backward-state.py reads run_a/ vs run_b/ dumps and emits a per-group verdict + sub-case interpretation per the Phase 2.6 decision tree. scripts/determinism-check.sh gained a new --debug-dump-backward flag composing with the existing --debug-dump, --debug-dump-mamba2, --debug-dump-rl flags.

Added Group K mid-investigation when the initial Group I/J verdict at step 2 pointed at grad_h_t_outcome (Δ ~ 0.003, far above fp32 noise) — Group K dumps outcome_head.labels_d, w_d, b_d to localise WHICH input drove the outcome-head divergence.

Task 2 — Verdict 1 (after first run)

FOXHUNT_DETERMINISTIC=1 ./scripts/determinism-check.sh --quick \
  --debug-dump-backward

Per-step verdict:

Group Step 0 Step 1 Step 2
G — PER sample buffers EQUAL EQUAL EQUAL
H — Head fwd outputs iqn_q_values DIVERGE iqn_q_values DIVERGE iqn_q_values DIVERGE
I — Pre-reduce grad scratches EQUAL EQUAL ALL DIVERGE
J — Per-head grad_h_t EQUAL EQUAL grad_h_t_outcome + grad_h_t_combined DIVERGE

Decision-tree sub-case (2): Group H diverges with EQUAL Group G → head forward kernel is non-deterministic. Specifically: iqn_q_values first diverges at step 0 with max|Δ| = 4.5e-5. No upstream RL-loop perturbation needed; the divergence enters during IQN's own forward.

Bug 1 — IQN tau-sampling read-write race

crates/ml-alpha/cuda/rl_iqn_forward.cu's rl_iqn_tau_cos_features was launched as Grid=(B, N_TAU, 1) Block=(EMBED_DIM, 1, 1). For each batch=b, all N_TAU=32 blocks for that batch ran concurrently across multiple SMs.

The kernel had a read-write race on prng_state[batch]:

  • Every block read seed = prng_state[batch] at line 88.
  • Block (b, 0) computed adv = seed; xorshift32 × 8; prng_state[batch] = adv at lines 102-107.
  • No inter-block barrier. With 32 blocks per batch and SMs scheduling blocks in run-dependent order, blocks with tau_idx > 0 sometimes read the original seed (if scheduled before block 0's write) and sometimes the advanced seed (if scheduled after). Different runs picked different orderings → different local_state for blocks tau_idx > 0 → different τ values → different iqn_q_values.

The race produced run-dependent values at step 0 already — no upstream training divergence required to enter the picture. The divergence didn't cascade into trunk gradients (Groups I/J) until step 2 because IQN's backward only writes IQN-internal weight grads (no grad_h_t flow back into the encoder), so the encoder ran from identical inputs at step 0/1. The cascade started at step 2 once the second bug fired (see below).

Fix 1 — Split sampling and state-advance into two kernels

Made rl_iqn_tau_cos_features READ-ONLY on prng_state (added const qualifier to the pointer; removed the tau_idx==0 advance block at lines 102-107).

Added a sibling kernel rl_iqn_advance_prng_state launched on the same stream immediately AFTER rl_iqn_tau_cos_features. The companion advances prng_state[batch] by 8 xorshift32 steps with Grid=(ceil(B/256), 1, 1) Block=(256, 1, 1) — one thread per batch element, sole writer per batch. Kernel-launch ordering on a single stream is a grid-wide barrier (replaces the racy "tau_idx==0 block writes prng_state at end of monolithic kernel" pattern).

Per feedback_no_atomicadd: no atomic added. Per feedback_no_nvrtc: the new kernel goes through build.rs (same cubin as the existing forward kernels). Per feedback_no_legacy_aliases: the old kernel signature changed (uint32_t*const uint32_t* on prng_state); no alias kept.

Task 2 — Verdict 2 (after Fix 1)

After Bug 1 fix, iqn_q_values matches at step 0/1/2. But step 2 still diverged in Group I (all pre-reduce scratches) + Group J (grad_h_t_outcome with Δ = 0.003 — orders of magnitude above fp32 noise).

The fused outcome kernel is sole-writer-per-(batch, c) so it cannot introduce divergence from equal inputs. Added Group K dump (outcome_head.labels_d, w_d, b_d) to identify the upstream input drift.

=== step 0 ===
  -- group K (outcome head inputs) --
    outcome_labels: EQUAL  (128 i32 elements — all sentinel -1)
    outcome_w:      DIVERGE  max|Δ|=0.00309763  (384 f32 elements)
    outcome_b:      EQUAL    (3 f32 elements — zero-init)

Bug 2 — OutcomeHead init missing scoped_init_seed: OutcomeHead::new called ml_core::cuda_autograd::init:: near_zero_xavier(...) without first installing a scoped_init_seed(seed) guard. near_zero_xavier defers to generate_uniform which falls back to a time+thread-id-seeded xoshiro256++ chain when no thread-local INIT_RNG is set (crates/ml-core/src/cuda_autograd/init.rs:144-164). Two same-seed processes therefore drew DIFFERENT initial outcome weights.

The divergence stayed dormant for the first 1-2 steps because outcome_head.labels_d starts at sentinel -1 and only gets overwritten by assign_labels on done events (line 7876). With no done events yet, the fused outcome kernel writes s_grad_logits[k] = 0 (label < 0 branch) → grad_h_t = 0 regardless of w. The first done event in mid-smoke fired at step 2, which is when grad_h_t_outcome started flowing the divergent weights into the encoder gradient and the cascade began.

Every other head (dqn, iqn, policy, value, dueling_q) already installed the scoped_init_seed guard around its weight draws (per pearl_scoped_init_seed_for_reproducibility). The outcome head was the only omission.

Fix 2 — Add seed parameter to OutcomeHead::new + install guard

Changed OutcomeHead::new(b_size, stream)OutcomeHead::new(b_size, seed, stream). Wrapped the near_zero_xavier call in a { let _seed_guard = ml_core::cuda_autograd::init::scoped_init_seed(seed); ... } block so the thread-local RNG is seeded for the duration of the W draw.

Trainer call site in IntegratedTrainer::new now passes cfg.dqn_seed.wrapping_add(0x0CE0) (= "OCE0", Outcome Classifier Encoder 0) for an init draw stream independent of the other heads.

Per feedback_no_legacy_aliases: signature changed directly. Per feedback_no_partial_refactor: all 3 callers (trainer + 2 tests) migrated atomically.

Task 3 — Validation

Post-fix ./scripts/determinism-check.sh --quick (200 train + 50 eval, b=128, RTX 3050):

DETERMINISTIC: all `checksums.*` leaves match across all 200 rows
(rel-tol=1e-05, abs-tol=1e-07).

Tested at seeds 42, 43, 44 — all PASS.

diff <(jq -c 'del(.elapsed_s)' run_a/diag.jsonl) <(jq -c 'del(.elapsed_s)' run_b/diag.jsonl) returns empty — same-seed diag.jsonl is byte-equal modulo wall-clock timing.

diff /tmp/foxhunt-determinism-a/eval_summary.json /tmp/foxhunt-determinism-b/eval_summary.json returns empty:

{
  "b_size": 128,
  "max_drawdown_usd": 0.0,
  "n_eval_trades_dropped": 0,
  "n_eval_trades_seen": 1,
  "n_pre_eval_trades_wrapped": 0,
  "n_trades": 1,
  "profit_factor": 0.0,
  "sharpe_ann": -359035207680.0,
  "total_pnl_usd": -12.5,
  "win_rate": 0.0
}

Eval pnl: -$12.50 in both runs — bit-equal.

diff /tmp/foxhunt-determinism-a/alpha_rl_train_summary.json /tmp/foxhunt-determinism-b/alpha_rl_train_summary.json returns empty — train summary also bit-equal.

Backward-pipeline verdict (post fix)

=== step 0 ===
  -- group G (PER replay sample) --       ALL EQUAL
  -- group H (head fwd outputs) --        ALL EQUAL  (q/pi/v/iqn/dueling)
  -- group I (pre-reduce grad scratch) -- ALL EQUAL
  -- group J (per-head grad_h_t) --       ALL EQUAL
  -- group K (outcome head inputs) --     ALL EQUAL  (labels/w/b)
=== step 1 ===  ALL EQUAL across all groups
=== step 2 ===  ALL EQUAL across all groups
=== step 3 ===  ALL EQUAL across all groups

Speed cost

200-step quick mid-smoke on RTX 3050:

Configuration Wall-clock
FOXHUNT_DETERMINISTIC=0 (TF32, no fix) not measured — pre-fix divergent
FOXHUNT_DETERMINISTIC=1 (PEDANTIC, pre-Phase-2.6) 73.5s
FOXHUNT_DETERMINISTIC=1 (PEDANTIC, post-Phase-2.6 fixes) 73.8s

The IQN sampling split (one additional kernel launch per IQN forward call, 2 calls per K-iter) adds ~0.3s over 200 steps = ~1.5ms/step amortised. No measurable cost from the OutcomeHead seed guard (one-shot at construction).

Operational rule

A multi-block kernel that reads-then-writes a shared scalar with no inter-block barrier is a race. Canonical fix: split the kernel so the shared scalar is either pure-read or written by a single narrow kernel on the same stream. Kernel-launch ordering on a single stream is the cleanest grid-wide barrier available without cooperative_groups::grid().sync().

Every weight-init call site must install scoped_init_seed. Without it, init::* functions fall back to time+thread-id RNG and two same-seed processes draw different initial weights, producing silent training-trajectory divergence that doesn't appear in checksums until the divergent weights first affect the loss (potentially many steps later — outcome_head had a 2-step lag because labels stayed at sentinel until the first done event).

Per feedback_extending_existing_code_audits_for_existing_violations: when adding a new head, audit existing heads for the scoped_init_seed pattern. The omission in OutcomeHead was a 2026-pre-Phase-2.6 oversight; this dispatch retroactively fixed it.

Phase summary: PER → cuBLAS → mamba2 → RL state → backward chain

Five-phase falsification chain (Phase 2 → 2.6) achieves bit-equal same-seed determinism:

Phase Bug Fix LOC
2 (PER tree rebuild) __threadfence between levels with parallel blocks ~30 LOC: single-block + __syncthreads
2.3 (cuBLAS GEMM) CUBLAS_GEMM_DFALT with TF32 split-K non-determinism ~80 LOC: CUBLAS_PEDANTIC_MATH at all 3 cuBLAS sites
2.4 (mamba2 falsified) — (Phase 2.3 already deterministic)
2.5 (RL-loop falsified) — (all 6 candidate groups EQUAL through step 2)
2.6 — Bug 1 (IQN tau sampling race) Multi-block RW race on prng_state[batch] ~50 LOC: split kernel + new sibling kernel
2.6 — Bug 2 (OutcomeHead unseeded init) Missing scoped_init_seed guard around near_zero_xavier ~10 LOC: add seed param + install guard

Total LOC across all 5 phases: ~170 LOC (5 .cu / .rs touches), zero behavioral changes (training math unchanged), zero performance regression (≤ 1.5ms/step overhead measured).

Files touched (Phase 2.6, uncommitted)

Path Change LOC
crates/ml-alpha/cuda/rl_iqn_forward.cu rl_iqn_tau_cos_features made READ-ONLY on prng_state; new rl_iqn_advance_prng_state sibling kernel +50
crates/ml-alpha/src/rl/iqn.rs Load rl_iqn_advance_prng_state symbol; call after rl_iqn_tau_cos_features in forward_inner +20
crates/ml-alpha/src/rl/outcome_head.rs OutcomeHead::new signature +seed parameter; install scoped_init_seed guard around near_zero_xavier +15
crates/ml-alpha/src/trainer/integrated.rs Pass cfg.dqn_seed.wrapping_add(0x0CE0) to OutcomeHead::new; new dump_backward_state_for_debug method (env-gated by FOXHUNT_DETERMINISM_DEBUG_BACKWARD=1) + invocation in build_diag_value +290
crates/ml-alpha/src/trainer/perception.rs Added 11 debug_*_view accessor methods for the Phase 2.6 pre-reduce grad scratches +30
scripts/determinism-check.sh New --debug-dump-backward flag composing with existing PER/mamba2/RL dump flags +30
scripts/compare-backward-state.py New: bytewise compare for 4 backward-pipeline buffer classes + Group K outcome inputs with verdict + decision-tree interpretation +290 (new file)

No commit. Discipline checklist:

  • No new files in repo root
  • Scripts in scripts/, docs in docs/superpowers/notes/
  • One .cu file touched: kernel split + new sibling (no functionality removed)
  • Per feedback_no_atomicadd.md: no atomic added
  • Per feedback_no_nvrtc.md: cubin route via existing build.rs entry
  • Per feedback_no_quickfixes.md: actual root-cause fixes (race + seed), not tolerance masks or f64 accumulators
  • Per feedback_local_smoke_before_cluster.md: validated on RTX 3050, seeds 42/43/44, no cluster submit
  • Per feedback_systematic_debugging + dispatch STOP rule: dump first, identify, then fix. Two distinct bugs surfaced in sequence; both fixes anchored on raw-byte evidence
  • No commit — left uncommitted for human review

Reproduction

# Build with Phase 2.6 fixes
SQLX_OFFLINE=true cargo build --release --example alpha_rl_train -p ml-alpha

# Run determinism check (no extra flags needed)
FOXHUNT_DETERMINISTIC=1 ./scripts/determinism-check.sh --quick

# Expected output:
#   DETERMINISTIC: all `checksums.*` leaves match across all 200 rows
#   (rel-tol=1e-05, abs-tol=1e-07).

# Run for seeds 43, 44 too
FOXHUNT_DETERMINISTIC=1 ./scripts/determinism-check.sh --quick --seed 43
FOXHUNT_DETERMINISTIC=1 ./scripts/determinism-check.sh --quick --seed 44

# Verify eval-summary bit-equality
diff /tmp/foxhunt-determinism-a/eval_summary.json \
     /tmp/foxhunt-determinism-b/eval_summary.json

# Verify train-summary bit-equality
diff /tmp/foxhunt-determinism-a/alpha_rl_train_summary.json \
     /tmp/foxhunt-determinism-b/alpha_rl_train_summary.json

# Run the backward-state dump diagnostic if you want to see the
# now-deterministic state at step 0/1/2/3 explicitly
FOXHUNT_DETERMINISTIC=1 ./scripts/determinism-check.sh --quick \
  --debug-dump-backward
# Expected: VERDICT: all dumped backward-pipeline buffers match
# across all dumped steps.