cortex/dispatcher/prefetch.rs
1// Copyright 2015-2025 Deyan Ginev. See the LICENSE
2// file at the top-level directory of this distribution.
3//
4// Licensed under the MIT license <LICENSE-MIT or http://opensource.org/licenses/MIT>.
5// This file may not be copied, modified, or distributed
6// except according to those terms.
7
8//! Input-archive prefetcher (D-20): warm the next batch of task input archives into the OS **page
9//! cache** ahead of dispatch, so the ventilator's inline `/data` read is served from RAM instead of
10//! the cold QLC-RAID6 platter.
11//!
12//! Measured cold read on `/data` is ~10 ms median per ~685 KB archive (p99 ~34 ms), which caps the
13//! single-threaded ventilator at ~100 dispatches/s — the binding bottleneck at full-arXiv scale,
14//! where the ~1 TB working set ≫ RAM so nearly every dispatch reads cold. (The 6.7 GB sandbox fits
15//! in cache and hides this entirely.) A pool of warmer threads `open + read → discard` the upcoming
16//! archives; the bytes land in **reclaimable page cache** — not dispatcher RSS — so this can never
17//! OOM (the kernel drops the clean cache before the workers' anonymous memory). It is the read-side
18//! mirror of the sink's D-7 writer fan-out, but leaves the dispatch loop **untouched** (D-4
19//! ordering preserved): the ventilator still does its own `File::open + read`, now served warm.
20//!
21//! Two bounds keep cache use sane (the prefetch is pure cache hygiene, not a correctness path — a
22//! warm that lags, is skipped, or fails just leaves a cold read exactly as before):
23//! - a **per-entry cap** (`prefetch_max_entry_mb`): a >cap monster is left for the ventilator's
24//! existing chunk-streaming read (O(chunk) resident), not read twice into cache;
25//! - a **per-batch byte budget** (`prefetch_budget_mb`): a batch that clusters large entries stops
26//! warming at the budget and cold-streams its tail, so it can't churn out Postgres's cache.
27
28use std::fs::File;
29use std::sync::Arc;
30use std::sync::atomic::{AtomicUsize, Ordering};
31use std::sync::mpsc::{Receiver, SyncSender, sync_channel};
32use std::thread::{self, JoinHandle};
33
34/// Pure warm-or-skip decision (unit-tested): warm an entry iff it is within the per-entry cap
35/// **and** the batch's warm budget still has room for it.
36fn should_warm(
37 size: usize,
38 max_entry_bytes: usize,
39 budget_used: usize,
40 budget_bytes: usize,
41) -> bool {
42 size <= max_entry_bytes && budget_used + size <= budget_bytes
43}
44
45/// One warmer thread: drain entry paths and pull each (within the caps) into the page cache by
46/// reading it to a sink. `metadata`/`open` failures are ignored — the ventilator will hit the same
47/// path and handle it (a cold read, or the missing-input warning), so a warmer is never a failure
48/// path of its own.
49fn warm_loop(
50 rx: &Receiver<String>,
51 budget: &Arc<AtomicUsize>,
52 max_entry_bytes: usize,
53 budget_bytes: usize,
54) {
55 while let Ok(path) = rx.recv() {
56 let Ok(meta) = std::fs::metadata(&path) else {
57 continue;
58 };
59 let size = meta.len() as usize;
60 if !should_warm(
61 size,
62 max_entry_bytes,
63 budget.load(Ordering::Relaxed),
64 budget_bytes,
65 ) {
66 continue;
67 }
68 budget.fetch_add(size, Ordering::Relaxed);
69 // Warm into page cache: read + discard. `io::copy` uses a small internal buffer, so the
70 // warmer's own RSS stays O(buffer); only the kernel retains the (reclaimable) file pages.
71 if let Ok(mut f) = File::open(&path) {
72 let _ = std::io::copy(&mut f, &mut std::io::sink());
73 }
74 }
75}
76
77/// A pool of page-cache warmer threads fed a batch of input-archive paths per refetch. Disabled
78/// (`input_prefetchers = 0`) it is an inert no-op — [`Self::warm_batch`] returns immediately and
79/// the ventilator reads inline exactly as before D-20. Held on the ventilator's stack; dropping it
80/// (on ventilator shutdown/restart) disconnects the warmers and joins them.
81pub struct Prefetcher {
82 /// One bounded command channel per warmer (round-robin fed, like the sink's writer pool). Empty
83 /// when disabled.
84 senders: Vec<SyncSender<String>>,
85 /// Cumulative bytes warmed for the current batch; reset at the start of each
86 /// [`Self::warm_batch`] and shared across the warmers to enforce the per-batch budget.
87 budget_used: Arc<AtomicUsize>,
88 handles: Vec<JoinHandle<()>>,
89}
90
91impl Prefetcher {
92 /// Spawn `threads` warmers (0 ⇒ disabled no-op). `channel_cap` bounds each warmer's pending-warm
93 /// queue; `max_entry_bytes`/`budget_bytes` are the per-entry and per-batch caps.
94 #[must_use]
95 pub fn new(
96 threads: usize,
97 channel_cap: usize,
98 max_entry_bytes: usize,
99 budget_bytes: usize,
100 ) -> Self {
101 let budget_used = Arc::new(AtomicUsize::new(0));
102 let mut senders = Vec::with_capacity(threads);
103 let mut handles = Vec::with_capacity(threads);
104 for _ in 0..threads {
105 let (tx, rx) = sync_channel::<String>(channel_cap.max(1));
106 let budget = budget_used.clone();
107 senders.push(tx);
108 handles.push(thread::spawn(move || {
109 warm_loop(&rx, &budget, max_entry_bytes, budget_bytes)
110 }));
111 }
112 Prefetcher {
113 senders,
114 budget_used,
115 handles,
116 }
117 }
118
119 /// Feed a fetch batch's input-archive paths (in **dispatch order**) to the warmers, resetting the
120 /// per-batch budget first. Non-blocking: a full warmer channel drops the path (it stays a cold
121 /// read — graceful). A disabled pool returns immediately. The ventilator calls this right after
122 /// `fetch_tasks` so the batch warms over the seconds before its tasks are dispatched.
123 pub fn warm_batch<I: IntoIterator<Item = String>>(&self, paths: I) {
124 if self.senders.is_empty() {
125 return;
126 }
127 // New batch: the previous batch's warmers have long since drained (they outpace dispatch ~Nx),
128 // so reset the cumulative-bytes budget for this one.
129 self.budget_used.store(0, Ordering::Relaxed);
130 for (i, path) in paths.into_iter().enumerate() {
131 let _ = self.senders[i % self.senders.len()].try_send(path);
132 }
133 }
134}
135
136impl Drop for Prefetcher {
137 fn drop(&mut self) {
138 // Drop the senders so each warmer's `recv` disconnects and the thread exits, then join — no
139 // orphaned warmers across a ventilator restart.
140 self.senders.clear();
141 for handle in self.handles.drain(..) {
142 let _ = handle.join();
143 }
144 }
145}
146
147#[cfg(test)]
148mod tests {
149 use super::*;
150
151 #[test]
152 fn should_warm_respects_per_entry_cap() {
153 let cap = 50 * 1024 * 1024; // 50 MiB
154 let budget = 8192 * 1024 * 1024; // 8 GiB
155 assert!(
156 should_warm(685 * 1024, cap, 0, budget),
157 "a typical small entry warms"
158 );
159 assert!(
160 should_warm(cap, cap, 0, budget),
161 "exactly at the cap still warms"
162 );
163 assert!(
164 !should_warm(cap + 1, cap, 0, budget),
165 "a 1-byte-over-cap monster is skipped (cold-streamed)"
166 );
167 }
168
169 #[test]
170 fn should_warm_respects_batch_budget() {
171 let cap = 50 * 1024 * 1024;
172 let budget = 100 * 1024 * 1024; // 100 MiB batch budget
173 assert!(
174 should_warm(40 * 1024 * 1024, cap, 50 * 1024 * 1024, budget),
175 "fits in remaining budget"
176 );
177 assert!(
178 !should_warm(40 * 1024 * 1024, cap, 70 * 1024 * 1024, budget),
179 "would exceed the batch budget → skip (tail cold-streams)"
180 );
181 assert!(
182 should_warm(0, cap, budget, budget),
183 "a zero-byte entry never trips the budget"
184 );
185 }
186
187 #[test]
188 fn disabled_pool_is_an_inert_no_op() {
189 let pf = Prefetcher::new(0, 64, 1, 1);
190 pf.warm_batch(vec![
191 "/nonexistent/a".to_string(),
192 "/nonexistent/b".to_string(),
193 ]);
194 // No panic, no threads, nothing warmed — the ventilator path is unchanged when disabled.
195 assert!(pf.senders.is_empty());
196 }
197
198 #[test]
199 fn enabled_pool_warms_real_files_then_shuts_down_cleanly() {
200 // Warm two temp files; the pool must accept them and join cleanly on drop (the warm itself is a
201 // page-cache side effect we can't assert portably, but the lifecycle + feed must be sound).
202 let dir = std::env::temp_dir();
203 let mut paths = Vec::new();
204 for i in 0..2 {
205 let p = dir.join(format!("cortex_prefetch_test_{i}.bin"));
206 std::fs::write(&p, vec![7u8; 4096]).unwrap();
207 paths.push(p.to_string_lossy().into_owned());
208 }
209 let pf = Prefetcher::new(2, 64, 50 * 1024 * 1024, 8192 * 1024 * 1024);
210 pf.warm_batch(paths.clone());
211 drop(pf); // joins the warmers
212 for p in paths {
213 std::fs::remove_file(&p).ok();
214 }
215 }
216}