feat(rl): FRD layer-1 backward (dW1, db1, dh_t with ReLU mask) — F.3c

Third and final FRD backward stage. Closes the chain from
softmax+CE loss back to the encoder's hidden state h_t.

Kernel `cuda/rl_frd_layer1_bwd.cu`:
  * grid_dim = (B, 1, 1), block_dim = (HIDDEN_DIM=128, 1, 1)
  * Phase 0: threads 0..63 stage dL/dpre_hidden = grad_hidden ×
    1{hidden > 0} into shared mem (the cached post-ReLU `hidden`
    buffer encodes the mask — hidden == 0 ⇔ pre-activation was
    ≤ 0 → ReLU killed it). Same thread also writes db1_per_batch.
  * Phase 1: each thread k (k < 128) writes one row of
    grad_W1_per_batch[b, k, 0..64] (64 writes per thread, no atomics)
  * Phase 2: same thread computes grad_h_t[b, k] =
    Σ_i W1[k, i] × dL/dpre_hidden[b, i]
  * Per-(b, k, i) sole-writer per feedback_no_atomicadd

Rust wiring `FrdHead::layer1_bwd` — takes h_t, hidden (forward cache),
grad_hidden (from layer2_bwd), self.w1_d; writes grad_w1_per_batch,
grad_b1_per_batch, grad_h_t. The grad_h_t buffer becomes the encoder-
upstream gradient that the trainer's grad_h_accumulate kernel folds
into the encoder's gradient with λ_frd scaling (same pattern as Q/π/V
heads — wiring lives in F.4).

Tests (2 new, 10/10 file total):
  * frd_layer1_bwd_finite_diff_w1 — perturbs the W1 slot with MAX
    |analytical gradient| (instead of an arbitrary fixed slot — fp32
    finite-diff is rounding-error-limited so a tiny gradient gives
    misleading rel_err). At max-magnitude slot (k=84, i=55): analytical
    = -0.0451, numerical = -0.0448, rel_err = 5.6e-3 — well within
    1e-2 tolerance (slightly looser than dW2's 5e-3 because dW1
    crosses an extra matmul + the ReLU mask boundary).
  * frd_layer1_bwd_relu_mask_zeros_grad — fixture with h_t = all -1
    produces ~half the hidden slots ReLU-masked (cached hidden = 0).
    For every masked slot i, asserts:
      * db1_per_batch[b, i] == 0 (exact equality — mask is hard 0)
      * dW1_per_batch[b, k, i] == 0 for every k (~32 × 128 = 4096
        slots checked)
    Empirically 32/64 masked, 32/64 active — confirms ReLU mask
    is wired through the chain correctly without leaking gradient
    through dead branches.

F.3 backward chain is now complete end-to-end:
  rl_frd_softmax_ce_grad (F.3a) → rl_frd_layer2_bwd (F.3b) →
  rl_frd_layer1_bwd (F.3c) → grad_h_t (consumed by F.4 wiring)

F.4 wires Adam optimizers for W1/b1/W2/b2 + grad_h_accumulate into
the encoder gradient + loader-side label generation + λ_frd × CE
into stats.l_total.
This commit is contained in:
jgrusewski
2026-05-24 18:40:30 +02:00
parent 91e2c5dc8a
commit 0f75d6bb7b
4 changed files with 366 additions and 0 deletions

View File

@@ -78,6 +78,7 @@ const KERNELS: &[&str] = &[
"rl_frd_fwd", // SP20 P3: Forward-Return-Distribution head fwd — 2-layer MLP [HIDDEN_DIM → FRD_HIDDEN_DIM → FRD_N_HORIZONS × FRD_N_ATOMS]; ReLU hidden cached for bwd; softmax + CE happen in bwd
"rl_frd_softmax_ce_grad", // SP20 P3 F.3a: per-(batch, horizon) softmax + CE loss + dL/dlogits; 1 block per (b, h), 21 threads; label = -1 sentinel masks the row
"rl_frd_layer2_bwd", // SP20 P3 F.3b: FRD head layer-2 backward — dW2 (per-batch scratch), db2 (per-batch scratch), dhidden (per-batch overwrite); 1 block per batch, 64 threads
"rl_frd_layer1_bwd", // SP20 P3 F.3c: FRD head layer-1 backward — dW1 (per-batch scratch), db1 (per-batch scratch), dh_t (per-batch overwrite); applies ReLU mask via cached post-ReLU hidden; 1 block per batch, 128 threads
];
// Cache bust v31 — five new reduce / derive kernels populate the input

