Skip to main content

oxicuda_graph/
node.rs

1//! Core graph node types for the OxiCUDA computation graph.
2//!
3//! This module defines the fundamental building blocks of the computation
4//! graph: typed identifiers (`NodeId`, `BufferId`, `StreamId`) and the
5//! `GraphNode` which describes a single GPU operation along with its
6//! data-flow edges (input and output buffer references).
7
8use std::fmt;
9
10// ---------------------------------------------------------------------------
11// Typed identifier wrappers
12// ---------------------------------------------------------------------------
13
14/// Unique identifier for a node in the computation graph.
15#[derive(Debug, Clone, Copy, PartialEq, Eq, PartialOrd, Ord, Hash)]
16pub struct NodeId(pub u32);
17
18impl fmt::Display for NodeId {
19    fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
20        write!(f, "N{}", self.0)
21    }
22}
23
24/// Unique identifier for a logical buffer (device memory region) in the graph.
25#[derive(Debug, Clone, Copy, PartialEq, Eq, PartialOrd, Ord, Hash)]
26pub struct BufferId(pub u32);
27
28impl fmt::Display for BufferId {
29    fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
30        write!(f, "B{}", self.0)
31    }
32}
33
34/// Identifier for a CUDA stream assignment.
35///
36/// `StreamId(0)` is the default stream. Streams with IDs ≥ 1 are
37/// independent execution queues that can run concurrently.
38#[derive(Debug, Clone, Copy, PartialEq, Eq, PartialOrd, Ord, Hash)]
39pub struct StreamId(pub u32);
40
41impl StreamId {
42    /// The default (null) stream.
43    pub const DEFAULT: Self = Self(0);
44}
45
46impl fmt::Display for StreamId {
47    fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
48        if self.0 == 0 {
49            write!(f, "S:default")
50        } else {
51            write!(f, "S{}", self.0)
52        }
53    }
54}
55
56// ---------------------------------------------------------------------------
57// Kernel launch configuration
58// ---------------------------------------------------------------------------
59
60/// GPU grid/block launch configuration for a kernel node.
61#[derive(Debug, Clone, Copy, PartialEq, Eq, Hash)]
62pub struct KernelConfig {
63    /// Grid dimensions `(x, y, z)`.
64    pub grid: (u32, u32, u32),
65    /// Block dimensions `(x, y, z)`.
66    pub block: (u32, u32, u32),
67    /// Dynamic shared memory in bytes.
68    pub shared_mem_bytes: u32,
69}
70
71impl KernelConfig {
72    /// Creates a simple 1-D launch configuration.
73    ///
74    /// * `num_blocks` — number of thread-blocks in the x dimension.
75    /// * `threads_per_block` — threads per block in the x dimension.
76    /// * `shared_mem_bytes` — bytes of dynamic shared memory per block.
77    #[must_use]
78    pub fn linear(num_blocks: u32, threads_per_block: u32, shared_mem_bytes: u32) -> Self {
79        Self {
80            grid: (num_blocks, 1, 1),
81            block: (threads_per_block, 1, 1),
82            shared_mem_bytes,
83        }
84    }
85
86    /// Total number of threads launched.
87    #[must_use]
88    pub fn total_threads(&self) -> u64 {
89        let g = self.grid;
90        let b = self.block;
91        u64::from(g.0)
92            * u64::from(g.1)
93            * u64::from(g.2)
94            * u64::from(b.0)
95            * u64::from(b.1)
96            * u64::from(b.2)
97    }
98
99    /// Total number of thread-blocks.
100    #[must_use]
101    pub fn total_blocks(&self) -> u64 {
102        let g = self.grid;
103        u64::from(g.0) * u64::from(g.1) * u64::from(g.2)
104    }
105}
106
107impl fmt::Display for KernelConfig {
108    fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
109        let (gx, gy, gz) = self.grid;
110        let (bx, by, bz) = self.block;
111        write!(
112            f,
113            "grid=({gx},{gy},{gz}) block=({bx},{by},{bz}) smem={}B",
114            self.shared_mem_bytes
115        )
116    }
117}
118
119// ---------------------------------------------------------------------------
120// Memcpy direction
121// ---------------------------------------------------------------------------
122
123/// Direction of a device memory copy.
124#[derive(Debug, Clone, Copy, PartialEq, Eq, Hash)]
125pub enum MemcpyDir {
126    /// Host-to-device transfer (upload).
127    HostToDevice,
128    /// Device-to-host transfer (download).
129    DeviceToHost,
130    /// Device-to-device transfer (on-device copy).
131    DeviceToDevice,
132    /// Peer-to-peer transfer across devices.
133    PeerToPeer { src_device: u32, dst_device: u32 },
134}
135
136impl fmt::Display for MemcpyDir {
137    fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
138        match self {
139            Self::HostToDevice => write!(f, "HtoD"),
140            Self::DeviceToHost => write!(f, "DtoH"),
141            Self::DeviceToDevice => write!(f, "DtoD"),
142            Self::PeerToPeer {
143                src_device,
144                dst_device,
145            } => {
146                write!(f, "PeerToPeer(dev{src_device}→dev{dst_device})")
147            }
148        }
149    }
150}
151
152// ---------------------------------------------------------------------------
153// NodeKind — the operation performed by a node
154// ---------------------------------------------------------------------------
155
156/// The operation kind associated with a [`GraphNode`].
157#[derive(Debug, Clone, PartialEq)]
158pub enum NodeKind {
159    /// Launches a PTX/CUDA kernel.
160    KernelLaunch {
161        /// Kernel function name (used to look up the loaded module).
162        function_name: String,
163        /// Grid/block configuration.
164        config: KernelConfig,
165        /// Whether this kernel is eligible for fusion with adjacent element-wise kernels.
166        fusible: bool,
167    },
168
169    /// Copies memory between host and device, or device to device.
170    Memcpy {
171        /// Transfer direction.
172        dir: MemcpyDir,
173        /// Transfer size in bytes.
174        size_bytes: usize,
175    },
176
177    /// Fills a device buffer with a byte pattern.
178    Memset {
179        /// Number of bytes to fill.
180        size_bytes: usize,
181        /// Byte fill value.
182        value: u8,
183    },
184
185    /// Records a CUDA event (signals completion of preceding work).
186    EventRecord,
187
188    /// Waits for a recorded event before proceeding.
189    EventWait,
190
191    /// A host-side callback inserted into the stream.
192    HostCallback {
193        /// Human-readable label for debugging and visualisation.
194        label: String,
195    },
196
197    /// A no-op synchronisation barrier (join point for multiple dependency chains).
198    Barrier,
199
200    /// A conditional subgraph fork (CUDA graph conditional node equivalent).
201    Conditional {
202        /// Condition variable — a boolean device buffer.
203        condition_buf: BufferId,
204        /// Which branch is the "true" path (node IDs within this graph).
205        true_branch: Vec<NodeId>,
206        /// Which branch is the "false" path.
207        false_branch: Vec<NodeId>,
208    },
209}
210
211impl NodeKind {
212    /// Returns `true` if this node performs device computation (not just memory movement).
213    #[must_use]
214    pub fn is_compute(&self) -> bool {
215        matches!(self, Self::KernelLaunch { .. })
216    }
217
218    /// Returns `true` if this node is a memory transfer.
219    #[must_use]
220    pub fn is_memory_op(&self) -> bool {
221        matches!(self, Self::Memcpy { .. } | Self::Memset { .. })
222    }
223
224    /// Returns `true` if this kernel is marked as fusible with adjacent element-wise kernels.
225    #[must_use]
226    pub fn is_fusible(&self) -> bool {
227        match self {
228            Self::KernelLaunch { fusible, .. } => *fusible,
229            _ => false,
230        }
231    }
232
233    /// Returns the function name for kernel launch nodes.
234    #[must_use]
235    pub fn function_name(&self) -> Option<&str> {
236        match self {
237            Self::KernelLaunch { function_name, .. } => Some(function_name.as_str()),
238            _ => None,
239        }
240    }
241
242    /// Returns a short human-readable tag for this operation kind.
243    #[must_use]
244    pub fn tag(&self) -> &'static str {
245        match self {
246            Self::KernelLaunch { .. } => "kernel",
247            Self::Memcpy { .. } => "memcpy",
248            Self::Memset { .. } => "memset",
249            Self::EventRecord => "event_record",
250            Self::EventWait => "event_wait",
251            Self::HostCallback { .. } => "host_cb",
252            Self::Barrier => "barrier",
253            Self::Conditional { .. } => "conditional",
254        }
255    }
256}
257
258impl fmt::Display for NodeKind {
259    fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
260        match self {
261            Self::KernelLaunch {
262                function_name,
263                config,
264                fusible,
265            } => {
266                let fuse = if *fusible { " [fusible]" } else { "" };
267                write!(f, "KernelLaunch({function_name}, {config}{fuse})")
268            }
269            Self::Memcpy { dir, size_bytes } => {
270                write!(f, "Memcpy({dir}, {size_bytes}B)")
271            }
272            Self::Memset { size_bytes, value } => {
273                write!(f, "Memset({size_bytes}B, 0x{value:02x})")
274            }
275            Self::EventRecord => write!(f, "EventRecord"),
276            Self::EventWait => write!(f, "EventWait"),
277            Self::HostCallback { label } => write!(f, "HostCallback({label})"),
278            Self::Barrier => write!(f, "Barrier"),
279            Self::Conditional { condition_buf, .. } => {
280                write!(f, "Conditional(cond={condition_buf})")
281            }
282        }
283    }
284}
285
286// ---------------------------------------------------------------------------
287// GraphNode — a node in the computation DAG
288// ---------------------------------------------------------------------------
289
290/// A single node in the OxiCUDA computation graph.
291///
292/// Each node represents one GPU operation. Data-flow is captured through
293/// explicit `inputs` and `outputs` buffer lists: an output buffer of one
294/// node becomes an input buffer of its successors, forming the data
295/// dependency graph alongside the explicit control dependency edges stored
296/// in `ComputeGraph`.
297#[derive(Debug, Clone)]
298pub struct GraphNode {
299    /// Unique identity within the graph.
300    pub id: NodeId,
301    /// The GPU operation this node performs.
302    pub kind: NodeKind,
303    /// Buffers consumed by this operation (read-only or read-write).
304    pub inputs: Vec<BufferId>,
305    /// Buffers produced or written by this operation.
306    pub outputs: Vec<BufferId>,
307    /// Preferred stream for this node (hint to the stream partitioner).
308    pub stream_hint: Option<StreamId>,
309    /// Estimated compute cost in "abstract units" for scheduling heuristics.
310    pub cost_hint: u64,
311    /// Optional human-readable name for debugging and profiling.
312    pub name: Option<String>,
313}
314
315impl GraphNode {
316    /// Creates a new node with the given identity and operation kind.
317    #[must_use]
318    pub fn new(id: NodeId, kind: NodeKind) -> Self {
319        Self {
320            id,
321            kind,
322            inputs: Vec::new(),
323            outputs: Vec::new(),
324            stream_hint: None,
325            cost_hint: 1,
326            name: None,
327        }
328    }
329
330    /// Attaches input buffer references to this node (builder pattern).
331    #[must_use]
332    pub fn with_inputs(mut self, inputs: impl IntoIterator<Item = BufferId>) -> Self {
333        self.inputs.extend(inputs);
334        self
335    }
336
337    /// Attaches output buffer references to this node (builder pattern).
338    #[must_use]
339    pub fn with_outputs(mut self, outputs: impl IntoIterator<Item = BufferId>) -> Self {
340        self.outputs.extend(outputs);
341        self
342    }
343
344    /// Sets the preferred stream for this node (builder pattern).
345    #[must_use]
346    pub fn with_stream(mut self, stream: StreamId) -> Self {
347        self.stream_hint = Some(stream);
348        self
349    }
350
351    /// Sets the cost hint for scheduling (builder pattern).
352    #[must_use]
353    pub fn with_cost(mut self, cost: u64) -> Self {
354        self.cost_hint = cost;
355        self
356    }
357
358    /// Sets the display name for this node (builder pattern).
359    #[must_use]
360    pub fn with_name(mut self, name: impl Into<String>) -> Self {
361        self.name = Some(name.into());
362        self
363    }
364
365    /// Returns the node's display name, falling back to its ID.
366    #[must_use]
367    pub fn display_name(&self) -> String {
368        self.name.clone().unwrap_or_else(|| self.id.to_string())
369    }
370
371    /// Returns `true` if this node has no input buffers (source node).
372    #[must_use]
373    pub fn is_source(&self) -> bool {
374        self.inputs.is_empty()
375    }
376
377    /// Returns `true` if this node has no output buffers (sink node).
378    #[must_use]
379    pub fn is_sink(&self) -> bool {
380        self.outputs.is_empty()
381    }
382}
383
384impl fmt::Display for GraphNode {
385    fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
386        let name = self.display_name();
387        write!(f, "[{}] {}", name, self.kind)?;
388        if !self.inputs.is_empty() {
389            let ins: Vec<String> = self.inputs.iter().map(|b| b.to_string()).collect();
390            write!(f, " ←({})", ins.join(","))?;
391        }
392        if !self.outputs.is_empty() {
393            let outs: Vec<String> = self.outputs.iter().map(|b| b.to_string()).collect();
394            write!(f, " →({})", outs.join(","))?;
395        }
396        Ok(())
397    }
398}
399
400// ---------------------------------------------------------------------------
401// BufferDescriptor — metadata for a logical buffer
402// ---------------------------------------------------------------------------
403
404/// Metadata describing a logical device buffer in the graph.
405///
406/// Buffers are abstract references to device memory regions. The memory
407/// planner uses buffer descriptors to assign physical allocations while
408/// maximising reuse across non-overlapping lifetimes.
409#[derive(Debug, Clone)]
410pub struct BufferDescriptor {
411    /// Unique identifier.
412    pub id: BufferId,
413    /// Size in bytes.
414    pub size_bytes: usize,
415    /// Optional human-readable name.
416    pub name: Option<String>,
417    /// If `true`, this buffer is externally managed (not handled by the planner).
418    pub external: bool,
419    /// Required alignment in bytes (must be a power of two).
420    pub alignment: usize,
421}
422
423impl BufferDescriptor {
424    /// Creates a new buffer descriptor.
425    #[must_use]
426    pub fn new(id: BufferId, size_bytes: usize) -> Self {
427        Self {
428            id,
429            size_bytes,
430            name: None,
431            external: false,
432            alignment: 256,
433        }
434    }
435
436    /// Marks this buffer as externally managed (builder pattern).
437    #[must_use]
438    pub fn external(mut self) -> Self {
439        self.external = true;
440        self
441    }
442
443    /// Sets the buffer's required alignment (builder pattern).
444    ///
445    /// # Panics
446    ///
447    /// Panics in debug builds if `align` is not a power of two or is zero.
448    #[must_use]
449    pub fn with_alignment(mut self, align: usize) -> Self {
450        debug_assert!(
451            align > 0 && align.is_power_of_two(),
452            "alignment must be a non-zero power of two"
453        );
454        self.alignment = align;
455        self
456    }
457
458    /// Sets a human-readable name (builder pattern).
459    #[must_use]
460    pub fn with_name(mut self, name: impl Into<String>) -> Self {
461        self.name = Some(name.into());
462        self
463    }
464}
465
466impl fmt::Display for BufferDescriptor {
467    fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
468        let name = self.name.as_deref().unwrap_or("<unnamed>");
469        let ext = if self.external { " [ext]" } else { "" };
470        write!(
471            f,
472            "Buffer({}, {} \"{}\"{})",
473            self.id, self.size_bytes, name, ext
474        )
475    }
476}
477
478// ---------------------------------------------------------------------------
479// Tests
480// ---------------------------------------------------------------------------
481
482#[cfg(test)]
483mod tests {
484    use super::*;
485
486    // --- NodeId, BufferId, StreamId ---
487
488    #[test]
489    fn node_id_display() {
490        assert_eq!(NodeId(0).to_string(), "N0");
491        assert_eq!(NodeId(42).to_string(), "N42");
492    }
493
494    #[test]
495    fn buffer_id_display() {
496        assert_eq!(BufferId(0).to_string(), "B0");
497        assert_eq!(BufferId(7).to_string(), "B7");
498    }
499
500    #[test]
501    fn stream_id_default_display() {
502        assert_eq!(StreamId::DEFAULT.to_string(), "S:default");
503        assert_eq!(StreamId(1).to_string(), "S1");
504    }
505
506    #[test]
507    fn node_id_ordering() {
508        assert!(NodeId(1) > NodeId(0));
509        assert!(NodeId(0) < NodeId(100));
510        let mut ids = vec![NodeId(3), NodeId(1), NodeId(2)];
511        ids.sort();
512        assert_eq!(ids, vec![NodeId(1), NodeId(2), NodeId(3)]);
513    }
514
515    // --- KernelConfig ---
516
517    #[test]
518    fn kernel_config_linear() {
519        let cfg = KernelConfig::linear(4, 256, 1024);
520        assert_eq!(cfg.grid, (4, 1, 1));
521        assert_eq!(cfg.block, (256, 1, 1));
522        assert_eq!(cfg.shared_mem_bytes, 1024);
523    }
524
525    #[test]
526    fn kernel_config_total_threads() {
527        let cfg = KernelConfig {
528            grid: (4, 2, 1),
529            block: (32, 4, 2),
530            shared_mem_bytes: 0,
531        };
532        assert_eq!(cfg.total_threads(), 4 * 2 * 32 * 4 * 2);
533    }
534
535    #[test]
536    fn kernel_config_total_blocks() {
537        let cfg = KernelConfig {
538            grid: (3, 3, 3),
539            block: (8, 8, 8),
540            shared_mem_bytes: 0,
541        };
542        assert_eq!(cfg.total_blocks(), 27);
543    }
544
545    #[test]
546    fn kernel_config_display() {
547        let cfg = KernelConfig::linear(8, 128, 0);
548        let s = cfg.to_string();
549        assert!(s.contains("grid=(8,1,1)"));
550        assert!(s.contains("block=(128,1,1)"));
551    }
552
553    // --- MemcpyDir ---
554
555    #[test]
556    fn memcpy_dir_display() {
557        assert_eq!(MemcpyDir::HostToDevice.to_string(), "HtoD");
558        assert_eq!(MemcpyDir::DeviceToHost.to_string(), "DtoH");
559        assert_eq!(MemcpyDir::DeviceToDevice.to_string(), "DtoD");
560        let p2p = MemcpyDir::PeerToPeer {
561            src_device: 0,
562            dst_device: 1,
563        };
564        assert!(p2p.to_string().contains("PeerToPeer"));
565    }
566
567    // --- NodeKind ---
568
569    #[test]
570    fn node_kind_is_compute() {
571        let k = NodeKind::KernelLaunch {
572            function_name: "add".into(),
573            config: KernelConfig::linear(1, 32, 0),
574            fusible: true,
575        };
576        assert!(k.is_compute());
577        assert!(
578            !NodeKind::Memset {
579                size_bytes: 1024,
580                value: 0
581            }
582            .is_compute()
583        );
584    }
585
586    #[test]
587    fn node_kind_is_memory_op() {
588        let m = NodeKind::Memcpy {
589            dir: MemcpyDir::HostToDevice,
590            size_bytes: 128,
591        };
592        assert!(m.is_memory_op());
593        let ms = NodeKind::Memset {
594            size_bytes: 64,
595            value: 0xff,
596        };
597        assert!(ms.is_memory_op());
598        let k = NodeKind::KernelLaunch {
599            function_name: "k".into(),
600            config: KernelConfig::linear(1, 32, 0),
601            fusible: false,
602        };
603        assert!(!k.is_memory_op());
604    }
605
606    #[test]
607    fn node_kind_is_fusible() {
608        let f = NodeKind::KernelLaunch {
609            function_name: "add".into(),
610            config: KernelConfig::linear(1, 32, 0),
611            fusible: true,
612        };
613        assert!(f.is_fusible());
614        let nf = NodeKind::KernelLaunch {
615            function_name: "custom".into(),
616            config: KernelConfig::linear(1, 32, 0),
617            fusible: false,
618        };
619        assert!(!nf.is_fusible());
620        assert!(!NodeKind::Barrier.is_fusible());
621    }
622
623    #[test]
624    fn node_kind_function_name() {
625        let k = NodeKind::KernelLaunch {
626            function_name: "scale".into(),
627            config: KernelConfig::linear(1, 32, 0),
628            fusible: false,
629        };
630        assert_eq!(k.function_name(), Some("scale"));
631        assert_eq!(NodeKind::Barrier.function_name(), None);
632    }
633
634    #[test]
635    fn node_kind_tag() {
636        assert_eq!(
637            NodeKind::KernelLaunch {
638                function_name: "x".into(),
639                config: KernelConfig::linear(1, 1, 0),
640                fusible: false
641            }
642            .tag(),
643            "kernel"
644        );
645        assert_eq!(
646            NodeKind::Memcpy {
647                dir: MemcpyDir::HostToDevice,
648                size_bytes: 1
649            }
650            .tag(),
651            "memcpy"
652        );
653        assert_eq!(
654            NodeKind::Memset {
655                size_bytes: 1,
656                value: 0
657            }
658            .tag(),
659            "memset"
660        );
661        assert_eq!(NodeKind::EventRecord.tag(), "event_record");
662        assert_eq!(NodeKind::EventWait.tag(), "event_wait");
663        assert_eq!(NodeKind::Barrier.tag(), "barrier");
664        assert_eq!(
665            NodeKind::HostCallback { label: "x".into() }.tag(),
666            "host_cb"
667        );
668    }
669
670    #[test]
671    fn node_kind_display_kernel() {
672        let k = NodeKind::KernelLaunch {
673            function_name: "vector_add".into(),
674            config: KernelConfig::linear(4, 256, 0),
675            fusible: true,
676        };
677        let s = k.to_string();
678        assert!(s.contains("vector_add"));
679        assert!(s.contains("fusible"));
680    }
681
682    #[test]
683    fn node_kind_display_memcpy() {
684        let m = NodeKind::Memcpy {
685            dir: MemcpyDir::DeviceToHost,
686            size_bytes: 4096,
687        };
688        let s = m.to_string();
689        assert!(s.contains("DtoH"));
690        assert!(s.contains("4096"));
691    }
692
693    // --- GraphNode ---
694
695    #[test]
696    fn graph_node_new() {
697        let n = GraphNode::new(NodeId(5), NodeKind::Barrier);
698        assert_eq!(n.id, NodeId(5));
699        assert!(n.inputs.is_empty());
700        assert!(n.outputs.is_empty());
701        assert!(n.stream_hint.is_none());
702        assert_eq!(n.cost_hint, 1);
703        assert!(n.name.is_none());
704    }
705
706    #[test]
707    fn graph_node_builder_pattern() {
708        let n = GraphNode::new(NodeId(0), NodeKind::Barrier)
709            .with_inputs([BufferId(0), BufferId(1)])
710            .with_outputs([BufferId(2)])
711            .with_stream(StreamId(1))
712            .with_cost(100)
713            .with_name("barrier_join");
714        assert_eq!(n.inputs.len(), 2);
715        assert_eq!(n.outputs.len(), 1);
716        assert_eq!(n.stream_hint, Some(StreamId(1)));
717        assert_eq!(n.cost_hint, 100);
718        assert_eq!(n.display_name(), "barrier_join");
719    }
720
721    #[test]
722    fn graph_node_is_source_and_sink() {
723        let source = GraphNode::new(NodeId(0), NodeKind::Barrier).with_outputs([BufferId(0)]);
724        assert!(source.is_source());
725        assert!(!source.is_sink());
726
727        let sink = GraphNode::new(NodeId(1), NodeKind::Barrier).with_inputs([BufferId(0)]);
728        assert!(!sink.is_source());
729        assert!(sink.is_sink());
730    }
731
732    #[test]
733    fn graph_node_display_name_fallback() {
734        let n = GraphNode::new(NodeId(7), NodeKind::Barrier);
735        assert_eq!(n.display_name(), "N7");
736    }
737
738    #[test]
739    fn graph_node_display() {
740        let n = GraphNode::new(
741            NodeId(3),
742            NodeKind::Memcpy {
743                dir: MemcpyDir::HostToDevice,
744                size_bytes: 512,
745            },
746        )
747        .with_inputs([BufferId(0)])
748        .with_outputs([BufferId(1)])
749        .with_name("upload");
750        let s = n.to_string();
751        assert!(s.contains("upload"));
752        assert!(s.contains("HtoD"));
753        assert!(s.contains("B0"));
754        assert!(s.contains("B1"));
755    }
756
757    // --- BufferDescriptor ---
758
759    #[test]
760    fn buffer_descriptor_new() {
761        let b = BufferDescriptor::new(BufferId(0), 4096);
762        assert_eq!(b.id, BufferId(0));
763        assert_eq!(b.size_bytes, 4096);
764        assert!(!b.external);
765        assert_eq!(b.alignment, 256);
766    }
767
768    #[test]
769    fn buffer_descriptor_builder() {
770        let b = BufferDescriptor::new(BufferId(1), 1024)
771            .external()
772            .with_alignment(512)
773            .with_name("weights");
774        assert!(b.external);
775        assert_eq!(b.alignment, 512);
776        assert_eq!(b.name.as_deref(), Some("weights"));
777    }
778
779    #[test]
780    fn buffer_descriptor_display() {
781        let b = BufferDescriptor::new(BufferId(2), 8192).with_name("activations");
782        let s = b.to_string();
783        assert!(s.contains("B2"));
784        assert!(s.contains("8192"));
785        assert!(s.contains("activations"));
786    }
787}