use std::sync::Arc;
use anyhow::Result;
use super::model_host::ModelHost;
use super::serve_load::{Carried, load_model};
use crate::cli;
#[derive(Debug)]
pub(crate) struct SwapOutcome {
pub previous: Option<cli::ServeArgs>,
}
fn refuse_if_shutting_down(shutting_down: bool) -> Result<()> {
anyhow::ensure!(
!shutting_down,
"shutdown is in progress — not starting a model load"
);
Ok(())
}
fn preflight_kernel_target(args: &cli::ServeArgs) -> Result<()> {
let model_dir = super::serve_phases::resolve_model_dir(args)?;
let (config, _) = super::serve_phases::load_model_config(&model_dir)?;
let model_dir_str = model_dir.display().to_string();
let model_refs: Vec<&str> = [
args.model.as_deref(),
args.model_name.as_deref(),
Some(model_dir_str.as_str()),
]
.into_iter()
.flatten()
.collect();
let resolved = metrale_kernels::ptx_for_config(
&config.model_type,
config.hidden_size,
&model_refs,
args.kernel_target.as_deref(),
)
.map_err(|e| anyhow::anyhow!("{e} — the running model is untouched"))?;
if resolved.is_none() {
anyhow::bail!(
"this build has no compiled kernels for model_type '{}' / hidden_size={} \
(available: {:?}) — the running model is untouched",
config.model_type,
config.hidden_size,
metrale_kernels::available_targets()
.iter()
.map(|t| &t.target.model)
.collect::<Vec<_>>()
);
}
Ok(())
}
fn carry_process_flags(next: &mut cli::ServeArgs, previous: &cli::ServeArgs) {
next.auto_swap = previous.auto_swap;
next.bind = previous.bind.clone();
next.port = previous.port;
next.dump = previous.dump.clone();
}
fn pin_published_weight_quantization(next: &mut cli::ServeArgs) {
let published = metrale_model_layers::layers::weight_quantization();
if next.weight_quant_tier().ok() == Some(published) {
return;
}
tracing::warn!(
"this recipe asks for --weight-quantization {} (downcast {:?}), but this process \
published {} (downcast {:?}) at startup and keeps it; restart the server to change it",
next.weight_quantization.0.name(),
metrale_config::W4a4Downcast::from_flags(next.w4a4_downcast, next.w4a4_downcast_wide),
published.tier().name(),
published.downcast()
);
next.weight_quantization = cli::flag_values::WeightQuantizationArg(published.tier());
next.w4a4_downcast = published.downcast() != metrale_config::W4a4Downcast::Off;
next.w4a4_downcast_wide = published.downcast() == metrale_config::W4a4Downcast::Wide;
}
fn pin_published_activation_quantization(next: &mut cli::ServeArgs) {
let published = metrale_model_layers::layers::activation_quantization();
if next.activation_quantization == *published {
return;
}
tracing::warn!(
"this recipe asks for --activation-quantization {}, but this process published {} at \
startup and keeps it; restart the server to change it",
next.activation_quantization,
published
);
next.activation_quantization = published.clone();
}
const DRAIN_GRACE: std::time::Duration = std::time::Duration::from_secs(30);
fn wait_for_sole_owner<T>(state: &Arc<T>, grace: std::time::Duration) -> usize {
let deadline = std::time::Instant::now() + grace;
while Arc::strong_count(state) > 1 && std::time::Instant::now() < deadline {
std::thread::sleep(std::time::Duration::from_millis(50));
}
Arc::strong_count(state) - 1
}
fn release_state(host: &Arc<ModelHost>, grace: std::time::Duration) -> Result<Carried> {
let Some(state) = host.take() else {
return match host.process() {
Some(carried) => Ok(carried),
None => Carried::from_env().map_err(|e| anyhow::anyhow!("{e}")),
};
};
let carried = Carried::from_previous(&state);
let holders = wait_for_sole_owner(&state, grace);
if holders > 0 {
host.publish(state);
anyhow::bail!(
"cannot swap: {holders} reference(s) to the running model outlived \
the {}s drain window, so it can never be released. The model is \
still serving. This is a leaked `Arc<AppState>` — most likely one \
bound into a router layer or a spawned task rather than resolved \
per request.",
grace.as_secs()
);
}
drop(state);
Ok(carried)
}
fn signal_listener_phases(host: &Arc<ModelHost>) {
if let Some((bind, port)) = host.bound() {
metrale_telemetry::progress::phase(10, "router");
metrale_telemetry::progress::phase(11, "listening");
metrale_telemetry::progress::ready(port);
tracing::info!(
"{}",
super::serve_router::ready_line(&bind, port, host.live_model().as_deref())
);
}
}
pub(crate) fn swap(host: &Arc<ModelHost>, next: cli::ServeArgs) -> Result<SwapOutcome> {
let _swapping = host.swap_guard();
let previous_args = host.args();
let mut next = next;
if let Some(previous) = previous_args.as_ref() {
if next.port != previous.port || next.bind != previous.bind {
tracing::warn!(
"this recipe asks to bind {}:{}, but the listener is on {}:{} for the process \
lifetime — serving the new model there instead",
next.bind,
next.port,
previous.bind,
previous.port
);
}
carry_process_flags(&mut next, previous);
}
pin_published_weight_quantization(&mut next);
pin_published_activation_quantization(&mut next);
if previous_args.as_ref() == Some(&next) && host.current().is_some() {
return Ok(SwapOutcome {
previous: previous_args,
});
}
cli::validate_serve_args(&next).map_err(|e| anyhow::anyhow!("{e}"))?;
refuse_if_shutting_down(crate::tui::shutdown::requested())?;
let runtime = host.runtime();
let _entered = runtime.as_ref().map(|h| h.enter());
anyhow::ensure!(
next.world_size <= 1 && next.rank == 0,
"hot-swap is single-node only (world_size={}, rank={})",
next.world_size,
next.rank
);
preflight_kernel_target(&next)?;
let carried = release_state(host, DRAIN_GRACE)?;
if let Some(handle) = host.take_scheduler() {
handle
.join()
.map_err(|_| anyhow::anyhow!("the scheduler thread panicked while draining"))?;
}
let next_args = next.clone();
let tui_handles_tx = host.tui_handles();
let load_err = match load_model(next, tui_handles_tx.clone(), carried.clone()) {
Ok(Some(prepared)) => {
host.set_scheduler(prepared.scheduler);
host.set_args(next_args);
host.publish(prepared.state);
signal_listener_phases(host);
return Ok(SwapOutcome {
previous: previous_args,
});
}
Ok(None) => anyhow::anyhow!("hot-swap reached an EP-worker path on rank 0"),
Err(e) => e,
};
let Some(previous) = previous_args else {
return Err(load_err
.context("the new model failed to load and there was no previous model to restore"));
};
tracing::warn!("load failed, restoring the previous model: {load_err:#}");
match load_model(previous.clone(), tui_handles_tx, carried) {
Ok(Some(prepared)) => {
host.set_scheduler(prepared.scheduler);
host.set_args(previous);
host.publish(prepared.state);
signal_listener_phases(host);
Err(load_err.context("the new model failed to load; the previous one was restored"))
}
Ok(None) => Err(load_err.context("restore reached an EP-worker path")),
Err(restore_err) => Err(load_err.context(format!(
"the new model failed to load AND the previous one could not be \
restored ({restore_err:#}) — no model is loaded"
))),
}
}
#[cfg(test)]
#[path = "model_swap_tests.rs"]
mod tests;
#[cfg(test)]
mod drain_tests {
use super::wait_for_sole_owner;
use std::sync::Arc;
use std::time::{Duration, Instant};
#[test]
fn a_holder_that_lets_go_is_waited_for_rather_than_refused() {
let state = Arc::new(0u32);
let borrowed = state.clone();
std::thread::spawn(move || {
std::thread::sleep(Duration::from_millis(150));
drop(borrowed);
});
assert_eq!(wait_for_sole_owner(&state, Duration::from_secs(5)), 0);
}
#[test]
fn a_holder_that_never_lets_go_is_reported_not_waited_on_forever() {
let state = Arc::new(0u32);
let _leaked = state.clone();
let began = Instant::now();
assert_eq!(wait_for_sole_owner(&state, Duration::from_millis(200)), 1);
assert!(began.elapsed() < Duration::from_secs(2), "bounded");
}
#[test]
fn an_unshared_state_is_released_without_waiting() {
let state = Arc::new(0u32);
let began = Instant::now();
assert_eq!(wait_for_sole_owner(&state, Duration::from_secs(30)), 0);
assert!(
began.elapsed() < Duration::from_millis(50),
"no sleep at all"
);
}
}