Skip to content

Commit 74a157a

Browse files
fix(e2e): ensure reused runtimes have an engine
Signed-off-by: Michael Roy <michael.roy@amd.com>
1 parent d616d14 commit 74a157a

1 file changed

Lines changed: 136 additions & 1 deletion

File tree

xtask/src/e2e_prewarm.rs

Lines changed: 136 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -360,10 +360,25 @@ pub fn run(channel: &str, keep: usize, prewarm_dir: &Path) -> Result<()> {
360360
}
361361
Decision::Reuse { reason } => {
362362
println!("pre-warm: reusing the shared {channel} runtime ({reason})");
363-
return Ok(());
364363
}
365364
}
366365

366+
// Unconditional, and deliberately BEFORE the reuse early return below. The
367+
// runtime and the serving engine are installed separately: `install sdk`
368+
// lays down the ROCm runtime, `engines install` builds the engine venv
369+
// against it. `decide` only ever reasons about the runtime, so a tree whose
370+
// runtime is current but whose engine was never installed — or was left
371+
// behind with an older runtime — resolves to `Reuse`, which used to return
372+
// here having done nothing. The shared tree then served every GPU scenario a
373+
// runtime with no engine, which is the one thing those lanes exist to
374+
// exercise. Re-checking a warm tree is cheap: without `--reinstall`,
375+
// `engines install` on a ready engine installs nothing.
376+
ensure_default_engine(&rocm, prewarm_dir)?;
377+
378+
if !runtime_changed(&decision) {
379+
return Ok(());
380+
}
381+
367382
// An install/update that exits 0 without leaving a registry behind is the
368383
// confusing case the lanes used to call out by hand: every scenario then falls
369384
// back to installing its own runtime and the job quietly blows its time cap.
@@ -394,6 +409,59 @@ pub fn run(channel: &str, keep: usize, prewarm_dir: &Path) -> Result<()> {
394409
Ok(())
395410
}
396411

412+
/// Whether `decision` put a new runtime in the tree, and so whether the registry
413+
/// check and the retention prune at the end of [`run`] have anything to do.
414+
///
415+
/// Read AFTER the engine check, never inside the decision's own match arm: the
416+
/// engine is installed separately from the runtime, so every decision — reuse
417+
/// most of all, since it is the one a warm runner takes every time — has to
418+
/// reach that check before this can end the pre-warm early.
419+
const fn runtime_changed(decision: &Decision) -> bool {
420+
!matches!(decision, Decision::Reuse { .. })
421+
}
422+
423+
/// Install the engine the active runtime would serve on, so the shared tree has
424+
/// one before a scenario asks it to serve.
425+
///
426+
/// Which engine that is comes from the CLI rather than from a constant here:
427+
/// `rocm engines list` marks the engine `serve` picks for the detected GPU with
428+
/// `* ` (vLLM on Instinct, Lemonade on Strix), and the pre-warm must agree with
429+
/// `serve` on every runner without this file learning the hardware map.
430+
///
431+
/// Fatal on failure, like [`repair_poisoned_runtimes`] and unlike the freshness
432+
/// path: reusing a stale-but-working runtime keeps a lane meaningful, whereas
433+
/// serving with no engine fails every GPU scenario later and for reasons that
434+
/// name none of this.
435+
fn ensure_default_engine(rocm: &Path, prewarm_dir: &Path) -> Result<()> {
436+
let output = rocm_command(rocm, prewarm_dir)
437+
.args(["engines", "list"])
438+
.output()
439+
.context("failed to run `rocm engines list`")?;
440+
if !output.status.success() {
441+
bail!("`rocm engines list` exited with {}", output.status);
442+
}
443+
let inventory = String::from_utf8_lossy(&output.stdout);
444+
let engine = default_engine_from_inventory(&inventory)
445+
.context("`rocm engines list` did not identify a default engine")?;
446+
println!("pre-warm: ensuring the {engine} engine is installed");
447+
rocm_command(rocm, prewarm_dir)
448+
.args(["engines", "install", engine, "--yes"])
449+
.status_ok("rocm engines install")
450+
}
451+
452+
/// The engine `rocm engines list` marks as the default for this host, if any.
453+
///
454+
/// The inventory renders one line per engine as `{marker} {name:10} {note}` with
455+
/// the marker in column 0, then indents that engine's detail lines (` adapter:
456+
/// …`, ` runtime: …`) beneath it. Matching `* ` at the start of the line
457+
/// unindented is therefore what separates the default engine's own line from
458+
/// everything else the report prints.
459+
fn default_engine_from_inventory(inventory: &str) -> Option<&str> {
460+
inventory
461+
.lines()
462+
.find_map(|line| line.strip_prefix("* ")?.split_whitespace().next())
463+
}
464+
397465
/// Drop any managed runtime in the shared tree that records an install root
398466
/// outside it, so the pre-warm reinstalls instead of serving a dead one.
399467
///
@@ -731,6 +799,73 @@ mode=managed status=ready\n install_root: /tmp/rocm-e2e-XXXX/data/runtimes/
731799
assert_eq!(poisoned[0].format, "tarball");
732800
}
733801

