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 = loaded.runtime_op.forward_output_row_elems(&output_shape);
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 = loaded.runtime_op.forward_output_row_elems(&output_shape);
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 = loaded.runtime_op.forward_output_row_elems(output_shape);
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(anyhow::anyhow!(
1415                        "capture_graph: scratch memset failed at node {idx} \
1416                         (shape={output_shape:?}, scratch_total={scratch_total}): {}",
1417                        Error::from_cuda_error(s),
1418                    ));
1419                    break 'capture;
1420                }
1421            }
1422
1423            for launch_idx in 0..n_launches {
1424                let mut packer = CudaArgPacker::new();
1425                if n_launches == 1 {
1426                    loaded.runtime_op.pack_args(
1427                        &act_inputs,
1428                        &param_ptrs,
1429                        kernel_out_raw,
1430                        output_shape,
1431                        required_stride as i32,
1432                        &mut packer,
1433                    );
1434                } else {
1435                    loaded.runtime_op.pack_args_for_launch(
1436                        launch_idx,
1437                        &act_inputs,
1438                        &param_ptrs,
1439                        kernel_out_raw,
1440                        output_shape,
1441                        required_stride as i32,
1442                        &mut packer,
1443                    );
1444                }
1445                // Triton trailing args: global scratch pad + profile scratch pad.
1446                packer.visit_ptr(scratch_ptr as *mut core::ffi::c_void);
1447                packer.visit_ptr(std::ptr::null_mut());
1448
1449                let grid = if n_launches == 1 {
1450                    loaded.runtime_op.grid(output_shape)
1451                } else {
1452                    loaded
1453                        .runtime_op
1454                        .grid_for_launch(launch_idx, &input_shapes_ref, output_shape)
1455                };
1456                let cfg = CudaLaunchConfig {
1457                    grid,
1458                    block,
1459                    cluster,
1460                };
1461                if let Err(e) = device.launch_on_stream(&loaded.program, &cfg, &mut packer, stream)
1462                {
1463                    capture_err = Some(anyhow::anyhow!(
1464                        "capture_graph: launch failed at node {idx} \
1465                         (grid={grid:?}, block={block:?}, cluster={cluster:?}, \
1466                         shape={output_shape:?}, row_stride={required_stride}, \
1467                         scratch={}): {e:#}",
1468                        loaded.program.metadata.global_scratch_size,
1469                    ));
1470                    break 'capture;
1471                }
1472            }
1473
1474            // Depad: copy valid rows from padded → tight output buffer (async, captured).
1475            // Single cuMemcpy2DAsync replaces the previous per-row loop, giving the
1476            // driver one node in the graph instead of n_rows nodes.
1477            if let Some((padded_ptr, ns, rs, n_rows, eb)) = node_padded[idx] {
1478                let params = cuda::CUDA_MEMCPY2D {
1479                    srcMemoryType: cuda::CUmemorytype_enum_CU_MEMORYTYPE_DEVICE,
1480                    srcDevice: padded_ptr,
1481                    srcPitch: rs * eb,
1482                    dstMemoryType: cuda::CUmemorytype_enum_CU_MEMORYTYPE_DEVICE,
1483                    dstDevice: tight_out_ptr,
1484                    dstPitch: ns * eb,
1485                    WidthInBytes: ns * eb,
1486                    Height: n_rows,
1487                    ..Default::default()
1488                };
1489                let s = unsafe { cuda::cuMemcpy2DAsync_v2(&params, stream) };
1490                if s != cuda::cudaError_enum_CUDA_SUCCESS {
1491                    capture_err = Some(anyhow::anyhow!(
1492                        "capture_graph: cuMemcpy2DAsync depad failed at node {idx} \
1493                         (ns={ns}, rs={rs}, n_rows={n_rows}, eb={eb}): {}",
1494                        Error::from_cuda_error(s),
1495                    ));
1496                    break 'capture;
1497                }
1498            }
1499        }
1500
1501        // ── Phase 5: end capture and instantiate ─────────────────────────────
1502        let mut graph: cuda::CUgraph = std::ptr::null_mut();
1503        let end_s = unsafe { cuda::cuStreamEndCapture(stream, &mut graph) };
1504
1505        if let Some(err) = capture_err {
1506            // A kernel record step failed — discard the partial graph.
1507            unsafe {
1508                if !graph.is_null() {
1509                    cuda::cuGraphDestroy(graph);
1510                }
1511                cuda::cuStreamDestroy_v2(stream);
1512            }
1513            for &p in &owned {
1514                let _ = mem::free(p);
1515            }
1516            return Err(err);
1517        }
1518        if end_s != cuda::cudaError_enum_CUDA_SUCCESS {
1519            unsafe {
1520                if !graph.is_null() {
1521                    cuda::cuGraphDestroy(graph);
1522                }
1523                cuda::cuStreamDestroy_v2(stream);
1524            }
1525            for &p in &owned {
1526                let _ = mem::free(p);
1527            }
1528            return Err(anyhow::anyhow!(
1529                "capture_graph: cuStreamEndCapture failed: {}",
1530                Error::from_cuda_error(end_s)
1531            ));
1532        }
1533
1534        let mut graph_exec: cuda::CUgraphExec = std::ptr::null_mut();
1535        let inst_s = unsafe { cuda::cuGraphInstantiateWithFlags(&mut graph_exec, graph, 0) };
1536        unsafe { cuda::cuGraphDestroy(graph) };
1537        if inst_s != cuda::cudaError_enum_CUDA_SUCCESS {
1538            unsafe { cuda::cuStreamDestroy_v2(stream) };
1539            for &p in &owned {
1540                let _ = mem::free(p);
1541            }
1542            return Err(anyhow::anyhow!(
1543                "capture_graph: cuGraphInstantiateWithFlags failed: {}",
1544                Error::from_cuda_error(inst_s)
1545            ));
1546        }
1547
1548        if output_node_indices.is_empty() {
1549            unsafe {
1550                cuda::cuStreamDestroy_v2(stream);
1551            }
1552            for &p in &owned {
1553                let _ = mem::free(p);
1554            }
1555            return Err(anyhow!(
1556                "capture_graph: output_node_indices must not be empty"
1557            ));
1558        }
1559        let mut output_bufs: Vec<(DevicePtr, usize)> =
1560            Vec::with_capacity(output_node_indices.len());
1561        let mut output_shapes: Vec<Vec<usize>> = Vec::with_capacity(output_node_indices.len());
1562        for &oi in output_node_indices {
1563            if oi >= self.nodes.len() || self.nodes[oi].is_none() {
1564                unsafe {
1565                    cuda::cuStreamDestroy_v2(stream);
1566                }
1567                for &p in &owned {
1568                    let _ = mem::free(p);
1569                }
1570                return Err(anyhow!(
1571                    "capture_graph: output node {oi} is an Input or out of bounds"
1572                ));
1573            }
1574            let shape = concrete_shapes[oi].clone();
1575            let n_elems: usize = shape.iter().product();
1576            output_bufs.push((node_out_bufs[oi], n_elems));
1577            output_shapes.push(shape);
1578        }
1579
1580        // ── Phase 5: allocate pinned (page-locked) host staging buffers ─────────
1581        // One buffer per input and per output.  cuMemcpyHtoD/DtoH over pinned
1582        // memory uses DMA directly, skipping the driver's internal pageable
1583        // staging bounce and achieving full PCIe bandwidth.
1584        let mut pinned_inputs: Vec<*mut f32> = Vec::with_capacity(input_bufs.len());
1585        for &(_, n_elems) in &input_bufs {
1586            match mem::alloc_host::<f32>(n_elems) {
1587                Ok(ptr) => pinned_inputs.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                    return Err(e);
1601                }
1602            }
1603        }
1604        let mut pinned_outputs: Vec<*mut f32> = Vec::with_capacity(output_bufs.len());
1605        for &(_, n_elems) in &output_bufs {
1606            match mem::alloc_host::<f32>(n_elems) {
1607                Ok(ptr) => pinned_outputs.push(ptr),
1608                Err(e) => {
1609                    unsafe {
1610                        cuda::cuStreamDestroy_v2(stream);
1611                    }
1612                    for &p in &owned {
1613                        let _ = mem::free(p);
1614                    }
1615                    for &p in &pinned_inputs {
1616                        unsafe {
1617                            let _ = mem::free_host(p);
1618                        }
1619                    }
1620                    for &p in &pinned_outputs {
1621                        unsafe {
1622                            let _ = mem::free_host(p);
1623                        }
1624                    }
1625                    return Err(e);
1626                }
1627            }
1628        }
1629
1630        Ok(CudaGraphModel {
1631            stream,
1632            graph_exec,
1633            input_bufs,
1634            output_bufs,
1635            output_shapes,
1636            pinned_inputs,
1637            pinned_outputs,
1638            _owned: owned,
1639        })
1640    }
1641
1642    fn topo_sort(&self) -> Vec<usize> {
1643        let n = self.nodes.len();
1644        let mut in_deg: Vec<usize> = (0..n).map(|i| self.parents[i].len()).collect();
1645        let mut dependents: Vec<Vec<usize>> = vec![vec![]; n];
1646        for i in 0..n {
1647            for &p in &self.parents[i] {
1648                dependents[p].push(i);
1649            }
1650        }
1651        let mut stack: Vec<usize> = (0..n).filter(|&i| in_deg[i] == 0).collect();
1652        let mut order = Vec::with_capacity(n);
1653        while let Some(id) = stack.pop() {
1654            order.push(id);
1655            for &dep in &dependents[id] {
1656                in_deg[dep] -= 1;
1657                if in_deg[dep] == 0 {
1658                    stack.push(dep);
1659                }
1660            }
1661        }
1662        order
1663    }
1664}
1665
1666/// Activation buffers retained from a `forward_train` call.
1667///
1668/// Implements `Drop` so device buffers are freed automatically.
1669#[cfg(feature = "training")]
1670pub struct ActivationCache {
1671    /// Per-node activation tensor, indexed by DAG node index. `None` for nodes with no cached
1672    /// activation (e.g. `Input` placeholders).
1673    pub tensors: Vec<Option<TensorRef>>,
1674}
1675
1676#[cfg(feature = "training")]
1677impl Drop for ActivationCache {
1678    fn drop(&mut self) {
1679        for tr in self.tensors.iter().flatten() {
1680            if let Err(e) = mem::free(tr.ptr) {
1681                eprintln!("ActivationCache: failed to free buffer: {e}");
1682            }
1683        }
1684    }
1685}
1686
1687// ---------------------------------------------------------------------------
1688// CudaGraphModel — captured inference graph for fixed-batch execution
1689// ---------------------------------------------------------------------------
1690
1691/// A CUDA graph compiled from a [`LoadedModel`] for fixed-batch inference.
1692///
1693/// Created via [`LoadedModel::capture_graph`]. All device buffers are
1694/// pre-allocated; the kernel sequence is captured once and replayed on each
1695/// [`run`] call with a single `cuGraphLaunch` + `cuStreamSynchronize`.
1696pub struct CudaGraphModel {
1697    stream: cuda::CUstream,
1698    graph_exec: cuda::CUgraphExec,
1699    /// Pre-allocated input buffers (one per `Input` node, topological order).
1700    input_bufs: Vec<(DevicePtr, usize)>,
1701    /// Pre-allocated output buffers, in the order of `output_node_indices`.
1702    output_bufs: Vec<(DevicePtr, usize)>,
1703    output_shapes: Vec<Vec<usize>>,
1704    /// Page-locked (pinned) staging buffers for inputs — one per `input_bufs`
1705    /// entry. Pinned memory enables direct DMA, avoiding the driver's internal
1706    /// pageable staging bounce and achieving full PCIe bandwidth.
1707    pinned_inputs: Vec<*mut f32>,
1708    /// Page-locked (pinned) staging buffers for outputs — one per `output_bufs`.
1709    pinned_outputs: Vec<*mut f32>,
1710    /// All owned device allocations freed on drop.
1711    _owned: Vec<DevicePtr>,
1712}
1713
1714// SAFETY: CudaGraphModel is used from a single thread. The raw CUDA handles
1715// and pinned pointers are not shared across threads.
1716unsafe impl Send for CudaGraphModel {}
1717
1718impl CudaGraphModel {
1719    /// Concrete shapes of the output tensors, in the order of `output_node_indices`.
1720    pub fn output_shapes(&self) -> &[Vec<usize>] {
1721        &self.output_shapes
1722    }
1723
1724    /// Like [`run`] but also returns GPU execution time in milliseconds.
1725    ///
1726    /// The GPU time is measured with CUDA events bracketing only `cuGraphLaunch`
1727    /// (pure kernel execution, excluding host↔device copies).
1728    /// The returned `f32` is milliseconds of GPU time for the whole batch.
1729    pub fn run_timed(&self, inputs: &[&[f32]]) -> Result<(Vec<Vec<f32>>, f32)> {
1730        if inputs.len() != self.input_bufs.len() {
1731            return Err(anyhow!(
1732                "CudaGraphModel::run_timed: expected {} inputs, got {}",
1733                self.input_bufs.len(),
1734                inputs.len()
1735            ));
1736        }
1737        for (i, (&(ptr, n_elems), &data)) in self.input_bufs.iter().zip(inputs.iter()).enumerate() {
1738            if data.len() != n_elems {
1739                return Err(anyhow!(
1740                    "CudaGraphModel::run_timed: input[{i}] has {} elements, expected {n_elems}",
1741                    data.len()
1742                ));
1743            }
1744            unsafe { mem::copy_h_to_d(ptr, data.as_ptr(), n_elems) }?;
1745        }
1746
1747        // Create CUDA events to measure GPU kernel time.
1748        let mut ev_start = cuda::CUevent::default();
1749        let mut ev_end = cuda::CUevent::default();
1750        let cu_event_default = 0u32;
1751        unsafe {
1752            cuda::cuEventCreate(&mut ev_start, cu_event_default);
1753            cuda::cuEventCreate(&mut ev_end, cu_event_default);
1754            cuda::cuEventRecord(ev_start, self.stream);
1755        }
1756
1757        let launch_s = unsafe { cuda::cuGraphLaunch(self.graph_exec, self.stream) };
1758        if launch_s != cuda::cudaError_enum_CUDA_SUCCESS {
1759            unsafe {
1760                cuda::cuEventDestroy_v2(ev_start);
1761                cuda::cuEventDestroy_v2(ev_end);
1762            }
1763            return Err(Error::from_cuda_error(launch_s).into());
1764        }
1765
1766        unsafe {
1767            cuda::cuEventRecord(ev_end, self.stream);
1768        }
1769
1770        let sync_s = unsafe { cuda::cuEventSynchronize(ev_end) };
1771        if sync_s != cuda::cudaError_enum_CUDA_SUCCESS {
1772            unsafe {
1773                cuda::cuEventDestroy_v2(ev_start);
1774                cuda::cuEventDestroy_v2(ev_end);
1775            }
1776            return Err(Error::from_cuda_error(sync_s).into());
1777        }
1778
1779        let mut gpu_ms = 0.0f32;
1780        unsafe {
1781            cuda::cudaEventElapsedTime(&mut gpu_ms, ev_start, ev_end);
1782            cuda::cuEventDestroy_v2(ev_start);
1783            cuda::cuEventDestroy_v2(ev_end);
1784        }
1785
1786        let mut result = Vec::with_capacity(self.output_bufs.len());
1787        for &(ptr, n_elems) in &self.output_bufs {
1788            let mut out = vec![0.0_f32; n_elems];
1789            unsafe { mem::copy_d_to_h(out.as_mut_ptr(), ptr, n_elems) }?;
1790            result.push(out);
1791        }
1792        Ok((result, gpu_ms))
1793    }
1794
1795    /// Copy f32 `inputs` to device, replay the CUDA graph, copy f32 outputs to host.
1796    ///
1797    /// Returns one `Vec<f32>` per requested output node (same order as
1798    /// `output_node_indices` passed to [`LoadedModel::capture_graph`]).
1799    pub fn run(&self, inputs: &[&[f32]]) -> Result<Vec<Vec<f32>>> {
1800        if inputs.len() != self.input_bufs.len() {
1801            return Err(anyhow!(
1802                "CudaGraphModel::run: expected {} inputs, got {}",
1803                self.input_bufs.len(),
1804                inputs.len()
1805            ));
1806        }
1807        for (i, (&(ptr, n_elems), &data)) in self.input_bufs.iter().zip(inputs.iter()).enumerate() {
1808            if data.len() != n_elems {
1809                return Err(anyhow!(
1810                    "CudaGraphModel::run: input[{i}] has {} elements, expected {n_elems}",
1811                    data.len()
1812                ));
1813            }
1814            unsafe { mem::copy_h_to_d(ptr, data.as_ptr(), n_elems) }?;
1815        }
1816
1817        let launch_s = unsafe { cuda::cuGraphLaunch(self.graph_exec, self.stream) };
1818        if launch_s != cuda::cudaError_enum_CUDA_SUCCESS {
1819            return Err(Error::from_cuda_error(launch_s).into());
1820        }
1821        let sync_s = unsafe { cuda::cuStreamSynchronize(self.stream) };
1822        if sync_s != cuda::cudaError_enum_CUDA_SUCCESS {
1823            return Err(Error::from_cuda_error(sync_s).into());
1824        }
1825
1826        let mut result = Vec::with_capacity(self.output_bufs.len());
1827        for &(ptr, n_elems) in &self.output_bufs {
1828            let mut out = vec![0.0_f32; n_elems];
1829            unsafe { mem::copy_d_to_h(out.as_mut_ptr(), ptr, n_elems) }?;
1830            result.push(out);
1831        }
1832        Ok(result)
1833    }
1834
1835    /// Mutable slice into the `i`-th pinned (page-locked) input staging buffer.
1836    ///
1837    /// Write your input data here before calling [`run_inplace`] /
1838    /// [`run_timed_inplace`] to avoid the intermediate CPU copy that
1839    /// [`run`] / [`run_timed`] perform when given a pageable `&[f32]`.
1840    ///
1841    /// # Safety
1842    /// The slice is valid until this `CudaGraphModel` is dropped.
1843    pub fn input_slice_mut(&mut self, i: usize) -> &mut [f32] {
1844        let (_, n_elems) = self.input_bufs[i];
1845        unsafe { std::slice::from_raw_parts_mut(self.pinned_inputs[i], n_elems) }
1846    }
1847
1848    /// Immutable slice into the `i`-th pinned (page-locked) output staging
1849    /// buffer. Valid after [`run_inplace`] / [`run_timed_inplace`] returns.
1850    pub fn output_slice(&self, i: usize) -> &[f32] {
1851        let (_, n_elems) = self.output_bufs[i];
1852        unsafe { std::slice::from_raw_parts(self.pinned_outputs[i], n_elems) }
1853    }
1854
1855    /// Copy pinned inputs → device, launch graph, copy device → pinned outputs.
1856    ///
1857    /// Callers must fill [`input_slice_mut`] before calling and read
1858    /// [`output_slice`] afterwards.  No heap allocations are performed.
1859    pub fn run_inplace(&self) -> Result<()> {
1860        for (i, &(dev_ptr, n_elems)) in self.input_bufs.iter().enumerate() {
1861            unsafe { mem::copy_h_to_d(dev_ptr, self.pinned_inputs[i], n_elems) }?;
1862        }
1863        let launch_s = unsafe { cuda::cuGraphLaunch(self.graph_exec, self.stream) };
1864        if launch_s != cuda::cudaError_enum_CUDA_SUCCESS {
1865            return Err(Error::from_cuda_error(launch_s).into());
1866        }
1867        let sync_s = unsafe { cuda::cuStreamSynchronize(self.stream) };
1868        if sync_s != cuda::cudaError_enum_CUDA_SUCCESS {
1869            return Err(Error::from_cuda_error(sync_s).into());
1870        }
1871        for (i, &(dev_ptr, n_elems)) in self.output_bufs.iter().enumerate() {
1872            unsafe { mem::copy_d_to_h(self.pinned_outputs[i], dev_ptr, n_elems) }?;
1873        }
1874        Ok(())
1875    }
1876
1877    /// Like [`run_inplace`] but also returns GPU execution time in milliseconds.
1878    pub fn run_timed_inplace(&self) -> Result<f32> {
1879        for (i, &(dev_ptr, n_elems)) in self.input_bufs.iter().enumerate() {
1880            unsafe { mem::copy_h_to_d(dev_ptr, self.pinned_inputs[i], n_elems) }?;
1881        }
1882
1883        let mut ev_start = cuda::CUevent::default();
1884        let mut ev_end = cuda::CUevent::default();
1885        unsafe {
1886            cuda::cuEventCreate(&mut ev_start, 0);
1887            cuda::cuEventCreate(&mut ev_end, 0);
1888            cuda::cuEventRecord(ev_start, self.stream);
1889        }
1890
1891        let launch_s = unsafe { cuda::cuGraphLaunch(self.graph_exec, self.stream) };
1892        if launch_s != cuda::cudaError_enum_CUDA_SUCCESS {
1893            unsafe {
1894                cuda::cuEventDestroy_v2(ev_start);
1895                cuda::cuEventDestroy_v2(ev_end);
1896            }
1897            return Err(Error::from_cuda_error(launch_s).into());
1898        }
1899
1900        unsafe {
1901            cuda::cuEventRecord(ev_end, self.stream);
1902        }
1903
1904        let sync_s = unsafe { cuda::cuEventSynchronize(ev_end) };
1905        if sync_s != cuda::cudaError_enum_CUDA_SUCCESS {
1906            unsafe {
1907                cuda::cuEventDestroy_v2(ev_start);
1908                cuda::cuEventDestroy_v2(ev_end);
1909            }
1910            return Err(Error::from_cuda_error(sync_s).into());
1911        }
1912
1913        let mut gpu_ms = 0.0f32;
1914        unsafe {
1915            cuda::cudaEventElapsedTime(&mut gpu_ms, ev_start, ev_end);
1916            cuda::cuEventDestroy_v2(ev_start);
1917            cuda::cuEventDestroy_v2(ev_end);
1918        }
1919
1920        for (i, &(dev_ptr, n_elems)) in self.output_bufs.iter().enumerate() {
1921            unsafe { mem::copy_d_to_h(self.pinned_outputs[i], dev_ptr, n_elems) }?;
1922        }
1923        Ok(gpu_ms)
1924    }
1925}
1926
1927impl Drop for CudaGraphModel {
1928    fn drop(&mut self) {
1929        unsafe {
1930            cuda::cuGraphExecDestroy(self.graph_exec);
1931            cuda::cuStreamDestroy_v2(self.stream);
1932        }
1933        for &ptr in &self._owned {
1934            if ptr != 0 && mem::free(ptr).is_err() {
1935                eprintln!("CudaGraphModel::drop: failed to free buffer at {ptr:#x}");
1936            }
1937        }
1938        for &ptr in &self.pinned_inputs {
1939            if !ptr.is_null()
1940                && let Err(e) = unsafe { mem::free_host(ptr) }
1941            {
1942                eprintln!("CudaGraphModel::drop: failed to free pinned input: {e}");
1943            }
1944        }
1945        for &ptr in &self.pinned_outputs {
1946            if !ptr.is_null()
1947                && let Err(e) = unsafe { mem::free_host(ptr) }
1948            {
1949                eprintln!("CudaGraphModel::drop: failed to free pinned output: {e}");
1950            }
1951        }
1952    }
1953}
1954
1955/// A pre-compiled `adamw_step` kernel ready for use in `LoadedModel::adamw_step`.
1956///
1957/// Create via:
1958/// ```ignore
1959/// let ptx = std::fs::read(compile_kernel(&AdamwStep::new(1024), &target, true)?)?;
1960/// let kernel = AdamwKernel::from_ptx(&ptx)?;
1961/// ```
1962#[cfg(feature = "training")]
1963pub struct AdamwKernel {
1964    pub(crate) program: CudaProgram<'static, ErasedKernel>,
1965}
1966
1967#[cfg(feature = "training")]
1968impl AdamwKernel {
1969    /// Loads a pre-compiled `adamw_step` kernel from raw PTX bytes.
1970    pub fn from_ptx(ptx: &[u8]) -> Result<Self> {
1971        let program = CudaProgram::<ErasedKernel>::try_from_ptx(ptx)?;
1972        Ok(Self { program })
1973    }
1974}