Files
TheRock/rocm-systems/emulation/mirage/tests/emulator_benchmark.rs
T
derek.holloway 7502bf64a4
CodeQL / Analyze (actions) (push) Canceled after 0s
CodeQL / Analyze (python) (push) Canceled after 0s
pre-commit / pre-commit (push) Canceled after 0s
Multi-Arch CI / setup (push) Canceled after 0s
Publish build_manylinux_x86_64 images / publish_build_manylinux_x86_64 (push) Canceled after 0s
Setup (Multi-Arch) / setup (push) Canceled after 0s
Multi-Arch CI / Linux::${{ fromJSON(needs.setup.outputs.linux_build_config || '{}').build_variant_label || 'skip' }} (push) Canceled after 0s
Publish TheRock Docker image / build-and-push-image (push) Canceled after 0s
Publish no_rocm_image_ubi10 image / publish_no_rocm_image_ubi10 (push) Canceled after 0s
Multi-Arch CI / Windows::${{ fromJSON(needs.setup.outputs.windows_build_config || '{}').build_variant_label || 'skip' }} (push) Canceled after 0s
Publish TheRock Docker image / build-and-push-image (push) Canceled after 0s
Multi-Arch CI / Manifest Diff (push) Canceled after 0s
TheRock Manifest Diff Report / Generate Manifest Diff Report (push) Canceled after 0s
Multi-Arch CI / CI Summary (push) Canceled after 0s
Publish no_rocm_image_ubuntu24_04 images / publish_no_rocm_image_ubuntu24_04 (push) Canceled after 0s
Publish no_rocm_image_ubuntu24_04_ocl_rt images / publish_no_rocm_image_ubuntu24_04_ocl_rt (push) Canceled after 0s
Publish TheRock Docker image / build-and-push-image (push) Canceled after 0s
Publish TheRock Docker image / build-and-push-image (push) Canceled after 0s
Publish no_rocm_image_ubuntu24_04 images / publish_no_rocm_image_ubuntu24_04_rocgdb (push) Canceled after 0s
Publish no_rocm_image_ubuntu24_04_rocgdb images / publish_no_rocm_image_ubuntu24_04_rocgdb (push) Canceled after 0s
Publish no_rocm_image_ubuntu24_04 images / publish_no_rocm_image_ubuntu24_04_ocl_rt (push) Canceled after 0s
Publish TheRock Docker image / build-and-push-image (push) Canceled after 0s
Unit Tests / Unit Tests :: ubuntu-24.04 (push) Canceled after 0s
Unit Tests / Unit Tests :: windows-2022 (push) Canceled after 0s
Publish no_rocm_image_ubuntu24_04 images / publish_no_rocm_image_ubuntu24_04_media (push) Canceled after 0s
Unit Tests / Unit Tests Summary (push) Canceled after 0s
Publish no_rocm_image_ubuntu24_04 images / publish_no_rocm_image_ubuntu24_04_openmpi (push) Canceled after 0s
Flatten subprojects into a single base monorepo
2026-09-02 16:34:47 -07:00

