Files
TheRock/rocm-systems/emulation/mirage/tests/matrix_e2e.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

583 lines
18 KiB
Rust

//! Matrix-driven end-to-end tests for the `mirage` CLI.
//!
//! This test enumerates the full cross product of the dimensions
//! described in [`tests/matrix.md`] and drives each runnable
//! combination through the canonical lifecycle:
//!
//! ```text
//! create -> run -> delete -> ensure deleted
//! ```
//!
//! Every combination is exercised end-to-end against the real `mirage`
//! binary (via `assert_cmd`) under an isolated XDG root. Combinations
//! that the current host *cannot* run are deliberately **skipped** with
//! a recorded reason rather than failed, so the same suite is
//! meaningful on a laptop, in CI, and on an emulation host:
//!
//! * `rocjitsu-dbt` is skipped unless a translation-target GPU is
//! physically present (DBT runs translated code on real hardware).
//! * `rocjitsu` is skipped when its KMD library cannot be located.
//! * the `race` plugin is skipped when the selected backend does not
//! advertise it.
//!
//! The containerised dimensions (`podman`, `docker`) are driven through
//! a hermetic mock provider — a small shell script standing in for the
//! container CLI — so the provider bring-up/teardown contract is
//! exercised without requiring a real image or daemon, mirroring
//! `tests/container_e2e.rs`.
use std::collections::BTreeMap;
use std::path::{Path, PathBuf};
use std::process::{Command, ExitStatus, Stdio};
use std::time::{Duration, Instant};
use assert_cmd::prelude::*;
use tempfile::TempDir;
// ---------------------------------------------------------------------------
// Matrix dimensions
// ---------------------------------------------------------------------------
/// The emulator backend under test (`### emulator` in matrix.md).
#[derive(Clone, Copy, Debug, PartialEq, Eq)]
enum Emulator {
Rocjitsu,
RocjitsuDbt,
}
impl Emulator {
fn kind(self) -> &'static str {
match self {
Emulator::Rocjitsu => "rocjitsu",
Emulator::RocjitsuDbt => "rocjitsu-dbt",
}
}
}
/// How the session's nodes are hosted (`### containerization`).
#[derive(Clone, Copy, Debug, PartialEq, Eq)]
enum Container {
/// Run directly on the node — no container runtime involved.
Node,
Podman,
Docker,
}
impl Container {
fn label(self) -> &'static str {
match self {
Container::Node => "node",
Container::Podman => "podman",
Container::Docker => "docker",
}
}
fn is_containerized(self) -> bool {
!matches!(self, Container::Node)
}
}
/// The emulated GPU (`### hardware`). Names match the builtin agents.
#[derive(Clone, Copy, Debug, PartialEq, Eq)]
enum Hardware {
Mi350x,
Mi450x,
}
impl Hardware {
fn agent(self) -> &'static str {
match self {
Hardware::Mi350x => "MI350X",
Hardware::Mi450x => "MI450X",
}
}
}
/// The workload (`### payload`).
#[derive(Clone, Copy, Debug, PartialEq, Eq)]
enum Payload {
TinyTorch,
Rccl,
Crash,
}
impl Payload {
fn label(self) -> &'static str {
match self {
Payload::TinyTorch => "tiny_torch",
Payload::Rccl => "rccl",
Payload::Crash => "crash",
}
}
/// Number of emulated nodes the payload spans.
fn nodes(self) -> u32 {
match self {
Payload::Rccl => 2,
_ => 1,
}
}
/// The command to run, plus the exit-code contract.
///
/// The heavy real workloads (a torch import, an RCCL all-reduce) are
/// represented here by lightweight, deterministic stand-ins that
/// print a sentinel: the goal of this suite is to prove the *mirage
/// lifecycle* (create/run/clean-up) across the matrix, not to
/// benchmark the emulator. The real torch fixture is driven
/// separately by `tests/run_tiny_torch_mi350.sh`.
fn argv(self) -> Vec<&'static str> {
match self {
Payload::TinyTorch => vec!["/bin/sh", "-c", "echo tiny_torch_ok"],
// Each rank prints once; with two nodes the orchestrator runs
// the command on both.
Payload::Rccl => vec!["/bin/sh", "-c", "echo rccl_ok"],
// Simulate a crashing workload: emit output, then exit with a
// SIGSEGV-style code. mirage must still tear the session down.
Payload::Crash => vec!["/bin/sh", "-c", "echo crashing; exit 139"],
}
}
/// Whether a clean (zero) exit is expected.
fn expect_success(self) -> bool {
!matches!(self, Payload::Crash)
}
}
/// Emulator plugins (`### plugins`).
#[derive(Clone, Copy, Debug, PartialEq, Eq)]
enum Plugin {
None,
Race,
}
impl Plugin {
fn label(self) -> &'static str {
match self {
Plugin::None => "none",
Plugin::Race => "race",
}
}
}
/// One point in the matrix.
#[derive(Clone, Copy, Debug)]
struct Combo {
emulator: Emulator,
container: Container,
hardware: Hardware,
payload: Payload,
plugin: Plugin,
}
impl Combo {
fn name(&self) -> String {
format!(
"{}+{}+{}+{}+{}",
self.emulator.kind(),
self.container.label(),
self.hardware.agent().to_lowercase(),
self.payload.label(),
self.plugin.label(),
)
}
}
/// The full cross product of every dimension.
fn all_combos() -> Vec<Combo> {
let mut combos = Vec::new();
for emulator in [Emulator::Rocjitsu, Emulator::RocjitsuDbt] {
for container in [Container::Node, Container::Podman, Container::Docker] {
for hardware in [Hardware::Mi350x, Hardware::Mi450x] {
for payload in [Payload::TinyTorch, Payload::Rccl, Payload::Crash] {
for plugin in [Plugin::None, Plugin::Race] {
combos.push(Combo {
emulator,
container,
hardware,
payload,
plugin,
});
}
}
}
}
}
combos
}
// ---------------------------------------------------------------------------
// Host capabilities
// ---------------------------------------------------------------------------
/// What the current host can actually run, queried once up front.
struct Caps {
/// Per-emulator `(installed, supported)` from `mirage emulators`.
emulators: BTreeMap<String, (bool, bool)>,
/// Plugins each backend advertises, lower-cased for matching.
plugins: BTreeMap<String, Vec<String>>,
}
impl Caps {
/// Decide why (if at all) a combination cannot run on this host.
fn skip_reason(&self, c: &Combo) -> Option<String> {
let (installed, supported) = self
.emulators
.get(c.emulator.kind())
.copied()
.unwrap_or((false, false));
match c.emulator {
// The DBT backend translates code objects and runs them on a
// *real* GPU; with no translation-target hardware present it
// is impossible — this is the "skip unsupported hardware"
// case called out in matrix.md.
Emulator::RocjitsuDbt if !supported => {
return Some("rocjitsu-dbt unsupported: no translation-target GPU present".into());
}
Emulator::RocjitsuDbt if !installed => {
return Some("rocjitsu-dbt not installed: HSA tools hook library not found".into());
}
// MI450X (gfx1250) is deliberately not a DBT-translatable
// source ISA, so even with hardware it cannot be a guest.
Emulator::RocjitsuDbt if c.hardware == Hardware::Mi450x => {
return Some(
"rocjitsu-dbt: MI450X (gfx1250) is not a translatable source ISA".into(),
);
}
// The software emulator runs anywhere, but only if its KMD
// library can be found; otherwise every exec would fail loudly.
Emulator::Rocjitsu if !installed => {
return Some("rocjitsu not installed: KMD library not found".into());
}
_ => {}
}
if c.plugin == Plugin::Race
&& !self
.plugins
.get(c.emulator.kind())
.is_some_and(|plugins| plugins.iter().any(|plugin| plugin == "race"))
{
return Some(format!(
"race plugin not advertised by {}",
c.emulator.kind()
));
}
None
}
}
/// Query `mirage emulators --json` for the install/support state of
/// every backend and the plugins they advertise.
fn probe_caps(mirage_bin: &Path) -> Caps {
let mut cmd = Command::new(mirage_bin);
cmd.args(["--json", "emulators"]).env_remove("MIRAGE_LOG");
let out = cmd.output().expect("run `mirage emulators`");
let json: serde_json::Value =
serde_json::from_slice(&out.stdout).expect("emulators output should be JSON");
let mut emulators = BTreeMap::new();
let mut plugins = BTreeMap::new();
if let Some(arr) = json.as_array() {
for e in arr {
let name = e["name"].as_str().unwrap_or_default().to_string();
let installed = e["installed"].as_bool().unwrap_or(false);
let supported = e["support"]["supported"].as_bool().unwrap_or(false);
let emulator_plugins = e["plugins"]
.as_array()
.into_iter()
.flatten()
.filter_map(|plugin| plugin.as_str().map(str::to_lowercase))
.collect();
plugins.insert(name.clone(), emulator_plugins);
emulators.insert(name, (installed, supported));
}
}
Caps { emulators, plugins }
}
// ---------------------------------------------------------------------------
// Per-combo harness
// ---------------------------------------------------------------------------
/// An isolated XDG root plus a mock container provider for one run.
struct Env {
_dir: TempDir,
runtime: PathBuf,
config: PathBuf,
state: PathBuf,
provider: PathBuf,
mirage_bin: PathBuf,
}
impl Env {
fn new() -> Self {
let dir = tempfile::tempdir().unwrap();
let runtime = dir.path().join("runtime");
let config = dir.path().join("config");
let state = dir.path().join("state");
let provider = dir.path().join("mock-provider.sh");
write_mock_provider(&provider);
Self {
_dir: dir,
runtime,
config,
state,
provider,
mirage_bin: PathBuf::from(env!("CARGO_BIN_EXE_mirage")),
}
}
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)
// The mock provider records every node's pid under this dir.
.env("MOCK_DIR", self._dir.path())
.env_remove("MIRAGE_LOG");
c
}
fn session_root(&self) -> PathBuf {
self.runtime.join("mirage/session")
}
/// True when no session directories remain.
fn no_sessions_left(&self) -> bool {
match std::fs::read_dir(self.session_root()) {
Ok(mut entries) => entries.next().is_none(),
Err(_) => true,
}
}
}
/// A hermetic `docker`/`podman` stand-in. It satisfies the host's
/// bring-up path (pull/network/run/rm) and, for `run`, launches the
/// per-node host (`mirage host --session <id> --rank <n>`) directly so
/// the workload actually executes — exactly the contract the real
/// container entrypoint provides. Mirrors `tests/container_e2e.rs`.
fn write_mock_provider(path: &Path) {
use std::os::unix::fs::PermissionsExt;
let script = r#"#!/bin/sh
case "$1" in
pull) exit 0 ;;
image) [ "$2" = inspect ] && exit 1; exit 0 ;;
network) [ "$2" = inspect ] && exit 1; exit 0 ;;
run)
shift; name=""; sid=""; rank=""
while [ $# -gt 0 ]; do
case "$1" in
--name) name="$2"; shift 2 ;;
--session) sid="$2"; shift 2 ;;
--rank) rank="$2"; shift 2 ;;
*) shift ;;
esac
done
if [ -n "$sid" ] && [ -n "$rank" ]; then
"$MIRAGE_BIN" host --session "$sid" --rank "$rank" </dev/null >/dev/null 2>&1 &
[ -n "$name" ] && echo $! > "$MOCK_DIR/$name.pid"
fi
echo "cid-$rank"; exit 0 ;;
rm)
for a in "$@"; do
case "$a" in
mirage-*)
if [ -f "$MOCK_DIR/$a.pid" ]; then
kill "$(cat "$MOCK_DIR/$a.pid")" 2>/dev/null
rm -f "$MOCK_DIR/$a.pid"
fi ;;
esac
done
exit 0 ;;
inspect) echo true; exit 0 ;;
*) exit 0 ;;
esac
"#;
std::fs::write(path, script).unwrap();
std::fs::set_permissions(path, std::fs::Permissions::from_mode(0o755)).unwrap();
}
/// Outcome of attempting one combination.
enum Outcome {
Ran,
Skipped(String),
}
/// Drive a single combination through create -> run -> delete ->
/// ensure-deleted. Panics on any deviation from the expected contract.
fn run_combo(c: &Combo, caps: &Caps) -> Outcome {
if let Some(reason) = caps.skip_reason(c) {
return Outcome::Skipped(reason);
}
let env = Env::new();
let profile = c.name();
// 1. create
let mut create = env.mirage();
create.args([
"profile",
"create",
&profile,
"--emulator",
c.emulator.kind(),
"--agent",
c.hardware.agent(),
"--no-input",
]);
if c.payload.nodes() > 1 {
create.args(["--num-nodes", "2"]);
}
if c.container.is_containerized() {
create.args(["--image", "img:latest", "--container-provider"]);
create.arg(&env.provider);
}
create.assert().success();
// Confirm the profile is persisted and readable.
env.mirage()
.args(["profile", "show", &profile])
.assert()
.success();
// 2. run — the workhorse that creates a transient session, executes
// the payload (every node), then tears the session back down. A
// timeout guards against regressions that could otherwise hang the
// whole suite (e.g. a multi-node aggregator waiting forever).
let mut run = env.mirage();
run.args(["run", "--profile", &profile]);
if c.plugin == Plugin::Race {
run.args(["--plugin", "race"]);
}
run.arg("--");
run.args(c.payload.argv());
let status = run_with_timeout(run, Duration::from_secs(60), &profile);
if c.payload.expect_success() {
assert!(
status.success(),
"[{}] run failed: status={:?}",
profile,
status.code(),
);
} else {
assert!(
!status.success(),
"[{}] crash payload unexpectedly succeeded",
profile,
);
}
// 3. ensure deleted — the transient session must clean itself up,
// leaving no session directories behind (regardless of how the
// payload exited).
let cleaned = wait_for(Duration::from_secs(5), || env.no_sessions_left());
assert!(
cleaned,
"[{}] session was not cleaned up after run; session root still populated",
profile,
);
// 4. delete the profile and confirm it is gone.
env.mirage()
.args(["profile", "delete", &profile, "-f"])
.assert()
.success();
env.mirage()
.args(["profile", "show", &profile])
.assert()
.failure();
Outcome::Ran
}
fn wait_for<F: FnMut() -> bool>(timeout: Duration, mut f: F) -> bool {
let deadline = Instant::now() + timeout;
while Instant::now() < deadline {
if f() {
return true;
}
std::thread::sleep(Duration::from_millis(25));
}
f()
}
/// Run a command to completion, but fail loudly (rather than hang the
/// whole suite) if it overruns `timeout`. stdio is discarded — callers
/// assert on the exit status, and tiny payload output is uninteresting.
fn run_with_timeout(mut cmd: Command, timeout: Duration, ctx: &str) -> ExitStatus {
cmd.stdin(Stdio::null())
.stdout(Stdio::null())
.stderr(Stdio::null());
let mut child = cmd.spawn().expect("spawn `mirage run`");
let deadline = Instant::now() + timeout;
loop {
match child.try_wait().expect("poll child") {
Some(status) => return status,
None if Instant::now() >= deadline => {
let _ = child.kill();
let _ = child.wait();
panic!("[{ctx}] `mirage run` did not finish within {timeout:?}");
}
None => std::thread::sleep(Duration::from_millis(50)),
}
}
}
// ---------------------------------------------------------------------------
// The matrix test
// ---------------------------------------------------------------------------
#[test]
fn matrix_lifecycle_across_all_dimensions() {
let mirage_bin = PathBuf::from(env!("CARGO_BIN_EXE_mirage"));
let caps = probe_caps(&mirage_bin);
let combos = all_combos();
let total = combos.len();
let mut ran = 0usize;
let mut skipped = 0usize;
eprintln!("\nmirage testing matrix — {total} combinations\n");
eprintln!(
" {:<58} RESULT",
"COMBINATION (emulator+container+hw+payload+plugin)"
);
for c in &combos {
match run_combo(c, &caps) {
Outcome::Ran => {
ran += 1;
eprintln!(" {:<58} RAN", c.name());
}
Outcome::Skipped(reason) => {
skipped += 1;
eprintln!(" {:<58} SKIP ({reason})", c.name());
}
}
}
eprintln!("\nmatrix summary: {ran} ran, {skipped} skipped, {total} total\n");
// Sanity: the matrix must be coherent. Every dbt + mi450x and every
// race-plugin combinations are expected to skip on a host without
// that hardware/plugin, but the suite must never silently skip
// *everything* on a host where the software emulator is available.
if caps
.emulators
.get("rocjitsu")
.map(|(installed, _)| *installed)
.unwrap_or(false)
{
assert!(
ran > 0,
"rocjitsu is installed but no matrix combination ran"
);
}
}