Skip to main content

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}