555 lines
18 KiB
Rust
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
//! Performance + accuracy benchmark across the mirage emulator
//! backends (`noop`, `rocjitsu`, `hotswap`).
//!
//! This is an *integration benchmark*: it drives the real `mirage`
//! CLI exactly as a user would (`profile create` → `run`), executing a
//! real HIP GPU workload under each installed-and-supported emulator
//! and measuring two things per backend:
//!
//! * **Accuracy** — the workload kernel validates its own numeric
//! result on-device (`c[i] == 3*i`) and only prints the
//! `hip_kernel_ok` sentinel + exits `0` when every element is
//! correct. A run counts as *correct* iff exit code is `0` **and**
//! the sentinel is present. This is a genuine functional-accuracy
//! check: under rocjitsu's software simulator a wrong answer means
//! the simulator mis-modelled the GPU; under hotswap a wrong answer
//! means the load-time ISA rewrite produced incorrect code.
//! * **Performance** — wall-clock latency of the end-to-end
//! `mirage run`, sampled over N iterations (after one warm-up), with
//! min / mean / median / max / stddev reported. A `/bin/true` run is
//! also timed per emulator to isolate fixed framework + emulator
//! bring-up overhead from the workload cost.
//!
//! The benchmark is **capability-gated**: emulators that are not
//! installed or not supported on this host are skipped (and noted in
//! the report), and the whole benchmark no-ops with a single
//! `skipping: <reason>` line when `hipcc` is unavailable — it never
//! silently passes without measuring anything.
//!
//! On completion it writes a polished Markdown report to
//! `target/emulator-benchmark/report.md` (override with
//! `MIRAGE_BENCH_REPORT`). Iteration count is configurable via
//! `MIRAGE_BENCH_ITERS` (default 5).
use std::fmt::Write as _;
use std::path::{Path, PathBuf};
use std::process::Command;
use std::time::Instant;
use tempfile::TempDir;
/// Emulator backends to compare, in report order.
const EMULATORS: &[&str] = &["noop", "rocjitsu", "hotswap"];
/// GPU architectures baked into the benchmark fat binary so that the
/// same executable runs natively (`noop`), under the software
/// simulator (`rocjitsu`), and after a load-time rewrite (`hotswap`,
/// which retargets `gfx1250` code onto the physical card).
const OFFLOAD_ARCHS: &[&str] = &["gfx942", "gfx950", "gfx1250"];
// ----- capability detection --------------------------------------------------
fn hipcc_available() -> bool {
Command::new("hipcc")
.arg("--version")
.output()
.map(|o| o.status.success())
.unwrap_or(false)
}
/// One row of `mirage emulators --json`.
#[derive(serde::Deserialize)]
struct EmulatorRow {
name: String,
installed: bool,
support: SupportRow,
}
#[derive(serde::Deserialize)]
struct SupportRow {
supported: bool,
reason: String,
}
// ----- harness ---------------------------------------------------------------
struct Env {
_dir: TempDir,
config: PathBuf,
runtime: PathBuf,
state: PathBuf,
mirage_bin: PathBuf,
}
impl Env {
fn new() -> Self {
let dir = tempfile::tempdir().unwrap();
Self {
config: dir.path().join("config"),
runtime: dir.path().join("runtime"),
state: dir.path().join("state"),
mirage_bin: PathBuf::from(env!("CARGO_BIN_EXE_mirage")),
_dir: dir,
}
}
fn mirage(&self) -> Command {
let mut c = Command::new(&self.mirage_bin);
c.env("XDG_CONFIG_HOME", &self.config)
.env("XDG_RUNTIME_DIR", &self.runtime)
.env("XDG_STATE_HOME", &self.state)
.env("MIRAGE_BIN", &self.mirage_bin)
.env_remove("MIRAGE_LOG");
c
}
/// The emulator registry as mirage sees it on this host.
fn registry(&self) -> Vec<EmulatorRow> {
let out = self
.mirage()
.args(["emulators", "--json"])
.output()
.expect("failed to run `mirage emulators --json`");
serde_json::from_slice(&out.stdout).expect("malformed emulators JSON")
}
}
fn fixtures_dir() -> PathBuf {
PathBuf::from(env!("CARGO_MANIFEST_DIR")).join("tests/fixtures/ml")
}
fn iters() -> usize {
std::env::var("MIRAGE_BENCH_ITERS")
.ok()
.and_then(|v| v.parse().ok())
.filter(|&n| n > 0)
.unwrap_or(5)
}
fn report_path() -> PathBuf {
std::env::var("MIRAGE_BENCH_REPORT")
.map(PathBuf::from)
.unwrap_or_else(|_| {
PathBuf::from(env!("CARGO_MANIFEST_DIR")).join("target/emulator-benchmark/report.md")
})
}
/// Compile `tiny_hip.hip` into a multi-arch fat binary so it can run
/// under every backend. Returns the binary path on success.
fn compile_fat_binary(out_dir: &Path) -> PathBuf {
let src = fixtures_dir().join("tiny_hip.hip");
let bin = out_dir.join("bench_hip");
let mut cmd = Command::new("hipcc");
for arch in OFFLOAD_ARCHS {
cmd.arg(format!("--offload-arch={arch}"));
}
let status = cmd
.arg("-std=c++20")
.arg(&src)
.arg("-o")
.arg(&bin)
.status()
.expect("failed to invoke hipcc");
assert!(status.success(), "hipcc failed to build {}", src.display());
bin
}
// ----- measurement -----------------------------------------------------------
/// Outcome of a single timed `mirage run`.
struct Sample {
wall_ms: f64,
exit_ok: bool,
correct: bool,
}
/// Run `mirage run --profile <emu> -- <args...>` once, timing the full
/// invocation and checking the accuracy sentinel.
fn timed_run(env: &Env, emulator: &str, args: &[&Path], sentinel: Option<&str>) -> Sample {
let mut cmd = env.mirage();
cmd.arg("run").args(["--profile", emulator]).arg("--");
for a in args {
cmd.arg(a);
}
let start = Instant::now();
let out = cmd.output().expect("failed to spawn `mirage run`");
let wall_ms = start.elapsed().as_secs_f64() * 1000.0;
let exit_ok = out.status.success();
let stdout = String::from_utf8_lossy(&out.stdout);
let correct = match sentinel {
Some(s) => exit_ok && stdout.contains(s),
None => exit_ok,
};
Sample {
wall_ms,
exit_ok,
correct,
}
}
/// Aggregate statistics over a set of samples.
struct Stats {
min: f64,
max: f64,
mean: f64,
median: f64,
stddev: f64,
}
impl Stats {
fn from(times: &[f64]) -> Self {
let n = times.len();
let mut sorted = times.to_vec();
sorted.sort_by(|a, b| a.partial_cmp(b).unwrap());
let sum: f64 = sorted.iter().sum();
let mean = sum / n as f64;
let median = if n.is_multiple_of(2) {
(sorted[n / 2 - 1] + sorted[n / 2]) / 2.0
} else {
sorted[n / 2]
};
let var = sorted.iter().map(|t| (t - mean).powi(2)).sum::<f64>() / n as f64;
Stats {
min: sorted[0],
max: sorted[n - 1],
mean,
median,
stddev: var.sqrt(),
}
}
}
/// Per-emulator collected result.
struct EmulatorResult {
name: String,
/// `None` when the emulator was skipped; carries the reason in
/// `skip_reason`.
workload: Option<Stats>,
overhead: Option<Stats>,
runs: usize,
correct: usize,
exit_ok: usize,
skip_reason: Option<String>,
}
// ----- the benchmark ---------------------------------------------------------
#[test]
fn benchmark_emulators_and_write_report() {
if !hipcc_available() {
eprintln!("skipping: hipcc not on PATH (cannot build the GPU workload)");
return;
}
let env = Env::new();
let registry = env.registry();
let has_physical_gpu = !mirage_core::hardware::gpu_gfx_versions().is_empty();
// Build the workload once; shared by every backend.
let bin_dir = tempfile::tempdir().unwrap();
let fat = compile_fat_binary(bin_dir.path());
let true_bin = PathBuf::from("/bin/true");
let n = iters();
let mut results: Vec<EmulatorResult> = Vec::new();
for &emu in EMULATORS {
let row = registry.iter().find(|r| r.name == emu);
// Skip emulators that aren't usable on this host, recording why.
let skip = match row {
None => Some("not present in the registry".to_string()),
Some(r) if !r.installed => Some("not installed".to_string()),
Some(r) if !r.support.supported => Some(format!("unsupported: {}", r.support.reason)),
Some(_) if emu == "noop" && !has_physical_gpu => {
Some("unsupported for GPU benchmark: no physical GPU detected".to_string())
}
Some(_) => None,
};
if let Some(reason) = skip {
results.push(EmulatorResult {
name: emu.to_string(),
workload: None,
overhead: None,
runs: 0,
correct: 0,
exit_ok: 0,
skip_reason: Some(reason),
});
continue;
}
// Fresh profile per backend, fully non-interactive.
env.mirage()
.args(["profile", "create", emu, "--emulator", emu, "--no-input"])
.output()
.expect("failed to create profile");
// Warm-up (asset extraction, first-touch caches) — not timed.
let _ = timed_run(&env, emu, &[&fat], Some("hip_kernel_ok"));
let mut wl_times = Vec::with_capacity(n);
let mut oh_times = Vec::with_capacity(n);
let mut correct = 0usize;
let mut exit_ok = 0usize;
for _ in 0..n {
let s = timed_run(&env, emu, &[&fat], Some("hip_kernel_ok"));
if s.correct {
correct += 1;
}
if s.exit_ok {
exit_ok += 1;
}
wl_times.push(s.wall_ms);
// Framework + emulator bring-up overhead baseline.
let o = timed_run(&env, emu, &[&true_bin], None);
oh_times.push(o.wall_ms);
}
results.push(EmulatorResult {
name: emu.to_string(),
workload: Some(Stats::from(&wl_times)),
overhead: Some(Stats::from(&oh_times)),
runs: n,
correct,
exit_ok,
skip_reason: None,
});
}
let report = render_report(&results, n);
let path = report_path();
if let Some(parent) = path.parent() {
std::fs::create_dir_all(parent).ok();
}
std::fs::write(&path, &report).expect("failed to write benchmark report");
eprintln!("benchmark report written to {}", path.display());
// Echo to test output too, so `cargo test -- --nocapture` shows it.
eprintln!("\n{report}");
// Contract: at least one emulator must have actually run, and every
// emulator that ran must have been functionally correct on every
// iteration (no silent accuracy regressions).
let ran: Vec<&EmulatorResult> = results.iter().filter(|r| r.runs > 0).collect();
assert!(
!ran.is_empty(),
"no emulator was runnable on this host; benchmark measured nothing"
);
for r in ran {
assert_eq!(
r.correct,
r.runs,
"emulator `{}` produced an incorrect result in {}/{} runs \
(accuracy regression)",
r.name,
r.runs - r.correct,
r.runs
);
}
}
// ----- report rendering ------------------------------------------------------
fn fmt_ms(v: f64) -> String {
format!("{v:.1}")
}
fn render_report(results: &[EmulatorResult], n: usize) -> String {
let mut s = String::new();
let now = chrono::Utc::now().format("%Y-%m-%d %H:%M:%S UTC");
writeln!(s, "# Mirage Emulator Benchmark Report").unwrap();
writeln!(s).unwrap();
writeln!(s, "_Generated {now}_").unwrap();
writeln!(s).unwrap();
writeln!(
s,
"Comparison of the mirage emulator backends running an identical real \
HIP GPU workload (a vector-add kernel that self-validates its result \
on-device) end-to-end through the `mirage` CLI."
)
.unwrap();
writeln!(s).unwrap();
// Methodology.
writeln!(s, "## Methodology").unwrap();
writeln!(s).unwrap();
writeln!(
s,
"- **Workload:** `tiny_hip.hip`, a HIP vector-add (`c[i] = a[i] + b[i]`) \
compiled as a fat binary for `{}`. The kernel checks every element \
on-device and prints the `hip_kernel_ok` sentinel only when all \
results are correct.",
OFFLOAD_ARCHS.join("`, `")
)
.unwrap();
writeln!(
s,
"- **Driver:** each run is a full `mirage run --profile <emulator> -- \
<binary>` invocation (profile create → session bring-up → exec → \
attach → teardown), exactly as an end user would invoke it."
)
.unwrap();
writeln!(
s,
"- **Samples:** {n} timed iterations per backend after one untimed \
warm-up. Wall-clock latency is reported as min / mean / median / max \
/ stddev."
)
.unwrap();
writeln!(
s,
"- **Overhead baseline:** a `mirage run ... -- /bin/true` is timed \
alongside each workload run to separate fixed framework + emulator \
bring-up cost from the workload itself."
)
.unwrap();
writeln!(
s,
"- **Accuracy:** a run is *correct* iff it exits `0` **and** emits the \
`hip_kernel_ok` sentinel — a genuine functional-correctness check of \
the emulated numeric result."
)
.unwrap();
writeln!(s).unwrap();
// Backends under test.
writeln!(s, "## Backends").unwrap();
writeln!(s).unwrap();
writeln!(s, "| Emulator | Role |").unwrap();
writeln!(s, "| --- | --- |").unwrap();
writeln!(
s,
"| `noop` | Pass-through baseline — runs the workload directly on the \
physical GPU with no emulation. |"
)
.unwrap();
writeln!(
s,
"| `rocjitsu` | Software GPU emulator — executes the workload against a \
simulated AMD GPU (no physical GPU required). |"
)
.unwrap();
writeln!(
s,
"| `hotswap` | Load-time ISA rewriter — retargets device code built for \
another GPU (e.g. `gfx1250`) onto the physical card at load time. |"
)
.unwrap();
writeln!(s).unwrap();
// Accuracy table.
writeln!(s, "## Accuracy").unwrap();
writeln!(s).unwrap();
writeln!(s, "| Emulator | Correct runs | Exit-0 runs | Result |").unwrap();
writeln!(s, "| --- | --- | --- | --- |").unwrap();
for r in results {
if let Some(reason) = &r.skip_reason {
writeln!(s, "| `{}` | — | — | skipped ({reason}) |", r.name).unwrap();
} else {
let verdict = if r.correct == r.runs {
"✅ accurate"
} else {
"❌ incorrect"
};
writeln!(
s,
"| `{}` | {}/{} | {}/{} | {verdict} |",
r.name, r.correct, r.runs, r.exit_ok, r.runs
)
.unwrap();
}
}
writeln!(s).unwrap();
// Performance table.
writeln!(s, "## Performance (end-to-end `mirage run`, milliseconds)").unwrap();
writeln!(s).unwrap();
writeln!(
s,
"| Emulator | Min | Mean | Median | Max | Stddev | Overhead (median) | Workload Δ (median) |"
)
.unwrap();
writeln!(
s,
"| --- | ---: | ---: | ---: | ---: | ---: | ---: | ---: |"
)
.unwrap();
for r in results {
match (&r.workload, &r.overhead) {
(Some(w), Some(o)) => {
let delta = w.median - o.median;
writeln!(
s,
"| `{}` | {} | {} | {} | {} | {} | {} | {} |",
r.name,
fmt_ms(w.min),
fmt_ms(w.mean),
fmt_ms(w.median),
fmt_ms(w.max),
fmt_ms(w.stddev),
fmt_ms(o.median),
fmt_ms(delta),
)
.unwrap();
}
_ => {
let reason = r.skip_reason.as_deref().unwrap_or("skipped");
writeln!(
s,
"| `{}` | — | — | — | — | — | — | skipped ({reason}) |",
r.name
)
.unwrap();
}
}
}
writeln!(s).unwrap();
writeln!(
s,
"_“Workload Δ” is the median workload latency minus the median \
`/bin/true` overhead for the same backend — an estimate of the time \
attributable to executing the kernel under that emulator._"
)
.unwrap();
writeln!(s).unwrap();
// Relative performance (vs noop), when noop ran.
if let Some(base) = results
.iter()
.find(|r| r.name == "noop")
.and_then(|r| r.workload.as_ref())
.map(|w| w.median)
{
writeln!(s, "## Relative latency (vs `noop` baseline)").unwrap();
writeln!(s).unwrap();
writeln!(s, "| Emulator | Median | Slowdown vs `noop` |").unwrap();
writeln!(s, "| --- | ---: | ---: |").unwrap();
for r in results {
if let Some(w) = &r.workload {
let ratio = w.median / base;
writeln!(
s,
"| `{}` | {} ms | {:.2}× |",
r.name,
fmt_ms(w.median),
ratio
)
.unwrap();
}
}
writeln!(s).unwrap();
}
writeln!(
s,
"> Note: latencies are dominated by fixed `mirage` session \
orchestration; for this small kernel the per-emulator compute \
difference is best read from the *Workload Δ* column. Larger \
workloads widen the gap, especially for the software simulator."
)
.unwrap();
s
}