feat(dqn): SP2 A3 — fused NaN check populate + launch wrapper

Replaces A2's CudaSlice<u64>/<i32> field types with MappedU64Buffer/
MappedI32Buffer per feedback_no_htod_htoh_only_mapped_pinned. Mapped-
pinned eliminates the HtoD copy entirely — the kernel reads via the
device-mapped pointer (cuMemHostGetDevicePointer_v2) while the trainer
writes through the same mapped pages on the host side.

Adds populate_nan_check_meta on GpuDqnTrainer (one-shot construction-
time write of 12 (ptr, len) tuples for slots 24-35). Slot 31 deferred
(null entry); slots 27/28 nullable on Option<u64> (None when IQN
inactive); slots 33-35 null (inline checks fire separately at backward
orchestration phases — kept individual for entry-point localization).

Adds launch_nan_check_fused_f32 (per-step kernel launch wrapper with
grid_dim=12, block_dim=256, base_flag_idx=24). Registers
dqn_nan_check_fused_f32_kernel in compile_training_kernels (tuple
43→44, info log 38→39 utility kernels) — same module as the per-buffer
dqn_nan_check_f32 to share the captured replay group.

Constructor-time wire-up lands in FusedTrainingCtx::new after gpu_iqn
construction (gpu_iqn is owned by FusedTrainingCtx, not GpuDqnTrainer
— mirrors the same Option<u64> arg pattern used by
apply_iqn_trunk_gradient and run_nan_checks_post_backward).

Wrapper unused yet — call-site replacement (8 individual check_nan_f32
calls in run_nan_checks_post_backward → single fused launch) lands in
A4.

Audit doc updated (Invariant 7).

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
This commit is contained in:
jgrusewski
2026-04-30 09:14:03 +02:00
parent 82b6bd369e
commit fbf48df9de
3 changed files with 191 additions and 15 deletions

View File

