1use std::collections::HashMap;
15use std::fs::File;
16use std::io::{BufRead, BufReader};
17use std::os::unix::process::CommandExt;
18use std::path::{Path, PathBuf};
19use std::process::{Child, Command, Stdio};
20use std::sync::atomic::AtomicBool;
21use std::thread::sleep;
22use std::time::{Duration, Instant};
23
24use anyhow::{Context, Result};
25use serde_json::{Value, json};
26
27use crate::cargo_program;
28use crate::color::Style;
29use crate::interrupt;
30use crate::progress::ProgressSpinner;
31use crate::spec::{METRIC_NAME, Spec};
32use crate::sudo::Sudo;
33
34const SCX_STATE_PATH: &str = "/sys/kernel/sched_ext/state";
35
36#[derive(Debug, Clone)]
37pub struct Verdict {
38 pub ok: bool,
39 pub stage: String,
40 pub metric_name: String,
41 pub goal: String,
42 pub value: Option<f64>,
43 pub median: Option<f64>,
44 pub stddev: Option<f64>,
45 pub errors: Vec<String>,
46 pub raw: Value,
47}
48
49impl Verdict {
50 pub fn is_complete(&self) -> bool {
51 self.ok && self.stage == "complete" && self.value.is_some()
52 }
53}
54
55fn scx_state() -> Option<String> {
57 std::fs::read_to_string(SCX_STATE_PATH)
58 .ok()
59 .map(|s| s.trim().to_string())
60}
61
62fn wait_for_state(want: &str, timeout: Duration, interrupted: &AtomicBool) -> bool {
64 let deadline = Instant::now() + timeout;
65 loop {
66 if scx_state().as_deref() == Some(want) {
67 return true;
68 }
69 if interrupt::requested(interrupted) {
70 return false;
71 }
72 if Instant::now() >= deadline {
73 return scx_state().as_deref() == Some(want);
74 }
75 sleep(Duration::from_millis(100));
76 }
77}
78
79fn scx_is_enabled(state: Option<&str>) -> bool {
80 state == Some("enabled")
81}
82
83fn sleep_interruptible(duration: Duration, interrupted: &AtomicBool) -> bool {
85 let deadline = Instant::now() + duration;
86 loop {
87 if interrupt::requested(interrupted) {
88 return true;
89 }
90 let now = Instant::now();
91 if now >= deadline {
92 return false;
93 }
94 sleep((deadline - now).min(Duration::from_millis(100)));
95 }
96}
97
98fn cargo_build(
100 repo_root: &Path,
101 package: &str,
102 profile: &str,
103 interrupted: &AtomicBool,
104) -> (bool, String) {
105 let cargo = cargo_program();
106 let mut command = Command::new(&cargo);
107 command
108 .args(["build", "--profile", profile, "-p", package])
109 .current_dir(repo_root)
110 .stdout(Stdio::piped())
111 .stderr(Stdio::piped());
112 unsafe {
113 command.pre_exec(|| {
114 libc::setsid();
115 Ok(())
116 });
117 }
118 let mut child = match command.spawn() {
119 Ok(child) => child,
120 Err(e) => {
121 return (
122 false,
123 format!("spawn {} build: {e}", cargo.to_string_lossy()),
124 );
125 }
126 };
127 let pid = child.id() as i32;
128 loop {
129 if interrupt::requested(interrupted) {
130 unsafe {
131 libc::kill(-pid, libc::SIGINT);
132 }
133 let deadline = Instant::now() + Duration::from_secs(2);
134 loop {
135 match child.try_wait() {
136 Ok(Some(_)) | Err(_) => break,
137 Ok(None) if Instant::now() >= deadline => {
138 unsafe {
139 libc::kill(-pid, libc::SIGKILL);
140 }
141 break;
142 }
143 Ok(None) => sleep(Duration::from_millis(50)),
144 }
145 }
146 let out = match child.wait_with_output() {
147 Ok(out) => out,
148 Err(e) => {
149 return (
150 false,
151 format!("cargo build interrupted; collect output: {e}"),
152 );
153 }
154 };
155 let mut s = String::from("cargo build interrupted by Ctrl-C\n");
156 s.push_str(&String::from_utf8_lossy(&out.stdout));
157 s.push_str(&String::from_utf8_lossy(&out.stderr));
158 return (false, s);
159 }
160 match child.try_wait() {
161 Ok(Some(_)) => break,
162 Ok(None) => sleep(Duration::from_millis(100)),
163 Err(e) => {
164 return (
165 false,
166 format!("poll {} build: {e}", cargo.to_string_lossy()),
167 );
168 }
169 }
170 }
171 let out = match child.wait_with_output() {
172 Ok(out) => out,
173 Err(e) => {
174 return (
175 false,
176 format!("collect {} build output: {e}", cargo.to_string_lossy()),
177 );
178 }
179 };
180 let mut s = String::from_utf8_lossy(&out.stdout).to_string();
181 s.push_str(&String::from_utf8_lossy(&out.stderr));
182 (out.status.success(), s)
183}
184
185pub(crate) fn binary_path(repo_root: &Path, package: &str, profile: &str) -> PathBuf {
188 let subdir = match profile {
189 "release" => "release",
190 "dev" => "debug",
191 other => other,
192 };
193 repo_root.join("target").join(subdir).join(package)
194}
195
196fn read_lossy(path: &Path) -> String {
197 std::fs::read(path)
198 .map(|b| String::from_utf8_lossy(&b).into_owned())
199 .unwrap_or_default()
200}
201
202fn perf_available() -> bool {
203 Command::new("perf")
204 .arg("--version")
205 .stdout(Stdio::null())
206 .stderr(Stdio::null())
207 .status()
208 .is_ok()
209}
210
211fn perf_mmap_pages_per_cpu(max_total_bytes: u64) -> u64 {
215 let cpus = std::thread::available_parallelism()
216 .map(|n| n.get() as u64)
217 .unwrap_or(1)
218 .max(1);
219 const PAGE_SIZE: u64 = 4096;
220 let pages = (max_total_bytes / PAGE_SIZE / cpus).max(1);
221 let mut pow2 = 1;
222 while pow2 <= pages / 2 {
223 pow2 *= 2;
224 }
225 pow2
226}
227
228#[derive(Default, Debug)]
229struct TraceStats {
230 start_ts: Option<f64>,
231 end_ts: Option<f64>,
232 events: HashMap<String, u64>,
233 per_cpu_events: HashMap<String, u64>,
234}
235
236impl TraceStats {
237 fn total(&self) -> u64 {
238 self.events.values().sum()
239 }
240}
241
242fn trace_cpu(line: &str) -> Option<String> {
243 let start = line.find('[')? + 1;
244 let end = line[start..].find(']')? + start;
245 Some(line[start..end].trim().to_string())
246}
247
248fn trace_event(line: &str) -> Option<(f64, String, Option<String>)> {
249 let cpu = trace_cpu(line);
250 let cpu_end = line.find(']')? + 1;
251 let mut tokens = line[cpu_end..].split_whitespace();
252 while let Some(token) = tokens.next() {
253 let ts_token = token.trim_end_matches(':');
254 if let Ok(ts) = ts_token.parse::<f64>() {
255 for token in tokens {
256 let event = token.trim_end_matches(':');
257 if event.is_empty() || event.parse::<f64>().is_ok() {
258 continue;
259 }
260 return Some((ts, event.to_string(), cpu));
261 }
262 return None;
263 }
264 }
265 None
266}
267
268fn update_trace_stats(stats: &mut TraceStats, line: &str) {
269 if let Some((ts, event, cpu)) = trace_event(line) {
270 stats.start_ts = Some(stats.start_ts.map_or(ts, |start| start.min(ts)));
271 stats.end_ts = Some(stats.end_ts.map_or(ts, |end| end.max(ts)));
272 *stats.events.entry(event).or_insert(0) += 1;
273 if let Some(cpu) = cpu {
274 *stats.per_cpu_events.entry(cpu).or_insert(0) += 1;
275 }
276 }
277}
278
279fn trace_window_sec(start_ts: Option<f64>, end_ts: Option<f64>) -> Option<f64> {
280 let window = end_ts? - start_ts?;
281 (window > 0.0).then_some(window)
282}
283
284fn round_trace_float(value: f64) -> f64 {
285 (value * 1_000_000.0).round() / 1_000_000.0
286}
287
288fn rounded_option(value: Option<f64>) -> Value {
289 match value {
290 Some(value) => json!(round_trace_float(value)),
291 None => Value::Null,
292 }
293}
294
295fn rate_value(count: u64, window: Option<f64>) -> Value {
296 match window {
297 Some(window) => json!(round_trace_float(count as f64 / window)),
298 None => Value::Null,
299 }
300}
301
302fn sorted_counts(map: &HashMap<String, u64>) -> Vec<(&String, &u64)> {
303 let mut v = map.iter().collect::<Vec<_>>();
304 v.sort_by(|(ka, va), (kb, vb)| vb.cmp(va).then_with(|| ka.cmp(kb)));
305 v
306}
307
308fn event_counts_json(events: &HashMap<String, u64>) -> Value {
309 json!(
310 sorted_counts(events)
311 .into_iter()
312 .map(|(event, count)| json!({"event": event, "count": count}))
313 .collect::<Vec<_>>()
314 )
315}
316
317fn event_rates_json(events: &HashMap<String, u64>, window: Option<f64>) -> Value {
318 json!(
319 sorted_counts(events)
320 .into_iter()
321 .map(|(event, count)| json!({"event": event, "per_sec": rate_value(*count, window)}))
322 .collect::<Vec<_>>()
323 )
324}
325
326fn cpu_event_rate_summary(stats: &TraceStats, window: Option<f64>, limit: usize) -> Value {
327 let mut cpus = stats.per_cpu_events.iter().collect::<Vec<_>>();
328 cpus.sort_by(|(cpu_a, count_a), (cpu_b, count_b)| {
329 count_b.cmp(count_a).then_with(|| cpu_a.cmp(cpu_b))
330 });
331
332 let rates = match window {
333 Some(window) => cpus
334 .iter()
335 .map(|(_, count)| **count as f64 / window)
336 .collect::<Vec<_>>(),
337 None => Vec::new(),
338 };
339 let max_rate = rates.iter().copied().reduce(f64::max);
340 let median_rate = if rates.is_empty() {
341 None
342 } else {
343 Some(median(&rates))
344 };
345 let max_to_median = match (max_rate, median_rate) {
346 (Some(max), Some(median)) if median > 0.0 => Some(max / median),
347 _ => None,
348 };
349 let top_cpus = cpus
350 .iter()
351 .take(limit)
352 .map(|(cpu, count)| {
353 json!({
354 "cpu": cpu,
355 "events": count,
356 "events_per_sec": rate_value(**count, window),
357 })
358 })
359 .collect::<Vec<_>>();
360
361 json!({
362 "cpus_observed": stats.per_cpu_events.len(),
363 "rates_scope": "per-CPU observed event totals divided by the global retained trace_window.duration_sec",
364 "median_events_per_sec": rounded_option(median_rate),
365 "max_events_per_sec": rounded_option(max_rate),
366 "max_to_median_ratio": rounded_option(max_to_median),
367 "top_cpus_by_total_events": top_cpus,
368 })
369}
370
371fn trace_stats_json(
372 stats: &TraceStats,
373 trace_path: &Path,
374 report_path: &Path,
375 events: &[String],
376) -> Value {
377 let window = trace_window_sec(stats.start_ts, stats.end_ts);
378 json!({
379 "enabled": true,
380 "backend": "perf",
381 "events": events,
382 "artifacts": {
383 "perf_data": trace_path.display().to_string(),
384 "perf_script": report_path.display().to_string(),
385 },
386 "trace_window": {
387 "start_ts": rounded_option(stats.start_ts),
388 "end_ts": rounded_option(stats.end_ts),
389 "duration_sec": rounded_option(window),
390 "scope": "retained perf script sample window; max-size limits may make this shorter than the workload",
391 },
392 "event_count_semantics": "perf sample records. Tracepoints are sampled per occurrence; hardware PMU events are sampled according to perf's configured/default sampling period, not raw hardware event totals.",
393 "event_count_total": stats.total(),
394 "event_rate_total_per_sec": rate_value(stats.total(), window),
395 "event_counts": event_counts_json(&stats.events),
396 "event_rates_per_sec": event_rates_json(&stats.events, window),
397 "cpu_event_rate_summary": cpu_event_rate_summary(stats, window, 8),
398 })
399}
400
401struct TraceRecorder {
402 child: Child,
403 trace_path: PathBuf,
404 log_path: PathBuf,
405}
406
407impl TraceRecorder {
408 fn start(
409 sudo: &Sudo,
410 trace_path: PathBuf,
411 log_path: PathBuf,
412 max_total_bytes: u64,
413 max_size_arg: &str,
414 events: &[String],
415 ) -> Result<Self> {
416 let _ = std::fs::remove_file(&trace_path);
417 let _ = sudo
418 .command("rm", &["-f".to_string(), trace_path.display().to_string()])
419 .status();
420 let log = File::create(&log_path)
421 .with_context(|| format!("create perf record log {}", log_path.display()))?;
422 let log_err = log.try_clone().context("clone perf record log handle")?;
423 let mut args = vec![
424 "record".to_string(),
425 "-a".to_string(),
426 "-q".to_string(),
427 "--sample-cpu".to_string(),
428 "-T".to_string(),
429 "-o".to_string(),
430 trace_path.display().to_string(),
431 "--max-size".to_string(),
432 max_size_arg.to_string(),
433 "-m".to_string(),
434 perf_mmap_pages_per_cpu(max_total_bytes).to_string(),
435 ];
436 for event in events {
437 args.push("-e".to_string());
438 args.push(event.to_string());
439 }
440 args.push("--".to_string());
441 args.push("sleep".to_string());
442 args.push("2147483647".to_string());
443 let mut command = sudo.command("perf", &args);
444 command.stdout(log).stderr(log_err).stdin(Stdio::null());
445 unsafe {
446 command.pre_exec(|| {
447 libc::setsid();
448 Ok(())
449 });
450 }
451 let mut child = command
452 .spawn()
453 .with_context(|| format!("spawn perf record {}", trace_path.display()))?;
454 sleep(Duration::from_millis(250));
455 if let Some(status) = child.try_wait().context("poll perf startup")? {
456 anyhow::bail!(
457 "perf record exited during startup with {status}; log tail:\n{}",
458 tail(&read_lossy(&log_path), 1000)
459 );
460 }
461 Ok(Self {
462 child,
463 trace_path,
464 log_path,
465 })
466 }
467
468 fn stop(mut self, sudo: &Sudo, events: &[String]) -> Value {
469 let pid = self.child.id() as i32;
470 unsafe {
471 libc::kill(-pid, libc::SIGINT);
472 }
473 let deadline = Instant::now() + Duration::from_secs(10);
474 let mut stopped = false;
475 loop {
476 match self.child.try_wait() {
477 Ok(Some(_)) => {
478 stopped = true;
479 break;
480 }
481 Ok(None) => {
482 if Instant::now() >= deadline {
483 break;
484 }
485 sleep(Duration::from_millis(100));
486 }
487 Err(_) => break,
488 }
489 }
490 if !stopped {
491 unsafe {
492 libc::kill(-pid, libc::SIGKILL);
493 }
494 let _ = self.child.wait();
495 }
496 summarize_trace(sudo, &self.trace_path, &self.log_path, events)
497 }
498}
499
500fn summarize_trace(sudo: &Sudo, trace_path: &Path, log_path: &Path, events: &[String]) -> Value {
501 if !trace_path.exists() {
502 return json!({
503 "enabled": false,
504 "backend": "perf",
505 "error": format!("perf record did not create {}", trace_path.display()),
506 "log_tail": tail(&read_lossy(log_path), 1000),
507 });
508 }
509 let report_path = trace_path.with_extension("script");
510 let report = match File::create(&report_path) {
511 Ok(f) => f,
512 Err(e) => {
513 return json!({
514 "enabled": false,
515 "backend": "perf",
516 "perf_data": trace_path.display().to_string(),
517 "error": format!("create perf script report {}: {e}", report_path.display()),
518 "log_tail": tail(&read_lossy(log_path), 1000),
519 });
520 }
521 };
522 let report_err = match report.try_clone() {
523 Ok(f) => f,
524 Err(e) => {
525 return json!({
526 "enabled": false,
527 "backend": "perf",
528 "perf_data": trace_path.display().to_string(),
529 "error": format!("clone perf script report handle: {e}"),
530 "log_tail": tail(&read_lossy(log_path), 1000),
531 });
532 }
533 };
534 let args = vec![
535 "script".to_string(),
536 "--ns".to_string(),
537 "-F".to_string(),
538 "comm,pid,cpu,time,event".to_string(),
539 "-i".to_string(),
540 trace_path.display().to_string(),
541 ];
542 let status = sudo
543 .command("perf", &args)
544 .stdout(report)
545 .stderr(report_err)
546 .status();
547 if !matches!(status, Ok(s) if s.success()) {
548 return json!({
549 "enabled": false,
550 "backend": "perf",
551 "perf_data": trace_path.display().to_string(),
552 "perf_script": report_path.display().to_string(),
553 "error": format!("perf script failed: {:?}", status),
554 "report_tail": tail(&read_lossy(&report_path), 1000),
555 "log_tail": tail(&read_lossy(log_path), 1000),
556 });
557 }
558
559 let file = match File::open(&report_path) {
560 Ok(f) => f,
561 Err(e) => {
562 return json!({
563 "enabled": false,
564 "backend": "perf",
565 "perf_data": trace_path.display().to_string(),
566 "perf_script": report_path.display().to_string(),
567 "error": format!("open perf script report: {e}"),
568 "log_tail": tail(&read_lossy(log_path), 1000),
569 });
570 }
571 };
572 let mut stats = TraceStats::default();
573 for line in BufReader::new(file).lines().map_while(Result::ok) {
574 update_trace_stats(&mut stats, &line);
575 }
576 trace_stats_json(&stats, trace_path, &report_path, events)
577}
578
579fn run_capture(
583 cmd: &str,
584 timeout: Duration,
585 out_path: &Path,
586 progress: Option<Style>,
587 label: String,
588 interrupted: &AtomicBool,
589) -> Result<String> {
590 let out = File::create(out_path)
591 .with_context(|| format!("create workload log {}", out_path.display()))?;
592 let err = out.try_clone().context("clone workload log handle")?;
593 let mut command = Command::new("sh");
594 command
595 .arg("-c")
596 .arg(cmd)
597 .stdout(out)
598 .stderr(err)
599 .stdin(Stdio::null());
600 unsafe {
602 command.pre_exec(|| {
603 libc::setsid();
604 Ok(())
605 });
606 }
607 let mut child = command
608 .spawn()
609 .with_context(|| format!("spawn workload: {cmd}"))?;
610 let _spinner = progress.map(|style| ProgressSpinner::stdout(label, style));
611 let pid = child.id() as i32;
612 let deadline = Instant::now() + timeout;
613 loop {
614 match child.try_wait().context("poll workload")? {
615 Some(_) => break,
616 None => {
617 if interrupt::requested(interrupted) {
618 unsafe {
619 libc::kill(-pid, libc::SIGINT);
620 }
621 let deadline = Instant::now() + Duration::from_secs(2);
622 loop {
623 match child.try_wait().context("poll interrupted workload")? {
624 Some(_) => break,
625 None if Instant::now() >= deadline => {
626 unsafe {
627 libc::kill(-pid, libc::SIGKILL);
628 }
629 let _ = child.wait();
630 break;
631 }
632 None => sleep(Duration::from_millis(50)),
633 }
634 }
635 break;
636 }
637 if Instant::now() >= deadline {
638 unsafe {
639 libc::kill(-pid, libc::SIGKILL);
640 }
641 let _ = child.wait();
642 break;
643 }
644 sleep(Duration::from_millis(100));
645 }
646 }
647 }
648 Ok(read_lossy(out_path))
649}
650
651fn extract_metric(text: &str) -> Option<f64> {
654 let value = text.trim();
655 if value.is_empty() {
656 return None;
657 }
658 value.parse::<f64>().ok()
659}
660
661fn median(values: &[f64]) -> f64 {
662 let mut v = values.to_vec();
663 v.sort_by(|a, b| a.partial_cmp(b).unwrap_or(std::cmp::Ordering::Equal));
664 let n = v.len();
665 if n % 2 == 1 {
666 v[n / 2]
667 } else {
668 (v[n / 2 - 1] + v[n / 2]) / 2.0
669 }
670}
671
672fn pstdev(values: &[f64]) -> f64 {
674 let n = values.len();
675 if n <= 1 {
676 return 0.0;
677 }
678 let mean = values.iter().sum::<f64>() / n as f64;
679 let var = values.iter().map(|x| (x - mean).powi(2)).sum::<f64>() / n as f64;
680 var.sqrt()
681}
682
683fn binary_help(binary: &Path) -> String {
688 match std::process::Command::new(binary).arg("--help").output() {
689 Ok(out) => {
690 let mut help = String::from_utf8_lossy(&out.stdout).into_owned();
691 help.push_str(&String::from_utf8_lossy(&out.stderr));
692 help
693 }
694 Err(_) => String::new(),
695 }
696}
697
698struct Scheduler {
700 binary: PathBuf,
701 stats_interval: u64,
702 supports_stats: bool,
703 supports_verbose: bool,
704 log_path: PathBuf,
705 child: Option<Child>,
706}
707
708impl Scheduler {
709 fn new(binary: PathBuf, stats_interval: u64, log_path: PathBuf) -> Self {
710 let help = binary_help(&binary);
711 Scheduler {
712 binary,
713 stats_interval,
714 supports_stats: help.contains("--stats"),
715 supports_verbose: help.contains("--verbose"),
716 log_path,
717 child: None,
718 }
719 }
720
721 fn start(&mut self, sudo: &Sudo) -> Result<()> {
722 let log = File::create(&self.log_path)
723 .with_context(|| format!("create scheduler log {}", self.log_path.display()))?;
724 let log_err = log.try_clone().context("clone scheduler log handle")?;
725 let mut full_args: Vec<String> = Vec::new();
726 if self.supports_verbose {
730 full_args.push("--verbose".into());
731 }
732 if self.supports_stats {
735 full_args.push("--stats".into());
736 full_args.push(self.stats_interval.to_string());
737 }
738 let mut command = sudo.command(&self.binary.to_string_lossy(), &full_args);
739 command.stdout(log).stderr(log_err).stdin(Stdio::null());
740 unsafe {
742 command.pre_exec(|| {
743 libc::setsid();
744 Ok(())
745 });
746 }
747 self.child = Some(
748 command
749 .spawn()
750 .with_context(|| format!("spawn scheduler {}", self.binary.display()))?,
751 );
752 Ok(())
753 }
754
755 fn log_text(&self) -> String {
756 read_lossy(&self.log_path)
757 }
758
759 fn stop(&mut self, sudo: &Sudo) -> Option<String> {
761 if let Some(child) = self.child.as_mut()
762 && matches!(child.try_wait(), Ok(None))
763 {
764 unsafe {
766 libc::kill(child.id() as i32, libc::SIGINT);
767 }
768 let deadline = Instant::now() + Duration::from_secs(8);
769 loop {
770 match child.try_wait() {
771 Ok(Some(_)) | Err(_) => break,
772 Ok(None) => {
773 if Instant::now() >= deadline {
774 break;
775 }
776 sleep(Duration::from_millis(100));
777 }
778 }
779 }
780 }
781 let base = self
785 .binary
786 .file_name()
787 .map(|s| s.to_string_lossy().into_owned())
788 .unwrap_or_default();
789 for sig in ["-INT", "-KILL"] {
790 if !matches!(scx_state().as_deref(), None | Some("disabled")) {
791 let _ = sudo
792 .command("pkill", &[sig.into(), "-x".into(), base.clone()])
793 .status();
794 let never_interrupted = AtomicBool::new(false);
795 wait_for_state("disabled", Duration::from_secs(6), &never_interrupted);
796 }
797 }
798 scx_state()
799 }
800}
801
802pub fn exec_scheduler_foreground(
808 repo_root: &Path,
809 package: &str,
810 profile: &str,
811 sudo: &Sudo,
812) -> Result<()> {
813 match scx_state() {
814 None => {
815 anyhow::bail!("{SCX_STATE_PATH} not found; kernel lacks sched_ext");
816 }
817 Some(s) if s != "disabled" => {
818 anyhow::bail!("a scheduler is already attached (state={s:?})");
819 }
820 _ => {}
821 }
822 sudo.authenticate().context("sudo authentication failed")?;
823
824 let sched_bin = binary_path(repo_root, package, profile);
825 if !sched_bin.exists() {
826 anyhow::bail!("scheduler binary not found at {}", sched_bin.display());
827 }
828
829 let sched_prog = sched_bin.to_string_lossy().into_owned();
830 let args = Vec::new();
831 let mut command = sudo.command(&sched_prog, &args);
832 Err::<(), _>(command.exec()).with_context(|| format!("exec scheduler {}", sched_bin.display()))
833}
834
835struct Measurement {
837 value: Option<f64>,
838 scheduler_stats: String,
839 runtime_ok: bool,
840 sched_trace: Option<Value>,
841}
842
843fn measure_once(
844 spec: &Spec,
845 sched_bin: &Path,
846 workdir: &Path,
847 run_idx: u64,
848 sudo: &Sudo,
849 errors: &mut Vec<String>,
850 progress: Option<Style>,
851 trace_enabled: bool,
852 interrupted: &AtomicBool,
853) -> Result<Measurement> {
854 let log_path = workdir.join(format!("sched.run{run_idx}.log"));
855 let mut sched = Scheduler::new(
856 sched_bin.to_path_buf(),
857 spec.scheduler.stats_interval,
858 log_path,
859 );
860
861 sched.start(sudo)?;
862 let warmup = spec.scheduler.warmup_time;
863 let mut wl_out = String::new();
864 let mut sched_trace = None;
865 let attached = wait_for_state("enabled", Duration::from_secs(warmup + 6), interrupted);
866 if attached {
867 if !sleep_interruptible(Duration::from_secs(warmup), interrupted) {
868 let duration = Duration::from_secs(spec.workload.duration);
869 let cmd = spec
870 .workload
871 .command
872 .as_deref()
873 .context("[workload].command is required")?;
874 let out_path = workdir.join(format!("wl.run{run_idx}.out"));
875 let total_runs = spec.runs();
876 let label = if total_runs > 1 {
877 format!("running workload ({}/{total_runs})...", run_idx + 1)
878 } else {
879 "running workload...".to_string()
880 };
881 let trace_recorder = if trace_enabled {
882 let trace_path = workdir.join(format!("perf.run{run_idx}.data"));
883 let trace_log_path = workdir.join(format!("perf.run{run_idx}.log"));
884 let max_trace_bytes = spec.tracing.max_trace_bytes()?;
885 match TraceRecorder::start(
886 sudo,
887 trace_path,
888 trace_log_path,
889 max_trace_bytes,
890 &spec.tracing.max_trace_size,
891 &spec.tracing.trace_events,
892 ) {
893 Ok(recorder) => Some(recorder),
894 Err(e) => {
895 sched_trace = Some(json!({
896 "enabled": false,
897 "error": format!("{e:#}"),
898 }));
899 None
900 }
901 }
902 } else {
903 None
904 };
905 let workload = run_capture(cmd, duration, &out_path, progress, label, interrupted);
906 if let Some(recorder) = trace_recorder {
907 sched_trace = Some(recorder.stop(sudo, &spec.tracing.trace_events));
908 }
909 wl_out = workload?;
910 }
911 } else {
912 errors.push(format!(
913 "run {run_idx}: scheduler did not attach; log tail:\n{}",
914 tail(&sched.log_text(), 2000)
915 ));
916 }
917
918 let runtime_ok = if attached {
919 let post_workload_state = scx_state();
920 if scx_is_enabled(post_workload_state.as_deref()) {
921 true
922 } else {
923 errors.push(format!(
924 "run {run_idx}: scheduler is no longer enabled after workload \
925 (scx state={:?}); treating candidate as a regression to avoid \
926 measuring the default scheduler; log tail:\n{}",
927 post_workload_state,
928 tail(&sched.log_text(), 2000)
929 ));
930 false
931 }
932 } else {
933 true
934 };
935
936 let final_state = sched.stop(sudo);
937 if !matches!(final_state.as_deref(), None | Some("disabled")) {
938 errors.push(format!(
939 "run {run_idx}: teardown failed, scx state={:?}",
940 final_state
941 ));
942 }
943 if !attached {
944 return Ok(Measurement {
945 value: None,
946 scheduler_stats: sched.log_text(),
947 runtime_ok,
948 sched_trace,
949 });
950 }
951 if !runtime_ok {
952 return Ok(Measurement {
953 value: None,
954 scheduler_stats: sched.log_text(),
955 runtime_ok,
956 sched_trace,
957 });
958 }
959
960 let val = extract_metric(&wl_out);
961 if val.is_none() {
962 errors.push(format!(
963 "run {run_idx}: workload command output was not a plain number"
964 ));
965 }
966 Ok(Measurement {
967 value: val,
968 scheduler_stats: sched.log_text(),
969 runtime_ok,
970 sched_trace,
971 })
972}
973
974fn tail(s: &str, n: usize) -> String {
975 if s.len() > n {
976 s[s.len() - n..].to_string()
977 } else {
978 s.to_string()
979 }
980}
981
982#[allow(clippy::too_many_arguments)]
984fn fail(
985 stage: &str,
986 spec: Option<&Spec>,
987 errors: Vec<String>,
988 extra: Vec<(&str, Value)>,
989) -> Verdict {
990 let (metric_name, goal, package) = match spec {
991 Some(s) => (
992 METRIC_NAME.to_string(),
993 s.goal.direction.clone(),
994 Some(s.scheduler.package.clone()),
995 ),
996 None => (METRIC_NAME.to_string(), "minimize".to_string(), None),
997 };
998 let mut raw = serde_json::Map::new();
999 raw.insert("ok".into(), json!(false));
1000 raw.insert("stage".into(), json!(stage));
1001 if let Some(p) = &package {
1002 raw.insert("package".into(), json!(p));
1003 }
1004 raw.insert("errors".into(), json!(errors));
1005 for (k, v) in extra {
1006 raw.insert(k.into(), v);
1007 }
1008 Verdict {
1009 ok: false,
1010 stage: stage.to_string(),
1011 metric_name,
1012 goal,
1013 value: None,
1014 median: None,
1015 stddev: None,
1016 errors,
1017 raw: Value::Object(raw),
1018 }
1019}
1020
1021pub fn run_validation(
1023 repo_root: &Path,
1024 spec_path: &Path,
1025 sudo: &Sudo,
1026 verbose: bool,
1027 progress: Option<Style>,
1028 tracing_enabled: bool,
1029 interrupted: &AtomicBool,
1030) -> Result<Verdict> {
1031 let spec = match Spec::load(spec_path) {
1032 Ok(s) => s,
1033 Err(e) => return Ok(fail("spec", None, vec![format!("{e:#}")], vec![])),
1034 };
1035
1036 if interrupt::requested(interrupted) {
1037 return Ok(fail(
1038 "interrupted",
1039 Some(&spec),
1040 vec!["interrupted by Ctrl-C".to_string()],
1041 vec![],
1042 ));
1043 }
1044
1045 if spec.workload.command.is_none() {
1046 return Ok(fail(
1047 "spec",
1048 Some(&spec),
1049 vec![
1050 "[workload].command is required; it must run the workload and print one numeric \
1051 metric value"
1052 .to_string(),
1053 ],
1054 vec![],
1055 ));
1056 }
1057
1058 let package = spec.scheduler.package.clone();
1059 let profile = spec.scheduler.profile.clone();
1060
1061 if verbose {
1063 eprintln!(
1064 " validate: {} build --profile {profile} -p {package}",
1065 cargo_program().to_string_lossy()
1066 );
1067 }
1068 let _spinner = progress.map(|style| {
1069 ProgressSpinner::stdout(
1070 format!("building scheduler {package} for validation..."),
1071 style,
1072 )
1073 });
1074 let (ok, build_out) = cargo_build(repo_root, &package, &profile, interrupted);
1075 drop(_spinner);
1076 if interrupt::requested(interrupted) {
1077 return Ok(fail(
1078 "interrupted",
1079 Some(&spec),
1080 vec!["interrupted by Ctrl-C".to_string()],
1081 vec![("stderr", json!(tail(&build_out, 4000)))],
1082 ));
1083 }
1084 if !ok {
1085 return Ok(fail(
1086 "build",
1087 Some(&spec),
1088 vec!["cargo build failed".to_string()],
1089 vec![("stderr", json!(tail(&build_out, 4000)))],
1090 ));
1091 }
1092 let sched_bin = binary_path(repo_root, &package, &profile);
1093 if !sched_bin.exists() {
1094 return Ok(fail(
1095 "build",
1096 Some(&spec),
1097 vec![format!("built binary not found at {}", sched_bin.display())],
1098 vec![],
1099 ));
1100 }
1101
1102 match scx_state() {
1105 None => {
1106 return Ok(fail(
1107 "preflight",
1108 Some(&spec),
1109 vec![format!(
1110 "{SCX_STATE_PATH} not found; kernel lacks sched_ext"
1111 )],
1112 vec![],
1113 ));
1114 }
1115 Some(s) if s != "disabled" => {
1116 return Ok(fail(
1117 "preflight",
1118 Some(&spec),
1119 vec![format!("a scheduler is already attached (state={s:?})")],
1120 vec![],
1121 ));
1122 }
1123 _ => {}
1124 }
1125 if let Err(e) = sudo.authenticate() {
1126 return Ok(fail(
1127 "preflight",
1128 Some(&spec),
1129 vec![
1130 "sudo authentication failed; run as root, configure passwordless \
1131 sudo, set [system].sudo_passwd_file in the spec, or point \
1132 SCX_SUDO_PASSWORD_FILE at a file containing the sudo password"
1133 .to_string(),
1134 tail(&format!("{e}"), 500),
1135 ],
1136 vec![],
1137 ));
1138 }
1139
1140 let workdir = repo_root.join("target").join(format!("{package}_validate"));
1141 std::fs::create_dir_all(&workdir)
1142 .with_context(|| format!("create workdir {}", workdir.display()))?;
1143
1144 let mut errors: Vec<String> = Vec::new();
1145 let mut runtime_failed = false;
1146 let trace_enabled = tracing_enabled && perf_available();
1147 if verbose && tracing_enabled && !trace_enabled {
1148 eprintln!(" validate: perf not available; tracing disabled");
1149 }
1150
1151 let mut values: Vec<f64> = Vec::new();
1156 let mut last_stats = String::new();
1157 let mut trace_runs: Vec<Value> = Vec::new();
1158 for i in 0..spec.runs() {
1159 if interrupt::requested(interrupted) {
1160 break;
1161 }
1162 if verbose {
1163 eprintln!(" validate: run {}/{}", i + 1, spec.runs());
1164 }
1165 let measurement = measure_once(
1166 &spec,
1167 &sched_bin,
1168 &workdir,
1169 i,
1170 sudo,
1171 &mut errors,
1172 progress,
1173 trace_enabled,
1174 interrupted,
1175 )?;
1176 runtime_failed |= !measurement.runtime_ok;
1177 last_stats = measurement.scheduler_stats;
1178 if let Some(trace) = measurement.sched_trace {
1179 trace_runs.push(json!({
1180 "run": i,
1181 "summary": trace,
1182 }));
1183 }
1184 if let Some(v) = measurement.value {
1185 values.push(v);
1186 }
1187 if interrupt::requested(interrupted) {
1188 break;
1189 }
1190 }
1191
1192 if interrupt::requested(interrupted) {
1193 return Ok(fail(
1194 "interrupted",
1195 Some(&spec),
1196 vec!["interrupted by Ctrl-C".to_string()],
1197 vec![
1198 ("scheduler_log_tail", json!(tail(&last_stats, 2000))),
1199 ("sched_trace", json!(trace_runs)),
1200 ],
1201 ));
1202 }
1203
1204 if runtime_failed {
1205 return Ok(fail(
1206 "runtime",
1207 Some(&spec),
1208 errors,
1209 vec![
1210 ("scheduler_log_tail", json!(tail(&last_stats, 2000))),
1211 ("sched_trace", json!(trace_runs)),
1212 ],
1213 ));
1214 }
1215 if values.is_empty() && scx_state().as_deref() != Some("disabled") {
1216 return Ok(fail(
1217 "attach",
1218 Some(&spec),
1219 errors,
1220 vec![
1221 ("scheduler_log_tail", json!(tail(&last_stats, 2000))),
1222 ("sched_trace", json!(trace_runs)),
1223 ],
1224 ));
1225 }
1226 if values.is_empty() {
1227 let errs = if errors.is_empty() {
1228 vec!["no metric value extracted".to_string()]
1229 } else {
1230 errors
1231 };
1232 return Ok(fail(
1233 "metric",
1234 Some(&spec),
1235 errs,
1236 vec![
1237 ("scheduler_log_tail", json!(tail(&last_stats, 2000))),
1238 ("sched_trace", json!(trace_runs)),
1239 ],
1240 ));
1241 }
1242
1243 let med = median(&values);
1244 let std = pstdev(&values);
1245 let raw = json!({
1246 "ok": true,
1247 "stage": "complete",
1248 "package": package,
1249 "metric": {"name": METRIC_NAME, "goal": spec.goal.direction, "value": med},
1250 "runs": values,
1251 "median": med,
1252 "stddev": std,
1253 "scheduler_log_tail": tail(&last_stats, 2000),
1254 "sched_trace": trace_runs,
1255 "errors": errors,
1256 });
1257 Ok(Verdict {
1258 ok: true,
1259 stage: "complete".to_string(),
1260 metric_name: METRIC_NAME.to_string(),
1261 goal: spec.goal.direction.clone(),
1262 value: Some(med),
1263 median: Some(med),
1264 stddev: Some(std),
1265 errors,
1266 raw,
1267 })
1268}
1269
1270#[cfg(test)]
1271mod tests {
1272 use super::*;
1273
1274 #[test]
1275 fn extract_metric_parses_plain_number() {
1276 assert_eq!(extract_metric(" 42.5\n"), Some(42.5));
1277 assert_eq!(extract_metric(""), None);
1278 assert_eq!(extract_metric("value=42.5"), None);
1279 assert_eq!(extract_metric("42.5\n43.5"), None);
1280 }
1281
1282 #[test]
1283 fn trace_stats_parse_events_generically() {
1284 let mut stats = TraceStats::default();
1285 assert_eq!(
1286 trace_event("bash-10 [003] d..2. 1.000: sched_switch: prev_comm=bash prev_pid=10"),
1287 Some((1.0, "sched_switch".to_string(), Some("003".to_string())))
1288 );
1289 assert_eq!(
1290 trace_event("workload 1234 [007] 2.000000001: 100000 cache-misses:"),
1291 Some((
1292 2.000000001,
1293 "cache-misses".to_string(),
1294 Some("007".to_string())
1295 ))
1296 );
1297 update_trace_stats(
1298 &mut stats,
1299 "bash-10 [003] d..2. 1.000: sched_switch: prev_comm=bash prev_pid=10 prev_prio=120 prev_state=R ==> next_comm=schbench next_pid=11 next_prio=120",
1300 );
1301 update_trace_stats(
1302 &mut stats,
1303 "kworker-7 [001] d..2. 1.001: sched_wakeup: comm=schbench pid=11 prio=120 target_cpu=003",
1304 );
1305 update_trace_stats(
1306 &mut stats,
1307 "kworker-8 [002] d..2. 1.002: sched_wakeup_new: comm=worker pid=12 prio=120 target_cpu=004",
1308 );
1309 update_trace_stats(
1310 &mut stats,
1311 "bash-10 [003] d..2. 1.003: sched_migrate_task: comm=schbench pid=11 prio=120 orig_cpu=003 dest_cpu=005",
1312 );
1313 update_trace_stats(
1314 &mut stats,
1315 "irq/99-42 [005] d..2. 1.004: irq_handler_entry: irq=99 name=test_irq",
1316 );
1317
1318 assert_eq!(stats.total(), 5);
1319 assert_eq!(stats.events.get("sched_switch"), Some(&1));
1320 assert_eq!(stats.events.get("sched_wakeup"), Some(&1));
1321 assert_eq!(stats.events.get("sched_wakeup_new"), Some(&1));
1322 assert_eq!(stats.events.get("sched_migrate_task"), Some(&1));
1323 assert_eq!(stats.events.get("irq_handler_entry"), Some(&1));
1324 assert_eq!(stats.per_cpu_events.get("003"), Some(&2));
1325 assert_eq!(stats.start_ts, Some(1.0));
1326 assert_eq!(stats.end_ts, Some(1.004));
1327
1328 let summary = trace_stats_json(
1329 &stats,
1330 Path::new("/tmp/perf.data"),
1331 Path::new("/tmp/perf.script"),
1332 &[
1333 "sched:sched_switch".to_string(),
1334 "irq:irq_handler_entry".to_string(),
1335 ],
1336 );
1337 assert_eq!(summary["trace_window"]["start_ts"], json!(1.0));
1338 assert_eq!(summary["trace_window"]["end_ts"], json!(1.004));
1339 assert_eq!(summary["trace_window"]["duration_sec"], json!(0.004));
1340 assert_eq!(summary["event_count_total"], json!(5));
1341 assert_eq!(summary["event_rate_total_per_sec"], json!(1250.0));
1342 assert_eq!(
1343 summary["event_counts"][0],
1344 json!({"event": "irq_handler_entry", "count": 1})
1345 );
1346 assert_eq!(
1347 summary["event_rates_per_sec"][0],
1348 json!({"event": "irq_handler_entry", "per_sec": 250.0})
1349 );
1350 assert!(!summary.as_object().unwrap().contains_key("sched_switch"));
1351 assert!(!summary.as_object().unwrap().contains_key("top_switch_cpu"));
1352 assert!(
1353 !summary
1354 .as_object()
1355 .unwrap()
1356 .contains_key("top_cpu_event_rates")
1357 );
1358 assert_eq!(summary["cpu_event_rate_summary"]["cpus_observed"], json!(4));
1359 assert_eq!(
1360 summary["cpu_event_rate_summary"]["median_events_per_sec"],
1361 json!(250.0)
1362 );
1363 assert_eq!(
1364 summary["cpu_event_rate_summary"]["max_events_per_sec"],
1365 json!(500.0)
1366 );
1367 assert_eq!(
1368 summary["cpu_event_rate_summary"]["max_to_median_ratio"],
1369 json!(2.0)
1370 );
1371 assert_eq!(
1372 summary["cpu_event_rate_summary"]["top_cpus_by_total_events"][0]["cpu"],
1373 json!("003")
1374 );
1375 assert_eq!(
1376 summary["cpu_event_rate_summary"]["top_cpus_by_total_events"][0]["events"],
1377 json!(2)
1378 );
1379 assert_eq!(
1380 summary["cpu_event_rate_summary"]["top_cpus_by_total_events"][0]["events_per_sec"],
1381 json!(500.0)
1382 );
1383 }
1384
1385 #[test]
1386 fn scx_is_enabled_only_accepts_enabled_state() {
1387 assert!(scx_is_enabled(Some("enabled")));
1388 assert!(!scx_is_enabled(Some("disabled")));
1389 assert!(!scx_is_enabled(Some("enabling")));
1390 assert!(!scx_is_enabled(None));
1391 }
1392
1393 #[test]
1394 fn median_odd_even() {
1395 assert_eq!(median(&[3.0, 1.0, 2.0]), 2.0);
1396 assert_eq!(median(&[1.0, 2.0, 3.0, 4.0]), 2.5);
1397 }
1398
1399 #[test]
1400 fn pstdev_matches_population() {
1401 let s = pstdev(&[1.0, 2.0, 3.0, 4.0, 5.0]);
1403 assert!((s - 1.414_213_562).abs() < 1e-6);
1404 assert_eq!(pstdev(&[42.0]), 0.0);
1405 }
1406
1407 #[test]
1408 fn binary_path_profiles() {
1409 let r = Path::new("/repo");
1410 assert_eq!(
1411 binary_path(r, "scx_forge", "release"),
1412 Path::new("/repo/target/release/scx_forge")
1413 );
1414 assert_eq!(
1415 binary_path(r, "scx_forge", "dev"),
1416 Path::new("/repo/target/debug/scx_forge")
1417 );
1418 assert_eq!(
1419 binary_path(r, "scx_forge", "bench"),
1420 Path::new("/repo/target/bench/scx_forge")
1421 );
1422 }
1423}