View File

@@ -0,0 +1,79 @@
// rl_frd_layer1_bwd.cu — FRD head backward stage 3 (SP20 P3 F.3c).
//
// Given dL/dhidden [B, FRD_HIDDEN_DIM] (from rl_frd_layer2_bwd) and
// the cached post-ReLU hidden [B, FRD_HIDDEN_DIM] (from rl_frd_fwd),
// compute the layer-1 gradients via the chain rule with ReLU mask:
//
// pre_hidden[b, i] = Σ_k h_t[b, k] × W1[k, i] + b1[i]
// hidden[b, i] = ReLU(pre_hidden[b, i])
//
// dL/dpre_hidden[b, i] = dL/dhidden[b, i] × 1{hidden[b, i] > 0}
// dL/dW1[k, i] = Σ_b h_t[b, k] × dL/dpre_hidden[b, i] ← reduce over B
// dL/db1[i] = Σ_b dL/dpre_hidden[b, i] ← reduce over B
// dL/dh_t[b, k] = Σ_i W1[k, i] × dL/dpre_hidden[b, i] ← per-batch overwrite
//
// Per-batch scratch:
// grad_W1_per_batch[b, k, i] = h_t[b, k] × dL/dpre_hidden[b, i]
// grad_b1_per_batch[b, i] = dL/dpre_hidden[b, i]
//
// Block layout: 1 block per batch, HIDDEN_DIM=128 threads.
// * Phase 0: stage post-ReLU `hidden` (64 slots) + dL/dhidden into
// shared, apply ReLU mask in-place to produce dL/dpre_hidden
// (only first 64 threads work; remaining 64 idle).
// * Phase 1: each thread k (k < 128) is the sole writer of:
// - grad_W1_per_batch[b, k, 0..64] (64 writes per thread)
// - grad_h_t[b, k] (one Σ_i across 64 outputs)
// * Phase 2: thread i (i < 64) writes grad_b1_per_batch[b, i].
//
// Per `feedback_no_atomicadd`: per-(b, k, i) sole-writer pattern.
// Per `feedback_cpu_is_read_only`: pure device-side.
#include <stdint.h>
#define HIDDEN_DIM 128
#define FRD_HIDDEN_DIM 64
extern "C" __global__ void rl_frd_layer1_bwd(
const float* __restrict__ h_t, // [B, HIDDEN_DIM]
const float* __restrict__ hidden, // [B, FRD_HIDDEN_DIM] (post-ReLU cache)
const float* __restrict__ grad_hidden, // [B, FRD_HIDDEN_DIM] (from layer2_bwd)
const float* __restrict__ w1, // [HIDDEN_DIM, FRD_HIDDEN_DIM]
int b_size,
float* __restrict__ grad_w1_per_batch, // [B, HIDDEN_DIM, FRD_HIDDEN_DIM]
float* __restrict__ grad_b1_per_batch, // [B, FRD_HIDDEN_DIM]
float* __restrict__ grad_h_t // [B, HIDDEN_DIM]
) {
const int b = blockIdx.x;
const int k = threadIdx.x;
if (b >= b_size || k >= HIDDEN_DIM) return;
// Stage dL/dpre_hidden in shared — apply ReLU mask using the
// cached post-ReLU hidden (hidden > 0 ⇒ mask=1, else mask=0).
__shared__ float s_grad_pre[FRD_HIDDEN_DIM];
if (k < FRD_HIDDEN_DIM) {
const int idx = b * FRD_HIDDEN_DIM + k;
const float h_val = hidden[idx];
const float g_val = grad_hidden[idx];
s_grad_pre[k] = (h_val > 0.0f) ? g_val : 0.0f;
// grad_b1_per_batch[b, i] = dL/dpre_hidden[b, i] — same value.
grad_b1_per_batch[idx] = s_grad_pre[k];
}
__syncthreads();
const float h_t_bk = h_t[b * HIDDEN_DIM + k];
// grad_W1_per_batch[b, k, i] = h_t_bk × s_grad_pre[i]
const int row_off = (b * HIDDEN_DIM + k) * FRD_HIDDEN_DIM;
#pragma unroll
for (int i = 0; i < FRD_HIDDEN_DIM; ++i) {
grad_w1_per_batch[row_off + i] = h_t_bk * s_grad_pre[i];
}
// grad_h_t[b, k] = Σ_i W1[k, i] × s_grad_pre[i]
float acc = 0.0f;
#pragma unroll
for (int i = 0; i < FRD_HIDDEN_DIM; ++i) {
acc += w1[k * FRD_HIDDEN_DIM + i] * s_grad_pre[i];
}
grad_h_t[b * HIDDEN_DIM + k] = acc;
}

