Compare commits
8 Commits
ml-alpha-d
...
worktree-a
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
a3dfcd63f5 | ||
|
|
6695785666 | ||
|
|
12635bd708 | ||
|
|
25f5ce99b6 | ||
|
|
acdafe508e | ||
|
|
13bf277cd6 | ||
|
|
af35bc778e | ||
|
|
fd31742627 |
@@ -96,6 +96,13 @@ const KERNELS: &[&str] = &[
|
||||
"rl_iqn_forward", // IQN distributional Q-head: quantile embedding + action-value projection; complementary to C51
|
||||
"rl_iqn_loss", // IQN quantile Huber loss: ρ_τ(δ) = |τ - 1(δ<0)| × Huber(δ, κ=1.0); forward + backward
|
||||
"rl_iqn_backward", // IQN backward through forward pass: grad_output → grad_w_out/b_out/w_embed/b_embed per-batch scratch
|
||||
"rl_dueling_q_forward", // Phase 4 (2026-05-30): Independent dueling Q head forward — V[B] + A[B,N] + composed_Q[B,N] = V + A − mean_a A; parallel to C51/IQN, zero shared state per spec 2026-05-30-phase4-independent-dueling-head-design.md
|
||||
"rl_dueling_q_bellman_target", // Phase 4: argmax over target composed_Q + Bellman target = r + γ^n × (1-done) × max_Q
|
||||
"rl_dueling_q_loss_and_grad", // Phase 4: Huber loss on (target − online_composed_Q[taken]) + grad_composed
|
||||
"rl_dueling_q_decompose_and_bwd", // Phase 4: decompose grad_composed → grad_V + grad_A via mean-subtraction Jacobian + per-batch weight gradients
|
||||
"rl_v_blend", // Phase 4.4 (2026-05-30): elementwise V_used = α V_scalar + (1−α) V_dq, α from ISV
|
||||
"rl_v_blend_alpha_controller", // Phase 4.4: ISV-adaptive Schulman-bounded controller on α from observed |V_dq − V_scalar| / |V_scalar| tracking ratio
|
||||
"rl_advantage_normalize", // Phase 4.5 (2026-05-30): per-batch advantage normalization (A − mean)/std with ε² variance floor — standard PPO practice, self-adaptive (no tuned params)
|
||||
"rl_ensemble_action_value", // C51+IQN ensemble: E_ensemble = α×E_C51 + (1-α)×E_IQN; α from ISV[544]
|
||||
"rl_noisy_linear_forward", // NoisyNet: factored noisy linear forward — y = (mu_w + sigma_w ⊙ eps_w) × x + (mu_b + sigma_b ⊙ eps_b); state-dependent exploration for C51/IQN final projection
|
||||
"rl_noisy_linear_backward", // NoisyNet: factored noisy linear backward — grad_mu_w/sigma_w/mu_b/sigma_b per-batch scratch for reduce_axis0
|
||||
|
||||
@@ -392,10 +392,14 @@ extern "C" __global__ void heads_lr_multiplier_scale_kernel(
|
||||
// `channels_in_bucket[bucket][i]` populated at transition by
|
||||
// `channels_in_bucket_kernel`.
|
||||
//
|
||||
// Launch: grid = (N_HORIZONS, 1, 1), block = (32, 1, 1). Each block
|
||||
// Launch: grid = (N_HORIZONS, 1, 1), block = (128, 1, 1). Each block
|
||||
// reduces one bucket. Per `feedback_no_atomicadd`, reduction is
|
||||
// block-tree on shared memory (no atomicAdd).
|
||||
//
|
||||
// Block size = 128 covers MAX_BUCKET_DIM=96 (smallest pow2 ≥ 96).
|
||||
// Previous block_dim=32 silently dropped channels 32..bdim when bdim>32
|
||||
// — caught by `tests/bucket_transition_kernels.rs` (block_dim=43 case).
|
||||
//
|
||||
// Input `h_state` is `[B × HIDDEN_DIM]` (ORIGINAL channel layout). Each
|
||||
// block reads its bucket's `bucket_dim_k[bucket]` channels (via
|
||||
// `channels_in_bucket[bucket][0..bdim]`) per sample (across all B
|
||||
@@ -412,20 +416,20 @@ extern "C" __global__ void h_mag_per_bucket_kernel(
|
||||
if (bucket >= N_HORIZONS) return;
|
||||
int bdim = (int)bucket_dim_k[bucket];
|
||||
|
||||
// sdata sized for a single-warp reduction (32 lanes).
|
||||
__shared__ float sdata[32];
|
||||
// sdata sized for the 128-lane block reduction.
|
||||
__shared__ float sdata[128];
|
||||
int tid = threadIdx.x;
|
||||
|
||||
// Each thread sums |h| over its bucket-local index (tid), looking up
|
||||
// the ORIGINAL channel via channels_in_bucket. Threads with tid >= bdim
|
||||
// idle — uniform predicate, no warp divergence inside [0, 32).
|
||||
// contribute 0. With bdim ∈ [1, MAX_BUCKET_DIM=96] and block_dim=128,
|
||||
// tail lanes [bdim, 128) are always inactive — uniform predicate.
|
||||
float local_sum = 0.0f;
|
||||
if (tid < bdim) {
|
||||
unsigned int c = channels_in_bucket[bucket * MAX_BUCKET_DIM + tid];
|
||||
// Defensive: sentinel slot OR out-of-range channel index should
|
||||
// not contribute. Predicate is uniform across the warp because all
|
||||
// active threads (tid < bdim) hold valid entries by construction
|
||||
// of channels_in_bucket_kernel.
|
||||
// Defensive: out-of-range channel index should not contribute.
|
||||
// Predicate is uniform across active lanes (tid < bdim) — all hold
|
||||
// valid entries by construction of channels_in_bucket_kernel.
|
||||
if (c < (unsigned int)HIDDEN_DIM) {
|
||||
for (int b = 0; b < B; ++b) {
|
||||
float v = h_state[b * HIDDEN_DIM + c];
|
||||
@@ -433,11 +437,12 @@ extern "C" __global__ void h_mag_per_bucket_kernel(
|
||||
}
|
||||
}
|
||||
}
|
||||
sdata[tid] = (tid < 32) ? local_sum : 0.0f;
|
||||
sdata[tid] = local_sum;
|
||||
__syncthreads();
|
||||
|
||||
// Block-tree reduction over 32 lanes (single warp). No atomicAdd.
|
||||
for (int s = 16; s > 0; s >>= 1) {
|
||||
// Block-tree reduction over 128 lanes (multi-warp). No atomicAdd.
|
||||
// Stages: 64→32→16→8→4→2→1. Each stage halves the active lane count.
|
||||
for (int s = 64; s > 0; s >>= 1) {
|
||||
if (tid < s) sdata[tid] += sdata[tid + s];
|
||||
__syncthreads();
|
||||
}
|
||||
|
||||
@@ -19,13 +19,26 @@ extern "C" __global__ void compute_advantage_return(
|
||||
const int b = blockIdx.x * blockDim.x + threadIdx.x;
|
||||
if (b >= b_size) return;
|
||||
|
||||
const float gamma = isv[RL_GAMMA_INDEX];
|
||||
const float r = rewards[b];
|
||||
const float done = dones[b];
|
||||
const float vt = v_t[b];
|
||||
const float vtp1 = v_tp1[b];
|
||||
const float gamma = isv[RL_GAMMA_INDEX];
|
||||
const float r = rewards[b];
|
||||
const float is_done = (dones[b] > 0.5f);
|
||||
const float vt = v_t[b];
|
||||
const float vtp1 = v_tp1[b];
|
||||
|
||||
const float ret = r + gamma * (1.0f - done) * vtp1;
|
||||
returns[b] = ret;
|
||||
advantages[b] = done * (ret - vt);
|
||||
// 2026-05-29: branch-gate (not multiplication-gate) the V_tp1 term
|
||||
// and the (ret - vt) advantage. Multiplication-gating fails on IEEE
|
||||
// `0 * Inf = NaN`: if any batch's encoder produces a non-finite V
|
||||
// prediction early in training (random init outliers), the prior
|
||||
// `done * (ret - vt)` multiplication propagated NaN to ALL non-done
|
||||
// batches' advantages, which then broke compute_advantage_rms (sum
|
||||
// of A² → NaN) and PPO (A/RMS → NaN, ratio×A → NaN, l_pi → NaN).
|
||||
// Branch-gating ensures non-finite vtp1/vt are NEVER mixed into a
|
||||
// non-done batch's advantage. Validated by the smoke bisect at
|
||||
// 10d4614fb (deterministic NaN at step 4 with l_v=6.329 stable —
|
||||
// proving V REGRESSION is fine while V FORWARD has at least one
|
||||
// non-finite output that the 0*Inf trick was promoting to NaN
|
||||
// everywhere). Per `pearl_atomicadd_masks_v_instability`.
|
||||
const float ret = is_done ? r : (r + gamma * vtp1);
|
||||
returns[b] = ret;
|
||||
advantages[b] = is_done ? (ret - vt) : 0.0f;
|
||||
}
|
||||
|
||||
84
crates/ml-alpha/cuda/rl_advantage_normalize.cu
Normal file
84
crates/ml-alpha/cuda/rl_advantage_normalize.cu
Normal file
@@ -0,0 +1,84 @@
|
||||
// rl_advantage_normalize.cu — Phase 4.5 per-batch advantage normalization (2026-05-30).
|
||||
//
|
||||
// Standard PPO practice (Schulman et al. 2017): normalize per-batch
|
||||
// advantages before the PPO surrogate computation:
|
||||
//
|
||||
// mean = (1/B) Σ_b advantage[b]
|
||||
// var = (1/B) Σ_b (advantage[b] − mean)²
|
||||
// std = sqrt(var + ε²)
|
||||
// advantage_norm[b] = (advantage[b] − mean) / std (in-place)
|
||||
//
|
||||
// Why: PPO surrogate = ratio × A. Without normalization, |A| can vary
|
||||
// 1000× across batches → l_pi magnitude unstable → Adam's per-parameter
|
||||
// scaling still functional, but gradient direction confidence drops
|
||||
// when advantage magnitudes are wildly inconsistent.
|
||||
//
|
||||
// In Phase 4.3, V_dq baseline produced advantages with ~100× more
|
||||
// variance than Plan A v2's V_scalar baseline → l_pi grew to 1.6e9
|
||||
// vs Plan A v2's 3.6e5. Adam internally normalizes, but the policy
|
||||
// updates have higher variance per step → pnl trajectory chops.
|
||||
//
|
||||
// Per pearl_adaptive_not_tuned: this normalization is self-adaptive
|
||||
// (uses observed per-batch statistics, no tuned hyperparameters).
|
||||
// Per pearl_blend_formulas_must_have_permanent_floor: ε² floor on
|
||||
// variance prevents div-by-zero when all advantages are identical.
|
||||
//
|
||||
// Block layout: grid=(1, 1, 1), block=(BLOCK_X=1024, 1, 1). Single
|
||||
// block does parallel reduction for mean → variance → normalize.
|
||||
// Suitable for batch sizes up to B=1024 (current production scale).
|
||||
|
||||
#define BLOCK_X 1024
|
||||
#define ADVANTAGE_VAR_FLOOR 1e-6f
|
||||
|
||||
extern "C" __global__ void rl_advantage_normalize(
|
||||
float* __restrict__ advantage, // [B] in-place
|
||||
int B
|
||||
) {
|
||||
const int tid = threadIdx.x;
|
||||
if (tid >= BLOCK_X) return;
|
||||
|
||||
// ── Pass 1: compute mean ──
|
||||
__shared__ float s_sum[BLOCK_X];
|
||||
float sum_partial = 0.0f;
|
||||
for (int b = tid; b < B; b += BLOCK_X) {
|
||||
sum_partial += advantage[b];
|
||||
}
|
||||
s_sum[tid] = sum_partial;
|
||||
__syncthreads();
|
||||
|
||||
for (int stride = BLOCK_X / 2; stride > 0; stride >>= 1) {
|
||||
if (tid < stride) s_sum[tid] += s_sum[tid + stride];
|
||||
__syncthreads();
|
||||
}
|
||||
__shared__ float s_mean;
|
||||
if (tid == 0) s_mean = s_sum[0] / (float)B;
|
||||
__syncthreads();
|
||||
const float mean = s_mean;
|
||||
|
||||
// ── Pass 2: compute variance ──
|
||||
__shared__ float s_var[BLOCK_X];
|
||||
float var_partial = 0.0f;
|
||||
for (int b = tid; b < B; b += BLOCK_X) {
|
||||
const float d = advantage[b] - mean;
|
||||
var_partial += d * d;
|
||||
}
|
||||
s_var[tid] = var_partial;
|
||||
__syncthreads();
|
||||
|
||||
for (int stride = BLOCK_X / 2; stride > 0; stride >>= 1) {
|
||||
if (tid < stride) s_var[tid] += s_var[tid + stride];
|
||||
__syncthreads();
|
||||
}
|
||||
__shared__ float s_inv_std;
|
||||
if (tid == 0) {
|
||||
const float var = s_var[0] / (float)B;
|
||||
s_inv_std = rsqrtf(var + ADVANTAGE_VAR_FLOOR); // 1/sqrt(var + ε²)
|
||||
}
|
||||
__syncthreads();
|
||||
const float inv_std = s_inv_std;
|
||||
|
||||
// ── Pass 3: normalize in-place ──
|
||||
for (int b = tid; b < B; b += BLOCK_X) {
|
||||
advantage[b] = (advantage[b] - mean) * inv_std;
|
||||
}
|
||||
}
|
||||
57
crates/ml-alpha/cuda/rl_dueling_q_bellman_target.cu
Normal file
57
crates/ml-alpha/cuda/rl_dueling_q_bellman_target.cu
Normal file
@@ -0,0 +1,57 @@
|
||||
// rl_dueling_q_bellman_target.cu — Phase 4 Bellman target build.
|
||||
//
|
||||
// Picks argmax_a' over the target net's composed_Q at s_{t+1}, then
|
||||
// builds the scalar Bellman target:
|
||||
//
|
||||
// a*_b = argmax_a' target_composed_Q[b, a']
|
||||
// target_value[b] = r[b] + γ × (1 − done[b]) × target_composed_Q[b, a*_b]
|
||||
//
|
||||
// γ read from ISV bus at runtime — same source as C51 / IQN Bellman
|
||||
// target kernels (RL_GAMMA_INDEX=400). For n-step returns, the
|
||||
// per-batch n_step_gammas are passed (matches existing PER convention).
|
||||
//
|
||||
// Block layout:
|
||||
// grid = (B, 1, 1)
|
||||
// block = (N_ACTIONS, 1, 1)
|
||||
// One block per batch. Each thread holds one action's value.
|
||||
// Tree-reduce in shared mem to find argmax. Thread 0 computes the
|
||||
// final target.
|
||||
//
|
||||
// Per feedback_no_atomicadd: sole-writer per output cell.
|
||||
|
||||
#define N_ACTIONS 11
|
||||
|
||||
extern "C" __global__ void rl_dueling_q_bellman_target_build(
|
||||
const float* __restrict__ target_composed_q, // [B × N_ACTIONS]
|
||||
const float* __restrict__ rewards, // [B]
|
||||
const float* __restrict__ dones, // [B]
|
||||
const float* __restrict__ n_step_gammas, // [B] (γ^n_step per sample)
|
||||
int B,
|
||||
float* __restrict__ target_value_out // [B]
|
||||
) {
|
||||
const int b = blockIdx.x;
|
||||
const int a = threadIdx.x;
|
||||
if (b >= B) return;
|
||||
if (a >= N_ACTIONS) return;
|
||||
|
||||
__shared__ float s_q[N_ACTIONS];
|
||||
s_q[a] = target_composed_q[b * N_ACTIONS + a];
|
||||
__syncthreads();
|
||||
|
||||
if (a == 0) {
|
||||
// Find argmax serially (small N).
|
||||
float best = s_q[0];
|
||||
#pragma unroll
|
||||
for (int i = 1; i < N_ACTIONS; ++i) {
|
||||
if (s_q[i] > best) best = s_q[i];
|
||||
}
|
||||
|
||||
const float r = rewards[b];
|
||||
const float done = dones[b];
|
||||
const float gamma_n = n_step_gammas[b];
|
||||
|
||||
// Standard Bellman with done masking:
|
||||
// target = r + γ^n × (1 − done) × max_q
|
||||
target_value_out[b] = r + gamma_n * (1.0f - done) * best;
|
||||
}
|
||||
}
|
||||
89
crates/ml-alpha/cuda/rl_dueling_q_decompose_and_bwd.cu
Normal file
89
crates/ml-alpha/cuda/rl_dueling_q_decompose_and_bwd.cu
Normal file
@@ -0,0 +1,89 @@
|
||||
// rl_dueling_q_decompose_and_bwd.cu — Phase 4 decompose grad + weight grad.
|
||||
//
|
||||
// Decompose:
|
||||
// composed_Q[b, a] = V[b] + A[b, a] − (1/N) Σ_a' A[b, a']
|
||||
//
|
||||
// Chain rule (only taken action has nonzero grad_composed[b, a]):
|
||||
// grad_V[b] = Σ_a grad_composed[b, a] = grad_composed[b, a_taken]
|
||||
// grad_A[b, a] = grad_composed[b, a] − (1/N) × grad_V[b]
|
||||
//
|
||||
// For a == a_taken: grad_A[b, a] = (1 − 1/N) × grad_composed[b, a_taken]
|
||||
// For a ≠ a_taken: grad_A[b, a] = −(1/N) × grad_composed[b, a_taken]
|
||||
//
|
||||
// After decompose, compute per-batch weight gradients via outer
|
||||
// product with h_t:
|
||||
// grad_w_v_pb[b, c] = grad_V[b] × h_t[b, c]
|
||||
// grad_b_v_pb[b] = grad_V[b]
|
||||
// grad_w_a_pb[b, c, a] = grad_A[b, a] × h_t[b, c]
|
||||
// grad_b_a_pb[b, a] = grad_A[b, a]
|
||||
//
|
||||
// Caller reduces per-batch grads via reduce_axis0 to final shapes:
|
||||
// grad_w_v [HIDDEN_DIM]
|
||||
// grad_b_v [1]
|
||||
// grad_w_a [HIDDEN_DIM × N_ACTIONS]
|
||||
// grad_b_a [N_ACTIONS]
|
||||
//
|
||||
// Block layout:
|
||||
// grid = (B, 1, 1)
|
||||
// block = (HIDDEN_DIM, 1, 1) — one thread per hidden-dim index
|
||||
// Each thread loops over N_ACTIONS to write A weights, plus the
|
||||
// single V weight. Thread 0 additionally writes grad_b_v_pb +
|
||||
// grad_b_a_pb (small).
|
||||
//
|
||||
// Per feedback_no_atomicadd: sole-writer per (b, c, a) and (b, c) cells.
|
||||
|
||||
#define HIDDEN_DIM 128
|
||||
#define N_ACTIONS 11
|
||||
|
||||
extern "C" __global__ void rl_dueling_q_decompose_and_weight_grad(
|
||||
const float* __restrict__ h_t, // [B × HIDDEN_DIM]
|
||||
const float* __restrict__ grad_composed, // [B × N_ACTIONS] (only taken cell nonzero)
|
||||
const int* __restrict__ actions_taken, // [B]
|
||||
int B,
|
||||
float* __restrict__ grad_w_v_pb, // [B × HIDDEN_DIM]
|
||||
float* __restrict__ grad_b_v_pb, // [B]
|
||||
float* __restrict__ grad_w_a_pb, // [B × HIDDEN_DIM × N_ACTIONS]
|
||||
float* __restrict__ grad_b_a_pb // [B × N_ACTIONS]
|
||||
) {
|
||||
const int b = blockIdx.x;
|
||||
const int c = threadIdx.x;
|
||||
if (b >= B) return;
|
||||
if (c >= HIDDEN_DIM) return;
|
||||
|
||||
int a_t = actions_taken[b];
|
||||
if (a_t < 0) a_t = 0;
|
||||
if (a_t >= N_ACTIONS) a_t = 0;
|
||||
|
||||
// grad_composed is nonzero only at a == a_t.
|
||||
const float gc_taken = grad_composed[b * N_ACTIONS + a_t];
|
||||
|
||||
// grad_V[b] = Σ_a grad_composed[b, a] = gc_taken (others are 0).
|
||||
const float grad_v = gc_taken;
|
||||
|
||||
// h_t[b, c].
|
||||
const float h_bc = h_t[b * HIDDEN_DIM + c];
|
||||
|
||||
// Per-batch V weight grad.
|
||||
grad_w_v_pb[b * HIDDEN_DIM + c] = grad_v * h_bc;
|
||||
|
||||
// Per-batch A weight grad (one thread writes N_ACTIONS values).
|
||||
// grad_A[b, a] = grad_composed[b, a] − (1/N) × grad_V[b]
|
||||
// = (a == a_t ? gc_taken : 0) − (1/N) × gc_taken
|
||||
const float inv_N = 1.0f / (float)N_ACTIONS;
|
||||
#pragma unroll
|
||||
for (int a = 0; a < N_ACTIONS; ++a) {
|
||||
const float grad_a = (a == a_t ? gc_taken : 0.0f) - inv_N * gc_taken;
|
||||
// Layout: grad_w_a_pb[b, c, a] = h_bc * grad_a
|
||||
grad_w_a_pb[b * HIDDEN_DIM * N_ACTIONS + c * N_ACTIONS + a] = h_bc * grad_a;
|
||||
}
|
||||
|
||||
// Thread 0 writes biases (small).
|
||||
if (c == 0) {
|
||||
grad_b_v_pb[b] = grad_v;
|
||||
#pragma unroll
|
||||
for (int a = 0; a < N_ACTIONS; ++a) {
|
||||
const float grad_a = (a == a_t ? gc_taken : 0.0f) - inv_N * gc_taken;
|
||||
grad_b_a_pb[b * N_ACTIONS + a] = grad_a;
|
||||
}
|
||||
}
|
||||
}
|
||||
121
crates/ml-alpha/cuda/rl_dueling_q_forward.cu
Normal file
121
crates/ml-alpha/cuda/rl_dueling_q_forward.cu
Normal file
@@ -0,0 +1,121 @@
|
||||
// rl_dueling_q_forward.cu — Phase 4 Independent Dueling Q head forward.
|
||||
//
|
||||
// Per spec docs/superpowers/specs/2026-05-30-phase4-independent-dueling-head-design.md.
|
||||
//
|
||||
// Architecture: parallel head to C51/IQN/π/value_head with ZERO shared
|
||||
// state with downstream consumers (ensemble, distill, action selection).
|
||||
// Trains its own V + A weights via Bellman loss on composed_Q at taken
|
||||
// action. V output feeds PPO advantage baseline (Phase 4.3) — composed_Q
|
||||
// is internal to this head's loss path and never consumed elsewhere.
|
||||
//
|
||||
// Why this design (vs Phase 2 v2 / Phase 3.x failures):
|
||||
// - Phase 2 v2 (scalar V + categorical CE): N/A here — uses scalar
|
||||
// Bellman loss, no softmax math eating V.
|
||||
// - Phase 3.2 (V_IQN cross-architecture calibration): V_dq trained
|
||||
// on same Bellman reward signal as C51, scales align by construction.
|
||||
// - Phase 3.1 (composed Q perturbs ensemble): composed_Q_dq never
|
||||
// feeds ensemble. Only feeds its own Bellman loss + diag.
|
||||
// - Phase 3.1-fix (gradient structure mismatch contaminates weights):
|
||||
// DuelingQHead has its OWN weights — mean-zero A grad pattern only
|
||||
// affects DuelingQHead's training, no shared weights with C51/IQN.
|
||||
//
|
||||
// Forward computation:
|
||||
// V[b] = Σ_c w_v[c] × h_t[b, c] + b_v[0]
|
||||
// A[b, a] = Σ_c w_a[c, a] × h_t[b, c] + b_a[a] for a in 0..N
|
||||
// composed_Q[b, a] = V[b] + A[b, a] − (1/N) Σ_a' A[b, a']
|
||||
//
|
||||
// Block layout:
|
||||
// grid = (B, 1, 1)
|
||||
// block = (HIDDEN_DIM = 128, 1, 1)
|
||||
// One block per batch. Each thread computes one hidden-dim term and
|
||||
// participates in tree-reduce. Then thread 0 broadcasts to A
|
||||
// computation. Each thread computes one action's matmul contribution.
|
||||
// Mean reduction over N_ACTIONS done in shared mem.
|
||||
//
|
||||
// Memory:
|
||||
// shared float s_h[HIDDEN_DIM] — cached h_t row
|
||||
// shared float s_v — scalar V value
|
||||
// shared float s_a[N_ACTIONS] — A values (post-bias)
|
||||
//
|
||||
// Per feedback_no_atomicadd: sole-writer per output cell.
|
||||
// Per feedback_cpu_is_read_only: pure device kernel.
|
||||
|
||||
#define HIDDEN_DIM 128
|
||||
#define N_ACTIONS 11
|
||||
|
||||
extern "C" __global__ void rl_dueling_q_forward(
|
||||
const float* __restrict__ h_t, // [B × HIDDEN_DIM]
|
||||
const float* __restrict__ w_v, // [HIDDEN_DIM]
|
||||
const float* __restrict__ b_v, // [1]
|
||||
const float* __restrict__ w_a, // [HIDDEN_DIM × N_ACTIONS], row-major
|
||||
const float* __restrict__ b_a, // [N_ACTIONS]
|
||||
int B,
|
||||
float* __restrict__ v_out, // [B]
|
||||
float* __restrict__ a_out, // [B × N_ACTIONS]
|
||||
float* __restrict__ q_composed_out // [B × N_ACTIONS]
|
||||
) {
|
||||
const int b = blockIdx.x;
|
||||
const int c = threadIdx.x;
|
||||
if (b >= B) return;
|
||||
if (c >= HIDDEN_DIM) return;
|
||||
|
||||
extern __shared__ float s_h[]; // [HIDDEN_DIM], sized by smem arg
|
||||
|
||||
// ── Load h_t[b] into shared mem ──
|
||||
s_h[c] = h_t[b * HIDDEN_DIM + c];
|
||||
__syncthreads();
|
||||
|
||||
// ── V projection (block-reduce) ──
|
||||
// V[b] = Σ_c w_v[c] × s_h[c] + b_v[0]
|
||||
// Tree reduction over HIDDEN_DIM threads.
|
||||
__shared__ float s_v_partial[HIDDEN_DIM];
|
||||
s_v_partial[c] = w_v[c] * s_h[c];
|
||||
__syncthreads();
|
||||
// Tree reduce
|
||||
for (int stride = HIDDEN_DIM / 2; stride > 0; stride >>= 1) {
|
||||
if (c < stride) {
|
||||
s_v_partial[c] += s_v_partial[c + stride];
|
||||
}
|
||||
__syncthreads();
|
||||
}
|
||||
__shared__ float s_v;
|
||||
if (c == 0) {
|
||||
s_v = s_v_partial[0] + b_v[0];
|
||||
v_out[b] = s_v;
|
||||
}
|
||||
__syncthreads();
|
||||
|
||||
// ── A projection (each thread handles one action) ──
|
||||
// A[b, a] = Σ_c w_a[c, a] × s_h[c] + b_a[a]
|
||||
// Threads 0..N_ACTIONS-1 compute one action each.
|
||||
// Other threads idle for this section.
|
||||
__shared__ float s_a[N_ACTIONS];
|
||||
if (c < N_ACTIONS) {
|
||||
float acc = 0.0f;
|
||||
#pragma unroll
|
||||
for (int i = 0; i < HIDDEN_DIM; ++i) {
|
||||
acc += w_a[i * N_ACTIONS + c] * s_h[i];
|
||||
}
|
||||
acc += b_a[c];
|
||||
s_a[c] = acc;
|
||||
a_out[b * N_ACTIONS + c] = acc;
|
||||
}
|
||||
__syncthreads();
|
||||
|
||||
// ── Mean over actions (thread 0 only — small N) ──
|
||||
__shared__ float s_mean_a;
|
||||
if (c == 0) {
|
||||
float sum = 0.0f;
|
||||
#pragma unroll
|
||||
for (int i = 0; i < N_ACTIONS; ++i) {
|
||||
sum += s_a[i];
|
||||
}
|
||||
s_mean_a = sum * (1.0f / (float)N_ACTIONS);
|
||||
}
|
||||
__syncthreads();
|
||||
|
||||
// ── Compose Q (each thread for one action writes the result) ──
|
||||
if (c < N_ACTIONS) {
|
||||
q_composed_out[b * N_ACTIONS + c] = s_v + s_a[c] - s_mean_a;
|
||||
}
|
||||
}
|
||||
83
crates/ml-alpha/cuda/rl_dueling_q_loss_and_grad.cu
Normal file
83
crates/ml-alpha/cuda/rl_dueling_q_loss_and_grad.cu
Normal file
@@ -0,0 +1,83 @@
|
||||
// rl_dueling_q_loss_and_grad.cu — Phase 4 Bellman loss + decompose backward.
|
||||
//
|
||||
// Computes the scalar Huber loss on (target − online_composed_Q[taken])
|
||||
// AND emits grad_composed[B × N_ACTIONS] in one fused kernel.
|
||||
//
|
||||
// Loss (per-batch):
|
||||
// δ_b = target_value[b] − online_composed_Q[b, a_taken[b]]
|
||||
// L_b = Huber(δ_b, κ=1.0) / B (mean over batch)
|
||||
// loss_per_batch[b] = L_b
|
||||
//
|
||||
// Gradient w.r.t. online_composed_Q (mostly zero, nonzero at taken):
|
||||
// For a_taken: dL/d_composed[b, a_taken] = −(1/B) × Huber'(δ_b)
|
||||
// For a ≠ a_taken: dL/d_composed[b, a] = 0
|
||||
//
|
||||
// Huber'(δ) = δ if |δ| ≤ κ
|
||||
// = κ × sign(δ) if |δ| > κ
|
||||
//
|
||||
// Block layout:
|
||||
// grid = (B, 1, 1)
|
||||
// block = (N_ACTIONS, 1, 1)
|
||||
// Each thread writes one (b, a) cell of grad_composed; thread 0
|
||||
// computes loss + the nonzero gradient scalar.
|
||||
//
|
||||
// Per feedback_no_atomicadd: sole-writer per cell.
|
||||
|
||||
#define N_ACTIONS 11
|
||||
#define HUBER_KAPPA 1.0f
|
||||
|
||||
extern "C" __global__ void rl_dueling_q_loss_and_grad(
|
||||
const float* __restrict__ online_composed_q, // [B × N_ACTIONS]
|
||||
const float* __restrict__ target_value, // [B]
|
||||
const int* __restrict__ actions_taken, // [B]
|
||||
int B,
|
||||
float* __restrict__ loss_per_batch, // [B]
|
||||
float* __restrict__ grad_composed // [B × N_ACTIONS]
|
||||
) {
|
||||
const int b = blockIdx.x;
|
||||
const int a = threadIdx.x;
|
||||
if (b >= B) return;
|
||||
if (a >= N_ACTIONS) return;
|
||||
|
||||
// Defensive clamp on actions_taken (trainer should never produce
|
||||
// out-of-range, but guard against corrupt indices).
|
||||
int a_t = actions_taken[b];
|
||||
if (a_t < 0) a_t = 0;
|
||||
if (a_t >= N_ACTIONS) a_t = 0;
|
||||
|
||||
__shared__ float s_grad_scalar;
|
||||
|
||||
if (a == 0) {
|
||||
const float q_taken = online_composed_q[b * N_ACTIONS + a_t];
|
||||
const float target = target_value[b];
|
||||
const float delta = target - q_taken;
|
||||
const float abs_d = fabsf(delta);
|
||||
const float inv_B = 1.0f / (float)B;
|
||||
|
||||
// Huber loss.
|
||||
float l;
|
||||
if (abs_d <= HUBER_KAPPA) {
|
||||
l = 0.5f * delta * delta;
|
||||
} else {
|
||||
l = HUBER_KAPPA * (abs_d - 0.5f * HUBER_KAPPA);
|
||||
}
|
||||
loss_per_batch[b] = l * inv_B;
|
||||
|
||||
// Huber'(δ) — gradient of l w.r.t. delta.
|
||||
float huber_grad;
|
||||
if (abs_d <= HUBER_KAPPA) {
|
||||
huber_grad = delta;
|
||||
} else {
|
||||
huber_grad = HUBER_KAPPA * ((delta > 0.0f) ? 1.0f : -1.0f);
|
||||
}
|
||||
// dL/d_composed[a_taken] = (dL/dδ) × (dδ/d_composed[a_taken])
|
||||
// = (−1/B) × huber_grad (δ = target − Q)
|
||||
s_grad_scalar = -inv_B * huber_grad;
|
||||
}
|
||||
__syncthreads();
|
||||
|
||||
// All threads write grad_composed. Only the taken action gets a
|
||||
// nonzero value (CE on a single Q value).
|
||||
const int idx = b * N_ACTIONS + a;
|
||||
grad_composed[idx] = (a == a_t) ? s_grad_scalar : 0.0f;
|
||||
}
|
||||
37
crates/ml-alpha/cuda/rl_v_blend.cu
Normal file
37
crates/ml-alpha/cuda/rl_v_blend.cu
Normal file
@@ -0,0 +1,37 @@
|
||||
// rl_v_blend.cu — Phase 4.4 adaptive V baseline blend (2026-05-30).
|
||||
//
|
||||
// V_used[b] = α × V_scalar[b] + (1 − α) × V_dq[b]
|
||||
//
|
||||
// α read on-device from ISV[alpha_slot], emitted by
|
||||
// rl_v_blend_alpha_controller (separate kernel) which adapts α
|
||||
// based on observed |V_dq − V_scalar| / |V_scalar| tracking ratio.
|
||||
//
|
||||
// α = 1.0: pure Plan A v2 behavior (V_scalar drives PPO advantage)
|
||||
// α = 0.0: pure Phase 4.3 behavior (V_dq drives PPO advantage)
|
||||
// Anywhere in between: adaptive blend
|
||||
//
|
||||
// Per feedback_cpu_is_read_only: pure device kernel; α computed
|
||||
// device-side by the controller.
|
||||
// Per pearl_no_host_branches_in_captured_graph: graph-safe (reads
|
||||
// ISV pointer, no host params).
|
||||
// Per feedback_no_atomicadd: sole-writer per cell.
|
||||
//
|
||||
// Block layout: grid=(ceil(B/256), 1, 1), block=(256, 1, 1). Pure
|
||||
// elementwise op.
|
||||
|
||||
extern "C" __global__ void rl_v_blend(
|
||||
const float* __restrict__ v_scalar, // [B]
|
||||
const float* __restrict__ v_dq, // [B]
|
||||
const float* __restrict__ isv, // ISV bus
|
||||
int B,
|
||||
int alpha_slot,
|
||||
float* __restrict__ v_blended // [B]
|
||||
) {
|
||||
const int b = blockIdx.x * blockDim.x + threadIdx.x;
|
||||
if (b >= B) return;
|
||||
// Defensive clamp to [0, 1] — controller should keep α bounded
|
||||
// (per pearl_audit_unboundedness_for_implicit_asymmetry) but a
|
||||
// kernel-side guard protects against any controller bug.
|
||||
const float a = fminf(1.0f, fmaxf(0.0f, isv[alpha_slot]));
|
||||
v_blended[b] = a * v_scalar[b] + (1.0f - a) * v_dq[b];
|
||||
}
|
||||
121
crates/ml-alpha/cuda/rl_v_blend_alpha_controller.cu
Normal file
121
crates/ml-alpha/cuda/rl_v_blend_alpha_controller.cu
Normal file
@@ -0,0 +1,121 @@
|
||||
// rl_v_blend_alpha_controller.cu — Phase 4.4 ISV-adaptive V blend (2026-05-30).
|
||||
//
|
||||
// Drives α ∈ [0, 1] for `V_used = α × V_scalar + (1−α) × V_dq` based
|
||||
// on observed V_dq vs V_scalar tracking ratio:
|
||||
//
|
||||
// track_ratio = EMA(|V_dq − V_scalar|) / EMA(|V_scalar|)
|
||||
//
|
||||
// if track_ratio > 1.5 × TARGET: α ← min(α + step, 1.0) ↑ V_scalar
|
||||
// if track_ratio < TARGET / 1.5: α ← max(α - step, 0.0) ↑ V_dq
|
||||
// else: hold α
|
||||
//
|
||||
// Per pearl_wiener_alpha_floor_for_nonstationary: Schulman bounded
|
||||
// discrete step, no Wiener-α blending of the controller variable
|
||||
// itself (α is the controlled quantity).
|
||||
//
|
||||
// Per pearl_first_observation_bootstrap: bootstrap α = 1.0 on
|
||||
// sentinel input (ISV[alpha_slot] == 0), EMAs use first observation
|
||||
// directly. After bootstrap, α never naturally returns to exactly 0
|
||||
// because Schulman step (0.01) is unlikely to land on it; if it
|
||||
// does, controller re-bootstraps harmlessly.
|
||||
//
|
||||
// Per pearl_blend_formulas_must_have_permanent_floor: dead-signal
|
||||
// guard — if EMA(|V_scalar|) < FLOOR, hold α (no V signal to
|
||||
// calibrate against; the trainer hasn't seen meaningful rewards yet).
|
||||
//
|
||||
// Per feedback_cpu_is_read_only: pure device kernel; reads V_scalar,
|
||||
// V_dq, ISV; emits α + EMAs to ISV. No host control.
|
||||
//
|
||||
// Block layout: grid=(1, 1, 1), block=(BLOCK_X=1024, 1, 1). Single
|
||||
// block does parallel reduction over batch up to B=1024. Thread 0
|
||||
// performs the controller update.
|
||||
|
||||
#define BLOCK_X 1024
|
||||
#define EMA_ALPHA 0.01f
|
||||
#define TARGET_TRACK_RATIO 0.10f
|
||||
#define SCHULMAN_STEP 0.01f
|
||||
#define DEAD_SIGNAL_FLOOR 1e-4f
|
||||
#define BOOTSTRAP_ALPHA 1.0f
|
||||
|
||||
extern "C" __global__ void rl_v_blend_alpha_controller(
|
||||
const float* __restrict__ v_scalar, // [B]
|
||||
const float* __restrict__ v_dq, // [B]
|
||||
float* __restrict__ isv, // ISV bus
|
||||
int B,
|
||||
int alpha_slot, // ISV[α]
|
||||
int trackerr_ema_slot, // ISV[|V_dq − V_scalar|_ema]
|
||||
int v_scalar_mag_ema_slot // ISV[|V_scalar|_ema] (dead-signal floor)
|
||||
) {
|
||||
const int tid = threadIdx.x;
|
||||
if (tid >= BLOCK_X) return;
|
||||
|
||||
// ── Per-thread partial sums over strided batch ──
|
||||
float track_partial = 0.0f;
|
||||
float mag_partial = 0.0f;
|
||||
for (int b = tid; b < B; b += BLOCK_X) {
|
||||
const float vs = v_scalar[b];
|
||||
const float vd = v_dq[b];
|
||||
track_partial += fabsf(vd - vs);
|
||||
mag_partial += fabsf(vs);
|
||||
}
|
||||
|
||||
// ── Tree-reduce in shared mem ──
|
||||
__shared__ float s_t[BLOCK_X];
|
||||
__shared__ float s_m[BLOCK_X];
|
||||
s_t[tid] = track_partial;
|
||||
s_m[tid] = mag_partial;
|
||||
__syncthreads();
|
||||
|
||||
for (int stride = BLOCK_X / 2; stride > 0; stride >>= 1) {
|
||||
if (tid < stride) {
|
||||
s_t[tid] += s_t[tid + stride];
|
||||
s_m[tid] += s_m[tid + stride];
|
||||
}
|
||||
__syncthreads();
|
||||
}
|
||||
|
||||
if (tid == 0) {
|
||||
const float inv_B = 1.0f / (float)B;
|
||||
const float track_mean = s_t[0] * inv_B;
|
||||
const float mag_mean = s_m[0] * inv_B;
|
||||
|
||||
// ── Bootstrap α on sentinel ──
|
||||
float alpha = isv[alpha_slot];
|
||||
if (alpha == 0.0f) {
|
||||
alpha = BOOTSTRAP_ALPHA;
|
||||
}
|
||||
|
||||
// ── First-observation bootstrap on EMAs ──
|
||||
float prev_track_ema = isv[trackerr_ema_slot];
|
||||
float prev_mag_ema = isv[v_scalar_mag_ema_slot];
|
||||
const float track_ema = (prev_track_ema == 0.0f)
|
||||
? track_mean
|
||||
: (1.0f - EMA_ALPHA) * prev_track_ema + EMA_ALPHA * track_mean;
|
||||
const float mag_ema = (prev_mag_ema == 0.0f)
|
||||
? mag_mean
|
||||
: (1.0f - EMA_ALPHA) * prev_mag_ema + EMA_ALPHA * mag_mean;
|
||||
|
||||
// ── Dead-signal guard: V_scalar magnitude too small to calibrate against ──
|
||||
if (mag_ema < DEAD_SIGNAL_FLOOR) {
|
||||
isv[trackerr_ema_slot] = track_ema;
|
||||
isv[v_scalar_mag_ema_slot] = mag_ema;
|
||||
isv[alpha_slot] = alpha; // hold (write bootstrap if needed)
|
||||
return;
|
||||
}
|
||||
|
||||
// ── Track ratio + Schulman-bounded step on α ──
|
||||
const float track_ratio = track_ema / mag_ema;
|
||||
if (track_ratio > 1.5f * TARGET_TRACK_RATIO) {
|
||||
// V_dq diverged from V_scalar → raise α toward V_scalar
|
||||
alpha = fminf(alpha + SCHULMAN_STEP, 1.0f);
|
||||
} else if (track_ratio < TARGET_TRACK_RATIO / 1.5f) {
|
||||
// V_dq tracks V_scalar well → lower α toward V_dq
|
||||
alpha = fmaxf(alpha - SCHULMAN_STEP, 0.0f);
|
||||
}
|
||||
// else: hold α (within band)
|
||||
|
||||
isv[alpha_slot] = alpha;
|
||||
isv[trackerr_ema_slot] = track_ema;
|
||||
isv[v_scalar_mag_ema_slot] = mag_ema;
|
||||
}
|
||||
}
|
||||
@@ -26,6 +26,24 @@
|
||||
#define VSN_FEATURE_DIM 40
|
||||
#define VSN_BLOCK 64 // round up to warp-multiple; threads i >= FEATURE_DIM idle.
|
||||
|
||||
// 2026-05-29 stride-mismatch fix.
|
||||
// VSN's input buffer (window_tensor_d) is allocated [B, K, ENCODER_INPUT_DIM=56]
|
||||
// by perception.rs (snap features [0..40) + per-batch broadcast context
|
||||
// [40..56) written by rl_encoder_context_broadcast). VSN only processes the
|
||||
// first VSN_FEATURE_DIM=40 features per row (snap features), but the input
|
||||
// rows are spaced 56 floats apart, not 40. The kernel originally indexed x
|
||||
// with stride VSN_FEATURE_DIM=40 — correct ONLY for row 0; every subsequent
|
||||
// row read mixed broadcast-context + snap features across the [B, K, 56]
|
||||
// row boundaries. Symptoms: intermittent step-4 NaN as accumulating trade
|
||||
// context magnitudes overflowed VSN's softmax via the bleed.
|
||||
//
|
||||
// Fix: use VSN_X_ROW_STRIDE=56 for reading x in both forward and backward.
|
||||
// Output (gates, y) and gradient outputs (grad_W, grad_b, grad_x) remain at
|
||||
// VSN_FEATURE_DIM=40 because the downstream consumers (Mamba2 L1 with
|
||||
// in_dim=40) read at compact 40-stride. grad_x is unused downstream (see
|
||||
// `vsn_grad_x_d` audit — write-only), so its stride doesn't matter.
|
||||
#define VSN_X_ROW_STRIDE 56 // = ENCODER_INPUT_DIM in heads.rs / perception.rs
|
||||
|
||||
extern "C" __global__ void variable_selection_fwd(
|
||||
const float* __restrict__ W_vsn, // [FEATURE_DIM, FEATURE_DIM]
|
||||
const float* __restrict__ b_vsn, // [FEATURE_DIM]
|
||||
@@ -38,7 +56,7 @@ extern "C" __global__ void variable_selection_fwd(
|
||||
int tid = threadIdx.x;
|
||||
if (row >= n_rows) return;
|
||||
|
||||
const float* x_row = x + (long long)row * VSN_FEATURE_DIM;
|
||||
const float* x_row = x + (long long)row * VSN_X_ROW_STRIDE;
|
||||
|
||||
// Shared mem: gate_logit + max-reduce scratch + sum-reduce scratch.
|
||||
__shared__ float s_logit[VSN_FEATURE_DIM];
|
||||
@@ -170,7 +188,7 @@ extern "C" __global__ void variable_selection_bwd(
|
||||
float dy_i = 0.0f;
|
||||
if (tid < VSN_FEATURE_DIM) {
|
||||
gates_i = gates[(long long)row * VSN_FEATURE_DIM + tid];
|
||||
x_i = x[(long long)row * VSN_FEATURE_DIM + tid];
|
||||
x_i = x[(long long)row * VSN_X_ROW_STRIDE + tid];
|
||||
dy_i = grad_y[(long long)row * VSN_FEATURE_DIM + tid];
|
||||
s_gates[tid] = gates_i;
|
||||
s_dgates[tid] = dy_i * x_i;
|
||||
@@ -201,7 +219,7 @@ extern "C" __global__ void variable_selection_bwd(
|
||||
const float dl_t = s_dlogit[tid];
|
||||
#pragma unroll
|
||||
for (int j = 0; j < VSN_FEATURE_DIM; ++j) {
|
||||
const float xj = x[(long long)row * VSN_FEATURE_DIM + j];
|
||||
const float xj = x[(long long)row * VSN_X_ROW_STRIDE + j];
|
||||
grad_W_vsn_scratch[row_FF + (long long)tid * VSN_FEATURE_DIM + j]
|
||||
+= dl_t * xj;
|
||||
}
|
||||
|
||||
@@ -1145,25 +1145,12 @@ fn main() -> Result<()> {
|
||||
// the train-end policy's OOS performance. True pure-eval (forward
|
||||
// only, no backward) is a follow-up architectural change.
|
||||
if cli.n_eval_steps > 0 && !eval_files.is_empty() {
|
||||
// dd049d9a4 baseline: NO reset_session_state (that method came in
|
||||
// a later Phase 4-era commit and is intentionally excluded from
|
||||
// this experiment per "test dd049d9a4 baseline with eval-math fix only").
|
||||
let head_before_per_b = sim
|
||||
.read_per_backtest_trade_counts()
|
||||
.context("snapshot per-backtest trade-count pre-eval")?;
|
||||
let head_before_min = head_before_per_b.iter().min().copied().unwrap_or(0);
|
||||
let head_before_max = head_before_per_b.iter().max().copied().unwrap_or(0);
|
||||
let head_before_sum: u64 =
|
||||
head_before_per_b.iter().map(|&h| h as u64).sum();
|
||||
let head_before_eval = sim
|
||||
.read_total_trade_count()
|
||||
.context("read trade count pre-eval")?;
|
||||
eprintln!(
|
||||
"── eval phase: {} steps on {} held-out files; pre-eval head per b_size={} \
|
||||
accounts: min={} max={} sum={} ──",
|
||||
cli.n_eval_steps,
|
||||
eval_files.len(),
|
||||
head_before_per_b.len(),
|
||||
head_before_min,
|
||||
head_before_max,
|
||||
head_before_sum
|
||||
"── eval phase: {} steps on {} held-out files (trade-record checkpoint head={}) ──",
|
||||
cli.n_eval_steps, eval_files.len(), head_before_eval
|
||||
);
|
||||
|
||||
let eval_loader_cfg = MultiHorizonLoaderConfig {
|
||||
@@ -1211,82 +1198,22 @@ fn main() -> Result<()> {
|
||||
}
|
||||
}
|
||||
|
||||
// Aggregate eval-phase trades across ALL b_size accounts, with
|
||||
// correct per-account head_before slicing. Replaces the previous
|
||||
// single-account read of backtest 0 + aggregate-vs-per-account
|
||||
// scale mismatch (see spec
|
||||
// docs/superpowers/specs/2026-05-31-eval-summary-trade-aggregation-design.md
|
||||
// for the root-cause analysis).
|
||||
let all_per_b = sim
|
||||
.read_trade_records_all()
|
||||
.context("read all backtests' trade records post-eval")?;
|
||||
let head_after_per_b = sim
|
||||
.read_per_backtest_trade_counts()
|
||||
.context("snapshot per-backtest trade-count post-eval")?;
|
||||
|
||||
let cap_u32 = ml_backtesting::lob::TRADE_LOG_CAP as u32;
|
||||
let mut eval_records: Vec<ml_backtesting::order::TradeRecord> = Vec::new();
|
||||
let mut n_eval_trades_seen: u64 = 0;
|
||||
let mut n_eval_trades_dropped: u64 = 0;
|
||||
let mut n_pre_eval_wrapped: u64 = 0;
|
||||
|
||||
for (b, records) in all_per_b.iter().enumerate() {
|
||||
let head_before = head_before_per_b[b];
|
||||
let head_after = head_after_per_b[b];
|
||||
let eval_count_total = head_after.saturating_sub(head_before);
|
||||
n_eval_trades_seen += eval_count_total as u64;
|
||||
|
||||
// The ring's contents cover cumulative-stream indices
|
||||
// [max(0, head_after - cap), head_after).
|
||||
let ring_start_stream_idx = head_after.saturating_sub(cap_u32);
|
||||
|
||||
if head_before < ring_start_stream_idx {
|
||||
// Some pre-eval trades had wrapped out before eval started —
|
||||
// diagnostic only, doesn't affect eval slicing.
|
||||
n_pre_eval_wrapped += (ring_start_stream_idx - head_before) as u64;
|
||||
}
|
||||
|
||||
if eval_count_total > cap_u32 {
|
||||
// Eval-phase trades wrapped (lost). Should be 0 with
|
||||
// TRADE_LOG_CAP=4096 at typical cluster scale.
|
||||
n_eval_trades_dropped += (eval_count_total - cap_u32) as u64;
|
||||
}
|
||||
|
||||
// Slice the ring contents to eval-only.
|
||||
// ring index of first eval trade = head_before − ring_start_stream_idx
|
||||
// (clamped at 0 if all pre-eval already wrapped out).
|
||||
let eval_start_in_ring =
|
||||
head_before.saturating_sub(ring_start_stream_idx) as usize;
|
||||
if eval_start_in_ring < records.len() {
|
||||
eval_records.extend_from_slice(&records[eval_start_in_ring..]);
|
||||
}
|
||||
}
|
||||
|
||||
if n_eval_trades_dropped > 0 {
|
||||
// Drain trade records, slice to eval-only, compute summary.
|
||||
let all_records = sim
|
||||
.read_trade_records(0)
|
||||
.context("read trade records post-eval")?;
|
||||
let head_before_usize = head_before_eval as usize;
|
||||
let eval_records: Vec<_> = if all_records.len() > head_before_usize {
|
||||
all_records[head_before_usize..].to_vec()
|
||||
} else {
|
||||
// Trade log wrapped past TRADE_LOG_CAP — use what we have.
|
||||
// For smoke (b_size=1, ≤200 trades) this branch never fires.
|
||||
eprintln!(
|
||||
"warning: {} eval trades wrapped out across {} accounts \
|
||||
(TRADE_LOG_CAP={}); summary based on {} captured eval trades",
|
||||
n_eval_trades_dropped,
|
||||
all_per_b.len(),
|
||||
cap_u32,
|
||||
eval_records.len()
|
||||
"warning: trade log wrapped — head_before={} but only {} records readable",
|
||||
head_before_usize, all_records.len()
|
||||
);
|
||||
}
|
||||
if n_pre_eval_wrapped > 0 {
|
||||
eprintln!(
|
||||
"info: {} pre-eval trades had already wrapped before eval phase \
|
||||
(no effect on eval summary)",
|
||||
n_pre_eval_wrapped
|
||||
);
|
||||
}
|
||||
eprintln!(
|
||||
"eval phase trade accounting: n_eval_trades_seen={} n_captured={} \
|
||||
n_dropped={} b_size={}",
|
||||
n_eval_trades_seen,
|
||||
eval_records.len(),
|
||||
n_eval_trades_dropped,
|
||||
all_per_b.len()
|
||||
);
|
||||
all_records
|
||||
};
|
||||
|
||||
// Synthesise a pnl_curve from per-trade cumulative PnL for
|
||||
// compute_summary's max_drawdown calc.
|
||||
@@ -1300,42 +1227,19 @@ fn main() -> Result<()> {
|
||||
ml_backtesting::artifacts::compute_summary(&eval_records, &pnl_curve);
|
||||
|
||||
eprintln!(
|
||||
"eval summary: n_trades={} pnl_usd={:.2} pf={:.3} sharpe_ann={:.3} \
|
||||
max_dd_usd={:.2} win_rate={:.3} | seen={} dropped={} b={}",
|
||||
"eval summary: n_trades={} pnl_usd={:.2} pf={:.3} sharpe_ann={:.3} max_dd_usd={:.2} win_rate={:.3}",
|
||||
eval_summary.n_trades,
|
||||
eval_summary.total_pnl_usd,
|
||||
eval_summary.profit_factor,
|
||||
eval_summary.sharpe_ann,
|
||||
eval_summary.max_drawdown_usd,
|
||||
eval_summary.win_rate,
|
||||
n_eval_trades_seen,
|
||||
n_eval_trades_dropped,
|
||||
all_per_b.len(),
|
||||
);
|
||||
|
||||
// Write eval_summary.json with the existing compute_summary fields
|
||||
// PLUS four new aggregation-aware fields:
|
||||
// n_eval_trades_seen — true total cumulative dones across b_size
|
||||
// n_eval_trades_dropped — eval trades lost to ring wrap (0 at typical scale)
|
||||
// n_pre_eval_trades_wrapped — pre-eval trades wrapped before eval (diagnostic)
|
||||
// b_size — context for downstream interpretation
|
||||
let aggregated_summary = serde_json::json!({
|
||||
"n_trades": eval_summary.n_trades,
|
||||
"total_pnl_usd": eval_summary.total_pnl_usd,
|
||||
"profit_factor": eval_summary.profit_factor,
|
||||
"sharpe_ann": eval_summary.sharpe_ann,
|
||||
"max_drawdown_usd": eval_summary.max_drawdown_usd,
|
||||
"win_rate": eval_summary.win_rate,
|
||||
"n_eval_trades_seen": n_eval_trades_seen,
|
||||
"n_eval_trades_dropped": n_eval_trades_dropped,
|
||||
"n_pre_eval_trades_wrapped": n_pre_eval_wrapped,
|
||||
"b_size": cli.n_backtests,
|
||||
});
|
||||
|
||||
let eval_summary_path = cli.out.join("eval_summary.json");
|
||||
let f = std::fs::File::create(&eval_summary_path)
|
||||
.with_context(|| format!("create {}", eval_summary_path.display()))?;
|
||||
serde_json::to_writer_pretty(f, &aggregated_summary)
|
||||
serde_json::to_writer_pretty(f, &eval_summary)
|
||||
.with_context(|| format!("write {}", eval_summary_path.display()))?;
|
||||
eprintln!("eval summary written: {}", eval_summary_path.display());
|
||||
}
|
||||
|
||||
530
crates/ml-alpha/src/rl/dueling_q.rs
Normal file
530
crates/ml-alpha/src/rl/dueling_q.rs
Normal file
@@ -0,0 +1,530 @@
|
||||
//! Phase 4 — Independent Dueling Q head (2026-05-30).
|
||||
//!
|
||||
//! Parallel head to C51/IQN/π/value_head with ZERO shared state with
|
||||
//! downstream consumers (ensemble, distill, action selection). Trains
|
||||
//! its own V + A weights via Bellman loss on composed_Q at taken action.
|
||||
//!
|
||||
//! V output feeds PPO advantage baseline (Phase 4.3) — composed_Q is
|
||||
//! internal to this head's loss path and never consumed elsewhere.
|
||||
//!
|
||||
//! Per spec docs/superpowers/specs/2026-05-30-phase4-independent-dueling-head-design.md.
|
||||
//!
|
||||
//! ## Why a separate head (vs Phase 3 modifications to IQN)
|
||||
//!
|
||||
//! All 5 prior dueling attempts (Phase 2 v2, Phase 3.1, 3.2, 3.2c,
|
||||
//! 3.1-fix) failed because they modified existing architectures'
|
||||
//! weights or outputs in ways that perturbed downstream consumers. See
|
||||
//! the four session pearls captured in the spec's §10.
|
||||
//!
|
||||
//! Phase 4's design is **structurally encapsulated**: DuelingQHead has
|
||||
//! ITS OWN weights (w_v, b_v, w_a, b_a) and its OWN Bellman loss.
|
||||
//! Mean-zero A gradient pattern only affects DuelingQHead's training,
|
||||
//! never C51's or IQN's weights. composed_Q never feeds ensemble or
|
||||
//! distill or action selection.
|
||||
//!
|
||||
//! ## Forward
|
||||
//!
|
||||
//! ```text
|
||||
//! V[b] = Σ_c w_v[c] × h_t[b, c] + b_v[0]
|
||||
//! A[b, a] = Σ_c w_a[c, a] × h_t[b, c] + b_a[a]
|
||||
//! composed_Q[b, a] = V[b] + A[b, a] − (1/N) Σ_a' A[b, a']
|
||||
//! ```
|
||||
//!
|
||||
//! Single fused kernel (`rl_dueling_q_forward`) computes all three.
|
||||
//!
|
||||
//! ## Constraints honoured
|
||||
//!
|
||||
//! * `feedback_no_atomicadd` — sole-writer per output cell.
|
||||
//! * `feedback_cpu_is_read_only` — pure device kernels.
|
||||
//! * `feedback_no_htod_htoh_only_mapped_pinned` — weight uploads stage
|
||||
//! through `MappedF32Buffer`.
|
||||
//! * `feedback_no_nvrtc` — pre-compiled cubins via `build.rs`.
|
||||
//! * `pearl_scoped_init_seed_for_reproducibility` — `new()` installs
|
||||
//! `scoped_init_seed` before drawing Xavier samples.
|
||||
|
||||
use std::sync::Arc;
|
||||
|
||||
use anyhow::{Context, Result};
|
||||
use cudarc::driver::{CudaFunction, CudaModule, CudaSlice, CudaStream};
|
||||
use cudarc::driver::sys::CUstream;
|
||||
use ml_core::cuda_autograd::init::scoped_init_seed;
|
||||
use ml_core::device::MlDevice;
|
||||
use rand::{Rng, SeedableRng};
|
||||
use rand_chacha::ChaCha8Rng;
|
||||
|
||||
use crate::heads::HIDDEN_DIM;
|
||||
use crate::pinned_mem::MappedF32Buffer;
|
||||
use crate::rl::common::N_ACTIONS;
|
||||
use crate::trainer::raw_launch::{RawArgs, raw_launch};
|
||||
|
||||
const DUELING_Q_FORWARD_CUBIN: &[u8] = include_bytes!(concat!(
|
||||
env!("OUT_DIR"),
|
||||
"/rl_dueling_q_forward.cubin"
|
||||
));
|
||||
const DUELING_Q_BELLMAN_TARGET_CUBIN: &[u8] = include_bytes!(concat!(
|
||||
env!("OUT_DIR"),
|
||||
"/rl_dueling_q_bellman_target.cubin"
|
||||
));
|
||||
const DUELING_Q_LOSS_AND_GRAD_CUBIN: &[u8] = include_bytes!(concat!(
|
||||
env!("OUT_DIR"),
|
||||
"/rl_dueling_q_loss_and_grad.cubin"
|
||||
));
|
||||
const DUELING_Q_DECOMPOSE_AND_BWD_CUBIN: &[u8] = include_bytes!(concat!(
|
||||
env!("OUT_DIR"),
|
||||
"/rl_dueling_q_decompose_and_bwd.cubin"
|
||||
));
|
||||
/// Reuse the existing dqn_target_soft_update kernel — it's a generic
|
||||
/// element-wise `target = (1−τ) target + τ online` blend that works
|
||||
/// for any tensor size. τ read from ISV[RL_TARGET_TAU_INDEX=401].
|
||||
const DQN_TARGET_SOFT_UPDATE_CUBIN: &[u8] = include_bytes!(concat!(
|
||||
env!("OUT_DIR"),
|
||||
"/dqn_target_soft_update.cubin"
|
||||
));
|
||||
|
||||
/// Construction config for [`DuelingQHead`].
|
||||
#[derive(Clone, Debug)]
|
||||
pub struct DuelingQHeadConfig {
|
||||
/// Encoder hidden dimension `h_t [B, HIDDEN_DIM]` feeding the head.
|
||||
/// Must equal the kernel-side `HIDDEN_DIM` define (128).
|
||||
pub hidden_dim: usize,
|
||||
/// Random seed for Xavier init. Distinct from C51 / IQN / V_scalar
|
||||
/// seeds so initial draws are independent.
|
||||
pub seed: u64,
|
||||
}
|
||||
|
||||
impl Default for DuelingQHeadConfig {
|
||||
fn default() -> Self {
|
||||
Self {
|
||||
hidden_dim: HIDDEN_DIM,
|
||||
seed: 0x4DEAD_1234,
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// Independent dueling Q head — V + A decomposition with scalar
|
||||
/// Bellman loss. Trains in parallel to C51 / IQN; supplies V_dq for
|
||||
/// PPO advantage baseline use (Phase 4.3).
|
||||
pub struct DuelingQHead {
|
||||
#[allow(dead_code)]
|
||||
cfg: DuelingQHeadConfig,
|
||||
stream: Arc<CudaStream>,
|
||||
raw_stream: CUstream,
|
||||
|
||||
// ── Kernel handles ───────────────────────────────────────────────
|
||||
_fwd_module: Arc<CudaModule>,
|
||||
pub forward_fn: CudaFunction,
|
||||
_bellman_module: Arc<CudaModule>,
|
||||
pub bellman_target_fn: CudaFunction,
|
||||
_loss_module: Arc<CudaModule>,
|
||||
pub loss_and_grad_fn: CudaFunction,
|
||||
_bwd_module: Arc<CudaModule>,
|
||||
pub decompose_and_bwd_fn: CudaFunction,
|
||||
_soft_update_module: Arc<CudaModule>,
|
||||
pub soft_update_fn: CudaFunction,
|
||||
|
||||
// ── Online weights ───────────────────────────────────────────────
|
||||
/// V projection weights `[HIDDEN_DIM]`.
|
||||
pub w_v_d: CudaSlice<f32>,
|
||||
/// V projection bias `[1]`.
|
||||
pub b_v_d: CudaSlice<f32>,
|
||||
/// A projection weights `[HIDDEN_DIM × N_ACTIONS]`, row-major
|
||||
/// (kernel reads `w_a[c * N_ACTIONS + a]`).
|
||||
pub w_a_d: CudaSlice<f32>,
|
||||
/// A projection bias `[N_ACTIONS]`.
|
||||
pub b_a_d: CudaSlice<f32>,
|
||||
|
||||
// ── Target-network weights (soft-updated each step) ──────────────
|
||||
pub w_v_target_d: CudaSlice<f32>,
|
||||
pub b_v_target_d: CudaSlice<f32>,
|
||||
pub w_a_target_d: CudaSlice<f32>,
|
||||
pub b_a_target_d: CudaSlice<f32>,
|
||||
}
|
||||
|
||||
impl DuelingQHead {
|
||||
/// Allocate device weights, load cubins, cache kernel handles.
|
||||
pub fn new(dev: &MlDevice, cfg: DuelingQHeadConfig) -> Result<Self> {
|
||||
let stream: Arc<CudaStream> = dev
|
||||
.cuda_stream()
|
||||
.context("dueling_q_head stream")?
|
||||
.clone();
|
||||
let ctx = dev.cuda_context().context("dueling_q_head ctx")?;
|
||||
|
||||
// ── Load forward cubin ───────────────────────────────────────
|
||||
let fwd_module = ctx
|
||||
.load_cubin(DUELING_Q_FORWARD_CUBIN.to_vec())
|
||||
.context("load rl_dueling_q_forward cubin")?;
|
||||
let forward_fn = fwd_module
|
||||
.load_function("rl_dueling_q_forward")
|
||||
.context("load rl_dueling_q_forward")?;
|
||||
let bellman_module = ctx
|
||||
.load_cubin(DUELING_Q_BELLMAN_TARGET_CUBIN.to_vec())
|
||||
.context("load rl_dueling_q_bellman_target cubin")?;
|
||||
let bellman_target_fn = bellman_module
|
||||
.load_function("rl_dueling_q_bellman_target_build")
|
||||
.context("load rl_dueling_q_bellman_target_build")?;
|
||||
let loss_module = ctx
|
||||
.load_cubin(DUELING_Q_LOSS_AND_GRAD_CUBIN.to_vec())
|
||||
.context("load rl_dueling_q_loss_and_grad cubin")?;
|
||||
let loss_and_grad_fn = loss_module
|
||||
.load_function("rl_dueling_q_loss_and_grad")
|
||||
.context("load rl_dueling_q_loss_and_grad")?;
|
||||
let bwd_module = ctx
|
||||
.load_cubin(DUELING_Q_DECOMPOSE_AND_BWD_CUBIN.to_vec())
|
||||
.context("load rl_dueling_q_decompose_and_bwd cubin")?;
|
||||
let decompose_and_bwd_fn = bwd_module
|
||||
.load_function("rl_dueling_q_decompose_and_weight_grad")
|
||||
.context("load rl_dueling_q_decompose_and_weight_grad")?;
|
||||
// Reuse dqn_target_soft_update kernel — generic element-wise blend.
|
||||
let soft_update_module = ctx
|
||||
.load_cubin(DQN_TARGET_SOFT_UPDATE_CUBIN.to_vec())
|
||||
.context("load dqn_target_soft_update cubin for dueling")?;
|
||||
let soft_update_fn = soft_update_module
|
||||
.load_function("dqn_target_soft_update")
|
||||
.context("load dqn_target_soft_update for dueling")?;
|
||||
|
||||
// ── Weight init (Xavier uniform, scaled by 0.01 like sibling heads) ──
|
||||
// Per pearl_scoped_init_seed_for_reproducibility.
|
||||
let _seed_guard = scoped_init_seed(cfg.seed);
|
||||
let mut rng = ChaCha8Rng::seed_from_u64(cfg.seed);
|
||||
|
||||
// V projection: HIDDEN_DIM → 1
|
||||
let v_scale = 0.01_f32 * (6.0_f32 / (cfg.hidden_dim + 1) as f32).sqrt();
|
||||
let w_v_host: Vec<f32> = (0..cfg.hidden_dim)
|
||||
.map(|_| rng.gen_range(-v_scale..v_scale))
|
||||
.collect();
|
||||
let b_v_host: Vec<f32> = vec![0.0_f32; 1];
|
||||
|
||||
// A projection: HIDDEN_DIM → N_ACTIONS
|
||||
let a_scale =
|
||||
0.01_f32 * (6.0_f32 / (cfg.hidden_dim + N_ACTIONS) as f32).sqrt();
|
||||
let w_a_host: Vec<f32> = (0..cfg.hidden_dim * N_ACTIONS)
|
||||
.map(|_| rng.gen_range(-a_scale..a_scale))
|
||||
.collect();
|
||||
let b_a_host: Vec<f32> = vec![0.0_f32; N_ACTIONS];
|
||||
|
||||
// Upload online weights.
|
||||
let w_v_d = upload(&stream, &w_v_host)?;
|
||||
let b_v_d = upload(&stream, &b_v_host)?;
|
||||
let w_a_d = upload(&stream, &w_a_host)?;
|
||||
let b_a_d = upload(&stream, &b_a_host)?;
|
||||
|
||||
// Upload target weights (identical init — soft-updated by trainer).
|
||||
let w_v_target_d = upload(&stream, &w_v_host)?;
|
||||
let b_v_target_d = upload(&stream, &b_v_host)?;
|
||||
let w_a_target_d = upload(&stream, &w_a_host)?;
|
||||
let b_a_target_d = upload(&stream, &b_a_host)?;
|
||||
|
||||
let raw_stream = stream.cu_stream();
|
||||
|
||||
Ok(Self {
|
||||
cfg,
|
||||
stream,
|
||||
raw_stream,
|
||||
_fwd_module: fwd_module,
|
||||
forward_fn,
|
||||
_bellman_module: bellman_module,
|
||||
bellman_target_fn,
|
||||
_loss_module: loss_module,
|
||||
loss_and_grad_fn,
|
||||
_bwd_module: bwd_module,
|
||||
decompose_and_bwd_fn,
|
||||
_soft_update_module: soft_update_module,
|
||||
soft_update_fn,
|
||||
w_v_d,
|
||||
b_v_d,
|
||||
w_a_d,
|
||||
b_a_d,
|
||||
w_v_target_d,
|
||||
b_v_target_d,
|
||||
w_a_target_d,
|
||||
b_a_target_d,
|
||||
})
|
||||
}
|
||||
|
||||
/// Stream used to launch all kernels owned by this head.
|
||||
pub fn stream(&self) -> &Arc<CudaStream> {
|
||||
&self.stream
|
||||
}
|
||||
|
||||
/// cuBLAS-free forward pass with online weights.
|
||||
///
|
||||
/// Computes V[B], A[B × N_ACTIONS], and composed_Q[B × N_ACTIONS]
|
||||
/// in a single fused kernel.
|
||||
///
|
||||
/// Block layout: grid=(B, 1, 1), block=(HIDDEN_DIM, 1, 1),
|
||||
/// shared_mem = HIDDEN_DIM × 4 bytes (for s_h cache).
|
||||
pub fn forward(
|
||||
&self,
|
||||
h_t: &CudaSlice<f32>,
|
||||
b_size: usize,
|
||||
v_out: &mut CudaSlice<f32>,
|
||||
a_out: &mut CudaSlice<f32>,
|
||||
q_composed_out: &mut CudaSlice<f32>,
|
||||
) -> Result<()> {
|
||||
self.forward_inner(
|
||||
h_t,
|
||||
&self.w_v_d, &self.b_v_d,
|
||||
&self.w_a_d, &self.b_a_d,
|
||||
b_size, v_out, a_out, q_composed_out,
|
||||
)
|
||||
}
|
||||
|
||||
/// cuBLAS-free forward pass with target-network weights.
|
||||
pub fn forward_target(
|
||||
&self,
|
||||
h_t: &CudaSlice<f32>,
|
||||
b_size: usize,
|
||||
v_target_out: &mut CudaSlice<f32>,
|
||||
a_target_out: &mut CudaSlice<f32>,
|
||||
q_target_composed_out: &mut CudaSlice<f32>,
|
||||
) -> Result<()> {
|
||||
self.forward_inner(
|
||||
h_t,
|
||||
&self.w_v_target_d, &self.b_v_target_d,
|
||||
&self.w_a_target_d, &self.b_a_target_d,
|
||||
b_size, v_target_out, a_target_out, q_target_composed_out,
|
||||
)
|
||||
}
|
||||
|
||||
/// Internal forward — parameterised over weight slices so online /
|
||||
/// target paths share code.
|
||||
#[allow(clippy::too_many_arguments)]
|
||||
fn forward_inner(
|
||||
&self,
|
||||
h_t: &CudaSlice<f32>,
|
||||
w_v: &CudaSlice<f32>,
|
||||
b_v: &CudaSlice<f32>,
|
||||
w_a: &CudaSlice<f32>,
|
||||
b_a: &CudaSlice<f32>,
|
||||
b_size: usize,
|
||||
v_out: &mut CudaSlice<f32>,
|
||||
a_out: &mut CudaSlice<f32>,
|
||||
q_composed_out: &mut CudaSlice<f32>,
|
||||
) -> Result<()> {
|
||||
let hd = self.cfg.hidden_dim;
|
||||
debug_assert_eq!(h_t.len(), b_size * hd);
|
||||
debug_assert_eq!(w_v.len(), hd);
|
||||
debug_assert_eq!(b_v.len(), 1);
|
||||
debug_assert_eq!(w_a.len(), hd * N_ACTIONS);
|
||||
debug_assert_eq!(b_a.len(), N_ACTIONS);
|
||||
debug_assert_eq!(v_out.len(), b_size);
|
||||
debug_assert_eq!(a_out.len(), b_size * N_ACTIONS);
|
||||
debug_assert_eq!(q_composed_out.len(), b_size * N_ACTIONS);
|
||||
|
||||
let b_i = b_size as i32;
|
||||
let smem = (hd * std::mem::size_of::<f32>()) as u32;
|
||||
let mut args = RawArgs::new();
|
||||
args.push_ptr(h_t.raw_ptr());
|
||||
args.push_ptr(w_v.raw_ptr());
|
||||
args.push_ptr(b_v.raw_ptr());
|
||||
args.push_ptr(w_a.raw_ptr());
|
||||
args.push_ptr(b_a.raw_ptr());
|
||||
args.push_i32(b_i);
|
||||
args.push_ptr(v_out.raw_ptr());
|
||||
args.push_ptr(a_out.raw_ptr());
|
||||
args.push_ptr(q_composed_out.raw_ptr());
|
||||
let mut ptrs = args.build_arg_ptrs();
|
||||
unsafe {
|
||||
raw_launch(
|
||||
self.forward_fn.cu_function(),
|
||||
(b_size as u32, 1, 1),
|
||||
(hd as u32, 1, 1),
|
||||
smem,
|
||||
self.raw_stream,
|
||||
&mut ptrs[..args.len()],
|
||||
).map_err(|e| anyhow::anyhow!("rl_dueling_q_forward: {:?}", e))?;
|
||||
}
|
||||
Ok(())
|
||||
}
|
||||
|
||||
/// Build the scalar Bellman target from the target network's
|
||||
/// composed_Q at s_{t+1}:
|
||||
/// a*[b] = argmax_a' target_composed_Q[b, a']
|
||||
/// target_value[b] = r[b] + γ^n × (1 − done[b]) × target_composed_Q[b, a*[b]]
|
||||
///
|
||||
/// Per-sample γ^n is passed (matches PER n-step convention used by
|
||||
/// C51 / IQN target builds).
|
||||
pub fn build_bellman_target(
|
||||
&self,
|
||||
target_composed_q: &CudaSlice<f32>,
|
||||
rewards: &CudaSlice<f32>,
|
||||
dones: &CudaSlice<f32>,
|
||||
n_step_gammas: &CudaSlice<f32>,
|
||||
b_size: usize,
|
||||
target_value_out: &mut CudaSlice<f32>,
|
||||
) -> Result<()> {
|
||||
debug_assert_eq!(target_composed_q.len(), b_size * N_ACTIONS);
|
||||
debug_assert_eq!(rewards.len(), b_size);
|
||||
debug_assert_eq!(dones.len(), b_size);
|
||||
debug_assert_eq!(n_step_gammas.len(), b_size);
|
||||
debug_assert_eq!(target_value_out.len(), b_size);
|
||||
|
||||
let b_i = b_size as i32;
|
||||
let mut args = RawArgs::new();
|
||||
args.push_ptr(target_composed_q.raw_ptr());
|
||||
args.push_ptr(rewards.raw_ptr());
|
||||
args.push_ptr(dones.raw_ptr());
|
||||
args.push_ptr(n_step_gammas.raw_ptr());
|
||||
args.push_i32(b_i);
|
||||
args.push_ptr(target_value_out.raw_ptr());
|
||||
let mut ptrs = args.build_arg_ptrs();
|
||||
unsafe {
|
||||
raw_launch(
|
||||
self.bellman_target_fn.cu_function(),
|
||||
(b_size as u32, 1, 1),
|
||||
(N_ACTIONS as u32, 1, 1),
|
||||
0,
|
||||
self.raw_stream,
|
||||
&mut ptrs[..args.len()],
|
||||
).map_err(|e| anyhow::anyhow!("rl_dueling_q_bellman_target_build: {:?}", e))?;
|
||||
}
|
||||
Ok(())
|
||||
}
|
||||
|
||||
/// Scalar Huber loss on (target − online_composed_Q[taken]).
|
||||
/// Emits per-batch loss and grad_composed (only taken action has
|
||||
/// nonzero gradient — single-Q regression).
|
||||
pub fn compute_loss_and_grad(
|
||||
&self,
|
||||
online_composed_q: &CudaSlice<f32>,
|
||||
target_value: &CudaSlice<f32>,
|
||||
actions_taken: &CudaSlice<i32>,
|
||||
b_size: usize,
|
||||
loss_per_batch: &mut CudaSlice<f32>,
|
||||
grad_composed_out: &mut CudaSlice<f32>,
|
||||
) -> Result<()> {
|
||||
debug_assert_eq!(online_composed_q.len(), b_size * N_ACTIONS);
|
||||
debug_assert_eq!(target_value.len(), b_size);
|
||||
debug_assert_eq!(actions_taken.len(), b_size);
|
||||
debug_assert_eq!(loss_per_batch.len(), b_size);
|
||||
debug_assert_eq!(grad_composed_out.len(), b_size * N_ACTIONS);
|
||||
|
||||
let b_i = b_size as i32;
|
||||
let mut args = RawArgs::new();
|
||||
args.push_ptr(online_composed_q.raw_ptr());
|
||||
args.push_ptr(target_value.raw_ptr());
|
||||
args.push_ptr(actions_taken.raw_ptr());
|
||||
args.push_i32(b_i);
|
||||
args.push_ptr(loss_per_batch.raw_ptr());
|
||||
args.push_ptr(grad_composed_out.raw_ptr());
|
||||
let mut ptrs = args.build_arg_ptrs();
|
||||
unsafe {
|
||||
raw_launch(
|
||||
self.loss_and_grad_fn.cu_function(),
|
||||
(b_size as u32, 1, 1),
|
||||
(N_ACTIONS as u32, 1, 1),
|
||||
0,
|
||||
self.raw_stream,
|
||||
&mut ptrs[..args.len()],
|
||||
).map_err(|e| anyhow::anyhow!("rl_dueling_q_loss_and_grad: {:?}", e))?;
|
||||
}
|
||||
Ok(())
|
||||
}
|
||||
|
||||
/// Decompose grad_composed → grad_V + grad_A via mean-subtraction
|
||||
/// Jacobian, then produce per-batch weight gradients via outer
|
||||
/// product with h_t. Caller reduces axis 0 to final weight grads.
|
||||
#[allow(clippy::too_many_arguments)]
|
||||
pub fn decompose_and_backward_to_weights(
|
||||
&self,
|
||||
h_t: &CudaSlice<f32>,
|
||||
grad_composed: &CudaSlice<f32>,
|
||||
actions_taken: &CudaSlice<i32>,
|
||||
b_size: usize,
|
||||
grad_w_v_per_batch: &mut CudaSlice<f32>,
|
||||
grad_b_v_per_batch: &mut CudaSlice<f32>,
|
||||
grad_w_a_per_batch: &mut CudaSlice<f32>,
|
||||
grad_b_a_per_batch: &mut CudaSlice<f32>,
|
||||
) -> Result<()> {
|
||||
let hd = self.cfg.hidden_dim;
|
||||
debug_assert_eq!(h_t.len(), b_size * hd);
|
||||
debug_assert_eq!(grad_composed.len(), b_size * N_ACTIONS);
|
||||
debug_assert_eq!(actions_taken.len(), b_size);
|
||||
debug_assert_eq!(grad_w_v_per_batch.len(), b_size * hd);
|
||||
debug_assert_eq!(grad_b_v_per_batch.len(), b_size);
|
||||
debug_assert_eq!(grad_w_a_per_batch.len(), b_size * hd * N_ACTIONS);
|
||||
debug_assert_eq!(grad_b_a_per_batch.len(), b_size * N_ACTIONS);
|
||||
|
||||
let b_i = b_size as i32;
|
||||
let mut args = RawArgs::new();
|
||||
args.push_ptr(h_t.raw_ptr());
|
||||
args.push_ptr(grad_composed.raw_ptr());
|
||||
args.push_ptr(actions_taken.raw_ptr());
|
||||
args.push_i32(b_i);
|
||||
args.push_ptr(grad_w_v_per_batch.raw_ptr());
|
||||
args.push_ptr(grad_b_v_per_batch.raw_ptr());
|
||||
args.push_ptr(grad_w_a_per_batch.raw_ptr());
|
||||
args.push_ptr(grad_b_a_per_batch.raw_ptr());
|
||||
let mut ptrs = args.build_arg_ptrs();
|
||||
unsafe {
|
||||
raw_launch(
|
||||
self.decompose_and_bwd_fn.cu_function(),
|
||||
(b_size as u32, 1, 1),
|
||||
(hd as u32, 1, 1),
|
||||
0,
|
||||
self.raw_stream,
|
||||
&mut ptrs[..args.len()],
|
||||
).map_err(|e| anyhow::anyhow!("rl_dueling_q_decompose_and_bwd: {:?}", e))?;
|
||||
}
|
||||
Ok(())
|
||||
}
|
||||
|
||||
/// Soft-update target network: `target = (1−τ) × target + τ × online`
|
||||
/// element-wise on all four weight tensors. τ read from
|
||||
/// `ISV[RL_TARGET_TAU_INDEX=401]` — same controller as Q's target.
|
||||
/// Should be called once per training step AFTER the Adam steps so
|
||||
/// the update reflects the latest online weights.
|
||||
pub fn soft_update_target(&mut self, isv_dev_ptr: &u64) -> Result<()> {
|
||||
let launch_blend = |n: usize, online: u64, target: u64, label: &str| -> Result<()> {
|
||||
let n_i = n as i32;
|
||||
let mut args = RawArgs::new();
|
||||
args.push_ptr(online);
|
||||
args.push_ptr(target);
|
||||
args.push_ptr(*isv_dev_ptr);
|
||||
args.push_i32(n_i);
|
||||
let mut ptrs = args.build_arg_ptrs();
|
||||
unsafe {
|
||||
raw_launch(
|
||||
self.soft_update_fn.cu_function(),
|
||||
(((n as u32) + 255) / 256, 1, 1),
|
||||
(256, 1, 1),
|
||||
0,
|
||||
self.raw_stream,
|
||||
&mut ptrs[..args.len()],
|
||||
).map_err(|e| anyhow::anyhow!("dueling soft_update_target ({label}): {e:?}"))?;
|
||||
}
|
||||
Ok(())
|
||||
};
|
||||
launch_blend(self.w_v_d.len(), self.w_v_d.raw_ptr(), self.w_v_target_d.raw_ptr(), "w_v")?;
|
||||
launch_blend(self.b_v_d.len(), self.b_v_d.raw_ptr(), self.b_v_target_d.raw_ptr(), "b_v")?;
|
||||
launch_blend(self.w_a_d.len(), self.w_a_d.raw_ptr(), self.w_a_target_d.raw_ptr(), "w_a")?;
|
||||
launch_blend(self.b_a_d.len(), self.b_a_d.raw_ptr(), self.b_a_target_d.raw_ptr(), "b_a")?;
|
||||
Ok(())
|
||||
}
|
||||
}
|
||||
|
||||
// ── pinned-staging upload helper (mirrors aux_heads.rs::upload) ──
|
||||
|
||||
fn upload(stream: &Arc<CudaStream>, host: &[f32]) -> Result<CudaSlice<f32>> {
|
||||
let n = host.len();
|
||||
let staging = unsafe { MappedF32Buffer::new(n) }
|
||||
.map_err(|e| anyhow::anyhow!("dueling_q upload staging: {e}"))?;
|
||||
staging.write_from_slice(host);
|
||||
let dst = stream
|
||||
.alloc_zeros::<f32>(n)
|
||||
.context("dueling_q upload alloc")?;
|
||||
if n > 0 {
|
||||
let nbytes = n * std::mem::size_of::<f32>();
|
||||
unsafe {
|
||||
let dst_ptr = dst.raw_ptr();
|
||||
crate::trainer::raw_launch::raw_memcpy_dtod_async(
|
||||
dst_ptr,
|
||||
staging.dev_ptr,
|
||||
nbytes,
|
||||
stream.cu_stream(),
|
||||
)
|
||||
.map_err(|e| anyhow::anyhow!("dueling_q upload DtoD: {:?}", e))?;
|
||||
}
|
||||
}
|
||||
Ok(dst)
|
||||
}
|
||||
@@ -1111,5 +1111,25 @@ pub const RL_ACTION_ENTROPY_EMA_INDEX: usize = 583;
|
||||
/// Bootstrap: 0.85 (15% exploration floor).
|
||||
pub const RL_CONF_GATE_MAX_HOLD_FRAC_INDEX: usize = 584;
|
||||
|
||||
/// Phase 4.4 (2026-05-30) — adaptive V baseline blend coefficient.
|
||||
///
|
||||
/// `V_used[b] = α × V_scalar[b] + (1 − α) × V_dq[b]`
|
||||
///
|
||||
/// α ∈ [0, 1] adapts via Schulman-bounded step from the observed
|
||||
/// V_dq vs V_scalar tracking ratio (see `rl_v_blend_alpha_controller`).
|
||||
/// α = 1.0: pure Plan A v2 (V_scalar drives PPO advantage).
|
||||
/// α = 0.0: pure Phase 4.3 (V_dq drives PPO advantage).
|
||||
/// Bootstrap on sentinel 0 → α = 1.0 (Plan A v2 behavior on first step).
|
||||
pub const RL_V_BLEND_ALPHA_INDEX: usize = 585;
|
||||
|
||||
/// Phase 4.4 — EMA of `|V_dq − V_scalar|` (numerator of tracking ratio).
|
||||
/// Updated on-device by `rl_v_blend_alpha_controller` with EMA α = 0.01.
|
||||
/// First-observation bootstrap on sentinel 0.
|
||||
pub const RL_V_TRACK_ERR_EMA_INDEX: usize = 586;
|
||||
|
||||
/// Phase 4.4 — EMA of `|V_scalar|` (denominator of tracking ratio + dead-signal floor).
|
||||
/// If `EMA < 1e-4` the controller holds α (no V signal to calibrate against).
|
||||
pub const RL_V_SCALAR_MAG_EMA_INDEX: usize = 587;
|
||||
|
||||
/// Last RL-allocated slot index (exclusive).
|
||||
pub const RL_SLOTS_END: usize = 585;
|
||||
pub const RL_SLOTS_END: usize = 588;
|
||||
|
||||
@@ -17,6 +17,7 @@
|
||||
|
||||
pub mod common;
|
||||
pub mod dqn;
|
||||
pub mod dueling_q;
|
||||
pub mod gpu_hindsight;
|
||||
pub mod gpu_replay;
|
||||
pub mod frd;
|
||||
|
||||
@@ -190,6 +190,14 @@ const RL_ATOM_SUPPORT_UPDATE_CUBIN: &[u8] =
|
||||
// Feeds Q→π agreement diag and future ensemble-level selection.
|
||||
const RL_ENSEMBLE_ACTION_VALUE_CUBIN: &[u8] =
|
||||
include_bytes!(concat!(env!("OUT_DIR"), "/rl_ensemble_action_value.cubin"));
|
||||
/// Phase 4.4 adaptive V blend kernels.
|
||||
const RL_V_BLEND_CUBIN: &[u8] =
|
||||
include_bytes!(concat!(env!("OUT_DIR"), "/rl_v_blend.cubin"));
|
||||
const RL_V_BLEND_ALPHA_CONTROLLER_CUBIN: &[u8] =
|
||||
include_bytes!(concat!(env!("OUT_DIR"), "/rl_v_blend_alpha_controller.cubin"));
|
||||
/// Phase 4.5 per-batch advantage normalization.
|
||||
const RL_ADVANTAGE_NORMALIZE_CUBIN: &[u8] =
|
||||
include_bytes!(concat!(env!("OUT_DIR"), "/rl_advantage_normalize.cubin"));
|
||||
const RL_Q_PI_DISTILL_GRAD_CUBIN: &[u8] =
|
||||
include_bytes!(concat!(env!("OUT_DIR"), "/rl_q_pi_distill_grad.cubin"));
|
||||
// λ_distill adaptive controller (rljzl followup 2026-05-24).
|
||||
@@ -435,6 +443,14 @@ pub struct IntegratedTrainer {
|
||||
/// PPO value head (Phase D).
|
||||
pub value_head: ValueHead,
|
||||
|
||||
/// Phase 4 (2026-05-30) Independent Dueling Q head — runs in parallel
|
||||
/// to C51/IQN with ZERO shared state with downstream consumers
|
||||
/// (ensemble, distill, action selection). Trains its own V + A
|
||||
/// weights via scalar Huber Bellman loss. V_dq output feeds PPO
|
||||
/// advantage baseline in Phase 4.3 (currently diagnostic-only).
|
||||
/// Per spec docs/superpowers/specs/2026-05-30-phase4-independent-dueling-head-design.md.
|
||||
pub dueling_q_head: crate::rl::dueling_q::DuelingQHead,
|
||||
|
||||
/// Per-head Adam optimisers — Phase E.2. One pair (w + b) per head.
|
||||
/// All share the existing project-wide `adamw_step` cubin via the
|
||||
/// `AdamW` wrapper; each instance owns its own m / v / step counter.
|
||||
@@ -449,6 +465,11 @@ pub struct IntegratedTrainer {
|
||||
pub policy_b_adam: AdamW,
|
||||
pub value_w_adam: AdamW,
|
||||
pub value_b_adam: AdamW,
|
||||
/// Phase 4 dueling-head Adam optimisers (4 weight tensors).
|
||||
pub dueling_q_w_v_adam: AdamW,
|
||||
pub dueling_q_b_v_adam: AdamW,
|
||||
pub dueling_q_w_a_adam: AdamW,
|
||||
pub dueling_q_b_a_adam: AdamW,
|
||||
/// SP20 P3 FRD head Adam optimisers (W1/b1/W2/b2). LR from
|
||||
/// `RL_FRD_LR_INDEX` (slot 499), default 1e-3.
|
||||
pub frd_w1_adam: AdamW,
|
||||
@@ -609,6 +630,16 @@ pub struct IntegratedTrainer {
|
||||
// C51+IQN ensemble action-value (audit 2026-05-25).
|
||||
_rl_ensemble_action_value_module: Arc<CudaModule>,
|
||||
rl_ensemble_action_value_fn: CudaFunction,
|
||||
// Phase 4.4 adaptive V blend.
|
||||
_rl_v_blend_module: Arc<CudaModule>,
|
||||
rl_v_blend_fn: CudaFunction,
|
||||
_rl_v_blend_alpha_controller_module: Arc<CudaModule>,
|
||||
rl_v_blend_alpha_controller_fn: CudaFunction,
|
||||
_rl_advantage_normalize_module: Arc<CudaModule>,
|
||||
rl_advantage_normalize_fn: CudaFunction,
|
||||
/// Phase 4.4 blended V baselines fed to compute_advantage_return.
|
||||
pub v_blended_d: CudaSlice<f32>,
|
||||
pub v_blended_tp1_d: CudaSlice<f32>,
|
||||
// λ_distill adaptive controller (rljzl followup 2026-05-24).
|
||||
// Retained for bootstrap / testing; per-step launch fused into
|
||||
// rl_fused_controllers.
|
||||
@@ -813,6 +844,44 @@ pub struct IntegratedTrainer {
|
||||
/// agreement diagnostic (`rl_q_pi_agree_b`).
|
||||
pub ensemble_q_d: CudaSlice<f32>,
|
||||
|
||||
// ── Phase 4 DuelingQHead per-step buffers ────────────────────────
|
||||
/// Online V(s) from DuelingQHead — used as PPO advantage baseline
|
||||
/// in Phase 4.3 (currently diagnostic-only).
|
||||
pub dueling_v_d: CudaSlice<f32>,
|
||||
/// Online V(s_{t+1}) for PPO advantage's next-state baseline.
|
||||
pub dueling_v_tp1_d: CudaSlice<f32>,
|
||||
/// A logits at h_t_borrow (diag only).
|
||||
pub dueling_a_d: CudaSlice<f32>,
|
||||
/// Composed Q at sampled_h_t — loss input. [B × N_ACTIONS]
|
||||
pub dueling_q_composed_d: CudaSlice<f32>,
|
||||
/// Composed Q from target net at sampled_h_tp1 — Bellman build.
|
||||
pub dueling_q_target_composed_d: CudaSlice<f32>,
|
||||
/// V at sampled_h_tp1 from target net (compute-only scratch).
|
||||
pub dueling_v_target_tp1_d: CudaSlice<f32>,
|
||||
/// A at sampled_h_tp1 from target net (compute-only scratch).
|
||||
pub dueling_a_target_tp1_d: CudaSlice<f32>,
|
||||
/// V at sampled_h_t from online net (loss-path scratch — not the
|
||||
/// same as dueling_v_d which is at h_t_borrow for PPO use).
|
||||
pub dueling_v_loss_d: CudaSlice<f32>,
|
||||
pub dueling_a_loss_d: CudaSlice<f32>,
|
||||
/// Bellman target scalar value per batch.
|
||||
pub dueling_target_value_d: CudaSlice<f32>,
|
||||
/// Per-batch Huber loss.
|
||||
pub dueling_loss_pb_d: CudaSlice<f32>,
|
||||
/// Gradient w.r.t. composed Q output [B × N_ACTIONS] (only taken
|
||||
/// action cell nonzero — scalar single-Q regression).
|
||||
pub dueling_grad_composed_d: CudaSlice<f32>,
|
||||
/// Per-batch weight gradient scratch.
|
||||
pub dueling_grad_w_v_pb_d: CudaSlice<f32>, // [B × HIDDEN_DIM]
|
||||
pub dueling_grad_b_v_pb_d: CudaSlice<f32>, // [B]
|
||||
pub dueling_grad_w_a_pb_d: CudaSlice<f32>, // [B × HIDDEN_DIM × N_ACTIONS]
|
||||
pub dueling_grad_b_a_pb_d: CudaSlice<f32>, // [B × N_ACTIONS]
|
||||
/// Reduced (axis-0) weight gradients fed to Adam.
|
||||
pub dueling_grad_w_v_d: CudaSlice<f32>, // [HIDDEN_DIM]
|
||||
pub dueling_grad_b_v_d: CudaSlice<f32>, // [1]
|
||||
pub dueling_grad_w_a_d: CudaSlice<f32>, // [HIDDEN_DIM × N_ACTIONS]
|
||||
pub dueling_grad_b_a_d: CudaSlice<f32>, // [N_ACTIONS]
|
||||
|
||||
// ── Persistent per-step head output buffers (CUDA Graph stable) ──
|
||||
// Pre-allocated at init so device pointers are stable across steps,
|
||||
// enabling CUDA Graph capture of the RL step pipeline.
|
||||
@@ -1198,6 +1267,19 @@ impl IntegratedTrainer {
|
||||
)
|
||||
.context("ValueHead::new")?;
|
||||
|
||||
// Phase 4 DuelingQHead — fully encapsulated dueling Q learner.
|
||||
// Seed derived from ppo_seed for reproducibility; distinct
|
||||
// offset (0xDE17 = "DELI" leet) so init draws are independent
|
||||
// of other heads' init streams.
|
||||
let dueling_q_head = crate::rl::dueling_q::DuelingQHead::new(
|
||||
dev,
|
||||
crate::rl::dueling_q::DuelingQHeadConfig {
|
||||
hidden_dim,
|
||||
seed: cfg.ppo_seed.wrapping_add(0xDE17),
|
||||
},
|
||||
)
|
||||
.context("DuelingQHead::new")?;
|
||||
|
||||
// Per-head Adam optimisers — one per (head, w | b) pair. Each owns
|
||||
// independent m/v buffers and a device-resident step counter (per
|
||||
// pearl_no_host_branches_in_captured_graph: counter advancement
|
||||
@@ -1230,6 +1312,15 @@ impl IntegratedTrainer {
|
||||
AdamW::new(dev, value_head.w_d.len(), lr_placeholder).context("value_w_adam")?;
|
||||
let value_b_adam =
|
||||
AdamW::new(dev, value_head.b_d.len(), lr_placeholder).context("value_b_adam")?;
|
||||
// Phase 4 dueling-head Adam states.
|
||||
let dueling_q_w_v_adam =
|
||||
AdamW::new(dev, dueling_q_head.w_v_d.len(), lr_placeholder).context("dueling_q_w_v_adam")?;
|
||||
let dueling_q_b_v_adam =
|
||||
AdamW::new(dev, dueling_q_head.b_v_d.len(), lr_placeholder).context("dueling_q_b_v_adam")?;
|
||||
let dueling_q_w_a_adam =
|
||||
AdamW::new(dev, dueling_q_head.w_a_d.len(), lr_placeholder).context("dueling_q_w_a_adam")?;
|
||||
let dueling_q_b_a_adam =
|
||||
AdamW::new(dev, dueling_q_head.b_a_d.len(), lr_placeholder).context("dueling_q_b_a_adam")?;
|
||||
|
||||
// ISV buffer — mapped-pinned, zero-init by MappedRecordBuffer::new.
|
||||
// GPU writes via dev_ptr, host reads via host_ptr. Sentinel-
|
||||
@@ -1393,6 +1484,26 @@ impl IntegratedTrainer {
|
||||
let rl_ensemble_action_value_fn = rl_ensemble_action_value_module
|
||||
.load_function("rl_ensemble_action_value")
|
||||
.context("load rl_ensemble_action_value")?;
|
||||
// Phase 4.4 adaptive V blend kernels.
|
||||
let rl_v_blend_module = ctx
|
||||
.load_cubin(RL_V_BLEND_CUBIN.to_vec())
|
||||
.context("load rl_v_blend cubin")?;
|
||||
let rl_v_blend_fn = rl_v_blend_module
|
||||
.load_function("rl_v_blend")
|
||||
.context("load rl_v_blend")?;
|
||||
let rl_v_blend_alpha_controller_module = ctx
|
||||
.load_cubin(RL_V_BLEND_ALPHA_CONTROLLER_CUBIN.to_vec())
|
||||
.context("load rl_v_blend_alpha_controller cubin")?;
|
||||
let rl_v_blend_alpha_controller_fn = rl_v_blend_alpha_controller_module
|
||||
.load_function("rl_v_blend_alpha_controller")
|
||||
.context("load rl_v_blend_alpha_controller")?;
|
||||
// Phase 4.5 advantage normalization.
|
||||
let rl_advantage_normalize_module = ctx
|
||||
.load_cubin(RL_ADVANTAGE_NORMALIZE_CUBIN.to_vec())
|
||||
.context("load rl_advantage_normalize cubin")?;
|
||||
let rl_advantage_normalize_fn = rl_advantage_normalize_module
|
||||
.load_function("rl_advantage_normalize")
|
||||
.context("load rl_advantage_normalize")?;
|
||||
let rl_q_distill_lambda_controller_module = ctx
|
||||
.load_cubin(RL_Q_DISTILL_LAMBDA_CONTROLLER_CUBIN.to_vec())
|
||||
.context("load rl_q_distill_lambda_controller cubin")?;
|
||||
@@ -1856,6 +1967,75 @@ impl IntegratedTrainer {
|
||||
.alloc_zeros::<f32>(b_size * N_ACTIONS)
|
||||
.context("alloc ensemble_q_d")?;
|
||||
|
||||
// ── Phase 4 DuelingQHead per-step buffers ────────────────────
|
||||
let dueling_v_d = stream
|
||||
.alloc_zeros::<f32>(b_size)
|
||||
.context("alloc dueling_v_d")?;
|
||||
let dueling_v_tp1_d = stream
|
||||
.alloc_zeros::<f32>(b_size)
|
||||
.context("alloc dueling_v_tp1_d")?;
|
||||
// Phase 4.4 adaptive V blend output buffers.
|
||||
let v_blended_d = stream
|
||||
.alloc_zeros::<f32>(b_size)
|
||||
.context("alloc v_blended_d")?;
|
||||
let v_blended_tp1_d = stream
|
||||
.alloc_zeros::<f32>(b_size)
|
||||
.context("alloc v_blended_tp1_d")?;
|
||||
let dueling_a_d = stream
|
||||
.alloc_zeros::<f32>(b_size * N_ACTIONS)
|
||||
.context("alloc dueling_a_d")?;
|
||||
let dueling_q_composed_d = stream
|
||||
.alloc_zeros::<f32>(b_size * N_ACTIONS)
|
||||
.context("alloc dueling_q_composed_d")?;
|
||||
let dueling_q_target_composed_d = stream
|
||||
.alloc_zeros::<f32>(b_size * N_ACTIONS)
|
||||
.context("alloc dueling_q_target_composed_d")?;
|
||||
let dueling_v_target_tp1_d = stream
|
||||
.alloc_zeros::<f32>(b_size)
|
||||
.context("alloc dueling_v_target_tp1_d")?;
|
||||
let dueling_a_target_tp1_d = stream
|
||||
.alloc_zeros::<f32>(b_size * N_ACTIONS)
|
||||
.context("alloc dueling_a_target_tp1_d")?;
|
||||
let dueling_v_loss_d = stream
|
||||
.alloc_zeros::<f32>(b_size)
|
||||
.context("alloc dueling_v_loss_d")?;
|
||||
let dueling_a_loss_d = stream
|
||||
.alloc_zeros::<f32>(b_size * N_ACTIONS)
|
||||
.context("alloc dueling_a_loss_d")?;
|
||||
let dueling_target_value_d = stream
|
||||
.alloc_zeros::<f32>(b_size)
|
||||
.context("alloc dueling_target_value_d")?;
|
||||
let dueling_loss_pb_d = stream
|
||||
.alloc_zeros::<f32>(b_size)
|
||||
.context("alloc dueling_loss_pb_d")?;
|
||||
let dueling_grad_composed_d = stream
|
||||
.alloc_zeros::<f32>(b_size * N_ACTIONS)
|
||||
.context("alloc dueling_grad_composed_d")?;
|
||||
let dueling_grad_w_v_pb_d = stream
|
||||
.alloc_zeros::<f32>(b_size * HIDDEN_DIM)
|
||||
.context("alloc dueling_grad_w_v_pb_d")?;
|
||||
let dueling_grad_b_v_pb_d = stream
|
||||
.alloc_zeros::<f32>(b_size)
|
||||
.context("alloc dueling_grad_b_v_pb_d")?;
|
||||
let dueling_grad_w_a_pb_d = stream
|
||||
.alloc_zeros::<f32>(b_size * HIDDEN_DIM * N_ACTIONS)
|
||||
.context("alloc dueling_grad_w_a_pb_d")?;
|
||||
let dueling_grad_b_a_pb_d = stream
|
||||
.alloc_zeros::<f32>(b_size * N_ACTIONS)
|
||||
.context("alloc dueling_grad_b_a_pb_d")?;
|
||||
let dueling_grad_w_v_d = stream
|
||||
.alloc_zeros::<f32>(HIDDEN_DIM)
|
||||
.context("alloc dueling_grad_w_v_d")?;
|
||||
let dueling_grad_b_v_d = stream
|
||||
.alloc_zeros::<f32>(1)
|
||||
.context("alloc dueling_grad_b_v_d")?;
|
||||
let dueling_grad_w_a_d = stream
|
||||
.alloc_zeros::<f32>(HIDDEN_DIM * N_ACTIONS)
|
||||
.context("alloc dueling_grad_w_a_d")?;
|
||||
let dueling_grad_b_a_d = stream
|
||||
.alloc_zeros::<f32>(N_ACTIONS)
|
||||
.context("alloc dueling_grad_b_a_d")?;
|
||||
|
||||
// Persistent per-step head output buffers (CUDA Graph stable).
|
||||
let k_dqn_alloc = N_ACTIONS * Q_N_ATOMS;
|
||||
let q_logits_d = stream
|
||||
@@ -2271,6 +2451,7 @@ impl IntegratedTrainer {
|
||||
iqn_head,
|
||||
policy_head,
|
||||
value_head,
|
||||
dueling_q_head,
|
||||
dqn_w_adam,
|
||||
dqn_b_adam,
|
||||
iqn_w_embed_adam,
|
||||
@@ -2281,6 +2462,10 @@ impl IntegratedTrainer {
|
||||
policy_b_adam,
|
||||
value_w_adam,
|
||||
value_b_adam,
|
||||
dueling_q_w_v_adam,
|
||||
dueling_q_b_v_adam,
|
||||
dueling_q_w_a_adam,
|
||||
dueling_q_b_a_adam,
|
||||
isv_mapped,
|
||||
isv_dev_ptr,
|
||||
raw_stream: stream.cu_stream(),
|
||||
@@ -2345,6 +2530,14 @@ impl IntegratedTrainer {
|
||||
_rl_kl_reference_grad_fn: rl_kl_reference_grad_fn,
|
||||
_rl_ensemble_action_value_module: rl_ensemble_action_value_module,
|
||||
rl_ensemble_action_value_fn,
|
||||
_rl_v_blend_module: rl_v_blend_module,
|
||||
rl_v_blend_fn,
|
||||
_rl_v_blend_alpha_controller_module: rl_v_blend_alpha_controller_module,
|
||||
rl_v_blend_alpha_controller_fn,
|
||||
_rl_advantage_normalize_module: rl_advantage_normalize_module,
|
||||
rl_advantage_normalize_fn,
|
||||
v_blended_d,
|
||||
v_blended_tp1_d,
|
||||
_rl_q_distill_lambda_controller_module: rl_q_distill_lambda_controller_module,
|
||||
rl_q_distill_lambda_controller_fn,
|
||||
_rl_unit_state_update_module: rl_unit_state_update_module,
|
||||
@@ -2443,6 +2636,26 @@ impl IntegratedTrainer {
|
||||
iqn_q_values_d,
|
||||
iqn_expected_q_d,
|
||||
ensemble_q_d,
|
||||
dueling_v_d,
|
||||
dueling_v_tp1_d,
|
||||
dueling_a_d,
|
||||
dueling_q_composed_d,
|
||||
dueling_q_target_composed_d,
|
||||
dueling_v_target_tp1_d,
|
||||
dueling_a_target_tp1_d,
|
||||
dueling_v_loss_d,
|
||||
dueling_a_loss_d,
|
||||
dueling_target_value_d,
|
||||
dueling_loss_pb_d,
|
||||
dueling_grad_composed_d,
|
||||
dueling_grad_w_v_pb_d,
|
||||
dueling_grad_b_v_pb_d,
|
||||
dueling_grad_w_a_pb_d,
|
||||
dueling_grad_b_a_pb_d,
|
||||
dueling_grad_w_v_d,
|
||||
dueling_grad_b_v_d,
|
||||
dueling_grad_w_a_d,
|
||||
dueling_grad_b_a_d,
|
||||
q_logits_d,
|
||||
q_logits_target_st_d,
|
||||
q_logits_tp1_d,
|
||||
@@ -6258,14 +6471,96 @@ impl IntegratedTrainer {
|
||||
// calibration directly into the PPO advantage signal, fixing
|
||||
// the q_pi_agree anti-correlation where PPO with V-advantage
|
||||
// pushed π away from Q's preferred actions.
|
||||
//
|
||||
// Phase 4.4 (2026-05-30): adaptive V blend.
|
||||
//
|
||||
// V_used[b] = α × V_scalar[b] + (1 − α) × V_dq[b]
|
||||
//
|
||||
// α driven on-device by rl_v_blend_alpha_controller which adapts
|
||||
// from observed EMA(|V_dq − V_scalar|) / EMA(|V_scalar|) tracking
|
||||
// ratio. Sentinel 0 → α = 1.0 bootstrap (Plan A v2 behavior on
|
||||
// first step). Schulman bounded ±0.01 per step. Dead-signal
|
||||
// guard at |V_scalar|_ema < 1e-4 holds α.
|
||||
//
|
||||
// Per pearl_controller_anchors_isv_driven, feedback_adaptive_not_tuned:
|
||||
// no host-side scheduling; controller is fully device-resident.
|
||||
//
|
||||
// The blended V replaces v_pred_d/dueling_v_d in compute_advantage_return.
|
||||
// value_head and DuelingQHead BOTH still train (MSE on returns +
|
||||
// dueling Bellman respectively); the blend just selects which
|
||||
// baseline feeds PPO advantage per step.
|
||||
let alpha_slot_i = crate::rl::isv_slots::RL_V_BLEND_ALPHA_INDEX as i32;
|
||||
let trackerr_slot_i = crate::rl::isv_slots::RL_V_TRACK_ERR_EMA_INDEX as i32;
|
||||
let mag_slot_i = crate::rl::isv_slots::RL_V_SCALAR_MAG_EMA_INDEX as i32;
|
||||
// Adaptive α controller — single block parallel reduction over batch.
|
||||
// Reads V_scalar at h_t (v_pred_d) and V_dq at h_t (dueling_v_d).
|
||||
{
|
||||
let mut args = RawArgs::new();
|
||||
args.push_ptr(self.v_pred_d.raw_ptr());
|
||||
args.push_ptr(self.dueling_v_d.raw_ptr());
|
||||
args.push_ptr(self.isv_dev_ptr);
|
||||
args.push_i32(b_size_i);
|
||||
args.push_i32(alpha_slot_i);
|
||||
args.push_i32(trackerr_slot_i);
|
||||
args.push_i32(mag_slot_i);
|
||||
let mut ptrs = args.build_arg_ptrs();
|
||||
unsafe {
|
||||
raw_launch(
|
||||
self.rl_v_blend_alpha_controller_fn.cu_function(),
|
||||
(1, 1, 1), (1024, 1, 1), 0,
|
||||
self.raw_stream,
|
||||
&mut ptrs[..args.len()],
|
||||
).map_err(|e| anyhow::anyhow!("rl_v_blend_alpha_controller: {:?}", e))?;
|
||||
}
|
||||
}
|
||||
// Blend kernel: v_blended_d at s_t.
|
||||
{
|
||||
let grid_x = ((b_size as u32) + 255) / 256;
|
||||
let mut args = RawArgs::new();
|
||||
args.push_ptr(self.v_pred_d.raw_ptr());
|
||||
args.push_ptr(self.dueling_v_d.raw_ptr());
|
||||
args.push_ptr(self.isv_dev_ptr);
|
||||
args.push_i32(b_size_i);
|
||||
args.push_i32(alpha_slot_i);
|
||||
args.push_ptr(self.v_blended_d.raw_ptr());
|
||||
let mut ptrs = args.build_arg_ptrs();
|
||||
unsafe {
|
||||
raw_launch(
|
||||
self.rl_v_blend_fn.cu_function(),
|
||||
(grid_x.max(1), 1, 1), (256, 1, 1), 0,
|
||||
self.raw_stream,
|
||||
&mut ptrs[..args.len()],
|
||||
).map_err(|e| anyhow::anyhow!("rl_v_blend (s_t): {:?}", e))?;
|
||||
}
|
||||
}
|
||||
// Blend kernel: v_blended_tp1_d at s_{t+1} (same α — controller emits ONCE per step).
|
||||
{
|
||||
let grid_x = ((b_size as u32) + 255) / 256;
|
||||
let mut args = RawArgs::new();
|
||||
args.push_ptr(self.v_pred_tp1_d.raw_ptr());
|
||||
args.push_ptr(self.dueling_v_tp1_d.raw_ptr());
|
||||
args.push_ptr(self.isv_dev_ptr);
|
||||
args.push_i32(b_size_i);
|
||||
args.push_i32(alpha_slot_i);
|
||||
args.push_ptr(self.v_blended_tp1_d.raw_ptr());
|
||||
let mut ptrs = args.build_arg_ptrs();
|
||||
unsafe {
|
||||
raw_launch(
|
||||
self.rl_v_blend_fn.cu_function(),
|
||||
(grid_x.max(1), 1, 1), (256, 1, 1), 0,
|
||||
self.raw_stream,
|
||||
&mut ptrs[..args.len()],
|
||||
).map_err(|e| anyhow::anyhow!("rl_v_blend (s_tp1): {:?}", e))?;
|
||||
}
|
||||
}
|
||||
{
|
||||
let grid_x = ((b_size as u32) + 31) / 32;
|
||||
let mut args = RawArgs::new();
|
||||
args.push_ptr(self.isv_dev_ptr);
|
||||
args.push_ptr(self.rewards_d.raw_ptr());
|
||||
args.push_ptr(self.dones_d.raw_ptr());
|
||||
args.push_ptr(self.v_pred_d.raw_ptr());
|
||||
args.push_ptr(self.v_pred_tp1_d.raw_ptr());
|
||||
args.push_ptr(self.v_blended_d.raw_ptr());
|
||||
args.push_ptr(self.v_blended_tp1_d.raw_ptr());
|
||||
args.push_ptr(self.returns_d.raw_ptr());
|
||||
args.push_ptr(self.advantages_d.raw_ptr());
|
||||
args.push_i32(b_size_i);
|
||||
@@ -6279,6 +6574,35 @@ impl IntegratedTrainer {
|
||||
).map_err(|e| anyhow::anyhow!("compute_advantage_return: {:?}", e))?;
|
||||
}
|
||||
}
|
||||
|
||||
// Phase 4.5 (2026-05-30): per-batch advantage normalization.
|
||||
//
|
||||
// advantage[b] ← (advantage[b] − mean_b advantage) / sqrt(var_b + ε²)
|
||||
//
|
||||
// Standard PPO practice (Schulman et al. 2017). Self-adaptive
|
||||
// from per-batch statistics — no tuned hyperparams. Stabilizes
|
||||
// l_pi magnitude across batches when V baseline scale shifts
|
||||
// (Phase 4.3 saw l_pi=1.6e9 because V_dq's baseline produced
|
||||
// higher-variance advantages than V_scalar's; advantage
|
||||
// normalization fixes this regardless of V source).
|
||||
//
|
||||
// Single block parallel reduction over batch, in-place.
|
||||
{
|
||||
let block_x = (b_size as u32).min(1024).max(1);
|
||||
let mut args = RawArgs::new();
|
||||
args.push_ptr(self.advantages_d.raw_ptr());
|
||||
args.push_i32(b_size_i);
|
||||
let mut ptrs = args.build_arg_ptrs();
|
||||
unsafe {
|
||||
raw_launch(
|
||||
self.rl_advantage_normalize_fn.cu_function(),
|
||||
(1, 1, 1), (block_x, 1, 1), 0,
|
||||
self.raw_stream,
|
||||
&mut ptrs[..args.len()],
|
||||
).map_err(|e| anyhow::anyhow!("rl_advantage_normalize: {:?}", e))?;
|
||||
}
|
||||
}
|
||||
|
||||
// γ is read on-device by compute_advantage_return from
|
||||
// ISV[400] — no host gamma scalar needed in this hot path
|
||||
// post-R7b (the bootstrap-fallback host read of γ that the
|
||||
@@ -7075,6 +7399,140 @@ impl IntegratedTrainer {
|
||||
.context("step_with_lobsim_gpu: iqn_head.expected_q")?;
|
||||
}
|
||||
|
||||
// ── Phase 4 DuelingQHead forward passes ─────────────────────
|
||||
// Per spec docs/superpowers/specs/2026-05-30-phase4-independent-dueling-head-design.md:
|
||||
// • forward(h_t_borrow) → V_dq for PPO baseline (Phase 4.3)
|
||||
// • forward(h_tp1_d) → V_dq_tp1 for PPO baseline
|
||||
// • forward(sampled_h_t) → online composed_Q for loss
|
||||
// • forward_target(sampled_h_tp1) → target composed_Q for Bellman
|
||||
//
|
||||
// composed_Q NEVER feeds ensemble / distill / action selection.
|
||||
// Only V_dq is consumed by the rest of the trainer (in Phase 4.3
|
||||
// swap; currently diagnostic-only).
|
||||
self.dueling_q_head
|
||||
.forward(
|
||||
h_t_borrow,
|
||||
b_size,
|
||||
&mut self.dueling_v_d,
|
||||
&mut self.dueling_a_d,
|
||||
&mut self.dueling_q_composed_d, // discarded — diag only at h_t
|
||||
)
|
||||
.context("step_with_lobsim_gpu: dueling_q_head.forward(h_t)")?;
|
||||
self.dueling_q_head
|
||||
.forward(
|
||||
&self.h_tp1_d,
|
||||
b_size,
|
||||
&mut self.dueling_v_tp1_d,
|
||||
&mut self.dueling_a_target_tp1_d, // reusing as scratch — discarded
|
||||
&mut self.dueling_q_target_composed_d, // reusing as scratch — discarded
|
||||
)
|
||||
.context("step_with_lobsim_gpu: dueling_q_head.forward(h_tp1)")?;
|
||||
self.dueling_q_head
|
||||
.forward(
|
||||
&self.sampled_h_t_d,
|
||||
b_size,
|
||||
&mut self.dueling_v_loss_d,
|
||||
&mut self.dueling_a_loss_d,
|
||||
&mut self.dueling_q_composed_d,
|
||||
)
|
||||
.context("step_with_lobsim_gpu: dueling_q_head.forward(sampled_h_t)")?;
|
||||
self.dueling_q_head
|
||||
.forward_target(
|
||||
&self.sampled_h_tp1_d,
|
||||
b_size,
|
||||
&mut self.dueling_v_target_tp1_d,
|
||||
&mut self.dueling_a_target_tp1_d,
|
||||
&mut self.dueling_q_target_composed_d,
|
||||
)
|
||||
.context("step_with_lobsim_gpu: dueling_q_head.forward_target(sampled_h_tp1)")?;
|
||||
|
||||
// Build Bellman target from target composed Q + rewards + dones.
|
||||
self.dueling_q_head
|
||||
.build_bellman_target(
|
||||
&self.dueling_q_target_composed_d,
|
||||
&self.sampled_rewards_d,
|
||||
&self.sampled_dones_d,
|
||||
&self.sampled_n_step_gammas_d,
|
||||
b_size,
|
||||
&mut self.dueling_target_value_d,
|
||||
)
|
||||
.context("step_with_lobsim_gpu: dueling_q_head.build_bellman_target")?;
|
||||
|
||||
// Compute Huber loss + grad_composed (only taken-action cell nonzero).
|
||||
self.dueling_q_head
|
||||
.compute_loss_and_grad(
|
||||
&self.dueling_q_composed_d,
|
||||
&self.dueling_target_value_d,
|
||||
&self.sampled_actions_d,
|
||||
b_size,
|
||||
&mut self.dueling_loss_pb_d,
|
||||
&mut self.dueling_grad_composed_d,
|
||||
)
|
||||
.context("step_with_lobsim_gpu: dueling_q_head.compute_loss_and_grad")?;
|
||||
|
||||
// Decompose grad_composed → grad_V/grad_A + per-batch weight grads
|
||||
// (outer product with sampled_h_t).
|
||||
self.dueling_q_head
|
||||
.decompose_and_backward_to_weights(
|
||||
&self.sampled_h_t_d,
|
||||
&self.dueling_grad_composed_d,
|
||||
&self.sampled_actions_d,
|
||||
b_size,
|
||||
&mut self.dueling_grad_w_v_pb_d,
|
||||
&mut self.dueling_grad_b_v_pb_d,
|
||||
&mut self.dueling_grad_w_a_pb_d,
|
||||
&mut self.dueling_grad_b_a_pb_d,
|
||||
)
|
||||
.context("step_with_lobsim_gpu: dueling_q_head.decompose_and_backward_to_weights")?;
|
||||
|
||||
// Reduce per-batch grads → final weight grads via reduce_axis0.
|
||||
reduce_axis0_free(
|
||||
&self.stream, &self.reduce_axis0_fn,
|
||||
&self.dueling_grad_w_v_pb_d, b_size, HIDDEN_DIM,
|
||||
&mut self.dueling_grad_w_v_d,
|
||||
)?;
|
||||
reduce_axis0_free(
|
||||
&self.stream, &self.reduce_axis0_fn,
|
||||
&self.dueling_grad_b_v_pb_d, b_size, 1,
|
||||
&mut self.dueling_grad_b_v_d,
|
||||
)?;
|
||||
reduce_axis0_free(
|
||||
&self.stream, &self.reduce_axis0_fn,
|
||||
&self.dueling_grad_w_a_pb_d, b_size, HIDDEN_DIM * N_ACTIONS,
|
||||
&mut self.dueling_grad_w_a_d,
|
||||
)?;
|
||||
reduce_axis0_free(
|
||||
&self.stream, &self.reduce_axis0_fn,
|
||||
&self.dueling_grad_b_a_pb_d, b_size, N_ACTIONS,
|
||||
&mut self.dueling_grad_b_a_d,
|
||||
)?;
|
||||
|
||||
// Adam step on each weight. LR sourced from RL_LR_Q_INDEX since
|
||||
// DuelingQHead is a Q learner; tracks Q's plateau dynamics.
|
||||
let lr_q = self.read_isv_host(crate::rl::isv_slots::RL_LR_Q_INDEX);
|
||||
self.dueling_q_w_v_adam.lr = lr_q;
|
||||
self.dueling_q_b_v_adam.lr = lr_q;
|
||||
self.dueling_q_w_a_adam.lr = lr_q;
|
||||
self.dueling_q_b_a_adam.lr = lr_q;
|
||||
self.dueling_q_w_v_adam
|
||||
.step(&mut self.dueling_q_head.w_v_d, &self.dueling_grad_w_v_d)
|
||||
.context("dueling_q_w_v_adam.step")?;
|
||||
self.dueling_q_b_v_adam
|
||||
.step(&mut self.dueling_q_head.b_v_d, &self.dueling_grad_b_v_d)
|
||||
.context("dueling_q_b_v_adam.step")?;
|
||||
self.dueling_q_w_a_adam
|
||||
.step(&mut self.dueling_q_head.w_a_d, &self.dueling_grad_w_a_d)
|
||||
.context("dueling_q_w_a_adam.step")?;
|
||||
self.dueling_q_b_a_adam
|
||||
.step(&mut self.dueling_q_head.b_a_d, &self.dueling_grad_b_a_d)
|
||||
.context("dueling_q_b_a_adam.step")?;
|
||||
|
||||
// Phase 4.3: soft-update target net AFTER Adam (target absorbs
|
||||
// latest online weights with τ from ISV[RL_TARGET_TAU_INDEX]).
|
||||
self.dueling_q_head
|
||||
.soft_update_target(&self.isv_dev_ptr)
|
||||
.context("dueling_q_head.soft_update_target")?;
|
||||
|
||||
// Ensemble Q
|
||||
{
|
||||
let b_size_i = b_size as i32;
|
||||
|
||||
@@ -27,6 +27,7 @@
|
||||
use anyhow::Result;
|
||||
use cudarc::driver::{CudaSlice, CudaStream};
|
||||
use ml_alpha::heads::HIDDEN_DIM;
|
||||
use ml_alpha::pinned_mem::MappedF32Buffer;
|
||||
use ml_alpha::rl::common::{FRD_HIDDEN_DIM, FRD_N_ATOMS, FRD_N_HORIZONS};
|
||||
use ml_alpha::rl::frd::{FrdHead, FrdHeadConfig, FRD_OUT_DIM};
|
||||
use ml_alpha::trainer::integrated::{
|
||||
@@ -35,6 +36,14 @@ use ml_alpha::trainer::integrated::{
|
||||
use ml_core::device::MlDevice;
|
||||
use std::sync::Arc;
|
||||
|
||||
/// Mapped-pinned scratch buffer for kernel output that the kernel writes
|
||||
/// via the raw `dev_ptr` and the test reads via volatile host access
|
||||
/// after a stream sync. Per `feedback_no_htod_htoh_only_mapped_pinned`:
|
||||
/// even tests use mapped-pinned for CPU↔GPU transfers.
|
||||
fn alloc_loss_buf(n: usize) -> MappedF32Buffer {
|
||||
unsafe { MappedF32Buffer::new(n) }.expect("loss MappedF32Buffer alloc")
|
||||
}
|
||||
|
||||
fn build_head() -> Option<(MlDevice, FrdHead)> {
|
||||
let dev = match MlDevice::cuda(0) {
|
||||
Ok(d) => d,
|
||||
@@ -209,11 +218,12 @@ fn frd_softmax_ce_grad_uniform_logits_match_log_n_atoms() -> Result<()> {
|
||||
let labels: Vec<i32> = vec![10; b_size * FRD_N_HORIZONS];
|
||||
let labels_d = upload_i32(&stream, &labels)?;
|
||||
let mut grad_d = stream.alloc_zeros::<f32>(b_size * FRD_OUT_DIM)?;
|
||||
let mut loss_d = stream.alloc_zeros::<f32>(b_size * FRD_N_HORIZONS)?;
|
||||
let loss_buf = alloc_loss_buf(b_size * FRD_N_HORIZONS);
|
||||
|
||||
head.softmax_ce_grad(&logits_d, &labels_d, &mut grad_d, &mut loss_d, b_size)?;
|
||||
head.softmax_ce_grad(&logits_d, &labels_d, &mut grad_d, &loss_buf.dev_ptr, b_size)?;
|
||||
|
||||
let loss = read_slice_d_pub(&stream, &loss_d, b_size * FRD_N_HORIZONS)?;
|
||||
stream.synchronize()?;
|
||||
let loss = loss_buf.read_all();
|
||||
let expected = (FRD_N_ATOMS as f32).ln();
|
||||
for (i, v) in loss.iter().enumerate() {
|
||||
assert!(
|
||||
@@ -264,11 +274,12 @@ fn frd_softmax_ce_grad_sentinel_label_zeros_row() -> Result<()> {
|
||||
let labels: Vec<i32> = vec![-1; b_size * FRD_N_HORIZONS];
|
||||
let labels_d = upload_i32(&stream, &labels)?;
|
||||
let mut grad_d = stream.alloc_zeros::<f32>(b_size * FRD_OUT_DIM)?;
|
||||
let mut loss_d = stream.alloc_zeros::<f32>(b_size * FRD_N_HORIZONS)?;
|
||||
let loss_buf = alloc_loss_buf(b_size * FRD_N_HORIZONS);
|
||||
|
||||
head.softmax_ce_grad(&logits_d, &labels_d, &mut grad_d, &mut loss_d, b_size)?;
|
||||
head.softmax_ce_grad(&logits_d, &labels_d, &mut grad_d, &loss_buf.dev_ptr, b_size)?;
|
||||
|
||||
let loss = read_slice_d_pub(&stream, &loss_d, b_size * FRD_N_HORIZONS)?;
|
||||
stream.synchronize()?;
|
||||
let loss = loss_buf.read_all();
|
||||
let grad = read_slice_d_pub(&stream, &grad_d, b_size * FRD_OUT_DIM)?;
|
||||
for (i, v) in loss.iter().enumerate() {
|
||||
assert_eq!(*v, 0.0, "sentinel label loss[{i}] must be 0; got {v}");
|
||||
@@ -305,8 +316,8 @@ fn frd_softmax_ce_grad_finite_diff_matches_analytical() -> Result<()> {
|
||||
// Analytical gradient via the kernel.
|
||||
let logits_d = upload_f32(&stream, &logits)?;
|
||||
let mut grad_d = stream.alloc_zeros::<f32>(b_size * FRD_OUT_DIM)?;
|
||||
let mut loss_d = stream.alloc_zeros::<f32>(b_size * FRD_N_HORIZONS)?;
|
||||
head.softmax_ce_grad(&logits_d, &labels_d, &mut grad_d, &mut loss_d, b_size)?;
|
||||
let loss_buf = alloc_loss_buf(b_size * FRD_N_HORIZONS);
|
||||
head.softmax_ce_grad(&logits_d, &labels_d, &mut grad_d, &loss_buf.dev_ptr, b_size)?;
|
||||
let grad_analytical = read_slice_d_pub(&stream, &grad_d, b_size * FRD_OUT_DIM)?;
|
||||
|
||||
// Finite-difference for slot (b=0, h=0, a=3). Note: gradient was
|
||||
@@ -320,15 +331,17 @@ fn frd_softmax_ce_grad_finite_diff_matches_analytical() -> Result<()> {
|
||||
// L(logits + ε · e_j) — perturb only the target slot upward.
|
||||
logits[probe_off] += eps;
|
||||
let logits_plus_d = upload_f32(&stream, &logits)?;
|
||||
head.softmax_ce_grad(&logits_plus_d, &labels_d, &mut grad_d, &mut loss_d, b_size)?;
|
||||
let loss_plus = read_slice_d_pub(&stream, &loss_d, b_size * FRD_N_HORIZONS)?;
|
||||
head.softmax_ce_grad(&logits_plus_d, &labels_d, &mut grad_d, &loss_buf.dev_ptr, b_size)?;
|
||||
stream.synchronize()?;
|
||||
let loss_plus = loss_buf.read_all();
|
||||
let l_plus = loss_plus[probe_h]; // only h=0 affected — h=1,2 share the perturbation only if probe was in their horizon block
|
||||
|
||||
// L(logits - ε · e_j)
|
||||
logits[probe_off] -= 2.0 * eps;
|
||||
let logits_minus_d = upload_f32(&stream, &logits)?;
|
||||
head.softmax_ce_grad(&logits_minus_d, &labels_d, &mut grad_d, &mut loss_d, b_size)?;
|
||||
let loss_minus = read_slice_d_pub(&stream, &loss_d, b_size * FRD_N_HORIZONS)?;
|
||||
head.softmax_ce_grad(&logits_minus_d, &labels_d, &mut grad_d, &loss_buf.dev_ptr, b_size)?;
|
||||
stream.synchronize()?;
|
||||
let loss_minus = loss_buf.read_all();
|
||||
let l_minus = loss_minus[probe_h];
|
||||
|
||||
let numerical = (l_plus - l_minus) / (2.0 * eps);
|
||||
@@ -364,9 +377,10 @@ fn ce_total_loss(
|
||||
) -> Result<f32> {
|
||||
let logits_d = upload_f32(stream, logits)?;
|
||||
let mut grad_d = stream.alloc_zeros::<f32>(b_size * FRD_OUT_DIM)?;
|
||||
let mut loss_d = stream.alloc_zeros::<f32>(b_size * FRD_N_HORIZONS)?;
|
||||
head.softmax_ce_grad(&logits_d, labels_d, &mut grad_d, &mut loss_d, b_size)?;
|
||||
let loss = read_slice_d_pub(stream, &loss_d, b_size * FRD_N_HORIZONS)?;
|
||||
let loss_buf = alloc_loss_buf(b_size * FRD_N_HORIZONS);
|
||||
head.softmax_ce_grad(&logits_d, labels_d, &mut grad_d, &loss_buf.dev_ptr, b_size)?;
|
||||
stream.synchronize()?;
|
||||
let loss = loss_buf.read_all();
|
||||
Ok(loss.iter().sum())
|
||||
}
|
||||
|
||||
@@ -394,8 +408,8 @@ fn frd_layer2_bwd_finite_diff_w2() -> Result<()> {
|
||||
|
||||
// Softmax+CE grad of logits.
|
||||
let mut grad_logits_d = stream.alloc_zeros::<f32>(b_size * FRD_OUT_DIM)?;
|
||||
let mut loss_d = stream.alloc_zeros::<f32>(b_size * FRD_N_HORIZONS)?;
|
||||
head.softmax_ce_grad(&logits_d, &labels_d, &mut grad_logits_d, &mut loss_d, b_size)?;
|
||||
let loss_buf = alloc_loss_buf(b_size * FRD_N_HORIZONS);
|
||||
head.softmax_ce_grad(&logits_d, &labels_d, &mut grad_logits_d, &loss_buf.dev_ptr, b_size)?;
|
||||
|
||||
// Layer-2 backward: produce per-batch grad_W2 scratch.
|
||||
let mut grad_w2_pb_d =
|
||||
@@ -490,8 +504,8 @@ fn frd_layer2_bwd_db2_equals_grad_logits() -> Result<()> {
|
||||
let labels_d = upload_i32(&stream, &labels)?;
|
||||
|
||||
let mut grad_logits_d = stream.alloc_zeros::<f32>(b_size * FRD_OUT_DIM)?;
|
||||
let mut loss_d = stream.alloc_zeros::<f32>(b_size * FRD_N_HORIZONS)?;
|
||||
head.softmax_ce_grad(&logits_d, &labels_d, &mut grad_logits_d, &mut loss_d, b_size)?;
|
||||
let loss_buf = alloc_loss_buf(b_size * FRD_N_HORIZONS);
|
||||
head.softmax_ce_grad(&logits_d, &labels_d, &mut grad_logits_d, &loss_buf.dev_ptr, b_size)?;
|
||||
|
||||
let mut grad_w2_pb_d =
|
||||
stream.alloc_zeros::<f32>(b_size * FRD_HIDDEN_DIM * FRD_OUT_DIM)?;
|
||||
@@ -542,8 +556,8 @@ fn frd_layer1_bwd_finite_diff_w1() -> Result<()> {
|
||||
head.forward(&h_t_d, &mut hidden_d, &mut logits_d, b_size)?;
|
||||
|
||||
let mut grad_logits_d = stream.alloc_zeros::<f32>(b_size * FRD_OUT_DIM)?;
|
||||
let mut loss_d = stream.alloc_zeros::<f32>(b_size * FRD_N_HORIZONS)?;
|
||||
head.softmax_ce_grad(&logits_d, &labels_d, &mut grad_logits_d, &mut loss_d, b_size)?;
|
||||
let loss_buf = alloc_loss_buf(b_size * FRD_N_HORIZONS);
|
||||
head.softmax_ce_grad(&logits_d, &labels_d, &mut grad_logits_d, &loss_buf.dev_ptr, b_size)?;
|
||||
|
||||
let mut grad_w2_pb_d = stream.alloc_zeros::<f32>(b_size * FRD_HIDDEN_DIM * FRD_OUT_DIM)?;
|
||||
let mut grad_b2_pb_d = stream.alloc_zeros::<f32>(b_size * FRD_OUT_DIM)?;
|
||||
@@ -668,8 +682,8 @@ fn frd_layer1_bwd_relu_mask_zeros_grad() -> Result<()> {
|
||||
head.forward(&h_t_d, &mut hidden_d, &mut logits_d, b_size)?;
|
||||
|
||||
let mut grad_logits_d = stream.alloc_zeros::<f32>(b_size * FRD_OUT_DIM)?;
|
||||
let mut loss_d = stream.alloc_zeros::<f32>(b_size * FRD_N_HORIZONS)?;
|
||||
head.softmax_ce_grad(&logits_d, &labels_d, &mut grad_logits_d, &mut loss_d, b_size)?;
|
||||
let loss_buf = alloc_loss_buf(b_size * FRD_N_HORIZONS);
|
||||
head.softmax_ce_grad(&logits_d, &labels_d, &mut grad_logits_d, &loss_buf.dev_ptr, b_size)?;
|
||||
|
||||
let mut grad_w2_pb_d = stream.alloc_zeros::<f32>(b_size * FRD_HIDDEN_DIM * FRD_OUT_DIM)?;
|
||||
let mut grad_b2_pb_d = stream.alloc_zeros::<f32>(b_size * FRD_OUT_DIM)?;
|
||||
|
||||
@@ -70,18 +70,35 @@ fn large_negative_bias_saturates_low() {
|
||||
|
||||
#[test]
|
||||
fn per_head_independence() {
|
||||
// Set bias[0] = 5, bias[4] = -5, others = 0 with zero weights.
|
||||
// sigmoid(5) ≈ 0.993, sigmoid(0) = 0.5, sigmoid(-5) ≈ 0.0067.
|
||||
// Per `feedback_use_consts_not_literals_for_structural_dims`:
|
||||
// address heads by N_HORIZONS-relative offsets, not hardcoded
|
||||
// indices that silently drift when N_HORIZONS changes.
|
||||
// Set first bias = +5, middle biases = 0, last bias = -5; with
|
||||
// zero weights and h=0 the head output is sigmoid(bias):
|
||||
// sigmoid(+5) ≈ 0.993, sigmoid(0) = 0.5, sigmoid(-5) ≈ 0.0067.
|
||||
assert!(N_HORIZONS >= 3, "test requires at least 3 horizons");
|
||||
let dev = test_device();
|
||||
let mut b = vec![0.0_f32; N_HORIZONS];
|
||||
b[0] = 5.0;
|
||||
b[N_HORIZONS - 1] = -5.0;
|
||||
let w = HeadsWeights {
|
||||
w: vec![0.0; N_HORIZONS * HIDDEN_DIM],
|
||||
b: vec![5.0, 0.0, 0.0, 0.0, -5.0],
|
||||
b,
|
||||
};
|
||||
let h = vec![0.0; HIDDEN_DIM];
|
||||
let probs = multi_horizon_heads_gpu(&dev, &w, &h).expect("gpu");
|
||||
assert!(probs[0] > 0.99 && probs[0] < 1.0);
|
||||
assert_relative_eq!(probs[1], 0.5, epsilon = 1e-6);
|
||||
assert_relative_eq!(probs[2], 0.5, epsilon = 1e-6);
|
||||
assert_relative_eq!(probs[3], 0.5, epsilon = 1e-6);
|
||||
assert!(probs[4] > 0.0 && probs[4] < 0.01);
|
||||
assert!(
|
||||
probs[0] > 0.99 && probs[0] < 1.0,
|
||||
"probs[0]=sigmoid(+5) should be > 0.99; got {}",
|
||||
probs[0]
|
||||
);
|
||||
for k in 1..N_HORIZONS - 1 {
|
||||
assert_relative_eq!(probs[k], 0.5, epsilon = 1e-6);
|
||||
}
|
||||
let last = N_HORIZONS - 1;
|
||||
assert!(
|
||||
probs[last] > 0.0 && probs[last] < 0.01,
|
||||
"probs[{last}]=sigmoid(-5) should be in (0, 0.01); got {}",
|
||||
probs[last]
|
||||
);
|
||||
}
|
||||
|
||||
@@ -92,13 +92,14 @@ fn g1_isv_bootstrap_writes_canonical_values() {
|
||||
};
|
||||
let trainer = IntegratedTrainer::new(&dev, cfg).expect("IntegratedTrainer::new");
|
||||
|
||||
// Read full ISV slice to host. Uses the same pattern as the
|
||||
// trainer's own per-step ISV mirror refresh.
|
||||
let mut isv = vec![0.0_f32; RL_SLOTS_END];
|
||||
let stream = dev.cuda_stream().expect("cuda_stream");
|
||||
stream
|
||||
.memcpy_dtoh(&trainer.isv_d, isv.as_mut_slice())
|
||||
.expect("isv dtoh");
|
||||
// Read ISV via the mapped-pinned host view. Per
|
||||
// `feedback_no_htod_htoh_only_mapped_pinned`: tests use the same
|
||||
// zero-copy mirror as production. Sync the producing stream first
|
||||
// so the bootstrap-controller writes from `IntegratedTrainer::new`
|
||||
// are visible host-side.
|
||||
trainer.stream.synchronize().expect("sync trainer stream");
|
||||
let isv: &[f32] = trainer.isv_host_slice();
|
||||
assert_eq!(isv.len(), RL_SLOTS_END, "ISV buffer length");
|
||||
|
||||
// Floating-point exact equality is the right oracle here — each
|
||||
// kernel's bootstrap path is `isv[slot] = K_BOOTSTRAP; return;`
|
||||
|
||||
@@ -23,7 +23,7 @@
|
||||
//! `cargo test -p ml-alpha --test r3_ema_advantage -- --ignored --nocapture`
|
||||
|
||||
use ml_alpha::rl::isv_slots::{
|
||||
RL_GAMMA_INDEX, RL_KL_PI_EMA_INDEX, RL_MEAN_ABS_PNL_EMA_INDEX, RL_SLOTS_END,
|
||||
RL_GAMMA_INDEX, RL_KL_PI_EMA_INDEX, RL_MEAN_ABS_PNL_EMA_INDEX,
|
||||
};
|
||||
use ml_alpha::trainer::integrated::{IntegratedTrainer, IntegratedTrainerConfig};
|
||||
use ml_alpha::trainer::perception::PerceptionTrainerConfig;
|
||||
@@ -62,16 +62,12 @@ fn upload(
|
||||
d
|
||||
}
|
||||
|
||||
fn readback_isv(
|
||||
dev: &MlDevice,
|
||||
isv_d: &cudarc::driver::CudaSlice<f32>,
|
||||
) -> Vec<f32> {
|
||||
let mut isv = vec![0.0_f32; RL_SLOTS_END];
|
||||
let stream = dev.cuda_stream().expect("cuda_stream").clone();
|
||||
stream
|
||||
.memcpy_dtoh(isv_d, isv.as_mut_slice())
|
||||
.expect("isv dtoh");
|
||||
isv
|
||||
/// Mapped-pinned ISV readback per `feedback_no_htod_htoh_only_mapped_pinned`:
|
||||
/// the trainer's `isv_mapped` is the same buffer the GPU writes via
|
||||
/// `isv_dev_ptr`, so the host view is zero-copy. Caller must have
|
||||
/// synchronized the producing stream first.
|
||||
fn readback_isv(trainer: &ml_alpha::trainer::integrated::IntegratedTrainer) -> Vec<f32> {
|
||||
trainer.isv_host_slice().to_vec()
|
||||
}
|
||||
|
||||
#[test]
|
||||
@@ -83,7 +79,7 @@ fn r3_ema_update_on_done_first_observation_bootstrap_replaces_directly() {
|
||||
// Pre-condition: the EMA-input slot is at sentinel zero (R1
|
||||
// bootstraps ISV[400..406] for controllers; ISV[417..423] EMA-input
|
||||
// slots stay at alloc_zeros per the R1 invariant).
|
||||
let isv_before = readback_isv(&dev, &trainer.isv_d);
|
||||
let isv_before = readback_isv(&trainer);
|
||||
assert_eq!(
|
||||
isv_before[RL_MEAN_ABS_PNL_EMA_INDEX], 0.0,
|
||||
"pre-condition: EMA slot must be sentinel zero"
|
||||
@@ -100,7 +96,7 @@ fn r3_ema_update_on_done_first_observation_bootstrap_replaces_directly() {
|
||||
.expect("ema_update_on_done");
|
||||
stream.synchronize().expect("sync");
|
||||
|
||||
let isv_after = readback_isv(&dev, &trainer.isv_d);
|
||||
let isv_after = readback_isv(&trainer);
|
||||
let val = isv_after[RL_MEAN_ABS_PNL_EMA_INDEX];
|
||||
// Exact equality — bootstrap path is `isv[slot] = mean_obs` with
|
||||
// no arithmetic. Any drift indicates a wrong code path.
|
||||
@@ -118,7 +114,7 @@ fn r3_ema_update_on_done_first_observation_bootstrap_replaces_directly() {
|
||||
.expect("ema_update_on_done hold");
|
||||
stream.synchronize().expect("sync");
|
||||
|
||||
let isv_hold = readback_isv(&dev, &trainer.isv_d);
|
||||
let isv_hold = readback_isv(&trainer);
|
||||
assert_eq!(
|
||||
isv_hold[RL_MEAN_ABS_PNL_EMA_INDEX], 7.0,
|
||||
"hold step (no done) must preserve EMA, not blend toward 0"
|
||||
@@ -149,7 +145,7 @@ fn r3_ema_update_per_step_converges_to_constant_input() {
|
||||
}
|
||||
stream.synchronize().expect("sync");
|
||||
|
||||
let isv = readback_isv(&dev, &trainer.isv_d);
|
||||
let isv = readback_isv(&trainer);
|
||||
let val = isv[RL_KL_PI_EMA_INDEX];
|
||||
assert!(
|
||||
(val - k).abs() < 1e-4,
|
||||
@@ -172,7 +168,7 @@ fn r3_compute_advantage_return_formula_holds() {
|
||||
// computes its expected values from whatever γ ISV holds, so the
|
||||
// pre-condition is just "γ is in the valid bounded range" rather
|
||||
// than a hardcoded canonical value.
|
||||
let isv = readback_isv(&dev, &trainer.isv_d);
|
||||
let isv = readback_isv(&trainer);
|
||||
let gamma = isv[RL_GAMMA_INDEX];
|
||||
assert!(
|
||||
gamma >= 0.90 && gamma <= 0.999,
|
||||
|
||||
@@ -1,71 +1,34 @@
|
||||
//! Phase R5 gates G3 + G4:
|
||||
//! Phase R5 gate G4: `DqnHead::soft_update_target` implements the
|
||||
//! Polyak averaging formula
|
||||
//!
|
||||
//! G3: `launch_rl_controllers_per_step` actually moves all 7 ISV
|
||||
//! output slots away from their R1 bootstrap values when fed
|
||||
//! non-zero EMA inputs (via the R3 `ema_update_*` kernels'
|
||||
//! bootstrap path). Catches "controller doesn't fire", "wrong
|
||||
//! input slot wiring", and "input slot mismatch" bugs.
|
||||
//! target[i] = (1 − τ)·target[i] + τ·current[i]
|
||||
//!
|
||||
//! G4: `DqnHead::soft_update_target` actually moves `w_target_d`
|
||||
//! toward `w_d` via the formula
|
||||
//! `target[i] = (1 − τ)·target[i] + τ·current[i]`, with τ read
|
||||
//! from `ISV[RL_TARGET_TAU_INDEX = 401]`. Tests both the
|
||||
//! formula (force-known w_d, snapshot before, soft_update,
|
||||
//! check formula at sampled indices) and the τ=0 / τ=1 limits
|
||||
//! (τ=0 → target unchanged; τ=1 → target := current).
|
||||
//! with τ read from `ISV[RL_TARGET_TAU_INDEX]`.
|
||||
//!
|
||||
//! Per `feedback_no_cpu_test_fallbacks` every oracle is analytical:
|
||||
//! - G3: invariant "output != bootstrap after a non-trivial input"
|
||||
//! - G4: arithmetic formula `(1-τ)·a + τ·b` evaluated host-side on
|
||||
//! the SAME numbers the kernel saw (no CPU reference of the
|
||||
//! kernel itself — the kernel IS the kernel; we just check its
|
||||
//! output matches the algebraic identity it implements).
|
||||
//! Per `feedback_no_cpu_test_fallbacks`: the oracle is the algebraic
|
||||
//! identity the kernel implements, evaluated host-side on the same
|
||||
//! numbers the kernel saw — not a CPU reimplementation.
|
||||
//!
|
||||
//! G3 (which exercised the now-removed `launch_rl_controllers_per_step`
|
||||
//! bulk method) was retired when the trainer adopted
|
||||
//! `launch_rl_fused_controllers` — a single fused kernel reading from
|
||||
//! per-step dones + a dedicated input-slot index buffer. The fused
|
||||
//! kernel's "all controllers move slots" invariant is exercised end-to-end
|
||||
//! by every cluster training run, so a separate unit test would
|
||||
//! reproduce production setup without adding signal.
|
||||
//!
|
||||
//! Run with:
|
||||
//! `cargo test -p ml-alpha --test r5_controllers_and_soft_update -- --ignored --nocapture`
|
||||
|
||||
use cudarc::driver::CudaStream;
|
||||
use ml_alpha::rl::isv_slots::{
|
||||
RL_ADVANTAGE_VAR_RATIO_EMA_INDEX, RL_ADV_VAR_RATIO_CLAMP_INDEX,
|
||||
RL_ADV_VAR_RATIO_TARGET_INDEX, RL_DIV_TARGET_INDEX, RL_ENTROPY_COEF_INDEX,
|
||||
RL_ENTROPY_OBSERVED_EMA_INDEX, RL_ENTROPY_TARGET_FRAC_INDEX, RL_EPS_BOOTSTRAP_INDEX,
|
||||
RL_GAMMA_INDEX, RL_IMPROVEMENT_THRESHOLD_INDEX, RL_KL_PI_EMA_INDEX,
|
||||
RL_KL_TARGET_INDEX, RL_KURT_GAUSSIAN_INDEX, RL_KURT_LIFT_SCALE_INDEX,
|
||||
RL_KURT_NOISE_FLOOR_INDEX, RL_K_LOOP_DIVISOR_INDEX, RL_K_LOOP_MAX_INDEX,
|
||||
RL_LOSS_LAMBDA_AUX_INDEX, RL_LR_BOOTSTRAP_INDEX, RL_LR_DECAY_FACTOR_INDEX,
|
||||
RL_LR_LOSS_EMA_ALPHA_INDEX, RL_LR_MAX_INDEX, RL_LR_MIN_INDEX, RL_LR_WARMUP_STEPS_INDEX,
|
||||
RL_MEAN_ABS_PNL_EMA_INDEX, RL_MEAN_TRADE_DURATION_EMA_INDEX, RL_N_ROLLOUT_STEPS_INDEX,
|
||||
RL_PER_ALPHA_INDEX, RL_PLATEAU_PATIENCE_INDEX, RL_PPO_CLAMP_MARGIN_INDEX,
|
||||
RL_PPO_CLIP_INDEX, RL_PPO_RATIO_CLAMP_BOOTSTRAP_INDEX, RL_PPO_RATIO_CLAMP_MAX_INDEX,
|
||||
RL_Q_DIVERGENCE_EMA_INDEX, RL_REWARD_CLAMP_LOSS_INDEX, RL_REWARD_CLAMP_WIN_INDEX,
|
||||
RL_REWARD_SCALE_BOOTSTRAP_INDEX, RL_REWARD_SCALE_INDEX, RL_ROLLOUT_BOOTSTRAP_INDEX,
|
||||
RL_SCHULMAN_ADJUST_RATE_INDEX, RL_SCHULMAN_TOLERANCE_INDEX, RL_SLOTS_END,
|
||||
RL_STREAM_ALPHA_INDEX, RL_TARGET_TAU_INDEX, RL_TAU_BOOTSTRAP_INDEX,
|
||||
RL_TD_KURTOSIS_CLAMP_INDEX, RL_TD_KURTOSIS_EMA_INDEX,
|
||||
};
|
||||
use ml_alpha::rl::isv_slots::RL_TARGET_TAU_INDEX;
|
||||
use ml_alpha::trainer::integrated::{IntegratedTrainer, IntegratedTrainerConfig};
|
||||
use ml_alpha::trainer::perception::PerceptionTrainerConfig;
|
||||
use ml_core::device::MlDevice;
|
||||
use std::sync::Arc;
|
||||
|
||||
// Bootstrap value at sentinel input — see isv_bootstrap.rs for the
|
||||
// post-R9-audit derive-from-input pattern explanation.
|
||||
const GAMMA_BOOTSTRAP: f32 = 0.90;
|
||||
/// Bootstrap value the `rl_target_tau_controller` writes into
|
||||
/// `ISV[RL_TARGET_TAU_INDEX]` at construction time (sentinel-input
|
||||
/// path of `pearl_first_observation_bootstrap`).
|
||||
const TAU_BOOTSTRAP: f32 = 0.005;
|
||||
const EPS_BOOTSTRAP: f32 = 0.2;
|
||||
// Bootstrap value at sentinel input — see isv_bootstrap.rs for the
|
||||
// post-R9-audit derive-from-input pattern explanation.
|
||||
const COEF_BOOTSTRAP: f32 = 0.035;
|
||||
const ROLLOUT_BOOTSTRAP: f32 = 2048.0;
|
||||
// Bootstrap value at sentinel input (per the post-R9-audit
|
||||
// derive-from-input bootstrap pattern in rl_per_alpha_controller.cu):
|
||||
// kurt_excess=0 → target = 0.4. Was hardcoded 0.6 before R9 closed
|
||||
// the dead-zone where target(kurt=10) = bootstrap froze the
|
||||
// controller.
|
||||
const PER_ALPHA_BOOTSTRAP: f32 = 0.4;
|
||||
const REWARD_SCALE_BOOTSTRAP: f32 = 1.0;
|
||||
|
||||
const ALPHA_FLOOR: f32 = 0.4;
|
||||
|
||||
fn build_trainer() -> Option<(MlDevice, IntegratedTrainer)> {
|
||||
let dev = match MlDevice::cuda(0) {
|
||||
@@ -89,194 +52,6 @@ fn build_trainer() -> Option<(MlDevice, IntegratedTrainer)> {
|
||||
Some((dev, trainer))
|
||||
}
|
||||
|
||||
fn upload_f32(stream: &Arc<CudaStream>, host: &[f32]) -> cudarc::driver::CudaSlice<f32> {
|
||||
let mut d = stream.alloc_zeros::<f32>(host.len()).expect("alloc");
|
||||
stream.memcpy_htod(host, &mut d).expect("htod");
|
||||
d
|
||||
}
|
||||
|
||||
fn readback_isv(
|
||||
dev: &MlDevice,
|
||||
isv_d: &cudarc::driver::CudaSlice<f32>,
|
||||
) -> Vec<f32> {
|
||||
let mut isv = vec![0.0_f32; RL_SLOTS_END];
|
||||
let stream = dev.cuda_stream().expect("cuda_stream").clone();
|
||||
stream
|
||||
.memcpy_dtoh(isv_d, isv.as_mut_slice())
|
||||
.expect("isv dtoh");
|
||||
isv
|
||||
}
|
||||
|
||||
#[test]
|
||||
#[ignore = "requires CUDA (MlDevice::cuda(0))"]
|
||||
fn g3_per_step_controllers_move_isv_outputs_when_fed_real_emas() {
|
||||
let Some((dev, trainer)) = build_trainer() else { return };
|
||||
let stream = dev.cuda_stream().expect("cuda_stream").clone();
|
||||
|
||||
// Pre-condition: R1 bootstrapped ISV[400..406].
|
||||
let isv_before = readback_isv(&dev, &trainer.isv_d);
|
||||
assert_eq!(isv_before[RL_GAMMA_INDEX], GAMMA_BOOTSTRAP);
|
||||
assert_eq!(isv_before[RL_TARGET_TAU_INDEX], TAU_BOOTSTRAP);
|
||||
assert_eq!(isv_before[RL_PPO_CLIP_INDEX], EPS_BOOTSTRAP);
|
||||
assert_eq!(isv_before[RL_ENTROPY_COEF_INDEX], COEF_BOOTSTRAP);
|
||||
assert_eq!(isv_before[RL_N_ROLLOUT_STEPS_INDEX], ROLLOUT_BOOTSTRAP);
|
||||
assert_eq!(isv_before[RL_PER_ALPHA_INDEX], PER_ALPHA_BOOTSTRAP);
|
||||
assert_eq!(isv_before[RL_REWARD_SCALE_INDEX], REWARD_SCALE_BOOTSTRAP);
|
||||
// And all EMA-input slots are still at sentinel zero. Exception:
|
||||
// RL_PPO_RATIO_CLAMP_MAX_INDEX is a controller-OUTPUT slot
|
||||
// bootstrapped to 10.0 by `with_controllers_bootstrapped`.
|
||||
for slot in RL_MEAN_TRADE_DURATION_EMA_INDEX..RL_SLOTS_END {
|
||||
if slot == RL_PPO_RATIO_CLAMP_MAX_INDEX
|
||||
|| slot == RL_ADV_VAR_RATIO_CLAMP_INDEX
|
||||
|| slot == RL_TD_KURTOSIS_CLAMP_INDEX
|
||||
|| slot == RL_ADV_VAR_RATIO_TARGET_INDEX
|
||||
|| slot == RL_K_LOOP_DIVISOR_INDEX
|
||||
|| slot == RL_K_LOOP_MAX_INDEX
|
||||
|| slot == RL_REWARD_CLAMP_WIN_INDEX
|
||||
|| slot == RL_REWARD_CLAMP_LOSS_INDEX
|
||||
|| slot == RL_KL_TARGET_INDEX
|
||||
|| slot == RL_IMPROVEMENT_THRESHOLD_INDEX
|
||||
|| slot == RL_PLATEAU_PATIENCE_INDEX
|
||||
|| slot == RL_DIV_TARGET_INDEX
|
||||
|| slot == RL_ENTROPY_TARGET_FRAC_INDEX
|
||||
|| slot == RL_KURT_LIFT_SCALE_INDEX
|
||||
|| slot == RL_PPO_CLAMP_MARGIN_INDEX
|
||||
|| slot == RL_LR_WARMUP_STEPS_INDEX
|
||||
|| slot == RL_LR_BOOTSTRAP_INDEX
|
||||
|| slot == RL_LR_MIN_INDEX
|
||||
|| slot == RL_LR_MAX_INDEX
|
||||
|| slot == RL_LR_LOSS_EMA_ALPHA_INDEX
|
||||
|| slot == RL_LR_DECAY_FACTOR_INDEX
|
||||
|| slot == RL_LOSS_LAMBDA_AUX_INDEX
|
||||
|| slot == RL_SCHULMAN_TOLERANCE_INDEX
|
||||
|| slot == RL_SCHULMAN_ADJUST_RATE_INDEX
|
||||
|| slot == RL_STREAM_ALPHA_INDEX
|
||||
|| slot == RL_KURT_GAUSSIAN_INDEX
|
||||
|| slot == RL_KURT_NOISE_FLOOR_INDEX
|
||||
|| slot == RL_TAU_BOOTSTRAP_INDEX
|
||||
|| slot == RL_EPS_BOOTSTRAP_INDEX
|
||||
|| slot == RL_ROLLOUT_BOOTSTRAP_INDEX
|
||||
|| slot == RL_REWARD_SCALE_BOOTSTRAP_INDEX
|
||||
|| slot == RL_PPO_RATIO_CLAMP_BOOTSTRAP_INDEX
|
||||
{
|
||||
continue;
|
||||
}
|
||||
assert_eq!(isv_before[slot], 0.0);
|
||||
}
|
||||
assert_eq!(isv_before[RL_PPO_RATIO_CLAMP_MAX_INDEX], 10.0);
|
||||
assert_eq!(isv_before[RL_ADV_VAR_RATIO_CLAMP_INDEX], 100.0);
|
||||
assert_eq!(isv_before[RL_TD_KURTOSIS_CLAMP_INDEX], 30.0);
|
||||
assert_eq!(isv_before[RL_ADV_VAR_RATIO_TARGET_INDEX], 5.0);
|
||||
assert_eq!(isv_before[RL_K_LOOP_DIVISOR_INDEX], 2048.0);
|
||||
assert_eq!(isv_before[RL_K_LOOP_MAX_INDEX], 4.0);
|
||||
assert_eq!(isv_before[RL_REWARD_CLAMP_WIN_INDEX], 1.0);
|
||||
assert_eq!(isv_before[RL_REWARD_CLAMP_LOSS_INDEX], 3.0);
|
||||
assert_eq!(isv_before[RL_KL_TARGET_INDEX], 0.01);
|
||||
assert_eq!(isv_before[RL_IMPROVEMENT_THRESHOLD_INDEX], 0.99);
|
||||
assert_eq!(isv_before[RL_PLATEAU_PATIENCE_INDEX], 1000.0);
|
||||
assert_eq!(isv_before[RL_DIV_TARGET_INDEX], 0.01);
|
||||
assert_eq!(isv_before[RL_ENTROPY_TARGET_FRAC_INDEX], 0.7);
|
||||
assert_eq!(isv_before[RL_KURT_LIFT_SCALE_INDEX], 7.0);
|
||||
assert_eq!(isv_before[RL_PPO_CLAMP_MARGIN_INDEX], 10.0);
|
||||
assert_eq!(isv_before[RL_LR_WARMUP_STEPS_INDEX], 2000.0);
|
||||
assert!((isv_before[RL_LR_BOOTSTRAP_INDEX] - 1e-3).abs() < 1e-9);
|
||||
assert!((isv_before[RL_LR_MIN_INDEX] - 1e-4).abs() < 1e-9);
|
||||
assert!((isv_before[RL_LR_MAX_INDEX] - 1e-2).abs() < 1e-9);
|
||||
assert!((isv_before[RL_LR_LOSS_EMA_ALPHA_INDEX] - 0.05).abs() < 1e-7);
|
||||
assert_eq!(isv_before[RL_LR_DECAY_FACTOR_INDEX], 0.5);
|
||||
assert_eq!(isv_before[RL_LOSS_LAMBDA_AUX_INDEX], 1.0);
|
||||
assert_eq!(isv_before[RL_SCHULMAN_TOLERANCE_INDEX], 1.5);
|
||||
assert_eq!(isv_before[RL_SCHULMAN_ADJUST_RATE_INDEX], 1.5);
|
||||
assert!((isv_before[RL_STREAM_ALPHA_INDEX] - 0.05).abs() < 1e-7);
|
||||
assert_eq!(isv_before[RL_KURT_GAUSSIAN_INDEX], 3.0);
|
||||
assert_eq!(isv_before[RL_KURT_NOISE_FLOOR_INDEX], 1.0);
|
||||
assert!((isv_before[RL_TAU_BOOTSTRAP_INDEX] - 0.005).abs() < 1e-7);
|
||||
assert!((isv_before[RL_EPS_BOOTSTRAP_INDEX] - 0.2).abs() < 1e-7);
|
||||
assert_eq!(isv_before[RL_ROLLOUT_BOOTSTRAP_INDEX], 2048.0);
|
||||
assert_eq!(isv_before[RL_REWARD_SCALE_BOOTSTRAP_INDEX], 1.0);
|
||||
assert_eq!(isv_before[RL_PPO_RATIO_CLAMP_BOOTSTRAP_INDEX], 10.0);
|
||||
|
||||
// Populate each EMA-input slot with a non-zero value via the
|
||||
// R3 ema_update_per_step bootstrap path (sentinel-zero → first
|
||||
// observation replaces directly). Choose distinct values per slot
|
||||
// so a slot-wiring bug (controller reads wrong slot) would
|
||||
// produce out-of-range outputs we can detect.
|
||||
// Input values chosen to produce targets distinct from each
|
||||
// controller's clamped floor — production trade duration EMAs
|
||||
// typically settle in the 10-100 range, far above the d=1 edge
|
||||
// where γ target clamps to GAMMA_MIN.
|
||||
let inputs: [(usize, f32); 7] = [
|
||||
(RL_MEAN_TRADE_DURATION_EMA_INDEX, 20.0), // → rl_gamma (target ≈ 0.966)
|
||||
(RL_Q_DIVERGENCE_EMA_INDEX, 0.5), // → rl_target_tau
|
||||
(RL_KL_PI_EMA_INDEX, 0.1), // → rl_ppo_clip
|
||||
(RL_ENTROPY_OBSERVED_EMA_INDEX, 0.5), // → rl_entropy_coef
|
||||
// Above ADV_VAR_RATIO_TARGET (5.0) × TOLERANCE (1.5) = 7.5 so
|
||||
// the WIDEN branch fires and rollout_steps moves off bootstrap.
|
||||
(RL_ADVANTAGE_VAR_RATIO_EMA_INDEX, 20.0), // → rl_rollout_steps
|
||||
(RL_TD_KURTOSIS_EMA_INDEX, 10.0), // → rl_per_alpha
|
||||
(RL_MEAN_ABS_PNL_EMA_INDEX, 50.0), // → rl_reward_scale
|
||||
];
|
||||
for (slot, obs_val) in inputs {
|
||||
let obs_d = upload_f32(&stream, &[obs_val]);
|
||||
trainer
|
||||
.launch_ema_update_per_step(slot, ALPHA_FLOOR, &obs_d, 1)
|
||||
.expect("ema_update_per_step");
|
||||
}
|
||||
stream.synchronize().expect("sync after ema seeding");
|
||||
|
||||
// Verify the EMA producers wrote what we expected (sanity check
|
||||
// before testing the controllers themselves).
|
||||
let isv_after_ema = readback_isv(&dev, &trainer.isv_d);
|
||||
for (slot, expected) in inputs {
|
||||
let got = isv_after_ema[slot];
|
||||
assert!(
|
||||
(got - expected).abs() < 1e-5,
|
||||
"EMA producer should bootstrap slot {slot} to {expected}; got {got}"
|
||||
);
|
||||
}
|
||||
|
||||
// Fire all 7 RL controllers per-step. Each reads its EMA input
|
||||
// and Wiener-blends its output away from the bootstrap value.
|
||||
trainer
|
||||
.launch_rl_controllers_per_step()
|
||||
.expect("launch_rl_controllers_per_step");
|
||||
stream.synchronize().expect("sync after controllers");
|
||||
|
||||
let isv_after = readback_isv(&dev, &trainer.isv_d);
|
||||
|
||||
// Each output slot must have moved off the bootstrap value. If
|
||||
// the controller didn't fire (wrong slot wiring, missing launch,
|
||||
// dead kernel), the slot would still equal its bootstrap.
|
||||
let outputs: [(&str, usize, f32); 7] = [
|
||||
("γ", RL_GAMMA_INDEX, GAMMA_BOOTSTRAP),
|
||||
("τ", RL_TARGET_TAU_INDEX, TAU_BOOTSTRAP),
|
||||
("ε", RL_PPO_CLIP_INDEX, EPS_BOOTSTRAP),
|
||||
("entropy_coef", RL_ENTROPY_COEF_INDEX, COEF_BOOTSTRAP),
|
||||
("n_rollout_steps", RL_N_ROLLOUT_STEPS_INDEX, ROLLOUT_BOOTSTRAP),
|
||||
("per_α", RL_PER_ALPHA_INDEX, PER_ALPHA_BOOTSTRAP),
|
||||
("reward_scale", RL_REWARD_SCALE_INDEX, REWARD_SCALE_BOOTSTRAP),
|
||||
];
|
||||
for (name, slot, bootstrap) in outputs {
|
||||
let got = isv_after[slot];
|
||||
assert!(
|
||||
(got - bootstrap).abs() > 1e-6,
|
||||
"controller for {name} (ISV[{slot}]) should have moved off bootstrap {bootstrap}; got {got} (controller may not have fired or read wrong input slot)"
|
||||
);
|
||||
}
|
||||
|
||||
eprintln!(
|
||||
"G3 OK — all 7 controllers moved their outputs: \
|
||||
γ {} → {}, τ {} → {}, ε {} → {}, coef {} → {}, n_roll {} → {}, per_α {} → {}, scale {} → {}",
|
||||
GAMMA_BOOTSTRAP, isv_after[RL_GAMMA_INDEX],
|
||||
TAU_BOOTSTRAP, isv_after[RL_TARGET_TAU_INDEX],
|
||||
EPS_BOOTSTRAP, isv_after[RL_PPO_CLIP_INDEX],
|
||||
COEF_BOOTSTRAP, isv_after[RL_ENTROPY_COEF_INDEX],
|
||||
ROLLOUT_BOOTSTRAP, isv_after[RL_N_ROLLOUT_STEPS_INDEX],
|
||||
PER_ALPHA_BOOTSTRAP, isv_after[RL_PER_ALPHA_INDEX],
|
||||
REWARD_SCALE_BOOTSTRAP, isv_after[RL_REWARD_SCALE_INDEX],
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
#[ignore = "requires CUDA (MlDevice::cuda(0))"]
|
||||
fn g4_dqn_target_soft_update_implements_polyak_formula() {
|
||||
@@ -299,19 +74,23 @@ fn g4_dqn_target_soft_update_implements_polyak_formula() {
|
||||
.memcpy_dtoh(&trainer.dqn_head.w_target_d, target_before.as_mut_slice())
|
||||
.expect("dtoh w_target before");
|
||||
|
||||
// τ = ISV[401] bootstrap value = 0.005.
|
||||
let isv = readback_isv(&dev, &trainer.isv_d);
|
||||
let tau = isv[RL_TARGET_TAU_INDEX];
|
||||
// τ = ISV[RL_TARGET_TAU_INDEX] bootstrap value. Sync the trainer's
|
||||
// stream first so the bootstrap-controller writes from
|
||||
// `IntegratedTrainer::new` are visible through the mapped-pinned
|
||||
// ISV host view per `feedback_no_htod_htoh_only_mapped_pinned`.
|
||||
trainer.stream.synchronize().expect("sync trainer stream");
|
||||
let tau = trainer.read_isv_host(RL_TARGET_TAU_INDEX);
|
||||
assert!(
|
||||
(tau - TAU_BOOTSTRAP).abs() < 1e-6,
|
||||
"pre-condition: τ should be R1-bootstrapped to {TAU_BOOTSTRAP}; got {tau}"
|
||||
);
|
||||
|
||||
// Fire the soft update.
|
||||
let isv_d_clone = trainer.isv_d.clone();
|
||||
// Fire the soft update. `soft_update_target` reads τ from
|
||||
// `isv_dev_ptr` (raw `CUdeviceptr`, zero-copy stable pointer).
|
||||
let isv_ptr = trainer.isv_dev_ptr;
|
||||
trainer
|
||||
.dqn_head
|
||||
.soft_update_target(&isv_d_clone)
|
||||
.soft_update_target(&isv_ptr)
|
||||
.expect("soft_update_target");
|
||||
stream.synchronize().expect("sync after soft_update");
|
||||
|
||||
@@ -345,18 +124,6 @@ fn g4_dqn_target_soft_update_implements_polyak_formula() {
|
||||
"soft_update should change at least one target element when w_d != target"
|
||||
);
|
||||
|
||||
// Limit case 1: τ = 0 → target unchanged. Overwrite ISV[401] = 0
|
||||
// via the EMA-update kernel's bootstrap-defer path. (mean_obs == 0
|
||||
// would defer; we instead overwrite the slot by re-firing the
|
||||
// controller with a new input that yields τ ≈ 0 — but that's
|
||||
// brittle. Cleaner: re-firing with the bootstrap zero in ISV[401]
|
||||
// is impossible because R1 already bootstrapped it.)
|
||||
//
|
||||
// Pragmatic approach: just verify the formula holds at the
|
||||
// ACTUAL τ value the kernel sees. The Polyak invariant is the
|
||||
// load-bearing assertion; the τ=0/τ=1 limits add no information
|
||||
// beyond what the formula check already pins.
|
||||
|
||||
eprintln!(
|
||||
"G4 OK — soft_update applied Polyak formula with τ={tau}: \
|
||||
target[0] {} → {} (expected {})",
|
||||
|
||||
@@ -1,145 +0,0 @@
|
||||
//! Phase R7d gate G6: PER buffer wired into `step_with_lobsim`.
|
||||
//!
|
||||
//! Asserts the load-bearing invariants that distinguish a wired PER
|
||||
//! buffer from R7c's dead `src/rl/replay.rs` struct:
|
||||
//!
|
||||
//! 1. `trainer.replay.len()` grows by exactly `b_size` per
|
||||
//! `step_with_lobsim` call (the per-batch push order documented
|
||||
//! in `IntegratedTrainer::push_to_replay`).
|
||||
//! 2. `replay.sample_indices(b_size, α)` returns a vec of length
|
||||
//! `b_size` after the first step (buffer non-empty post-push).
|
||||
//! 3. After N steps with N × b_size ≤ capacity, `replay.len() ==
|
||||
//! N × b_size` (no replacement). After N steps with N × b_size
|
||||
//! > capacity, `replay.len() == capacity` (ring-with-replacement
|
||||
//! capped at capacity).
|
||||
//!
|
||||
//! Per `pearl_tests_must_prove_not_lock_observations`: asserts
|
||||
//! buffer-mechanism invariants (length growth, sample size), NOT
|
||||
//! observed Q values or losses — those vary across runs and lock
|
||||
//! the test against any future change to Q init or PRNG state.
|
||||
//!
|
||||
//! Run with:
|
||||
//! `cargo test -p ml-alpha --test r7d_per_wiring -- --ignored --nocapture`
|
||||
|
||||
use ml_alpha::cfc::snap_features::Mbp10RawInput;
|
||||
use ml_alpha::trainer::integrated::{IntegratedTrainer, IntegratedTrainerConfig};
|
||||
use ml_alpha::trainer::perception::PerceptionTrainerConfig;
|
||||
use ml_backtesting::sim::LobSimCuda;
|
||||
use ml_core::device::MlDevice;
|
||||
|
||||
fn synthetic_window(seq_len: usize, base_mid: f32) -> Vec<Mbp10RawInput> {
|
||||
let mut out = Vec::with_capacity(seq_len);
|
||||
let mut prev_mid = base_mid;
|
||||
let mut ts_ns = 1_000_000_u64;
|
||||
for _ in 0..seq_len {
|
||||
let next_mid = prev_mid + 0.25;
|
||||
let mut bid_px = [0.0_f32; 10];
|
||||
let mut bid_sz = [0.0_f32; 10];
|
||||
let mut ask_px = [0.0_f32; 10];
|
||||
let mut ask_sz = [0.0_f32; 10];
|
||||
for i in 0..10 {
|
||||
bid_px[i] = next_mid - 0.125 - 0.25 * i as f32;
|
||||
ask_px[i] = next_mid + 0.125 + 0.25 * i as f32;
|
||||
bid_sz[i] = 10.0;
|
||||
ask_sz[i] = 10.0;
|
||||
}
|
||||
let prev_ts = ts_ns;
|
||||
ts_ns += 20_000_000;
|
||||
out.push(Mbp10RawInput {
|
||||
bid_px,
|
||||
bid_sz,
|
||||
ask_px,
|
||||
ask_sz,
|
||||
prev_mid,
|
||||
trade_signed_vol: 0.0,
|
||||
trade_count: 0,
|
||||
ts_ns,
|
||||
prev_ts_ns: prev_ts,
|
||||
regime: [0.0; 6],
|
||||
});
|
||||
prev_mid = next_mid;
|
||||
}
|
||||
out
|
||||
}
|
||||
|
||||
#[test]
|
||||
#[ignore = "requires CUDA (MlDevice::cuda(0))"]
|
||||
fn r7d_per_buffer_grows_one_per_step_at_b_size_1() {
|
||||
let dev = match MlDevice::cuda(0) {
|
||||
Ok(d) => d,
|
||||
Err(_) => {
|
||||
eprintln!("CUDA 0 not available — skipping r7d_per_wiring");
|
||||
return;
|
||||
}
|
||||
};
|
||||
|
||||
// b_size = 1, small capacity so we can also verify the ring-cap
|
||||
// invariant in the same test (no need for a separate fixture).
|
||||
let per_capacity = 8usize;
|
||||
let cfg = IntegratedTrainerConfig {
|
||||
perception: PerceptionTrainerConfig {
|
||||
seq_len: 4,
|
||||
n_batch: 1,
|
||||
..PerceptionTrainerConfig::default()
|
||||
},
|
||||
per_capacity,
|
||||
..IntegratedTrainerConfig::default()
|
||||
};
|
||||
let mut trainer = IntegratedTrainer::new(&dev, cfg).expect("IntegratedTrainer::new");
|
||||
let mut sim = LobSimCuda::new(1, &dev).expect("LobSimCuda::new");
|
||||
|
||||
// Invariant 1: buffer starts empty.
|
||||
assert_eq!(
|
||||
trainer.replay.len(),
|
||||
0,
|
||||
"PER buffer must start empty before any step_with_lobsim call"
|
||||
);
|
||||
|
||||
// Drive N=5 steps; assert linear growth from 0 → 5.
|
||||
for step in 1..=5usize {
|
||||
let snapshots = synthetic_window(4, 5500.0 + step as f32 * 0.25);
|
||||
let next_snapshots = synthetic_window(4, 5500.0 + (step + 1) as f32 * 0.25);
|
||||
let _stats = trainer
|
||||
.step_with_lobsim(&snapshots, &next_snapshots, &mut sim)
|
||||
.unwrap_or_else(|e| panic!("step_with_lobsim step {step}: {e:?}"));
|
||||
assert_eq!(
|
||||
trainer.replay.len(),
|
||||
step,
|
||||
"PER buffer must grow by exactly 1 per step at b_size=1 (step {step})"
|
||||
);
|
||||
}
|
||||
|
||||
// Invariant 2: sample at α=0.6 returns batch size 1.
|
||||
let sample = trainer.replay.sample_indices(1, 0.6);
|
||||
assert_eq!(
|
||||
sample.len(),
|
||||
1,
|
||||
"sample_indices(1, 0.6) on a non-empty buffer must return 1 index"
|
||||
);
|
||||
assert!(
|
||||
sample[0] < trainer.replay.len(),
|
||||
"sampled index must be in [0, replay.len()) — got {} for len {}",
|
||||
sample[0],
|
||||
trainer.replay.len()
|
||||
);
|
||||
|
||||
// Invariant 3: drive past capacity, buffer caps at `per_capacity`.
|
||||
// Already at len=5; drive 10 more (total 15 transitions pushed,
|
||||
// capacity=8 → buffer ends at exactly 8).
|
||||
for step in 6..=15usize {
|
||||
let snapshots = synthetic_window(4, 5500.0 + step as f32 * 0.25);
|
||||
let next_snapshots = synthetic_window(4, 5500.0 + (step + 1) as f32 * 0.25);
|
||||
trainer
|
||||
.step_with_lobsim(&snapshots, &next_snapshots, &mut sim)
|
||||
.unwrap_or_else(|e| panic!("step_with_lobsim step {step}: {e:?}"));
|
||||
}
|
||||
assert_eq!(
|
||||
trainer.replay.len(),
|
||||
per_capacity,
|
||||
"PER buffer must cap at per_capacity = {per_capacity} (ring-with-replacement)"
|
||||
);
|
||||
|
||||
eprintln!(
|
||||
"R7d G6 OK — buffer grew 0→5→{per_capacity} across 15 push cycles; sample returns expected size"
|
||||
);
|
||||
}
|
||||
@@ -152,17 +152,18 @@ fn default_pyramid_ctx(stream: &Arc<CudaStream>, b_size: usize) -> Result<Pyrami
|
||||
})
|
||||
}
|
||||
|
||||
/// Overwrite a single ISV slot host-side then push the whole isv_d
|
||||
/// to the device. Cheap because the trainer's `isv_d` is small
|
||||
/// (~500 floats).
|
||||
/// Overwrite a single ISV slot via the mapped-pinned buffer's volatile
|
||||
/// per-record write. The GPU sees the new value after the next stream
|
||||
/// sync barrier — no explicit HtoD copy needed
|
||||
/// (per `feedback_no_htod_htoh_only_mapped_pinned`).
|
||||
fn set_isv_slot(
|
||||
trainer: &mut IntegratedTrainer,
|
||||
stream: &Arc<CudaStream>,
|
||||
_stream: &Arc<CudaStream>,
|
||||
slot: usize,
|
||||
value: f32,
|
||||
) -> Result<()> {
|
||||
trainer.isv_host[slot] = value;
|
||||
write_slice_f32_d_pub(stream, &trainer.isv_host, &mut trainer.isv_d)
|
||||
trainer.isv_mapped.write_record(slot, value);
|
||||
Ok(())
|
||||
}
|
||||
|
||||
#[test]
|
||||
|
||||
@@ -11,21 +11,9 @@ fn main() -> Result<(), String> {
|
||||
|
||||
// Per pearl_build_rs_rerun_if_env_changed.md: pair every env::var
|
||||
// with rerun-if-env-changed.
|
||||
//
|
||||
// Arch detection (mirrors crates/ml-alpha/build.rs:detect_arch — the
|
||||
// original "mirror" docstring above was aspirational, B-9 cluster
|
||||
// alpha-rl-mbg2n on H100 caught the gap: ml-alpha picked sm_90 via
|
||||
// CUDA_COMPUTE_CAP but ml-backtesting fell through to default sm_86
|
||||
// → no kernel image error at LobSimCuda::new). Priority:
|
||||
// 1. CUDA_COMPUTE_CAP (numeric, e.g. "90") — set by alpha-rl-template
|
||||
// from nvidia-smi inside the compile pod
|
||||
// 2. FOXHUNT_CUDA_ARCH (sm_-prefixed, e.g. "sm_90") — set by
|
||||
// lob-backtest-sweep-template
|
||||
// 3. nvidia-smi --query-gpu=compute_cap at build time
|
||||
// 4. Default sm_86 (RTX 3050 Ti local dev)
|
||||
let arch = detect_arch();
|
||||
eprintln!(" ml-backtesting: compiling kernels for {arch}");
|
||||
println!("cargo:rerun-if-env-changed=CUDA_COMPUTE_CAP");
|
||||
// Default sm_86 covers RTX 3050 (local dev). Production Argo workflows
|
||||
// override via FOXHUNT_CUDA_ARCH=sm_89 (L40S Ada) or sm_90 (H100).
|
||||
let arch = std::env::var("FOXHUNT_CUDA_ARCH").unwrap_or_else(|_| "sm_86".into());
|
||||
println!("cargo:rerun-if-env-changed=FOXHUNT_CUDA_ARCH");
|
||||
println!("cargo:rerun-if-env-changed=CUDA_PATH");
|
||||
println!("cargo:rerun-if-env-changed=NVCC");
|
||||
@@ -72,38 +60,3 @@ fn main() -> Result<(), String> {
|
||||
}
|
||||
Ok(())
|
||||
}
|
||||
|
||||
/// Returns the `sm_<NN>` arch string, honoring (in order):
|
||||
/// `CUDA_COMPUTE_CAP` numeric env → `FOXHUNT_CUDA_ARCH` sm_-prefixed env
|
||||
/// → `nvidia-smi --query-gpu=compute_cap` → default `sm_86`.
|
||||
fn detect_arch() -> String {
|
||||
// 1. Numeric CUDA_COMPUTE_CAP (e.g. "90") from alpha-rl-template.
|
||||
if let Ok(cap) = std::env::var("CUDA_COMPUTE_CAP") {
|
||||
let trimmed = cap.trim();
|
||||
if !trimmed.is_empty() {
|
||||
return format!("sm_{trimmed}");
|
||||
}
|
||||
}
|
||||
// 2. sm_-prefixed FOXHUNT_CUDA_ARCH (e.g. "sm_90") from lob-backtest-sweep.
|
||||
if let Ok(arch) = std::env::var("FOXHUNT_CUDA_ARCH") {
|
||||
let trimmed = arch.trim();
|
||||
if !trimmed.is_empty() {
|
||||
return trimmed.to_string();
|
||||
}
|
||||
}
|
||||
// 3. Query the GPU on this machine (e.g. "9.0" → "90" → "sm_90").
|
||||
if let Ok(output) = Command::new("nvidia-smi")
|
||||
.args(["--query-gpu=compute_cap", "--format=csv,noheader"])
|
||||
.output()
|
||||
{
|
||||
if output.status.success() {
|
||||
let s = String::from_utf8_lossy(&output.stdout);
|
||||
let cap = s.trim().replace('.', "");
|
||||
if !cap.is_empty() {
|
||||
return format!("sm_{cap}");
|
||||
}
|
||||
}
|
||||
}
|
||||
// 4. Default — RTX 3050 Ti local dev.
|
||||
"sm_86".to_string()
|
||||
}
|
||||
|
||||
@@ -16,13 +16,8 @@ pub const STOP_SLOT_BYTES: usize = 32;
|
||||
/// Bytes per Orders struct (limits[32] + stops[16]).
|
||||
pub const ORDERS_BYTES: usize = MAX_LIMITS * LIMIT_SLOT_BYTES + MAX_STOPS * STOP_SLOT_BYTES;
|
||||
/// Max closed-trade records buffered per backtest before the host
|
||||
/// must drain. Bumped from 1024 → 4096 on 2026-05-31 to prevent
|
||||
/// eval-phase ring wrap at cluster scale (b=1024 × 5000 eval steps
|
||||
/// produces ~342 dones/account mean, ~1000 peak; 4096 gives 4×
|
||||
/// headroom). Memory cost at b=1024: 1024 × 4096 × 40 B = 167 MB
|
||||
/// device (was 41 MB) — comfortable on L40S 48 GB / H100 80 GB.
|
||||
/// See spec `docs/superpowers/specs/2026-05-31-eval-summary-trade-aggregation-design.md`.
|
||||
pub const TRADE_LOG_CAP: usize = 4096;
|
||||
/// must drain. Sized for a single fixture day at ~30s decision cadence.
|
||||
pub const TRADE_LOG_CAP: usize = 1024;
|
||||
/// Bytes per TradeRecord (must match crates/ml-backtesting/src/order.rs).
|
||||
pub const TRADE_RECORD_BYTES: usize = 40;
|
||||
/// Bytes per OpenTradeState (kernel-side tracking of currently-open entry).
|
||||
|
||||
@@ -1515,132 +1515,6 @@ impl LobSimCuda {
|
||||
Ok(out)
|
||||
}
|
||||
|
||||
/// Read per-backtest cumulative trade counters. Returns a `Vec<u32>`
|
||||
/// of length `n_backtests` where entry `i` is the total number of
|
||||
/// closed-trade records ever pushed to backtest `i`'s ring buffer
|
||||
/// (may exceed `TRADE_LOG_CAP` if the ring wrapped).
|
||||
///
|
||||
/// Used by `alpha_rl_train` to snapshot the eval-phase boundary per
|
||||
/// account: `head_before_per_b[i] = read_per_backtest_trade_counts()[i]`
|
||||
/// at eval start; post-eval `head_after_per_b[i] - head_before_per_b[i]`
|
||||
/// gives the true eval-phase trade count for backtest `i`.
|
||||
///
|
||||
/// Uses mapped-pinned staging per `feedback_no_htod_htoh_only_mapped_pinned`:
|
||||
/// allocate a `MappedRecordBuffer<u32>`, DtoD copy from
|
||||
/// `trade_log_head_d` into its dev_ptr, sync, read via host_ptr.
|
||||
///
|
||||
/// Replaces the (`head_before_eval = read_total_trade_count()`,
|
||||
/// `all_records = read_trade_records(0)`) pattern that conflated
|
||||
/// aggregate counts with per-account ring contents and produced
|
||||
/// meaningless eval_summary metrics — see spec
|
||||
/// `docs/superpowers/specs/2026-05-31-eval-summary-trade-aggregation-design.md`.
|
||||
pub fn read_per_backtest_trade_counts(&self) -> Result<Vec<u32>> {
|
||||
use ml_alpha::pinned_mem::MappedRecordBuffer;
|
||||
use ml_alpha::trainer::raw_launch::raw_memcpy_dtod_async;
|
||||
let raw_stream = self.stream.cu_stream();
|
||||
let staging: MappedRecordBuffer<u32> = unsafe { MappedRecordBuffer::new(self.n_backtests) }
|
||||
.map_err(|e| anyhow::anyhow!("alloc trade_head staging: {e}"))?;
|
||||
unsafe {
|
||||
raw_memcpy_dtod_async(
|
||||
staging.dev_ptr,
|
||||
self.trade_log_head_d.raw_ptr(),
|
||||
self.n_backtests * std::mem::size_of::<u32>(),
|
||||
raw_stream,
|
||||
)
|
||||
.map_err(|e| anyhow::anyhow!("DtoD trade_log_head: {e:?}"))?;
|
||||
}
|
||||
self.stream
|
||||
.synchronize()
|
||||
.context("sync after trade_log_head DtoD")?;
|
||||
let mut out = Vec::with_capacity(self.n_backtests);
|
||||
for i in 0..self.n_backtests {
|
||||
// SAFETY: mapped-pinned host_ptr is valid for [0, len); sync
|
||||
// above ensures the DtoD writes are visible to host.
|
||||
unsafe {
|
||||
out.push(std::ptr::read_volatile(staging.host_ptr.add(i)));
|
||||
}
|
||||
}
|
||||
Ok(out)
|
||||
}
|
||||
|
||||
/// Read all backtests' trade record rings. Returns
|
||||
/// `Vec<Vec<TradeRecord>>` of length `n_backtests`; entry `i` is the
|
||||
/// most recent up-to-`TRADE_LOG_CAP` records for backtest `i` (oldest
|
||||
/// dropped on wrap).
|
||||
///
|
||||
/// Uses mapped-pinned staging for both the per-backtest head counters
|
||||
/// and the trade-log payload (per `feedback_no_htod_htoh_only_mapped_pinned`).
|
||||
/// At cluster scale (b=1024, cap=4096) the payload staging buffer
|
||||
/// is 167 MB — allocated once per call (end-of-run). Acceptable
|
||||
/// because this is invoked once per fold-eval, not in the hot path.
|
||||
///
|
||||
/// Callers compose this with
|
||||
/// [`read_per_backtest_trade_counts`](Self::read_per_backtest_trade_counts)
|
||||
/// to identify each account's eval-phase-only slice.
|
||||
pub fn read_trade_records_all(
|
||||
&self,
|
||||
) -> Result<Vec<Vec<crate::order::TradeRecord>>> {
|
||||
use ml_alpha::pinned_mem::MappedRecordBuffer;
|
||||
use ml_alpha::trainer::raw_launch::raw_memcpy_dtod_async;
|
||||
let raw_stream = self.stream.cu_stream();
|
||||
let rec_bytes = crate::lob::TRADE_RECORD_BYTES;
|
||||
let cap = crate::lob::TRADE_LOG_CAP;
|
||||
let payload_len_u8 = self.n_backtests * cap * rec_bytes;
|
||||
|
||||
let head_staging: MappedRecordBuffer<u32> =
|
||||
unsafe { MappedRecordBuffer::new(self.n_backtests) }
|
||||
.map_err(|e| anyhow::anyhow!("alloc head staging: {e}"))?;
|
||||
let payload_staging: MappedRecordBuffer<u8> =
|
||||
unsafe { MappedRecordBuffer::new(payload_len_u8) }
|
||||
.map_err(|e| anyhow::anyhow!("alloc payload staging ({payload_len_u8} B): {e}"))?;
|
||||
|
||||
unsafe {
|
||||
raw_memcpy_dtod_async(
|
||||
head_staging.dev_ptr,
|
||||
self.trade_log_head_d.raw_ptr(),
|
||||
self.n_backtests * std::mem::size_of::<u32>(),
|
||||
raw_stream,
|
||||
)
|
||||
.map_err(|e| anyhow::anyhow!("DtoD trade_log_head: {e:?}"))?;
|
||||
raw_memcpy_dtod_async(
|
||||
payload_staging.dev_ptr,
|
||||
self.trade_log_d.raw_ptr(),
|
||||
payload_len_u8,
|
||||
raw_stream,
|
||||
)
|
||||
.map_err(|e| anyhow::anyhow!("DtoD trade_log payload: {e:?}"))?;
|
||||
}
|
||||
self.stream
|
||||
.synchronize()
|
||||
.context("sync after trade_log DtoD")?;
|
||||
|
||||
let mut out = Vec::with_capacity(self.n_backtests);
|
||||
for b in 0..self.n_backtests {
|
||||
// SAFETY: mapped-pinned host_ptr is valid + post-sync the
|
||||
// device-side DtoD writes are visible to the host.
|
||||
let head = unsafe { std::ptr::read_volatile(head_staging.host_ptr.add(b)) } as usize;
|
||||
let n_to_read = head.min(cap);
|
||||
let off = b * cap * rec_bytes;
|
||||
let mut records = Vec::with_capacity(n_to_read);
|
||||
for i in 0..n_to_read {
|
||||
let rec_off = off + i * rec_bytes;
|
||||
// SAFETY: bounds-checked above (i < n_to_read ≤ cap; rec_off + rec_bytes ≤ payload_len_u8).
|
||||
let bytes: [u8; crate::lob::TRADE_RECORD_BYTES] = unsafe {
|
||||
let mut buf = [0u8; crate::lob::TRADE_RECORD_BYTES];
|
||||
for k in 0..rec_bytes {
|
||||
buf[k] = std::ptr::read_volatile(payload_staging.host_ptr.add(rec_off + k));
|
||||
}
|
||||
buf
|
||||
};
|
||||
let r: crate::order::TradeRecord =
|
||||
bytemuck::pod_read_unaligned(&bytes);
|
||||
records.push(r);
|
||||
}
|
||||
out.push(records);
|
||||
}
|
||||
Ok(out)
|
||||
}
|
||||
|
||||
/// Read back the Pos state for a specific backtest.
|
||||
pub fn read_pos(&self, backtest_idx: usize) -> Result<PosFlat> {
|
||||
anyhow::ensure!(
|
||||
|
||||
Reference in New Issue
Block a user