pub mod format;
pub mod resources;
pub mod timeline;
mod server;
pub(crate) use server::dashboard_bind_is_loopback;
use std::fmt::Write;
use std::time::{Duration, Instant};
use crate::graph::Graph;
pub use format::{format_eta, format_bytes, format_metric};
pub use resources::{ResourceSample, ResourceSampler, GpuSnapshot};
pub use timeline::{Timeline, TimelineBroadcast, TimelineEvent, EventKind, TimelineSample, GpuTimelineSample, TimelineSummary};
#[derive(Debug, Clone, Default)]
pub struct GpuMetrics {
pub device_index: u8,
pub throughput: f64,
pub chunk_ratio: f64,
pub shard_size: i64,
}
#[derive(Clone)]
pub struct EpochRecord {
pub epoch: usize,
pub duration_secs: f64,
pub metrics: Vec<(String, f64)>,
pub resources: ResourceSample,
pub gpu_metrics: Vec<GpuMetrics>,
}
pub trait Metrics {
fn into_metrics(self) -> Vec<(String, f64)>;
fn gpu_metrics(&self) -> Vec<GpuMetrics> { Vec::new() }
}
impl<'a> Metrics for &'a [(&'a str, f64)] {
fn into_metrics(self) -> Vec<(String, f64)> {
self.iter().map(|(k, v)| (k.to_string(), *v)).collect()
}
}
impl<const N: usize> Metrics for &[(&str, f64); N] {
fn into_metrics(self) -> Vec<(String, f64)> {
self.iter().map(|(k, v)| (k.to_string(), *v)).collect()
}
}
fn graph_gpu_metrics(graph: &Graph) -> Vec<GpuMetrics> {
graph
.aggregated_gpu_tabs()
.into_iter()
.map(|(device_index, throughput, chunk_ratio)| GpuMetrics {
device_index,
throughput,
chunk_ratio,
shard_size: 0,
})
.collect()
}
impl Metrics for &Graph {
fn into_metrics(self) -> Vec<(String, f64)> {
self.latest_metrics()
}
fn gpu_metrics(&self) -> Vec<GpuMetrics> {
graph_gpu_metrics(self)
}
}
impl<'a> Metrics for (&'a Graph, &'a [(&'a str, f64)]) {
fn into_metrics(self) -> Vec<(String, f64)> {
let (graph, extra) = self;
let mut m = graph.latest_metrics();
m.extend(extra.iter().map(|(k, v)| (k.to_string(), *v)));
m
}
fn gpu_metrics(&self) -> Vec<GpuMetrics> {
graph_gpu_metrics(self.0)
}
}
impl<'a, const N: usize> Metrics for (&'a Graph, &'a [(&'a str, f64); N]) {
fn into_metrics(self) -> Vec<(String, f64)> {
let (graph, extra) = self;
let mut m = graph.latest_metrics();
m.extend(extra.iter().map(|(k, v)| (k.to_string(), *v)));
m
}
fn gpu_metrics(&self) -> Vec<GpuMetrics> {
graph_gpu_metrics(self.0)
}
}
impl Metrics for &crate::distributed::EpochMetrics {
fn into_metrics(self) -> Vec<(String, f64)> {
let mut out = Vec::with_capacity(self.scalars.len() + 1);
out.push(("loss".to_string(), self.avg_loss));
let mut keys: Vec<&String> = self.scalars.keys().collect();
keys.sort();
for k in keys {
out.push((k.clone(), self.scalars[k]));
}
out
}
fn gpu_metrics(&self) -> Vec<GpuMetrics> {
self.device_indices.iter().enumerate().map(|(i, &dev)| {
GpuMetrics {
device_index: dev,
throughput: self.per_rank_throughput.get(i).copied().unwrap_or(0.0),
chunk_ratio: self.per_rank_batch_share.get(i).copied().unwrap_or(0.0),
shard_size: 0, }
}).collect()
}
}
pub struct Monitor {
total_epochs: usize,
epochs: Vec<EpochRecord>,
start_time: Instant,
sampler: ResourceSampler,
server: Option<server::DashboardServer>,
save_html: Option<String>,
svg_snapshot: Option<String>,
metadata: Option<serde_json::Value>,
graph_label: Option<String>,
graph_hash: Option<String>,
hardware: String,
is_primary: bool,
silent_summary: bool,
}
impl Monitor {
pub fn new(total_epochs: usize) -> Self {
let is_primary = Self::detect_is_primary();
let hardware = crate::tensor::hardware_summary();
if Self::in_cluster_mode() {
crate::distributed::cluster_dashboard_emit::stash_hardware(
hardware.clone(),
);
}
Self {
total_epochs,
epochs: Vec::with_capacity(total_epochs),
start_time: Instant::now(),
sampler: ResourceSampler::new(),
server: None,
save_html: None,
svg_snapshot: None,
metadata: None,
graph_label: None,
graph_hash: None,
hardware,
is_primary,
silent_summary: false,
}
}
fn in_cluster_mode() -> bool {
matches!(
crate::distributed::LocalCluster::from_env(),
Ok(Some(_))
)
}
fn in_launcher_process() -> bool {
std::env::var_os(
crate::distributed::launcher::ENV_FULL_CLUSTER_JSON,
)
.is_some()
}
pub fn silent_summary(&mut self) -> &mut Self {
self.silent_summary = true;
self
}
fn detect_is_primary() -> bool {
match crate::distributed::LocalCluster::from_env() {
Ok(Some(cluster)) => match cluster.my_rank() {
Ok((rank, _)) => rank == 0,
Err(_) => true,
},
_ => true,
}
}
pub fn is_primary(&self) -> bool {
self.is_primary
}
pub fn serve(&mut self, port: u16) -> std::io::Result<()> {
if Self::in_launcher_process() {
return Ok(());
}
if Self::in_cluster_mode() {
crate::distributed::cluster_dashboard_emit::stash_port(port);
return Ok(());
}
if !self.is_primary {
return Ok(());
}
self.bind_dashboard_locally(port)?;
crate::msg!(" dashboard: http://localhost:{}", port);
Ok(())
}
pub(crate) fn serve_local_unconditional(
&mut self,
port: u16,
) -> std::io::Result<()> {
self.bind_dashboard_locally(port)
}
pub(crate) fn shutdown_dashboard_server(&mut self) {
if let Some(ref mut srv) = self.server {
srv.shutdown();
}
}
fn bind_dashboard_locally(&mut self, port: u16) -> std::io::Result<()> {
let srv = server::DashboardServer::start(port)?;
srv.set_hardware(self.hardware.clone());
if !Self::in_launcher_process() {
let init_sample = self.sampler.sample();
if init_sample.gpus.len() >= 2 {
srv.set_gpu_init(Self::gpu_init_json(&init_sample.gpus));
}
}
self.server = Some(srv);
Ok(())
}
pub fn save_html(&mut self, path: &str) {
self.save_html = Some(path.to_string());
}
pub fn set_metadata(&mut self, meta: serde_json::Value) {
if Self::in_cluster_mode() {
crate::distributed::cluster_dashboard_emit::stash_metadata(
meta.to_string(),
);
}
if let Some(ref srv) = self.server {
srv.set_metadata(meta.to_string());
}
self.metadata = Some(meta);
}
pub fn watch(&mut self, graph: &Graph) {
self.capture_graph_identity(graph);
if let Ok(svg_bytes) = graph.svg(None) {
self.set_svg(&String::from_utf8_lossy(&svg_bytes));
}
}
pub fn watch_profiled(&mut self, graph: &Graph) {
self.capture_graph_identity(graph);
if let Ok(svg_bytes) = graph.svg_with_profile(None) {
self.set_svg(&String::from_utf8_lossy(&svg_bytes));
} else if let Ok(svg_bytes) = graph.svg(None) {
self.set_svg(&String::from_utf8_lossy(&svg_bytes));
}
}
pub fn set_svg(&mut self, svg: &str) {
self.svg_snapshot = Some(svg.to_string());
if Self::in_cluster_mode() {
crate::distributed::cluster_dashboard_emit::stash_svg(
svg.to_string(),
self.graph_label.clone(),
self.graph_hash.clone(),
);
}
if let Some(ref srv) = self.server {
srv.set_svg(svg.to_string());
}
}
pub fn set_hardware(&mut self, hardware: impl Into<String>) {
self.hardware = hardware.into();
if let Some(ref srv) = self.server {
srv.set_hardware(self.hardware.clone());
}
}
pub fn log_epoch_record(&mut self, record: EpochRecord) {
if !self.is_primary {
return;
}
let epoch = record.epoch;
self.epochs.push(record);
if let Some(ref srv) = self.server {
srv.push_epoch(self.epoch_to_json(epoch));
}
}
pub fn set_identity(&mut self, label: Option<&str>, hash: Option<&str>) {
self.graph_label = label.map(|s| s.to_string());
self.graph_hash = hash.map(|s| s.to_string());
if let Some(ref srv) = self.server {
srv.set_label_hash(
self.graph_label.clone(),
self.graph_hash.clone(),
);
}
}
fn capture_graph_identity(&mut self, graph: &Graph) {
self.graph_label = graph.label().map(|s| s.to_string());
self.graph_hash = Some(graph.structural_hash().to_string());
if let Some(ref srv) = self.server {
srv.set_label_hash(
self.graph_label.clone(),
self.graph_hash.clone(),
);
}
self.capture_param_info(graph);
}
fn capture_param_info(&mut self, graph: &Graph) {
use crate::nn::Module;
let params = graph.parameters();
let total: i64 = params.iter()
.map(|p| p.variable.shape().iter().product::<i64>())
.sum();
let trainable: i64 = params.iter()
.filter(|p| !p.is_frozen())
.map(|p| p.variable.shape().iter().product::<i64>())
.sum();
let frozen = total - trainable;
let param_info = serde_json::json!({
"parameters": {
"total": total,
"trainable": trainable,
"frozen": frozen,
}
});
let merged = match &self.metadata {
Some(existing) => {
if let (serde_json::Value::Object(mut base), serde_json::Value::Object(extra)) =
(param_info.clone(), existing.clone())
{
base.extend(extra);
serde_json::Value::Object(base)
} else {
existing.clone()
}
}
None => param_info,
};
if let Some(ref srv) = self.server {
srv.set_metadata(merged.to_string());
}
self.metadata = Some(merged);
}
pub fn log(&mut self, epoch: usize, duration: Duration, metrics: impl Metrics) {
if !self.is_primary {
return;
}
let gpu_metrics = metrics.gpu_metrics();
let metrics = metrics.into_metrics();
let duration_secs = duration.as_secs_f64();
let resources = self.sampler.sample();
let record = EpochRecord {
epoch,
duration_secs,
metrics: metrics.clone(),
resources: resources.clone(),
gpu_metrics: gpu_metrics.clone(),
};
self.epochs.push(record);
let mut line = String::with_capacity(256);
let epoch_display = epoch + 1;
let width = digit_count(self.total_epochs);
let _ = write!(line, " epoch {:>w$}/{}", epoch_display, self.total_epochs, w = width);
for (name, val) in &metrics {
let _ = write!(line, " {}={}", name, format_metric(*val));
}
let _ = write!(line, " [{}",format_eta(duration_secs));
if epoch_display < self.total_epochs {
let k = self.epochs.len().min(5);
let recent: f64 = self.epochs[self.epochs.len() - k..]
.iter()
.map(|r| r.duration_secs)
.sum::<f64>()
/ k as f64;
let remaining = recent * (self.total_epochs - epoch_display) as f64;
let _ = write!(line, " ETA {}", format_eta(remaining));
}
line.push(']');
let res = &resources;
if let Some(alloc) = res.vram_allocated_bytes {
let spill = match res.vram_total_bytes {
Some(total) if alloc > total => alloc - total,
_ => 0,
};
let label = match res.aggregate_rank {
Some(idx) => format!("VRAM[cuda{idx}]"),
None => String::from("VRAM"),
};
let _ = write!(
line,
" {}: {} / {}",
label,
format_bytes(alloc),
format_bytes(spill),
);
}
if let Some(gpu) = res.gpu_util_percent {
let label = match res.aggregate_rank {
Some(idx) => format!("gpu[cuda{idx}]"),
None => String::from("gpu"),
};
let _ = write!(line, " {label} {:.0}%", gpu);
}
crate::msg!("{}", line);
if let Some(ref srv) = self.server {
srv.push_epoch(self.epoch_to_json(epoch));
}
}
pub fn finish(&mut self) {
self.finish_inner();
}
pub fn finish_with(&mut self, graph: &Graph) {
if let Ok(svg_bytes) = graph.svg_with_profile(None) {
self.set_svg(&String::from_utf8_lossy(&svg_bytes));
} else if let Ok(svg_bytes) = graph.svg(None) {
self.set_svg(&String::from_utf8_lossy(&svg_bytes));
} else {
eprintln!(" warning: could not generate graph SVG (is graphviz installed?)");
}
self.finish_inner();
}
fn finish_inner(&mut self) {
if !self.is_primary {
return;
}
if !self.silent_summary {
let total_time = self.start_time.elapsed().as_secs_f64();
let mut line = format!(" training complete in {}", format_eta(total_time));
if let Some(last) = self.epochs.last() {
for (name, val) in &last.metrics {
let _ = write!(line, " | {}: {}", name, format_metric(*val));
}
}
crate::msg!("{}", line);
}
if let Some(ref path) = self.save_html {
match self.build_archive() {
Ok(html) => {
if let Err(e) = std::fs::write(path, html) {
eprintln!(" warning: failed to save dashboard archive: {}", e);
} else {
crate::msg!(" saved: {}", path);
}
}
Err(e) => eprintln!(" warning: failed to build dashboard archive: {}", e),
}
}
if let Some(ref mut srv) = self.server {
srv.shutdown();
}
}
pub fn history(&self) -> &[EpochRecord] {
&self.epochs
}
pub fn write_log(&self, path: &str) -> std::io::Result<()> {
let mut b = String::with_capacity(4096);
let _ = writeln!(b, "# flodl training log");
let width = digit_count(self.total_epochs);
for record in &self.epochs {
let _ = write!(b, "epoch {:>w$}/{}", record.epoch + 1, self.total_epochs, w = width);
for (name, val) in &record.metrics {
let _ = write!(b, " {}={}", name, format_metric(*val));
}
let _ = write!(b, " [{}]", format_eta(record.duration_secs));
b.push('\n');
}
if !self.epochs.is_empty() {
let total = self.start_time.elapsed().as_secs_f64();
let _ = writeln!(b, "# total: {}", format_eta(total));
}
std::fs::write(path, b)
}
pub fn export_csv(&self, path: &str) -> std::io::Result<()> {
if self.epochs.is_empty() {
return Ok(());
}
let metric_names: Vec<&str> = self.epochs[0]
.metrics
.iter()
.map(|(k, _)| k.as_str())
.collect();
let mut b = String::with_capacity(4096);
b.push_str("epoch,duration_s");
for name in &metric_names {
b.push(',');
b.push_str(name);
}
b.push_str(",cpu_pct,ram_used,gpu_pct,vram_alloc,vram_spill\n");
for record in &self.epochs {
let _ = write!(b, "{},{:.3}", record.epoch + 1, record.duration_secs);
for (_, val) in &record.metrics {
let _ = write!(b, ",{:.8}", val);
}
let spill = match (record.resources.vram_allocated_bytes, record.resources.vram_total_bytes) {
(Some(alloc), Some(total)) if alloc > total => (alloc - total).to_string(),
_ => String::new(),
};
let _ = write!(
b,
",{},{},{},{},{}",
record.resources.cpu_percent.map_or("".to_string(), |v| format!("{:.1}", v)),
record.resources.ram_used_bytes.map_or("".to_string(), |v| v.to_string()),
record.resources.gpu_util_percent.map_or("".to_string(), |v| format!("{:.1}", v)),
record.resources.vram_allocated_bytes.map_or("".to_string(), |v| v.to_string()),
spill,
);
b.push('\n');
}
std::fs::write(path, b)
}
fn build_archive(&self) -> std::result::Result<String, std::fmt::Error> {
let mut data_json = String::from("[");
for (i, record) in self.epochs.iter().enumerate() {
if i > 0 { data_json.push(','); }
let _ = write!(data_json, "{}", self.epoch_record_to_json(record));
}
data_json.push(']');
let svg_js = match &self.svg_snapshot {
Some(svg) => {
let escaped = svg
.replace('\\', "\\\\")
.replace('`', "\\`")
.replace("${", "\\${");
format!("`{}`", escaped)
}
None => "null".to_string(),
};
let label_js = match &self.graph_label {
Some(l) => format!("\"{}\"", l.replace('\\', "\\\\").replace('"', "\\\"")),
None => "null".to_string(),
};
let hash_js = match &self.graph_hash {
Some(h) => format!("\"{}\"", h),
None => "null".to_string(),
};
let meta_js = match &self.metadata {
Some(v) => v.to_string(),
None => "null".to_string(),
};
let total_time = self.start_time.elapsed().as_secs_f64();
let hw_js = format!("\"{}\"", self.hardware.replace('\\', "\\\\").replace('"', "\\\""));
let gpu_init_js = self.epochs.first()
.filter(|e| e.resources.gpus.len() >= 2)
.map(|e| Self::gpu_init_json(&e.resources.gpus))
.unwrap_or_else(|| "null".to_string());
let archive_consts = format!(
"\nconst ARCHIVE_DATA={};\nconst ARCHIVE_SVG={};\nconst ARCHIVE_COMPLETE=\"Complete ({})\";\nconst ARCHIVE_LABEL={};\nconst ARCHIVE_HASH={};\nconst ARCHIVE_META={};\nconst ARCHIVE_HARDWARE={};\nconst ARCHIVE_GPU_INIT={};\n",
data_json,
svg_js,
format_eta(total_time),
label_js,
hash_js,
meta_js,
hw_js,
gpu_init_js,
);
let archive_block = format!("<script>{}</script>", neutralize_script_close(&archive_consts));
let template = include_str!("dashboard.html");
let html = template
.replace("<title>floDl Training Dashboard</title>",
"<title>floDl Training Report</title>")
.replace("<script>", &format!("{}\n<script>", archive_block));
Ok(html)
}
fn write_resources(b: &mut String, res: &ResourceSample) {
b.push_str(",\"resources\":{");
let mut first = true;
if let Some(cpu) = res.cpu_percent
&& cpu.is_finite()
{
let _ = write!(b, "\"cpu\":{:.1}", cpu);
first = false;
}
if let (Some(used), Some(total)) = (res.ram_used_bytes, res.ram_total_bytes) {
if !first { b.push(','); }
let _ = write!(b, "\"ram_used\":{},\"ram_total\":{}", used, total);
first = false;
}
if let Some(gpu) = res.gpu_util_percent
&& gpu.is_finite()
{
if !first { b.push(','); }
let _ = write!(b, "\"gpu\":{:.1}", gpu);
first = false;
}
if let Some(alloc) = res.vram_allocated_bytes {
if !first { b.push(','); }
let _ = write!(b, "\"vram_alloc\":{}", alloc);
if let Some(total) = res.vram_total_bytes {
let _ = write!(b, ",\"vram_total\":{}", total);
}
}
b.push('}');
}
fn write_gpus(b: &mut String, res: &ResourceSample, ddp: &[GpuMetrics]) {
if res.gpus.is_empty() && ddp.is_empty() {
return;
}
b.push_str(",\"gpus\":[");
let hw = &res.gpus;
let n = hw.len().max(ddp.len());
for i in 0..n {
if i > 0 { b.push(','); }
b.push('{');
let mut first = true;
if let Some(gpu) = hw.get(i) {
let _ = write!(b, "\"dev\":{}", gpu.device_index);
first = false;
if !gpu.name.is_empty() {
let _ = write!(b, ",\"name\":\"{}\"", gpu.name);
}
if let Some(util) = gpu.util_percent {
let _ = write!(b, ",\"util\":{:.1}", util);
}
if let Some(alloc) = gpu.vram_allocated_bytes {
let _ = write!(b, ",\"vram_alloc\":{}", alloc);
}
if let Some(total) = gpu.vram_total_bytes {
let _ = write!(b, ",\"vram_total\":{}", total);
}
}
if let Some(m) = ddp.get(i) {
if first {
let _ = write!(b, "\"dev\":{}", m.device_index);
}
let _ = write!(b, ",\"throughput\":{:.4}", m.throughput);
let _ = write!(b, ",\"chunk\":{:.4}", m.chunk_ratio);
let _ = write!(b, ",\"shard\":{}", m.shard_size);
}
b.push('}');
}
b.push(']');
}
fn gpu_init_json(gpus: &[resources::GpuSnapshot]) -> String {
use std::fmt::Write;
let mut b = String::from("[");
for (i, gpu) in gpus.iter().enumerate() {
if i > 0 { b.push(','); }
b.push('{');
let _ = write!(b, "\"dev\":{}", gpu.device_index);
if !gpu.name.is_empty() {
let _ = write!(b, ",\"name\":\"{}\"", gpu.name);
}
if let Some(total) = gpu.vram_total_bytes {
let _ = write!(b, ",\"vram_total\":{}", total);
}
b.push('}');
}
b.push(']');
b
}
fn write_metrics(b: &mut String, metrics: &[(String, f64)]) {
b.push_str(",\"metrics\":{");
for (i, (name, val)) in metrics.iter().enumerate() {
if i > 0 { b.push(','); }
if val.is_finite() {
let _ = write!(b, "\"{}\":{:.8}", name, val);
} else {
let _ = write!(b, "\"{}\":null", name);
}
}
b.push('}');
}
fn epoch_record_to_json(&self, record: &EpochRecord) -> String {
self.epoch_json(record, record.epoch + 1, None)
}
fn epoch_to_json(&self, epoch: usize) -> String {
let record = &self.epochs[self.epochs.len() - 1];
let epoch_display = epoch + 1;
let eta = if epoch_display < self.total_epochs {
let elapsed = self.start_time.elapsed().as_secs_f64();
let per_epoch = elapsed / epoch_display as f64;
Some(per_epoch * (self.total_epochs - epoch_display) as f64)
} else {
None
};
self.epoch_json(record, epoch_display, eta)
}
fn epoch_json(&self, record: &EpochRecord, epoch_display: usize, eta: Option<f64>) -> String {
let mut b = String::with_capacity(512);
b.push('{');
let _ = write!(
b,
"\"epoch\":{},\"total\":{},\"duration\":{:.4}",
epoch_display,
self.total_epochs,
record.duration_secs,
);
if let Some(remaining) = eta
&& remaining.is_finite()
{
let _ = write!(b, ",\"eta\":{:.1}", remaining);
}
Self::write_metrics(&mut b, &record.metrics);
Self::write_resources(&mut b, &record.resources);
Self::write_gpus(&mut b, &record.resources, &record.gpu_metrics);
b.push('}');
b
}
}
fn digit_count(n: usize) -> usize {
if n == 0 { return 1; }
((n as f64).log10().floor() as usize) + 1
}
pub(crate) fn neutralize_script_close(body: &str) -> String {
body.replace("</script", "<\\/script")
.replace("</SCRIPT", "<\\/SCRIPT")
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn test_monitor_basic() {
let mut monitor = Monitor::new(10);
monitor.log(0, Duration::from_millis(100), &[("loss", 1.5)]);
monitor.log(1, Duration::from_millis(90), &[("loss", 1.2)]);
assert_eq!(monitor.history().len(), 2);
assert_eq!(monitor.history()[1].epoch, 1);
}
#[test]
fn test_neutralize_script_close() {
assert_eq!(neutralize_script_close("a</script>b"), "a<\\/script>b");
assert_eq!(neutralize_script_close("x</SCRIPT>y"), "x<\\/SCRIPT>y");
assert_eq!(neutralize_script_close("safe data"), "safe data");
}
#[test]
fn test_archive_html_neutralizes_script_close_in_data() {
let mut monitor = Monitor::new(10);
monitor.set_identity(Some("evil</script><script>alert(1)</script>"), None);
monitor.set_metadata(serde_json::json!({
"note": "meta</script><img src=x onerror=alert(2)>"
}));
monitor.log(0, Duration::from_millis(100), &[("loss", 1.0)]);
let html = monitor.build_archive().unwrap();
assert!(html.contains("evil<\\/script><script>alert(1)<\\/script>"),
"label </script> not neutralized");
assert!(html.contains("meta<\\/script>"), "metadata </script> not neutralized");
assert!(!html.contains("evil</script>"), "raw label breakout present");
assert!(!html.contains("meta</script>"), "raw metadata breakout present");
}
#[test]
fn test_log_with_graph() {
use crate::*;
let dev = crate::tensor::test_device();
let model = FlowBuilder::from(Linear::on_device(2, 4, dev).unwrap())
.through(Linear::on_device(4, 2, dev).unwrap())
.tag("output")
.build()
.unwrap();
let mut monitor = Monitor::new(5);
model.record_scalar("loss", 1.5);
model.record_scalar("loss", 1.3);
model.flush(&[]);
monitor.log(0, Duration::from_millis(50), (&model, &[("lr", 0.01)]));
assert_eq!(monitor.history().len(), 1);
let metrics = &monitor.history()[0].metrics;
assert!(metrics.iter().any(|(k, _)| k == "loss"), "missing graph metric 'loss'");
assert!(metrics.iter().any(|(k, _)| k == "lr"), "missing extra metric 'lr'");
let loss = metrics.iter().find(|(k, _)| k == "loss").unwrap().1;
assert!((loss - 1.4).abs() < 1e-10);
}
#[test]
fn test_log_graph_only() {
use crate::*;
let dev = crate::tensor::test_device();
let model = FlowBuilder::from(Linear::on_device(2, 4, dev).unwrap())
.through(Linear::on_device(4, 2, dev).unwrap())
.build()
.unwrap();
let mut monitor = Monitor::new(5);
model.record_scalar("loss", 2.0);
model.flush(&[]);
monitor.log(0, Duration::from_millis(50), &model);
let metrics = &monitor.history()[0].metrics;
assert_eq!(metrics.len(), 1);
assert_eq!(metrics[0].0, "loss");
assert!((metrics[0].1 - 2.0).abs() < 1e-10);
}
#[test]
fn test_digit_count() {
assert_eq!(digit_count(0), 1);
assert_eq!(digit_count(9), 1);
assert_eq!(digit_count(10), 2);
assert_eq!(digit_count(100), 3);
assert_eq!(digit_count(999), 3);
}
#[test]
fn test_watch_captures_label_hash() {
use crate::*;
let dev = crate::tensor::test_device();
let model = FlowBuilder::from(Linear::on_device(2, 4, dev).unwrap())
.label("test-model")
.through(Linear::on_device(4, 2, dev).unwrap())
.build()
.unwrap();
let mut monitor = Monitor::new(5);
monitor.watch(&model);
assert_eq!(monitor.graph_label.as_deref(), Some("test-model"));
assert!(monitor.graph_hash.is_some());
assert_eq!(monitor.graph_hash.as_ref().unwrap().len(), 64);
}
#[test]
fn test_build_archive_with_metadata() {
use crate::*;
let dev = crate::tensor::test_device();
let model = FlowBuilder::from(Linear::on_device(2, 4, dev).unwrap())
.label("meta-test")
.through(Linear::on_device(4, 2, dev).unwrap())
.build()
.unwrap();
let mut monitor = Monitor::new(5);
monitor.watch(&model);
monitor.set_metadata(serde_json::json!({
"lr": 0.001,
"batch_size": 32
}));
monitor.log(0, Duration::from_millis(50), &[("loss", 1.0)]);
let html = monitor.build_archive().unwrap();
assert!(html.contains("ARCHIVE_LABEL"));
assert!(html.contains("ARCHIVE_HASH"));
assert!(html.contains("ARCHIVE_META"));
assert!(html.contains("meta-test"));
assert!(html.contains("batch_size"));
}
#[test]
fn is_primary_defaults_true_when_no_cluster_env() {
let _guard = crate::distributed::cluster::ENV_MUTEX.lock().unwrap();
unsafe {
std::env::remove_var(crate::distributed::cluster::ENV_CLUSTER_JSON);
}
let monitor = Monitor::new(1);
assert!(
monitor.is_primary(),
"no cluster envelope -> single-host mode -> primary",
);
}
#[test]
fn is_primary_true_for_cluster_rank_zero() {
let envelope = serde_json::json!({
"controller": { "host": "127.0.0.1", "port": 29500 },
"world_size": 1,
"num_workers": 1,
"worker": {
"host": "master",
"ranks": [0],
"local_devices": [0],
"nccl_socket_ifname": "lo",
"path": "/tmp",
"arch": null,
}
});
let hex = crate::distributed::cluster::hex_encode(
&serde_json::to_vec(&envelope).unwrap(),
);
let _guard = crate::distributed::cluster::ENV_MUTEX.lock().unwrap();
crate::distributed::cluster::set_thread_local_rank_override(Some(0));
crate::distributed::cluster::set_thread_hostname_override(Some("master"));
unsafe {
std::env::set_var(
crate::distributed::cluster::ENV_CLUSTER_JSON,
&hex,
);
}
let is_primary = Monitor::new(1).is_primary();
unsafe {
std::env::remove_var(crate::distributed::cluster::ENV_CLUSTER_JSON);
}
crate::distributed::cluster::set_thread_local_rank_override(None);
crate::distributed::cluster::set_thread_hostname_override(None);
assert!(
is_primary,
"host owns rank 0 -> Monitor is the primary (dashboard) rank",
);
}
#[test]
fn is_primary_false_for_cluster_rank_nonzero() {
let envelope = serde_json::json!({
"controller": { "host": "127.0.0.1", "port": 29500 },
"world_size": 2,
"num_workers": 2,
"worker": {
"host": "worker",
"ranks": [1],
"local_devices": [0],
"nccl_socket_ifname": "lo",
"path": "/tmp",
"arch": null,
}
});
let hex = crate::distributed::cluster::hex_encode(
&serde_json::to_vec(&envelope).unwrap(),
);
let _guard = crate::distributed::cluster::ENV_MUTEX.lock().unwrap();
crate::distributed::cluster::set_thread_local_rank_override(Some(0));
crate::distributed::cluster::set_thread_hostname_override(Some("worker"));
unsafe {
std::env::set_var(
crate::distributed::cluster::ENV_CLUSTER_JSON,
&hex,
);
}
let is_primary = Monitor::new(1).is_primary();
unsafe {
std::env::remove_var(crate::distributed::cluster::ENV_CLUSTER_JSON);
}
crate::distributed::cluster::set_thread_local_rank_override(None);
crate::distributed::cluster::set_thread_hostname_override(None);
assert!(
!is_primary,
"host does not own rank 0 -> Monitor must no-op on serve/log/finish",
);
}
}