fix(dqn): SP1 Phase C — F1 NaN cuBLAS-overflow surgical fix (slots 26 + 32)

Patches two cuBLAS GEMM backward operations identified by SP1 Phase B
smoke smoke-test-xvzgk (commit f139a63ee) as the F1 ep2 NaN sources:

1. apply_iqn_trunk_gradient (gpu_dqn_trainer.rs:6885+):
   - Pre-call: clamp bw_d_h_s2 (the IQN DtoD-overwrite target that feeds
     the GRN trunk encoder's cuBLAS Linear_a/Linear_b dW/dX/dB GEMMs;
     symmetric with slot 27 source-side check) to ±1e6×ISV[96].
   - Post-call: sanitize iqn_trunk_m (slot 26 output) — isfinite-or-zero
     + magnitude clamp; defence-in-depth.

2. launch_cublas_backward_to main backward path:
   - Pre-call: clamp d_value_logits_buf + d_adv_logits_buf (slots 24/25
     inputs that feed bw.backward_full's dueling/branch backward GEMMs)
     to ±1e6×ISV[21].
   - Post-call: sanitize bn_d_concat_buf (slot 32 output, gated on
     bottleneck_dim > 0).

Both fixes use the new clamp_finite_f32_kernel utility (CUDA — replaces
NaN/Inf with 0 + clamps finite values to ±max_abs) + launch_clamp_finite_f32
(Rust wrapper). Kernel lives in dqn_utility_kernels.cu (already in
build.rs); loaded into the same module as the NaN check kernels in
compile_training_kernels — both call sites land inside captured children
(forward_child for slot 32, aux_child for slot 26) and use only stream-
bound launch_builder, so the kernel is graph-safe.

ISV-driven max_abs bound = 1e6 × isv[<slot>] with 1e3 ε floor for
uninitialized state (Invariant 1 carve-out per
feedback_isv_for_adaptive_bounds). Wide guard band — F0 inputs sit
several orders of magnitude below the threshold (F0 ISV[96] ≈ 1.0
LayerNorm RMS, F0 ISV[21] ≈ 1.0 Q-value scale; F0 |iqn_d_h_s2| ≤ ~10²,
F0 |d_value_logits| ≤ ~10³ post the existing 5.0 norm-clip at
line 16747), so guards are no-ops on F0; F1 overflows trigger clamp.
F0 paper-review pre-smoke confirmed.

Combined RELATED commit per feedback_no_partial_refactor — both kernels
share the same unsafe-pattern (cuBLAS GEMM extreme-intermediate-product
overflow) + the same fix template, so they ship together.

Phase B slots 24-35 remain as permanent diagnostic infrastructure;
will catch any future regression of this NaN class.

SP1 Phase D validation smoke pending.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
This commit is contained in:
jgrusewski
2026-04-30 01:10:46 +02:00
parent 2ba0eef718
commit 97f1d25f54
3 changed files with 180 additions and 4 deletions

View File

@@ -404,6 +404,40 @@ extern "C" __global__ void dqn_zero_kernel(
if (i < n) buf[i] = 0.0f;
}
/* ══════════════════════════════════════════════════════════════════════
* IN-PLACE FINITE CLAMP KERNEL — SP1 Phase C surgical fix infrastructure.
*
* For each element of `buf`:
* - If !isfinite(v) (NaN or ±Inf): set to 0.0f.
* - Else clamp finite |v| to ±max_abs (ISV-driven bound supplied by host).
*
* Used as pre-call sanitisation on cuBLAS GEMM backward inputs (slots 27 +
* 24/25 per `docs/dqn-backward-nan-audit.md`) and as post-call defence-in-
* depth on outputs (slots 26 + 32) where extreme intermediate-product
* accumulator overflow has been observed (smoke-test-xvzgk @ commit
* f139a63ee). max_abs is sourced from existing ISV slots:
* - 1e6 × ISV[H_S2_RMS_EMA_INDEX=96] for the IQN trunk path
* - 1e6 × ISV[Q_DIR_ABS_REF_INDEX=21] for the Bottleneck Linear path
* with a 1e3 floor applied host-side when the ISV slot is uninitialised
* (Invariant 1 ε carve-out — prevents zero-clamping a hot buffer when the
* ISV producer hasn't run yet).
*
* Launch config: grid=(ceil(n/256)), block=(256). In-place — no extra
* device memory and no DtoD/HtoH copies (per
* `feedback_no_htod_htoh_only_mapped_pinned`).
* ══════════════════════════════════════════════════════════════════════ */
extern "C" __global__ void clamp_finite_f32_kernel(
float* __restrict__ buf,
int n,
float max_abs
) {
int idx = blockIdx.x * blockDim.x + threadIdx.x;
if (idx >= n) return;
float v = buf[idx];
/* NaN/Inf → 0; finite values clamped to ±max_abs */
buf[idx] = isfinite(v) ? fmaxf(fminf(v, max_abs), -max_abs) : 0.0f;
}
/* ══════════════════════════════════════════════════════════════════════
* REGIME-ADAPTIVE PER SCALING KERNEL
*

View File

@@ -2655,6 +2655,16 @@ pub struct GpuDqnTrainer {
pub(crate) nan_check_f32_kernel: CudaFunction,
/// Second NaN check kernel handle (formerly f32, now f32 — both check f32 buffers).
pub(crate) nan_check_f32_kernel_b: CudaFunction,
/// SP1 Phase C surgical fix: in-place finite clamp kernel
/// (`clamp_finite_f32_kernel` in `dqn_utility_kernels.cu`). Replaces
/// NaN/Inf with 0 and clamps finite |v| to ±max_abs. max_abs is sourced
/// from existing ISV slots (1e6 × ISV[H_S2_RMS_EMA_INDEX=96] for the
/// IQN trunk path; 1e6 × ISV[Q_DIR_ABS_REF_INDEX=21] for the
/// Bottleneck Linear path) with a 1e3 floor for uninitialised state.
/// Used as pre-call sanitisation on cuBLAS GEMM backward inputs and
/// post-call defence-in-depth on outputs at slots 26/32 per
/// `docs/dqn-backward-nan-audit.md`.
pub(crate) clamp_finite_f32_kernel: CudaFunction,
/// SP1 Phase B: NaN flag buffer expanded from 24 → 48 slots. Slots 0-23
/// are forward-path checks (existing); slots 24-35 are backward-path
/// checks (new — wired by Task 4); slots 36-47 are reserved headroom
@@ -6938,6 +6948,22 @@ impl GpuDqnTrainer {
// `docs/dqn-backward-nan-audit.md` (slot 35 row).
self.check_nan_f32(self.ptrs.bw_d_h_s2, b * sh2, 35)?;
// SP1 Phase C surgical fix (slot 27 input sanitise): clamp NaN/Inf
// to 0 + bound magnitude to 1e6 × ISV[H_S2_RMS_EMA_INDEX=96] on the
// GEMM input gradient. Pre-call defence — bounds the cuBLAS GEMM
// accumulator's intermediate products so the reductions inside
// `encoder_backward_chain` (Linear_a/Linear_b dW/dX/dB GEMMs) cannot
// produce f32-overflow NaN from the kind of pathological inputs
// observed at smoke-test-xvzgk (commit f139a63ee). 1e3 ε floor
// covers the uninitialised-ISV case (Invariant 1 carve-out per
// `feedback_isv_for_adaptive_bounds`). F0 paper-review: F0
// ISV[96] ≈ 1.0 (LayerNorm output RMS), max_abs = 1e6 — F0-typical
// |iqn_d_h_s2| ≤ ~10² so guard is a no-op on F0; F1 overflows
// (≥ 1e6) trigger clamp.
let h_s2_rms_ema = self.read_isv_signal_at(H_S2_RMS_EMA_INDEX);
let max_abs_input = (1e6_f32 * h_s2_rms_ema).max(1e3_f32);
self.launch_clamp_finite_f32(self.ptrs.bw_d_h_s2, b * sh2, max_abs_input)?;
// ── 3. GRN trunk backward (encoder_backward_chain) ──
let w_ptrs = f32_weight_ptrs_from_base(self.ptrs.params_ptr, &param_sizes);
let states_ptr = if self.config.bottleneck_dim > 0 {
@@ -6992,6 +7018,21 @@ impl GpuDqnTrainer {
h_s1_save_ptr,
)?;
// SP1 Phase C surgical fix (slot 26 output sanitise): post-call
// defence-in-depth — `encoder_backward_chain`'s cuBLAS Linear_a/
// Linear_b/Linear_residual dW/dX/dB GEMMs accumulate onto
// `iqn_trunk_m` (= `trunk_scratch_base`) with beta=1; an extreme
// intermediate-product overflow can produce NaN/Inf even when the
// input gradient was finite (residual 8% from
// `session_2026-04-05_nan_investigation.md` cross-checked against
// smoke-test-xvzgk slot 26). isfinite-or-zero + magnitude clamp
// catches that residual class. Same `max_abs_input` bound as the
// pre-call clamp because both inputs and outputs of the trunk GEMM
// share the H_S2_RMS_EMA-driven magnitude scale.
self.launch_clamp_finite_f32(
self.ptrs.iqn_trunk_m, trunk_grad_total, max_abs_input,
)?;
// ── 3b. Plan 4 Task 1B-iv-ext: VSN backward chain ─────────────────
// Consumes the bn_d_concat just produced by encoder_backward_chain,
// assembles `d_vsn_gated_state`, and writes 24 VSN dW/dB into
@@ -9374,7 +9415,7 @@ impl GpuDqnTrainer {
// per array. Stack is set once in DQNTrainer::new() (64KB for all kernels).
// ── Compile utility kernels (grad_norm, adam_update, etc.) ─
let (grad_norm_kernel, grad_norm_finalize_kernel, grad_norm_finalize_adam, grad_norm_finalize_post_aux, adam_update_kernel, adam_update_post_aux, scale_f32_post_aux, saxpy_kernel, zero_kernel, regime_scale_kernel, shrink_perturb, _relu_mask_in_module, spectral_norm_kernel, clip_grad_kernel, pad_states_kernel, saxpy_f32_kernel, scale_f32_kernel, stochastic_depth_kernel, stochastic_depth_rng_kernel, bn_tanh_backward_kernel, bn_bias_grad_kernel, bn_tanh_concat_kernel_fn, vaccine_dot_kernel, vaccine_project_kernel, vaccine_dot_finalize, causal_intervene_kernel_fn, causal_reduce_kernel_fn, causal_mean_reduce_kernel_fn, pruning_mask_kernel, pruning_compute_kernel, her_inplace_kernel, nan_check_f32_kernel, nan_check_f32_kernel_b, popart_normalize_kernel, saxpy_f32_adam_grad, saxpy_f32_aux, scale_f32_aux, distill_saxpy_aux, grad_norm_standalone_post_aux, shrink_perturb_ungraphed, scale_f32_ungraphed, popart_robust_kernel) =
let (grad_norm_kernel, grad_norm_finalize_kernel, grad_norm_finalize_adam, grad_norm_finalize_post_aux, adam_update_kernel, adam_update_post_aux, scale_f32_post_aux, saxpy_kernel, zero_kernel, regime_scale_kernel, shrink_perturb, _relu_mask_in_module, spectral_norm_kernel, clip_grad_kernel, pad_states_kernel, saxpy_f32_kernel, scale_f32_kernel, stochastic_depth_kernel, stochastic_depth_rng_kernel, bn_tanh_backward_kernel, bn_bias_grad_kernel, bn_tanh_concat_kernel_fn, vaccine_dot_kernel, vaccine_project_kernel, vaccine_dot_finalize, causal_intervene_kernel_fn, causal_reduce_kernel_fn, causal_mean_reduce_kernel_fn, pruning_mask_kernel, pruning_compute_kernel, her_inplace_kernel, nan_check_f32_kernel, nan_check_f32_kernel_b, clamp_finite_f32_kernel, popart_normalize_kernel, saxpy_f32_adam_grad, saxpy_f32_aux, scale_f32_aux, distill_saxpy_aux, grad_norm_standalone_post_aux, shrink_perturb_ungraphed, scale_f32_ungraphed, popart_robust_kernel) =
compile_training_kernels(&stream, &config)?;
// Separate grad_norm instance for non-graph launches (outside CUDA graph).
@@ -12618,6 +12659,7 @@ impl GpuDqnTrainer {
her_inplace_kernel,
nan_check_f32_kernel,
nan_check_f32_kernel_b,
clamp_finite_f32_kernel,
nan_flags_buf,
pruning_epoch: prune_ep,
pruning_fraction: prune_frac,
@@ -15147,6 +15189,44 @@ impl GpuDqnTrainer {
.map_err(|e| MLError::ModelError(format!("nan_flags reset: {e}")))
}
/// SP1 Phase C surgical fix: launch in-place finite clamp on `buf_ptr`.
///
/// Replaces NaN/Inf with 0 and clamps finite |v| to ±max_abs (host-side
/// `f32`). max_abs is sourced from existing ISV slots (1e6 ×
/// ISV[H_S2_RMS_EMA_INDEX=96] for the IQN trunk path; 1e6 ×
/// ISV[Q_DIR_ABS_REF_INDEX=21] for the Bottleneck Linear path) with a
/// `1e3` ε floor applied at the call site for uninitialised ISV state
/// (Invariant 1 carve-out per `feedback_isv_for_adaptive_bounds`).
///
/// Pattern matches the existing `isfinite`-guard precedent in
/// `iqn_backward_per_sample` (line 612) and `iqn_adam_kernel` (line 873).
/// The kernel is in-place — no DtoD/HtoD/HtoH copies (per
/// `feedback_no_htod_htoh_only_mapped_pinned`). Graph-safe — both call
/// sites land inside captured children (forward_child for slot 32,
/// aux_child for slot 26) and the kernel uses only stream-bound
/// `launch_builder` like `check_nan_f32`.
pub fn launch_clamp_finite_f32(
&self,
buf_ptr: u64,
n: usize,
max_abs: f32,
) -> Result<(), MLError> {
let n_i32 = n as i32;
let blocks = ((n + 255) / 256) as u32;
unsafe {
self.stream
.launch_builder(&self.clamp_finite_f32_kernel)
.arg(&buf_ptr).arg(&n_i32).arg(&max_abs)
.launch(LaunchConfig {
grid_dim: (blocks.max(1), 1, 1),
block_dim: (256, 1, 1),
shared_mem_bytes: 0,
})
.map_err(|e| MLError::ModelError(format!("clamp_finite_f32 launch: {e}")))?;
}
Ok(())
}
/// Read PopArt running variance from GPU (single scalar DtoH).
pub fn read_popart_var(&self, out: &mut [f32; 1]) -> Result<(), MLError> {
self.stream.memcpy_dtoh(&self.popart_var, out)
@@ -18506,6 +18586,32 @@ impl GpuDqnTrainer {
} else {
0u64
};
// SP1 Phase C surgical fix (slots 24/25 input sanitise): pre-call
// clamp on the cuBLAS GEMM inputs that feed `backward_full`. NaN
// or extreme finite values in `d_value_logits_buf` /
// `d_adv_logits_buf` propagate through the dueling backward GEMMs
// into `d_h_s2`/`bn_d_concat_buf` and produce the slot 32 NaN
// observed at smoke-test-xvzgk (commit f139a63ee). Bound by 1e6 ×
// ISV[Q_DIR_ABS_REF_INDEX=21] (the direction-branch |Q| EMA — same
// reference scale used elsewhere for q-magnitude bounds), with a
// 1e3 ε floor for uninitialised state (Invariant 1 carve-out per
// `feedback_isv_for_adaptive_bounds`). F0 paper-review: F0
// ISV[21] ≈ 1.0 (Q-value scale at convergence), max_abs = 1e6 —
// F0-typical |d_value_logits| ≤ ~10³ post the existing 5.0
// norm-clip (line 16747) so guard is a no-op on F0; F1 overflows
// (≥ 1e6) trigger clamp. NB: the existing line-16747 norm-clamp
// is a per-buffer-norm operation (single L2 norm of the entire
// buffer); this kernel is a per-element magnitude clamp — they
// catch different failure modes (norm = aggregate; clamp_finite
// = per-element NaN/Inf/extreme), so both stay.
let q_dir_abs_ref = self.read_isv_signal_at(Q_DIR_ABS_REF_INDEX);
let max_abs_q = (1e6_f32 * q_dir_abs_ref).max(1e3_f32);
let n_val_clamp = self.d_value_logits_buf.len();
let n_adv_clamp = self.d_adv_logits_buf.len();
self.launch_clamp_finite_f32(d_value_logits_ptr, n_val_clamp, max_abs_q)?;
self.launch_clamp_finite_f32(d_adv_logits_ptr, n_adv_clamp, max_abs_q)?;
bw.backward_full(
&self.stream,
d_value_logits_ptr,
@@ -18637,6 +18743,22 @@ impl GpuDqnTrainer {
h_s1_ptr,
)?;
// SP1 Phase C surgical fix (slot 32 output sanitise): post-call
// defence-in-depth on `bn_d_concat_buf` (= `s1_dx_output` when
// bottleneck active). The trunk-encoder backward writes here via
// Linear_a (beta=0) + Linear_residual (beta=1) cuBLAS GEMMs whose
// intermediate-product accumulator can overflow even on finite
// inputs at extreme F1-ep2 magnitudes (smoke-test-xvzgk slot 32
// signature). Reuses `max_abs_q` from the slot 24/25 pre-call
// clamp because the same Q-magnitude reference scale bounds both
// ends of the dueling backward chain. Skipped when bottleneck is
// disabled (`bn_d_concat_buf` is unused / `s1_dx_output == 0`).
if self.config.bottleneck_dim > 0 {
let bn_d_concat_len = self.bn_d_concat_buf.len();
let bn_d_concat_ptr = self.bn_d_concat_buf.raw_ptr();
self.launch_clamp_finite_f32(bn_d_concat_ptr, bn_d_concat_len, max_abs_q)?;
}
// ── #31 Bottleneck backward: chain rule through tanh + GEMM ──
// After backward_full, d_bn_concat_buf has dL/d(bn_concat) [B, concat_dim] f32.
// Chain rule (post-1B-iii — bottleneck Linear's forward consumes
@@ -20335,7 +20457,7 @@ fn deflate_rank_one(mat: &[f32], n: usize, lambda_1: f32) -> Vec<f32> {
fn compile_training_kernels(
stream: &Arc<CudaStream>,
config: &GpuDqnTrainConfig,
) -> Result<(CudaFunction, CudaFunction, CudaFunction, CudaFunction, CudaFunction, CudaFunction, CudaFunction, CudaFunction, CudaFunction, CudaFunction, CudaFunction, CudaFunction, CudaFunction, CudaFunction, CudaFunction, CudaFunction, CudaFunction, CudaFunction, CudaFunction, CudaFunction, CudaFunction, CudaFunction, CudaFunction, CudaFunction, CudaFunction, CudaFunction, CudaFunction, CudaFunction, CudaFunction, CudaFunction, CudaFunction, CudaFunction, CudaFunction, CudaFunction, CudaFunction, CudaFunction, CudaFunction, CudaFunction, CudaFunction, CudaFunction, CudaFunction, CudaFunction), MLError> {
) -> Result<(CudaFunction, CudaFunction, CudaFunction, CudaFunction, CudaFunction, CudaFunction, CudaFunction, CudaFunction, CudaFunction, CudaFunction, CudaFunction, CudaFunction, CudaFunction, CudaFunction, CudaFunction, CudaFunction, CudaFunction, CudaFunction, CudaFunction, CudaFunction, CudaFunction, CudaFunction, CudaFunction, CudaFunction, CudaFunction, CudaFunction, CudaFunction, CudaFunction, CudaFunction, CudaFunction, CudaFunction, CudaFunction, CudaFunction, CudaFunction, CudaFunction, CudaFunction, CudaFunction, CudaFunction, CudaFunction, CudaFunction, CudaFunction, CudaFunction, CudaFunction), MLError> {
info!(
state_dim = ml_core::state_layout::STATE_DIM,
total_params = compute_total_params(config),
@@ -20468,14 +20590,22 @@ fn compile_training_kernels(
.map_err(|e| MLError::ModelError(format!("dqn_nan_check_f32 load: {e}")))?;
let nan_check_f32_b = module.load_function("dqn_nan_check_f32_b")
.map_err(|e| MLError::ModelError(format!("dqn_nan_check_f32_b load: {e}")))?;
// SP1 Phase C surgical fix: in-place finite clamp kernel (replaces NaN/Inf
// with 0; clamps finite |v| to ±max_abs from caller). Loaded from the same
// `module` as the NaN check kernels because both are launched inside
// `submit_forward_ops_main` (graph-captured forward_child) and
// `apply_iqn_trunk_gradient` (aux_child) — sharing the module is correct
// because they belong to the same captured replay group.
let clamp_finite_f32 = module.load_function("clamp_finite_f32_kernel")
.map_err(|e| MLError::ModelError(format!("clamp_finite_f32_kernel load: {e}")))?;
let popart_normalize = module.load_function("popart_normalize_kernel")
.map_err(|e| MLError::ModelError(format!("popart_normalize_kernel load: {e}")))?;
let popart_robust = ungraphed_module.load_function("popart_normalize_robust")
.map_err(|e| MLError::ModelError(format!("popart_normalize_robust load: {e}")))?;
info!("GpuDqnTrainer: 37 utility kernels loaded from precompiled cubin (5 CUmodules)");
Ok((grad_norm, grad_norm_finalize, grad_norm_finalize_adam, grad_norm_finalize_post_aux, adam_update, adam_update_post_aux, scale_f32_post_aux, saxpy, zero, regime_scale, shrink_perturb, _relu_mask_from_module, spectral_norm, clip_grad, pad_states, saxpy_f32, scale_f32, stochastic_depth, stochastic_depth_rng, bn_tanh_bw, bn_bias_grad, bn_tanh_concat, vaccine_dot, vaccine_project, vaccine_dot_finalize, causal_intervene, causal_reduce, causal_mean_reduce, pruning_mask_fn, pruning_compute_fn, her_inplace, nan_check_f32, nan_check_f32_b, popart_normalize, saxpy_f32_adam_grad, saxpy_f32_aux, scale_f32_aux, distill_saxpy_aux, grad_norm_standalone_post_aux, shrink_perturb_ungraphed, scale_f32_ungraphed, popart_robust))
info!("GpuDqnTrainer: 38 utility kernels loaded from precompiled cubin (5 CUmodules)");
Ok((grad_norm, grad_norm_finalize, grad_norm_finalize_adam, grad_norm_finalize_post_aux, adam_update, adam_update_post_aux, scale_f32_post_aux, saxpy, zero, regime_scale, shrink_perturb, _relu_mask_from_module, spectral_norm, clip_grad, pad_states, saxpy_f32, scale_f32, stochastic_depth, stochastic_depth_rng, bn_tanh_bw, bn_bias_grad, bn_tanh_concat, vaccine_dot, vaccine_project, vaccine_dot_finalize, causal_intervene, causal_reduce, causal_mean_reduce, pruning_mask_fn, pruning_compute_fn, her_inplace, nan_check_f32, nan_check_f32_b, clamp_finite_f32, popart_normalize, saxpy_f32_adam_grad, saxpy_f32_aux, scale_f32_aux, distill_saxpy_aux, grad_norm_standalone_post_aux, shrink_perturb_ungraphed, scale_f32_ungraphed, popart_robust))
}
/// Load the standalone Polyak EMA kernel from precompiled cubin.

View File

@@ -2246,3 +2246,15 @@ SP1 Phase B foundation — stale-doc cleanup + Task 4 prep (2026-04-29): doc-onl
SP1 Phase B accessors (2026-04-29): added 5 new accessor methods exposing backward-path buffer device pointers for Task 4's per-step NaN checks. **Scope diverged from plan** — the plan's "delegate accessors on `GpuDqnTrainer` for slots 27 + 28" is structurally invalid: `GpuDqnTrainer` does NOT own `GpuIqnHead` (the IQN head is owned by `FusedTrainingCtx` at `fused_training.rs:289`, alongside the trainer at line 234). The audit's per-slot accessor table (lines 535-536) is correct: slot 27/28 accessors land on `GpuIqnHead`, and Task 4's `run_nan_checks_post_backward(batch_size, iqn_d_h_s2_ptr, d_branch_logits_ptr, ...)` will receive the IQN pointers as `u64` arguments from the `FusedTrainingCtx` call site — same pattern already in use at `gpu_dqn_trainer.rs:6843` (`apply_iqn_trunk_gradient(&mut self, iqn_d_h_s2_ptr: u64, ...)`). Per `feedback_no_legacy_aliases.md`: no delegate wrappers — Task 4 either calls the IQN accessor directly via the `gpu_iqn` field on `FusedTrainingCtx` or receives the pointer via method args; no shadow accessor on `GpuDqnTrainer`. New accessors landed: `GpuDqnTrainer::{d_value_logits_buf_ptr, d_adv_logits_buf_ptr, cql_d_value_logits_ptr, aux_dh_s2_nb_buf_ptr}` (slots 24, 25, 29, 30 — 4 methods) + `GpuIqnHead::d_branch_logits_buf_ptr` (slot 28). Slot 27 (`iqn_d_h_s2_buf`) reuses the existing `GpuIqnHead::d_h_s2_raw_ptr()` accessor at `gpu_iqn_head.rs:1660` — no new method (per `feedback_no_legacy_aliases.md`, the existing accessor is sufficient; renaming + chasing one call site adds churn without value). Slots 26, 32, 33-35 reuse pre-existing handles (`self.ptrs.iqn_trunk_m`, `self.bn_d_concat_buf()` accessor, `self.ptrs.bw_d_h_s2`). Slot 31 (`ensemble_d_logits_buf`) deferred per Task 2 commit `387335e2b` (cross-struct on `FusedDqnTraining`). NO new scratch buffer added — the plan's `bw_d_h_s2_pre_saxpy` scratch + DtoD-copy approach was superseded by the audit's 3-call-site reformulation (slots 33/34/35 are post-main / post-aux / post-iqn snapshots of the same `bw_d_h_s2`, taken via inline call sites in Task 4). Pattern follows commit `e9096c7be`'s GRN-block accessor style (concise `pub(crate) fn name_ptr(&self) -> u64`, doc-comment referencing slot number + audit doc + buffer semantics). Additive — no behavioral change; new accessors consumed by Task 4's NaN check call sites.
SP1 Phase B instrumentation — Task 4 (2026-04-29): wired per-step backward-path NaN checks. New `GpuDqnTrainer::run_nan_checks_post_backward(batch_size, iqn_d_h_s2_ptr: Option<u64>, iqn_d_branch_logits_buf_ptr: Option<u64>)` mirrors `run_nan_checks_post_forward`'s pattern (single-block GPU reduce per buffer via `check_nan_f32`, no atomicAdd, no DtoH per step) and covers slots 24-30 + 32 — the eight backward-path buffers with stable post-backward state. Three INLINE `check_nan_f32` calls at orchestration call sites cover slots 33/34/35: **slot 33** in `launch_cublas_backward_to` AFTER `backward_full` + branch-concat accumulations and BEFORE `aux_heads_backward` (main-path entry-point check); **slot 34** in `launch_cublas_backward_to` AFTER `aux_heads_backward` SAXPY (compared against 33 to localise aux-head poisoning); **slot 35** in `apply_iqn_trunk_gradient` AFTER `graph_safe_copy_f32(bw_d_h_s2 ← iqn_d_h_s2_ptr, ...)` and BEFORE `encoder_backward_chain` (covers the IQN trunk DtoD overwrite — symmetric with slot 27 which checks the SOURCE side). Sequential 33→34→35 fire pattern localises the entry point: 33 alone = main backward chain; 34 fires after clean 33 = aux SAXPY; 35 fires after clean 34 = IQN DtoD or per-sample backward. **IQN pointers passed as `Option<u64>`** because `GpuIqnHead` is a sibling on `FusedTrainingCtx` (per Task 3 deviation finding, audit lines 535-536); `None` (when `iqn_lambda == 0.0` and `gpu_iqn = None`) cleanly skips slots 27/28 — semantically honest "buffer doesn't exist this run" rather than false-clean. **Call sites:** `fused_training.rs` ungraphed step at line 1519 + capture closure at line 2324 (both updated atomically — `feedback_no_partial_refactor`). The post-backward checks join the same `post_aux` captured child as the existing pre/post forward checks; the inline 33/34 checks land in the `forward` child (because `launch_cublas_backward_to` is called inside `submit_forward_ops_main`); slot 35 lands in the `aux` child (because `apply_iqn_trunk_gradient` is called inside `submit_aux_ops`). Slot 31 (`ensemble_d_logits_buf`) cleanly deferred per Task 2 commit `387335e2b` — its owner is `FusedDqnTraining`, and the ensemble Phase B saxpy guards must be verified before un-deferring; not partial — slot 31's deferral is documented in the new method's docstring + the audit doc. Lengths use `CudaSlice::len()` where the slice is owned by `GpuDqnTrainer` (slots 24, 25, 29, 30, 32 — auto-syncs with allocator padding), `iqn_trunk_m.len()` for slot 26 (matches the `trunk_params` allocation), `b * sh2` for slots 27/30/33/34/35, and inline `tba * b * q` arithmetic for slot 28 (since `GpuIqnHead::d_branch_logits_buf.len()` is private and computing it on the trainer side avoids adding a length accessor for one call site). Each check uses existing `check_nan_f32(buf_ptr, len, flag_idx)` infrastructure — no new kernel, no new scratch buffer, no DtoD/HtoD/HtoH copies. Flags accumulate within fold; reset on fold boundary via existing `reset_nan_flags()`. Readback flow (commit `d1808df14`) consumes them in BOTH `halt_nan` and `halt_grad_collapse` paths via the name tables annotated in commit `387335e2b`. Permanent diagnostic infrastructure — stays as production-grade regression sentinel after the surgical fix lands.
SP1 Phase C surgical fix (2026-04-29): patched two cuBLAS GEMM backward operations identified by Phase B smoke `smoke-test-xvzgk` at commit `f139a63ee`:
1. `apply_iqn_trunk_gradient` (gpu_dqn_trainer.rs:6885+) — pre-call clamp on `bw_d_h_s2` (the IQN DtoD-overwrite target that feeds the GRN trunk encoder's cuBLAS Linear_a/Linear_b dW/dX/dB GEMMs; symmetric with slot 27 source-side check); post-call sanitize on `iqn_trunk_m` (slot 26 buffer). ISV-driven max_abs bound = `1e6 * isv[H_S2_RMS_EMA_INDEX=96]` with `1e3` ε floor (Invariant 1 carve-out for ISV-uninitialized state).
2. `launch_cublas_backward_to` main backward path (gpu_dqn_trainer.rs:18516+) — pre-call clamp on `d_value_logits_buf` + `d_adv_logits_buf` inputs (slot 24/25 buffers — feed the dueling/branch backward GEMMs in `bw.backward_full(...)` which produces `d_h_s2` and ultimately `bn_d_concat_buf`); post-call sanitize on `bn_d_concat_buf` output (slot 32 buffer, gated on `bottleneck_dim > 0`). ISV-driven max_abs bound = `1e6 * isv[Q_DIR_ABS_REF_INDEX=21]` with `1e3` ε floor.
New utility: `clamp_finite_f32_kernel` (CUDA — replaces NaN/Inf with 0 + clamps finite values to ±max_abs); `launch_clamp_finite_f32` (Rust wrapper). Pattern matches the existing `isfinite`-guard precedent in `iqn_backward_per_sample` (line 612) + `iqn_adam_kernel` (line 873). Kernel lives in `dqn_utility_kernels.cu` (already in `build.rs`); loaded into the same `module` as the NaN check kernels in `compile_training_kernels`. Both call sites land inside captured children (forward_child for slot 32 inputs/output via `launch_cublas_backward_to` → `submit_forward_ops_main`; aux_child for slot 26 input/output via `apply_iqn_trunk_gradient` → `submit_aux_ops`); the kernel uses only stream-bound `launch_builder` like `check_nan_f32`, so it is graph-safe.
F0 risk: low — guards activate at 1e6× ISV magnitude (several orders of magnitude wider than F0-typical inputs); F0 paper-review confirmed guards are no-ops on F0 dynamics (F0 ISV[96] ≈ 1.0 LayerNorm RMS; F0 ISV[21] ≈ 1.0 Q-value scale; F0 |iqn_d_h_s2| ≤ ~10²; F0 |d_value_logits| ≤ ~10³ post the existing 5.0 norm-clip at line 16747).
Permanent diagnostic infrastructure (Phase B slots 24-35) stays as regression sentinel — will detect any future regression of the cuBLAS-overflow NaN class. Combined RELATED commit per `feedback_no_partial_refactor` — both kernels patched in one commit since they share the same unsafe-pattern (cuBLAS GEMM extreme-intermediate-product overflow) + the same fix template.