Skip to main content

entrenar/autograd/cuda_forward/
normalization.rs

1#![allow(unsafe_code)]
2#![allow(trivial_casts)]
3#![allow(clippy::borrow_as_ptr)]
4#![allow(clippy::ref_as_ptr)]
5
6#[cfg(feature = "cuda")]
7use trueno_gpu::driver::{CudaStream, GpuBuffer, LaunchConfig};
8#[cfg(feature = "cuda")]
9use trueno_gpu::kernels::{
10    BatchedFusedResidualRmsNormKernel, BatchedRopeNeoxBackwardKernel, BatchedRopeNeoxKernel,
11    BatchedVectorizedRmsNormKernel, Kernel, LayerNormKernel, PerHeadRmsNormKernel, RopeNeoxKernel,
12};
13
14use crate::autograd::cuda_tensor::{CudaTensorError, Result};
15
16#[cfg(feature = "cuda")]
17use super::cache::FORWARD_KERNEL_CACHE;
18
19/// Layer normalization forward pass on GPU
20///
21/// Computes: output = gamma * (input - mean) / sqrt(var + eps) + beta
22#[cfg(feature = "cuda")]
23pub fn layer_norm_forward(
24    input: &GpuBuffer<f32>,
25    gamma: &GpuBuffer<f32>,
26    beta: &GpuBuffer<f32>,
27    output: &mut GpuBuffer<f32>,
28    batch_size: u32,
29    hidden_size: u32,
30    stream: &CudaStream,
31) -> Result<()> {
32    let cache = FORWARD_KERNEL_CACHE.get().ok_or(CudaTensorError::DeviceNotInitialized)?;
33    let mut cache = cache.lock().map_err(|_err| {
34        CudaTensorError::KernelError("Failed to acquire kernel cache lock".to_string())
35    })?;
36
37    let kernel = LayerNormKernel::new(hidden_size);
38    let kernel_name = kernel.name();
39
40    let key = format!("layer_norm_forward_{hidden_size}");
41    let module = match cache.get_cached(&key) {
42        Some(m) => m,
43        None => {
44            let ptx = kernel.emit_ptx_for_target(cache.sm_target());
45            cache.get_or_compile(&key, &ptx)?
46        }
47    };
48
49    let config = LaunchConfig {
50        grid: (batch_size, 1, 1),
51        block: (256.min(hidden_size), 1, 1),
52        shared_mem: 0,
53    };
54
55    let input_ptr = input.as_ptr();
56    let gamma_ptr = gamma.as_ptr();
57    let beta_ptr = beta.as_ptr();
58    let output_ptr = output.as_ptr();
59
60    let mut args: [*mut std::ffi::c_void; 6] = [
61        &input_ptr as *const _ as *mut _,
62        &gamma_ptr as *const _ as *mut _,
63        &beta_ptr as *const _ as *mut _,
64        &output_ptr as *const _ as *mut _,
65        &batch_size as *const _ as *mut _,
66        &hidden_size as *const _ as *mut _,
67    ];
68
69    // SAFETY: Kernel launch requires FFI. All buffers are valid GPU allocations with
70    // matching sizes, and the kernel parameters match the expected PTX signature.
71    unsafe {
72        stream.launch_kernel(module, kernel_name, &config, &mut args).map_err(|e| {
73            CudaTensorError::KernelError(format!("LayerNorm forward launch failed: {e:?}"))
74        })?;
75    }
76
77    Ok(())
78}
79
80/// RMS normalization forward pass on GPU (LLaMA-style)
81///
82/// Computes: output = gamma * input / sqrt(mean(input^2) + eps)
83///
84/// Uses BatchedVectorizedRmsNormKernel: single kernel launch processes all
85/// batch_size rows in parallel via grid.y = batch_size, 256 threads per block.
86///
87/// ALB-076: Previously launched one 32-thread kernel per row (2048 launches for
88/// batch=4, seq=512). nsys profiling showed this was 97.1% of all GPU time.
89/// Single batched launch eliminates 100K+ kernel launches per step.
90#[cfg(feature = "cuda")]
91pub fn rms_norm_forward(
92    input: &GpuBuffer<f32>,
93    gamma: &GpuBuffer<f32>,
94    output: &mut GpuBuffer<f32>,
95    batch_size: u32,
96    hidden_size: u32,
97    stream: &CudaStream,
98) -> Result<()> {
99    // Backwards-compatible default for legacy callers (Llama default).
100    // Production callers in transformer/cuda_block.rs should call
101    // rms_norm_forward_with_eps directly with config.rms_norm_eps so
102    // Qwen2 / Qwen2.5 (rms_norm_eps=1e-6) gets the right epsilon.
103    rms_norm_forward_with_eps(input, gamma, output, batch_size, hidden_size, 1e-5, stream)
104}
105
106/// FALSIFY-CUDA-RMSNORM-EPS-PARITY-001 (eps-aware variant): batched RMSNorm
107/// forward that honours `config.rms_norm_eps` instead of hardcoding 1e-5.
108///
109/// Pre-fix: `rms_norm_forward` constructed `BatchedVectorizedRmsNormKernel::new`
110/// (eps=1e-5, the Llama default) regardless of model. Qwen2 / Qwen2.5
111/// uses `rms_norm_eps=1e-6` per its config.json. The 9e-6 absolute eps
112/// difference compounds over 24 layers × 2 RMSNorms per block = 48 calls,
113/// and is one of the residual contributors to CUDA-CPU forward divergence
114/// surfaced by `apr-pretrain-cuda-forward-parity-v1.yaml`.
115///
116/// Cache key includes eps bits so two different epsilons compile to two
117/// different PTX modules; otherwise a stale cached module would silently
118/// shadow the new eps.
119#[cfg(feature = "cuda")]
120pub fn rms_norm_forward_with_eps(
121    input: &GpuBuffer<f32>,
122    gamma: &GpuBuffer<f32>,
123    output: &mut GpuBuffer<f32>,
124    batch_size: u32,
125    hidden_size: u32,
126    eps: f32,
127    stream: &CudaStream,
128) -> Result<()> {
129    let cache = FORWARD_KERNEL_CACHE.get().ok_or(CudaTensorError::DeviceNotInitialized)?;
130    let mut cache = cache.lock().map_err(|_err| {
131        CudaTensorError::KernelError("Failed to acquire kernel cache lock".to_string())
132    })?;
133
134    let kernel = BatchedVectorizedRmsNormKernel::new(hidden_size, batch_size).with_epsilon(eps);
135
136    // Cache key MUST include eps bits — different eps values compile to
137    // different PTX (the constant is baked into `mov.f32`).
138    let eps_bits = eps.to_bits();
139    let key = format!("batched_rmsnorm_fwd_{hidden_size}_eps{eps_bits:08x}");
140    let module = match cache.get_cached(&key) {
141        Some(m) => m,
142        None => {
143            let ptx = kernel.emit_ptx_for_target(cache.sm_target());
144            cache.get_or_compile(&key, &ptx)?
145        }
146    };
147
148    // Grid: (1, batch_size, 1) — one block per row, all rows in parallel
149    // Block: (256, 1, 1) — 8 warps per block for parallel reduction
150    let config = LaunchConfig {
151        grid: (1, batch_size, 1),
152        block: (256, 1, 1),
153        shared_mem: 8 * 4, // 8 warp partial sums (f32)
154    };
155
156    let input_ptr = input.as_ptr();
157    let output_ptr = output.as_ptr();
158    let gamma_ptr = gamma.as_ptr();
159
160    let mut args: [*mut std::ffi::c_void; 3] = [
161        &input_ptr as *const _ as *mut _,
162        &output_ptr as *const _ as *mut _,
163        &gamma_ptr as *const _ as *mut _,
164    ];
165
166    // SAFETY: Kernel launch requires FFI. input has batch_size * hidden_size elements,
167    // output has batch_size * hidden_size elements, gamma has hidden_size elements.
168    // Parameters match PTX signature (u64 input_ptr, u64 output_ptr, u64 gamma_ptr).
169    unsafe {
170        stream.launch_kernel(module, "batched_rmsnorm_vectorized", &config, &mut args).map_err(
171            |e| CudaTensorError::KernelError(format!("RMSNorm forward launch failed: {e:?}")),
172        )?;
173    }
174
175    Ok(())
176}
177
178/// Per-head RMSNorm forward pass on GPU (ENT-270: QK-norm for Qwen3).
179///
180/// Applies RMSNorm independently to each attention head:
181///   output[h] = input[h] / sqrt(mean(input[h]^2) + eps) * gamma
182///
183/// Input layout: `[num_heads * head_dim]` (single sequence position, interleaved).
184/// Gamma: `[head_dim]` (shared across all heads).
185///
186/// For seq_len > 1, call once per position (loop in caller).
187#[cfg(feature = "cuda")]
188pub fn per_head_rmsnorm_forward(
189    input: &GpuBuffer<f32>,
190    gamma: &GpuBuffer<f32>,
191    output: &mut GpuBuffer<f32>,
192    num_heads: u32,
193    head_dim: u32,
194    pos_offset: usize,
195    stream: &CudaStream,
196) -> Result<()> {
197    let cache = FORWARD_KERNEL_CACHE.get().ok_or(CudaTensorError::DeviceNotInitialized)?;
198    let mut cache = cache.lock().map_err(|_err| {
199        CudaTensorError::KernelError("Failed to acquire kernel cache lock".to_string())
200    })?;
201
202    let kernel = PerHeadRmsNormKernel::new(head_dim, num_heads);
203
204    let key = format!("per_head_rmsnorm_fwd_{head_dim}_{num_heads}");
205    let module = match cache.get_cached(&key) {
206        Some(m) => m,
207        None => {
208            let ptx = kernel.emit_ptx_for_target(cache.sm_target());
209            cache.get_or_compile(&key, &ptx)?
210        }
211    };
212
213    // One block per head, one warp (32 threads) per block
214    let config = LaunchConfig { grid: (num_heads, 1, 1), block: (32, 1, 1), shared_mem: 0 };
215
216    // Offset into the buffer for this position
217    let stride = (num_heads * head_dim) as usize;
218    let input_offset = pos_offset * stride;
219    let output_offset = pos_offset * stride;
220
221    // CUdeviceptr is u64 — use arithmetic, not pointer .add()
222    let input_ptr = input.as_ptr() + (input_offset * std::mem::size_of::<f32>()) as u64;
223    let output_ptr = output.as_ptr() + (output_offset * std::mem::size_of::<f32>()) as u64;
224    let gamma_ptr = gamma.as_ptr();
225
226    let mut args: [*mut std::ffi::c_void; 3] = [
227        &input_ptr as *const _ as *mut _,
228        &output_ptr as *const _ as *mut _,
229        &gamma_ptr as *const _ as *mut _,
230    ];
231
232    // SAFETY: launches a CUDA kernel via the driver API. The argument pointer array, grid/block config, and module/function name match the kernel's signature, and every referenced device buffer is allocated, correctly sized, and lives until the stream-ordered launch completes.
233    unsafe {
234        stream.launch_kernel(module, "per_head_rmsnorm", &config, &mut args).map_err(|e| {
235            CudaTensorError::KernelError(format!("PerHeadRmsNorm forward failed: {e:?}"))
236        })?;
237    }
238
239    Ok(())
240}
241
242/// RoPE (NeoX/half-rotation) forward pass on GPU (ENT-270).
243///
244/// Applies rotary position embeddings with half-rotation layout:
245///   pairs at (i, i + half_dim) — required for Qwen/LLaMA models.
246///
247/// Input layout: `[num_heads * head_dim]` (single sequence position, interleaved).
248///
249/// For seq_len > 1, call once per position with the position index.
250#[cfg(feature = "cuda")]
251pub fn rope_neox_forward(
252    input: &GpuBuffer<f32>,
253    output: &mut GpuBuffer<f32>,
254    num_heads: u32,
255    head_dim: u32,
256    pos: u32,
257    pos_offset: usize,
258    theta: f32,
259    stream: &CudaStream,
260) -> Result<()> {
261    let cache = FORWARD_KERNEL_CACHE.get().ok_or(CudaTensorError::DeviceNotInitialized)?;
262    let mut cache = cache.lock().map_err(|_err| {
263        CudaTensorError::KernelError("Failed to acquire kernel cache lock".to_string())
264    })?;
265
266    let kernel = RopeNeoxKernel::new(num_heads, head_dim, theta);
267
268    // FALSIFY-CUDA-ROPE-THETA-CACHE-KEY-001: theta is baked into the
269    // PTX at build_ptx time (RopeNeoxKernel::build_ptx captures
270    // self.theta into the closure as `mov.f32 imm`). Two calls with
271    // different theta values produce different PTX, so the cache key
272    // MUST include theta_bits — otherwise the first theta to populate
273    // the cache wins and subsequent calls silently use the wrong theta
274    // (e.g. Llama 1e4 caches first → Qwen 1e6 calls reuse 1e4 PTX).
275    let theta_bits = theta.to_bits();
276    let key = format!("rope_neox_fwd_{num_heads}_{head_dim}_th{theta_bits:08x}");
277    let module = match cache.get_cached(&key) {
278        Some(m) => m,
279        None => {
280            let ptx = kernel.emit_ptx_for_target(cache.sm_target());
281            cache.get_or_compile(&key, &ptx)?
282        }
283    };
284
285    // One block per head, half_dim threads per block
286    let config =
287        LaunchConfig { grid: (num_heads, 1, 1), block: (head_dim / 2, 1, 1), shared_mem: 0 };
288
289    // Offset into buffer for this position
290    let stride = (num_heads * head_dim) as usize;
291    let byte_offset = pos_offset * stride * std::mem::size_of::<f32>();
292
293    // CUdeviceptr is u64 — use arithmetic, not pointer .add()
294    let input_ptr = input.as_ptr() + byte_offset as u64;
295    let output_ptr = output.as_ptr() + byte_offset as u64;
296
297    let mut args: [*mut std::ffi::c_void; 3] = [
298        &input_ptr as *const _ as *mut _,
299        &output_ptr as *const _ as *mut _,
300        &pos as *const _ as *mut _,
301    ];
302
303    // SAFETY: launches a CUDA kernel via the driver API. The argument pointer array, grid/block config, and module/function name match the kernel's signature, and every referenced device buffer is allocated, correctly sized, and lives until the stream-ordered launch completes.
304    unsafe {
305        stream.launch_kernel(module, "rope_neox", &config, &mut args).map_err(|e| {
306            CudaTensorError::KernelError(format!("RoPE NeoX forward failed: {e:?}"))
307        })?;
308    }
309
310    Ok(())
311}
312
313/// Batched RoPE NeoX forward — processes all seq_len positions in a single kernel launch.
314///
315/// Replaces per-position `rope_neox_forward` loop to avoid ~2048 kernel launches per block.
316/// Uses Grid(num_heads, seq_len, 1) with positions read from a GPU buffer.
317///
318/// Input layout: `[seq_len, num_heads * head_dim]` (interleaved).
319#[cfg(feature = "cuda")]
320pub fn batched_rope_neox_forward(
321    input: &GpuBuffer<f32>,
322    output: &mut GpuBuffer<f32>,
323    positions: &GpuBuffer<u32>,
324    num_heads: u32,
325    head_dim: u32,
326    seq_len: u32,
327    theta: f32,
328    stream: &CudaStream,
329) -> Result<()> {
330    let cache = FORWARD_KERNEL_CACHE.get().ok_or(CudaTensorError::DeviceNotInitialized)?;
331    let mut cache = cache.lock().map_err(|_err| {
332        CudaTensorError::KernelError("Failed to acquire kernel cache lock".to_string())
333    })?;
334
335    // FALSIFY-CUDA-NF4-TRAIN-LOSS-PARITY-001: MUST be the NEOX split-half
336    // kernel. `BatchedRopeKernel` rotates ADJACENT pairs (GPT-J convention);
337    // wiring it here (ALB-119) rotated Q/K in the wrong basis for
338    // Qwen2/LLaMA weights -> finite-garbage CE ~13-14, flat loss, NaN adapters.
339    let kernel = BatchedRopeNeoxKernel::new(num_heads, head_dim, seq_len, theta);
340
341    // FALSIFY-CUDA-ROPE-THETA-CACHE-KEY-001: cache key MUST include
342    // theta_bits (and seq_len, which is also baked in via grid sizing).
343    // See `rope_neox_forward` rationale.
344    let theta_bits = theta.to_bits();
345    let key = format!("batched_rope_neox_fwd_{num_heads}_{head_dim}_{seq_len}_th{theta_bits:08x}");
346    let module = match cache.get_cached(&key) {
347        Some(m) => m,
348        None => {
349            let ptx = kernel.emit_ptx_for_target(cache.sm_target());
350            cache.get_or_compile(&key, &ptx)?
351        }
352    };
353
354    let config =
355        LaunchConfig { grid: (num_heads, seq_len, 1), block: (head_dim / 2, 1, 1), shared_mem: 0 };
356
357    let input_ptr = input.as_ptr();
358    let output_ptr = output.as_ptr();
359    let positions_ptr = positions.as_ptr();
360
361    let mut args: [*mut std::ffi::c_void; 3] = [
362        &input_ptr as *const _ as *mut _,
363        &output_ptr as *const _ as *mut _,
364        &positions_ptr as *const _ as *mut _,
365    ];
366
367    // SAFETY: launches a CUDA kernel via the driver API. The argument pointer array, grid/block config, and module/function name match the kernel's signature, and every referenced device buffer is allocated, correctly sized, and lives until the stream-ordered launch completes.
368    unsafe {
369        stream.launch_kernel(module, "batched_rope_neox", &config, &mut args).map_err(|e| {
370            CudaTensorError::KernelError(format!("Batched RoPE NeoX forward failed: {e:?}"))
371        })?;
372    }
373
374    Ok(())
375}
376
377/// Batched RoPE NeoX backward — inverse rotation for gradient flow.
378///
379/// Applies R^T(-θ) to gradients so Q/K projection backward receives
380/// correctly-framed gradients. Without this, dW_q and dW_k are computed
381/// in the rotated coordinate frame, producing incorrect weight updates.
382#[cfg(feature = "cuda")]
383pub fn batched_rope_neox_backward(
384    grad_input: &GpuBuffer<f32>,
385    grad_output: &mut GpuBuffer<f32>,
386    positions: &GpuBuffer<u32>,
387    num_heads: u32,
388    head_dim: u32,
389    seq_len: u32,
390    theta: f32,
391    stream: &CudaStream,
392) -> Result<()> {
393    let cache = FORWARD_KERNEL_CACHE.get().ok_or(CudaTensorError::DeviceNotInitialized)?;
394    let mut cache = cache.lock().map_err(|_err| {
395        CudaTensorError::KernelError("Failed to acquire kernel cache lock".to_string())
396    })?;
397
398    // FALSIFY-CUDA-NF4-TRAIN-LOSS-PARITY-001: NEOX transpose rotation (must
399    // mirror the NEOX forward above, not the adjacent-pair kernel).
400    let kernel = BatchedRopeNeoxBackwardKernel::new(num_heads, head_dim, seq_len, theta);
401
402    // FALSIFY-CUDA-ROPE-THETA-CACHE-KEY-001: cache key MUST include
403    // theta_bits. See `rope_neox_forward` rationale.
404    let theta_bits = theta.to_bits();
405    let key = format!("batched_rope_neox_bwd_{num_heads}_{head_dim}_{seq_len}_th{theta_bits:08x}");
406    let module = match cache.get_cached(&key) {
407        Some(m) => m,
408        None => {
409            let ptx = kernel.emit_ptx_for_target(cache.sm_target());
410            cache.get_or_compile(&key, &ptx)?
411        }
412    };
413
414    let config =
415        LaunchConfig { grid: (num_heads, seq_len, 1), block: (head_dim / 2, 1, 1), shared_mem: 0 };
416
417    let input_ptr = grad_input.as_ptr();
418    let output_ptr = grad_output.as_ptr();
419    let positions_ptr = positions.as_ptr();
420
421    let mut args: [*mut std::ffi::c_void; 3] = [
422        &input_ptr as *const _ as *mut _,
423        &output_ptr as *const _ as *mut _,
424        &positions_ptr as *const _ as *mut _,
425    ];
426
427    // SAFETY: launches a CUDA kernel via the driver API. The argument pointer array, grid/block config, and module/function name match the kernel's signature, and every referenced device buffer is allocated, correctly sized, and lives until the stream-ordered launch completes.
428    unsafe {
429        stream.launch_kernel(module, "batched_rope_neox_backward", &config, &mut args).map_err(
430            |e| CudaTensorError::KernelError(format!("Batched RoPE NeoX backward failed: {e:?}")),
431        )?;
432    }
433
434    Ok(())
435}
436
437/// Fused residual add + RMSNorm forward: output = RMSNorm(residual + input, gamma)
438///
439/// Contract: entrenar#321 — eliminates NaN cascade in layers 24-27 by fusing
440/// the residual add with RMSNorm into a single kernel pass. The RMSNorm
441/// normalization prevents activation explosion through the residual chain.
442///
443/// Saves the un-normalized residual sum in `residual_out` for backward pass.
444///
445/// # Parameters
446/// - `residual`: Previous layer output (residual connection input)
447/// - `input`: Current block output to add
448/// - `residual_out`: Stores residual + input (for backward, can alias residual)
449/// - `output`: RMSNorm(residual + input) * gamma
450/// - `gamma`: Scale weights (hidden_size elements)
451/// - `batch_size`: Number of rows (seq_len)
452/// - `hidden_size`: Number of columns per row
453#[cfg(feature = "cuda")]
454pub fn fused_residual_rmsnorm_forward(
455    residual: &GpuBuffer<f32>,
456    input: &GpuBuffer<f32>,
457    residual_out: &mut GpuBuffer<f32>,
458    output: &mut GpuBuffer<f32>,
459    gamma: &GpuBuffer<f32>,
460    batch_size: u32,
461    hidden_size: u32,
462    eps: f32,
463    stream: &CudaStream,
464) -> Result<()> {
465    // FALSIFY-CUDA-FUSED-RMSNORM-DEADLOCK-001 (wave of 4, all fixed here):
466    //
467    // 1. Self-deadlock: this function held the FORWARD_KERNEL_CACHE mutex
468    //    guard while calling the public `residual_add_forward`, which
469    //    re-locks the SAME non-reentrant `std::sync::Mutex` on the same
470    //    thread — `Mutex::lock_contended` futex-waited forever and froze
471    //    every `apr finetune -m qlora` run on the first transformer block
472    //    forward. Fixed structurally: the batched kernel writes
473    //    `residual_out` itself, so the nested call is gone entirely.
474    // 2. Single-row kernel launched as batched: the old
475    //    `FusedResidualRmsNormKernel` has no ctaid indexing (one warp, one
476    //    row) but was launched with grid.y = batch_size — every block
477    //    redundantly computed row 0 and rows 1.. were never written.
478    //    `BatchedFusedResidualRmsNormKernel` (PMAT-092) indexes rows via
479    //    ctaid.y.
480    // 3. eps not threaded: the kernel default (1e-5, Llama) was silently
481    //    used for Qwen2 models (1e-6). Callers now pass
482    //    `config.rms_norm_eps`, and the cache key includes the eps bits
483    //    (PMAT-698k lesson: eps-less keys shadow stale PTX).
484    // 4. Missing pre-warm: this kernel JIT-compiled mid-training
485    //    (Blackwell stream-poisoning class, PMAT-698). pre_warm_for_model
486    //    now warms it at both Qwen2 (1e-6) and Llama (1e-5) eps.
487    let cache = FORWARD_KERNEL_CACHE.get().ok_or(CudaTensorError::DeviceNotInitialized)?;
488    let mut cache = cache.lock().map_err(|_err| {
489        CudaTensorError::KernelError("Failed to acquire kernel cache lock".to_string())
490    })?;
491
492    let eps_bits = eps.to_bits();
493    let key = format!("batched_fused_residual_rmsnorm_{hidden_size}_eps{eps_bits:08x}");
494    let module = match cache.get_cached(&key) {
495        Some(m) => m,
496        None => {
497            let kernel =
498                BatchedFusedResidualRmsNormKernel::new(hidden_size, batch_size).with_epsilon(eps);
499            let ptx = kernel.emit_ptx_for_target(cache.sm_target());
500            cache.get_or_compile(&key, &ptx)?
501        }
502    };
503
504    // Grid: (1, batch_size, 1) — one block per row via ctaid.y
505    // Block: (256, 1, 1) — 8 warps; shared: 8 warp partial sums (f32)
506    let config = LaunchConfig { grid: (1, batch_size, 1), block: (256, 1, 1), shared_mem: 8 * 4 };
507
508    let residual_ptr = residual.as_ptr();
509    let input_ptr = input.as_ptr();
510    let residual_out_ptr = residual_out.as_ptr();
511    let output_ptr = output.as_ptr();
512    let gamma_ptr = gamma.as_ptr();
513
514    let mut args: [*mut std::ffi::c_void; 5] = [
515        &residual_ptr as *const _ as *mut _,
516        &input_ptr as *const _ as *mut _,
517        &residual_out_ptr as *const _ as *mut _,
518        &output_ptr as *const _ as *mut _,
519        &gamma_ptr as *const _ as *mut _,
520    ];
521
522    // Launch fused kernel:
523    //   residual_out = residual + input
524    //   output       = RMSNorm(residual + input) * gamma
525    // (`residual_out` may alias `residual`: pass 1 reads each element before
526    // writing it back, per-thread, so the aliased store is ordered safely.)
527    // SAFETY: launches a CUDA kernel via the driver API. The argument pointer array, grid/block config, and module/function name match the kernel's signature, and every referenced device buffer is allocated, correctly sized, and lives until the stream-ordered launch completes.
528    unsafe {
529        stream
530            .launch_kernel(module, "batched_fused_residual_rmsnorm", &config, &mut args)
531            .map_err(|e| {
532                CudaTensorError::KernelError(format!(
533                    "Fused residual+RMSNorm forward failed: {e:?}"
534                ))
535            })?;
536    }
537
538    Ok(())
539}
540
541#[cfg(all(test, feature = "cuda"))]
542mod tests {
543    use super::*;
544    use crate::autograd::cuda_forward::cache::init_forward_kernel_cache;
545    use crate::autograd::cuda_tensor::CudaDevice;
546    use trueno_gpu::driver::GpuBuffer;
547
548    /// Reference CPU RMSNorm matching the kernel's exact arithmetic order:
549    /// rms = sqrt(mean(x^2) + eps); y = (x / rms) * gamma.
550    fn cpu_rmsnorm_reference(input: &[f32], gamma: &[f32], eps: f32) -> Vec<f32> {
551        let n = input.len() as f32;
552        let mean_sq: f32 = input.iter().map(|v| v * v).sum::<f32>() / n;
553        let rms = (mean_sq + eps).sqrt();
554        input.iter().zip(gamma.iter()).map(|(&x, &g)| (x / rms) * g).collect()
555    }
556
557    /// FALSIFY-CUDA-RMSNORM-EPS-PARITY-001: With Qwen's eps=1e-6 the
558    /// CUDA `rms_norm_forward_with_eps` MUST match the CPU reference to
559    /// within 1e-5 absolute. The legacy `rms_norm_forward` (eps=1e-5
560    /// hardcoded) cannot meet this bound on Qwen because the eps in the
561    /// kernel disagrees with the reference's eps.
562    ///
563    /// On main pre-fix this test FAILS for `rms_norm_forward` (legacy)
564    /// because the kernel uses eps=1e-5 while the CPU ref uses 1e-6.
565    /// Post-fix `rms_norm_forward_with_eps(eps=1e-6)` passes by
566    /// construction — the kernel compiles with the same eps the
567    /// reference uses, so diffs are bounded by f32 round-off only.
568    #[test]
569    fn falsify_cuda_rmsnorm_eps_parity_qwen_1e_minus_6() {
570        let device = match CudaDevice::default_device() {
571            Ok(d) => d,
572            Err(e) => {
573                eprintln!("[falsify-cuda-rmsnorm-eps-parity-001] skipping (no CUDA host): {e}");
574                return;
575            }
576        };
577        let ctx = device.context().clone();
578        let stream = device.stream();
579        if let Err(e) = init_forward_kernel_cache(ctx.clone()) {
580            eprintln!("[falsify-cuda-rmsnorm-eps-parity-001] kernel cache init failed: {e}");
581            return;
582        }
583
584        // Qwen 0.5B hidden size; values intentionally small so mean_sq is
585        // small enough that the eps difference 1e-5 vs 1e-6 actually
586        // moves the rms denominator measurably. Real Qwen activations
587        // post-embedding have std~0.02 so this is realistic.
588        let hidden_size = 896usize;
589        let batch_size = 4u32;
590        let total = batch_size as usize * hidden_size;
591        let input_data: Vec<f32> =
592            (0..total).map(|i| (((i as f32) * 0.013).sin()) * 0.02).collect();
593        let gamma_data: Vec<f32> =
594            (0..hidden_size).map(|i| 1.0 + ((i as f32) * 0.005).cos() * 0.1).collect();
595
596        // Build CPU reference once; it's the same per-row.
597        let mut cpu_out = Vec::with_capacity(total);
598        for b in 0..batch_size as usize {
599            let row = &input_data[b * hidden_size..(b + 1) * hidden_size];
600            cpu_out.extend(cpu_rmsnorm_reference(row, &gamma_data, 1e-6));
601        }
602
603        let input_gpu = GpuBuffer::from_host(&ctx, &input_data).expect("input");
604        let gamma_gpu = GpuBuffer::from_host(&ctx, &gamma_data).expect("gamma");
605        let mut output_gpu = GpuBuffer::<f32>::new(&ctx, total).expect("output alloc");
606
607        rms_norm_forward_with_eps(
608            &input_gpu,
609            &gamma_gpu,
610            &mut output_gpu,
611            batch_size,
612            hidden_size as u32,
613            1e-6,
614            stream,
615        )
616        .expect("kernel launch");
617        stream.synchronize().expect("sync");
618
619        let mut gpu_out = vec![0.0f32; total];
620        output_gpu.copy_to_host(&mut gpu_out).expect("download");
621
622        let max_diff =
623            cpu_out.iter().zip(gpu_out.iter()).map(|(c, g)| (c - g).abs()).fold(0.0f32, f32::max);
624
625        eprintln!("[falsify-cuda-rmsnorm-eps-parity-001] max_diff={max_diff} (Qwen eps=1e-6)");
626        assert!(
627            max_diff < 1e-4,
628            "FALSIFY-CUDA-RMSNORM-EPS-PARITY-001: max_diff={max_diff} >= 1e-4. \
629             CUDA RMSNorm kernel disagrees with CPU reference at Qwen eps=1e-6. \
630             Pre-fix root cause: BatchedVectorizedRmsNormKernel::new hardcodes \
631             epsilon=1e-5 (Llama default) so calling `rms_norm_forward` for \
632             Qwen2 silently uses the wrong eps. Fix: \
633             `rms_norm_forward_with_eps(.., eps, ..)` threads `config.rms_norm_eps` \
634             into the kernel and the cache key includes eps bits to avoid stale \
635             PTX shadowing. See contract apr-pretrain-cuda-rmsnorm-eps-parity-v1.yaml."
636        );
637    }
638
639    /// FALSIFY-CUDA-FUSED-RMSNORM-DEADLOCK-001: `fused_residual_rmsnorm_forward`
640    /// with a `residual_out` buffer DISTINCT from `residual` must complete
641    /// (liveness) and produce correct results (oracle).
642    ///
643    /// On main pre-fix this test DEADLOCKS: the function acquired the
644    /// `FORWARD_KERNEL_CACHE` mutex guard for its whole body, then called the
645    /// public `residual_add_forward` (normalization.rs:494) which re-locks the
646    /// SAME non-reentrant `std::sync::Mutex` on the same thread →
647    /// `Mutex::lock_contended` futex-waits forever. This froze every
648    /// `apr finetune -m qlora` run on the first `CudaNf4TransformerBlock::
649    /// forward` (gdb: thread 1 stuck in lock_contended ← residual_add_forward
650    /// ← fused_residual_rmsnorm_forward ← CudaNf4TransformerBlock::forward).
651    ///
652    /// The watchdog thread converts the pre-fix deadlock into a bounded test
653    /// failure instead of a hung test binary.
654    ///
655    /// Oracle (not just liveness): `residual_out == residual + input`
656    /// element-wise, and `output` matches the CPU RMSNorm reference of
657    /// `residual + input` at the threaded eps=1e-6 (Qwen2). The output
658    /// oracle also caught the single-row kernel being launched as batched
659    /// (rows 1.. never written, max_diff=2.36 vs reference) — see the
660    /// wave-of-4 fix note in `fused_residual_rmsnorm_forward`.
661    #[test]
662    fn falsify_cuda_fused_rmsnorm_distinct_residual_out_no_deadlock() {
663        use std::sync::mpsc;
664        use std::time::Duration;
665
666        let hidden_size = 1536usize; // Qwen2.5-Coder-1.5B hidden (live repro dims)
667        let batch_size = 4u32;
668        let total = batch_size as usize * hidden_size;
669
670        let residual_data: Vec<f32> =
671            (0..total).map(|i| (((i as f32) * 0.017).sin()) * 0.02).collect();
672        let input_data: Vec<f32> =
673            (0..total).map(|i| (((i as f32) * 0.011).cos()) * 0.02).collect();
674        let gamma_data: Vec<f32> =
675            (0..hidden_size).map(|i| 1.0 + ((i as f32) * 0.007).cos() * 0.1).collect();
676
677        enum Outcome {
678            NoCuda(String),
679            Done { residual_out: Vec<f32>, output: Vec<f32> },
680        }
681
682        let (tx, rx) = mpsc::channel();
683        let res_clone = residual_data.clone();
684        let inp_clone = input_data.clone();
685        let gam_clone = gamma_data.clone();
686        // Detached worker: on the pre-fix deadlock it blocks forever and the
687        // watchdog below fails the test after the timeout instead of hanging.
688        std::thread::spawn(move || {
689            let device = match CudaDevice::default_device() {
690                Ok(d) => d,
691                Err(e) => {
692                    let _ = tx.send(Outcome::NoCuda(format!("{e}")));
693                    return;
694                }
695            };
696            let ctx = device.context().clone();
697            let stream = device.stream();
698            if let Err(e) = init_forward_kernel_cache(ctx.clone()) {
699                let _ = tx.send(Outcome::NoCuda(format!("cache init: {e}")));
700                return;
701            }
702
703            let residual_gpu = GpuBuffer::from_host(&ctx, &res_clone).expect("residual");
704            let input_gpu = GpuBuffer::from_host(&ctx, &inp_clone).expect("input");
705            let gamma_gpu = GpuBuffer::from_host(&ctx, &gam_clone).expect("gamma");
706            // DISTINCT residual_out buffer — the deadlock precondition.
707            let mut residual_out_gpu =
708                GpuBuffer::<f32>::new(&ctx, res_clone.len()).expect("residual_out alloc");
709            let mut output_gpu =
710                GpuBuffer::<f32>::new(&ctx, res_clone.len()).expect("output alloc");
711
712            fused_residual_rmsnorm_forward(
713                &residual_gpu,
714                &input_gpu,
715                &mut residual_out_gpu,
716                &mut output_gpu,
717                &gamma_gpu,
718                batch_size,
719                hidden_size as u32,
720                1e-6, // Qwen2 rms_norm_eps (the live repro model family)
721                stream,
722            )
723            .expect("fused_residual_rmsnorm_forward");
724            stream.synchronize().expect("sync");
725
726            let mut residual_out = vec![0.0f32; res_clone.len()];
727            let mut output = vec![0.0f32; res_clone.len()];
728            residual_out_gpu.copy_to_host(&mut residual_out).expect("download residual_out");
729            output_gpu.copy_to_host(&mut output).expect("download output");
730            let _ = tx.send(Outcome::Done { residual_out, output });
731        });
732
733        let outcome = rx.recv_timeout(Duration::from_secs(120)).unwrap_or_else(|_| {
734            panic!(
735                "FALSIFY-CUDA-FUSED-RMSNORM-DEADLOCK-001: \
736                 fused_residual_rmsnorm_forward did not complete within 120s with a \
737                 distinct residual_out buffer. Pre-fix root cause: the function held \
738                 the FORWARD_KERNEL_CACHE mutex guard while calling the public \
739                 residual_add_forward, which re-locks the same non-reentrant mutex \
740                 on the same thread (self-deadlock). Fix: enqueue the residual add \
741                 BEFORE acquiring the cache lock."
742            )
743        });
744
745        let (residual_out, output) = match outcome {
746            Outcome::NoCuda(reason) => {
747                eprintln!(
748                    "[falsify-cuda-fused-rmsnorm-deadlock-001] skipping (no CUDA host): {reason}"
749                );
750                return;
751            }
752            Outcome::Done { residual_out, output } => (residual_out, output),
753        };
754
755        // Oracle 1: residual_out = residual + input (single f32 add — exact).
756        let max_add_diff = residual_data
757            .iter()
758            .zip(input_data.iter())
759            .zip(residual_out.iter())
760            .map(|((r, i), out)| (r + i - out).abs())
761            .fold(0.0f32, f32::max);
762        assert!(
763            max_add_diff == 0.0,
764            "FALSIFY-CUDA-FUSED-RMSNORM-DEADLOCK-001: residual_out != residual + input \
765             (max_diff={max_add_diff})"
766        );
767
768        // Oracle 2: output = RMSNorm(residual + input) * gamma at the eps the
769        // caller threads through (1e-6, Qwen2). Pre-fix the kernel silently
770        // used its 1e-5 default AND only ever wrote row 0 (single-row kernel
771        // launched with grid.y = batch_size), so this oracle also falsifies
772        // the batched-row and eps-threading defects, not just liveness.
773        let summed: Vec<f32> =
774            residual_data.iter().zip(input_data.iter()).map(|(r, i)| r + i).collect();
775        let mut cpu_out = Vec::with_capacity(total);
776        for b in 0..batch_size as usize {
777            let row = &summed[b * hidden_size..(b + 1) * hidden_size];
778            cpu_out.extend(cpu_rmsnorm_reference(row, &gamma_data, 1e-6));
779        }
780        let max_norm_diff =
781            cpu_out.iter().zip(output.iter()).map(|(c, g)| (c - g).abs()).fold(0.0f32, f32::max);
782        assert!(
783            max_norm_diff < 1e-4,
784            "FALSIFY-CUDA-FUSED-RMSNORM-DEADLOCK-001: output disagrees with CPU \
785             RMSNorm(residual+input) reference (max_diff={max_norm_diff})"
786        );
787    }
788}