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}