@@ -2655,6 +2655,14 @@ 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,
/// SP2: fused multi-buffer NaN check kernel handle
/// (`dqn_nan_check_fused_f32_kernel` in `dqn_utility_kernels.cu`). One launch
/// processes 12 backward-path slots (24-35) in 12 blocks. Reads `(ptr, len)`
/// tables from `nan_check_buf_ptrs` / `nan_check_buf_lens` via the mapped
/// device pointer; null-pointer entries (deferred slot 31, optional IQN
/// slots when inactive, slots 33-35 inline elsewhere) no-op via the
/// kernel's null-pointer guard.
pub(crate) nan_check_fused_f32_kernel: CudaFunction,
/// SP1 Phase C surgical fix: in-place finite clamp kernel
/// (`dqn_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
@@ -2673,14 +2681,18 @@ pub struct GpuDqnTrainer {
pub(crate) nan_flags_buf: CudaSlice<i32>,
/// SP2: fused NaN-check buffer pointer table. 12 device pointers (u64) for
/// slots 24-35. Populated ONCE in the constructor (slot pointers are
/// stable across the trainer's lifetime). Slot 31 (deferred ensemble)
/// holds 0 — the fused kernel skips that block.
pub(crate) nan_check_buf_ptrs: CudaSlice<u64>,
/// slots 24-35. Host+device-visible mapped-pinned buffer; populated via
/// host-side write (no HtoD copy) per
/// `feedback_no_htod_htoh_only_mapped_pinned`. Populated ONCE in
/// `populate_nan_check_meta` (slot pointers are stable across the
/// trainer's lifetime). Slot 31 (deferred ensemble) holds 0 — the fused
/// kernel skips that block.
pub(crate) nan_check_buf_ptrs: MappedU64Buffer,
/// SP2: fused NaN-check buffer length table. 12 i32 lengths for slots
/// 24-35. Populated alongside nan_check_buf_ptrs.
pub(crate) nan_check_buf_lens: CudaSlice<i32>,
/// 24-35. Host+device-visible mapped-pinned buffer; populated via
/// host-side write (no HtoD copy) alongside `nan_check_buf_ptrs`.
pub(crate) nan_check_buf_lens: MappedI32Buffer,
/// #20 Pruning epoch (epoch at which to compute the mask).
pruning_epoch: usize,
@@ -9448,7 +9460,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, 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) =
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, nan_check_fused_f32_kernel, 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).
@@ -11311,12 +11323,20 @@ impl GpuDqnTrainer {
.map_err(|e| MLError::ModelError(format!("nan_flags alloc: {e}")))?;
// SP2: fused NaN-check (ptr, len) tables. 12 entries each. Allocated
// as zeros; populated once (next commit) after all backward-path
// buffers are constructed. The kernel reads `buf_ptrs[blockIdx.x]`
// and `buf_lens[blockIdx.x]` per block.
let nan_check_buf_ptrs = stream.alloc_zeros::<u64>(12)
// as mapped-pinned (`cuMemHostAlloc(DEVICEMAP|PORTABLE)`) per
// `feedback_no_htod_htoh_only_mapped_pinned` — host-side writes are
// visible to the kernel through the device-mapped pointer with zero
// HtoD copies. Populated once via `populate_nan_check_meta` after all
// backward-path buffers are constructed AND `gpu_iqn` is known. The
// kernel reads `buf_ptrs[blockIdx.x]` and `buf_lens[blockIdx.x]` per
// block.
//
// Safety: a CUDA context is active on this thread (the GpuDqnTrainer
// constructor runs within `FusedTrainingCtx::new` which has already
// initialised CUDA via the shared cublas handle).
let nan_check_buf_ptrs = unsafe { MappedU64Buffer::new(12) }
.map_err(|e| MLError::ModelError(format!("nan_check_buf_ptrs alloc: {e}")))?;
let nan_check_buf_lens = stream.alloc_zeros::<i32>(12)
let nan_check_buf_lens = unsafe { MappedI32Buffer::new(12) }
.map_err(|e| MLError::ModelError(format!("nan_check_buf_lens alloc: {e}")))?;
// v8: PopArt running statistics buffers
@@ -12701,6 +12721,7 @@ impl GpuDqnTrainer {
her_inplace_kernel,
nan_check_f32_kernel,
nan_check_f32_kernel_b,
nan_check_fused_f32_kernel,
clamp_finite_f32_kernel,
nan_flags_buf,
nan_check_buf_ptrs,
@@ -15227,6 +15248,132 @@ impl GpuDqnTrainer {
Ok(())
}
/// SP2: populate the fused NaN-check `(ptr, len)` tables. Called once
/// during construction after all backward-path buffers are allocated AND
/// `gpu_iqn` is known (its accessors feed slot 27/28 entries when IQN is
/// active; null/0 when None — the kernel's null-pointer guard skips them).
///
/// Slot index → buffer mapping (mirrors the per-slot accessor table in
/// `docs/dqn-backward-nan-audit.md` and the `run_nan_checks_post_backward`
/// per-slot launches):
/// 0 (slot 24) d_value_logits_buf — len = `d_value_logits_buf.len()`
/// 1 (slot 25) d_adv_logits_buf — len = `d_adv_logits_buf.len()`
/// 2 (slot 26) iqn_trunk_m — len = `trunk_param_count`
/// 3 (slot 27) iqn d_h_s2 buf — len = b * sh2 (or 0 if IQN inactive)
/// 4 (slot 28) iqn d_branch_logits_buf — len = total_branch_actions * b * q (or 0)
/// 5 (slot 29) cql_d_value_logits — len = `cql_d_value_logits.len()`
/// 6 (slot 30) aux_dh_s2_nb_buf — len = `aux_dh_s2_nb_buf.len()`
/// 7 (slot 31) ensemble (deferred) — ptr=0, len=0 → kernel skips block
/// 8 (slot 32) bn_d_concat_buf — len = `bn_d_concat_buf().len()`
/// 9-11 (slots 33-35) bw_d_h_s2 inline — ptr=0, len=0 → fused kernel skips
/// (inline checks fire at backward orchestration phases — see
/// `launch_cublas_backward_to` slots 33/34 and
/// `apply_iqn_trunk_gradient` slot 35).
///
/// Mapped-pinned host write — no HtoD copy. The kernel reads the same
/// memory through the device-mapped pointer (`dev_ptr`) once the launch
/// stream barrier ensures coherence.
pub(crate) fn populate_nan_check_meta(
&mut self,
b: usize,
sh2: usize,
iqn_d_h_s2_ptr: Option<u64>,
iqn_d_branch_logits_buf_ptr: Option<u64>,
iqn_q: Option<usize>,
) -> Result<(), MLError> {
// Slot-28 length expression: `tba × b × q` per
// `run_nan_checks_post_backward` slot 28. tba = b0+b1+b2+b3.
let tba = self.config.branch_0_size
+ self.config.branch_1_size
+ self.config.branch_2_size
+ self.config.branch_3_size;
let bn_d_concat_len = self.bn_d_concat_buf().len() as i32;
let trunk_param_count = self.trunk_param_count as i32;
let entries: [(u64, i32); 12] = [
// slot 24 — post-c51_grad value gradient
(self.d_value_logits_buf_ptr(), self.d_value_logits_buf.len() as i32),
// slot 25 — post-c51_grad branch advantage
(self.d_adv_logits_buf_ptr(), self.d_adv_logits_buf.len() as i32),
// slot 26 — IQN trunk cuBLAS bwd output
(self.ptrs.iqn_trunk_m, trunk_param_count),
// slot 27 — IQN dh_s2 (caller-supplied; 0 when IQN inactive)
(
iqn_d_h_s2_ptr.unwrap_or(0),
if iqn_d_h_s2_ptr.is_some() { (b * sh2) as i32 } else { 0 },
),
// slot 28 — IQN d_branch_logits_buf
(
iqn_d_branch_logits_buf_ptr.unwrap_or(0),
match (iqn_d_branch_logits_buf_ptr, iqn_q) {
(Some(_), Some(q)) => (tba * b * q.max(1)) as i32,
_ => 0,
},
),
// slot 29 — CQL gradient buffer
(self.cql_d_value_logits_ptr(), self.cql_d_value_logits.len() as i32),
// slot 30 — aux next-bar dh_s2
(self.aux_dh_s2_nb_buf_ptr(), self.aux_dh_s2_nb_buf.len() as i32),
// slot 31 — deferred ensemble (owned by FusedDqnTraining); null entry → kernel skip
(0, 0),
// slot 32 — bottleneck Linear backward dy
(self.bn_d_concat_buf().raw_ptr(), bn_d_concat_len),
// slots 33-35 — bw_d_h_s2 inline checks fire separately at backward
// orchestration phases (post-main / post-aux / post-iqn). Fused
// kernel skips these via null-pointer guard.
(0, 0),
(0, 0),
(0, 0),
];
// Host-side write through the mapped-pinned pages — kernel sees the
// values via dev_ptr after the next stream-sync barrier (no HtoD copy
// required, per `feedback_no_htod_htoh_only_mapped_pinned`).
let mut ptrs_view = [0u64; 12];
let mut lens_view = [0i32; 12];
for (i, (ptr, len)) in entries.iter().enumerate() {
ptrs_view[i] = *ptr;
lens_view[i] = *len;
}
self.nan_check_buf_ptrs.write_from_slice(&ptrs_view);
self.nan_check_buf_lens.write_from_slice(&lens_view);
Ok(())
}
/// SP2: launch the fused NaN-check kernel. Single launch processes all 12
/// backward-path slots (24-35) in 12 blocks, in parallel. Slots with null
/// pointer (deferred slot 31, optional IQN slots 27/28 when inactive,
/// slots 33-35 inline elsewhere) no-op via the kernel's null-pointer
/// guard. Sticky-flag semantics preserved (kernel only writes 1, never
/// clears).
///
/// Replaces the 8-launch sequence in `run_nan_checks_post_backward`.
/// Wrapper unused at A3 commit time — call-site replacement lands in A4.
pub(crate) fn launch_nan_check_fused_f32(&mut self) -> Result<(), MLError> {
let nan_flags_ptr = self.nan_flags_buf.raw_ptr();
let buf_ptrs_dev = self.nan_check_buf_ptrs.dev_ptr;
let buf_lens_dev = self.nan_check_buf_lens.dev_ptr;
const BLOCKS: u32 = 12;
const THREADS: u32 = 256;
const BASE_FLAG_IDX: i32 = 24;
let cfg = LaunchConfig {
grid_dim: (BLOCKS, 1, 1),
block_dim: (THREADS, 1, 1),
shared_mem_bytes: 0,
};
unsafe {
self.stream
.launch_builder(&self.nan_check_fused_f32_kernel)
.arg(&buf_ptrs_dev)
.arg(&buf_lens_dev)
.arg(&BASE_FLAG_IDX)
.arg(&nan_flags_ptr)
.launch(cfg)
.map_err(|e| MLError::ModelError(format!("nan_check_fused_f32: {e}")))?;
}
Ok(())
}
/// Zero the NaN flags buffer (call before a batch of check_nan calls).
pub fn reset_nan_flags(&mut self) -> Result<(), MLError> {
self.stream.memset_zeros(&mut self.nan_flags_buf)
@@ -20529,7 +20676,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, 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, CudaFunction), MLError> {
info!(
state_dim = ml_core::state_layout::STATE_DIM,
total_params = compute_total_params(config),
@@ -20662,6 +20809,13 @@ 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}")))?;
// SP2: fused multi-buffer NaN check kernel. Single launch processes 12
// backward-path slots (24-35) in 12 blocks. Replaces 8 individual
// dqn_nan_check_f32 launches in `run_nan_checks_post_backward` (Task A4
// wires the call site). Loaded from the same `module` as the per-buffer
// variant — both belong to the post_aux_child captured replay group.
let nan_check_fused_f32 = module.load_function("dqn_nan_check_fused_f32_kernel")
.map_err(|e| MLError::ModelError(format!("dqn_nan_check_fused_f32_kernel 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
@@ -20676,8 +20830,8 @@ fn compile_training_kernels(
let popart_robust = ungraphed_module.load_function("popart_normalize_robust")
.map_err(|e| MLError::ModelError(format!("popart_normalize_robust load: {e}")))?;
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))
info!("GpuDqnTrainer: 39 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, nan_check_fused_f32, 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

@@ -653,6 +653,26 @@ impl FusedTrainingCtx {
None
};
// SP2: populate fused NaN-check (ptr, len) tables via host-side write
// through the trainer's mapped-pinned `nan_check_buf_ptrs` /
// `nan_check_buf_lens` (no HtoD copy per
// `feedback_no_htod_htoh_only_mapped_pinned`). Slot pointers are
// stable across the trainer's lifetime — populate ONCE here, after
// `gpu_iqn` has been constructed so its slot 27/28 accessors resolve
// correctly. When IQN is inactive (`iqn_lambda == 0.0` →
// `gpu_iqn = None`), slots 27/28 receive null entries which the fused
// kernel skips via its null-pointer guard.
let iqn_q_for_meta = gpu_iqn
.as_ref()
.map(|_| crate::cuda_pipeline::gpu_iqn_head::FIXED_TAUS.len());
trainer.populate_nan_check_meta(
batch_size,
shared_h2,
gpu_iqn.as_ref().map(|h| h.d_h_s2_raw_ptr()),
gpu_iqn.as_ref().map(|h| h.d_branch_logits_buf_ptr()),
iqn_q_for_meta,
).map_err(|e| anyhow::anyhow!("populate_nan_check_meta: {e}"))?;
// GPU attention — always active. 4-head self-attention on h_s2.
let attn_config = GpuAttentionConfig {
state_dim: shared_h2,

View File

@@ -2268,3 +2268,5 @@ SP1 closure (2026-04-29): F1 cold-start clamp pathology fixed at commit `ab21334
SP2 Phase A1 — fused NaN-check kernel (2026-04-29): foundational kernel-only commit appending `dqn_nan_check_fused_f32_kernel` to `crates/ml/src/cuda_pipeline/dqn_utility_kernels.cu`. Single-launch replacement target for the 8 per-step `dqn_nan_check_f32` calls in `run_nan_checks_post_backward` (slots 24-30 + 32). Each block (`blockIdx.x = 0..N_SLOTS`) scans its assigned buffer end-to-end via grid-strided loop, block-local `__syncthreads_or` reduce (no atomicAdd per `feedback_no_atomicadd`), sticky-flag write `nan_flags_buf[base_flag_idx + slot] = 1` only when `has_nan && threadIdx.x == 0`. Nullptr bounds check `if (buf == nullptr || n == 0) return;` makes deferred slots (e.g. slot 31 ensemble) and unused entries no-op cleanly — same call site can pass null for cross-struct slots without spurious flag writes. Invariants preserved from `dqn_nan_check_f32`: sticky-flag (writes 1, never clears), graph-capture safe (pure kernel launch), no DtoD/HtoD/HtoH copies. `extern "C"` linkage matches existing `dqn_*` symbol convention for cudarc `module.load_function` lookup. Unused yet — Rust wrapper, metadata buffer (per-slot ptr/len arrays), and call-site replacement land in subsequent A2/A3/A4 commits. Drives F0 regression remediation: 8 per-step kernel launches collapse to 1 fused launch (~7× fewer graph nodes for backward NaN coverage) while preserving identical diagnostic semantics. Pattern reference for future fused diagnostic launches if SP3 expands the slot range.
SP2 Phase A2 — nan-check (ptr, len) device tables (2026-04-29): added two device buffers on `GpuDqnTrainer` to feed the fused NaN-check kernel from A1: `nan_check_buf_ptrs: CudaSlice<u64>` (12 entries, one per slot 24-35) and `nan_check_buf_lens: CudaSlice<i32>` (12 entries). Both are `stream.alloc_zeros` allocations adjacent to `nan_flags_buf`. Two separate buffers chosen over a packed-stride layout for direct ABI match with the kernel signature `(const float* const* buf_ptrs, const int* buf_lens, ...)` — no struct-stride alignment concerns. Sized at 12 (slots 24-35); slots 36-47 headroom is reserved for SP3 Mech 5 extension which will resize both buffers in lockstep. Slot 31 (deferred ensemble, cross-struct on `FusedDqnTraining` per A1's null-skip pattern) entry will hold 0 — kernel skips that block on `buf == nullptr`. Allocated as zeros; populated once in A3 (slot pointers are stable across the trainer's lifetime — no per-step host→device traffic, satisfying `feedback_no_htod_htoh_only_mapped_pinned` once A3 selects the population path: mapped-pinned helper from `mapped_pinned.rs` for the one-shot construction-time write). Unused yet — A3 wires the population + Rust launch wrapper, A4 replaces the 8 individual `check_nan_f32` calls in `run_nan_checks_post_backward` with a single fused launch. Field-level only — no behavioral change.
SP2 Phase A3 — populate + launch wrapper (2026-04-29): refactored A2's `nan_check_buf_ptrs: CudaSlice<u64>` / `nan_check_buf_lens: CudaSlice<i32>` to mapped-pinned (`MappedU64Buffer` / `MappedI32Buffer` from `mapped_pinned.rs`) per `feedback_no_htod_htoh_only_mapped_pinned` — host-side write through the mapped pages is visible to the kernel via the `dev_ptr` returned by `cuMemHostGetDevicePointer_v2`, eliminating the HtoD copy entirely. Added `populate_nan_check_meta(b, sh2, iqn_d_h_s2_ptr, iqn_d_branch_logits_buf_ptr, iqn_q)` on `GpuDqnTrainer` — one-shot construction-time writer of 12 `(ptr, len)` tuples covering slots 24-35 (mirrors the per-slot accessor table at the end of `docs/dqn-backward-nan-audit.md` and the per-slot launches in `run_nan_checks_post_backward`). Slot 31 deferred ensemble entry: `(0, 0)`. Slots 27/28 IQN entries: `Option<u64>` ptrs gated on `gpu_iqn.is_some()` — when IQN is inactive the entries stay zero and the fused kernel skips them via its null-pointer guard. Slots 33-35 (`bw_d_h_s2` inline checks) hold null entries — fused kernel skips; the inline `check_nan_f32` calls at the three backward orchestration phases (`launch_cublas_backward_to` post-main / post-aux / `apply_iqn_trunk_gradient` post-iqn) continue to fire individually for entry-point localization. Added `launch_nan_check_fused_f32` Rust wrapper — single launch with `grid_dim=12, block_dim=256, BASE_FLAG_IDX=24`, mirrors the kernel signature `(buf_ptrs_dev, buf_lens_dev, base_flag_idx, nan_flags_ptr)`. Kernel registered in the precompiled-cubin loader (`compile_training_kernels` tuple 43→44, 38→39 utility kernels logged) and stored on `GpuDqnTrainer` as `nan_check_fused_f32_kernel: CudaFunction` — same `module` (forward_child / aux_child / post_aux_child captured replay group) as the per-buffer `dqn_nan_check_f32` to preserve graph-capture compatibility for A4's call-site replacement. Constructor-time wire-up lives in `FusedTrainingCtx::new` (after `gpu_iqn` is constructed) — chosen over the GpuDqnTrainer constructor because `gpu_iqn` is owned by `FusedTrainingCtx`, mirroring the same `Option<u64>` argument pattern used by `apply_iqn_trunk_gradient(iqn_d_h_s2_ptr, ...)` and `run_nan_checks_post_backward`. Wrapper unused at A3 commit time — A4 replaces the 8-launch sequence in `run_nan_checks_post_backward` with the single fused launch. Zero new HtoD copies; pre-commit Invariant 7 satisfied; no ISV slots added; sticky-flag semantics preserved at the kernel.