Skip to main content

vyre_driver_wgpu/engine/multi_gpu/
stream_shard.rs

1use std::cmp::{Ordering, Reverse};
2use std::collections::BinaryHeap;
3
4/// Deterministic content-addressed device pick.
5///
6/// Computes `blake3(key)` and maps the first 4 bytes (little-endian) onto
7/// `[0, n_gpus)`. Callers use this as the initial landing device; overflow
8/// handling lives in [`StreamShardAllocator`].
9///
10/// `n_gpus == 0` is a configuration bug; WGPU stream sharding is a
11/// GPU-resident path and must not silently route work to a non-existent device.
12///
13/// # Errors
14///
15/// Returns [`StreamShardError::ZeroGpus`] when no GPU devices are available.
16pub fn shard_by_blake3(key: &[u8], n_gpus: u32) -> Result<u32, StreamShardError> {
17    if n_gpus == 0 {
18        return Err(StreamShardError::ZeroGpus);
19    }
20    let hash = blake3::hash(key);
21    let bytes = hash.as_bytes();
22    let bytes = [bytes[0], bytes[1], bytes[2], bytes[3]];
23    Ok(u32::from_le_bytes(bytes) % n_gpus)
24}
25
26/// Stream-shard scheduling failure.
27#[derive(Debug, Clone, Copy, Eq, PartialEq)]
28pub enum StreamShardError {
29    /// No GPU devices were available to schedule onto.
30    ZeroGpus,
31    /// A GPU/device index did not fit host indexing.
32    IndexTooLarge {
33        /// Field being converted.
34        label: &'static str,
35    },
36    /// A host index did not fit the public u32 device-index ABI.
37    IndexTooWide {
38        /// Field being converted.
39        label: &'static str,
40    },
41    /// A supplied device id was outside the live GPU set.
42    DeviceOutOfRange {
43        /// Device id supplied by the caller.
44        device: u32,
45        /// Number of live GPUs.
46        n_gpus: u32,
47    },
48    /// Accumulated per-GPU load overflowed.
49    LoadOverflow,
50    /// Stale heap compaction threshold overflowed host indexing.
51    HeapLimitOverflow,
52    /// Host scheduler state allocation failed before GPU stream assignment.
53    AllocationFailed {
54        /// Scheduler state being allocated.
55        label: &'static str,
56    },
57}
58
59impl std::fmt::Display for StreamShardError {
60    fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
61        match self {
62            Self::ZeroGpus => write!(
63                f,
64                "stream-shard allocator received zero GPUs. Fix: probe adapters before scheduling and fail configuration if none are visible."
65            ),
66            Self::IndexTooLarge { label } => write!(
67                f,
68                "stream-shard {label} cannot fit host usize. Fix: reduce GPU count or shard the scheduler."
69            ),
70            Self::IndexTooWide { label } => write!(
71                f,
72                "stream-shard {label} cannot fit u32. Fix: reduce GPU count or shard the scheduler."
73            ),
74            Self::DeviceOutOfRange { device, n_gpus } => write!(
75                f,
76                "stream-shard device {device} is outside live GPU count {n_gpus}. Fix: only seed load for probed GPU ordinals."
77            ),
78            Self::LoadOverflow => write!(
79                f,
80                "stream-shard GPU load overflowed u64. Fix: shard the stream or lower per-item cost before scheduling."
81            ),
82            Self::HeapLimitOverflow => write!(
83                f,
84                "stream-shard heap compaction threshold overflowed usize. Fix: recreate the allocator before continuing."
85            ),
86            Self::AllocationFailed { label } => write!(
87                f,
88                "stream-shard {label} allocation failed. Fix: split the stream batch or lower host memory pressure before scheduling."
89            ),
90        }
91    }
92}
93
94impl std::error::Error for StreamShardError {}
95
96/// Streaming shard allocator.
97///
98/// Callers feed `(key, cost)` pairs; the allocator returns the target device
99/// plus a running snapshot of per-device load. Initial landing is
100/// [`shard_by_blake3`]. If the target device's running cost exceeds the
101/// least-loaded device's cost by more than `spill_threshold`, the item spills
102/// to the least-loaded device.
103#[derive(Clone, Copy, Debug, Eq, PartialEq)]
104struct ShardHeapEntry {
105    cost: u64,
106    device: u32,
107}
108
109impl Ord for ShardHeapEntry {
110    fn cmp(&self, other: &Self) -> Ordering {
111        self.cost
112            .cmp(&other.cost)
113            .then_with(|| self.device.cmp(&other.device))
114    }
115}
116
117impl PartialOrd for ShardHeapEntry {
118    fn partial_cmp(&self, other: &Self) -> Option<Ordering> {
119        Some(self.cmp(other))
120    }
121}
122
123/// Streaming shard allocator.
124///
125/// Callers feed `(key, cost)` pairs; the allocator returns the target device
126/// plus a running snapshot of per-device load. Initial landing is
127/// [`shard_by_blake3`]. If the target device's running cost exceeds the
128/// least-loaded device's cost by more than `spill_threshold`, the item spills
129/// to the least-loaded device. Least-loaded selection is heap-backed and keeps
130/// stale heap entries compacted to GPU-count scale.
131pub struct StreamShardAllocator {
132    per_device_cost: Vec<u64>,
133    least_loaded: BinaryHeap<Reverse<ShardHeapEntry>>,
134    n_gpus: u32,
135    spill_threshold: u64,
136}
137
138impl StreamShardAllocator {
139    /// Create an allocator for `n_gpus` devices with an initial zero-cost load
140    /// vector.
141    pub fn new(n_gpus: u32, spill_threshold: u64) -> Result<Self, StreamShardError> {
142        if n_gpus == 0 {
143            return Err(StreamShardError::ZeroGpus);
144        }
145        let gpus = n_gpus;
146        let gpu_capacity = u32_to_usize(gpus, "GPU count")?;
147        let mut least_loaded = BinaryHeap::new();
148        vyre_foundation::allocation::try_reserve_binary_heap_to_capacity(
149            &mut least_loaded,
150            gpu_capacity,
151        )
152        .map_err(|_| StreamShardError::AllocationFailed {
153            label: "least-loaded heap",
154        })?;
155        for device in 0..gpus {
156            least_loaded.push(Reverse(ShardHeapEntry { cost: 0, device }));
157        }
158        let mut per_device_cost = Vec::new();
159        vyre_driver::allocation::try_reserve_vec_to_capacity(&mut per_device_cost, gpu_capacity)
160            .map_err(|_| StreamShardError::AllocationFailed {
161                label: "per-device cost vector",
162            })?;
163        per_device_cost.resize(gpu_capacity, 0);
164        Ok(Self {
165            per_device_cost,
166            least_loaded,
167            n_gpus: gpus,
168            spill_threshold,
169        })
170    }
171
172    /// Inject pre-existing load, such as already-queued work.
173    pub fn seed_load(&mut self, device: u32, cost: u64) -> Result<(), StreamShardError> {
174        let device_index = u32_to_usize(device, "device index")?;
175        let slot = self.per_device_cost.get_mut(device_index).ok_or(
176            StreamShardError::DeviceOutOfRange {
177                device,
178                n_gpus: self.n_gpus,
179            },
180        )?;
181        *slot = checked_load_add(*slot, cost)?;
182        ensure_heap_spare(&mut self.least_loaded, 1, "least-loaded heap update")?;
183        self.least_loaded.push(Reverse(ShardHeapEntry {
184            cost: *slot,
185            device,
186        }));
187        self.compact_heap_if_needed()?;
188        Ok(())
189    }
190
191    /// Assign one item.
192    ///
193    /// Returns the chosen device index, or `None` when `cost` is zero.
194    pub fn assign(&mut self, key: &[u8], cost: u64) -> Result<Option<u32>, StreamShardError> {
195        if cost == 0 {
196            return Ok(None);
197        }
198        let initial = u32_to_usize(shard_by_blake3(key, self.n_gpus)?, "initial device index")?;
199        let initial_cost = self.per_device_cost[initial];
200
201        let (least_idx, least_cost) =
202            self.least_loaded_device()?
203                .ok_or(StreamShardError::DeviceOutOfRange {
204                    device: 0,
205                    n_gpus: self.n_gpus,
206                })?;
207        let least_index = u32_to_usize(least_idx, "least-loaded device index")?;
208
209        let target =
210            if initial_cost > least_cost && initial_cost - least_cost > self.spill_threshold {
211                least_index
212            } else {
213                initial
214            };
215
216        self.per_device_cost[target] = checked_load_add(self.per_device_cost[target], cost)?;
217        ensure_heap_spare(&mut self.least_loaded, 1, "least-loaded heap update")?;
218        self.least_loaded.push(Reverse(ShardHeapEntry {
219            cost: self.per_device_cost[target],
220            device: usize_to_u32(target, "target device index")?,
221        }));
222        self.compact_heap_if_needed()?;
223        Ok(Some(usize_to_u32(target, "target device index")?))
224    }
225
226    /// Snapshot of per-device cost. Index = device id.
227    #[must_use]
228    pub fn load(&self) -> &[u64] {
229        &self.per_device_cost
230    }
231
232    fn least_loaded_device(&mut self) -> Result<Option<(u32, u64)>, StreamShardError> {
233        while let Some(Reverse(entry)) = self.least_loaded.peek().copied() {
234            let current = self
235                .per_device_cost
236                .get(u32_to_usize(entry.device, "heap device index")?)
237                .copied();
238            let Some(current) = current else {
239                self.least_loaded.pop();
240                continue;
241            };
242            if current == entry.cost {
243                return Ok(Some((entry.device, entry.cost)));
244            }
245            self.least_loaded.pop();
246        }
247        Ok(None)
248    }
249
250    fn compact_heap_if_needed(&mut self) -> Result<(), StreamShardError> {
251        let live = self.per_device_cost.len();
252        if self.least_loaded.len() <= stale_heap_limit(live)? {
253            return Ok(());
254        }
255        self.least_loaded.clear();
256        vyre_foundation::allocation::try_reserve_binary_heap_to_capacity(
257            &mut self.least_loaded,
258            live,
259        )
260        .map_err(|_| StreamShardError::AllocationFailed {
261            label: "least-loaded heap compaction",
262        })?;
263        for (device, &cost) in self.per_device_cost.iter().enumerate() {
264            self.least_loaded.push(Reverse(ShardHeapEntry {
265                cost,
266                device: usize_to_u32(device, "heap rebuild device index")?,
267            }));
268        }
269        Ok(())
270    }
271
272    #[cfg(test)]
273    fn heap_len_for_diagnostics(&self) -> usize {
274        self.least_loaded.len()
275    }
276}
277
278fn u32_to_usize(value: u32, label: &'static str) -> Result<usize, StreamShardError> {
279    usize::try_from(value).map_err(|_| StreamShardError::IndexTooLarge { label })
280}
281
282fn usize_to_u32(value: usize, label: &'static str) -> Result<u32, StreamShardError> {
283    u32::try_from(value).map_err(|_| StreamShardError::IndexTooWide { label })
284}
285
286fn checked_load_add(current: u64, cost: u64) -> Result<u64, StreamShardError> {
287    current
288        .checked_add(cost)
289        .ok_or(StreamShardError::LoadOverflow)
290}
291
292fn stale_heap_limit(live: usize) -> Result<usize, StreamShardError> {
293    Ok(live
294        .checked_mul(4)
295        .ok_or(StreamShardError::HeapLimitOverflow)?
296        .max(8))
297}
298
299fn ensure_heap_spare(
300    heap: &mut BinaryHeap<Reverse<ShardHeapEntry>>,
301    additional: usize,
302    label: &'static str,
303) -> Result<(), StreamShardError> {
304    let target_capacity = heap
305        .len()
306        .checked_add(additional)
307        .ok_or(StreamShardError::HeapLimitOverflow)?;
308    vyre_foundation::allocation::try_reserve_binary_heap_to_capacity(heap, target_capacity)
309        .map_err(|_| StreamShardError::AllocationFailed { label })
310}
311
312#[cfg(test)]
313mod tests {
314    use super::*;
315
316    #[test]
317    fn shard_by_blake3_is_deterministic() {
318        let key = b"src/foo.rs";
319        let a = shard_by_blake3(key, 4).expect("Fix: non-zero GPU count should shard");
320        let b = shard_by_blake3(key, 4).expect("Fix: non-zero GPU count should shard");
321        assert_eq!(a, b);
322        assert!(a < 4);
323    }
324
325    #[test]
326    fn shard_by_blake3_spreads_across_devices() {
327        let keys: Vec<Vec<u8>> = (0..128)
328            .map(|i| format!("src/file_{i}.rs").into_bytes())
329            .collect();
330        let mut hits = [0u32; 4];
331        for k in &keys {
332            hits[shard_by_blake3(k, 4).expect("Fix: non-zero GPU count should shard") as usize] +=
333                1;
334        }
335        for h in &hits {
336            assert!(*h > 0, "blake3 sharding must hit every device: {hits:?}");
337        }
338    }
339
340    #[test]
341    fn shard_by_blake3_n_zero_returns_error_instead_of_faking_device_zero() {
342        let error = shard_by_blake3(b"anything", 0)
343            .expect_err("zero visible GPUs must be a configuration failure");
344        let message = error.to_string();
345        assert!(
346            message.contains("zero GPUs") && message.contains("probe adapters"),
347            "zero-GPU sharding failure must explain the configuration fix: {message}"
348        );
349    }
350
351    #[test]
352    fn stream_allocator_initial_placement_matches_hash() {
353        let mut allocator =
354            StreamShardAllocator::new(4, 100).expect("Fix: non-zero GPU count should construct");
355        let key = b"cold/file.bin";
356        let initial = shard_by_blake3(key, 4).expect("Fix: non-zero GPU count should shard");
357        let assigned = allocator
358            .assign(key, 10)
359            .expect("Fix: stream sharding should not overflow")
360            .expect("Fix: non-zero cost accepted; restore this invariant before continuing.");
361        assert_eq!(assigned, initial);
362        assert_eq!(allocator.load()[initial as usize], 10);
363    }
364
365    #[test]
366    fn stream_allocator_rejects_zero_cost() {
367        let mut allocator =
368            StreamShardAllocator::new(2, 0).expect("Fix: non-zero GPU count should construct");
369        assert!(allocator
370            .assign(b"x", 0)
371            .expect("Fix: zero-cost assignment should not overflow")
372            .is_none());
373    }
374
375    #[test]
376    fn stream_allocator_spills_when_imbalance_exceeds_threshold() {
377        let mut allocator =
378            StreamShardAllocator::new(2, 5).expect("Fix: non-zero GPU count should construct");
379        let mut key = vec![0u8; 4];
380        while shard_by_blake3(&key, 2).expect("Fix: non-zero GPU count should shard") != 0 {
381            key[0] = key[0].wrapping_add(1);
382        }
383        allocator
384            .seed_load(0, 100)
385            .expect("Fix: seed load should fit");
386
387        let target = allocator
388            .assign(&key, 1)
389            .expect("Fix: stream sharding should not overflow")
390            .expect("Fix: assigned; restore this invariant before continuing.");
391        assert_eq!(target, 1, "heavy initial must spill to least-loaded");
392    }
393
394    #[test]
395    fn stream_allocator_stays_affine_under_threshold() {
396        let mut allocator =
397            StreamShardAllocator::new(2, 100).expect("Fix: non-zero GPU count should construct");
398        let mut key = vec![0u8; 4];
399        while shard_by_blake3(&key, 2).expect("Fix: non-zero GPU count should shard") != 0 {
400            key[0] = key[0].wrapping_add(1);
401        }
402        allocator
403            .seed_load(0, 50)
404            .expect("Fix: seed load should fit");
405        let target = allocator
406            .assign(&key, 1)
407            .expect("Fix: stream sharding should not overflow")
408            .expect("Fix: assigned; restore this invariant before continuing.");
409        assert_eq!(target, 0, "affinity wins when imbalance <= spill_threshold");
410    }
411
412    #[test]
413    fn stream_allocator_load_monotone() {
414        let mut allocator =
415            StreamShardAllocator::new(3, 0).expect("Fix: non-zero GPU count should construct");
416        for i in 0..30 {
417            let key = format!("path{i}").into_bytes();
418            allocator
419                .assign(&key, 1)
420                .expect("Fix: stream sharding should not overflow")
421                .expect("Fix: assigned; restore this invariant before continuing.");
422        }
423        let total: u64 = allocator.load().iter().sum();
424        assert_eq!(total, 30, "every assignment must bump total load by cost");
425    }
426
427    #[test]
428    fn stream_allocator_heap_compacts_stale_updates_to_gpu_count_scale() {
429        let mut allocator =
430            StreamShardAllocator::new(4, 0).expect("Fix: non-zero GPU count should construct");
431        for i in 0..128 {
432            allocator
433                .assign(format!("path{i}").as_bytes(), 1)
434                .expect("Fix: stream sharding should not overflow")
435                .expect("Fix: non-zero work must assign to a GPU");
436        }
437        let load_before = allocator.load().to_vec();
438
439        allocator
440            .assign(b"trigger-stale-pop", 1)
441            .expect("Fix: stream sharding should not overflow")
442            .expect("Fix: non-zero work must assign to a GPU");
443
444        assert_eq!(
445            allocator.load().iter().sum::<u64>(),
446            load_before.iter().sum::<u64>() + 1,
447            "Fix: heap-backed assignment must preserve exact load accounting"
448        );
449        assert!(
450            allocator.heap_len_for_diagnostics() <= allocator.load().len() * 4,
451            "Fix: stale heap entries must be compacted to GPU-count scale instead of stream-length scale"
452        );
453    }
454
455    #[test]
456    fn stream_shard_source_has_no_release_path_infallible_allocation() {
457        let source = include_str!("stream_shard.rs");
458        let production = source
459            .split("#[cfg(test)]")
460            .next()
461            .expect("Fix: stream-shard production source must precede tests");
462        assert!(
463            !production.contains("BinaryHeap::with_capacity")
464                && !production.contains("vec![0u64; gpu_capacity]")
465                && !production.contains(".reserve_exact("),
466            "Fix: WGPU stream sharding must report allocation pressure instead of aborting on infallible scheduler allocation."
467        );
468        assert!(
469            production.contains("try_reserve_binary_heap_to_capacity")
470                && production.contains("try_reserve_vec_to_capacity")
471                && production.contains("ensure_heap_spare")
472                && production.contains("AllocationFailed"),
473            "Fix: WGPU stream sharding must reserve scheduler state fallibly before GPU assignment."
474        );
475    }
476}