Skip to main content

cargo/ops/cargo_report/
timings.rs

1//! The `cargo report timings` command.
2
3use crate::util::data_structures::HashSet;
4use std::fs::File;
5use std::io::BufReader;
6use std::path::PathBuf;
7
8use crate::util::data_structures::IndexMap;
9use anyhow::Context as _;
10use cargo_util::paths;
11use cargo_util_terminal::report::Level;
12use indexmap::map::Entry;
13use itertools::Itertools as _;
14use tempfile::TempDir;
15
16use crate::AlreadyPrintedError;
17use crate::CargoResult;
18use crate::GlobalContext;
19use crate::compiler::UnitIndex;
20use crate::compiler::timings::CompilationSection;
21use crate::compiler::timings::UnitData;
22use crate::compiler::timings::report::RenderContext;
23use crate::compiler::timings::report::aggregate_sections;
24use crate::compiler::timings::report::compute_concurrency;
25use crate::compiler::timings::report::round_to_centisecond;
26use crate::compiler::timings::report::write_html;
27use crate::ops::cargo_report::util::find_log_file;
28use crate::ops::cargo_report::util::unit_target_description;
29use crate::util::log_message::FingerprintStatus;
30use crate::util::log_message::LogMessage;
31use crate::util::log_message::Target;
32use crate::util::logger::RunId;
33use crate::util::style;
34use crate::workspace::Workspace;
35
36pub struct ReportTimingsOptions<'gctx> {
37    /// Whether to attempt to open the browser after the report is generated
38    pub open_result: bool,
39    pub gctx: &'gctx GlobalContext,
40    pub id: Option<RunId>,
41}
42
43/// Collects sections data for later post-processing through [`aggregate_sections`].
44struct UnitEntry {
45    target: Target,
46    data: UnitData,
47    sections: IndexMap<String, CompilationSection>,
48    rmeta_time: Option<f64>,
49    /// Whether the job queue actually ran this unit.
50    started: bool,
51}
52
53pub fn report_timings(
54    gctx: &GlobalContext,
55    ws: Option<&Workspace<'_>>,
56    opts: ReportTimingsOptions<'_>,
57) -> CargoResult<()> {
58    let Some((log, run_id)) = find_log_file(gctx, ws, opts.id.as_ref())? else {
59        let context = if let Some(ws) = ws {
60            format!(" for workspace at `{}`", ws.root().display())
61        } else {
62            String::new()
63        };
64        let (title, note) = if let Some(id) = &opts.id {
65            (
66                format!("session `{id}` not found{context}"),
67                "run `cargo report sessions` to list available sessions",
68            )
69        } else {
70            (
71                format!("no sessions found{context}"),
72                "run command with `-Z build-analysis` to generate log files",
73            )
74        };
75        let report = [Level::ERROR
76            .primary_title(title)
77            .element(Level::NOTE.message(note))];
78        gctx.shell().print_report(&report, false)?;
79        return Err(AlreadyPrintedError::new(anyhow::anyhow!("")).into());
80    };
81
82    let reader = BufReader::new(File::open(&log)?);
83    let iter = serde_json::Deserializer::from_reader(reader)
84        .into_iter::<LogMessage>()
85        .enumerate()
86        .filter_map(|(idx, msg)| match msg {
87            Ok(msg) => Some(msg),
88            Err(e) => {
89                tracing::warn!("failed to parse log message at index {idx}: {e}");
90                None
91            }
92        });
93    let ctx = prepare_context(iter, &run_id, true)
94        .with_context(|| format!("failed to analyze log at `{}`", log.display()))?;
95
96    // If we are in a workspace,
97    // put timing reports in <target-dir>/cargo-timings/` as usual for easy access.
98    // Otherwise in a temporary directory
99    let reports_dir = if let Some(ws) = ws {
100        let target_dir = ws.target_dir();
101        let target_dir = target_dir.as_path_unlocked();
102        paths::create_dir_all_excluded_from_backups_atomic(target_dir)?;
103        let timings_dir = target_dir.join("cargo-timings");
104        paths::create_dir_all(&timings_dir)?;
105        timings_dir
106    } else if let Ok(path) = gctx.get_env("__CARGO_TEST_REPORT_TIMINGS_TEMPDIR") {
107        PathBuf::from(path.to_owned())
108    } else {
109        TempDir::with_prefix("cargo-timings-")?.keep()
110    };
111
112    let timing_path = reports_dir.join(format!("cargo-timing-{run_id}.html"));
113
114    let mut out_file = std::fs::OpenOptions::new()
115        .write(true)
116        .create(true)
117        .truncate(true)
118        .open(&timing_path)
119        .with_context(|| format!("failed to open `{}`", timing_path.display()))?;
120
121    write_html(ctx, &mut out_file)?;
122
123    let link = gctx.shell().err_file_hyperlink(&timing_path);
124    let msg = format!("report saved to {link}{}{link:#}", timing_path.display());
125    gctx.shell()
126        .status_with_color("Timing", msg, &style::NOTE)?;
127
128    if opts.open_result {
129        crate::util::open::open(&timing_path, gctx)?;
130    }
131
132    Ok(())
133}
134
135pub(crate) fn prepare_context<I>(
136    log: I,
137    run_id: &RunId,
138    error_if_no_units: bool,
139) -> CargoResult<RenderContext<'_>>
140where
141    I: Iterator<Item = LogMessage>,
142{
143    let mut ctx = RenderContext {
144        start_str: run_id.timestamp().to_string(),
145        root_units: Default::default(),
146        profile: Default::default(),
147        total_fresh: Default::default(),
148        total_dirty: Default::default(),
149        unit_data: Default::default(),
150        concurrency: Default::default(),
151        cpu_usage: Default::default(),
152        rustc_version: Default::default(),
153        host: Default::default(),
154        requested_targets: Default::default(),
155        jobs: 0,
156        num_cpus: None,
157        error: &None,
158    };
159    let mut units: IndexMap<_, UnitEntry> = IndexMap::default();
160
161    let mut platform_targets = HashSet::default();
162
163    let mut requested_units: HashSet<UnitIndex> = HashSet::default();
164
165    for msg in log {
166        match msg {
167            LogMessage::BuildStarted {
168                command: _,
169                cwd: _,
170                host,
171                jobs,
172                num_cpus,
173                profile,
174                rustc_version,
175                rustc_version_verbose,
176                target_dir: _,
177                workspace_root: _,
178            } => {
179                let rustc_version = rustc_version_verbose
180                    .lines()
181                    .next()
182                    .map(ToOwned::to_owned)
183                    .unwrap_or(rustc_version);
184                ctx.host = host;
185                ctx.jobs = jobs;
186                ctx.num_cpus = num_cpus;
187                ctx.profile = profile;
188                ctx.rustc_version = rustc_version;
189            }
190            LogMessage::UnitRegistered {
191                package_id,
192                target,
193                mode,
194                platform,
195                index,
196                features,
197                requested,
198                dependencies: _,
199            } => {
200                if requested {
201                    requested_units.insert(index);
202                }
203                platform_targets.insert(platform);
204
205                let version = package_id
206                    .version()
207                    .map(|v| v.to_string())
208                    .unwrap_or_else(|| "N/A".into());
209
210                let target_str = unit_target_description(&target, mode);
211
212                let mode_str = if mode.is_run_custom_build() {
213                    "run-custom-build"
214                } else {
215                    "todo"
216                };
217
218                let data = UnitData {
219                    i: index,
220                    name: package_id.name().to_string(),
221                    version,
222                    mode: mode_str.to_owned(),
223                    target: target_str,
224                    features,
225                    start: 0.0,
226                    duration: 0.0,
227                    unblocked_units: Vec::new(),
228                    unblocked_rmeta_units: Vec::new(),
229                    sections: None,
230                };
231
232                units.insert(
233                    index,
234                    UnitEntry {
235                        target,
236                        data,
237                        sections: IndexMap::default(),
238                        rmeta_time: None,
239                        started: false,
240                    },
241                );
242            }
243            LogMessage::UnitFingerprint { status, .. } => match status {
244                FingerprintStatus::New => ctx.total_dirty += 1,
245                FingerprintStatus::Dirty => ctx.total_dirty += 1,
246                FingerprintStatus::Fresh => ctx.total_fresh += 1,
247            },
248            LogMessage::UnitStarted { index, elapsed } => {
249                units
250                    .entry(index)
251                    .and_modify(|unit| {
252                        unit.data.start = elapsed;
253                        unit.started = true;
254                    })
255                    .or_insert_with(|| {
256                        unreachable!("unit {index} must have been registered first")
257                    });
258            }
259            LogMessage::UnitRmetaFinished {
260                index,
261                elapsed,
262                unblocked,
263            } => match units.entry(index) {
264                Entry::Occupied(mut e) => {
265                    let elapsed = f64::max(elapsed - e.get().data.start, 0.0);
266                    e.get_mut().data.unblocked_rmeta_units = unblocked;
267                    e.get_mut().data.duration = elapsed;
268                    e.get_mut().rmeta_time = Some(elapsed);
269                }
270                Entry::Vacant(_) => {
271                    tracing::warn!(
272                        "section `frontend` ended, but unit {index} has no start recorded"
273                    )
274                }
275            },
276            LogMessage::UnitSectionStarted {
277                index,
278                elapsed,
279                section,
280            } => match units.entry(index) {
281                Entry::Occupied(mut e) => {
282                    let elapsed = f64::max(elapsed - e.get().data.start, 0.0);
283                    if e.get_mut()
284                        .sections
285                        .insert(
286                            section.clone(),
287                            CompilationSection {
288                                start: elapsed,
289                                end: None,
290                            },
291                        )
292                        .is_some()
293                    {
294                        tracing::warn!(
295                            "section `{section}` for unit {index} started more than once",
296                        );
297                    }
298                }
299                Entry::Vacant(_) => {
300                    tracing::warn!(
301                        "section `{section}` started, but unit {index} has no start recorded"
302                    )
303                }
304            },
305            LogMessage::UnitSectionFinished {
306                index,
307                elapsed,
308                section,
309            } => match units.entry(index) {
310                Entry::Occupied(mut e) => {
311                    let elapsed = f64::max(elapsed - e.get().data.start, 0.0);
312                    if let Some(section) = e.get_mut().sections.get_mut(&section) {
313                        section.end = Some(elapsed);
314                    } else {
315                        tracing::warn!(
316                            "section `{section}` for unit {index} ended, but section `{section}` has no start recorded"
317                        );
318                    }
319                }
320                Entry::Vacant(_) => {
321                    tracing::warn!(
322                        "section `{section}` ended, but unit {index} has no start recorded"
323                    )
324                }
325            },
326            LogMessage::UnitFinished {
327                index,
328                elapsed,
329                unblocked,
330            } => match units.entry(index) {
331                Entry::Occupied(mut e) => {
332                    let elapsed = f64::max(elapsed - e.get().data.start, 0.0);
333                    e.get_mut().data.duration = elapsed;
334                    e.get_mut().data.unblocked_units = unblocked;
335                }
336                Entry::Vacant(_) => {
337                    tracing::warn!("unit {index} ended, but it has no start recorded");
338                }
339            },
340            _ => {} // skip non-timing logs
341        }
342    }
343
344    // A build can legitimately have no units at all, such as an idle
345    // `cargo test` with `test = false` and `doctest = false`,
346    // so `--timings` always renders its report.
347    // `cargo report timings` instead treats a unit-less
348    // log as corrupted or truncated and errors out.
349    // See https://github.com/rust-lang/cargo/issues/17212.
350    if error_if_no_units && units.is_empty() {
351        anyhow::bail!("no timing data found in log");
352    }
353
354    ctx.root_units = {
355        let mut root_map: IndexMap<_, Vec<_>> = IndexMap::default();
356        for index in requested_units {
357            let unit = &units[&index];
358            // Pretty much like `workspace::Target::description_named`
359            let target_desc = if unit.target.kind == "lib" {
360                "lib".to_owned()
361            } else if unit.target.kind == "build-script" {
362                "build script".to_owned()
363            } else {
364                format!(r#" {} "{}""#, unit.target.name, unit.target.kind)
365            };
366            root_map.entry(index).or_default().push(target_desc);
367        }
368        root_map
369            .into_iter()
370            .sorted_by_key(|(i, _)| *i)
371            .map(|(index, targets)| {
372                let unit = &units[&index];
373                let pkg_desc = format!("{} {}", unit.data.name, unit.data.version);
374                (pkg_desc, targets)
375            })
376            .collect()
377    };
378
379    let started: HashSet<UnitIndex> = units
380        .iter()
381        .filter(|(_, entry)| entry.started)
382        .map(|(index, _)| *index)
383        .collect();
384
385    // Units that never started, like fresh units, and
386    // units that were run outside the job queue are
387    // excluded so they don't show up as zero-duration rows.
388    // See https://github.com/rust-lang/cargo/issues/17212.
389    let unit_data: Vec<_> = units
390        .into_values()
391        .filter(|entry| entry.started)
392        .map(
393            |UnitEntry {
394                 target: _,
395                 mut data,
396                 sections,
397                 rmeta_time,
398                 started: _,
399             }| {
400                // A finished unit can unblock units that never started, like
401                // fresh units. Drop them so rows never reference units absent
402                // from the report.
403                data.unblocked_units.retain(|index| started.contains(index));
404                data.unblocked_rmeta_units
405                    .retain(|index| started.contains(index));
406
407                // Post-processing for compilation sections we've collected so far.
408                data.sections = aggregate_sections(sections, data.duration, rmeta_time);
409                data.start = round_to_centisecond(data.start);
410                data.duration = round_to_centisecond(data.duration);
411                data
412            },
413        )
414        .sorted_unstable_by(|a, b| a.start.partial_cmp(&b.start).unwrap())
415        .collect();
416
417    ctx.unit_data = unit_data;
418    ctx.concurrency = compute_concurrency(&ctx.unit_data);
419    ctx.requested_targets = platform_targets.into_iter().sorted_unstable().collect();
420
421    Ok(ctx)
422}