fix(sp22-vnext): K=3 aux_outcome_labels CF-half buffer underrun

Phase B4b-2 introduced `exp_aux_to_label_per_sample` sized
`[alloc_episodes × alloc_timesteps]` (no cf-mult expansion — the
B4b-1 per-step kernel only writes the on-policy half). The batch
finalisation cloned into the emitted `aux_outcome_labels` with size
`total = base_total × 2` (cf-mult expanded), so
`dtod_clone_i32(src, total, ...)` invoked `src.slice(..total)` on a
half-sized source → `CudaSlice::try_slice` returned `None` → the
internal `unwrap()` panicked at cudarc safe/core.rs:1648.

Repro: workflow train-xzv56 panicked after rollout completed
(timestep=999) on fold 0; rollout itself ran clean, the OOM from
the prior commit is gone.

Fix: replace the single `dtod_clone_i32` with a 3-step build:

  1. alloc_zeros::<i32>(total)
  2. cuMemsetD32Async(ptr, 0xFFFFFFFFu32, total, stream) — fills
     all `total` slots with i32 mask sentinel -1 (byte pattern
     0xFFFFFFFF reinterprets as i32(-1))
  3. memcpy_dtod the first `base_total` real labels from
     exp_aux_to_label_per_sample into the on-policy half

CF half remains at -1. The K=3 sparse-CE loss masks `label == -1`
out of the mean and B_valid count, so CF samples contribute zero
gradient — exactly the semantic we want (CF actions have no
observed trade-close outcome to predict against).

Lib suite: 1016/0 green. Audit doc updated.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
This commit is contained in:
jgrusewski
2026-05-14 15:26:46 +02:00
parent 0acf77e656
commit 256a5fa5ab
2 changed files with 96 additions and 10 deletions

View File

