Files
foxhunt/crates/ml-alpha/cuda/rl_q_pi_distill_grad.cu
jgrusewski d3175711b9 feat(rl): SP20 P4 — N_ACTIONS 9→11 with HalfFlat actions
Action enum extended:
  a9  = HalfFlatLong   (close ⌈|pos|/2⌉ of long position, no-op if not long)
  a10 = HalfFlatShort  (close ⌈|pos|/2⌉ of short position, no-op if not short)

`actions_to_market_targets.cu` extended with a9/a10 handlers:
  HalfFlatLong (pos > 0):  side=1 sell, size=max(1, (position_lots+1)/2)
  HalfFlatShort (pos < 0): side=0 buy,  size=max(1, (|position_lots|+1)/2)

Round-up division ensures min 1 lot closes — single-lot positions
fully close on HalfFlat (the half rounds up to 1).

N_ACTIONS=9 → 11 propagated to all 10 .cu kernels:
  argmax_expected_q, bellman_target_projection, dqn_distributional_q,
  log_pi_at_action, ppo_clipped_surrogate, rl_action_kernel,
  rl_entropy_coef_controller, rl_pi_action_kernel, rl_q_pi_agree_b,
  rl_q_pi_distill_grad

Rust-side N_ACTIONS const bumped to 11 in src/rl/common.rs.

CLI alpha_rl_train.rs action_hist + windowed_act_hist refactored
to reference `N_ACTIONS` const instead of literal 11. Caught DURING
this commit's dogfood: an intermediate state had `[0u32; 11]` but
left `(0..9).contains(&a)` unchanged — HalfFlat samples silently
dropped (a9/a10 showed 0% in diag despite P_MIN=0.02 floor
guaranteeing 2% each). Fix uses N_ACTIONS const everywhere; new
pearl `feedback_use_consts_not_literals_for_structural_dims`
codifies the meta-pattern (Rust code mirroring kernel structural
dims must reference the const, NEVER duplicate the literal —
audit-isv only scans .cu files, this class of bug is currently
unaudited in .rs).

Audits PASS:
  audit-isv: all kernel #defines allowlisted (BOOK_LEVELS,
             ACTION_*, structural dims)
  audit-wiring: all 4 actions in manifest (TrailTighten, TrailLoosen,
                HalfFlatLong, HalfFlatShort) have consumers

Local 1k-step smoke (RTX 3050 Ti, 13.5s):
  * Exit 0, 0 NaN/inf, 16000/16000 samples accounted for
  * Action distribution: all 11 used in 7-11% range
  * HalfFL=7.99%, HalfFS=7.51% — π samples them under multinomial
  * Trail=19.34% — agent continues to value trail-stop actions

Trail-stop check (rl_trail_stop_check.cu) currently still routes
force-close through a3/a4 (FlatFromLong/Short) rather than per-unit
partial-flat via a9/a10. That routing upgrade is SP20 P5b follow-up
work — it requires the per-unit close_unit_index buffer wiring per
spec §3 P5. Adding a9/a10 to the action space is the foundational
prerequisite; consumer kernel uses come with P5b.

Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
2026-05-24 17:17:33 +02:00

