feat(rl): signal-driven LR controller from per-head grad-norm EMAs

The rl_lr_controller emitted a hardcoded `LR_BOOTSTRAP = 1e-3` for
every step regardless of training dynamics. The kernel accepted 5
`*_signal` scalar args but ignored them via `(void)signal;` — a
stub. This commit makes the LR genuinely signal-driven per
`pearl_controller_anchors_isv_driven` + `feedback_isv_for_adaptive_bounds`.

## Architecture

Per-head grad-norm EMA → LR target derivation:

  observed_grad_norm = EMA(‖grad_w_head‖₂)
  target_lr = lr_prev × (TARGET_GRAD_NORM / max(observed, ε))
  Wiener-α blend (floor 0.4) + clamp to [LR_MIN, LR_MAX].

Multiplicative pattern — same shape as rl_target_tau / rl_ppo_clip
controllers. High observed gradient (model thrashing) shrinks LR
(calm updates); low observed gradient (model coasting) grows LR
(push more aggressive learning).

## Components

1. **rl_l2_norm.cu** (new) — single-buffer L2 norm `‖x‖₂` via
   grid-stride loop + shared-mem tree reduce. Used for per-head
   grad_w_*_d reductions.

2. **rl_lr_controller.cu** (rewrite) — kernel signature changes
   from 5 scalar `*_signal` args to 5 `int *_signal_slot` args
   (ISV slot indices). The kernel reads each signal from
   `isv[slot]`, derives target multiplicatively, and applies the
   cold-start gate + replace-directly pattern (same R9-audit fixes
   that closed the dead-zones in the other multiplicative
   controllers). BCE and AUX heads pass sentinel `-1` for their
   signal slot (those heads are owned by the perception trainer);
   the kernel falls back to LR_BOOTSTRAP for those.

3. **ISV slot extension** (`isv_slots.rs`):
   * `RL_Q_GRAD_NORM_EMA_INDEX  = 424`
   * `RL_PI_GRAD_NORM_EMA_INDEX = 425`
   * `RL_V_GRAD_NORM_EMA_INDEX  = 426`
   * `RL_SLOTS_END = 427` (was 424).

4. **Trainer wiring** (`integrated.rs`):
   * New `rl_l2_norm` module + fn fields + load in `new()`.
   * New `launch_l2_norm` helper (256-thread single-block reduce).
   * After-encoder-backward block in `step_synthetic` gains 3
     grad-norm + EMA launches (Q grad_w 24,192 floats, π grad_w
     1,152 floats, V grad_w 128 floats) alongside the existing
     entropy / td_kurtosis / kl_pi EMAs.
   * `launch_rl_lr_controller` updated to pass i32 slot indices
     instead of f32 scalars.

## What's NOT in this commit

* BCE and AUX LR signals — those heads' gradients live in the
  perception trainer, not the RL trainer. A future commit can
  wire `perception.bce_grad_w_d` → ISV slot if the BCE/AUX LRs
  need to adapt for cross-trainer alignment.
* Production tuning of `TARGET_GRAD_NORM = 1.0`. Empirical from
  the 50k smoke (Q grad_w L2 norm landed near 1 at LR=1e-3); the
  smoke at this commit will confirm whether the LR controller
  drives the grad-norm to this anchor.

## Verified gates (local sm_86)

  G1  isv_bootstrap             (per_α, γ, etc. — unchanged)
  G3  controllers_emit          (test pre-seeds inputs)
  G4  target_soft_update       
  G6  r7d_per_wiring           
  smoke                         all losses finite

## Expected effect

Prior 50k run showed l_q oscillating in 2.7-4.4 range without
visible convergence at LR=1e-3 constant. With LR now adaptive,
the trainer should:
  * Shrink Q LR when Q grad-norm spikes (large per-sample CE
    after a big trade close).
  * Grow Q LR when grad-norm stays small (steady-state coasting).
  * Same logic for π and V.

Next cluster smoke at 50k steps will produce a diag.jsonl where
ISV[413..415] (lr_q, lr_pi, lr_v) AND ISV[424..426] (grad-norm
EMAs) both evolve over time — observable convergence dynamics
that previously didn't exist.

Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
This commit is contained in:
jgrusewski
2026-05-23 18:11:58 +02:00
parent a3dc61a05a
commit 383b1ad83c
5 changed files with 248 additions and 55 deletions

