use std::collections::{BTreeMap, BTreeSet};
use serde_json::{json, Map, Value};
use crate::metrics::EpochMetrics;
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
pub enum Severity {
Info,
Warn,
Critical,
}
impl Severity {
pub fn as_str(self) -> &'static str {
match self {
Severity::Info => "info",
Severity::Warn => "warn",
Severity::Critical => "critical",
}
}
}
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
pub enum Reduction {
Mean,
Sum,
Max,
Min,
Last,
}
impl Reduction {
pub fn as_str(self) -> &'static str {
match self {
Reduction::Mean => "mean",
Reduction::Sum => "sum",
Reduction::Max => "max",
Reduction::Min => "min",
Reduction::Last => "last",
}
}
pub fn reduce(self, contribs: &[(f64, f64)]) -> Option<f64> {
if contribs.is_empty() {
return None;
}
match self {
Reduction::Sum => Some(contribs.iter().map(|(v, _)| *v).sum()),
Reduction::Max => contribs.iter().map(|(v, _)| *v).reduce(f64::max),
Reduction::Min => contribs.iter().map(|(v, _)| *v).reduce(f64::min),
Reduction::Mean => {
let wsum: f64 = contribs.iter().map(|(_, w)| *w).sum();
if wsum > 0.0 {
let num: f64 = contribs.iter().map(|(v, w)| v * w).sum();
Some(num / wsum)
} else {
None
}
}
Reduction::Last => contribs
.iter()
.copied()
.max_by(|a, b| a.1.partial_cmp(&b.1).unwrap_or(std::cmp::Ordering::Equal))
.map(|(v, _)| v),
}
}
}
pub fn core_reduction(key: &str) -> Option<Reduction> {
Some(match key {
"throughput" | "batch_share" => Reduction::Sum,
"loss" => Reduction::Mean,
"data_starve" | "compute_only_ms" => Reduction::Max,
_ => return None,
})
}
fn reduction_for(key: &str, user: &Reductions) -> Reduction {
core_reduction(key)
.or_else(|| user.get(key).copied())
.unwrap_or(Reduction::Mean)
}
pub type Reductions = BTreeMap<String, Reduction>;
#[derive(Debug, Clone, Copy, Default, PartialEq)]
pub struct Res {
pub gpu_util: Option<f64>,
pub gpu_util_max: Option<f64>,
pub vram_alloc: Option<f64>,
pub vram_alloc_max: Option<f64>,
pub vram_total: Option<f64>,
}
#[derive(Debug, Clone, Copy, Default)]
pub struct ResAcc {
gpu_util: crate::monitor::envelope::EnvelopeAcc,
vram_alloc: crate::monitor::envelope::EnvelopeAcc,
vram_total: crate::monitor::envelope::EnvelopeAcc,
}
impl ResAcc {
pub fn push(
&mut self,
gpu_util: Option<f64>,
vram_alloc: Option<f64>,
vram_total: Option<f64>,
) {
self.gpu_util.push_opt(gpu_util);
self.vram_alloc.push_opt(vram_alloc);
self.vram_total.push_opt(vram_total);
}
pub fn is_empty(&self) -> bool {
self.gpu_util.count() == 0
&& self.vram_alloc.count() == 0
&& self.vram_total.count() == 0
}
pub fn take(&mut self) -> Res {
let gpu = self.gpu_util.take();
let alloc = self.vram_alloc.take();
Res {
gpu_util: gpu.map(|e| e.mean),
gpu_util_max: gpu.map(|e| e.max),
vram_alloc: alloc.map(|e| e.mean),
vram_alloc_max: alloc.map(|e| e.max),
vram_total: self.vram_total.take().map(|e| e.max),
}
}
}
#[derive(Debug, Clone, Default)]
pub struct Leaf {
pub path: Vec<String>,
pub work: f64,
pub metrics: BTreeMap<String, f64>,
pub res: Res,
pub device: Option<u8>,
pub alive: bool,
pub label: Option<String>,
}
#[derive(Debug, Clone)]
pub struct NodeRecord {
pub path: Vec<String>,
pub work: f64,
pub metrics: BTreeMap<String, f64>,
pub res: Res,
pub children: Vec<NodeRecord>,
pub device: Option<u8>,
pub alive: bool,
pub label: Option<String>,
pub epoch_complete: bool,
}
impl NodeRecord {
pub fn is_leaf(&self) -> bool {
self.children.is_empty()
}
fn alive_children(&self) -> usize {
if self.is_leaf() {
usize::from(self.alive)
} else {
self.children.iter().filter(|c| c.alive).count()
}
}
pub fn to_record_json(
&self,
ts: u64,
tick: Option<u64>,
epoch: Option<usize>,
sev: Severity,
) -> Value {
let mut obj = Map::new();
obj.insert("v".into(), json!(1));
obj.insert("ts".into(), json!(ts));
obj.insert("sev".into(), json!(sev.as_str()));
obj.insert("path".into(), json!(self.path.join("/")));
obj.insert("kind".into(), json!("node"));
if let Some(t) = tick {
obj.insert("tick".into(), json!(t));
}
if let Some(e) = epoch {
obj.insert("epoch".into(), json!(e));
}
if self.epoch_complete {
obj.insert("epoch_complete".into(), json!(true));
}
if let Some(ref l) = self.label {
obj.insert("label".into(), json!(l));
}
let mut metrics = Map::new();
for (k, v) in &self.metrics {
metrics.insert(k.clone(), json!(v));
}
obj.insert("metrics".into(), Value::Object(metrics));
obj.insert("work".into(), json!(self.work));
let mut res = Map::new();
if let Some(v) = self.res.gpu_util {
res.insert("gpu_util".into(), json!(v));
}
if let Some(v) = self.res.gpu_util_max {
res.insert("gpu_util_max".into(), json!(v));
}
if let Some(v) = self.res.vram_alloc {
res.insert("vram_alloc".into(), json!(v));
}
if let Some(v) = self.res.vram_alloc_max {
res.insert("vram_alloc_max".into(), json!(v));
}
if let Some(v) = self.res.vram_total {
res.insert("vram_total".into(), json!(v));
}
if !res.is_empty() {
obj.insert("res".into(), Value::Object(res));
}
if self.is_leaf() {
if let Some(d) = self.device {
obj.insert("device".into(), json!(d));
}
obj.insert("alive".into(), json!(self.alive));
} else {
obj.insert("children".into(), json!(self.children.len()));
obj.insert("alive".into(), json!(self.alive_children()));
}
Value::Object(obj)
}
pub fn flat_records(&self, ts: u64, tick: Option<u64>, epoch: Option<usize>) -> Vec<Value> {
let mut out = Vec::new();
self.push_flat(ts, tick, epoch, &mut out);
out
}
fn push_flat(&self, ts: u64, tick: Option<u64>, epoch: Option<usize>, out: &mut Vec<Value>) {
out.push(self.to_record_json(ts, tick, epoch, Severity::Info));
for c in &self.children {
c.push_flat(ts, tick, epoch, out);
}
}
pub fn from_epoch_metrics(
m: &EpochMetrics,
hosts: Option<&[String]>,
user_reductions: &Reductions,
extras: &[RankExtras],
) -> NodeRecord {
let n = m.device_indices.len();
let host_refs: Vec<&str> = (0..n)
.map(|r| hosts.and_then(|hs| hs.get(r)).map(String::as_str).unwrap_or(""))
.collect();
let leaves: Vec<Leaf> = (0..n)
.map(|r| {
let mut metrics: BTreeMap<String, f64> = m
.per_rank
.get(r)
.map(|hm| hm.iter().map(|(k, v)| (k.clone(), *v)).collect())
.unwrap_or_default();
if let Some(&t) = m.per_rank_throughput.get(r) {
metrics.insert("throughput".into(), t);
}
if let Some(&s) = m.per_rank_batch_share.get(r) {
metrics.insert("batch_share".into(), s);
}
if let Some(&d) = m.per_rank_data_starve_ms.get(r) {
metrics.insert("data_starve".into(), d);
}
if let Some(&c) = m.per_rank_compute_only_ms.get(r) {
metrics.insert("compute_only_ms".into(), c);
}
if let Some(&Some(l)) = m.per_rank_loss.get(r) {
metrics.insert("loss".into(), l);
}
let extra = extras.get(r);
let work = if m.per_rank_samples.len() == n {
m.per_rank_samples[r] as f64
} else {
m.per_rank_batch_share.get(r).copied().unwrap_or(0.0)
};
Leaf {
path: leaf_path_segments(r, &host_refs),
work,
metrics,
res: extra.map(|e| e.res).unwrap_or_default(),
device: m.device_indices.get(r).copied(),
alive: true,
label: extra.and_then(|e| e.label.clone()),
}
})
.collect();
let mut root = build_tree(&leaves, user_reductions);
root.metrics.entry("loss".to_string()).or_insert(m.avg_loss);
for (k, v) in &m.scalars {
root.metrics.entry(k.clone()).or_insert(*v);
}
root.mark_epoch_complete();
root
}
fn mark_epoch_complete(&mut self) {
self.epoch_complete = true;
for c in &mut self.children {
c.mark_epoch_complete();
}
}
}
#[derive(Debug, Clone, Default)]
pub struct RankExtras {
pub res: Res,
pub label: Option<String>,
}
pub fn cohort_tiering(hosts: &[&str]) -> (bool, bool) {
let mut named: Vec<&str> = hosts.iter().copied().filter(|h| !h.is_empty()).collect();
named.sort_unstable();
named.dedup();
let multi_host = named.len() > 1;
(multi_host, hosts.len() == 1 && !multi_host)
}
pub fn leaf_path_segments(rank: usize, hosts: &[&str]) -> Vec<String> {
let (multi_host, root_only) = cohort_tiering(hosts);
if root_only {
return Vec::new();
}
let host = hosts.get(rank).copied().unwrap_or("");
let mut p = Vec::new();
if multi_host && !host.is_empty() {
p.push(host.to_string());
}
p.push(format!("rank{rank}"));
p
}
pub fn rank_record_path(rank: usize, hosts: &[&str]) -> String {
let mut p = vec!["root".to_string()];
p.extend(leaf_path_segments(rank, hosts));
p.join("/")
}
pub fn build_tree(leaves: &[Leaf], user_reductions: &Reductions) -> NodeRecord {
let entries: Vec<(&[String], &Leaf)> =
leaves.iter().map(|l| (l.path.as_slice(), l)).collect();
build_node(&["root".to_string()], &entries, user_reductions)
}
fn build_node(
prefix: &[String],
entries: &[(&[String], &Leaf)],
user: &Reductions,
) -> NodeRecord {
let nested: Vec<(&[String], &Leaf)> =
entries.iter().filter(|(p, _)| !p.is_empty()).copied().collect();
if nested.is_empty() {
let leaf = entries
.first()
.map(|(_, l)| *l)
.expect("build_node: node has neither children nor a terminal leaf");
return NodeRecord {
path: prefix.to_vec(),
work: leaf.work,
metrics: leaf.metrics.clone(),
res: leaf.res,
children: Vec::new(),
device: leaf.device,
alive: leaf.alive,
label: leaf.label.clone(),
epoch_complete: false,
};
}
debug_assert!(
entries.iter().all(|(p, _)| !p.is_empty()),
"build_node: a node is both a leaf and an interior (mixed paths)"
);
let mut groups: BTreeMap<String, Vec<(&[String], &Leaf)>> = BTreeMap::new();
for (p, l) in &nested {
let (head, tail) = p.split_first().expect("nested entry has a segment");
groups.entry(head.clone()).or_default().push((tail, *l));
}
let children: Vec<NodeRecord> = groups
.iter()
.map(|(seg, subs)| {
let mut child_prefix = prefix.to_vec();
child_prefix.push(seg.clone());
build_node(&child_prefix, subs, user)
})
.collect();
aggregate(prefix.to_vec(), children, user)
}
fn aggregate(path: Vec<String>, children: Vec<NodeRecord>, user: &Reductions) -> NodeRecord {
let work = children.iter().map(|c| c.work).sum();
let alive = children.iter().any(|c| c.alive);
let mut keys: BTreeSet<&str> = BTreeSet::new();
for c in &children {
for k in c.metrics.keys() {
keys.insert(k.as_str());
}
}
let mut metrics = BTreeMap::new();
for k in keys {
let red = reduction_for(k, user);
let contribs: Vec<(f64, f64)> = children
.iter()
.filter_map(|c| c.metrics.get(k).map(|v| (*v, c.work)))
.collect();
if let Some(v) = red.reduce(&contribs) {
metrics.insert(k.to_string(), v);
}
}
let res = Res {
gpu_util: Reduction::Mean.reduce(&res_contribs(&children, |r| r.gpu_util)),
gpu_util_max: Reduction::Max.reduce(&res_contribs(&children, |r| r.gpu_util_max)),
vram_alloc: Reduction::Sum.reduce(&res_contribs(&children, |r| r.vram_alloc)),
vram_alloc_max: Reduction::Sum.reduce(&res_contribs(&children, |r| r.vram_alloc_max)),
vram_total: Reduction::Sum.reduce(&res_contribs(&children, |r| r.vram_total)),
};
NodeRecord {
path,
work,
metrics,
res,
children,
device: None,
label: None,
alive,
epoch_complete: false,
}
}
fn res_contribs(children: &[NodeRecord], f: impl Fn(&Res) -> Option<f64>) -> Vec<(f64, f64)> {
children
.iter()
.filter_map(|c| f(&c.res).map(|v| (v, c.work)))
.collect()
}
pub fn meta_record(user_reductions: &Reductions, ts: u64) -> Value {
let mut red = Map::new();
for (k, r) in user_reductions {
red.insert(k.clone(), json!(r.as_str()));
}
json!({ "v": 1, "ts": ts, "kind": "meta", "reductions": Value::Object(red) })
}
#[cfg(test)]
mod tests {
use super::*;
fn leaf(path: &[&str], work: f64, metrics: &[(&str, f64)]) -> Leaf {
Leaf {
path: path.iter().map(|s| s.to_string()).collect(),
work,
metrics: metrics.iter().map(|(k, v)| (k.to_string(), *v)).collect(),
res: Res::default(),
device: None,
alive: true,
label: None,
}
}
fn m(node: &NodeRecord, key: &str) -> Option<f64> {
node.metrics.get(key).copied()
}
#[test]
fn absent_is_none_not_zero() {
for red in [
Reduction::Mean,
Reduction::Sum,
Reduction::Max,
Reduction::Min,
Reduction::Last,
] {
assert_eq!(red.reduce(&[]), None, "{red:?}");
}
}
#[test]
fn mean_is_work_weighted() {
assert_eq!(Reduction::Mean.reduce(&[(0.0, 3.0), (1.0, 1.0)]), Some(0.25));
}
#[test]
fn mean_with_zero_total_work_is_absent() {
assert_eq!(Reduction::Mean.reduce(&[(1.0, 0.0), (2.0, 0.0)]), None);
}
#[test]
fn sum_over_reporters_only() {
assert_eq!(Reduction::Sum.reduce(&[(2.0, 1.0), (3.0, 9.0)]), Some(5.0));
}
#[test]
fn max_min_last() {
assert_eq!(Reduction::Max.reduce(&[(2.0, 1.0), (5.0, 1.0)]), Some(5.0));
assert_eq!(Reduction::Min.reduce(&[(2.0, 1.0), (5.0, 1.0)]), Some(2.0));
assert_eq!(Reduction::Last.reduce(&[(2.0, 1.0), (5.0, 9.0)]), Some(5.0));
}
#[test]
fn root_only_single_rank() {
let root = build_tree(&[leaf(&[], 1.0, &[("loss", 0.5)])], &Reductions::new());
assert!(root.is_leaf());
assert_eq!(root.path, vec!["root"]);
assert_eq!(m(&root, "loss"), Some(0.5));
assert_eq!(root.work, 1.0);
}
#[test]
fn two_ranks_sum_and_weighted_mean() {
let root = build_tree(
&[
leaf(&["rank0"], 3.0, &[("loss", 0.2), ("throughput", 10.0)]),
leaf(&["rank1"], 1.0, &[("loss", 0.6), ("throughput", 4.0)]),
],
&Reductions::new(),
);
assert!(!root.is_leaf());
assert_eq!(root.children.len(), 2);
assert_eq!(root.work, 4.0);
assert!((m(&root, "loss").unwrap() - 0.3).abs() < 1e-12);
assert_eq!(m(&root, "throughput"), Some(14.0));
}
#[test]
fn hierarchical_equals_flat() {
let ranks = [
(["h1", "rank0"], 2.0, 0.10),
(["h1", "rank1"], 3.0, 0.40),
(["h2", "rank2"], 5.0, 0.90),
];
let leaves: Vec<Leaf> = ranks
.iter()
.map(|(p, w, l)| leaf(&p[..], *w, &[("loss", *l)]))
.collect();
let root = build_tree(&leaves, &Reductions::new());
let flat_num: f64 = ranks.iter().map(|(_, w, l)| w * l).sum();
let flat_den: f64 = ranks.iter().map(|(_, w, _)| *w).sum();
let flat = flat_num / flat_den;
assert!((m(&root, "loss").unwrap() - flat).abs() < 1e-12);
assert_eq!(root.children.len(), 2);
assert_eq!(root.work, 10.0);
let h1 = root.children.iter().find(|c| c.path.last().unwrap() == "h1").unwrap();
assert_eq!(h1.children.len(), 2);
assert_eq!(h1.work, 5.0);
}
#[test]
fn uniform_field_set_and_schema_at_every_level() {
let root = build_tree(
&[
leaf(&["h1", "rank0"], 1.0, &[("loss", 0.2)]),
leaf(&["h2", "rank1"], 1.0, &[("loss", 0.4)]),
],
&Reductions::new(),
);
let root_keys: BTreeSet<&String> = root.metrics.keys().collect();
for c in &root.children {
let child_keys: BTreeSet<&String> = c.metrics.keys().collect();
assert_eq!(root_keys, child_keys);
}
let root_json = root.to_record_json(0, Some(0), None, Severity::Info);
assert!(root_json.get("children").is_some());
assert!(root_json.get("metrics").is_some());
assert!(root_json.get("work").is_some());
let leaf_node = &root.children[0].children[0];
let leaf_json = leaf_node.to_record_json(0, Some(0), None, Severity::Info);
assert!(leaf_json.get("alive").is_some());
assert!(leaf_json.get("children").is_none());
}
#[test]
fn user_reduction_override_and_core_wins() {
let mut user = Reductions::new();
user.insert("samples_seen".into(), Reduction::Sum);
user.insert("throughput".into(), Reduction::Mean);
let root = build_tree(
&[
leaf(&["rank0"], 1.0, &[("samples_seen", 100.0), ("throughput", 10.0)]),
leaf(&["rank1"], 1.0, &[("samples_seen", 50.0), ("throughput", 6.0)]),
],
&user,
);
assert_eq!(m(&root, "samples_seen"), Some(150.0)); assert_eq!(m(&root, "throughput"), Some(16.0)); }
#[test]
fn absent_metric_excluded_not_zero() {
let root = build_tree(
&[
leaf(&["rank0"], 1.0, &[("grad_norm", 2.0)]),
leaf(&["rank1"], 1.0, &[("loss", 0.5)]),
],
&Reductions::new(),
);
assert_eq!(m(&root, "grad_norm"), Some(2.0));
}
#[test]
fn res_mean_sum_and_absent() {
let mk = |util: Option<f64>, alloc: Option<f64>, work: f64| Leaf {
path: vec!["rank".into()],
work,
metrics: BTreeMap::new(),
res: Res {
gpu_util: util,
gpu_util_max: util,
vram_alloc: alloc,
vram_alloc_max: alloc,
vram_total: None,
},
device: None,
alive: true,
label: None,
};
let mut a = mk(Some(80.0), Some(1000.0), 3.0);
a.path = vec!["rank0".into()];
let mut b = mk(Some(40.0), None, 1.0);
b.path = vec!["rank1".into()];
let root = build_tree(&[a, b], &Reductions::new());
assert_eq!(root.res.gpu_util, Some(70.0));
assert_eq!(root.res.vram_alloc, Some(1000.0));
assert_eq!(root.res.vram_total, None);
}
#[test]
fn flat_records_cover_every_node() {
let root = build_tree(
&[
leaf(&["h1", "rank0"], 1.0, &[("loss", 0.2)]),
leaf(&["h1", "rank1"], 1.0, &[("loss", 0.4)]),
],
&Reductions::new(),
);
let recs = root.flat_records(1234, Some(7), Some(3));
assert_eq!(recs.len(), 4);
let paths: BTreeSet<String> = recs
.iter()
.map(|r| r["path"].as_str().unwrap().to_string())
.collect();
assert!(paths.contains("root"));
assert!(paths.contains("root/h1"));
assert!(paths.contains("root/h1/rank0"));
for r in &recs {
assert_eq!(r["tick"], json!(7));
assert_eq!(r["epoch"], json!(3));
assert_eq!(r["kind"], json!("node"));
}
}
#[test]
fn leaf_record_carries_device_and_alive() {
let mut l = leaf(&["rank0"], 1.0, &[("loss", 0.2)]);
l.device = Some(1);
l.alive = false;
let root = build_tree(&[l], &Reductions::new()); let rec = root.children[0].to_record_json(0, Some(0), None, Severity::Info);
assert_eq!(rec["device"], json!(1));
assert_eq!(rec["alive"], json!(false));
let root_rec = root.to_record_json(0, Some(0), None, Severity::Info);
assert_eq!(root_rec["alive"], json!(0));
assert_eq!(root_rec["children"], json!(1));
}
#[test]
fn meta_record_declares_user_reductions() {
let mut user = Reductions::new();
user.insert("samples_seen".into(), Reduction::Sum);
let meta = meta_record(&user, 99);
assert_eq!(meta["kind"], json!("meta"));
assert_eq!(meta["reductions"]["samples_seen"], json!("sum"));
}
fn epoch_metrics_2ranks() -> EpochMetrics {
let mut per_rank = vec![BTreeMap::new(), BTreeMap::new()];
per_rank[0].insert("acc".to_string(), 0.90);
per_rank[1].insert("acc".to_string(), 0.70);
let per_rank: Vec<std::collections::HashMap<String, f64>> = per_rank
.into_iter()
.map(|b| b.into_iter().collect())
.collect();
EpochMetrics {
epoch: 4,
scalars: std::collections::HashMap::new(),
per_rank,
avg_loss: 0.3,
per_rank_loss: vec![Some(0.2), Some(0.6)],
per_rank_samples: vec![750, 250],
epoch_ms: 100.0,
per_rank_throughput: vec![10.0, 4.0],
per_rank_batch_share: vec![0.75, 0.25],
per_rank_share_complete_ms: vec![90.0, 95.0],
per_rank_compute_only_ms: vec![80.0, 85.0],
per_rank_data_starve_ms: vec![5.0, 40.0],
device_indices: vec![0, 1],
}
}
#[test]
fn from_epoch_metrics_builds_weighted_tree() {
let em = epoch_metrics_2ranks();
let root = NodeRecord::from_epoch_metrics(&em, None, &Reductions::new(), &[]);
assert_eq!(root.children.len(), 2);
assert!((root.work - 1000.0).abs() < 1e-12);
assert!((m(&root, "throughput").unwrap() - 14.0).abs() < 1e-9);
assert!((m(&root, "acc").unwrap() - 0.85).abs() < 1e-12);
assert_eq!(m(&root, "data_starve"), Some(40.0));
let rank1 = root.children.iter().find(|c| c.path.last().unwrap() == "rank1").unwrap();
assert_eq!(rank1.device, Some(1));
assert_eq!(m(rank1, "loss"), Some(0.6));
assert!((m(&root, "loss").unwrap() - 0.3).abs() < 1e-12);
}
#[test]
fn from_epoch_metrics_degenerate_inputs() {
let mut em = epoch_metrics_2ranks();
em.per_rank_samples = vec![];
em.per_rank_loss = vec![Some(0.2), None];
let root = NodeRecord::from_epoch_metrics(&em, None, &Reductions::new(), &[]);
assert!((root.work - 1.0).abs() < 1e-12);
let rank1 = root.children.iter().find(|c| c.path.last().unwrap() == "rank1").unwrap();
assert!(!rank1.metrics.contains_key("loss"));
let rank0 = root.children.iter().find(|c| c.path.last().unwrap() == "rank0").unwrap();
assert_eq!(m(rank0, "loss"), Some(0.2));
assert!((m(&root, "loss").unwrap() - 0.2).abs() < 1e-12);
}
#[test]
fn from_epoch_metrics_hosts_tier() {
let em = epoch_metrics_2ranks();
let hosts = vec!["hostA".to_string(), "hostB".to_string()];
let root = NodeRecord::from_epoch_metrics(&em, Some(&hosts), &Reductions::new(), &[]);
assert_eq!(root.children.len(), 2);
let host_a = root.children.iter().find(|c| c.path.last().unwrap() == "hostA").unwrap();
assert_eq!(host_a.children.len(), 1);
assert_eq!(host_a.children[0].path, vec!["root", "hostA", "rank0"]);
}
#[test]
fn co_hosted_ranks_get_no_host_tier() {
let em = epoch_metrics_2ranks();
let hosts = vec!["h1".to_string(), "h1".to_string()];
let root = NodeRecord::from_epoch_metrics(&em, Some(&hosts), &Reductions::new(), &[]);
let paths: Vec<String> =
root.children.iter().map(|c| c.path.join("/")).collect();
assert_eq!(paths, vec!["root/rank0", "root/rank1"]);
}
#[test]
fn path_helpers_match_the_built_tree() {
let cases: Vec<Vec<&str>> = vec![
vec!["h1"], vec!["h1", "h1"], vec!["h1", "h2"], vec!["", ""], ];
for hosts in cases {
let leaves: Vec<Leaf> = (0..hosts.len())
.map(|r| Leaf {
path: leaf_path_segments(r, &hosts),
work: 1.0,
alive: true,
..Default::default()
})
.collect();
let root = build_tree(&leaves, &Reductions::new());
let mut built = Vec::new();
collect_leaf_paths(&root, &mut built);
let expected: Vec<String> =
(0..hosts.len()).map(|r| rank_record_path(r, &hosts)).collect();
let mut expected_sorted = expected.clone();
expected_sorted.sort();
expected_sorted.dedup();
built.sort();
assert_eq!(built, expected_sorted, "hosts={hosts:?}");
}
}
fn collect_leaf_paths(n: &NodeRecord, out: &mut Vec<String>) {
if n.is_leaf() {
out.push(n.path.join("/"));
}
for c in &n.children {
collect_leaf_paths(c, out);
}
}
#[test]
fn root_aggregates_fill_keys_the_rollup_cannot_produce() {
let mut em = epoch_metrics_2ranks();
em.per_rank_loss = vec![None, None];
em.scalars.insert("eval_acc".to_string(), 0.91);
let root = NodeRecord::from_epoch_metrics(&em, None, &Reductions::new(), &[]);
assert_eq!(m(&root, "loss"), Some(0.3), "avg_loss injected at root");
assert_eq!(m(&root, "eval_acc"), Some(0.91), "root-only scalar injected");
assert_eq!(m(&root.children[0], "loss"), None);
assert_eq!(m(&root.children[0], "eval_acc"), None);
}
#[test]
fn rollup_wins_over_injection_and_the_two_agree() {
let mut em = epoch_metrics_2ranks();
em.scalars.insert("acc".to_string(), 0.85);
let root = NodeRecord::from_epoch_metrics(&em, None, &Reductions::new(), &[]);
assert!((m(&root, "acc").unwrap() - 0.85).abs() < 1e-12);
}
#[test]
fn epoch_records_are_marked_complete_at_every_level() {
let em = epoch_metrics_2ranks();
let hosts = vec!["hostA".to_string(), "hostB".to_string()];
let root = NodeRecord::from_epoch_metrics(&em, Some(&hosts), &Reductions::new(), &[]);
assert!(root.epoch_complete);
assert!(root.children.iter().all(|h| h.epoch_complete));
assert!(root.children.iter().all(|h| h.children.iter().all(|r| r.epoch_complete)));
let recs = root.flat_records(1, None, Some(4));
assert!(recs.iter().all(|r| r["epoch_complete"] == true));
assert!(recs.iter().all(|r| r.get("tick").is_none()), "no window index");
let window = build_tree(
&[Leaf { path: vec!["rank0".into()], work: 1.0, alive: true, ..Default::default() }],
&Reductions::new(),
);
assert!(window.flat_records(1, Some(7), Some(4))[0].get("epoch_complete").is_none());
}
#[test]
fn res_and_label_ride_the_epoch_leaves_and_roll_up() {
let em = epoch_metrics_2ranks();
let extras = vec![
RankExtras {
res: Res {
gpu_util: Some(90.0),
gpu_util_max: Some(95.0),
vram_alloc: Some(1000.0),
vram_alloc_max: Some(1200.0),
vram_total: Some(4000.0),
},
label: Some("RTX 5060 Ti".to_string()),
},
RankExtras {
res: Res {
gpu_util: Some(50.0),
gpu_util_max: Some(99.0),
vram_alloc: Some(500.0),
vram_alloc_max: Some(600.0),
vram_total: Some(6000.0),
},
label: Some("GP106".to_string()),
},
];
let root = NodeRecord::from_epoch_metrics(&em, None, &Reductions::new(), &extras);
assert!((root.res.gpu_util.unwrap() - 80.0).abs() < 1e-9);
assert_eq!(root.res.gpu_util_max, Some(99.0));
assert_eq!(root.res.vram_alloc, Some(1500.0));
assert_eq!(root.res.vram_alloc_max, Some(1800.0));
assert_eq!(root.res.vram_total, Some(10000.0));
let r0 = &root.children[0];
assert_eq!(r0.label.as_deref(), Some("RTX 5060 Ti"));
assert_eq!(root.label, None);
let rec = r0.to_record_json(0, None, None, Severity::Info);
assert_eq!(rec["label"], "RTX 5060 Ti");
assert_eq!(rec["res"]["gpu_util"], 90.0);
let bare = NodeRecord::from_epoch_metrics(&em, None, &Reductions::new(), &[]);
assert_eq!(bare.res.gpu_util, None);
assert!(bare.children[0].to_record_json(0, None, None, Severity::Info).get("res").is_none());
}
#[test]
fn res_acc_publishes_the_interval_not_the_last_reading() {
let mut acc = ResAcc::default();
for util in [100.0, 100.0, 100.0, 0.0] {
acc.push(Some(util), Some(2_000.0), Some(6_000.0));
}
let res = acc.take();
assert_eq!(res.gpu_util, Some(75.0), "mean over the interval");
assert_eq!(
res.gpu_util_max,
Some(100.0),
"the busy stretch survives; latest-wins would have reported 0",
);
assert_eq!(res.vram_total, Some(6_000.0));
}
#[test]
fn res_acc_drains_so_a_sampleless_interval_stays_absent() {
let mut acc = ResAcc::default();
acc.push(Some(80.0), Some(1_000.0), Some(4_000.0));
assert!(!acc.is_empty());
assert_eq!(acc.take().gpu_util, Some(80.0));
assert!(acc.is_empty());
let second = acc.take();
assert_eq!(second, Res::default());
assert_eq!(second.gpu_util, None);
assert_eq!(second.gpu_util_max, None);
}
#[test]
fn res_acc_keeps_absent_fields_absent() {
let mut acc = ResAcc::default();
acc.push(Some(60.0), None, None);
let res = acc.take();
assert_eq!(res.gpu_util, Some(60.0));
assert_eq!(res.vram_alloc, None);
assert_eq!(res.vram_alloc_max, None);
assert_eq!(res.vram_total, None);
}
}