Skip to main content

ripsync_core/
tune.rs

1//! Device-tier auto-tuning.
2//!
3//! ripsync runs on everything from a 2-core NAS to a 32-core workstation. A fixed
4//! set of defaults is wrong at both ends: too many threads thrash a small box, too
5//! few leave a big one idle. [`detect`] probes the machine (CPU count, and total
6//! RAM where it can be read without `unsafe` or extra dependencies) and picks a
7//! [`Profile`]; [`Profile::params`] turns that into concrete [`TuneParams`] —
8//! worker threads, copy-buffer size, `io_uring` queue depth, and zstd level —
9//! consumed across the walk, copy, and remote paths.
10
11/// A device performance tier.
12#[derive(Debug, Clone, Copy, PartialEq, Eq)]
13pub enum Profile {
14    /// Small / constrained device (≤2 cores or ≤2 GiB RAM): conserve memory and
15    /// avoid oversubscription.
16    Low,
17    /// Typical desktop / laptop: balanced defaults.
18    Balanced,
19    /// Workstation / server (≥8 cores and ≥16 GiB RAM): use the hardware fully.
20    High,
21}
22
23/// Concrete knobs derived from a [`Profile`] for a given core count.
24#[derive(Debug, Clone, Copy, PartialEq, Eq)]
25pub struct TuneParams {
26    /// Worker threads for the parallel walk and apply phases.
27    pub threads: usize,
28    /// Buffer size for the portable copy path, in bytes.
29    pub copy_buffer: usize,
30    /// `io_uring` submission/completion queue depth (Linux backend).
31    pub uring_queue_depth: usize,
32    /// Default zstd level for wire compression.
33    pub zstd_level: i32,
34}
35
36impl Profile {
37    /// Turn this tier into concrete parameters for a machine with `cores` CPUs.
38    #[must_use]
39    pub fn params(self, cores: usize) -> TuneParams {
40        let cores = cores.max(1);
41        match self {
42            Profile::Low => TuneParams {
43                threads: cores.min(2),
44                copy_buffer: 256 * 1024,
45                uring_queue_depth: 32,
46                zstd_level: 1,
47            },
48            Profile::Balanced => TuneParams {
49                threads: cores,
50                copy_buffer: 1024 * 1024,
51                uring_queue_depth: 128,
52                zstd_level: 3,
53            },
54            Profile::High => TuneParams {
55                threads: cores,
56                copy_buffer: 8 * 1024 * 1024,
57                uring_queue_depth: 512,
58                zstd_level: 6,
59            },
60        }
61    }
62
63    /// The tier's lowercase name.
64    #[must_use]
65    pub fn name(self) -> &'static str {
66        match self {
67            Profile::Low => "low",
68            Profile::Balanced => "balanced",
69            Profile::High => "high",
70        }
71    }
72}
73
74/// Classify a machine from its core count and (optionally known) total RAM in GiB.
75///
76/// `Low` when ≤2 cores or ≤2 GiB. `High` when ≥8 cores and (RAM unknown or
77/// ≥16 GiB). `Balanced` otherwise.
78#[must_use]
79pub fn resolve(cores: usize, ram_gib: Option<f64>) -> Profile {
80    let low = cores <= 2 || ram_gib.is_some_and(|r| r <= 2.0);
81    if low {
82        return Profile::Low;
83    }
84    let high = cores >= 8 && ram_gib.is_none_or(|r| r >= 16.0);
85    if high {
86        Profile::High
87    } else {
88        Profile::Balanced
89    }
90}
91
92/// Auto-detect the device profile for the current machine.
93#[must_use]
94pub fn detect() -> Profile {
95    resolve(num_cpus::get(), total_ram_gib())
96}
97
98/// Best-effort total physical RAM in GiB. Returns `None` where it cannot be read
99/// without `unsafe` or extra dependencies (the classifier then uses cores alone).
100#[must_use]
101pub fn total_ram_gib() -> Option<f64> {
102    #[cfg(target_os = "linux")]
103    {
104        let text = std::fs::read_to_string("/proc/meminfo").ok()?;
105        for line in text.lines() {
106            if let Some(rest) = line.strip_prefix("MemTotal:") {
107                let kib: f64 = rest.trim().trim_end_matches("kB").trim().parse().ok()?;
108                return Some(kib / (1024.0 * 1024.0));
109            }
110        }
111        None
112    }
113    #[cfg(not(target_os = "linux"))]
114    {
115        None
116    }
117}
118
119#[cfg(test)]
120mod tests {
121    use super::*;
122
123    #[test]
124    fn classifies_tiers() {
125        assert_eq!(resolve(1, Some(16.0)), Profile::Low); // few cores
126        assert_eq!(resolve(16, Some(1.0)), Profile::Low); // tiny RAM
127        assert_eq!(resolve(4, Some(8.0)), Profile::Balanced);
128        assert_eq!(resolve(8, Some(16.0)), Profile::High);
129        assert_eq!(resolve(12, None), Profile::High); // unknown RAM, many cores
130        assert_eq!(resolve(4, None), Profile::Balanced);
131    }
132
133    #[test]
134    fn params_scale_with_tier() {
135        let low = Profile::Low.params(16);
136        let high = Profile::High.params(16);
137        assert_eq!(low.threads, 2);
138        assert_eq!(high.threads, 16);
139        assert!(high.copy_buffer > low.copy_buffer);
140        assert!(high.uring_queue_depth > low.uring_queue_depth);
141    }
142
143    #[test]
144    fn params_never_zero_threads() {
145        assert_eq!(Profile::Balanced.params(0).threads, 1);
146    }
147}