Skip to main content

vyre_runtime/
scheduler.rs

1//! Multi-GPU work stealing scheduler (Innovation I.7).
2//!
3//! Partitions a large Program or batch of Programs across all
4//! registered physical devices.
5
6use std::ops::Range;
7use std::sync::atomic::{AtomicUsize, Ordering};
8use vyre_driver::BackendError;
9
10/// A unit of work assigned to one GPU.
11#[derive(Debug, Clone, PartialEq, Eq)]
12pub struct Shard {
13    /// Stable backend identifier for the GPU backend receiving this shard.
14    pub backend_id: &'static str,
15    /// Half-open byte/item range assigned to the backend.
16    pub work_range: Range<usize>,
17}
18
19/// Dynamic work-stealing scheduler.
20pub struct WorkStealingScheduler {
21    backend_ids: Vec<&'static str>,
22    /// Atomic work index used by dispatch loops to let fast backends
23    /// steal more fine-grained work units. Worker threads call
24    /// [`Self::claim_next_unit`] which atomically increments the index;
25    /// the returned value is the unit index they own. This is the
26    /// work-stealing primitive  -  fast backends pull more units, slow
27    /// backends pull fewer.
28    work_index: AtomicUsize,
29}
30
31impl WorkStealingScheduler {
32    /// Create a scheduler over the live runtime backends available to the process.
33    pub fn new(backend_ids: Vec<&'static str>) -> Self {
34        Self {
35            backend_ids,
36            work_index: AtomicUsize::new(0),
37        }
38    }
39
40    /// Partition a large haystack across available GPUs.
41    pub fn partition(&self, total_len: usize) -> Vec<Shard> {
42        self.try_partition(total_len).unwrap_or_default()
43    }
44
45    /// Partition a large haystack across available GPUs with explicit staging
46    /// allocation failure reporting.
47    pub fn try_partition(&self, total_len: usize) -> Result<Vec<Shard>, BackendError> {
48        let mut shards = Vec::new();
49        self.try_partition_into(total_len, &mut shards)?;
50        Ok(shards)
51    }
52
53    /// Atomically claim the next fine-grained work unit. Worker threads
54    /// call this in a loop; the returned value is the unit index they
55    /// own. When the returned index is `>= num_units`, the worker is
56    /// done. This is the work-stealing primitive: fast backends call
57    /// `claim_next_unit` more times in the same wall-clock window.
58    ///
59    /// # Examples
60    ///
61    /// ```
62    /// use vyre_runtime::scheduler::WorkStealingScheduler;
63    /// let scheduler = WorkStealingScheduler::new(Vec::new());
64    /// assert_eq!(scheduler.claim_next_unit(), 0);
65    /// assert_eq!(scheduler.claim_next_unit(), 1);
66    /// scheduler.reset_unit_cursor();
67    /// assert_eq!(scheduler.claim_next_unit(), 0);
68    /// ```
69    #[must_use]
70    pub fn claim_next_unit(&self) -> usize {
71        self.work_index.fetch_add(1, Ordering::AcqRel)
72    }
73
74    /// Reset the work-unit cursor to zero. Call between dispatches that
75    /// reuse the same scheduler.
76    pub fn reset_unit_cursor(&self) {
77        self.work_index.store(0, Ordering::Release);
78    }
79
80    /// Partition a large haystack into many fine-grained work units
81    /// assigned round-robin to backends. A caller-side dispatch loop
82    /// uses [`Self::claim_next_unit`] to let worker threads atomically
83    /// claim units so fast backends steal more work.
84    ///
85    /// # Panics
86    /// Panics when `total_len` cannot be partitioned into work units. Callers that must
87    /// recover use the `try_` twin.
88    pub fn partition_into(&self, total_len: usize, out: &mut Vec<Shard>) {
89        // Clearing to empty on failure leaves NO work units, the dispatch loop
90        // then claims nothing and the scan silently processes nothing (Law 10
91        // recall loss). Fail loud; callers use try_partition_into.
92        if let Err(error) = self.try_partition_into(total_len, out) {
93            panic!("vyre-runtime work-unit partition failed: {error}");
94        }
95    }
96
97    /// Partition into caller-owned storage with explicit staging allocation
98    /// failure reporting.
99    pub fn try_partition_into(
100        &self,
101        total_len: usize,
102        out: &mut Vec<Shard>,
103    ) -> Result<(), BackendError> {
104        let n = self.backend_ids.len();
105        out.clear();
106        if n == 0 || total_len == 0 {
107            return Ok(());
108        }
109        let work_unit_size = partition_work_unit_size(total_len, n);
110        let num_units = total_len.div_ceil(work_unit_size);
111        vyre_foundation::allocation::try_reserve_vec_to_capacity(out, num_units).map_err(
112            |error| BackendError::InvalidProgram {
113                fix: format!(
114                    "Fix: scheduler could not reserve {num_units} GPU work shard(s): {error}. Shard the workload before work-stealing partitioning."
115                ),
116            },
117        )?;
118        let mut start = 0;
119        for i in 0..num_units {
120            let end = (start + work_unit_size).min(total_len);
121            out.push(Shard {
122                backend_id: self.backend_ids[i % n],
123                work_range: start..end,
124            });
125            start = end;
126        }
127        Ok(())
128    }
129}
130
131fn partition_work_unit_size(total_len: usize, backend_count: usize) -> usize {
132    if total_len == 0 || backend_count == 0 {
133        return 1;
134    }
135    let denominator = backend_count.checked_mul(4).unwrap_or(usize::MAX);
136    (total_len / denominator.max(1)).max(1)
137}
138
139#[cfg(test)]
140fn partition_ranges(total_len: usize, backend_count: usize) -> Vec<Range<usize>> {
141    if backend_count == 0 || total_len == 0 {
142        return Vec::new();
143    }
144    let work_unit_size = partition_work_unit_size(total_len, backend_count);
145    let num_units = total_len.div_ceil(work_unit_size);
146    let mut ranges = Vec::with_capacity(num_units);
147    let mut start = 0;
148    for _ in 0..num_units {
149        let end = (start + work_unit_size).min(total_len);
150        ranges.push(start..end);
151        start = end;
152    }
153    ranges
154}
155
156#[cfg(test)]
157mod tests {
158    use super::{partition_ranges, WorkStealingScheduler};
159
160    #[test]
161    fn partition_ranges_produces_fine_grained_units() {
162        let ranges = partition_ranges(10, 3);
163        assert_eq!(ranges.len(), 10);
164        assert_eq!(
165            ranges,
166            vec![0..1, 1..2, 2..3, 3..4, 4..5, 5..6, 6..7, 7..8, 8..9, 9..10]
167        );
168    }
169
170    #[test]
171    fn partition_ranges_never_emits_empty_shards() {
172        let ranges = partition_ranges(2, 8);
173        assert_eq!(ranges, vec![0..1, 1..2]);
174    }
175
176    #[test]
177    fn partition_ranges_uses_overflow_safe_work_unit_math() {
178        let ranges = partition_ranges(2, usize::MAX);
179        assert_eq!(ranges[0], 0..1);
180        assert_eq!(ranges[1], 1..2);
181        assert_eq!(
182            super::partition_work_unit_size(2, usize::MAX),
183            1,
184            "backend_count * 4 overflow must not panic or enlarge the work unit"
185        );
186    }
187
188    #[test]
189    fn scheduler_partition_into_reuses_output_storage() {
190        let scheduler = WorkStealingScheduler::new(vec!["a", "b", "c"]);
191        let mut shards = Vec::with_capacity(10);
192
193        scheduler.partition_into(10, &mut shards);
194        let ptr = shards.as_ptr();
195        scheduler.partition_into(10, &mut shards);
196
197        assert_eq!(shards.as_ptr(), ptr);
198        assert_eq!(shards.len(), 10);
199        assert_eq!(shards[0].backend_id, "a");
200        assert_eq!(shards[0].work_range, 0..1);
201        assert_eq!(shards[1].backend_id, "b");
202        assert_eq!(shards[1].work_range, 1..2);
203        assert_eq!(shards[9].backend_id, "a");
204        assert_eq!(shards[9].work_range, 9..10);
205        assert_eq!(
206            scheduler
207                .work_index
208                .load(std::sync::atomic::Ordering::Relaxed),
209            0
210        );
211    }
212}