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:
@@ -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,
|
||||
|
||||
@@ -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
|
||||
|
||||
Reference in New Issue
Block a user