Files
foxhunt/crates/ml-alpha/cuda/rl_dueling_q_decompose_and_bwd.cu
jgrusewski 13bf277cd6 feat(rl): Phase 4.1 — DuelingQHead loss + Bellman target + decompose backward
Implements §4.2/4.3/4.4 + §5 of Phase 4 spec.

Three new CUDA kernels:

1. rl_dueling_q_bellman_target.cu — argmax over target composed_Q at
   s_{t+1}, build target_value = r + γ^n × (1-done) × max_Q. Grid
   (B,1,1), block (N_ACTIONS,1,1). Reads γ via per-sample n_step_gammas
   passed by trainer (matches PER convention).

2. rl_dueling_q_loss_and_grad.cu — scalar Huber loss on
   (target − online_composed_Q[taken]). Emits per-batch loss and
   grad_composed (only taken action has nonzero gradient — this is
   single-Q scalar regression, not distributional CE). Grid (B,1,1),
   block (N_ACTIONS,1,1). Threads write zero into non-taken cells.

3. rl_dueling_q_decompose_and_bwd.cu — decompose grad_composed →
   grad_V + grad_A via mean-subtraction Jacobian:
     grad_V[b]    = Σ_a grad_composed[b, a] = grad_composed[b, a_taken]
     grad_A[b, a] = grad_composed[b, a] − (1/N) × grad_V[b]
   Then computes per-batch weight gradients via outer product with h_t:
     grad_w_v_pb[b, c]    = grad_V[b] × h_t[b, c]
     grad_b_v_pb[b]       = grad_V[b]
     grad_w_a_pb[b, c, a] = grad_A[b, a] × h_t[b, c]
     grad_b_a_pb[b, a]    = grad_A[b, a]
   Grid (B,1,1), block (HIDDEN_DIM,1,1). Thread 0 also writes biases.

DuelingQHead Rust API (3 new methods):
  - build_bellman_target(target_composed_q, r, dones, n_step_gammas, B, out)
  - compute_loss_and_grad(online_composed_q, target_value, actions, B,
                          loss_pb, grad_composed_out)
  - decompose_and_backward_to_weights(h_t, grad_composed, actions, B,
                                      grad_w_v_pb, grad_b_v_pb,
                                      grad_w_a_pb, grad_b_a_pb)

Caller (trainer) is responsible for reduce_axis0 of per-batch grads
→ final weight gradients [HIDDEN_DIM], [1], [HIDDEN_DIM × N_ACTIONS],
[N_ACTIONS]. Will be wired in Phase 4.2.

Soft-update of target weights still TODO — recommend reusing
dqn_target_soft_update kernel pattern in Phase 4.2.

Validated:
  - cargo build --release clean
  - integrated_trainer_smoke (1 step) passes

Per pearl_complement_internal_loss_vs_external_consumers: this loss
path is fully internal to DuelingQHead. No downstream consumer ever
sees composed_Q or grad_composed. The four prior session failure
modes are structurally impossible.

🤖 Generated with [Claude Code](https://claude.com/claude-code)

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

90 lines
3.3 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_dueling_q_decompose_and_bwd.cu — Phase 4 decompose grad + weight grad.
//
// Decompose:
// composed_Q[b, a] = V[b] + A[b, a] (1/N) Σ_a' A[b, a']
//
// Chain rule (only taken action has nonzero grad_composed[b, a]):
// grad_V[b] = Σ_a grad_composed[b, a] = grad_composed[b, a_taken]
// grad_A[b, a] = grad_composed[b, a] (1/N) × grad_V[b]
//
// For a == a_taken: grad_A[b, a] = (1 1/N) × grad_composed[b, a_taken]
// For a ≠ a_taken: grad_A[b, a] = (1/N) × grad_composed[b, a_taken]
//
// After decompose, compute per-batch weight gradients via outer
// product with h_t:
// grad_w_v_pb[b, c] = grad_V[b] × h_t[b, c]
// grad_b_v_pb[b] = grad_V[b]
// grad_w_a_pb[b, c, a] = grad_A[b, a] × h_t[b, c]
// grad_b_a_pb[b, a] = grad_A[b, a]
//
// Caller reduces per-batch grads via reduce_axis0 to final shapes:
// grad_w_v [HIDDEN_DIM]
// grad_b_v [1]
// grad_w_a [HIDDEN_DIM × N_ACTIONS]
// grad_b_a [N_ACTIONS]
//
// Block layout:
// grid = (B, 1, 1)
// block = (HIDDEN_DIM, 1, 1) — one thread per hidden-dim index
// Each thread loops over N_ACTIONS to write A weights, plus the
// single V weight. Thread 0 additionally writes grad_b_v_pb +
// grad_b_a_pb (small).
//
// Per feedback_no_atomicadd: sole-writer per (b, c, a) and (b, c) cells.
#define HIDDEN_DIM 128
#define N_ACTIONS 11
extern "C" __global__ void rl_dueling_q_decompose_and_weight_grad(
const float* __restrict__ h_t, // [B × HIDDEN_DIM]
const float* __restrict__ grad_composed, // [B × N_ACTIONS] (only taken cell nonzero)
const int* __restrict__ actions_taken, // [B]
int B,
float* __restrict__ grad_w_v_pb, // [B × HIDDEN_DIM]
float* __restrict__ grad_b_v_pb, // [B]
float* __restrict__ grad_w_a_pb, // [B × HIDDEN_DIM × N_ACTIONS]
float* __restrict__ grad_b_a_pb // [B × N_ACTIONS]
) {
const int b = blockIdx.x;
const int c = threadIdx.x;
if (b >= B) return;
if (c >= HIDDEN_DIM) return;
int a_t = actions_taken[b];
if (a_t < 0) a_t = 0;
if (a_t >= N_ACTIONS) a_t = 0;
// grad_composed is nonzero only at a == a_t.
const float gc_taken = grad_composed[b * N_ACTIONS + a_t];
// grad_V[b] = Σ_a grad_composed[b, a] = gc_taken (others are 0).
const float grad_v = gc_taken;
// h_t[b, c].
const float h_bc = h_t[b * HIDDEN_DIM + c];
// Per-batch V weight grad.
grad_w_v_pb[b * HIDDEN_DIM + c] = grad_v * h_bc;
// Per-batch A weight grad (one thread writes N_ACTIONS values).
// grad_A[b, a] = grad_composed[b, a] (1/N) × grad_V[b]
// = (a == a_t ? gc_taken : 0) (1/N) × gc_taken
const float inv_N = 1.0f / (float)N_ACTIONS;
#pragma unroll
for (int a = 0; a < N_ACTIONS; ++a) {
const float grad_a = (a == a_t ? gc_taken : 0.0f) - inv_N * gc_taken;
// Layout: grad_w_a_pb[b, c, a] = h_bc * grad_a
grad_w_a_pb[b * HIDDEN_DIM * N_ACTIONS + c * N_ACTIONS + a] = h_bc * grad_a;
}
// Thread 0 writes biases (small).
if (c == 0) {
grad_b_v_pb[b] = grad_v;
#pragma unroll
for (int a = 0; a < N_ACTIONS; ++a) {
const float grad_a = (a == a_t ? gc_taken : 0.0f) - inv_N * gc_taken;
grad_b_a_pb[b * N_ACTIONS + a] = grad_a;
}
}
}