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}