Files
foxhunt/crates/ml-alpha/cuda/cfc_step.cu
jgrusewski b23f8f2efa perf(ml-alpha): NVIDIA-grade rewrite of CfC K-loop hot kernels — 2.15× faster
Local L40S profile (perception_overfit smoke) GPU kernel time:
  1589ms → 739ms  (53.5% reduction, 2.15× speedup).
Wall-clock smoke: 9.6s → 4.94s (1.94× faster).

Per-kernel deltas (nsys --cuda-graph-trace=node):

  reduce_axis0:              362ms → 10ms   (36× faster)
    Block layout: per-column (1 block / output) → 32-wide column tile
    (block_dim = 32 × 8). Cross-thread reads were strided by n_tail
    (~40K floats = 160KB stride) — one cache line per thread, 8× HBM
    bandwidth wasted. New tile gives coalesced 128B transactions per
    warp. Block tree-reduce kept (no atomicAdd, per feedback_no_atomicadd).
    +1 shared-mem pad to eliminate 32-way bank conflict on the ty reduce.

  multi_horizon_heads_grn_bwd_batched:  540ms → 113ms  (4.8× faster)
    1. Stage h_row[HIDDEN] and a1[5,HEAD_MID] in shared at block entry.
       Eliminates ~28K redundant DRAM reads/block across Pass 3 + Pass 5.
    2. Pass 5 reorder: k outer / i inner with d_z1[k,m] pinned in
       register; writes to grad_w1_scratch are sequential per-thread.
    3. Block size 64 → 128 threads. Pass 5/6 now partition over i
       (output column): cross-thread writes become COALESCED 128B/warp
       (was stride-128 = 512B). Passes 2/3/4 gate on (tid < HEAD_MID).
    4. Pass 3 thread role: m_out → m_in/n. Same coalescing fix on
       grad_w2 writes AND w2 reads in the d_eta_2 sum.

  cfc_step_backward_batched: 351ms → 271ms  (1.3× faster)
    1. Stage x_b[n_in] and h_old_b[n_hid] in shared (was 128× redundant
       DRAM reads per block; now 1× cooperative load).
    2. Pass 1 thread role: i (output row) → k (output col). For each
       i loop iteration, the warp writes grad_w_in[..., tid] /
       grad_w_rec[..., tid] — COALESCED 128B/warp (was stride-128
       non-coalesced).

  multi_horizon_heads_grn_fwd_batched:  211ms → 204ms
    Stage h_row[HIDDEN] in shared — Pass 1 and Pass 3 both consume.

  cfc_step_batched (fwd):     95ms →  94ms
    Stage x_b and h_old_b in shared.

Shared-mem budgets fit comfortably under the 48KB SM cap (~6KB / ~2KB
respectively). All 9 perception_overfit tests pass — gradient
correctness validated end-to-end (constant-signal overfit, K-loop
capture/replay, stride-4 path, evaluate-only paths).

Discipline:
  - Block tree-reduce only, never atomicAdd
  - No nvrtc; pre-compiled cubins via build.rs
  - Mapped-pinned-only is unaffected (CPU↔GPU contract untouched)
  - Single source of truth: replaced kernels in place, no v2 suffixes

Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
2026-05-18 19:07:29 +02:00

