Skip to main content

scx_mitosis/
config.rs

1// Copyright (c) Meta Platforms, Inc. and affiliates.
2
3// This software may be used and distributed according to the terms of the
4// GNU General Public License version 2.
5
6use std::collections::{HashMap, HashSet};
7use std::fs;
8use std::os::unix::fs::MetadataExt;
9use std::path::{Path, PathBuf};
10
11use anyhow::Context;
12use anyhow::Result;
13use anyhow::bail;
14use regex::Regex;
15use scx_utils::Cpumask;
16use serde::Deserialize;
17use serde::de;
18use serde_json::Value;
19use tracing::debug;
20
21use crate::cell_manager::CpuRecipient;
22
23#[derive(Clone, Debug)]
24pub struct ConfiguredSubcell {
25    pub id: u32,
26    pub name: String,
27    pub matches: Vec<Vec<SubcellMatch>>,
28}
29
30#[derive(Clone, Debug)]
31pub struct ConfiguredCell {
32    pub id: u32,
33    pub subcells: Vec<ConfiguredSubcell>,
34}
35
36#[derive(Clone, Debug)]
37pub struct ConfiguredCellResolution {
38    pub cell_assignments: Vec<(u64, u32)>,
39    pub cell_recipients: Vec<CpuRecipient>,
40    pub cells: Vec<ConfiguredCell>,
41}
42
43#[derive(Clone, Debug, Deserialize, PartialEq, Eq)]
44pub enum SubcellMatch {
45    CommPrefix(String),
46}
47
48#[derive(Clone, Debug, Deserialize)]
49enum CellMatch {
50    CgroupContains(String),
51    CgroupRegex(String),
52}
53
54#[derive(Clone, Debug, Deserialize)]
55struct CellSpec {
56    name: String,
57    #[serde(default, deserialize_with = "deserialize_optional_cell_match")]
58    matches: Option<CellMatch>,
59    #[serde(default)]
60    subcells: Vec<SubcellSpec>,
61}
62
63#[derive(Clone, Debug, Deserialize)]
64struct SubcellSpec {
65    name: String,
66    #[serde(default = "default_subcell_matches")]
67    matches: Vec<Vec<SubcellMatch>>,
68}
69
70#[derive(Clone, Debug)]
71struct CompiledCellSpec {
72    matcher: Option<CompiledCellMatch>,
73    subcells: Vec<ConfiguredSubcell>,
74}
75
76#[derive(Clone, Debug)]
77enum CompiledCellMatch {
78    CgroupContains(String),
79    CgroupRegex(Regex),
80}
81
82#[derive(Clone, Debug)]
83struct CgroupEntry {
84    path: PathBuf,
85    path_string: String,
86    cgid: u64,
87    cpuset: Option<Cpumask>,
88}
89
90#[derive(Clone, Debug)]
91struct MatchedCgroup {
92    spec_idx: usize,
93    cgid: u64,
94    cpuset: Option<Cpumask>,
95}
96
97#[derive(Clone, Debug)]
98pub struct ConfiguredCells {
99    specs: Vec<CompiledCellSpec>,
100    root_spec_idx: Option<usize>,
101    cgroup_root: PathBuf,
102    all_cpus: Cpumask,
103    max_cells: u32,
104    path_cell_ids: HashMap<PathBuf, u32>,
105    free_cell_ids: Vec<u32>,
106    next_cell_id: u32,
107}
108
109impl ConfiguredCells {
110    pub fn load(path: &Path, max_cells: u32, all_cpus: Cpumask) -> Result<Self> {
111        Self::load_with_root(path, PathBuf::from("/sys/fs/cgroup"), max_cells, all_cpus)
112    }
113
114    fn load_with_root(
115        path: &Path,
116        cgroup_root: PathBuf,
117        max_cells: u32,
118        all_cpus: Cpumask,
119    ) -> Result<Self> {
120        let contents = fs::read_to_string(path)
121            .with_context(|| format!("reading cell config {}", path.display()))?;
122        let specs: Vec<CellSpec> = serde_json::from_str(&contents)
123            .with_context(|| format!("parsing cell config {}", path.display()))?;
124        let specs = compile_specs(specs)?;
125        let root_spec_idx = specs.iter().rposition(|spec| spec.matcher.is_none());
126        if specs
127            .iter()
128            .enumerate()
129            .any(|(idx, spec)| spec.matcher.is_none() && Some(idx) != root_spec_idx)
130        {
131            bail!("only the final catch-all cell spec may use an empty match");
132        }
133
134        Ok(Self {
135            specs,
136            root_spec_idx,
137            cgroup_root,
138            all_cpus,
139            max_cells,
140            path_cell_ids: HashMap::new(),
141            free_cell_ids: Vec::new(),
142            next_cell_id: 1,
143        })
144    }
145
146    pub fn resolve(
147        &mut self,
148        cell_demands: Option<&HashMap<u32, f64>>,
149        cell0_min_cpus: usize,
150    ) -> Result<ConfiguredCellResolution> {
151        let cgroups = collect_cgroups(&self.cgroup_root)?;
152        let matched = self.match_cgroups(&cgroups);
153        self.reconcile_cell_ids(&matched)?;
154
155        let mut cell_assignments = Vec::new();
156        let mut cell_recipients = Vec::new();
157        let mut cells = Vec::new();
158
159        cells.push(ConfiguredCell {
160            id: 0,
161            subcells: self.root_subcells(),
162        });
163        cell_recipients.push(CpuRecipient::root(
164            0,
165            demand_weight(cell_demands, 0),
166            &self.all_cpus,
167            cell0_min_cpus,
168        ));
169
170        let mut matched_paths: Vec<_> = matched.keys().cloned().collect();
171        matched_paths.sort();
172
173        for path in matched_paths {
174            let matched_cgroup = matched
175                .get(&path)
176                .expect("BUG: matched path disappeared during config resolution");
177            let cell_id = *self
178                .path_cell_ids
179                .get(&path)
180                .expect("BUG: matched path is missing a cell id");
181            let spec = self
182                .specs
183                .get(matched_cgroup.spec_idx)
184                .expect("BUG: matched cgroup references missing spec");
185
186            cell_assignments.push((matched_cgroup.cgid, cell_id));
187            let weight = demand_weight(cell_demands, cell_id);
188            cell_recipients.push(match &matched_cgroup.cpuset {
189                Some(cpuset) => CpuRecipient::pinned(cell_id, weight, cpuset),
190                None => CpuRecipient::unpinned(cell_id, weight, &self.all_cpus),
191            });
192            cells.push(ConfiguredCell {
193                id: cell_id,
194                subcells: spec.subcells.clone(),
195            });
196        }
197
198        Ok(ConfiguredCellResolution {
199            cell_assignments,
200            cell_recipients,
201            cells,
202        })
203    }
204
205    pub fn format_cell_config(&self, assignments: &[crate::cell_manager::CpuAssignment]) -> String {
206        let names: HashMap<u32, String> = self
207            .path_cell_ids
208            .iter()
209            .map(|(path, id)| {
210                let name = path
211                    .strip_prefix(&self.cgroup_root)
212                    .ok()
213                    .and_then(|rel| rel.to_str())
214                    .filter(|rel| !rel.is_empty())
215                    .map(|rel| format!("/{}", rel.trim_start_matches('/')))
216                    .unwrap_or_else(|| path.display().to_string());
217                (*id, name)
218            })
219            .collect();
220
221        assignments
222            .iter()
223            .map(|assignment| {
224                let name = names
225                    .get(&assignment.id)
226                    .cloned()
227                    .unwrap_or_else(|| "root".to_string());
228                format!(
229                    "cell{}({})={}",
230                    assignment.id,
231                    name,
232                    assignment.primary.to_cpulist()
233                )
234            })
235            .collect::<Vec<_>>()
236            .join(" ")
237    }
238
239    fn match_cgroups(&self, cgroups: &[CgroupEntry]) -> HashMap<PathBuf, MatchedCgroup> {
240        let mut raw_matches = HashMap::new();
241
242        for cgroup in cgroups {
243            if cgroup.path == self.cgroup_root {
244                continue;
245            }
246
247            for (spec_idx, spec) in self.specs.iter().enumerate() {
248                if Some(spec_idx) == self.root_spec_idx {
249                    continue;
250                }
251                if !spec.matches(&cgroup.path_string) {
252                    continue;
253                }
254
255                raw_matches.insert(
256                    cgroup.path.clone(),
257                    MatchedCgroup {
258                        spec_idx,
259                        cgid: cgroup.cgid,
260                        cpuset: cgroup.cpuset.clone(),
261                    },
262                );
263                break;
264            }
265        }
266
267        let mut paths: Vec<_> = raw_matches.keys().cloned().collect();
268        paths.sort_by_key(|path| path.components().count());
269
270        let mut matched: HashMap<PathBuf, MatchedCgroup> = HashMap::new();
271        for path in paths {
272            let raw_match = raw_matches
273                .get(&path)
274                .expect("BUG: raw matched path disappeared");
275            let covered_by_ancestor = path.ancestors().skip(1).any(|ancestor| {
276                matched
277                    .get(ancestor)
278                    .is_some_and(|ancestor_match| ancestor_match.spec_idx <= raw_match.spec_idx)
279            });
280            if covered_by_ancestor {
281                continue;
282            }
283
284            matched.insert(path.clone(), raw_match.clone());
285        }
286
287        matched
288    }
289
290    fn reconcile_cell_ids(&mut self, matched: &HashMap<PathBuf, MatchedCgroup>) -> Result<()> {
291        let matched_paths: HashSet<_> = matched.keys().cloned().collect();
292        let removed: Vec<_> = self
293            .path_cell_ids
294            .keys()
295            .filter(|path| !matched_paths.contains(*path))
296            .cloned()
297            .collect();
298
299        for path in removed {
300            if let Some(cell_id) = self.path_cell_ids.remove(&path) {
301                debug!("Removed configured cell {} for {}", cell_id, path.display());
302                self.free_cell_ids.push(cell_id);
303            }
304        }
305
306        let mut paths: Vec<_> = matched.keys().cloned().collect();
307        paths.sort();
308        for path in paths {
309            if self.path_cell_ids.contains_key(&path) {
310                continue;
311            }
312            let cell_id = self.allocate_cell_id()?;
313            debug!("Created configured cell {} for {}", cell_id, path.display());
314            self.path_cell_ids.insert(path, cell_id);
315        }
316
317        Ok(())
318    }
319
320    fn allocate_cell_id(&mut self) -> Result<u32> {
321        if let Some(id) = self.free_cell_ids.pop() {
322            return Ok(id);
323        }
324        if self.next_cell_id >= self.max_cells {
325            bail!("Cell ID space exhausted (max_cells={})", self.max_cells);
326        }
327        let id = self.next_cell_id;
328        self.next_cell_id += 1;
329        Ok(id)
330    }
331
332    fn root_subcells(&self) -> Vec<ConfiguredSubcell> {
333        self.root_spec_idx
334            .and_then(|idx| self.specs.get(idx))
335            .map(|spec| spec.subcells.clone())
336            .unwrap_or_else(default_configured_subcells)
337    }
338
339    pub fn all_cpus(&self) -> &Cpumask {
340        &self.all_cpus
341    }
342
343    /// Cgroup path of `cell_id` relative to the cgroup root, or "/" for the
344    /// root cell and for ids that no longer map to a matched cgroup.
345    pub fn cgroup_path_for_cell(&self, cell_id: u32) -> String {
346        self.path_cell_ids
347            .iter()
348            .find(|&(_, &id)| id == cell_id)
349            .map(|(path, _)| {
350                path.strip_prefix(&self.cgroup_root)
351                    .map(|rel| format!("/{}", rel.to_string_lossy()))
352                    .unwrap_or_else(|_| path.to_string_lossy().into_owned())
353            })
354            .unwrap_or_else(|| "/".to_string())
355    }
356}
357
358impl CompiledCellSpec {
359    fn matches(&self, cgroup_path: &str) -> bool {
360        match &self.matcher {
361            Some(CompiledCellMatch::CgroupContains(substr)) => cgroup_path.contains(substr),
362            Some(CompiledCellMatch::CgroupRegex(regex)) => regex.is_match(cgroup_path),
363            None => true,
364        }
365    }
366}
367
368fn compile_specs(specs: Vec<CellSpec>) -> Result<Vec<CompiledCellSpec>> {
369    if specs.is_empty() {
370        bail!("cell config must contain at least one cell spec");
371    }
372
373    specs
374        .into_iter()
375        .map(|spec| {
376            let matcher = match spec.matches {
377                Some(CellMatch::CgroupContains(substr)) => {
378                    Some(CompiledCellMatch::CgroupContains(substr))
379                }
380                Some(CellMatch::CgroupRegex(expr)) => Some(CompiledCellMatch::CgroupRegex(
381                    Regex::new(&expr).with_context(|| {
382                        format!("invalid CgroupRegex '{}' for cell '{}'", expr, spec.name)
383                    })?,
384                )),
385                None => None,
386            };
387
388            Ok(CompiledCellSpec {
389                matcher,
390                subcells: normalize_subcells(spec.subcells)?,
391            })
392        })
393        .collect()
394}
395
396fn normalize_subcells(subcells: Vec<SubcellSpec>) -> Result<Vec<ConfiguredSubcell>> {
397    if subcells.is_empty() {
398        return Ok(default_configured_subcells());
399    }
400
401    let mut normalized = Vec::new();
402    let mut next_id = 1;
403    let mut catch_all = None;
404
405    for subcell in subcells {
406        if is_catch_all_subcell(&subcell) {
407            if catch_all.is_some() {
408                bail!("cell config contains multiple catch-all subcells");
409            }
410            catch_all = Some(ConfiguredSubcell {
411                id: 0,
412                name: subcell.name,
413                matches: subcell.matches,
414            });
415            continue;
416        }
417
418        normalized.push(ConfiguredSubcell {
419            id: next_id,
420            name: subcell.name,
421            matches: subcell.matches,
422        });
423        next_id += 1;
424    }
425
426    let mut result = vec![catch_all.unwrap_or_else(default_catch_all_subcell)];
427    result.extend(normalized);
428
429    if result.len() > crate::MAX_SUBCELLS_PER_CELL {
430        bail!(
431            "cell config has too many subcells: {} > {}",
432            result.len(),
433            crate::MAX_SUBCELLS_PER_CELL
434        );
435    }
436
437    Ok(result)
438}
439
440fn is_catch_all_subcell(subcell: &SubcellSpec) -> bool {
441    subcell.matches.iter().any(|ands| ands.is_empty())
442}
443
444fn default_configured_subcells() -> Vec<ConfiguredSubcell> {
445    vec![default_catch_all_subcell()]
446}
447
448fn default_catch_all_subcell() -> ConfiguredSubcell {
449    ConfiguredSubcell {
450        id: 0,
451        name: "rest".to_string(),
452        matches: vec![Vec::new()],
453    }
454}
455
456fn default_subcell_matches() -> Vec<Vec<SubcellMatch>> {
457    vec![Vec::new()]
458}
459
460fn demand_weight(cell_demands: Option<&HashMap<u32, f64>>, cell_id: u32) -> f64 {
461    cell_demands
462        .and_then(|demands| demands.get(&cell_id).copied())
463        .unwrap_or(1.0)
464        .max(0.0)
465}
466
467fn collect_cgroups(root: &Path) -> Result<Vec<CgroupEntry>> {
468    let mut cgroups = Vec::new();
469    collect_cgroups_inner(root, &mut cgroups)?;
470    cgroups.sort_by(|a, b| a.path.cmp(&b.path));
471    Ok(cgroups)
472}
473
474fn collect_cgroups_inner(path: &Path, cgroups: &mut Vec<CgroupEntry>) -> Result<()> {
475    let metadata = match fs::metadata(path) {
476        Ok(metadata) => metadata,
477        Err(e) if e.kind() == std::io::ErrorKind::NotFound => return Ok(()),
478        Err(e) => {
479            return Err(e).with_context(|| format!("reading metadata for {}", path.display()));
480        }
481    };
482
483    if !metadata.is_dir() {
484        return Ok(());
485    }
486
487    // Transient cgroups (oneshot services, exiting containers) can disappear
488    // between listing the parent and descending into them. Treat every
489    // NotFound along the walk as "this cgroup is gone" and skip it; the next
490    // resolve sees the tree as it is then.
491    let entries = match fs::read_dir(path) {
492        Ok(entries) => entries,
493        Err(e) if e.kind() == std::io::ErrorKind::NotFound => return Ok(()),
494        Err(e) => return Err(e).with_context(|| format!("reading {}", path.display())),
495    };
496
497    cgroups.push(CgroupEntry {
498        path: path.to_path_buf(),
499        path_string: path.display().to_string(),
500        cgid: metadata.ino(),
501        cpuset: read_cpuset(path)?,
502    });
503
504    for entry in entries {
505        let entry = match entry {
506            Ok(entry) => entry,
507            Err(e) if e.kind() == std::io::ErrorKind::NotFound => break,
508            Err(e) => {
509                return Err(e)
510                    .with_context(|| format!("reading directory entry in {}", path.display()));
511            }
512        };
513        let file_type = match entry.file_type() {
514            Ok(file_type) => file_type,
515            Err(e) if e.kind() == std::io::ErrorKind::NotFound => continue,
516            Err(e) => {
517                return Err(e)
518                    .with_context(|| format!("reading file type for {}", entry.path().display()));
519            }
520        };
521        if file_type.is_dir() {
522            collect_cgroups_inner(&entry.path(), cgroups)?;
523        }
524    }
525
526    Ok(())
527}
528
529fn read_cpuset(cgroup_path: &Path) -> Result<Option<Cpumask>> {
530    let cpuset_path = cgroup_path.join("cpuset.cpus");
531    match fs::read_to_string(&cpuset_path) {
532        Ok(content) => {
533            let content = content.trim();
534            if content.is_empty() {
535                Ok(None)
536            } else {
537                Cpumask::from_cpulist(content)
538                    .with_context(|| {
539                        format!(
540                            "failed to parse cpuset '{}' from {}",
541                            content,
542                            cpuset_path.display()
543                        )
544                    })
545                    .map(Some)
546            }
547        }
548        Err(e) if e.kind() == std::io::ErrorKind::NotFound => Ok(None),
549        Err(e) => Err(e).with_context(|| format!("reading {}", cpuset_path.display())),
550    }
551}
552
553fn deserialize_optional_cell_match<'de, D>(deserializer: D) -> Result<Option<CellMatch>, D::Error>
554where
555    D: serde::Deserializer<'de>,
556{
557    let value = Value::deserialize(deserializer)?;
558    if value.as_object().is_some_and(|obj| obj.is_empty()) {
559        return Ok(None);
560    }
561    serde_json::from_value(value)
562        .map(Some)
563        .map_err(de::Error::custom)
564}
565
566#[cfg(test)]
567mod tests {
568    use super::*;
569    use std::io::Write;
570
571    use tempfile::NamedTempFile;
572    use tempfile::TempDir;
573
574    fn write_config(contents: &str) -> NamedTempFile {
575        let mut file = NamedTempFile::new().unwrap();
576        file.write_all(contents.as_bytes()).unwrap();
577        file
578    }
579
580    #[test]
581    fn parses_example_config_and_makes_rest_subcell_zero() {
582        let config = write_config(
583            r#"
584            [
585              {
586                "name": "group",
587                "matches": { "CgroupRegex": "abc-xyz-[^/]+\\.group\\.slice" },
588                "subcells": [
589                  { "name": "worker-a", "matches": [[{ "CommPrefix": "worker-a" }]] },
590                  { "name": "worker-b", "matches": [[{ "CommPrefix": "worker-b" }]] },
591                  { "name": "rest", "matches": [[]] }
592                ]
593              },
594              { "name": "workload.slice", "matches": { "CgroupContains": "workload.slice" } },
595              { "name": "rest", "matches": {} }
596            ]
597            "#,
598        );
599        let configured = ConfiguredCells::load_with_root(
600            config.path(),
601            PathBuf::from("/tmp"),
602            256,
603            Cpumask::new(),
604        )
605        .unwrap();
606
607        assert_eq!(configured.root_spec_idx, Some(2));
608        let subcells = &configured.specs[0].subcells;
609        assert_eq!(subcells[0].id, 0);
610        assert_eq!(subcells[0].name, "rest");
611        assert_eq!(subcells[1].id, 1);
612        assert_eq!(subcells[1].name, "worker-a");
613        assert_eq!(subcells[2].id, 2);
614        assert_eq!(subcells[2].name, "worker-b");
615    }
616
617    #[test]
618    fn descendant_inherits_same_matching_cell_spec() {
619        let config = write_config(
620            r#"
621            [
622              { "name": "parent", "matches": { "CgroupContains": "parent.slice" } },
623              { "name": "rest", "matches": {} }
624            ]
625            "#,
626        );
627        let root = TempDir::new().unwrap();
628        fs::create_dir(root.path().join("parent.slice")).unwrap();
629        fs::create_dir(root.path().join("parent.slice/child.scope")).unwrap();
630
631        let mut configured = ConfiguredCells::load_with_root(
632            config.path(),
633            root.path().to_path_buf(),
634            256,
635            Cpumask::new(),
636        )
637        .unwrap();
638        let resolution = configured.resolve(None, 0).unwrap();
639
640        assert_eq!(resolution.cell_assignments.len(), 1);
641    }
642
643    #[test]
644    fn higher_priority_descendant_gets_own_cell() {
645        let config = write_config(
646            r#"
647            [
648              { "name": "child", "matches": { "CgroupContains": "child.scope" } },
649              { "name": "parent", "matches": { "CgroupContains": "parent.slice" } },
650              { "name": "rest", "matches": {} }
651            ]
652            "#,
653        );
654        let root = TempDir::new().unwrap();
655        fs::create_dir(root.path().join("parent.slice")).unwrap();
656        fs::create_dir(root.path().join("parent.slice/child.scope")).unwrap();
657
658        let mut configured = ConfiguredCells::load_with_root(
659            config.path(),
660            root.path().to_path_buf(),
661            256,
662            Cpumask::new(),
663        )
664        .unwrap();
665        let resolution = configured.resolve(None, 0).unwrap();
666
667        assert_eq!(resolution.cell_assignments.len(), 2);
668    }
669}