ktstr/scenario/ops/types/
resolve.rs

1//! [`CpusetSpec`] → concrete [`BTreeSet<usize>`] resolution
2//! (`validate` / `resolve` / `resolve_quiet` / `resolve_inner`).
3//! Sibling to the construction impl block in [`super::op`]; this
4//! file holds the topology-aware logic that maps a spec onto the
5//! `Ctx`-observed usable-CPU set.
6
7use std::collections::BTreeSet;
8
9use crate::scenario::Ctx;
10
11use super::CpusetSpec;
12
13// ---------------------------------------------------------------------------
14// CpusetSpec resolution
15// ---------------------------------------------------------------------------
16
17impl CpusetSpec {
18    /// Check whether this spec can produce a non-empty cpuset for the
19    /// given topology. Returns `Err` with a human-readable reason on
20    /// failure.
21    pub fn validate(&self, ctx: &Ctx) -> std::result::Result<(), String> {
22        let usable = ctx.topo.usable_cpus();
23        match self {
24            CpusetSpec::Llc(idx) if *idx >= ctx.topo.num_llcs() => Err(format!(
25                "Llc({idx}) out of range: topology has {} LLCs",
26                ctx.topo.num_llcs()
27            )),
28            CpusetSpec::Numa(node) if *node >= ctx.topo.num_numa_nodes() => Err(format!(
29                "Numa({node}) out of range: topology has {} NUMA nodes",
30                ctx.topo.num_numa_nodes()
31            )),
32            CpusetSpec::Disjoint { of, .. } | CpusetSpec::Overlap { of, .. } if *of == 0 => {
33                Err("partition count (of) must be > 0".into())
34            }
35            CpusetSpec::Disjoint { index, of, .. } | CpusetSpec::Overlap { index, of, .. }
36                if *index >= *of =>
37            {
38                Err(format!("index {index} >= partition count {of}"))
39            }
40            CpusetSpec::Range {
41                start_frac,
42                end_frac,
43            } if !start_frac.is_finite() || !end_frac.is_finite() => Err(format!(
44                "Range start_frac ({start_frac}) or end_frac ({end_frac}) is not finite"
45            )),
46            CpusetSpec::Range {
47                start_frac,
48                end_frac,
49            } if *start_frac < 0.0 || *end_frac > 1.0 => Err(format!(
50                "Range fracs must lie in [0.0, 1.0]: start_frac={start_frac}, end_frac={end_frac}"
51            )),
52            CpusetSpec::Range {
53                start_frac,
54                end_frac,
55            } if start_frac >= end_frac => Err(format!(
56                "Range start_frac ({start_frac}) >= end_frac ({end_frac})"
57            )),
58            CpusetSpec::Overlap { frac, .. } if !frac.is_finite() => {
59                Err(format!("Overlap frac ({frac}) is not finite"))
60            }
61            CpusetSpec::Overlap { frac, .. } if *frac < 0.0 || *frac > 1.0 => {
62                Err(format!("Overlap frac ({frac}) must lie in [0.0, 1.0]"))
63            }
64            CpusetSpec::Disjoint { of, .. } | CpusetSpec::Overlap { of, .. }
65                if usable.len() < *of =>
66            {
67                Err(format!(
68                    "not enough usable CPUs ({}) for {} partitions",
69                    usable.len(),
70                    of
71                ))
72            }
73            CpusetSpec::Exact(cpus) if cpus.is_empty() => {
74                Err("CpusetSpec::Exact(empty) would assign no CPUs to the \
75                 cgroup; cpuset.cpus rejects an empty mask and the \
76                 cgroup would become unschedulable"
77                    .into())
78            }
79            CpusetSpec::Exact(cpus) => {
80                // Reject only CPUs the topology doesn't physically have
81                // (`all_cpuset`), not the ones outside `usable_cpuset`.
82                // A scheduler author may intentionally pin to an
83                // isolated CPU (e.g. the root-reserved one) for
84                // testing; writing it to cpuset.cpus is a legitimate
85                // operation and the kernel is the final authority on
86                // whether the write succeeds. Only truly-nonexistent
87                // CPU indices are guaranteed to produce EINVAL.
88                let all = ctx.topo.all_cpuset();
89                let missing: Vec<usize> =
90                    cpus.iter().copied().filter(|c| !all.contains(c)).collect();
91                if !missing.is_empty() {
92                    return Err(format!(
93                        "CpusetSpec::Exact contains CPU(s) {missing:?} \
94                         outside the topology's physical CPU set (max \
95                         CPU index: {}); writing them to cpuset.cpus \
96                         would fail with EINVAL",
97                        all.iter().next_back().copied().unwrap_or(0),
98                    ));
99                }
100                Ok(())
101            }
102            _ => Ok(()),
103        }
104    }
105
106    /// Resolve to a concrete CPU set given the topology.
107    ///
108    /// **Callers SHOULD run [`Self::validate`] first and propagate
109    /// its error.** `apply_setup` and `apply_ops::SetCpuset` do so
110    /// via `anyhow::bail!`, then call [`Self::resolve_quiet`] which
111    /// skips the warns this method emits on degenerate inputs.
112    ///
113    /// Defense-in-depth: every malformed input that `validate`
114    /// rejects (out-of-range `Llc`/`Numa`, partition `of == 0`,
115    /// `index >= of`, inverted or non-finite `Range.start_frac` /
116    /// `end_frac`, out-of-bounds `Overlap.frac`) also has a
117    /// panic-free fallback here — out-of-range indices clamp to the
118    /// last valid index with a `tracing::warn!`, `of == 0` returns
119    /// an empty set with a warn, and inverted/non-finite fracs
120    /// clamp to `[0, len]` so the resulting slice never inverts.
121    /// Skipping `validate` therefore degrades into a usable
122    /// (possibly empty) cpuset rather than crashing the caller, but
123    /// the warns surface the silent-degradation case — a caller who
124    /// computed a CPU count via [`crate::scenario::Ctx::cpuset_cpus`]
125    /// (which doesn't validate) sees the warn instead of silently
126    /// planning against the wrong denominator.
127    pub fn resolve(&self, ctx: &Ctx) -> BTreeSet<usize> {
128        self.resolve_inner(ctx, false)
129    }
130
131    /// Like [`Self::resolve`] but suppresses the degenerate-input
132    /// `tracing::warn!`s. Use this from call sites that pair the
133    /// resolution with a [`Self::validate`] call (either before or
134    /// after this one) and bail on its error — validate is the
135    /// canonical error channel for malformed specs, and a warn
136    /// here would be redundant noise on a path already known-
137    /// broken via the validate gate. `apply_setup` resolves first
138    /// (to keep the workers_pct empty-cpuset diagnostic ahead of
139    /// validate's generic empty-Exact rejection) and validates
140    /// after; `Op::SetCpuset` validates first and resolves after.
141    /// Both patterns satisfy the contract.
142    pub fn resolve_quiet(&self, ctx: &Ctx) -> BTreeSet<usize> {
143        self.resolve_inner(ctx, true)
144    }
145
146    fn resolve_inner(&self, ctx: &Ctx, quiet: bool) -> BTreeSet<usize> {
147        let usable = ctx.topo.usable_cpus();
148        match self {
149            CpusetSpec::Llc(idx) => {
150                if *idx >= ctx.topo.num_llcs() {
151                    // Graceful fallback: clamp to last LLC instead of panicking.
152                    let clamped = ctx.topo.num_llcs().saturating_sub(1);
153                    if !quiet {
154                        tracing::warn!(
155                            llc_idx = idx,
156                            num_llcs = ctx.topo.num_llcs(),
157                            clamped,
158                            "CpusetSpec::Llc index out of range, clamping",
159                        );
160                    }
161                    ctx.topo.llc_aligned_cpuset(clamped)
162                } else {
163                    ctx.topo.llc_aligned_cpuset(*idx)
164                }
165            }
166            CpusetSpec::Numa(idx) => {
167                if *idx >= ctx.topo.num_numa_nodes() {
168                    let clamped = ctx.topo.num_numa_nodes().saturating_sub(1);
169                    if !quiet {
170                        tracing::warn!(
171                            numa_node = idx,
172                            num_numa_nodes = ctx.topo.num_numa_nodes(),
173                            clamped,
174                            "CpusetSpec::Numa index out of range, clamping",
175                        );
176                    }
177                    ctx.topo.numa_aligned_cpuset(clamped)
178                } else {
179                    ctx.topo.numa_aligned_cpuset(*idx)
180                }
181            }
182            CpusetSpec::Range {
183                start_frac,
184                end_frac,
185            } => {
186                let len = usable.len();
187                // Defense-in-depth: clamp non-finite fracs to 0 (NaN
188                // would saturate to 0 via `as usize` anyway; explicit
189                // check matches validate's rejection reason).
190                let sf = if start_frac.is_finite() {
191                    *start_frac
192                } else {
193                    0.0
194                };
195                let ef = if end_frac.is_finite() { *end_frac } else { 0.0 };
196                let start = (len as f64 * sf) as usize;
197                let end = (len as f64 * ef) as usize;
198                // Guard against inverted Range (start_frac > end_frac)
199                // — `&usable[start..end]` panics when start > end even
200                // if both are clamped to `len`. `e = end.min(len).max(s)`
201                // clamps `e` up to `s`, so when start > end the slice is
202                // empty (`usable[s..s]`) instead of panicking.
203                let s = start.min(len);
204                let e = end.min(len).max(s);
205                usable[s..e].iter().copied().collect()
206            }
207            CpusetSpec::Disjoint { index, of } => {
208                if *of == 0 {
209                    // Defense-in-depth: `validate` rejects of==0 with a
210                    // clear error. If a caller reaches `resolve` with
211                    // of==0 anyway (skipped validate, or used a
212                    // malformed programmatic spec), returning an empty
213                    // set is safer than the div-by-zero panic.
214                    if !quiet {
215                        tracing::warn!("CpusetSpec::Disjoint with of=0 — returning empty cpuset");
216                    }
217                    return BTreeSet::new();
218                }
219                let chunk = usable.len() / of;
220                let start = index * chunk;
221                let end = if *index == of - 1 {
222                    usable.len()
223                } else {
224                    (index + 1) * chunk
225                };
226                let s = start.min(usable.len());
227                let e = end.min(usable.len()).max(s);
228                usable[s..e].iter().copied().collect()
229            }
230            CpusetSpec::Overlap { index, of, frac } => {
231                if *of == 0 {
232                    if !quiet {
233                        tracing::warn!("CpusetSpec::Overlap with of=0 — returning empty cpuset");
234                    }
235                    return BTreeSet::new();
236                }
237                let chunk = usable.len() / of;
238                // Clamp finite frac to [0.0, 1.0]; map non-finite frac
239                // to 0.0, so the overlap computation stays bounded.
240                let frac = if frac.is_finite() {
241                    frac.clamp(0.0, 1.0)
242                } else {
243                    0.0
244                };
245                let overlap = (chunk as f64 * frac) as usize;
246                let start = if *index == 0 {
247                    0
248                } else {
249                    (index * chunk).saturating_sub(overlap)
250                };
251                let end = if *index == of - 1 {
252                    usable.len()
253                } else {
254                    ((index + 1) * chunk + overlap).min(usable.len())
255                };
256                let s = start.min(usable.len());
257                let e = end.min(usable.len()).max(s);
258                usable[s..e].iter().copied().collect()
259            }
260            CpusetSpec::Exact(cpus) => cpus.clone(),
261        }
262    }
263}