vyre_driver_wgpu/engine/multi_gpu/
stream_shard.rs1use std::cmp::{Ordering, Reverse};
2use std::collections::BinaryHeap;
3
4pub 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#[derive(Debug, Clone, Copy, Eq, PartialEq)]
28pub enum StreamShardError {
29 ZeroGpus,
31 IndexTooLarge {
33 label: &'static str,
35 },
36 IndexTooWide {
38 label: &'static str,
40 },
41 DeviceOutOfRange {
43 device: u32,
45 n_gpus: u32,
47 },
48 LoadOverflow,
50 HeapLimitOverflow,
52 AllocationFailed {
54 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#[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
123pub 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 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 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 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 #[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}