Skip to main content

teeny_cuda/model/
mod.rs

1/*
2 * Copyright (c) 2026 Teenygrad.
3 *
4 * Licensed under the Apache License, Version 2.0 (the "License");
5 * you may not use this file except in compliance with the License.
6 * You may obtain a copy of the License at
7 *
8 *   http://www.apache.org/licenses/LICENSE-2.0
9 *
10 * Unless required by applicable law or agreed to in writing, software
11 * distributed under the License is distributed on an "AS IS" BASIS,
12 * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
13 * See the License for the specific language governing permissions and
14 * limitations under the License.
15 */
16
17use std::{collections::HashMap, marker::PhantomData, sync::Arc};
18
19use anyhow::anyhow;
20use teeny_core::{
21    device::program::ArgVisitor,
22    graph::{DtypeRepr, Shape},
23    model::{Model, RuntimeOp},
24    utils::dag::Dag,
25};
26
27use crate::{
28    cuda,
29    device::{
30        CudaArgPacker, CudaDevice, CudaLaunchConfig,
31        mem::{self, DevicePtr},
32        program::{CudaProgram, ErasedKernel},
33    },
34    errors::{Error, Result},
35};
36
37// ---------------------------------------------------------------------------
38// Inline PTX for GPU-side f32 gradient accumulation: dst[i] += src[i]
39// ---------------------------------------------------------------------------
40
41#[cfg(feature = "training")]
42const GRAD_ACCUM_F32_PTX: &[u8] = b"\
43// meta:name=grad_accum_f32\n\
44// meta:num_warps=4\n\
45.version 7.0\n\
46.target sm_60\n\
47.address_size 64\n\
48\n\
49.visible .entry grad_accum_f32(\n\
50    .param .u64 param0,\n\
51    .param .u64 param1,\n\
52    .param .u32 param2,\n\
53    .param .u64 param3,\n\
54    .param .u64 param4\n\
55)\n\
56{\n\
57    .reg .pred %p0;\n\
58    .reg .u32 %r<5>;\n\
59    .reg .u64 %rd<5>;\n\
60    .reg .f32 %f<3>;\n\
61    ld.param.u64 %rd0, [param0];\n\
62    ld.param.u64 %rd1, [param1];\n\
63    ld.param.u32 %r0,  [param2];\n\
64    mov.u32 %r1, %ctaid.x;\n\
65    mov.u32 %r2, %ntid.x;\n\
66    mov.u32 %r3, %tid.x;\n\
67    mad.lo.u32 %r4, %r1, %r2, %r3;\n\
68    setp.ge.u32 %p0, %r4, %r0;\n\
69    @%p0 bra $L__return;\n\
70    mul.wide.u32 %rd2, %r4, 4;\n\
71    add.u64 %rd3, %rd0, %rd2;\n\
72    add.u64 %rd4, %rd1, %rd2;\n\
73    ld.global.f32 %f0, [%rd3];\n\
74    ld.global.f32 %f1, [%rd4];\n\
75    add.f32 %f2, %f0, %f1;\n\
76    st.global.f32 [%rd3], %f2;\n\
77$L__return:\n\
78    ret;\n\
79}\n\
80";
81
82// ---------------------------------------------------------------------------
83// TensorRef — a device buffer pointer with a concrete runtime shape
84// ---------------------------------------------------------------------------
85
86/// A reference to a device-side tensor: raw device pointer + concrete shape.
87///
88/// `shape` is always fully concrete (no `None` dims).
89#[derive(Clone, Debug)]
90pub struct TensorRef {
91    /// The device pointer to this tensor's data.
92    pub ptr: DevicePtr,
93    /// This tensor's concrete shape.
94    pub shape: Vec<usize>,
95}
96
97impl TensorRef {
98    /// Wraps an existing device pointer and shape as a `TensorRef`.
99    pub fn new(ptr: DevicePtr, shape: Vec<usize>) -> Self {
100        Self { ptr, shape }
101    }
102
103    /// Total number of elements (product of `shape`).
104    pub fn n_elements(&self) -> usize {
105        self.shape.iter().product()
106    }
107
108    /// Allocate a device buffer, copy `data` to it, and return a `TensorRef`.
109    ///
110    /// `data.len()` must equal `shape.iter().product()`.
111    /// The caller owns the allocation; call [`TensorRef::free`] when done.
112    pub fn from_host_f32(data: &[f32], shape: Vec<usize>) -> Result<Self> {
113        assert_eq!(
114            data.len(),
115            shape.iter().product::<usize>(),
116            "data length must match shape product"
117        );
118        let ptr = mem::alloc(std::mem::size_of_val(data))?;
119        unsafe { mem::copy_h_to_d(ptr, data.as_ptr(), data.len()) }?;
120        Ok(Self { ptr, shape })
121    }
122
123    /// Copy the device buffer contents to a host `Vec<f32>`.
124    pub fn to_host_f32(&self) -> Result<Vec<f32>> {
125        let n = self.n_elements();
126        let mut out = vec![0.0_f32; n];
127        unsafe { mem::copy_d_to_h(out.as_mut_ptr(), self.ptr, n) }?;
128        Ok(out)
129    }
130
131    /// Free the underlying device buffer.
132    ///
133    /// Only call this on `TensorRef`s that own their allocation (created via
134    /// [`TensorRef::from_host_f32`] or [`TensorRef::new`] with a freshly
135    /// allocated pointer).  Do **not** call this on refs borrowed from an
136    /// [`ActivationCache`] — the cache frees them on drop.
137    pub fn free(self) -> Result<()> {
138        mem::free(self.ptr)
139    }
140}
141
142fn dtype_bytes(dtype: DtypeRepr) -> usize {
143    match dtype {
144        DtypeRepr::Bool | DtypeRepr::I8 | DtypeRepr::U8 => 1,
145        DtypeRepr::I16 | DtypeRepr::U16 | DtypeRepr::F16 | DtypeRepr::BF16 => 2,
146        DtypeRepr::I32 | DtypeRepr::U32 | DtypeRepr::F32 => 4,
147        DtypeRepr::I64 | DtypeRepr::U64 | DtypeRepr::F64 => 8,
148    }
149}
150
151fn resolve_shape(shape: &Shape, batch_size: usize) -> Vec<usize> {
152    shape.iter().map(|d| d.unwrap_or(batch_size)).collect()
153}
154
155// ---------------------------------------------------------------------------
156// CompiledNode — one PTX-compiled graph node
157// ---------------------------------------------------------------------------
158
159/// One PTX-compiled graph node: where its compiled kernel lives on disk, its output
160/// shape/dtype, and how to dispatch it at runtime.
161pub struct CompiledNode {
162    /// Path to the compiled `.o` PTX file. Empty for `Input` placeholder nodes.
163    pub ptx_path: String,
164    /// The kernel's entry point symbol name.
165    pub entry_point: String,
166    /// This node's output shape.
167    pub output_shape: Shape,
168    /// This node's output dtype.
169    pub output_dtype: DtypeRepr,
170    /// Runtime dispatch: arg-packing + grid computation. `None` for Input nodes.
171    pub runtime_op: Option<Arc<dyn RuntimeOp>>,
172    /// Path to compiled backward PTX. `None` if no backward kernel for this op.
173    #[cfg(feature = "training")]
174    pub backward_ptx_path: Option<String>,
175    /// Entry point name for the backward kernel.
176    #[cfg(feature = "training")]
177    pub backward_entry_point: String,
178}
179
180// ---------------------------------------------------------------------------
181// CudaModel — compiled but not yet loaded into GPU memory
182// ---------------------------------------------------------------------------
183
184/// A compiled model: a DAG of [`CompiledNode`]s, not yet loaded into GPU memory. Call
185/// [`CudaModel::load`] to load it and get a runnable `LoadedModel`.
186pub struct CudaModel<'a> {
187    /// The compiled node DAG.
188    pub dag: Dag<CompiledNode>,
189    /// DAG node index → dotted name (e.g. `"model.0.conv"`), populated from the
190    /// source `Graph::names` field during compilation.
191    pub names: HashMap<usize, String>,
192    _marker: PhantomData<&'a ()>,
193}
194
195impl<'a> Model<'a> for CudaModel<'a> {
196    type Input = TensorRef;
197    type Output = TensorRef;
198
199    fn forward(&self, _input: Self::Input) -> teeny_core::errors::Result<Self::Output> {
200        Err(anyhow!(
201            "call CudaModel::load() first, then LoadedModel::forward()"
202        ))
203    }
204}
205
206impl<'a> CudaModel<'a> {
207    /// Wraps a compiled node DAG as a `CudaModel`, with no node names.
208    pub fn new(dag: Dag<CompiledNode>) -> Result<Self> {
209        Ok(Self {
210            dag,
211            names: HashMap::new(),
212            _marker: PhantomData,
213        })
214    }
215
216    /// Wraps a compiled node DAG as a `CudaModel`, with the given node-index → name mapping.
217    pub fn with_names(dag: Dag<CompiledNode>, names: HashMap<usize, String>) -> Result<Self> {
218        Ok(Self {
219            dag,
220            names,
221            _marker: PhantomData,
222        })
223    }
224
225    /// Load all compiled PTX kernels into GPU memory and pre-allocate
226    /// zero-initialised parameter buffers, producing a `LoadedModel` ready
227    /// for inference.
228    ///
229    /// `batch_size` resolves dynamic (`None`) shape dimensions when computing
230    /// parameter buffer sizes.
231    pub fn load(self, _device: &CudaDevice<'_>, batch_size: usize) -> Result<LoadedModel> {
232        let names = self.names;
233        let n = self.dag.len();
234        let topo = self.dag.topological_sort();
235
236        // Snapshot parent lists before consuming the dag.
237        let parents: Vec<Vec<usize>> = (0..n).map(|i| self.dag.node(i).parents.clone()).collect();
238
239        // Consume the dag into (parents, CompiledNode) pairs.
240        let compiled: Vec<CompiledNode> = self.dag.into_iter().map(|node| node.value).collect();
241
242        let mut loaded_nodes: Vec<Option<LoadedNode>> = (0..n).map(|_| None).collect();
243
244        // Track correctly-computed concrete shapes for each node so that ops whose
245        // first dimension is `k * batch_size` (e.g. attention pack/unpack) propagate
246        // the true shape rather than the naive `batch_size` substitution.
247        let mut concrete_shapes: Vec<Vec<usize>> = compiled
248            .iter()
249            .map(|cn| resolve_shape(&cn.output_shape, batch_size))
250            .collect();
251
252        for &idx in &topo {
253            let cn = &compiled[idx];
254            let Some(rop) = cn.runtime_op.as_ref() else {
255                // Input placeholder: shape is already correct in concrete_shapes.
256                continue;
257            };
258
259            // Gather concrete input shapes, using the correctly-propagated shapes.
260            let parent_shapes: Vec<Vec<usize>> = parents[idx]
261                .iter()
262                .map(|&p| concrete_shapes[p].clone())
263                .collect();
264            let parent_shape_refs: Vec<&[usize]> =
265                parent_shapes.iter().map(|s| s.as_slice()).collect();
266            let raw_output_shape = resolve_shape(&cn.output_shape, batch_size);
267            let output_shape =
268                rop.compute_concrete_output_shape(&parent_shape_refs, &raw_output_shape);
269            concrete_shapes[idx] = output_shape.clone();
270
271            // Allocate and zero-init device buffers for each parameter slot.
272            let p_shapes = rop.param_shapes(&parent_shape_refs, &output_shape);
273            let mut param_bufs: Vec<DevicePtr> = Vec::with_capacity(p_shapes.len());
274            for (pi, ps) in p_shapes.iter().enumerate() {
275                let n_elems: usize = ps.iter().product();
276                let byte_size = n_elems * dtype_bytes(cn.output_dtype);
277                let ptr = mem::alloc(byte_size)?;
278                unsafe { cuda::cuMemsetD8_v2(ptr, 0, byte_size) };
279                if let Some(cpu_bytes) = rop.param_init_data(pi) {
280                    unsafe { mem::copy_h_to_d::<u8>(ptr, cpu_bytes.as_ptr(), cpu_bytes.len())? };
281                }
282                param_bufs.push(ptr);
283            }
284
285            // JIT-compile the PTX via the CUDA driver.
286            let ptx = std::fs::read(&cn.ptx_path)
287                .map_err(|e| anyhow!("failed to read PTX for node {idx}: {e}"))?;
288            let program = CudaProgram::<ErasedKernel>::try_from_ptx(&ptx)?;
289
290            #[cfg(feature = "training")]
291            let backward_program = if let Some(ref bwd_path) = cn.backward_ptx_path {
292                let bwd_ptx = std::fs::read(bwd_path)
293                    .map_err(|e| anyhow!("failed to read backward PTX for node {idx}: {e}"))?;
294                Some(CudaProgram::<ErasedKernel>::try_from_ptx(&bwd_ptx)?)
295            } else {
296                None
297            };
298
299            // Allocate zero-initialised gradient + optimizer state buffers per param.
300            #[cfg(feature = "training")]
301            let (grad_param_bufs, optim_m_bufs, optim_v_bufs) = {
302                let mut grads = Vec::with_capacity(p_shapes.len());
303                let mut ms = Vec::with_capacity(p_shapes.len());
304                let mut vs = Vec::with_capacity(p_shapes.len());
305                for ps in &p_shapes {
306                    let n_elems: usize = ps.iter().product();
307                    let byte_size = n_elems * dtype_bytes(cn.output_dtype);
308                    let gp = mem::alloc(byte_size)?;
309                    let mp = mem::alloc(byte_size)?;
310                    let vp = mem::alloc(byte_size)?;
311                    unsafe {
312                        cuda::cuMemsetD8_v2(gp, 0, byte_size);
313                        cuda::cuMemsetD8_v2(mp, 0, byte_size);
314                        cuda::cuMemsetD8_v2(vp, 0, byte_size);
315                    }
316                    grads.push(gp);
317                    ms.push(mp);
318                    vs.push(vp);
319                }
320                (grads, ms, vs)
321            };
322
323            loaded_nodes[idx] = Some(LoadedNode {
324                program,
325                output_shape: cn.output_shape.clone(),
326                output_dtype: cn.output_dtype,
327                runtime_op: Arc::clone(rop),
328                param_bufs,
329                param_shapes: p_shapes,
330                #[cfg(feature = "training")]
331                backward_program,
332                #[cfg(feature = "training")]
333                grad_param_bufs,
334                #[cfg(feature = "training")]
335                optim_m_bufs,
336                #[cfg(feature = "training")]
337                optim_v_bufs,
338            });
339        }
340
341        Ok(LoadedModel {
342            nodes: loaded_nodes,
343            parents,
344            names,
345            #[cfg(feature = "training")]
346            optim_step: 0,
347            #[cfg(feature = "training")]
348            accum_program: None,
349        })
350    }
351}
352
353// ---------------------------------------------------------------------------
354// LoadedNode — kernel + param buffers, fully loaded in GPU memory
355// ---------------------------------------------------------------------------
356
357struct LoadedNode {
358    program: CudaProgram<'static, ErasedKernel>,
359    output_shape: Shape,
360    output_dtype: DtypeRepr,
361    runtime_op: Arc<dyn RuntimeOp>,
362    /// Zero-initialised device buffers for model parameters (weights, biases).
363    param_bufs: Vec<DevicePtr>,
364    /// Concrete shape of each param buffer — stored so callers can initialise weights.
365    param_shapes: Vec<Vec<usize>>,
366    /// Compiled backward kernel. `None` if this op has no backward.
367    #[cfg(feature = "training")]
368    backward_program: Option<CudaProgram<'static, ErasedKernel>>,
369    /// Per-parameter gradient buffers (dW, db …), same shapes as `param_bufs`.
370    #[cfg(feature = "training")]
371    grad_param_bufs: Vec<DevicePtr>,
372    /// AdamW first-moment (exp_avg) per parameter, same shapes as `param_bufs`.
373    #[cfg(feature = "training")]
374    optim_m_bufs: Vec<DevicePtr>,
375    /// AdamW second-moment (exp_avg_sq) per parameter, same shapes as `param_bufs`.
376    #[cfg(feature = "training")]
377    optim_v_bufs: Vec<DevicePtr>,
378}
379
380impl Drop for LoadedNode {
381    fn drop(&mut self) {
382        for &ptr in &self.param_bufs {
383            if let Err(e) = mem::free(ptr) {
384                eprintln!("LoadedNode: failed to free param buffer: {e}");
385            }
386        }
387        #[cfg(feature = "training")]
388        for &ptr in &self.grad_param_bufs {
389            if let Err(e) = mem::free(ptr) {
390                eprintln!("LoadedNode: failed to free grad param buffer: {e}");
391            }
392        }
393        #[cfg(feature = "training")]
394        for &ptr in &self.optim_m_bufs {
395            if let Err(e) = mem::free(ptr) {
396                eprintln!("LoadedNode: failed to free optim m buffer: {e}");
397            }
398        }
399        #[cfg(feature = "training")]
400        for &ptr in &self.optim_v_bufs {
401            if let Err(e) = mem::free(ptr) {
402                eprintln!("LoadedNode: failed to free optim v buffer: {e}");
403            }
404        }
405    }
406}
407
408// ---------------------------------------------------------------------------
409// LoadedModel — eager-loaded model ready for inference
410// ---------------------------------------------------------------------------
411
412/// A [`CudaModel`] with all kernels loaded into GPU memory and parameter buffers allocated,
413/// ready to run inference (and, with the `training` feature, training steps).
414pub struct LoadedModel {
415    /// Per-DAG-node loaded kernel. `None` for `Input` placeholder nodes.
416    nodes: Vec<Option<LoadedNode>>,
417    /// Parent node indices per node (same topology as the compiled DAG).
418    parents: Vec<Vec<usize>>,
419    /// DAG node index → dotted name (e.g. `"model.0.conv"`).
420    names: HashMap<usize, String>,
421    /// AdamW step counter for bias correction (incremented each `adamw_step` call).
422    #[cfg(feature = "training")]
423    optim_step: u32,
424    /// Lazily-compiled f32 gradient accumulation kernel (`dst[i] += src[i]`).
425    #[cfg(feature = "training")]
426    accum_program: Option<CudaProgram<'static, ErasedKernel>>,
427}
428
429impl LoadedModel {
430    /// Iterate over every node that has parameter buffers.
431    ///
432    /// Yields `(node_idx, param_shapes)` where `param_shapes[i]` is the concrete
433    /// shape of parameter slot `i` (e.g. `[out_features, in_features]` for a
434    /// weight matrix). Use `load_param_f32(node_idx, i, data)` to upload values.
435    pub fn param_info(&self) -> impl Iterator<Item = (usize, &[Vec<usize>])> {
436        self.nodes.iter().enumerate().filter_map(|(idx, node)| {
437            node.as_ref()
438                .filter(|n| !n.param_shapes.is_empty())
439                .map(|n| (idx, n.param_shapes.as_slice()))
440        })
441    }
442
443    /// Iterate over every named parameter slot.
444    ///
445    /// Yields `(full_key, node_idx, param_idx)` where `full_key` is the dotted
446    /// safetensors key (e.g. `"model.0.conv.weight"`), built by joining the
447    /// node name from the graph with the slot name from the runtime op.
448    /// Nodes without a name or without parameters are skipped.
449    pub fn param_info_named(&self) -> impl Iterator<Item = (String, usize, usize)> + '_ {
450        self.nodes
451            .iter()
452            .enumerate()
453            .filter_map(|(node_idx, node)| {
454                let n = node.as_ref().filter(|n| !n.param_shapes.is_empty())?;
455                let node_name = self.names.get(&node_idx)?;
456                let slot_names = n.runtime_op.param_names();
457                Some((node_idx, node_name, n, slot_names))
458            })
459            .flat_map(|(node_idx, node_name, n, slot_names)| {
460                (0..n.param_shapes.len()).filter_map(move |param_idx| {
461                    let slot = slot_names.get(param_idx).copied().unwrap_or("");
462                    if slot.is_empty() {
463                        return None;
464                    }
465                    let key = format!("{node_name}.{slot}");
466                    Some((key, node_idx, param_idx))
467                })
468            })
469    }
470
471    /// Copy `f32` parameter data into a node's pre-allocated device buffer.
472    ///
473    /// `node_idx`  — the DAG node index.
474    /// `param_idx` — which parameter slot (0 = weight, 1 = bias, …).
475    /// `data`      — host `f32` slice; must match the buffer element count exactly.
476    pub fn load_param_f32(
477        &mut self,
478        node_idx: usize,
479        param_idx: usize,
480        data: &[f32],
481    ) -> Result<()> {
482        let node = self.nodes[node_idx]
483            .as_ref()
484            .ok_or_else(|| anyhow!("node {node_idx} is an Input placeholder"))?;
485        let ptr = *node
486            .param_bufs
487            .get(param_idx)
488            .ok_or_else(|| anyhow!("node {node_idx} has no param at index {param_idx}"))?;
489        unsafe { mem::copy_h_to_d(ptr, data.as_ptr(), data.len()) }
490    }
491
492    /// Copy the accumulated parameter gradient (dL/dParam) back to host as `f32`.
493    ///
494    /// Call after `backward` and before `zero_grad`.
495    #[cfg(feature = "training")]
496    pub fn read_param_grad_f32(&self, node_idx: usize, param_idx: usize) -> Result<Vec<f32>> {
497        let node = self.nodes[node_idx]
498            .as_ref()
499            .ok_or_else(|| anyhow!("node {node_idx} is an Input placeholder"))?;
500        let &ptr = node
501            .grad_param_bufs
502            .get(param_idx)
503            .ok_or_else(|| anyhow!("node {node_idx} has no grad param at index {param_idx}"))?;
504        let n_elems: usize = node.param_shapes[param_idx].iter().product();
505        let mut out = vec![0.0_f32; n_elems];
506        unsafe { mem::copy_d_to_h(out.as_mut_ptr(), ptr, n_elems) }?;
507        Ok(out)
508    }
509
510    /// Return the parent node indices for a DAG node.
511    pub fn node_parents(&self, idx: usize) -> &[usize] {
512        self.parents.get(idx).map(|v| v.as_slice()).unwrap_or(&[])
513    }
514
515    /// Run a single forward pass through the loaded model.
516    ///
517    /// `device`     — the CUDA device context.
518    /// `batch_size` — concrete value for dynamic (`None`) batch dimensions.
519    /// `inputs`     — device tensors matched to `Input` nodes in topological order.
520    ///
521    /// Returns the `TensorRef` of the last DAG node. Intermediate output buffers
522    /// are allocated per-call and freed when the returned `TensorRef` is dropped
523    /// (caller owns the final buffer; all intermediate ones are freed at the end).
524    pub fn forward(
525        &self,
526        device: &CudaDevice<'_>,
527        batch_size: usize,
528        inputs: &[TensorRef],
529    ) -> Result<TensorRef> {
530        let n = self.nodes.len();
531        let topo = self.topo_sort();
532
533        // ctx[i] = TensorRef for node i once it has been computed.
534        let mut ctx: Vec<Option<TensorRef>> = vec![None; n];
535        // Intermediate output buffers that we own and must free.
536        let mut intermediate_ptrs: Vec<DevicePtr> = Vec::new();
537        let mut input_cursor = 0usize;
538
539        for &idx in &topo {
540            if self.nodes[idx].is_none() {
541                // Input placeholder — assign from caller-provided inputs.
542                let tr = inputs
543                    .get(input_cursor)
544                    .ok_or_else(|| anyhow!("too few inputs: needed >{input_cursor}"))?
545                    .clone();
546                ctx[idx] = Some(tr);
547                input_cursor += 1;
548                continue;
549            }
550
551            let loaded = self.nodes[idx].as_ref().unwrap();
552
553            // Gather activation inputs from the context.
554            let parent_refs: Vec<&TensorRef> = self.parents[idx]
555                .iter()
556                .map(|&p| {
557                    ctx[p]
558                        .as_ref()
559                        .expect("parent must be computed before child")
560                })
561                .collect();
562            let act_input_shapes: Vec<&[usize]> =
563                parent_refs.iter().map(|tr| tr.shape.as_slice()).collect();
564            let raw_output_shape = resolve_shape(&loaded.output_shape, batch_size);
565            let output_shape = loaded
566                .runtime_op
567                .compute_concrete_output_shape(&act_input_shapes, &raw_output_shape);
568
569            // Allocate tight output buffer.
570            let n_elems: usize = output_shape.iter().product();
571            let elem_bytes = dtype_bytes(loaded.output_dtype);
572            let byte_size = n_elems * elem_bytes;
573            let out_ptr = mem::alloc(byte_size)?;
574            intermediate_ptrs.push(out_ptr);
575
576            // Some ops (e.g. linear_forward) use TMA and require a row stride that
577            // is a multiple of 16 bytes.  Allocate a padded output buffer when needed.
578            let natural_stride = output_shape.last().copied().unwrap_or(1);
579            let required_stride = loaded.runtime_op.forward_output_row_stride(&output_shape);
580            let n_rows = output_shape.iter().product::<usize>() / natural_stride.max(1);
581
582            let (kernel_out_ptr, padded_out) = if required_stride > natural_stride {
583                let padded_bytes = n_rows * required_stride * elem_bytes;
584                let padded = mem::alloc(padded_bytes)?;
585                unsafe {
586                    cuda::cuMemsetD8_v2(padded, 0, padded_bytes);
587                }
588                (padded, Some(padded))
589            } else {
590                (out_ptr, None)
591            };
592
593            // Build arg inputs: (raw ptr, concrete shape slice).
594            let act_inputs: Vec<(teeny_core::model::RawPtr, &[usize])> = parent_refs
595                .iter()
596                .map(|tr| (tr.ptr as *mut core::ffi::c_void, tr.shape.as_slice()))
597                .collect();
598
599            let param_ptrs: Vec<teeny_core::model::RawPtr> = loaded
600                .param_bufs
601                .iter()
602                .map(|&p| p as *mut core::ffi::c_void)
603                .collect();
604
605            // Launch kernel(s). Most ops need one launch; multi-input scatter
606            // ops (e.g. channel-cat) set n_launches > 1.
607            let n_launches = loaded.runtime_op.n_launches();
608            let input_shapes: Vec<&[usize]> = act_inputs.iter().map(|(_, s)| *s).collect();
609            let block = [loaded.program.metadata.threads_per_block(), 1, 1];
610            let cluster = [loaded.program.metadata.num_ctas, 1, 1];
611            let out_raw = kernel_out_ptr as *mut core::ffi::c_void;
612
613            let mut last_result = Ok(());
614            for launch_idx in 0..n_launches {
615                let mut packer = CudaArgPacker::new();
616                if n_launches == 1 {
617                    loaded.runtime_op.pack_args(
618                        &act_inputs,
619                        &param_ptrs,
620                        out_raw,
621                        &output_shape,
622                        required_stride as i32,
623                        &mut packer,
624                    );
625                } else {
626                    loaded.runtime_op.pack_args_for_launch(
627                        launch_idx,
628                        &act_inputs,
629                        &param_ptrs,
630                        out_raw,
631                        &output_shape,
632                        required_stride as i32,
633                        &mut packer,
634                    );
635                }
636                let grid = if n_launches == 1 {
637                    loaded.runtime_op.grid(&output_shape)
638                } else {
639                    loaded
640                        .runtime_op
641                        .grid_for_launch(launch_idx, &input_shapes, &output_shape)
642                };
643                last_result = device.launch_with_packer(
644                    &loaded.program,
645                    &CudaLaunchConfig {
646                        grid,
647                        block,
648                        cluster,
649                    },
650                    &mut packer,
651                );
652                if last_result.is_err() {
653                    break;
654                }
655            }
656
657            // Copy valid rows from padded output back to tight buffer, then free padded.
658            if let Some(padded) = padded_out {
659                if last_result.is_ok() {
660                    mem::copy_rows_d_to_d(
661                        out_ptr,
662                        natural_stride * elem_bytes,
663                        padded,
664                        required_stride * elem_bytes,
665                        natural_stride * elem_bytes,
666                        n_rows,
667                    )?;
668                }
669                mem::free(padded).ok();
670            }
671            last_result?;
672
673            ctx[idx] = Some(TensorRef::new(out_ptr, output_shape));
674        }
675
676        let last_idx = *topo.last().ok_or_else(|| anyhow!("empty model"))?;
677        let result = ctx[last_idx]
678            .clone()
679            .ok_or_else(|| anyhow!("last node produced no output"))?;
680
681        // Free all intermediate buffers except the output of the last node.
682        // The last node's buffer is returned to the caller (who must free it).
683        for ptr in intermediate_ptrs {
684            if ptr != result.ptr {
685                let _ = mem::free(ptr).map_err(|e| {
686                    eprintln!("LoadedModel::forward: failed to free intermediate buffer: {e}");
687                });
688            }
689        }
690
691        Ok(result)
692    }
693
694    // ── Training-only methods ────────────────────────────────────────────────
695
696    /// Run a forward pass and retain ALL intermediate activation buffers.
697    ///
698    /// Returns `(final_output, activation_cache)` where `activation_cache[i]`
699    /// is the output tensor of node `i`. Call `drop(cache)` after `backward`
700    /// to release the device buffers.
701    #[cfg(feature = "training")]
702    pub fn forward_train(
703        &self,
704        device: &CudaDevice<'_>,
705        batch_size: usize,
706        inputs: &[TensorRef],
707    ) -> Result<(TensorRef, ActivationCache)> {
708        let n = self.nodes.len();
709        let topo = self.topo_sort();
710
711        let mut ctx: Vec<Option<TensorRef>> = vec![None; n];
712        let mut input_cursor = 0usize;
713
714        for &idx in &topo {
715            if self.nodes[idx].is_none() {
716                let tr = inputs
717                    .get(input_cursor)
718                    .ok_or_else(|| anyhow!("too few inputs: needed >{input_cursor}"))?
719                    .clone();
720                ctx[idx] = Some(tr);
721                input_cursor += 1;
722                continue;
723            }
724
725            let loaded = self.nodes[idx].as_ref().unwrap();
726
727            let parent_refs: Vec<&TensorRef> = self.parents[idx]
728                .iter()
729                .map(|&p| {
730                    ctx[p]
731                        .as_ref()
732                        .expect("parent must be computed before child")
733                })
734                .collect();
735            let act_inputs: Vec<(teeny_core::model::RawPtr, &[usize])> = parent_refs
736                .iter()
737                .map(|tr| (tr.ptr as *mut core::ffi::c_void, tr.shape.as_slice()))
738                .collect();
739            let param_ptrs: Vec<teeny_core::model::RawPtr> = loaded
740                .param_bufs
741                .iter()
742                .map(|&p| p as *mut core::ffi::c_void)
743                .collect();
744
745            let input_shapes: Vec<&[usize]> = act_inputs.iter().map(|(_, s)| *s).collect();
746            let raw_output_shape = resolve_shape(&loaded.output_shape, batch_size);
747            let output_shape = loaded
748                .runtime_op
749                .compute_concrete_output_shape(&input_shapes, &raw_output_shape);
750
751            let n_elems: usize = output_shape.iter().product();
752            let elem_bytes = dtype_bytes(loaded.output_dtype);
753            let byte_size = n_elems * elem_bytes;
754            let out_ptr = mem::alloc(byte_size)?;
755
756            // TMA alignment: allocate a padded output buffer when the op requires it.
757            let natural_stride = output_shape.last().copied().unwrap_or(1);
758            let required_stride = loaded.runtime_op.forward_output_row_stride(&output_shape);
759            let n_rows = output_shape.iter().product::<usize>() / natural_stride.max(1);
760
761            let (kernel_out_ptr, padded_out) = if required_stride > natural_stride {
762                let padded_bytes = n_rows * required_stride * elem_bytes;
763                let padded = mem::alloc(padded_bytes)?;
764                unsafe {
765                    cuda::cuMemsetD8_v2(padded, 0, padded_bytes);
766                }
767                (padded, Some(padded))
768            } else {
769                (out_ptr, None)
770            };
771
772            let n_launches = loaded.runtime_op.n_launches();
773            let block = [loaded.program.metadata.threads_per_block(), 1, 1];
774            let cluster = [loaded.program.metadata.num_ctas, 1, 1];
775            let out_raw = kernel_out_ptr as *mut core::ffi::c_void;
776
777            let mut launch_result = Ok(());
778            for launch_idx in 0..n_launches {
779                let mut packer = CudaArgPacker::new();
780                if n_launches == 1 {
781                    loaded.runtime_op.pack_args(
782                        &act_inputs,
783                        &param_ptrs,
784                        out_raw,
785                        &output_shape,
786                        required_stride as i32,
787                        &mut packer,
788                    );
789                } else {
790                    loaded.runtime_op.pack_args_for_launch(
791                        launch_idx,
792                        &act_inputs,
793                        &param_ptrs,
794                        out_raw,
795                        &output_shape,
796                        required_stride as i32,
797                        &mut packer,
798                    );
799                }
800                let grid = if n_launches == 1 {
801                    loaded.runtime_op.grid(&output_shape)
802                } else {
803                    loaded
804                        .runtime_op
805                        .grid_for_launch(launch_idx, &input_shapes, &output_shape)
806                };
807                launch_result = device.launch_with_packer(
808                    &loaded.program,
809                    &CudaLaunchConfig {
810                        grid,
811                        block,
812                        cluster,
813                    },
814                    &mut packer,
815                );
816                if launch_result.is_err() {
817                    break;
818                }
819            }
820
821            if let Some(padded) = padded_out {
822                if launch_result.is_ok() {
823                    mem::copy_rows_d_to_d(
824                        out_ptr,
825                        natural_stride * elem_bytes,
826                        padded,
827                        required_stride * elem_bytes,
828                        natural_stride * elem_bytes,
829                        n_rows,
830                    )?;
831                }
832                mem::free(padded).ok();
833            }
834            launch_result?;
835
836            ctx[idx] = Some(TensorRef::new(out_ptr, output_shape));
837        }
838
839        let last_idx = *topo.last().ok_or_else(|| anyhow!("empty model"))?;
840        let output = ctx[last_idx]
841            .clone()
842            .ok_or_else(|| anyhow!("last node produced no output"))?;
843
844        Ok((output, ActivationCache { tensors: ctx }))
845    }
846
847    /// Zero all parameter gradient buffers. Call before each backward pass.
848    #[cfg(feature = "training")]
849    pub fn zero_grad(&mut self) {
850        for node in self.nodes.iter().flatten() {
851            for (&gp, ps) in node.grad_param_bufs.iter().zip(node.param_shapes.iter()) {
852                let byte_size = ps.iter().product::<usize>() * dtype_bytes(node.output_dtype);
853                unsafe {
854                    cuda::cuMemsetD8_v2(gp, 0, byte_size);
855                }
856            }
857        }
858    }
859
860    /// Apply an AdamW update to all parameters using the accumulated gradient buffers.
861    ///
862    /// `kernel` — pre-compiled `adamw_step` PTX (compile with `AdamwStep::new(1024)` from
863    ///            `teeny_kernels::nn::optim::adam`).
864    // AdamW's hyperparameters (lr, beta1, beta2, eps, weight_decay) are each independently
865    // meaningful at call sites; bundling them into a struct wouldn't be clearer.
866    #[cfg(feature = "training")]
867    #[allow(clippy::too_many_arguments)]
868    pub fn adamw_step(
869        &mut self,
870        device: &CudaDevice<'_>,
871        kernel: &AdamwKernel,
872        lr: f32,
873        beta1: f32,
874        beta2: f32,
875        eps: f32,
876        weight_decay: f32,
877    ) -> Result<()> {
878        self.optim_step += 1;
879        let bias_correction1 = 1.0_f32 - beta1.powi(self.optim_step as i32);
880        let bias_correction2 = 1.0_f32 - beta2.powi(self.optim_step as i32);
881        let step_size = lr / bias_correction1;
882        let bias_corr2_sqrt = bias_correction2.sqrt();
883
884        for node in self.nodes.iter().flatten() {
885            if node.param_bufs.is_empty() {
886                continue;
887            }
888            for i in 0..node.param_bufs.len() {
889                let n_elems: usize = node.param_shapes[i].iter().product();
890                let mut packer = CudaArgPacker::new();
891                packer.visit_ptr(node.param_bufs[i] as *mut core::ffi::c_void); // params_ptr
892                packer.visit_ptr(node.grad_param_bufs[i] as *mut core::ffi::c_void); // grad_ptr
893                packer.visit_ptr(node.optim_m_bufs[i] as *mut core::ffi::c_void); // exp_avg_ptr
894                packer.visit_ptr(node.optim_v_bufs[i] as *mut core::ffi::c_void); // exp_avg_sq_ptr
895                packer.visit_i32(n_elems as i32); // n_elements
896                packer.visit_f32(step_size); // step_size
897                packer.visit_f32(bias_corr2_sqrt); // bias_corr2_sqrt
898                packer.visit_f32(beta1); // beta1
899                packer.visit_f32(beta2); // beta2
900                packer.visit_f32(eps); // eps
901                packer.visit_f32(weight_decay); // weight_decay
902                packer.visit_f32(lr); // lr
903
904                let threads = kernel.program.metadata.threads_per_block();
905                let grid = [n_elems.div_ceil(threads as usize) as u32, 1, 1];
906                let block = [threads, 1, 1];
907                device.launch_with_packer(
908                    &kernel.program,
909                    &CudaLaunchConfig {
910                        grid,
911                        block,
912                        cluster: [1, 1, 1],
913                    },
914                    &mut packer,
915                )?;
916            }
917        }
918        Ok(())
919    }
920
921    /// Indices of all DAG nodes that have no children (sinks / output nodes).
922    ///
923    /// For single-output models this returns one element.  YOLO26 returns two:
924    /// the boxes node and the scores node.
925    pub fn terminal_node_indices(&self) -> Vec<usize> {
926        let n = self.nodes.len();
927        let mut has_child = vec![false; n];
928        for i in 0..n {
929            for &p in &self.parents[i] {
930                has_child[p] = true;
931            }
932        }
933        (0..n).filter(|&i| !has_child[i]).collect()
934    }
935
936    /// Terminal node indices sorted by output tensor element count (ascending).
937    ///
938    /// For YOLO26 this reliably gives `[boxes_idx, scores_idx]`: boxes has
939    /// `4·A` elements in the channel dim while scores has `nc·A`, and nc > 4
940    /// for all practical detection models.
941    /// Return the name for a node index, if one was recorded during compilation.
942    pub fn node_name(&self, idx: usize) -> Option<&str> {
943        self.names.get(&idx).map(|s| s.as_str())
944    }
945
946    /// Terminal (no-consumer) node indices, sorted by output element count (largest last).
947    pub fn terminal_node_indices_sorted_by_size(&self) -> Vec<usize> {
948        let mut terminals = self.terminal_node_indices();
949        terminals.sort_by_key(|&i| {
950            self.nodes[i]
951                .as_ref()
952                .map(|n| n.output_shape.iter().filter_map(|&d| d).product::<usize>())
953                .unwrap_or(0)
954        });
955        terminals
956    }
957
958    /// Backward pass seeded from a single output node (common case for single-output models).
959    ///
960    /// `grad_output` — dL/d(model_output), provided by the loss backward.
961    /// `cache`       — the activation cache returned by `forward_train`.
962    #[cfg(feature = "training")]
963    pub fn backward(
964        &mut self,
965        device: &CudaDevice<'_>,
966        batch_size: usize,
967        grad_output: TensorRef,
968        cache: &ActivationCache,
969    ) -> Result<()> {
970        let topo = self.topo_sort();
971        let last_idx = *topo.last().ok_or_else(|| anyhow!("empty model"))?;
972        self.backward_multi(device, batch_size, &[(last_idx, grad_output)], cache)
973    }
974
975    /// Backward pass seeded from multiple output nodes (e.g. YOLO26 boxes + scores).
976    ///
977    /// `seed_grads` — list of `(node_idx, grad_tensor)` pairs, one per output node.
978    /// `cache`      — the activation cache returned by `forward_train`.
979    #[cfg(feature = "training")]
980    pub fn backward_multi(
981        &mut self,
982        device: &CudaDevice<'_>,
983        batch_size: usize,
984        seed_grads: &[(usize, TensorRef)],
985        cache: &ActivationCache,
986    ) -> Result<()> {
987        // Lazy-compile the gradient accumulation kernel on first use.
988        if self.accum_program.is_none() {
989            self.accum_program = Some(CudaProgram::<ErasedKernel>::try_from_ptx(
990                GRAD_ACCUM_F32_PTX,
991            )?);
992        }
993
994        let n = self.nodes.len();
995        let topo = self.topo_sort();
996
997        // grad_ctx[i]: gradient of the loss w.r.t. node i's output (device ptr).
998        let mut grad_ctx: Vec<Option<DevicePtr>> = vec![None; n];
999        // All intermediate gradient buffers we allocated (freed after backward).
1000        let mut owned_grad_ptrs: Vec<DevicePtr> = Vec::new();
1001
1002        for (node_idx, grad) in seed_grads {
1003            grad_ctx[*node_idx] = Some(grad.ptr);
1004        }
1005
1006        for &idx in topo.iter().rev() {
1007            let grad_in_ptr = match grad_ctx[idx] {
1008                Some(p) => p,
1009                None => continue,
1010            };
1011
1012            // Clone parent indices early to avoid split borrows.
1013            let parent_indices: Vec<usize> = self.parents[idx].clone();
1014
1015            {
1016                let loaded = match self.nodes[idx].as_ref() {
1017                    Some(n) => n,
1018                    None => continue,
1019                };
1020                let bwd_prog = match loaded.backward_program.as_ref() {
1021                    Some(p) => p,
1022                    None => continue,
1023                };
1024
1025                let output_shape = resolve_shape(&loaded.output_shape, batch_size);
1026                let node_out_ptr = cache.tensors[idx]
1027                    .as_ref()
1028                    .ok_or_else(|| anyhow!("activation cache missing for node {idx}"))?
1029                    .ptr;
1030
1031                // Gather parent activation refs from cache.
1032                let parent_trs: Vec<&TensorRef> = parent_indices
1033                    .iter()
1034                    .map(|&p| {
1035                        cache.tensors[p]
1036                            .as_ref()
1037                            .expect("activation cache must have parent activation")
1038                    })
1039                    .collect();
1040
1041                let act_inputs: Vec<(teeny_core::model::RawPtr, &[usize])> = parent_trs
1042                    .iter()
1043                    .map(|tr| (tr.ptr as *mut core::ffi::c_void, tr.shape.as_slice()))
1044                    .collect();
1045                let param_ptrs: Vec<teeny_core::model::RawPtr> = loaded
1046                    .param_bufs
1047                    .iter()
1048                    .map(|&p| p as *mut core::ffi::c_void)
1049                    .collect();
1050                let grad_param_rawptrs: Vec<teeny_core::model::RawPtr> = loaded
1051                    .grad_param_bufs
1052                    .iter()
1053                    .map(|&p| p as *mut core::ffi::c_void)
1054                    .collect();
1055
1056                // Allocate zero-initialised gradient buffers for each activation parent.
1057                let mut grad_input_ptrs: Vec<DevicePtr> = Vec::with_capacity(parent_trs.len());
1058                for tr in &parent_trs {
1059                    let n_elems: usize = tr.shape.iter().product();
1060                    let byte_size = n_elems * dtype_bytes(loaded.output_dtype);
1061                    let gptr = mem::alloc(byte_size)?;
1062                    unsafe {
1063                        cuda::cuMemsetD8_v2(gptr, 0, byte_size);
1064                    }
1065                    grad_input_ptrs.push(gptr);
1066                    owned_grad_ptrs.push(gptr);
1067                }
1068
1069                let grad_input_rawptrs: Vec<teeny_core::model::RawPtr> = grad_input_ptrs
1070                    .iter()
1071                    .map(|&p| p as *mut core::ffi::c_void)
1072                    .collect();
1073
1074                let input_shapes: Vec<&[usize]> =
1075                    parent_trs.iter().map(|tr| tr.shape.as_slice()).collect();
1076
1077                // Some kernels (e.g. linear_backward) use TMA, which requires
1078                // 16-byte aligned row strides.  If the natural stride is too
1079                // small, allocate a zero-padded copy and use the padded stride.
1080                let natural_stride = output_shape.last().copied().unwrap_or(1);
1081                let required_stride = loaded
1082                    .runtime_op
1083                    .backward_grad_output_row_stride(&output_shape);
1084                let elem_bytes = dtype_bytes(loaded.output_dtype);
1085                let n_rows = output_shape.iter().product::<usize>() / natural_stride.max(1);
1086
1087                let (dy_ptr, padded_dy) = if required_stride > natural_stride {
1088                    let padded_bytes = n_rows * required_stride * elem_bytes;
1089                    let padded = mem::alloc(padded_bytes)?;
1090                    unsafe {
1091                        cuda::cuMemsetD8_v2(padded, 0, padded_bytes);
1092                    }
1093                    mem::copy_rows_d_to_d(
1094                        padded,
1095                        required_stride * elem_bytes,
1096                        grad_in_ptr,
1097                        natural_stride * elem_bytes,
1098                        natural_stride * elem_bytes,
1099                        n_rows,
1100                    )?;
1101                    (padded, Some(padded))
1102                } else {
1103                    (grad_in_ptr, None)
1104                };
1105
1106                let bwd_block = [bwd_prog.metadata.threads_per_block(), 1, 1];
1107                let bwd_cluster = [bwd_prog.metadata.num_ctas, 1, 1];
1108                let n_bwd_launches = loaded.runtime_op.n_backward_launches();
1109                let node_out_raw = node_out_ptr as teeny_core::model::RawPtr;
1110                let dy_raw = dy_ptr as teeny_core::model::RawPtr;
1111
1112                let mut bwd_result = Ok(());
1113                for launch_idx in 0..n_bwd_launches {
1114                    let mut packer = CudaArgPacker::new();
1115                    if n_bwd_launches == 1 {
1116                        loaded.runtime_op.pack_backward_args(
1117                            &act_inputs,
1118                            &param_ptrs,
1119                            node_out_raw,
1120                            &output_shape,
1121                            dy_raw,
1122                            required_stride as i32,
1123                            &grad_input_rawptrs,
1124                            &grad_param_rawptrs,
1125                            &mut packer,
1126                        );
1127                    } else {
1128                        loaded.runtime_op.pack_backward_args_for_launch(
1129                            launch_idx,
1130                            &act_inputs,
1131                            &param_ptrs,
1132                            node_out_raw,
1133                            &output_shape,
1134                            dy_raw,
1135                            required_stride as i32,
1136                            &grad_input_rawptrs,
1137                            &grad_param_rawptrs,
1138                            &mut packer,
1139                        );
1140                    }
1141                    let grid = if n_bwd_launches == 1 {
1142                        loaded
1143                            .runtime_op
1144                            .backward_grid(&input_shapes, &output_shape)
1145                    } else {
1146                        loaded.runtime_op.backward_grid_for_launch(
1147                            launch_idx,
1148                            &input_shapes,
1149                            &output_shape,
1150                        )
1151                    };
1152                    bwd_result = device.launch_with_packer(
1153                        bwd_prog,
1154                        &CudaLaunchConfig {
1155                            grid,
1156                            block: bwd_block,
1157                            cluster: bwd_cluster,
1158                        },
1159                        &mut packer,
1160                    );
1161                    if bwd_result.is_err() {
1162                        break;
1163                    }
1164                }
1165
1166                // Free the padded dy buffer after the (synchronous) launch completes.
1167                if let Some(padded) = padded_dy {
1168                    mem::free(padded).ok();
1169                }
1170
1171                bwd_result?;
1172
1173                // Propagate gradients to parent nodes. If a parent already has a
1174                // gradient (fan-out node), accumulate: existing += new_contrib.
1175                for (i, &pidx) in parent_indices.iter().enumerate() {
1176                    if let Some(existing) = grad_ctx[pidx] {
1177                        let n_elems: usize = parent_trs[i].shape.iter().product();
1178                        self.accum_grad_f32(device, existing, grad_input_ptrs[i], n_elems)?;
1179                    } else {
1180                        grad_ctx[pidx] = Some(grad_input_ptrs[i]);
1181                    }
1182                }
1183            }
1184        }
1185
1186        // Free all intermediate gradient buffers.
1187        for ptr in owned_grad_ptrs {
1188            let _ = mem::free(ptr).map_err(|e| {
1189                eprintln!("LoadedModel::backward_multi: failed to free grad buffer: {e}");
1190            });
1191        }
1192
1193        Ok(())
1194    }
1195
1196    /// In-place GPU accumulation: `dst[i] += src[i]` for `n_elems` f32 values.
1197    #[cfg(feature = "training")]
1198    fn accum_grad_f32(
1199        &self,
1200        device: &CudaDevice<'_>,
1201        dst: DevicePtr,
1202        src: DevicePtr,
1203        n_elems: usize,
1204    ) -> Result<()> {
1205        let prog = self
1206            .accum_program
1207            .as_ref()
1208            .expect("accum_program must be initialised before calling accum_grad_f32");
1209        let threads: u32 = prog.metadata.threads_per_block();
1210        let grid = [n_elems.div_ceil(threads as usize) as u32, 1, 1];
1211        let block = [threads, 1, 1];
1212        let mut packer = CudaArgPacker::new();
1213        packer.visit_ptr(dst as *mut core::ffi::c_void);
1214        packer.visit_ptr(src as *mut core::ffi::c_void);
1215        packer.visit_i32(n_elems as i32);
1216        device.launch_with_packer(
1217            prog,
1218            &CudaLaunchConfig {
1219                grid,
1220                block,
1221                cluster: [1, 1, 1],
1222            },
1223            &mut packer,
1224        )
1225    }
1226
1227    /// Capture a fixed-batch CUDA graph for low-overhead repeated inference.
1228    ///
1229    /// All device buffers (intermediate activations, scratch pads, TMA padding)
1230    /// are pre-allocated once. The kernel sequence is then recorded via
1231    /// `cuStreamBeginCapture_v2` / `cuStreamEndCapture` and instantiated as a
1232    /// `CUgraphExec`. Subsequent [`CudaGraphModel::run`] calls replay it with a
1233    /// single `cuGraphLaunch` + `cuStreamSynchronize`.
1234    ///
1235    /// # Parameters
1236    /// - `batch_size` — resolves dynamic (`None`) shape dimensions.
1237    /// - `input_shapes` — concrete shape per `Input` node, in topological order.
1238    /// - `output_node_indices` — which DAG node indices to read back as outputs.
1239    ///   Use [`LoadedModel::terminal_node_indices_sorted_by_size`] to obtain them.
1240    ///
1241    /// # Constraints
1242    /// - Inference-only (no backward pass).
1243    /// - Fixed topology: re-capture if `batch_size` or input shapes change.
1244    /// - f32 inputs and outputs assumed for the convenience [`CudaGraphModel::run`] API.
1245    pub fn capture_graph(
1246        &self,
1247        device: &CudaDevice<'_>,
1248        batch_size: usize,
1249        input_shapes: &[Vec<usize>],
1250        output_node_indices: &[usize],
1251    ) -> Result<CudaGraphModel> {
1252        let n = self.nodes.len();
1253        let topo = self.topo_sort();
1254
1255        // ── Phase 1: concrete output shapes for every node ──────────────────
1256        let mut concrete_shapes: Vec<Vec<usize>> = vec![vec![]; n];
1257        let mut input_cursor = 0usize;
1258        for &idx in &topo {
1259            if self.nodes[idx].is_none() {
1260                let shape = input_shapes
1261                    .get(input_cursor)
1262                    .ok_or_else(|| anyhow!("capture_graph: input_shapes[{input_cursor}] missing"))?
1263                    .clone();
1264                concrete_shapes[idx] = shape;
1265                input_cursor += 1;
1266            } else {
1267                let loaded = self.nodes[idx].as_ref().unwrap();
1268                let parent_shapes: Vec<&[usize]> = self.parents[idx]
1269                    .iter()
1270                    .map(|&p| concrete_shapes[p].as_slice())
1271                    .collect();
1272                let raw = resolve_shape(&loaded.output_shape, batch_size);
1273                concrete_shapes[idx] = loaded
1274                    .runtime_op
1275                    .compute_concrete_output_shape(&parent_shapes, &raw);
1276            }
1277        }
1278
1279        // ── Phase 2: pre-allocate all device buffers ─────────────────────────
1280        // All pointers accumulated here; CudaGraphModel::drop frees them.
1281        let mut owned: Vec<DevicePtr> = Vec::new();
1282        let mut node_out_bufs: Vec<DevicePtr> = vec![0; n];
1283        let mut node_scratch_bufs: Vec<DevicePtr> = vec![0; n];
1284        type PaddedEntry = Option<(DevicePtr, usize, usize, usize, usize)>;
1285        // (padded_ptr, natural_stride, required_stride, n_rows, elem_bytes)
1286        let mut node_padded: Vec<PaddedEntry> = vec![None; n];
1287        let mut input_bufs: Vec<(DevicePtr, usize)> = Vec::new();
1288
1289        for &idx in &topo {
1290            if self.nodes[idx].is_none() {
1291                // f32 input buffer — filled by the caller before each run().
1292                let n_elems: usize = concrete_shapes[idx].iter().product();
1293                let ptr = mem::alloc(n_elems * 4)?;
1294                owned.push(ptr);
1295                node_out_bufs[idx] = ptr;
1296                input_bufs.push((ptr, n_elems));
1297                continue;
1298            }
1299
1300            let loaded = self.nodes[idx].as_ref().unwrap();
1301            let output_shape = &concrete_shapes[idx];
1302            let n_elems: usize = output_shape.iter().product();
1303            let elem_bytes = dtype_bytes(loaded.output_dtype);
1304
1305            // Tight output buffer for this node.
1306            let out_ptr = mem::alloc(n_elems * elem_bytes)?;
1307            owned.push(out_ptr);
1308            node_out_bufs[idx] = out_ptr;
1309
1310            // TMA padded buffer when the op requires a wider row stride.
1311            let natural_stride = output_shape.last().copied().unwrap_or(1);
1312            let required_stride = loaded.runtime_op.forward_output_row_stride(output_shape);
1313            if required_stride > natural_stride {
1314                let n_rows = n_elems / natural_stride.max(1);
1315                let padded_bytes = n_rows * required_stride * elem_bytes;
1316                let padded_ptr = mem::alloc(padded_bytes)?;
1317                unsafe { cuda::cuMemsetD8_v2(padded_ptr, 0, padded_bytes) };
1318                owned.push(padded_ptr);
1319                node_padded[idx] = Some((
1320                    padded_ptr,
1321                    natural_stride,
1322                    required_stride,
1323                    n_rows,
1324                    elem_bytes,
1325                ));
1326            }
1327
1328            // Global scratch pad for TMA descriptors.
1329            let grid = loaded.runtime_op.grid(output_shape);
1330            let num_ctas = (grid[0] * grid[1] * grid[2]) as u64;
1331            let scratch_total = loaded.program.metadata.global_scratch_size * num_ctas;
1332            if scratch_total > 0 {
1333                let scratch_ptr = mem::alloc(scratch_total as usize)?;
1334                unsafe { cuda::cuMemsetD8_v2(scratch_ptr, 0, scratch_total as usize) };
1335                owned.push(scratch_ptr);
1336                node_scratch_bufs[idx] = scratch_ptr;
1337            }
1338        }
1339
1340        // ── Phase 3: create stream and begin graph capture ───────────────────
1341        let mut stream: cuda::CUstream = std::ptr::null_mut();
1342        {
1343            let s = unsafe { cuda::cuStreamCreate(&mut stream, 0) };
1344            if s != cuda::cudaError_enum_CUDA_SUCCESS {
1345                for &p in &owned {
1346                    let _ = mem::free(p);
1347                }
1348                return Err(Error::from_cuda_error(s).into());
1349            }
1350        }
1351
1352        {
1353            let s = unsafe {
1354                cuda::cuStreamBeginCapture_v2(
1355                    stream,
1356                    cuda::CUstreamCaptureMode_enum_CU_STREAM_CAPTURE_MODE_GLOBAL,
1357                )
1358            };
1359            if s != cuda::cudaError_enum_CUDA_SUCCESS {
1360                unsafe { cuda::cuStreamDestroy_v2(stream) };
1361                for &p in &owned {
1362                    let _ = mem::free(p);
1363                }
1364                return Err(Error::from_cuda_error(s).into());
1365            }
1366        }
1367
1368        // ── Phase 4: record kernels into the capture stream ──────────────────
1369        let mut capture_err: Option<anyhow::Error> = None;
1370        'capture: for &idx in &topo {
1371            let Some(loaded) = self.nodes[idx].as_ref() else {
1372                continue;
1373            };
1374            let output_shape = &concrete_shapes[idx];
1375
1376            let act_inputs: Vec<(teeny_core::model::RawPtr, &[usize])> = self.parents[idx]
1377                .iter()
1378                .map(|&p| {
1379                    (
1380                        node_out_bufs[p] as *mut core::ffi::c_void,
1381                        concrete_shapes[p].as_slice(),
1382                    )
1383                })
1384                .collect();
1385            let param_ptrs: Vec<teeny_core::model::RawPtr> = loaded
1386                .param_bufs
1387                .iter()
1388                .map(|&p| p as *mut core::ffi::c_void)
1389                .collect();
1390            let input_shapes_ref: Vec<&[usize]> = act_inputs.iter().map(|(_, s)| *s).collect();
1391
1392            let required_stride = loaded.runtime_op.forward_output_row_stride(output_shape);
1393            let scratch_ptr = node_scratch_bufs[idx];
1394            let (kernel_out_raw, tight_out_ptr) = if let Some((padded_ptr, ..)) = node_padded[idx] {
1395                (padded_ptr as *mut core::ffi::c_void, node_out_bufs[idx])
1396            } else {
1397                let op = node_out_bufs[idx];
1398                (op as *mut core::ffi::c_void, op)
1399            };
1400
1401            let block = [loaded.program.metadata.threads_per_block(), 1, 1];
1402            let cluster = [loaded.program.metadata.num_ctas, 1, 1];
1403            let n_launches = loaded.runtime_op.n_launches();
1404
1405            // Re-zero scratch each replay so TMA descriptors start clean.
1406            if scratch_ptr != 0 {
1407                let grid = loaded.runtime_op.grid(output_shape);
1408                let num_ctas = (grid[0] * grid[1] * grid[2]) as u64;
1409                let scratch_total = loaded.program.metadata.global_scratch_size * num_ctas;
1410                let s = unsafe {
1411                    cuda::cuMemsetD8Async(scratch_ptr, 0, scratch_total as usize, stream)
1412                };
1413                if s != cuda::cudaError_enum_CUDA_SUCCESS {
1414                    capture_err = Some(Error::from_cuda_error(s).into());
1415                    break 'capture;
1416                }
1417            }
1418
1419            for launch_idx in 0..n_launches {
1420                let mut packer = CudaArgPacker::new();
1421                if n_launches == 1 {
1422                    loaded.runtime_op.pack_args(
1423                        &act_inputs,
1424                        &param_ptrs,
1425                        kernel_out_raw,
1426                        output_shape,
1427                        required_stride as i32,
1428                        &mut packer,
1429                    );
1430                } else {
1431                    loaded.runtime_op.pack_args_for_launch(
1432                        launch_idx,
1433                        &act_inputs,
1434                        &param_ptrs,
1435                        kernel_out_raw,
1436                        output_shape,
1437                        required_stride as i32,
1438                        &mut packer,
1439                    );
1440                }
1441                // Triton trailing args: global scratch pad + profile scratch pad.
1442                packer.visit_ptr(scratch_ptr as *mut core::ffi::c_void);
1443                packer.visit_ptr(std::ptr::null_mut());
1444
1445                let grid = if n_launches == 1 {
1446                    loaded.runtime_op.grid(output_shape)
1447                } else {
1448                    loaded
1449                        .runtime_op
1450                        .grid_for_launch(launch_idx, &input_shapes_ref, output_shape)
1451                };
1452                let cfg = CudaLaunchConfig {
1453                    grid,
1454                    block,
1455                    cluster,
1456                };
1457                if let Err(e) = device.launch_on_stream(&loaded.program, &cfg, &mut packer, stream)
1458                {
1459                    capture_err = Some(e);
1460                    break 'capture;
1461                }
1462            }
1463
1464            // Depad: copy valid rows from padded → tight output buffer (async, captured).
1465            // Single cuMemcpy2DAsync replaces the previous per-row loop, giving the
1466            // driver one node in the graph instead of n_rows nodes.
1467            if let Some((padded_ptr, ns, rs, n_rows, eb)) = node_padded[idx] {
1468                let params = cuda::CUDA_MEMCPY2D {
1469                    srcMemoryType: cuda::CUmemorytype_enum_CU_MEMORYTYPE_DEVICE,
1470                    srcDevice: padded_ptr,
1471                    srcPitch: rs * eb,
1472                    dstMemoryType: cuda::CUmemorytype_enum_CU_MEMORYTYPE_DEVICE,
1473                    dstDevice: tight_out_ptr,
1474                    dstPitch: ns * eb,
1475                    WidthInBytes: ns * eb,
1476                    Height: n_rows,
1477                    ..Default::default()
1478                };
1479                let s = unsafe { cuda::cuMemcpy2DAsync_v2(&params, stream) };
1480                if s != cuda::cudaError_enum_CUDA_SUCCESS {
1481                    capture_err = Some(Error::from_cuda_error(s).into());
1482                    break 'capture;
1483                }
1484            }
1485        }
1486
1487        // ── Phase 5: end capture and instantiate ─────────────────────────────
1488        let mut graph: cuda::CUgraph = std::ptr::null_mut();
1489        let end_s = unsafe { cuda::cuStreamEndCapture(stream, &mut graph) };
1490
1491        if let Some(err) = capture_err {
1492            // A kernel record step failed — discard the partial graph.
1493            unsafe {
1494                if !graph.is_null() {
1495                    cuda::cuGraphDestroy(graph);
1496                }
1497                cuda::cuStreamDestroy_v2(stream);
1498            }
1499            for &p in &owned {
1500                let _ = mem::free(p);
1501            }
1502            return Err(err);
1503        }
1504        if end_s != cuda::cudaError_enum_CUDA_SUCCESS {
1505            unsafe {
1506                if !graph.is_null() {
1507                    cuda::cuGraphDestroy(graph);
1508                }
1509                cuda::cuStreamDestroy_v2(stream);
1510            }
1511            for &p in &owned {
1512                let _ = mem::free(p);
1513            }
1514            return Err(Error::from_cuda_error(end_s).into());
1515        }
1516
1517        let mut graph_exec: cuda::CUgraphExec = std::ptr::null_mut();
1518        let inst_s = unsafe { cuda::cuGraphInstantiateWithFlags(&mut graph_exec, graph, 0) };
1519        unsafe { cuda::cuGraphDestroy(graph) };
1520        if inst_s != cuda::cudaError_enum_CUDA_SUCCESS {
1521            unsafe { cuda::cuStreamDestroy_v2(stream) };
1522            for &p in &owned {
1523                let _ = mem::free(p);
1524            }
1525            return Err(Error::from_cuda_error(inst_s).into());
1526        }
1527
1528        if output_node_indices.is_empty() {
1529            unsafe {
1530                cuda::cuStreamDestroy_v2(stream);
1531            }
1532            for &p in &owned {
1533                let _ = mem::free(p);
1534            }
1535            return Err(anyhow!(
1536                "capture_graph: output_node_indices must not be empty"
1537            ));
1538        }
1539        let mut output_bufs: Vec<(DevicePtr, usize)> =
1540            Vec::with_capacity(output_node_indices.len());
1541        let mut output_shapes: Vec<Vec<usize>> = Vec::with_capacity(output_node_indices.len());
1542        for &oi in output_node_indices {
1543            if oi >= self.nodes.len() || self.nodes[oi].is_none() {
1544                unsafe {
1545                    cuda::cuStreamDestroy_v2(stream);
1546                }
1547                for &p in &owned {
1548                    let _ = mem::free(p);
1549                }
1550                return Err(anyhow!(
1551                    "capture_graph: output node {oi} is an Input or out of bounds"
1552                ));
1553            }
1554            let shape = concrete_shapes[oi].clone();
1555            let n_elems: usize = shape.iter().product();
1556            output_bufs.push((node_out_bufs[oi], n_elems));
1557            output_shapes.push(shape);
1558        }
1559
1560        // ── Phase 5: allocate pinned (page-locked) host staging buffers ─────────
1561        // One buffer per input and per output.  cuMemcpyHtoD/DtoH over pinned
1562        // memory uses DMA directly, skipping the driver's internal pageable
1563        // staging bounce and achieving full PCIe bandwidth.
1564        let mut pinned_inputs: Vec<*mut f32> = Vec::with_capacity(input_bufs.len());
1565        for &(_, n_elems) in &input_bufs {
1566            match mem::alloc_host::<f32>(n_elems) {
1567                Ok(ptr) => pinned_inputs.push(ptr),
1568                Err(e) => {
1569                    unsafe {
1570                        cuda::cuStreamDestroy_v2(stream);
1571                    }
1572                    for &p in &owned {
1573                        let _ = mem::free(p);
1574                    }
1575                    for &p in &pinned_inputs {
1576                        unsafe {
1577                            let _ = mem::free_host(p);
1578                        }
1579                    }
1580                    return Err(e);
1581                }
1582            }
1583        }
1584        let mut pinned_outputs: Vec<*mut f32> = Vec::with_capacity(output_bufs.len());
1585        for &(_, n_elems) in &output_bufs {
1586            match mem::alloc_host::<f32>(n_elems) {
1587                Ok(ptr) => pinned_outputs.push(ptr),
1588                Err(e) => {
1589                    unsafe {
1590                        cuda::cuStreamDestroy_v2(stream);
1591                    }
1592                    for &p in &owned {
1593                        let _ = mem::free(p);
1594                    }
1595                    for &p in &pinned_inputs {
1596                        unsafe {
1597                            let _ = mem::free_host(p);
1598                        }
1599                    }
1600                    for &p in &pinned_outputs {
1601                        unsafe {
1602                            let _ = mem::free_host(p);
1603                        }
1604                    }
1605                    return Err(e);
1606                }
1607            }
1608        }
1609
1610        Ok(CudaGraphModel {
1611            stream,
1612            graph_exec,
1613            input_bufs,
1614            output_bufs,
1615            output_shapes,
1616            pinned_inputs,
1617            pinned_outputs,
1618            _owned: owned,
1619        })
1620    }
1621
1622    fn topo_sort(&self) -> Vec<usize> {
1623        let n = self.nodes.len();
1624        let mut in_deg: Vec<usize> = (0..n).map(|i| self.parents[i].len()).collect();
1625        let mut dependents: Vec<Vec<usize>> = vec![vec![]; n];
1626        for i in 0..n {
1627            for &p in &self.parents[i] {
1628                dependents[p].push(i);
1629            }
1630        }
1631        let mut stack: Vec<usize> = (0..n).filter(|&i| in_deg[i] == 0).collect();
1632        let mut order = Vec::with_capacity(n);
1633        while let Some(id) = stack.pop() {
1634            order.push(id);
1635            for &dep in &dependents[id] {
1636                in_deg[dep] -= 1;
1637                if in_deg[dep] == 0 {
1638                    stack.push(dep);
1639                }
1640            }
1641        }
1642        order
1643    }
1644}
1645
1646/// Activation buffers retained from a `forward_train` call.
1647///
1648/// Implements `Drop` so device buffers are freed automatically.
1649#[cfg(feature = "training")]
1650pub struct ActivationCache {
1651    /// Per-node activation tensor, indexed by DAG node index. `None` for nodes with no cached
1652    /// activation (e.g. `Input` placeholders).
1653    pub tensors: Vec<Option<TensorRef>>,
1654}
1655
1656#[cfg(feature = "training")]
1657impl Drop for ActivationCache {
1658    fn drop(&mut self) {
1659        for tr in self.tensors.iter().flatten() {
1660            if let Err(e) = mem::free(tr.ptr) {
1661                eprintln!("ActivationCache: failed to free buffer: {e}");
1662            }
1663        }
1664    }
1665}
1666
1667// ---------------------------------------------------------------------------
1668// CudaGraphModel — captured inference graph for fixed-batch execution
1669// ---------------------------------------------------------------------------
1670
1671/// A CUDA graph compiled from a [`LoadedModel`] for fixed-batch inference.
1672///
1673/// Created via [`LoadedModel::capture_graph`]. All device buffers are
1674/// pre-allocated; the kernel sequence is captured once and replayed on each
1675/// [`run`] call with a single `cuGraphLaunch` + `cuStreamSynchronize`.
1676pub struct CudaGraphModel {
1677    stream: cuda::CUstream,
1678    graph_exec: cuda::CUgraphExec,
1679    /// Pre-allocated input buffers (one per `Input` node, topological order).
1680    input_bufs: Vec<(DevicePtr, usize)>,
1681    /// Pre-allocated output buffers, in the order of `output_node_indices`.
1682    output_bufs: Vec<(DevicePtr, usize)>,
1683    output_shapes: Vec<Vec<usize>>,
1684    /// Page-locked (pinned) staging buffers for inputs — one per `input_bufs`
1685    /// entry. Pinned memory enables direct DMA, avoiding the driver's internal
1686    /// pageable staging bounce and achieving full PCIe bandwidth.
1687    pinned_inputs: Vec<*mut f32>,
1688    /// Page-locked (pinned) staging buffers for outputs — one per `output_bufs`.
1689    pinned_outputs: Vec<*mut f32>,
1690    /// All owned device allocations freed on drop.
1691    _owned: Vec<DevicePtr>,
1692}
1693
1694// SAFETY: CudaGraphModel is used from a single thread. The raw CUDA handles
1695// and pinned pointers are not shared across threads.
1696unsafe impl Send for CudaGraphModel {}
1697
1698impl CudaGraphModel {
1699    /// Concrete shapes of the output tensors, in the order of `output_node_indices`.
1700    pub fn output_shapes(&self) -> &[Vec<usize>] {
1701        &self.output_shapes
1702    }
1703
1704    /// Like [`run`] but also returns GPU execution time in milliseconds.
1705    ///
1706    /// The GPU time is measured with CUDA events bracketing only `cuGraphLaunch`
1707    /// (pure kernel execution, excluding host↔device copies).
1708    /// The returned `f32` is milliseconds of GPU time for the whole batch.
1709    pub fn run_timed(&self, inputs: &[&[f32]]) -> Result<(Vec<Vec<f32>>, f32)> {
1710        if inputs.len() != self.input_bufs.len() {
1711            return Err(anyhow!(
1712                "CudaGraphModel::run_timed: expected {} inputs, got {}",
1713                self.input_bufs.len(),
1714                inputs.len()
1715            ));
1716        }
1717        for (i, (&(ptr, n_elems), &data)) in self.input_bufs.iter().zip(inputs.iter()).enumerate() {
1718            if data.len() != n_elems {
1719                return Err(anyhow!(
1720                    "CudaGraphModel::run_timed: input[{i}] has {} elements, expected {n_elems}",
1721                    data.len()
1722                ));
1723            }
1724            unsafe { mem::copy_h_to_d(ptr, data.as_ptr(), n_elems) }?;
1725        }
1726
1727        // Create CUDA events to measure GPU kernel time.
1728        let mut ev_start = cuda::CUevent::default();
1729        let mut ev_end = cuda::CUevent::default();
1730        let cu_event_default = 0u32;
1731        unsafe {
1732            cuda::cuEventCreate(&mut ev_start, cu_event_default);
1733            cuda::cuEventCreate(&mut ev_end, cu_event_default);
1734            cuda::cuEventRecord(ev_start, self.stream);
1735        }
1736
1737        let launch_s = unsafe { cuda::cuGraphLaunch(self.graph_exec, self.stream) };
1738        if launch_s != cuda::cudaError_enum_CUDA_SUCCESS {
1739            unsafe {
1740                cuda::cuEventDestroy_v2(ev_start);
1741                cuda::cuEventDestroy_v2(ev_end);
1742            }
1743            return Err(Error::from_cuda_error(launch_s).into());
1744        }
1745
1746        unsafe {
1747            cuda::cuEventRecord(ev_end, self.stream);
1748        }
1749
1750        let sync_s = unsafe { cuda::cuEventSynchronize(ev_end) };
1751        if sync_s != cuda::cudaError_enum_CUDA_SUCCESS {
1752            unsafe {
1753                cuda::cuEventDestroy_v2(ev_start);
1754                cuda::cuEventDestroy_v2(ev_end);
1755            }
1756            return Err(Error::from_cuda_error(sync_s).into());
1757        }
1758
1759        let mut gpu_ms = 0.0f32;
1760        unsafe {
1761            cuda::cudaEventElapsedTime(&mut gpu_ms, ev_start, ev_end);
1762            cuda::cuEventDestroy_v2(ev_start);
1763            cuda::cuEventDestroy_v2(ev_end);
1764        }
1765
1766        let mut result = Vec::with_capacity(self.output_bufs.len());
1767        for &(ptr, n_elems) in &self.output_bufs {
1768            let mut out = vec![0.0_f32; n_elems];
1769            unsafe { mem::copy_d_to_h(out.as_mut_ptr(), ptr, n_elems) }?;
1770            result.push(out);
1771        }
1772        Ok((result, gpu_ms))
1773    }
1774
1775    /// Copy f32 `inputs` to device, replay the CUDA graph, copy f32 outputs to host.
1776    ///
1777    /// Returns one `Vec<f32>` per requested output node (same order as
1778    /// `output_node_indices` passed to [`LoadedModel::capture_graph`]).
1779    pub fn run(&self, inputs: &[&[f32]]) -> Result<Vec<Vec<f32>>> {
1780        if inputs.len() != self.input_bufs.len() {
1781            return Err(anyhow!(
1782                "CudaGraphModel::run: expected {} inputs, got {}",
1783                self.input_bufs.len(),
1784                inputs.len()
1785            ));
1786        }
1787        for (i, (&(ptr, n_elems), &data)) in self.input_bufs.iter().zip(inputs.iter()).enumerate() {
1788            if data.len() != n_elems {
1789                return Err(anyhow!(
1790                    "CudaGraphModel::run: input[{i}] has {} elements, expected {n_elems}",
1791                    data.len()
1792                ));
1793            }
1794            unsafe { mem::copy_h_to_d(ptr, data.as_ptr(), n_elems) }?;
1795        }
1796
1797        let launch_s = unsafe { cuda::cuGraphLaunch(self.graph_exec, self.stream) };
1798        if launch_s != cuda::cudaError_enum_CUDA_SUCCESS {
1799            return Err(Error::from_cuda_error(launch_s).into());
1800        }
1801        let sync_s = unsafe { cuda::cuStreamSynchronize(self.stream) };
1802        if sync_s != cuda::cudaError_enum_CUDA_SUCCESS {
1803            return Err(Error::from_cuda_error(sync_s).into());
1804        }
1805
1806        let mut result = Vec::with_capacity(self.output_bufs.len());
1807        for &(ptr, n_elems) in &self.output_bufs {
1808            let mut out = vec![0.0_f32; n_elems];
1809            unsafe { mem::copy_d_to_h(out.as_mut_ptr(), ptr, n_elems) }?;
1810            result.push(out);
1811        }
1812        Ok(result)
1813    }
1814
1815    /// Mutable slice into the `i`-th pinned (page-locked) input staging buffer.
1816    ///
1817    /// Write your input data here before calling [`run_inplace`] /
1818    /// [`run_timed_inplace`] to avoid the intermediate CPU copy that
1819    /// [`run`] / [`run_timed`] perform when given a pageable `&[f32]`.
1820    ///
1821    /// # Safety
1822    /// The slice is valid until this `CudaGraphModel` is dropped.
1823    pub fn input_slice_mut(&mut self, i: usize) -> &mut [f32] {
1824        let (_, n_elems) = self.input_bufs[i];
1825        unsafe { std::slice::from_raw_parts_mut(self.pinned_inputs[i], n_elems) }
1826    }
1827
1828    /// Immutable slice into the `i`-th pinned (page-locked) output staging
1829    /// buffer. Valid after [`run_inplace`] / [`run_timed_inplace`] returns.
1830    pub fn output_slice(&self, i: usize) -> &[f32] {
1831        let (_, n_elems) = self.output_bufs[i];
1832        unsafe { std::slice::from_raw_parts(self.pinned_outputs[i], n_elems) }
1833    }
1834
1835    /// Copy pinned inputs → device, launch graph, copy device → pinned outputs.
1836    ///
1837    /// Callers must fill [`input_slice_mut`] before calling and read
1838    /// [`output_slice`] afterwards.  No heap allocations are performed.
1839    pub fn run_inplace(&self) -> Result<()> {
1840        for (i, &(dev_ptr, n_elems)) in self.input_bufs.iter().enumerate() {
1841            unsafe { mem::copy_h_to_d(dev_ptr, self.pinned_inputs[i], n_elems) }?;
1842        }
1843        let launch_s = unsafe { cuda::cuGraphLaunch(self.graph_exec, self.stream) };
1844        if launch_s != cuda::cudaError_enum_CUDA_SUCCESS {
1845            return Err(Error::from_cuda_error(launch_s).into());
1846        }
1847        let sync_s = unsafe { cuda::cuStreamSynchronize(self.stream) };
1848        if sync_s != cuda::cudaError_enum_CUDA_SUCCESS {
1849            return Err(Error::from_cuda_error(sync_s).into());
1850        }
1851        for (i, &(dev_ptr, n_elems)) in self.output_bufs.iter().enumerate() {
1852            unsafe { mem::copy_d_to_h(self.pinned_outputs[i], dev_ptr, n_elems) }?;
1853        }
1854        Ok(())
1855    }
1856
1857    /// Like [`run_inplace`] but also returns GPU execution time in milliseconds.
1858    pub fn run_timed_inplace(&self) -> Result<f32> {
1859        for (i, &(dev_ptr, n_elems)) in self.input_bufs.iter().enumerate() {
1860            unsafe { mem::copy_h_to_d(dev_ptr, self.pinned_inputs[i], n_elems) }?;
1861        }
1862
1863        let mut ev_start = cuda::CUevent::default();
1864        let mut ev_end = cuda::CUevent::default();
1865        unsafe {
1866            cuda::cuEventCreate(&mut ev_start, 0);
1867            cuda::cuEventCreate(&mut ev_end, 0);
1868            cuda::cuEventRecord(ev_start, self.stream);
1869        }
1870
1871        let launch_s = unsafe { cuda::cuGraphLaunch(self.graph_exec, self.stream) };
1872        if launch_s != cuda::cudaError_enum_CUDA_SUCCESS {
1873            unsafe {
1874                cuda::cuEventDestroy_v2(ev_start);
1875                cuda::cuEventDestroy_v2(ev_end);
1876            }
1877            return Err(Error::from_cuda_error(launch_s).into());
1878        }
1879
1880        unsafe {
1881            cuda::cuEventRecord(ev_end, self.stream);
1882        }
1883
1884        let sync_s = unsafe { cuda::cuEventSynchronize(ev_end) };
1885        if sync_s != cuda::cudaError_enum_CUDA_SUCCESS {
1886            unsafe {
1887                cuda::cuEventDestroy_v2(ev_start);
1888                cuda::cuEventDestroy_v2(ev_end);
1889            }
1890            return Err(Error::from_cuda_error(sync_s).into());
1891        }
1892
1893        let mut gpu_ms = 0.0f32;
1894        unsafe {
1895            cuda::cudaEventElapsedTime(&mut gpu_ms, ev_start, ev_end);
1896            cuda::cuEventDestroy_v2(ev_start);
1897            cuda::cuEventDestroy_v2(ev_end);
1898        }
1899
1900        for (i, &(dev_ptr, n_elems)) in self.output_bufs.iter().enumerate() {
1901            unsafe { mem::copy_d_to_h(self.pinned_outputs[i], dev_ptr, n_elems) }?;
1902        }
1903        Ok(gpu_ms)
1904    }
1905}
1906
1907impl Drop for CudaGraphModel {
1908    fn drop(&mut self) {
1909        unsafe {
1910            cuda::cuGraphExecDestroy(self.graph_exec);
1911            cuda::cuStreamDestroy_v2(self.stream);
1912        }
1913        for &ptr in &self._owned {
1914            if ptr != 0 && mem::free(ptr).is_err() {
1915                eprintln!("CudaGraphModel::drop: failed to free buffer at {ptr:#x}");
1916            }
1917        }
1918        for &ptr in &self.pinned_inputs {
1919            if !ptr.is_null()
1920                && let Err(e) = unsafe { mem::free_host(ptr) }
1921            {
1922                eprintln!("CudaGraphModel::drop: failed to free pinned input: {e}");
1923            }
1924        }
1925        for &ptr in &self.pinned_outputs {
1926            if !ptr.is_null()
1927                && let Err(e) = unsafe { mem::free_host(ptr) }
1928            {
1929                eprintln!("CudaGraphModel::drop: failed to free pinned output: {e}");
1930            }
1931        }
1932    }
1933}
1934
1935/// A pre-compiled `adamw_step` kernel ready for use in `LoadedModel::adamw_step`.
1936///
1937/// Create via:
1938/// ```ignore
1939/// let ptx = std::fs::read(compile_kernel(&AdamwStep::new(1024), &target, true)?)?;
1940/// let kernel = AdamwKernel::from_ptx(&ptx)?;
1941/// ```
1942#[cfg(feature = "training")]
1943pub struct AdamwKernel {
1944    pub(crate) program: CudaProgram<'static, ErasedKernel>,
1945}
1946
1947#[cfg(feature = "training")]
1948impl AdamwKernel {
1949    /// Loads a pre-compiled `adamw_step` kernel from raw PTX bytes.
1950    pub fn from_ptx(ptx: &[u8]) -> Result<Self> {
1951        let program = CudaProgram::<ErasedKernel>::try_from_ptx(ptx)?;
1952        Ok(Self { program })
1953    }
1954}