trueno_gpu/graph/mod.rs
1//! PMAT-291: Tensor Compute Graph for GPU Inference
2//!
3//! Inspired by ggml's compute graph pattern (transpiled via decy for reference).
4//! Pure Rust, no FFI. Reduces ~430 individual cuLaunchKernel dispatches to ~15
5//! tensor-level operations per decode step.
6//!
7//! # Design (from cross-project analysis)
8//!
9//! - ggml: C tensor graph with ~15 nodes, CUDA graph replay = 1 launch
10//! - vLLM: PyTorch/inductor IR fusion + CUDA graphs (~80 nodes)
11//! - realizr current: 430 individual kernel dispatches
12//! - realizr target: ~15 tensor ops via this module + CUDA graph replay
13//!
14//! # Academic References
15//!
16//! - [Kwon et al., SOSP 2023] PagedAttention (arxiv:2309.06180)
17//! - [Yu et al., OSDI 2022] Orca iteration-level scheduling
18//! - [Dao, NeurIPS 2022] FlashAttention (arxiv:2205.14135)
19
20pub mod executor;
21
22pub use executor::{execute_graph, KernelDispatch};
23
24/// Tensor operation types for decoder inference.
25///
26/// Each variant maps to ONE kernel dispatch. The goal is to express
27/// an entire transformer layer as ~5 operations:
28/// 1. RmsNorm (pre-attention)
29/// 2. QKV+Attention (fused projection + attention + output projection)
30/// 3. Residual add
31/// 4. RmsNorm (pre-FFN)
32/// 5. FFN (gate+up+swiglu+down fused)
33/// 6. Residual add
34#[derive(Debug, Clone, Copy, PartialEq, Eq)]
35pub enum TensorOp {
36 /// Matrix-vector multiply (Q4K dequant+GEMV or cuBLASLt GEMM)
37 MulMat,
38 /// Element-wise add (residual connections)
39 Add,
40 /// RMS normalization
41 RmsNorm,
42 /// Rotary position embedding
43 Rope,
44 /// Softmax (attention scores)
45 SoftMax,
46 /// Element-wise multiply (SwiGLU gate)
47 Mul,
48 /// SiLU activation
49 Silu,
50 /// Memory copy (KV cache scatter)
51 Copy,
52 /// No-op (input tensor, leaf node)
53 None,
54}
55
56/// A node in the compute graph.
57///
58/// Each node represents a tensor operation with device-memory pointers
59/// to its data and references to input nodes (by index).
60#[derive(Debug, Clone)]
61pub struct TensorNode {
62 /// Operation to perform
63 pub op: TensorOp,
64 /// Device pointer to output data
65 pub data_ptr: u64,
66 /// Output dimensions [rows, cols, batch, unused]
67 pub shape: [u32; 4],
68 /// Indices of input nodes in the graph (max 3: src0, src1, src2)
69 pub inputs: Vec<usize>,
70 /// Operation-specific parameters (e.g., epsilon for RmsNorm, position for RoPE)
71 pub params: OpParams,
72}
73
74/// Operation-specific parameters.
75#[derive(Debug, Clone)]
76pub struct OpParams {
77 /// Weight pointer (for MulMat: quantized weights on device)
78 pub weight_ptr: u64,
79 /// Normalization gamma pointer (for RmsNorm)
80 pub gamma_ptr: u64,
81 /// Scalar parameter (epsilon for RmsNorm, etc.)
82 pub scalar: f32,
83 /// Integer parameter (position for RoPE, etc.)
84 pub int_param: u32,
85 /// PERF-050 (aprender#2753): GGML quantization type code of `weight_ptr`.
86 ///
87 /// Without this the graph carried only a pointer, and the dispatcher had no way to know
88 /// what it pointed at -- so it assumed Q4_K for every MulMat. In a q4_k_m model that is
89 /// right for 170 tensors and wrong for 29: attn_v, ffn_down and the LM head are Q6_K, and
90 /// dequantizing Q6_K with the Q4_K kernel produced garbage of magnitude ~5e8 with NaNs.
91 ///
92 /// The numeric GGML code is used rather than a Rust enum because this crate must not depend
93 /// on the serving crate's `WeightQuantType`. 12 = Q4_K, which is also the `Default`, so
94 /// nodes that do not set it behave exactly as before.
95 pub weight_qtype: u32,
96 /// PERF-050 (aprender#2769): bias vector applied after a MulMat, 0 when there is none.
97 ///
98 /// The graph had no bias node and no way to express one, so the QKV bias Qwen2.5 models
99 /// carry was silently dropped on this path while the M=1 path applied it. Defaults to 0,
100 /// which means "no bias" and reproduces the previous behaviour exactly.
101 pub bias_ptr: u64,
102 /// Length of `bias_ptr` in elements; 0 means no bias.
103 pub bias_len: usize,
104}
105
106/// GGML type code for Q4_K, the historical hardcoded assumption and this field's default.
107pub const GGML_TYPE_Q4_K: u32 = 12;
108
109/// Compute graph: topologically sorted list of tensor operations.
110///
111/// Built once per model architecture, reused every decode step.
112/// Only the data pointers and parameters change between steps.
113#[derive(Debug, Clone)]
114pub struct ComputeGraph {
115 /// Nodes in topological order (leafs first, output last)
116 pub nodes: Vec<TensorNode>,
117 /// Number of leaf nodes (inputs, no operation)
118 pub n_leafs: usize,
119}
120
121impl ComputeGraph {
122 /// Create an empty compute graph.
123 pub fn new() -> Self {
124 Self {
125 nodes: Vec::new(),
126 n_leafs: 0,
127 }
128 }
129
130 /// Add a leaf node (input tensor, no operation).
131 pub fn add_leaf(&mut self, data_ptr: u64, shape: [u32; 4]) -> usize {
132 let idx = self.nodes.len();
133 self.nodes.push(TensorNode {
134 op: TensorOp::None,
135 data_ptr,
136 shape,
137 inputs: Vec::new(),
138 params: OpParams::default(),
139 });
140 self.n_leafs += 1;
141 idx
142 }
143
144 /// Add an operation node with inputs.
145 pub fn add_op(
146 &mut self,
147 op: TensorOp,
148 data_ptr: u64,
149 shape: [u32; 4],
150 inputs: Vec<usize>,
151 params: OpParams,
152 ) -> usize {
153 let idx = self.nodes.len();
154 self.nodes.push(TensorNode {
155 op,
156 data_ptr,
157 shape,
158 inputs,
159 params,
160 });
161 idx
162 }
163
164 /// Number of operation nodes (excludes leafs).
165 pub fn n_ops(&self) -> usize {
166 self.nodes.len() - self.n_leafs
167 }
168}
169
170impl Default for ComputeGraph {
171 fn default() -> Self {
172 Self::new()
173 }
174}
175
176#[cfg(test)]
177mod tests {
178 use super::*;
179
180 #[test]
181 fn test_empty_graph() {
182 let g = ComputeGraph::new();
183 assert_eq!(g.nodes.len(), 0);
184 assert_eq!(g.n_leafs, 0);
185 assert_eq!(g.n_ops(), 0);
186 }
187
188 #[test]
189 fn test_simple_graph() {
190 let mut g = ComputeGraph::new();
191
192 // Input tensor (leaf)
193 let input = g.add_leaf(0x1000, [1536, 1, 1, 0]);
194
195 // RmsNorm
196 let normed = g.add_op(
197 TensorOp::RmsNorm,
198 0x2000,
199 [1536, 1, 1, 0],
200 vec![input],
201 OpParams {
202 gamma_ptr: 0x3000,
203 scalar: 1e-6,
204 ..Default::default()
205 },
206 );
207
208 // MulMat (Q projection)
209 let q = g.add_op(
210 TensorOp::MulMat,
211 0x4000,
212 [1536, 1, 1, 0],
213 vec![normed],
214 OpParams {
215 weight_ptr: 0x5000,
216 ..Default::default()
217 },
218 );
219
220 assert_eq!(g.nodes.len(), 3);
221 assert_eq!(g.n_leafs, 1);
222 assert_eq!(g.n_ops(), 2);
223 assert_eq!(g.nodes[q].op, TensorOp::MulMat);
224 assert_eq!(g.nodes[q].inputs, vec![normed]);
225 }
226}
227
228impl Default for OpParams {
229 fn default() -> Self {
230 Self {
231 weight_ptr: 0,
232 gamma_ptr: 0,
233 scalar: 0.0,
234 int_param: 0,
235 // PERF-050: Q4_K, matching the assumption this field replaces, so an unset node is
236 // bit-identical to the old behaviour.
237 weight_qtype: GGML_TYPE_Q4_K,
238 bias_ptr: 0,
239 bias_len: 0,
240 }
241 }
242}