178 lines
7.2 KiB
Plaintext
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
// rl_q_pi_distill_grad.cu — Q→π KL distillation gradient kernel.
//
// Audit 2026-05-24 (vj5f6 follow-up): the C51 V_MAX lift made Q
// distributional learning calibrated to actual reward magnitudes
// (l_q dropped 100×), but reward economics didn't change because
// per Option B (`pearl_q_thompson_actor_makes_pi_dead_weight`), π
// drives action selection via multinomial sample of softmax(pi_logits)
// and is trained by PPO surrogate using advantage = returns - V.
// V regression doesn't benefit from C51 atom-span calibration, so
// Q's improved knowledge stays trapped in the critic.
//
// Fix: add a KL distillation term to the policy loss that pulls π
// toward a Boltzmann distribution over Q's expected action values:
//
// E_Q[s,a] = sum_z(softmax(q_logits[s,a,*])[z] × atom_supports[z])
// π_target = softmax(E_Q[s,*] / τ) over actions
// L_distill = KL(π_target || π_new) (forward KL)
// ∂L/∂logits = λ × (π_new(a) - π_target(a)) (xent-like)
//
// The kernel ADDS this gradient to pi_grad_logits (additive — does
// not overwrite the existing PPO surrogate gradient). PPO retains
// the dominant learning signal; distill is a soft bias toward Q's
// argmax. Forward KL chosen over reverse so π_target's high-prob
// actions (Q-preferred) dominate the gradient; reverse KL would
// just keep π broad.
//
// Block layout: one block per batch, N_ACTIONS threads. Each thread
// handles one action's E_Q + softmax row + final gradient write.
// Shared mem holds the E_Q vector + π_target + π_new for the
// per-block softmax reductions.
//
// Per `feedback_no_atomicadd`: shared-mem reductions only, no atomics.
// Per `feedback_cpu_is_read_only`: all state in ISV / device buffers.
#include <stdint.h>
#define N_ACTIONS 11
#define Q_N_ATOMS 21
#define RL_Q_DISTILL_LAMBDA_INDEX 486
#define RL_Q_DISTILL_TEMPERATURE_INDEX 487
#define RL_Q_DISTILL_KL_EMA_INDEX 488
// audit-isv 2026-05-24: KL_EMA_ALPHA was hardcoded 0.05f — caught
// by scripts/audit-isv.sh on the first manifest-driven dogfood
// run. ISV-resident now per SP20 §0.1.
#define RL_Q_DISTILL_KL_EMA_ALPHA_INDEX 493
extern "C" __global__ void rl_q_pi_distill_grad(
const float* __restrict__ q_logits, // [B × N_ACTIONS × Q_N_ATOMS]
const float* __restrict__ pi_logits, // [B × N_ACTIONS]
const float* __restrict__ atom_supports, // [Q_N_ATOMS]
float* __restrict__ isv, // ISV bus (RW — KL_ema)
float* __restrict__ pi_grad_logits, // [B × N_ACTIONS] — ADD to
int B
) {
const int b = blockIdx.x;
const int a = threadIdx.x;
if (b >= B || a >= N_ACTIONS) return;
const float lambda = isv[RL_Q_DISTILL_LAMBDA_INDEX];
const float tau = isv[RL_Q_DISTILL_TEMPERATURE_INDEX];
// Skip work if distillation is disabled (λ=0). Still need to
// syncthreads to keep the block lockstep — but a single read +
// early return on all threads is safe since they all read the
// same λ. (Actually, returning here means subsequent
// __syncthreads in the kernel hang; safer to keep going with
// grad=0 contribution.)
const bool active = (lambda > 0.0f);
// ── Step 1: each thread computes E_Q for its OWN action ─────
//
// E_Q[a] = sum_z(softmax(q_logits[b,a,*])[z] × atom_supports[z])
//
// Numerically-stable softmax: subtract max before exp.
__shared__ float s_eq[N_ACTIONS];
const int q_base = (b * N_ACTIONS + a) * Q_N_ATOMS;
float max_q = q_logits[q_base];
#pragma unroll
for (int z = 1; z < Q_N_ATOMS; ++z) {
max_q = fmaxf(max_q, q_logits[q_base + z]);
}
float sum_exp = 0.0f;
float probs_local[Q_N_ATOMS];
#pragma unroll
for (int z = 0; z < Q_N_ATOMS; ++z) {
probs_local[z] = expf(q_logits[q_base + z] - max_q);
sum_exp += probs_local[z];
}
const float inv_sum = (sum_exp > 1e-9f) ? (1.0f / sum_exp)
: (1.0f / (float)Q_N_ATOMS);
float e_q = 0.0f;
#pragma unroll
for (int z = 0; z < Q_N_ATOMS; ++z) {
e_q += probs_local[z] * inv_sum * atom_supports[z];
}
s_eq[a] = e_q;
__syncthreads();
// ── Step 2: thread 0 builds π_target = softmax(E_Q / τ) ─────
__shared__ float s_pi_target[N_ACTIONS];
if (a == 0) {
float max_eq = s_eq[0];
#pragma unroll
for (int i = 1; i < N_ACTIONS; ++i) max_eq = fmaxf(max_eq, s_eq[i]);
const float tau_safe = fmaxf(tau, 1e-3f); // guard against τ=0
float total = 0.0f;
#pragma unroll
for (int i = 0; i < N_ACTIONS; ++i) {
s_pi_target[i] = expf((s_eq[i] - max_eq) / tau_safe);
total += s_pi_target[i];
}
const float inv = (total > 1e-9f) ? (1.0f / total)
: (1.0f / (float)N_ACTIONS);
#pragma unroll
for (int i = 0; i < N_ACTIONS; ++i) s_pi_target[i] *= inv;
}
__syncthreads();
// ── Step 3: thread 0 builds π_new = softmax(pi_logits) ──────
__shared__ float s_pi_new[N_ACTIONS];
if (a == 0) {
const int pi_base = b * N_ACTIONS;
float max_pi = pi_logits[pi_base];
#pragma unroll
for (int i = 1; i < N_ACTIONS; ++i) {
max_pi = fmaxf(max_pi, pi_logits[pi_base + i]);
}
float total = 0.0f;
#pragma unroll
for (int i = 0; i < N_ACTIONS; ++i) {
s_pi_new[i] = expf(pi_logits[pi_base + i] - max_pi);
total += s_pi_new[i];
}
const float inv = (total > 1e-9f) ? (1.0f / total)
: (1.0f / (float)N_ACTIONS);
#pragma unroll
for (int i = 0; i < N_ACTIONS; ++i) s_pi_new[i] *= inv;
}
__syncthreads();
// ── Step 4: ADD distill gradient. ───────────────────────────
//
// ∂KL/∂logits[a] = λ × (π_new(a) - π_target(a))
//
// Additive so PPO's surrogate gradient (already written) keeps
// its contribution. Each thread writes one action's grad slot.
if (active) {
const int pi_idx = b * N_ACTIONS + a;
pi_grad_logits[pi_idx] += lambda * (s_pi_new[a] - s_pi_target[a]);
}
// ── Step 5 (diag): thread 0 of block 0 EMAs the per-batch
// mean KL(π_target || π_new) into ISV. Done by block 0 only so
// we don't race across the grid; we reduce within this block
// first then commit.
__shared__ float s_kl;
if (a == 0) {
float kl = 0.0f;
#pragma unroll
for (int i = 0; i < N_ACTIONS; ++i) {
const float pt = s_pi_target[i];
const float pn = fmaxf(s_pi_new[i], 1e-12f);
if (pt > 1e-12f) kl += pt * logf(pt / pn);
}
s_kl = kl;
}
__syncthreads();
if (b == 0 && a == 0) {
const float kl_prev = isv[RL_Q_DISTILL_KL_EMA_INDEX];
const float alpha = isv[RL_Q_DISTILL_KL_EMA_ALPHA_INDEX];
const float kl_new = (kl_prev == 0.0f) ? s_kl
: (1.0f - alpha) * kl_prev + alpha * s_kl;
isv[RL_Q_DISTILL_KL_EMA_INDEX] = kl_new;
}
}