Files
foxhunt/crates/ml-alpha/cuda/dqn_distributional_q.cu
jgrusewski 5f272db5c0 feat(rl): /B normalize loss reporting — batch-invariant JSONL metrics
PPO forward loss and DQN CE loss atomicAdd now divide by B before
accumulation. Loss values in JSONL are now means, not sums — same
magnitude regardless of batch size.

l_q: thousands → 26, l_pi: thousands → 18 at b=16.
Same values expected at b=256 and b=1024.

Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
2026-05-26 23:41:03 +02:00

367 lines
17 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 = (BWD_BLOCK_SIZE = 256, 1, 1).
// One thread per (action, atom) pair (231 live threads,
// 25 padding). Softmax uses shared-memory reduce (21
// atoms for the taken action). Loss uses a 256-wide
// block-level tree-reduce in shared memory. 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 (action, atom)
// pair plus padding threads for occupancy.
//
// Block layout (occupancy-optimised):
// grid = (B, 1, 1)
// block = (BWD_BLOCK_SIZE = 256, 1, 1)
// Threads 0..N_ACTIONS*Q_N_ATOMS-1 (= 0..230) each own one
// `(action, atom)` slot in grad_logits[batch, :]. The 21 threads
// whose action == actions_taken[batch] participate in the softmax
// reduction via shared memory (they are NOT necessarily contiguous
// in warp layout — see virtual-lane mapping below).
// Threads 231..255 are padding and exit after the block-level loss
// reduction.
//
// The previous kernel used block=(Q_N_ATOMS=21,1,1) — only 21 threads
// per block, wasting ~84% of each SM on L40S (128 CUDA cores/SM).
// This version launches 256 threads, achieving full SM occupancy.
//
// Softmax virtual-lane mapping: the taken action's 21 atoms live at
// thread indices `act * Q_N_ATOMS + 0 .. act * Q_N_ATOMS + 20`. These
// are NOT guaranteed to be in the same warp (e.g. act=1 → threads
// 21..41, which straddle the warp 0/1 boundary at thread 32). The
// reduction therefore uses shared memory (not warp shuffle) which
// works for any action index.
//
// Loss accumulation: block-level tree-reduce in shared memory (no
// atomicAdd per `feedback_no_atomicadd.md`). Thread 0 of each block
// writes the per-batch CE to loss_per_batch[batch] and adds to
// loss_out[0] via atomicAdd on a SINGLE scalar (the cross-batch
// accumulator — see ATOMIC NOTE in the file header; deferred fix).
//
// 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
// actions_taken [B] — integer action index in
// [0, N_ACTIONS) per batch
// B — batch size
// Outputs:
// loss_out [1] — Σ_b L_b (cross-batch atomicAdd — see header).
// loss_per_batch [B] — per-sample CE for PER priority update.
// grad_logits [B × N_ACTIONS × Q_N_ATOMS] — ∂L/∂logits. Non-taken
// actions get zero grad.
// ─────────────────────────────────────────────────────────────────────
#define BWD_BLOCK_SIZE 256
#define BWD_K_OUT (N_ACTIONS * Q_N_ATOMS) // 231
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 tid = threadIdx.x;
if (batch >= B) return;
// Decompose tid into (action, atom) for the BWD_K_OUT=231 live threads.
// Threads tid >= BWD_K_OUT are padding — they participate in the loss
// tree-reduce but do not read/write the logits/grad arrays.
const int my_action = tid / Q_N_ATOMS; // 0..10 for tid < 231
const int my_atom = tid % Q_N_ATOMS; // 0..20 for tid < 231
const int is_live = (tid < BWD_K_OUT);
const int act = actions_taken[batch];
// ── Invalid action guard ─────────────────────────────────────────
// Defensive: zero all grads and loss for this batch sample.
if (act < 0 || act >= N_ACTIONS) {
if (is_live) {
grad_logits[batch * BWD_K_OUT + tid] = 0.0f;
}
if (tid == 0) {
loss_per_batch[batch] = 0.0f;
}
return;
}
const int is_taken = is_live && (my_action == act);
// ── Softmax over the taken action's atoms ────────────────────────
// The 21 threads with my_action == act load the raw logits for the
// taken action. We use shared memory for the max-reduce and
// sum-exp-reduce since the 21 atoms may span two warps depending
// on the action index.
__shared__ float s_logits[Q_N_ATOMS];
__shared__ float s_exp[Q_N_ATOMS];
__shared__ float s_max;
__shared__ float s_sumexp;
if (is_taken) {
const int base_taken = batch * BWD_K_OUT + act * Q_N_ATOMS;
s_logits[my_atom] = logits[base_taken + my_atom];
}
__syncthreads();
// Thread 0 computes max over Q_N_ATOMS=21 — sequential is cheaper
// than a tree-reduce for 21 elements.
if (tid == 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-atom exp, written by the taken-action threads.
if (is_taken) {
s_exp[my_atom] = expf(s_logits[my_atom] - s_max);
}
__syncthreads();
// Thread 0 computes sum-exp.
if (tid == 0) {
float sum = 0.0f;
#pragma unroll
for (int z = 0; z < Q_N_ATOMS; ++z) {
sum += s_exp[z];
}
s_sumexp = sum;
}
__syncthreads();
// ── Gradient writeback (all BWD_K_OUT=231 live threads) ──────────
// Taken-action threads: grad = p[z] - target[z].
// Non-taken-action threads: grad = 0.
if (is_live) {
float grad_val = 0.0f;
if (is_taken) {
const float p_z = s_exp[my_atom] / s_sumexp;
const float t_z = target_dist[batch * Q_N_ATOMS + my_atom];
grad_val = p_z - t_z;
}
grad_logits[batch * BWD_K_OUT + tid] = grad_val;
}
// ── Per-atom CE contribution (taken-action threads only) ─────────
// Each of the 21 taken-action threads computes its
// `-target[z] * log(p[z])` contribution. Padding and non-taken
// threads contribute 0. The block-level tree-reduce below sums
// these into a single per-batch CE.
float my_ce = 0.0f;
if (is_taken) {
const float pz = s_exp[my_atom] / s_sumexp;
const float pz_c = fmaxf(pz, 1e-7f);
const float tz = target_dist[batch * Q_N_ATOMS + my_atom];
my_ce = -tz * logf(pz_c);
}
// ── Block-level tree-reduce for loss (no atomicAdd within block) ─
// BWD_BLOCK_SIZE=256 = 8 warps. Tree-reduce in shared memory.
__shared__ float s_ce[BWD_BLOCK_SIZE];
s_ce[tid] = my_ce;
__syncthreads();
#pragma unroll
for (int stride = BWD_BLOCK_SIZE / 2; stride > 0; stride >>= 1) {
if (tid < stride) {
s_ce[tid] += s_ce[tid + stride];
}
__syncthreads();
}
// Thread 0 writes the per-batch CE and accumulates into loss_out.
// The cross-batch atomicAdd on loss_out is the Phase C deferred
// fix (see ATOMIC NOTE in the file header). grad_logits is never
// atomicAdded.
if (tid == 0) {
const float ce = s_ce[0];
loss_per_batch[batch] = ce;
atomicAdd(loss_out, ce / (float)B);
}
}
// ─────────────────────────────────────────────────────────────────────
// 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;
}