802+
/// A real `rocm engines list` on an Instinct host, captured verbatim: the
803+
/// default engine's line carries the `* ` marker in column 0, and its own
804+
/// detail lines are indented beneath it.
805+
const ENGINES_READY: &str = "\
806+
Local model engines
807+
Built-in engines are included with rocm-cli. External plugins are optional.
808+
ROCm GPU execution is required.
809+
Plugin folders:
810+
1. /w/e2e-prewarm/data/engines/plugins (primary)
811+
lemonade default embedded Lemonade server with ROCm llama.cpp backend
812+
adapter: built-in
813+
runtime: not found
814+
* vllm Linux/WSL ROCm GPU serving engine through external vLLM
815+
adapter: built-in
816+
runtime: /w/e2e-prewarm/data/runtimes/wheel/release-wheel-gfx94x-dcgpu-7-15-0
817+
protocol: 0.1.0
818+
";
819+
820+
#[test]
821+
fn the_marked_engine_is_the_one_pre_warmed() {
822+
// Which engine to install comes from the CLI's own host detection, not
823+
// from a hardware map duplicated here.
824+
assert_eq!(default_engine_from_inventory(ENGINES_READY), Some("vllm"));
825+
}
826+
827+
#[test]
828+
fn an_indented_detail_line_is_not_read_as_the_default() {
829+
// Every engine's detail lines are indented under it, and a note may well
830+
// start with a bullet. Only the marker in column 0 names the default.
831+
let inventory = "\
832+
Local model engines
833+
* lemonade default embedded Lemonade server with ROCm llama.cpp backend
834+
adapter: built-in
835+
* not a marker
836+
";
837+
assert_eq!(default_engine_from_inventory(inventory), Some("lemonade"));
838+
}
839+
840+
#[test]
841+
fn an_inventory_without_a_default_engine_names_none() {
842+
// `ensure_default_engine` turns this into an error rather than guessing an
843+
// engine: installing the wrong one costs a multi-GiB build and still
844+
// leaves the lane with nothing to serve on.
845+
assert_eq!(default_engine_from_inventory(""), None);
846+
assert_eq!(
847+
default_engine_from_inventory("Local model engines\n lemonade embedded\n"),
848+
None
849+
);
850+
}
851+
852+
#[test]
853+
fn reusing_the_shared_runtime_still_reaches_the_engine_check() {
854+
// The regression this guards: `Reuse` — the decision EVERY warm runner
855+
// takes, run after run — used to end `run` with a `return` inside its own
856+
// match arm, before anything looked at the engine. The early return is now
857+
// this predicate, read only AFTER `ensure_default_engine`, so reuse cannot
858+
// skip the engine. Reuse must still install and update NOTHING, which is
859+
// what keeps it cheap enough to re-check the engine on every run.
860+
assert!(!runtime_changed(&Decision::Reuse {
861+
reason: "up to date".to_owned()
862+
}));
863+
assert!(runtime_changed(&Decision::Install));
864+
assert!(runtime_changed(&Decision::Update {
865+
runtime_key: "release-wheel-gfx94x-dcgpu-7-13-0".to_owned()
866+
}));
867+
}
868+
734869
#[test]
735870
fn no_managed_runtime_installs() {
736871
assert_eq!(decide(EMPTY, "release"), Decision::Install);

0 commit comments

Comments
 (0)