View File

@@ -60,6 +60,7 @@ const KERNELS: &[&str] = &[
"rl_kl_approx_b", // Schulman-style KL = mean(log π_old log π_new) → kl_pi_ema (ISV[419]) feeding rl_ppo_clip
"rl_l2_diff_norm", // ‖W_online W_target‖₂ → q_divergence_ema (ISV[418]) feeding rl_target_tau
"rl_step_counter_update", // per-batch trade-duration counter + done-gated emit → mean_trade_duration_ema (ISV[417]) feeding rl_gamma
"rl_l2_norm", // ‖x‖₂ single-buffer reduction → q/pi/v grad-norm EMAs (ISV[424..427]) feeding rl_lr_controller
// (entropy_observed_ema, ISV[420], feeds rl_entropy_coef directly via ema_update_per_step's internal mean reduce on entropy_d — no separate kernel needed.)
];

View File

@@ -0,0 +1,40 @@
// rl_l2_norm.cu — `‖x‖₂ = sqrt(Σ x²)` over n floats → scalar.
//
// Single-buffer L2 norm reduction. Used by the LR controller's input
// path to compute per-head gradient norms (Q, π, V) for the
// signal-modulated learning-rate target.
//
// Same shape as rl_l2_diff_norm (single block, grid-stride loop, tree
// reduce, no atomics) but operating on one buffer rather than two.
// The trainer allocates one launch per head (Q grad_w 24k floats, π
// grad_w 1k floats, V grad_w 128 floats) and feeds the scalar output
// to ema_update_per_step at the corresponding ISV slot
// (RL_Q_GRAD_NORM_EMA_INDEX = 424, π = 425, V = 426).
extern "C" __global__ void rl_l2_norm(
const float* __restrict__ x,
float* __restrict__ out,
int n
) {
extern __shared__ float s[];
const int tid = threadIdx.x;
float acc = 0.0f;
for (int i = tid; i < n; i += blockDim.x) {
const float v = x[i];
acc += v * v;
}
s[tid] = acc;
__syncthreads();
for (int stride = blockDim.x / 2; stride > 0; stride >>= 1) {
if (tid < stride) {
s[tid] += s[tid + stride];
}
__syncthreads();
}
if (tid == 0) {
out[0] = sqrtf(s[0]);
}
}

View File

