# K-loop Parallelization — Block-per-Batch Kernel Refactor **Status:** Design **Date:** 2026-05-17 **Affects:** `crates/ml-alpha/cuda/{cfc_step.cu, multi_horizon_heads.cu, variable_selection.cu, attention_pool.cu}` + `crates/ml-alpha/src/trainer/perception.rs` ## Problem Five backward / K-loop kernels in `ml-alpha` use the "ONE block per launch, internal `n_batch` loop" pattern: | Kernel | Calls per training step | |---|---| | `cfc_step_batched` (fwd) | 64 (K-loop) | | `cfc_step_backward_batched` (bwd) | 64 (K-loop) | | `multi_horizon_heads_grn_bwd_batched` | 64 (K-loop) | | `variable_selection_bwd` | 1 | | `attention_pool_bwd` | 1 | Each runs as a single block (`grid_dim=(1,1,1)`, `block_dim=(C,1,1)` where C is the channel/feature dim) and iterates over the batch sequentially inside the kernel body. On an L40S with 142 SMs and B=32 this puts **<1% of the GPU to work** in the K-loop's critical path. Empirical evidence from t6z89 (Phase 1+2+3 fold-1, stride=4): epoch wall 8 min for 750 steps → 640 ms/step. Estimated real compute per step is < 10 ms based on FLOP count. The gap (~630 ms/step idle) is the single-SM bottleneck. Architectural impact: 3-fold CV at 30 epochs × 3 folds × 8 min = 12 hours wall. With the fix, target is 3 min/epoch → 4.5 hours. Future decision-stride sweeps, state-dim sweeps, and the Phase 3 CV all compound this. ## Goal Parallelize the five kernels across the batch dimension so they use B simultaneous thread blocks instead of one. **Realistic target:** ≥ 3× per-epoch speedup (8 min → ≤ 2.5 min). **Stretch:** ≥ 5× (8 min → ≤ 1.5 min). Higher multiples (e.g., 10×+) would require either bigger batch (B=128+), H100 hardware, or mixed-precision — all out of scope here. The K-loop block-per-batch refactor alone is bounded by Amdahl on the non-K-loop work (Mamba2 scan, LayerNorm, cuBLAS calls) which is already parallel. ## Non-goals - Mamba2's selective scan kernel (already parallel via `backward_with_slices_into`). - LayerNorm fwd/bwd (already parallel across rows). - Forward GRN, snap_feature_assemble, BCE (already parallel). - Fusing the K-loop into a single launch — would require parallel-scan reformulation of CfC's recurrence; out of scope. - Mixed-precision (BF16) — orthogonal, considered in a future spec. - **Hardware change.** L40S remains the target pool (`ci-training-l40s`). H100 is intentionally excluded from this round — the goal is to fix the architectural bottleneck on the hardware we're already running on, so the speedup is attributable to the refactor (not a hardware bump). H100 may revisit in a later spec once the L40S floor is established. ## Architecture Choice For every refactored kernel, the same three changes: **1. Launch config** ```rust - LaunchConfig { grid_dim: (1, 1, 1), block_dim: (C, 1, 1), shared_mem_bytes: ... } + LaunchConfig { grid_dim: (b_sz as u32, 1, 1), block_dim: (C, 1, 1), shared_mem_bytes: ... } ``` **2. Kernel body** ```cuda - int i = blockIdx.x * blockDim.x + threadIdx.x; - if (i >= C) return; - for (int bi = 0; bi < n_batch; ++bi) { - /* per-batch work */ - } + int bi = blockIdx.x; + int i = threadIdx.x; + if (bi >= n_batch || i >= C) return; + /* process exactly (bi, i) — no batch loop */ ``` **3. Param-grad writes (bwd kernels only)** ```cuda - grad_w[i * n_in + k] += d_pre * x_b[k]; // safe when sequential + grad_w_scratch[(bi * C + i) * n_in + k] += d_pre * x_b[k]; // sole writer per (bi, i) ``` Followed by one reducer kernel call per (bwd-kernel × param-tensor) pair: ```cuda // reduce_axis0 — single parameterised reducer, ONE cubin, ONE symbol. extern "C" __global__ void reduce_axis0( const float* per_batch, // [B, N] int n_batch, int n_tail, float* out // [N] — OVERWRITE ); ``` Block tree-reduce across `bi` (no atomicAdd, per `feedback_no_atomicadd.md`), grid over the output tail dim. ### Why scratch+reducer, not cooperative_groups grid sync Cooperative grid sync (`cooperative_groups::this_grid().sync()`) would skip the separate reducer launch. Net additional saving: ~64 launches/step × ~20 µs = ~1.3 ms/step ≈ 1% on top of the block-per-batch win. Costs: CUDA Graph capture risk (`pearl_cudarc_disable_event_tracking_for_graph_capture` already documents fragility here), `-rdc=true` build flag change, new cudarc API (`cudaLaunchCooperativeKernel`) not used elsewhere in foxhunt, harder nsys profiling, concurrent-block-resident constraint. Scratch+reducer matches the existing LayerNorm-bwd-reducer pattern and is CUDA-Graph-safe. ### Large shared-mem note `cfc_step_backward_batched` uses `extern __shared__ float smem[]` sized `(B+1) * n_hid * 4` bytes — currently 16.5 KiB at B=32, n_hid=128. The refactored version has **one block per batch** with shared mem sized `(1+1) * n_hid * 4` = 1 KiB per block (one row of `sd_pre` for the block's single `bi`, plus `sdecay[n_hid]`). Lower per-block shared mem, no `cudaFuncSetAttribute` call needed. If a future expansion increases shared mem above 48 KiB per block we'd need `cudaFuncAttributeMaxDynamicSharedMemorySize` — flagged for awareness, not action. ### Per-K-iteration accumulation invariant The K-loop calls `cfc_bwd` and `grn_bwd` 64 times per training step. Each call's scratch writes are `+=`. The scratch is `memset_zeros` ONCE at step start (alongside other grad accumulators), then 64 K-iterations accumulate into it. After the K-loop completes, one reducer launch per param tensor sums B → final param grad. Same total work as the current sequential pattern, parallelized across B. ### AdamW-after-reducer invariant The final grad buffer (e.g., `grad_w_in_d`) was previously zeroed at step start, accumulated by the bwd kernel, then read by AdamW. After the refactor: - Final grad buffer is **OVERWRITTEN by the reducer**, not zeroed at step start. - Final grad buffer holds meaningful data **only after the reducer for it has run in the current step**. - **AdamW must run after the reducer.** Any future code path that reads a final grad without running the reducer first (diagnostic dump, alternative optimizer path, checkpoint serialiser) will read stale data from the previous step. This invariant is asserted via the ordering inside `dispatch_train_step`: reducer launches sit between the bwd kernels and the AdamW step calls, all captured in the same CUDA Graph region. ## Per-Kernel Breakdown | Kernel | File | Param-grad scratch at B=32 | Scratch bytes | Reducer launches | |---|---|---|---|---| | `cfc_step_batched` (fwd) | `cuda/cfc_step.cu` | none | 0 | 0 | | `cfc_step_backward_batched` | `cuda/cfc_step.cu` | `grad_w_in[B,128,128]` + `grad_w_rec[B,128,128]` + `grad_b[B,128]` + `grad_tau[B,128]` | 4.1 MB | 4 | | `multi_horizon_heads_grn_bwd_batched` | `cuda/multi_horizon_heads.cu` | 10 tensors, biggest `grad_w1[B,5,64,128]` | 8.0 MB | 10 | | `variable_selection_bwd` | `cuda/variable_selection.cu` | `grad_W_vsn[B,40,40]` + `grad_b_vsn[B,40]` | 210 KB | 2 | | `attention_pool_bwd` | `cuda/attention_pool.cu` | `grad_Q[B,128]` | 16 KB | 1 | | **Totals** | — | — | **~12 MB** | **17 per step** | **Scratch sizing formula:** for kernel K with parameters {Pᵢ} of trailing-dim size {Nᵢ}, scratch is ``` scratch_bytes(K, B) = B × Σᵢ (Nᵢ × 4) ``` Linear in B. At B=128 → ~48 MB total (still < 0.1 % of L40S 48 GB). At B=512 → ~190 MB. Memory ceiling for B at this architecture is bound by other allocators long before scratch matters. Reducer launches (17/step) are microsecond-scale (small grid, simple kernel) → ~340 µs total overhead, rounding error against the K-loop savings. ## Trainer Wiring **New `PerceptionTrainer` fields (~17):** - `cfc_grad_w_in_scratch_d`, `cfc_grad_w_rec_scratch_d`, `cfc_grad_b_scratch_d`, `cfc_grad_tau_scratch_d` - `grn_grad_w1_scratch_d`, `grn_grad_b1_scratch_d`, `grn_grad_w2_scratch_d`, `grn_grad_b2_scratch_d`, `grn_grad_w_gate_scratch_d`, `grn_grad_b_gate_scratch_d`, `grn_grad_w_main_scratch_d`, `grn_grad_b_main_scratch_d`, `grn_grad_w_skip_scratch_d`, `grn_grad_b_skip_scratch_d` - `vsn_grad_w_scratch_d`, `vsn_grad_b_scratch_d` - `attn_grad_q_scratch_d` - `reduce_axis0_fn: CudaFunction` - `_reduce_module: Arc` **Step lifecycle:** 1. **Step start:** `memset_zeros` the new scratch buffers. The OLD final grad buffers are no longer zeroed at step start — they are reducer outputs (OVERWRITE semantics). 2. **K-loop bwd:** 64 cfc_bwd + 64 grn_bwd calls write `+=` into scratch. 3. **After K-loop:** 14 reducer launches (4 cfc + 10 grn) collapse scratch → final grad buffers. 4. **VSN bwd + reducer (2) + attn pool bwd + reducer (1):** standalone path. For these, scratch zero + write + reducer all happen in one logical step (the kernel runs once, so the K-accumulation invariant is moot — but we still memset scratch at step start for uniformity with the rest of the pattern). 5. **AdamW step:** unchanged. Consumes final grad buffers. Runs strictly after all reducers per the AdamW-after-reducer invariant. **Unchanged:** - Forward path (except cfc_step_batched fwd's launch config — same kernel signature, no scratch). - BCE / ISV lambda. - LayerNorm bwd (already uses scratch+reducer via `layer_norm_reduce_param_grads`). - Mamba2 (already parallel internally). - CUDA Graph capture / replay (reducers are plain `launch_builder.launch()` — same pattern as LN reducer which captures cleanly). ## Build System - `crates/ml-alpha/build.rs` KERNELS list gets `"reduce_axis0"`. - Cache-bust comment bumped to `v11`. - No new `-rdc=true` flag, no nvcc API change. ## Tests + Acceptance ### Correctness gates **1. All 8 existing `perception_overfit` tests pass** (B=1 path). Synthetic constant-direction signal still converges 0.30 → 0.00 by step 50 at both stride=1 and stride=4. Catches any chain-rule break. **2. NEW: `stacked_trainer_loss_shrinks_at_batch_32` test.** Same synthetic signal, but `n_batch: 32`. Asserts loss converges from baseline to < 0.1 within 200 steps. This is the **first test that actually exercises the cross-batch reduction code path** — the existing perception_overfit suite uses B=1 throughout, so without this we'd never validate the reducer at real B. **3. All existing `backward_finite_diff` tests pass** (single-sample GPU helpers). Orthogonal code path, but verifying confirms the kernel module loading + gradient invariants are intact. **4. NEW: `cfc_bwd_b1_matches_single_sample` test (`cfc_step` only).** Run identical synthetic input through the refactored `cfc_step_backward_batched` at B=1, compare against the single-sample helper `cfc_step_backward_gpu`. Assert `relative_eq` ≤ 1e-6 (NOT bit-exact — FP addition is non-associative and the kernels may sum in different orders even with identical math). **5. NEW: `cfc_bwd_scratch_clears_between_steps` test.** Run two `trainer.step(seq_A, labels_A)` calls in succession. After step A, read each cfc scratch buffer; after step B, read each scratch buffer; assert step B's scratch reflects ONLY seq_B's contribution (i.e., scratch was zeroed at start of step B, not left holding step A's accumulation). **Note on B=1 oracle asymmetry.** Single-sample GPU helpers exist only for `cfc_step` (via `crates/ml-alpha/src/cfc/step.rs::cfc_step_backward_gpu`). There is NO single-sample helper for the GRN bwd, VSN bwd, or attention pool bwd kernels, and `feedback_no_cpu_test_fallbacks.md` forbids writing a CPU reference oracle. Validation for the other three refactored kernels rests on: - The new B=32 synthetic smoke (gate #2) — sees them work end-to-end. - The fact that the refactor preserves chain rule structure unchanged — only the parallelization changes. - The cluster A/B AUC trajectory match (gate #7) — catches numerical regressions on real data. Writing single-sample helpers for GRN / VSN / attn could be done, but the engineering cost (~3 kernels × 100 LoC each + binding code) exceeds the marginal validation value given the convergence and A/B safety nets. ### Performance gates **6. Local synthetic-overfit step time ≥ 3× faster at B=32 (release build).** Pre-refactor: ~250 ms/step. Post-refactor target: ~80 ms/step. Measured by wall delta on `cargo test stacked_trainer_loss_shrinks_at_batch_32 --release`. **7. Cluster epoch wall time ≥ 3× faster (stretch: ≥ 5×).** A/B Argo submission. Same config as t6z89 (`--decision-stride 4 --seq-len 64 --mamba2-state-dim 32 --epochs 5 --cv-fold 1 --cv-n-folds 3 --cv-train-window 4`). Compare timestamps between successive `epoch complete` log lines. Pre: 8 min/epoch (t6z89). Post target: ≤ 2.5 min/epoch. **8. AUC trajectory matches t6z89 within ±0.005.** The perf-refactor run must show mean_auc and h6000 within ±0.005 of t6z89's per-epoch values at the same epoch index. Larger deviation indicates a numerical regression. ### Post-refactor bottleneck analysis (informational, not a gate) After block-per-batch lands on L40S the next limiter is expected to be one of: - **Mamba2's selective scan** — already parallel across (batch, channels) but K-sequential by construction. ~5-10% of step time today; possibly more after K-loop is fast. - **Kernel launch latency floor** — ~350 kernel launches per step at ~5 µs each = ~2 ms minimum step time (with CUDA Graph) or much higher without. - **cuBLAS calls inside Mamba2's W_in / W_a / W_b / W_c projections** — pre-allocated workspaces help but small matmuls don't saturate the GPU. These set the floor for any future optimization round on L40S. Once that floor is known, a follow-up spec can decide whether to spend on H100 hardware, BF16 trunk, or larger batch — but this spec stays on L40S so the speedup is attributable to the refactor alone. ## Risks + Mitigations | Risk | Mitigation | |---|---| | FP order drift from cross-batch reduction | Deterministic reducer (sum in `bi` index order via stride-loop). Gate #4 (`relative_eq` 1e-6) catches drift large enough to matter. | | CUDA Graph capture breaks with reducer launches | Reducers are plain `launch_builder.launch()` — identical pattern to `layer_norm_reduce_param_grads`. `evaluate_works_after_captured_training_step` guards this. | | K-loop scratch accumulation bug | Gate #5 (`cfc_bwd_scratch_clears_between_steps`) explicitly asserts scratch = 0 at step N+1. Gate #2 (B=32 convergence) diverges immediately on chain-rule break. | | Existing single-sample finite-diff tests break | These use single-sample GPU helpers — orthogonal code path, unaffected by this refactor. If they break, the refactor accidentally touched the wrong file. | | Memory budget at future B | `scratch_bytes ≈ B × Σ(param tensor sizes)`. At B=512 → ~190 MB. No mitigation needed at any plausible scale. | | Refactor lands in pieces leaving half-old-half-new state | Each kernel refactor is one atomic commit covering kernel + scratch + reducer wiring + smoke. No `_legacy` symbols. `feedback_no_partial_refactor.md` + `feedback_no_legacy_aliases.md`. | | AdamW reads stale grad | The AdamW-after-reducer invariant is structurally enforced by the order of calls in `dispatch_train_step`. Documented in the trainer-wiring section; any future diagnostic / checkpoint code that needs final grads must run reducers first. | ## Rollout Each kernel refactor lands as a single atomic commit on `ml-alpha-phase-a`. No worktree, no merge step — commits go straight to the active branch (per session decision). **Commit 1: `reduce_axis0` kernel + first consumer (cfc_step refactor).** Bundles three things to avoid the orphan-kernel anti-pattern (`feedback_wire_everything_up.md`): - New `cuda/reduce_axis0.cu` + build.rs registration - Refactor `cfc_step_batched` fwd (launch config only — no scratch) - Refactor `cfc_step_backward_batched` + add 4 cfc scratch fields + zero scratch at step start + 4 reducer launches after K-loop bwd - Add `cfc_bwd_b1_matches_single_sample` + `cfc_bwd_scratch_clears_between_steps` tests - All existing smokes pass + new B=32 smoke (`stacked_trainer_loss_shrinks_at_batch_32`) **Commit 2: GRN bwd refactor.** Refactor `multi_horizon_heads_grn_bwd_batched` + 10 scratch fields + 10 reducer launches. All smokes pass. **Commit 3: VSN bwd refactor.** Refactor `variable_selection_bwd` + 2 scratch fields + 2 reducer launches. All smokes pass. **Commit 4: attention pool bwd refactor.** Refactor `attention_pool_bwd` + 1 scratch field + 1 reducer launch. All smokes pass. **Step 5 (no commit): local A/B perf benchmark.** `cargo test --release` wall-time delta old vs new. Verify gate #6 (≥ 3× local). **Step 6 (no commit): cluster A/B benchmark.** Argo submit on commit 4's HEAD with t6z89-identical config. Verify gates #7 (≥ 3× wall) and #8 (AUC within ±0.005). ## Rollback If a gate fails after a commit lands: - **Single broken commit:** `git revert ` on `ml-alpha-phase-a`. Atomic-commit discipline (one kernel per commit) means each revert touches one kernel cleanly. - **Cascade breakage across multiple commits:** revert the range. E.g., if commits 2 + 3 both broke and only commit 1 is salvageable, `git revert ..`. The architectural Phase 1+2+3 work (everything before commit 1) and t6z89's reference commit `7a558b88b` remain in history regardless. - **Worst case (perf is fine but AUC trajectory diverges by >0.005):** revert everything; we've kept the architectural state and gained data about which kernel changed semantics. Then write a follow-up spec that adds the missing oracle (e.g., GRN single-sample helper) before retrying. `ml-alpha-phase-a`'s history before commit 1 (currently `54aa69c10` + the spec commit) remains the safe rollback point for the entire refactor. ## Open Questions - Optimal `block_dim` for `reduce_axis0` itself (likely 256 threads; can be parameterised via launch config — decided in implementation). - Whether to fuse multiple reducer launches into a multi-tensor sweep kernel (probably one per tensor — per-launch overhead is negligible vs code-complexity savings).