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 { Ok(()) } else { Err(format!("{what}: {r:?}").into()) }
33}
34
35/// Enumerate every KERNEL node of a captured graph with its launch params and symbol name.
36/// Non-kernel nodes (memcpy/memset/empty) are skipped — geometry updates only apply to
37/// kernel nodes; everything else replays as captured.
38pub fn kernel_nodes(graph: &cudarc::driver::CudaGraph)
39    -> Result<Vec<KernelNode>, Box<dyn std::error::Error>>
40{
41    let g = graph.cu_graph();
42    let mut n: usize = 0;
43    unsafe { cu_try(sys::cuGraphGetNodes(g, std::ptr::null_mut(), &mut n), "cuGraphGetNodes(count)")?; }
44    let mut nodes: Vec<sys::CUgraphNode> = vec![std::ptr::null_mut(); n];
45    unsafe { cu_try(sys::cuGraphGetNodes(g, nodes.as_mut_ptr(), &mut n), "cuGraphGetNodes")?; }
46    nodes.truncate(n);
47    let mut out = Vec::with_capacity(n);
48    for node in nodes {
49        let mut ty = sys::CUgraphNodeType::CU_GRAPH_NODE_TYPE_EMPTY;
50        unsafe { cu_try(sys::cuGraphNodeGetType(node, &mut ty), "cuGraphNodeGetType")?; }
51        if ty != sys::CUgraphNodeType::CU_GRAPH_NODE_TYPE_KERNEL { continue; }
52        let mut params: sys::CUDA_KERNEL_NODE_PARAMS = unsafe { std::mem::zeroed() };
53        unsafe { cu_try(sys::cuGraphKernelNodeGetParams_v2(node, &mut params),
54                        "cuGraphKernelNodeGetParams_v2")?; }
55        let mut cname: *const std::ffi::c_char = std::ptr::null();
56        let name = unsafe {
57            if sys::cuFuncGetName(&mut cname, params.func) == sys::CUresult::CUDA_SUCCESS
58                && !cname.is_null() {
59                std::ffi::CStr::from_ptr(cname).to_string_lossy().into_owned()
60            } else { String::from("<unknown>") }
61        };
62        out.push(KernelNode { node, params, name });
63    }
64    Ok(out)
65}
66
67/// Node-type census of a captured graph (debug: which node types remain — mem-alloc/free
68/// nodes are the graph-launch-latency suspects).
69pub fn node_census(graph: &cudarc::driver::CudaGraph)
70    -> Result<std::collections::BTreeMap<String, usize>, Box<dyn std::error::Error>>
71{
72    let g = graph.cu_graph();
73    let mut n: usize = 0;
74    unsafe { cu_try(sys::cuGraphGetNodes(g, std::ptr::null_mut(), &mut n), "cuGraphGetNodes(count)")?; }
75    let mut nodes: Vec<sys::CUgraphNode> = vec![std::ptr::null_mut(); n];
76    unsafe { cu_try(sys::cuGraphGetNodes(g, nodes.as_mut_ptr(), &mut n), "cuGraphGetNodes")?; }
77    nodes.truncate(n);
78    let mut out: std::collections::BTreeMap<String, usize> = Default::default();
79    for node in nodes {
80        let mut ty = sys::CUgraphNodeType::CU_GRAPH_NODE_TYPE_EMPTY;
81        unsafe { cu_try(sys::cuGraphNodeGetType(node, &mut ty), "cuGraphNodeGetType")?; }
82        *out.entry(format!("{ty:?}")).or_insert(0) += 1;
83    }
84    Ok(out)
85}
86
87/// Push updated launch params for one node into the instantiated exec. `params` is the
88/// (edited) struct from [`kernel_nodes`] — same node topology, new geometry/arg values.
89pub fn set_exec_params(graph: &cudarc::driver::CudaGraph, node: sys::CUgraphNode,
90                       params: &sys::CUDA_KERNEL_NODE_PARAMS)
91    -> Result<(), Box<dyn std::error::Error>>
92{
93    unsafe { cu_try(sys::cuGraphExecKernelNodeSetParams_v2(graph.cu_graph_exec(), node, params),
94                    "cuGraphExecKernelNodeSetParams_v2") }
95}
96
97/// Overwrite one i32 scalar argument in the node's driver-owned kernelParams staging.
98/// `idx` is the kernel's parameter position (launch_builder arg order). The write alone
99/// does NOT reach the exec — call [`set_exec_params`] after editing to push the change.
100///
101/// # Safety
102/// `idx` must be a valid parameter index for the node's kernel and that parameter must be
103/// a 4-byte scalar; writing a wrong slot corrupts the launch.
104pub unsafe fn write_i32_arg(params: &sys::CUDA_KERNEL_NODE_PARAMS, idx: usize, val: i32) {
105    unsafe {
106        let slot = *params.kernelParams.add(idx) as *mut i32;
107        *slot = val;
108    }
109}
110
111/// Read an i32 scalar argument from the node's kernelParams staging (see [`write_i32_arg`]).
112///
113/// # Safety
114/// Same contract as [`write_i32_arg`] — `idx` must name a 4-byte scalar parameter.
115pub unsafe fn read_i32_arg(params: &sys::CUDA_KERNEL_NODE_PARAMS, idx: usize) -> i32 {
116    unsafe { *(*params.kernelParams.add(idx) as *const i32) }
117}
118
119/// Read a pointer-valued argument (device pointer as u64) from kernelParams staging.
120///
121/// # Safety
122/// `idx` must name an 8-byte pointer parameter.
123pub unsafe fn read_ptr_arg(params: &sys::CUDA_KERNEL_NODE_PARAMS, idx: usize) -> u64 {
124    unsafe { *(*params.kernelParams.add(idx) as *const u64) }
125}
126
127/// One fa-decode main node with its paired combine — the per-token geometry-update unit.
128///
129/// Both classes get the FULL update (grid.y + n_splits arg + paired combine's n_splits):
130/// the partial buffers are `zeros()` allocations whose memset is CAPTURED — every replay
131/// re-zeroes them, so any split slot the main doesn't write holds m=0.0 (NOT the NEG_INF
132/// empty the combine skips). The combine's merge count must therefore exactly equal the
133/// main's written split count. `n_splits` is simultaneously the key partition and the
134/// partial stride in every fa kernel, so main + combine move as one value:
135/// - vec dc twins (`fa_decode_vec_q*_dc`): per = ceil(T_kv/n_splits), arg idx 11; the live
136///   count comes from the caller's split ladder (eager lockstep).
137/// - scalar unified (`fa_decode_f32`, ctr non-null): ns_eff = ceil(T_kv/split_keys) in-
138///   kernel; setting n_splits (idx 12) = that same value keeps stride == partition.
139pub struct FaMain {
140    node: sys::CUgraphNode,
141    params: sys::CUDA_KERNEL_NODE_PARAMS,
142    /// gridDimX at capture = n_head_kv (vec) / n_head (scalar) — the split-ladder key.
143    nkv: u32,
144    /// captured grid.y — the bucket split count; live updates never exceed it (the partial
145    /// buffers were sized for it).
146    bucket_splits: u32,
147    /// scalar-unified main: `split_keys` arg value (read at plan build) — grid-only shrink.
148    self_split_keys: Option<i32>,
149    combine: Option<(sys::CUgraphNode, sys::CUDA_KERNEL_NODE_PARAMS, usize /*nsp idx*/)>,
150    /// last applied split count — updates are pushed only on change (splits step every
151    /// `split_keys` tokens, so exec updates are rare, not per-token).
152    cur: u32,
153}
154
155unsafe impl Send for FaMain {}
156
157const VEC_NSP_IDX: usize = 11;      // Q,K,V,pO,pM,pL,hd,nh,nhkv,ctr,scale,[n_splits],ktb,vtb
158const VEC_PARTO_IDX: usize = 3;
159const SCALAR_NSP_IDX: usize = 12;   // ...,hd,nh,nhkv,tkv_host,ctr,scale,[n_splits],[split_keys],...
160const SCALAR_SKI_IDX: usize = 13;
161const COMBINE_NSP_IDX: usize = 6;      // pO,pM,pL,O,hd,nh,[n_splits]
162const COMBINE_Q8_NSP_IDX: usize = 7;   // pO,pM,pL,out_q,out_d,hd,nh,[n_splits]
163const COMBINE_PARTO_IDX: usize = 0;
164
165/// Classify a captured graph's fa-decode nodes into per-token-updatable [`FaMain`]s.
166/// Pairing main->combine is by partO pointer identity (arg staging), not node order.
167/// Nodes that aren't fa mains/combines are left untouched (they replay as captured).
168pub fn fa_plan(graph: &cudarc::driver::CudaGraph)
169    -> Result<Vec<FaMain>, Box<dyn std::error::Error>>
170{
171    let nodes = kernel_nodes(graph)?;
172    // partO POINTERS ARE NOT UNIQUE: the partial buffers are pool transients, freed per
173    // layer and reused by the next — pointer identity alone pairs many mains to one
174    // combine (the token-2 corruption, 2026-07-12). Pair 1:1 in NODE ORDER: each main
175    // takes the first unconsumed combine AFTER it whose partO pointer matches (single-
176    // stream capture appends nodes in issue order, and the combine is always issued
177    // right after its main within one fa_decode_* call).
178    let mut mains: Vec<(usize, FaMain)> = Vec::new();
179    let mut combines: Vec<Option<(usize, u64, sys::CUgraphNode, sys::CUDA_KERNEL_NODE_PARAMS,
180                                   usize)>> = Vec::new();
181    for (i, n) in nodes.iter().enumerate() {
182        match n.name.as_str() {
183            "fa_decode_vec_q_v4_dc" | "fa_decode_vec_q_v4_deep_dc"
184            | "fa_decode_vec_q_v3_dc" | "fa_decode_vec_q_v2_dc"
185            | "fa_decode_vec_q_dc" | "fa_decode_vec_q_dpl16_dc" => {
186                mains.push((i, FaMain {
187                    nkv: n.params.gridDimX, bucket_splits: n.params.gridDimY,
188                    self_split_keys: None, combine: None, cur: n.params.gridDimY,
189                    node: n.node, params: n.params,
190                }));
191            }
192            "fa_decode_f32" => {
193                let ski = unsafe { read_i32_arg(&n.params, SCALAR_SKI_IDX) };
194                mains.push((i, FaMain {
195                    nkv: n.params.gridDimX, bucket_splits: n.params.gridDimY,
196                    self_split_keys: Some(ski), combine: None, cur: n.params.gridDimY,
197                    node: n.node, params: n.params,
198                }));
199            }
200            "fa_decode_combine_f32" | "fa_decode_combine_q8_1" => {
201                let po = unsafe { read_ptr_arg(&n.params, COMBINE_PARTO_IDX) };
202                let nsp_idx = if n.name == "fa_decode_combine_q8_1" { COMBINE_Q8_NSP_IDX }
203                              else { COMBINE_NSP_IDX };
204                combines.push(Some((i, po, n.node, n.params, nsp_idx)));
205            }
206            _ => {}
207        }
208    }
209    let mut out = Vec::with_capacity(mains.len());
210    for (mi, mut m) in mains {
211        let po = unsafe { read_ptr_arg(&m.params, VEC_PARTO_IDX) };
212        let slot = combines.iter_mut()
213            .filter(|c| c.as_ref().is_some_and(|(ci, cpo, ..)| *ci > mi && *cpo == po))
214            .min_by_key(|c| c.as_ref().unwrap().0);
215        match slot {
216            Some(c) => { let (_, _, cn, cp, ci) = c.take().unwrap();
217                         m.combine = Some((cn, cp, ci)); }
218            // a main without its combine cannot be updated consistently (stride vs merge
219            // count would diverge) — refuse loudly rather than corrupt replays.
220            None => return Err("fa_plan: fa main has no partO-paired combine node".into()),
221        }
222        out.push(m);
223    }
224    Ok(out)
225}
226
227/// Retune every fa main (and paired combine) in the instantiated exec to the live `t_kv`:
228/// vec mains get the EAGER split count ns = ceil(t_kv/split_keys(t_kv, nkv)); scalar mains
229/// shrink grid.y to their in-kernel ns_eff. No-op when the counts haven't stepped.
230/// `split_keys` is the caller's ladder (fa_split_keys) so graph and eager stay in lockstep.
231// PDL EDGE-REWRITE ARM KILLED (2026-07-13): post-capture rewrite of captured edges to the
232// programmatic encoding worked in pdl_probe (2690 -> 2434 ns/pair) but the ENGINE's captured
233// graphs contain cuMemAllocAsync ALLOC NODES (cudarc allocs inside the captured step) and
234// CUDA returns CUDA_ERROR_NOT_SUPPORTED for edge topology edits on such graphs. The live PDL
235// mechanism is LAUNCH-SIDE instead: Engine::pdl-attributed launches of the MEMRA_PDL_ENTRY
236// consumer kernels (lib.rs pdl launcher) — capture encodes the programmatic edges natively.
237pub fn fa_apply(graph: &cudarc::driver::CudaGraph, plan: &mut [FaMain], t_kv: usize,
238                split_keys: impl Fn(usize, usize) -> usize)
239    -> Result<(), Box<dyn std::error::Error>>
240{
241    for m in plan.iter_mut() {
242        let ns = match m.self_split_keys {
243            Some(ski) => (t_kv + ski as usize - 1) / (ski as usize).max(1),
244            None => { let sp = split_keys(t_kv, m.nkv as usize).max(1);
245                      (t_kv + sp - 1) / sp }
246        }.max(1) as u32;
247        let ns = ns.min(m.bucket_splits);
248        if ns == m.cur { continue; }
249        m.params.gridDimY = ns;
250        let nsp_idx = if m.self_split_keys.is_some() { SCALAR_NSP_IDX } else { VEC_NSP_IDX };
251        unsafe { write_i32_arg(&m.params, nsp_idx, ns as i32); }
252        set_exec_params(graph, m.node, &m.params)?;
253        if let Some((cn, cp, ci)) = &m.combine {
254            unsafe { write_i32_arg(cp, *ci, ns as i32); }
255            set_exec_params(graph, *cn, cp)?;
256        }
257        m.cur = ns;
258    }
259    Ok(())
260}