Skip to main content

cortiq_engine/
pin.rs

1//! Keep the working set resident.
2//!
3//! An mmap'd model is demand-paged, and the page cache evicts by age. For a
4//! mixture of experts that is the wrong policy: one cold expert, touched
5//! once, can evict a hot one that every token needs. The routing field says
6//! which experts a task actually uses — measured on this model, 99 of 256
7//! carry 95% of the mass — so the fix is not to load the rest on demand
8//! (mmap already does) but to stop the rest from evicting what matters.
9//!
10//! `CMF_MOE_PIN=<stats.json>` pins the skeleton plus the covered experts;
11//! `CMF_MOE_PIN_COVER` sets the fraction (default 0.95).
12//!
13//! Two things make this fail quietly if unattended. `RLIMIT_MEMLOCK` is 8 MB
14//! on a stock container, so the first `mlock` past that returns ENOMEM and
15//! every later one does too — the limit is raised here, and if that is
16//! refused the caller is told rather than left with 8 MB pinned. And pinning
17//! more than physical memory is a way to invite the OOM killer, so the
18//! budget is checked against what the machine has.
19
20use std::collections::HashMap;
21
22#[cfg(not(any(target_os = "linux", target_os = "android")))]
23mod imp {
24    /// Pinning is a server-side concern and the pieces it needs — mlock
25    /// through libc, /proc/meminfo — are Linux's. Elsewhere it reports that
26    /// it did nothing rather than pretending otherwise.
27    pub fn raise_memlock_limit() -> Option<u64> {
28        None
29    }
30    pub fn lock_slice(_b: &[u8]) -> std::io::Result<()> {
31        Err(std::io::Error::new(
32            std::io::ErrorKind::Unsupported,
33            "закрепление памяти поддержано только на Linux",
34        ))
35    }
36    pub fn phys_mem() -> Option<(u64, u64)> {
37        None
38    }
39}
40
41#[cfg(any(target_os = "linux", target_os = "android"))]
42mod imp {
43    /// Raise the locked-memory limit as far as the process is allowed to.
44    /// Returns the new soft limit in bytes, or None if it could not be read.
45    pub fn raise_memlock_limit() -> Option<u64> {
46        unsafe {
47            let mut rl = libc::rlimit {
48                rlim_cur: 0,
49                rlim_max: 0,
50            };
51            if libc::getrlimit(libc::RLIMIT_MEMLOCK, &mut rl) != 0 {
52                return None;
53            }
54            // Raising the soft limit to the hard one is not enough: a stock
55            // container ships 8 MB for BOTH, and the first tensor of any real
56            // model is bigger than that. With CAP_SYS_RESOURCE the hard limit
57            // can go too, so try that first and fall back.
58            for want in [
59                libc::rlimit {
60                    rlim_cur: libc::RLIM_INFINITY,
61                    rlim_max: libc::RLIM_INFINITY,
62                },
63                libc::rlimit {
64                    rlim_cur: rl.rlim_max,
65                    rlim_max: rl.rlim_max,
66                },
67            ] {
68                if libc::setrlimit(libc::RLIMIT_MEMLOCK, &want) == 0 {
69                    rl.rlim_cur = want.rlim_cur;
70                    break;
71                }
72            }
73            Some(rl.rlim_cur as u64)
74        }
75    }
76
77    /// Total and available physical memory in bytes, if the kernel will say.
78    pub fn phys_mem() -> Option<(u64, u64)> {
79        let s = std::fs::read_to_string("/proc/meminfo").ok()?;
80        let get = |k: &str| -> Option<u64> {
81            s.lines()
82                .find(|l| l.starts_with(k))?
83                .split_whitespace()
84                .nth(1)?
85                .parse::<u64>()
86                .ok()
87                .map(|kb| kb * 1024)
88        };
89        Some((get("MemTotal:")?, get("MemAvailable:")?))
90    }
91
92    /// mlock one byte range. The kernel wants page-aligned addresses; a slice
93    /// from the middle of a mapping is not, so the range is widened outward.
94    pub fn lock_slice(b: &[u8]) -> std::io::Result<()> {
95        if b.is_empty() {
96            return Ok(());
97        }
98        let page = unsafe { libc::sysconf(libc::_SC_PAGESIZE) } as usize;
99        let start = b.as_ptr() as usize;
100        let aligned = start & !(page - 1);
101        let len = (start - aligned) + b.len();
102        let rc = unsafe { libc::mlock(aligned as *const libc::c_void, len) };
103        if rc == 0 {
104            Ok(())
105        } else {
106            Err(std::io::Error::last_os_error())
107        }
108    }
109}
110
111pub use imp::raise_memlock_limit;
112use imp::{lock_slice, phys_mem};
113
114/// The experts holding `cover` of a layer's routing mass, per layer.
115pub fn hot_experts(stats_path: &str, cover: f64) -> Option<HashMap<usize, Vec<usize>>> {
116    let text = std::fs::read_to_string(stats_path)
117        .map_err(|e| tracing::warn!("CMF_MOE_PIN: cannot read {stats_path}: {e}"))
118        .ok()?;
119    let map: HashMap<String, Vec<u64>> = serde_json::from_str(&text)
120        .map_err(|e| tracing::warn!("CMF_MOE_PIN: bad JSON in {stats_path}: {e}"))
121        .ok()?;
122    let mut out = HashMap::new();
123    for (k, counts) in map {
124        let Ok(li) = k.parse::<usize>() else { continue };
125        let total: u64 = counts.iter().sum();
126        if total == 0 {
127            continue;
128        }
129        let mut order: Vec<usize> = (0..counts.len()).collect();
130        order.sort_by_key(|&i| std::cmp::Reverse(counts[i]));
131        let mut acc = 0u64;
132        let mut keep = Vec::new();
133        for i in order {
134            if counts[i] == 0 {
135                break;
136            }
137            acc += counts[i];
138            keep.push(i);
139            if acc as f64 >= cover * total as f64 {
140                break;
141            }
142        }
143        out.insert(li, keep);
144    }
145    Some(out)
146}
147
148/// What a pinning pass did, for the log and for the caller to judge.
149pub struct Pinned {
150    pub bytes: u64,
151    pub tensors: usize,
152    pub skipped: usize,
153    pub limit: Option<u64>,
154}
155
156/// Pin every named tensor that exists, stopping cleanly at the first refusal
157/// rather than hammering the kernel with thousands of doomed calls.
158pub fn pin_tensors(model: &cortiq_core::CmfModel, names: &[String]) -> Pinned {
159    let limit = raise_memlock_limit();
160    let mut want: u64 = 0;
161    for n in names {
162        if let Ok(b) = model.tensor_bytes(n) {
163            want += b.len() as u64;
164        }
165    }
166    if let Some((total, avail)) = phys_mem() {
167        if want > avail {
168            tracing::warn!(
169                "закрепление {:.1} ГБ при доступных {:.1} ГБ из {:.1} — \
170                 закрепляю столько, сколько поместится, остальное останется \
171                 подкачиваемым",
172                want as f64 / 1e9,
173                avail as f64 / 1e9,
174                total as f64 / 1e9
175            );
176        }
177    }
178    let mut out = Pinned {
179        bytes: 0,
180        tensors: 0,
181        skipped: 0,
182        limit,
183    };
184    let mut fails = 0usize;
185    for n in names {
186        let Ok(b) = model.tensor_bytes(n) else {
187            out.skipped += 1;
188            continue;
189        };
190        match lock_slice(b) {
191            Ok(()) => {
192                out.bytes += b.len() as u64;
193                out.tensors += 1;
194            }
195            Err(e) => {
196                // One oversized tensor is not a reason to abandon the rest —
197                // the first attempt gave up on the embedding and pinned
198                // nothing at all. Give up only when it is clearly hopeless.
199                out.skipped += 1;
200                fails += 1;
201                if fails == 1 {
202                    tracing::warn!("первое закрепление не удалось ({n}): {e}");
203                }
204                if fails >= 64 && out.bytes < 1 << 30 {
205                    tracing::warn!(
206                        "закрепление упирается в RLIMIT_MEMLOCK = {:.0} МБ и поднять \
207                         его не дали (контейнеры обычно снимают CAP_SYS_RESOURCE). \
208                         Лечится вне процесса: `--ulimit memlock=-1` у докера, \
209                         LimitMEMLOCK=infinity в systemd, или запуск с этой \
210                         привилегией. Пропущено {} тензоров.",
211                        limit.unwrap_or(0) as f64 / 1e6,
212                        names.len() - out.skipped
213                    );
214                    break;
215                }
216            }
217        }
218    }
219    out
220}