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>
87 lines
4.0 KiB
Plaintext
87 lines
4.0 KiB
Plaintext
/* =====================================================================
|
||
* rl_per_tree_rebuild.cu — GPU-resident PER: bottom-up sum-tree rebuild
|
||
*
|
||
* Grid=(1), Block=(1024). Single-block deterministic rebuild.
|
||
*
|
||
* Rebuilds the entire sum-tree from leaf priorities (at indices
|
||
* [capacity..2*capacity)) up to the root (index 1). All levels are
|
||
* processed within ONE block: __syncthreads() between levels is a
|
||
* proper barrier guaranteeing every thread in the block sees every
|
||
* other thread's writes from the previous level.
|
||
*
|
||
* Determinism rationale (Phase 2 §2.F fix — Option C):
|
||
* The previous Grid=(128) launch used __threadfence() between levels.
|
||
* __threadfence() is a memory-ordering primitive, NOT a grid-wide
|
||
* barrier — it orders THIS thread's writes globally but does not
|
||
* wait for OTHER blocks' writes to be visible. Under the previous
|
||
* geometry, block N could read level-L nodes while block M's
|
||
* level-L writes were still in flight, producing different
|
||
* addition orderings across same-seed runs. Same-seed runs of the
|
||
* determinism diagnostic at b=128 saw root priority diverge
|
||
* ~592.7 vs ~695.7 at step 2 (Phase 2 sub-investigation dumps).
|
||
*
|
||
* By collapsing to a single block, we lose grid-level parallelism
|
||
* but gain bit-deterministic execution: __syncthreads() is a hard
|
||
* intra-block barrier, and every internal node is the deterministic
|
||
* sum of its two specific children written in a definite order
|
||
* (sequential grid-stride within one block, fixed thread→node
|
||
* mapping via i = tid + k*blockDim.x).
|
||
*
|
||
* Speed cost: capacity=32768 → ~65k floating-point adds total, 15
|
||
* levels = 15 syncs. On RTX 3050 the kernel runs ~30-80 μs which
|
||
* is a ~10× slowdown vs the parallel version but well within the
|
||
* per-step budget (the step itself is ~30-50 ms at b=128).
|
||
*
|
||
* Launch: Grid=(1,1,1), Block=(1024,1,1). Block size 1024 is the
|
||
* CUDA hard maximum; works on all foxhunt GPUs (sm_86 / sm_89 /
|
||
* sm_90). If capacity grows past 2^21 in the future, switch to
|
||
* cooperative_groups::grid().sync() for a multi-block deterministic
|
||
* path.
|
||
*
|
||
* No atomicAdd — each internal node is written by exactly one thread.
|
||
* No __threadfence() — __syncthreads() is the only inter-level barrier.
|
||
* ===================================================================== */
|
||
|
||
extern "C" __global__ void rl_per_tree_rebuild(
|
||
float* __restrict__ priority_tree, /* [2 * capacity] */
|
||
int capacity
|
||
)
|
||
{
|
||
/* Single block, single-block-stride loop per level. blockIdx.x is
|
||
* guaranteed 0 by the launch geometry, but guard anyway so a
|
||
* future caller passing Grid>(1,1,1) is a silent no-op rather than
|
||
* a corrupted tree. */
|
||
if (blockIdx.x != 0) return;
|
||
|
||
const int tid = threadIdx.x;
|
||
const int block_size = blockDim.x;
|
||
|
||
/* Bottom-up: level 0 = parents of leaves, last level = root.
|
||
* At level L, there are (capacity >> (L+1)) internal nodes,
|
||
* with indices [capacity >> (L+1) .. capacity >> L). */
|
||
int nodes_at_level = capacity >> 1; /* level 0: cap/2 nodes */
|
||
int start = nodes_at_level; /* first node index at this level */
|
||
|
||
while (nodes_at_level >= 1) {
|
||
/* Each thread processes multiple nodes via block-stride loop.
|
||
* Mapping `i = tid + k*block_size` is FIXED across runs:
|
||
* thread t always writes nodes (start + t), (start + t + B),
|
||
* (start + t + 2B), ... ensuring a deterministic write order.
|
||
*/
|
||
for (int i = tid; i < nodes_at_level; i += block_size) {
|
||
const int node = start + i;
|
||
priority_tree[node] = priority_tree[2 * node]
|
||
+ priority_tree[2 * node + 1];
|
||
}
|
||
|
||
/* Intra-block barrier — proper synchronisation, unlike the
|
||
* previous __threadfence() which did NOT wait for other
|
||
* blocks' writes. */
|
||
__syncthreads();
|
||
|
||
/* Move up one level */
|
||
nodes_at_level >>= 1;
|
||
start >>= 1;
|
||
}
|
||
}
|