Skip to main content

memra_engine/
graph_update.rs

1//! CUDA-graph exec-update (shared, model-agnostic): capture a decode step ONCE, then
2//! re-tune individual kernel nodes' launch geometry per token via
3//! `cuGraphExecKernelNodeSetParams` — the llama.cpp graph-serving mechanism (their decode
4//! replays one instantiated graph per token with exact per-token grid shapes; nsys shows
5//! zero launch gaps AND eager-exact grids, where a fixed-bucket replay wastes split blocks).
6//!
7//! Mechanism: `cuGraphKernelNodeGetParams_v2` returns the node's `CUDA_KERNEL_NODE_PARAMS`
8//! whose `kernelParams` staging is DRIVER-OWNED and stays valid for the node's lifetime —
9//! scalar args are updated by writing through those pointers, geometry by editing the
10//! struct's gridDim fields, then `cuGraphExecKernelNodeSetParams` pushes the new params
11//! into the instantiated exec (topology-preserving update; no re-instantiate).
12//!
13//! Safety model: every function here takes the raw handles from a live
14//! [`cudarc::driver::CudaGraph`] (which owns destruction); callers must keep that graph
15//! (and the capture keeper) alive while updating/launching.
16
17use cudarc::driver::sys;
18
19/// One kernel node of a captured graph: raw node handle, its full launch params
20/// (grid/block/smem + driver-owned `kernelParams` staging), and the resolved symbol name.
21pub struct KernelNode {
22    pub node: sys::CUgraphNode,
23    pub params: sys::CUDA_KERNEL_NODE_PARAMS,
24    pub name: String,
25}
26
27// The raw CUgraphNode/param pointers are context-bound, not thread-bound; the Engine
28// already serializes all graph work on its decode stream's thread.
29unsafe impl Send for KernelNode {}
30
31fn cu_try(r: sys::CUresult, what: &str) -> Result<(), Box<dyn std::error::Error>> {
32    if r == sys::CUresult::CUDA_SUCCESS {
33        Ok(())
34    } else {
35        Err(format!("{what}: {r:?}").into())
36    }
37}
38
39/// Enumerate every KERNEL node of a captured graph with its launch params and symbol name.
40/// Non-kernel nodes (memcpy/memset/empty) are skipped — geometry updates only apply to
41/// kernel nodes; everything else replays as captured.
42pub fn kernel_nodes(
43    graph: &cudarc::driver::CudaGraph,
44) -> Result<Vec<KernelNode>, Box<dyn std::error::Error>> {
45    let g = graph.cu_graph();
46    let mut n: usize = 0;
47    unsafe {
48        cu_try(
49            sys::cuGraphGetNodes(g, std::ptr::null_mut(), &mut n),
50            "cuGraphGetNodes(count)",
51        )?;
52    }
53    let mut nodes: Vec<sys::CUgraphNode> = vec![std::ptr::null_mut(); n];
54    unsafe {
55        cu_try(
56            sys::cuGraphGetNodes(g, nodes.as_mut_ptr(), &mut n),
57            "cuGraphGetNodes",
58        )?;
59    }
60    nodes.truncate(n);
61    let mut out = Vec::with_capacity(n);
62    for node in nodes {
63        let mut ty = sys::CUgraphNodeType::CU_GRAPH_NODE_TYPE_EMPTY;
64        unsafe {
65            cu_try(sys::cuGraphNodeGetType(node, &mut ty), "cuGraphNodeGetType")?;
66        }
67        if ty != sys::CUgraphNodeType::CU_GRAPH_NODE_TYPE_KERNEL {
68            continue;
69        }
70        let mut params: sys::CUDA_KERNEL_NODE_PARAMS = unsafe { std::mem::zeroed() };
71        unsafe {
72            cu_try(
73                sys::cuGraphKernelNodeGetParams_v2(node, &mut params),
74                "cuGraphKernelNodeGetParams_v2",
75            )?;
76        }
77        let mut cname: *const std::ffi::c_char = std::ptr::null();
78        let name = unsafe {
79            if sys::cuFuncGetName(&mut cname, params.func) == sys::CUresult::CUDA_SUCCESS
80                && !cname.is_null()
81            {
82                std::ffi::CStr::from_ptr(cname)
83                    .to_string_lossy()
84                    .into_owned()
85            } else {
86                String::from("<unknown>")
87            }
88        };
89        out.push(KernelNode { node, params, name });
90    }
91    Ok(out)
92}
93
94/// Node-type census of a captured graph (debug: which node types remain — mem-alloc/free
95/// nodes are the graph-launch-latency suspects).
96pub fn node_census(
97    graph: &cudarc::driver::CudaGraph,
98) -> Result<std::collections::BTreeMap<String, usize>, Box<dyn std::error::Error>> {
99    let g = graph.cu_graph();
100    let mut n: usize = 0;
101    unsafe {
102        cu_try(
103            sys::cuGraphGetNodes(g, std::ptr::null_mut(), &mut n),
104            "cuGraphGetNodes(count)",
105        )?;
106    }
107    let mut nodes: Vec<sys::CUgraphNode> = vec![std::ptr::null_mut(); n];
108    unsafe {
109        cu_try(
110            sys::cuGraphGetNodes(g, nodes.as_mut_ptr(), &mut n),
111            "cuGraphGetNodes",
112        )?;
113    }
114    nodes.truncate(n);
115    let mut out: std::collections::BTreeMap<String, usize> = Default::default();
116    for node in nodes {
117        let mut ty = sys::CUgraphNodeType::CU_GRAPH_NODE_TYPE_EMPTY;
118        unsafe {
119            cu_try(sys::cuGraphNodeGetType(node, &mut ty), "cuGraphNodeGetType")?;
120        }
121        *out.entry(format!("{ty:?}")).or_insert(0) += 1;
122    }
123    Ok(out)
124}
125
126/// Push updated launch params for one node into the instantiated exec. `params` is the
127/// (edited) struct from [`kernel_nodes`] — same node topology, new geometry/arg values.
128pub fn set_exec_params(
129    graph: &cudarc::driver::CudaGraph,
130    node: sys::CUgraphNode,
131    params: &sys::CUDA_KERNEL_NODE_PARAMS,
132) -> Result<(), Box<dyn std::error::Error>> {
133    unsafe {
134        cu_try(
135            sys::cuGraphExecKernelNodeSetParams_v2(graph.cu_graph_exec(), node, params),
136            "cuGraphExecKernelNodeSetParams_v2",
137        )
138    }
139}
140
141/// Overwrite one i32 scalar argument in the node's driver-owned kernelParams staging.
142/// `idx` is the kernel's parameter position (launch_builder arg order). The write alone
143/// does NOT reach the exec — call [`set_exec_params`] after editing to push the change.
144///
145/// # Safety
146/// `idx` must be a valid parameter index for the node's kernel and that parameter must be
147/// a 4-byte scalar; writing a wrong slot corrupts the launch.
148pub unsafe fn write_i32_arg(params: &sys::CUDA_KERNEL_NODE_PARAMS, idx: usize, val: i32) {
149    unsafe {
150        let slot = *params.kernelParams.add(idx) as *mut i32;
151        *slot = val;
152    }
153}
154
155/// Read an i32 scalar argument from the node's kernelParams staging (see [`write_i32_arg`]).
156///
157/// # Safety
158/// Same contract as [`write_i32_arg`] — `idx` must name a 4-byte scalar parameter.
159pub unsafe fn read_i32_arg(params: &sys::CUDA_KERNEL_NODE_PARAMS, idx: usize) -> i32 {
160    unsafe { *(*params.kernelParams.add(idx) as *const i32) }
161}
162
163/// Read a pointer-valued argument (device pointer as u64) from kernelParams staging.
164///
165/// # Safety
166/// `idx` must name an 8-byte pointer parameter.
167pub unsafe fn read_ptr_arg(params: &sys::CUDA_KERNEL_NODE_PARAMS, idx: usize) -> u64 {
168    unsafe { *(*params.kernelParams.add(idx) as *const u64) }
169}
170
171/// One fa-decode main node with its paired combine — the per-token geometry-update unit.
172///
173/// Both classes get the FULL update (grid.y + n_splits arg + paired combine's n_splits):
174/// the partial buffers are `zeros()` allocations whose memset is CAPTURED — every replay
175/// re-zeroes them, so any split slot the main doesn't write holds m=0.0 (NOT the NEG_INF
176/// empty the combine skips). The combine's merge count must therefore exactly equal the
177/// main's written split count. `n_splits` is simultaneously the key partition and the
178/// partial stride in every fa kernel, so main + combine move as one value:
179/// - vec dc twins (`fa_decode_vec_q*_dc`): per = ceil(T_kv/n_splits), arg idx 11; the live
180///   count comes from the caller's split ladder (eager lockstep).
181/// - scalar unified (`fa_decode_f32`, ctr non-null): ns_eff = ceil(T_kv/split_keys) in-
182///   kernel; setting n_splits (idx 12) = that same value keeps stride == partition.
183pub struct FaMain {
184    node: sys::CUgraphNode,
185    params: sys::CUDA_KERNEL_NODE_PARAMS,
186    /// gridDimX at capture = n_head_kv (vec) / n_head (scalar) — the split-ladder key.
187    nkv: u32,
188    /// captured grid.y — the bucket split count; live updates never exceed it (the partial
189    /// buffers were sized for it).
190    bucket_splits: u32,
191    /// scalar-unified main: `split_keys` arg value (read at plan build) — grid-only shrink.
192    self_split_keys: Option<i32>,
193    combine: Option<(
194        sys::CUgraphNode,
195        sys::CUDA_KERNEL_NODE_PARAMS,
196        usize, /*nsp idx*/
197    )>,
198    /// last applied split count — updates are pushed only on change (splits step every
199    /// `split_keys` tokens, so exec updates are rare, not per-token).
200    cur: u32,
201}
202
203unsafe impl Send for FaMain {}
204
205const VEC_NSP_IDX: usize = 11; // Q,K,V,pO,pM,pL,hd,nh,nhkv,ctr,scale,[n_splits],ktb,vtb
206const VEC_PARTO_IDX: usize = 3;
207const SCALAR_NSP_IDX: usize = 12; // ...,hd,nh,nhkv,tkv_host,ctr,scale,[n_splits],[split_keys],...
208const SCALAR_SKI_IDX: usize = 13;
209const COMBINE_NSP_IDX: usize = 6; // pO,pM,pL,O,hd,nh,[n_splits]
210const COMBINE_Q8_NSP_IDX: usize = 7; // pO,pM,pL,out_q,out_d,hd,nh,[n_splits]
211const COMBINE_PARTO_IDX: usize = 0;
212
213/// Classify a captured graph's fa-decode nodes into per-token-updatable [`FaMain`]s.
214/// Pairing main->combine is by partO pointer identity (arg staging), not node order.
215/// Nodes that aren't fa mains/combines are left untouched (they replay as captured).
216pub fn fa_plan(
217    graph: &cudarc::driver::CudaGraph,
218) -> Result<Vec<FaMain>, Box<dyn std::error::Error>> {
219    let nodes = kernel_nodes(graph)?;
220    // partO POINTERS ARE NOT UNIQUE: the partial buffers are pool transients, freed per
221    // layer and reused by the next — pointer identity alone pairs many mains to one
222    // combine (the token-2 corruption, 2026-07-12). Pair 1:1 in NODE ORDER: each main
223    // takes the first unconsumed combine AFTER it whose partO pointer matches (single-
224    // stream capture appends nodes in issue order, and the combine is always issued
225    // right after its main within one fa_decode_* call).
226    let mut mains: Vec<(usize, FaMain)> = Vec::new();
227    let mut combines: Vec<
228        Option<(
229            usize,
230            u64,
231            sys::CUgraphNode,
232            sys::CUDA_KERNEL_NODE_PARAMS,
233            usize,
234        )>,
235    > = Vec::new();
236    for (i, n) in nodes.iter().enumerate() {
237        match n.name.as_str() {
238            "fa_decode_vec_q_v4_dc"
239            | "fa_decode_vec_q_v4_deep_dc"
240            | "fa_decode_vec_q_v3_dc"
241            | "fa_decode_vec_q_v2_dc"
242            | "fa_decode_vec_q_dc"
243            | "fa_decode_vec_q_dpl16_dc" => {
244                mains.push((
245                    i,
246                    FaMain {
247                        nkv: n.params.gridDimX,
248                        bucket_splits: n.params.gridDimY,
249                        self_split_keys: None,
250                        combine: None,
251                        cur: n.params.gridDimY,
252                        node: n.node,
253                        params: n.params,
254                    },
255                ));
256            }
257            "fa_decode_f32" => {
258                let ski = unsafe { read_i32_arg(&n.params, SCALAR_SKI_IDX) };
259                mains.push((
260                    i,
261                    FaMain {
262                        nkv: n.params.gridDimX,
263                        bucket_splits: n.params.gridDimY,
264                        self_split_keys: Some(ski),
265                        combine: None,
266                        cur: n.params.gridDimY,
267                        node: n.node,
268                        params: n.params,
269                    },
270                ));
271            }
272            "fa_decode_combine_f32" | "fa_decode_combine_q8_1" => {
273                let po = unsafe { read_ptr_arg(&n.params, COMBINE_PARTO_IDX) };
274                let nsp_idx = if n.name == "fa_decode_combine_q8_1" {
275                    COMBINE_Q8_NSP_IDX
276                } else {
277                    COMBINE_NSP_IDX
278                };
279                combines.push(Some((i, po, n.node, n.params, nsp_idx)));
280            }
281            _ => {}
282        }
283    }
284    let mut out = Vec::with_capacity(mains.len());
285    for (mi, mut m) in mains {
286        let po = unsafe { read_ptr_arg(&m.params, VEC_PARTO_IDX) };
287        let slot = combines
288            .iter_mut()
289            .filter(|c| {
290                c.as_ref()
291                    .is_some_and(|(ci, cpo, ..)| *ci > mi && *cpo == po)
292            })
293            .min_by_key(|c| c.as_ref().unwrap().0);
294        match slot {
295            Some(c) => {
296                let (_, _, cn, cp, ci) = c.take().unwrap();
297                m.combine = Some((cn, cp, ci));
298            }
299            // a main without its combine cannot be updated consistently (stride vs merge
300            // count would diverge) — refuse loudly rather than corrupt replays.
301            None => return Err("fa_plan: fa main has no partO-paired combine node".into()),
302        }
303        out.push(m);
304    }
305    Ok(out)
306}
307
308/// Retune every fa main (and paired combine) in the instantiated exec to the live `t_kv`:
309/// vec mains get the EAGER split count ns = ceil(t_kv/split_keys(t_kv, nkv)); scalar mains
310/// shrink grid.y to their in-kernel ns_eff. No-op when the counts haven't stepped.
311/// `split_keys` is the caller's ladder (fa_split_keys) so graph and eager stay in lockstep.
312// PDL EDGE-REWRITE ARM KILLED (2026-07-13): post-capture rewrite of captured edges to the
313// programmatic encoding worked in pdl_probe (2690 -> 2434 ns/pair) but the ENGINE's captured
314// graphs contain cuMemAllocAsync ALLOC NODES (cudarc allocs inside the captured step) and
315// CUDA returns CUDA_ERROR_NOT_SUPPORTED for edge topology edits on such graphs. The live PDL
316// mechanism is LAUNCH-SIDE instead: Engine::pdl-attributed launches of the MEMRA_PDL_ENTRY
317// consumer kernels (lib.rs pdl launcher) — capture encodes the programmatic edges natively.
318pub fn fa_apply(
319    graph: &cudarc::driver::CudaGraph,
320    plan: &mut [FaMain],
321    t_kv: usize,
322    split_keys: impl Fn(usize, usize) -> usize,
323) -> Result<(), Box<dyn std::error::Error>> {
324    for m in plan.iter_mut() {
325        let ns = match m.self_split_keys {
326            Some(ski) => (t_kv + ski as usize - 1) / (ski as usize).max(1),
327            None => {
328                let sp = split_keys(t_kv, m.nkv as usize).max(1);
329                (t_kv + sp - 1) / sp
330            }
331        }
332        .max(1) as u32;
333        let ns = ns.min(m.bucket_splits);
334        if ns == m.cur {
335            continue;
336        }
337        m.params.gridDimY = ns;
338        let nsp_idx = if m.self_split_keys.is_some() {
339            SCALAR_NSP_IDX
340        } else {
341            VEC_NSP_IDX
342        };
343        unsafe {
344            write_i32_arg(&m.params, nsp_idx, ns as i32);
345        }
346        set_exec_params(graph, m.node, &m.params)?;
347        if let Some((cn, cp, ci)) = &m.combine {
348            unsafe {
349                write_i32_arg(cp, *ci, ns as i32);
350            }
351            set_exec_params(graph, *cn, cp)?;
352        }
353        m.cur = ns;
354    }
355    Ok(())
356}