Skip to main content

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}