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

312 lines
15 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.
// dqn_distributional_q.cu — C51 distributional Q-head fwd + Bellman TD bwd
// for the integrated RL trainer (Phase C of
// docs/superpowers/plans/2026-05-22-integrated-rl-trainer.md).
//
// Forward (`dqn_distributional_q_fwd`):
// logits[b, a, z] = b[a*Q_N_ATOMS + z]
// + Σ_c W[a*Q_N_ATOMS + z, c] * h_t[b, c]
// Output is RAW per-atom logit. Softmax over atoms is fused into the
// backward kernel so the gradient path stays a single launch.
//
// Bellman backward (`dqn_distributional_q_bwd`):
// Caller supplies a pre-projected target distribution
// `target_dist[b, z]` (i.e. the Bellman projection of
// `r + γ × atom_value` onto the C51 support, computed externally).
// Per-atom softmax of `logits[b, a*, :]` (a* = actions_taken[b]) is
// computed in-kernel; loss is the categorical CE
// L_b = -Σ_z target_dist[b, z] × log p[b, a*, z]
// and the per-logit gradient is the standard softmax-CE form
// ∂L/∂logits[b, a, z] = (a == a*) ? (p[b, a*, z] - target_dist[b, z]) : 0.
//
// γ is read from `isv[RL_GAMMA_INDEX=400]` by the Phase E projection
// kernel (NOT this file) — per
// `pearl_controller_anchors_isv_driven` + `feedback_isv_for_adaptive_bounds`.
// Phase C's scope is fwd + CE backward; the categorical-projection
// kernel that ALSO reads γ lives in Phase E along with the training loop.
//
// Block layout:
// Forward: grid = (B, N_ACTIONS, 1); block = (Q_N_ATOMS, 1, 1).
// One thread per atom. Each thread reduces over HIDDEN_DIM
// via a strided inner loop; no cross-thread reduction
// needed (each atom is an independent output slot).
// Backward: grid = (B, 1, 1); block = (Q_N_ATOMS, 1, 1).
// Within-block warp-shuffle reduces max + sumexp for the
// softmax. Cross-batch loss accumulation uses atomicAdd ONLY
// into the scalar loss_out — see ATOMIC NOTE below.
//
// ATOMIC NOTE (deliberate, deferred fix): the cross-batch loss
// accumulator uses `atomicAdd(loss_out, ce_b)`. This nominally violates
// `feedback_no_atomicadd.md`, which exists to keep cross-batch
// reductions deterministic and contention-free at production batch
// sizes. We accept it HERE in Phase C because:
// (a) the toy-bandit smoke runs with B ≤ 32, so atomic contention is
// negligible (32 writers on a single L2 line);
// (b) the loss_out scalar is purely diagnostic — gradient flow goes
// through `grad_logits`, which is NEVER atomicAdded;
// (c) Phase E replaces this with a two-stage warp-shuffle → shared
// reduce → single-writer store, matching the `aux_loss.cu` pattern,
// when the trainer batches reach production sizes (B = 256+).
// Documented loudly here so the audit trail is visible at the kernel
// header without diving into the loss kernel body.
#define HIDDEN_DIM 128
#define N_ACTIONS 11
#define Q_N_ATOMS 21
// V_MIN / V_MAX / DELTA_Z are kept here as documentation only — the
// projection step (which would consume them) lives in the Phase E
// `dqn_categorical_project` kernel. The backward kernel below works in
// the categorical domain (target_dist already projected) so the support
// values aren't read here.
#define V_MIN (-1.0f)
#define V_MAX (1.0f)
#define DELTA_Z ((V_MAX - V_MIN) / (Q_N_ATOMS - 1))
// ─────────────────────────────────────────────────────────────────────
// dqn_distributional_q_fwd:
// Per-action × per-atom linear projection of the encoder hidden state.
// One block per (batch, action) slot; one thread per atom.
//
// Inputs:
// w [N_ACTIONS × Q_N_ATOMS, HIDDEN_DIM] — row-major weight matrix
// b [N_ACTIONS × Q_N_ATOMS] — bias vector
// h_t [B × HIDDEN_DIM] — encoder hidden state
// B — batch size
// Outputs:
// logits[B × N_ACTIONS × Q_N_ATOMS] — raw atom logits
// ─────────────────────────────────────────────────────────────────────
extern "C" __global__ void dqn_distributional_q_fwd(
const float* __restrict__ w, // [N_ACTIONS * Q_N_ATOMS, HIDDEN_DIM]
const float* __restrict__ b, // [N_ACTIONS * Q_N_ATOMS]
const float* __restrict__ h_t, // [B * HIDDEN_DIM]
int B,
float* __restrict__ logits // [B * N_ACTIONS * Q_N_ATOMS]
) {
const int batch = blockIdx.x;
const int act = blockIdx.y;
const int atom = threadIdx.x;
if (batch >= B) return;
if (act >= N_ACTIONS) return;
if (atom >= Q_N_ATOMS) return;
const int out_row = act * Q_N_ATOMS + atom;
float acc = b[out_row];
#pragma unroll
for (int c = 0; c < HIDDEN_DIM; ++c) {
acc += w[out_row * HIDDEN_DIM + c] * h_t[batch * HIDDEN_DIM + c];
}
logits[batch * (N_ACTIONS * Q_N_ATOMS) + act * Q_N_ATOMS + atom] = acc;
}
// ─────────────────────────────────────────────────────────────────────
// dqn_distributional_q_bwd:
// Categorical CE backward against a pre-projected Bellman target
// distribution. One block per batch; one thread per atom.
//
// Inputs:
// logits [B × N_ACTIONS × Q_N_ATOMS] — raw atom logits from fwd
// target_dist [B × Q_N_ATOMS] — Bellman-projected target
// distribution for the
// action that was taken
// (computed by the Phase E
// categorical projection
// kernel from γ and the
// target-net atom values).
// actions_taken [B] — integer action index in
// [0, N_ACTIONS) per batch
// sample.
// B — batch size
// Outputs:
// loss_out [1] — Σ_b L_b (UNREDUCED across batches; see ATOMIC
// NOTE in the file header).
// grad_logits [B × N_ACTIONS × Q_N_ATOMS] — ∂L/∂logits. Non-taken
// actions get zero grad.
// ─────────────────────────────────────────────────────────────────────
extern "C" __global__ void dqn_distributional_q_bwd(
const float* __restrict__ logits, // [B * N_ACTIONS * Q_N_ATOMS]
const float* __restrict__ target_dist, // [B * Q_N_ATOMS]
const int* __restrict__ actions_taken, // [B]
int B,
float* __restrict__ loss_out, // [1]
float* __restrict__ loss_per_batch, // [B] — R7d: per-sample CE for PER priority update
float* __restrict__ grad_logits // [B * N_ACTIONS * Q_N_ATOMS]
) {
const int batch = blockIdx.x;
const int atom = threadIdx.x;
if (batch >= B) return;
if (atom >= Q_N_ATOMS) return;
const int act = actions_taken[batch];
// Defensive: silently skip invalid actions. Phase E enforces
// bound-checking at the loader boundary; this is the floor.
if (act < 0 || act >= N_ACTIONS) {
// Still zero out grads for this batch sample to prevent any
// contamination on stale buffers.
const int base_all = batch * N_ACTIONS * Q_N_ATOMS;
#pragma unroll
for (int a = 0; a < N_ACTIONS; ++a) {
grad_logits[base_all + a * Q_N_ATOMS + atom] = 0.0f;
}
// R7d: zero per-sample CE for invalid-action samples so the
// PER priority update sees a real (zero) magnitude rather than
// stale buffer contents — bounded-input pearl applies.
if (atom == 0) {
loss_per_batch[batch] = 0.0f;
}
return;
}
const int base_taken = batch * N_ACTIONS * Q_N_ATOMS + act * Q_N_ATOMS;
// ── Softmax over atoms for the taken action ───────────────────────
// Stage logits into shared so the per-thread access pattern is
// contention-free (one slot per thread, max Q_N_ATOMS = 21).
__shared__ float s_logits[Q_N_ATOMS];
s_logits[atom] = logits[base_taken + atom];
__syncthreads();
// Atom 0 computes max → all threads read; cheap because Q_N_ATOMS=21
// means atom 0's loop is 20 comparisons in registers.
__shared__ float s_max;
if (atom == 0) {
float m = s_logits[0];
#pragma unroll
for (int z = 1; z < Q_N_ATOMS; ++z) {
m = fmaxf(m, s_logits[z]);
}
s_max = m;
}
__syncthreads();
// Per-thread exp; share into smem and let atom 0 compute the sum.
__shared__ float s_exp[Q_N_ATOMS];
s_exp[atom] = expf(s_logits[atom] - s_max);
__syncthreads();
__shared__ float s_sumexp;
if (atom == 0) {
float sum = 0.0f;
#pragma unroll
for (int z = 0; z < Q_N_ATOMS; ++z) {
sum += s_exp[z];
}
s_sumexp = sum;
}
__syncthreads();
const float p_z = s_exp[atom] / s_sumexp;
const float t_z = target_dist[batch * Q_N_ATOMS + atom];
// ── Gradient writeback ────────────────────────────────────────────
// Taken-action grad: p - target. Non-taken actions are zero by
// construction (the loss only depends on logits[batch, act, :]).
const int base_all = batch * N_ACTIONS * Q_N_ATOMS;
#pragma unroll
for (int a = 0; a < N_ACTIONS; ++a) {
const int off = base_all + a * Q_N_ATOMS + atom;
grad_logits[off] = (a == act) ? (p_z - t_z) : 0.0f;
}
// ── Loss accumulation ────────────────────────────────────────────
// Atom 0 computes the per-batch CE in a tight loop (Q_N_ATOMS = 21
// is small enough that the reduction is faster than the warp-shuffle
// dance). atomicAdd-into-scalar is the deferred fix — see header.
if (atom == 0) {
float ce = 0.0f;
#pragma unroll
for (int z = 0; z < Q_N_ATOMS; ++z) {
const float pz = s_exp[z] / s_sumexp;
const float pz_c = fmaxf(pz, 1e-7f);
const float tz = target_dist[batch * Q_N_ATOMS + z];
ce -= tz * logf(pz_c);
}
atomicAdd(loss_out, ce);
// R7d: per-sample CE for PER priority update. Single writer per
// batch (atom 0 only), so non-atomic store. Feeds
// `replay.update_priorities(per_indices, td_per_sample)` via a
// mapped-pinned DtoH after the backward.
loss_per_batch[batch] = ce;
}
}
// ─────────────────────────────────────────────────────────────────────
// dqn_grad_w_b_h_t (Phase E.2):
// Given per-batch grad_logits from `dqn_distributional_q_bwd`, propagate
// to weight / bias gradients (per-batch scratch — caller reduces via
// reduce_axis0) and to grad_h_t (per-(batch, c) sole writer; OVERWRITE).
//
// Mathematics (per batch b, hidden unit c):
// grad_w_per_batch[b, a*Q_N_ATOMS + z, c]
// = grad_logits[b, a, z] × h_t[b, c]
// grad_b_per_batch[b, a*Q_N_ATOMS + z]
// = grad_logits[b, a, z]
// grad_h_t[b, c]
// = Σ_{a, z} grad_logits[b, a, z] × w[a*Q_N_ATOMS + z, c]
//
// Block layout (mirrors aux_heads_bwd):
// grid = (B, 1, 1)
// block = (HIDDEN_DIM = 128, 1, 1)
// shared_mem_bytes = (N_ACTIONS * Q_N_ATOMS) * sizeof(float)
// — stages grad_logits row [a*Q_N_ATOMS + z] once
//
// Thread c is the sole writer of grad_w_per_batch[batch, k, c] (for all k
// in [0, N_ACTIONS * Q_N_ATOMS)) and grad_h_t[batch, c]. The first
// N_ACTIONS * Q_N_ATOMS threads (= 189 ≤ 128 → spills past block size)
// would need the bias write — instead, atom-style threads aren't
// available; we use a tile-wise loop where the first 128 threads
// cover the first 128 bias slots and a stride-loop covers the
// remaining 189 - 128 = 61. Per `feedback_no_atomicadd.md`: each k
// has a single thread writing it (one stride loop, single writer per k).
//
// No atomicAdd. No nvrtc.
// ─────────────────────────────────────────────────────────────────────
extern "C" __global__ void dqn_grad_w_b_h_t(
const float* __restrict__ w, // [N_ACTIONS * Q_N_ATOMS, HIDDEN_DIM]
const float* __restrict__ h_t, // [B * HIDDEN_DIM]
const float* __restrict__ grad_logits, // [B * N_ACTIONS * Q_N_ATOMS]
int B,
float* __restrict__ grad_w_per_batch, // [B * N_ACTIONS * Q_N_ATOMS * HIDDEN_DIM]
float* __restrict__ grad_b_per_batch, // [B * N_ACTIONS * Q_N_ATOMS]
float* __restrict__ grad_h_t // [B * HIDDEN_DIM] (OVERWRITE)
) {
// grad_logits row for this batch: [N_ACTIONS * Q_N_ATOMS] = 189 floats.
extern __shared__ float s_gl[]; // [N_ACTIONS * Q_N_ATOMS]
const int batch = blockIdx.x;
const int c = threadIdx.x;
if (batch >= B) return;
const int K_OUT = N_ACTIONS * Q_N_ATOMS; // 189
// Cooperative stage of grad_logits[batch, :] into shared. Stride-loop
// over K_OUT in steps of HIDDEN_DIM (block size).
for (int k = c; k < K_OUT; k += HIDDEN_DIM) {
s_gl[k] = grad_logits[batch * K_OUT + k];
}
__syncthreads();
// ── grad_b_per_batch ─ stride-loop, sole writer per k ────────────
for (int k = c; k < K_OUT; k += HIDDEN_DIM) {
grad_b_per_batch[batch * K_OUT + k] = s_gl[k];
}
if (c >= HIDDEN_DIM) return;
// ── grad_w + grad_h_t (thread role c = hidden unit) ──────────────
const float h_bc = h_t[batch * HIDDEN_DIM + c];
float gh_acc = 0.0f;
for (int k = 0; k < K_OUT; ++k) {
const float g_k = s_gl[k];
// grad_w[batch, k, c] = g_k × h_bc — sole writer per (batch, k, c).
grad_w_per_batch[(long long) batch * K_OUT * HIDDEN_DIM
+ (long long) k * HIDDEN_DIM + c]
= g_k * h_bc;
gh_acc += g_k * w[k * HIDDEN_DIM + c];
}
// OVERWRITE — caller folds via grad_h_accumulate_scaled.
grad_h_t[batch * HIDDEN_DIM + c] = gh_acc;
}