diff --git a/crates/ml/src/cuda_pipeline/gpu_experience_collector.rs b/crates/ml/src/cuda_pipeline/gpu_experience_collector.rs index efa8f9f3a..801f994d7 100644 --- a/crates/ml/src/cuda_pipeline/gpu_experience_collector.rs +++ b/crates/ml/src/cuda_pipeline/gpu_experience_collector.rs @@ -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::(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, diff --git a/docs/dqn-wire-up-audit.md b/docs/dqn-wire-up-audit.md index 6e315a93b..3aa46eb99 100644 --- a/docs/dqn-wire-up-audit.md +++ b/docs/dqn-wire-up-audit.md @@ -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::(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