use std::sync::mpsc;
use std::sync::Arc;
use crate::autograd::Variable;
use crate::data::BatchDataSet;
use crate::nn::{Module, Optimizer, Parameter};
use crate::tensor::{Device, Result, Tensor, TensorError};
use crate::distributed::ddp_run::{
ApplyPolicy, AverageBackend, ConvergenceGuard, DdpRunConfig, RankCallbacks,
EpochMetrics, EvalResultFn, MetricsFn, SchedulerFn, TrainedState,
};
use super::coord_config::build_coord_config_from_builder;
pub struct DdpHandle {
pub(super) devices: Vec<Device>,
pub(super) final_state: Option<TrainedState>,
pub(super) metrics_rx: Option<mpsc::Receiver<EpochMetrics>>,
pub(super) launcher_driver: Option<std::thread::JoinHandle<Result<()>>>,
pub(super) launcher_abort: Option<std::sync::Arc<std::sync::atomic::AtomicBool>>,
pub(super) architecture_svg: Option<String>,
pub(super) graph_label: Option<String>,
pub(super) graph_hash: Option<String>,
pub(super) training_meta: Option<serde_json::Value>,
}
pub(crate) fn clean_process_exit(code: i32) -> ! {
use std::io::Write;
let _ = std::io::stdout().flush();
let _ = std::io::stderr().flush();
unsafe { libc::_exit(code) }
}
impl DdpHandle {
fn maybe_auto_promote() -> Result<()> {
#[cfg(not(test))]
{
use crate::distributed::launcher::ENV_FULL_CLUSTER_JSON;
if crate::distributed::launcher::role_env_pristine() {
let gpus = crate::sys::detect_gpus();
if gpus.len() >= 2 {
match crate::distributed::ClusterBuilder::all_local_gpus() {
Ok(full) => {
let hex = crate::distributed::cluster::hex_encode(
full.to_json().to_string().as_bytes(),
);
unsafe {
std::env::set_var(ENV_FULL_CLUSTER_JSON, hex);
}
}
Err(e) => {
return Err(crate::tensor::TensorError::new(&format!(
"auto-promote multi-GPU failed: {e}"
)));
}
}
}
}
}
Ok(())
}
#[allow(clippy::too_many_arguments, clippy::type_complexity)]
pub(super) fn launch<F, M, G, O, T>(
model_factory: F,
optim_factory: G,
train_fn: T,
dataset: Arc<dyn BatchDataSet>,
batch_size: usize,
num_epochs: usize,
policy: ApplyPolicy,
backend: AverageBackend,
config: DdpRunConfig,
rank_callbacks: RankCallbacks<M>,
metrics_fn: Option<MetricsFn>,
scheduler_fn: Option<SchedulerFn>,
convergence_guard: Option<Box<dyn ConvergenceGuard>>,
eval_result_fn: Option<EvalResultFn>,
) -> Result<Self>
where
F: Fn(Device) -> Result<M> + Send + Sync + 'static,
M: Module + 'static,
G: Fn(&[Parameter]) -> O + Send + Sync + 'static,
O: Optimizer + 'static,
T: Fn(&M, &[Tensor]) -> Result<Variable> + Send + Sync + 'static,
{
Self::maybe_auto_promote()?;
match crate::distributed::launcher::dispatch()? {
crate::distributed::launcher::Role::Launcher => {
let full = crate::distributed::launcher::FullCluster::from_env()?;
if let Some(msg) = checkpoint_shared_storage_warning(
full.spans_multiple_workers(),
config.save_path.as_deref(),
config.resume_from.as_deref(),
) {
eprintln!("{msg}");
}
let (sink_tx, sink_rx) =
mpsc::channel::<EpochMetrics>();
let mut model_schema: Option<crate::distributed::ModelSchema> = None;
match model_factory(Device::CPU) {
Ok(probe) => {
model_schema =
Some(crate::distributed::ModelSchema::from_module(&probe));
let params: Vec<crate::tensor::Tensor> = probe
.parameters()
.iter()
.map(|p| p.variable.data())
.collect();
let buffers: Vec<crate::tensor::Tensor> =
probe.buffers().iter().map(|b| b.get()).collect();
let wire_bytes =
crate::distributed::wire::tensors_wire_bytes(¶ms)
+ crate::distributed::wire::tensors_wire_bytes(&buffers);
crate::distributed::wire::set_frame_ceiling(
crate::distributed::wire::derive_frame_ceiling(wire_bytes),
);
}
Err(e) => {
eprintln!(
"cluster launcher: model schema capture failed \
(consensus checkpoints will be meta-only): {e}"
);
}
}
let mut outer_optimizer =
rank_callbacks.outer_optimizer_factory.as_ref().map(|f| f());
if let (Some(opt), Some(stem)) =
(outer_optimizer.as_mut(), config.resume_from.as_ref())
{
let outer_path = crate::distributed::CheckpointBundle::model_path(stem)
.with_extension("outer.fdl");
if outer_path.exists() {
match model_factory(Device::CPU) {
Ok(probe) => {
let loaded = outer_path
.to_str()
.ok_or_else(|| {
crate::tensor::TensorError::new(
"resume: non-utf8 outer-momentum path",
)
})
.and_then(|p| {
crate::distributed::load_outer_momentum(&probe, p)
})
.and_then(|m| opt.load_checkpoint_state(m));
match loaded {
Ok(()) => eprintln!(
" resume: loaded outer-optimizer momentum from {}",
outer_path.display()
),
Err(e) => eprintln!(
" resume: outer-momentum load from {} failed \
({e}); outer optimizer starts from zero momentum",
outer_path.display()
),
}
}
Err(e) => eprintln!(
" resume: probe model for outer-momentum shapes failed \
({e}); outer optimizer starts from zero momentum"
),
}
}
}
let dataset_len = dataset.len() * config.augment.max(1);
let coord_spec = crate::distributed::launcher::CoordSpec {
backend,
config_factory: Box::new(move |world_size| {
let mut coord_config = build_coord_config_from_builder(
policy,
backend,
&config,
convergence_guard,
metrics_fn,
eval_result_fn,
world_size,
dataset_len,
batch_size,
num_epochs,
)?;
coord_config = coord_config.metrics_sink_tx(sink_tx);
if let Some(schema) = model_schema {
coord_config = coord_config.model_schema(schema);
}
Ok(coord_config)
}),
};
let launcher_abort =
Arc::new(std::sync::atomic::AtomicBool::new(false));
let abort_for_driver = Arc::clone(&launcher_abort);
let driver = std::thread::Builder::new()
.name("flodl-launcher-driver".to_string())
.spawn(move || {
crate::distributed::launcher::run_launcher_with_config(
full,
Some(coord_spec),
outer_optimizer,
abort_for_driver,
)
})
.map_err(|e| {
crate::tensor::TensorError::new(&format!(
"spawn launcher driver thread: {e}"
))
})?;
return Ok(DdpHandle {
devices: Vec::new(),
final_state: None,
metrics_rx: Some(sink_rx),
launcher_driver: Some(driver),
launcher_abort: Some(launcher_abort),
architecture_svg: None,
graph_label: None,
graph_hash: None,
training_meta: None,
});
}
crate::distributed::launcher::Role::Relay => {
match crate::distributed::launcher::run_relay() {
Ok(()) => clean_process_exit(0),
Err(e) => {
eprintln!("cluster relay: {e}");
clean_process_exit(1);
}
}
}
crate::distributed::launcher::Role::Agent => {
match crate::distributed::launcher::run_agent() {
Ok(()) => clean_process_exit(0),
Err(e) => {
eprintln!("cluster agent: {e}");
clean_process_exit(1);
}
}
}
crate::distributed::launcher::Role::Rank
| crate::distributed::launcher::Role::SingleDevice => {}
}
match crate::distributed::cluster::LocalCluster::from_env() {
Ok(Some(cluster)) => {
if let Err(e) =
crate::distributed::launcher::claim_cluster_entry("rank")
{
eprintln!("flodl cluster rank: {e}");
clean_process_exit(1);
}
let dispatch_result: Result<Self> = Self::run_cluster_rank_via_coord(
cluster,
policy,
backend,
model_factory,
optim_factory,
train_fn,
dataset,
batch_size,
num_epochs,
config,
scheduler_fn,
rank_callbacks,
);
return match dispatch_result {
Ok(h) => Ok(h),
Err(e) => {
eprintln!(
"flodl cluster rank: pre-rendezvous setup failed: {e}"
);
clean_process_exit(1);
}
};
}
Ok(None) => {
}
Err(e) => {
eprintln!(
"flodl cluster rank: envelope parse failed: {e}"
);
clean_process_exit(1);
}
}
let devices = crate::tensor::usable_cuda_devices();
if devices.len() < 2 {
let dev = devices.first().copied().unwrap_or(Device::CPU);
let scheduler = scheduler_fn.map(|f| f(1));
let RankCallbacks { checkpoint_fn, epoch_fn, eval_fn, eval_dataset, .. } =
rank_callbacks;
return Self::run_single(
&model_factory, &optim_factory, &train_fn,
dataset, batch_size, num_epochs, dev,
checkpoint_fn,
config.checkpoint_every,
epoch_fn,
metrics_fn,
config.max_grad_norm,
config.vram_pool,
config.vram_max_usage,
config.ram_max_usage,
config.sample_cache,
config.disk_stage_gb,
config.disk_stage_dir.clone(),
config.augment,
config.transform.clone(),
scheduler,
eval_fn,
eval_dataset,
config.eval_every_epochs,
eval_result_fn,
);
}
Err(crate::tensor::TensorError::new(
"in-process multi-GPU training has been removed: on 2+ GPUs use \
Trainer::run / Trainer::builder().run() (process-per-rank \
auto-promote), or Ddp::wrap for manual thread-based DDP",
))
}
#[allow(clippy::too_many_arguments, clippy::type_complexity)]
pub(super) fn into_worker<F, M, G, O, T>(
model_factory: F,
optim_factory: G,
train_fn: T,
dataset: Arc<dyn BatchDataSet>,
batch_size: usize,
num_epochs: usize,
policy: ApplyPolicy,
backend: AverageBackend,
config: DdpRunConfig,
rank_callbacks: RankCallbacks<M>,
metrics_fn: Option<MetricsFn>,
scheduler_fn: Option<SchedulerFn>,
convergence_guard: Option<Box<dyn ConvergenceGuard>>,
eval_result_fn: Option<EvalResultFn>,
) -> Result<crate::distributed::ddp_run::Worker<M>>
where
F: Fn(Device) -> Result<M> + Send + Sync + 'static,
M: Module + 'static,
G: Fn(&[Parameter]) -> O + Send + Sync + 'static,
O: Optimizer + 'static,
T: Fn(&M, &[Tensor]) -> Result<Variable> + Send + Sync + 'static,
{
Self::maybe_auto_promote()?;
if matches!(
crate::distributed::launcher::dispatch()?,
crate::distributed::launcher::Role::Launcher
| crate::distributed::launcher::Role::Relay
| crate::distributed::launcher::Role::Agent
) {
let mut handle = Self::launch(
model_factory,
optim_factory,
train_fn,
dataset,
batch_size,
num_epochs,
policy,
backend,
config,
rank_callbacks,
metrics_fn,
scheduler_fn,
convergence_guard,
eval_result_fn,
)?;
if let Some(driver) = handle.launcher_driver.take() {
match driver.join() {
Ok(Ok(())) => clean_process_exit(0),
Ok(Err(e)) => {
eprintln!("flodl cluster launcher: {e}");
clean_process_exit(1);
}
Err(_) => {
eprintln!("flodl cluster launcher: driver thread panicked");
clean_process_exit(1);
}
}
}
clean_process_exit(0);
}
match crate::distributed::cluster::LocalCluster::from_env() {
Ok(Some(cluster)) => {
if let Err(e) =
crate::distributed::launcher::claim_cluster_entry("rank")
{
eprintln!("flodl cluster rank: {e}");
clean_process_exit(1);
}
return match Self::run_cluster_rank_worker(
cluster,
policy,
backend,
model_factory,
optim_factory,
dataset,
batch_size,
config,
scheduler_fn,
rank_callbacks,
) {
Ok(worker) => Ok(worker),
Err(e) => {
eprintln!("flodl cluster rank: pre-rendezvous setup failed: {e}");
clean_process_exit(1);
}
};
}
Ok(None) => {
}
Err(e) => {
eprintln!("flodl cluster rank: envelope parse failed: {e}");
clean_process_exit(1);
}
}
let devices = crate::tensor::usable_cuda_devices();
if devices.len() < 2 {
let dev = devices.first().copied().unwrap_or(Device::CPU);
let scheduler = scheduler_fn.map(|f| f(1));
let RankCallbacks { checkpoint_fn, eval_fn, eval_dataset, .. } =
rank_callbacks;
return Self::run_single_worker(
&model_factory, &optim_factory,
dataset, batch_size, num_epochs, dev,
checkpoint_fn,
config.max_grad_norm,
config.vram_pool,
config.vram_max_usage,
config.ram_max_usage,
config.sample_cache,
config.disk_stage_gb,
config.disk_stage_dir.clone(),
config.augment,
config.transform.clone(),
scheduler,
eval_fn,
eval_dataset,
);
}
Err(crate::tensor::TensorError::new(
"in-process multi-GPU has been removed: on 2+ GPUs use \
Trainer::builder().into_worker() (process-per-rank auto-promote), \
or Ddp::wrap for manual thread-based DDP",
))
}
pub fn world_size(&self) -> usize {
self.devices.len()
}
pub fn devices(&self) -> &[Device] {
&self.devices
}
pub fn architecture_svg(&self) -> Option<&str> {
self.architecture_svg.as_deref()
}
pub fn setup_monitor(&self, monitor: &mut crate::monitor::Monitor) {
if let Some(svg) = &self.architecture_svg {
monitor.set_svg(svg);
}
monitor.set_identity(
self.graph_label.as_deref(),
self.graph_hash.as_deref(),
);
if let Some(meta) = &self.training_meta {
monitor.set_metadata(meta.clone());
}
}
pub fn poll_metrics(&self) -> Vec<EpochMetrics> {
match &self.metrics_rx {
Some(rx) => {
let mut out = Vec::new();
while let Ok(m) = rx.try_recv() {
out.push(m);
}
out
}
None => Vec::new(),
}
}
pub fn next_metrics(&self) -> Option<EpochMetrics> {
self.metrics_rx.as_ref().and_then(|rx| rx.recv().ok())
}
pub fn shutdown(mut self) -> Result<TrainedState> {
if let Some(flag) = self.launcher_abort.take() {
flag.store(true, std::sync::atomic::Ordering::SeqCst);
}
self.join()
}
pub fn join(mut self) -> Result<TrainedState> {
if let Some(state) = self.final_state.take() {
return Ok(state);
}
if let Some(driver) = self.launcher_driver.take() {
return match driver.join() {
Ok(Ok(())) => {
eprintln!(
"flodl ddp: join() on the launcher returns an EMPTY \
TrainedState (ranks are separate processes); use the \
checkpoint bundle for the final model"
);
Ok(TrainedState {
params: Vec::new(),
buffers: Vec::new(),
})
}
Ok(Err(e)) => Err(e),
Err(_) => Err(TensorError::new(
"join: launcher driver thread panicked",
)),
};
}
Err(TensorError::new("join: no trained state available"))
}
}
fn checkpoint_shared_storage_warning(
spans_multiple_workers: bool,
save_path: Option<&str>,
resume_from: Option<&str>,
) -> Option<String> {
if !spans_multiple_workers {
return None;
}
let stem = match (save_path, resume_from) {
(Some(s), Some(r)) if s != r => {
format!("save_path {s:?} / resume_from {r:?}")
}
(Some(s), _) => format!("{s:?}"),
(_, Some(r)) => format!("{r:?}"),
(None, None) => return None,
};
Some(format!(
"flodl cluster: checkpoint/resume path {stem} must resolve to SHARED \
storage visible to every host. In a multi-host cluster the elected \
checkpoint rank and each worker's save-on-failure write on their OWN \
host, while the controller writes the `.meta.json` sidecar and reads \
it back on resume on ITS host -- a host-local path scatters the \
bundle and breaks resume. If the path already resolves to shared \
storage (NAS / virtiofs / SSHFS / the shared project mount) on all \
hosts, ignore this."
))
}
#[cfg(test)]
mod tests {
use super::checkpoint_shared_storage_warning;
#[test]
fn no_warning_on_single_worker_cluster() {
assert!(
checkpoint_shared_storage_warning(false, Some("runs/ckpt"), None).is_none()
);
}
#[test]
fn no_warning_when_no_checkpoint_path_set() {
assert!(checkpoint_shared_storage_warning(true, None, None).is_none());
}
#[test]
fn warns_multi_host_with_save_path() {
let msg = checkpoint_shared_storage_warning(true, Some("runs/ckpt"), None)
.expect("multi-host + save_path must warn");
assert!(msg.contains("runs/ckpt"));
assert!(msg.contains("SHARED"));
}
#[test]
fn warns_multi_host_with_resume_only() {
let msg = checkpoint_shared_storage_warning(true, None, Some("runs/ckpt"))
.expect("multi-host + resume_from must warn");
assert!(msg.contains("runs/ckpt"));
}
#[test]
fn names_both_paths_when_they_differ() {
let msg =
checkpoint_shared_storage_warning(true, Some("out/save"), Some("in/resume"))
.expect("must warn");
assert!(msg.contains("out/save"));
assert!(msg.contains("in/resume"));
}
}