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.
128///
129/// # Safety
130/// `node` must be a kernel-node handle enumerated from THIS `graph` (via [`kernel_nodes`])
131/// and still alive — i.e. the graph has not been destroyed or re-captured since. `params`
132/// must be that node's own params struct (same `func`, same `kernelParams` staging, same
133/// topology) with only values edited; a foreign or stale handle is UB in the driver, not
134/// a clean `CUresult`. This was a safe `pub fn` taking the raw handle, which is exactly
135/// the shape clippy's deny-by-default `not_unsafe_ptr_arg_deref` rejects: a safe signature
136/// promising that ANY pointer argument is fine when the driver contract says otherwise.
137pub unsafe fn set_exec_params(
138    graph: &cudarc::driver::CudaGraph,
139    node: sys::CUgraphNode,
140    params: &sys::CUDA_KERNEL_NODE_PARAMS,
141) -> Result<(), Box<dyn std::error::Error>> {
142    unsafe {
143        cu_try(
144            sys::cuGraphExecKernelNodeSetParams_v2(graph.cu_graph_exec(), node, params),
145            "cuGraphExecKernelNodeSetParams_v2",
146        )
147    }
148}
149
150/// Overwrite one i32 scalar argument in the node's driver-owned kernelParams staging.
151/// `idx` is the kernel's parameter position (launch_builder arg order). The write alone
152/// does NOT reach the exec — call [`set_exec_params`] after editing to push the change.
153///
154/// # Safety
155/// `idx` must be a valid parameter index for the node's kernel and that parameter must be
156/// a 4-byte scalar; writing a wrong slot corrupts the launch.
157pub unsafe fn write_i32_arg(params: &sys::CUDA_KERNEL_NODE_PARAMS, idx: usize, val: i32) {
158    unsafe {
159        let slot = *params.kernelParams.add(idx) as *mut i32;
160        *slot = val;
161    }
162}
163
164/// Read an i32 scalar argument from the node's kernelParams staging (see [`write_i32_arg`]).
165///
166/// # Safety
167/// Same contract as [`write_i32_arg`] — `idx` must name a 4-byte scalar parameter.
168pub unsafe fn read_i32_arg(params: &sys::CUDA_KERNEL_NODE_PARAMS, idx: usize) -> i32 {
169    unsafe { *(*params.kernelParams.add(idx) as *const i32) }
170}
171
172/// Read a pointer-valued argument (device pointer as u64) from kernelParams staging.
173///
174/// # Safety
175/// `idx` must name an 8-byte pointer parameter.
176pub unsafe fn read_ptr_arg(params: &sys::CUDA_KERNEL_NODE_PARAMS, idx: usize) -> u64 {
177    unsafe { *(*params.kernelParams.add(idx) as *const u64) }
178}
179
180/// One fa-decode main node with its paired combine — the per-token geometry-update unit.
181///
182/// Both classes get the FULL update (grid.y + n_splits arg + paired combine's n_splits):
183/// the partial buffers are `zeros()` allocations whose memset is CAPTURED — every replay
184/// re-zeroes them, so any split slot the main doesn't write holds m=0.0 (NOT the NEG_INF
185/// empty the combine skips). The combine's merge count must therefore exactly equal the
186/// main's written split count. `n_splits` is simultaneously the key partition and the
187/// partial stride in every fa kernel, so main + combine move as one value:
188/// - vec dc twins (`fa_decode_vec_q*_dc`): per = ceil(T_kv/n_splits), arg idx 11; the live
189///   count comes from the caller's split ladder (eager lockstep).
190/// - scalar unified (`fa_decode_f32`, ctr non-null): ns_eff = ceil(T_kv/split_keys) in-
191///   kernel; setting n_splits (idx 12) = that same value keeps stride == partition.
192pub struct FaMain {
193    node: sys::CUgraphNode,
194    params: sys::CUDA_KERNEL_NODE_PARAMS,
195    /// gridDimX at capture = n_head_kv (vec) / n_head (scalar) — the split-ladder key.
196    nkv: u32,
197    /// captured grid.y — the bucket split count; live updates never exceed it (the partial
198    /// buffers were sized for it).
199    bucket_splits: u32,
200    /// scalar-unified main: `split_keys` arg value (read at plan build) — grid-only shrink.
201    self_split_keys: Option<i32>,
202    combine: Option<(
203        sys::CUgraphNode,
204        sys::CUDA_KERNEL_NODE_PARAMS,
205        usize, /*nsp idx*/
206    )>,
207    /// last applied split count — updates are pushed only on change (splits step every
208    /// `split_keys` tokens, so exec updates are rare, not per-token).
209    cur: u32,
210}
211
212unsafe impl Send for FaMain {}
213
214const VEC_NSP_IDX: usize = 11; // Q,K,V,pO,pM,pL,hd,nh,nhkv,ctr,scale,[n_splits],ktb,vtb
215const VEC_PARTO_IDX: usize = 3;
216const SCALAR_NSP_IDX: usize = 12; // ...,hd,nh,nhkv,tkv_host,ctr,scale,[n_splits],[split_keys],...
217const SCALAR_SKI_IDX: usize = 13;
218const COMBINE_NSP_IDX: usize = 6; // pO,pM,pL,O,hd,nh,[n_splits]
219const COMBINE_Q8_NSP_IDX: usize = 7; // pO,pM,pL,out_q,out_d,hd,nh,[n_splits]
220const COMBINE_PARTO_IDX: usize = 0;
221
222/// Classify a captured graph's fa-decode nodes into per-token-updatable [`FaMain`]s.
223/// Pairing main->combine is by partO pointer identity (arg staging), not node order.
224/// Nodes that aren't fa mains/combines are left untouched (they replay as captured).
225pub fn fa_plan(
226    graph: &cudarc::driver::CudaGraph,
227) -> Result<Vec<FaMain>, Box<dyn std::error::Error>> {
228    let nodes = kernel_nodes(graph)?;
229    // partO POINTERS ARE NOT UNIQUE: the partial buffers are pool transients, freed per
230    // layer and reused by the next — pointer identity alone pairs many mains to one
231    // combine (the token-2 corruption, 2026-07-12). Pair 1:1 in NODE ORDER: each main
232    // takes the first unconsumed combine AFTER it whose partO pointer matches (single-
233    // stream capture appends nodes in issue order, and the combine is always issued
234    // right after its main within one fa_decode_* call).
235    let mut mains: Vec<(usize, FaMain)> = Vec::new();
236    let mut combines: Vec<
237        Option<(
238            usize,
239            u64,
240            sys::CUgraphNode,
241            sys::CUDA_KERNEL_NODE_PARAMS,
242            usize,
243        )>,
244    > = Vec::new();
245    for (i, n) in nodes.iter().enumerate() {
246        match n.name.as_str() {
247            "fa_decode_vec_q_v4_dc"
248            | "fa_decode_vec_q_v4_deep_dc"
249            | "fa_decode_vec_q_v3_dc"
250            | "fa_decode_vec_q_v2_dc"
251            | "fa_decode_vec_q_dc"
252            | "fa_decode_vec_q_dpl16_dc" => {
253                mains.push((
254                    i,
255                    FaMain {
256                        nkv: n.params.gridDimX,
257                        bucket_splits: n.params.gridDimY,
258                        self_split_keys: None,
259                        combine: None,
260                        cur: n.params.gridDimY,
261                        node: n.node,
262                        params: n.params,
263                    },
264                ));
265            }
266            "fa_decode_f32" => {
267                let ski = unsafe { read_i32_arg(&n.params, SCALAR_SKI_IDX) };
268                mains.push((
269                    i,
270                    FaMain {
271                        nkv: n.params.gridDimX,
272                        bucket_splits: n.params.gridDimY,
273                        self_split_keys: Some(ski),
274                        combine: None,
275                        cur: n.params.gridDimY,
276                        node: n.node,
277                        params: n.params,
278                    },
279                ));
280            }
281            "fa_decode_combine_f32" | "fa_decode_combine_q8_1" => {
282                let po = unsafe { read_ptr_arg(&n.params, COMBINE_PARTO_IDX) };
283                let nsp_idx = if n.name == "fa_decode_combine_q8_1" {
284                    COMBINE_Q8_NSP_IDX
285                } else {
286                    COMBINE_NSP_IDX
287                };
288                combines.push(Some((i, po, n.node, n.params, nsp_idx)));
289            }
290            _ => {}
291        }
292    }
293    let mut out = Vec::with_capacity(mains.len());
294    for (mi, mut m) in mains {
295        let po = unsafe { read_ptr_arg(&m.params, VEC_PARTO_IDX) };
296        let slot = combines
297            .iter_mut()
298            .filter(|c| {
299                c.as_ref()
300                    .is_some_and(|(ci, cpo, ..)| *ci > mi && *cpo == po)
301            })
302            .min_by_key(|c| c.as_ref().unwrap().0);
303        match slot {
304            Some(c) => {
305                let (_, _, cn, cp, ci) = c.take().unwrap();
306                m.combine = Some((cn, cp, ci));
307            }
308            // a main without its combine cannot be updated consistently (stride vs merge
309            // count would diverge) — refuse loudly rather than corrupt replays.
310            None => return Err("fa_plan: fa main has no partO-paired combine node".into()),
311        }
312        out.push(m);
313    }
314    Ok(out)
315}
316
317/// Retune every fa main (and paired combine) in the instantiated exec to the live `t_kv`:
318/// vec mains get the EAGER split count ns = ceil(t_kv/split_keys(t_kv, nkv)); scalar mains
319/// shrink grid.y to their in-kernel ns_eff. No-op when the counts haven't stepped.
320/// `split_keys` is the caller's ladder (fa_split_keys) so graph and eager stay in lockstep.
321/// `plan` must be the [`fa_plan`] of THIS `graph`: the node handles inside are only
322/// meaningful against the exec they were enumerated from (a mismatched graph is refused by
323/// the driver's handle validation as a `CUresult` error, not honored).
324// PDL EDGE-REWRITE ARM KILLED (2026-07-13): post-capture rewrite of captured edges to the
325// programmatic encoding worked in pdl_probe (2690 -> 2434 ns/pair) but the ENGINE's captured
326// graphs contain cuMemAllocAsync ALLOC NODES (cudarc allocs inside the captured step) and
327// CUDA returns CUDA_ERROR_NOT_SUPPORTED for edge topology edits on such graphs. The live PDL
328// mechanism is LAUNCH-SIDE instead: Engine::pdl-attributed launches of the MEMRA_PDL_ENTRY
329// consumer kernels (lib.rs pdl launcher) — capture encodes the programmatic edges natively.
330pub fn fa_apply(
331    graph: &cudarc::driver::CudaGraph,
332    plan: &mut [FaMain],
333    t_kv: usize,
334    split_keys: impl Fn(usize, usize) -> usize,
335) -> Result<(), Box<dyn std::error::Error>> {
336    for m in plan.iter_mut() {
337        let ns = match m.self_split_keys {
338            Some(ski) => (t_kv + ski as usize - 1) / (ski as usize).max(1),
339            None => {
340                let sp = split_keys(t_kv, m.nkv as usize).max(1);
341                (t_kv + sp - 1) / sp
342            }
343        }
344        .max(1) as u32;
345        let ns = ns.min(m.bucket_splits);
346        if ns == m.cur {
347            continue;
348        }
349        m.params.gridDimY = ns;
350        let nsp_idx = if m.self_split_keys.is_some() {
351            SCALAR_NSP_IDX
352        } else {
353            VEC_NSP_IDX
354        };
355        // SAFETY: `m.node`/`m.params` (and the paired combine's) were enumerated by
356        // `fa_plan` -> `kernel_nodes` from the caller's live graph, and `FaMain`'s fields
357        // are private to this module, so a plan can only hold handles that came from that
358        // enumeration. The arg indices are the captured kernels' own layouts (the
359        // *_NSP_IDX tables above), and only scalar values are edited — same func, same
360        // staging, same topology, which is the [`set_exec_params`] contract.
361        unsafe {
362            write_i32_arg(&m.params, nsp_idx, ns as i32);
363            set_exec_params(graph, m.node, &m.params)?;
364        }
365        if let Some((cn, cp, ci)) = &m.combine {
366            unsafe {
367                write_i32_arg(cp, *ci, ns as i32);
368                set_exec_params(graph, *cn, cp)?;
369            }
370        }
371        m.cur = ns;
372    }
373    Ok(())
374}