Skip to main content
← PSoXide SDK guides and examples · SDK f2fec1c301f1 · All Cargo features enabled; check feature gates before use.

psx_residency_sim/
churn.rs

1//! Fragmentation under churn: how a pool behaves when variable-length runs
2//! come and go.
3//!
4//! Measured on the host with the synthetic workload of the `churn_table` test
5//! (256-page pool, runs of 2 to 40 pages uniformly, 4000 allocations per
6//! seed with random victims freed until each fits, averaged over seeds 1 to 8;
7//! this is a stress shape, not a game trace):
8//!
9//! | policy | blocked by fragmentation | mean free-space fragmentation | mean pool use |
10//! | --- | --- | --- | --- |
11//! | first-fit | 1846 of 4000 | 558 permille | 811 permille |
12//! | best-fit | 1732 of 4000 | 520 permille | 823 permille |
13//! | first-fit + compaction | 0 | 107 permille | 946 permille |
14//! | best-fit + compaction | 0 | 103 permille | 946 permille |
15//!
16//! Placement policy matters little; compaction (about 450 000 pages moved per
17//! 4000 allocations here) is what turns scattered free pages back into usable
18//! ones. That is why the engine compacts movable categories before it evicts,
19//! and why pinned and in-flight runs, which cannot move, are the cost.
20
21use alloc::vec::Vec;
22use psx_residency::{PagePool, Placement};
23
24/// A churn experiment.
25#[derive(Copy, Clone, Debug)]
26pub struct ChurnConfig {
27    /// Pool size in pages.
28    pub pages: u32,
29    /// Smallest and largest run, in pages (uniform).
30    pub run_pages: (u32, u32),
31    /// Operations to perform. Each allocates one run; when the pool is too
32    /// full to place it, random live runs are freed until it fits.
33    pub operations: u32,
34    /// Placement policy.
35    pub placement: Placement,
36    /// Whether a failed placement first tries compaction (one run per step,
37    /// as the engine does) before freeing runs.
38    pub compact: bool,
39    /// Generator seed.
40    pub seed: u64,
41}
42
43/// What a churn run found.
44#[derive(Copy, Clone, Debug, Default, PartialEq, Eq)]
45pub struct ChurnReport {
46    /// Placements attempted.
47    pub allocations: u32,
48    /// Placements that failed although enough pages were free in total, after
49    /// compaction if it is on: the definition of fragmentation loss.
50    pub fragmentation_failures: u32,
51    /// Runs freed to make room (evictions).
52    pub forced_frees: u32,
53    /// Pages moved by compaction.
54    pub pages_moved: u64,
55    /// Mean free-space fragmentation after each operation, in permille.
56    pub mean_fragmentation_permille: u32,
57    /// Worst fragmentation seen, in permille.
58    pub max_fragmentation_permille: u32,
59    /// Mean share of the pool in use after each operation, in permille.
60    pub mean_utilization_permille: u32,
61}
62
63/// Run the experiment. Deterministic for a given configuration.
64pub fn churn_report(config: ChurnConfig) -> ChurnReport {
65    let mut pool = PagePool::<128>::new(0, config.pages, config.placement);
66    let mut live = Vec::new();
67    let mut rng = config.seed | 1;
68    let mut next = move || {
69        rng ^= rng << 13;
70        rng ^= rng >> 7;
71        rng ^= rng << 17;
72        (rng >> 11) as u32
73    };
74    let (low, high) = config.run_pages;
75    let mut report = ChurnReport::default();
76    let mut fragmentation_sum = 0u64;
77    let mut utilization_sum = 0u64;
78    for _ in 0..config.operations {
79        let pages = low + next() % (high - low + 1);
80        report.allocations += 1;
81        let mut counted = false;
82        loop {
83            if pool.live_count() < 128 {
84                if let Some((handle, _)) = pool.allocate(pages) {
85                    live.push(handle);
86                    break;
87                }
88            }
89            if config.compact {
90                if let Some(moved) = pool.compact_step(|_, _| {}) {
91                    report.pages_moved += moved.from.page_count as u64;
92                    continue;
93                }
94            }
95            if !counted && pool.free_pages() >= pages {
96                report.fragmentation_failures += 1;
97                counted = true;
98            }
99            // Evict a random live run.
100            let victim = next() as usize % live.len();
101            let handle = live.swap_remove(victim);
102            pool.free(handle);
103            report.forced_frees += 1;
104        }
105        let shape = pool.fragmentation();
106        fragmentation_sum += shape.permille() as u64;
107        report.max_fragmentation_permille = report.max_fragmentation_permille.max(shape.permille());
108        utilization_sum += pool.used_pages() as u64 * 1000 / config.pages as u64;
109    }
110    let operations = config.operations.max(1) as u64;
111    report.mean_fragmentation_permille = (fragmentation_sum / operations) as u32;
112    report.mean_utilization_permille = (utilization_sum / operations) as u32;
113    report
114}
115
116#[cfg(test)]
117mod tests {
118    use super::*;
119
120    fn run(placement: Placement, compact: bool, seed: u64) -> ChurnReport {
121        churn_report(ChurnConfig {
122            pages: 256,
123            run_pages: (2, 40),
124            operations: 4_000,
125            placement,
126            compact,
127            seed,
128        })
129    }
130
131    #[test]
132    #[cfg_attr(miri, ignore)] // exhaustive sweep, runs natively
133    fn churn_is_deterministic() {
134        assert_eq!(
135            run(Placement::BestFit, false, 7),
136            run(Placement::BestFit, false, 7)
137        );
138    }
139
140    #[test]
141    #[cfg_attr(miri, ignore)] // exhaustive sweep, runs natively
142    fn compaction_removes_fragmentation_failures() {
143        for seed in 1..=5 {
144            let without = run(Placement::FirstFit, false, seed);
145            let with = run(Placement::FirstFit, true, seed);
146            assert!(without.fragmentation_failures > 0, "{without:?}");
147            // With compaction a placement fails only when the pool is truly
148            // short of pages, never because they are scattered.
149            assert_eq!(with.fragmentation_failures, 0, "{with:?}");
150            assert!(with.mean_utilization_permille >= without.mean_utilization_permille);
151            assert!(with.pages_moved > 0);
152        }
153    }
154
155    #[test]
156    #[cfg_attr(miri, ignore)] // exhaustive sweep, runs natively
157    fn best_fit_and_first_fit_both_keep_the_pool_busy() {
158        for placement in [Placement::FirstFit, Placement::BestFit] {
159            let report = run(placement, false, 3);
160            assert!(report.mean_utilization_permille > 500, "{report:?}");
161            assert!(report.max_fragmentation_permille <= 1000);
162        }
163    }
164
165    /// Prints the fragmentation comparison quoted in the crate docs
166    /// (`cargo test -p psx-residency --features sim -- --nocapture churn_table`).
167    #[test]
168    #[cfg_attr(miri, ignore)] // exhaustive sweep, runs natively
169    fn churn_table() {
170        for (name, placement, compact) in [
171            ("first-fit", Placement::FirstFit, false),
172            ("best-fit", Placement::BestFit, false),
173            ("first-fit + compaction", Placement::FirstFit, true),
174            ("best-fit + compaction", Placement::BestFit, true),
175        ] {
176            let mut total = ChurnReport::default();
177            let seeds = 8u32;
178            for seed in 1..=seeds as u64 {
179                let report = run(placement, compact, seed);
180                total.fragmentation_failures += report.fragmentation_failures;
181                total.forced_frees += report.forced_frees;
182                total.pages_moved += report.pages_moved;
183                total.mean_fragmentation_permille += report.mean_fragmentation_permille;
184                total.max_fragmentation_permille = total
185                    .max_fragmentation_permille
186                    .max(report.max_fragmentation_permille);
187                total.mean_utilization_permille += report.mean_utilization_permille;
188            }
189            std::println!(
190                "{name:24} frag-fail {:5}  forced-free {:6}  moved {:8}  mean-frag {:4}  max-frag {:4}  mean-util {:4}",
191                total.fragmentation_failures / seeds,
192                total.forced_frees / seeds,
193                total.pages_moved / seeds as u64,
194                total.mean_fragmentation_permille / seeds,
195                total.max_fragmentation_permille,
196                total.mean_utilization_permille / seeds,
197            );
198        }
199    }
200}