View File

@@ -62,6 +62,8 @@ const FRD_SOFTMAX_CE_GRAD_CUBIN: &[u8] =
include_bytes!(concat!(env!("OUT_DIR"), "/rl_frd_softmax_ce_grad.cubin"));
const FRD_LAYER2_BWD_CUBIN: &[u8] =
include_bytes!(concat!(env!("OUT_DIR"), "/rl_frd_layer2_bwd.cubin"));
const FRD_LAYER1_BWD_CUBIN: &[u8] =
include_bytes!(concat!(env!("OUT_DIR"), "/rl_frd_layer1_bwd.cubin"));
/// Per-batch output width: `FRD_N_HORIZONS × FRD_N_ATOMS`. Each batch
/// row contains 3 contiguous horizon blocks of 21 atom logits.
@@ -93,6 +95,8 @@ pub struct FrdHead {
softmax_ce_grad_fn: CudaFunction,
_layer2_bwd_module: Arc<CudaModule>,
layer2_bwd_fn: CudaFunction,
_layer1_bwd_module: Arc<CudaModule>,
layer1_bwd_fn: CudaFunction,
pub w1_d: CudaSlice<f32>, // [HIDDEN_DIM, FRD_HIDDEN_DIM]
pub b1_d: CudaSlice<f32>, // [FRD_HIDDEN_DIM]
@@ -122,6 +126,12 @@ impl FrdHead {
let layer2_bwd_fn = layer2_bwd_module
.load_function("rl_frd_layer2_bwd")
.context("load rl_frd_layer2_bwd fn")?;
let layer1_bwd_module = ctx
.load_cubin(FRD_LAYER1_BWD_CUBIN.to_vec())
.context("load rl_frd_layer1_bwd cubin")?;
let layer1_bwd_fn = layer1_bwd_module
.load_function("rl_frd_layer1_bwd")
.context("load rl_frd_layer1_bwd fn")?;
// Per pearl_scoped_init_seed_for_reproducibility — guard around
// Xavier draws so GPU init helpers downstream see the same RNG.
@@ -159,6 +169,8 @@ impl FrdHead {
softmax_ce_grad_fn,
_layer2_bwd_module: layer2_bwd_module,
layer2_bwd_fn,
_layer1_bwd_module: layer1_bwd_module,
layer1_bwd_fn,
w1_d,
b1_d,
w2_d,
@@ -260,6 +272,63 @@ impl FrdHead {
Ok(())
}
/// Backward stage 3 (F.3c): layer-1 weight gradients with ReLU mask.
///
/// Given `h_t` (encoder hidden, the layer-1 input), `hidden` (post-
/// ReLU cache from forward — used for the ReLU mask), `grad_hidden`
/// (from `layer2_bwd`), and `self.w1_d`, computes:
/// * `grad_w1_per_batch[b, k, i] = h_t[b, k] × dpre_hidden[b, i]`
/// * `grad_b1_per_batch[b, i] = dpre_hidden[b, i]`
/// * `grad_h_t[b, k] = Σ_i W1[k, i] × dpre_hidden[b, i]`
///
/// where `dpre_hidden[b, i] = grad_hidden[b, i] × 1{hidden[b, i] > 0}`
/// is the ReLU-masked upstream gradient.
///
/// `grad_h_t` is the encoder-upstream gradient — the caller's
/// `grad_h_accumulate` kernel folds it into the encoder's grad with
/// a `λ_frd`-scaled accumulator (same pattern as Q / π / V heads).
#[allow(clippy::too_many_arguments)]
pub fn layer1_bwd(
&self,
h_t_d: &CudaSlice<f32>,
hidden_d: &CudaSlice<f32>,
grad_hidden_d: &CudaSlice<f32>,
grad_w1_per_batch_d: &mut CudaSlice<f32>,
grad_b1_per_batch_d: &mut CudaSlice<f32>,
grad_h_t_d: &mut CudaSlice<f32>,
b_size: usize,
) -> Result<()> {
debug_assert_eq!(h_t_d.len(), b_size * HIDDEN_DIM);
debug_assert_eq!(hidden_d.len(), b_size * FRD_HIDDEN_DIM);
debug_assert_eq!(grad_hidden_d.len(), b_size * FRD_HIDDEN_DIM);
debug_assert_eq!(
grad_w1_per_batch_d.len(),
b_size * HIDDEN_DIM * FRD_HIDDEN_DIM
);
debug_assert_eq!(grad_b1_per_batch_d.len(), b_size * FRD_HIDDEN_DIM);
debug_assert_eq!(grad_h_t_d.len(), b_size * HIDDEN_DIM);
let cfg = LaunchConfig {
grid_dim: (b_size as u32, 1, 1),
block_dim: (HIDDEN_DIM as u32, 1, 1),
shared_mem_bytes: 0,
};
let b_size_i = b_size as i32;
let mut launch = self.stream.launch_builder(&self.layer1_bwd_fn);
launch
.arg(h_t_d)
.arg(hidden_d)
.arg(grad_hidden_d)
.arg(&self.w1_d)
.arg(&b_size_i)
.arg(grad_w1_per_batch_d)
.arg(grad_b1_per_batch_d)
.arg(grad_h_t_d);
unsafe {
launch.launch(cfg).context("rl_frd_layer1_bwd launch")?;
}
Ok(())
}
/// Forward pass — fills `logits_out_d [B, FRD_OUT_DIM]` and caches
/// the post-ReLU hidden activations in `hidden_out_d [B,
/// FRD_HIDDEN_DIM]` for the backward kernel.

View File

@@ -518,3 +518,220 @@ fn frd_layer2_bwd_db2_equals_grad_logits() -> Result<()> {
);
Ok(())
}
#[test]
#[ignore = "requires CUDA (MlDevice::cuda(0))"]
fn frd_layer1_bwd_finite_diff_w1() -> Result<()> {
let Some((dev, mut head)) = build_head() else { return Ok(()) };
let stream = dev.cuda_stream()?.clone();
let b_size = 1;
use rand::{Rng, SeedableRng};
use rand_chacha::ChaCha8Rng;
let mut r = ChaCha8Rng::seed_from_u64(0xB1);
let h_t: Vec<f32> = (0..b_size * HIDDEN_DIM)
.map(|_| r.gen_range(-1.0..1.0))
.collect();
let h_t_d = upload_f32(&stream, &h_t)?;
let labels: Vec<i32> = vec![5, 10, 15];
let labels_d = upload_i32(&stream, &labels)?;
// Full backward chain at the unperturbed weights.
let mut hidden_d = stream.alloc_zeros::<f32>(b_size * FRD_HIDDEN_DIM)?;
let mut logits_d = stream.alloc_zeros::<f32>(b_size * FRD_OUT_DIM)?;
head.forward(&h_t_d, &mut hidden_d, &mut logits_d, b_size)?;
let mut grad_logits_d = stream.alloc_zeros::<f32>(b_size * FRD_OUT_DIM)?;
let mut loss_d = stream.alloc_zeros::<f32>(b_size * FRD_N_HORIZONS)?;
head.softmax_ce_grad(&logits_d, &labels_d, &mut grad_logits_d, &mut loss_d, b_size)?;
let mut grad_w2_pb_d = stream.alloc_zeros::<f32>(b_size * FRD_HIDDEN_DIM * FRD_OUT_DIM)?;
let mut grad_b2_pb_d = stream.alloc_zeros::<f32>(b_size * FRD_OUT_DIM)?;
let mut grad_hidden_d = stream.alloc_zeros::<f32>(b_size * FRD_HIDDEN_DIM)?;
head.layer2_bwd(
&hidden_d,
&grad_logits_d,
&mut grad_w2_pb_d,
&mut grad_b2_pb_d,
&mut grad_hidden_d,
b_size,
)?;
let mut grad_w1_pb_d = stream.alloc_zeros::<f32>(b_size * HIDDEN_DIM * FRD_HIDDEN_DIM)?;
let mut grad_b1_pb_d = stream.alloc_zeros::<f32>(b_size * FRD_HIDDEN_DIM)?;
let mut grad_h_t_d = stream.alloc_zeros::<f32>(b_size * HIDDEN_DIM)?;
head.layer1_bwd(
&h_t_d,
&hidden_d,
&grad_hidden_d,
&mut grad_w1_pb_d,
&mut grad_b1_pb_d,
&mut grad_h_t_d,
b_size,
)?;
// Finite-diff probe: perturb W1[k=30, i=20] by ±ε. Pick an
// (k, i) likely to land on an active ReLU branch (use a moderate
// i within the 64 hidden slots — random init usually has ~50%
// active so any pick has ~50% chance of being a non-mask slot).
let probe_k = 30_usize;
let probe_i = 20_usize;
let probe_off = probe_k * FRD_HIDDEN_DIM + probe_i;
let eps = 1e-3_f32;
let grad_w1 = read_slice_d_pub(&stream, &grad_w1_pb_d, HIDDEN_DIM * FRD_HIDDEN_DIM)?;
// Pick the slot with MAX |analytical| — fp32 finite-diff rel_err
// is dominated by absolute rounding noise (~1e-5 at ε=1e-3 across
// the deep matmul chain), so a tiny-magnitude gradient produces
// misleading relative error. Probing the largest gradient gives
// the cleanest sanity check.
let _ = (probe_k, probe_i, probe_off);
let (chosen_off, &chosen_analytical) = grad_w1
.iter()
.enumerate()
.max_by(|(_, a), (_, b)| a.abs().partial_cmp(&b.abs()).unwrap())
.expect("non-empty grad_w1");
let chosen_k = chosen_off / FRD_HIDDEN_DIM;
let chosen_i = chosen_off % FRD_HIDDEN_DIM;
assert!(
chosen_analytical.abs() > 1e-3,
"max |dW1| should be at least 1e-3 for a meaningful finite-diff; got {chosen_analytical}"
);
let mut w1_host = read_slice_d_pub(&stream, &head.w1_d, HIDDEN_DIM * FRD_HIDDEN_DIM)?;
let original = w1_host[chosen_off];
// L(W1 + ε · e_(k,i))
w1_host[chosen_off] = original + eps;
write_slice_f32_d_pub(&stream, &w1_host, &mut head.w1_d)?;
let mut hidden_plus = stream.alloc_zeros::<f32>(b_size * FRD_HIDDEN_DIM)?;
let mut logits_plus = stream.alloc_zeros::<f32>(b_size * FRD_OUT_DIM)?;
head.forward(&h_t_d, &mut hidden_plus, &mut logits_plus, b_size)?;
let logits_plus_h = read_slice_d_pub(&stream, &logits_plus, b_size * FRD_OUT_DIM)?;
let l_plus = ce_total_loss(&head, &stream, &logits_plus_h, &labels_d, b_size)?;
// L(W1 - ε · e_(k,i))
w1_host[chosen_off] = original - eps;
write_slice_f32_d_pub(&stream, &w1_host, &mut head.w1_d)?;
let mut hidden_minus = stream.alloc_zeros::<f32>(b_size * FRD_HIDDEN_DIM)?;
let mut logits_minus = stream.alloc_zeros::<f32>(b_size * FRD_OUT_DIM)?;
head.forward(&h_t_d, &mut hidden_minus, &mut logits_minus, b_size)?;
let logits_minus_h = read_slice_d_pub(&stream, &logits_minus, b_size * FRD_OUT_DIM)?;
let l_minus = ce_total_loss(&head, &stream, &logits_minus_h, &labels_d, b_size)?;
// Restore W1.
w1_host[chosen_off] = original;
write_slice_f32_d_pub(&stream, &w1_host, &mut head.w1_d)?;
let numerical = (l_plus - l_minus) / (2.0 * eps);
let rel_err = (numerical - chosen_analytical).abs() / (chosen_analytical.abs().max(1e-6));
// 1e-2 tolerance is slightly looser than dW2's 5e-3 because the
// dW1 chain crosses an extra matmul + the ReLU mask boundary
// (which is a non-differentiable point — at hidden ≈ 0 the
// finite-diff straddles the mask discontinuity).
assert!(
rel_err < 1e-2,
"dW1 finite-diff mismatch at (k={chosen_k}, i={chosen_i}): \
analytical={chosen_analytical:.6}, numerical={numerical:.6}, rel_err={rel_err:.6}"
);
eprintln!(
"PASS — dW1 finite-diff at (k={chosen_k}, i={chosen_i}): \
analytical={:.6} numerical={:.6} rel_err={:.2e}",
chosen_analytical, numerical, rel_err
);
Ok(())
}
#[test]
#[ignore = "requires CUDA (MlDevice::cuda(0))"]
fn frd_layer1_bwd_relu_mask_zeros_grad() -> Result<()> {
// Invariant: if cached `hidden[b, i] == 0` (i.e. the pre-activation
// landed on the negative half of ReLU), then:
// * grad_b1_per_batch[b, i] = 0 (db1 = dpre_hidden = grad_hidden × mask)
// * grad_W1_per_batch[b, k, i] = 0 for every k
//
// Construct a fixture where some hidden slots are guaranteed
// negative: feed h_t = all -1.0 (so half of Xavier-init W1's
// products average negative).
let Some((dev, head)) = build_head() else { return Ok(()) };
let stream = dev.cuda_stream()?.clone();
let b_size = 1;
let h_t = vec![-1.0_f32; b_size * HIDDEN_DIM];
let h_t_d = upload_f32(&stream, &h_t)?;
let labels: Vec<i32> = vec![5, 10, 15];
let labels_d = upload_i32(&stream, &labels)?;
let mut hidden_d = stream.alloc_zeros::<f32>(b_size * FRD_HIDDEN_DIM)?;
let mut logits_d = stream.alloc_zeros::<f32>(b_size * FRD_OUT_DIM)?;
head.forward(&h_t_d, &mut hidden_d, &mut logits_d, b_size)?;
let mut grad_logits_d = stream.alloc_zeros::<f32>(b_size * FRD_OUT_DIM)?;
let mut loss_d = stream.alloc_zeros::<f32>(b_size * FRD_N_HORIZONS)?;
head.softmax_ce_grad(&logits_d, &labels_d, &mut grad_logits_d, &mut loss_d, b_size)?;
let mut grad_w2_pb_d = stream.alloc_zeros::<f32>(b_size * FRD_HIDDEN_DIM * FRD_OUT_DIM)?;
let mut grad_b2_pb_d = stream.alloc_zeros::<f32>(b_size * FRD_OUT_DIM)?;
let mut grad_hidden_d = stream.alloc_zeros::<f32>(b_size * FRD_HIDDEN_DIM)?;
head.layer2_bwd(
&hidden_d,
&grad_logits_d,
&mut grad_w2_pb_d,
&mut grad_b2_pb_d,
&mut grad_hidden_d,
b_size,
)?;
let mut grad_w1_pb_d = stream.alloc_zeros::<f32>(b_size * HIDDEN_DIM * FRD_HIDDEN_DIM)?;
let mut grad_b1_pb_d = stream.alloc_zeros::<f32>(b_size * FRD_HIDDEN_DIM)?;
let mut grad_h_t_d = stream.alloc_zeros::<f32>(b_size * HIDDEN_DIM)?;
head.layer1_bwd(
&h_t_d,
&hidden_d,
&grad_hidden_d,
&mut grad_w1_pb_d,
&mut grad_b1_pb_d,
&mut grad_h_t_d,
b_size,
)?;
let hidden = read_slice_d_pub(&stream, &hidden_d, b_size * FRD_HIDDEN_DIM)?;
let grad_b1 = read_slice_d_pub(&stream, &grad_b1_pb_d, b_size * FRD_HIDDEN_DIM)?;
let grad_w1 = read_slice_d_pub(&stream, &grad_w1_pb_d, b_size * HIDDEN_DIM * FRD_HIDDEN_DIM)?;
let mut masked_count = 0;
let mut unmasked_count = 0;
for i in 0..FRD_HIDDEN_DIM {
if hidden[i] == 0.0 {
masked_count += 1;
// ReLU mask zeros dpre_hidden → db1 + entire column of dW1 must be 0.
assert_eq!(
grad_b1[i], 0.0,
"db1[{i}] should be 0 under ReLU mask (hidden=0); got {}",
grad_b1[i]
);
for k in 0..HIDDEN_DIM {
let off = k * FRD_HIDDEN_DIM + i;
assert_eq!(
grad_w1[off], 0.0,
"dW1[{k}, {i}] should be 0 under ReLU mask (hidden=0); got {}",
grad_w1[off]
);
}
} else {
unmasked_count += 1;
}
}
assert!(
masked_count > 0,
"expected at least one ReLU-masked hidden slot under h_t = all -1; \
got 0 (all-positive hidden — Xavier init may have flipped signs)"
);
eprintln!(
"PASS — ReLU mask zeros dW1 + db1 for {} of {} hidden slots ({} active)",
masked_count, FRD_HIDDEN_DIM, unmasked_count
);
Ok(())
}