1use crate::memory::{Arena, MemTag};
13use alloc::vec;
14use alloc::vec::Vec;
15
16#[derive(Clone, Copy, PartialEq, Eq, Debug)]
18pub enum StreamState {
19 Unloaded,
21 Pending,
23 Resident,
25}
26
27#[derive(Clone, Copy, Debug)]
28struct Item {
29 state: StreamState,
30 score: f32,
34 last_touch: u64,
37 bytes: u64,
42 blocked: bool,
45}
46
47#[derive(Debug, Default, PartialEq, Eq)]
49pub struct StreamPlan {
50 pub to_load: Vec<usize>,
52 pub to_evict: Vec<usize>,
54}
55
56pub struct StreamPlanner {
65 items: Vec<Item>,
66 load_budget: usize,
68 resident_cap: usize,
71 byte_budget: Option<u64>,
76 scratch: Arena,
80}
81
82fn scratch_bytes(count: usize) -> usize {
84 (2 * count * size_of::<usize>()).max(size_of::<usize>())
85}
86
87impl StreamPlanner {
88 pub fn new(count: usize, load_budget: usize, resident_cap: usize) -> Self {
93 Self {
94 items: vec![
95 Item {
96 state: StreamState::Unloaded,
97 score: 0.0,
98 last_touch: 0,
99 bytes: 0,
100 blocked: false,
101 };
102 count
103 ],
104 load_budget: load_budget.max(1),
105 resident_cap: resident_cap.max(1),
106 byte_budget: None,
107 scratch: Arena::tagged(scratch_bytes(count), MemTag::Scratch),
108 }
109 }
110
111 pub fn set_byte_budget(&mut self, budget: Option<u64>) {
116 self.byte_budget = budget;
117 }
118
119 pub fn byte_budget(&self) -> Option<u64> {
122 self.byte_budget
123 }
124
125 pub fn len(&self) -> usize {
127 self.items.len()
128 }
129
130 pub fn is_empty(&self) -> bool {
132 self.items.is_empty()
133 }
134
135 pub fn state(&self, id: usize) -> Option<StreamState> {
137 self.items.get(id).map(|i| i.state)
138 }
139
140 pub fn set_score(&mut self, id: usize, score: f32) {
143 if let Some(item) = self.items.get_mut(id) {
144 item.score = score;
145 }
146 }
147
148 pub fn touch(&mut self, id: usize, frame: u64) {
151 if let Some(item) = self.items.get_mut(id) {
152 item.last_touch = frame;
153 }
154 }
155
156 pub fn mark_resident(&mut self, id: usize, frame: u64, bytes: u64) {
162 if let Some(item) = self.items.get_mut(id) {
163 item.state = StreamState::Resident;
164 item.last_touch = frame;
165 item.bytes = bytes;
166 }
167 }
168
169 pub fn set_blocked(&mut self, id: usize, blocked: bool) {
173 if let Some(item) = self.items.get_mut(id) {
174 item.blocked = blocked;
175 }
176 }
177
178 pub fn mark_unloaded(&mut self, id: usize) {
183 if let Some(item) = self.items.get_mut(id) {
184 item.state = StreamState::Unloaded;
185 }
186 }
187
188 pub fn resident_bytes(&self) -> u64 {
191 self.items
192 .iter()
193 .filter(|it| it.state == StreamState::Resident)
194 .map(|it| it.bytes)
195 .sum()
196 }
197
198 pub fn counts(&self) -> (usize, usize, usize) {
200 let mut resident = 0;
201 let mut pending = 0;
202 let mut unloaded = 0;
203 for item in &self.items {
204 match item.state {
205 StreamState::Resident => resident += 1,
206 StreamState::Pending => pending += 1,
207 StreamState::Unloaded => unloaded += 1,
208 }
209 }
210 (resident, pending, unloaded)
211 }
212
213 pub fn plan(&mut self) -> StreamPlan {
229 let mut plan = StreamPlan::default();
230
231 for (id, item) in self.items.iter_mut().enumerate() {
235 if item.blocked && item.state == StreamState::Resident {
236 item.state = StreamState::Unloaded;
237 plan.to_evict.push(id);
238 }
239 }
240
241 self.scratch.reset();
245
246 let mut candidates = self
248 .scratch
249 .vec::<usize>(self.items.len())
250 .expect("scratch is sized for every item");
251 candidates.extend(
252 self.items
253 .iter()
254 .enumerate()
255 .filter(|(_, it)| it.state == StreamState::Unloaded && !it.blocked)
256 .map(|(id, _)| id),
257 );
258 if candidates.is_empty() {
259 return plan;
260 }
261 candidates.sort_unstable_by(|&a, &b| {
264 self.items[a]
265 .score
266 .partial_cmp(&self.items[b].score)
267 .unwrap_or(core::cmp::Ordering::Equal)
268 .then(a.cmp(&b))
269 });
270
271 let mut residents = self
279 .scratch
280 .vec::<usize>(self.items.len())
281 .expect("scratch is sized for every item");
282 residents.extend(
283 self.items
284 .iter()
285 .enumerate()
286 .filter(|(_, it)| it.state == StreamState::Resident)
287 .map(|(id, _)| id),
288 );
289 residents.sort_unstable_by(|&a, &b| {
290 let (ia, ib) = (&self.items[a], &self.items[b]);
291 ib.score
292 .partial_cmp(&ia.score)
293 .unwrap_or(core::cmp::Ordering::Equal)
294 .then(ia.last_touch.cmp(&ib.last_touch))
295 .then(a.cmp(&b))
296 });
297
298 let mut occ = self.occupied();
303 let mut resident_bytes = self.resident_bytes();
304 let mut evicted = 0usize;
306
307 for &id in candidates.iter() {
308 if plan.to_load.len() >= self.load_budget {
309 break;
310 }
311 let cand_score = self.items[id].score;
312 let cand_bytes = self.items[id].bytes;
313
314 let mut tent_occ = occ;
320 let mut tent_bytes = resident_bytes;
321 let mut cursor = evicted;
322 let fits = loop {
323 let count_ok = tent_occ < self.resident_cap;
324 let byte_ok = self
325 .byte_budget
326 .is_none_or(|b| tent_bytes + cand_bytes <= b);
327 if count_ok && byte_ok {
328 break true;
329 }
330 match residents.get(cursor) {
331 Some(&victim) if self.items[victim].score > cand_score => {
332 tent_occ -= 1;
333 tent_bytes -= self.items[victim].bytes;
334 cursor += 1;
335 }
336 _ => break false,
339 }
340 };
341
342 if fits {
343 for &victim in &residents[evicted..cursor] {
344 self.items[victim].state = StreamState::Unloaded;
345 plan.to_evict.push(victim);
346 }
347 evicted = cursor;
348 self.items[id].state = StreamState::Pending;
349 plan.to_load.push(id);
350 occ = tent_occ + 1;
354 resident_bytes = tent_bytes;
355 } else if self.byte_budget.is_none() {
356 break;
359 }
360 }
363
364 plan
365 }
366
367 fn occupied(&self) -> usize {
369 self.items
370 .iter()
371 .filter(|it| it.state != StreamState::Unloaded)
372 .count()
373 }
374}
375
376#[cfg(test)]
377mod tests {
378 use super::*;
379
380 #[test]
381 fn new_planner_has_all_items_unloaded() {
382 let p = StreamPlanner::new(3, 4, 8);
383 assert_eq!(p.len(), 3);
384 for id in 0..3 {
385 assert_eq!(p.state(id), Some(StreamState::Unloaded));
386 }
387 assert_eq!(p.state(3), None);
388 assert_eq!(p.counts(), (0, 0, 3));
389 }
390
391 #[test]
395 fn the_scratch_reservation_covers_the_worst_frame() {
396 const COUNT: usize = 32;
397 let mut p = StreamPlanner::new(COUNT, COUNT, COUNT);
398 let plan = p.plan();
399 assert_eq!(plan.to_load.len(), COUNT, "every item is a candidate");
400 for id in 0..COUNT {
401 p.mark_resident(id, 0, 1);
402 }
403 let _ = p.plan();
404
405 assert!(p.scratch.peak() <= p.scratch.capacity());
406 assert_eq!(p.scratch.capacity(), scratch_bytes(COUNT));
407 }
408
409 #[test]
412 fn repeated_planning_reuses_the_same_reservation() {
413 let mut p = StreamPlanner::new(16, 4, 8);
414 let capacity = p.scratch.capacity();
415 for frame in 0..16u64 {
416 for id in 0..16 {
417 p.set_score(id, ((id as u64 + frame) % 16) as f32);
418 }
419 let _ = p.plan();
420 }
421 assert_eq!(p.scratch.capacity(), capacity);
422 assert!(p.scratch.peak() <= capacity);
423 }
424
425 #[test]
426 fn zero_budget_and_cap_are_clamped_to_one() {
427 let mut p = StreamPlanner::new(2, 0, 0);
428 let plan = p.plan();
429 assert_eq!(plan.to_load.len(), 1);
431 }
432
433 #[test]
434 fn plan_loads_nearest_items_first_within_budget() {
435 let mut p = StreamPlanner::new(4, 2, 8);
436 p.set_score(0, 30.0);
437 p.set_score(1, 10.0);
438 p.set_score(2, 20.0);
439 p.set_score(3, 40.0);
440 let plan = p.plan();
441 assert_eq!(plan.to_load, vec![1, 2]);
443 assert!(plan.to_evict.is_empty());
444 assert_eq!(p.state(1), Some(StreamState::Pending));
445 assert_eq!(p.state(2), Some(StreamState::Pending));
446 assert_eq!(p.state(0), Some(StreamState::Unloaded));
447 }
448
449 #[test]
450 fn pending_items_are_not_re_dispatched() {
451 let mut p = StreamPlanner::new(3, 1, 8);
452 let first = p.plan();
453 assert_eq!(first.to_load.len(), 1);
454 let dispatched = first.to_load[0];
455 let second = p.plan();
456 assert!(!second.to_load.contains(&dispatched));
457 }
458
459 #[test]
460 fn resident_cap_blocks_loading_when_no_eviction_is_worthwhile() {
461 let mut p = StreamPlanner::new(3, 4, 2);
462 p.set_score(0, 1.0);
464 p.set_score(1, 2.0);
465 p.set_score(2, 99.0);
466 let plan = p.plan();
467 assert_eq!(plan.to_load, vec![0, 1]);
468 p.mark_resident(0, 1, 0);
469 p.mark_resident(1, 1, 0);
470 let plan = p.plan();
472 assert!(plan.to_load.is_empty());
473 assert!(plan.to_evict.is_empty());
474 }
475
476 #[test]
477 fn closer_candidate_evicts_a_farther_resident() {
478 let mut p = StreamPlanner::new(3, 4, 2);
479 p.set_score(0, 50.0);
480 p.set_score(1, 60.0);
481 p.set_score(2, 99.0);
482 let plan = p.plan();
483 assert_eq!(plan.to_load, vec![0, 1]);
484 p.mark_resident(0, 1, 0);
485 p.mark_resident(1, 1, 0);
486 p.set_score(2, 10.0);
488 let plan = p.plan();
489 assert_eq!(plan.to_load, vec![2]);
490 assert_eq!(plan.to_evict, vec![1]);
491 assert_eq!(p.state(1), Some(StreamState::Unloaded));
492 assert_eq!(p.state(2), Some(StreamState::Pending));
493 }
494
495 #[test]
496 fn eviction_breaks_score_ties_toward_least_recently_touched() {
497 let mut p = StreamPlanner::new(3, 4, 2);
498 p.set_score(0, 5.0);
499 p.set_score(1, 5.0);
500 p.set_score(2, 99.0); let plan = p.plan();
502 assert_eq!(plan.to_load, vec![0, 1]);
503 p.mark_resident(0, 1, 0);
504 p.mark_resident(1, 1, 0);
505 p.touch(0, 100);
507 p.touch(1, 50);
508 p.set_score(2, 1.0);
510 let plan = p.plan();
511 assert_eq!(plan.to_evict, vec![1]);
512 }
513
514 #[test]
515 fn counts_track_state_transitions() {
516 let mut p = StreamPlanner::new(3, 1, 8);
517 assert_eq!(p.counts(), (0, 0, 3));
518 let plan = p.plan();
519 let id = plan.to_load[0];
520 assert_eq!(p.counts(), (0, 1, 2));
521 p.mark_resident(id, 1, 0);
522 assert_eq!(p.counts(), (1, 0, 2));
523 p.mark_unloaded(id);
524 assert_eq!(p.counts(), (0, 0, 3));
525 }
526
527 #[test]
528 fn empty_planner_plans_nothing() {
529 let mut p = StreamPlanner::new(0, 4, 8);
530 assert_eq!(p.len(), 0);
531 assert_eq!(p.plan(), StreamPlan::default());
532 }
533
534 #[test]
535 fn blocked_item_is_never_scheduled_to_load() {
536 let mut p = StreamPlanner::new(2, 4, 8);
537 p.set_score(0, 1.0);
538 p.set_score(1, 2.0);
539 p.set_blocked(0, true);
540 let plan = p.plan();
541 assert_eq!(plan.to_load, vec![1]);
542 assert_eq!(p.state(0), Some(StreamState::Unloaded));
543 }
544
545 #[test]
546 fn blocked_resident_is_evicted_unconditionally() {
547 let mut p = StreamPlanner::new(2, 4, 8);
548 p.mark_resident(0, 1, 100);
549 p.mark_resident(1, 1, 100);
550 p.set_blocked(0, true);
551 let plan = p.plan();
552 assert_eq!(plan.to_evict, vec![0]);
553 assert!(plan.to_load.is_empty(), "blocked item must not reload");
554 assert_eq!(p.state(0), Some(StreamState::Unloaded));
555 assert_eq!(p.state(1), Some(StreamState::Resident));
556 assert_eq!(p.resident_bytes(), 100);
557 }
558
559 #[test]
560 fn unblocking_makes_an_item_loadable_again() {
561 let mut p = StreamPlanner::new(1, 4, 8);
562 p.set_blocked(0, true);
563 assert!(p.plan().to_load.is_empty());
564 p.set_blocked(0, false);
565 assert_eq!(p.plan().to_load, vec![0]);
566 }
567
568 #[test]
569 fn blocked_pending_item_is_evicted_after_its_load_completes() {
570 let mut p = StreamPlanner::new(1, 4, 8);
571 let plan = p.plan();
572 assert_eq!(plan.to_load, vec![0]);
573 p.set_blocked(0, true);
575 assert_eq!(p.plan(), StreamPlan::default());
576 p.mark_resident(0, 2, 64);
578 assert_eq!(p.plan().to_evict, vec![0]);
579 }
580
581 fn seed_bytes(p: &mut StreamPlanner, id: usize, bytes: u64) {
585 p.mark_resident(id, 0, bytes);
586 p.mark_unloaded(id);
587 }
588
589 #[test]
590 fn byte_budget_accessor_reflects_set_and_clear() {
591 let mut p = StreamPlanner::new(1, 4, 8);
592 assert_eq!(p.byte_budget(), None);
593 p.set_byte_budget(Some(4096));
594 assert_eq!(p.byte_budget(), Some(4096));
595 p.set_byte_budget(None);
596 assert_eq!(p.byte_budget(), None);
597 }
598
599 #[test]
600 fn resident_bytes_sums_resident_items_only() {
601 let mut p = StreamPlanner::new(3, 4, 8);
602 assert_eq!(p.resident_bytes(), 0);
603 p.mark_resident(0, 1, 100);
604 p.mark_resident(1, 1, 250);
605 assert_eq!(p.resident_bytes(), 350);
606 p.mark_unloaded(0);
608 assert_eq!(p.resident_bytes(), 250);
609 }
610
611 #[test]
612 fn no_byte_budget_ignores_item_bytes() {
613 let mut p = StreamPlanner::new(3, 4, 8);
615 p.set_score(0, 5.0);
616 p.set_score(1, 6.0);
617 p.mark_resident(0, 1, 10_000);
618 p.mark_resident(1, 1, 10_000);
619 seed_bytes(&mut p, 2, 10_000);
620 p.set_score(2, 7.0);
621 let plan = p.plan();
622 assert_eq!(plan.to_load, vec![2]);
624 assert!(plan.to_evict.is_empty());
625 }
626
627 #[test]
628 fn byte_budget_evicts_worst_scored_resident_to_fit() {
629 let mut p = StreamPlanner::new(3, 4, 100);
631 p.set_byte_budget(Some(100));
632 p.set_score(0, 5.0);
634 p.set_score(1, 80.0);
635 p.mark_resident(0, 1, 50);
636 p.mark_resident(1, 1, 50);
637 assert_eq!(p.resident_bytes(), 100);
638 seed_bytes(&mut p, 2, 50);
640 p.set_score(2, 10.0);
641 let plan = p.plan();
642 assert_eq!(plan.to_evict, vec![1]);
644 assert_eq!(plan.to_load, vec![2]);
645 }
646
647 #[test]
648 fn count_cap_binds_first_for_small_items() {
649 let mut p = StreamPlanner::new(3, 4, 2);
651 p.set_byte_budget(Some(10_000));
652 p.set_score(0, 10.0);
653 p.set_score(1, 20.0);
654 p.set_score(2, 99.0);
655 let plan = p.plan();
656 assert_eq!(plan.to_load, vec![0, 1]);
657 p.mark_resident(0, 1, 8);
658 p.mark_resident(1, 1, 8);
659 seed_bytes(&mut p, 2, 8);
662 p.set_score(2, 15.0);
663 let plan = p.plan();
664 assert_eq!(plan.to_evict, vec![1]);
665 assert_eq!(plan.to_load, vec![2]);
666 assert_eq!(p.resident_bytes(), 8); }
668
669 #[test]
670 fn byte_budget_binds_first_for_large_items() {
671 let mut p = StreamPlanner::new(3, 4, 100);
673 p.set_byte_budget(Some(100));
674 p.set_score(0, 10.0);
675 p.set_score(1, 20.0);
676 p.mark_resident(0, 1, 50);
677 p.mark_resident(1, 1, 50); seed_bytes(&mut p, 2, 50);
679 p.set_score(2, 15.0);
680 let plan = p.plan();
681 assert_eq!(plan.to_evict, vec![1]);
683 assert_eq!(plan.to_load, vec![2]);
684 assert!(p.resident_bytes() + 50 <= 100);
685 }
686
687 #[test]
688 fn large_candidate_evicts_multiple_farther_residents_but_not_a_closer_one() {
689 let mut p = StreamPlanner::new(5, 4, 100);
690 p.set_byte_budget(Some(100));
691 p.set_score(0, 5.0);
693 p.set_score(1, 30.0);
694 p.set_score(2, 40.0);
695 p.set_score(3, 50.0);
696 p.mark_resident(0, 1, 20);
697 p.mark_resident(1, 1, 20);
698 p.mark_resident(2, 1, 20);
699 p.mark_resident(3, 1, 20);
700 assert_eq!(p.resident_bytes(), 80);
701 seed_bytes(&mut p, 4, 60);
705 p.set_score(4, 10.0);
706 let plan = p.plan();
707 assert_eq!(plan.to_load, vec![4]);
708 assert_eq!(plan.to_evict, vec![3, 2]);
709 assert_eq!(p.state(0), Some(StreamState::Resident));
710 assert_eq!(p.state(1), Some(StreamState::Resident));
711 assert!(p.resident_bytes() + 60 <= 100);
712 }
713
714 #[test]
715 fn byte_budget_does_not_evict_a_resident_closer_than_the_candidate() {
716 let mut p = StreamPlanner::new(2, 4, 100);
717 p.set_byte_budget(Some(50));
718 p.set_score(0, 5.0);
720 p.mark_resident(0, 1, 50);
721 seed_bytes(&mut p, 1, 50);
723 p.set_score(1, 99.0);
724 let plan = p.plan();
725 assert!(plan.to_load.is_empty());
726 assert!(plan.to_evict.is_empty());
727 assert_eq!(p.state(0), Some(StreamState::Resident));
728 }
729
730 fn worst_resident_ref(
734 items: &[Item],
735 excluded: &[usize],
736 tentative: &[usize],
737 ) -> Option<usize> {
738 let mut worst: Option<usize> = None;
739 for (id, item) in items.iter().enumerate() {
740 if item.state != StreamState::Resident
741 || excluded.contains(&id)
742 || tentative.contains(&id)
743 {
744 continue;
745 }
746 match worst {
747 None => worst = Some(id),
748 Some(w) => {
749 let better = item.score > items[w].score
750 || (item.score == items[w].score && item.last_touch < items[w].last_touch);
751 if better {
752 worst = Some(id);
753 }
754 }
755 }
756 }
757 worst
758 }
759
760 fn plan_reference(
763 items: &mut [Item],
764 load_budget: usize,
765 resident_cap: usize,
766 byte_budget: Option<u64>,
767 ) -> StreamPlan {
768 let mut plan = StreamPlan::default();
769 let mut candidates: Vec<usize> = items
770 .iter()
771 .enumerate()
772 .filter(|(_, it)| it.state == StreamState::Unloaded)
773 .map(|(id, _)| id)
774 .collect();
775 candidates.sort_by(|&a, &b| {
776 items[a]
777 .score
778 .partial_cmp(&items[b].score)
779 .unwrap_or(core::cmp::Ordering::Equal)
780 });
781 for &id in &candidates {
782 if plan.to_load.len() >= load_budget {
783 break;
784 }
785 let cand_score = items[id].score;
786 let cand_bytes = items[id].bytes;
787 let mut occ = items
788 .iter()
789 .filter(|it| it.state != StreamState::Unloaded)
790 .count();
791 let mut resident_bytes: u64 = items
792 .iter()
793 .filter(|it| it.state == StreamState::Resident)
794 .map(|it| it.bytes)
795 .sum();
796 let mut victims: Vec<usize> = Vec::new();
797 let fits = loop {
798 let count_ok = occ < resident_cap;
799 let byte_ok = byte_budget.is_none_or(|b| resident_bytes + cand_bytes <= b);
800 if count_ok && byte_ok {
801 break true;
802 }
803 match worst_resident_ref(items, &plan.to_evict, &victims) {
804 Some(victim) if items[victim].score > cand_score => {
805 occ -= 1;
806 resident_bytes -= items[victim].bytes;
807 victims.push(victim);
808 }
809 _ => break false,
810 }
811 };
812 if fits {
813 for &victim in &victims {
814 items[victim].state = StreamState::Unloaded;
815 plan.to_evict.push(victim);
816 }
817 items[id].state = StreamState::Pending;
818 plan.to_load.push(id);
819 } else if byte_budget.is_none() {
820 break;
821 }
822 }
823 plan
824 }
825
826 #[test]
830 fn plan_matches_reference_on_random_scenarios() {
831 let mut seed: u64 = 0x1234_5678_9abc_def0;
833 let mut next = || {
834 seed = seed
835 .wrapping_mul(6364136223846793005)
836 .wrapping_add(1442695040888963407);
837 (seed >> 33) as u32
838 };
839
840 for _ in 0..4000 {
841 let count = (next() % 12) as usize;
842 let items: Vec<Item> = (0..count)
843 .map(|_| {
844 let state = match next() % 3 {
845 0 => StreamState::Unloaded,
846 1 => StreamState::Pending,
847 _ => StreamState::Resident,
848 };
849 Item {
850 state,
851 score: (next() % 6) as f32,
853 last_touch: (next() % 4) as u64,
854 bytes: (next() % 20) as u64,
855 blocked: false,
856 }
857 })
858 .collect();
859 let load_budget = ((next() % 5) + 1) as usize;
860 let resident_cap = ((next() % 8) + 1) as usize;
861 let byte_budget = if next() % 2 == 0 {
862 None
863 } else {
864 Some((next() % 60) as u64)
865 };
866
867 let mut ref_items = items.clone();
868 let ref_plan = plan_reference(&mut ref_items, load_budget, resident_cap, byte_budget);
869
870 let mut p = StreamPlanner {
871 items: items.clone(),
872 load_budget,
873 resident_cap,
874 byte_budget,
875 scratch: Arena::with_capacity(scratch_bytes(items.len())),
876 };
877 let got = p.plan();
878
879 assert_eq!(got, ref_plan, "plan differs (count={count})");
880 for (i, (a, b)) in p.items.iter().zip(ref_items.iter()).enumerate() {
881 assert_eq!(a.state, b.state, "state[{i}] differs (count={count})");
882 }
883 }
884 }
885}