Files
foxhunt/crates/ml-alpha/cuda/dqn_distributional_q.cu
jgrusewski c7ccf0c301 feat(rl): R7d — PER wired + off-policy DQN with stop-grad on encoder
Closes plan A9 (rebuild plan's "PER wiring" R7 scope second half;
R7c-data shipped the first half last commit). The `ReplayBuffer` in
`src/rl/replay.rs` has sat as dead code since Phase C — this commit
makes it load-bearing per `feedback_always_per` ("PER always enabled;
non-PER paths are dead code").

## Architecture: off-policy Q + on-policy PPO + V + stop-grad encoder

Shared-encoder pattern with the canonical off-policy + shared-encoder
discipline: the Q head trains from PER-sampled past transitions,
PPO + V train on current-step on-policy data, and the encoder receives
gradient signal ONLY from PPO + V (and BCE/aux via the perception
trainer's separate `step_batched` path). Standard pattern in SAC,
R2D2, IMPALA.

Stop-grad is implemented by computing Q's `grad_h_t` (via
`backward_to_w_b_h(sampled_h_t, ...)`) but NOT accumulating it into
`grad_h_t_combined_d` — the encoder backward only sees π + V
contributions. Per `feedback_no_hiding` the discarded buffer is
allocated and written (the kernel API requires the writeback target);
the discard is a deliberate design call documented at the
accumulation site.

## Wiring summary

### Kernel: `dqn_distributional_q_bwd`
* New `loss_per_batch [B]` output. Atom 0 of each block writes the
  per-sample CE loss (non-atomic — single writer per batch).
  `loss_out [1]` continues to atomicAdd the scalar sum for the
  diagnostic total. Build.rs cache bust v30.

### `DqnHead::backward_logits` (Rust wrapper)
* New `loss_per_batch: &mut CudaSlice<f32>` arg. Migrated atomically
  in the same commit per `feedback_no_partial_refactor` — only
  caller is the integrated trainer.

### `IntegratedTrainerConfig`
* New `per_capacity: usize` (default 4096, matches `replay.rs` doc
  ceiling for naive O(N) sampling).
* New `per_seed: u64` (default 0x9E37_79B9_7F4A_7C15).
* `Default` impl added so test fixtures forward-compat via
  `..IntegratedTrainerConfig::default()`. All 5 existing test
  fixtures migrated.

### `IntegratedTrainer`
* New fields: `replay: ReplayBuffer`, `sampled_h_t_d`,
  `sampled_h_tp1_d`, `sampled_actions_d`, `sampled_rewards_d`,
  `sampled_dones_d`, `sampled_next_actions_d`, `td_per_sample_d`.
* New methods: `push_to_replay(b_size)` — DtoH per-batch metadata
  (action/reward/done/log_pi_old) + alloc per-transition
  `CudaSlice<f32>(HIDDEN_DIM)` ×2 + DtoD per-batch slice copies +
  push to `ReplayBuffer`. `sample_and_gather(b_size)` — read
  per_α from ISV[405], call `replay.sample_indices`, gather sampled
  transitions' h_t/h_tp1 device payloads via per-batch DtoD into
  `sampled_h_t_d` / `sampled_h_tp1_d`, HtoD upload action/reward/done.

### `step_with_lobsim` orchestration
After `compute_advantage_return` and BEFORE `step_synthetic`:
  1. DtoH full ISV slice to refresh `isv_host` (so PER reads ISV[405]
     for per_α).
  2. `push_to_replay(b_size)` — push current step's transitions.
  3. `sample_and_gather(b_size)` — return `per_indices` for the
     priority update.
  4. `step_synthetic(snapshots)` — runs π + V on current-step h_t,
     Q on SAMPLED h_t (off-policy).
  5. DtoH `td_per_sample_d` → host; `replay.update_priorities(
     per_indices, td_per_sample_host)`.
  6. Target-net soft update (unchanged from R5).

### `step_synthetic` redirects (Q path → sampled, π/V stay on-policy)
* Q forward: `forward(&self.sampled_h_t_d)` (was `h_t_borrow`).
* New: forward online Q on `&self.sampled_h_tp1_d` → local scratch +
  `argmax_expected_q` → `self.sampled_next_actions_d`. The
  Double-DQN argmax MUST be recomputed each step (online net weights
  drift faster than transitions recycle through replay; storing
  argmax at push time would feed stale-action data into the
  projection).
* `forward_target(&self.sampled_h_tp1_d)` (was `&self.h_tp1_d`).
* `select_action_atoms(..., &self.sampled_next_actions_d, ...)`
  (was `&self.next_actions_d`).
* `project_bellman_target(..., &self.sampled_rewards_d,
  &self.sampled_dones_d, ...)` (was `rewards_d` / `dones_d`).
* `backward_logits(..., &self.sampled_actions_d, ...,
  &mut self.td_per_sample_d, ...)` (added per-sample loss output).
* `backward_to_w_b_h(&self.sampled_h_t_d, ...)` (was `h_t_borrow`).
* Q grad_h_t accumulation REMOVED from Step 10 (stop-grad).

## Test: r7d_per_wiring.rs (gate G6)
Three invariants per `pearl_tests_must_prove_not_lock_observations`:
  1. `replay.len()` grows by exactly `b_size` per `step_with_lobsim`
     call (push semantics).
  2. `sample_indices(b_size, α)` returns vec of length `b_size` on a
     non-empty buffer.
  3. Buffer caps at `per_capacity` (ring-with-random-replacement).
Drives 15 steps with `per_capacity=8`, asserts growth 0→5→8 across
the cap boundary.

## Acceptable host traffic this commit adds
* Per-step DtoH of 4 × b_size scalars (action/reward/done/log_pi_old)
  for PER push metadata.
* Per-step DtoH of b_size floats (td_per_sample_d) for
  update_priorities.
* Per-step HtoD of 3 × b_size scalars (sampled action/reward/done)
  for sampled metadata gather.
* Per-step DtoD of 2 × b_size × HIDDEN_DIM floats (per-batch h_t /
  h_tp1 slices) for PER push + gather.
PER bookkeeping is a control-plane operation by design (host-side
priority/index management); the device-side training hot path
(encoder, Q/π/V forward/backward, Adam) stays GPU-pure. GPU sum-tree
+ device-resident transitions are a Phase R-future optimization
flagged in `replay.rs`'s doc.

## What's NOT in this commit
* Q `loss_per_batch [B]` is now wired through `backward_logits` but
  the DtoH happens inside step_with_lobsim (not inside
  step_synthetic). Earlier R7d sketches considered a separate
  `dqn_offpolicy_step` method; the in-step_synthetic redirect
  approach landed because it touches fewer lines + reuses the
  existing scratch buffer allocations + matches the trainer's
  established λ-weighted multi-head pattern. A future refactor
  could split for clarity.

Local sm_86 smoke gates: `cargo test -p ml-alpha --test
r7d_per_wiring -- --ignored --nocapture` (G6) +
`integrated_trainer_smoke` (end-to-end). Cluster smoke deferred to
R9.

Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
2026-05-23 12:59:42 +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 9
#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;
}