365 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.
// cfc_step.cu — one CfC time step (Hasani 2022 closed-form recurrence)
// + batched variants + 3D transpose helper.
// Transpose a [N1, N2, N3] row-major tensor to [N2, N1, N3]. Used by
// the batched PerceptionTrainer to convert Mamba2's [B, K, hidden]
// per-step output into the [K, B, hidden] layout that makes per-K
// slot access contiguous in the trainer's K loop (and back again for
// grad_h_enriched_seq feeding Mamba2's backward).
//
// Launches one block per (i, j) pair, blockDim.x threads cover N3.
extern "C" __global__ void transpose_3d_swap_01(
const float* __restrict__ in, // [N1, N2, N3]
float* __restrict__ out, // [N2, N1, N3]
int N1,
int N2,
int N3
) {
int i = blockIdx.x;
int j = blockIdx.y;
int k = blockIdx.z * blockDim.x + threadIdx.x;
if (i >= N1 || j >= N2 || k >= N3) return;
out[((long long)j * N1 + i) * N3 + k] = in[((long long)i * N2 + j) * N3 + k];
}
//
// pre[i] = sum_k w_in[i,k] * x[k] + sum_k w_rec[i,k] * h_old[k] + b[i]
// decay[i] = exp(-dt_s / max(tau[i], 1e-6))
// h_new[i] = h_old[i] * decay[i] + (1 - decay[i]) * tanh(pre[i])
//
// Launched as one block of n_hid threads (or grid of blocks if n_hid > 128).
// Each thread computes one hidden unit; reductions are intra-thread (each
// thread does its own dot product). No atomicAdd. Per
// `feedback_no_atomicadd.md` and the GPU/CPU contract.
extern "C" __global__ void cfc_step(
const float* __restrict__ w_in, // [n_hid, n_in], row-major
const float* __restrict__ w_rec, // [n_hid, n_hid], row-major
const float* __restrict__ b, // [n_hid]
const float* __restrict__ tau, // [n_hid]
const float* __restrict__ x, // [n_in]
const float* __restrict__ h_old, // [n_hid]
float dt_s,
int n_in,
int n_hid,
float* __restrict__ h_new // [n_hid]
) {
int i = blockIdx.x * blockDim.x + threadIdx.x;
if (i >= n_hid) return;
float pre = b[i];
for (int k = 0; k < n_in; ++k) {
pre += w_in[i * n_in + k] * x[k];
}
for (int k = 0; k < n_hid; ++k) {
pre += w_rec[i * n_hid + k] * h_old[k];
}
const float decay = expf(-dt_s / fmaxf(tau[i], 1e-6f));
h_new[i] = h_old[i] * decay + (1.0f - decay) * tanhf(pre);
}
// Backward through ONE cfc_step (truncated BPTT, K=1).
//
// Forward (for reference):
// pre[i] = b[i] + sum_k W_in[i,k] x[k] + sum_k W_rec[i,k] h_old[k]
// τ_eff = max(tau[i], 1e-6)
// decay[i] = exp(-dt / τ_eff)
// s[i] = tanh(pre[i])
// h_new[i] = h_old[i] * decay + (1 - decay) * s
//
// Given grad_h_new[i] = dL/dh_new[i], compute:
// d_decay[i] = grad_h_new[i] * (h_old[i] - s[i])
// d_pre[i] = grad_h_new[i] * (1 - decay) * (1 - s^2)
// grad_tau[i] += d_decay[i] * decay[i] * dt / τ_eff² (when tau > eps)
// grad_b[i] += d_pre[i]
// grad_W_in[i,k] += d_pre[i] * x[k]
// grad_W_rec[i,k] += d_pre[i] * h_old[k]
// grad_h_old[i] += grad_h_new[i] * decay + sum_j d_pre[j] * W_rec[j, i]
//
// tau IS trained — grad_tau receives a `+=` accumulation (callers must
// pre-zero across the per-position K loop).
//
// One thread per hidden unit (128 threads in one block). Block tree-
// reduce not needed — each thread owns its own grad_W rows and grad_h
// component; grad_h_old accumulates via shared `d_pre` reads.
extern "C" __global__ void cfc_step_backward(
const float* __restrict__ w_in, // [n_hid, n_in]
const float* __restrict__ w_rec, // [n_hid, n_hid]
const float* __restrict__ b,
const float* __restrict__ tau,
const float* __restrict__ x, // [n_in]
const float* __restrict__ h_old, // [n_hid]
const float* __restrict__ grad_h_new, // [n_hid]
float dt_s,
int n_in,
int n_hid,
float* __restrict__ grad_w_in, // [n_hid, n_in]
float* __restrict__ grad_w_rec, // [n_hid, n_hid]
float* __restrict__ grad_b, // [n_hid]
float* __restrict__ grad_tau, // [n_hid] — accumulated via +=
float* __restrict__ grad_h_old, // [n_hid]
float* __restrict__ grad_x // [n_in] — set to nullptr to skip
) {
extern __shared__ float smem[];
float* sd_pre = smem; // [n_hid]
float* sdecay = sd_pre + n_hid; // [n_hid]
int i = blockIdx.x * blockDim.x + threadIdx.x;
if (i >= n_hid) return;
// Recompute forward pre + tanh to derive d_pre.
float pre = b[i];
for (int k = 0; k < n_in; ++k) pre += w_in[i * n_in + k] * x[k];
for (int k = 0; k < n_hid; ++k) pre += w_rec[i * n_hid + k] * h_old[k];
const float s = tanhf(pre);
const float tau_eps = 1e-6f;
const float tau_eff = fmaxf(tau[i], tau_eps);
const float decay = expf(-dt_s / tau_eff);
const float dh = grad_h_new[i];
const float d_pre = dh * (1.0f - decay) * (1.0f - s * s);
sd_pre[i] = d_pre;
sdecay[i] = decay;
__syncthreads();
// Parameter-grad writes use `+=`: callers may invoke this kernel
// multiple times per training step (per-position supervision) and
// need the gradients summed across calls. Callers MUST pre-zero
// grad_b / grad_w_in / grad_w_rec / grad_tau at the start of each step.
grad_b[i] += d_pre;
for (int k = 0; k < n_in; ++k) {
grad_w_in[i * n_in + k] += d_pre * x[k];
}
for (int k = 0; k < n_hid; ++k) {
grad_w_rec[i * n_hid + k] += d_pre * h_old[k];
}
// grad_tau accumulation. Zero contribution if tau is clamped to eps
// (max() inactive on this side) — strict d(max)/d(tau) = 0 there.
if (tau[i] > tau_eps) {
const float d_decay = dh * (h_old[i] - s);
grad_tau[i] += d_decay * decay * dt_s / (tau_eff * tau_eff);
}
// grad_h_old[i] = dh * decay + sum_j d_pre[j] * W_rec[j, i]
float gh = dh * decay;
for (int j = 0; j < n_hid; ++j) {
gh += sd_pre[j] * w_rec[j * n_hid + i];
}
grad_h_old[i] = gh;
// grad_x[k] = sum_i d_pre[i] * W_in[i, k]. Each thread i contributes to
// n_in different output positions — different threads write to different
// grad_x slots if we partition by i, but multiple i's contribute to each k.
// Avoid atomicAdd: rely on thread 0 to do the n_in × n_hid sum
// sequentially (n_in is small — typically 32 or 128, this is one-time per
// backward call). Discriminated by nullptr → skip path for back-compat.
if (grad_x != nullptr && i == 0) {
for (int k = 0; k < n_in; ++k) {
float gx = 0.0f;
for (int j = 0; j < n_hid; ++j) {
gx += sd_pre[j] * w_in[j * n_in + k];
}
grad_x[k] = gx;
}
}
}
// ─── Batched variants ────────────────────────────────────────────────
//
// Phase B (2026-05-17): block-per-batch refactor. Launch contract:
// grid=(n_batch, 1, 1), block=(n_hid, 1, 1).
// Each block handles ONE batch's n_hid channels in parallel.
//
// Param-grad writes go through per-batch scratch buffers
// (grad_w_in_scratch [B, n_hid, n_in] etc.). Thread (bi, i) is the
// sole writer to its slice of each scratch tensor; the K-loop's 64
// invocations accumulate via += within the same (bi, i, *) slice.
// After the K-loop the caller runs `reduce_axis0` to sum B → final
// grad buffer (OVERWRITE semantics). No atomicAdd per
// feedback_no_atomicadd.md.
//
// Shared memory budget per block: sd_pre[n_hid] + sdecay[n_hid] =
// 2 * n_hid * 4 bytes. At n_hid=128 → 1 KiB. Far below 48 KiB limit,
// no cudaFuncSetAttribute needed.
// Block-per-batch (Phase B): grid=(n_batch, 1, 1), block=(n_hid, 1, 1).
// Each block handles ONE batch's n_hid channels in parallel. Removes
// the internal batch loop — frees ~31 SMs per launch at B=32.
extern "C" __global__ void cfc_step_batched(
const float* __restrict__ w_in, // [n_hid, n_in], row-major
const float* __restrict__ w_rec, // [n_hid, n_hid], row-major
const float* __restrict__ b, // [n_hid]
const float* __restrict__ tau, // [n_hid]
const float* __restrict__ x, // [n_batch, n_in]
const float* __restrict__ h_old, // [n_batch, n_hid]
float dt_s,
int n_in,
int n_hid,
int n_batch,
float* __restrict__ h_new // [n_batch, n_hid]
) {
// Dynamic shared layout (caller supplies (n_in + n_hid) * 4 bytes):
// s_x [n_in] — staged x_b row (read by every channel thread)
// s_h_old [n_hid] — staged h_old_b row (same)
extern __shared__ float smem_fwd[];
float* s_x = smem_fwd;
float* s_h_old = s_x + n_in;
int bi = blockIdx.x;
int tid = threadIdx.x;
if (bi >= n_batch) return;
// Cooperative staging: 128× redundant DRAM reads per row → 1× per row
// shared across all channel threads. Both rows fit since n_in = n_hid
// = HIDDEN.
if (tid < n_in) s_x[tid] = x[(long long)bi * n_in + tid];
if (tid < n_hid) s_h_old[tid] = h_old[(long long)bi * n_hid + tid];
__syncthreads();
if (tid >= n_hid) return;
const int i = tid;
const float bias_i = b[i];
const float decay = expf(-dt_s / fmaxf(tau[i], 1e-6f));
const float one_minus_decay = 1.0f - decay;
float pre = bias_i;
for (int k = 0; k < n_in; ++k) pre += w_in[i * n_in + k] * s_x[k];
for (int k = 0; k < n_hid; ++k) pre += w_rec[i * n_hid + k] * s_h_old[k];
h_new[(long long)bi * n_hid + i] =
s_h_old[i] * decay + one_minus_decay * tanhf(pre);
}
// Block-per-batch backward.
// grid=(n_batch, 1, 1) block=(n_hid, 1, 1)
// shared mem: 2 * n_hid * 4 bytes (sd_pre + sdecay; one row each)
//
// Param-grad scratch tensors hold per-batch slices. Thread (bi, i) is
// sole writer to its slice of each scratch tensor for every K-iteration
// the K-loop calls this kernel — no race even with B parallel blocks.
// Caller zeroes the scratch buffers once per training step; this kernel
// uses += to accumulate across the K-loop's 64 invocations.
//
// Outputs grad_h_old and grad_x are per-batch indexed; each block bi is
// sole writer to its [bi, :] slice (overwrite, single launch).
extern "C" __global__ void cfc_step_backward_batched(
const float* __restrict__ w_in, // [n_hid, n_in]
const float* __restrict__ w_rec, // [n_hid, n_hid]
const float* __restrict__ b, // [n_hid]
const float* __restrict__ tau, // [n_hid]
const float* __restrict__ x, // [n_batch, n_in]
const float* __restrict__ h_old, // [n_batch, n_hid]
const float* __restrict__ grad_h_new, // [n_batch, n_hid]
float dt_s,
int n_in,
int n_hid,
int n_batch,
float* __restrict__ grad_w_in_scratch, // [n_batch, n_hid, n_in] accum +=
float* __restrict__ grad_w_rec_scratch, // [n_batch, n_hid, n_hid] accum +=
float* __restrict__ grad_b_scratch, // [n_batch, n_hid] accum +=
float* __restrict__ grad_tau_scratch, // [n_batch, n_hid] accum +=
float* __restrict__ grad_h_old, // [n_batch, n_hid] overwrite
float* __restrict__ grad_x // [n_batch, n_in] overwrite (nullptr OK)
) {
extern __shared__ float smem[];
// Shared layout — caller passes (2*n_hid + n_in + n_hid) * 4 bytes:
// sd_pre [n_hid] — d_pre_b per channel (set in Pass 0, consumed Pass 1..3)
// sdecay [n_hid] — reserved for future passes / kept for ABI symmetry
// s_x [n_in] — staged x_b row (eliminates 128× redundant DRAM reads)
// s_h_old [n_hid] — staged h_old_b row (same)
float* sd_pre = smem;
float* sdecay = sd_pre + n_hid;
float* s_x = sdecay + n_hid;
float* s_h_old = s_x + n_in;
int bi = blockIdx.x;
int tid = threadIdx.x;
// block_dim is >= max(n_hid, n_in); each thread serves both roles
// across passes. Most paths gate on whether (tid < n_hid) / (tid < n_in).
if (bi >= n_batch) return;
// Cooperative staging — coalesced loads, single sync.
if (tid < n_in) s_x[tid] = x[(long long)bi * n_in + tid];
if (tid < n_hid) s_h_old[tid] = h_old[(long long)bi * n_hid + tid];
__syncthreads();
// Pass 0: compute pre[i], d_pre[i], decay[i]; write per-channel scratch.
// Thread tid serves the channel role i = tid. Reads from shared.
float dh_b = 0.0f;
float decay_i = 0.0f;
float s_i = 0.0f;
if (tid < n_hid) {
const int i = tid;
const float bias_i = b[i];
const float tau_eps = 1e-6f;
const float tau_i_raw = tau[i];
const float tau_eff = fmaxf(tau_i_raw, tau_eps);
decay_i = expf(-dt_s / tau_eff);
sdecay[i] = decay_i;
dh_b = grad_h_new[(long long)bi * n_hid + i];
float pre = bias_i;
for (int k = 0; k < n_in; ++k) pre += w_in[i * n_in + k] * s_x[k];
for (int k = 0; k < n_hid; ++k) pre += w_rec[i * n_hid + k] * s_h_old[k];
s_i = tanhf(pre);
const float d_pre_b = dh_b * (1.0f - decay_i) * (1.0f - s_i * s_i);
sd_pre[i] = d_pre_b;
// Per-channel scratch (one float per thread, no coalescing concern).
grad_b_scratch[(long long)bi * n_hid + i] += d_pre_b;
// Branchless tau-grad — gates contribution by (tau > eps) without
// warp divergence.
const float d_decay_b = dh_b * (s_h_old[i] - s_i);
const float gate = (tau_i_raw > tau_eps) ? 1.0f : 0.0f;
const float contrib = d_decay_b * decay_i * dt_s / (tau_eff * tau_eff);
grad_tau_scratch[(long long)bi * n_hid + i] += gate * contrib;
}
__syncthreads();
// Pass 1: grad_w_in[bi, i, k] += d_pre[i] * x[k]
// grad_w_rec[bi, i, k] += d_pre[i] * h_old[k]
// Thread tid serves the column role k = tid. For each i, every warp
// thread writes to grad_w_*[i, tid] → COALESCED 128B/transaction.
// Prior layout (thread = i) strided cross-thread writes by n_in/n_hid
// = 512B, paying 32 separate transactions per warp instruction.
if (tid < n_in) {
const int k = tid;
const float x_k = s_x[k];
const float h_old_k = s_h_old[k]; // valid because n_in == n_hid here
for (int i = 0; i < n_hid; ++i) {
const float d_pre_i = sd_pre[i];
grad_w_in_scratch[((long long)bi * n_hid + i) * n_in + k]
+= d_pre_i * x_k;
grad_w_rec_scratch[((long long)bi * n_hid + i) * n_hid + k]
+= d_pre_i * h_old_k;
}
}
// Pass 2: grad_h_old[bi, i] = sum_j sd_pre[j] * W_rec[j, i] + dh_b * decay.
// Thread tid is back to channel role i = tid; dh_b and decay_i
// stayed in registers from Pass 0.
if (tid < n_hid) {
const int i = tid;
float gh = dh_b * decay_i;
for (int j = 0; j < n_hid; ++j) {
gh += sd_pre[j] * w_rec[j * n_hid + i];
}
grad_h_old[(long long)bi * n_hid + i] = gh;
}
// Pass 3: grad_x[bi, k] = sum_j sd_pre[j] * W_in[j, k]
// Thread tid serves column role k = tid. Sequential along j, sd_pre
// in shared. One coalesced write per thread (grad_x layout matches).
if (grad_x != nullptr && tid < n_in) {
const int k = tid;
float gx = 0.0f;
for (int j = 0; j < n_hid; ++j) {
gx += sd_pre[j] * w_in[j * n_in + k];
}
grad_x[(long long)bi * n_in + k] = gx;
}
}