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>
206 lines
8.0 KiB
Python
Executable File
206 lines
8.0 KiB
Python
Executable File
#!/usr/bin/env python3
|
||
"""Compare raw PER state dumps between two determinism runs.
|
||
|
||
Reads the dumps produced by
|
||
`IntegratedTrainer::dump_per_state_for_debug` (env-gated on
|
||
`FOXHUNT_DETERMINISM_DEBUG=1`) and prints a per-step exact-equality
|
||
verdict for each of:
|
||
|
||
- `sample_prng_d` (B u32) — device PRNG state per batch
|
||
- `priority_tree_d` (2 × cap f32) — PER priority sum-tree
|
||
- `sample_indices_d` (B u32) — sampled leaf indices
|
||
|
||
Hypothesis table (spec §2.F sub-investigation):
|
||
HYPOTHESIS A (PRNG): prng differs → seed propagation
|
||
between runs (xorshift32 bug
|
||
cold-init diverging)
|
||
HYPOTHESIS B (Tree-walk): prng EQUAL, tree EQUAL, → fp comparison
|
||
indices differ `u <= left_val`
|
||
flipping near the
|
||
fp epsilon boundary
|
||
inside rl_per_sample
|
||
HYPOTHESIS C (Tree-rebuild): prng EQUAL, tree differs → parallel
|
||
(even tiny f32 deltas) reduction order in
|
||
rl_per_tree_rebuild
|
||
(children-before-
|
||
parents ordering)
|
||
|
||
Output is written to stdout. Exit codes:
|
||
0 both runs identical for all three buffers at all dumped steps
|
||
1 divergence found; verdict printed (one of A / B / C)
|
||
2 inputs missing or malformed
|
||
|
||
Usage:
|
||
python3 scripts/compare-per-state.py <run_a_dir> <run_b_dir> [--b-size N]
|
||
|
||
Each dir contains `step_{0,1,2}_{prng,tree,indices}.bin`.
|
||
|
||
Per `feedback_cpu_is_read_only`: this script ONLY reads device dumps;
|
||
it performs no compute that affects training.
|
||
"""
|
||
from __future__ import annotations
|
||
|
||
import argparse
|
||
import sys
|
||
from pathlib import Path
|
||
import struct
|
||
|
||
|
||
def read_u32(path: Path) -> list[int]:
|
||
"""Read a raw little-endian u32 binary file into a Python list."""
|
||
data = path.read_bytes()
|
||
if len(data) % 4 != 0:
|
||
raise ValueError(f"{path}: size {len(data)} not a multiple of 4")
|
||
n = len(data) // 4
|
||
return list(struct.unpack(f"<{n}I", data))
|
||
|
||
|
||
def read_f32(path: Path) -> list[float]:
|
||
"""Read a raw little-endian f32 binary file into a Python list."""
|
||
data = path.read_bytes()
|
||
if len(data) % 4 != 0:
|
||
raise ValueError(f"{path}: size {len(data)} not a multiple of 4")
|
||
n = len(data) // 4
|
||
return list(struct.unpack(f"<{n}f", data))
|
||
|
||
|
||
def compare_lists(a: list, b: list, label: str) -> tuple[bool, int, str]:
|
||
"""Compare two equal-length lists; return (equal, first_diff_idx, summary)."""
|
||
if len(a) != len(b):
|
||
return False, -1, f"{label}: length mismatch A={len(a)} B={len(b)}"
|
||
diffs = []
|
||
for i, (x, y) in enumerate(zip(a, b)):
|
||
if x != y:
|
||
diffs.append(i)
|
||
if len(diffs) >= 5:
|
||
break
|
||
if not diffs:
|
||
return True, -1, f"{label}: EQUAL ({len(a)} elements)"
|
||
sample = ", ".join(
|
||
f"[{i}]: A={a[i]!r} B={b[i]!r}" for i in diffs[:3]
|
||
)
|
||
return False, diffs[0], (
|
||
f"{label}: DIVERGE at idx {diffs[0]} "
|
||
f"(showing up to 3): {sample}"
|
||
)
|
||
|
||
|
||
def main() -> int:
|
||
parser = argparse.ArgumentParser(description=__doc__)
|
||
parser.add_argument("run_a", type=Path)
|
||
parser.add_argument("run_b", type=Path)
|
||
parser.add_argument(
|
||
"--b-size",
|
||
type=int,
|
||
default=128,
|
||
help="batch size used by the run (default: 128)",
|
||
)
|
||
args = parser.parse_args()
|
||
|
||
if not args.run_a.is_dir() or not args.run_b.is_dir():
|
||
print(
|
||
f"ERROR: run dirs missing: {args.run_a} or {args.run_b}",
|
||
file=sys.stderr,
|
||
)
|
||
return 2
|
||
|
||
# Track verdict across steps. We pick the FIRST step that shows
|
||
# divergence and report which buffer(s) diverge there.
|
||
overall_any_diverge = False
|
||
first_diverge_step: int | None = None
|
||
first_diverge_state: dict[str, bool] = {}
|
||
|
||
for step in (0, 1, 2):
|
||
print(f"=== step {step} ===")
|
||
files = {
|
||
"prng": (
|
||
args.run_a / f"step_{step}_prng.bin",
|
||
args.run_b / f"step_{step}_prng.bin",
|
||
read_u32,
|
||
),
|
||
"indices": (
|
||
args.run_a / f"step_{step}_indices.bin",
|
||
args.run_b / f"step_{step}_indices.bin",
|
||
read_u32,
|
||
),
|
||
"tree": (
|
||
args.run_a / f"step_{step}_tree.bin",
|
||
args.run_b / f"step_{step}_tree.bin",
|
||
read_f32,
|
||
),
|
||
}
|
||
# Skip the step if any file missing.
|
||
missing = [name for name, (pa, pb, _) in files.items()
|
||
if not (pa.is_file() and pb.is_file())]
|
||
if missing:
|
||
print(f" SKIP — missing files: {missing}")
|
||
continue
|
||
|
||
step_state: dict[str, bool] = {}
|
||
for name, (pa, pb, reader) in files.items():
|
||
try:
|
||
a = reader(pa)
|
||
b = reader(pb)
|
||
except ValueError as e:
|
||
print(f" ERROR reading {name}: {e}")
|
||
return 2
|
||
equal, _, summary = compare_lists(a, b, name)
|
||
print(f" {summary}")
|
||
step_state[name] = equal
|
||
if not equal:
|
||
overall_any_diverge = True
|
||
|
||
if (not all(step_state.values())) and first_diverge_step is None:
|
||
first_diverge_step = step
|
||
first_diverge_state = dict(step_state)
|
||
|
||
print()
|
||
if not overall_any_diverge:
|
||
print("VERDICT: all three buffers match across all dumped steps.")
|
||
print("PER state itself is bit-identical between runs; the")
|
||
print("downstream `checksums.replay_sample_indices` divergence")
|
||
print("observed by determinism-check.sh must be a SUM-OF-SQUARES")
|
||
print("permutation artifact (the checksum is permutation-invariant).")
|
||
print("Re-investigate: indices may differ in ORDER but match as a")
|
||
print("multiset, indicating per-batch slot-binding non-determinism")
|
||
print("upstream of PER.")
|
||
return 0
|
||
|
||
assert first_diverge_step is not None
|
||
prng_eq = first_diverge_state.get("prng", True)
|
||
tree_eq = first_diverge_state.get("tree", True)
|
||
idx_eq = first_diverge_state.get("indices", True)
|
||
|
||
print(f"VERDICT (first divergent step = {first_diverge_step}):")
|
||
if not prng_eq:
|
||
print(" HYPOTHESIS A — PRNG seeding non-determinism.")
|
||
print(" sample_prng_d differs between runs at step",
|
||
first_diverge_step,
|
||
"→ the device-side xorshift32 state was already divergent")
|
||
print(" before the tree walk. Apply Option A fix: explicit")
|
||
print(" cli.seed-derived seeding in trainer construction.")
|
||
return 1
|
||
if prng_eq and tree_eq and not idx_eq:
|
||
print(" HYPOTHESIS B — Tree-walk fp-comparison flip.")
|
||
print(" prng matches, tree matches, but sample_indices diverge")
|
||
print(" → the `u <= left_val` comparison in rl_per_sample.cu:88")
|
||
print(" is producing different paths despite identical inputs.")
|
||
print(" Apply Option B fix: integer priority sum-tree.")
|
||
return 1
|
||
if prng_eq and not tree_eq:
|
||
print(" HYPOTHESIS C — Tree-rebuild non-determinism.")
|
||
print(" prng matches, but priority_tree itself diverges")
|
||
print(" → rl_per_tree_rebuild.cu accumulation order is")
|
||
print(" non-deterministic across runs (parallel children-before-")
|
||
print(" parents not enforced strongly enough).")
|
||
print(" Apply Option C fix: serialize the rebuild bottom-up.")
|
||
return 1
|
||
# Should not be reachable (one of A/B/C must hold once any diverges).
|
||
print(" UNEXPECTED state — review per-buffer summary above.")
|
||
print(f" prng_eq={prng_eq} tree_eq={tree_eq} idx_eq={idx_eq}")
|
||
return 1
|
||
|
||
|
||
if __name__ == "__main__":
|
||
sys.exit(main())
|