Skip to main content

entrenar/autograd/
cuda_training.rs

1//! CUDA-accelerated training utilities
2//!
3//! This module provides high-level training primitives that use CUDA kernels
4//! when available, with automatic CPU fallback.
5//!
6//! # Architecture (SPEC-FT-001 v3.2.0)
7//!
8//! ```text
9//! CudaTrainer
10//!   ├── device: CudaDevice
11//!   ├── forward: gemm_forward kernel
12//!   ├── backward: gemm_backward_a/b kernels
13//!   └── optimizer: adamw_step_cuda kernel
14//! ```
15//!
16//! # Example
17//!
18//! ```ignore
19//! use entrenar::autograd::cuda_training::CudaTrainer;
20//!
21//! let trainer = CudaTrainer::new()?;
22//! let logits = trainer.matmul_forward(&hidden, &weights, m, k, n)?;
23//! trainer.adamw_step(&mut weights, &grads, lr, step)?;
24//! ```
25
26#[cfg(feature = "cuda")]
27use std::sync::Arc;
28
29#[cfg(feature = "cuda")]
30use trueno_gpu::driver::{cuda_available, CudaContext, CudaStream, GpuBuffer};
31
32use super::cuda_tensor::{CudaTensorError, Result};
33#[cfg(feature = "cuda")]
34use provable_contracts_macros::requires;
35
36#[cfg(feature = "cuda")]
37use super::cuda_backward::{gemm_backward_a, gemm_backward_b, init_kernel_cache};
38#[cfg(feature = "cuda")]
39use super::cuda_forward::{gemm_forward, init_forward_kernel_cache};
40#[cfg(feature = "cuda")]
41use super::cuda_optim::{adamw_step_cuda, gradient_clip_cuda, init_optim_kernel_cache};
42
43/// CUDA-accelerated training context
44///
45/// Manages GPU resources and provides high-level training operations.
46#[cfg(feature = "cuda")]
47pub struct CudaTrainer {
48    ctx: Arc<CudaContext>,
49    stream: CudaStream,
50    step: u32,
51}
52
53#[cfg(feature = "cuda")]
54impl CudaTrainer {
55    /// Create a new CUDA trainer on the default GPU
56    pub fn new() -> Result<Self> {
57        Self::with_device(0)
58    }
59
60    /// Create a new CUDA trainer on the specified GPU
61    pub fn with_device(device_id: i32) -> Result<Self> {
62        if !cuda_available() {
63            return Err(CudaTensorError::CudaNotAvailable("No CUDA driver found".into()));
64        }
65
66        let ctx = Arc::new(
67            CudaContext::new(device_id)
68                .map_err(|e| CudaTensorError::CudaNotAvailable(format!("{e:?}")))?,
69        );
70        let stream = CudaStream::new(&ctx)
71            .map_err(|e| CudaTensorError::AllocationFailed(format!("{e:?}")))?;
72
73        // Initialize all kernel caches
74        init_forward_kernel_cache(ctx.clone())?;
75        init_kernel_cache(ctx.clone())?;
76        init_optim_kernel_cache(ctx.clone())?;
77
78        // FALSIFY-CUDA-NF4-FORWARD-NAN-001: no bind-once cuBLAS stream setup
79        // here. Every cuBLAS dispatch site binds the handle to the CALLER's
80        // stream per call (see cuda_forward::bind_cublas_stream) — a global
81        // bind-once would dangle when the owning trainer (and its stream)
82        // drops while the process-global handle lives on.
83
84        Ok(Self { ctx, stream, step: 0 })
85    }
86
87    /// Get the CUDA context
88    pub fn context(&self) -> &Arc<CudaContext> {
89        &self.ctx
90    }
91
92    /// Get the CUDA stream
93    pub fn stream(&self) -> &CudaStream {
94        &self.stream
95    }
96
97    /// Synchronize the stream (wait for all operations to complete)
98    pub fn synchronize(&self) -> Result<()> {
99        self.stream.synchronize().map_err(|e| CudaTensorError::KernelError(format!("{e:?}")))
100    }
101
102    /// Allocate a GPU buffer from host data
103    pub fn upload(&self, data: &[f32]) -> Result<GpuBuffer<f32>> {
104        let mut buf = GpuBuffer::from_host(&self.ctx, data)
105            .map_err(|e| CudaTensorError::AllocationFailed(format!("{e:?}")))?;
106        // PMAT-420: Set context for thread-safe transfers
107        buf.set_context(&self.ctx);
108        Ok(buf)
109    }
110
111    /// Allocate a zero-initialized GPU buffer
112    pub fn zeros(&self, len: usize) -> Result<GpuBuffer<f32>> {
113        let data = vec![0.0f32; len];
114        self.upload(&data)
115    }
116
117    /// Query free VRAM in MB (via cuMemGetInfo).
118    /// Returns None if query fails.
119    pub fn free_memory_mb(&self) -> Option<u64> {
120        self.ctx.memory_info().map(|(free, _total)| (free / (1024 * 1024)) as u64).ok()
121    }
122
123    /// Download GPU buffer to host
124    pub fn download(&self, buffer: &GpuBuffer<f32>) -> Result<Vec<f32>> {
125        let mut result = vec![0.0f32; buffer.len()];
126        buffer
127            .copy_to_host(&mut result)
128            .map_err(|e| CudaTensorError::TransferFailed(format!("{e:?}")))?;
129        Ok(result)
130    }
131
132    /// Matrix multiply forward pass: C = A @ B
133    ///
134    /// # Arguments
135    /// - `a`: Input matrix (m × k)
136    /// - `b`: Weight matrix (k × n)
137    /// - `c`: Output matrix (m × n)
138    /// - `m`, `k`, `n`: Matrix dimensions
139    pub fn matmul_forward(
140        &self,
141        a: &GpuBuffer<f32>,
142        b: &GpuBuffer<f32>,
143        c: &mut GpuBuffer<f32>,
144        m: u32,
145        k: u32,
146        n: u32,
147    ) -> Result<()> {
148        gemm_forward(a, b, c, m, k, n, &self.stream)
149    }
150
151    /// Matrix multiply backward pass for weight gradients
152    ///
153    /// Given C = A @ B, computes:
154    /// - grad_A = grad_C @ B^T
155    /// - grad_B = A^T @ grad_C
156    // Contract: backward-pass-v1 / matmul_backward
157    #[requires(m > 0 && k > 0 && n > 0)]
158    pub fn matmul_backward(
159        &self,
160        a: &GpuBuffer<f32>,
161        b: &GpuBuffer<f32>,
162        grad_c: &GpuBuffer<f32>,
163        grad_a: &mut GpuBuffer<f32>,
164        grad_b: &mut GpuBuffer<f32>,
165        m: u32,
166        k: u32,
167        n: u32,
168    ) -> Result<()> {
169        gemm_backward_a(grad_c, b, grad_a, m, k, n, &self.stream)?;
170        gemm_backward_b(a, grad_c, grad_b, m, k, n, &self.stream)?;
171        Ok(())
172    }
173
174    /// AdamW optimizer step on GPU
175    ///
176    /// Updates weights in-place using the AdamW algorithm.
177    pub fn adamw_step(
178        &mut self,
179        params: &mut GpuBuffer<f32>,
180        grads: &GpuBuffer<f32>,
181        m_state: &mut GpuBuffer<f32>,
182        v_state: &mut GpuBuffer<f32>,
183        lr: f32,
184        beta1: f32,
185        beta2: f32,
186        eps: f32,
187        weight_decay: f32,
188    ) -> Result<()> {
189        self.step += 1;
190        let n = params.len() as u32;
191        adamw_step_cuda(
192            params,
193            grads,
194            m_state,
195            v_state,
196            lr,
197            beta1,
198            beta2,
199            eps,
200            weight_decay,
201            self.step,
202            n,
203            &self.stream,
204        )
205    }
206
207    /// Apply gradient clipping
208    pub fn clip_gradients(&self, grads: &mut GpuBuffer<f32>, max_norm: f32) -> Result<()> {
209        // Compute gradient norm on CPU (requires download)
210        let grad_data = self.download(grads)?;
211        let grad_norm: f32 = grad_data.iter().map(|x| x * x).sum::<f32>().sqrt();
212
213        // Compute scale factor
214        let scale = if grad_norm > max_norm { max_norm / grad_norm } else { 1.0 };
215
216        // Apply clipping on GPU
217        gradient_clip_cuda(grads, scale, grads.len() as u32, &self.stream)
218    }
219
220    /// Get current optimizer step count
221    pub fn step_count(&self) -> u32 {
222        self.step
223    }
224
225    /// Reset optimizer step count (for new training run)
226    pub fn reset_step(&mut self) {
227        self.step = 0;
228    }
229
230    /// Get device name
231    pub fn device_name(&self) -> String {
232        self.ctx.device_name().unwrap_or_else(|_err| "Unknown GPU".to_string())
233    }
234
235    /// Get total GPU memory in bytes
236    pub fn total_memory(&self) -> usize {
237        self.ctx.total_memory().unwrap_or(0)
238    }
239}
240
241#[cfg(feature = "cuda")]
242#[allow(clippy::missing_fields_in_debug)]
243impl std::fmt::Debug for CudaTrainer {
244    fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
245        f.debug_struct("CudaTrainer")
246            .field("device", &self.device_name())
247            .field("memory_gb", &(self.total_memory() as f64 / 1e9))
248            .field("step", &self.step)
249            .finish()
250    }
251}
252
253// CPU fallback when CUDA is not available
254#[cfg(not(feature = "cuda"))]
255pub struct CudaTrainer;
256
257#[cfg(not(feature = "cuda"))]
258impl CudaTrainer {
259    pub fn new() -> Result<Self> {
260        Err(CudaTensorError::CudaNotAvailable("Compiled without CUDA support".into()))
261    }
262}
263
264/// Check if CUDA training is available
265pub fn cuda_training_available() -> bool {
266    #[cfg(feature = "cuda")]
267    {
268        trueno_gpu::driver::cuda_available()
269    }
270    #[cfg(not(feature = "cuda"))]
271    {
272        false
273    }
274}
275
276#[cfg(test)]
277mod tests {
278    use super::*;
279
280    #[test]
281    fn test_cuda_training_available() {
282        // Just verify the function compiles and runs
283        let _ = cuda_training_available();
284    }
285
286    #[test]
287    #[cfg(feature = "cuda")]
288    fn test_cuda_trainer_creation() {
289        if !cuda_training_available() {
290            return;
291        }
292
293        let trainer = CudaTrainer::new();
294        assert!(trainer.is_ok());
295
296        let trainer = trainer.expect("operation should succeed");
297        assert!(!trainer.device_name().is_empty());
298        assert!(trainer.total_memory() > 0);
299    }
300
301    /// FALSIFY-CUDA-NF4-FORWARD-NAN-001
302    /// (contract: cuda-nf4-forward-stream-ordering-v1.yaml)
303    ///
304    /// Cross-stream ordering oracle: PTX kernels enqueue on the trainer's
305    /// CU_STREAM_NON_BLOCKING stream while cuBLAS GEMMs execute on whatever
306    /// stream the handle is bound to. Every cuBLAS dispatch site MUST bind
307    /// the handle to the caller's stream (`bind_cublas_stream`); otherwise
308    /// cuBLAS runs on the legacy default stream, which a non-blocking
309    /// stream does NOT implicitly synchronize with — the GEMM reads its
310    /// input while the producer kernels are still writing it.
311    ///
312    /// This is the root cause of the `apr finetune -m qlora` NaN-loss
313    /// defect: rmsnorm→QKV-GEMM, softmax→scores@V and final-norm→lm_head
314    /// all raced, producing NaN on the FIRST training step (~all steps at
315    /// seq≥512, ~1/3 at seq~30). The oracle below makes the race
316    /// deterministic: a ~20ms chain of elementwise adds on the trainer
317    /// stream produces X = 1+STEPS everywhere; the cuBLAS row-sum GEMM
318    /// issued right behind it reads X mid-chain when unbound (RED) and
319    /// the exact final value when bound (GREEN).
320    ///
321    /// Skips (passes vacuously) when CUDA or cuBLAS is unavailable —
322    /// without cuBLAS `gemm_forward` falls back to a PTX kernel on the
323    /// trainer stream and no cross-stream boundary exists.
324    #[test]
325    #[cfg(feature = "cuda")]
326    fn falsify_cuda_nf4_forward_nan_001_cublas_stream_ordering() {
327        use crate::autograd::cuda_forward::{gemm_forward, residual_add_forward};
328
329        if !cuda_training_available() {
330            return;
331        }
332        let trainer = CudaTrainer::new().expect("CUDA trainer");
333        let stream = trainer.stream();
334
335        const DIM: usize = 4096;
336        const STEPS: usize = 100; // ~20ms of producer work on the trainer stream
337        let n = DIM * DIM;
338
339        let mut buf_x = trainer.upload(&vec![1.0f32; n]).expect("upload x");
340        let mut buf_y = trainer.zeros(n).expect("alloc y");
341        let delta = trainer.upload(&vec![1.0f32; n]).expect("upload delta");
342        let ones_col = trainer.upload(&vec![1.0f32; DIM]).expect("upload ones");
343        let mut out = trainer.zeros(DIM).expect("alloc out");
344
345        // Pre-warm cuBLAS + the PTX add kernel OUTSIDE the timed race:
346        // the first SGEMM per handle pays lazy kernel-selection cost
347        // (tens of ms host-side) that would otherwise let the producer
348        // chain drain before the consumer is even issued, masking the
349        // missing stream binding. Quiesce both streams afterwards.
350        gemm_forward(&buf_x, &ones_col, &mut out, DIM as u32, DIM as u32, 1, stream)
351            .expect("cuBLAS warm-up gemm");
352        residual_add_forward(&buf_x, &delta, &mut buf_y, n as u32, stream).expect("warm-up add");
353        trainer.synchronize().expect("trainer stream quiesce");
354        let _ = trainer.download(&out).expect("NULL stream quiesce");
355        // Reset X to 1.0 after warm-up (buf_y holds warm-up garbage; the
356        // chain below overwrites it on the first iteration).
357        buf_x.copy_from_host(&vec![1.0f32; n]).expect("reset x");
358
359        // Producer chain on the trainer stream: X ends as (1 + STEPS) everywhere.
360        for _ in 0..STEPS {
361            residual_add_forward(&buf_x, &delta, &mut buf_y, n as u32, stream)
362                .expect("residual add");
363            std::mem::swap(&mut buf_x, &mut buf_y);
364        }
365
366        // Consumer: cuBLAS row-sum GEMM, out[DIM,1] = X[DIM,DIM] @ ones[DIM,1].
367        // Issued from the host immediately — if the handle is unbound this
368        // overlaps the still-running add chain on the default stream.
369        gemm_forward(&buf_x, &ones_col, &mut out, DIM as u32, DIM as u32, 1, stream)
370            .expect("cuBLAS gemm");
371
372        trainer.synchronize().expect("stream sync");
373        let result = trainer.download(&out).expect("download out");
374
375        let expected = (1.0 + STEPS as f32) * DIM as f32; // 413,696
376        let worst = result.iter().fold(0.0f32, |acc, &v| acc.max((v - expected).abs()));
377        assert!(
378            worst <= expected * 1e-3,
379            "FALSIFY-CUDA-NF4-FORWARD-NAN-001: cuBLAS GEMM read the activation \
380             matrix while the producer stream was still writing it \
381             (worst |Δ|={worst}, expected {expected}). The cuBLAS handle is not \
382             bound to the caller's CU_STREAM_NON_BLOCKING stream — restore the \
383             per-call bind_cublas_stream() at the cuBLAS dispatch sites \
384             (cuda_forward::matmul / matmul_f16, cuda_backward::gemm)."
385        );
386    }
387
388    #[test]
389    #[cfg(feature = "cuda")]
390    fn test_cuda_trainer_upload_download() {
391        if !cuda_training_available() {
392            return;
393        }
394
395        let trainer = CudaTrainer::new().expect("operation should succeed");
396        let data = vec![1.0, 2.0, 3.0, 4.0];
397
398        let gpu_buffer = trainer.upload(&data).expect("load should succeed");
399        let result = trainer.download(&gpu_buffer).expect("load should succeed");
400
401        assert_eq!(data, result);
402    }
403
404    #[test]
405    #[cfg(feature = "cuda")]
406    fn test_cuda_trainer_zeros() {
407        if !cuda_training_available() {
408            return;
409        }
410
411        let trainer = CudaTrainer::new().expect("operation should succeed");
412        let gpu_buffer = trainer.zeros(100).expect("operation should succeed");
413        let result = trainer.download(&gpu_buffer).expect("load should succeed");
414
415        assert_eq!(result.len(), 100);
416        assert!(result.iter().all(|&x| x == 0.0));
417    }
418
419    #[test]
420    #[cfg(feature = "cuda")]
421    fn test_cuda_trainer_synchronize() {
422        if !cuda_training_available() {
423            return;
424        }
425
426        let trainer = CudaTrainer::new().expect("operation should succeed");
427        // Synchronize should succeed
428        assert!(trainer.synchronize().is_ok());
429    }
430
431    #[test]
432    #[cfg(feature = "cuda")]
433    fn test_cuda_trainer_context_and_stream() {
434        if !cuda_training_available() {
435            return;
436        }
437
438        let trainer = CudaTrainer::new().expect("operation should succeed");
439        // Accessing context and stream should not panic
440        let _ctx = trainer.context();
441        let _stream = trainer.stream();
442    }
443
444    #[test]
445    #[cfg(feature = "cuda")]
446    fn test_cuda_trainer_step_count() {
447        if !cuda_training_available() {
448            return;
449        }
450
451        let mut trainer = CudaTrainer::new().expect("operation should succeed");
452        assert_eq!(trainer.step_count(), 0);
453
454        // Simulate an optimizer step by calling adamw_step
455        let mut params = trainer.upload(&[1.0, 2.0, 3.0]).expect("load should succeed");
456        let grads = trainer.upload(&[0.1, 0.1, 0.1]).expect("load should succeed");
457        let mut m_state = trainer.zeros(3).expect("operation should succeed");
458        let mut v_state = trainer.zeros(3).expect("operation should succeed");
459
460        trainer
461            .adamw_step(
462                &mut params,
463                &grads,
464                &mut m_state,
465                &mut v_state,
466                0.001,
467                0.9,
468                0.999,
469                1e-8,
470                0.0,
471            )
472            .expect("operation should succeed");
473
474        assert_eq!(trainer.step_count(), 1);
475
476        trainer.reset_step();
477        assert_eq!(trainer.step_count(), 0);
478    }
479
480    #[test]
481    #[cfg(feature = "cuda")]
482    fn test_cuda_trainer_matmul_forward() {
483        if !cuda_training_available() {
484            return;
485        }
486
487        let trainer = CudaTrainer::new().expect("operation should succeed");
488
489        // 2x3 @ 3x2 = 2x2
490        let a_data: Vec<f32> = vec![1.0, 2.0, 3.0, 4.0, 5.0, 6.0]; // 2x3
491        let b_data: Vec<f32> = vec![1.0, 2.0, 3.0, 4.0, 5.0, 6.0]; // 3x2
492        let c_data: Vec<f32> = vec![0.0; 4]; // 2x2
493
494        let a = trainer.upload(&a_data).expect("load should succeed");
495        let b = trainer.upload(&b_data).expect("load should succeed");
496        let mut c = trainer.upload(&c_data).expect("load should succeed");
497
498        trainer.matmul_forward(&a, &b, &mut c, 2, 3, 2).expect("operation should succeed");
499        trainer.synchronize().expect("operation should succeed");
500
501        let result = trainer.download(&c).expect("load should succeed");
502        // Verify result is not all zeros (matmul should produce non-zero output)
503        assert!(!result.iter().all(|&x| x == 0.0));
504    }
505
506    #[test]
507    #[cfg(feature = "cuda")]
508    fn test_cuda_trainer_clip_gradients() {
509        if !cuda_training_available() {
510            return;
511        }
512
513        let trainer = CudaTrainer::new().expect("operation should succeed");
514
515        // Create large gradients that should be clipped
516        let grad_data: Vec<f32> = vec![10.0, 10.0, 10.0, 10.0]; // norm = 20
517        let mut grads = trainer.upload(&grad_data).expect("load should succeed");
518
519        // Clip to max_norm = 1.0
520        trainer.clip_gradients(&mut grads, 1.0).expect("operation should succeed");
521        trainer.synchronize().expect("operation should succeed");
522
523        let result = trainer.download(&grads).expect("load should succeed");
524        // Gradients should be scaled down
525        let norm: f32 = result.iter().map(|x| x * x).sum::<f32>().sqrt();
526        assert!(norm <= 1.1, "Gradient norm should be clipped to ~1.0, got {norm}");
527    }
528
529    #[test]
530    #[cfg(feature = "cuda")]
531    fn test_cuda_trainer_debug_impl() {
532        if !cuda_training_available() {
533            return;
534        }
535
536        let trainer = CudaTrainer::new().expect("operation should succeed");
537        let debug_str = format!("{trainer:?}");
538        assert!(debug_str.contains("CudaTrainer"));
539        assert!(debug_str.contains("device"));
540        assert!(debug_str.contains("step"));
541    }
542}