Skip to content

Commit 813783e

Browse files
committed
Add diff of bootstrap steps
1 parent 6df2d58 commit 813783e

File tree

5 files changed

+162
-40
lines changed

5 files changed

+162
-40
lines changed

Diff for: src/build_helper/src/metrics.rs

+22-12
Original file line numberDiff line numberDiff line change
@@ -109,26 +109,31 @@ pub struct BuildStep {
109109
pub r#type: String,
110110
pub children: Vec<BuildStep>,
111111
pub duration: Duration,
112+
// Full name of the step, including all parent names
113+
pub full_name: String,
112114
}
113115

114116
impl BuildStep {
115117
/// Create a `BuildStep` representing a single invocation of bootstrap.
116118
/// The most important thing is that the build step aggregates the
117119
/// durations of all children, so that it can be easily accessed.
118120
pub fn from_invocation(invocation: &JsonInvocation) -> Self {
119-
fn parse(node: &JsonNode) -> Option<BuildStep> {
121+
fn parse(node: &JsonNode, parent_name: &str) -> Option<BuildStep> {
120122
match node {
121123
JsonNode::RustbuildStep {
122124
type_: kind,
123125
children,
124126
duration_excluding_children_sec,
125127
..
126128
} => {
127-
let children: Vec<_> = children.into_iter().filter_map(parse).collect();
129+
let full_name = format!("{parent_name}-{kind}");
130+
let children: Vec<_> =
131+
children.into_iter().filter_map(|s| parse(s, &full_name)).collect();
128132
let children_duration = children.iter().map(|c| c.duration).sum::<Duration>();
129133
Some(BuildStep {
130134
r#type: kind.to_string(),
131135
children,
136+
full_name,
132137
duration: children_duration
133138
+ Duration::from_secs_f64(*duration_excluding_children_sec),
134139
})
@@ -138,8 +143,13 @@ impl BuildStep {
138143
}
139144

140145
let duration = Duration::from_secs_f64(invocation.duration_including_children_sec);
141-
let children: Vec<_> = invocation.children.iter().filter_map(parse).collect();
142-
Self { r#type: "total".to_string(), children, duration }
146+
147+
// The root "total" step is kind of a virtual step that encompasses all other steps,
148+
// but it is not a real parent of the other steps.
149+
// We thus start the parent name hierarchy here and use an empty string
150+
// as the parent name of the top-level steps.
151+
let children: Vec<_> = invocation.children.iter().filter_map(|s| parse(s, "")).collect();
152+
Self { r#type: "total".to_string(), children, duration, full_name: "total".to_string() }
143153
}
144154

145155
pub fn find_all_by_type(&self, r#type: &str) -> Vec<&Self> {
@@ -159,7 +169,7 @@ impl BuildStep {
159169

160170
/// Returns a Vec with all substeps, ordered by their hierarchical order.
161171
/// The first element of the tuple is the depth of a given step.
162-
fn linearize_steps(&self) -> Vec<(u32, &BuildStep)> {
172+
pub fn linearize_steps(&self) -> Vec<(u32, &BuildStep)> {
163173
let mut substeps: Vec<(u32, &BuildStep)> = Vec::new();
164174

165175
fn visit<'a>(step: &'a BuildStep, level: u32, substeps: &mut Vec<(u32, &'a BuildStep)>) {
@@ -180,14 +190,14 @@ pub fn format_build_steps(root: &BuildStep) -> String {
180190

181191
let mut output = String::new();
182192
for (level, step) in root.linearize_steps() {
183-
let label = format!(
184-
"{}{}",
185-
".".repeat(level as usize),
186-
// Bootstrap steps can be generic and thus contain angle brackets (<...>).
187-
// However, Markdown interprets these as HTML, so we need to escap ethem.
188-
step.r#type.replace('<', "&lt;").replace('>', "&gt;")
189-
);
193+
let label = format!("{}{}", ".".repeat(level as usize), escape_step_name(step));
190194
writeln!(output, "{label:.<65}{:>8.2}s", step.duration.as_secs_f64()).unwrap();
191195
}
192196
output
193197
}
198+
199+
/// Bootstrap steps can be generic and thus contain angle brackets (<...>).
200+
/// However, Markdown interprets these as HTML, so we need to escap ethem.
201+
pub fn escape_step_name(step: &BuildStep) -> String {
202+
step.r#type.replace('<', "&lt;").replace('>', "&gt;")
203+
}

Diff for: src/ci/citool/Cargo.lock

+7
Original file line numberDiff line numberDiff line change
@@ -107,6 +107,7 @@ dependencies = [
107107
"build_helper",
108108
"clap",
109109
"csv",
110+
"diff",
110111
"glob-match",
111112
"insta",
112113
"serde",
@@ -241,6 +242,12 @@ dependencies = [
241242
"powerfmt",
242243
]
243244

245+
[[package]]
246+
name = "diff"
247+
version = "0.1.13"
248+
source = "registry+https://github.com/rust-lang/crates.io-index"
249+
checksum = "56254986775e3233ffa9c4d7d3faaf6d36a2c09d30b20687e9f88bc8bafc16c8"
250+
244251
[[package]]
245252
name = "displaydoc"
246253
version = "0.2.5"

Diff for: src/ci/citool/Cargo.toml

+1
Original file line numberDiff line numberDiff line change
@@ -7,6 +7,7 @@ edition = "2021"
77
anyhow = "1"
88
clap = { version = "4.5", features = ["derive"] }
99
csv = "1"
10+
diff = "0.1"
1011
glob-match = "0.2"
1112
serde = { version = "1", features = ["derive"] }
1213
serde_yaml = "0.9"

Diff for: src/ci/citool/src/analysis.rs

+107-7
Original file line numberDiff line numberDiff line change
@@ -1,33 +1,134 @@
11
use std::collections::{BTreeMap, HashMap, HashSet};
2+
use std::fmt::Debug;
23

34
use build_helper::metrics::{
4-
BuildStep, JsonRoot, TestOutcome, TestSuite, TestSuiteMetadata, format_build_steps,
5+
BuildStep, JsonRoot, TestOutcome, TestSuite, TestSuiteMetadata, escape_step_name,
6+
format_build_steps,
57
};
68

79
use crate::metrics;
810
use crate::metrics::{JobMetrics, JobName, get_test_suites};
911
use crate::utils::{output_details, pluralize};
1012

11-
pub fn output_bootstrap_stats(metrics: &JsonRoot) {
13+
/// Outputs durations of individual bootstrap steps from the gathered bootstrap invocations,
14+
/// and also a table with summarized information about executed tests.
15+
pub fn output_bootstrap_stats(metrics: &JsonRoot, parent_metrics: Option<&JsonRoot>) {
1216
if !metrics.invocations.is_empty() {
1317
println!("# Bootstrap steps");
14-
record_bootstrap_step_durations(&metrics);
18+
record_bootstrap_step_durations(&metrics, parent_metrics);
1519
record_test_suites(&metrics);
1620
}
1721
}
1822

19-
fn record_bootstrap_step_durations(metrics: &JsonRoot) {
23+
fn record_bootstrap_step_durations(metrics: &JsonRoot, parent_metrics: Option<&JsonRoot>) {
24+
let parent_steps: HashMap<String, BuildStep> = parent_metrics
25+
.map(|metrics| {
26+
metrics
27+
.invocations
28+
.iter()
29+
.map(|invocation| {
30+
(invocation.cmdline.clone(), BuildStep::from_invocation(invocation))
31+
})
32+
.collect()
33+
})
34+
.unwrap_or_default();
35+
2036
for invocation in &metrics.invocations {
2137
let step = BuildStep::from_invocation(invocation);
2238
let table = format_build_steps(&step);
2339
eprintln!("Step `{}`\n{table}\n", invocation.cmdline);
24-
output_details(&invocation.cmdline, || {
40+
output_details(&format!("{} (steps)", invocation.cmdline), || {
2541
println!("<pre><code>{table}</code></pre>");
2642
});
43+
44+
// If there was a parent bootstrap invocation with the same cmdline, diff it
45+
if let Some(parent_step) = parent_steps.get(&invocation.cmdline) {
46+
let table = format_build_step_diffs(&step, parent_step);
47+
48+
let duration_before = parent_step.duration.as_secs();
49+
let duration_after = step.duration.as_secs();
50+
output_details(
51+
&format!("{} (diff) ({duration_before}s -> {duration_after}s)", invocation.cmdline),
52+
|| {
53+
println!("{table}");
54+
},
55+
);
56+
}
2757
}
2858
eprintln!("Recorded {} bootstrap invocation(s)", metrics.invocations.len());
2959
}
3060

61+
/// Creates a table that displays a diff between the durations of steps between
62+
/// two bootstrap invocations.
63+
/// It also shows steps that were missing before/after.
64+
fn format_build_step_diffs(current: &BuildStep, parent: &BuildStep) -> String {
65+
use std::fmt::Write;
66+
67+
// Helper struct that compares steps by their full name
68+
struct StepByName<'a>((u32, &'a BuildStep));
69+
70+
impl<'a> PartialEq for StepByName<'a> {
71+
fn eq(&self, other: &Self) -> bool {
72+
self.0.1.full_name.eq(&other.0.1.full_name)
73+
}
74+
}
75+
76+
fn get_steps(step: &BuildStep) -> Vec<StepByName> {
77+
step.linearize_steps().into_iter().map(|v| StepByName(v)).collect()
78+
}
79+
80+
let steps_before = get_steps(parent);
81+
let steps_after = get_steps(current);
82+
83+
let mut table = "| Step | Before | After | Change |\n".to_string();
84+
writeln!(table, "|:-----|-------:|------:|-------:|").unwrap();
85+
86+
// Try to match removed, added and same steps using a classic diff algorithm
87+
for result in diff::slice(&steps_before, &steps_after) {
88+
let (step, before, after, change) = match result {
89+
// The step was found both before and after
90+
diff::Result::Both(before, after) => {
91+
let duration_before = before.0.1.duration;
92+
let duration_after = after.0.1.duration;
93+
let pct_change = duration_after.as_secs_f64() / duration_before.as_secs_f64();
94+
let pct_change = pct_change * 100.0;
95+
// Normalize around 100, to get + for regression and - for improvements
96+
let pct_change = pct_change - 100.0;
97+
(
98+
before,
99+
format!("{:.2}s", duration_before.as_secs_f64()),
100+
format!("{:.2}s", duration_after.as_secs_f64()),
101+
format!("{pct_change:.1}%"),
102+
)
103+
}
104+
// The step was only found in the parent, so it was likely removed
105+
diff::Result::Left(removed) => (
106+
removed,
107+
format!("{:.2}s", removed.0.1.duration.as_secs_f64()),
108+
"".to_string(),
109+
"(removed)".to_string(),
110+
),
111+
// The step was only found in the current commit, so it was likely added
112+
diff::Result::Right(added) => (
113+
added,
114+
"".to_string(),
115+
format!("{:.2}s", added.0.1.duration.as_secs_f64()),
116+
"(added)".to_string(),
117+
),
118+
};
119+
120+
let prefix = ".".repeat(step.0.0 as usize);
121+
writeln!(
122+
table,
123+
"| {prefix}{} | {before} | {after} | {change} |",
124+
escape_step_name(step.0.1),
125+
)
126+
.unwrap();
127+
}
128+
129+
table
130+
}
131+
31132
fn record_test_suites(metrics: &JsonRoot) {
32133
let suites = metrics::get_test_suites(&metrics);
33134

@@ -82,8 +183,7 @@ fn render_table(suites: BTreeMap<String, TestSuiteRecord>) -> String {
82183
table
83184
}
84185

85-
/// Computes a post merge CI analysis report of test differences
86-
/// between the `parent` and `current` commits.
186+
/// Outputs a report of test differences between the `parent` and `current` commits.
87187
pub fn output_test_diffs(job_metrics: HashMap<JobName, JobMetrics>) {
88188
let aggregated_test_diffs = aggregate_test_diffs(&job_metrics);
89189
report_test_diffs(aggregated_test_diffs);

Diff for: src/ci/citool/src/main.rs

+25-21
Original file line numberDiff line numberDiff line change
@@ -144,31 +144,35 @@ fn postprocess_metrics(
144144
job_name: Option<String>,
145145
) -> anyhow::Result<()> {
146146
let metrics = load_metrics(&metrics_path)?;
147-
output_bootstrap_stats(&metrics);
148147

149-
let (Some(parent), Some(job_name)) = (parent, job_name) else {
150-
return Ok(());
151-
};
152-
153-
// This command is executed also on PR builds, which might not have parent metrics
154-
// available, because some PR jobs don't run on auto builds, and PR jobs do not upload metrics
155-
// due to missing permissions.
156-
// To avoid having to detect if this is a PR job, and to avoid having failed steps in PR jobs,
157-
// we simply print an error if the parent metrics were not found, but otherwise exit
158-
// successfully.
159-
match download_job_metrics(&job_name, &parent).context("cannot download parent metrics") {
160-
Ok(parent_metrics) => {
161-
let job_metrics = HashMap::from([(
162-
job_name,
163-
JobMetrics { parent: Some(parent_metrics), current: metrics },
164-
)]);
165-
output_test_diffs(job_metrics);
166-
}
167-
Err(error) => {
168-
eprintln!("Metrics for job `{job_name}` and commit `{parent}` not found: {error:?}");
148+
if let (Some(parent), Some(job_name)) = (parent, job_name) {
149+
// This command is executed also on PR builds, which might not have parent metrics
150+
// available, because some PR jobs don't run on auto builds, and PR jobs do not upload metrics
151+
// due to missing permissions.
152+
// To avoid having to detect if this is a PR job, and to avoid having failed steps in PR jobs,
153+
// we simply print an error if the parent metrics were not found, but otherwise exit
154+
// successfully.
155+
match download_job_metrics(&job_name, &parent).context("cannot download parent metrics") {
156+
Ok(parent_metrics) => {
157+
output_bootstrap_stats(&metrics, Some(&parent_metrics));
158+
159+
let job_metrics = HashMap::from([(
160+
job_name,
161+
JobMetrics { parent: Some(parent_metrics), current: metrics },
162+
)]);
163+
output_test_diffs(job_metrics);
164+
return Ok(());
165+
}
166+
Err(error) => {
167+
eprintln!(
168+
"Metrics for job `{job_name}` and commit `{parent}` not found: {error:?}"
169+
);
170+
}
169171
}
170172
}
171173

174+
output_bootstrap_stats(&metrics, None);
175+
172176
Ok(())
173177
}
174178

0 commit comments

Comments
 (0)