Skip to content

Commit 2ae5d34

Browse files
authored
Rollup merge of #138930 - Kobzol:analyze-bootstrap-diffs, r=marcoieni
Add bootstrap step diff to CI job analysis This PR adds another analysis to the job analysis report in GitHub summary. It compares (diffs) bootstrap steps executed by the parent run and by the current commit. This will help us figure out if the bootstrap invocation did something different than before, and also how did the duration of individual steps and bootstrap invocations change. Can be tested on the #119899 PR like this: ```bash $ curl https://ci-artifacts.rust-lang.org/rustc-builds/3d3394eb64ee2f99ad1a2b849b376220fd38263e/metrics-mingw-check.json > metrics.json $ cargo run --manifest-path src/ci/citool/Cargo.toml postprocess-metrics metrics.json --job-name mingw-check --parent 961351c > out.md ``` r? `@marcoie`
2 parents 30344f7 + 813783e commit 2ae5d34

File tree

5 files changed

+178
-51
lines changed

5 files changed

+178
-51
lines changed

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

+38-23
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> {
@@ -156,33 +166,38 @@ impl BuildStep {
156166
child.find_by_type(r#type, result);
157167
}
158168
}
159-
}
160169

161-
/// Writes build steps into a nice indented table.
162-
pub fn format_build_steps(root: &BuildStep) -> String {
163-
use std::fmt::Write;
164-
165-
let mut substeps: Vec<(u32, &BuildStep)> = Vec::new();
170+
/// Returns a Vec with all substeps, ordered by their hierarchical order.
171+
/// The first element of the tuple is the depth of a given step.
172+
pub fn linearize_steps(&self) -> Vec<(u32, &BuildStep)> {
173+
let mut substeps: Vec<(u32, &BuildStep)> = Vec::new();
166174

167-
fn visit<'a>(step: &'a BuildStep, level: u32, substeps: &mut Vec<(u32, &'a BuildStep)>) {
168-
substeps.push((level, step));
169-
for child in &step.children {
170-
visit(child, level + 1, substeps);
175+
fn visit<'a>(step: &'a BuildStep, level: u32, substeps: &mut Vec<(u32, &'a BuildStep)>) {
176+
substeps.push((level, step));
177+
for child in &step.children {
178+
visit(child, level + 1, substeps);
179+
}
171180
}
181+
182+
visit(self, 0, &mut substeps);
183+
substeps
172184
}
185+
}
173186

174-
visit(root, 0, &mut substeps);
187+
/// Writes build steps into a nice indented table.
188+
pub fn format_build_steps(root: &BuildStep) -> String {
189+
use std::fmt::Write;
175190

176191
let mut output = String::new();
177-
for (level, step) in substeps {
178-
let label = format!(
179-
"{}{}",
180-
".".repeat(level as usize),
181-
// Bootstrap steps can be generic and thus contain angle brackets (<...>).
182-
// However, Markdown interprets these as HTML, so we need to escap ethem.
183-
step.r#type.replace('<', "&lt;").replace('>', "&gt;")
184-
);
192+
for (level, step) in root.linearize_steps() {
193+
let label = format!("{}{}", ".".repeat(level as usize), escape_step_name(step));
185194
writeln!(output, "{label:.<65}{:>8.2}s", step.duration.as_secs_f64()).unwrap();
186195
}
187196
output
188197
}
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)