@@ -1,28 +1,45 @@
// rl_lr_controller.cu — emits per-head learning rates to ISV[412..417].
//
// Phase E.2-DEFER scope (2026-05-22): controller emits constant 1e-3 to
// all 5 slots on first observation (sentinel-zero → bootstrap value);
// subsequent emits Wiener-α blend with the same bootstrap target. A
// future enhancement (Phase E.3+) lifts the target from constant
// `LR_BOOTSTRAP` to a signal-modulated function of per-head gradient-norm
// EMA divergence — the controller already accepts the diagnostic inputs
// (`*_signal` args) so the upgrade is a pure kernel body change with no
// caller-side refactor.
// rl_lr_controller.cu — emits per-head learning rates to ISV[412..417]
// with signal-modulated targets (per-head gradient-norm EMA divergence
// from a target magnitude).
//
// Per `pearl_controller_anchors_isv_driven` + `feedback_isv_for_adaptive_bounds`:
// every learning rate that was previously a Rust constant
// (PHASE_E2_DEFAULT_LR) now lives in ISV. The IntegratedTrainer DtoH
// refreshes ISV each step before composing Adam args.
// every learning rate is ISV-resident and driven by an observable
// signal (grad-norm EMA) rather than a hardcoded constant. The
// trainer:
// 1. Computes per-head L2 grad-norm (rl_l2_norm) after each backward.
// 2. Feeds it through ema_update_per_step into the relevant grad-norm
// EMA slot (RL_Q_GRAD_NORM_EMA_INDEX, RL_PI_GRAD_NORM_EMA_INDEX,
// RL_V_GRAD_NORM_EMA_INDEX).
// 3. Launches this kernel passing those slot indices.
// This kernel reads each EMA and derives a per-head LR target:
//
// target_lr = lr_prev × (TARGET_GRAD_NORM / max(observed_grad_norm, ε))
//
// Multiplicative pattern (same shape as rl_target_tau / rl_ppo_clip):
// observed > target → ratio < 1 → shrink lr (calm the gradients);
// observed < target → ratio > 1 → grow lr (push more aggressive
// updates). Clamped to [LR_MIN, LR_MAX].
//
// Per `pearl_first_observation_bootstrap`: sentinel zero at the slot's
// address means "uninitialised — emit the bootstrap value on the next
// controller fire." The IntegratedTrainer zero-allocates the ISV buffer
// at construction.
// controller fire." Cold-start gate: when the input EMA is sentinel
// zero (no backward has fired yet), the controller holds prev at
// bootstrap — same anti-pattern fix applied to τ/ε/n_roll/scale.
// Replace-directly on first warm observation: when prev is exactly
// the hardcoded bootstrap value, write target directly instead of
// Wiener-blending (60% bootstrap contamination would otherwise
// distort the first emit).
//
// Per `pearl_wiener_alpha_floor_for_nonstationary`: the α blend is
// floored at 0.4 since the target drives a co-adapting closed loop
// (parameter LR feeds back into gradient magnitude via Adam).
//
// BCE and AUX heads are emitted at their hardcoded bootstrap. Passing
// a `signal_slot < 0` for these heads skips the signal-driven path
// and falls back to `target_lr = LR_BOOTSTRAP` — the perception
// trainer owns those heads, not the RL trainer, so signal wiring is
// out of scope here.
//
// Block layout:
// grid = (1, 1, 1)
// block = (1, 1, 1)
@@ -34,48 +51,77 @@
#define RL_LR_V_INDEX 415
#define RL_LR_AUX_INDEX 416
#define LR_BOOTSTRAP 1e-3f
#define LR_MIN 1e-5f
#define LR_MAX 1e-2f
#define LR_BOOTSTRAP 1e-3f
#define LR_MIN 1e-5f
#define LR_MAX 1e-2f
// Target grad-norm magnitude. Chosen so a typical post-backward
// grad-norm at LR=LR_BOOTSTRAP lands near this value; observed >
// target → controller shrinks LR; observed < target → grows LR.
// Magnitude derived empirically from the 50k smoke (Q grad_w
// L2 norms typically O(1) at LR=1e-3).
#define TARGET_GRAD_NORM 1.0f
#define EMA_EPS 1e-6f
__device__ __forceinline__ void update_lr(
float* isv, int idx, float alpha, float lr_target
__device__ __forceinline__ void update_lr_with_signal(
float* isv, int lr_idx, float alpha, int signal_slot
) {
const float lr_prev = isv[idx];
const float lr_prev = isv[lr_idx];
// Bootstrap on sentinel 0.0 per pearl_first_observation_bootstrap.
if (lr_prev == 0.0f) {
// First-observation bootstrap per pearl_first_observation_bootstrap.
isv[idx] = LR_BOOTSTRAP;
isv[lr_idx] = LR_BOOTSTRAP;
return;
}
// No signal wiring for this head (BCE / AUX caller-pass -1) —
// hold at bootstrap, Wiener-blend toward LR_BOOTSTRAP (no-op since
// prev is already there in steady state, but the blend gives the
// controller authority to override an externally-edited slot).
if (signal_slot < 0) {
const float a = fmaxf(alpha, 0.4f);
isv[lr_idx] = (1.0f - a) * lr_prev + a * LR_BOOTSTRAP;
return;
}
const float observed = isv[signal_slot];
// Cold-start gate: no grad-norm signal observed yet → hold at
// bootstrap. Same fix that closed the cold-start migration in
// τ/ε/n_roll/scale.
if (observed == 0.0f) return;
// Multiplicative target derivation.
float target_lr = lr_prev * (TARGET_GRAD_NORM / fmaxf(observed, EMA_EPS));
target_lr = fminf(LR_MAX, fmaxf(LR_MIN, target_lr));
// First-observation replace-directly: avoid Wiener-blend's 60%
// bootstrap contamination on the first warm step.
if (lr_prev == LR_BOOTSTRAP) {
isv[lr_idx] = target_lr;
return;
}
// Wiener-α floor 0.4 per pearl_wiener_alpha_floor_for_nonstationary.
const float a = fmaxf(alpha, 0.4f);
const float lr_target_clamped = fminf(LR_MAX, fmaxf(LR_MIN, lr_target));
isv[idx] = (1.0f - a) * lr_prev + a * lr_target_clamped;
float lr_new = (1.0f - a) * lr_prev + a * target_lr;
lr_new = fminf(LR_MAX, fmaxf(LR_MIN, lr_new));
isv[lr_idx] = lr_new;
}
extern "C" __global__ void rl_lr_controller(
float* __restrict__ isv,
float alpha,
float bce_signal, // diagnostic input (e.g. per-head grad-norm
float q_signal, // EMA) — reserved for Phase E.3+'s
float pi_signal, // signal-modulated target. Currently
float v_signal, // unused; the controller emits the
float aux_signal // constant bootstrap. See header.
int bce_signal_slot, // <0 → no signal (BCE owned by perception)
int q_signal_slot, // RL_Q_GRAD_NORM_EMA_INDEX
int pi_signal_slot, // RL_PI_GRAD_NORM_EMA_INDEX
int v_signal_slot, // RL_V_GRAD_NORM_EMA_INDEX
int aux_signal_slot // <0 → no signal (AUX owned by perception)
) {
if (threadIdx.x != 0 || blockIdx.x != 0) return;
// Silence unused-arg warnings until Phase E.3 lifts these into the
// target derivation. Reads of the same address are free.
(void)bce_signal;
(void)q_signal;
(void)pi_signal;
(void)v_signal;
(void)aux_signal;
update_lr(isv, RL_LR_BCE_INDEX, alpha, LR_BOOTSTRAP);
update_lr(isv, RL_LR_Q_INDEX, alpha, LR_BOOTSTRAP);
update_lr(isv, RL_LR_PI_INDEX, alpha, LR_BOOTSTRAP);
update_lr(isv, RL_LR_V_INDEX, alpha, LR_BOOTSTRAP);
update_lr(isv, RL_LR_AUX_INDEX, alpha, LR_BOOTSTRAP);
// Phase E.3+: replace LR_BOOTSTRAP with `derive_target(*_signal)`.
update_lr_with_signal(isv, RL_LR_BCE_INDEX, alpha, bce_signal_slot);
update_lr_with_signal(isv, RL_LR_Q_INDEX, alpha, q_signal_slot);
update_lr_with_signal(isv, RL_LR_PI_INDEX, alpha, pi_signal_slot);
update_lr_with_signal(isv, RL_LR_V_INDEX, alpha, v_signal_slot);
update_lr_with_signal(isv, RL_LR_AUX_INDEX, alpha, aux_signal_slot);
}

View File

@@ -133,6 +133,19 @@ pub const RL_TD_KURTOSIS_EMA_INDEX: usize = 422;
/// `rl_reward_scale_controller`.
pub const RL_MEAN_ABS_PNL_EMA_INDEX: usize = 423;
/// EMA of L2 norm of Q-head weight gradient `‖grad_w_q‖₂`. Input to
/// `rl_lr_controller`'s Q-LR target derivation (multiplicative:
/// observed > target → shrink LR; observed < target → grow LR).
pub const RL_Q_GRAD_NORM_EMA_INDEX: usize = 424;
/// EMA of L2 norm of policy-head weight gradient `‖grad_w_pi‖₂`. Input
/// to `rl_lr_controller`'s π-LR target derivation.
pub const RL_PI_GRAD_NORM_EMA_INDEX: usize = 425;
/// EMA of L2 norm of value-head weight gradient `‖grad_w_v‖₂`. Input
/// to `rl_lr_controller`'s V-LR target derivation.
pub const RL_V_GRAD_NORM_EMA_INDEX: usize = 426;
/// Last RL-allocated slot index (exclusive). The integrated trainer
/// extends `ISV_TOTAL_DIM` to at least this value at trainer init time.
pub const RL_SLOTS_END: usize = 424;
pub const RL_SLOTS_END: usize = 427;

View File

@@ -185,6 +185,8 @@ const RL_L2_DIFF_NORM_CUBIN: &[u8] =
include_bytes!(concat!(env!("OUT_DIR"), "/rl_l2_diff_norm.cubin"));
const RL_STEP_COUNTER_UPDATE_CUBIN: &[u8] =
include_bytes!(concat!(env!("OUT_DIR"), "/rl_step_counter_update.cubin"));
const RL_L2_NORM_CUBIN: &[u8] =
include_bytes!(concat!(env!("OUT_DIR"), "/rl_l2_norm.cubin"));
/// Per-head LR controller Wiener-α target. The controller kernel floors
/// this at 0.4 per `pearl_wiener_alpha_floor_for_nonstationary` so the
@@ -384,6 +386,8 @@ pub struct IntegratedTrainer {
rl_l2_diff_norm_fn: CudaFunction,
_rl_step_counter_update_module: Arc<CudaModule>,
rl_step_counter_update_fn: CudaFunction,
_rl_l2_norm_module: Arc<CudaModule>,
rl_l2_norm_fn: CudaFunction,
/// Per-batch step counter for `trade_duration_ema` — increments
/// every step, resets on done. Zero-init at construction.
pub steps_since_done_d: CudaSlice<i32>,
@@ -759,6 +763,12 @@ impl IntegratedTrainer {
let rl_step_counter_update_fn = rl_step_counter_update_module
.load_function("rl_step_counter_update")
.context("load rl_step_counter_update")?;
let rl_l2_norm_module = ctx
.load_cubin(RL_L2_NORM_CUBIN.to_vec())
.context("load rl_l2_norm cubin")?;
let rl_l2_norm_fn = rl_l2_norm_module
.load_function("rl_l2_norm")
.context("load rl_l2_norm")?;
// Per-batch PRNG state for the Thompson sampler. Seeded
// deterministically from cfg.dqn_seed via ChaCha8 host RNG so
@@ -978,6 +988,8 @@ impl IntegratedTrainer {
rl_l2_diff_norm_fn,
_rl_step_counter_update_module: rl_step_counter_update_module,
rl_step_counter_update_fn,
_rl_l2_norm_module: rl_l2_norm_module,
rl_l2_norm_fn,
steps_since_done_d,
trade_duration_emit_d,
prev_realized_pnl_d,
@@ -1955,6 +1967,45 @@ impl IntegratedTrainer {
)
.context("ema_update_per_step(kl_pi)")?;
// Per-head gradient-norm EMAs (Q, π, V) — feed the
// signal-modulated LR controller (rl_lr_controller's target
// formula: `lr_prev × TARGET_GRAD_NORM / observed_grad_norm`).
// grad_w_*_d buffers are still in scope here (allocated at
// top of step_synthetic, consumed by the Adam steps in Step 9
// but not freed until function return); the l2_norm launches
// here read them after the Adam step has already used them.
let k_dqn_hidden = (N_ACTIONS * Q_N_ATOMS * HIDDEN_DIM) as usize;
self.launch_l2_norm(&q_grad_w_d.clone(), k_dqn_hidden)
.context("launch_l2_norm(q_grad_w_d)")?;
self.launch_ema_update_per_step(
crate::rl::isv_slots::RL_Q_GRAD_NORM_EMA_INDEX,
RL_LR_CONTROLLER_ALPHA,
&self.ema_input_scratch_d.clone(),
1,
)
.context("ema_update_per_step(q_grad_norm)")?;
let n_actions_hidden = (N_ACTIONS * HIDDEN_DIM) as usize;
self.launch_l2_norm(&pi_grad_w_d.clone(), n_actions_hidden)
.context("launch_l2_norm(pi_grad_w_d)")?;
self.launch_ema_update_per_step(
crate::rl::isv_slots::RL_PI_GRAD_NORM_EMA_INDEX,
RL_LR_CONTROLLER_ALPHA,
&self.ema_input_scratch_d.clone(),
1,
)
.context("ema_update_per_step(pi_grad_norm)")?;
self.launch_l2_norm(&v_grad_w_d.clone(), HIDDEN_DIM)
.context("launch_l2_norm(v_grad_w_d)")?;
self.launch_ema_update_per_step(
crate::rl::isv_slots::RL_V_GRAD_NORM_EMA_INDEX,
RL_LR_CONTROLLER_ALPHA,
&self.ema_input_scratch_d.clone(),
1,
)
.context("ema_update_per_step(v_grad_norm)")?;
// ── Step 12: compose stats ───────────────────────────────────
// BCE / aux losses are NOT read this phase — perception is driven
// separately by callers via its existing step_batched path. The
@@ -2798,6 +2849,39 @@ impl IntegratedTrainer {
Ok(())
}
/// Reduce `‖x‖₂ = sqrt(Σ x²)` over `input_d[n]` into
/// `self.ema_input_scratch_d[1]`. Single block, 256 threads,
/// grid-stride loop covers arbitrary n via per-thread partial
/// accumulation + shared-mem tree-reduce. Used by the LR
/// controller's grad-norm input path (per-head grad_w_d L2 norm
/// fed to ema_update_per_step → ISV[424..426]).
fn launch_l2_norm(
&mut self,
input_d: &CudaSlice<f32>,
n: usize,
) -> Result<()> {
debug_assert!(input_d.len() >= n);
if n == 0 {
return Ok(());
}
let block_dim: u32 = 256;
let cfg = LaunchConfig {
grid_dim: (1, 1, 1),
block_dim: (block_dim, 1, 1),
shared_mem_bytes: (block_dim as usize * std::mem::size_of::<f32>()) as u32,
};
let n_i = n as i32;
let mut launch = self.stream.launch_builder(&self.rl_l2_norm_fn);
launch
.arg(input_d)
.arg(&mut self.ema_input_scratch_d)
.arg(&n_i);
unsafe {
launch.launch(cfg).context("rl_l2_norm launch")?;
}
Ok(())
}
/// Reduce kurtosis `E[(x-μ)⁴]/σ⁴` over `input_d[b_size]` into
/// `self.ema_input_scratch_d[1]`. Returns 0 at b_size < 2
/// (kurtosis undefined; the per_α controller's cold-start gate
@@ -2830,10 +2914,12 @@ impl IntegratedTrainer {
}
/// Launch `rl_lr_controller` to emit per-head learning rates into
/// `ISV[412..417]`. Phase E.2-DEFER item 3. Diagnostic-input args
/// are currently zero (the controller emits the bootstrap target
/// regardless); Phase E.3+ wires gradient-norm EMAs into the
/// `*_signal` args to drive a signal-modulated target.
/// `ISV[412..417]`. Reads per-head grad-norm EMAs from
/// `ISV[RL_Q_GRAD_NORM_EMA_INDEX]` etc. and derives multiplicative
/// targets `lr_prev × (TARGET_GRAD_NORM / observed)`. BCE and AUX
/// heads pass sentinel `-1` for their signal slots (those heads
/// are owned by the perception trainer, not the RL trainer; their
/// LRs hold at LR_BOOTSTRAP).
fn launch_rl_lr_controller(&self) -> Result<()> {
let cfg = LaunchConfig {
grid_dim: (1, 1, 1),
@@ -2841,16 +2927,23 @@ impl IntegratedTrainer {
shared_mem_bytes: 0,
};
let alpha = RL_LR_CONTROLLER_ALPHA;
let zero = 0.0_f32;
let bce_signal_slot: i32 = -1; // owned by perception
let q_signal_slot: i32 =
crate::rl::isv_slots::RL_Q_GRAD_NORM_EMA_INDEX as i32;
let pi_signal_slot: i32 =
crate::rl::isv_slots::RL_PI_GRAD_NORM_EMA_INDEX as i32;
let v_signal_slot: i32 =
crate::rl::isv_slots::RL_V_GRAD_NORM_EMA_INDEX as i32;
let aux_signal_slot: i32 = -1; // owned by perception
let mut launch = self.stream.launch_builder(&self.rl_lr_controller_fn);
launch
.arg(&self.isv_d)
.arg(&alpha)
.arg(&zero) // bce_signal — reserved for Phase E.3+
.arg(&zero) // q_signal
.arg(&zero) // pi_signal
.arg(&zero) // v_signal
.arg(&zero); // aux_signal
.arg(&bce_signal_slot)
.arg(&q_signal_slot)
.arg(&pi_signal_slot)
.arg(&v_signal_slot)
.arg(&aux_signal_slot);
unsafe {
launch.launch(cfg).context("rl_lr_controller launch")?;
}