Files
foxhunt/crates/ml-alpha/cuda/rl_iqn_forward.cu
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

283 lines
13 KiB
Plaintext
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
// rl_iqn_forward.cu — IQN forward pass: split pipeline for cuBLAS SGEMM.
//
// The monolithic kernel is replaced by three custom kernels interleaved
// with two cuBLAS SGEMM calls driven from Rust. The pipeline:
//
// 1. rl_iqn_tau_cos_features (custom):
// - Thread 0 of each (batch, tau) block samples tau ~ U(0,1) via
// inline xorshift32 and writes to tau[batch, tau_idx].
// - All threads compute cos_features[j] = cos((j+1) * pi * tau)
// for j = 0..EMBED_DIM-1.
// Output: tau[B, N_TAU], cos_features[B*N_TAU, EMBED_DIM].
//
// 2. cuBLAS SGEMM (from Rust):
// embed_out[B*N_TAU, HIDDEN_DIM] = cos_features[B*N_TAU, EMBED_DIM]
// @ W_embed[EMBED_DIM, HIDDEN_DIM]
//
// 3. rl_iqn_relu_hadamard (custom):
// embed_out += b_embed (bias add)
// phi = ReLU(embed_out)
// combined = h_t ⊙ phi (hadamard product with broadcast over tau)
// Output: combined[B*N_TAU, HIDDEN_DIM].
//
// 4. cuBLAS SGEMM (from Rust):
// q_raw[B*N_TAU, N_ACTIONS] = combined[B*N_TAU, HIDDEN_DIM]
// @ W_out[HIDDEN_DIM, N_ACTIONS]
//
// 5. rl_iqn_bias_add_q (custom):
// q_values = q_raw + b_out (broadcast bias)
// Output: q_values[B, N_TAU, N_ACTIONS].
//
// 6. rl_iqn_expected_q (unchanged):
// E[Q(s,a)] = mean_tau Q(s, tau, a).
//
// Per `feedback_no_atomicadd.md`: no atomicAdd.
// Per `feedback_cpu_is_read_only.md`: all compute on GPU.
// Per `feedback_no_nvrtc.md`: pre-compiled cubin via build.rs.
#include <stdint.h>
#define HIDDEN_DIM 128
#define N_ACTIONS 11
#define N_TAU_MAX 64
#define EMBED_DIM 64
#define PI_F 3.14159265f
// Inline xorshift32 PRNG — identical to rl_sample_tau.cu.
__device__ static uint32_t xorshift32_iqn(uint32_t* state) {
uint32_t x = *state;
x ^= x << 13;
x ^= x >> 17;
x ^= x << 5;
*state = x;
return x;
}
// ─────────────────────────────────────────────────────────────────────
// rl_iqn_tau_cos_features:
// Stage 1: inline tau sampling + cosine basis computation.
//
// Grid = (B, N_TAU, 1)
// Block = (EMBED_DIM, 1, 1) — one thread per embedding dimension.
//
// DETERMINISM (Phase 2.6, 2026-06-02): `prng_state` is now READ-ONLY
// in this kernel. The previous version had a read-write race: all
// N_TAU blocks per batch read `prng_state[batch]` at the head of the
// kernel, and the (tau_idx == 0) block wrote back the advanced state
// at the end — with NO inter-block barrier. Blocks with tau_idx > 0
// running on a different SM could read the post-write value (if the
// tau_idx == 0 block finished first) OR the pre-write value
// (otherwise), depending on SM scheduling. Different runs picked
// different orderings, producing run-dependent τ values for
// tau_idx > 0 and hence run-dependent `iqn_q_values` even at step 0
// (no upstream RL-loop divergence required). Diagnosed via Phase 2.6
// `dump_backward_state_for_debug` Group H verdict (iqn_q_values
// DIVERGE at step 0 idx 55, max|Δ|=4.5e-5) while Groups G/I/J stayed
// EQUAL through steps 0/1 — see
// `docs/superpowers/notes/2026-06-02-determinism-phase2.6-backward
// -kernel-fix.md`.
//
// Fix: kernel now READS prng_state[batch] only (no writes); the
// state advancement is performed by `rl_iqn_advance_prng_state`
// in a separate single-thread-per-batch launch that runs AFTER
// `rl_iqn_tau_cos_features` finishes via stream ordering — no race
// possible. Per `feedback_no_atomicadd.md` + canonical Phase-2
// PER-rebuild rule: "fixed accumulation order requires a real
// barrier between stages, and kernel-launch ordering on a single
// stream is the cleanest grid-wide barrier we have".
//
// Inputs:
// prng_state [B] — per-batch xorshift32 seed (READ-ONLY)
// B — batch size
// N_TAU — number of quantile samples
// Outputs:
// tau [B, N_TAU] — sampled U(0,1) quantile fractions
// cos_features [B*N_TAU, EMBED_DIM] — cos((j+1) * pi * tau)
// ─────────────────────────────────────────────────────────────────────
extern "C" __global__ void rl_iqn_tau_cos_features(
const uint32_t* __restrict__ prng_state, // [B] READ-ONLY (advanced by sibling kernel)
int B,
int N_TAU,
float* __restrict__ tau, // [B, N_TAU]
float* __restrict__ cos_features // [B*N_TAU, EMBED_DIM]
) {
const int batch = blockIdx.x;
const int tau_idx = blockIdx.y;
const int j = threadIdx.x; // embedding dim index
if (batch >= B) return;
if (tau_idx >= N_TAU) return;
if (j >= EMBED_DIM) return;
// Thread 0 samples tau for this (batch, tau_idx) pair.
__shared__ float s_tau_val;
if (j == 0) {
uint32_t seed = prng_state[batch];
if (seed == 0u) seed = (uint32_t)(batch + 1) * 2654435761u + 0xBEEFu;
uint32_t local_state = seed ^ (uint32_t)(tau_idx * 2654435761u);
#pragma unroll
for (int w = 0; w < 4; ++w) xorshift32_iqn(&local_state);
const uint32_t r = xorshift32_iqn(&local_state);
const float u = (float)(r >> 8) * (1.0f / 16777216.0f);
tau[batch * N_TAU + tau_idx] = u;
s_tau_val = u;
// prng_state advancement moved to `rl_iqn_advance_prng_state`
// (see kernel below); no writes to prng_state in this kernel.
}
__syncthreads();
const float tau_val = s_tau_val;
// cos_features[row, j] = cos((j+1) * pi * tau_val)
const int row = batch * N_TAU + tau_idx;
cos_features[row * EMBED_DIM + j] = cosf((float)(j + 1) * PI_F * tau_val);
}
// ─────────────────────────────────────────────────────────────────────
// rl_iqn_advance_prng_state:
// Companion to `rl_iqn_tau_cos_features` — advances the per-batch
// xorshift32 PRNG state by exactly 8 steps. Launched AFTER
// `rl_iqn_tau_cos_features` so the read-only sampling sees a stable
// value for prng_state[batch] across all (tau_idx) blocks, then
// this kernel applies a single deterministic update per batch.
//
// Determinism rationale: by separating "read state for tau sampling"
// from "advance state for next call", we eliminate the read-write
// race the previous monolithic kernel had. Both kernels run on the
// same stream, so the launch ordering is a grid-wide barrier
// (no __threadfence required).
//
// Grid = (ceil(B/256), 1, 1)
// Block = (256, 1, 1) — one thread per batch element.
//
// Inputs:
// B — batch size
// Outputs:
// prng_state [B] — advanced 8 xorshift32 steps in place.
// `0` seed bootstrap matches the
// `rl_iqn_tau_cos_features` convention
// `(batch + 1) * 2654435761u + 0xBEEFu`
// so the first advance from the
// cold-start sentinel is identical to
// what the prior monolithic kernel
// produced for tau_idx==0.
// ─────────────────────────────────────────────────────────────────────
extern "C" __global__ void rl_iqn_advance_prng_state(
uint32_t* __restrict__ prng_state, // [B]
int B
) {
const int batch = blockIdx.x * blockDim.x + threadIdx.x;
if (batch >= B) return;
uint32_t seed = prng_state[batch];
if (seed == 0u) seed = (uint32_t)(batch + 1) * 2654435761u + 0xBEEFu;
uint32_t adv = seed;
#pragma unroll
for (int w = 0; w < 8; ++w) xorshift32_iqn(&adv);
prng_state[batch] = adv;
}
// ─────────────────────────────────────────────────────────────────────
// rl_iqn_relu_hadamard:
// Stage 3: bias-add → ReLU → element-wise product with h_t.
//
// Grid = (B*N_TAU, ceil(HIDDEN_DIM / 256), 1)
// Block = (min(HIDDEN_DIM, 256), 1, 1)
//
// Inputs:
// embed_out [B*N_TAU, HIDDEN_DIM] — output of cuBLAS SGEMM (no bias)
// b_embed [HIDDEN_DIM] — embedding bias
// h_t [B, HIDDEN_DIM] — encoder hidden state
// M — total rows = B * N_TAU
// B — batch size (for h_t indexing)
// N_TAU — quantile count
// Outputs:
// combined [B*N_TAU, HIDDEN_DIM] — h_t ⊙ ReLU(embed_out + b_embed)
// embed_pre_relu [B*N_TAU, HIDDEN_DIM] — embed_out + b_embed (before ReLU,
// saved for backward)
// ─────────────────────────────────────────────────────────────────────
extern "C" __global__ void rl_iqn_relu_hadamard(
const float* __restrict__ embed_out, // [M, HIDDEN_DIM]
const float* __restrict__ b_embed, // [HIDDEN_DIM]
const float* __restrict__ h_t, // [B, HIDDEN_DIM]
int M, // B * N_TAU
int B,
int N_TAU,
float* __restrict__ combined, // [M, HIDDEN_DIM]
float* __restrict__ embed_pre_relu // [M, HIDDEN_DIM] (saved for bwd)
) {
const int row = blockIdx.x;
const int c = blockIdx.y * blockDim.x + threadIdx.x;
if (row >= M) return;
if (c >= HIDDEN_DIM) return;
const int batch = row / N_TAU;
float val = embed_out[row * HIDDEN_DIM + c] + b_embed[c];
embed_pre_relu[row * HIDDEN_DIM + c] = val;
float phi_c = fmaxf(val, 0.0f);
combined[row * HIDDEN_DIM + c] = h_t[batch * HIDDEN_DIM + c] * phi_c;
}
// ─────────────────────────────────────────────────────────────────────
// rl_iqn_bias_add_q:
// Stage 5: add b_out bias to the cuBLAS SGEMM output.
//
// Grid = (B*N_TAU, 1, 1)
// Block = (N_ACTIONS, 1, 1)
//
// In-place: q_values[row, a] += b_out[a]
// ─────────────────────────────────────────────────────────────────────
extern "C" __global__ void rl_iqn_bias_add_q(
float* __restrict__ q_values, // [M, N_ACTIONS] (mutated in-place)
const float* __restrict__ b_out, // [N_ACTIONS]
int M // B * N_TAU
) {
const int row = blockIdx.x;
const int a = threadIdx.x;
if (row >= M) return;
if (a >= N_ACTIONS) return;
q_values[row * N_ACTIONS + a] += b_out[a];
}
// ─────────────────────────────────────────────────────────────────────
// rl_iqn_expected_q:
// Compute E[Q(s, a)] = (1/N_TAU) × Σ_{tau} Q(s, tau, a)
// for ensemble action selection.
//
// Unchanged from the original monolithic kernel.
//
// Inputs:
// q_values [B, N_TAU, N_ACTIONS] — full quantile Q tensor
// B — batch size
// N_TAU — number of quantile samples
// Outputs:
// expected_q [B, N_ACTIONS] — mean Q per action
//
// Block layout: grid = (B, 1, 1); block = (N_ACTIONS, 1, 1).
// ─────────────────────────────────────────────────────────────────────
extern "C" __global__ void rl_iqn_expected_q(
const float* __restrict__ q_values, // [B, N_TAU, N_ACTIONS]
int B,
int N_TAU,
float* __restrict__ expected_q // [B, N_ACTIONS]
) {
const int batch = blockIdx.x;
const int act = threadIdx.x;
if (batch >= B) return;
if (act >= N_ACTIONS) return;
float sum = 0.0f;
const int base = batch * N_TAU * N_ACTIONS;
for (int t = 0; t < N_TAU; ++t) {
sum += q_values[base + t * N_ACTIONS + act];
}
expected_q[batch * N_ACTIONS + act] = sum / (float)N_TAU;
}