Revises all 12 issues from the self-review pass:
1. (HIGH) Soften bit-equivalence claim — single-sample helper and
batched kernel at B=1 are different CUDA kernels; FP order may
differ. Acceptance is relative_eq ≤ 1e-6, not bit-exact.
2. (HIGH) Explicit asymmetry: only cfc_step has a single-sample GPU
oracle. GRN/VSN/attn bwd rely on smoke + chain-rule preservation.
feedback_no_cpu_test_fallbacks.md forbids a CPU reference oracle.
3. (HIGH) Realistic targets — 3× floor, 5× stretch. Drops 15× claim
which was Amdahl-bounded under any reasonable assumption.
4. (MED) AdamW-after-reducer invariant stated explicitly: final grad
buffer is OVERWRITE by reducer, meaningful only after reducer ran.
5. (MED) New B=32 smoke test (stacked_trainer_loss_shrinks_at_batch_32)
actually exercises the cross-batch reduction path; existing
perception_overfit suite is all B=1.
6. (MED) Rollout commit 1 bundles reduce_axis0 + first consumer
(cfc_step refactor) to avoid feedback_wire_everything_up.md
orphan-kernel anti-pattern.
7. (LOW) Drop "merge to ml-alpha-phase-a" — user already chose direct
commits to that branch; clarify in Rollout.
8. (LOW) Add explicit scratch-sizing formula:
scratch_bytes ≈ B × Σ(param tensor sizes per kernel).
9. (LOW) Remove resolved open question (memset_zeros ordering).
10. (STRUCT) Post-refactor bottleneck analysis section — names the
next L40S floor (Mamba2 scan, launch latency, cuBLAS).
11. (STRUCT) cudaFuncSetAttribute note — refactored cfc_bwd's per-
block shared mem drops to 1 KiB; no attribute change needed.
12. (STRUCT) Explicit Rollback section — atomic-commit-per-kernel +
revert strategy; rollback baseline is the spec commit
(54aa69c10) on ml-alpha-phase-a.
Plus locks the target pool to L40S — speedup must be attributable to
the refactor, not to a hardware bump. H100 / BF16 / larger batch
become candidates for a follow-up spec once the L40S floor is known.
Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
264 lines
18 KiB
Markdown
264 lines
18 KiB
Markdown
# 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<CudaModule>`
|
||
|
||
**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 <sha>` 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 <sha2>..<sha4>`. 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).
|