use super::*;
#[derive(Copy, Clone, Debug, PartialEq, Eq, Hash, PartialOrd, Ord)]
pub enum Dimension {
Kernel,
Scheduler,
Topology,
WorkType,
ProjectCommit,
KernelCommit,
RunSource,
CpuBudget,
}
impl Dimension {
pub const ALL: &'static [Dimension] = &[
Dimension::Kernel,
Dimension::Scheduler,
Dimension::Topology,
Dimension::WorkType,
Dimension::ProjectCommit,
Dimension::KernelCommit,
Dimension::RunSource,
Dimension::CpuBudget,
];
pub fn pairing_dims(slicing: &[Dimension]) -> Vec<Dimension> {
Self::ALL
.iter()
.copied()
.filter(|d| !slicing.contains(d))
.collect()
}
pub fn name(self) -> &'static str {
match self {
Dimension::Kernel => "kernel",
Dimension::Scheduler => "scheduler",
Dimension::Topology => "topology",
Dimension::WorkType => "work-type",
Dimension::ProjectCommit => "project-commit",
Dimension::KernelCommit => "kernel-commit",
Dimension::RunSource => "run-source",
Dimension::CpuBudget => "cpu-budget",
}
}
}
#[cfg(test)]
pub(crate) const LEGACY_PAIRING_DIMS: &[Dimension] = &[Dimension::Topology, Dimension::WorkType];
pub fn derive_slicing_dims(filter_a: &RowFilter, filter_b: &RowFilter) -> Vec<Dimension> {
let mut out = Vec::new();
for &dim in Dimension::ALL {
let differs = match dim {
Dimension::Kernel => sorted_dedup(&filter_a.kernels) != sorted_dedup(&filter_b.kernels),
Dimension::Scheduler => {
sorted_dedup(&filter_a.schedulers) != sorted_dedup(&filter_b.schedulers)
}
Dimension::Topology => {
sorted_dedup(&filter_a.topologies) != sorted_dedup(&filter_b.topologies)
}
Dimension::WorkType => {
sorted_dedup(&filter_a.work_types) != sorted_dedup(&filter_b.work_types)
}
Dimension::ProjectCommit => {
sorted_dedup(&filter_a.project_commits) != sorted_dedup(&filter_b.project_commits)
}
Dimension::KernelCommit => {
sorted_dedup(&filter_a.kernel_commits) != sorted_dedup(&filter_b.kernel_commits)
}
Dimension::RunSource => {
sorted_dedup(&filter_a.run_sources) != sorted_dedup(&filter_b.run_sources)
}
Dimension::CpuBudget => {
sorted_dedup(&filter_a.cpu_budgets) != sorted_dedup(&filter_b.cpu_budgets)
}
};
if differs {
out.push(dim);
}
}
out
}
fn sorted_dedup(v: &[String]) -> Vec<&str> {
let mut s: Vec<&str> = v.iter().map(String::as_str).collect();
s.sort_unstable();
s.dedup();
s
}
pub(crate) fn render_side_label(
filter: &RowFilter,
dims: &[Dimension],
bare_label: &str,
) -> String {
if dims.is_empty() {
return bare_label.to_string();
}
let mut parts: Vec<String> = Vec::new();
for &dim in dims {
let part = match dim {
Dimension::Kernel => render_vec_dim(&filter.kernels, bare_label),
Dimension::Scheduler => render_vec_dim(&filter.schedulers, bare_label),
Dimension::Topology => render_vec_dim(&filter.topologies, bare_label),
Dimension::WorkType => render_vec_dim(&filter.work_types, bare_label),
Dimension::ProjectCommit => render_vec_dim(&filter.project_commits, bare_label),
Dimension::KernelCommit => render_vec_dim(&filter.kernel_commits, bare_label),
Dimension::RunSource => render_vec_dim(&filter.run_sources, bare_label),
Dimension::CpuBudget => render_vec_dim(&filter.cpu_budgets, bare_label),
};
parts.push(part);
}
parts.join(":")
}
fn render_vec_dim(values: &[String], bare_label: &str) -> String {
if values.is_empty() || values.len() > 3 {
bare_label.to_string()
} else {
let mut sorted: Vec<&str> = values.iter().map(String::as_str).collect();
sorted.sort_unstable();
sorted.join("|")
}
}
#[derive(Clone, Debug, PartialEq, Eq, Hash, PartialOrd, Ord, serde::Serialize)]
pub(crate) struct PairingKey(pub Vec<String>);
impl PairingKey {
pub fn from_row(row: &GauntletRow, pairing_dims: &[Dimension]) -> Self {
let mut parts = Vec::with_capacity(1 + pairing_dims.len());
parts.push(row.scenario.clone());
for &dim in pairing_dims {
parts.push(match dim {
Dimension::Kernel => row.kernel_version.clone().unwrap_or_default(),
Dimension::Scheduler => row.scheduler.clone(),
Dimension::Topology => row.topology.clone(),
Dimension::WorkType => row.work_type.clone(),
Dimension::ProjectCommit => commit_pairing_key_part(&row.commit),
Dimension::KernelCommit => commit_pairing_key_part(&row.kernel_commit),
Dimension::RunSource => row.run_source.clone().unwrap_or_default(),
Dimension::CpuBudget => row.cpu_budget.map(|n| n.to_string()).unwrap_or_default(),
});
}
PairingKey(parts)
}
}
fn commit_pairing_key_part(value: &Option<String>) -> String {
let Some(s) = value.as_deref() else {
return String::new();
};
s.strip_suffix("-dirty").unwrap_or(s).to_string()
}
#[derive(Clone, Debug)]
#[non_exhaustive]
pub struct AveragedGroup {
pub row: GauntletRow,
pub passes_observed: u32,
pub skips_observed: u32,
pub inconclusives_observed: u32,
pub failures_observed: u32,
pub total_observed: u32,
}
fn update_dirty_tracking(
value: &Option<String>,
any_clean: &mut bool,
any_dirty: &mut bool,
first_base: &mut Option<String>,
) {
let Some(s) = value.as_deref() else { return };
let (base, is_dirty) = match s.strip_suffix("-dirty") {
Some(base) => (base, true),
None => (s, false),
};
if is_dirty {
*any_dirty = true;
} else {
*any_clean = true;
}
if first_base.is_none() {
*first_base = Some(base.to_string());
}
}
fn render_mixed_dirty(
any_clean: bool,
any_dirty: bool,
first_base: &Option<String>,
first_commit: &Option<String>,
) -> Option<String> {
if any_clean
&& any_dirty
&& let Some(base) = first_base
{
return Some(format!("{base}+mixed"));
}
first_commit.clone()
}
struct Accumulator<'a> {
first: &'a GauntletRow,
total_observed: u32,
passes_observed: u32,
skips_observed: u32,
inconclusives_observed: u32,
failures_observed: u32,
any_skipped: bool,
any_failed: bool,
any_inconclusive: bool,
any_project_clean: bool,
any_project_dirty: bool,
any_kernel_clean: bool,
any_kernel_dirty: bool,
first_project_base: Option<String>,
first_kernel_base: Option<String>,
sum_spread: f64,
sum_migrations: u64,
sum_migration_ratio: f64,
sum_stuck_count: f64,
sum_fallback_count: i64,
sum_keep_last_count: i64,
sum_total_iterations: u64,
sum_page_locality: f64,
sum_cross_node_mig: f64,
max_gap_ms: u64,
max_imbalance_ratio: f64,
max_max_dsq_depth: u32,
ext_pairs: BTreeMap<String, Vec<(f64, usize)>>,
sum_run_sample_count: usize,
}
impl<'a> Accumulator<'a> {
fn new(first: &'a GauntletRow) -> Self {
Accumulator {
first,
total_observed: 0,
passes_observed: 0,
skips_observed: 0,
inconclusives_observed: 0,
failures_observed: 0,
any_skipped: false,
any_failed: false,
any_inconclusive: false,
any_project_clean: false,
any_project_dirty: false,
any_kernel_clean: false,
any_kernel_dirty: false,
first_project_base: None,
first_kernel_base: None,
sum_spread: 0.0,
sum_migrations: 0,
sum_migration_ratio: 0.0,
sum_stuck_count: 0.0,
sum_fallback_count: 0,
sum_keep_last_count: 0,
sum_total_iterations: 0,
sum_page_locality: 0.0,
sum_cross_node_mig: 0.0,
max_gap_ms: 0,
max_imbalance_ratio: 0.0,
max_max_dsq_depth: 0,
ext_pairs: BTreeMap::new(),
sum_run_sample_count: 0,
}
}
fn observe(&mut self, row: &GauntletRow) {
self.total_observed += 1;
update_dirty_tracking(
&row.commit,
&mut self.any_project_clean,
&mut self.any_project_dirty,
&mut self.first_project_base,
);
update_dirty_tracking(
&row.kernel_commit,
&mut self.any_kernel_clean,
&mut self.any_kernel_dirty,
&mut self.first_kernel_base,
);
if row.is_skip() {
self.any_skipped = true;
self.skips_observed += 1;
return;
}
if row.is_fail() {
self.any_failed = true;
self.failures_observed += 1;
return;
}
if row.is_inconclusive() {
self.any_inconclusive = true;
self.inconclusives_observed += 1;
return;
}
self.passes_observed += 1;
self.sum_spread += row.spread;
self.sum_migrations = self.sum_migrations.saturating_add(row.migrations);
self.sum_migration_ratio += row.migration_ratio;
self.sum_stuck_count += row.stuck_count;
self.sum_fallback_count = self.sum_fallback_count.saturating_add(row.fallback_count);
self.sum_keep_last_count = self.sum_keep_last_count.saturating_add(row.keep_last_count);
self.sum_total_iterations = self
.sum_total_iterations
.saturating_add(row.total_iterations);
self.sum_page_locality += row.page_locality;
self.sum_cross_node_mig += row.cross_node_migration_ratio;
self.max_gap_ms = self.max_gap_ms.max(row.gap_ms);
if row.imbalance_ratio > self.max_imbalance_ratio {
self.max_imbalance_ratio = row.imbalance_ratio;
}
self.max_max_dsq_depth = self.max_max_dsq_depth.max(row.max_dsq_depth);
self.sum_run_sample_count = self
.sum_run_sample_count
.saturating_add(row.run_sample_count);
for (k, v) in &row.ext_metrics {
self.ext_pairs
.entry(k.clone())
.or_default()
.push((*v, row.run_sample_count));
}
}
fn into_averaged_group(self) -> AveragedGroup {
let acc = self;
let n = acc.passes_observed;
let denom = if n == 0 { 1.0 } else { f64::from(n) };
let round_u64 = |sum: u64| -> u64 { (sum as f64 / denom).round() as u64 };
let round_i64 = |sum: i64| -> i64 { (sum as f64 / denom).round() as i64 };
let project_commit_rendered = render_mixed_dirty(
acc.any_project_clean,
acc.any_project_dirty,
&acc.first_project_base,
&acc.first.commit,
);
let kernel_commit_rendered = render_mixed_dirty(
acc.any_kernel_clean,
acc.any_kernel_dirty,
&acc.first_kernel_base,
&acc.first.kernel_commit,
);
let ext_metrics = fold_ext_metrics(acc.ext_pairs);
let aggregated = GauntletRow {
scenario: acc.first.scenario.clone(),
topology: acc.first.topology.clone(),
work_type: acc.first.work_type.clone(),
scheduler: acc.first.scheduler.clone(),
kernel_version: acc.first.kernel_version.clone(),
commit: project_commit_rendered,
kernel_commit: kernel_commit_rendered,
run_source: acc.first.run_source.clone(),
cpu_budget: acc.first.cpu_budget,
vcpus: acc.first.vcpus,
passed: !acc.any_failed && !acc.any_inconclusive && !acc.any_skipped && n > 0,
skipped: !acc.any_failed && !acc.any_inconclusive && acc.any_skipped,
inconclusive: !acc.any_failed && acc.any_inconclusive,
run_sample_count: acc.sum_run_sample_count,
spread: acc.sum_spread / denom,
gap_ms: acc.max_gap_ms,
imbalance_ratio: acc.max_imbalance_ratio,
max_dsq_depth: acc.max_max_dsq_depth,
migrations: round_u64(acc.sum_migrations),
migration_ratio: acc.sum_migration_ratio / denom,
stuck_count: acc.sum_stuck_count / denom,
fallback_count: round_i64(acc.sum_fallback_count),
keep_last_count: round_i64(acc.sum_keep_last_count),
total_iterations: round_u64(acc.sum_total_iterations),
page_locality: acc.sum_page_locality / denom,
cross_node_migration_ratio: acc.sum_cross_node_mig / denom,
ext_metrics,
phases: Vec::new(),
};
AveragedGroup {
row: aggregated,
passes_observed: acc.passes_observed,
skips_observed: acc.skips_observed,
inconclusives_observed: acc.inconclusives_observed,
failures_observed: acc.failures_observed,
total_observed: acc.total_observed,
}
}
}
fn fold_ext_metrics(ext_pairs: BTreeMap<String, Vec<(f64, usize)>>) -> BTreeMap<String, f64> {
let mut ext_metrics: std::collections::BTreeMap<String, f64> = ext_pairs
.into_iter()
.filter_map(|(k, pairs)| {
if let Some(def) = metric_def(&k) {
if matches!(def.kind, MetricKind::Rate { .. }) {
return None;
}
aggregate_samples_weighted(&pairs, def.kind).map(|v| (k, v))
} else {
let n = pairs.len();
if n == 0 {
None
} else {
let sum: f64 = pairs.iter().map(|(v, _)| *v).sum();
Some((k, sum / n as f64))
}
}
})
.collect();
derive_rate_metrics(&mut ext_metrics);
ext_metrics
}
pub fn group_and_average_by(
rows: &[GauntletRow],
pairing_dims: &[Dimension],
) -> Vec<AveragedGroup> {
type Key = PairingKey;
let mut order: Vec<Key> = Vec::new();
let mut groups: BTreeMap<Key, Accumulator<'_>> = BTreeMap::new();
for row in rows {
let key = PairingKey::from_row(row, pairing_dims);
let acc = groups.entry(key.clone()).or_insert_with(|| {
order.push(key);
Accumulator::new(row)
});
acc.observe(row);
}
let mut out = Vec::with_capacity(order.len());
for key in order {
let acc = groups
.remove(&key)
.expect("first-seen key must still be in groups map");
out.push(acc.into_averaged_group());
}
out
}
pub fn sidecar_to_row(sc: &crate::test_support::SidecarResult) -> GauntletRow {
let finite_or_zero = |field: &str, v: f64| -> f64 {
if v.is_finite() {
v
} else {
tracing::warn!(
test = %sc.test_name,
field,
value = v,
"non-finite f64 in GauntletRow field; substituting 0.0",
);
0.0
}
};
GauntletRow {
scenario: sc.test_name.clone(),
topology: sc.topology.clone(),
work_type: sc.work_type.clone(),
scheduler: sc.scheduler.clone(),
kernel_version: sc.kernel_version.clone(),
commit: sc.project_commit.clone(),
kernel_commit: sc.kernel_commit.clone(),
run_source: sc.run_source.clone(),
cpu_budget: (sc.cpu_budget != 0).then_some(sc.cpu_budget),
vcpus: (sc.vcpus != 0).then_some(sc.vcpus),
passed: sc.is_pass(),
skipped: sc.is_skip(),
inconclusive: sc.is_inconclusive(),
run_sample_count: sc.monitor.as_ref().map(|m| m.total_samples).unwrap_or(0),
spread: finite_or_zero("spread", sc.stats.worst_spread),
gap_ms: sc.stats.worst_gap_ms,
migrations: sc.stats.total_migrations,
migration_ratio: finite_or_zero("migration_ratio", sc.stats.worst_migration_ratio),
imbalance_ratio: finite_or_zero(
"imbalance_ratio",
sc.monitor
.as_ref()
.map(|m| m.max_imbalance_ratio)
.unwrap_or(0.0),
),
max_dsq_depth: sc
.monitor
.as_ref()
.map(|m| m.max_local_dsq_depth)
.unwrap_or(0),
stuck_count: sc.monitor.as_ref().map(|m| m.stuck_count).unwrap_or(0) as f64,
fallback_count: sc
.monitor
.as_ref()
.and_then(|m| m.event_deltas.as_ref())
.map(|e| e.total_fallback)
.unwrap_or(0),
keep_last_count: sc
.monitor
.as_ref()
.and_then(|m| m.event_deltas.as_ref())
.map(|e| e.total_dispatch_keep_last)
.unwrap_or(0),
total_iterations: sc.stats.total_iterations,
page_locality: finite_or_zero("page_locality", sc.stats.worst_page_locality),
cross_node_migration_ratio: finite_or_zero(
"cross_node_migration_ratio",
sc.stats.worst_cross_node_migration_ratio,
),
ext_metrics: sc
.stats
.ext_metrics
.iter()
.filter_map(|(k, &v)| {
if crate::test_support::is_truncation_sentinel_name(k) {
return None;
}
if v.is_finite() {
Some((k.clone(), v))
} else {
tracing::warn!(
test = %sc.test_name,
metric = %k,
value = v,
"dropping non-finite ext_metric; serde_json rejects NaN/Infinity",
);
None
}
})
.collect(),
phases: sc.stats.phases.clone(),
}
}