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}