@@ -5515,16 +5515,64 @@ impl GpuExperienceCollector {
&self.stream, &self.aux_conf_per_sample, total, "aux_conf"
)?;
// SP22 H6 vNext Phase B4b-2 (2026-05-14): copy the per-(env, t)
// trade-outcome label tile out of the collector's scratch into
// the emitted batch. Same dtod-clone pattern as `aux_sign_labels`
// (the K=2 sibling label column above). The kernel's per-step
// writes (Phase B4b-1 amendment) accumulate into the scratch
// across the entire rollout — every (env, t) slot has its label
// by the time the rollout completes.
let aux_outcome_labels = dtod_clone_i32(
&self.stream, &self.exp_aux_to_label_per_sample, total, "aux_outcome_labels"
)?;
// SP22 H6 vNext Phase B4b-2 (2026-05-14): build the per-sample
// trade-outcome label column for the emitted batch. The K=2
// sibling (`aux_sign_labels`) is filled by `aux_sign_label_kernel`
// walking `0..total` directly — total = base_total × 2 includes
// the counterfactual expansion. The K=3 per-step producer
// (`trade_outcome_label_kernel`, B4b-1) writes only the first
// `base_total = n_episodes × timesteps` slots of
// `exp_aux_to_label_per_sample` (sized `[alloc_episodes ×
// alloc_timesteps]` — pre-cf-mult). A naive
// `dtod_clone_i32(src, total)` would `slice(..total)` and panic
// (cudarc safe/core.rs Option::unwrap on None — workflow
// train-xzv56 repro at fold 0, post-rollout).
//
// Fix: alloc `[total]`, fill ALL slots with the mask sentinel
// -1 via `cuMemsetD32Async` (pattern 0xFFFFFFFF reinterprets as
// i32 = -1), then `memcpy_dtod` the first `base_total` real
// labels into the on-policy half. CF half stays at -1; the K=3
// sparse-CE loss masks `-1` labels out of the mean + B_valid
// count, so CF samples contribute zero gradient — matches the
// K=2 head's semantic (CF labels are also undefined; the K=2
// sign-label kernel writes the same `bar+lookahead` future-
// price label to both halves which collapses to a redundant
// observation, while we explicitly mask).
let aux_outcome_labels = {
let mut buf = self.stream.alloc_zeros::<i32>(total)
.map_err(|e| MLError::ModelError(format!(
"alloc aux_outcome_labels i32[{total}]: {e}"
)))?;
// Fill all `total` slots with i32 sentinel -1 via byte
// pattern 0xFFFFFFFF. Async on the collector stream — no
// stream-sync needed since the downstream consumers (replay
// insert + K=3 loss) launch on the same stream.
#[allow(unsafe_code)]
unsafe {
let rc = cudarc::driver::sys::cuMemsetD32Async(
buf.raw_ptr(),
0xFFFF_FFFFu32, // i32::MIN pattern? no — 0xFFFFFFFF = i32(-1)
total, // word count (NOT bytes), per CUDA API
self.stream.cu_stream(),
);
if rc != cudarc::driver::sys::cudaError_enum::CUDA_SUCCESS {
return Err(MLError::ModelError(format!(
"cuMemsetD32Async aux_outcome_labels mask-fill: {rc:?}"
)));
}
}
// Copy the on-policy half (first `base_total` slots) from
// the per-step scratch buffer. `base_total` exactly matches
// `exp_aux_to_label_per_sample.len() = alloc_episodes ×
// alloc_timesteps`, so the slice fits.
let src_view = self.exp_aux_to_label_per_sample.slice(..base_total);
let mut dst_view = buf.slice_mut(..base_total);
self.stream.memcpy_dtod(&src_view, &mut dst_view)
.map_err(|e| MLError::ModelError(format!(
"DtoD aux_outcome_labels on-policy half: {e}"
)))?;
buf
};
Ok(GpuExperienceBatch {
states,

View File

@@ -2,6 +2,44 @@
**Status:** Populated during Plan 1 Task 6 (A.5 orphan audit). Updated on every commit per Invariant 7.
## 2026-05-14 — fix(sp22-vnext): K=3 aux_outcome_labels CF-half buffer underrun
**Branch:** `sp20-aux-h-fixed`. **Trigger:** workflow `train-xzv56`
panic `Option::unwrap()` at cudarc safe/core.rs:1648 after rollout
collection completed (timestep=999) on fold 0.
**Root cause:** Phase B4b-2 introduced a per-(env, t) trade-outcome
label tile `exp_aux_to_label_per_sample` sized `[alloc_episodes ×
alloc_timesteps]` (no cf-mult expansion — the B4b-1 kernel only
writes to the on-policy half). The batch-finalisation site cloned
into the emitted `aux_outcome_labels` with size
`total = base_total × 2` (cf-mult expanded), so
`dtod_clone_i32(src, total, ...)` invoked `src.slice(..total)` on a
half-sized buffer → `CudaSlice::try_slice` returned `None`
`split_at`-family `unwrap()` panicked at cudarc safe/core.rs:1648.
**Fix:** replace the single `dtod_clone_i32` call with a 3-step
build:
1. `alloc_zeros::<i32>(total)` for the destination
2. `cuMemsetD32Async(ptr, 0xFFFFFFFFu32, total, stream)` — fills all
`total` slots with i32 mask sentinel `-1` (byte pattern 0xFFFFFFFF
reinterprets as `i32(-1)`)
3. `memcpy_dtod` the first `base_total` real labels from
`exp_aux_to_label_per_sample` into the on-policy half
The CF half remains at `-1`. The K=3 sparse-CE loss already masks
`label == -1` out of the mean and `B_valid` count, so CF samples
contribute zero gradient — exactly the semantic we want (CF actions
have no observed trade-close outcome to predict against; their
"trade" is counterfactual).
**Tests:** `SQLX_OFFLINE=true cargo test -p ml --lib` 1016/0 green.
The bug was a runtime-only failure (only manifested on real rollout
shape `n_episodes × timesteps × 2`), not caught by lib unit tests
which use smaller batches.
**Touched:** `crates/ml/src/cuda_pipeline/gpu_experience_collector.rs`.
## 2026-05-14 — config(dqn): strip H100-tuned VRAM overrides from dqn-production.toml
**Branch:** `sp20-aux-h-fixed`. **Trigger:** workflow `train-ft8ph` OOM at