Skip to main content

znippy_common/
poolplan.rs

1//! `PoolPlan` — znippy's **cut policy** for a compress pass.
2//!
3//! ## Why this is znippy's and not the pool's
4//!
5//! The slot pool itself is `znippy_zoomies::chunk_revolver::ChunkRevolver` in
6//! coalescing mode — one canonical implementation, upstream, shared with every
7//! other consumer. What lives here is the part that is **not** pool mechanics:
8//! how big a slice znippy cuts a file into, and how much buffer it is willing to
9//! reserve to do it.
10//!
11//! `slice_size` is **output-affecting**. It is the big/small partition threshold
12//! in `compress_dir` AND the length a big file is cut into, so it lands in
13//! `chunk_seq`, in `fdata_offset`, and in every chunk checksum. Change it and
14//! the archive changes. `slot_size` and `num_slots` are **not** output-affecting:
15//! they decide only how many slices coalesce into one buffer and how many
16//! buffers are in flight. The reader publishes and claims a fresh slot whenever
17//! the next slice does not fit, and the sink writes in producer order either
18//! way.
19//!
20//! That split is the whole point of planning them separately: the pool's memory
21//! footprint can be shrunk to fit a cgroup limit while the bytes on disk stay
22//! **identical**. A pool crate cannot own that decision, because it does not
23//! know what an archive is.
24//!
25//! Until 2026-08-25 this module also carried `Magazine`/`Clip`/`Round`/`Ejector`
26//! — a fork of pool mechanics that now exist canonically upstream as
27//! `ChunkRevolver::with_coalescing` / `Filler` / `PackedSlice` / `Returner`.
28//! The fork was deleted rather than kept: measured over 133 650 real files
29//! (985.7 MiB), the canonical implementation beat the replica in 5 of 5
30//! interleaved runs by 5-15 %, with 7.9x fewer allocations.
31
32/// Slot size the pool uses when memory is plentiful and the input is large —
33/// the historical hardcoded value, kept exactly so the fat path is unchanged.
34pub const DEFAULT_SLOT_SIZE: usize = 200 * 1024 * 1024;
35
36/// Slot count the pool uses when memory is plentiful and the input is large.
37/// `DEFAULT_NUM_SLOTS * DEFAULT_SLOT_SIZE` = 1.6 GiB, the old unconditional
38/// reservation.
39pub const DEFAULT_NUM_SLOTS: usize = 8;
40
41/// The geometry of one pass's slot pool: how many slots, how big each is, and —
42/// the load-bearing part — the `slice_size` at which the reader cuts files.
43///
44/// ## Why `slice_size` is planned separately from `slot_size`
45/// `slice_size` is an **output-affecting** quantity: it is the big/small
46/// partition threshold in `compress_dir` AND the chunk length a big file is cut
47/// into, so it lands in `chunk_seq` / `fdata_offset` / every chunk checksum.
48/// Change it and the archive changes.
49///
50/// `slot_size` and `num_slots` are **not** output-affecting. They decide only how
51/// many slices coalesce into one buffer and how many buffers are in flight — the
52/// reader publishes and claims a fresh slot whenever the next slice does not fit
53/// ([`Clip::remaining`]), and the compress sink writes in producer order
54/// regardless. So the pool's memory footprint can be shrunk to fit the input or a
55/// memory budget while the bytes on disk stay **identical**.
56///
57/// That is exactly what [`PoolPlan::plan`] does: `slice_size` is pinned to
58/// `DEFAULT_SLOT_SIZE / num_workers` — the value the old hardcoded pool implied —
59/// and only the reservation moves.
60#[derive(Debug, Clone, Copy, PartialEq, Eq)]
61pub struct PoolPlan {
62    pub num_slots: usize,
63    pub slot_size: usize,
64    pub slice_size: usize,
65}
66
67impl PoolPlan {
68    /// Bytes this plan will allocate up front.
69    pub fn bytes(&self) -> u64 {
70        self.num_slots as u64 * self.slot_size as u64
71    }
72
73    /// The unshrunk geometry: `DEFAULT_NUM_SLOTS × DEFAULT_SLOT_SIZE` (1.6 GiB).
74    pub fn default_for(num_workers: usize) -> Self {
75        PoolPlan {
76            num_slots: DEFAULT_NUM_SLOTS,
77            slot_size: DEFAULT_SLOT_SIZE,
78            slice_size: Self::slice_size_for(num_workers),
79        }
80    }
81
82    /// The output-affecting cut length. Depends ONLY on `num_workers`, never on
83    /// how much of the pool we could afford — that invariant is what keeps a
84    /// memory-shrunk run byte-identical to a fat-box run on the same host.
85    pub fn slice_size_for(num_workers: usize) -> usize {
86        (DEFAULT_SLOT_SIZE / num_workers.max(1)).max(1)
87    }
88
89    /// Plan a pool that is no larger than it needs to be.
90    ///
91    /// The reservation is the smallest of three bounds:
92    ///  * **the old default** — `DEFAULT_NUM_SLOTS × DEFAULT_SLOT_SIZE`; this
93    ///    function never reserves *more* than the code did before,
94    ///  * **the input** — a pass that will read `input_bytes` can never keep more
95    ///    than `ceil(input_bytes / slice_size)` slices in flight, so slots beyond
96    ///    that are pure waste (a 5 MiB staging tree no longer reserves 1.6 GiB),
97    ///  * **the budget** — `budget_bytes` from the caller (see
98    ///    `common_config::slot_pool_budget_bytes`, which reads the cgroup limit),
99    ///    so a constrained pod shrinks instead of being OOM-killed.
100    ///
101    /// The floor is one slot of one slice: the pipeline must be able to hold the
102    /// largest cut it can produce, so it always makes progress no matter how
103    /// small the budget claims to be.
104    pub fn plan(input_bytes: u64, num_workers: usize, budget_bytes: u64) -> Self {
105        let slice_size = Self::slice_size_for(num_workers);
106        let slice = slice_size as u64;
107
108        // Slices the *default* pool holds. Reaching this bound means nothing is
109        // constrained — return the historical geometry EXACTLY (not a rounded
110        // reconstruction of it), so the fat/bench path is bit-for-bit the old code.
111        let default_slices = (DEFAULT_NUM_SLOTS as u64) * (DEFAULT_SLOT_SIZE as u64) / slice;
112
113        let needed = input_bytes.div_ceil(slice).max(1);
114        let affordable = (budget_bytes / slice).max(1);
115        let slices = needed.min(affordable).min(default_slices);
116
117        if slices >= default_slices {
118            return Self::default_for(num_workers);
119        }
120
121        // Spread the affordable slices over as many slots as we can (more slots =
122        // more reader/worker overlap). Round the per-slot count DOWN: rounding up
123        // would multiply the shortfall by `num_slots` and blow the budget the
124        // whole plan exists to respect.
125        let num_slots = (slices as usize).min(DEFAULT_NUM_SLOTS).max(1);
126        let per_slot = ((slices as usize) / num_slots).max(1);
127        let slot_size = (per_slot * slice_size).min(DEFAULT_SLOT_SIZE);
128
129        PoolPlan { num_slots, slot_size, slice_size }
130    }
131}
132
133#[cfg(test)]
134mod tests {
135    use super::*;
136
137    /// MEMORY BOUND: the pool reservation follows the input and the budget, and
138    /// the cut length — the only output-affecting quantity — never moves.
139    ///
140    /// The old code reserved `8 × 200 MiB = 1.6 GiB` before opening a file,
141    /// whatever the input size and whatever the memory limit; that is what
142    /// OOM-killed a constrained backup pod. Inject real byte counts, assert real
143    /// reservations.
144    #[test]
145    fn pool_plan_is_bounded_by_input_and_budget() {
146        const WORKERS: usize = 16;
147        let slice = PoolPlan::slice_size_for(WORKERS);
148        let huge = u64::MAX / 4;
149        let default_bytes = (DEFAULT_NUM_SLOTS * DEFAULT_SLOT_SIZE) as u64;
150
151        // 1. UNCONSTRAINED — big input, plentiful RAM — is EXACTLY the old
152        //    geometry. The fat/bench path must not move.
153        let fat = PoolPlan::plan(huge, WORKERS, huge);
154        assert_eq!(
155            fat,
156            PoolPlan::default_for(WORKERS),
157            "an unconstrained plan must be the historical 8 × 200 MiB geometry"
158        );
159        assert_eq!(fat.bytes(), default_bytes, "unconstrained reservation is 1.6 GiB");
160
161        // 2. TINY INPUT, plentiful RAM: a 5 MiB staging tree no longer reserves
162        //    1.6 GiB — the reservation is bounded by the bytes there are to read.
163        let five_mib = 5 * 1024 * 1024;
164        let tiny = PoolPlan::plan(five_mib, WORKERS, huge);
165        assert!(
166            tiny.bytes() < default_bytes / 100,
167            "5 MiB of input reserved {} bytes — must be a sliver of the old 1.6 GiB",
168            tiny.bytes()
169        );
170        assert!(
171            tiny.bytes() >= five_mib,
172            "the pool must still be able to hold the input in flight ({} < {five_mib})",
173            tiny.bytes()
174        );
175
176        // 3. CONSTRAINED POD: big input, 64 MiB budget → the reservation obeys the
177        //    budget instead of OOM-killing the pod.
178        let budget = 64 * 1024 * 1024;
179        let pod = PoolPlan::plan(huge, WORKERS, budget);
180        assert!(
181            pod.bytes() <= budget,
182            "planned {} bytes over a {budget}-byte budget",
183            pod.bytes()
184        );
185
186        // 4. ABSURD BUDGET: still makes progress — one slot holding one slice.
187        let starved = PoolPlan::plan(huge, WORKERS, 1);
188        assert_eq!(starved.num_slots, 1);
189        assert_eq!(starved.slot_size, slice, "the floor is exactly one slice");
190
191        // 5. THE INVARIANT: slice_size is identical in every one of them, so all
192        //    four write byte-identical archives — only the RAM footprint differs.
193        for p in [fat, tiny, pod, starved] {
194            assert_eq!(
195                p.slice_size, slice,
196                "slice_size moved with the memory plan — that changes the big/small \
197                 partition and every big-file chunk boundary, i.e. the archive bytes"
198            );
199            assert!(p.slice_size <= p.slot_size, "a slice must fit in a slot");
200            assert!(p.num_slots >= 1 && p.slot_size >= 1);
201        }
202
203        // 6. SWEEP — the budget is honoured at EVERY size, not just the round
204        //    ones. (Rounding the per-slot slice count UP instead of DOWN broke
205        //    exactly here: a 64 MiB budget planned 110 MiB, because a 1-slice
206        //    shortfall got multiplied by 8 slots.)
207        for workers in [1usize, 2, 3, 7, 16, 29, 32, 64] {
208            let ss = PoolPlan::slice_size_for(workers) as u64;
209            for budget in [
210                1u64, 7, ss - 1, ss, ss + 1, ss * 3, ss * 8, ss * 9, ss * 17,
211                64 << 20, 100 << 20, 256 << 20, 1 << 30,
212            ] {
213                let p = PoolPlan::plan(huge, workers, budget);
214                assert_eq!(p.slice_size as u64, ss, "slice_size moved (workers={workers})");
215                assert!(p.slice_size <= p.slot_size, "slice must fit in a slot");
216                // The floor is one slice: below that the pipeline cannot run at
217                // all, so a budget under one slice buys exactly one slice.
218                let allowed = budget.max(ss).min(default_bytes);
219                assert!(
220                    p.bytes() <= allowed,
221                    "workers={workers} budget={budget}: planned {} bytes, allowed {allowed}",
222                    p.bytes()
223                );
224            }
225            // And the input bound holds independently of the budget.
226            for input in [0u64, 1, ss / 2, ss, ss * 5, ss * 300, 1 << 30] {
227                let p = PoolPlan::plan(input, workers, huge);
228                let allowed = input.max(ss).next_multiple_of(ss).min(default_bytes) + ss;
229                assert!(
230                    p.bytes() <= allowed,
231                    "workers={workers} input={input}: planned {} bytes for {input} of input",
232                    p.bytes()
233                );
234            }
235        }
236    }
237}