memo(crt-a): forward_only cost investigation — Case 2 (stateless K-window)
forward_only requires the full K=seq_len window on every call and resets Mamba2 h_s2 to zero each invocation — no cross-call state carry. Additionally, the call is inside the stride=200 gate in harness.rs (spec §3.2 claim "already every event" is incorrect as of HEAD). Moving to stride=1 without a forward_step implementation would be a ~200x GPU cost increase. A0.5 must implement forward_step with persistent h_s2, a dedicated K=1 CUDA graph, and session-reset hook. Memo documents exact file paths, line numbers, required struct/method changes, and open questions for the A0.5 implementer. Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
This commit is contained in:
@@ -0,0 +1,318 @@
|
|||||||
|
# CRT Phase A — forward_only Cost Investigation
|
||||||
|
|
||||||
|
**Date:** 2026-05-20
|
||||||
|
**Branch:** `ml-alpha-phase-a`
|
||||||
|
**Task:** A0 (read-only research)
|
||||||
|
**Author:** CRT-A0 agent
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 1. TL;DR
|
||||||
|
|
||||||
|
**This is Case 2: stateless K-window.** Every call to `forward_only` in
|
||||||
|
`crates/ml-alpha/src/trainer/perception.rs` processes the full K-window from
|
||||||
|
scratch. The Mamba2 SSM recurrent state (`h_s2`) is zero-initialised once at
|
||||||
|
scratch allocation time and is never updated between calls — the scan kernel
|
||||||
|
treats it as a fixed zero residual, not a carry-over from the previous call.
|
||||||
|
Concretely, `h_s2` is a `[B, hidden_dim]` buffer that never receives a write
|
||||||
|
after construction.
|
||||||
|
|
||||||
|
**However:** the spec's §3.2 claim "Trunk forward pass (already every event)"
|
||||||
|
is **WRONG** as of HEAD. The current harness at `harness.rs:242` places
|
||||||
|
`forward_only` INSIDE the `if self.event_count % stride == 0` gate — it is
|
||||||
|
called every 200 events, not every event. This directly contradicts §3.2 and is
|
||||||
|
the primary finding of this investigation. Moving `forward_only` outside the
|
||||||
|
stride gate would mean calling the full K=64 window scan 200× more often,
|
||||||
|
which is a ~200× GPU cost increase for that call alone.
|
||||||
|
|
||||||
|
**Recommendation for A0.5:** A0.5 MUST implement `forward_step` — an
|
||||||
|
incremental single-step SSM inference that carries persistent `h_s2` state
|
||||||
|
across calls instead of zeroing it per call. This is the only path to ≤ 2×
|
||||||
|
wall-time on Phase A. Task CRT.A.0.5 is not a no-op.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 2. Evidence
|
||||||
|
|
||||||
|
### 2.1 forward_only is inside the stride gate
|
||||||
|
|
||||||
|
File: `crates/ml-backtesting/src/harness.rs`
|
||||||
|
|
||||||
|
```rust
|
||||||
|
// Line 242 — both forward_only AND step_decision are gated:
|
||||||
|
if self.event_count % stride == 0 && self.snapshot_window.len() == self.seq_len {
|
||||||
|
let window: Vec<Mbp10RawInput> = self.snapshot_window.iter().cloned().collect();
|
||||||
|
let probs_all = self.trainer.forward_only(&window) // line 244
|
||||||
|
.context("trainer.forward_only")?;
|
||||||
|
// ...
|
||||||
|
self.sim.broadcast_alpha(&last_probs)?;
|
||||||
|
self.sim.step_decision_with_latency(raw.ts_ns, &self.sim_config)?;
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
The comment at line 239 says "At decision-stride boundaries: run forward inference + sim decision." Both operations are inside the same gate. The spec's §3.2 says "Trunk forward pass (already every event)" — this is incorrect.
|
||||||
|
|
||||||
|
### 2.2 forward_only signature requires the full K-window
|
||||||
|
|
||||||
|
File: `crates/ml-alpha/src/trainer/perception.rs`, line 2301:
|
||||||
|
|
||||||
|
```rust
|
||||||
|
pub fn forward_only(&mut self, snapshots: &[Mbp10RawInput]) -> Result<Vec<f32>> {
|
||||||
|
let b_sz = self.cfg.n_batch;
|
||||||
|
let k_seq = self.cfg.seq_len;
|
||||||
|
anyhow::ensure!(
|
||||||
|
snapshots.len() == b_sz * k_seq, // line 2305 — requires B*K snapshots
|
||||||
|
...
|
||||||
|
);
|
||||||
|
...
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
The function hard-enforces that exactly `n_batch * seq_len` snapshots are
|
||||||
|
provided. With the default `seq_len=64` and `n_batch=1`, every call requires
|
||||||
|
exactly 64 snapshots. There is no single-step variant.
|
||||||
|
|
||||||
|
### 2.3 Mamba2 h_s2 is never carried between calls
|
||||||
|
|
||||||
|
File: `crates/ml-alpha/src/mamba2_block.rs`, lines 151–165:
|
||||||
|
|
||||||
|
```rust
|
||||||
|
/// `h_s2` is zero-initialised once and never written (no residual carry
|
||||||
|
/// from a prior chunk in the supervised path). The scan kernel reads it
|
||||||
|
/// as a constant addition to h_enriched_seq.
|
||||||
|
pub struct Mamba2BlockForwardScratch {
|
||||||
|
...
|
||||||
|
pub h_s2: GpuTensor, // [n_batch, hidden_dim] (zero residual)
|
||||||
|
...
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
And in `forward_train_seq_into` (`mamba2_block.rs:1116`), `h_s2` is passed as
|
||||||
|
the initial-state argument to the scan kernel:
|
||||||
|
|
||||||
|
```rust
|
||||||
|
.arg(scratch.h_s2.cuda_data()) // line 1116 — always zero, never updated
|
||||||
|
```
|
||||||
|
|
||||||
|
`h_s2` is allocated once in `Mamba2BlockForwardScratch::new` via
|
||||||
|
`GpuTensor::zeros` (line 191) and never written afterward. It is a constant
|
||||||
|
zero buffer. Each call to `forward_train_seq_into` starts the SSM scan from
|
||||||
|
h=0 regardless of what was computed on the previous call.
|
||||||
|
|
||||||
|
The same pattern holds for both Mamba2 layers:
|
||||||
|
- Layer 1: `self.trunk.mamba2_l1_mut().forward_train_seq_into(&self.vsn_out_d, &mut self.mamba2_fwd_scratch)` at line 2506
|
||||||
|
- Layer 2: `self.trunk.mamba2_l2_mut().forward_train_seq_into(&self.ln_a_out_d, &mut self.mamba2_l2_fwd_scratch)` at line 2531
|
||||||
|
|
||||||
|
Both scratch objects hold their own `h_s2: GpuTensor` initialised to zeros.
|
||||||
|
|
||||||
|
### 2.4 The CUDA Graph capture bakes in K=seq_len steps
|
||||||
|
|
||||||
|
File: `crates/ml-alpha/src/trainer/perception.rs`, lines 2358–2396:
|
||||||
|
|
||||||
|
The three-state machine (warmup → capture → replay) captures the full kernel
|
||||||
|
chain — VSN → Mamba2×2 → LN×2 → transpose → attn-pool → CfC K-loop → heads —
|
||||||
|
for a fixed `(b_sz, k_seq, total_snaps)` shape. A `forward_step` variant would
|
||||||
|
need its own graph with `(b_sz, 1, b_sz)` shape and persistent state passed via
|
||||||
|
device pointers rather than the scratch zero buffer.
|
||||||
|
|
||||||
|
### 2.5 Production seq_len
|
||||||
|
|
||||||
|
From `crates/ml-alpha/examples/alpha_train.rs`, line 62:
|
||||||
|
|
||||||
|
```rust
|
||||||
|
#[arg(long, default_value_t = 64)]
|
||||||
|
seq_len: usize,
|
||||||
|
```
|
||||||
|
|
||||||
|
Default seq_len is 64. Each `forward_only` call runs 64 Mamba2 steps for both
|
||||||
|
layers.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 3. Cost Analysis
|
||||||
|
|
||||||
|
### 3.1 Per-call kernel work
|
||||||
|
|
||||||
|
Each `forward_only` call executes:
|
||||||
|
1. Host-side staging fill: `B*K * ~60` float32 writes (< 1 µs, OUTSIDE graph)
|
||||||
|
2. CUDA Graph replay or first-pass dispatch:
|
||||||
|
- `snap_feature_assemble_batched`: `B*K` rows
|
||||||
|
- VSN forward: `B*K` rows
|
||||||
|
- Mamba2 L1 `scan_fwd_seq`: K sequential steps over `[B, K, hidden_dim]`
|
||||||
|
- LN_a forward: `B*K` rows
|
||||||
|
- Mamba2 L2 `scan_fwd_seq`: K sequential steps
|
||||||
|
- LN_b forward: `B*K` rows
|
||||||
|
- Transpose: `[B, K, H]` → `[K, B, H]`
|
||||||
|
- Attention pool: K steps
|
||||||
|
- CfC K-loop: K steps
|
||||||
|
- Multi-horizon heads: K positions × N_HORIZONS outputs
|
||||||
|
3. Stream sync + DtoH probs: `K * B * N_HORIZONS` floats (64 × 1 × 4 = 256 f32)
|
||||||
|
|
||||||
|
The Mamba2 scan is a sequential K-step recurrence — it cannot be parallelised
|
||||||
|
over K. Cost scales linearly with K.
|
||||||
|
|
||||||
|
### 3.2 Cost at stride=200 vs stride=1
|
||||||
|
|
||||||
|
At `decision_stride=200`, `forward_only` is called once per 200 events.
|
||||||
|
If the stride gate is removed without adding `forward_step`, `forward_only`
|
||||||
|
is called once per event: **200× more calls, each still running K=64 steps**.
|
||||||
|
|
||||||
|
With CUDA Graph replay the per-call overhead is low (mostly the DtoD staging
|
||||||
|
copy + graph launch), but the actual Mamba2 scan work is O(K) per call. Calling
|
||||||
|
K=64-step inference 200× more frequently is a ~200× increase in GPU kernel
|
||||||
|
work for the encoder path.
|
||||||
|
|
||||||
|
The controller kernels (`decision_policy_*` + `seed_inflight_limits_batched`)
|
||||||
|
are indeed light (block-per-backtest, O(1) work). The spec's §3.4 cost estimate
|
||||||
|
correctly identifies these as the dominant concern — but it implicitly assumes
|
||||||
|
the trunk forward pass stays at stride=200. It is not correct that the forward
|
||||||
|
pass is "already every event."
|
||||||
|
|
||||||
|
### 3.3 Correct cost model for `forward_step`
|
||||||
|
|
||||||
|
If `forward_step` is implemented with persistent h_s2 state:
|
||||||
|
- Per-call work: 1 Mamba2 step × 2 layers = O(hidden_dim × state_dim)
|
||||||
|
- No K-window scatter/gather: only 1 snapshot staged per call
|
||||||
|
- No CfC K-loop: only the final position's head output needed
|
||||||
|
- Estimated per-call GPU time: ~1/64 of current `forward_only`
|
||||||
|
|
||||||
|
At stride=1 with `forward_step`, total encoder GPU work ≈ 200/64 ≈ 3.1× current.
|
||||||
|
Combined with controller kernels (light, 200× more calls), total wall-time
|
||||||
|
should land in the 2–4× range, consistent with the §3.4 budget of ≤ 2×
|
||||||
|
(achievable if the forward step is sufficiently fast relative to controller work).
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 4. Recommendation for Task A0.5
|
||||||
|
|
||||||
|
**A0.5 is required and non-trivial.** The following kernel changes are needed:
|
||||||
|
|
||||||
|
### 4.1 New Rust struct: `Mamba2BlockStepScratch`
|
||||||
|
|
||||||
|
Mirrors `Mamba2BlockForwardScratch` but sized for `K=1`:
|
||||||
|
- `x: GpuTensor` — `[B, hidden_dim]`
|
||||||
|
- `a_proj: GpuTensor` — `[B, state_dim]`
|
||||||
|
- `b_proj: GpuTensor` — `[B, state_dim]`
|
||||||
|
- `h_s2: GpuTensor` — **`[B, hidden_dim]` — PERSISTENT, updated each call**
|
||||||
|
- `h_out: GpuTensor` — `[B, hidden_dim]` — single-step enriched output
|
||||||
|
|
||||||
|
Location: add to `crates/ml-alpha/src/mamba2_block.rs` alongside
|
||||||
|
`Mamba2BlockForwardScratch`.
|
||||||
|
|
||||||
|
### 4.2 New kernel call path: `Mamba2Block::step_into`
|
||||||
|
|
||||||
|
Mirrors `forward_train_seq_into` but:
|
||||||
|
- Input shape: `[B, 1, in_dim]` (single snapshot)
|
||||||
|
- Calls `kernel_fwd_seq` with `k_i32 = 1`
|
||||||
|
- After the scan, **copies `h_out` into `h_s2`** (carry-forward)
|
||||||
|
- Or: a dedicated `mamba2_alpha_scan_step` kernel that reads/writes `h_s2`
|
||||||
|
in-place
|
||||||
|
|
||||||
|
The Mamba2 `scan_fwd_seq` kernel at
|
||||||
|
`crates/ml-alpha/src/kernels/mamba2_alpha_kernel.cu` (referenced at
|
||||||
|
`crates/ml-alpha/build.rs:11`) will need to be called with K=1. Check whether
|
||||||
|
the kernel handles K=1 without corner-case issues (e.g., shared-memory tile
|
||||||
|
sizing assumptions). If K=1 is not safe, a dedicated `scan_fwd_step` kernel
|
||||||
|
variant is required.
|
||||||
|
|
||||||
|
### 4.3 New method: `PerceptionTrainer::forward_step`
|
||||||
|
|
||||||
|
Signature:
|
||||||
|
|
||||||
|
```rust
|
||||||
|
pub fn forward_step(&mut self, snapshot: &Mbp10RawInput) -> Result<[f32; N_HORIZONS]>
|
||||||
|
```
|
||||||
|
|
||||||
|
Behaviour:
|
||||||
|
1. Stage 1 snapshot into pinned buffers (outside CUDA Graph)
|
||||||
|
2. Run VSN on 1 row
|
||||||
|
3. Run `Mamba2Block::step_into` (L1) — updates L1 h_s2 in-place
|
||||||
|
4. Run LN_a on 1 row
|
||||||
|
5. Run `Mamba2Block::step_into` (L2) — updates L2 h_s2 in-place
|
||||||
|
6. Run LN_b on 1 row
|
||||||
|
7. Run the final CfC step + head projection for 1 position
|
||||||
|
8. Sync + DtoH: `N_HORIZONS` floats (4 f32)
|
||||||
|
9. Return probs for the current (single) position
|
||||||
|
|
||||||
|
This method should have its own CUDA Graph (capture on second call, replay
|
||||||
|
thereafter). Because K=1, the graph is small and replay overhead dominates —
|
||||||
|
confirm with profiling that graph replay is faster than eager dispatch at K=1.
|
||||||
|
|
||||||
|
### 4.4 Harness changes (in A1)
|
||||||
|
|
||||||
|
Once `forward_step` exists, `harness.rs` changes are:
|
||||||
|
|
||||||
|
```rust
|
||||||
|
// Before (stride-gated):
|
||||||
|
if self.event_count % stride == 0 && self.snapshot_window.len() == self.seq_len {
|
||||||
|
let window = self.snapshot_window.iter().cloned().collect::<Vec<_>>();
|
||||||
|
let probs_all = self.trainer.forward_only(&window)?;
|
||||||
|
...
|
||||||
|
}
|
||||||
|
|
||||||
|
// After (every event, once window bootstrapped):
|
||||||
|
if self.snapshot_window.len() == self.seq_len {
|
||||||
|
let probs = self.trainer.forward_step(&raw)?;
|
||||||
|
self.sim.broadcast_alpha(&probs)?;
|
||||||
|
self.sim.step_decision_with_latency(raw.ts_ns, &self.sim_config)?;
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
The sliding window (`snapshot_window: VecDeque<Mbp10RawInput>`) is still
|
||||||
|
needed for the warm-up period (until K events are seen) but thereafter serves
|
||||||
|
only as a `len() == self.seq_len` bootstrap check — the SSM state carries
|
||||||
|
forward in the L1/L2 scratch buffers.
|
||||||
|
|
||||||
|
### 4.5 Bootstrap concern
|
||||||
|
|
||||||
|
`forward_only` bootstraps Mamba2 with K=64 steps of context. `forward_step`
|
||||||
|
starts from h_s2=0. For the first `seq_len-1` events the model has no
|
||||||
|
accumulated context — the predictions will be from a cold-start state.
|
||||||
|
|
||||||
|
Mitigation options (implementer should choose):
|
||||||
|
a. Keep the first inference as `forward_only` (at `event_count == seq_len-1`),
|
||||||
|
then transfer the final `h_s2` state from that call into the step scratch,
|
||||||
|
and switch to `forward_step` thereafter. This requires a new method
|
||||||
|
`PerceptionTrainer::transfer_step_state_from_window` or similar.
|
||||||
|
b. Accept cold-start predictions for the first K events; the Wiener-α EMA
|
||||||
|
in conviction smoothing (spec §4.2) will dampen the cold-start noise.
|
||||||
|
c. Use option (b) but gate decisions on `event_count >= seq_len` — already
|
||||||
|
present in the harness via the `self.snapshot_window.len() == self.seq_len`
|
||||||
|
check.
|
||||||
|
|
||||||
|
Option (c) is the simplest: no changes to bootstrap semantics, the existing
|
||||||
|
warm-up guard stays, and the first K events remain decision-free. The h_s2
|
||||||
|
state simply accumulates naturally from zero.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 5. Open Questions for A0.5 Implementer
|
||||||
|
|
||||||
|
1. **K=1 in scan_fwd_seq kernel**: Does the existing Mamba2 CUDA kernel at
|
||||||
|
`mamba2_alpha_kernel.cu` handle `K=1` safely? Shared-memory block sizing and
|
||||||
|
warp-tile assumptions may break at K=1. If so, a dedicated `scan_fwd_step`
|
||||||
|
kernel variant is required. Verify with the K=1 case before implementing the
|
||||||
|
full graph.
|
||||||
|
|
||||||
|
2. **h_s2 carry-forward mechanism**: The cleanest implementation writes h_out
|
||||||
|
over h_s2 in-place at the end of each step. But `h_s2` in the current
|
||||||
|
scratch is declared immutable (`cuda_data()` not `data_mut()`) and is passed
|
||||||
|
read-only to the scan kernel. Either add a write-back pass after the scan, or
|
||||||
|
restructure `step_into` to accept a mutable h_s2 that it writes in-place.
|
||||||
|
|
||||||
|
3. **CUDA Graph at K=1**: Graph replay overhead is typically 5–15 µs. At K=1,
|
||||||
|
the actual kernel work may be shorter than the replay overhead. Profile the
|
||||||
|
K=1 eager dispatch vs graph replay on the L40S to determine whether graphing
|
||||||
|
is worth it.
|
||||||
|
|
||||||
|
4. **Session resets**: The SSM state in `forward_step` accumulates across
|
||||||
|
session boundaries. The harness already has session-gap detection (via
|
||||||
|
`ts_ns` gaps). On session-gap detection, `forward_step` should reset h_s2 to
|
||||||
|
zero and require a new bootstrap period. Add a `reset_step_state()` method
|
||||||
|
and call it from the harness on session-gap events.
|
||||||
|
|
||||||
|
5. **Spec §3.2 correction**: The spec states "Trunk forward pass (already every
|
||||||
|
event)" — this is factually incorrect as of HEAD. The spec should be
|
||||||
|
corrected (or a note added) to reflect that `forward_only` is currently
|
||||||
|
stride-gated and that `forward_step` is the A0.5 deliverable. The A1
|
||||||
|
implementer should know the spec was wrong on this point.
|
||||||
Reference in New Issue
Block a user