Skip to main content

oxicuda_graph/optimizer/
memory.rs

1//! Memory planning — buffer allocation via live-interval graph colouring.
2//!
3//! The memory planner assigns device memory "slots" to logical buffers so
4//! that two buffers whose live intervals do not overlap can share the same
5//! physical memory region (offset within a pool allocation).
6//!
7//! # Algorithm
8//!
9//! This is an instance of the **interval graph colouring** problem, which
10//! on an interval graph is solvable optimally in O(n log n) time using a
11//! greedy "earliest deadline first" scan:
12//!
13//! 1. Sort buffers by live-interval start position.
14//! 2. Maintain a priority queue of "free slots" keyed by their end position.
15//! 3. For each buffer (in order): reuse a free slot whose end position is
16//!    ≤ the buffer's start position (best-fit by size); or allocate a new
17//!    slot if no free slot is large enough.
18//!
19//! The result is a `MemoryPlan` that tells each buffer which slot and byte
20//! offset to use within the device memory pool.
21
22use crate::analysis::liveness_analyse;
23use crate::error::{GraphError, GraphResult};
24use crate::graph::ComputeGraph;
25use crate::node::BufferId;
26
27// ---------------------------------------------------------------------------
28// SlotAssignment — per-buffer result
29// ---------------------------------------------------------------------------
30
31/// The memory slot assigned to a single buffer.
32#[derive(Debug, Clone, PartialEq, Eq)]
33pub struct SlotAssignment {
34    /// The buffer being assigned.
35    pub buf: BufferId,
36    /// Index of the memory slot (0-based).
37    pub slot: usize,
38    /// Byte offset within the pool allocation for this slot.
39    pub offset: usize,
40    /// Size of the slot in bytes (≥ buffer size, aligned).
41    pub slot_size: usize,
42    /// Size of the buffer in bytes.
43    pub buf_size: usize,
44}
45
46// ---------------------------------------------------------------------------
47// MemoryPlan
48// ---------------------------------------------------------------------------
49
50/// The complete memory allocation plan for a graph.
51#[derive(Debug, Clone)]
52pub struct MemoryPlan {
53    /// Per-buffer slot assignments.
54    pub assignments: Vec<SlotAssignment>,
55    /// Slot sizes in bytes (indexed by slot id).
56    pub slot_sizes: Vec<usize>,
57    /// Total pool size required (= sum of slot sizes, aligned).
58    pub total_bytes: usize,
59    /// Number of distinct slots (= colouring number of the interval graph).
60    pub num_slots: usize,
61    /// Number of external buffers (not managed by the pool).
62    pub external_count: usize,
63}
64
65impl MemoryPlan {
66    /// Returns the slot assignment for a buffer, or `None` if unassigned.
67    pub fn assignment(&self, buf: BufferId) -> Option<&SlotAssignment> {
68        self.assignments.iter().find(|a| a.buf == buf)
69    }
70
71    /// Returns the memory reduction factor (original sum of sizes vs pool size).
72    ///
73    /// A value of `0.5` means the pool is 50% of the naive sum (2× memory saved).
74    /// Returns `1.0` if no buffers are managed.
75    pub fn compression_ratio(&self) -> f64 {
76        let naive: usize = self.assignments.iter().map(|a| a.buf_size).sum();
77        if naive == 0 {
78            return 1.0;
79        }
80        self.total_bytes as f64 / naive as f64
81    }
82}
83
84// ---------------------------------------------------------------------------
85// Alignment helper
86// ---------------------------------------------------------------------------
87
88fn align_up(size: usize, align: usize) -> usize {
89    if align == 0 {
90        return size;
91    }
92    (size + align - 1) & !(align - 1)
93}
94
95// ---------------------------------------------------------------------------
96// analyse — entry point
97// ---------------------------------------------------------------------------
98
99/// Runs the memory planning pass on `graph`.
100///
101/// # Errors
102///
103/// Returns [`GraphError::EmptyGraph`] if the graph has no nodes.
104/// Returns [`GraphError::MemoryPlanningFailed`] if planning fails.
105pub fn analyse(graph: &ComputeGraph) -> GraphResult<MemoryPlan> {
106    if graph.is_empty() {
107        return Err(GraphError::EmptyGraph);
108    }
109
110    let liveness = liveness_analyse(graph)?;
111
112    // Alignment requirement (256 bytes is CUDA's standard requirement).
113    const ALIGN: usize = 256;
114
115    // Separate external buffers from managed ones.
116    let mut managed: Vec<_> = liveness
117        .sorted_by_start()
118        .into_iter()
119        .filter(|iv| !iv.external && iv.size_bytes > 0)
120        .collect();
121
122    // Sort by start position (already sorted by sorted_by_start, but confirm).
123    managed.sort_by_key(|iv| (iv.start(), iv.buf.0));
124
125    let external_count = liveness.all_intervals().filter(|iv| iv.external).count();
126
127    // Active slots: sorted by end_step.
128    // We use a simple linear scan here (interval graph width is typically small).
129    // active[i] = (end_step, slot_id, slot_size).
130    let mut active: Vec<(usize, usize, usize)> = Vec::new(); // (end, slot_id, size)
131    let mut slot_sizes: Vec<usize> = Vec::new();
132    let mut assignments: Vec<SlotAssignment> = Vec::new();
133    let mut next_slot = 0usize;
134
135    for iv in &managed {
136        let start = iv.start();
137        let end = iv.end();
138        let needed = align_up(iv.size_bytes, ALIGN);
139
140        // Find a free slot: a slot whose end_step < start and size >= needed.
141        // Among candidates, prefer the smallest slot that still fits (best fit).
142        let candidate_pos = active
143            .iter()
144            .enumerate()
145            .filter(|(_, (end_step, _, sz))| *end_step < start && *sz >= needed)
146            .min_by_key(|(_, (_, _, sz))| *sz)
147            .map(|(pos, _)| pos);
148
149        let slot_id = if let Some(pos) = candidate_pos {
150            let (_, sid, sz) = active.remove(pos);
151            // Re-enter the slot as active for this buffer's lifetime.
152            active.push((end, sid, sz));
153            sid
154        } else {
155            // Allocate a new slot.
156            let sid = next_slot;
157            next_slot += 1;
158            slot_sizes.push(needed);
159            active.push((end, sid, needed));
160            sid
161        };
162
163        assignments.push(SlotAssignment {
164            buf: iv.buf,
165            slot: slot_id,
166            offset: 0, // offsets assigned after slot sizes are known
167            slot_size: slot_sizes[slot_id],
168            buf_size: iv.size_bytes,
169        });
170    }
171
172    // Assign byte offsets: lay slots out consecutively in memory.
173    let mut slot_offsets = vec![0usize; slot_sizes.len()];
174    let mut cursor = 0usize;
175    for (i, &sz) in slot_sizes.iter().enumerate() {
176        slot_offsets[i] = cursor;
177        cursor += sz;
178    }
179    let total_bytes = cursor;
180
181    // Fill in offsets.
182    for asgn in &mut assignments {
183        asgn.offset = slot_offsets[asgn.slot];
184    }
185
186    Ok(MemoryPlan {
187        assignments,
188        slot_sizes,
189        total_bytes,
190        num_slots: next_slot,
191        external_count,
192    })
193}
194
195// ---------------------------------------------------------------------------
196// Tests
197// ---------------------------------------------------------------------------
198
199#[cfg(test)]
200mod tests {
201    use super::*;
202    use crate::builder::GraphBuilder;
203
204    #[test]
205    fn memory_empty_graph() {
206        let g = ComputeGraph::new();
207        assert!(matches!(analyse(&g), Err(GraphError::EmptyGraph)));
208    }
209
210    #[test]
211    fn memory_no_buffers() {
212        let mut b = GraphBuilder::new().with_auto_infer_edges(false);
213        b.add_barrier("n");
214        let g = b.build().unwrap();
215        let plan = analyse(&g).unwrap();
216        assert_eq!(plan.total_bytes, 0);
217        assert_eq!(plan.num_slots, 0);
218    }
219
220    #[test]
221    fn memory_single_buffer() {
222        let mut b = GraphBuilder::new().with_auto_infer_edges(false);
223        let buf = b.alloc_buffer("x", 1024);
224        let w = b.add_barrier("w");
225        let r = b.add_barrier("r");
226        b.set_outputs(w, [buf]);
227        b.set_inputs(r, [buf]);
228        b.dep(w, r);
229        let g = b.build().unwrap();
230        let plan = analyse(&g).unwrap();
231        assert_eq!(plan.num_slots, 1);
232        assert!(plan.total_bytes >= 1024);
233        let asgn = plan.assignment(buf).unwrap();
234        assert_eq!(asgn.buf, buf);
235        assert_eq!(asgn.slot, 0);
236    }
237
238    #[test]
239    fn memory_non_overlapping_buffers_share_slot() {
240        // buf0: live [0,1], buf1: live [2,3] — should share same slot.
241        let mut b = GraphBuilder::new().with_auto_infer_edges(false);
242        let buf0 = b.alloc_buffer("b0", 512);
243        let buf1 = b.alloc_buffer("b1", 512);
244        let n0 = b.add_barrier("n0"); // step 0: writes buf0
245        let n1 = b.add_barrier("n1"); // step 1: reads buf0
246        let n2 = b.add_barrier("n2"); // step 2: writes buf1
247        let n3 = b.add_barrier("n3"); // step 3: reads buf1
248        b.set_outputs(n0, [buf0]);
249        b.set_inputs(n1, [buf0]);
250        b.set_outputs(n2, [buf1]);
251        b.set_inputs(n3, [buf1]);
252        b.chain(&[n0, n1, n2, n3]);
253        let g = b.build().unwrap();
254        let plan = analyse(&g).unwrap();
255        // Ideally they share one slot.
256        let a0 = plan.assignment(buf0);
257        let a1 = plan.assignment(buf1);
258        if let (Some(a0), Some(a1)) = (a0, a1) {
259            assert_eq!(
260                a0.slot, a1.slot,
261                "non-overlapping buffers should share a slot"
262            );
263        }
264        assert_eq!(plan.num_slots, 1);
265    }
266
267    #[test]
268    fn memory_overlapping_buffers_use_different_slots() {
269        // Both buffers alive concurrently.
270        let mut b = GraphBuilder::new().with_auto_infer_edges(false);
271        let buf0 = b.alloc_buffer("b0", 1024);
272        let buf1 = b.alloc_buffer("b1", 2048);
273        let w0 = b.add_barrier("w0");
274        let w1 = b.add_barrier("w1");
275        let reader = b.add_barrier("reader");
276        b.set_outputs(w0, [buf0]);
277        b.set_outputs(w1, [buf1]);
278        b.set_inputs(reader, [buf0, buf1]);
279        b.fan_in(&[w0, w1], reader);
280        let g = b.build().unwrap();
281        let plan = analyse(&g).unwrap();
282        let a0 = plan.assignment(buf0).unwrap();
283        let a1 = plan.assignment(buf1).unwrap();
284        assert_ne!(a0.slot, a1.slot);
285        assert_eq!(plan.num_slots, 2);
286    }
287
288    #[test]
289    fn memory_external_buffer_not_counted() {
290        let mut b = GraphBuilder::new().with_auto_infer_edges(false);
291        let ext = b.alloc_external_buffer("weights", 65536);
292        let r = b.add_barrier("r");
293        b.set_inputs(r, [ext]);
294        let g = b.build().unwrap();
295        let plan = analyse(&g).unwrap();
296        assert_eq!(plan.external_count, 1);
297        assert_eq!(plan.num_slots, 0);
298        assert_eq!(plan.total_bytes, 0);
299    }
300
301    #[test]
302    fn memory_alignment_respected() {
303        let mut b = GraphBuilder::new().with_auto_infer_edges(false);
304        let buf = b.alloc_buffer("small", 17); // 17 bytes → must align to 256
305        let w = b.add_barrier("w");
306        b.set_outputs(w, [buf]);
307        let g = b.build().unwrap();
308        let plan = analyse(&g).unwrap();
309        assert!(plan.total_bytes >= 256);
310        let asgn = plan.assignment(buf).unwrap();
311        assert!(asgn.slot_size >= 256);
312    }
313
314    #[test]
315    fn memory_compression_ratio_improves_with_sharing() {
316        // Three sequential non-overlapping buffers, same size → ideal ratio ~1/3.
317        let mut b = GraphBuilder::new().with_auto_infer_edges(false);
318        let bufs: Vec<_> = (0..3)
319            .map(|i| b.alloc_buffer(&format!("b{i}"), 1024))
320            .collect();
321        let nodes: Vec<_> = (0..6).map(|i| b.add_barrier(&format!("n{i}"))).collect();
322        // n0 writes buf0, n1 reads buf0, n2 writes buf1, n3 reads buf1, ...
323        for i in 0..3 {
324            b.set_outputs(nodes[2 * i], [bufs[i]]);
325            b.set_inputs(nodes[2 * i + 1], [bufs[i]]);
326        }
327        b.chain(&nodes);
328        let g = b.build().unwrap();
329        let plan = analyse(&g).unwrap();
330        // With perfect sharing, total = 1 * 256 (aligned) vs naive = 3 * 1024 = 3072.
331        // ratio should be < 1.0 (better than naive).
332        assert!(
333            plan.compression_ratio() <= 1.0,
334            "ratio = {}",
335            plan.compression_ratio()
336        );
337    }
338
339    #[test]
340    fn memory_slot_offsets_non_overlapping() {
341        // Multiple concurrent buffers → different slots and non-overlapping offsets.
342        let mut b = GraphBuilder::new().with_auto_infer_edges(false);
343        let buf0 = b.alloc_buffer("b0", 1024);
344        let buf1 = b.alloc_buffer("b1", 2048);
345        let buf2 = b.alloc_buffer("b2", 512);
346        let w0 = b.add_barrier("w0");
347        let w1 = b.add_barrier("w1");
348        let w2 = b.add_barrier("w2");
349        let sink = b.add_barrier("sink");
350        b.set_outputs(w0, [buf0]);
351        b.set_outputs(w1, [buf1]);
352        b.set_outputs(w2, [buf2]);
353        b.set_inputs(sink, [buf0, buf1, buf2]);
354        b.fan_in(&[w0, w1, w2], sink);
355        let g = b.build().unwrap();
356        let plan = analyse(&g).unwrap();
357        // All concurrent → 3 slots with non-overlapping offsets.
358        let a0 = plan.assignment(buf0).unwrap();
359        let a1 = plan.assignment(buf1).unwrap();
360        let a2 = plan.assignment(buf2).unwrap();
361        // Offsets should not overlap.
362        let ranges = [
363            (a0.offset, a0.offset + a0.slot_size),
364            (a1.offset, a1.offset + a1.slot_size),
365            (a2.offset, a2.offset + a2.slot_size),
366        ];
367        for i in 0..ranges.len() {
368            for j in (i + 1)..ranges.len() {
369                let (s0, e0) = ranges[i];
370                let (s1, e1) = ranges[j];
371                assert!(e0 <= s1 || e1 <= s0, "slots {i} and {j} overlap in memory");
372            }
373        }
374    }
375}