use std::collections::BTreeMap;
use std::sync::atomic::{AtomicBool, Ordering};
use std::time::{Duration, Instant};
use anyhow::{Context, Result, bail};
use metrale_bench::{TargetEndpoint, gate};
const POLL: Duration = Duration::from_millis(500);
static STARTED: AtomicBool = AtomicBool::new(false);
pub struct SelfServed {
pub target: TargetEndpoint,
pub recipe_id: String,
pub recipe_sha256: String,
pub overrides: BTreeMap<String, String>,
pub resolved: BTreeMap<String, String>,
pub serve_env: BTreeMap<String, String>,
pub baseline_entry: gate::ModelBaseline,
server: Option<tokio::task::JoinHandle<Result<()>>>,
}
impl SelfServed {
pub fn external(
target: TargetEndpoint,
recipe_id: String,
recipe_sha256: String,
overrides: BTreeMap<String, String>,
resolved: BTreeMap<String, String>,
serve_env: BTreeMap<String, String>,
baseline_entry: gate::ModelBaseline,
) -> Self {
Self {
target,
recipe_id,
recipe_sha256,
overrides,
resolved,
serve_env,
baseline_entry,
server: None,
}
}
pub async fn shutdown(mut self) {
crate::tui::shutdown::request("benchmark gate run finished");
if let Some(server) = self.server.take() {
server.abort();
let _ = server.await;
}
}
}
impl Drop for SelfServed {
fn drop(&mut self) {
let Some(server) = self.server.take() else {
return;
};
eprintln!("gate: tearing down the self-started server (no explicit shutdown ran)");
server.abort();
}
}
pub async fn serve_for(
benchmark_id: &str,
hardware: Option<&str>,
checkpoint: Option<&str>,
overrides: BTreeMap<String, String>,
) -> Result<SelfServed> {
let plan = super::bench_serve_plan::plan_serve(benchmark_id, hardware, checkpoint, overrides)?;
let reconciled = plan.reconcile_env()?;
refuse_unapplied_levers(&plan.recipe_id, &reconciled)?;
let port = metrale_bench::benchmarks::agentic::score::free_port()?;
let serve_args = plan.serve_args(port)?;
let resolved = plan.disclosed(port)?;
let forward = serve_args.forward;
check_box_is_free_enough(
serve_args.gpu_memory_utilization,
&plan.recipe_id,
plan.limits.memory.min_free_fraction,
)?;
claim_start_slot(&STARTED, crate::tui::shutdown::requested())?;
let model = plan.model;
eprintln!(
"gate: serving {model} from recipe {} on port {port}",
plan.recipe_id
);
let server =
tokio::spawn(async move { crate::main_modules::serve::serve(serve_args, None).await });
let mut served = SelfServed {
target: TargetEndpoint::local(port, &model),
recipe_id: plan.recipe_id,
recipe_sha256: plan.recipe_sha256,
overrides: plan.requested,
resolved,
serve_env: reconciled.env,
baseline_entry: plan.entry,
server: Some(server),
};
await_serving(
&served.target,
&model,
served.server.as_mut().expect("just constructed as Some"),
Duration::from_secs(plan.limits.timing.boot_timeout_s),
)
.await?;
eprintln!("gate: endpoint is serving {model}");
super::bench_serve_plan::attach_live_forward(&served.target, forward, &mut served.resolved)
.await?;
Ok(served)
}
fn claim_start_slot(started: &AtomicBool, shutdown_requested: bool) -> Result<()> {
if shutdown_requested {
bail!(
"a shutdown has already been requested in this process, and that latch has no reset. \
A server started now would return without ever serving, so the run is refused here \
rather than after a fifteen-minute wait for a listener that is not coming."
);
}
if started.swap(true, Ordering::SeqCst) {
bail!(
"a benchmark gate already started a server in this process; the shutdown latch is \
one-way, so a second one cannot come up. Run one benchmark per invocation."
);
}
Ok(())
}
fn refuse_unapplied_levers(
recipe_id: &str,
reconciled: &metrale_bench::serve_env::Reconciled,
) -> Result<()> {
if reconciled.missing.is_empty() {
return Ok(());
}
let exports = reconciled
.missing
.iter()
.map(|(k, v)| format!("{k}={v}"))
.collect::<Vec<_>>()
.join(" ");
bail!(
"recipe {recipe_id:?} is measured under {} serve lever(s) this process does not carry: \
{exports}. A gate that serves in-process cannot set them after it has started, so \
export them first:\n env {exports} met benchmark run …\nor run with \
--serve-reuse, which starts the server as a child and hands it the recipe's set.",
reconciled.missing.len()
);
}
pub(super) fn check_box_is_free_enough(
util: f64,
recipe_id: &str,
min_free_fraction: f64,
) -> Result<()> {
let Some((total_gib, avail_gib)) = host_memory_gib() else {
eprintln!("gate: cannot read host memory; skipping the free-memory preflight");
return Ok(());
};
eprintln!(
"{}",
headroom_verdict(total_gib, avail_gib, util, recipe_id, min_free_fraction)?
);
Ok(())
}
fn headroom_verdict(
total_gib: f64,
avail_gib: f64,
util: f64,
recipe_id: &str,
min_free_fraction: f64,
) -> Result<String> {
let free_fraction = avail_gib / total_gib;
if free_fraction < min_free_fraction {
bail!(
"this box is not free enough to serve recipe {recipe_id:?}: only {avail_gib:.0} GiB \
of {total_gib:.0} GiB is available ({:.0} %, below the {:.0} % a self-start \
requires). Something else is holding memory — check `sudo docker ps` and \
`nvidia-smi --query-compute-apps=pid,used_memory --format=csv`, free it, and \
re-run. This is not a judgement on the recipe's --gpu-memory-utilization {util:.2}, \
which is served exactly as written: co-tenancy is what turns a working \
utilisation into an OOM freeze on unified memory, and it corrupts the measurement \
well before that.",
free_fraction * 100.0,
min_free_fraction * 100.0,
);
}
Ok(format!(
"gate: {avail_gib:.0} GiB of {total_gib:.0} GiB free ({:.0} %); serving at {util:.2} as the recipe states",
free_fraction * 100.0
))
}
fn host_memory_gib() -> Option<(f64, f64)> {
let text = std::fs::read_to_string("/proc/meminfo").ok()?;
let field = |name: &str| -> Option<f64> {
text.lines()
.find(|l| l.starts_with(name))?
.split_whitespace()
.nth(1)?
.parse::<f64>()
.ok()
.map(|kb| kb / 1024.0 / 1024.0)
};
let (total, avail) = (field("MemTotal:")?, field("MemAvailable:")?);
(total > 0.0 && avail.is_finite()).then_some((total, avail))
}
async fn await_serving(
target: &TargetEndpoint,
model: &str,
server: &mut tokio::task::JoinHandle<Result<()>>,
boot_timeout: Duration,
) -> Result<()> {
let deadline = Instant::now() + boot_timeout;
loop {
if server.is_finished() {
return match server.await {
Ok(Err(e)) => Err(e).with_context(|| {
format!("the server failed before it began serving {model:?}")
}),
Ok(Ok(())) => bail!(
"the server returned before it began serving {model:?} — it stopped without \
an error, which should not happen while the accept loop is running"
),
Err(join) => Err(anyhow::Error::new(join))
.with_context(|| format!("the server task died serving {model:?}")),
};
}
let last = match metrale_bench::http::list_models(target, Duration::from_secs(5)).await {
Ok(models) if models.iter().any(|m| m == model) => return Ok(()),
Ok(models) => format!("the endpoint is serving {models:?}"),
Err(e) => format!("{e:#}"),
};
if Instant::now() >= deadline {
bail!(
"{model:?} did not come up within {}s — {last}",
boot_timeout.as_secs()
);
}
tokio::time::sleep(POLL).await;
}
}
#[cfg(test)]
#[path = "bench_selfstart_tests.rs"]
mod tests;