Skip to main content

scx_pandemonium/cli/
bench.rs

1use std::fs::{self, File};
2use std::os::unix::process::CommandExt;
3use std::process::{Command, Stdio};
4use std::time::{Duration, Instant};
5
6use anyhow::{bail, Result};
7use clap::ValueEnum;
8
9use super::child_guard::ChildGuard;
10use super::report::{format_delta, format_latency_delta, mean_stdev, percentile, save_report};
11use super::{binary_path, is_scx_active, self_exe, wait_for_activation, LOG_DIR, TARGET_DIR};
12
13#[derive(Clone, ValueEnum)]
14pub enum BenchMode {
15    /// A/B using own compilation as workload
16    #[value(name = "self")]
17    SelfBuild,
18    /// Compile + interactive probe (wakeup latency)
19    Contention,
20    /// Compile + audio playback (xruns)
21    Mixed,
22    /// A/B with user-provided command
23    Cmd,
24}
25
26// BUILD RELEASE BINARY AND RUN BENCH, SAVING LOGS
27pub fn run_bench_run(
28    mode: BenchMode,
29    cmd: Option<&str>,
30    iterations: usize,
31    clean_cmd: Option<&str>,
32    sched_args: &[String],
33) -> Result<()> {
34    fs::create_dir_all(LOG_DIR)?;
35
36    let build_out = File::create(format!("{}/build.out", LOG_DIR))?;
37    let build_err = File::create(format!("{}/build.err", LOG_DIR))?;
38    let status = Command::new("cargo")
39        .args(["build", "--release"])
40        .env("CARGO_TARGET_DIR", TARGET_DIR)
41        .stdout(Stdio::from(build_out))
42        .stderr(Stdio::from(build_err))
43        .status()?;
44    if !status.success() {
45        bail!("BUILD FAILED. SEE {}/build.err", LOG_DIR);
46    }
47
48    let extra_args: Vec<String> = sched_args.to_vec();
49
50    let bench_out = File::create(format!("{}/bench.out", LOG_DIR))?;
51    let bench_err = File::create(format!("{}/bench.err", LOG_DIR))?;
52    let mut bench_cmd = Command::new(binary_path());
53    let mode_name = mode
54        .to_possible_value()
55        .ok_or_else(|| anyhow::anyhow!("INVALID BENCH MODE"))?
56        .get_name()
57        .to_string();
58    bench_cmd
59        .arg("bench")
60        .arg("--mode")
61        .arg(mode_name)
62        .arg("--iterations")
63        .arg(iterations.to_string());
64    if let Some(c) = cmd {
65        bench_cmd.arg("--cmd").arg(c);
66    }
67    if let Some(cc) = clean_cmd {
68        bench_cmd.arg("--clean-cmd").arg(cc);
69    }
70    if !extra_args.is_empty() {
71        bench_cmd.arg("--").args(extra_args);
72    }
73
74    let status = bench_cmd
75        .stdout(Stdio::from(bench_out))
76        .stderr(Stdio::from(bench_err))
77        .status()?;
78    if !status.success() {
79        bail!("BENCH FAILED. SEE {}/bench.err", LOG_DIR);
80    }
81
82    log_info!("Build logs: {}/build.out {}/build.err", LOG_DIR, LOG_DIR);
83    log_info!("Bench logs: {}/bench.out {}/bench.err", LOG_DIR, LOG_DIR);
84    Ok(())
85}
86
87fn timed_run(cmd: &str) -> Option<f64> {
88    log_info!("Running: {}", cmd);
89    let start = Instant::now();
90    let result = Command::new("sh")
91        .args(["-c", cmd])
92        .stdout(Stdio::null())
93        .stderr(Stdio::piped())
94        .output();
95    let elapsed = start.elapsed().as_secs_f64();
96    match result {
97        Ok(r) if r.status.success() => {
98            log_info!("Completed in {:.2}s", elapsed);
99            Some(elapsed)
100        }
101        Ok(r) => {
102            let stderr = String::from_utf8_lossy(&r.stderr);
103            log_error!(
104                "Command failed (exit {}): {}",
105                r.status.code().unwrap_or(-1),
106                &stderr[..stderr.len().min(500)]
107            );
108            None
109        }
110        Err(e) => {
111            log_error!("Command failed: {}", e);
112            None
113        }
114    }
115}
116
117fn start_scheduler(sched_args: &[String]) -> Result<ChildGuard> {
118    let bin = binary_path();
119    let mut args = Vec::new();
120    args.extend(sched_args.iter().cloned());
121
122    let child = Command::new("sudo")
123        .arg(&bin)
124        .args(&args)
125        .process_group(0)
126        .stdout(Stdio::piped())
127        .stderr(Stdio::piped())
128        .spawn()?;
129    Ok(ChildGuard::new(child))
130}
131
132fn stop_scheduler(guard: &mut ChildGuard) {
133    guard.stop();
134}
135
136fn ensure_scheduler_started(sched_args: &[String]) -> Result<ChildGuard> {
137    let guard = start_scheduler(sched_args)?;
138    if !wait_for_activation(10) {
139        bail!("PANDEMONIUM DID NOT ACTIVATE WITHIN 10S");
140    }
141    log_info!("PANDEMONIUM is active");
142    std::thread::sleep(Duration::from_secs(2));
143    Ok(guard)
144}
145
146pub fn run_bench(
147    mode: BenchMode,
148    cmd: Option<&str>,
149    iterations: usize,
150    clean_cmd: Option<&str>,
151    sched_args: &[String],
152) -> Result<()> {
153    match mode {
154        BenchMode::SelfBuild => bench_general(
155            &format!("CARGO_TARGET_DIR={} cargo build --release", TARGET_DIR),
156            iterations,
157            Some(&format!("cargo clean --target-dir {}", TARGET_DIR)),
158            sched_args,
159        ),
160        BenchMode::Cmd => {
161            let cmd = cmd.ok_or_else(|| anyhow::anyhow!("--cmd required for --mode cmd"))?;
162            bench_general(cmd, iterations, clean_cmd, sched_args)
163        }
164        BenchMode::Mixed => bench_mixed(sched_args),
165        BenchMode::Contention => bench_contention(sched_args),
166    }
167}
168
169// A/B BENCHMARK: EEVDF VS PANDEMONIUM (GENERIC)
170fn bench_general(
171    cmd: &str,
172    iterations: usize,
173    clean_cmd: Option<&str>,
174    sched_args: &[String],
175) -> Result<()> {
176    let sep = "=".repeat(60);
177    log_info!("PANDEMONIUM A/B benchmark");
178    log_info!("Command: {}", cmd);
179    log_info!("Iterations: {}", iterations);
180    if let Some(cc) = clean_cmd {
181        log_info!("Clean cmd: {}", cc);
182    }
183
184    if is_scx_active() {
185        bail!("SCHED_EXT IS ALREADY ACTIVE. STOP IT BEFORE BENCHMARKING.");
186    }
187
188    // PHASE 1: EEVDF BASELINE
189    log_info!("Phase 1: EEVDF baseline");
190    let mut eevdf_times = Vec::new();
191    for i in 0..iterations {
192        log_info!("Iteration {}/{}", i + 1, iterations);
193        if let Some(cc) = clean_cmd {
194            let _ = Command::new("sh").args(["-c", cc]).output();
195        }
196        match timed_run(cmd) {
197            Some(t) => eevdf_times.push(t),
198            None => bail!("ABORTING BENCHMARK: COMMAND FAILED"),
199        }
200    }
201
202    // PHASE 2: START PANDEMONIUM
203    log_info!("Phase 2: starting PANDEMONIUM");
204    let mut pand_proc = ensure_scheduler_started(sched_args)?;
205
206    // PHASE 3: PANDEMONIUM BENCHMARK
207    log_info!("Phase 3: PANDEMONIUM benchmark");
208    let mut pand_times = Vec::new();
209    for i in 0..iterations {
210        log_info!("Iteration {}/{}", i + 1, iterations);
211        if let Some(cc) = clean_cmd {
212            let _ = Command::new("sh").args(["-c", cc]).output();
213        }
214        match timed_run(cmd) {
215            Some(t) => pand_times.push(t),
216            None => {
217                stop_scheduler(&mut pand_proc);
218                bail!("ABORTING BENCHMARK: COMMAND FAILED");
219            }
220        }
221    }
222
223    // PHASE 4: STOP
224    log_info!("Phase 4: stopping PANDEMONIUM");
225    stop_scheduler(&mut pand_proc);
226    log_info!("PANDEMONIUM stopped");
227
228    // RESULTS
229    let (eevdf_mean, eevdf_std) = mean_stdev(&eevdf_times);
230    let (pand_mean, pand_std) = mean_stdev(&pand_times);
231    let delta_pct = if eevdf_mean > 0.0 {
232        ((pand_mean - eevdf_mean) / eevdf_mean) * 100.0
233    } else {
234        0.0
235    };
236
237    let mut report = Vec::new();
238    report.push(sep.clone());
239    report.push("BENCHMARK RESULTS".to_string());
240    report.push(sep.clone());
241    report.push(format!("COMMAND: {}", cmd));
242    report.push(format!("ITERATIONS: {}", iterations));
243    report.push(String::new());
244    report.push(format!(
245        "EEVDF:       {:.2}s +/- {:.2}s",
246        eevdf_mean, eevdf_std
247    ));
248    report.push(format!(
249        "  RUNS: {}",
250        eevdf_times
251            .iter()
252            .map(|t| format!("{:.2}s", t))
253            .collect::<Vec<_>>()
254            .join(", ")
255    ));
256    report.push(format!(
257        "PANDEMONIUM: {:.2}s +/- {:.2}s",
258        pand_mean, pand_std
259    ));
260    report.push(format!(
261        "  RUNS: {}",
262        pand_times
263            .iter()
264            .map(|t| format!("{:.2}s", t))
265            .collect::<Vec<_>>()
266            .join(", ")
267    ));
268    report.push(String::new());
269    report.push(format_delta(delta_pct, "BUILD"));
270    report.push(sep.clone());
271
272    let report_text = report.join("\n") + "\n";
273    for line in &report {
274        println!("{}", line);
275    }
276
277    let path = save_report(&report_text, "benchmark")?;
278    println!("\nSAVED TO {}", path);
279    Ok(())
280}
281
282// PW-TOP SNAPSHOT: CAPTURE PIPEWIRE XRUN COUNTS
283fn pw_top_snapshot() -> Vec<(String, i64)> {
284    let mut child = match Command::new("pw-top")
285        .arg("-b")
286        .stdout(Stdio::piped())
287        .stderr(Stdio::null())
288        .spawn()
289    {
290        Ok(c) => c,
291        Err(_) => return Vec::new(),
292    };
293
294    std::thread::sleep(Duration::from_millis(1500));
295    let _ = child.kill();
296    let output = match child.wait_with_output() {
297        Ok(o) => o,
298        Err(_) => return Vec::new(),
299    };
300
301    let stdout = String::from_utf8_lossy(&output.stdout);
302    let mut entries = Vec::new();
303    for line in stdout.lines() {
304        let line = line.trim();
305        if line.is_empty() || line.starts_with("S ") {
306            continue;
307        }
308        let parts: Vec<&str> = line.split_whitespace().collect();
309        if parts.len() < 10 {
310            continue;
311        }
312        if !matches!(parts[0], "R" | "S" | "C") {
313            continue;
314        }
315        if let Ok(err) = parts[8].parse::<i64>() {
316            let name = parts[9..]
317                .join(" ")
318                .trim_start_matches(['+', ' '])
319                .to_string();
320            entries.push((name, err));
321        }
322    }
323    entries
324}
325
326fn pw_audio_playing() -> bool {
327    Command::new("pactl")
328        .args(["list", "sink-inputs", "short"])
329        .output()
330        .map(|o| o.status.success() && !o.stdout.is_empty())
331        .unwrap_or(false)
332}
333
334fn pw_get_xruns() -> i64 {
335    pw_top_snapshot().iter().map(|(_, err)| err).sum()
336}
337
338// MIXED BENCHMARK: COMPILE + AUDIO
339fn bench_mixed(sched_args: &[String]) -> Result<()> {
340    let sep = "=".repeat(60);
341    log_info!("PANDEMONIUM mixed workload benchmark");
342
343    if !pw_audio_playing() {
344        bail!("NO AUDIO PLAYING. START AUDIO PLAYBACK FIRST.");
345    }
346
347    let entries = pw_top_snapshot();
348    log_info!("Active PipeWire nodes:");
349    for (name, err) in &entries {
350        log_info!("  {} (xruns: {})", name, err);
351    }
352
353    if is_scx_active() {
354        bail!("SCHED_EXT IS ALREADY ACTIVE. STOP IT BEFORE BENCHMARKING.");
355    }
356
357    let build_cmd = format!("CARGO_TARGET_DIR={} cargo build --release", TARGET_DIR);
358    let clean_cmd = format!("cargo clean --target-dir {}", TARGET_DIR);
359
360    let sched_args = sched_args.to_vec();
361
362    // PHASE 1: EEVDF
363    log_info!("Phase 1: EEVDF (default scheduler)");
364    let _ = Command::new("sh").args(["-c", &clean_cmd]).output();
365    let xruns_before = pw_get_xruns();
366    log_info!("Xruns before: {}", xruns_before);
367    let eevdf_time = timed_run(&build_cmd).ok_or_else(|| anyhow::anyhow!("BUILD FAILED"))?;
368    let xruns_after = pw_get_xruns();
369    let eevdf_xruns = xruns_after - xruns_before;
370    log_info!("Xruns after: {} (delta: {})", xruns_after, eevdf_xruns);
371
372    // PHASE 2: START PANDEMONIUM
373    log_info!("Phase 2: starting PANDEMONIUM");
374    let mut pand_proc = ensure_scheduler_started(&sched_args)?;
375
376    // PHASE 3: PANDEMONIUM
377    log_info!("Phase 3: PANDEMONIUM");
378    let _ = Command::new("sh").args(["-c", &clean_cmd]).output();
379    let xruns_before = pw_get_xruns();
380    log_info!("Xruns before: {}", xruns_before);
381    let pand_time = match timed_run(&build_cmd) {
382        Some(t) => t,
383        None => {
384            stop_scheduler(&mut pand_proc);
385            bail!("BUILD FAILED");
386        }
387    };
388    let xruns_after = pw_get_xruns();
389    let pand_xruns = xruns_after - xruns_before;
390    log_info!("Xruns after: {} (delta: {})", xruns_after, pand_xruns);
391
392    // PHASE 4: STOP
393    log_info!("Phase 4: stopping PANDEMONIUM");
394    stop_scheduler(&mut pand_proc);
395    log_info!("PANDEMONIUM stopped");
396
397    // RESULTS
398    let delta_pct = if eevdf_time > 0.0 {
399        ((pand_time - eevdf_time) / eevdf_time) * 100.0
400    } else {
401        0.0
402    };
403    let xrun_delta = pand_xruns - eevdf_xruns;
404
405    let mut report = Vec::new();
406    report.push(sep.clone());
407    report.push("MIXED WORKLOAD BENCHMARK RESULTS".to_string());
408    report.push(sep.clone());
409    report.push("WORKLOAD: CARGO BUILD --RELEASE + AUDIO PLAYBACK".to_string());
410    report.push(String::new());
411    report.push(format!(
412        "{:<16} {:>12} {:>12}",
413        "SCHEDULER", "BUILD TIME", "AUDIO XRUNS"
414    ));
415    report.push(format!(
416        "{} {} {}",
417        "-".repeat(16),
418        "-".repeat(12),
419        "-".repeat(12)
420    ));
421    report.push(format!(
422        "{:<16} {:>11.2}s {:>12}",
423        "EEVDF", eevdf_time, eevdf_xruns
424    ));
425    report.push(format!(
426        "{:<16} {:>11.2}s {:>12}",
427        "PANDEMONIUM", pand_time, pand_xruns
428    ));
429    report.push(String::new());
430    report.push(format_delta(delta_pct, "BUILD"));
431    if xrun_delta < 0 {
432        report.push(format!(
433            "XRUN DELTA:  {:+} (PANDEMONIUM HAS FEWER AUDIO GLITCHES)",
434            xrun_delta
435        ));
436    } else if xrun_delta > 0 {
437        report.push(format!(
438            "XRUN DELTA:  {:+} (PANDEMONIUM HAS MORE AUDIO GLITCHES)",
439            xrun_delta
440        ));
441    } else {
442        report.push("XRUN DELTA:  0 (SAME AUDIO QUALITY)".to_string());
443    }
444    report.push(sep.clone());
445
446    let report_text = report.join("\n") + "\n";
447    for line in &report {
448        println!("{}", line);
449    }
450
451    let path = save_report(&report_text, "mixed")?;
452    println!("\nSAVED TO {}", path);
453    Ok(())
454}
455
456// CONTENTION BENCHMARK: COMPILE + INTERACTIVE PROBE
457fn bench_contention(sched_args: &[String]) -> Result<()> {
458    let sep = "=".repeat(60);
459    log_info!("PANDEMONIUM contention benchmark");
460    log_info!("Workload: cargo build --release + interactive probe (10ms sleep/wake)");
461
462    if is_scx_active() {
463        bail!("SCHED_EXT IS ALREADY ACTIVE. STOP IT BEFORE BENCHMARKING.");
464    }
465
466    let build_cmd = format!("CARGO_TARGET_DIR={} cargo build --release", TARGET_DIR);
467    let clean_cmd = format!("cargo clean --target-dir {}", TARGET_DIR);
468
469    // COPY SELF TO SAFE LOCATION -- cargo clean DELETES THE TARGET DIR
470    // WHICH CONTAINS THE VERY BINARY WE'RE RUNNING FROM
471    std::fs::create_dir_all(super::LOG_DIR)?;
472    let probe_exe = format!("{}/probe", super::LOG_DIR);
473    std::fs::copy(self_exe(), &probe_exe)?;
474
475    let sched_args = sched_args.to_vec();
476
477    struct PhaseResult {
478        name: String,
479        build_time: f64,
480        samples: usize,
481        median: f64,
482        p99: f64,
483        worst: f64,
484    }
485
486    let phases: Vec<(&str, bool)> = vec![("EEVDF (DEFAULT)", false), ("PANDEMONIUM", true)];
487
488    let mut results = Vec::new();
489
490    for (phase_name, use_scheduler) in &phases {
491        log_info!("Phase: {}", phase_name);
492
493        let mut pand_proc = if *use_scheduler {
494            Some(ensure_scheduler_started(&sched_args)?)
495        } else {
496            None
497        };
498
499        // CLEAN BUILD
500        let _ = Command::new("sh").args(["-c", &clean_cmd]).output();
501
502        // START PROBE WITH DEATH PIPE + PROCESS GROUP
503        let (death_read, death_write) = super::death_pipe::create_death_pipe()
504            .map_err(|e| anyhow::anyhow!("DEATH PIPE: {}", e))?;
505        let death_write_copy = death_write;
506        let probe_proc = unsafe {
507            Command::new(&probe_exe)
508                .arg("probe")
509                .arg("--death-pipe-fd")
510                .arg(death_read.to_string())
511                .process_group(0)
512                .stdout(Stdio::piped())
513                .stderr(Stdio::null())
514                .pre_exec(move || {
515                    libc::close(death_write_copy);
516                    libc::prctl(libc::PR_SET_PDEATHSIG, libc::SIGTERM as libc::c_ulong);
517                    Ok(())
518                })
519                .spawn()?
520        };
521        super::death_pipe::close_fd(death_read);
522        let probe_guard = ChildGuard::new(probe_proc);
523
524        // RUN BUILD
525        log_info!("Building...");
526        let build_start = Instant::now();
527        let build_result = Command::new("sh")
528            .args(["-c", &build_cmd])
529            .stdout(Stdio::null())
530            .stderr(Stdio::piped())
531            .output()?;
532        let build_time = build_start.elapsed().as_secs_f64();
533
534        if !build_result.status.success() {
535            log_error!(
536                "Build failed (exit {})",
537                build_result.status.code().unwrap_or(-1)
538            );
539            drop(probe_guard);
540            super::death_pipe::close_fd(death_write);
541            if let Some(ref mut p) = pand_proc {
542                stop_scheduler(p);
543            }
544            bail!("BUILD FAILED");
545        }
546
547        // LET PROBE SETTLE
548        std::thread::sleep(Duration::from_secs(1));
549
550        // STOP PROBE AND COLLECT OUTPUT
551        unsafe {
552            libc::killpg(probe_guard.id() as i32, libc::SIGTERM);
553        }
554        let probe_child = probe_guard.into_child();
555        let probe_output = probe_child.wait_with_output()?;
556        super::death_pipe::close_fd(death_write);
557        let probe_stdout = String::from_utf8_lossy(&probe_output.stdout);
558
559        // STOP SCHEDULER IF RUNNING
560        if let Some(ref mut p) = pand_proc {
561            stop_scheduler(p);
562            log_info!("PANDEMONIUM stopped");
563        }
564
565        // PARSE PROBE OUTPUT
566        let mut overshoots: Vec<f64> = probe_stdout
567            .lines()
568            .filter_map(|line| line.trim().parse::<f64>().ok())
569            .collect();
570        overshoots.sort_by(|a, b| a.partial_cmp(b).unwrap());
571
572        let n = overshoots.len();
573        let med = percentile(&overshoots, 50.0);
574        let p99 = percentile(&overshoots, 99.0);
575        let worst = overshoots.last().copied().unwrap_or(0.0);
576
577        log_info!("Build time: {:.2}s", build_time);
578        log_info!("Probe samples: {}", n);
579        log_info!("Median overshoot: {:.0}us", med);
580        log_info!("P99 overshoot: {:.0}us", p99);
581        log_info!("Worst overshoot: {:.0}us", worst);
582
583        results.push(PhaseResult {
584            name: phase_name.to_string(),
585            build_time,
586            samples: n,
587            median: med,
588            p99,
589            worst,
590        });
591    }
592
593    // REPORT
594    let eevdf = &results[0];
595    let pand = &results[1];
596
597    let build_delta = if eevdf.build_time > 0.0 {
598        ((pand.build_time - eevdf.build_time) / eevdf.build_time) * 100.0
599    } else {
600        0.0
601    };
602    let med_delta = pand.median - eevdf.median;
603    let p99_delta = pand.p99 - eevdf.p99;
604
605    let mut report = Vec::new();
606    report.push(sep.clone());
607    report.push("CONTENTION BENCHMARK RESULTS".to_string());
608    report.push(sep.clone());
609    report
610        .push("WORKLOAD: CARGO BUILD --RELEASE + INTERACTIVE PROBE (10MS SLEEP/WAKE)".to_string());
611    report.push(String::new());
612    report.push(format!(
613        "{:<24} {:>8} {:>8} {:>8} {:>8} {:>8}",
614        "SCHEDULER", "BUILD", "SAMPLES", "MEDIAN", "P99", "WORST"
615    ));
616    report.push(format!(
617        "{} {} {} {} {} {}",
618        "-".repeat(24),
619        "-".repeat(8),
620        "-".repeat(8),
621        "-".repeat(8),
622        "-".repeat(8),
623        "-".repeat(8),
624    ));
625    for r in &results {
626        report.push(format!(
627            "{:<24} {:>7.2}s {:>8} {:>7.0}us {:>7.0}us {:>7.0}us",
628            r.name, r.build_time, r.samples, r.median, r.p99, r.worst,
629        ));
630    }
631    report.push(String::new());
632    report.push(format_delta(build_delta, "BUILD"));
633    report.push(format_latency_delta(med_delta, "MEDIAN"));
634    report.push(format_latency_delta(p99_delta, "P99"));
635    report.push(sep.clone());
636
637    let report_text = report.join("\n") + "\n";
638    for line in &report {
639        println!("{}", line);
640    }
641
642    let path = save_report(&report_text, "contention")?;
643    println!("\nSAVED TO {}", path);
644    Ok(())
645}