feat(sp20): Phase 1.2 sp20_emas_compute kernel
Component 5 / Kernel 1 of the SP20 design — central state-tracker
that updates 8 Wiener-α EMAs per training step:
- 4 ISV slots: ALPHA_EMA (511), WR_EMA (512),
HOLD_PCT_EMA (515), HOLD_REWARD_EMA (516)
- 4 internal: trade_duration_ema, aux_conf_p50_ema,
aux_conf_std_ema, aux_dir_acc_ema (private
scratch consumed by Phase 1.3 controllers)
Per-EMA i32 observation counters (mapped-pinned obs_count[8])
handle the pearl_first_observation_bootstrap sentinel transition
correctly even when 0.0 is a legitimate observation (e.g.,
first-loss WR=0). count==0 ⇒ replace, count>0 ⇒ Wiener-blend at
α = 0.4 (WIENER_ALPHA_FLOOR per
pearl_wiener_alpha_floor_for_nonstationary).
Phase 1.2 lands kernel + launcher + 5 tests (4 GPU oracle, 1
floor lock) + build entry + audit doc atomically per
feedback_no_partial_refactor. Production wire-up (Phase 1.4)
deferred — kernel is dead code until then.
Verified on RTX 3050 Ti (sm_86):
- 4 GPU oracle tests pass: first_observation_replaces_sentinel,
wiener_alpha_converges_to_long_run_mean,
hold_reward_ema_gated_on_hold_bars,
per_step_emas_fire_unconditionally
- 4 launcher unit tests pass (constants + struct sanity)
- 1 floor-lock test pass (WIENER_ALPHA_FLOOR == 0.4)
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
This commit is contained in:
@@ -10907,3 +10907,207 @@ caller.
|
||||
AUX_GATE_TEMP / HOLD_COST_SCALE` from the EMAs.
|
||||
- Phase 1.4: training-loop wire-up calls all 3 producers in
|
||||
sequence on the same stream as the aux-head forward.
|
||||
|
||||
## 2026-05-09 — SP20 Phase 1.2: sp20_emas_compute kernel (additive)
|
||||
|
||||
### Component
|
||||
|
||||
- New CUDA kernel `crates/ml/src/cuda_pipeline/sp20_emas_compute_kernel.cu`
|
||||
- New Rust launcher `crates/ml/src/cuda_pipeline/sp20_emas_compute.rs`
|
||||
(re-exported from `cuda_pipeline::sp20_emas_compute`)
|
||||
- New GPU oracle test `crates/ml/tests/sp20_emas_compute_test.rs`
|
||||
- Cubin manifest entry in `crates/ml/build.rs`
|
||||
|
||||
### Purpose
|
||||
|
||||
Component 5 / Kernel 1 of the SP20 fused-producer chain — the central
|
||||
state-tracker that updates 8 Wiener-α EMAs per training step. Phase
|
||||
1.4 will land the production launch site atomically with Kernel 2
|
||||
(`sp20_controllers_compute`, Phase 1.3) per
|
||||
`feedback_no_partial_refactor`.
|
||||
|
||||
### EMAs maintained
|
||||
|
||||
Per `pearl_wiener_alpha_floor_for_nonstationary` (α floor 0.4):
|
||||
|
||||
| EMA | Storage | Gate | Observation source |
|
||||
|------------------|--------------------|---------------------------|---------------------------------------------|
|
||||
| `ALPHA_EMA` | ISV[511] | `is_close == 1` | `alpha = R_event - hold_baseline` |
|
||||
| `WR_EMA` | ISV[512] | `is_close == 1` | `is_win` ∈ {0.0, 1.0} |
|
||||
| `TRADE_DURATION` | internal[0] | `is_close == 1` | `trade_duration` (bars) |
|
||||
| `HOLD_PCT_EMA` | ISV[515] | per-step (always) | `(action_is_hold == 1) ? 1 : 0` |
|
||||
| `HOLD_REWARD_EMA`| ISV[516] | `action_is_hold == 1` | `r_per_bar = -aux_conf * cost_scale` |
|
||||
| `AUX_P50` | internal[1] | per-step (always) | `aux_logits_p50` (Phase 1.1 output) |
|
||||
| `AUX_STD` | internal[2] | per-step (always) | `aux_logits_std` (Phase 1.1 output) |
|
||||
| `AUX_DIR_ACC` | internal[3] | per-step (always) | `aux_dir_acc` ∈ {0.0, 1.0} |
|
||||
|
||||
The 4 internal scratch slots are deliberately NOT exposed as ISV
|
||||
constants — Phase 1.3's `sp20_controllers_compute` consumes them
|
||||
in-kernel to derive the public ISV controllers (`N_STEP`,
|
||||
`TARGET_HOLD_PCT`, `AUX_GATE_TEMP`, `AUX_CONF_THRESHOLD`). Keeping
|
||||
them private keeps the SP20 ISV slot count fixed at the 10 reserved
|
||||
in `f5eed1fa7`.
|
||||
|
||||
### Bootstrap design — counter-based, not value-based
|
||||
|
||||
Per `pearl_first_observation_bootstrap`: sentinel = 0; first
|
||||
observation REPLACES directly. The naive value-based sentinel-detect
|
||||
pattern (`fabsf(current - 0) < EPS` ⇒ replace) used in
|
||||
`sp17_a_var_ema_kernel.cu` does NOT work for some SP20 EMAs because
|
||||
0.0 is a *plausible legitimate observation*:
|
||||
|
||||
- `WR_EMA`: first-loss ⇒ legit observation 0.0; second-loss observation
|
||||
would be incorrectly treated as "still un-bootstrapped" and replace
|
||||
instead of blend, never converging away from 0.
|
||||
- `HOLD_PCT_EMA`: long initial non-Hold streak legitimately keeps
|
||||
the EMA at 0; a first Hold action would over-correct.
|
||||
- `HOLD_REWARD_EMA`: post-bootstrap, identical r_per_bar = −0.05
|
||||
observations would all read `current ≈ −0.05`, not equal sentinel,
|
||||
so this case happens to work — but the contract is asymmetric
|
||||
across EMAs which is a contract bug waiting to happen.
|
||||
|
||||
This kernel uses a **per-EMA i32 observation counter** (8 entries in
|
||||
a separate mapped-pinned `obs_count` buffer, aligned with
|
||||
`OBS_COUNT_*` constants in the launcher) that atomically transitions
|
||||
0 → 1 on the first firing observation. `count == 0` ⇒ replace,
|
||||
`count > 0` ⇒ Wiener-blend at α = 0.4. This pattern is safer than
|
||||
value-based sentinel detect for any EMA whose value range includes 0
|
||||
as a legitimate observation; subsequent SP20 EMA producers (Kernel 2
|
||||
controller outputs that aren't EMAs themselves) won't need the same
|
||||
mechanism, but any future EMA-style producer added here SHOULD reuse
|
||||
the same counter buffer rather than reverting to value-based detect.
|
||||
|
||||
### Algorithm
|
||||
|
||||
Single-block, single-thread kernel. Per launch, for each of the 8
|
||||
EMAs:
|
||||
|
||||
1. If the gate is OFF for this EMA this step, skip.
|
||||
2. Read `prev` from the target buffer (ISV or internal).
|
||||
3. Read `count` from `obs_count[ema_idx]`.
|
||||
4. If `count == 0`, `next = observation` (Pearl-A bootstrap).
|
||||
5. Else `next = (1 - α) * prev + α * observation` with α = 0.4
|
||||
(`WIENER_ALPHA_FLOOR_F`).
|
||||
6. Write `next` back; increment `obs_count[ema_idx]`.
|
||||
|
||||
Single `__threadfence_system()` at end covers all writes (single
|
||||
thread, sequentially consistent within itself; the fence makes them
|
||||
PCIe-visible to mapped-pinned host pages).
|
||||
|
||||
### Wiring contract
|
||||
|
||||
Phase 1.2 wires kernel + launcher + tests + build entry **only**.
|
||||
There is NO production caller in this commit — the kernel is dead
|
||||
code awaiting Phase 1.4's atomic wire-up of:
|
||||
|
||||
1. Trainer-owned `MappedF32Buffer<4>` for the `internal` scratch.
|
||||
2. Trainer-owned `MappedI32Buffer<8>` for the `obs_count` counters.
|
||||
3. Per-step plumbing of the 9 scalar inputs (`is_close`, `is_win`,
|
||||
`alpha`, `trade_duration`, `action_is_hold`, `r_per_bar`,
|
||||
`aux_p50_in`, `aux_std_in`, `aux_dir_acc`) from their producers
|
||||
(trade-physics for the close/win/duration; reward kernel for the
|
||||
alpha; experience collector for the hold-state and r_per_bar;
|
||||
`sp20_stats_compute` Phase 1.1 for the two stats; aux-head
|
||||
compare for `aux_dir_acc`).
|
||||
4. Stream-ordering: launch on the SAME stream as `sp20_stats_compute`
|
||||
so the stream-ordering invariant guarantees `aux_p50_in` /
|
||||
`aux_std_in` reads see the latest stats.
|
||||
5. Fold-boundary reset: `internal` buffer + `obs_count` counters
|
||||
must reset alongside ISV slots [510..520) at fold transitions.
|
||||
Phase 1.4 adds the dispatch arms.
|
||||
|
||||
The `cuda_pipeline::sp20_emas_compute` module exports
|
||||
`launch_sp20_emas_compute` + the contract constants
|
||||
(`WIENER_ALPHA_FLOOR`, `EMA_INTERNAL_*`, `OBS_COUNT_*`,
|
||||
`EmaInputs`) the wire-up will consume.
|
||||
|
||||
### Pearls + invariants honoured
|
||||
|
||||
- `pearl_first_observation_bootstrap` — counter-based bootstrap;
|
||||
count==0 ⇒ replace, count>0 ⇒ blend.
|
||||
- `pearl_wiener_alpha_floor_for_nonstationary` — α = 0.4 hardcoded
|
||||
per `feedback_isv_for_adaptive_bounds` (structural floor, not
|
||||
adaptive bound).
|
||||
- `feedback_no_atomicadd` — single-thread kernel; no concurrent
|
||||
writes anywhere.
|
||||
- `feedback_no_cpu_compute_strict` — every blend lives in the
|
||||
kernel; the launcher only packs scalar inputs into kernel args.
|
||||
- `feedback_no_htod_htoh_only_mapped_pinned` — both ISV and the
|
||||
`internal` / `obs_count` buffers are mapped-pinned; kernel emits
|
||||
`__threadfence_system()` for PCIe-visible coherence.
|
||||
- `pearl_no_host_branches_in_captured_graph` — single-block,
|
||||
single-thread kernel; safe to capture in the per-step CUDA
|
||||
Graph.
|
||||
- `feedback_no_partial_refactor` — kernel + launcher + tests +
|
||||
build entry land in one commit; production wire-up lands
|
||||
atomically in Phase 1.4 with the controller producer.
|
||||
- `feedback_no_stubs` — every output written for real; no
|
||||
return-zero placeholders.
|
||||
|
||||
### Tests
|
||||
|
||||
`crates/ml/tests/sp20_emas_compute_test.rs` — GPU oracle tests
|
||||
(all `#[ignore = "requires GPU"]` except the floor lock):
|
||||
|
||||
1. `first_observation_replaces_sentinel` — fire one trade-close
|
||||
with `is_win=1, alpha=0.5, trade_duration=7`; verify
|
||||
`WR_EMA == 1.0`, `ALPHA_EMA == 0.5`,
|
||||
`internal[TRADE_DURATION] == 7.0` (no blending). Also
|
||||
verifies the per-EMA counters track only firings.
|
||||
2. `wiener_alpha_converges_to_long_run_mean` — 100 alternating
|
||||
`is_win = 1/0/1/0…` events; assert `|WR_EMA - 0.5| < 0.30`.
|
||||
Bound is generous to absorb the alternation noise inherent to
|
||||
a binary-indicator EMA at α = 0.4.
|
||||
3. `hold_reward_ema_gated_on_hold_bars` — 5 non-Hold bars
|
||||
(`action_is_hold=0, r_per_bar=−0.05`) followed by 5 Hold bars
|
||||
with same `r_per_bar`; verify `HOLD_REWARD_EMA ≈ −0.05` after
|
||||
all 10 (only 5 fired, first bootstrapped, next 4 were
|
||||
identical-input no-ops). Counter check: HOLD_REWARD count == 5.
|
||||
4. `per_step_emas_fire_unconditionally` — 10 steps with
|
||||
`is_close=0, action_is_hold=0`, `aux_p50=0.2, aux_std=0.1,
|
||||
aux_dir_acc=1.0`; verify the 4 per-step EMAs reach exactly
|
||||
those values (first observation bootstraps; subsequent
|
||||
identical observations are no-op blends). Counter check:
|
||||
per-step counters == 10, per-trade counters == 0.
|
||||
5. `wiener_alpha_floor_locked_at_0_4` — non-GPU contract lock.
|
||||
|
||||
Plus 4 unit tests in the launcher module (Wiener floor lock,
|
||||
internal/obs_count slot count locks, EmaInputs sentinel safety).
|
||||
|
||||
### Files modified
|
||||
|
||||
| File | Status | Purpose |
|
||||
|------|--------|---------|
|
||||
| `crates/ml/src/cuda_pipeline/sp20_emas_compute_kernel.cu` | NEW | Single-block 8-EMA producer kernel |
|
||||
| `crates/ml/src/cuda_pipeline/sp20_emas_compute.rs` | NEW | Rust launcher + contract constants |
|
||||
| `crates/ml/tests/sp20_emas_compute_test.rs` | NEW | 4 GPU oracle tests + 1 floor-lock test |
|
||||
| `crates/ml/src/cuda_pipeline/mod.rs` | +pub mod | Module declaration |
|
||||
| `crates/ml/build.rs` | +cubin entry | Compile entry for nvcc |
|
||||
| `docs/dqn-wire-up-audit.md` | This entry | Audit log |
|
||||
|
||||
### Verification
|
||||
|
||||
```
|
||||
SQLX_OFFLINE=true CUDA_COMPUTE_CAP=86 cargo check -p ml --features cuda
|
||||
SQLX_OFFLINE=true CUDA_COMPUTE_CAP=86 cargo test -p ml \
|
||||
--test sp20_emas_compute_test --features cuda -- --ignored --nocapture
|
||||
SQLX_OFFLINE=true CUDA_COMPUTE_CAP=86 cargo test -p ml \
|
||||
--features cuda --lib sp20_emas_compute
|
||||
```
|
||||
|
||||
All 4 GPU oracle tests + 4 launcher unit tests + 1 floor-lock test
|
||||
pass on RTX 3050 Ti (sm_86). L40S verification deferred until Phase
|
||||
1.4 lands the production caller.
|
||||
|
||||
### Phase 1.2 → Phase 1.4 forward references
|
||||
|
||||
- Phase 1.3: `sp20_controllers_compute_kernel.cu` reads the 4 ISV
|
||||
EMAs (511, 512, 515, 516) + the 4 internal scratch slots
|
||||
(`internal[0..4]`) and derives the 6 controller outputs
|
||||
(LOSS_CAP, HOLD_COST_SCALE, TARGET_HOLD_PCT, N_STEP,
|
||||
AUX_CONF_THRESHOLD, AUX_GATE_TEMP).
|
||||
- Phase 1.4: trainer plumbs the per-step inputs into the
|
||||
`EmaInputs` struct, allocates the `internal` /
|
||||
`obs_count` buffers (with fold-reset registry entries), and
|
||||
launches all 3 SP20 kernels in sequence on the same stream as
|
||||
the aux-head forward (Kernel 3 → Kernel 1 → Kernel 2).
|
||||
|
||||
Reference in New Issue
Block a user