scx_pandemonium/topology.rs
1// PANDEMONIUM CPU CACHE TOPOLOGY
2// PARSES SYSFS AT STARTUP, POPULATES BPF MAP FOR CACHE-AWARE DISPATCH
3//
4// BPF dispatch() USES THE CACHE DOMAIN MAP TO PREFER TASKS THAT LAST
5// RAN ON THE SAME CPU OR AN L2 SIBLING. THIS PRESERVES CACHE WARMTH
6// AND REDUCES THE THROUGHPUT GAP CAUSED BY BLIND NODE-DSQ CONSUMPTION.
7
8use anyhow::Result;
9
10use crate::scheduler::Scheduler;
11
12// FIEDLER-VALUE / TOPOLOGY TIME CONSTANT
13// lambda_2 IS THE SECOND-SMALLEST EIGENVALUE OF THE WEIGHTED GRAPH LAPLACIAN
14// (THE "ALGEBRAIC CONNECTIVITY" OR "SPECTRAL GAP"). 1/lambda_2 IS THE MIXING
15// TIME OF A RANDOM WALK ACROSS THE CPU GRAPH -- A CANONICAL "TIME CONSTANT"
16// FOR HOW FAST WORK PROPAGATES ACROSS THE TOPOLOGY. EVERY TIMING/THRESHOLD
17// FORMULA IN THE SCHEDULER DERIVES FROM tau VIA scale_tau() (BPF) OR
18// scale_tau_u64() (RUST); ad-hoc nr_cpus FORMULAS ARE CRUDE APPROXIMATIONS
19// OF THIS AND ARE EXPLICITLY MIGRATED OUT.
20//
21// CARVE-OUT: ONLY ABSOLUTE-COUNT QUANTITIES KEEP nr_cpu_ids. GRAPH-SHAPE
22// QUANTITIES (including search budgets sized by spectral connectivity) ARE
23// EXPRESSED THROUGH tau VIA lambda_2 = TAU_SCALE_NS / tau. TWO SITES IN
24// main.bpf.c INTENTIONALLY KEEP nr_cpu_ids:
25// - select_cpu()'s wake_wide() flips threshold (matches the kernel's
26// wake_wide() convention; an external interface).
27// - tick()'s rotating-scan budget switch (coverage over the active CPU
28// range, not a graph-shape decision).
29// Everything else -- timing, oscillator dynamics, search budgets, depth
30// gates -- derives from tau in apply_tau_scaling().
31//
32// EXTRACTION IS O(n log n) ON TOP OF THE EXISTING O(n^3) Jacobi; NEGLIGIBLE.
33// REFERENCE: CHEEGER'S INEQUALITY BOUNDS lambda_2 AGAINST GRAPH BOTTLENECK.
34const LAMBDA_ZERO_EPS: f64 = 1e-8;
35const TAU_SCALE_NS: f64 = 1.6e8; // 160MS. CAPACITY-AWARE ANCHOR: AT THE
36 // 12C REFERENCE (lambda_2=12, N=12)
37 // tau = 160ms / sqrt(144) = 13.3MS.
38const TAU_FLOOR_NS: u64 = 1_000_000; // 1MS
39const TAU_CEIL_NS: u64 = 40_000_000; // 40MS
40
41// CoDel TARGET EQUILIBRIUM CLAMP RANGE. THE CONTROLLER'S MEAN-REVERTING
42// TARGET IN ABSENCE OF DISTURBANCE. SAME ORDER OF MAGNITUDE AS THE
43// CoDel TARGET RANGE ITSELF (FLOOR ~200us, CEILING ~8MS).
44const C_EQ_FLOOR_NS: u64 = 200_000; // 200us
45const C_EQ_CEIL_NS: u64 = 8_000_000; // 8ms
46
47#[derive(Clone, Copy, Debug)]
48pub struct TopologySpectrum {
49 pub fiedler: f64, // lambda_2
50 pub tau_ns: u64, // clamped TAU_SCALE_NS / lambda_2
51 pub codel_eq_ns: u64, // <R_eff> * 2m * tau, clamped
52 // Phi migration-potential distance->wait scale (Q16). The extra head-wait a
53 // steal must clear before crossing to a peer = (reff * this) >> 16 ns. Always
54 // computed now (T1): the continuous metric prices distance on every part, no
55 // binary topology gate -- on a monolithic part it calibrates to the L2 seam.
56 pub phi_dist_scale_q16: u64,
57}
58
59// T2: the emergent domain tree. Leaves are tightly-coupled CPU sets (an L2 group
60// / core -- nothing meaningful to partition below); internal nodes are a
61// min-conductance cut carrying its phi, the price to cross that seam. T3's
62// bounded-local steal climbs this tree: drain your leaf, then your subtree,
63// crossing a cut only when its phi says the imbalance pays. The discrete cache domain
64// enum is gone -- this structure emerges from the cache graph, per machine.
65#[derive(Debug, Clone)]
66pub enum DomainNode {
67 Leaf(Vec<usize>),
68 Cut {
69 phi: f64,
70 left: Box<DomainNode>,
71 right: Box<DomainNode>,
72 },
73}
74
75#[allow(dead_code)]
76impl DomainNode {
77 // Flatten to the leaf CPU sets -- each an emergent atomic domain.
78 pub fn leaves(&self) -> Vec<Vec<usize>> {
79 match self {
80 DomainNode::Leaf(cpus) => vec![cpus.clone()],
81 DomainNode::Cut { left, right, .. } => {
82 let mut v = left.leaves();
83 v.extend(right.leaves());
84 v
85 }
86 }
87 }
88
89 // Every cut's phi -- the crossing-price ladder (coarser seams cost less, so
90 // phi rises with depth as the steal climbs toward more tightly-coupled work).
91 pub fn cut_phis(&self) -> Vec<f64> {
92 match self {
93 DomainNode::Leaf(_) => Vec::new(),
94 DomainNode::Cut { phi, left, right } => {
95 let mut v = vec![*phi];
96 v.extend(left.cut_phis());
97 v.extend(right.cut_phis());
98 v
99 }
100 }
101 }
102}
103
104fn extract_fiedler(eigenvalues: &[f64]) -> f64 {
105 // Jacobi RETURNS EIGENVALUES UNSORTED. FOR A CONNECTED LAPLACIAN THE
106 // SMALLEST EIGENVALUE IS 0 (SKIPPED VIA LAMBDA_ZERO_EPS). FOR A
107 // DISCONNECTED GRAPH (HOTPLUG PARTITION) SEVERAL EIGENVALUES ARE ~0;
108 // lambda_2 IS THE SMALLEST STRICTLY POSITIVE ONE.
109 let mut v: Vec<f64> = eigenvalues.to_vec();
110 v.sort_by(|a, b| a.partial_cmp(b).unwrap_or(std::cmp::Ordering::Equal));
111 v.into_iter()
112 .find(|&x| x > LAMBDA_ZERO_EPS)
113 .unwrap_or(LAMBDA_ZERO_EPS)
114}
115
116fn compute_tau_ns(fiedler: f64, n: usize) -> u64 {
117 // CAPACITY-AWARE: tau = TAU_SCALE_NS / sqrt(lambda_2 * N) -- the geometric
118 // mean of connectivity (1/lambda_2) and capacity (1/sqrt(N)). The old
119 // pure-connectivity law gave a well-connected but capacity-starved
120 // topology (a 2-core L2 pair, lambda_2=20) a tiny tau (8ms) and thus tight
121 // tolerances exactly where scarce CPUs need loose ones -- which the
122 // apply_tau_scaling floors then patched back up. sqrt(N) penalizes small N,
123 // so 2C loosens to ~25ms with no floor needed. The 12C reference is
124 // preserved: lambda_2=12, N=12 -> sqrt(144)=12 -> 160ms/12 = 13.3ms.
125 let denom = (fiedler.max(LAMBDA_ZERO_EPS) * (n.max(1) as f64)).sqrt();
126 let raw = TAU_SCALE_NS / denom.max(LAMBDA_ZERO_EPS);
127 (raw as u64).clamp(TAU_FLOOR_NS, TAU_CEIL_NS)
128}
129
130// CoDel TARGET EQUILIBRIUM FROM THE LAPLACIAN SPECTRUM.
131// FORMULA: c_eq = <R_eff> * 2m * tau
132// SPECTRAL FORM:
133// <R_eff> = Tr(L+) / N = (1/N) * sum_{lambda > 0} 1 / lambda
134// 2m = Tr(L) = sum_{lambda} lambda
135// tau = TAU_SCALE_NS / lambda_2 (already computed, in ns)
136//
137// PHYSICAL INTERPRETATION: c_eq is the natural commute-time scale of
138// the topology graph -- the average time it takes work to bounce
139// between two CPUs along the topology's slowest paths. The CoDel
140// target's mean-reverting equilibrium settles to this value in the
141// absence of disturbance, so the stall detector tightens around the
142// topology's intrinsic timescale instead of a hand-picked constant.
143//
144// CLAMPED TO [200us, 8ms] -- THE CoDel TARGET RANGE ITSELF.
145fn compute_codel_eq_ns(eigenvalues: &[f64], n: usize, tau_ns: u64) -> u64 {
146 if n == 0 {
147 return TAU_FLOOR_NS;
148 }
149 let mut sum_inv_lambda = 0.0f64;
150 let mut sum_lambda = 0.0f64;
151 for &lambda in eigenvalues {
152 sum_lambda += lambda;
153 if lambda > LAMBDA_ZERO_EPS {
154 sum_inv_lambda += 1.0 / lambda;
155 }
156 }
157 let avg_reff = sum_inv_lambda / n as f64;
158 let two_m = sum_lambda;
159 let raw_ns = avg_reff * two_m * tau_ns as f64;
160 (raw_ns as u64).clamp(C_EQ_FLOOR_NS, C_EQ_CEIL_NS)
161}
162
163#[allow(dead_code)]
164pub struct CpuTopology {
165 pub nr_cpus: usize,
166 pub l2_domain: Vec<u32>, // l2_domain[cpu] = group_id
167 pub l2_groups: Vec<Vec<u32>>, // l2_groups[group_id] = [cpu, ...]
168 pub socket_domain: Vec<u32>, // socket_domain[cpu] = socket_id
169 pub llc_domain: Vec<u32>, // llc_domain[cpu] = L3 GROUP (== socket WHEN MONOLITHIC)
170 pub nr_sockets: u32,
171}
172
173impl CpuTopology {
174 pub fn detect(nr_cpus: usize) -> Result<Self> {
175 let mut l2_domain = vec![0u32; nr_cpus];
176 let mut seen_groups: Vec<Vec<u32>> = Vec::new();
177
178 for cpu in 0..nr_cpus {
179 let path = format!(
180 "/sys/devices/system/cpu/cpu{}/cache/index2/shared_cpu_list",
181 cpu
182 );
183 let content = match std::fs::read_to_string(&path) {
184 Ok(s) => s,
185 Err(_) => {
186 // CPU MIGHT BE OFFLINE OR HAVE NO L2 INFO -- ASSIGN OWN GROUP
187 l2_domain[cpu] = cpu as u32;
188 continue;
189 }
190 };
191
192 let members = parse_cpu_list(content.trim());
193
194 // CHECK IF THIS GROUP ALREADY EXISTS
195 let group_id = match seen_groups.iter().position(|g| *g == members) {
196 Some(id) => id as u32,
197 None => {
198 let id = seen_groups.len() as u32;
199 seen_groups.push(members.clone());
200 id
201 }
202 };
203
204 l2_domain[cpu] = group_id;
205 }
206
207 // DETECT SOCKET (PHYSICAL PACKAGE)
208 let mut socket_domain = vec![0u32; nr_cpus];
209 let mut seen_sockets: Vec<u32> = Vec::new();
210
211 for cpu in 0..nr_cpus {
212 let path = format!(
213 "/sys/devices/system/cpu/cpu{}/topology/physical_package_id",
214 cpu
215 );
216 let pkg_id = match std::fs::read_to_string(&path) {
217 Ok(s) => s.trim().parse::<u32>().unwrap_or(0),
218 Err(_) => 0,
219 };
220 if !seen_sockets.contains(&pkg_id) {
221 seen_sockets.push(pkg_id);
222 }
223 let socket_idx = seen_sockets.iter().position(|&s| s == pkg_id).unwrap() as u32;
224 socket_domain[cpu] = socket_idx;
225 }
226
227 let nr_sockets = seen_sockets.len() as u32;
228
229 // DETECT L3 / cache domain / cache domain DOMAIN (index3). ON AMD multi-domain PARTS index3
230 // SUBDIVIDES THE SOCKET INTO cache domain GROUPS; ON MONOLITHIC-L3 PARTS IT
231 // SPANS THE WHOLE SOCKET. USE THE TIER ONLY WHEN IT GENUINELY
232 // SUBDIVIDES A SOCKET (MORE L3 GROUPS THAN SOCKETS) AND index3 WAS
233 // PRESENT FOR EVERY CPU; OTHERWISE llc_domain == socket_domain SO THE
234 // CROSS-DOMAIN RUNG IN build_laplacian NEVER FIRES (EXACT NO-OP).
235 let mut llc_domain = vec![0u32; nr_cpus];
236 let mut seen_llc: Vec<Vec<u32>> = Vec::new();
237 let mut llc_ok = true;
238 for cpu in 0..nr_cpus {
239 let path = format!(
240 "/sys/devices/system/cpu/cpu{}/cache/index3/shared_cpu_list",
241 cpu
242 );
243 let content = match std::fs::read_to_string(&path) {
244 Ok(s) => s,
245 Err(_) => {
246 llc_ok = false;
247 break;
248 }
249 };
250 let members = parse_cpu_list(content.trim());
251 let group_id = match seen_llc.iter().position(|g| *g == members) {
252 Some(id) => id as u32,
253 None => {
254 let id = seen_llc.len() as u32;
255 seen_llc.push(members);
256 id
257 }
258 };
259 llc_domain[cpu] = group_id;
260 }
261 // MAX_OVERFLOW_DOMAINS mirrors src/bpf/intf.h: the BPF side creates exactly
262 // this many per-domain overflow DSQs. If a (multi-socket, high-cache domain) box has
263 // more L3 groups than that, degrade to the socket domain rather than let
264 // a cache domain id index an uncreated DSQ -> dispatch failure -> ejection.
265 const MAX_OVERFLOW_DOMAINS: usize = 32;
266 let llc_subdivides = llc_ok
267 && seen_llc.len() > nr_sockets as usize
268 && seen_llc.len() <= MAX_OVERFLOW_DOMAINS;
269 if !llc_subdivides {
270 llc_domain = socket_domain.clone();
271 }
272
273 Ok(Self {
274 nr_cpus,
275 l2_domain,
276 l2_groups: seen_groups,
277 socket_domain,
278 llc_domain,
279 nr_sockets,
280 })
281 }
282
283 // WRITE L2 DOMAIN MAP TO BPF ARRAY VIA SCHEDULER
284 pub fn populate_bpf_map(&self, sched: &mut Scheduler) -> Result<()> {
285 for cpu in 0..self.nr_cpus {
286 sched.write_cache_domain(cpu as u32, self.l2_domain[cpu])?;
287 }
288 // nr_overflow_domains = number of distinct llc_domain values (the overflow-domain count)
289 let mut seen: Vec<u32> = Vec::new();
290 for &g in &self.llc_domain {
291 if !seen.contains(&g) {
292 seen.push(g);
293 }
294 }
295 sched.write_nr_overflow_domains(seen.len() as u32);
296 Ok(())
297 }
298
299 // WRITE L2 SIBLINGS FLAT ARRAY TO BPF MAP
300 // l2_siblings[group_id * 8 + slot] = cpu_id, SENTINEL u32::MAX MARKS END
301 pub fn populate_l2_siblings_map(&self, sched: &Scheduler) -> Result<()> {
302 const MAX_L2_SIBLINGS: usize = 8;
303 for (gid, members) in self.l2_groups.iter().enumerate() {
304 for (slot, &cpu) in members.iter().enumerate().take(MAX_L2_SIBLINGS) {
305 sched.write_l2_sibling(gid as u32, slot as u32, cpu)?;
306 }
307 if members.len() < MAX_L2_SIBLINGS {
308 sched.write_l2_sibling(gid as u32, members.len() as u32, u32::MAX)?;
309 }
310 }
311 Ok(())
312 }
313
314 // RESISTANCE AFFINITY (KYNG-DINIC ELECTRICAL FLOW MODEL)
315 //
316 // EFFECTIVE RESISTANCE R_eff(u,v) BETWEEN TWO CPUs CAPTURES THE TRUE
317 // MIGRATION COST THROUGH ALL TOPOLOGY PATHS. COMPUTED FROM THE LAPLACIAN
318 // PSEUDOINVERSE OF THE CPU TOPOLOGY GRAPH:
319 // R_eff(i,j) = L+[i,i] + L+[j,j] - 2*L+[i,j]
320 //
321 // EDGE CONDUCTANCES (INVERSE RESISTANCE):
322 // L2 SIBLINGS: 10.0 (SHARED L2, NEAR-ZERO MIGRATION COST)
323 // SAME L3 / cache domain: 3.0 (SHARED LLC; ONLY WHEN A SOCKET HOLDS >1 cache domain)
324 // CROSS-DOMAIN SOCKET: 1.0 (CROSS-DOMAIN INTERCONNECT HOP, ~8x CORE-TO-CORE LATENCY)
325 // CROSS-SOCKET: 0.3 (NUMA HOP, HIGH COST)
326 // RAISING THE same-domain RUNG (NOT LOWERING THE CROSS-DOMAIN CUT) RANKS
327 // SAME-L3 PEERS AHEAD OF CROSS-L3 ONES WITHOUT MOVING lambda_2: THE CROSS-L3
328 // CUT STAYS 1.0, SO tau AND codel_eq ARE UNCHANGED. THE L3 RUNG IS ALWAYS ON;
329 // on a monolithic part llc_domain == socket_domain, so it coincides with the
330 // socket rung and the continuous R_eff metric calibrates to the L2 boundary.
331 //
332 // THE LAPLACIAN L = D - W WHERE D IS DEGREE MATRIX, W IS WEIGHTED ADJACENCY.
333 // L+ (MOORE-PENROSE PSEUDOINVERSE) COMPUTED VIA EIGENDECOMPOSITION:
334 // L+ = sum_{i: lambda_i > 0} (1/lambda_i) * v_i * v_i^T
335 //
336 // FOR n CPUs THIS IS O(n^3) -- TRIVIAL AT SCHEDULER STARTUP (n <= 256).
337 //
338 // REFERENCE: Christiano-Kelner-Madry-Spielman-Teng (STOC 2011),
339 // Chen-Kyng-Liu-Peng-Gutenberg-Sachdeva (FOCS 2022)
340
341 // Phi FIX A: SMT siblings SHARE L2, so a move between them costs ~0 cache.
342 // Make the edge very stiff -> R_eff(SMT-sib) ~ 0, which lands the Phi migration
343 // barrier exactly at the physical-core / L2 boundary (a real cold-L2 refill)
344 // instead of penalizing free intra-core moves. lambda_2 is the cross-domain Fiedler
345 // cut (independent of L2 stiffness) so tau is unchanged, and codel_eq is already
346 // clamped at its ceiling, so the oscillator timescales are invariant.
347 const CONDUCTANCE_L2: f64 = 1000.0; // L2 / SMT SIBLINGS
348 const CONDUCTANCE_LLC: f64 = 3.0; // SAME L3 (ABOVE SOCKET; always-on rung)
349 const CONDUCTANCE_SOCKET: f64 = 1.0; // SAME SOCKET, CROSS-DOMAIN (IF HOP) -- OR MONOLITHIC SAME-SOCKET
350 const CONDUCTANCE_CROSS: f64 = 0.3; // CROSS-SOCKET NUMA HOP
351
352 // BUILD WEIGHTED GRAPH LAPLACIAN FROM CPU TOPOLOGY
353 // Conductance edge weight between two CPUs, derived from the cache hierarchy.
354 // The single source of truth for BOTH the Laplacian (R_eff / tau) and the
355 // domain cut below, so the emergent locality boundary and the placement metric
356 // price the exact same graph -- the continuous metric drives everything.
357 fn conductance(&self, i: usize, j: usize) -> f64 {
358 if self.l2_domain[i] == self.l2_domain[j] {
359 Self::CONDUCTANCE_L2
360 } else if self.llc_domain[i] == self.llc_domain[j] {
361 Self::CONDUCTANCE_LLC
362 } else if self.socket_domain[i] == self.socket_domain[j] {
363 Self::CONDUCTANCE_SOCKET
364 } else {
365 Self::CONDUCTANCE_CROSS
366 }
367 }
368
369 fn build_laplacian(&self) -> Vec<f64> {
370 let n = self.nr_cpus;
371 let mut l = vec![0.0f64; n * n];
372 for i in 0..n {
373 for j in (i + 1)..n {
374 let w = self.conductance(i, j);
375 l[i * n + j] = -w;
376 l[j * n + i] = -w;
377 l[i * n + i] += w;
378 l[j * n + j] += w;
379 }
380 }
381 l
382 }
383
384 // ---- T2: emergent domain cut (SOSA min-conductance) --------------------
385 // The discrete cache domain layer is replaced by domains that EMERGE from the
386 // cache graph. The boundary is the min-conductance cut: phi = cut_weight /
387 // min(vol_a, vol_b). Low phi = a loosely-coupled seam = a real domain edge;
388 // the phi of the cut IS the cross-domain crossing price (THE FLAG: the price
389 // draws the boundary, no gate). Balance-free -- the seam falls where the
390 // silicon divides (asymmetric X3D / P+E included), not where volume balances.
391
392 // Conductance phi of a bipartition of `members` (in_side[c] = c is on side A).
393 // O(|members|^2); boot-time only. The random-walk variant (next) avoids the
394 // full scan for large N -- this exact form is the ground-truth + the price.
395 #[allow(dead_code)]
396 fn cut_conductance(&self, members: &[usize], in_side: &[bool]) -> f64 {
397 let mut cut = 0.0f64;
398 let (mut vol_a, mut vol_b) = (0.0f64, 0.0f64);
399 for &a in members {
400 for &b in members {
401 if a == b {
402 continue;
403 }
404 let w = self.conductance(a, b);
405 if in_side[a] {
406 vol_a += w;
407 } else {
408 vol_b += w;
409 }
410 if in_side[a] != in_side[b] {
411 cut += w; // each crossing edge counted twice (a,b and b,a)
412 }
413 }
414 }
415 cut /= 2.0;
416 let denom = vol_a.min(vol_b);
417 if denom <= 0.0 {
418 f64::INFINITY
419 } else {
420 cut / denom
421 }
422 }
423
424 // Fiedler vector (eigenvector of lambda_2) of the full graph -- the ground
425 // truth the scalable random-walk cut is cross-checked against. eigenvectors
426 // are column-major: component i of eigenvector k is eigenvectors[i*n + k].
427 #[allow(dead_code)]
428 fn fiedler_vector(eigenvalues: &[f64], eigenvectors: &[f64], n: usize) -> Vec<f64> {
429 let mut idx: Vec<usize> = (0..n).collect();
430 idx.sort_by(|&a, &b| {
431 eigenvalues[a]
432 .partial_cmp(&eigenvalues[b])
433 .unwrap_or(std::cmp::Ordering::Equal)
434 });
435 let k = if n >= 2 { idx[1] } else { idx[0] }; // 2nd smallest = lambda_2
436 (0..n).map(|i| eigenvectors[i * n + k]).collect()
437 }
438
439 // Single-level min-conductance cut of `members` via the Fiedler sweep: order
440 // the members by their Fiedler component, sweep every prefix as side A, keep
441 // the split with the lowest phi. Balance-free. Returns (side_a, side_b, phi),
442 // or None for a singleton. fvec is indexed by global CPU id.
443 #[allow(dead_code)]
444 fn fiedler_sweep_cut(
445 &self,
446 members: &[usize],
447 fvec: &[f64],
448 ) -> Option<(Vec<usize>, Vec<usize>, f64)> {
449 if members.len() < 2 {
450 return None;
451 }
452 let mut ordered = members.to_vec();
453 ordered.sort_by(|&a, &b| {
454 fvec[a]
455 .partial_cmp(&fvec[b])
456 .unwrap_or(std::cmp::Ordering::Equal)
457 });
458 let mut in_side = vec![false; self.nr_cpus];
459 let (mut best_phi, mut best_k) = (f64::INFINITY, 1usize);
460 for k in 1..ordered.len() {
461 in_side[ordered[k - 1]] = true; // grow the prefix one vertex
462 let phi = self.cut_conductance(members, &in_side);
463 if phi < best_phi {
464 best_phi = phi;
465 best_k = k;
466 }
467 }
468 Some((
469 ordered[..best_k].to_vec(),
470 ordered[best_k..].to_vec(),
471 best_phi,
472 ))
473 }
474
475 // True when every member shares one L2 group -- the atomic leaf. L2 siblings
476 // are maximally coupled; there is no meaningful seam to find below them.
477 #[allow(dead_code)]
478 fn all_same_l2(&self, members: &[usize]) -> bool {
479 members
480 .windows(2)
481 .all(|w| self.l2_domain[w[0]] == self.l2_domain[w[1]])
482 }
483
484 // Min-conductance cut of an ARBITRARY CPU subset (recursion-safe). The global
485 // Fiedler degrades inside a subtree, so this builds the INDUCED Laplacian on
486 // `members`, eigendecomposes it, and sweeps by the SUBSET's own Fiedler. (2d
487 // replaces these internals with a local random walk -- no eigensolve, so it
488 // scales; the tree-builder is agnostic to which produces the cut.) Returns
489 // global CPU ids.
490 #[allow(dead_code)]
491 fn domain_cut(&self, members: &[usize]) -> Option<(Vec<usize>, Vec<usize>, f64)> {
492 let k = members.len();
493 if k < 2 {
494 return None;
495 }
496 let mut lap = vec![0.0f64; k * k]; // induced Laplacian, local-indexed 0..k
497 for ia in 0..k {
498 for ib in (ia + 1)..k {
499 let w = self.conductance(members[ia], members[ib]);
500 lap[ia * k + ib] = -w;
501 lap[ib * k + ia] = -w;
502 lap[ia * k + ia] += w;
503 lap[ib * k + ib] += w;
504 }
505 }
506 let (ev, evec) = Self::symmetric_eigen(&lap, k);
507 let fsub = Self::fiedler_vector(&ev, &evec, k); // local-indexed
508 let mut order: Vec<usize> = (0..k).collect();
509 order.sort_by(|&a, &b| {
510 fsub[a]
511 .partial_cmp(&fsub[b])
512 .unwrap_or(std::cmp::Ordering::Equal)
513 });
514 let mut in_side = vec![false; self.nr_cpus]; // global-indexed for cut_conductance
515 let (mut best_phi, mut best_k) = (f64::INFINITY, 1usize);
516 for s in 1..k {
517 in_side[members[order[s - 1]]] = true; // grow the prefix (global id)
518 let phi = self.cut_conductance(members, &in_side);
519 if phi < best_phi {
520 best_phi = phi;
521 best_k = s;
522 }
523 }
524 let side_a: Vec<usize> = order[..best_k].iter().map(|&li| members[li]).collect();
525 let side_b: Vec<usize> = order[best_k..].iter().map(|&li| members[li]).collect();
526 Some((side_a, side_b, best_phi))
527 }
528
529 // Below this many CPUs the exact eigen cut is cheap, so use it; above it,
530 // the O(n^3) Jacobi is the wall and the random-walk cut takes over. This is an
531 // implementation dispatch on cost, not a placement gate (THE FLAG untouched).
532 const WALK_CUT_THRESHOLD: usize = 64;
533
534 // SCALABLE min-conductance cut: the SAME sweep as domain_cut, but the vertex
535 // ordering comes from a RANDOM WALK instead of an eigendecomposition -- no
536 // O(n^3) eigensolve, so it scales. Power iteration on the NORMALIZED operator
537 // M = 2I - L_sym, where L_sym = I - D^{-1/2} A D^{-1/2} is the symmetric
538 // normalized Laplacian (eigenvalues in [0,2] regardless of edge-weight scale,
539 // so stiff L2 edges don't dominate the shift the way a raw cI - L would). Its
540 // null eigenvector is D^{1/2}*1 (the walk's stationary distribution, deflated
541 // out each step); the next is the normalized Fiedler -- the iterate converges
542 // to it with a gap set by the CONDUCTANCE structure, not the weight magnitude.
543 // The sweep order is f[i] = y[i] / sqrt(deg[i]) (un-normalizing back to the
544 // walk eigenvector). Deterministic hash start (not a ramp -- ramps can align
545 // with a non-Fiedler mode) so a topology yields the same domains every boot.
546 // Cross-checked against domain_cut as ground truth (the T2 gate).
547 #[allow(dead_code)]
548 fn walk_cut(&self, members: &[usize]) -> Option<(Vec<usize>, Vec<usize>, f64)> {
549 let k = members.len();
550 if k < 2 {
551 return None;
552 }
553 let mut adj = vec![0.0f64; k * k];
554 let mut deg = vec![0.0f64; k];
555 for ia in 0..k {
556 for ib in 0..k {
557 if ia == ib {
558 continue;
559 }
560 let w = self.conductance(members[ia], members[ib]);
561 adj[ia * k + ib] = w;
562 deg[ia] += w;
563 }
564 }
565 // D^{-1/2} and the stationary direction D^{1/2}*1 (L_sym's null vector).
566 let dis: Vec<f64> = deg
567 .iter()
568 .map(|&d| if d > 0.0 { 1.0 / d.sqrt() } else { 0.0 })
569 .collect();
570 let dsq: Vec<f64> = deg.iter().map(|&d| d.sqrt()).collect();
571 let dsq_sq: f64 = dsq.iter().map(|x| x * x).sum::<f64>().max(1e-30);
572 // Deflate out the stationary (null) component each step.
573 let deflate = |v: &mut [f64]| {
574 let dot: f64 = v.iter().zip(&dsq).map(|(a, b)| a * b).sum();
575 let coef = dot / dsq_sq;
576 for i in 0..k {
577 v[i] -= coef * dsq[i];
578 }
579 };
580 let mut y: Vec<f64> = (0..k)
581 .map(|i| {
582 let h = (i as u64).wrapping_mul(2654435761) & 0xffff;
583 h as f64 / 65535.0 - 0.5
584 })
585 .collect();
586 deflate(&mut y);
587 // A weak cut has a tiny normalized eigenvalue gap, so the per-iteration
588 // rate is near 1 and convergence can take thousands of steps -- iterate to
589 // CONVERGENCE (the direction stops moving), not a fixed count, capped high.
590 // Each step is O(k^2); a one-time boot cost, well under a millisecond.
591 const MAX_ITERS: usize = 20_000;
592 const TOL: f64 = 1e-10;
593 let mut w = vec![0.0f64; k];
594 for _ in 0..MAX_ITERS {
595 // (M y)[i] = y[i] + D^{-1/2}_i * sum_j A_ij * D^{-1/2}_j * y[j]
596 for i in 0..k {
597 let row = &adj[i * k..i * k + k];
598 let mut s = 0.0;
599 for j in 0..k {
600 s += row[j] * dis[j] * y[j];
601 }
602 w[i] = y[i] + dis[i] * s;
603 }
604 deflate(&mut w);
605 let norm = w.iter().map(|x| x * x).sum::<f64>().sqrt();
606 if norm < 1e-12 {
607 break;
608 }
609 // cos angle between the new direction and the old unit vector y.
610 let dot: f64 = w.iter().zip(&y).map(|(a, b)| a * b).sum::<f64>() / norm;
611 for i in 0..k {
612 y[i] = w[i] / norm;
613 }
614 if 1.0 - dot.abs() < TOL {
615 break;
616 }
617 }
618 // Sweep order by the un-normalized walk eigenvector f[i] = y[i]/sqrt(deg).
619 let f: Vec<f64> = (0..k).map(|i| y[i] * dis[i]).collect();
620 let mut order: Vec<usize> = (0..k).collect();
621 order.sort_by(|&a, &b| f[a].partial_cmp(&f[b]).unwrap_or(std::cmp::Ordering::Equal));
622 let mut in_side = vec![false; self.nr_cpus];
623 let (mut best_phi, mut best_k) = (f64::INFINITY, 1usize);
624 for s in 1..k {
625 in_side[members[order[s - 1]]] = true;
626 let phi = self.cut_conductance(members, &in_side);
627 if phi < best_phi {
628 best_phi = phi;
629 best_k = s;
630 }
631 }
632 Some((
633 order[..best_k].iter().map(|&li| members[li]).collect(),
634 order[best_k..].iter().map(|&li| members[li]).collect(),
635 best_phi,
636 ))
637 }
638
639 // Dispatch: exact eigen cut below the threshold, scalable random-walk cut
640 // above. Both produce the same boundary on real cache graphs (gate-tested).
641 #[allow(dead_code)]
642 fn best_cut(&self, members: &[usize]) -> Option<(Vec<usize>, Vec<usize>, f64)> {
643 if members.len() <= Self::WALK_CUT_THRESHOLD {
644 self.domain_cut(members)
645 } else {
646 self.walk_cut(members)
647 }
648 }
649
650 // Recurse the cut into the emergent domain tree. Leaf when the members are a
651 // single L2 group (or one CPU) -- maximally coupled, no seam. Each Cut carries
652 // its phi (the crossing price). This IS de-facto NUMA: the boundary is drawn
653 // by the conductance landscape, not a hardcoded topology table.
654 #[allow(dead_code)]
655 pub fn build_domain_tree(&self, members: &[usize]) -> DomainNode {
656 if members.len() <= 1 || self.all_same_l2(members) {
657 return DomainNode::Leaf(members.to_vec());
658 }
659 match self.best_cut(members) {
660 Some((a, b, phi)) if !a.is_empty() && !b.is_empty() => DomainNode::Cut {
661 phi,
662 left: Box::new(self.build_domain_tree(&a)),
663 right: Box::new(self.build_domain_tree(&b)),
664 },
665 _ => DomainNode::Leaf(members.to_vec()),
666 }
667 }
668
669 // Compute the emergent domain tree over all online CPUs -- the de-facto-NUMA
670 // hierarchy from which T3's bounded-local steal reads its locality clusters
671 // and per-cut crossing prices. Public: the T2 -> T3 hand-off point.
672 pub fn compute_domain_tree(&self) -> DomainNode {
673 self.build_domain_tree(&(0..self.nr_cpus).collect::<Vec<_>>())
674 }
675
676 // Log the emergent domains at boot -- observability that the tree the steal
677 // will climb matches the silicon: atomic-domain count, cut depth, the
678 // crossing-price (phi) range, and the first leaves.
679 pub fn log_domains(&self, tree: &DomainNode) {
680 let leaves = tree.leaves();
681 let phis = tree.cut_phis();
682 let (pmin, pmax) = phis.iter().fold((f64::INFINITY, 0.0f64), |(lo, hi), &p| {
683 (lo.min(p), hi.max(p))
684 });
685 log_info!(
686 "EMERGENT DOMAINS: {} atomic, {} cuts, crossing phi {:.4}..{:.4}",
687 leaves.len(),
688 phis.len(),
689 if phis.is_empty() { 0.0 } else { pmin },
690 pmax
691 );
692 let preview: Vec<String> = leaves.iter().take(8).map(|l| format!("{:?}", l)).collect();
693 log_info!("EMERGENT DOMAINS: leaves {}", preview.join(" "));
694 }
695
696 // T3b.1: the per-CPU-pair crossing-price matrix the bounded steal reads.
697 // m[i*n + j] = (phi * 1e6) of the LCA cut separating CPU i and CPU j -- the
698 // price to steal across that emergent domain boundary. A LOW phi is a loose
699 // seam (a major boundary -- socket / cross-L3 -- far, needs more imbalance to
700 // cross); a HIGH phi is a tight seam (near). Same-leaf pairs share NO cut:
701 // sentinel u32::MAX = maximally local, the steal never has to "cross" for them.
702 // Replaces the discrete domain map's discrete same/different-cache domain test with a continuous,
703 // emergent boundary price (THE FLAG: priced, not gated).
704 pub fn domain_cross_phi_matrix(&self, tree: &DomainNode) -> Vec<u32> {
705 let n = self.nr_cpus;
706 let mut m = vec![u32::MAX; n * n]; // default: no boundary (same leaf)
707 Self::fill_cross_phi(tree, n, &mut m);
708 m
709 }
710
711 fn fill_cross_phi(node: &DomainNode, n: usize, m: &mut [u32]) {
712 if let DomainNode::Cut { phi, left, right } = node {
713 let lc: Vec<usize> = left.leaves().concat();
714 let rc: Vec<usize> = right.leaves().concat();
715 let p = (phi * 1_000_000.0).round().clamp(0.0, u32::MAX as f64) as u32;
716 for &a in &lc {
717 for &b in &rc {
718 m[a * n + b] = p; // pairs whose lowest common ancestor IS this cut
719 m[b * n + a] = p;
720 }
721 }
722 Self::fill_cross_phi(left, n, m);
723 Self::fill_cross_phi(right, n, m);
724 }
725 }
726
727 // Number of emergent OVERFLOW DOMAINS to target = distinct L3 groups (the old
728 // per-domain count), so re-keying the overflow DSQs preserves granularity.
729 pub fn overflow_domain_count(&self) -> usize {
730 let mut v = self.llc_domain.clone();
731 v.sort_unstable();
732 v.dedup();
733 v.len().max(1)
734 }
735
736 // T3b.2: partition CPUs into emergent OVERFLOW DOMAINS -- the the discrete domain map
737 // replacement. Descend the tree from the root, repeatedly splitting the
738 // frontier subtree whose cut has the LOWEST phi (the coarsest, most-separable
739 // seam) until `target` domains exist or no cut remains. Each resulting subtree
740 // is one overflow domain; dom[cpu] is its id. The granularity is the old L3
741 // count, but the boundary is now drawn by the emergent tree, not the discrete domain map.
742 pub fn domain_partition(&self, tree: &DomainNode, target: usize) -> Vec<u32> {
743 let mut frontier: Vec<&DomainNode> = vec![tree];
744 while frontier.len() < target.max(1) {
745 let mut best: Option<(usize, f64)> = None;
746 for (i, node) in frontier.iter().enumerate() {
747 if let DomainNode::Cut { phi, .. } = node {
748 if best.map_or(true, |(_, bp)| *phi < bp) {
749 best = Some((i, *phi));
750 }
751 }
752 }
753 let Some((idx, _)) = best else { break }; // no cuts left to split
754 let node = frontier[idx];
755 if let DomainNode::Cut { left, right, .. } = node {
756 frontier.swap_remove(idx);
757 frontier.push(left.as_ref());
758 frontier.push(right.as_ref());
759 }
760 }
761 let n = self.nr_cpus;
762 let mut dom = vec![0u32; n];
763 for (id, node) in frontier.iter().enumerate() {
764 for leaf in node.leaves() {
765 for c in leaf {
766 if c < n {
767 dom[c] = id as u32;
768 }
769 }
770 }
771 }
772 dom
773 }
774
775 // SYMMETRIC EIGENDECOMPOSITION VIA JACOBI ROTATIONS
776 // RETURNS (eigenvalues, eigenvectors_column_major)
777 // SUITABLE FOR n <= 256. NO EXTERNAL DEPENDENCIES.
778 fn symmetric_eigen(mat: &[f64], n: usize) -> (Vec<f64>, Vec<f64>) {
779 let mut a = mat.to_vec();
780 // EIGENVECTORS START AS IDENTITY
781 let mut v = vec![0.0f64; n * n];
782 for i in 0..n {
783 v[i * n + i] = 1.0;
784 }
785
786 let max_iter = 100 * n * n;
787 for _ in 0..max_iter {
788 // FIND LARGEST OFF-DIAGONAL ELEMENT
789 let mut max_val = 0.0f64;
790 let mut p = 0;
791 let mut q = 1;
792 for i in 0..n {
793 for j in (i + 1)..n {
794 let val = a[i * n + j].abs();
795 if val > max_val {
796 max_val = val;
797 p = i;
798 q = j;
799 }
800 }
801 }
802 if max_val < 1e-12 {
803 break;
804 }
805
806 // COMPUTE ROTATION
807 let app = a[p * n + p];
808 let aqq = a[q * n + q];
809 let apq = a[p * n + q];
810 let theta = if (app - aqq).abs() < 1e-15 {
811 std::f64::consts::FRAC_PI_4
812 } else {
813 0.5 * (2.0 * apq / (app - aqq)).atan()
814 };
815 let c = theta.cos();
816 let s = theta.sin();
817
818 // APPLY ROTATION TO A
819 for i in 0..n {
820 if i == p || i == q {
821 continue;
822 }
823 let aip = a[i * n + p];
824 let aiq = a[i * n + q];
825 a[i * n + p] = c * aip + s * aiq;
826 a[p * n + i] = a[i * n + p];
827 a[i * n + q] = -s * aip + c * aiq;
828 a[q * n + i] = a[i * n + q];
829 }
830 let new_pp = c * c * app + 2.0 * s * c * apq + s * s * aqq;
831 let new_qq = s * s * app - 2.0 * s * c * apq + c * c * aqq;
832 a[p * n + p] = new_pp;
833 a[q * n + q] = new_qq;
834 a[p * n + q] = 0.0;
835 a[q * n + p] = 0.0;
836
837 // ACCUMULATE EIGENVECTORS
838 for i in 0..n {
839 let vip = v[i * n + p];
840 let viq = v[i * n + q];
841 v[i * n + p] = c * vip + s * viq;
842 v[i * n + q] = -s * vip + c * viq;
843 }
844 }
845
846 let eigenvalues: Vec<f64> = (0..n).map(|i| a[i * n + i]).collect();
847 (eigenvalues, v)
848 }
849
850 // COMPUTE LAPLACIAN PSEUDOINVERSE FROM EIGENDECOMPOSITION
851 fn compute_pseudoinverse(eigenvalues: &[f64], eigenvectors: &[f64], n: usize) -> Vec<f64> {
852 let mut l_pinv = vec![0.0f64; n * n];
853 for k in 0..n {
854 if eigenvalues[k].abs() < 1e-8 {
855 continue; // SKIP NULL EIGENVALUE (CONNECTED GRAPH HAS ONE)
856 }
857 let inv_lambda = 1.0 / eigenvalues[k];
858 for i in 0..n {
859 for j in 0..n {
860 l_pinv[i * n + j] +=
861 inv_lambda * eigenvectors[i * n + k] * eigenvectors[j * n + k];
862 }
863 }
864 }
865 l_pinv
866 }
867
868 // COMPUTE ALL-PAIRS EFFECTIVE RESISTANCE FROM PSEUDOINVERSE
869 // R_eff(i,j) = L+[i,i] + L+[j,j] - 2*L+[i,j]
870 fn extract_reff(l_pinv: &[f64], n: usize) -> Vec<f64> {
871 let mut r = vec![0.0f64; n * n];
872 for i in 0..n {
873 for j in (i + 1)..n {
874 let val = l_pinv[i * n + i] + l_pinv[j * n + j] - 2.0 * l_pinv[i * n + j];
875 r[i * n + j] = val.max(0.0);
876 r[j * n + i] = r[i * n + j];
877 }
878 }
879 r
880 }
881
882 // BUILD PER-CPU AFFINITY RANK: FOR EACH CPU, ALL OTHERS SORTED BY R_EFF
883 // Returns flat array: affinity_rank[cpu * nr_cpus + slot] = target_cpu
884 fn build_affinity_rank(reff: &[f64], n: usize) -> Vec<u32> {
885 let mut rank = vec![0u32; n * n];
886 for cpu in 0..n {
887 let mut others: Vec<(u64, u32)> = (0..n)
888 .filter(|&c| c != cpu)
889 .map(|c| {
890 // SORT KEY: R_EFF AS FIXED-POINT TO AVOID FLOAT COMPARISON ISSUES
891 let key = (reff[cpu * n + c] * 1_000_000.0) as u64;
892 (key, c as u32)
893 })
894 .collect();
895 others.sort();
896 for (slot, &(_, target)) in others.iter().enumerate() {
897 rank[cpu * n + slot] = target;
898 }
899 // FILL REMAINING SLOTS WITH SENTINEL
900 for slot in others.len()..n {
901 rank[cpu * n + slot] = u32::MAX;
902 }
903 }
904 rank
905 }
906
907 // COMPUTE RESISTANCE AFFINITY: FULL PIPELINE
908 // Returns (reff_matrix, affinity_rank, spectrum) for use by BPF and scheduler.
909 // Spectrum carries lambda_2 (Fiedler value) and its derived tau_ns, used as
910 // the universal topology time constant for every core-scaled knob.
911 pub fn compute_resistance_affinity(&self) -> (Vec<f64>, Vec<u32>, TopologySpectrum) {
912 let n = self.nr_cpus;
913 let laplacian = self.build_laplacian();
914 let (eigenvalues, eigenvectors) = Self::symmetric_eigen(&laplacian, n);
915 let fiedler = extract_fiedler(&eigenvalues);
916 let tau_ns = compute_tau_ns(fiedler, n);
917 let l_pinv = Self::compute_pseudoinverse(&eigenvalues, &eigenvectors, n);
918 let reff = Self::extract_reff(&l_pinv, n);
919 let rank = Self::build_affinity_rank(&reff, n);
920 let codel_eq_ns = compute_codel_eq_ns(&eigenvalues, n, tau_ns);
921 // Phi FIX B: distance scale calibrated so the most distant pair (max R_eff)
922 // maps to ~tau of required steal-wait, an SMT sibling (R_eff ~ 0) to ~0. Only
923 // sustained backlog (~tau) justifies a far move; a single queued slice does
924 // not. reff_norm uses the same 1e6 scale the BPF reff_value map stores.
925 // ALWAYS computed: on a single-L3 part the most distant pair is the cross-L2
926 // (cross-core) max, so reff_norm auto-calibrates the brake to the L2 boundary
927 // instead of vanishing -- the continuous metric drives placement on EVERY
928 // processor, with no binary topology gate in front of it (THE FLAG).
929 let max_reff = reff.iter().cloned().fold(0.0f64, f64::max);
930 let reff_norm = ((max_reff * 1_000_000.0).round() as u64).max(1);
931 let phi_dist_scale_q16 = tau_ns.saturating_mul(65536) / reff_norm;
932 (
933 reff,
934 rank,
935 TopologySpectrum {
936 fiedler,
937 tau_ns,
938 codel_eq_ns,
939 phi_dist_scale_q16,
940 },
941 )
942 }
943
944 // WRITE AFFINITY RANK TO BPF MAP
945 // affinity_rank[cpu * MAX_AFFINITY_CANDIDATES + slot] = target_cpu
946 //
947 // Emit the full sorted R_eff peer list per CPU, capped at the BPF
948 // table width (MAX_AFFINITY_CANDIDATES). Slots beyond the actual
949 // topology end (nr_cpus - 1) are written as explicit u32::MAX
950 // sentinels so the BPF early-exit fires correctly -- map zero-init
951 // would otherwise alias to "CPU 0" and silently mis-route.
952 pub fn populate_affinity_rank_map(
953 &self,
954 sched: &Scheduler,
955 reff: &[f64],
956 rank: &[u32],
957 phi_dist_scale_q16: u64,
958 domain_phi: &[u32],
959 ) -> Result<()> {
960 let stride = crate::bpf_intf::MAX_AFFINITY_CANDIDATES as usize;
961 let valid = self.nr_cpus.saturating_sub(1).min(stride);
962 for cpu in 0..self.nr_cpus {
963 for slot in 0..valid {
964 let val = rank[cpu * self.nr_cpus + slot];
965 sched.write_affinity_rank(cpu as u32, slot as u32, val)?;
966 // T3b.1: the emergent-domain crossing price to this ranked peer,
967 // 1:1 with the rank slot (sentinel for an out-of-range peer id).
968 let dphi = domain_phi
969 .get(cpu * self.nr_cpus + val as usize)
970 .copied()
971 .unwrap_or(u32::MAX);
972 sched.write_domain_phi(cpu as u32, slot as u32, dphi)?;
973 // FOLD THE PHI DISTANCE PENALTY AT INIT: reff_value stores the
974 // final steal extra-wait in ns, (R_eff * phi_dist_scale_q16) >> 16,
975 // so the BPF steal does one indexed read and no multiply. The 1e6
976 // scale matches build_affinity_rank's sort key. phi_dist_scale_q16
977 // is 0 on monolithic / --phi-scale 0 -> every penalty 0 -> flat
978 // codel_target (exact prior behavior).
979 let r_scaled = (reff[cpu * self.nr_cpus + val as usize] * 1_000_000.0)
980 .round()
981 .clamp(0.0, u32::MAX as f64) as u64;
982 let dist_extra =
983 (r_scaled.saturating_mul(phi_dist_scale_q16) >> 16).min(u32::MAX as u64) as u32;
984 sched.write_reff_value(cpu as u32, slot as u32, dist_extra)?;
985 }
986 for slot in valid..stride {
987 sched.write_affinity_rank(cpu as u32, slot as u32, u32::MAX)?;
988 sched.write_reff_value(cpu as u32, slot as u32, u32::MAX)?;
989 sched.write_domain_phi(cpu as u32, slot as u32, u32::MAX)?;
990 }
991 }
992 Ok(())
993 }
994
995 pub fn log_resistance_affinity(&self, reff: &[f64], rank: &[u32], spectrum: TopologySpectrum) {
996 log_info!(
997 "TOPOLOGY SPECTRUM: lambda2={:.4} tau={}ms codel_eq={}us",
998 spectrum.fiedler,
999 spectrum.tau_ns / 1_000_000,
1000 spectrum.codel_eq_ns / 1_000
1001 );
1002 let n = self.nr_cpus;
1003 // LOG TOP 3 AFFINITIES FOR CPU 0
1004 let mut parts = Vec::new();
1005 for slot in 0..3.min(n - 1) {
1006 let target = rank[slot] as usize;
1007 if target >= n {
1008 break;
1009 }
1010 let r = reff[target];
1011 parts.push(format!("CPU{}(R={:.3})", target, r));
1012 }
1013 log_info!("RESISTANCE AFFINITY: CPU 0 rank: {}", parts.join(", "));
1014
1015 // LOG L2 VS NON-L2 R_EFF FOR FIRST CPU
1016 if n >= 2 {
1017 let l2_sib = rank[0] as usize;
1018 let non_l2 = rank[1.min(n - 2)] as usize;
1019 log_info!(
1020 "RESISTANCE AFFINITY: R_eff L2={:.4} non-L2={:.4} ratio={:.1}x",
1021 reff[l2_sib],
1022 reff[non_l2],
1023 if reff[l2_sib] > 0.0 {
1024 reff[non_l2] / reff[l2_sib]
1025 } else {
1026 0.0
1027 }
1028 );
1029 }
1030 }
1031
1032 pub fn log_summary(&self) {
1033 for (gid, members) in self.l2_groups.iter().enumerate() {
1034 let cpus: Vec<String> = members.iter().map(|c| c.to_string()).collect();
1035 log_info!("L2 GROUP {}: [{}]", gid, cpus.join(","));
1036 }
1037 log_info!(
1038 "L2 GROUPS: {} across {} CPUs, {} SOCKETS",
1039 self.l2_groups.len(),
1040 self.nr_cpus,
1041 self.nr_sockets
1042 );
1043 let mut llc = self.llc_domain.clone();
1044 llc.sort_unstable();
1045 llc.dedup();
1046 log_info!(
1047 "LLC DOMAINS: {} (L3 rung always-on, continuous Phi)",
1048 llc.len()
1049 );
1050 }
1051}
1052
1053// PARSE KERNEL CPU LIST FORMAT: "0,6" or "0-2,6-8" or "3"
1054fn parse_cpu_list(s: &str) -> Vec<u32> {
1055 let mut result = Vec::new();
1056 for part in s.split(',') {
1057 let part = part.trim();
1058 if part.is_empty() {
1059 continue;
1060 }
1061 if let Some((start, end)) = part.split_once('-') {
1062 if let (Ok(s), Ok(e)) = (start.parse::<u32>(), end.parse::<u32>()) {
1063 for cpu in s..=e {
1064 result.push(cpu);
1065 }
1066 }
1067 } else if let Ok(cpu) = part.parse::<u32>() {
1068 result.push(cpu);
1069 }
1070 }
1071 result.sort();
1072 result.dedup();
1073 result
1074}
1075
1076#[cfg(test)]
1077mod t2_cut_tests {
1078 use super::*;
1079
1080 // 8 CPUs, no SMT (each its own L2), one socket, two L3 groups: {0..3},{4..7}.
1081 // Intra-L3 edges weigh CONDUCTANCE_LLC (3.0), inter-L3 same-socket weigh
1082 // CONDUCTANCE_SOCKET (1.0) -- two clusters joined by weak edges.
1083 fn synth_2domain() -> CpuTopology {
1084 CpuTopology {
1085 nr_cpus: 8,
1086 l2_domain: (0..8u32).collect(),
1087 l2_groups: Vec::new(),
1088 socket_domain: vec![0u32; 8],
1089 llc_domain: vec![0, 0, 0, 0, 1, 1, 1, 1],
1090 nr_sockets: 1,
1091 }
1092 }
1093
1094 #[test]
1095 fn min_conductance_cut_splits_on_llc() {
1096 let t = synth_2domain();
1097 let lap = t.build_laplacian();
1098 let (ev, evec) = CpuTopology::symmetric_eigen(&lap, 8);
1099 let fvec = CpuTopology::fiedler_vector(&ev, &evec, 8);
1100 let members: Vec<usize> = (0..8).collect();
1101 let (a, b, phi) = t.fiedler_sweep_cut(&members, &fvec).expect("cut");
1102 let (mut sa, mut sb) = (a.clone(), b.clone());
1103 sa.sort();
1104 sb.sort();
1105 let (llc0, llc1) = (vec![0usize, 1, 2, 3], vec![4usize, 5, 6, 7]);
1106 assert!(
1107 (sa == llc0 && sb == llc1) || (sa == llc1 && sb == llc0),
1108 "expected the L3 boundary, got {:?} | {:?}",
1109 sa,
1110 sb
1111 );
1112 assert!(phi.is_finite() && phi > 0.0 && phi < 1.0, "phi = {}", phi);
1113 }
1114
1115 #[test]
1116 fn cut_conductance_zero_weight_guard() {
1117 // A singleton member set has no valid bipartition -> None, not a panic.
1118 let t = synth_2domain();
1119 assert!(t.fiedler_sweep_cut(&[3usize], &vec![0.0; 8]).is_none());
1120 }
1121
1122 // 8 CPUs WITH SMT: 4 L2 pairs {0,1}{2,3}{4,5}{6,7}, two L3 groups {0..3},
1123 // {4..7}, one socket. L2-sib 1000, same-L3 cross-L2 3.0, cross-L3 same-socket
1124 // 1.0 -- a clean two-level hierarchy whose tree should be L3 over L2 pairs.
1125 fn synth_smt_2domain() -> CpuTopology {
1126 CpuTopology {
1127 nr_cpus: 8,
1128 l2_domain: vec![0, 0, 1, 1, 2, 2, 3, 3],
1129 l2_groups: Vec::new(),
1130 socket_domain: vec![0u32; 8],
1131 llc_domain: vec![0, 0, 0, 0, 1, 1, 1, 1],
1132 nr_sockets: 1,
1133 }
1134 }
1135
1136 #[test]
1137 fn top_cut_is_the_l3_seam() {
1138 let t = synth_smt_2domain();
1139 let (a, b, phi) = t.domain_cut(&(0..8).collect::<Vec<_>>()).expect("cut");
1140 let (mut sa, mut sb) = (a.clone(), b.clone());
1141 sa.sort();
1142 sb.sort();
1143 let (l3a, l3b) = (vec![0usize, 1, 2, 3], vec![4usize, 5, 6, 7]);
1144 assert!(
1145 (sa == l3a && sb == l3b) || (sa == l3b && sb == l3a),
1146 "top cut should be the L3 seam, got {:?} | {:?}",
1147 sa,
1148 sb
1149 );
1150 assert!(phi.is_finite() && phi > 0.0 && phi < 1.0, "phi = {}", phi);
1151 }
1152
1153 #[test]
1154 fn domain_tree_leaves_are_l2_groups() {
1155 let t = synth_smt_2domain();
1156 let tree = t.build_domain_tree(&(0..8).collect::<Vec<_>>());
1157 let mut leaves: Vec<Vec<usize>> = tree
1158 .leaves()
1159 .into_iter()
1160 .map(|mut l| {
1161 l.sort();
1162 l
1163 })
1164 .collect();
1165 leaves.sort();
1166 assert_eq!(
1167 leaves,
1168 vec![vec![0, 1], vec![2, 3], vec![4, 5], vec![6, 7]],
1169 "leaves should be the 4 L2 groups"
1170 );
1171 // The root cut (L3 seam) is the cheapest crossing: coarser seam, lower phi.
1172 let phis = tree.cut_phis();
1173 assert!(!phis.is_empty(), "tree should have cuts");
1174 let root_phi = phis[0];
1175 assert!(
1176 phis.iter().all(|&p| root_phi <= p + 1e-9),
1177 "root cut should be the lowest phi, got {:?}",
1178 phis
1179 );
1180 }
1181
1182 // Two cuts induce the same bipartition (ignoring which side is A vs B)?
1183 fn same_bipartition(
1184 a: &(Vec<usize>, Vec<usize>, f64),
1185 b: &(Vec<usize>, Vec<usize>, f64),
1186 ) -> bool {
1187 let norm = |c: &(Vec<usize>, Vec<usize>, f64)| {
1188 let (mut x, mut y) = (c.0.clone(), c.1.clone());
1189 x.sort();
1190 y.sort();
1191 if x < y {
1192 (x, y)
1193 } else {
1194 (y, x)
1195 }
1196 };
1197 norm(a) == norm(b)
1198 }
1199
1200 #[test]
1201 fn walk_cut_matches_eigen_cut_smt() {
1202 let t = synth_smt_2domain();
1203 let m: Vec<usize> = (0..8).collect();
1204 let eigen = t.domain_cut(&m).expect("eigen");
1205 let walk = t.walk_cut(&m).expect("walk");
1206 assert!(
1207 same_bipartition(&eigen, &walk),
1208 "walk {:?}|{:?} != eigen {:?}|{:?}",
1209 walk.0,
1210 walk.1,
1211 eigen.0,
1212 eigen.1
1213 );
1214 }
1215
1216 // 16 CPUs, 2 sockets {0..7}{8..15}, 4 L3 groups, 8 L2 pairs. The weakest seam
1217 // is cross-socket (CONDUCTANCE_CROSS 0.3) -- both cuts must land there,
1218 // exercising the random walk on a deeper graph than the 8-CPU case.
1219 fn synth_2socket() -> CpuTopology {
1220 CpuTopology {
1221 nr_cpus: 16,
1222 l2_domain: (0..16).map(|c| (c / 2) as u32).collect(),
1223 l2_groups: Vec::new(),
1224 socket_domain: (0..16).map(|c| (c / 8) as u32).collect(),
1225 llc_domain: (0..16).map(|c| (c / 4) as u32).collect(),
1226 nr_sockets: 2,
1227 }
1228 }
1229
1230 #[test]
1231 fn walk_cut_matches_eigen_cut_2socket() {
1232 let t = synth_2socket();
1233 let m: Vec<usize> = (0..16).collect();
1234 let eigen = t.domain_cut(&m).expect("eigen");
1235 let walk = t.walk_cut(&m).expect("walk");
1236 let (mut wa, mut wb) = (walk.0.clone(), walk.1.clone());
1237 wa.sort();
1238 wb.sort();
1239 let (s0, s1): (Vec<usize>, Vec<usize>) = ((0..8).collect(), (8..16).collect());
1240 assert!(
1241 (wa == s0 && wb == s1) || (wa == s1 && wb == s0),
1242 "walk top cut should be the socket seam, got {:?}|{:?}",
1243 wa,
1244 wb
1245 );
1246 assert!(same_bipartition(&eigen, &walk), "walk != eigen on 2-socket");
1247 }
1248
1249 #[test]
1250 fn compute_domain_tree_public_wrapper() {
1251 let t = synth_smt_2domain();
1252 let tree = t.compute_domain_tree();
1253 assert_eq!(tree.leaves().len(), 4, "smt 2-domain -> 4 L2-group leaves");
1254 }
1255
1256 #[test]
1257 fn single_cpu_is_one_leaf() {
1258 let t = CpuTopology {
1259 nr_cpus: 1,
1260 l2_domain: vec![0],
1261 l2_groups: Vec::new(),
1262 socket_domain: vec![0],
1263 llc_domain: vec![0],
1264 nr_sockets: 1,
1265 };
1266 let tree = t.compute_domain_tree();
1267 assert_eq!(tree.leaves(), vec![vec![0usize]]);
1268 assert!(tree.cut_phis().is_empty(), "a single CPU has no cuts");
1269 }
1270
1271 #[test]
1272 fn cross_phi_matrix_prices_the_boundaries() {
1273 let t = synth_smt_2domain();
1274 let tree = t.compute_domain_tree();
1275 let m = t.domain_cross_phi_matrix(&tree);
1276 let n = 8;
1277 // Same leaf {0,1}: no boundary -> sentinel.
1278 assert_eq!(m[0 * n + 1], u32::MAX, "same-leaf pair must be sentinel");
1279 // Cross-L2 same-L3 (0,2) and cross-L3 (0,4): real, priced boundaries.
1280 assert_ne!(m[0 * n + 2], u32::MAX);
1281 assert_ne!(m[0 * n + 4], u32::MAX);
1282 // Cross-L2 is the TIGHTER (nearer) seam -> higher phi than cross-L3.
1283 assert!(
1284 m[0 * n + 2] > m[0 * n + 4],
1285 "cross-L2 phi {} should exceed cross-L3 phi {}",
1286 m[0 * n + 2],
1287 m[0 * n + 4]
1288 );
1289 // CPUs 2 and 3 are the same sibling L2 pair: identical crossing price from 0.
1290 assert_eq!(m[0 * n + 2], m[0 * n + 3]);
1291 // Symmetric.
1292 assert_eq!(m[0 * n + 4], m[4 * n + 0]);
1293 }
1294
1295 #[test]
1296 fn overflow_partition_matches_l3_groups() {
1297 let t = synth_smt_2domain();
1298 assert_eq!(t.overflow_domain_count(), 2, "two L3 groups");
1299 let tree = t.compute_domain_tree();
1300 let dom = t.domain_partition(&tree, 2);
1301 assert!(
1302 dom[0] == dom[1] && dom[1] == dom[2] && dom[2] == dom[3],
1303 "L3 group 0 is one overflow domain: {:?}",
1304 dom
1305 );
1306 assert!(
1307 dom[4] == dom[5] && dom[5] == dom[6] && dom[6] == dom[7],
1308 "L3 group 1 is one overflow domain: {:?}",
1309 dom
1310 );
1311 assert_ne!(dom[0], dom[4], "the two L3 groups are distinct domains");
1312 // target 1 -> a single overflow domain (monolithic re-key).
1313 let mono = t.domain_partition(&tree, 1);
1314 assert!(
1315 mono.iter().all(|&d| d == 0),
1316 "target 1 = one domain: {:?}",
1317 mono
1318 );
1319 }
1320}