Skip to main content

ghostlink_core/
autotune.rs

1//! Unified auto-tuning for Ghost-Link subsystems.
2//!
3//! `AutoTuner` derives all tunable configuration from a single `SystemProfile`
4//! and provides a persistent disk cache so re-tuning is unnecessary across
5//! restarts as long as the hardware fingerprint has not changed.
6
7use std::collections::hash_map::DefaultHasher;
8use std::fs;
9use std::hash::{Hash, Hasher};
10use std::path::PathBuf;
11use std::sync::Mutex;
12
13use crate::host::RuntimeProfile;
14use crate::system_profile::SystemProfile;
15
16/// In-memory cache of the last-computed AutoTuner.
17/// Avoids re-tuning on repeated calls within the same process lifetime.
18static TUNE_CACHE: Mutex<Option<AutoTuner>> = Mutex::new(None);
19
20// ---------------------------------------------------------------------------
21// AutoTuner
22// ---------------------------------------------------------------------------
23
24/// Pre-computed tuning parameters for all Ghost-Link subsystems.
25#[derive(Clone, Debug)]
26pub struct AutoTuner {
27    /// System fingerprint used for cache invalidation.
28    pub fingerprint: u64,
29    /// Timestamp of the last full re-tune.
30    pub tuned_at: String,
31    /// Tuned health-check configuration.
32    pub health_config: super::health::HealthConfig,
33    /// Tuned load-balancing configuration.
34    pub load_balance_config: super::load_balance::LoadBalanceConfig,
35    /// Tuned planning hints (layer chunking).
36    pub planning_tuning: super::planning::PlanningTuning,
37    /// Recommended TCP transport settings.
38    pub tcp_config: TcpAutoConfig,
39    /// Recommended worker-pool sizing.
40    pub worker_pool: WorkerPoolConfig,
41}
42
43/// TCP transport settings derived from system profile + optional benchmarks.
44#[derive(Clone, Debug)]
45pub struct TcpAutoConfig {
46    pub max_inflight_batches: usize,
47    pub reconnect_attempts: usize,
48    pub reconnect_backoff_ms: u64,
49}
50
51/// Worker pool sizing derived from the system profile.
52#[derive(Clone, Debug)]
53pub struct WorkerPoolConfig {
54    pub compute_workers: usize,
55    pub io_workers: usize,
56    pub total_workers: usize,
57}
58
59impl AutoTuner {
60    /// Tune all subsystems from a `SystemProfile`.
61    /// Results are cached in-memory to avoid re-tuning on repeated calls.
62    pub fn from_system_profile(profile: &SystemProfile) -> Self {
63        let fp = compute_fingerprint(profile);
64        {
65            let cache = TUNE_CACHE.lock().unwrap();
66            if let Some(cached) = cache.as_ref() {
67                if cached.fingerprint == fp {
68                    return cached.clone();
69                }
70            }
71        }
72        let tuner = tune(profile);
73        let mut cache = TUNE_CACHE.lock().unwrap();
74        *cache = Some(tuner.clone());
75        tuner
76    }
77
78    /// Load a previously cached `AutoTuner` if the hardware fingerprint matches.
79    /// Checks the in-memory cache first, then falls back to disk.
80    pub fn load_cache() -> Option<Self> {
81        // Current fingerprint is needed to validate either cache — compute it
82        // once up front rather than only on the disk path. The in-memory fast
83        // path previously returned whatever was cached with no fingerprint
84        // check at all (unlike from_system_profile() and the disk path below),
85        // so a hardware change mid-process (GPU hot-plug/unplug, VRAM change)
86        // kept serving stale tuning forever from the in-memory cache.
87        let current = SystemProfile::detect_fast();
88        let current_fp = compute_fingerprint(&current);
89
90        // Fast path: in-memory cache hit
91        {
92            let cache = TUNE_CACHE.lock().unwrap();
93            if let Some(cached) = cache.as_ref() {
94                if cached.fingerprint == current_fp {
95                    return Some(cached.clone());
96                }
97            }
98        }
99
100        // Slow path: try disk
101        let path = cache_path()?;
102        let data = fs::read_to_string(&path).ok()?;
103        let cached: AutoTuner = serde_json::from_str(&data).ok()?;
104
105        if cached.fingerprint != current_fp {
106            return None;
107        }
108
109        // Populate in-memory cache for next time
110        let mut cache = TUNE_CACHE.lock().unwrap();
111        *cache = Some(cached.clone());
112        Some(cached)
113    }
114
115    /// Persist the current tuning to disk.
116    pub fn save_cache(&self) {
117        let path = match cache_path() {
118            Some(p) => p,
119            None => return,
120        };
121        if let Some(parent) = path.parent() {
122            let _ = fs::create_dir_all(parent);
123        }
124        if let Ok(data) = serde_json::to_string_pretty(self) {
125            let _ = fs::write(&path, data);
126        }
127    }
128
129    /// Run TCP transport benchmark and update `tcp_config`.
130    /// This is a no-op placeholder that uses profile-derived defaults;
131    /// the real benchmark lives in `ghost-link` (main.rs).
132    pub fn benchmark_tcp(&mut self) {
133        // Real TCP benchmarking (measuring throughput vs. max_inflight)
134        // is deferred to the binary crate. Here we apply profile-derived defaults.
135    }
136}
137
138// ---------------------------------------------------------------------------
139// Internal helpers
140// ---------------------------------------------------------------------------
141
142fn tune(profile: &SystemProfile) -> AutoTuner {
143    let fingerprint = compute_fingerprint(profile);
144    let tuned_at = now_iso8601();
145    let rp: RuntimeProfile = profile.into();
146
147    AutoTuner {
148        fingerprint,
149        tuned_at,
150        health_config: super::health::HealthConfig::autotuned(&rp),
151        load_balance_config: super::load_balance::LoadBalanceConfig::autotuned(&rp),
152        planning_tuning: super::planning::PlanningTuning::from_runtime_profile(&rp, 40),
153        tcp_config: tune_tcp(profile, &rp),
154        worker_pool: tune_worker_pool(profile, &rp),
155    }
156}
157
158fn compute_fingerprint(profile: &SystemProfile) -> u64 {
159    let mut hasher = DefaultHasher::new();
160    profile.cpu.logical_cores.hash(&mut hasher);
161    profile.memory.total_gb.to_bits().hash(&mut hasher);
162    for gpu in &profile.gpus {
163        gpu.name.hash(&mut hasher);
164        gpu.vram_gb.to_bits().hash(&mut hasher);
165    }
166    profile.npus.len().hash(&mut hasher);
167    profile.acceleration_mode.hash(&mut hasher);
168    hasher.finish()
169}
170
171fn cache_path() -> Option<PathBuf> {
172    let base = dirs_next::cache_dir().or_else(|| {
173        std::env::var("HOME")
174            .or_else(|_| std::env::var("USERPROFILE"))
175            .ok()
176            .map(PathBuf::from)
177    })?;
178    Some(base.join("ghostlink").join("autotune.json"))
179}
180
181fn now_iso8601() -> String {
182    let dur = std::time::SystemTime::now()
183        .duration_since(std::time::UNIX_EPOCH)
184        .unwrap_or_default();
185    let secs = dur.as_secs();
186    // Days since epoch
187    let days = secs / 86400;
188    let remaining = secs % 86400;
189    let hours = remaining / 3600;
190    let minutes = (remaining % 3600) / 60;
191    let seconds = remaining % 60;
192
193    // Compute year/month/day from days since 1970-01-01 (civil calendar)
194    let (year, month, day) = civil_from_days(days as i64);
195
196    format!(
197        "{:04}-{:02}-{:02}T{:02}:{:02}:{:02}Z",
198        year, month, day, hours, minutes, seconds
199    )
200}
201
202/// Convert days since 1970-01-01 to (year, month, day) in the Gregorian civil calendar.
203fn civil_from_days(days: i64) -> (i64, u32, u32) {
204    // Algorithm from Howard Hinnant
205    let z = days + 719468;
206    let era = if z >= 0 { z } else { z - 146096 } / 146097;
207    let doe = z - era * 146097;
208    let yoe = (doe - doe / 1460 + doe / 36524 - doe / 146096) / 365;
209    let y = yoe + era * 400;
210    let doy = doe - (365 * yoe + yoe / 4 - yoe / 100);
211    let mp = (5 * doy + 2) / 153;
212    let d = doy - (153 * mp + 2) / 5 + 1;
213    let m = if mp < 10 { mp + 3 } else { mp - 9 };
214    let y = if m <= 2 { y + 1 } else { y };
215    (y, m as u32, d as u32)
216}
217
218fn tune_tcp(profile: &SystemProfile, _rp: &RuntimeProfile) -> TcpAutoConfig {
219    let (max_inflight, reconnect_attempts, reconnect_backoff_ms) = match profile.acceleration_mode {
220        crate::host::AccelerationMode::Gpu => (256, 5, 5),
221        crate::host::AccelerationMode::Avx512 => (128, 3, 10),
222        _ => (64, 3, 10),
223    };
224
225    // Scale for multi-GPU
226    let gpu_count = profile
227        .gpus
228        .iter()
229        .filter(|g| g.vram_gb > 0.0)
230        .count()
231        .max(1);
232    TcpAutoConfig {
233        max_inflight_batches: max_inflight * gpu_count,
234        reconnect_attempts,
235        reconnect_backoff_ms,
236    }
237}
238
239fn tune_worker_pool(profile: &SystemProfile, rp: &RuntimeProfile) -> WorkerPoolConfig {
240    let compute = rp.recommended_workers.max(1);
241    let io = match profile.acceleration_mode {
242        crate::host::AccelerationMode::Gpu => compute / 2,
243        _ => compute / 4,
244    }
245    .max(1);
246    WorkerPoolConfig {
247        compute_workers: compute,
248        io_workers: io,
249        total_workers: compute + io,
250    }
251}
252
253// ---------------------------------------------------------------------------
254// Serde support (manual impls to avoid pulling serde derive on sub-types)
255// ---------------------------------------------------------------------------
256
257impl serde::Serialize for AutoTuner {
258    fn serialize<S: serde::Serializer>(&self, s: S) -> Result<S::Ok, S::Error> {
259        use serde::ser::SerializeStruct;
260        let mut st = s.serialize_struct("AutoTuner", 7)?;
261        st.serialize_field("fingerprint", &self.fingerprint)?;
262        st.serialize_field("tuned_at", &self.tuned_at)?;
263        st.serialize_field("healthy_latency_us", &self.health_config.healthy_latency_us)?;
264        st.serialize_field(
265            "degraded_latency_us",
266            &self.health_config.degraded_latency_us,
267        )?;
268        st.serialize_field("lock_timeout_us", &self.load_balance_config.lock_timeout_us)?;
269        st.serialize_field(
270            "max_inflight_batches",
271            &self.tcp_config.max_inflight_batches,
272        )?;
273        st.serialize_field("compute_workers", &self.worker_pool.compute_workers)?;
274        st.end()
275    }
276}
277
278impl<'de> serde::Deserialize<'de> for AutoTuner {
279    fn deserialize<D: serde::Deserializer<'de>>(d: D) -> Result<Self, D::Error> {
280        use serde::de::{MapAccess, Visitor};
281        struct TunerVisitor;
282        impl<'de> Visitor<'de> for TunerVisitor {
283            type Value = AutoTuner;
284            fn expecting(&self, f: &mut std::fmt::Formatter) -> std::fmt::Result {
285                f.write_str("struct AutoTuner")
286            }
287            fn visit_map<V: MapAccess<'de>>(self, mut map: V) -> Result<AutoTuner, V::Error> {
288                let mut fingerprint = 0u64;
289                let mut tuned_at = String::new();
290                let mut healthy_latency_us = 10.0f32;
291                let mut degraded_latency_us = 20.0f32;
292                let mut lock_timeout_us = 1000u64;
293                let mut max_inflight_batches = 64usize;
294                let mut compute_workers = 4usize;
295
296                while let Some(key) = map.next_key::<String>()? {
297                    match key.as_str() {
298                        "fingerprint" => fingerprint = map.next_value()?,
299                        "tuned_at" => tuned_at = map.next_value()?,
300                        "healthy_latency_us" => healthy_latency_us = map.next_value()?,
301                        "degraded_latency_us" => degraded_latency_us = map.next_value()?,
302                        "lock_timeout_us" => lock_timeout_us = map.next_value()?,
303                        "max_inflight_batches" => max_inflight_batches = map.next_value()?,
304                        "compute_workers" => compute_workers = map.next_value()?,
305                        _ => {
306                            let _: serde_json::Value = map.next_value()?;
307                        }
308                    }
309                }
310
311                Ok(AutoTuner {
312                    fingerprint,
313                    tuned_at,
314                    health_config: super::health::HealthConfig {
315                        healthy_latency_us,
316                        degraded_latency_us,
317                        ..Default::default()
318                    },
319                    load_balance_config: super::load_balance::LoadBalanceConfig {
320                        lock_timeout_us,
321                        ..Default::default()
322                    },
323                    planning_tuning: super::planning::PlanningTuning {
324                        max_layers_per_assignment: compute_workers.max(1),
325                    },
326                    tcp_config: TcpAutoConfig {
327                        max_inflight_batches,
328                        reconnect_attempts: 3,
329                        reconnect_backoff_ms: 10,
330                    },
331                    worker_pool: WorkerPoolConfig {
332                        compute_workers,
333                        io_workers: compute_workers / 2,
334                        total_workers: compute_workers + compute_workers / 2,
335                    },
336                })
337            }
338        }
339        d.deserialize_map(TunerVisitor)
340    }
341}
342
343// ---------------------------------------------------------------------------
344// Tests
345// ---------------------------------------------------------------------------
346
347#[cfg(test)]
348mod tests {
349    use super::*;
350    use crate::system_profile::SystemProfile;
351
352    #[test]
353    fn autotuner_derives_from_system_profile() {
354        let sp = SystemProfile::detect_fast();
355        let tuner = AutoTuner::from_system_profile(&sp);
356        assert!(tuner.worker_pool.compute_workers >= 1);
357        assert!(tuner.tcp_config.max_inflight_batches >= 1);
358    }
359
360    #[test]
361    fn autotuner_fingerprint_is_stable() {
362        let sp = SystemProfile::detect_fast();
363        let fp1 = compute_fingerprint(&sp);
364        let fp2 = compute_fingerprint(&sp);
365        assert_eq!(fp1, fp2);
366    }
367
368    #[test]
369    fn autotuner_round_trips_through_json() {
370        let sp = SystemProfile::detect_fast();
371        let tuner = AutoTuner::from_system_profile(&sp);
372        let json = serde_json::to_string_pretty(&tuner).expect("serialize");
373        let restored: AutoTuner = serde_json::from_str(&json).expect("deserialize");
374        assert_eq!(tuner.fingerprint, restored.fingerprint);
375        assert_eq!(
376            tuner.tcp_config.max_inflight_batches,
377            restored.tcp_config.max_inflight_batches
378        );
379    }
380
381    #[test]
382    fn autotuner_detects_hardware_change() {
383        let sp = SystemProfile::detect_fast();
384        let mut tuner = AutoTuner::from_system_profile(&sp);
385
386        // Tamper the fingerprint to simulate hardware change
387        tuner.fingerprint = 0;
388
389        let json = serde_json::to_string(&tuner).unwrap();
390        std::env::set_var("HOME", std::env::temp_dir());
391        // In a real scenario, load_cache would reject this due to fingerprint mismatch
392        let restored: AutoTuner = serde_json::from_str(&json).unwrap();
393        assert_eq!(restored.fingerprint, 0);
394    }
395
396    #[test]
397    fn load_cache_rejects_stale_in_memory_fingerprint() {
398        // Regression: load_cache()'s in-memory fast path used to return
399        // whatever was cached with no fingerprint check at all (unlike
400        // from_system_profile() and this same function's disk-fallback
401        // path). Force a stale, guaranteed-wrong fingerprint directly into
402        // the in-memory cache and confirm it's no longer blindly returned.
403        {
404            let mut cache = TUNE_CACHE.lock().unwrap();
405            let mut stale = tune(&SystemProfile::detect_fast());
406            stale.fingerprint = 0; // a real fingerprint is a hash of many fields; 0 is not realistically achievable
407            *cache = Some(stale);
408        }
409        if let Some(tuner) = AutoTuner::load_cache() {
410            assert_ne!(
411                tuner.fingerprint, 0,
412                "load_cache() returned the stale in-memory entry instead of rejecting it"
413            );
414        }
415        // None is also a valid outcome here (no matching on-disk cache either).
416    }
417
418    #[test]
419    fn tcp_config_scales_with_multi_gpu() {
420        use crate::host::{AccelerationMode, GpuBackend};
421        use crate::system_profile::{CpuInfo, GpuInfo, MemoryInfo};
422
423        let single = SystemProfile {
424            gpus: vec![GpuInfo {
425                name: "RTX 4090".into(),
426                vram_gb: 24.0,
427                backend: GpuBackend::Cuda,
428                compute_capability: "8.9".into(),
429                driver_version: "555".into(),
430                pci_address: None,
431            }],
432            cpu: CpuInfo {
433                logical_cores: 16,
434                ..Default::default()
435            },
436            memory: MemoryInfo {
437                total_gb: 64.0,
438                ..Default::default()
439            },
440            acceleration_mode: AccelerationMode::Gpu,
441            ..Default::default()
442        };
443
444        let dual = SystemProfile {
445            gpus: vec![
446                GpuInfo {
447                    name: "RTX 4090".into(),
448                    vram_gb: 24.0,
449                    backend: GpuBackend::Cuda,
450                    compute_capability: "8.9".into(),
451                    driver_version: "555".into(),
452                    pci_address: None,
453                },
454                GpuInfo {
455                    name: "RTX 3090".into(),
456                    vram_gb: 24.0,
457                    backend: GpuBackend::Cuda,
458                    compute_capability: "8.6".into(),
459                    driver_version: "555".into(),
460                    pci_address: None,
461                },
462            ],
463            cpu: CpuInfo {
464                logical_cores: 16,
465                ..Default::default()
466            },
467            memory: MemoryInfo {
468                total_gb: 64.0,
469                ..Default::default()
470            },
471            acceleration_mode: AccelerationMode::Gpu,
472            ..Default::default()
473        };
474
475        let s = AutoTuner::from_system_profile(&single);
476        let d = AutoTuner::from_system_profile(&dual);
477
478        assert!(d.tcp_config.max_inflight_batches > s.tcp_config.max_inflight_batches);
479    }
480}