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}