Skip to main content

entrenar/transformer/
cuda_block.rs

1//! CUDA-accelerated Transformer Block (ENT-147 through ENT-152)
2//!
3//! This module provides a fully GPU-accelerated transformer block using trueno-gpu kernels.
4//! All operations run on CUDA to achieve >70% GPU utilization.
5//!
6//! # Phase 22 Implementation Status
7//!
8//! - ENT-147: CUDA RMSNorm integration ✅
9//! - ENT-148: CUDA Softmax integration ✅
10//! - ENT-149: CUDA SiLU activation ✅
11//! - ENT-150: Fused SwiGLU kernel ✅
12//! - ENT-151: CUDA backward pass ✅
13//! - ENT-152: CudaTransformer wrapper ✅
14
15#![allow(dead_code)]
16// SAFETY: This module performs GPU memory transfers via CUDA driver FFI.
17// The unsafe blocks are limited to copy_from_host_async / copy_to_host_async
18// where we guarantee the host buffer outlives the async operation by syncing
19// the stream before the buffer goes out of scope.
20#![allow(unsafe_code)]
21
22// PMAT-483/entrenar#328: Per-operation profiling indices for forward pass.
23// Must match StepProfiler OP_* constants in step_profiler.rs.
24#[cfg(feature = "cuda")]
25const OP_RMSNORM_ATTN: usize = 0;
26#[cfg(feature = "cuda")]
27const OP_QKV_GEMM: usize = 1;
28#[cfg(feature = "cuda")]
29const OP_ATTENTION: usize = 2;
30#[cfg(feature = "cuda")]
31const OP_O_PROJ: usize = 3;
32#[cfg(feature = "cuda")]
33const OP_RMSNORM_FFN: usize = 4;
34#[cfg(feature = "cuda")]
35const OP_GATE_UP_GEMM: usize = 5;
36#[cfg(feature = "cuda")]
37const OP_SILU: usize = 6;
38#[cfg(feature = "cuda")]
39const OP_DOWN_GEMM: usize = 7;
40
41// PMAT-483: Per-operation profiling indices for backward pass.
42#[cfg(feature = "cuda")]
43const OP_LORA_FWD: usize = 8;
44#[cfg(feature = "cuda")]
45const OP_DOWN_BWD: usize = 9;
46#[cfg(feature = "cuda")]
47const OP_SWIGLU_BWD: usize = 10;
48#[cfg(feature = "cuda")]
49const OP_GATE_UP_BWD: usize = 11;
50#[cfg(feature = "cuda")]
51const OP_ATTN_BWD: usize = 12;
52#[cfg(feature = "cuda")]
53const OP_QKV_BWD: usize = 13;
54#[cfg(feature = "cuda")]
55const OP_NORM_BWD: usize = 14;
56#[cfg(feature = "cuda")]
57const OP_LORA_BWD: usize = 15;
58
59#[cfg(feature = "cuda")]
60use std::sync::Arc;
61
62#[cfg(feature = "cuda")]
63#[inline]
64fn saturating_u32(v: usize) -> u32 {
65    v.min(u32::MAX as usize) as u32
66}
67
68/// Consume a value without running its destructor (prevents GPU double-free).
69#[cfg(feature = "cuda")]
70#[inline]
71fn leak<T>(val: T) {
72    let _ = std::mem::ManuallyDrop::new(val);
73}
74
75/// APR_NAN_SCAN=1: env-gated per-op NaN/Inf bisection for the CUDA forward.
76///
77/// Diagnostic only — synchronizes the stream and downloads buffers, so it is
78/// slow. Do NOT combine with CUDA_GRAPH=1 (sync inside capture is invalid).
79#[cfg(feature = "cuda")]
80#[inline]
81pub(crate) fn nan_scan_enabled() -> bool {
82    static ENABLED: std::sync::OnceLock<bool> = std::sync::OnceLock::new();
83    *ENABLED.get_or_init(|| std::env::var("APR_NAN_SCAN").as_deref() == Ok("1"))
84}
85
86/// Download the first `n` elements of `buf` and report non-finite values.
87/// Returns the non-finite count (0 when APR_NAN_SCAN is unset).
88#[cfg(feature = "cuda")]
89pub(crate) fn nan_scan_f32(
90    tag: &str,
91    buf: &GpuBuffer<f32>,
92    n: usize,
93    stream: &CudaStream,
94) -> usize {
95    if !nan_scan_enabled() {
96        return 0;
97    }
98    if let Err(e) = stream.synchronize() {
99        eprintln!("[NAN-SCAN] {tag}: sync failed: {e:?}");
100        return 0;
101    }
102    let n = n.min(buf.len());
103    let mut host = vec![0.0f32; n];
104    if let Err(e) = buf.copy_to_host(&mut host) {
105        eprintln!("[NAN-SCAN] {tag}: download failed: {e:?}");
106        return 0;
107    }
108    let bad = host.iter().filter(|v| !v.is_finite()).count();
109    if bad > 0 {
110        let first_idx = host.iter().position(|v| !v.is_finite()).unwrap_or(0);
111        eprintln!(
112            "[NAN-SCAN] {tag}: {bad}/{n} non-finite (first at [{first_idx}]={})",
113            host[first_idx]
114        );
115    }
116    bad
117}
118
119#[cfg(feature = "cuda")]
120use trueno_gpu::driver::{CudaContext, CudaStream, GpuBuffer};
121
122#[cfg(feature = "cuda")]
123use crate::autograd::cuda_backward::{
124    batched_softmax_backward, gemm_backward_a, gemm_backward_a_fp16_dispatch,
125    gemm_backward_a_fp16_dispatch_accumulate, gemm_backward_b, rms_norm_backward, silu_backward,
126};
127#[cfg(feature = "cuda")]
128use crate::autograd::cuda_forward::{
129    batched_4d_gemm_forward, batched_rope_neox_backward, batched_rope_neox_forward,
130    batched_softmax_forward, batched_to_interleaved_forward, batched_transpose_forward,
131    cast_f32_to_f16_gpu, elementwise_mul_forward, expand_kv_heads, fused_residual_rmsnorm_forward,
132    fused_swiglu_forward, gemm_f16_to_f32_forward, gemm_forward, interleaved_to_batched_forward,
133    per_head_rmsnorm_forward, residual_add_forward, rms_norm_forward, rms_norm_forward_with_eps,
134    scale_forward, silu_forward,
135};
136#[cfg(feature = "cuda")]
137use crate::autograd::cuda_optim::{adamw_step_cuda, gradient_clip_cuda, squared_sum_cuda};
138#[cfg(feature = "cuda")]
139use crate::autograd::cuda_tensor::Result;
140
141#[cfg(feature = "cuda")]
142use super::config::TransformerConfig;
143
144/// CUDA-accelerated transformer block
145///
146/// All operations run on GPU with minimal CPU<->GPU transfers.
147#[cfg(feature = "cuda")]
148pub struct CudaTransformerBlock {
149    /// Configuration
150    config: TransformerConfig,
151    /// Layer index
152    layer_idx: usize,
153    /// Input RMSNorm weight (gamma)
154    input_norm_weight: GpuBuffer<f32>,
155    /// Post-attention RMSNorm weight (gamma)
156    post_attn_norm_weight: GpuBuffer<f32>,
157    /// Query projection weight (hidden_size x hidden_size)
158    w_q: GpuBuffer<f32>,
159    /// Key projection weight (hidden_size x kv_hidden_size)
160    w_k: GpuBuffer<f32>,
161    /// Value projection weight (hidden_size x kv_hidden_size)
162    w_v: GpuBuffer<f32>,
163    /// Output projection weight (hidden_size x hidden_size)
164    w_o: GpuBuffer<f32>,
165    /// FFN gate projection (hidden_size x intermediate_size)
166    w_gate: GpuBuffer<f32>,
167    /// FFN up projection (hidden_size x intermediate_size)
168    w_up: GpuBuffer<f32>,
169    /// FFN down projection (intermediate_size x hidden_size)
170    w_down: GpuBuffer<f32>,
171    /// CUDA context
172    ctx: Arc<CudaContext>,
173    /// Scratch buffers for intermediate results
174    scratch: CudaBlockScratch,
175    /// Pre-allocated host zero buffer for zeroing norm grad buffers [hidden_size]
176    norm_zero_buf: Vec<f32>,
177    /// ENT-270: QK-norm weights (per-head RMSNorm, shape=[head_dim])
178    q_norm_weight: Option<GpuBuffer<f32>>,
179    k_norm_weight: Option<GpuBuffer<f32>>,
180    /// FALSIFY-CUDA-FORWARD-PARITY-002: Q projection bias replicated
181    /// across max_seq_len rows. Allocated when `config.use_bias == true`
182    /// (Qwen2 family). Pre-replicated so `cuda_add_inplace` can apply
183    /// the bias broadcast in a single kernel call. Memory: max_seq_len
184    /// × q_dim × 4 bytes per layer (e.g., 512 × 896 × 4 = 1.75 MB on
185    /// Qwen 0.5B). Pre-fix this field did not exist; Qwen Q/K/V biases
186    /// silently dropped → val_loss > ln(vocab) per
187    /// `apr-pretrain-cuda-forward-parity-v1.yaml`.
188    b_q_replicated: Option<GpuBuffer<f32>>,
189    b_k_replicated: Option<GpuBuffer<f32>>,
190    b_v_replicated: Option<GpuBuffer<f32>>,
191}
192
193/// Preallocated scratch buffers for transformer forward/backward pass.
194///
195/// For fp32 blocks: per-layer (backward reads forward activations).
196/// For NF4 blocks: shared across all layers (forward-only, no backward).
197///
198/// # Contract (C-SCRATCH-001)
199///
200/// - **Precondition**: Allocated with matching `config` and `max_seq_len`
201/// - **Postcondition**: All buffers sized for worst-case `max_seq_len`
202/// - **Invariant**: NF4 layers run sequentially — one shared scratch is safe
203#[cfg(feature = "cuda")]
204pub(crate) struct CudaBlockScratch {
205    /// After input RMSNorm (seq_len * hidden_size)
206    norm1_out: GpuBuffer<f32>,
207    /// Q projection output (seq_len * hidden_size)
208    q: GpuBuffer<f32>,
209    /// K projection output (seq_len * kv_hidden_size)
210    k: GpuBuffer<f32>,
211    /// V projection output (seq_len * kv_hidden_size)
212    v: GpuBuffer<f32>,
213    /// Attention scores (num_heads * seq_len * seq_len)
214    attn_scores: GpuBuffer<f32>,
215    /// Attention output (seq_len * q_dim)
216    attn_out: GpuBuffer<f32>,
217    /// Output projection result
218    o_proj_out: GpuBuffer<f32>,
219    /// Residual after attention
220    residual1: GpuBuffer<f32>,
221    /// After post-attention RMSNorm
222    norm2_out: GpuBuffer<f32>,
223    /// FFN gate output (seq_len * intermediate_size)
224    gate_out: GpuBuffer<f32>,
225    /// FFN up output (seq_len * intermediate_size)
226    up_out: GpuBuffer<f32>,
227    /// FFN fused SwiGLU output: SiLU(gate) * up (seq_len * intermediate_size)
228    swiglu_out: GpuBuffer<f32>,
229    /// FFN down projection output
230    ffn_out: GpuBuffer<f32>,
231    /// FP16 activation cast buffers for FP16 GEMM dispatch (PMAT-470)
232    /// Allocated lazily on first use when FP16_GEMM=1.
233    norm1_out_f16: Option<GpuBuffer<u16>>,
234    attn_out_f16: Option<GpuBuffer<u16>>,
235    norm2_out_f16: Option<GpuBuffer<u16>>,
236    swiglu_out_f16: Option<GpuBuffer<u16>>,
237    // === Seq-dependent backward scratch (per-layer for activation reuse) ===
238    /// Gradient accumulator for hidden states
239    grad_hidden: GpuBuffer<f32>,
240    /// Gradient for SwiGLU intermediate
241    grad_swiglu: GpuBuffer<f32>,
242    // === Attention layout scratch buffers (GPU-only attention pipeline) ===
243    /// Q in batched layout [num_heads, seq_len, head_dim]
244    attn_q_batched: GpuBuffer<f32>,
245    /// K/V layout temp buffer [num_heads, seq_len, head_dim]
246    attn_kv_temp: GpuBuffer<f32>,
247    /// K transposed / second temp [num_heads, head_dim, seq_len]
248    attn_kv_temp2: GpuBuffer<f32>,
249    // === Attention backward scratch (seq-dependent) ===
250    /// Gradient for attention scores [num_heads * seq_len * seq_len]
251    /// Kept separate from attn_scores because softmax backward reads y while writing grad_x
252    grad_attn_scores: GpuBuffer<f32>,
253    // === LoRA scratch buffers (ENT-153: QLoRA) ===
254    /// LoRA intermediate: x @ A, sized [max_seq_len * max_lora_rank]
255    lora_inter: GpuBuffer<f32>,
256    /// LoRA temp for scaled addition, sized [max_seq_len * max_proj_dim]
257    /// (reuses largest projection dimension for Q/V LoRA output)
258    lora_temp: GpuBuffer<f32>,
259    /// Sequential position indices [0, 1, ..., max_seq_len-1] for batched RoPE
260    rope_positions: GpuBuffer<u32>,
261    /// PMAT-420: Contiguous causal mask for current seq_len [seq_len * seq_len]
262    causal_mask_contiguous: GpuBuffer<f32>,
263    /// PMAT-420: seq_len that causal_mask_contiguous was generated for (cache key)
264    pub(crate) causal_mask_cached_seq_len: usize,
265    /// PMAT-483/entrenar#328: Per-operation timing accumulator (microseconds).
266    /// Index matches StepProfiler OP_* constants. Accumulated across layers per step.
267    /// Zero-overhead when profiling disabled (check op_profiling_enabled first).
268    pub(crate) op_us: [u64; 16],
269    /// Whether per-op profiling is active this step
270    pub(crate) op_profiling_enabled: bool,
271}
272
273#[cfg(feature = "cuda")]
274impl CudaBlockScratch {
275    /// PMAT-483: Start timing an operation (no-op if profiling disabled).
276    #[inline]
277    pub(crate) fn op_begin(&self) -> Option<std::time::Instant> {
278        if self.op_profiling_enabled {
279            Some(std::time::Instant::now())
280        } else {
281            None
282        }
283    }
284
285    /// PMAT-483: Record elapsed time for an operation.
286    #[inline]
287    pub(crate) fn op_end(&mut self, start: Option<std::time::Instant>, op: usize) {
288        if let Some(t) = start {
289            if op < 16 {
290                self.op_us[op] += t.elapsed().as_micros() as u64;
291            }
292        }
293    }
294
295    /// Zero all forward scratch buffers to prevent backward gradient contamination.
296    /// entrenar#318 Tier 1: GPU-side memset via cuMemsetD32Async (no PCIe transfer).
297    /// Max sequence length this scratch was allocated for.
298    pub(crate) fn max_seq_len(&self, hidden_size: usize) -> usize {
299        self.norm1_out.len() / hidden_size.max(1)
300    }
301
302    #[rustfmt::skip]
303    pub(crate) fn zero_forward_buffers(&mut self, stream: &CudaStream) {
304        let z = |b: &mut GpuBuffer<f32>| { b.zero_async(stream).ok(); };
305        z(&mut self.norm1_out); z(&mut self.q); z(&mut self.k); z(&mut self.v); z(&mut self.attn_scores); z(&mut self.attn_out);
306        z(&mut self.o_proj_out); z(&mut self.residual1); z(&mut self.norm2_out); z(&mut self.gate_out); z(&mut self.up_out);
307        z(&mut self.swiglu_out); z(&mut self.ffn_out); z(&mut self.attn_q_batched); z(&mut self.attn_kv_temp); z(&mut self.attn_kv_temp2);
308        z(&mut self.grad_hidden); z(&mut self.grad_swiglu); z(&mut self.grad_attn_scores); z(&mut self.lora_inter); z(&mut self.lora_temp);
309        self.causal_mask_cached_seq_len = 0;
310    }
311
312    /// Allocate scratch buffers for a given model config and max sequence length.
313    ///
314    /// # Contract (C-SCRATCH-001)
315    ///
316    /// All buffer sizes are deterministic from (config, max_seq_len).
317    pub(crate) fn new(
318        config: &TransformerConfig,
319        max_seq_len: usize,
320        ctx: &Arc<CudaContext>,
321        lora_rank: usize,
322    ) -> Result<Self> {
323        let hidden_size = config.hidden_size;
324        let q_dim = config.q_dim();
325        let kv_hidden_size = config.num_kv_heads * config.head_dim();
326        let intermediate_size = config.intermediate_size;
327        let num_heads = config.num_attention_heads;
328        let head_dim = config.head_dim();
329
330        // LoRA scratch: max(q_dim, kv_hidden) for the largest projection output
331        let max_proj_dim = q_dim.max(kv_hidden_size);
332        // Minimum 1 element to avoid zero-size GPU allocation
333        let lora_inter_size = (max_seq_len * lora_rank).max(1);
334        let lora_temp_size = (max_seq_len * max_proj_dim).max(1);
335
336        // C-CAUSAL-001: Precompute causal mask [seq × seq] — shared across all heads
337        // 4 MB for seq=1024. Applied per-head in compute_attention_cuda.
338        let causal_mask_data: Vec<f32> = (0..max_seq_len * max_seq_len)
339            .map(|idx| {
340                let row = idx / max_seq_len;
341                let col = idx % max_seq_len;
342                if col <= row {
343                    0.0f32
344                } else {
345                    f32::NEG_INFINITY
346                }
347            })
348            .collect();
349        Ok(Self {
350            norm1_out: GpuBuffer::new(ctx, max_seq_len * hidden_size)?,
351            q: GpuBuffer::new(ctx, max_seq_len * q_dim)?,
352            k: GpuBuffer::new(ctx, max_seq_len * kv_hidden_size)?,
353            v: GpuBuffer::new(ctx, max_seq_len * kv_hidden_size)?,
354            attn_scores: GpuBuffer::new(ctx, num_heads * max_seq_len * max_seq_len)?,
355            attn_out: GpuBuffer::new(ctx, max_seq_len * q_dim)?,
356            o_proj_out: GpuBuffer::new(ctx, max_seq_len * hidden_size)?,
357            residual1: GpuBuffer::new(ctx, max_seq_len * hidden_size)?,
358            norm2_out: GpuBuffer::new(ctx, max_seq_len * hidden_size)?,
359            gate_out: GpuBuffer::new(ctx, max_seq_len * intermediate_size)?,
360            up_out: GpuBuffer::new(ctx, max_seq_len * intermediate_size)?,
361            swiglu_out: GpuBuffer::new(ctx, max_seq_len * intermediate_size)?,
362            ffn_out: GpuBuffer::new(ctx, max_seq_len * hidden_size)?,
363            norm1_out_f16: None,
364            attn_out_f16: None,
365            norm2_out_f16: None,
366            swiglu_out_f16: None,
367            grad_hidden: GpuBuffer::new(ctx, max_seq_len * hidden_size)?,
368            grad_swiglu: GpuBuffer::new(ctx, max_seq_len * intermediate_size)?,
369            attn_q_batched: GpuBuffer::new(ctx, num_heads * max_seq_len * head_dim)?,
370            attn_kv_temp: GpuBuffer::new(ctx, num_heads * max_seq_len * head_dim)?,
371            attn_kv_temp2: GpuBuffer::new(ctx, num_heads * max_seq_len * head_dim)?,
372            grad_attn_scores: GpuBuffer::new(
373                ctx,
374                num_heads * max_seq_len * max_seq_len.max(head_dim),
375            )?,
376            lora_inter: GpuBuffer::new(ctx, lora_inter_size)?,
377            lora_temp: GpuBuffer::new(ctx, lora_temp_size)?,
378            rope_positions: {
379                let positions: Vec<u32> = (0..max_seq_len as u32).collect();
380                let mut buf = GpuBuffer::new(ctx, max_seq_len)?;
381                buf.copy_from_host(&positions)?;
382                buf
383            },
384            causal_mask_contiguous: GpuBuffer::from_host(ctx, &causal_mask_data)?,
385            causal_mask_cached_seq_len: max_seq_len,
386            op_us: [0u64; 16],
387            op_profiling_enabled: false,
388        })
389    }
390
391    /// PMAT-420: Prepare a contiguous [seq_len * seq_len] causal mask. Cached: only regenerates
392    /// when seq_len changes. Cost: O(seq_len^2) CPU + one H2D upload (~0.01ms for seq=256).
393    pub(crate) fn prepare_causal_mask(
394        &mut self,
395        seq_len: usize,
396        ctx: &Arc<CudaContext>,
397    ) -> crate::autograd::cuda_tensor::Result<()> {
398        if seq_len == self.causal_mask_cached_seq_len {
399            return Ok(());
400        }
401        let mask_data: Vec<f32> = (0..seq_len * seq_len)
402            .map(|idx| {
403                let row = idx / seq_len;
404                let col = idx % seq_len;
405                if col <= row {
406                    0.0f32
407                } else {
408                    f32::NEG_INFINITY
409                }
410            })
411            .collect();
412        self.causal_mask_contiguous = GpuBuffer::from_host(ctx, &mask_data)?;
413        self.causal_mask_cached_seq_len = seq_len;
414        Ok(())
415    }
416}
417
418/// Shared gradient workspace for weight gradients (one per model, NOT per layer).
419///
420/// # Contract (C-GRADWS-001)
421///
422/// Backward processes layers sequentially — only one layer's weight gradients
423/// are computed at a time. Sharing this workspace across layers saves
424/// `(L-1) * per_layer_grad_weight_elements * 4` bytes of VRAM.
425///
426/// For Qwen3-4B: saves 35 * 372 MB = 13.0 GB.
427///
428/// - **Precondition**: Allocated once before training loop starts
429/// - **Postcondition**: After backward() for layer i, contains layer i's weight gradients
430/// - **Invariant**: Buffer sizes match model config; never reallocated during training
431#[cfg(feature = "cuda")]
432pub struct CudaGradWorkspace {
433    /// Gradient for input norm weight [hidden_size]
434    pub(crate) grad_input_norm: GpuBuffer<f32>,
435    /// Gradient for post-attention norm weight [hidden_size]
436    pub(crate) grad_post_attn_norm: GpuBuffer<f32>,
437    /// Gradient for FFN gate projection [hidden_size * intermediate_size]
438    pub(crate) grad_gate: GpuBuffer<f32>,
439    /// Gradient for FFN up projection [hidden_size * intermediate_size]
440    pub(crate) grad_up: GpuBuffer<f32>,
441    /// Gradient for FFN down projection [intermediate_size * hidden_size]
442    pub(crate) grad_down: GpuBuffer<f32>,
443    /// Gradient for Q projection weight [q_dim * hidden_size]
444    pub(crate) grad_w_q: GpuBuffer<f32>,
445    /// Gradient for K projection weight [hidden_size * kv_hidden_size]
446    pub(crate) grad_w_k: GpuBuffer<f32>,
447    /// Gradient for V projection weight [hidden_size * kv_hidden_size]
448    pub(crate) grad_w_v: GpuBuffer<f32>,
449    /// Gradient for output projection weight [hidden_size * q_dim]
450    pub(crate) grad_w_o: GpuBuffer<f32>,
451}
452
453#[cfg(feature = "cuda")]
454impl CudaGradWorkspace {
455    /// Allocate shared gradient workspace for the given model config.
456    ///
457    /// Called once per training run. GEMM weight gradients are fully overwritten
458    /// by each backward pass. Norm gradients use atomicAdd accumulation and MUST
459    /// be zeroed before each rms_norm_backward call (see `zero_norm_grads`).
460    pub fn new(ctx: &Arc<CudaContext>, config: &TransformerConfig) -> Result<Self> {
461        let h = config.hidden_size;
462        let q = config.q_dim();
463        let kv = config.num_kv_heads * config.head_dim();
464        let i = config.intermediate_size;
465
466        Ok(Self {
467            grad_input_norm: GpuBuffer::new(ctx, h)?,
468            grad_post_attn_norm: GpuBuffer::new(ctx, h)?,
469            grad_gate: GpuBuffer::new(ctx, h * i)?,
470            grad_up: GpuBuffer::new(ctx, h * i)?,
471            grad_down: GpuBuffer::new(ctx, i * h)?,
472            grad_w_q: GpuBuffer::new(ctx, q * h)?,
473            grad_w_k: GpuBuffer::new(ctx, h * kv)?,
474            grad_w_v: GpuBuffer::new(ctx, h * kv)?,
475            grad_w_o: GpuBuffer::new(ctx, h * q)?,
476        })
477    }
478
479    /// Zero norm gradient buffers before rms_norm_backward calls.
480    ///
481    /// The BatchedRmsNormBackwardKernel accumulates grad_gamma via atomicAdd,
482    /// so these buffers MUST be zeroed before each backward pass. Without this,
483    /// grad_gamma accumulates across steps → exploding norm gradients.
484    pub fn zero_norm_grads(&mut self, zero_buf: &[f32]) -> Result<()> {
485        let n = self.grad_input_norm.len();
486        self.grad_input_norm.copy_from_host(&zero_buf[..n]).map_err(|e| {
487            crate::autograd::cuda_tensor::CudaTensorError::TransferFailed(format!(
488                "Failed to zero grad_input_norm: {e:?}"
489            ))
490        })?;
491        self.grad_post_attn_norm.copy_from_host(&zero_buf[..n]).map_err(|e| {
492            crate::autograd::cuda_tensor::CudaTensorError::TransferFailed(format!(
493                "Failed to zero grad_post_attn_norm: {e:?}"
494            ))
495        })?;
496        Ok(())
497    }
498}
499
500/// GPU-resident AdamW optimizer state for one transformer block.
501///
502/// Stores first (m) and second (v) moment estimates for all 9 weight tensors:
503/// 7 matmul weights + 2 RMSNorm weights. All buffers live on GPU to avoid
504/// CPU↔GPU transfers during training.
505///
506/// # Contract (C-OPTSTATE-001)
507///
508/// - **Precondition**: CUDA context valid, all buffers allocated to match weight dimensions
509/// - **Postcondition**: m and v buffers initialized to zero (unbiased start)
510/// - **Invariant**: Buffer sizes immutable after creation; m/v never reallocated
511#[cfg(feature = "cuda")]
512pub struct GpuBlockOptimizerState {
513    // Attention projection optimizer states
514    m_w_q: GpuBuffer<f32>,
515    v_w_q: GpuBuffer<f32>,
516    m_w_k: GpuBuffer<f32>,
517    v_w_k: GpuBuffer<f32>,
518    m_w_v: GpuBuffer<f32>,
519    v_w_v: GpuBuffer<f32>,
520    m_w_o: GpuBuffer<f32>,
521    v_w_o: GpuBuffer<f32>,
522    // FFN projection optimizer states
523    m_w_gate: GpuBuffer<f32>,
524    v_w_gate: GpuBuffer<f32>,
525    m_w_up: GpuBuffer<f32>,
526    v_w_up: GpuBuffer<f32>,
527    m_w_down: GpuBuffer<f32>,
528    v_w_down: GpuBuffer<f32>,
529    // RMSNorm weight optimizer states
530    m_input_norm: GpuBuffer<f32>,
531    v_input_norm: GpuBuffer<f32>,
532    m_post_attn_norm: GpuBuffer<f32>,
533    v_post_attn_norm: GpuBuffer<f32>,
534}
535
536/// ALB-118: Download GPU optimizer state to host for checkpointing.
537#[cfg(feature = "cuda")]
538impl GpuBlockOptimizerState {
539    /// Returns (suffix, data) pairs for all 18 m/v buffers.
540    /// Suffix is e.g. "m.w_q", "v.w_gate" — caller prefixes with layer index.
541    pub fn download_to_host(
542        &self,
543    ) -> crate::autograd::cuda_tensor::Result<Vec<(String, Vec<f32>)>> {
544        let dl = |name: &str,
545                  buf: &GpuBuffer<f32>|
546         -> crate::autograd::cuda_tensor::Result<(String, Vec<f32>)> {
547            let mut host = vec![0.0f32; buf.len()];
548            buf.copy_to_host(&mut host).map_err(|e| {
549                crate::autograd::cuda_tensor::CudaTensorError::TransferFailed(format!(
550                    "optimizer D2H {name}: {e}"
551                ))
552            })?;
553            Ok((name.to_string(), host))
554        };
555        Ok(vec![
556            dl("m.w_q", &self.m_w_q)?,
557            dl("v.w_q", &self.v_w_q)?,
558            dl("m.w_k", &self.m_w_k)?,
559            dl("v.w_k", &self.v_w_k)?,
560            dl("m.w_v", &self.m_w_v)?,
561            dl("v.w_v", &self.v_w_v)?,
562            dl("m.w_o", &self.m_w_o)?,
563            dl("v.w_o", &self.v_w_o)?,
564            dl("m.w_gate", &self.m_w_gate)?,
565            dl("v.w_gate", &self.v_w_gate)?,
566            dl("m.w_up", &self.m_w_up)?,
567            dl("v.w_up", &self.v_w_up)?,
568            dl("m.w_down", &self.m_w_down)?,
569            dl("v.w_down", &self.v_w_down)?,
570            dl("m.input_norm", &self.m_input_norm)?,
571            dl("v.input_norm", &self.v_input_norm)?,
572            dl("m.post_attn_norm", &self.m_post_attn_norm)?,
573            dl("v.post_attn_norm", &self.v_post_attn_norm)?,
574        ])
575    }
576
577    /// ALB-118: Upload host optimizer state to GPU (checkpoint resume).
578    /// Missing keys are silently skipped (buffer stays zero-initialized).
579    pub fn restore_from_host(
580        &mut self,
581        data: &std::collections::HashMap<String, Vec<f32>>,
582    ) -> crate::autograd::cuda_tensor::Result<()> {
583        let ul = |name: &str,
584                  buf: &mut GpuBuffer<f32>,
585                  data: &std::collections::HashMap<String, Vec<f32>>|
586         -> crate::autograd::cuda_tensor::Result<()> {
587            if let Some(host_data) = data.get(name) {
588                if host_data.len() == buf.len() {
589                    buf.copy_from_host(host_data).map_err(|e| {
590                        crate::autograd::cuda_tensor::CudaTensorError::TransferFailed(format!(
591                            "optimizer H2D {name}: {e}"
592                        ))
593                    })?;
594                }
595            }
596            Ok(())
597        };
598        ul("m.w_q", &mut self.m_w_q, data)?;
599        ul("v.w_q", &mut self.v_w_q, data)?;
600        ul("m.w_k", &mut self.m_w_k, data)?;
601        ul("v.w_k", &mut self.v_w_k, data)?;
602        ul("m.w_v", &mut self.m_w_v, data)?;
603        ul("v.w_v", &mut self.v_w_v, data)?;
604        ul("m.w_o", &mut self.m_w_o, data)?;
605        ul("v.w_o", &mut self.v_w_o, data)?;
606        ul("m.w_gate", &mut self.m_w_gate, data)?;
607        ul("v.w_gate", &mut self.v_w_gate, data)?;
608        ul("m.w_up", &mut self.m_w_up, data)?;
609        ul("v.w_up", &mut self.v_w_up, data)?;
610        ul("m.w_down", &mut self.m_w_down, data)?;
611        ul("v.w_down", &mut self.v_w_down, data)?;
612        ul("m.input_norm", &mut self.m_input_norm, data)?;
613        ul("v.input_norm", &mut self.v_input_norm, data)?;
614        ul("m.post_attn_norm", &mut self.m_post_attn_norm, data)?;
615        ul("v.post_attn_norm", &mut self.v_post_attn_norm, data)?;
616        Ok(())
617    }
618}
619
620#[cfg(feature = "cuda")]
621impl CudaTransformerBlock {
622    /// Create a new CUDA transformer block from CPU tensors
623    ///
624    /// Uploads all weights to GPU memory.
625    #[allow(clippy::too_many_arguments)]
626    pub fn new(
627        config: &TransformerConfig,
628        layer_idx: usize,
629        ctx: Arc<CudaContext>,
630        input_norm_weight: &[f32],
631        post_attn_norm_weight: &[f32],
632        w_q: &[f32],
633        w_k: &[f32],
634        w_v: &[f32],
635        w_o: &[f32],
636        w_gate: &[f32],
637        w_up: &[f32],
638        w_down: &[f32],
639        max_seq_len: usize,
640        // FALSIFY-CUDA-FORWARD-PARITY-002: Optional Q/K/V projection
641        // biases (Qwen2 family has them; Llama doesn't). When present,
642        // each is replicated across max_seq_len rows so the existing
643        // `cuda_add_inplace` (residual_add) can apply the broadcast
644        // in one kernel call.
645        b_q: Option<&[f32]>,
646        b_k: Option<&[f32]>,
647        b_v: Option<&[f32]>,
648    ) -> Result<Self> {
649        let hidden_size = config.hidden_size;
650        let q_dim = config.q_dim(); // num_heads * head_dim (may differ from hidden_size)
651        let kv_hidden_size = config.num_kv_heads * config.head_dim();
652        let intermediate_size = config.intermediate_size;
653        let num_heads = config.num_attention_heads;
654
655        // Upload weights to GPU
656        let input_norm_weight = GpuBuffer::from_host(&ctx, input_norm_weight)?;
657        let post_attn_norm_weight = GpuBuffer::from_host(&ctx, post_attn_norm_weight)?;
658        let w_q = GpuBuffer::from_host(&ctx, w_q)?;
659        let w_k = GpuBuffer::from_host(&ctx, w_k)?;
660        let w_v = GpuBuffer::from_host(&ctx, w_v)?;
661        let w_o = GpuBuffer::from_host(&ctx, w_o)?;
662        let w_gate = GpuBuffer::from_host(&ctx, w_gate)?;
663        let w_up = GpuBuffer::from_host(&ctx, w_up)?;
664        let w_down = GpuBuffer::from_host(&ctx, w_down)?;
665
666        // C-CAUSAL-001: Precompute causal mask for NF4 path
667        let single_mask: Vec<f32> = (0..max_seq_len * max_seq_len)
668            .map(|idx| {
669                let row = idx / max_seq_len;
670                let col = idx % max_seq_len;
671                if col <= row {
672                    0.0f32
673                } else {
674                    f32::NEG_INFINITY
675                }
676            })
677            .collect();
678        // Allocate scratch buffers — Q and attn_out need q_dim, not hidden_size
679        let scratch = CudaBlockScratch {
680            norm1_out: GpuBuffer::new(&ctx, max_seq_len * hidden_size)?,
681            q: GpuBuffer::new(&ctx, max_seq_len * q_dim)?,
682            k: GpuBuffer::new(&ctx, max_seq_len * kv_hidden_size)?,
683            v: GpuBuffer::new(&ctx, max_seq_len * kv_hidden_size)?,
684            attn_scores: GpuBuffer::new(&ctx, num_heads * max_seq_len * max_seq_len)?,
685            attn_out: GpuBuffer::new(&ctx, max_seq_len * q_dim)?,
686            o_proj_out: GpuBuffer::new(&ctx, max_seq_len * hidden_size)?,
687            residual1: GpuBuffer::new(&ctx, max_seq_len * hidden_size)?,
688            norm2_out: GpuBuffer::new(&ctx, max_seq_len * hidden_size)?,
689            gate_out: GpuBuffer::new(&ctx, max_seq_len * intermediate_size)?,
690            up_out: GpuBuffer::new(&ctx, max_seq_len * intermediate_size)?,
691            swiglu_out: GpuBuffer::new(&ctx, max_seq_len * intermediate_size)?,
692            ffn_out: GpuBuffer::new(&ctx, max_seq_len * hidden_size)?,
693            norm1_out_f16: None,
694            attn_out_f16: None,
695            norm2_out_f16: None,
696            swiglu_out_f16: None,
697            // Seq-dependent backward scratch
698            grad_hidden: GpuBuffer::new(&ctx, max_seq_len * hidden_size)?,
699            grad_swiglu: GpuBuffer::new(&ctx, max_seq_len * intermediate_size)?,
700            // Attention layout scratch (all sized for num_heads, handles GQA expansion)
701            attn_q_batched: GpuBuffer::new(&ctx, num_heads * max_seq_len * config.head_dim())?,
702            attn_kv_temp: GpuBuffer::new(&ctx, num_heads * max_seq_len * config.head_dim())?,
703            attn_kv_temp2: GpuBuffer::new(&ctx, num_heads * max_seq_len * config.head_dim())?,
704            // Attention backward gradient buffers (ENT-151b)
705            // grad_attn_scores needs max(H*S*S, H*S*hd) for buffer reuse safety
706            grad_attn_scores: GpuBuffer::new(
707                &ctx,
708                num_heads * max_seq_len * max_seq_len.max(config.head_dim()),
709            )?,
710            // LoRA scratch (unused for fp32 blocks, minimum allocation)
711            lora_inter: GpuBuffer::new(&ctx, 1)?,
712            lora_temp: GpuBuffer::new(&ctx, 1)?,
713            rope_positions: {
714                let positions: Vec<u32> = (0..max_seq_len as u32).collect();
715                let mut buf = GpuBuffer::new(&ctx, max_seq_len)?;
716                buf.copy_from_host(&positions)?;
717                buf
718            },
719            causal_mask_contiguous: GpuBuffer::from_host(&ctx, &single_mask)?,
720            causal_mask_cached_seq_len: max_seq_len,
721            op_us: [0u64; 16],
722            op_profiling_enabled: false,
723        };
724
725        // FALSIFY-CUDA-FORWARD-PARITY-002: replicate Q/K/V biases across
726        // max_seq_len rows when use_bias=true. Each replicated buffer
727        // is used by `cuda_add_inplace` after the corresponding gemm to
728        // apply the broadcast bias. Pre-fix this allocation didn't
729        // exist; biases dropped silently on Qwen-init paths.
730        let replicate = |bias: Option<&[f32]>, dim: usize| -> Result<Option<GpuBuffer<f32>>> {
731            match bias {
732                Some(slice) => {
733                    debug_assert_eq!(
734                        slice.len(),
735                        dim,
736                        "bias slice len {} != expected dim {dim}",
737                        slice.len()
738                    );
739                    let mut repl: Vec<f32> = Vec::with_capacity(max_seq_len * dim);
740                    for _ in 0..max_seq_len {
741                        repl.extend_from_slice(slice);
742                    }
743                    Ok(Some(GpuBuffer::from_host(&ctx, &repl)?))
744                }
745                None => Ok(None),
746            }
747        };
748        let b_q_replicated = replicate(b_q, q_dim)?;
749        let b_k_replicated = replicate(b_k, kv_hidden_size)?;
750        let b_v_replicated = replicate(b_v, kv_hidden_size)?;
751
752        Ok(Self {
753            config: config.clone(),
754            layer_idx,
755            input_norm_weight,
756            post_attn_norm_weight,
757            w_q,
758            w_k,
759            w_v,
760            w_o,
761            w_gate,
762            w_up,
763            w_down,
764            ctx,
765            scratch,
766            norm_zero_buf: vec![0.0f32; hidden_size],
767            q_norm_weight: None, // ENT-270: set via set_qk_norm() after construction
768            k_norm_weight: None,
769            b_q_replicated,
770            b_k_replicated,
771            b_v_replicated,
772        })
773    }
774
775    /// Set QK-norm weights (ENT-270). Called after construction when loading Qwen3 models.
776    #[allow(dead_code)]
777    pub fn set_qk_norm(&mut self, q_norm: &[f32], k_norm: &[f32]) -> Result<()> {
778        self.q_norm_weight = Some(GpuBuffer::from_host(&self.ctx, q_norm)?);
779        self.k_norm_weight = Some(GpuBuffer::from_host(&self.ctx, k_norm)?);
780        Ok(())
781    }
782
783    /// Forward pass - all operations on GPU
784    ///
785    /// # Arguments
786    /// * `input` - Input tensor on GPU (seq_len * hidden_size)
787    /// * `output` - Output tensor on GPU (seq_len * hidden_size)
788    /// * `seq_len` - Sequence length
789    /// * `stream` - CUDA stream for async execution
790    pub fn forward(
791        &mut self,
792        input: &GpuBuffer<f32>,
793        output: &mut GpuBuffer<f32>,
794        seq_len: usize,
795        stream: &CudaStream,
796    ) -> Result<()> {
797        let hidden_size = self.config.hidden_size;
798        let q_dim = self.config.q_dim();
799        let kv_hidden_size = self.config.num_kv_heads * self.config.head_dim();
800        let intermediate_size = self.config.intermediate_size;
801
802        // === Pre-attention RMSNorm (ENT-147) ===
803        // FALSIFY-CUDA-RMSNORM-EPS-PARITY-001: thread `config.rms_norm_eps`
804        // through to the kernel so Qwen2 (1e-6) and Llama (1e-5) get the
805        // correct epsilon. Pre-fix this hardcoded 1e-5 for everyone.
806        rms_norm_forward_with_eps(
807            input,
808            &self.input_norm_weight,
809            &mut self.scratch.norm1_out,
810            saturating_u32(seq_len),
811            saturating_u32(hidden_size),
812            self.config.rms_norm_eps,
813            stream,
814        )?;
815
816        // === Q, K, V Projections (CUDA GEMM) ===
817        // C[seq,q_dim] = A[seq,hidden] @ B[hidden,q_dim]
818        gemm_forward(
819            &self.scratch.norm1_out,
820            &self.w_q,
821            &mut self.scratch.q,
822            saturating_u32(seq_len),
823            saturating_u32(hidden_size),
824            saturating_u32(q_dim),
825            stream,
826        )?;
827        // FALSIFY-CUDA-FORWARD-PARITY-003: apply Q bias broadcast when
828        // use_bias=true (Qwen2 family). The replicated bias buffer is
829        // allocated at block-construction time; here we add the first
830        // `seq_len * q_dim` elements element-wise.
831        if let Some(b_q_repl) = self.b_q_replicated.as_ref() {
832            cuda_add_inplace(&mut self.scratch.q, b_q_repl, seq_len * q_dim, stream)?;
833        }
834
835        gemm_forward(
836            &self.scratch.norm1_out,
837            &self.w_k,
838            &mut self.scratch.k,
839            saturating_u32(seq_len),
840            saturating_u32(hidden_size),
841            saturating_u32(kv_hidden_size),
842            stream,
843        )?;
844        if let Some(b_k_repl) = self.b_k_replicated.as_ref() {
845            cuda_add_inplace(&mut self.scratch.k, b_k_repl, seq_len * kv_hidden_size, stream)?;
846        }
847
848        gemm_forward(
849            &self.scratch.norm1_out,
850            &self.w_v,
851            &mut self.scratch.v,
852            saturating_u32(seq_len),
853            saturating_u32(hidden_size),
854            saturating_u32(kv_hidden_size),
855            stream,
856        )?;
857        if let Some(b_v_repl) = self.b_v_replicated.as_ref() {
858            cuda_add_inplace(&mut self.scratch.v, b_v_repl, seq_len * kv_hidden_size, stream)?;
859        }
860
861        // === Multi-Head Attention (GPU-only, zero CPU transfers) ===
862        self.compute_attention_cuda(seq_len, stream)?;
863
864        // === Output Projection ===
865        // C[seq,hidden] = A[seq,q_dim] @ B[q_dim,hidden]
866        gemm_forward(
867            &self.scratch.attn_out,
868            &self.w_o,
869            &mut self.scratch.o_proj_out,
870            saturating_u32(seq_len),
871            saturating_u32(q_dim),
872            saturating_u32(hidden_size),
873            stream,
874        )?;
875
876        // === Residual Add (input + attention_output) ===
877        cuda_add(
878            input,
879            &self.scratch.o_proj_out,
880            &mut self.scratch.residual1,
881            seq_len * hidden_size,
882            stream,
883        )?;
884
885        // === Post-attention RMSNorm ===
886        // FALSIFY-CUDA-RMSNORM-EPS-PARITY-001: see pre-attn note above.
887        rms_norm_forward_with_eps(
888            &self.scratch.residual1,
889            &self.post_attn_norm_weight,
890            &mut self.scratch.norm2_out,
891            saturating_u32(seq_len),
892            saturating_u32(hidden_size),
893            self.config.rms_norm_eps,
894            stream,
895        )?;
896
897        // === FFN: Gate + Up Projections ===
898        gemm_forward(
899            &self.scratch.norm2_out,
900            &self.w_gate,
901            &mut self.scratch.gate_out,
902            saturating_u32(seq_len),
903            saturating_u32(hidden_size),
904            saturating_u32(intermediate_size),
905            stream,
906        )?;
907
908        gemm_forward(
909            &self.scratch.norm2_out,
910            &self.w_up,
911            &mut self.scratch.up_out,
912            saturating_u32(seq_len),
913            saturating_u32(hidden_size),
914            saturating_u32(intermediate_size),
915            stream,
916        )?;
917
918        // === FFN: Fused SwiGLU (ENT-150) - SiLU(gate) * up in single kernel ===
919        fused_swiglu_forward(
920            &self.scratch.gate_out,
921            &self.scratch.up_out,
922            &mut self.scratch.swiglu_out,
923            saturating_u32(seq_len * intermediate_size),
924            stream,
925        )?;
926
927        // === FFN: Down Projection ===
928        gemm_forward(
929            &self.scratch.swiglu_out,
930            &self.w_down,
931            &mut self.scratch.ffn_out,
932            saturating_u32(seq_len),
933            saturating_u32(intermediate_size),
934            saturating_u32(hidden_size),
935            stream,
936        )?;
937
938        // === Final Residual Add (residual1 + ffn_output) ===
939        cuda_add(
940            &self.scratch.residual1,
941            &self.scratch.ffn_out,
942            output,
943            seq_len * hidden_size,
944            stream,
945        )?;
946
947        Ok(())
948    }
949
950    /// Compute multi-head attention entirely on GPU (zero CPU transfers)
951    ///
952    /// # Contract (C-ATTN-001)
953    ///
954    /// - **Precondition**: Q [seq, hidden], K [seq, kv_hidden], V [seq, kv_hidden] on GPU
955    /// - **Postcondition**: attn_out [seq, hidden] = concat(head_0..head_H) where
956    ///   head_h = softmax(Q_h @ K_{kv(h)}^T / √d_k) @ V_{kv(h)}
957    /// - **Invariant**: Zero gpu_to_vec / vec_to_gpu calls; numerically equivalent to CPU
958    ///
959    /// Uses existing trueno-gpu kernels:
960    /// - `InterleavedToBatchedKernel` for Q/K/V layout conversion
961    /// - `BatchedTransposeKernel` for K^T
962    /// - `Batched4DGemmKernel` for Q@K^T and attn@V
963    /// - `ScaleKernel` for 1/√d_k scaling
964    /// - `BatchedSoftmaxKernel` for row-wise softmax
965    /// - `BatchedToInterleavedKernel` for output layout conversion
966    /// - D2D copies for GQA head expansion
967    fn compute_attention_cuda(&mut self, seq_len: usize, stream: &CudaStream) -> Result<()> {
968        let num_heads = self.config.num_attention_heads;
969        let num_kv_heads = self.config.num_kv_heads;
970        let head_dim = self.config.head_dim();
971        let heads_per_kv = num_heads / num_kv_heads;
972        let scale = 1.0 / (head_dim as f32).sqrt();
973
974        let seq = saturating_u32(seq_len);
975        let nh = saturating_u32(num_heads);
976        let nkv = saturating_u32(num_kv_heads);
977        let hd = saturating_u32(head_dim);
978
979        // PMAT-420: Ensure causal mask is contiguous for current seq_len.
980        self.scratch.prepare_causal_mask(seq_len, &self.ctx)?;
981
982        // ── ENT-270: Apply QK-norm (per-head RMSNorm) on Q and K ──────────
983        // SAFETY: In-place GPU operations — CUDA kernels read all input before writing output.
984        // Rust borrow checker cannot verify GPU kernel memory access patterns, so we use
985        // raw pointer reborrow to allow the same buffer as both input and output.
986        if let Some(ref q_norm) = self.q_norm_weight {
987            for pos in 0..seq_len {
988                // SAFETY: reborrows `self.scratch.q` as `&` while the same buffer is also passed `&mut` to the in-place GPU kernel below. Sound because the CUDA kernel reads every input element before writing any output element, so the read and write views never alias a live access.
989                let q_ref = unsafe { &*(std::ptr::addr_of!(self.scratch.q)) };
990                per_head_rmsnorm_forward(q_ref, q_norm, &mut self.scratch.q, nh, hd, pos, stream)?;
991            }
992        }
993        if let Some(ref k_norm) = self.k_norm_weight {
994            for pos in 0..seq_len {
995                // SAFETY: reborrows `self.scratch.k` as `&` while the same buffer is also passed `&mut` to the in-place GPU kernel below. Sound because the CUDA kernel reads every input element before writing any output element, so the read and write views never alias a live access.
996                let k_ref = unsafe { &*(std::ptr::addr_of!(self.scratch.k)) };
997                per_head_rmsnorm_forward(k_ref, k_norm, &mut self.scratch.k, nkv, hd, pos, stream)?;
998            }
999        }
1000
1001        // ── ENT-270: Apply RoPE (NeoX half-rotation) on Q and K ──────────
1002        // ALB-119: Batched launch (2 kernels) replaces per-position loop (2*seq_len kernels)
1003        let rope_theta = self.config.rope_theta;
1004        {
1005            // SAFETY: reborrows `self.scratch.q` as `&` while the same buffer is also passed `&mut` to the in-place GPU kernel below. Sound because the CUDA kernel reads every input element before writing any output element, so the read and write views never alias a live access.
1006            let q_ref = unsafe { &*(std::ptr::addr_of!(self.scratch.q)) };
1007            batched_rope_neox_forward(
1008                q_ref,
1009                &mut self.scratch.q,
1010                &self.scratch.rope_positions,
1011                nh,
1012                hd,
1013                seq,
1014                rope_theta,
1015                stream,
1016            )?;
1017            // SAFETY: reborrows `self.scratch.k` as `&` while the same buffer is also passed `&mut` to the in-place GPU kernel below. Sound because the CUDA kernel reads every input element before writing any output element, so the read and write views never alias a live access.
1018            let k_ref = unsafe { &*(std::ptr::addr_of!(self.scratch.k)) };
1019            batched_rope_neox_forward(
1020                k_ref,
1021                &mut self.scratch.k,
1022                &self.scratch.rope_positions,
1023                nkv,
1024                hd,
1025                seq,
1026                rope_theta,
1027                stream,
1028            )?;
1029        }
1030
1031        // Step 1: Q interleaved [seq, num_heads * head_dim] → batched [num_heads, seq, head_dim]
1032        interleaved_to_batched_forward(
1033            &self.scratch.q,
1034            &mut self.scratch.attn_q_batched,
1035            seq,
1036            nh,
1037            hd,
1038            stream,
1039        )?;
1040
1041        // Step 2: K interleaved [seq, num_kv_heads * head_dim] → batched [num_kv_heads, seq, head_dim]
1042        interleaved_to_batched_forward(
1043            &self.scratch.k,
1044            &mut self.scratch.attn_kv_temp,
1045            seq,
1046            nkv,
1047            hd,
1048            stream,
1049        )?;
1050
1051        // Step 3: GQA expansion + transpose for K
1052        if heads_per_kv == 1 {
1053            // MHA: transpose directly [num_heads, seq, head_dim] → [num_heads, head_dim, seq]
1054            batched_transpose_forward(
1055                &self.scratch.attn_kv_temp,
1056                &mut self.scratch.attn_kv_temp2,
1057                nh,
1058                seq,
1059                hd,
1060                stream,
1061            )?;
1062        } else {
1063            // GQA: expand [num_kv_heads, seq, hd] → [num_heads, seq, hd] in attn_kv_temp2
1064            expand_kv_heads(
1065                &self.scratch.attn_kv_temp,
1066                &mut self.scratch.attn_kv_temp2,
1067                num_kv_heads,
1068                heads_per_kv,
1069                seq_len * head_dim,
1070                stream,
1071            )?;
1072            // Transpose expanded K: [num_heads, seq, hd] → [num_heads, hd, seq] in attn_kv_temp
1073            batched_transpose_forward(
1074                &self.scratch.attn_kv_temp2,
1075                &mut self.scratch.attn_kv_temp,
1076                nh,
1077                seq,
1078                hd,
1079                stream,
1080            )?;
1081            // Move K^T to attn_kv_temp2 for consistent naming below
1082            // (swap pointers via D2D copy — attn_kv_temp → attn_kv_temp2)
1083            // SAFETY: Both buffers are valid GPU allocations with matching sizes.
1084            unsafe {
1085                self.scratch
1086                    .attn_kv_temp2
1087                    .copy_from_buffer_async(&self.scratch.attn_kv_temp, stream)
1088                    .map_err(|e| {
1089                        crate::autograd::cuda_tensor::CudaTensorError::TransferFailed(format!(
1090                            "K^T buffer copy failed: {e}"
1091                        ))
1092                    })?;
1093            }
1094        }
1095
1096        // Step 4: Q @ K^T → attn_scores [num_heads, seq, seq]
1097        // attn_q_batched: [1, num_heads, seq, head_dim]
1098        // attn_kv_temp2:  [1, num_heads, head_dim, seq] (K transposed)
1099        // attn_scores:    [1, num_heads, seq, seq]
1100        batched_4d_gemm_forward(
1101            &self.scratch.attn_q_batched,
1102            &self.scratch.attn_kv_temp2,
1103            &mut self.scratch.attn_scores,
1104            1,
1105            nh,
1106            seq,
1107            seq,
1108            hd,
1109            stream,
1110        )?;
1111
1112        // Step 5: Scale scores by 1/√d_k (in-place)
1113        let total_scores = nh * seq * seq;
1114        {
1115            // SAFETY: In-place aliasing is safe for element-wise operations where each
1116            // element is read before being written. ScaleKernel processes elements
1117            // independently. The view is forgotten to prevent double-free.
1118            let scores_view = unsafe {
1119                GpuBuffer::<f32>::from_raw_parts(
1120                    self.scratch.attn_scores.as_ptr(),
1121                    self.scratch.attn_scores.len(),
1122                )
1123            };
1124            scale_forward(
1125                &scores_view,
1126                &mut self.scratch.attn_scores,
1127                scale,
1128                total_scores,
1129                stream,
1130            )?;
1131            leak(scores_view);
1132        }
1133
1134        // Step 5.5 (C-CAUSAL-001): Apply causal mask — add -inf to future positions
1135        // Loop over heads, adding [seq, seq] mask to each head's scores slice.
1136        // PMAT-420: Use causal_mask_contiguous (correctly strided for seq_len)
1137        // instead of causal_mask (strided at max_seq_len, causes row misalignment
1138        // when seq_len < max_seq_len, leading to NaN after deep layers).
1139        {
1140            let seq_sq = (seq * seq) as usize;
1141            let mask_ptr = self.scratch.causal_mask_contiguous.as_ptr();
1142            let scores_base = self.scratch.attn_scores.as_ptr();
1143            for head in 0..nh as usize {
1144                let byte_offset = (head * seq_sq * 4) as u64; // f32 = 4 bytes
1145                let head_ptr = scores_base + byte_offset;
1146                // SAFETY: mask and scores_slice are non-overlapping GPU regions.
1147                // output aliases scores_slice — safe for element-wise add (read before write).
1148                // Views are leaked to prevent double-free of GPU memory.
1149                let mask_view = unsafe { GpuBuffer::<f32>::from_raw_parts(mask_ptr, seq_sq) };
1150                // SAFETY: constructs a non-owning `GpuBuffer` view over an already-allocated device region (`ptr`, element count `len`) that stays live for the kernel call; the view is `leak()`ed afterwards so its Drop never frees the borrowed device allocation (no double-free).
1151                let scores_view = unsafe { GpuBuffer::<f32>::from_raw_parts(head_ptr, seq_sq) };
1152                // SAFETY: constructs a non-owning `GpuBuffer` view over an already-allocated device region (`ptr`, element count `len`) that stays live for the kernel call; the view is `leak()`ed afterwards so its Drop never frees the borrowed device allocation (no double-free).
1153                let mut out_view = unsafe { GpuBuffer::<f32>::from_raw_parts(head_ptr, seq_sq) };
1154                residual_add_forward(&mask_view, &scores_view, &mut out_view, seq * seq, stream)?;
1155                leak(mask_view);
1156                leak(scores_view);
1157                leak(out_view);
1158            }
1159        }
1160
1161        // Step 6: Row-wise softmax → attn_weights [num_heads * seq, seq] (in-place)
1162        let total_rows = nh * seq;
1163        {
1164            // SAFETY: In-place aliasing is safe for BatchedSoftmaxKernel which uses
1165            // shared memory for row-wise reduction. Each row is fully read into shared
1166            // memory before any output is written. The view is forgotten to prevent double-free.
1167            let scores_view = unsafe {
1168                GpuBuffer::<f32>::from_raw_parts(
1169                    self.scratch.attn_scores.as_ptr(),
1170                    self.scratch.attn_scores.len(),
1171                )
1172            };
1173            batched_softmax_forward(
1174                &scores_view,
1175                &mut self.scratch.attn_scores,
1176                total_rows,
1177                seq,
1178                stream,
1179            )?;
1180            leak(scores_view);
1181        }
1182
1183        // Step 7: V layout conversion + GQA expansion
1184        interleaved_to_batched_forward(
1185            &self.scratch.v,
1186            &mut self.scratch.attn_kv_temp,
1187            seq,
1188            nkv,
1189            hd,
1190            stream,
1191        )?;
1192
1193        if heads_per_kv == 1 {
1194            // MHA: V already in [num_heads, seq, head_dim] in attn_kv_temp
1195        } else {
1196            // GQA: expand V [num_kv_heads, seq, hd] → [num_heads, seq, hd]
1197            expand_kv_heads(
1198                &self.scratch.attn_kv_temp,
1199                &mut self.scratch.attn_kv_temp2,
1200                num_kv_heads,
1201                heads_per_kv,
1202                seq_len * head_dim,
1203                stream,
1204            )?;
1205            // Copy expanded V back to attn_kv_temp for the GEMM
1206            // SAFETY: Both buffers are valid GPU allocations with matching sizes.
1207            unsafe {
1208                self.scratch
1209                    .attn_kv_temp
1210                    .copy_from_buffer_async(&self.scratch.attn_kv_temp2, stream)
1211                    .map_err(|e| {
1212                        crate::autograd::cuda_tensor::CudaTensorError::TransferFailed(format!(
1213                            "V expanded buffer copy failed: {e}"
1214                        ))
1215                    })?;
1216            }
1217        }
1218
1219        // Step 8: attn_weights @ V → attn_result [num_heads, seq, head_dim]
1220        // attn_scores:   [1, num_heads, seq, seq]
1221        // attn_kv_temp:  [1, num_heads, seq, head_dim]
1222        // → attn_q_batched: [1, num_heads, seq, head_dim] (reuse Q buffer)
1223        batched_4d_gemm_forward(
1224            &self.scratch.attn_scores,
1225            &self.scratch.attn_kv_temp,
1226            &mut self.scratch.attn_q_batched,
1227            1,
1228            nh,
1229            seq,
1230            hd,
1231            seq,
1232            stream,
1233        )?;
1234
1235        // Step 9: Convert back to interleaved [seq, num_heads * head_dim] → attn_out
1236        batched_to_interleaved_forward(
1237            &self.scratch.attn_q_batched,
1238            &mut self.scratch.attn_out,
1239            seq,
1240            nh,
1241            hd,
1242            stream,
1243        )?;
1244
1245        Ok(())
1246    }
1247
1248    /// Get layer index
1249    pub fn layer_idx(&self) -> usize {
1250        self.layer_idx
1251    }
1252
1253    /// Get configuration
1254    pub fn config(&self) -> &TransformerConfig {
1255        &self.config
1256    }
1257
1258    /// Backward pass - gradient computation on GPU (ENT-151)
1259    ///
1260    /// Computes gradients for all parameters given upstream gradient.
1261    ///
1262    /// # Arguments
1263    /// * `input` - Original input from forward pass (seq_len * hidden_size)
1264    /// * `grad_output` - Gradient from upstream layer (seq_len * hidden_size)
1265    /// * `grad_input` - Output: gradient w.r.t. input (seq_len * hidden_size)
1266    /// * `seq_len` - Sequence length
1267    /// * `stream` - CUDA stream for async execution
1268    ///
1269    /// # Returns
1270    /// Gradients are accumulated into the scratch buffers:
1271    /// - `scratch.grad_input_norm` - Gradient for input RMSNorm weight
1272    /// - `scratch.grad_post_attn_norm` - Gradient for post-attention RMSNorm weight
1273    /// - `scratch.grad_gate/up/down` - Gradients for FFN weights
1274    /// - `scratch.grad_w_q/w_k/w_v/w_o` - Gradients for attention projection weights
1275    #[provable_contracts_macros::contract("backward-pass-v1", equation = "backward")]
1276    pub fn backward(
1277        &mut self,
1278        input: &GpuBuffer<f32>,
1279        grad_output: &GpuBuffer<f32>,
1280        grad_input: &mut GpuBuffer<f32>,
1281        seq_len: usize,
1282        stream: &CudaStream,
1283        grad_ws: &mut CudaGradWorkspace,
1284    ) -> Result<()> {
1285        let hidden_size = self.config.hidden_size;
1286        let intermediate_size = self.config.intermediate_size;
1287        let eps = 1e-5_f32;
1288
1289        // Zero norm gradient buffers before backward pass.
1290        // BatchedRmsNormBackwardKernel accumulates grad_gamma via atomicAdd,
1291        // so buffers must be zeroed before each call to prevent cross-step accumulation.
1292        grad_ws.zero_norm_grads(&self.norm_zero_buf)?;
1293
1294        // Backward through final residual: output = residual1 + ffn_output
1295        // grad_output flows to BOTH residual1 (identity skip) and ffn_output path.
1296        self.backward_ffn(grad_output, seq_len, hidden_size, intermediate_size, stream, grad_ws)?;
1297
1298        // Backward through post-attention RMSNorm (FFN path gradient only)
1299        self.backward_post_attn_norm(grad_input, seq_len, hidden_size, eps, stream, grad_ws)?;
1300
1301        // C-RESIDUAL-001 / entrenar#313: Second residual skip gradient.
1302        // Forward: output = residual1 + ffn_output
1303        // The identity skip grad_residual1 = grad_output must bypass the
1304        // post-attention RMSNorm backward entirely — add AFTER norm backward.
1305        cuda_add_inplace(grad_input, grad_output, seq_len * hidden_size, stream)?;
1306
1307        // Backward through attention: output projection, attention weights, Q/K/V projections
1308        // (ENT-151b: previously missing — attention params received no gradients)
1309        self.backward_attention(grad_input, seq_len, stream, grad_ws)?;
1310
1311        // Backward through first residual connection and input RMSNorm
1312        self.backward_residual_and_input_norm(
1313            input,
1314            grad_output,
1315            grad_input,
1316            seq_len,
1317            hidden_size,
1318            eps,
1319            stream,
1320            grad_ws,
1321        )?;
1322
1323        Ok(())
1324    }
1325
1326    /// Backward through FFN: down projection, SwiGLU, gate+up projections.
1327    ///
1328    /// SwiGLU(gate, up) = silu(gate) * up
1329    /// ∂L/∂gate = ∂L/∂swiglu * up * silu'(gate)
1330    /// ∂L/∂up   = ∂L/∂swiglu * silu(gate)
1331    ///
1332    /// Buffer reuse plan (all [S,I] unless noted):
1333    ///   grad_swiglu  = ∂L/∂swiglu          (computed step 1, read steps 2/4/6)
1334    ///   swiglu_out  → temp1 (step 2)       → silu(gate) (step 4)
1335    ///   up_out      → grad_gate (step 3)
1336    ///   gate_out    → grad_up (step 6)
1337    ///   ffn_out     → grad_norm2_gate [S,H] (step 8)
1338    ///   grad_hidden → grad_norm2_up [S,H]   (step 9)
1339    ///   norm2_out   → accumulated grad [S,H] (step 10)
1340    fn backward_ffn(
1341        &mut self,
1342        grad_output: &GpuBuffer<f32>,
1343        seq_len: usize,
1344        hidden_size: usize,
1345        intermediate_size: usize,
1346        stream: &CudaStream,
1347        grad_ws: &mut CudaGradWorkspace,
1348    ) -> Result<()> {
1349        let n_inter = saturating_u32(seq_len * intermediate_size);
1350        let n_hidden = saturating_u32(seq_len * hidden_size);
1351
1352        // Step 1: grad_swiglu = grad_ffn_out @ w_down^T  [S,I]
1353        gemm_backward_a(
1354            grad_output,
1355            &self.w_down,
1356            &mut self.scratch.grad_swiglu,
1357            saturating_u32(seq_len),
1358            saturating_u32(intermediate_size),
1359            saturating_u32(hidden_size),
1360            stream,
1361        )?;
1362
1363        // Step 2: grad_w_down = swiglu_out^T @ grad_ffn_out  [I,H]
1364        // (swiglu_out free after this)
1365        gemm_backward_b(
1366            &self.scratch.swiglu_out,
1367            grad_output,
1368            &mut grad_ws.grad_down,
1369            saturating_u32(seq_len),
1370            saturating_u32(intermediate_size),
1371            saturating_u32(hidden_size),
1372            stream,
1373        )?;
1374
1375        // === SwiGLU backward: swiglu = silu(gate) * up ===
1376
1377        // Step 3: temp1 = grad_swiglu * up_out → swiglu_out [S,I]
1378        elementwise_mul_forward(
1379            &self.scratch.grad_swiglu,
1380            &self.scratch.up_out,
1381            &mut self.scratch.swiglu_out,
1382            n_inter,
1383            stream,
1384        )?;
1385
1386        // Step 4: grad_gate = silu_backward(gate_out, temp1) → up_out [S,I]
1387        // Computes: (grad_swiglu * up_out) * silu'(gate_out) = correct ∂L/∂gate
1388        silu_backward(
1389            &self.scratch.gate_out,
1390            &self.scratch.swiglu_out,
1391            &mut self.scratch.up_out,
1392            stream,
1393        )?;
1394        // up_out now holds grad_gate [S,I]
1395
1396        // Step 5: silu_gate = silu(gate_out) → swiglu_out [S,I]
1397        silu_forward(&self.scratch.gate_out, &mut self.scratch.swiglu_out, n_inter, stream)?;
1398
1399        // Step 6: grad_up = grad_swiglu * silu_gate → gate_out [S,I]
1400        elementwise_mul_forward(
1401            &self.scratch.grad_swiglu,
1402            &self.scratch.swiglu_out,
1403            &mut self.scratch.gate_out,
1404            n_inter,
1405            stream,
1406        )?;
1407        // gate_out now holds grad_up [S,I]
1408
1409        // === Weight gradients ===
1410
1411        // Step 7a: grad_w_gate = norm2_out^T @ grad_gate (in up_out)  [H,I]
1412        gemm_backward_b(
1413            &self.scratch.norm2_out,
1414            &self.scratch.up_out,
1415            &mut grad_ws.grad_gate,
1416            saturating_u32(seq_len),
1417            saturating_u32(hidden_size),
1418            saturating_u32(intermediate_size),
1419            stream,
1420        )?;
1421
1422        // Step 7b: grad_w_up = norm2_out^T @ grad_up (in gate_out)  [H,I]
1423        gemm_backward_b(
1424            &self.scratch.norm2_out,
1425            &self.scratch.gate_out,
1426            &mut grad_ws.grad_up,
1427            saturating_u32(seq_len),
1428            saturating_u32(hidden_size),
1429            saturating_u32(intermediate_size),
1430            stream,
1431        )?;
1432
1433        // === Input gradient (accumulate gate + up paths) ===
1434
1435        // Step 8: grad_norm2_gate = grad_gate @ w_gate^T → ffn_out [S,H]
1436        gemm_backward_a(
1437            &self.scratch.up_out,
1438            &self.w_gate,
1439            &mut self.scratch.ffn_out,
1440            saturating_u32(seq_len),
1441            saturating_u32(hidden_size),
1442            saturating_u32(intermediate_size),
1443            stream,
1444        )?;
1445
1446        // Step 9: grad_norm2_up = grad_up @ w_up^T → grad_hidden [S,H]
1447        gemm_backward_a(
1448            &self.scratch.gate_out,
1449            &self.w_up,
1450            &mut self.scratch.grad_hidden,
1451            saturating_u32(seq_len),
1452            saturating_u32(hidden_size),
1453            saturating_u32(intermediate_size),
1454            stream,
1455        )?;
1456
1457        // Step 10: norm2_out = grad_norm2_gate + grad_norm2_up  [S,H]
1458        residual_add_forward(
1459            &self.scratch.ffn_out,
1460            &self.scratch.grad_hidden,
1461            &mut self.scratch.norm2_out,
1462            n_hidden,
1463            stream,
1464        )?;
1465
1466        Ok(())
1467    }
1468
1469    /// Backward through post-attention RMSNorm.
1470    fn backward_post_attn_norm(
1471        &mut self,
1472        grad_input: &mut GpuBuffer<f32>,
1473        seq_len: usize,
1474        hidden_size: usize,
1475        eps: f32,
1476        stream: &CudaStream,
1477        grad_ws: &mut CudaGradWorkspace,
1478    ) -> Result<()> {
1479        // D2D copy norm2_out → grad_hidden (avoids D2H + H2D round-trip)
1480        // SAFETY: Both buffers are valid GPU allocations with matching sizes.
1481        unsafe {
1482            self.scratch
1483                .grad_hidden
1484                .copy_from_buffer_async(&self.scratch.norm2_out, stream)
1485                .map_err(|e| {
1486                    crate::autograd::cuda_tensor::CudaTensorError::TransferFailed(format!(
1487                        "Backward norm D2D copy failed: {e}"
1488                    ))
1489                })?;
1490        }
1491
1492        rms_norm_backward(
1493            &self.scratch.residual1,
1494            &self.post_attn_norm_weight,
1495            &self.scratch.grad_hidden,
1496            grad_input,
1497            &mut grad_ws.grad_post_attn_norm,
1498            saturating_u32(seq_len),
1499            saturating_u32(hidden_size),
1500            eps,
1501            stream,
1502        )
1503    }
1504
1505    /// Backward through multi-head attention (ENT-151b)
1506    ///
1507    /// Reverses the forward attention pipeline:
1508    /// output_proj → layout → attn_weights@V → softmax → scale → Q@K^T → layout → Q/K/V proj
1509    ///
1510    /// # Contract (C-ATTN-BACK-001)
1511    ///
1512    /// - **Precondition**: grad_input contains gradient from post-attention norm backward,
1513    ///   scratch.{q, k, v, attn_scores, attn_out, norm1_out} contain forward pass values
1514    /// - **Postcondition**: grad_hidden contains gradient w.r.t. norm1_out (input to Q/K/V proj),
1515    ///   grad_w_{q,k,v,o} contain weight gradients for attention projections
1516    /// - **Invariant**: Zero CPU-side data transfers; all operations on GPU
1517    fn backward_attention(
1518        &mut self,
1519        grad_input: &mut GpuBuffer<f32>,
1520        seq_len: usize,
1521        stream: &CudaStream,
1522        grad_ws: &mut CudaGradWorkspace,
1523    ) -> Result<()> {
1524        let hidden_size = self.config.hidden_size;
1525        let q_dim = self.config.q_dim();
1526        let kv_hidden_size = self.config.num_kv_heads * self.config.head_dim();
1527        let num_heads = self.config.num_attention_heads;
1528        let num_kv_heads = self.config.num_kv_heads;
1529        let head_dim = self.config.head_dim();
1530        let heads_per_kv = num_heads / num_kv_heads;
1531        let scale = 1.0 / (head_dim as f32).sqrt();
1532
1533        let seq = saturating_u32(seq_len);
1534        let nh = saturating_u32(num_heads);
1535        let nkv = saturating_u32(num_kv_heads);
1536        let hd = saturating_u32(head_dim);
1537
1538        // === Step 4.1: Output projection backward ===
1539        // Forward: o_proj_out[seq,hidden] = attn_out[seq,q_dim] @ w_o[q_dim,hidden]
1540        //   m=seq, k=q_dim, n=hidden
1541        // grad_attn_out[seq,q_dim] = grad_o_proj[seq,hidden] @ w_o^T[hidden,q_dim]
1542        gemm_backward_a(
1543            grad_input,
1544            &self.w_o,
1545            &mut self.scratch.grad_hidden,
1546            seq,
1547            saturating_u32(q_dim),
1548            saturating_u32(hidden_size),
1549            stream,
1550        )?;
1551
1552        // grad_w_o[q_dim,hidden] = attn_out^T[q_dim,seq] @ grad_o_proj[seq,hidden]
1553        gemm_backward_b(
1554            &self.scratch.attn_out,
1555            grad_input,
1556            &mut grad_ws.grad_w_o,
1557            seq,
1558            saturating_u32(q_dim),
1559            saturating_u32(hidden_size),
1560            stream,
1561        )?;
1562
1563        // === Step 4.2: Layout conversion ===
1564        // grad_attn_out [seq, q_dim] → grad_attn_batched [num_heads, seq, head_dim]
1565        // Reuse attn_q_batched for grad_attn_batched
1566        interleaved_to_batched_forward(
1567            &self.scratch.grad_hidden,
1568            &mut self.scratch.attn_q_batched,
1569            seq,
1570            nh,
1571            hd,
1572            stream,
1573        )?;
1574
1575        // === Step 4.3: Backward through attn_weights @ V ===
1576        // Forward was: attn_result = attn_weights @ V_batched
1577        // Reconstruct V_batched from preserved v
1578        interleaved_to_batched_forward(
1579            &self.scratch.v,
1580            &mut self.scratch.attn_kv_temp,
1581            seq,
1582            nkv,
1583            hd,
1584            stream,
1585        )?;
1586
1587        // GQA expand V if needed
1588        if heads_per_kv > 1 {
1589            expand_kv_heads(
1590                &self.scratch.attn_kv_temp,
1591                &mut self.scratch.attn_kv_temp2,
1592                num_kv_heads,
1593                heads_per_kv,
1594                seq_len * head_dim,
1595                stream,
1596            )?;
1597            // SAFETY: Both buffers are valid GPU allocations with matching sizes.
1598            unsafe {
1599                self.scratch
1600                    .attn_kv_temp
1601                    .copy_from_buffer_async(&self.scratch.attn_kv_temp2, stream)
1602                    .map_err(|e| {
1603                        crate::autograd::cuda_tensor::CudaTensorError::TransferFailed(format!(
1604                            "Attn backward V expand D2D copy failed: {e}"
1605                        ))
1606                    })?;
1607            }
1608        }
1609        // attn_kv_temp now has V_batched [num_heads, seq, head_dim]
1610
1611        // Transpose V: [num_heads, seq, head_dim] → [num_heads, head_dim, seq]
1612        batched_transpose_forward(
1613            &self.scratch.attn_kv_temp,
1614            &mut self.scratch.attn_kv_temp2,
1615            nh,
1616            seq,
1617            hd,
1618            stream,
1619        )?;
1620        // attn_kv_temp2 = V^T [num_heads, head_dim, seq]
1621
1622        // grad_attn_weights = grad_attn_batched @ V^T → grad_attn_scores [H, seq, seq]
1623        batched_4d_gemm_forward(
1624            &self.scratch.attn_q_batched,
1625            &self.scratch.attn_kv_temp2,
1626            &mut self.scratch.grad_attn_scores,
1627            1,
1628            nh,
1629            seq,
1630            seq,
1631            hd,
1632            stream,
1633        )?;
1634
1635        // grad_V = attn_weights^T @ grad_attn_batched → attn_kv_temp [H, seq, hd]
1636        //
1637        // BUG FIX: Cannot transpose attn_scores [H,S,S] into attn_kv_temp2 [H,S,hd]
1638        // because H*S*S >> H*S*hd when S > hd (e.g. 350M: 4.2M vs 524K = 8× overflow).
1639        //
1640        // Use identity: grad_V = (grad_attn_batched^T @ attn_scores)^T
1641        // All intermediates are [H, hd, S] = [H, S, hd] size — no H*S*S buffer needed.
1642
1643        // Step A: transpose grad_attn_batched [H,S,hd] → [H,hd,S]
1644        batched_transpose_forward(
1645            &self.scratch.attn_q_batched,   // grad_attn_batched [H, S, hd]
1646            &mut self.scratch.attn_kv_temp, // temp: grad_attn_batched^T [H, hd, S]
1647            nh,
1648            seq,
1649            hd,
1650            stream,
1651        )?;
1652
1653        // Step B: GEMM [H,hd,S] @ [H,S,S] → [H,hd,S] (= grad_V^T)
1654        batched_4d_gemm_forward(
1655            &self.scratch.attn_kv_temp,      // grad_attn_batched^T [H, hd, S]
1656            &self.scratch.attn_scores,       // attn_weights [H, S, S]
1657            &mut self.scratch.attn_kv_temp2, // grad_V^T [H, hd, S]
1658            1,
1659            nh,
1660            hd,  // m
1661            seq, // n
1662            seq, // k
1663            stream,
1664        )?;
1665
1666        // Step C: transpose grad_V^T [H,hd,S] → grad_V [H,S,hd]
1667        batched_transpose_forward(
1668            &self.scratch.attn_kv_temp2,    // grad_V^T [H, hd, S]
1669            &mut self.scratch.attn_kv_temp, // grad_V [H, S, hd]
1670            nh,
1671            hd,
1672            seq,
1673            stream,
1674        )?;
1675        // attn_kv_temp = grad_V [num_heads, seq, head_dim]
1676
1677        // === Step 4.4: Softmax backward ===
1678        // attn_scores contains softmax output from forward pass
1679        // In-place: grad_attn_scores is both input (grad_output) and output (grad_input)
1680        // This is safe because the kernel reads all elements in pass 1 before writing in pass 2.
1681        let total_rows = nh * seq;
1682        {
1683            // SAFETY: In-place aliasing is safe for BatchedSoftmaxBackwardKernel which uses
1684            // a two-pass approach: pass 1 reads all y[i]*gy[i] to compute dot product,
1685            // pass 2 writes grad_x[i]. The view is forgotten to prevent double-free.
1686            let grad_scores_view = unsafe {
1687                GpuBuffer::<f32>::from_raw_parts(
1688                    self.scratch.grad_attn_scores.as_ptr(),
1689                    self.scratch.grad_attn_scores.len(),
1690                )
1691            };
1692            batched_softmax_backward(
1693                &self.scratch.attn_scores,
1694                &grad_scores_view,
1695                &mut self.scratch.grad_attn_scores,
1696                total_rows,
1697                seq,
1698                stream,
1699            )?;
1700            leak(grad_scores_view);
1701        }
1702        // grad_attn_scores now contains gradient through softmax
1703
1704        // === Step 4.5: Scale backward ===
1705        // Forward scaled by 1/√d_k, backward is same scale (linear operation)
1706        let total_scores = nh * seq * seq;
1707        {
1708            // SAFETY: In-place aliasing safe for element-wise scale (independent elements).
1709            let scores_view = unsafe {
1710                GpuBuffer::<f32>::from_raw_parts(
1711                    self.scratch.grad_attn_scores.as_ptr(),
1712                    self.scratch.grad_attn_scores.len(),
1713                )
1714            };
1715            scale_forward(
1716                &scores_view,
1717                &mut self.scratch.grad_attn_scores,
1718                scale,
1719                total_scores,
1720                stream,
1721            )?;
1722            leak(scores_view);
1723        }
1724
1725        // === Step 4.6: Backward through Q @ K^T ===
1726        // Forward was: scores = Q_batched @ K^T
1727        // Reconstruct K_batched and expand for GQA
1728        interleaved_to_batched_forward(
1729            &self.scratch.k,
1730            &mut self.scratch.attn_kv_temp2,
1731            seq,
1732            nkv,
1733            hd,
1734            stream,
1735        )?;
1736
1737        if heads_per_kv > 1 {
1738            // SAFETY: Both buffers are valid GPU allocations; attn_q_batched is about to be
1739            // overwritten anyway.
1740            unsafe {
1741                self.scratch
1742                    .attn_q_batched
1743                    .copy_from_buffer_async(&self.scratch.attn_kv_temp2, stream)
1744                    .map_err(|e| {
1745                        crate::autograd::cuda_tensor::CudaTensorError::TransferFailed(format!(
1746                            "Attn backward K copy for GQA expand failed: {e}"
1747                        ))
1748                    })?;
1749            }
1750            expand_kv_heads(
1751                &self.scratch.attn_q_batched,
1752                &mut self.scratch.attn_kv_temp2,
1753                num_kv_heads,
1754                heads_per_kv,
1755                seq_len * head_dim,
1756                stream,
1757            )?;
1758        }
1759        // attn_kv_temp2 = K_expanded [num_heads, seq, head_dim]
1760
1761        // grad_Q = grad_scores @ K_expanded → attn_q_batched [H, seq, hd]
1762        batched_4d_gemm_forward(
1763            &self.scratch.grad_attn_scores,
1764            &self.scratch.attn_kv_temp2,
1765            &mut self.scratch.attn_q_batched,
1766            1,
1767            nh,
1768            seq,
1769            hd,
1770            seq,
1771            stream,
1772        )?;
1773
1774        // grad_K^T = Q^T @ grad_scores
1775        // First reconstruct Q_batched from preserved q
1776        // Reconstruct Q_batched into o_proj_out (attn_q_batched already overwritten by grad_Q).
1777        interleaved_to_batched_forward(
1778            &self.scratch.q,
1779            &mut self.scratch.o_proj_out, // temp buffer for Q_batched
1780            seq,
1781            nh,
1782            hd,
1783            stream,
1784        )?;
1785
1786        // Transpose Q: [H, seq, hd] → [H, hd, seq]
1787        batched_transpose_forward(
1788            &self.scratch.o_proj_out,
1789            &mut self.scratch.attn_kv_temp2, // reuse for Q^T
1790            nh,
1791            seq,
1792            hd,
1793            stream,
1794        )?;
1795
1796        // grad_K^T = Q^T @ grad_scores → ffn_out as temp [H, hd, seq]
1797        batched_4d_gemm_forward(
1798            &self.scratch.attn_kv_temp2,
1799            &self.scratch.grad_attn_scores,
1800            &mut self.scratch.ffn_out, // reuse as temp for grad_K^T [H, hd, seq]
1801            1,
1802            nh,
1803            hd,
1804            seq,
1805            seq,
1806            stream,
1807        )?;
1808
1809        // Transpose grad_K^T → grad_K: [H, hd, seq] → [H, seq, hd]
1810        batched_transpose_forward(
1811            &self.scratch.ffn_out,
1812            &mut self.scratch.attn_kv_temp2, // grad_K [H, seq, hd]
1813            nh,
1814            hd,
1815            seq,
1816            stream,
1817        )?;
1818
1819        // === Step 4.7: GQA gradient reduction ===
1820        // grad_K and grad_V are in [num_heads, seq, hd], need to reduce to [num_kv_heads, seq, hd]
1821        if heads_per_kv > 1 {
1822            self.reduce_gqa_gradients(num_kv_heads, heads_per_kv, seq_len, head_dim, stream)?;
1823        }
1824
1825        // === Step 4.8: Convert gradients back to interleaved layout ===
1826        // grad_Q: attn_q_batched [H, seq, hd] → o_proj_out [seq, hidden] (interleaved)
1827        batched_to_interleaved_forward(
1828            &self.scratch.attn_q_batched,
1829            &mut self.scratch.o_proj_out,
1830            seq,
1831            nh,
1832            hd,
1833            stream,
1834        )?;
1835
1836        // grad_K: attn_kv_temp2 [nkv, seq, hd] → norm2_out [seq, kv_hidden] (interleaved)
1837        batched_to_interleaved_forward(
1838            &self.scratch.attn_kv_temp2,
1839            &mut self.scratch.norm2_out,
1840            seq,
1841            nkv,
1842            hd,
1843            stream,
1844        )?;
1845
1846        // grad_V: attn_kv_temp [nkv, seq, hd] → ffn_out [seq, kv_hidden] (interleaved)
1847        batched_to_interleaved_forward(
1848            &self.scratch.attn_kv_temp,
1849            &mut self.scratch.ffn_out,
1850            seq,
1851            nkv,
1852            hd,
1853            stream,
1854        )?;
1855
1856        // === Step 4.8b: RoPE backward (inverse rotation) ===
1857        // Forward applied RoPE to Q and K before attention. Backward must undo
1858        // the rotation so projection backward (step 4.9) gets unrotated gradients.
1859        // R^T(-θ): new_x0 = x0*cos + x1*sin, new_x1 = x1*cos - x0*sin
1860        let rope_theta = self.config.rope_theta;
1861        {
1862            // grad_Q in o_proj_out [seq, q_dim] — apply inverse rotation in-place
1863            // SAFETY: reborrows `self.scratch.o_proj_out` as `&` while the same buffer is also passed `&mut` to the in-place GPU kernel below. Sound because the CUDA kernel reads every input element before writing any output element, so the read and write views never alias a live access.
1864            let q_ref = unsafe { &*(std::ptr::addr_of!(self.scratch.o_proj_out)) };
1865            batched_rope_neox_backward(
1866                q_ref,
1867                &mut self.scratch.o_proj_out,
1868                &self.scratch.rope_positions,
1869                nh,
1870                hd,
1871                seq,
1872                rope_theta,
1873                stream,
1874            )?;
1875            // grad_K in norm2_out [seq, kv_hidden] — apply inverse rotation in-place
1876            // SAFETY: reborrows `self.scratch.norm2_out` as `&` while the same buffer is also passed `&mut` to the in-place GPU kernel below. Sound because the CUDA kernel reads every input element before writing any output element, so the read and write views never alias a live access.
1877            let k_ref = unsafe { &*(std::ptr::addr_of!(self.scratch.norm2_out)) };
1878            batched_rope_neox_backward(
1879                k_ref,
1880                &mut self.scratch.norm2_out,
1881                &self.scratch.rope_positions,
1882                nkv,
1883                hd,
1884                seq,
1885                rope_theta,
1886                stream,
1887            )?;
1888        }
1889
1890        // === Step 4.9: Q/K/V projection backward ===
1891        // Forward: q[seq,q_dim] = norm1[seq,hidden] @ w_q[hidden,q_dim]
1892        //   m=seq, k=hidden, n=q_dim
1893        // grad_norm1[seq,hidden] = grad_q[seq,q_dim] @ w_q^T[q_dim,hidden]
1894        gemm_backward_a(
1895            &self.scratch.o_proj_out, // grad_q interleaved [seq, q_dim]
1896            &self.w_q,
1897            &mut self.scratch.grad_hidden,
1898            seq,
1899            saturating_u32(hidden_size),
1900            saturating_u32(q_dim),
1901            stream,
1902        )?;
1903
1904        // grad_norm1 += grad_k @ w_k^T
1905        // Forward: k[seq,kv_hidden] = norm1[seq,hidden] @ w_k[hidden,kv_hidden]
1906        //   m=seq, k=hidden, n=kv_hidden
1907        // KAIZEN-057: cuda_add_inplace replaces residual_add_forward + D2D copy
1908        gemm_backward_a(
1909            &self.scratch.norm2_out, // grad_k interleaved
1910            &self.w_k,
1911            &mut self.scratch.grad_attn_scores, // temp for grad_k @ w_k^T
1912            seq,
1913            saturating_u32(hidden_size),
1914            saturating_u32(kv_hidden_size),
1915            stream,
1916        )?;
1917        cuda_add_inplace(
1918            &mut self.scratch.grad_hidden,
1919            &self.scratch.grad_attn_scores,
1920            seq_len * hidden_size,
1921            stream,
1922        )?;
1923
1924        // grad_norm1 += grad_v @ w_v^T
1925        // Forward: v[seq,kv_hidden] = norm1[seq,hidden] @ w_v[hidden,kv_hidden]
1926        //   m=seq, k=hidden, n=kv_hidden
1927        gemm_backward_a(
1928            &self.scratch.ffn_out, // grad_v interleaved
1929            &self.w_v,
1930            &mut self.scratch.grad_attn_scores, // temp for grad_v @ w_v^T
1931            seq,
1932            saturating_u32(hidden_size),
1933            saturating_u32(kv_hidden_size),
1934            stream,
1935        )?;
1936        cuda_add_inplace(
1937            &mut self.scratch.grad_hidden,
1938            &self.scratch.grad_attn_scores,
1939            seq_len * hidden_size,
1940            stream,
1941        )?;
1942
1943        // Weight gradients: grad_w_q[hidden,q_dim] = norm1_out^T[hidden,seq] @ grad_q[seq,q_dim]
1944        gemm_backward_b(
1945            &self.scratch.norm1_out,
1946            &self.scratch.o_proj_out, // grad_q [seq, q_dim]
1947            &mut grad_ws.grad_w_q,
1948            seq,
1949            saturating_u32(hidden_size),
1950            saturating_u32(q_dim),
1951            stream,
1952        )?;
1953
1954        // grad_w_k = norm1_out^T @ grad_k
1955        gemm_backward_b(
1956            &self.scratch.norm1_out,
1957            &self.scratch.norm2_out, // grad_k
1958            &mut grad_ws.grad_w_k,
1959            seq,
1960            saturating_u32(hidden_size),
1961            saturating_u32(kv_hidden_size),
1962            stream,
1963        )?;
1964
1965        // grad_w_v = norm1_out^T @ grad_v
1966        gemm_backward_b(
1967            &self.scratch.norm1_out,
1968            &self.scratch.ffn_out, // grad_v
1969            &mut grad_ws.grad_w_v,
1970            seq,
1971            saturating_u32(hidden_size),
1972            saturating_u32(kv_hidden_size),
1973            stream,
1974        )?;
1975
1976        // Copy grad_hidden → grad_input for downstream (residual backward)
1977        // SAFETY: Both buffers are valid GPU allocations with matching sizes.
1978        unsafe {
1979            grad_input.copy_from_buffer_async(&self.scratch.grad_hidden, stream).map_err(|e| {
1980                crate::autograd::cuda_tensor::CudaTensorError::TransferFailed(format!(
1981                    "Attn backward grad_hidden → grad_input D2D copy failed: {e}"
1982                ))
1983            })?;
1984        }
1985
1986        Ok(())
1987    }
1988
1989    /// Reduce GQA head gradients from [num_heads] to [num_kv_heads] by summing groups.
1990    ///
1991    /// Reads grad_K from `attn_kv_temp2` and grad_V from `attn_kv_temp` (both [H, seq, hd]).
1992    /// Writes reduced grad_K to `attn_kv_temp2` and reduced grad_V to `attn_kv_temp`
1993    /// (both [nkv, seq, hd]).
1994    ///
1995    /// Uses `grad_attn_scores`, `ffn_out`, `o_proj_out`, `grad_hidden` as scratch.
1996    fn reduce_gqa_gradients(
1997        &mut self,
1998        num_kv_heads: usize,
1999        heads_per_kv: usize,
2000        seq_len: usize,
2001        head_dim: usize,
2002        stream: &CudaStream,
2003    ) -> Result<()> {
2004        let elems_per_head = seq_len * head_dim;
2005
2006        // Reduce grad_K: attn_kv_temp2 [H] → grad_attn_scores [nkv]
2007        self.reduce_single_gqa_gradient(true, num_kv_heads, heads_per_kv, elems_per_head, stream)?;
2008
2009        // Reduce grad_V: attn_kv_temp [H] → ffn_out [nkv]
2010        self.reduce_single_gqa_gradient(false, num_kv_heads, heads_per_kv, elems_per_head, stream)?;
2011
2012        // Copy reduced results to known locations for step 4.8
2013        let kv_elems = num_kv_heads * elems_per_head;
2014        // SAFETY: Valid GPU allocations with sufficient size.
2015        unsafe {
2016            self.scratch
2017                .attn_kv_temp2
2018                .copy_from_buffer_at_async(&self.scratch.grad_attn_scores, 0, 0, kv_elems, stream)
2019                .map_err(|e| {
2020                    crate::autograd::cuda_tensor::CudaTensorError::TransferFailed(format!(
2021                        "GQA grad_K reduced final copy failed: {e}"
2022                    ))
2023                })?;
2024            self.scratch
2025                .attn_kv_temp
2026                .copy_from_buffer_at_async(&self.scratch.ffn_out, 0, 0, kv_elems, stream)
2027                .map_err(|e| {
2028                    crate::autograd::cuda_tensor::CudaTensorError::TransferFailed(format!(
2029                        "GQA grad_V reduced final copy failed: {e}"
2030                    ))
2031                })?;
2032        }
2033        Ok(())
2034    }
2035
2036    /// Reduce one gradient tensor from [num_heads] to [num_kv_heads] by summing groups.
2037    ///
2038    /// When `is_k=true`: reads from `attn_kv_temp2`, writes to `grad_attn_scores`.
2039    /// When `is_k=false`: reads from `attn_kv_temp`, writes to `ffn_out`.
2040    /// Uses `o_proj_out` and `grad_hidden` as scratch.
2041    fn reduce_single_gqa_gradient(
2042        &mut self,
2043        is_k: bool,
2044        num_kv_heads: usize,
2045        heads_per_kv: usize,
2046        elems_per_head: usize,
2047        stream: &CudaStream,
2048    ) -> Result<()> {
2049        let label = if is_k { "K" } else { "V" };
2050
2051        for kv_h in 0..num_kv_heads {
2052            let dst_offset = kv_h * elems_per_head;
2053            let first_h = kv_h * heads_per_kv;
2054            let src_offset = first_h * elems_per_head;
2055
2056            // Copy first head of group as base
2057            // SAFETY: All offsets are within buffer bounds.
2058            unsafe {
2059                let (dst, src) = if is_k {
2060                    (&mut self.scratch.grad_attn_scores, &self.scratch.attn_kv_temp2)
2061                } else {
2062                    (&mut self.scratch.ffn_out, &self.scratch.attn_kv_temp)
2063                };
2064                dst.copy_from_buffer_at_async(src, dst_offset, src_offset, elems_per_head, stream)
2065                    .map_err(|e| {
2066                        crate::autograd::cuda_tensor::CudaTensorError::TransferFailed(format!(
2067                            "GQA grad_{label} reduce base copy failed: {e}"
2068                        ))
2069                    })?;
2070            }
2071
2072            // Add remaining heads in group
2073            for rep in 1..heads_per_kv {
2074                let h = kv_h * heads_per_kv + rep;
2075                let h_offset = h * elems_per_head;
2076
2077                // Head extraction into o_proj_out buffer
2078                // SAFETY: Valid GPU allocations with sufficient size.
2079                unsafe {
2080                    let src =
2081                        if is_k { &self.scratch.attn_kv_temp2 } else { &self.scratch.attn_kv_temp };
2082                    self.scratch
2083                        .o_proj_out
2084                        .copy_from_buffer_at_async(src, 0, h_offset, elems_per_head, stream)
2085                        .map_err(|e| {
2086                            crate::autograd::cuda_tensor::CudaTensorError::TransferFailed(format!(
2087                                "GQA grad_{label} reduce head copy failed: {e}"
2088                            ))
2089                        })?;
2090                }
2091
2092                // Add: dst[dst_offset..] += o_proj_out[0..elems_per_head]
2093                // SAFETY: Creating non-owning views for arithmetic; forgotten to prevent double-free.
2094                unsafe {
2095                    let dst_buf =
2096                        if is_k { &self.scratch.grad_attn_scores } else { &self.scratch.ffn_out };
2097                    let dst_view = GpuBuffer::<f32>::from_raw_parts(
2098                        dst_buf.as_ptr() + (dst_offset as u64 * 4),
2099                        elems_per_head,
2100                    );
2101                    let src_view = GpuBuffer::<f32>::from_raw_parts(
2102                        self.scratch.o_proj_out.as_ptr(),
2103                        elems_per_head,
2104                    );
2105                    let mut sum_view = GpuBuffer::<f32>::from_raw_parts(
2106                        self.scratch.grad_hidden.as_ptr(),
2107                        elems_per_head,
2108                    );
2109                    residual_add_forward(
2110                        &dst_view,
2111                        &src_view,
2112                        &mut sum_view,
2113                        saturating_u32(elems_per_head),
2114                        stream,
2115                    )?;
2116                    // Copy sum back to dst at dst_offset
2117                    let dst_buf = if is_k {
2118                        &mut self.scratch.grad_attn_scores
2119                    } else {
2120                        &mut self.scratch.ffn_out
2121                    };
2122                    dst_buf
2123                        .copy_from_buffer_at_async(
2124                            &self.scratch.grad_hidden,
2125                            dst_offset,
2126                            0,
2127                            elems_per_head,
2128                            stream,
2129                        )
2130                        .map_err(|e| {
2131                            crate::autograd::cuda_tensor::CudaTensorError::TransferFailed(format!(
2132                                "GQA grad_{label} reduce sum copy failed: {e}"
2133                            ))
2134                        })?;
2135                    leak(dst_view);
2136                    leak(src_view);
2137                    leak(sum_view);
2138                }
2139            }
2140        }
2141        Ok(())
2142    }
2143
2144    /// Backward through first residual connection and input RMSNorm.
2145    fn backward_residual_and_input_norm(
2146        &mut self,
2147        input: &GpuBuffer<f32>,
2148        grad_output: &GpuBuffer<f32>,
2149        grad_input: &mut GpuBuffer<f32>,
2150        seq_len: usize,
2151        hidden_size: usize,
2152        eps: f32,
2153        stream: &CudaStream,
2154        grad_ws: &mut CudaGradWorkspace,
2155    ) -> Result<()> {
2156        // Forward was: norm_out = RMSNorm(input); attn_out = Attn(norm_out); residual1 = input + attn_out
2157        // Backward: grad_input = RMSNorm_backward(grad_through_attention) + grad_output
2158        //
2159        // C-RESIDUAL-001 / entrenar#313: The residual skip (grad_output) must be added
2160        // AFTER RMSNorm backward, not before. RMSNorm backward should only transform
2161        // the gradient that flows through the norm/attention path. The identity skip
2162        // bypasses the norm entirely.
2163
2164        // D2D copy grad_input (attention path gradient) to grad_hidden
2165        // SAFETY: Both buffers are valid GPU allocations with matching sizes.
2166        unsafe {
2167            self.scratch.grad_hidden.copy_from_buffer_async(grad_input, stream).map_err(|e| {
2168                crate::autograd::cuda_tensor::CudaTensorError::TransferFailed(format!(
2169                    "Backward residual grad_hidden D2D copy failed: {e}"
2170                ))
2171            })?;
2172        }
2173
2174        // RMSNorm backward: only applied to the attention path gradient
2175        rms_norm_backward(
2176            input,
2177            &self.input_norm_weight,
2178            &self.scratch.grad_hidden,
2179            grad_input,
2180            &mut grad_ws.grad_input_norm,
2181            saturating_u32(seq_len),
2182            saturating_u32(hidden_size),
2183            eps,
2184            stream,
2185        )?;
2186
2187        // NOW add the residual skip: grad_input += grad_output (identity connection)
2188        cuda_add_inplace(grad_input, grad_output, seq_len * hidden_size, stream)
2189    }
2190
2191    /// Initialize GPU-resident AdamW optimizer state for all block weights.
2192    ///
2193    /// Allocates zero-initialized first and second moment buffers for each of the
2194    /// 9 weight tensors (4 attention projections + 3 FFN projections + 2 RMSNorm).
2195    ///
2196    /// # Contract (C-OPTINIT-001)
2197    ///
2198    /// - **Precondition**: CUDA context is valid, sufficient GPU memory available
2199    /// - **Postcondition**: All m/v buffers are zero-initialized with dimensions
2200    ///   matching the corresponding weight tensors
2201    /// - **Invariant**: Total GPU memory for optimizer state = 2 × sum(weight_sizes) × 4 bytes
2202    pub fn init_optimizer_state(&self) -> Result<GpuBlockOptimizerState> {
2203        let hidden = self.config.hidden_size;
2204        let q_dim = self.config.q_dim();
2205        let kv_hidden = self.config.num_kv_heads * self.config.head_dim();
2206        let intermediate = self.config.intermediate_size;
2207
2208        // CRITICAL: Must zero-initialize m/v buffers. GpuBuffer::new() does NOT
2209        // zero memory (cuMemAlloc returns uninitialized VRAM). Uninitialized m/v
2210        // causes v_new = beta2 * GARBAGE which can be negative → sqrt(neg) → NaN.
2211        let z = |n: usize| -> Result<GpuBuffer<f32>> {
2212            Ok(GpuBuffer::from_host(&self.ctx, &vec![0.0f32; n])?)
2213        };
2214        Ok(GpuBlockOptimizerState {
2215            m_w_q: z(q_dim * hidden)?,
2216            v_w_q: z(q_dim * hidden)?,
2217            m_w_k: z(hidden * kv_hidden)?,
2218            v_w_k: z(hidden * kv_hidden)?,
2219            m_w_v: z(hidden * kv_hidden)?,
2220            v_w_v: z(hidden * kv_hidden)?,
2221            m_w_o: z(hidden * q_dim)?,
2222            v_w_o: z(hidden * q_dim)?,
2223            m_w_gate: z(hidden * intermediate)?,
2224            v_w_gate: z(hidden * intermediate)?,
2225            m_w_up: z(hidden * intermediate)?,
2226            v_w_up: z(hidden * intermediate)?,
2227            m_w_down: z(intermediate * hidden)?,
2228            v_w_down: z(intermediate * hidden)?,
2229            m_input_norm: z(hidden)?,
2230            v_input_norm: z(hidden)?,
2231            m_post_attn_norm: z(hidden)?,
2232            v_post_attn_norm: z(hidden)?,
2233        })
2234    }
2235
2236    /// Run GPU-resident AdamW optimizer step on all block weights.
2237    ///
2238    /// Updates weights in-place using gradients computed by `backward()`.
2239    /// All operations run on GPU — zero CPU↔GPU data transfers.
2240    ///
2241    /// # Contract (C-OPTSTEP-001)
2242    ///
2243    /// - **Precondition**: `backward()` completed for this block (scratch grad buffers valid),
2244    ///   `state` initialized via `init_optimizer_state()`, `step > 0`
2245    /// - **Postcondition**: All 9 weight tensors updated by AdamW rule,
2246    ///   m/v states updated with current gradient statistics
2247    /// - **Invariant**: Weight dimensions unchanged; no GPU memory allocated or freed
2248    pub fn optimizer_step(
2249        &mut self,
2250        state: &mut GpuBlockOptimizerState,
2251        step: u32,
2252        lr: f32,
2253        beta1: f32,
2254        beta2: f32,
2255        eps: f32,
2256        weight_decay: f32,
2257        stream: &CudaStream,
2258        grad_ws: &CudaGradWorkspace,
2259    ) -> Result<()> {
2260        debug_assert!(step > 0, "C-OPTSTEP-001: step must be > 0 for bias adjust");
2261
2262        // Pre-capture lengths to avoid borrow conflicts (len is immutable borrow,
2263        // adamw_step_cuda takes mutable borrow on same buffer)
2264        let n_wq = self.w_q.len() as u32;
2265        let n_wk = self.w_k.len() as u32;
2266        let n_wv = self.w_v.len() as u32;
2267        let n_wo = self.w_o.len() as u32;
2268        let n_gate = self.w_gate.len() as u32;
2269        let n_up = self.w_up.len() as u32;
2270        let n_down = self.w_down.len() as u32;
2271        let n_inorm = self.input_norm_weight.len() as u32;
2272        let n_panorm = self.post_attn_norm_weight.len() as u32;
2273
2274        // Attention projection weights
2275        adamw_step_cuda(
2276            &mut self.w_q,
2277            &grad_ws.grad_w_q,
2278            &mut state.m_w_q,
2279            &mut state.v_w_q,
2280            lr,
2281            beta1,
2282            beta2,
2283            eps,
2284            weight_decay,
2285            step,
2286            n_wq,
2287            stream,
2288        )?;
2289        adamw_step_cuda(
2290            &mut self.w_k,
2291            &grad_ws.grad_w_k,
2292            &mut state.m_w_k,
2293            &mut state.v_w_k,
2294            lr,
2295            beta1,
2296            beta2,
2297            eps,
2298            weight_decay,
2299            step,
2300            n_wk,
2301            stream,
2302        )?;
2303        adamw_step_cuda(
2304            &mut self.w_v,
2305            &grad_ws.grad_w_v,
2306            &mut state.m_w_v,
2307            &mut state.v_w_v,
2308            lr,
2309            beta1,
2310            beta2,
2311            eps,
2312            weight_decay,
2313            step,
2314            n_wv,
2315            stream,
2316        )?;
2317        adamw_step_cuda(
2318            &mut self.w_o,
2319            &grad_ws.grad_w_o,
2320            &mut state.m_w_o,
2321            &mut state.v_w_o,
2322            lr,
2323            beta1,
2324            beta2,
2325            eps,
2326            weight_decay,
2327            step,
2328            n_wo,
2329            stream,
2330        )?;
2331
2332        // FFN projection weights
2333        adamw_step_cuda(
2334            &mut self.w_gate,
2335            &grad_ws.grad_gate,
2336            &mut state.m_w_gate,
2337            &mut state.v_w_gate,
2338            lr,
2339            beta1,
2340            beta2,
2341            eps,
2342            weight_decay,
2343            step,
2344            n_gate,
2345            stream,
2346        )?;
2347        adamw_step_cuda(
2348            &mut self.w_up,
2349            &grad_ws.grad_up,
2350            &mut state.m_w_up,
2351            &mut state.v_w_up,
2352            lr,
2353            beta1,
2354            beta2,
2355            eps,
2356            weight_decay,
2357            step,
2358            n_up,
2359            stream,
2360        )?;
2361        adamw_step_cuda(
2362            &mut self.w_down,
2363            &grad_ws.grad_down,
2364            &mut state.m_w_down,
2365            &mut state.v_w_down,
2366            lr,
2367            beta1,
2368            beta2,
2369            eps,
2370            weight_decay,
2371            step,
2372            n_down,
2373            stream,
2374        )?;
2375
2376        // RMSNorm weights
2377        adamw_step_cuda(
2378            &mut self.input_norm_weight,
2379            &grad_ws.grad_input_norm,
2380            &mut state.m_input_norm,
2381            &mut state.v_input_norm,
2382            lr,
2383            beta1,
2384            beta2,
2385            eps,
2386            weight_decay,
2387            step,
2388            n_inorm,
2389            stream,
2390        )?;
2391        adamw_step_cuda(
2392            &mut self.post_attn_norm_weight,
2393            &grad_ws.grad_post_attn_norm,
2394            &mut state.m_post_attn_norm,
2395            &mut state.v_post_attn_norm,
2396            lr,
2397            beta1,
2398            beta2,
2399            eps,
2400            weight_decay,
2401            step,
2402            n_panorm,
2403            stream,
2404        )?;
2405
2406        Ok(())
2407    }
2408
2409    /// Download all weight data from GPU to host vectors.
2410    ///
2411    /// Used to synchronize GPU-updated weights back to CPU model for checkpointing.
2412    ///
2413    /// # Contract (C-DLWEIGHTS-001)
2414    ///
2415    /// - **Precondition**: Block weights are valid GPU allocations
2416    /// - **Postcondition**: Returned vectors have exact same length and content as GPU buffers
2417    /// - **Invariant**: GPU buffers are not modified
2418    pub fn download_weights(&self) -> Result<BlockWeights> {
2419        let download = |buf: &GpuBuffer<f32>| -> Result<Vec<f32>> {
2420            let mut host = vec![0.0f32; buf.len()];
2421            buf.copy_to_host(&mut host).map_err(|e| {
2422                crate::autograd::cuda_tensor::CudaTensorError::TransferFailed(format!(
2423                    "Weight download failed: {e}"
2424                ))
2425            })?;
2426            Ok(host)
2427        };
2428
2429        Ok(BlockWeights {
2430            w_q: download(&self.w_q)?,
2431            w_k: download(&self.w_k)?,
2432            w_v: download(&self.w_v)?,
2433            w_o: download(&self.w_o)?,
2434            w_gate: download(&self.w_gate)?,
2435            w_up: download(&self.w_up)?,
2436            w_down: download(&self.w_down)?,
2437            input_norm_weight: download(&self.input_norm_weight)?,
2438            post_attn_norm_weight: download(&self.post_attn_norm_weight)?,
2439        })
2440    }
2441}
2442
2443/// Downloaded weight data from a CUDA transformer block.
2444///
2445/// # Contract (C-BLOCKWT-001)
2446///
2447/// - **Invariant**: Vector lengths match original weight dimensions
2448#[cfg(feature = "cuda")]
2449pub struct BlockWeights {
2450    pub w_q: Vec<f32>,
2451    pub w_k: Vec<f32>,
2452    pub w_v: Vec<f32>,
2453    pub w_o: Vec<f32>,
2454    pub w_gate: Vec<f32>,
2455    pub w_up: Vec<f32>,
2456    pub w_down: Vec<f32>,
2457    pub input_norm_weight: Vec<f32>,
2458    pub post_attn_norm_weight: Vec<f32>,
2459}
2460
2461/// CUDA element-wise addition on GPU (zero CPU transfers)
2462///
2463/// Uses `ResidualAddKernel` — single kernel launch, no D2H/H2D transfers.
2464#[cfg(feature = "cuda")]
2465fn cuda_add(
2466    a: &GpuBuffer<f32>,
2467    b: &GpuBuffer<f32>,
2468    output: &mut GpuBuffer<f32>,
2469    n: usize,
2470    stream: &CudaStream,
2471) -> Result<()> {
2472    residual_add_forward(a, b, output, saturating_u32(n), stream)
2473}
2474
2475/// In-place add: `target += source` using residual add with aliased output.
2476///
2477/// # Safety
2478///
2479/// The ResidualAdd kernel reads `a[i]` and `b[i]` then writes `output[i] = a[i] + b[i]`.
2480/// When `a` and `output` alias the same GPU buffer, each element is read before written
2481/// (no inter-element dependency), so this is safe for elementwise operations.
2482#[cfg(feature = "cuda")]
2483pub(crate) fn cuda_add_inplace(
2484    target: &mut GpuBuffer<f32>,
2485    source: &GpuBuffer<f32>,
2486    n: usize,
2487    stream: &CudaStream,
2488) -> Result<()> {
2489    // SAFETY: ResidualAdd kernel is elementwise (output[i] = a[i] + b[i]).
2490    // Aliasing target as both input and output is safe because each element is
2491    // independent — the GPU reads a[i] before writing output[i] at the same address.
2492    let target_ref: &GpuBuffer<f32> = unsafe { &*std::ptr::from_ref::<GpuBuffer<f32>>(target) };
2493    residual_add_forward(target_ref, source, target, saturating_u32(n), stream)
2494}
2495
2496/// CUDA element-wise multiplication on GPU (zero CPU transfers)
2497///
2498/// Uses `ElementwiseMulKernel` — single kernel launch, no D2H/H2D transfers.
2499#[cfg(feature = "cuda")]
2500fn cuda_mul(
2501    a: &GpuBuffer<f32>,
2502    b: &GpuBuffer<f32>,
2503    output: &mut GpuBuffer<f32>,
2504    n: usize,
2505    stream: &CudaStream,
2506) -> Result<()> {
2507    crate::autograd::cuda_forward::elementwise_mul_forward(a, b, output, saturating_u32(n), stream)
2508}
2509
2510// CPU fallback stub
2511#[cfg(not(feature = "cuda"))]
2512pub struct CudaTransformerBlock;
2513
2514#[cfg(not(feature = "cuda"))]
2515impl CudaTransformerBlock {
2516    pub fn layer_idx(&self) -> usize {
2517        0
2518    }
2519}
2520
2521// =============================================================================
2522// CudaBlock — enum dispatching fp32 or NF4 transformer blocks
2523// =============================================================================
2524
2525/// Unified enum for CUDA transformer blocks (fp32 or NF4-quantized).
2526///
2527/// The classify pipeline stores `Vec<CudaBlock>` and calls `forward()` without
2528/// caring which quantization format the frozen weights use.
2529#[cfg(feature = "cuda")]
2530pub enum CudaBlock {
2531    /// Standard fp32 weights (full precision, ~16 GB for Qwen3-4B)
2532    Fp32(CudaTransformerBlock),
2533    /// NF4 quantized weights (~2 GB for Qwen3-4B, ~8x compression)
2534    Nf4(CudaNf4TransformerBlock),
2535}
2536
2537#[cfg(feature = "cuda")]
2538impl CudaBlock {
2539    /// Forward pass through the transformer block.
2540    ///
2541    /// For NF4 blocks, `shared_scratch` must be `Some` — shared across all layers (C-SCRATCH-001).
2542    /// For fp32 blocks, `shared_scratch` is ignored (each block owns its scratch for backward).
2543    pub(crate) fn forward(
2544        &mut self,
2545        input: &GpuBuffer<f32>,
2546        output: &mut GpuBuffer<f32>,
2547        seq_len: usize,
2548        stream: &CudaStream,
2549        shared_scratch: Option<&mut CudaBlockScratch>,
2550    ) -> Result<()> {
2551        match self {
2552            CudaBlock::Fp32(b) => b.forward(input, output, seq_len, stream),
2553            CudaBlock::Nf4(b) => {
2554                let scratch =
2555                    shared_scratch.expect("C-SCRATCH-001: NF4 blocks require shared scratch");
2556                b.forward(input, output, seq_len, stream, scratch)
2557            }
2558        }
2559    }
2560
2561    /// Get the layer index of this block.
2562    pub fn layer_idx(&self) -> usize {
2563        match self {
2564            CudaBlock::Fp32(b) => b.layer_idx(),
2565            CudaBlock::Nf4(b) => b.layer_idx,
2566        }
2567    }
2568
2569    /// Backward pass (only supported for fp32 blocks).
2570    ///
2571    /// NF4 blocks are frozen — backward is never called when `quantize_nf4` is active
2572    /// because `gpu_training` is set to `None`.
2573    pub fn backward(
2574        &mut self,
2575        input: &GpuBuffer<f32>,
2576        grad_output: &GpuBuffer<f32>,
2577        grad_input: &mut GpuBuffer<f32>,
2578        seq_len: usize,
2579        stream: &CudaStream,
2580        grad_ws: &mut CudaGradWorkspace,
2581    ) -> Result<()> {
2582        match self {
2583            CudaBlock::Fp32(b) => {
2584                b.backward(input, grad_output, grad_input, seq_len, stream, grad_ws)
2585            }
2586            CudaBlock::Nf4(_) => Err(crate::autograd::cuda_tensor::CudaTensorError::KernelError(
2587                "backward not supported on NF4 blocks (frozen weights)".into(),
2588            )),
2589        }
2590    }
2591
2592    /// Initialize optimizer state (only supported for fp32 blocks).
2593    pub fn init_optimizer_state(&self) -> Result<GpuBlockOptimizerState> {
2594        match self {
2595            CudaBlock::Fp32(b) => b.init_optimizer_state(),
2596            CudaBlock::Nf4(_) => Err(crate::autograd::cuda_tensor::CudaTensorError::KernelError(
2597                "init_optimizer_state not supported on NF4 blocks".into(),
2598            )),
2599        }
2600    }
2601
2602    /// Download weights from GPU (only supported for fp32 blocks).
2603    pub fn download_weights(&self) -> Result<BlockWeights> {
2604        match self {
2605            CudaBlock::Fp32(b) => b.download_weights(),
2606            CudaBlock::Nf4(_) => Err(crate::autograd::cuda_tensor::CudaTensorError::KernelError(
2607                "download_weights not supported on NF4 blocks".into(),
2608            )),
2609        }
2610    }
2611
2612    /// Optimizer step (only supported for fp32 blocks).
2613    pub fn optimizer_step(
2614        &mut self,
2615        state: &mut GpuBlockOptimizerState,
2616        step: u32,
2617        lr: f32,
2618        beta1: f32,
2619        beta2: f32,
2620        eps: f32,
2621        weight_decay: f32,
2622        stream: &CudaStream,
2623        grad_ws: &CudaGradWorkspace,
2624    ) -> Result<()> {
2625        match self {
2626            CudaBlock::Fp32(b) => {
2627                b.optimizer_step(state, step, lr, beta1, beta2, eps, weight_decay, stream, grad_ws)
2628            }
2629            CudaBlock::Nf4(_) => Err(crate::autograd::cuda_tensor::CudaTensorError::KernelError(
2630                "optimizer_step not supported on NF4 blocks (frozen weights)".into(),
2631            )),
2632        }
2633    }
2634
2635    /// NF4 backward pass with LoRA gradient computation (ENT-153).
2636    ///
2637    /// Only callable on NF4 blocks. Returns error for fp32 blocks.
2638    #[allow(clippy::too_many_arguments)]
2639    pub(crate) fn backward_nf4(
2640        &self,
2641        layer_input: &GpuBuffer<f32>,
2642        grad_output: &GpuBuffer<f32>,
2643        grad_input: &mut GpuBuffer<f32>,
2644        output_scratch: &mut GpuBuffer<f32>,
2645        seq_len: usize,
2646        stream: &CudaStream,
2647        shared_scratch: &mut CudaBlockScratch,
2648        grad_lora: &mut CudaLoraGradWorkspace,
2649    ) -> Result<()> {
2650        match self {
2651            CudaBlock::Nf4(b) => b.backward(
2652                layer_input,
2653                grad_output,
2654                grad_input,
2655                output_scratch,
2656                seq_len,
2657                stream,
2658                shared_scratch,
2659                grad_lora,
2660            ),
2661            CudaBlock::Fp32(_) => Err(crate::autograd::cuda_tensor::CudaTensorError::KernelError(
2662                "backward_nf4 only supported on NF4 blocks".into(),
2663            )),
2664        }
2665    }
2666
2667    /// Initialize LoRA optimizer state for NF4 blocks.
2668    pub(crate) fn init_lora_optimizer_state(&self) -> Result<GpuLoraOptimizerState> {
2669        match self {
2670            CudaBlock::Nf4(b) => b.init_lora_optimizer_state(),
2671            CudaBlock::Fp32(_) => Err(crate::autograd::cuda_tensor::CudaTensorError::KernelError(
2672                "init_lora_optimizer_state only supported on NF4 blocks".into(),
2673            )),
2674        }
2675    }
2676
2677    /// LoRA optimizer step for NF4 blocks.
2678    #[allow(clippy::too_many_arguments)]
2679    pub(crate) fn lora_optimizer_step(
2680        &mut self,
2681        state: &mut GpuLoraOptimizerState,
2682        step: u32,
2683        lr: f32,
2684        beta1: f32,
2685        beta2: f32,
2686        eps: f32,
2687        weight_decay: f32,
2688        stream: &CudaStream,
2689        grad_lora: &CudaLoraGradWorkspace,
2690    ) -> Result<()> {
2691        match self {
2692            CudaBlock::Nf4(b) => b.lora_optimizer_step(
2693                state,
2694                step,
2695                lr,
2696                beta1,
2697                beta2,
2698                eps,
2699                weight_decay,
2700                stream,
2701                grad_lora,
2702            ),
2703            CudaBlock::Fp32(_) => Err(crate::autograd::cuda_tensor::CudaTensorError::KernelError(
2704                "lora_optimizer_step only supported on NF4 blocks".into(),
2705            )),
2706        }
2707    }
2708
2709    /// Download LoRA weights from NF4 blocks.
2710    pub fn download_lora_weights(&self) -> Result<(Vec<f32>, Vec<f32>, Vec<f32>, Vec<f32>)> {
2711        match self {
2712            CudaBlock::Nf4(b) => b.download_lora_weights(),
2713            CudaBlock::Fp32(_) => Err(crate::autograd::cuda_tensor::CudaTensorError::KernelError(
2714                "download_lora_weights only supported on NF4 blocks".into(),
2715            )),
2716        }
2717    }
2718
2719    /// Upload LoRA weights to NF4 blocks for checkpoint resume (ENT-276).
2720    pub fn upload_lora_weights(
2721        &mut self,
2722        a_q: &[f32],
2723        b_q: &[f32],
2724        a_v: &[f32],
2725        b_v: &[f32],
2726    ) -> Result<()> {
2727        match self {
2728            CudaBlock::Nf4(b) => b.upload_lora_weights(a_q, b_q, a_v, b_v),
2729            CudaBlock::Fp32(_) => Err(crate::autograd::cuda_tensor::CudaTensorError::KernelError(
2730                "upload_lora_weights only supported on NF4 blocks".into(),
2731            )),
2732        }
2733    }
2734}
2735
2736/// CPU fallback stub for CudaBlock.
2737#[cfg(not(feature = "cuda"))]
2738pub enum CudaBlock {
2739    Fp32(CudaTransformerBlock),
2740}
2741
2742// =============================================================================
2743// NF4 Quantized Transformer Block (trueno#108: QLoRA support)
2744// =============================================================================
2745
2746/// CUDA-accelerated transformer block with NF4-quantized frozen weights.
2747///
2748/// Stores the 7 projection weights as packed NF4 (4-bit) + per-block scales instead
2749/// of fp32, achieving ~8x compression. Norm weights remain fp32 (negligible size).
2750///
2751/// # VRAM Savings (Qwen3-4B example)
2752///
2753/// | Component | fp32 | NF4 |
2754/// |-----------|------|-----|
2755/// | Frozen weights (36L × 7 projections) | 16.0 GB | 2.1 GB |
2756///
2757/// # Forward Only
2758///
2759/// NF4 blocks are frozen — no backward pass needed. LoRA adapters (fp32) handle
2760/// the trainable parameters separately. The forward pass uses fused dequant+GEMM
2761/// kernels that read NF4 directly without materializing fp32 weights.
2762#[cfg(feature = "cuda")]
2763pub struct CudaNf4TransformerBlock {
2764    config: TransformerConfig,
2765    layer_idx: usize,
2766    // Norm weights stay fp32 (tiny: 2 × hidden_size floats)
2767    input_norm_weight: GpuBuffer<f32>,
2768    post_attn_norm_weight: GpuBuffer<f32>,
2769    // Projection weights: NF4 quantized (packed data + per-block scales)
2770    w_q_nf4: GpuBuffer<u8>,
2771    w_q_scales: GpuBuffer<f32>,
2772    w_k_nf4: GpuBuffer<u8>,
2773    w_k_scales: GpuBuffer<f32>,
2774    w_v_nf4: GpuBuffer<u8>,
2775    w_v_scales: GpuBuffer<f32>,
2776    w_o_nf4: GpuBuffer<u8>,
2777    w_o_scales: GpuBuffer<f32>,
2778    w_gate_nf4: GpuBuffer<u8>,
2779    w_gate_scales: GpuBuffer<f32>,
2780    w_up_nf4: GpuBuffer<u8>,
2781    w_up_scales: GpuBuffer<f32>,
2782    w_down_nf4: GpuBuffer<u8>,
2783    w_down_scales: GpuBuffer<f32>,
2784    // ENT-287: Pre-dequantized fp32 weights for cuBLAS GEMM (correct weight layout)
2785    w_q_fp32: GpuBuffer<f32>,
2786    w_k_fp32: GpuBuffer<f32>,
2787    w_v_fp32: GpuBuffer<f32>,
2788    w_o_fp32: GpuBuffer<f32>,
2789    w_gate_fp32: GpuBuffer<f32>,
2790    w_up_fp32: GpuBuffer<f32>,
2791    w_down_fp32: GpuBuffer<f32>,
2792    // LoRA adapters for Q and V projections (ENT-153: QLoRA backward)
2793    // None when LoRA is not active (inference-only or non-QLoRA training)
2794    lora_a_q: Option<GpuBuffer<f32>>, // [hidden_size, rank]
2795    lora_b_q: Option<GpuBuffer<f32>>, // [rank, q_dim]
2796    lora_a_v: Option<GpuBuffer<f32>>, // [hidden_size, rank]
2797    lora_b_v: Option<GpuBuffer<f32>>, // [rank, kv_hidden]
2798    lora_scale: f32,
2799    lora_rank: usize,
2800    // QK-norm weights (ENT-270: per-head RMSNorm on Q and K, shape=[head_dim])
2801    q_norm_weight: Option<GpuBuffer<f32>>,
2802    k_norm_weight: Option<GpuBuffer<f32>>,
2803    // FALSIFY-CUDA-NF4-TRAIN-LOSS-PARITY-001: Q/K/V projection biases
2804    // replicated across max_seq_len rows (Qwen2 family use_bias=true).
2805    // Pre-fix the NF4 QLoRA block silently DROPPED the biases the CPU model
2806    // applies (attention.rs add_bias) — every layer's attention computed a
2807    // different function than the model being fine-tuned.
2808    b_q_replicated: Option<GpuBuffer<f32>>,
2809    b_k_replicated: Option<GpuBuffer<f32>>,
2810    b_v_replicated: Option<GpuBuffer<f32>>,
2811    // FP16 weight buffers for Tier 2 parity (PMAT-470): halve memory BW
2812    // When set, forward uses gemm_f16_to_f32 (fp16 weights × fp16 activations → fp32 output)
2813    w_q_fp16: Option<GpuBuffer<u16>>,
2814    w_k_fp16: Option<GpuBuffer<u16>>,
2815    w_v_fp16: Option<GpuBuffer<u16>>,
2816    w_o_fp16: Option<GpuBuffer<u16>>,
2817    w_gate_fp16: Option<GpuBuffer<u16>>,
2818    w_up_fp16: Option<GpuBuffer<u16>>,
2819    w_down_fp16: Option<GpuBuffer<u16>>,
2820    ctx: Arc<CudaContext>,
2821    // NF4 blocks do NOT own scratch — shared across all layers (C-SCRATCH-001)
2822}
2823
2824#[cfg(feature = "cuda")]
2825impl CudaNf4TransformerBlock {
2826    /// Create a new NF4 transformer block from fp32 CPU tensors.
2827    ///
2828    /// Quantizes all 7 projection weights to NF4 on CPU, then uploads the packed
2829    /// data and scales to GPU. Norm weights are uploaded as fp32.
2830    #[allow(clippy::too_many_arguments)]
2831    pub fn new(
2832        config: &TransformerConfig,
2833        layer_idx: usize,
2834        ctx: Arc<CudaContext>,
2835        input_norm_weight: &[f32],
2836        post_attn_norm_weight: &[f32],
2837        w_q: &[f32],
2838        w_k: &[f32],
2839        w_v: &[f32],
2840        w_o: &[f32],
2841        w_gate: &[f32],
2842        w_up: &[f32],
2843        w_down: &[f32],
2844        max_seq_len: usize, // used for bias replication; scratch is shared (C-SCRATCH-001)
2845        // ENT-153: Optional LoRA adapters for Q and V projections
2846        q_lora: Option<(&[f32], &[f32])>,
2847        v_lora: Option<(&[f32], &[f32])>,
2848        lora_scale: f32,
2849        lora_rank: usize,
2850        // ENT-270: Optional QK-norm weights (per-head RMSNorm, shape=[head_dim])
2851        q_norm: Option<&[f32]>,
2852        k_norm: Option<&[f32]>,
2853        // FALSIFY-CUDA-NF4-TRAIN-LOSS-PARITY-001: Q/K/V projection biases
2854        // (Qwen2 family use_bias=true; None for LLaMA/Qwen3)
2855        b_q: Option<&[f32]>,
2856        b_k: Option<&[f32]>,
2857        b_v: Option<&[f32]>,
2858    ) -> Result<Self> {
2859        use trueno_gpu::kernels::{quantize_nf4, NF4_BLOCK_SIZE};
2860
2861        let hidden_size = config.hidden_size;
2862        let q_dim = config.q_dim(); // num_heads * head_dim (may differ from hidden_size)
2863        let kv_hidden_size = config.num_kv_heads * config.head_dim();
2864        let intermediate_size = config.intermediate_size;
2865
2866        // ── C-NF4SHAPE-001: Weight shape contracts ──────────────────────
2867        // Ground truth: PMAT-331 validation in attention.rs from_pretrained()
2868        //   Q: [q_dim, hidden], K: [kv_hidden, hidden], V: [kv_hidden, hidden], O: [hidden, q_dim]
2869        //   gate: [intermediate, hidden], up: [intermediate, hidden], down: [hidden, intermediate]
2870        assert_eq!(
2871            w_q.len(),
2872            q_dim * hidden_size,
2873            "C-NF4SHAPE-001: w_q expected {}, got {} (q_dim={q_dim}, hidden={hidden_size})",
2874            q_dim * hidden_size,
2875            w_q.len()
2876        );
2877        assert_eq!(
2878            w_k.len(),
2879            kv_hidden_size * hidden_size,
2880            "C-NF4SHAPE-001: w_k expected {}, got {}",
2881            kv_hidden_size * hidden_size,
2882            w_k.len()
2883        );
2884        assert_eq!(
2885            w_v.len(),
2886            kv_hidden_size * hidden_size,
2887            "C-NF4SHAPE-001: w_v expected {}, got {}",
2888            kv_hidden_size * hidden_size,
2889            w_v.len()
2890        );
2891        assert_eq!(
2892            w_o.len(),
2893            hidden_size * q_dim,
2894            "C-NF4SHAPE-001: w_o expected {}, got {}",
2895            hidden_size * q_dim,
2896            w_o.len()
2897        );
2898        assert_eq!(
2899            w_gate.len(),
2900            intermediate_size * hidden_size,
2901            "C-NF4SHAPE-001: w_gate expected {}, got {}",
2902            intermediate_size * hidden_size,
2903            w_gate.len()
2904        );
2905        assert_eq!(
2906            w_up.len(),
2907            intermediate_size * hidden_size,
2908            "C-NF4SHAPE-001: w_up expected {}, got {}",
2909            intermediate_size * hidden_size,
2910            w_up.len()
2911        );
2912        assert_eq!(
2913            w_down.len(),
2914            hidden_size * intermediate_size,
2915            "C-NF4SHAPE-001: w_down expected {}, got {}",
2916            hidden_size * intermediate_size,
2917            w_down.len()
2918        );
2919
2920        // Upload norm weights as fp32
2921        let input_norm_weight = GpuBuffer::from_host(&ctx, input_norm_weight)?;
2922        let post_attn_norm_weight = GpuBuffer::from_host(&ctx, post_attn_norm_weight)?;
2923
2924        // Helper: quantize fp32 weight to NF4, upload packed data + scales to GPU
2925        // Returns (gpu_nf4, gpu_scales, cpu_quantized) — the CPU struct is retained
2926        // for dequantization into the cuBLAS fp32 buffer.
2927        let quantize_and_upload = |weights: &[f32],
2928                                   total: usize|
2929         -> Result<(
2930            GpuBuffer<u8>,
2931            GpuBuffer<f32>,
2932            trueno_gpu::kernels::Nf4Quantized,
2933        )> {
2934            assert_eq!(weights.len(), total, "weight length mismatch");
2935            assert!(
2936                total.is_multiple_of(NF4_BLOCK_SIZE),
2937                "weight count {total} not divisible by NF4 block size {NF4_BLOCK_SIZE}"
2938            );
2939
2940            let q = quantize_nf4(weights, total / NF4_BLOCK_SIZE, NF4_BLOCK_SIZE);
2941            let nf4_buf = GpuBuffer::from_host(&ctx, &q.data)?;
2942            let scales_buf = GpuBuffer::from_host(&ctx, &q.scales)?;
2943            Ok((nf4_buf, scales_buf, q))
2944        };
2945
2946        // Quantize all 7 projection weights (shape contracts already verified above)
2947        let (w_q_nf4, w_q_scales, w_q_nf4_q) = quantize_and_upload(w_q, q_dim * hidden_size)?;
2948        let (w_k_nf4, w_k_scales, w_k_nf4_q) =
2949            quantize_and_upload(w_k, kv_hidden_size * hidden_size)?;
2950        let (w_v_nf4, w_v_scales, w_v_nf4_q) =
2951            quantize_and_upload(w_v, kv_hidden_size * hidden_size)?;
2952        let (w_o_nf4, w_o_scales, w_o_nf4_q) = quantize_and_upload(w_o, hidden_size * q_dim)?;
2953        let (w_gate_nf4, w_gate_scales, w_gate_nf4_q) =
2954            quantize_and_upload(w_gate, intermediate_size * hidden_size)?;
2955        let (w_up_nf4, w_up_scales, w_up_nf4_q) =
2956            quantize_and_upload(w_up, intermediate_size * hidden_size)?;
2957        let (w_down_nf4, w_down_scales, w_down_nf4_q) =
2958            quantize_and_upload(w_down, hidden_size * intermediate_size)?;
2959
2960        // ENT-287: Dequantize NF4 weights and TRANSPOSE to [K,N] for standard cuBLAS GEMM.
2961        //
2962        // bitsandbytes does: F.dequantize_4bit(B).to(dtype).t()
2963        // The .t() transposes [N,K] → [K,N] so torch.nn.functional.linear
2964        // can use standard C = A @ B^T internally.
2965        //
2966        // We replicate this exactly:
2967        // 1. dequantize_nf4(q) → flat [N*K] in [N,K] row-major order
2968        // 2. CPU transpose [N,K] → [K,N]
2969        // 3. Upload transposed buffer to GPU
2970        // 4. Use standard gemm_forward (NoTrans, NoTrans) — same as LoRA weights
2971        use trueno_gpu::kernels::dequantize_nf4;
2972        let dequant_transpose_upload = |q: &trueno_gpu::kernels::Nf4Quantized,
2973                                        n: usize,
2974                                        k: usize|
2975         -> std::result::Result<
2976            GpuBuffer<f32>,
2977            crate::autograd::cuda_tensor::CudaTensorError,
2978        > {
2979            let deq = dequantize_nf4(q); // [N*K] in [N,K] row-major
2980            let nonzero = deq.iter().filter(|&&x| x != 0.0).count();
2981            eprintln!(
2982                "[TRACE] dequant n={n} k={k} len={} nonzero={nonzero} first5={:?}",
2983                deq.len(),
2984                &deq[..5.min(deq.len())]
2985            );
2986            assert_eq!(deq.len(), n * k, "dequant size mismatch: {} vs {}x{}", deq.len(), n, k);
2987            // Transpose [N,K] → [K,N]
2988            let mut transposed = vec![0.0f32; n * k];
2989            for row in 0..n {
2990                for col in 0..k {
2991                    transposed[col * n + row] = deq[row * k + col];
2992                }
2993            }
2994            let buf = GpuBuffer::from_host(&ctx, &transposed).map_err(|e| {
2995                crate::autograd::cuda_tensor::CudaTensorError::TransferFailed(format!(
2996                    "dequant transpose upload: {e:?}"
2997                ))
2998            })?;
2999            // Verify upload: must read FULL buffer then slice
3000            let mut verify_full = vec![0.0f32; buf.len()];
3001            let verify_ok = buf.copy_to_host(&mut verify_full).is_ok();
3002            let verify5: Vec<f32> = verify_full.iter().copied().take(5).collect();
3003            let nz = verify_full.iter().filter(|&&x| x != 0.0).count();
3004            eprintln!("[TRACE] uploaded ptr={:?} len={} copy_ok={verify_ok} nonzero={nz} verify[:5]={verify5:?}", buf.as_ptr(), buf.len());
3005            Ok(buf)
3006        };
3007        // Each weight is [out_features, in_features] = [N, K].
3008        // After transpose: [K, N] — standard cuBLAS B layout.
3009        let w_q_fp32 = dequant_transpose_upload(&w_q_nf4_q, q_dim, hidden_size)?;
3010        let w_k_fp32 = dequant_transpose_upload(&w_k_nf4_q, kv_hidden_size, hidden_size)?;
3011        let w_v_fp32 = dequant_transpose_upload(&w_v_nf4_q, kv_hidden_size, hidden_size)?;
3012        let w_o_fp32 = dequant_transpose_upload(&w_o_nf4_q, hidden_size, q_dim)?;
3013        let w_gate_fp32 = dequant_transpose_upload(&w_gate_nf4_q, intermediate_size, hidden_size)?;
3014        let w_up_fp32 = dequant_transpose_upload(&w_up_nf4_q, intermediate_size, hidden_size)?;
3015        let w_down_fp32 = dequant_transpose_upload(&w_down_nf4_q, hidden_size, intermediate_size)?;
3016
3017        // NF4 blocks do NOT allocate scratch — shared across all layers (C-SCRATCH-001).
3018        // Pipeline allocates one CudaBlockScratch and passes &mut to each forward() call.
3019        // Saves (L-1) * 214 MB = 7.5 GB for Qwen3-4B (36 layers).
3020
3021        // Upload LoRA adapters to GPU (ENT-153)
3022        // B matrices are pre-scaled by lora_scale to avoid a separate scale kernel in forward.
3023        let (lora_a_q, lora_b_q) = match q_lora {
3024            Some((a_data, b_data)) => {
3025                let a = GpuBuffer::from_host(&ctx, a_data)?;
3026                let scaled_b: Vec<f32> = b_data.iter().map(|&v| v * lora_scale).collect();
3027                let b = GpuBuffer::from_host(&ctx, &scaled_b)?;
3028                (Some(a), Some(b))
3029            }
3030            None => (None, None),
3031        };
3032        let (lora_a_v, lora_b_v) = match v_lora {
3033            Some((a_data, b_data)) => {
3034                let a = GpuBuffer::from_host(&ctx, a_data)?;
3035                let scaled_b: Vec<f32> = b_data.iter().map(|&v| v * lora_scale).collect();
3036                let b = GpuBuffer::from_host(&ctx, &scaled_b)?;
3037                (Some(a), Some(b))
3038            }
3039            None => (None, None),
3040        };
3041
3042        // ENT-270: Upload QK-norm weights if present
3043        let q_norm_weight = match q_norm {
3044            Some(w) => {
3045                assert_eq!(
3046                    w.len(),
3047                    config.head_dim(),
3048                    "ENT-270: q_norm weight expected [head_dim={}], got [{}]",
3049                    config.head_dim(),
3050                    w.len()
3051                );
3052                Some(GpuBuffer::from_host(&ctx, w)?)
3053            }
3054            None => None,
3055        };
3056        let k_norm_weight = match k_norm {
3057            Some(w) => {
3058                assert_eq!(
3059                    w.len(),
3060                    config.head_dim(),
3061                    "ENT-270: k_norm weight expected [head_dim={}], got [{}]",
3062                    config.head_dim(),
3063                    w.len()
3064                );
3065                Some(GpuBuffer::from_host(&ctx, w)?)
3066            }
3067            None => None,
3068        };
3069
3070        // FALSIFY-CUDA-NF4-TRAIN-LOSS-PARITY-001: replicate Q/K/V biases
3071        // across max_seq_len rows (same pattern as the FP32 block's
3072        // FALSIFY-CUDA-FORWARD-PARITY-002). Applied by cuda_add_inplace
3073        // after each projection GEMM in forward().
3074        let replicate = |bias: Option<&[f32]>, dim: usize| -> Result<Option<GpuBuffer<f32>>> {
3075            match bias {
3076                Some(slice) => {
3077                    assert_eq!(
3078                        slice.len(),
3079                        dim,
3080                        "NF4 bias slice len {} != expected dim {dim}",
3081                        slice.len()
3082                    );
3083                    let mut repl: Vec<f32> = Vec::with_capacity(max_seq_len * dim);
3084                    for _ in 0..max_seq_len {
3085                        repl.extend_from_slice(slice);
3086                    }
3087                    Ok(Some(GpuBuffer::from_host(&ctx, &repl)?))
3088                }
3089                None => Ok(None),
3090            }
3091        };
3092        let b_q_replicated = replicate(b_q, q_dim)?;
3093        let b_k_replicated = replicate(b_k, kv_hidden_size)?;
3094        let b_v_replicated = replicate(b_v, kv_hidden_size)?;
3095
3096        Ok(Self {
3097            config: config.clone(),
3098            layer_idx,
3099            input_norm_weight,
3100            post_attn_norm_weight,
3101            w_q_nf4,
3102            w_q_scales,
3103            w_k_nf4,
3104            w_k_scales,
3105            w_v_nf4,
3106            w_v_scales,
3107            w_o_nf4,
3108            w_o_scales,
3109            w_gate_nf4,
3110            w_gate_scales,
3111            w_up_nf4,
3112            w_up_scales,
3113            w_down_nf4,
3114            w_down_scales,
3115            w_q_fp32,
3116            w_k_fp32,
3117            w_v_fp32,
3118            w_o_fp32,
3119            w_gate_fp32,
3120            w_up_fp32,
3121            w_down_fp32,
3122            lora_a_q,
3123            lora_b_q,
3124            lora_a_v,
3125            lora_b_v,
3126            lora_scale,
3127            lora_rank,
3128            q_norm_weight,
3129            k_norm_weight,
3130            b_q_replicated,
3131            b_k_replicated,
3132            b_v_replicated,
3133            // FP16 weights: None by default, populated by set_fp16_weights() (PMAT-470)
3134            w_q_fp16: None,
3135            w_k_fp16: None,
3136            w_v_fp16: None,
3137            w_o_fp16: None,
3138            w_gate_fp16: None,
3139            w_up_fp16: None,
3140            w_down_fp16: None,
3141            ctx,
3142        })
3143    }
3144
3145    /// Cast fp32→fp16 weights + drop fp32 (PMAT-470/472). Frees ~2.6 GB VRAM.
3146    pub fn set_fp16_weights(&mut self, stream: &CudaStream) -> Result<()> {
3147        let cast_weight = |w_fp32: &GpuBuffer<f32>, ctx: &CudaContext| -> Result<GpuBuffer<u16>> {
3148            let n = w_fp32.len();
3149            let mut w_fp16 = GpuBuffer::<u16>::new(ctx, n)?;
3150            cast_f32_to_f16_gpu(w_fp32, &mut w_fp16, n as u32, stream)?;
3151            Ok(w_fp16)
3152        };
3153
3154        self.w_q_fp16 = Some(cast_weight(&self.w_q_fp32, &self.ctx)?);
3155        self.w_k_fp16 = Some(cast_weight(&self.w_k_fp32, &self.ctx)?);
3156        self.w_v_fp16 = Some(cast_weight(&self.w_v_fp32, &self.ctx)?);
3157        self.w_o_fp16 = Some(cast_weight(&self.w_o_fp32, &self.ctx)?);
3158        self.w_gate_fp16 = Some(cast_weight(&self.w_gate_fp32, &self.ctx)?);
3159        self.w_up_fp16 = Some(cast_weight(&self.w_up_fp32, &self.ctx)?);
3160        self.w_down_fp16 = Some(cast_weight(&self.w_down_fp32, &self.ctx)?);
3161
3162        stream.synchronize().map_err(|e| {
3163            crate::autograd::cuda_tensor::CudaTensorError::KernelError(format!(
3164                "FP16 weight cast sync failed: {e:?}"
3165            ))
3166        })?;
3167        // PMAT-472: Drop fp32 weights — backward now uses fp16 via gemm_backward_a_fp16_dispatch.
3168        // Frees ~2.6 GB VRAM on yoga 8GB, allowing GPU embeddings to fit.
3169        let dummy = |ctx: &CudaContext| GpuBuffer::<f32>::new(ctx, 1).unwrap();
3170        self.w_q_fp32 = dummy(&self.ctx);
3171        self.w_k_fp32 = dummy(&self.ctx);
3172        self.w_v_fp32 = dummy(&self.ctx);
3173        self.w_o_fp32 = dummy(&self.ctx);
3174        self.w_gate_fp32 = dummy(&self.ctx);
3175        self.w_up_fp32 = dummy(&self.ctx);
3176        self.w_down_fp32 = dummy(&self.ctx);
3177        eprintln!("[FP16] Weights cast + fp32 dropped (~2.6 GB freed)");
3178
3179        Ok(())
3180    }
3181
3182    /// Forward pass: cuBLAS GEMM with pre-dequantized weights (ENT-287, C-SCRATCH-001).
3183    #[rustfmt::skip]
3184    pub(crate) fn forward(
3185        &self,
3186        input: &GpuBuffer<f32>,
3187        output: &mut GpuBuffer<f32>,
3188        seq_len: usize,
3189        stream: &CudaStream,
3190        scratch: &mut CudaBlockScratch,
3191    ) -> Result<()> {
3192        use crate::autograd::cuda_forward::{gemm_forward, gemm_nf4_forward, gemm_nf4_tc_forward};
3193
3194        let hidden_size = self.config.hidden_size;
3195        let q_dim = self.config.q_dim();
3196        let kv_hidden_size = self.config.num_kv_heads * self.config.head_dim();
3197        let intermediate_size = self.config.intermediate_size;
3198
3199        // entrenar#318: scratch zeroing moved to forward_cuda_training (once per step, not per layer)
3200        scratch.prepare_causal_mask(seq_len, &self.ctx)?;
3201
3202        // === Pre-attention RMSNorm === (PMAT-483: per-op profiling)
3203        // FALSIFY-CUDA-RMSNORM-EPS-PARITY-001: thread config eps so Qwen2
3204        // (1e-6) and Llama (1e-5) get the right epsilon (was hardcoded 1e-5).
3205        let _t = scratch.op_begin();
3206        rms_norm_forward_with_eps(
3207            input,
3208            &self.input_norm_weight,
3209            &mut scratch.norm1_out,
3210            saturating_u32(seq_len),
3211            saturating_u32(hidden_size),
3212            self.config.rms_norm_eps,
3213            stream,
3214        )?;
3215        scratch.op_end(_t, OP_RMSNORM_ATTN);
3216
3217        if nan_scan_enabled() {
3218            let l = self.layer_idx;
3219            nan_scan_f32(&format!("L{l} input"), input, seq_len * hidden_size, stream);
3220            nan_scan_f32(&format!("L{l} norm1"), &scratch.norm1_out, seq_len * hidden_size, stream);
3221        }
3222
3223        // === Q, K, V Projections ===
3224        // Backend selection:
3225        //   FP16_GEMM=1: fp16 tensor core GEMM (Tier 2 parity, 2x BW savings)
3226        //   NF4_FUSED_GEMM=1: fused dequant+GEMM (8x less BW, 100% GPU, but naive PTX)
3227        //   Default: cuBLAS fp32 (197 tok/s, 7% GPU, memory-BW bound)
3228        static USE_NF4_GEMM: std::sync::OnceLock<bool> = std::sync::OnceLock::new();
3229        let nf4_gemm = *USE_NF4_GEMM.get_or_init(|| std::env::var("NF4_FUSED_GEMM").as_deref() == Ok("1"));
3230        static USE_NF4_TC_GEMM: std::sync::OnceLock<bool> = std::sync::OnceLock::new();
3231        let nf4_tc_gemm = *USE_NF4_TC_GEMM.get_or_init(|| std::env::var("NF4_TC_GEMM").as_deref() == Ok("1"));
3232        static USE_FP16_GEMM: std::sync::OnceLock<bool> = std::sync::OnceLock::new();
3233        let fp16_gemm = *USE_FP16_GEMM.get_or_init(|| std::env::var("FP16_GEMM").as_deref() == Ok("1"));
3234
3235        // FP16 path: cast activation once, reuse for all projections
3236        let act_n = (seq_len * hidden_size) as u32;
3237        if fp16_gemm && self.w_q_fp16.is_some() {
3238            // Lazy-allocate fp16 activation buffer
3239            if scratch.norm1_out_f16.is_none() {
3240                scratch.norm1_out_f16 = Some(GpuBuffer::new(&self.ctx, seq_len * hidden_size)?);
3241            }
3242            let f16_buf = scratch.norm1_out_f16.as_mut().unwrap();
3243            cast_f32_to_f16_gpu(&scratch.norm1_out, f16_buf, act_n, stream)?;
3244        }
3245
3246        let _t = scratch.op_begin(); // QKV GEMM timing
3247        if fp16_gemm && self.w_q_fp16.is_some() {
3248            let f16_act = scratch.norm1_out_f16.as_ref().unwrap();
3249            gemm_f16_to_f32_forward(f16_act, self.w_q_fp16.as_ref().unwrap(), &mut scratch.q,
3250                saturating_u32(seq_len), saturating_u32(hidden_size), saturating_u32(q_dim), stream)?;
3251        } else if nf4_tc_gemm {
3252            gemm_nf4_tc_forward(&scratch.norm1_out, &self.w_q_nf4, &self.w_q_scales, &mut scratch.q,
3253                saturating_u32(seq_len), saturating_u32(hidden_size), saturating_u32(q_dim), stream)?;
3254        } else if nf4_gemm {
3255            gemm_nf4_forward(&scratch.norm1_out, &self.w_q_nf4, &self.w_q_scales, &mut scratch.q,
3256                saturating_u32(seq_len), saturating_u32(hidden_size), saturating_u32(q_dim), stream)?;
3257        } else {
3258            gemm_forward(&scratch.norm1_out, &self.w_q_fp32, &mut scratch.q,
3259                saturating_u32(seq_len), saturating_u32(hidden_size), saturating_u32(q_dim), stream)?;
3260        }
3261
3262        // FALSIFY-CUDA-NF4-TRAIN-LOSS-PARITY-001: Q projection bias
3263        // (Qwen2 family). Must be applied BEFORE QK-norm/RoPE, matching the
3264        // CPU path (attention.rs: projection → bias → qk-norm → rope).
3265        if let Some(b_q_repl) = self.b_q_replicated.as_ref() {
3266            cuda_add_inplace(&mut scratch.q, b_q_repl, seq_len * q_dim, stream)?;
3267        }
3268
3269        // ENT-153: Q LoRA: q += (norm1_out @ A_q) @ B_q  (B_q pre-scaled by lora_scale)
3270        if let (Some(a_q), Some(b_q)) = (&self.lora_a_q, &self.lora_b_q) {
3271            let s = saturating_u32(seq_len);
3272            let h = saturating_u32(hidden_size);
3273            let r = saturating_u32(self.lora_rank);
3274            let qd = saturating_u32(q_dim);
3275            // lora_inter[seq, rank] = norm1_out[seq, hidden] @ A_q[hidden, rank]
3276            gemm_forward(&scratch.norm1_out, a_q, &mut scratch.lora_inter, s, h, r, stream)?;
3277            // lora_temp[seq, q_dim] = lora_inter[seq, rank] @ B_q[rank, q_dim]
3278            gemm_forward(&scratch.lora_inter, b_q, &mut scratch.lora_temp, s, r, qd, stream)?;
3279            // q += lora_temp (in-place add)
3280            cuda_add_inplace(&mut scratch.q, &scratch.lora_temp, seq_len * q_dim, stream)?;
3281        }
3282
3283        if fp16_gemm && self.w_k_fp16.is_some() {
3284            let f16_act = scratch.norm1_out_f16.as_ref().unwrap();
3285            gemm_f16_to_f32_forward(f16_act, self.w_k_fp16.as_ref().unwrap(), &mut scratch.k,
3286                saturating_u32(seq_len), saturating_u32(hidden_size), saturating_u32(kv_hidden_size), stream)?;
3287            gemm_f16_to_f32_forward(f16_act, self.w_v_fp16.as_ref().unwrap(), &mut scratch.v,
3288                saturating_u32(seq_len), saturating_u32(hidden_size), saturating_u32(kv_hidden_size), stream)?;
3289        } else if nf4_tc_gemm {
3290            // PMAT-481: NF4 tensor core GEMM for K and V projections (separate)
3291            gemm_nf4_tc_forward(&scratch.norm1_out, &self.w_k_nf4, &self.w_k_scales, &mut scratch.k,
3292                saturating_u32(seq_len), saturating_u32(hidden_size), saturating_u32(kv_hidden_size), stream)?;
3293            gemm_nf4_tc_forward(&scratch.norm1_out, &self.w_v_nf4, &self.w_v_scales, &mut scratch.v,
3294                saturating_u32(seq_len), saturating_u32(hidden_size), saturating_u32(kv_hidden_size), stream)?;
3295        } else if nf4_gemm {
3296            // PMAT-478: Fused K+V — shared input load (same pattern as Gate+Up)
3297            crate::autograd::cuda_forward::gemm_nf4_gate_up_forward(
3298                &scratch.norm1_out,
3299                &self.w_k_nf4, &self.w_k_scales,
3300                &self.w_v_nf4, &self.w_v_scales,
3301                &mut scratch.k, &mut scratch.v,
3302                saturating_u32(seq_len), saturating_u32(hidden_size),
3303                saturating_u32(kv_hidden_size), stream,
3304            )?;
3305        } else {
3306            gemm_forward(&scratch.norm1_out, &self.w_k_fp32, &mut scratch.k,
3307                saturating_u32(seq_len), saturating_u32(hidden_size), saturating_u32(kv_hidden_size), stream)?;
3308            gemm_forward(&scratch.norm1_out, &self.w_v_fp32, &mut scratch.v,
3309                saturating_u32(seq_len), saturating_u32(hidden_size), saturating_u32(kv_hidden_size), stream)?;
3310        }
3311
3312        // FALSIFY-CUDA-NF4-TRAIN-LOSS-PARITY-001: K/V projection biases
3313        // (Qwen2 family), applied for ALL K/V GEMM dispatch variants above.
3314        if let Some(b_k_repl) = self.b_k_replicated.as_ref() {
3315            cuda_add_inplace(&mut scratch.k, b_k_repl, seq_len * kv_hidden_size, stream)?;
3316        }
3317        if let Some(b_v_repl) = self.b_v_replicated.as_ref() {
3318            cuda_add_inplace(&mut scratch.v, b_v_repl, seq_len * kv_hidden_size, stream)?;
3319        }
3320
3321        scratch.op_end(_t, OP_QKV_GEMM); // End QKV timing (includes Q/K/V GEMMs + Q LoRA)
3322
3323        // ENT-153: V LoRA: v += (norm1_out @ A_v) @ B_v  (B_v pre-scaled by lora_scale)
3324        if let (Some(a_v), Some(b_v)) = (&self.lora_a_v, &self.lora_b_v) {
3325            let s = saturating_u32(seq_len);
3326            let h = saturating_u32(hidden_size);
3327            let r = saturating_u32(self.lora_rank);
3328            let vd = saturating_u32(kv_hidden_size);
3329            // lora_inter[seq, rank] = norm1_out[seq, hidden] @ A_v[hidden, rank]
3330            gemm_forward(&scratch.norm1_out, a_v, &mut scratch.lora_inter, s, h, r, stream)?;
3331            // lora_temp[seq, kv_hidden] = lora_inter[seq, rank] @ B_v[rank, kv_hidden]
3332            gemm_forward(&scratch.lora_inter, b_v, &mut scratch.lora_temp, s, r, vd, stream)?;
3333            // v += lora_temp (in-place add)
3334            cuda_add_inplace(&mut scratch.v, &scratch.lora_temp, seq_len * kv_hidden_size, stream)?;
3335        }
3336
3337        if nan_scan_enabled() {
3338            let l = self.layer_idx;
3339            nan_scan_f32(&format!("L{l} q-proj"), &scratch.q, seq_len * q_dim, stream);
3340            nan_scan_f32(&format!("L{l} k-proj"), &scratch.k, seq_len * kv_hidden_size, stream);
3341            nan_scan_f32(&format!("L{l} v-proj"), &scratch.v, seq_len * kv_hidden_size, stream);
3342        }
3343
3344        // === Multi-Head Attention (GPU-only, zero CPU transfers) ===
3345        let _t = scratch.op_begin();
3346        self.compute_attention_cuda(seq_len, stream, scratch)?;
3347        scratch.op_end(_t, OP_ATTENTION);
3348
3349        if nan_scan_enabled() {
3350            let l = self.layer_idx;
3351            nan_scan_f32(&format!("L{l} attn-out"), &scratch.attn_out, seq_len * q_dim, stream);
3352        }
3353
3354        // === Output Projection ===
3355        let _t = scratch.op_begin();
3356        if fp16_gemm && self.w_o_fp16.is_some() {
3357            if scratch.attn_out_f16.is_none() {
3358                scratch.attn_out_f16 = Some(GpuBuffer::new(&self.ctx, seq_len * q_dim)?);
3359            }
3360            let f16_buf = scratch.attn_out_f16.as_mut().unwrap();
3361            cast_f32_to_f16_gpu(&scratch.attn_out, f16_buf, (seq_len * q_dim) as u32, stream)?;
3362            gemm_f16_to_f32_forward(f16_buf, self.w_o_fp16.as_ref().unwrap(), &mut scratch.o_proj_out,
3363                saturating_u32(seq_len), saturating_u32(q_dim), saturating_u32(hidden_size), stream)?;
3364        } else if nf4_tc_gemm {
3365            gemm_nf4_tc_forward(&scratch.attn_out, &self.w_o_nf4, &self.w_o_scales, &mut scratch.o_proj_out,
3366                saturating_u32(seq_len), saturating_u32(q_dim), saturating_u32(hidden_size), stream)?;
3367        } else if nf4_gemm {
3368            gemm_nf4_forward(&scratch.attn_out, &self.w_o_nf4, &self.w_o_scales, &mut scratch.o_proj_out,
3369                saturating_u32(seq_len), saturating_u32(q_dim), saturating_u32(hidden_size), stream)?;
3370        } else {
3371            gemm_forward(&scratch.attn_out, &self.w_o_fp32, &mut scratch.o_proj_out,
3372                saturating_u32(seq_len), saturating_u32(q_dim), saturating_u32(hidden_size), stream)?;
3373        }
3374
3375        scratch.op_end(_t, OP_O_PROJ);
3376
3377        if nan_scan_enabled() {
3378            let l = self.layer_idx;
3379            nan_scan_f32(&format!("L{l} o-proj"), &scratch.o_proj_out, seq_len * hidden_size, stream);
3380        }
3381
3382        // === Fused Residual Add + RMSNorm (entrenar#321: eliminates NaN cascade) ===
3383        let _t = scratch.op_begin();
3384        // The separate cuda_add + rms_norm_forward allows activation explosion between
3385        // the two operations. Fusing them prevents NaN in layers 24-27 because RMSNorm
3386        // normalizes the residual sum immediately, before it can propagate.
3387        fused_residual_rmsnorm_forward(
3388            input,
3389            &scratch.o_proj_out,
3390            &mut scratch.residual1,
3391            &mut scratch.norm2_out,
3392            &self.post_attn_norm_weight,
3393            saturating_u32(seq_len),
3394            saturating_u32(hidden_size),
3395            self.config.rms_norm_eps,
3396            stream,
3397        )?;
3398
3399        scratch.op_end(_t, OP_RMSNORM_FFN); // Fused residual + RMSNorm
3400
3401        if nan_scan_enabled() {
3402            let l = self.layer_idx;
3403            nan_scan_f32(&format!("L{l} residual1"), &scratch.residual1, seq_len * hidden_size, stream);
3404            nan_scan_f32(&format!("L{l} norm2"), &scratch.norm2_out, seq_len * hidden_size, stream);
3405        }
3406
3407        // === FFN: Gate + Up + SwiGLU + Down ===
3408        let _t = scratch.op_begin(); // Gate+Up GEMM timing
3409        if fp16_gemm && self.w_gate_fp16.is_some() {
3410            if scratch.norm2_out_f16.is_none() {
3411                scratch.norm2_out_f16 = Some(GpuBuffer::new(&self.ctx, seq_len * hidden_size)?);
3412            }
3413            let f16_buf = scratch.norm2_out_f16.as_mut().unwrap();
3414            cast_f32_to_f16_gpu(&scratch.norm2_out, f16_buf, (seq_len * hidden_size) as u32, stream)?;
3415            gemm_f16_to_f32_forward(f16_buf, self.w_gate_fp16.as_ref().unwrap(), &mut scratch.gate_out,
3416                saturating_u32(seq_len), saturating_u32(hidden_size), saturating_u32(intermediate_size), stream)?;
3417            gemm_f16_to_f32_forward(f16_buf, self.w_up_fp16.as_ref().unwrap(), &mut scratch.up_out,
3418                saturating_u32(seq_len), saturating_u32(hidden_size), saturating_u32(intermediate_size), stream)?;
3419        } else if nf4_tc_gemm {
3420            // PMAT-481: NF4 tensor core GEMM for Gate and Up projections (separate)
3421            gemm_nf4_tc_forward(&scratch.norm2_out, &self.w_gate_nf4, &self.w_gate_scales, &mut scratch.gate_out,
3422                saturating_u32(seq_len), saturating_u32(hidden_size), saturating_u32(intermediate_size), stream)?;
3423            gemm_nf4_tc_forward(&scratch.norm2_out, &self.w_up_nf4, &self.w_up_scales, &mut scratch.up_out,
3424                saturating_u32(seq_len), saturating_u32(hidden_size), saturating_u32(intermediate_size), stream)?;
3425        } else if nf4_gemm {
3426            // PMAT-475: Fused gate+up — shared input load, saves M×K×4 bytes DRAM
3427            crate::autograd::cuda_forward::gemm_nf4_gate_up_forward(
3428                &scratch.norm2_out,
3429                &self.w_gate_nf4, &self.w_gate_scales,
3430                &self.w_up_nf4, &self.w_up_scales,
3431                &mut scratch.gate_out, &mut scratch.up_out,
3432                saturating_u32(seq_len), saturating_u32(hidden_size),
3433                saturating_u32(intermediate_size), stream,
3434            )?;
3435        } else {
3436            gemm_forward(&scratch.norm2_out, &self.w_gate_fp32, &mut scratch.gate_out,
3437                saturating_u32(seq_len), saturating_u32(hidden_size), saturating_u32(intermediate_size), stream)?;
3438            gemm_forward(&scratch.norm2_out, &self.w_up_fp32, &mut scratch.up_out,
3439                saturating_u32(seq_len), saturating_u32(hidden_size), saturating_u32(intermediate_size), stream)?;
3440        }
3441
3442        scratch.op_end(_t, OP_GATE_UP_GEMM);
3443
3444        if nan_scan_enabled() {
3445            let l = self.layer_idx;
3446            nan_scan_f32(&format!("L{l} gate"), &scratch.gate_out, seq_len * intermediate_size, stream);
3447            nan_scan_f32(&format!("L{l} up"), &scratch.up_out, seq_len * intermediate_size, stream);
3448        }
3449
3450        // === FFN: Fused SwiGLU ===
3451        let _t = scratch.op_begin();
3452        fused_swiglu_forward(&scratch.gate_out, &scratch.up_out, &mut scratch.swiglu_out,
3453            saturating_u32(seq_len * intermediate_size), stream)?;
3454        scratch.op_end(_t, OP_SILU);
3455
3456        if nan_scan_enabled() {
3457            let l = self.layer_idx;
3458            nan_scan_f32(&format!("L{l} swiglu"), &scratch.swiglu_out, seq_len * intermediate_size, stream);
3459        }
3460
3461        // === FFN: Down Projection ===
3462        let _t = scratch.op_begin();
3463        if fp16_gemm && self.w_down_fp16.is_some() {
3464            if scratch.swiglu_out_f16.is_none() {
3465                scratch.swiglu_out_f16 = Some(GpuBuffer::new(&self.ctx, seq_len * intermediate_size)?);
3466            }
3467            let f16_buf = scratch.swiglu_out_f16.as_mut().unwrap();
3468            cast_f32_to_f16_gpu(&scratch.swiglu_out, f16_buf, (seq_len * intermediate_size) as u32, stream)?;
3469            gemm_f16_to_f32_forward(f16_buf, self.w_down_fp16.as_ref().unwrap(), &mut scratch.ffn_out,
3470                saturating_u32(seq_len), saturating_u32(intermediate_size), saturating_u32(hidden_size), stream)?;
3471        } else if nf4_tc_gemm {
3472            gemm_nf4_tc_forward(&scratch.swiglu_out, &self.w_down_nf4, &self.w_down_scales, &mut scratch.ffn_out,
3473                saturating_u32(seq_len), saturating_u32(intermediate_size), saturating_u32(hidden_size), stream)?;
3474        } else if nf4_gemm {
3475            gemm_nf4_forward(&scratch.swiglu_out, &self.w_down_nf4, &self.w_down_scales, &mut scratch.ffn_out,
3476                saturating_u32(seq_len), saturating_u32(intermediate_size), saturating_u32(hidden_size), stream)?;
3477        } else {
3478            gemm_forward(&scratch.swiglu_out, &self.w_down_fp32, &mut scratch.ffn_out,
3479                saturating_u32(seq_len), saturating_u32(intermediate_size), saturating_u32(hidden_size), stream)?;
3480        }
3481
3482        scratch.op_end(_t, OP_DOWN_GEMM);
3483
3484        if nan_scan_enabled() {
3485            let l = self.layer_idx;
3486            nan_scan_f32(&format!("L{l} down"), &scratch.ffn_out, seq_len * hidden_size, stream);
3487        }
3488
3489        // === Final Residual Add ===
3490        cuda_add(&scratch.residual1, &scratch.ffn_out, output, seq_len * hidden_size, stream)?;
3491
3492        if nan_scan_enabled() {
3493            let l = self.layer_idx;
3494            nan_scan_f32(&format!("L{l} block-out"), output, seq_len * hidden_size, stream);
3495        }
3496
3497        Ok(())
3498    }
3499
3500    /// Layer index accessor.
3501    pub fn layer_idx(&self) -> usize {
3502        self.layer_idx
3503    }
3504}
3505
3506/// Helper: delegate attention computation using shared scratch buffers.
3507///
3508/// `CudaNf4TransformerBlock` reuses the same attention pipeline as the fp32 block
3509/// since attention operates on fp32 activations (Q/K/V are already dequantized by GEMM).
3510#[cfg(feature = "cuda")]
3511impl CudaNf4TransformerBlock {
3512    fn compute_attention_cuda(
3513        &self,
3514        seq_len: usize,
3515        stream: &CudaStream,
3516        scratch: &mut CudaBlockScratch,
3517    ) -> Result<()> {
3518        let num_heads = self.config.num_attention_heads;
3519        let num_kv_heads = self.config.num_kv_heads;
3520        let head_dim = self.config.head_dim();
3521        let heads_per_kv = num_heads / num_kv_heads;
3522
3523        let s = saturating_u32(seq_len);
3524        let nh = saturating_u32(num_heads);
3525        let nkv = saturating_u32(num_kv_heads);
3526        let hd = saturating_u32(head_dim);
3527
3528        // ── ENT-270: Apply QK-norm (per-head RMSNorm) on Q and K ──────────
3529        // Must happen BEFORE RoPE, matching CPU path ordering:
3530        //   projection → QK-norm → RoPE → attention
3531        // SAFETY: In-place GPU operations — CUDA kernels read all input before writing output.
3532        if let Some(ref q_norm) = self.q_norm_weight {
3533            for pos in 0..seq_len {
3534                // SAFETY: reborrows `scratch.q` as `&` while the same buffer is also passed `&mut` to the in-place GPU kernel below. Sound because the CUDA kernel reads every input element before writing any output element, so the read and write views never alias a live access.
3535                let q_ref = unsafe { &*(std::ptr::addr_of!(scratch.q)) };
3536                per_head_rmsnorm_forward(q_ref, q_norm, &mut scratch.q, nh, hd, pos, stream)?;
3537            }
3538        }
3539        if let Some(ref k_norm) = self.k_norm_weight {
3540            for pos in 0..seq_len {
3541                // SAFETY: reborrows `scratch.k` as `&` while the same buffer is also passed `&mut` to the in-place GPU kernel below. Sound because the CUDA kernel reads every input element before writing any output element, so the read and write views never alias a live access.
3542                let k_ref = unsafe { &*(std::ptr::addr_of!(scratch.k)) };
3543                per_head_rmsnorm_forward(k_ref, k_norm, &mut scratch.k, nkv, hd, pos, stream)?;
3544            }
3545        }
3546
3547        // ── ENT-270: Apply RoPE (NeoX half-rotation) on Q and K ──────────
3548        // ALB-119: Batched launch (2 kernels) replaces per-position loop (2*seq_len kernels)
3549        let rope_theta = self.config.rope_theta;
3550        {
3551            // SAFETY: reborrows `scratch.q` as `&` while the same buffer is also passed `&mut` to the in-place GPU kernel below. Sound because the CUDA kernel reads every input element before writing any output element, so the read and write views never alias a live access.
3552            let q_ref = unsafe { &*(std::ptr::addr_of!(scratch.q)) };
3553            batched_rope_neox_forward(
3554                q_ref,
3555                &mut scratch.q,
3556                &scratch.rope_positions,
3557                nh,
3558                hd,
3559                s,
3560                rope_theta,
3561                stream,
3562            )?;
3563            // SAFETY: reborrows `scratch.k` as `&` while the same buffer is also passed `&mut` to the in-place GPU kernel below. Sound because the CUDA kernel reads every input element before writing any output element, so the read and write views never alias a live access.
3564            let k_ref = unsafe { &*(std::ptr::addr_of!(scratch.k)) };
3565            batched_rope_neox_forward(
3566                k_ref,
3567                &mut scratch.k,
3568                &scratch.rope_positions,
3569                nkv,
3570                hd,
3571                s,
3572                rope_theta,
3573                stream,
3574            )?;
3575        }
3576
3577        if nan_scan_enabled() {
3578            let l = self.layer_idx;
3579            nan_scan_f32(
3580                &format!("L{l} attn.rope-q"),
3581                &scratch.q,
3582                seq_len * num_heads * head_dim,
3583                stream,
3584            );
3585            nan_scan_f32(
3586                &format!("L{l} attn.rope-k"),
3587                &scratch.k,
3588                seq_len * num_kv_heads * head_dim,
3589                stream,
3590            );
3591        }
3592
3593        // Q: interleaved → batched layout
3594        interleaved_to_batched_forward(&scratch.q, &mut scratch.attn_q_batched, s, nh, hd, stream)?;
3595
3596        // K: interleaved → batched, then GQA expand if needed
3597        interleaved_to_batched_forward(&scratch.k, &mut scratch.attn_kv_temp, s, nkv, hd, stream)?;
3598
3599        if heads_per_kv > 1 {
3600            expand_kv_heads(
3601                &scratch.attn_kv_temp,
3602                &mut scratch.attn_kv_temp2,
3603                num_kv_heads,
3604                heads_per_kv,
3605                seq_len * head_dim,
3606                stream,
3607            )?;
3608        } else {
3609            // SAFETY: D2D copy with matching buffer sizes
3610            unsafe {
3611                scratch
3612                    .attn_kv_temp2
3613                    .copy_from_buffer_async(&scratch.attn_kv_temp, stream)
3614                    .map_err(|e| {
3615                        crate::autograd::cuda_tensor::CudaTensorError::TransferFailed(format!(
3616                            "K copy failed: {e:?}"
3617                        ))
3618                    })?;
3619            }
3620        }
3621
3622        // K^T: transpose for attention scores
3623        batched_transpose_forward(
3624            &scratch.attn_kv_temp2,
3625            &mut scratch.attn_kv_temp,
3626            nh,
3627            s,
3628            hd,
3629            stream,
3630        )?;
3631
3632        // Q @ K^T → attention scores
3633        batched_4d_gemm_forward(
3634            &scratch.attn_q_batched,
3635            &scratch.attn_kv_temp,
3636            &mut scratch.attn_scores,
3637            1,
3638            nh,
3639            s,
3640            s,
3641            hd,
3642            stream,
3643        )?;
3644
3645        if nan_scan_enabled() {
3646            let l = self.layer_idx;
3647            nan_scan_f32(
3648                &format!("L{l} attn.q-batched"),
3649                &scratch.attn_q_batched,
3650                num_heads * seq_len * head_dim,
3651                stream,
3652            );
3653            nan_scan_f32(
3654                &format!("L{l} attn.kT"),
3655                &scratch.attn_kv_temp,
3656                num_heads * seq_len * head_dim,
3657                stream,
3658            );
3659            nan_scan_f32(
3660                &format!("L{l} attn.scores-raw"),
3661                &scratch.attn_scores,
3662                num_heads * seq_len * seq_len,
3663                stream,
3664            );
3665        }
3666
3667        // Scale by 1/sqrt(head_dim)
3668        let scale_factor = 1.0 / (head_dim as f32).sqrt();
3669        let total_scores = num_heads * seq_len * seq_len;
3670        // SAFETY: constructs a non-owning `GpuBuffer` view over an already-allocated device region (`ptr`, element count `len`) that stays live for the kernel call; the view is `leak()`ed afterwards so its Drop never frees the borrowed device allocation (no double-free).
3671        let scores_view = unsafe {
3672            GpuBuffer::<f32>::from_raw_parts(
3673                scratch.attn_scores.as_ptr(),
3674                scratch.attn_scores.len(),
3675            )
3676        };
3677        scale_forward(
3678            &scores_view,
3679            &mut scratch.attn_scores,
3680            scale_factor,
3681            saturating_u32(total_scores),
3682            stream,
3683        )?;
3684        leak(scores_view);
3685
3686        // Softmax (in-place: input aliased with output via unsafe view)
3687        // C-CAUSAL-001: Apply causal mask before softmax (NF4 path)
3688        // PMAT-420: Use causal_mask_contiguous (correctly strided for seq_len)
3689        // instead of causal_mask (strided at max_seq_len, causes row misalignment
3690        // when seq_len < max_seq_len, leading to NaN after deep layers).
3691        {
3692            let seq_sq = seq_len * seq_len;
3693            let mask_ptr = scratch.causal_mask_contiguous.as_ptr();
3694            let scores_base = scratch.attn_scores.as_ptr();
3695            for head in 0..num_heads {
3696                let byte_offset = (head * seq_sq * 4) as u64;
3697                let head_ptr = scores_base + byte_offset;
3698                // SAFETY: constructs a non-owning `GpuBuffer` view over an already-allocated device region (`ptr`, element count `len`) that stays live for the kernel call; the view is `leak()`ed afterwards so its Drop never frees the borrowed device allocation (no double-free).
3699                let mask_view = unsafe { GpuBuffer::<f32>::from_raw_parts(mask_ptr, seq_sq) };
3700                // SAFETY: constructs a non-owning `GpuBuffer` view over an already-allocated device region (`ptr`, element count `len`) that stays live for the kernel call; the view is `leak()`ed afterwards so its Drop never frees the borrowed device allocation (no double-free).
3701                let scores_view = unsafe { GpuBuffer::<f32>::from_raw_parts(head_ptr, seq_sq) };
3702                // SAFETY: constructs a non-owning `GpuBuffer` view over an already-allocated device region (`ptr`, element count `len`) that stays live for the kernel call; the view is `leak()`ed afterwards so its Drop never frees the borrowed device allocation (no double-free).
3703                let mut out_view = unsafe { GpuBuffer::<f32>::from_raw_parts(head_ptr, seq_sq) };
3704                residual_add_forward(
3705                    &mask_view,
3706                    &scores_view,
3707                    &mut out_view,
3708                    saturating_u32(seq_sq),
3709                    stream,
3710                )?;
3711                leak(mask_view);
3712                leak(scores_view);
3713                leak(out_view);
3714            }
3715        }
3716
3717        if nan_scan_enabled() {
3718            let l = self.layer_idx;
3719            // -inf from the causal mask is expected here; only NaN is a defect.
3720            if stream.synchronize().is_ok() {
3721                let n = num_heads * seq_len * seq_len;
3722                let mut host = vec![0.0f32; n.min(scratch.attn_scores.len())];
3723                if scratch.attn_scores.copy_to_host(&mut host).is_ok() {
3724                    let nan = host.iter().filter(|v| v.is_nan()).count();
3725                    if nan > 0 {
3726                        eprintln!("[NAN-SCAN] L{l} attn.scores-masked: {nan}/{n} NaN");
3727                    }
3728                }
3729            }
3730        }
3731
3732        // SAFETY: The softmax kernel reads each row completely into shared memory / registers
3733        // before writing output. The view is forgotten to prevent double-free.
3734        let scores_view = unsafe {
3735            GpuBuffer::<f32>::from_raw_parts(
3736                scratch.attn_scores.as_ptr(),
3737                scratch.attn_scores.len(),
3738            )
3739        };
3740        batched_softmax_forward(
3741            &scores_view,
3742            &mut scratch.attn_scores,
3743            saturating_u32(num_heads * seq_len),
3744            s,
3745            stream,
3746        )?;
3747        leak(scores_view);
3748
3749        if nan_scan_enabled() {
3750            let l = self.layer_idx;
3751            nan_scan_f32(
3752                &format!("L{l} attn.softmax"),
3753                &scratch.attn_scores,
3754                num_heads * seq_len * seq_len,
3755                stream,
3756            );
3757        }
3758
3759        // V: interleaved → batched, then GQA expand
3760        interleaved_to_batched_forward(&scratch.v, &mut scratch.attn_kv_temp, s, nkv, hd, stream)?;
3761
3762        if heads_per_kv > 1 {
3763            expand_kv_heads(
3764                &scratch.attn_kv_temp,
3765                &mut scratch.attn_kv_temp2,
3766                num_kv_heads,
3767                heads_per_kv,
3768                seq_len * head_dim,
3769                stream,
3770            )?;
3771        } else {
3772            // SAFETY: async GPU buffer copy within same CUDA stream; both buffers are
3773            // pre-allocated scratch with matching sizes, and stream ordering guarantees
3774            // the source is fully written before this copy executes.
3775            unsafe {
3776                scratch
3777                    .attn_kv_temp2
3778                    .copy_from_buffer_async(&scratch.attn_kv_temp, stream)
3779                    .map_err(|e| {
3780                        crate::autograd::cuda_tensor::CudaTensorError::TransferFailed(format!(
3781                            "V copy failed: {e:?}"
3782                        ))
3783                    })?;
3784            }
3785        }
3786
3787        if nan_scan_enabled() {
3788            let l = self.layer_idx;
3789            nan_scan_f32(
3790                &format!("L{l} attn.v-expanded"),
3791                &scratch.attn_kv_temp2,
3792                num_heads * seq_len * head_dim,
3793                stream,
3794            );
3795        }
3796
3797        // attn_scores @ V → attention output
3798        batched_4d_gemm_forward(
3799            &scratch.attn_scores,
3800            &scratch.attn_kv_temp2,
3801            &mut scratch.attn_q_batched,
3802            1,
3803            nh,
3804            s,
3805            hd,
3806            s,
3807            stream,
3808        )?;
3809
3810        if nan_scan_enabled() {
3811            let l = self.layer_idx;
3812            nan_scan_f32(
3813                &format!("L{l} attn.scoresV"),
3814                &scratch.attn_q_batched,
3815                num_heads * seq_len * head_dim,
3816                stream,
3817            );
3818        }
3819
3820        // Batched → interleaved layout
3821        batched_to_interleaved_forward(
3822            &scratch.attn_q_batched,
3823            &mut scratch.attn_out,
3824            s,
3825            nh,
3826            hd,
3827            stream,
3828        )?;
3829
3830        Ok(())
3831    }
3832}
3833
3834// =============================================================================
3835// QLoRA Backward Pass Types (ENT-153)
3836// =============================================================================
3837
3838/// Shared gradient workspace for LoRA weight gradients (one per model, NOT per layer).
3839///
3840/// Backward processes layers sequentially — only one layer's LoRA gradients
3841/// are computed at a time. Sharing this workspace saves
3842/// `(L-1) * per_layer_lora_grad_elements * 4` bytes of VRAM.
3843///
3844/// # Contract (C-LORAGRADWS-001)
3845///
3846/// - **Precondition**: Allocated once before training loop starts
3847/// - **Postcondition**: After backward() for layer i, contains layer i's LoRA gradients
3848/// - **Invariant**: Buffer sizes match model config; never reallocated during training
3849#[cfg(feature = "cuda")]
3850pub(crate) struct CudaLoraGradWorkspace {
3851    /// Gradient for LoRA A_q [hidden_size, rank]
3852    pub(crate) grad_lora_a_q: GpuBuffer<f32>,
3853    /// Gradient for LoRA B_q [rank, q_dim]
3854    pub(crate) grad_lora_b_q: GpuBuffer<f32>,
3855    /// Gradient for LoRA A_v [hidden_size, rank]
3856    pub(crate) grad_lora_a_v: GpuBuffer<f32>,
3857    /// Gradient for LoRA B_v [rank, kv_hidden]
3858    pub(crate) grad_lora_b_v: GpuBuffer<f32>,
3859    /// Gradient for input norm weight [hidden_size]
3860    pub(crate) grad_input_norm: GpuBuffer<f32>,
3861    /// Gradient for post-attention norm weight [hidden_size]
3862    pub(crate) grad_post_attn_norm: GpuBuffer<f32>,
3863}
3864
3865#[cfg(feature = "cuda")]
3866impl CudaLoraGradWorkspace {
3867    /// Allocate shared LoRA gradient workspace.
3868    pub(crate) fn new(
3869        ctx: &Arc<CudaContext>,
3870        config: &super::config::TransformerConfig,
3871        lora_rank: usize,
3872    ) -> Result<Self> {
3873        let h = config.hidden_size;
3874        let q_dim = config.q_dim();
3875        let kv = config.num_kv_heads * config.head_dim();
3876        let r = lora_rank;
3877
3878        Ok(Self {
3879            grad_lora_a_q: GpuBuffer::new(ctx, h * r)?,
3880            grad_lora_b_q: GpuBuffer::new(ctx, r * q_dim)?,
3881            grad_lora_a_v: GpuBuffer::new(ctx, h * r)?,
3882            grad_lora_b_v: GpuBuffer::new(ctx, r * kv)?,
3883            grad_input_norm: GpuBuffer::new(ctx, h)?,
3884            grad_post_attn_norm: GpuBuffer::new(ctx, h)?,
3885        })
3886    }
3887
3888    /// ENT-265: Clip all 6 LoRA gradient buffers by global L2 norm.
3889    ///
3890    /// Computes the global L2 norm across A_q, B_q, A_v, B_v, input_norm,
3891    /// and post_attn_norm. If the norm exceeds `max_norm`, scales all buffers
3892    /// down by `max_norm / (total_norm + 1e-6)`.
3893    ///
3894    /// Two-phase design: phase 1 reads norms (immutable), phase 2 applies
3895    /// scale (mutable). This satisfies the borrow checker when the workspace
3896    /// is behind a mutable reference.
3897    pub(crate) fn clip_gradients(&mut self, max_norm: f32, stream: &CudaStream) {
3898        // Phase 1: compute global L2 norm
3899        let sq_a_q = squared_sum_cuda(&self.grad_lora_a_q, self.grad_lora_a_q.len() as u32, stream)
3900            .unwrap_or(0.0);
3901        let sq_b_q = squared_sum_cuda(&self.grad_lora_b_q, self.grad_lora_b_q.len() as u32, stream)
3902            .unwrap_or(0.0);
3903        let sq_a_v = squared_sum_cuda(&self.grad_lora_a_v, self.grad_lora_a_v.len() as u32, stream)
3904            .unwrap_or(0.0);
3905        let sq_b_v = squared_sum_cuda(&self.grad_lora_b_v, self.grad_lora_b_v.len() as u32, stream)
3906            .unwrap_or(0.0);
3907        let sq_in =
3908            squared_sum_cuda(&self.grad_input_norm, self.grad_input_norm.len() as u32, stream)
3909                .unwrap_or(0.0);
3910        let sq_pa = squared_sum_cuda(
3911            &self.grad_post_attn_norm,
3912            self.grad_post_attn_norm.len() as u32,
3913            stream,
3914        )
3915        .unwrap_or(0.0);
3916        let total_norm = (sq_a_q + sq_b_q + sq_a_v + sq_b_v + sq_in + sq_pa).sqrt();
3917
3918        if total_norm <= max_norm {
3919            return;
3920        }
3921
3922        // Phase 2: apply clip scale
3923        let clip_scale = max_norm / (total_norm + 1e-6);
3924        let n_aq = self.grad_lora_a_q.len() as u32;
3925        let n_bq = self.grad_lora_b_q.len() as u32;
3926        let n_av = self.grad_lora_a_v.len() as u32;
3927        let n_bv = self.grad_lora_b_v.len() as u32;
3928        let n_in = self.grad_input_norm.len() as u32;
3929        let n_pa = self.grad_post_attn_norm.len() as u32;
3930        let _ = gradient_clip_cuda(&mut self.grad_lora_a_q, clip_scale, n_aq, stream);
3931        let _ = gradient_clip_cuda(&mut self.grad_lora_b_q, clip_scale, n_bq, stream);
3932        let _ = gradient_clip_cuda(&mut self.grad_lora_a_v, clip_scale, n_av, stream);
3933        let _ = gradient_clip_cuda(&mut self.grad_lora_b_v, clip_scale, n_bv, stream);
3934        let _ = gradient_clip_cuda(&mut self.grad_input_norm, clip_scale, n_in, stream);
3935        let _ = gradient_clip_cuda(&mut self.grad_post_attn_norm, clip_scale, n_pa, stream);
3936    }
3937}
3938
3939/// GPU-resident AdamW optimizer state for LoRA adapters in one NF4 block.
3940///
3941/// Stores first (m) and second (v) moment estimates for:
3942/// - 4 LoRA weight tensors (A_q, B_q, A_v, B_v)
3943/// - 2 RMSNorm weights (input_norm, post_attn_norm)
3944///
3945/// # Contract (C-LORAOPT-001)
3946///
3947/// - **Precondition**: CUDA context valid, buffers match weight dimensions
3948/// - **Postcondition**: m and v initialized to zero
3949/// - **Invariant**: Buffer sizes immutable after creation
3950#[cfg(feature = "cuda")]
3951pub(crate) struct GpuLoraOptimizerState {
3952    m_lora_a_q: GpuBuffer<f32>,
3953    v_lora_a_q: GpuBuffer<f32>,
3954    m_lora_b_q: GpuBuffer<f32>,
3955    v_lora_b_q: GpuBuffer<f32>,
3956    m_lora_a_v: GpuBuffer<f32>,
3957    v_lora_a_v: GpuBuffer<f32>,
3958    m_lora_b_v: GpuBuffer<f32>,
3959    v_lora_b_v: GpuBuffer<f32>,
3960    m_input_norm: GpuBuffer<f32>,
3961    v_input_norm: GpuBuffer<f32>,
3962    m_post_attn_norm: GpuBuffer<f32>,
3963    v_post_attn_norm: GpuBuffer<f32>,
3964}
3965
3966#[cfg(feature = "cuda")]
3967impl GpuLoraOptimizerState {
3968    fn new(
3969        ctx: &Arc<CudaContext>,
3970        config: &super::config::TransformerConfig,
3971        lora_rank: usize,
3972    ) -> Result<Self> {
3973        let h = config.hidden_size;
3974        let q_dim = config.q_dim();
3975        let kv = config.num_kv_heads * config.head_dim();
3976        let r = lora_rank;
3977
3978        // CRITICAL: Must zero-initialize m/v buffers. GpuBuffer::new() does NOT
3979        // zero memory (cuMemAlloc returns uninitialized VRAM).
3980        let z = |n: usize| -> Result<GpuBuffer<f32>> {
3981            Ok(GpuBuffer::from_host(ctx, &vec![0.0f32; n])?)
3982        };
3983        Ok(Self {
3984            m_lora_a_q: z(h * r)?,
3985            v_lora_a_q: z(h * r)?,
3986            m_lora_b_q: z(r * q_dim)?,
3987            v_lora_b_q: z(r * q_dim)?,
3988            m_lora_a_v: z(h * r)?,
3989            v_lora_a_v: z(h * r)?,
3990            m_lora_b_v: z(r * kv)?,
3991            v_lora_b_v: z(r * kv)?,
3992            m_input_norm: z(h)?,
3993            v_input_norm: z(h)?,
3994            m_post_attn_norm: z(h)?,
3995            v_post_attn_norm: z(h)?,
3996        })
3997    }
3998}
3999
4000// =============================================================================
4001// NF4 Block Backward Pass (ENT-153)
4002// =============================================================================
4003
4004#[cfg(feature = "cuda")]
4005impl CudaNf4TransformerBlock {
4006    /// Backward pass with activation checkpointing and LoRA gradient computation.
4007    ///
4008    /// # Activation Checkpointing
4009    ///
4010    /// Re-runs forward to regenerate intermediate activations. Only `layer_input`
4011    /// is saved per-layer (47 MB for 36 layers at seq_len=128). This is the standard
4012    /// NF4 QLoRA backward (C-QLORA-BWD-001): recompute activations, propagate gradients.
4013    #[allow(clippy::too_many_arguments)]
4014    pub(crate) fn backward(
4015        &self,
4016        layer_input: &GpuBuffer<f32>,
4017        grad_output: &GpuBuffer<f32>,
4018        grad_input: &mut GpuBuffer<f32>,
4019        output_scratch: &mut GpuBuffer<f32>,
4020        seq_len: usize,
4021        stream: &CudaStream,
4022        scratch: &mut CudaBlockScratch,
4023        grad_lora: &mut CudaLoraGradWorkspace,
4024    ) -> Result<()> {
4025        let hidden_size = self.config.hidden_size;
4026        let _q_dim = self.config.q_dim();
4027        let _kv_hidden_size = self.config.num_kv_heads * self.config.head_dim();
4028        let intermediate_size = self.config.intermediate_size;
4029        let eps = 1e-5_f32;
4030
4031        // === Step 0: Activation checkpointing — re-run forward ===
4032        // This repopulates scratch with all intermediates needed for backward.
4033        self.forward(layer_input, output_scratch, seq_len, stream, scratch).map_err(|e| {
4034            eprintln!(
4035                "[backward] Layer {} activation-checkpoint forward FAILED: {e:?}",
4036                self.layer_idx
4037            );
4038            e
4039        })?;
4040
4041        // === Step 1: FFN backward (NF4 transpose, no weight grads for frozen projections) ===
4042        self.backward_nf4_ffn(
4043            grad_output,
4044            seq_len,
4045            hidden_size,
4046            intermediate_size,
4047            stream,
4048            scratch,
4049        )?;
4050
4051        // === Step 2: Post-attn norm backward ===
4052        let _t = scratch.op_begin(); // OP_NORM_BWD timing (both norms)
4053        rms_norm_backward(
4054            &scratch.residual1,
4055            &self.post_attn_norm_weight,
4056            &scratch.grad_hidden, // grad_from_ffn is accumulated in grad_hidden by backward_nf4_ffn
4057            grad_input,           // temporarily store post-attn-norm grad here
4058            &mut grad_lora.grad_post_attn_norm,
4059            saturating_u32(seq_len),
4060            saturating_u32(hidden_size),
4061            eps,
4062            stream,
4063        )?;
4064
4065        // Add residual connection: grad flows through both ffn and skip path
4066        // grad_residual1 = grad_input (from norm backward) + grad_output (from residual skip)
4067        cuda_add_inplace(grad_input, grad_output, seq_len * hidden_size, stream)?;
4068
4069        // === Step 3: Attention backward (NF4 + LoRA for Q/V) ===
4070        self.backward_nf4_attention(
4071            grad_input, // grad coming into attention (from residual1)
4072            seq_len, stream, scratch, grad_lora,
4073        )?;
4074
4075        // === Step 4: Input norm backward + first residual ===
4076        // At this point, scratch.grad_hidden contains grad from attention block
4077        // (accumulated by backward_nf4_attention into norm1_out reusing grad_hidden)
4078        rms_norm_backward(
4079            layer_input,
4080            &self.input_norm_weight,
4081            &scratch.grad_hidden, // grad flowing into norm1
4082            grad_input,           // final grad_input for this layer
4083            &mut grad_lora.grad_input_norm,
4084            saturating_u32(seq_len),
4085            saturating_u32(hidden_size),
4086            eps,
4087            stream,
4088        )?;
4089
4090        scratch.op_end(_t, OP_NORM_BWD);
4091
4092        Ok(())
4093    }
4094
4095    /// FFN backward for NF4 blocks (ENT-287: cuBLAS fp32 GEMM).
4096    ///
4097    /// Propagates gradient through: down_proj → SwiGLU → gate/up projections.
4098    /// Uses cuBLAS GEMM with pre-dequantized fp32 weights for correct layout.
4099    /// No weight gradients for frozen NF4 weights.
4100    fn backward_nf4_ffn(
4101        &self,
4102        grad_output: &GpuBuffer<f32>,
4103        seq_len: usize,
4104        hidden_size: usize,
4105        intermediate_size: usize,
4106        stream: &CudaStream,
4107        scratch: &mut CudaBlockScratch,
4108    ) -> Result<()> {
4109        let s = saturating_u32(seq_len);
4110        let h = saturating_u32(hidden_size);
4111        let i_size = saturating_u32(intermediate_size);
4112        let n_inter = saturating_u32(seq_len * intermediate_size);
4113
4114        // PMAT-481: NF4 tensor core backward dispatch
4115        static USE_NF4_TC_BWD: std::sync::OnceLock<bool> = std::sync::OnceLock::new();
4116        let nf4_tc_bwd =
4117            *USE_NF4_TC_BWD.get_or_init(|| std::env::var("NF4_TC_BWD_GEMM").as_deref() == Ok("1"));
4118
4119        // Step 1: grad_swiglu[S,I] = grad_output[S,H] @ W_down^T (PMAT-472: fp16 dispatch)
4120        let _t = scratch.op_begin(); // OP_DOWN_BWD timing
4121        if nf4_tc_bwd {
4122            // NF4 TC backward: fused dequant+WMMA, no separate dequant kernel
4123            crate::autograd::cuda_forward::gemm_nf4_tc_backward_a(
4124                grad_output,
4125                &self.w_down_nf4,
4126                &self.w_down_scales,
4127                &mut scratch.grad_swiglu,
4128                s,
4129                h,      // N = hidden_size (grad_output cols)
4130                i_size, // K = intermediate_size (output cols = W_down rows)
4131                stream,
4132            )?;
4133        } else {
4134            gemm_backward_a_fp16_dispatch(
4135                grad_output,
4136                self.w_down_fp16.as_ref(),
4137                &self.w_down_fp32,
4138                &mut scratch.grad_swiglu,
4139                s,
4140                i_size,
4141                h,
4142                stream,
4143                &self.ctx,
4144            )?;
4145        }
4146
4147        scratch.op_end(_t, OP_DOWN_BWD);
4148
4149        // Step 2: SwiGLU backward: swiglu = silu(gate) * up
4150        // d_gate = d_swiglu * up * silu'(gate)
4151        // d_up   = d_swiglu * silu(gate)
4152        let _t = scratch.op_begin(); // OP_SWIGLU_BWD timing
4153
4154        // temp1 = d_swiglu * up_out → store in swiglu_out (reuse)
4155        elementwise_mul_forward(
4156            &scratch.grad_swiglu,
4157            &scratch.up_out,
4158            &mut scratch.swiglu_out,
4159            n_inter,
4160            stream,
4161        )?;
4162
4163        // silu_backward: d_gate_raw = temp1 * silu'(gate_out)
4164        // silu'(x) = silu(x) * (1 + x*(1-silu(x)))
4165        // Reuse up_out as storage for d_gate
4166        silu_backward(
4167            &scratch.gate_out,
4168            &scratch.swiglu_out,
4169            &mut scratch.up_out, // d_gate stored here
4170            stream,
4171        )?;
4172
4173        // d_up = d_swiglu * silu(gate) → store in gate_out (reuse)
4174        // Compute silu(gate) into swiglu_out (scratch) — NOT ffn_out which is [S,H] (too small)
4175        silu_forward(&scratch.gate_out, &mut scratch.swiglu_out, n_inter, stream)?;
4176        // d_up = d_swiglu * silu(gate)
4177        elementwise_mul_forward(
4178            &scratch.grad_swiglu,
4179            &scratch.swiglu_out,
4180            &mut scratch.gate_out, // d_up stored here
4181            n_inter,
4182            stream,
4183        )?;
4184
4185        scratch.op_end(_t, OP_SWIGLU_BWD);
4186
4187        // Step 3: gate/up backward (PMAT-472: fp16 dispatch, PMAT-481: TC dispatch)
4188        let _t = scratch.op_begin(); // OP_GATE_UP_BWD timing
4189                                     // PMAT-484: Fused backward — use cuBLAS beta=1.0 accumulate to eliminate
4190                                     // the separate cuda_add_inplace kernel launch (3 launches → 2).
4191        static USE_FUSED_BWD: std::sync::OnceLock<bool> = std::sync::OnceLock::new();
4192        let fused_bwd = *USE_FUSED_BWD
4193            .get_or_init(|| std::env::var("NF4_FUSED_BWD_GEMM").as_deref() == Ok("1"));
4194
4195        if nf4_tc_bwd {
4196            // PMAT-481: NF4 tensor core backward — fused dequant+WMMA per projection
4197            // Up backward: grad_up[S,H] = d_up[S,I] @ W_up^T
4198            crate::autograd::cuda_forward::gemm_nf4_tc_backward_a(
4199                &scratch.gate_out, // d_up stored in gate_out buffer
4200                &self.w_up_nf4,
4201                &self.w_up_scales,
4202                &mut scratch.grad_hidden,
4203                s,
4204                i_size, // N = intermediate_size (d_up cols)
4205                h,      // K = hidden_size (output cols)
4206                stream,
4207            )?;
4208            // Gate backward: grad_gate[S,H] = d_gate[S,I] @ W_gate^T
4209            crate::autograd::cuda_forward::gemm_nf4_tc_backward_a(
4210                &scratch.up_out, // d_gate stored in up_out buffer
4211                &self.w_gate_nf4,
4212                &self.w_gate_scales,
4213                &mut scratch.ffn_out,
4214                s,
4215                i_size, // N = intermediate_size
4216                h,      // K = hidden_size
4217                stream,
4218            )?;
4219            // Accumulate: grad_hidden += grad_gate
4220            cuda_add_inplace(
4221                &mut scratch.grad_hidden,
4222                &scratch.ffn_out,
4223                seq_len * hidden_size,
4224                stream,
4225            )?;
4226        } else if fused_bwd {
4227            // Fused path: compute up backward into grad_hidden, then accumulate gate
4228            gemm_backward_a_fp16_dispatch(
4229                &scratch.gate_out,
4230                self.w_up_fp16.as_ref(),
4231                &self.w_up_fp32,
4232                &mut scratch.grad_hidden,
4233                s,
4234                h,
4235                i_size,
4236                stream,
4237                &self.ctx,
4238            )?;
4239            // Accumulate gate backward into grad_hidden (beta=1.0)
4240            gemm_backward_a_fp16_dispatch_accumulate(
4241                &scratch.up_out,
4242                self.w_gate_fp16.as_ref(),
4243                &self.w_gate_fp32,
4244                &mut scratch.grad_hidden,
4245                s,
4246                h,
4247                i_size,
4248                stream,
4249                &self.ctx,
4250            )?;
4251        } else {
4252            // Unfused path: separate GEMMs + explicit add
4253            gemm_backward_a_fp16_dispatch(
4254                &scratch.up_out,
4255                self.w_gate_fp16.as_ref(),
4256                &self.w_gate_fp32,
4257                &mut scratch.ffn_out,
4258                s,
4259                h,
4260                i_size,
4261                stream,
4262                &self.ctx,
4263            )?;
4264            gemm_backward_a_fp16_dispatch(
4265                &scratch.gate_out,
4266                self.w_up_fp16.as_ref(),
4267                &self.w_up_fp32,
4268                &mut scratch.grad_hidden,
4269                s,
4270                h,
4271                i_size,
4272                stream,
4273                &self.ctx,
4274            )?;
4275
4276            // Accumulate: grad_hidden = grad_norm2_gate + grad_norm2_up
4277            cuda_add_inplace(
4278                &mut scratch.grad_hidden,
4279                &scratch.ffn_out,
4280                seq_len * hidden_size,
4281                stream,
4282            )?;
4283        }
4284        scratch.op_end(_t, OP_GATE_UP_BWD);
4285
4286        Ok(())
4287    }
4288
4289    /// Attention backward for NF4 blocks with LoRA gradient computation (ENT-287).
4290    ///
4291    /// Propagates gradient through O projection, attention mechanism, and Q/K/V projections.
4292    /// Computes LoRA weight gradients for Q and V projections.
4293    /// Uses cuBLAS GEMM with pre-dequantized fp32 weights.
4294    fn backward_nf4_attention(
4295        &self,
4296        grad_residual1: &GpuBuffer<f32>,
4297        seq_len: usize,
4298        stream: &CudaStream,
4299        scratch: &mut CudaBlockScratch,
4300        grad_lora: &mut CudaLoraGradWorkspace,
4301    ) -> Result<()> {
4302        use crate::autograd::cuda_forward::gemm_forward;
4303
4304        let hidden_size = self.config.hidden_size;
4305        let q_dim = self.config.q_dim();
4306        let kv_hidden_size = self.config.num_kv_heads * self.config.head_dim();
4307        let num_heads = self.config.num_attention_heads;
4308        let head_dim = self.config.head_dim();
4309
4310        let s = saturating_u32(seq_len);
4311        let h = saturating_u32(hidden_size);
4312        let qd = saturating_u32(q_dim);
4313        let kvh = saturating_u32(kv_hidden_size);
4314
4315        // Step 1: O projection backward (PMAT-481: TC dispatch)
4316        static USE_NF4_TC_BWD_O: std::sync::OnceLock<bool> = std::sync::OnceLock::new();
4317        let nf4_tc_bwd_o = *USE_NF4_TC_BWD_O
4318            .get_or_init(|| std::env::var("NF4_TC_BWD_GEMM").as_deref() == Ok("1"));
4319
4320        let _t = scratch.op_begin(); // OP_ATTN_BWD timing (O-proj + attention mechanism)
4321        if nf4_tc_bwd_o {
4322            crate::autograd::cuda_forward::gemm_nf4_tc_backward_a(
4323                grad_residual1,
4324                &self.w_o_nf4,
4325                &self.w_o_scales,
4326                &mut scratch.attn_out,
4327                s,
4328                h,  // N = hidden_size (grad_residual1 cols)
4329                qd, // K = q_dim (output cols = W_o rows)
4330                stream,
4331            )?;
4332        } else {
4333            gemm_backward_a_fp16_dispatch(
4334                grad_residual1,
4335                self.w_o_fp16.as_ref(),
4336                &self.w_o_fp32,
4337                &mut scratch.attn_out,
4338                s,
4339                qd,
4340                h,
4341                stream,
4342                &self.ctx,
4343            )?;
4344        }
4345
4346        // Step 2: Attention mechanism backward
4347        // This is complex (softmax backward, batched GEMMs) — reuse the fp32 attention backward
4348        // infrastructure since attention operates on fp32 activations.
4349        self.backward_nf4_attention_mechanism(seq_len, num_heads, head_dim, stream, scratch)?;
4350
4351        // After attention backward: scratch.norm1_out-related grads are accumulated.
4352        // grad_q is in scratch.q, grad_k in scratch.k, grad_v in scratch.v
4353
4354        // Step 2b: RoPE backward (inverse rotation) on grad_q and grad_k
4355        // Forward applied RoPE to Q and K. Undo rotation so projection backward
4356        // gets gradients in the unrotated coordinate frame.
4357        let rope_theta = self.config.rope_theta;
4358        let num_kv_heads = self.config.num_kv_heads;
4359        let nkv = saturating_u32(num_kv_heads);
4360        let nh = saturating_u32(num_heads);
4361        let hd = saturating_u32(head_dim);
4362        {
4363            // SAFETY: reborrows `scratch.q` as `&` while the same buffer is also passed `&mut` to the in-place GPU kernel below. Sound because the CUDA kernel reads every input element before writing any output element, so the read and write views never alias a live access.
4364            let q_ref = unsafe { &*(std::ptr::addr_of!(scratch.q)) };
4365            batched_rope_neox_backward(
4366                q_ref,
4367                &mut scratch.q,
4368                &scratch.rope_positions,
4369                nh,
4370                hd,
4371                s,
4372                rope_theta,
4373                stream,
4374            )?;
4375            // SAFETY: reborrows `scratch.k` as `&` while the same buffer is also passed `&mut` to the in-place GPU kernel below. Sound because the CUDA kernel reads every input element before writing any output element, so the read and write views never alias a live access.
4376            let k_ref = unsafe { &*(std::ptr::addr_of!(scratch.k)) };
4377            batched_rope_neox_backward(
4378                k_ref,
4379                &mut scratch.k,
4380                &scratch.rope_positions,
4381                nkv,
4382                hd,
4383                s,
4384                rope_theta,
4385                stream,
4386            )?;
4387        }
4388
4389        scratch.op_end(_t, OP_ATTN_BWD);
4390
4391        // Q/K/V backward (PMAT-472: fp16 dispatch, PMAT-481: TC dispatch)
4392        static USE_NF4_TC_BWD_ATTN: std::sync::OnceLock<bool> = std::sync::OnceLock::new();
4393        let nf4_tc_bwd = *USE_NF4_TC_BWD_ATTN
4394            .get_or_init(|| std::env::var("NF4_TC_BWD_GEMM").as_deref() == Ok("1"));
4395
4396        let _t = scratch.op_begin(); // OP_QKV_BWD timing
4397        if nf4_tc_bwd {
4398            // PMAT-481: NF4 tensor core backward for Q projection
4399            crate::autograd::cuda_forward::gemm_nf4_tc_backward_a(
4400                &scratch.q,
4401                &self.w_q_nf4,
4402                &self.w_q_scales,
4403                &mut scratch.o_proj_out,
4404                s,
4405                qd, // N = q_dim (grad_q cols)
4406                h,  // K = hidden_size (output cols)
4407                stream,
4408            )?;
4409        } else {
4410            gemm_backward_a_fp16_dispatch(
4411                &scratch.q,
4412                self.w_q_fp16.as_ref(),
4413                &self.w_q_fp32,
4414                &mut scratch.o_proj_out,
4415                s,
4416                h,
4417                qd,
4418                stream,
4419                &self.ctx,
4420            )?;
4421        }
4422
4423        // LoRA Q backward: compute grad_A_q, grad_B_q, and add to grad_norm1
4424        if let (Some(a_q), Some(b_q)) = (&self.lora_a_q, &self.lora_b_q) {
4425            let r = saturating_u32(self.lora_rank);
4426
4427            // Recompute: lora_inter_q = norm1_out @ A_q  [S, rank]
4428            gemm_forward(&scratch.norm1_out, a_q, &mut scratch.lora_inter, s, h, r, stream)?;
4429
4430            // grad_B_q = lora_inter_q^T @ grad_q  [rank, q_dim]
4431            // (Note: B_q was pre-scaled, so grad_B_q includes the scale factor)
4432            gemm_backward_b(
4433                &scratch.lora_inter,
4434                &scratch.q,
4435                &mut grad_lora.grad_lora_b_q,
4436                s,
4437                r,
4438                qd,
4439                stream,
4440            )?;
4441
4442            // grad_lora_inter = grad_q @ B_q^T  [S, rank]
4443            gemm_backward_a(
4444                &scratch.q,
4445                b_q,
4446                &mut scratch.lora_inter, // reuse for grad_lora_inter
4447                s,
4448                qd,
4449                r,
4450                stream,
4451            )?;
4452
4453            // grad_A_q = norm1_out^T @ grad_lora_inter  [H, rank]
4454            gemm_backward_b(
4455                &scratch.norm1_out,
4456                &scratch.lora_inter,
4457                &mut grad_lora.grad_lora_a_q,
4458                s,
4459                h,
4460                r,
4461                stream,
4462            )?;
4463
4464            // Add LoRA's contribution to grad_norm1: += grad_lora_inter @ A_q^T  [S, H]
4465            gemm_backward_a(
4466                &scratch.lora_inter,
4467                a_q,
4468                &mut scratch.lora_temp, // [S, H]
4469                s,
4470                r,
4471                h,
4472                stream,
4473            )?;
4474            cuda_add_inplace(
4475                &mut scratch.o_proj_out,
4476                &scratch.lora_temp,
4477                seq_len * hidden_size,
4478                stream,
4479            )?;
4480        }
4481
4482        // K+V backward (PMAT-484: fused, PMAT-481: TC dispatch)
4483        static USE_FUSED_BWD_ATTN: std::sync::OnceLock<bool> = std::sync::OnceLock::new();
4484        let fused_bwd = *USE_FUSED_BWD_ATTN
4485            .get_or_init(|| std::env::var("NF4_FUSED_BWD_GEMM").as_deref() == Ok("1"));
4486
4487        if nf4_tc_bwd {
4488            // PMAT-481: NF4 tensor core backward for K and V projections
4489            crate::autograd::cuda_forward::gemm_nf4_tc_backward_a(
4490                &scratch.k,
4491                &self.w_k_nf4,
4492                &self.w_k_scales,
4493                &mut scratch.ffn_out,
4494                s,
4495                kvh, // N = kv_hidden (grad_k cols)
4496                h,   // K = hidden_size (output cols)
4497                stream,
4498            )?;
4499            cuda_add_inplace(
4500                &mut scratch.o_proj_out,
4501                &scratch.ffn_out,
4502                seq_len * hidden_size,
4503                stream,
4504            )?;
4505            crate::autograd::cuda_forward::gemm_nf4_tc_backward_a(
4506                &scratch.v,
4507                &self.w_v_nf4,
4508                &self.w_v_scales,
4509                &mut scratch.ffn_out,
4510                s,
4511                kvh, // N = kv_hidden
4512                h,   // K = hidden_size
4513                stream,
4514            )?;
4515            cuda_add_inplace(
4516                &mut scratch.o_proj_out,
4517                &scratch.ffn_out,
4518                seq_len * hidden_size,
4519                stream,
4520            )?;
4521        } else if fused_bwd {
4522            // Fused: K and V backward accumulate directly into o_proj_out
4523            gemm_backward_a_fp16_dispatch_accumulate(
4524                &scratch.k,
4525                self.w_k_fp16.as_ref(),
4526                &self.w_k_fp32,
4527                &mut scratch.o_proj_out,
4528                s,
4529                h,
4530                kvh,
4531                stream,
4532                &self.ctx,
4533            )?;
4534            gemm_backward_a_fp16_dispatch_accumulate(
4535                &scratch.v,
4536                self.w_v_fp16.as_ref(),
4537                &self.w_v_fp32,
4538                &mut scratch.o_proj_out,
4539                s,
4540                h,
4541                kvh,
4542                stream,
4543                &self.ctx,
4544            )?;
4545        } else {
4546            // Unfused: K backward → temp, accumulate; V backward → temp, accumulate
4547            gemm_backward_a_fp16_dispatch(
4548                &scratch.k,
4549                self.w_k_fp16.as_ref(),
4550                &self.w_k_fp32,
4551                &mut scratch.ffn_out,
4552                s,
4553                h,
4554                kvh,
4555                stream,
4556                &self.ctx,
4557            )?;
4558            cuda_add_inplace(
4559                &mut scratch.o_proj_out,
4560                &scratch.ffn_out,
4561                seq_len * hidden_size,
4562                stream,
4563            )?;
4564
4565            gemm_backward_a_fp16_dispatch(
4566                &scratch.v,
4567                self.w_v_fp16.as_ref(),
4568                &self.w_v_fp32,
4569                &mut scratch.ffn_out,
4570                s,
4571                h,
4572                kvh,
4573                stream,
4574                &self.ctx,
4575            )?;
4576            cuda_add_inplace(
4577                &mut scratch.o_proj_out,
4578                &scratch.ffn_out,
4579                seq_len * hidden_size,
4580                stream,
4581            )?;
4582        }
4583
4584        // LoRA V backward
4585        if let (Some(a_v), Some(b_v)) = (&self.lora_a_v, &self.lora_b_v) {
4586            let r = saturating_u32(self.lora_rank);
4587
4588            // Recompute: lora_inter_v = norm1_out @ A_v  [S, rank]
4589            gemm_forward(&scratch.norm1_out, a_v, &mut scratch.lora_inter, s, h, r, stream)?;
4590
4591            // grad_B_v = lora_inter_v^T @ grad_v  [rank, kv_hidden]
4592            gemm_backward_b(
4593                &scratch.lora_inter,
4594                &scratch.v,
4595                &mut grad_lora.grad_lora_b_v,
4596                s,
4597                r,
4598                kvh,
4599                stream,
4600            )?;
4601
4602            // grad_lora_inter = grad_v @ B_v^T  [S, rank]
4603            gemm_backward_a(&scratch.v, b_v, &mut scratch.lora_inter, s, kvh, r, stream)?;
4604
4605            // grad_A_v = norm1_out^T @ grad_lora_inter  [H, rank]
4606            gemm_backward_b(
4607                &scratch.norm1_out,
4608                &scratch.lora_inter,
4609                &mut grad_lora.grad_lora_a_v,
4610                s,
4611                h,
4612                r,
4613                stream,
4614            )?;
4615
4616            // Add LoRA V's contribution to grad_norm1
4617            gemm_backward_a(&scratch.lora_inter, a_v, &mut scratch.lora_temp, s, r, h, stream)?;
4618            cuda_add_inplace(
4619                &mut scratch.o_proj_out,
4620                &scratch.lora_temp,
4621                seq_len * hidden_size,
4622                stream,
4623            )?;
4624        }
4625
4626        scratch.op_end(_t, OP_QKV_BWD);
4627
4628        // Step 6: Accumulated grad_norm1 is in scratch.o_proj_out → move to scratch.grad_hidden
4629        // SAFETY: stream-ordered device-to-device copy between two distinct `GpuBuffer`s of matching element length on the same context; both allocations outlive the async copy on `stream`.
4630        unsafe {
4631            scratch.grad_hidden.copy_from_buffer_async(&scratch.o_proj_out, stream).map_err(
4632                |e| {
4633                    crate::autograd::cuda_tensor::CudaTensorError::TransferFailed(format!(
4634                        "grad_norm1 copy failed: {e}"
4635                    ))
4636                },
4637            )?;
4638        }
4639
4640        Ok(())
4641    }
4642
4643    /// Attention mechanism backward (softmax, Q@K^T backward) for NF4 blocks.
4644    ///
4645    /// After this call:
4646    /// - scratch.q contains grad_q [S, q_dim]
4647    /// - scratch.k contains grad_k [S, kv_hidden]
4648    /// - scratch.v contains grad_v [S, kv_hidden]
4649    ///
4650    /// PMAT-486: Full attention backward (replaces previous no-op).
4651    /// Mirrors the FP32 backward in `backward_attention` (lines 1470-1810).
4652    ///
4653    /// Forward: attn_out = softmax(Q @ K^T / √d) @ V
4654    /// Backward:
4655    ///   1. grad_scores = grad_attn_batched @ V^T
4656    ///   2. grad_V = (grad_attn_batched^T @ attn_weights)^T  (buffer-safe identity)
4657    ///   3. softmax_backward(grad_scores, attn_weights) → grad_raw
4658    ///   4. grad_raw *= 1/√d
4659    ///   5. grad_Q = grad_raw @ K_expanded
4660    ///   6. grad_K = (Q^T @ grad_raw)^T
4661    ///   7. GQA reduction + batched→interleaved conversion
4662    ///
4663    /// Contract: attention-backward-v1.yaml
4664    fn backward_nf4_attention_mechanism(
4665        &self,
4666        seq_len: usize,
4667        num_heads: usize,
4668        head_dim: usize,
4669        stream: &CudaStream,
4670        scratch: &mut CudaBlockScratch,
4671    ) -> Result<()> {
4672        let num_kv_heads = self.config.num_kv_heads;
4673        let heads_per_kv = num_heads / num_kv_heads;
4674        let s = saturating_u32(seq_len);
4675        let nh = saturating_u32(num_heads);
4676        let nkv = saturating_u32(num_kv_heads);
4677        let hd = saturating_u32(head_dim);
4678        let scale = 1.0 / (head_dim as f32).sqrt();
4679
4680        // grad_attn_out is in scratch.attn_out [S, q_dim]
4681        // Convert to batched layout [NH, S, HD]
4682        interleaved_to_batched_forward(
4683            &scratch.attn_out,
4684            &mut scratch.attn_q_batched, // grad_attn_batched [NH, S, HD]
4685            s,
4686            nh,
4687            hd,
4688            stream,
4689        )?;
4690
4691        // === Step 1: Expand V for GQA and transpose ===
4692        // V is in scratch.v [S, kv_hidden] from activation checkpointing forward.
4693        // Convert to batched [NKV, S, HD], then GQA-expand to [NH, S, HD].
4694        interleaved_to_batched_forward(&scratch.v, &mut scratch.attn_kv_temp, s, nkv, hd, stream)?;
4695
4696        if heads_per_kv > 1 {
4697            expand_kv_heads(
4698                &scratch.attn_kv_temp,
4699                &mut scratch.attn_kv_temp2,
4700                num_kv_heads,
4701                heads_per_kv,
4702                seq_len * head_dim,
4703                stream,
4704            )?;
4705        } else {
4706            // SAFETY: stream-ordered device-to-device copy between two distinct `GpuBuffer`s of matching element length on the same context; both allocations outlive the async copy on `stream`.
4707            unsafe {
4708                scratch
4709                    .attn_kv_temp2
4710                    .copy_from_buffer_async(&scratch.attn_kv_temp, stream)
4711                    .map_err(|e| {
4712                        crate::autograd::cuda_tensor::CudaTensorError::TransferFailed(format!(
4713                            "V copy for attn backward: {e:?}"
4714                        ))
4715                    })?;
4716            }
4717        }
4718        // attn_kv_temp2 = V_expanded [NH, S, HD]
4719
4720        // Transpose V: [NH, S, HD] → [NH, HD, S]
4721        batched_transpose_forward(
4722            &scratch.attn_kv_temp2,
4723            &mut scratch.attn_kv_temp, // V^T [NH, HD, S]
4724            nh,
4725            s,
4726            hd,
4727            stream,
4728        )?;
4729
4730        // === Step 2: grad_attn_scores = grad_attn_batched @ V^T ===
4731        // [NH, S, HD] @ [NH, HD, S] → [NH, S, S]
4732        batched_4d_gemm_forward(
4733            &scratch.attn_q_batched,
4734            &scratch.attn_kv_temp,
4735            &mut scratch.grad_attn_scores,
4736            1,
4737            nh,
4738            s,
4739            s,
4740            hd,
4741            stream,
4742        )?;
4743
4744        // === Step 3: grad_V = (grad_attn_batched^T @ attn_weights)^T ===
4745        // Uses the identity to avoid needing an [NH, S, S] transpose buffer.
4746        // attn_weights in scratch.attn_scores [NH, S, S] from forward checkpoint.
4747
4748        // Step 3a: transpose grad_attn_batched [NH, S, HD] → [NH, HD, S]
4749        batched_transpose_forward(
4750            &scratch.attn_q_batched,
4751            &mut scratch.attn_kv_temp, // reuse: grad_attn_batched^T [NH, HD, S]
4752            nh,
4753            s,
4754            hd,
4755            stream,
4756        )?;
4757
4758        // Step 3b: [NH, HD, S] @ [NH, S, S] → [NH, HD, S] (= grad_V^T)
4759        batched_4d_gemm_forward(
4760            &scratch.attn_kv_temp,
4761            &scratch.attn_scores,       // attn_weights from forward
4762            &mut scratch.attn_kv_temp2, // grad_V^T [NH, HD, S]
4763            1,
4764            nh,
4765            hd,
4766            s,
4767            s,
4768            stream,
4769        )?;
4770
4771        // Step 3c: transpose grad_V^T [NH, HD, S] → grad_V [NH, S, HD]
4772        batched_transpose_forward(
4773            &scratch.attn_kv_temp2,
4774            &mut scratch.attn_kv_temp, // grad_V [NH, S, HD]
4775            nh,
4776            hd,
4777            s,
4778            stream,
4779        )?;
4780        // attn_kv_temp = grad_V [NH, S, HD]
4781
4782        // === Step 4: Softmax backward ===
4783        // In-place: grad_attn_scores is both input and output.
4784        let total_rows = nh * s;
4785        {
4786            // SAFETY: constructs a non-owning `GpuBuffer` view over an already-allocated device region (`ptr`, element count `len`) that stays live for the kernel call; the view is `leak()`ed afterwards so its Drop never frees the borrowed device allocation (no double-free).
4787            let grad_scores_view = unsafe {
4788                GpuBuffer::<f32>::from_raw_parts(
4789                    scratch.grad_attn_scores.as_ptr(),
4790                    scratch.grad_attn_scores.len(),
4791                )
4792            };
4793            batched_softmax_backward(
4794                &scratch.attn_scores,
4795                &grad_scores_view,
4796                &mut scratch.grad_attn_scores,
4797                total_rows,
4798                s,
4799                stream,
4800            )?;
4801            leak(grad_scores_view);
4802        }
4803
4804        // === Step 5: Scale backward (1/√d) ===
4805        let total_scores = saturating_u32(num_heads * seq_len * seq_len);
4806        {
4807            // SAFETY: constructs a non-owning `GpuBuffer` view over an already-allocated device region (`ptr`, element count `len`) that stays live for the kernel call; the view is `leak()`ed afterwards so its Drop never frees the borrowed device allocation (no double-free).
4808            let scores_view = unsafe {
4809                GpuBuffer::<f32>::from_raw_parts(
4810                    scratch.grad_attn_scores.as_ptr(),
4811                    scratch.grad_attn_scores.len(),
4812                )
4813            };
4814            scale_forward(
4815                &scores_view,
4816                &mut scratch.grad_attn_scores,
4817                scale,
4818                total_scores,
4819                stream,
4820            )?;
4821            leak(scores_view);
4822        }
4823
4824        // === Step 6: Reconstruct K, GQA expand, compute grad_Q ===
4825        interleaved_to_batched_forward(&scratch.k, &mut scratch.attn_kv_temp2, s, nkv, hd, stream)?;
4826
4827        if heads_per_kv > 1 {
4828            // SAFETY: stream-ordered device-to-device copy between two distinct `GpuBuffer`s of matching element length on the same context; both allocations outlive the async copy on `stream`.
4829            unsafe {
4830                scratch
4831                    .attn_q_batched
4832                    .copy_from_buffer_async(&scratch.attn_kv_temp2, stream)
4833                    .map_err(|e| {
4834                        crate::autograd::cuda_tensor::CudaTensorError::TransferFailed(format!(
4835                            "K copy for GQA expand: {e}"
4836                        ))
4837                    })?;
4838            }
4839            expand_kv_heads(
4840                &scratch.attn_q_batched,
4841                &mut scratch.attn_kv_temp2,
4842                num_kv_heads,
4843                heads_per_kv,
4844                seq_len * head_dim,
4845                stream,
4846            )?;
4847        }
4848        // attn_kv_temp2 = K_expanded [NH, S, HD]
4849
4850        // grad_Q = grad_raw_scores @ K_expanded → attn_q_batched [NH, S, HD]
4851        batched_4d_gemm_forward(
4852            &scratch.grad_attn_scores,
4853            &scratch.attn_kv_temp2,
4854            &mut scratch.attn_q_batched,
4855            1,
4856            nh,
4857            s,
4858            hd,
4859            s,
4860            stream,
4861        )?;
4862
4863        // === Step 7: Compute grad_K ===
4864        // grad_K^T = Q^T @ grad_raw_scores
4865        // Reconstruct Q_batched into o_proj_out (attn_q_batched has grad_Q now)
4866        interleaved_to_batched_forward(
4867            &scratch.q,
4868            &mut scratch.o_proj_out, // temp for Q_batched
4869            s,
4870            nh,
4871            hd,
4872            stream,
4873        )?;
4874
4875        // Transpose Q: [NH, S, HD] → [NH, HD, S]
4876        batched_transpose_forward(
4877            &scratch.o_proj_out,
4878            &mut scratch.attn_kv_temp2, // Q^T [NH, HD, S]
4879            nh,
4880            s,
4881            hd,
4882            stream,
4883        )?;
4884
4885        // grad_K^T = Q^T @ grad_raw_scores → ffn_out as temp [NH, HD, S]
4886        batched_4d_gemm_forward(
4887            &scratch.attn_kv_temp2,
4888            &scratch.grad_attn_scores,
4889            &mut scratch.ffn_out, // grad_K^T [NH, HD, S]
4890            1,
4891            nh,
4892            hd,
4893            s,
4894            s,
4895            stream,
4896        )?;
4897
4898        // Transpose grad_K^T → grad_K: [NH, HD, S] → [NH, S, HD]
4899        batched_transpose_forward(
4900            &scratch.ffn_out,
4901            &mut scratch.attn_kv_temp2, // grad_K [NH, S, HD]
4902            nh,
4903            hd,
4904            s,
4905            stream,
4906        )?;
4907
4908        // === Step 8: GQA gradient reduction ===
4909        if heads_per_kv > 1 {
4910            self.reduce_gqa_gradients_nf4(
4911                num_kv_heads,
4912                heads_per_kv,
4913                seq_len,
4914                head_dim,
4915                stream,
4916                scratch,
4917            )?;
4918        }
4919
4920        // === Step 9: Convert batched gradients → interleaved, store in scratch.q/k/v ===
4921        // grad_Q: attn_q_batched [NH, S, HD] → scratch.q [S, q_dim]
4922        batched_to_interleaved_forward(&scratch.attn_q_batched, &mut scratch.q, s, nh, hd, stream)?;
4923
4924        // grad_K: attn_kv_temp2 [NKV, S, HD] → scratch.k [S, kv_hidden]
4925        batched_to_interleaved_forward(&scratch.attn_kv_temp2, &mut scratch.k, s, nkv, hd, stream)?;
4926
4927        // grad_V: attn_kv_temp [NKV, S, HD] → scratch.v [S, kv_hidden]
4928        // (After GQA reduction, grad_V was reduced from [NH] to [NKV] heads)
4929        batched_to_interleaved_forward(&scratch.attn_kv_temp, &mut scratch.v, s, nkv, hd, stream)?;
4930
4931        Ok(())
4932    }
4933
4934    /// GQA gradient reduction for NF4 blocks.
4935    /// Reduces grad_K and grad_V from [num_heads, S, HD] to [num_kv_heads, S, HD]
4936    /// by summing across Q heads sharing each KV head.
4937    fn reduce_gqa_gradients_nf4(
4938        &self,
4939        num_kv_heads: usize,
4940        heads_per_kv: usize,
4941        seq_len: usize,
4942        head_dim: usize,
4943        stream: &CudaStream,
4944        scratch: &mut CudaBlockScratch,
4945    ) -> Result<()> {
4946        let chunk = seq_len * head_dim;
4947        for g in 0..num_kv_heads {
4948            let dst_off = g * chunk;
4949            // First Q head in group → initialize destination
4950            let src_off = g * heads_per_kv * chunk;
4951            // Copy first head
4952            {
4953                // SAFETY: constructs a non-owning `GpuBuffer` view over an already-allocated device region (`ptr`, element count `len`) that stays live for the kernel call; the view is `leak()`ed afterwards so its Drop never frees the borrowed device allocation (no double-free).
4954                let src = unsafe {
4955                    GpuBuffer::<f32>::from_raw_parts(
4956                        scratch.attn_kv_temp2.as_ptr() + (src_off * 4) as u64,
4957                        chunk,
4958                    )
4959                };
4960                // SAFETY: constructs a non-owning `GpuBuffer` view over an already-allocated device region (`ptr`, element count `len`) that stays live for the kernel call; the view is `leak()`ed afterwards so its Drop never frees the borrowed device allocation (no double-free).
4961                let mut dst = unsafe {
4962                    GpuBuffer::<f32>::from_raw_parts(
4963                        scratch.attn_kv_temp2.as_ptr() + (dst_off * 4) as u64,
4964                        chunk,
4965                    )
4966                };
4967                if src_off != dst_off {
4968                    // SAFETY: stream-ordered device-to-device copy between two distinct `GpuBuffer`s of matching element length on the same context; both allocations outlive the async copy on `stream`.
4969                    unsafe {
4970                        dst.copy_from_buffer_async(&src, stream).map_err(|e| {
4971                            crate::autograd::cuda_tensor::CudaTensorError::TransferFailed(format!(
4972                                "GQA K reduce copy: {e}"
4973                            ))
4974                        })?;
4975                    }
4976                }
4977                leak(src);
4978                leak(dst);
4979            }
4980            // Accumulate remaining heads
4981            for h in 1..heads_per_kv {
4982                let add_off = (g * heads_per_kv + h) * chunk;
4983                // SAFETY: constructs a non-owning `GpuBuffer` view over an already-allocated device region (`ptr`, element count `len`) that stays live for the kernel call; the view is `leak()`ed afterwards so its Drop never frees the borrowed device allocation (no double-free).
4984                let src = unsafe {
4985                    GpuBuffer::<f32>::from_raw_parts(
4986                        scratch.attn_kv_temp2.as_ptr() + (add_off * 4) as u64,
4987                        chunk,
4988                    )
4989                };
4990                // SAFETY: constructs a non-owning `GpuBuffer` view over an already-allocated device region (`ptr`, element count `len`) that stays live for the kernel call; the view is `leak()`ed afterwards so its Drop never frees the borrowed device allocation (no double-free).
4991                let mut dst = unsafe {
4992                    GpuBuffer::<f32>::from_raw_parts(
4993                        scratch.attn_kv_temp2.as_ptr() + (dst_off * 4) as u64,
4994                        chunk,
4995                    )
4996                };
4997                cuda_add_inplace(&mut dst, &src, chunk, stream)?;
4998                leak(src);
4999                leak(dst);
5000            }
5001            // Same for grad_V (in attn_kv_temp)
5002            {
5003                // SAFETY: constructs a non-owning `GpuBuffer` view over an already-allocated device region (`ptr`, element count `len`) that stays live for the kernel call; the view is `leak()`ed afterwards so its Drop never frees the borrowed device allocation (no double-free).
5004                let src = unsafe {
5005                    GpuBuffer::<f32>::from_raw_parts(
5006                        scratch.attn_kv_temp.as_ptr() + (src_off * 4) as u64,
5007                        chunk,
5008                    )
5009                };
5010                // SAFETY: constructs a non-owning `GpuBuffer` view over an already-allocated device region (`ptr`, element count `len`) that stays live for the kernel call; the view is `leak()`ed afterwards so its Drop never frees the borrowed device allocation (no double-free).
5011                let mut dst = unsafe {
5012                    GpuBuffer::<f32>::from_raw_parts(
5013                        scratch.attn_kv_temp.as_ptr() + (dst_off * 4) as u64,
5014                        chunk,
5015                    )
5016                };
5017                if src_off != dst_off {
5018                    // SAFETY: stream-ordered device-to-device copy between two distinct `GpuBuffer`s of matching element length on the same context; both allocations outlive the async copy on `stream`.
5019                    unsafe {
5020                        dst.copy_from_buffer_async(&src, stream).map_err(|e| {
5021                            crate::autograd::cuda_tensor::CudaTensorError::TransferFailed(format!(
5022                                "GQA V reduce copy: {e}"
5023                            ))
5024                        })?;
5025                    }
5026                }
5027                leak(src);
5028                leak(dst);
5029            }
5030            for h in 1..heads_per_kv {
5031                let add_off = (g * heads_per_kv + h) * chunk;
5032                // SAFETY: constructs a non-owning `GpuBuffer` view over an already-allocated device region (`ptr`, element count `len`) that stays live for the kernel call; the view is `leak()`ed afterwards so its Drop never frees the borrowed device allocation (no double-free).
5033                let src = unsafe {
5034                    GpuBuffer::<f32>::from_raw_parts(
5035                        scratch.attn_kv_temp.as_ptr() + (add_off * 4) as u64,
5036                        chunk,
5037                    )
5038                };
5039                // SAFETY: constructs a non-owning `GpuBuffer` view over an already-allocated device region (`ptr`, element count `len`) that stays live for the kernel call; the view is `leak()`ed afterwards so its Drop never frees the borrowed device allocation (no double-free).
5040                let mut dst = unsafe {
5041                    GpuBuffer::<f32>::from_raw_parts(
5042                        scratch.attn_kv_temp.as_ptr() + (dst_off * 4) as u64,
5043                        chunk,
5044                    )
5045                };
5046                cuda_add_inplace(&mut dst, &src, chunk, stream)?;
5047                leak(src);
5048                leak(dst);
5049            }
5050        }
5051        Ok(())
5052    }
5053
5054    /// Initialize LoRA optimizer state for this block.
5055    pub(crate) fn init_lora_optimizer_state(&self) -> Result<GpuLoraOptimizerState> {
5056        GpuLoraOptimizerState::new(&self.ctx, &self.config, self.lora_rank)
5057    }
5058
5059    /// LoRA optimizer step: update A_q, B_q, A_v, B_v and norm weights using AdamW.
5060    #[allow(clippy::too_many_arguments)]
5061    pub(crate) fn lora_optimizer_step(
5062        &mut self,
5063        state: &mut GpuLoraOptimizerState,
5064        step: u32,
5065        lr: f32,
5066        beta1: f32,
5067        beta2: f32,
5068        eps: f32,
5069        weight_decay: f32,
5070        stream: &CudaStream,
5071        grad_lora: &CudaLoraGradWorkspace,
5072    ) -> Result<()> {
5073        let h = self.config.hidden_size;
5074        let q_dim = self.config.q_dim();
5075        let kv = self.config.num_kv_heads * self.config.head_dim();
5076        let r = self.lora_rank;
5077
5078        // AdamW step for each LoRA weight
5079        if let Some(ref mut a_q) = self.lora_a_q {
5080            adamw_step_cuda(
5081                a_q,
5082                &grad_lora.grad_lora_a_q,
5083                &mut state.m_lora_a_q,
5084                &mut state.v_lora_a_q,
5085                lr,
5086                beta1,
5087                beta2,
5088                eps,
5089                weight_decay,
5090                step,
5091                saturating_u32(h * r),
5092                stream,
5093            )?;
5094        }
5095        if let Some(ref mut b_q) = self.lora_b_q {
5096            adamw_step_cuda(
5097                b_q,
5098                &grad_lora.grad_lora_b_q,
5099                &mut state.m_lora_b_q,
5100                &mut state.v_lora_b_q,
5101                lr,
5102                beta1,
5103                beta2,
5104                eps,
5105                weight_decay,
5106                step,
5107                saturating_u32(r * q_dim),
5108                stream,
5109            )?;
5110        }
5111        if let Some(ref mut a_v) = self.lora_a_v {
5112            adamw_step_cuda(
5113                a_v,
5114                &grad_lora.grad_lora_a_v,
5115                &mut state.m_lora_a_v,
5116                &mut state.v_lora_a_v,
5117                lr,
5118                beta1,
5119                beta2,
5120                eps,
5121                weight_decay,
5122                step,
5123                saturating_u32(h * r),
5124                stream,
5125            )?;
5126        }
5127        if let Some(ref mut b_v) = self.lora_b_v {
5128            adamw_step_cuda(
5129                b_v,
5130                &grad_lora.grad_lora_b_v,
5131                &mut state.m_lora_b_v,
5132                &mut state.v_lora_b_v,
5133                lr,
5134                beta1,
5135                beta2,
5136                eps,
5137                weight_decay,
5138                step,
5139                saturating_u32(r * kv),
5140                stream,
5141            )?;
5142        }
5143
5144        // AdamW step for norm weights
5145        adamw_step_cuda(
5146            &mut self.input_norm_weight,
5147            &grad_lora.grad_input_norm,
5148            &mut state.m_input_norm,
5149            &mut state.v_input_norm,
5150            lr,
5151            beta1,
5152            beta2,
5153            eps,
5154            weight_decay,
5155            step,
5156            saturating_u32(h),
5157            stream,
5158        )?;
5159        adamw_step_cuda(
5160            &mut self.post_attn_norm_weight,
5161            &grad_lora.grad_post_attn_norm,
5162            &mut state.m_post_attn_norm,
5163            &mut state.v_post_attn_norm,
5164            lr,
5165            beta1,
5166            beta2,
5167            eps,
5168            weight_decay,
5169            step,
5170            saturating_u32(h),
5171            stream,
5172        )?;
5173
5174        Ok(())
5175    }
5176
5177    /// Download LoRA weights from GPU to CPU for checkpoint saving.
5178    ///
5179    /// Returns (A_q, B_q, A_v, B_v) as flat f32 vectors.
5180    /// B matrices are returned WITH the baked-in scale (caller can divide by lora_scale
5181    /// if they need the unscaled version).
5182    pub fn download_lora_weights(&self) -> Result<(Vec<f32>, Vec<f32>, Vec<f32>, Vec<f32>)> {
5183        let download = |buf: &GpuBuffer<f32>| -> Result<Vec<f32>> {
5184            let mut host = vec![0.0f32; buf.len()];
5185            buf.copy_to_host(&mut host).map_err(|e| {
5186                crate::autograd::cuda_tensor::CudaTensorError::TransferFailed(format!(
5187                    "LoRA weight download failed: {e}"
5188                ))
5189            })?;
5190            Ok(host)
5191        };
5192        let a_q = self.lora_a_q.as_ref().map(&download).transpose()?.unwrap_or_default();
5193        let b_q = self.lora_b_q.as_ref().map(&download).transpose()?.unwrap_or_default();
5194        let a_v = self.lora_a_v.as_ref().map(&download).transpose()?.unwrap_or_default();
5195        let b_v = self.lora_b_v.as_ref().map(&download).transpose()?.unwrap_or_default();
5196        Ok((a_q, b_q, a_v, b_v))
5197    }
5198
5199    /// Upload LoRA weights from CPU to GPU for checkpoint resume (ENT-276).
5200    ///
5201    /// Overwrites the current LoRA adapter buffers with trained weights
5202    /// restored from a checkpoint. Call after `new()` to replace the fresh
5203    /// random init with previously trained adapters.
5204    pub fn upload_lora_weights(
5205        &mut self,
5206        a_q: &[f32],
5207        b_q: &[f32],
5208        a_v: &[f32],
5209        b_v: &[f32],
5210    ) -> Result<()> {
5211        let upload = |buf: &mut GpuBuffer<f32>, data: &[f32], name: &str| -> Result<()> {
5212            if data.len() != buf.len() {
5213                return Err(crate::autograd::cuda_tensor::CudaTensorError::TransferFailed(
5214                    format!(
5215                        "LoRA {name} size mismatch: checkpoint has {} but GPU buffer expects {}",
5216                        data.len(),
5217                        buf.len()
5218                    ),
5219                ));
5220            }
5221            buf.copy_from_host(data).map_err(|e| {
5222                crate::autograd::cuda_tensor::CudaTensorError::TransferFailed(format!(
5223                    "LoRA {name} upload failed: {e}"
5224                ))
5225            })
5226        };
5227        if let Some(ref mut buf) = self.lora_a_q {
5228            upload(buf, a_q, "a_q")?;
5229        }
5230        if let Some(ref mut buf) = self.lora_b_q {
5231            upload(buf, b_q, "b_q")?;
5232        }
5233        if let Some(ref mut buf) = self.lora_a_v {
5234            upload(buf, a_v, "a_v")?;
5235        }
5236        if let Some(ref mut buf) = self.lora_b_v {
5237            upload(buf, b_v, "b_v")?;
5238        }
5239        Ok(())
5240    }
5241}
5242
5243#[cfg(all(test, feature = "cuda"))]
5244#[path = "cuda_block_parity_probe.rs"]
5245mod parity_probe;
5246
5247#[cfg(test)]
5248mod tests {
5249    #[test]
5250    fn test_cuda_block_compiles() {
5251        // Basic compilation test
5252        #[cfg(feature = "cuda")]
5253        {
5254            use super::*;
5255            let _ = std::mem::size_of::<CudaTransformerBlock>();
5256            let _ = std::mem::size_of::<CudaNf4TransformerBlock>();
5257        }
5258    }
5259}