Skip to main content

fuel_parallel/
topology.rs

1// SPDX-License-Identifier: MIT OR Apache-2.0
2//! Device topology modelling.
3//!
4//! Describes the physical layout of compute devices and their interconnects so
5//! that placement and scheduling decisions can take transfer costs into account.
6//!
7//! This module contains only descriptive types — no CUDA or Metal API calls.
8//! Topology is constructed by the caller (or a platform-specific probe) and
9//! passed to scheduling/placement code.
10//!
11//! # Example
12//!
13//! ```rust
14//! use fuel_parallel::topology::{DeviceTopology, DeviceInfo, DeviceKind, Link, Interconnect};
15//!
16//! let mut topo = DeviceTopology::new();
17//! let gpu0 = topo.add_device(DeviceInfo::new(0, DeviceKind::Cuda, "RTX 4090")
18//!     .with_memory_bytes(24 * 1024 * 1024 * 1024));
19//! let gpu1 = topo.add_device(DeviceInfo::new(1, DeviceKind::Cuda, "RTX 4090")
20//!     .with_memory_bytes(24 * 1024 * 1024 * 1024));
21//! topo.add_link(gpu0, gpu1, Link::new(Interconnect::NvLink, 600_000));
22//!
23//! assert_eq!(topo.num_devices(), 2);
24//! assert_eq!(topo.bandwidth_mb_s(gpu0, gpu1), Some(600_000));
25//! ```
26
27use serde::{Deserialize, Serialize};
28use std::collections::HashMap;
29
30/// Opaque device identifier within a topology.
31#[derive(Debug, Clone, Copy, PartialEq, Eq, Hash, Serialize, Deserialize)]
32pub struct DeviceId(pub usize);
33
34/// Kind of compute device.
35#[derive(Debug, Clone, Copy, PartialEq, Eq, Hash, Serialize, Deserialize)]
36pub enum DeviceKind {
37    /// CPU (host).
38    Cpu,
39    /// NVIDIA CUDA GPU.
40    Cuda,
41    /// AMD ROCm GPU.
42    Rocm,
43    /// Apple Metal GPU.
44    Metal,
45    /// Intel GPU (Level Zero / oneAPI).
46    IntelGpu,
47    /// Other / custom.
48    Other,
49}
50
51/// Interconnect type between two devices.
52#[derive(Debug, Clone, Copy, PartialEq, Eq, Hash, Serialize, Deserialize)]
53pub enum Interconnect {
54    /// NVIDIA NVLink (high bandwidth, low latency).
55    NvLink,
56    /// PCI Express (standard host↔device or peer-to-peer).
57    Pcie,
58    /// AMD Infinity Fabric.
59    InfinityFabric,
60    /// Shared memory (same-device or CPU↔CPU).
61    SharedMemory,
62    /// Network (e.g. InfiniBand, RoCE).
63    Network,
64    /// Unknown / not characterized.
65    Unknown,
66}
67
68/// Descriptor for a single compute device.
69#[derive(Debug, Clone, Serialize, Deserialize)]
70pub struct DeviceInfo {
71    /// Hardware ordinal (e.g. CUDA device index).
72    pub ordinal: usize,
73    /// Device kind.
74    pub kind: DeviceKind,
75    /// Human-readable name (e.g. `"RTX 4090"`).
76    pub name: String,
77    /// Total device memory in bytes (0 = unknown).
78    pub memory_bytes: u64,
79    /// Compute throughput estimate in GFLOPS (0 = unknown).
80    pub gflops: f64,
81}
82
83impl DeviceInfo {
84    /// Create a minimal device descriptor.
85    pub fn new(ordinal: usize, kind: DeviceKind, name: impl Into<String>) -> Self {
86        Self {
87            ordinal,
88            kind,
89            name: name.into(),
90            memory_bytes: 0,
91            gflops: 0.0,
92        }
93    }
94
95    /// Builder: set memory capacity.
96    pub fn with_memory_bytes(mut self, bytes: u64) -> Self {
97        self.memory_bytes = bytes;
98        self
99    }
100
101    /// Builder: set compute throughput.
102    pub fn with_gflops(mut self, gflops: f64) -> Self {
103        self.gflops = gflops;
104        self
105    }
106}
107
108/// A directed link between two devices.
109#[derive(Debug, Clone, Serialize, Deserialize)]
110pub struct Link {
111    /// Interconnect type.
112    pub interconnect: Interconnect,
113    /// Bandwidth in MB/s (0 = unknown).
114    pub bandwidth_mb_s: u64,
115    /// Latency in microseconds (0 = unknown).
116    pub latency_us: u64,
117}
118
119impl Link {
120    /// Create a link with the given interconnect and bandwidth.
121    pub fn new(interconnect: Interconnect, bandwidth_mb_s: u64) -> Self {
122        Self {
123            interconnect,
124            bandwidth_mb_s,
125            latency_us: 0,
126        }
127    }
128
129    /// Builder: set latency.
130    pub fn with_latency_us(mut self, us: u64) -> Self {
131        self.latency_us = us;
132        self
133    }
134
135    /// Estimated transfer time in microseconds for `bytes` of data.
136    ///
137    /// Returns `None` if bandwidth is unknown (0).
138    pub fn transfer_time_us(&self, bytes: u64) -> Option<f64> {
139        if self.bandwidth_mb_s == 0 {
140            return None;
141        }
142        let mb = bytes as f64 / (1024.0 * 1024.0);
143        let seconds = mb / self.bandwidth_mb_s as f64;
144        Some(self.latency_us as f64 + seconds * 1_000_000.0)
145    }
146}
147
148/// Graph of devices and their interconnects.
149#[derive(Debug, Clone, Default)]
150pub struct DeviceTopology {
151    devices: Vec<DeviceInfo>,
152    /// Directed links keyed by (src, dst).
153    links: HashMap<(DeviceId, DeviceId), Link>,
154}
155
156impl DeviceTopology {
157    /// Create an empty topology.
158    pub fn new() -> Self {
159        Self::default()
160    }
161
162    /// Add a device. Returns its [`DeviceId`].
163    pub fn add_device(&mut self, info: DeviceInfo) -> DeviceId {
164        let id = DeviceId(self.devices.len());
165        self.devices.push(info);
166        id
167    }
168
169    /// Add a bidirectional link between two devices.
170    pub fn add_link(&mut self, a: DeviceId, b: DeviceId, link: Link) {
171        self.links.insert((a, b), link.clone());
172        self.links.insert((b, a), link);
173    }
174
175    /// Add a directed (asymmetric) link.
176    pub fn add_directed_link(&mut self, from: DeviceId, to: DeviceId, link: Link) {
177        self.links.insert((from, to), link);
178    }
179
180    /// Number of devices.
181    pub fn num_devices(&self) -> usize {
182        self.devices.len()
183    }
184
185    /// Get device info.
186    pub fn device(&self, id: DeviceId) -> Option<&DeviceInfo> {
187        self.devices.get(id.0)
188    }
189
190    /// Get the link between two devices.
191    pub fn link(&self, from: DeviceId, to: DeviceId) -> Option<&Link> {
192        self.links.get(&(from, to))
193    }
194
195    /// Get bandwidth (MB/s) between two devices, if known.
196    pub fn bandwidth_mb_s(&self, from: DeviceId, to: DeviceId) -> Option<u64> {
197        self.links.get(&(from, to)).map(|l| l.bandwidth_mb_s)
198    }
199
200    /// Estimate transfer time (µs) for `bytes` between two devices.
201    pub fn transfer_time_us(&self, from: DeviceId, to: DeviceId, bytes: u64) -> Option<f64> {
202        self.links.get(&(from, to))?.transfer_time_us(bytes)
203    }
204
205    /// All device IDs.
206    pub fn device_ids(&self) -> Vec<DeviceId> {
207        (0..self.devices.len()).map(DeviceId).collect()
208    }
209
210    /// Devices of a specific kind.
211    pub fn devices_of_kind(&self, kind: DeviceKind) -> Vec<DeviceId> {
212        self.devices
213            .iter()
214            .enumerate()
215            .filter(|(_, d)| d.kind == kind)
216            .map(|(i, _)| DeviceId(i))
217            .collect()
218    }
219
220    /// Total memory across all devices of a given kind.
221    pub fn total_memory(&self, kind: DeviceKind) -> u64 {
222        self.devices
223            .iter()
224            .filter(|d| d.kind == kind)
225            .map(|d| d.memory_bytes)
226            .sum()
227    }
228
229    /// Find the link with the highest bandwidth from a given device.
230    pub fn fastest_peer(&self, from: DeviceId) -> Option<(DeviceId, &Link)> {
231        self.links
232            .iter()
233            .filter(|((src, dst), _)| *src == from && *dst != from)
234            .max_by_key(|(_, l)| l.bandwidth_mb_s)
235            .map(|((_, dst), l)| (*dst, l))
236    }
237}
238
239#[cfg(test)]
240mod tests {
241    use super::*;
242
243    fn two_gpu_topology() -> DeviceTopology {
244        let mut topo = DeviceTopology::new();
245        topo.add_device(
246            DeviceInfo::new(0, DeviceKind::Cuda, "GPU 0").with_memory_bytes(24_000_000_000),
247        );
248        topo.add_device(
249            DeviceInfo::new(1, DeviceKind::Cuda, "GPU 1").with_memory_bytes(24_000_000_000),
250        );
251        topo.add_link(
252            DeviceId(0),
253            DeviceId(1),
254            Link::new(Interconnect::NvLink, 600_000),
255        );
256        topo
257    }
258
259    #[test]
260    fn add_devices_and_links() {
261        let topo = two_gpu_topology();
262        assert_eq!(topo.num_devices(), 2);
263        assert_eq!(topo.device(DeviceId(0)).unwrap().name, "GPU 0");
264    }
265
266    #[test]
267    fn bidirectional_link() {
268        let topo = two_gpu_topology();
269        assert_eq!(topo.bandwidth_mb_s(DeviceId(0), DeviceId(1)), Some(600_000));
270        assert_eq!(topo.bandwidth_mb_s(DeviceId(1), DeviceId(0)), Some(600_000));
271    }
272
273    #[test]
274    fn no_link() {
275        let topo = two_gpu_topology();
276        assert!(topo.bandwidth_mb_s(DeviceId(0), DeviceId(0)).is_none());
277    }
278
279    #[test]
280    fn transfer_time() {
281        let topo = two_gpu_topology();
282        // 600 GB/s ≈ 600_000 MB/s; 1 MB transfer ≈ 1/600_000 s ≈ 1.67 µs
283        let time = topo
284            .transfer_time_us(DeviceId(0), DeviceId(1), 1024 * 1024)
285            .unwrap();
286        assert!(time > 0.0 && time < 100.0);
287    }
288
289    #[test]
290    fn devices_of_kind() {
291        let mut topo = two_gpu_topology();
292        topo.add_device(DeviceInfo::new(0, DeviceKind::Cpu, "Host"));
293        assert_eq!(topo.devices_of_kind(DeviceKind::Cuda).len(), 2);
294        assert_eq!(topo.devices_of_kind(DeviceKind::Cpu).len(), 1);
295    }
296
297    #[test]
298    fn total_memory() {
299        let topo = two_gpu_topology();
300        assert_eq!(topo.total_memory(DeviceKind::Cuda), 48_000_000_000);
301    }
302
303    #[test]
304    fn fastest_peer() {
305        let mut topo = DeviceTopology::new();
306        topo.add_device(DeviceInfo::new(0, DeviceKind::Cuda, "GPU 0"));
307        topo.add_device(DeviceInfo::new(1, DeviceKind::Cuda, "GPU 1"));
308        topo.add_device(DeviceInfo::new(2, DeviceKind::Cuda, "GPU 2"));
309        topo.add_link(
310            DeviceId(0),
311            DeviceId(1),
312            Link::new(Interconnect::Pcie, 32_000),
313        );
314        topo.add_link(
315            DeviceId(0),
316            DeviceId(2),
317            Link::new(Interconnect::NvLink, 600_000),
318        );
319
320        let (peer, link) = topo.fastest_peer(DeviceId(0)).unwrap();
321        assert_eq!(peer, DeviceId(2));
322        assert_eq!(link.interconnect, Interconnect::NvLink);
323    }
324
325    #[test]
326    fn directed_link() {
327        let mut topo = DeviceTopology::new();
328        topo.add_device(DeviceInfo::new(0, DeviceKind::Cuda, "GPU 0"));
329        topo.add_device(DeviceInfo::new(1, DeviceKind::Cuda, "GPU 1"));
330        topo.add_directed_link(
331            DeviceId(0),
332            DeviceId(1),
333            Link::new(Interconnect::Pcie, 16_000),
334        );
335
336        assert!(topo.link(DeviceId(0), DeviceId(1)).is_some());
337        assert!(topo.link(DeviceId(1), DeviceId(0)).is_none());
338    }
339
340    #[test]
341    fn link_latency() {
342        let link = Link::new(Interconnect::Pcie, 32_000).with_latency_us(5);
343        // 1 MB at 32_000 MB/s ≈ 31.25 µs + 5 µs latency
344        let time = link.transfer_time_us(1024 * 1024).unwrap();
345        assert!(time > 30.0 && time < 40.0);
346    }
347
348    #[test]
349    fn device_builder_pattern() {
350        let info = DeviceInfo::new(0, DeviceKind::Cuda, "RTX 4090")
351            .with_memory_bytes(24_000_000_000)
352            .with_gflops(82_600.0);
353        assert_eq!(info.memory_bytes, 24_000_000_000);
354        assert!((info.gflops - 82_600.0).abs() < 1.0);
355    }
356
357    #[test]
358    fn empty_topology() {
359        let topo = DeviceTopology::new();
360        assert_eq!(topo.num_devices(), 0);
361        assert!(topo.device_ids().is_empty());
362    }
363}