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                    peak_memory: None,
228                    unblocked_units: Vec::new(),
229                    unblocked_rmeta_units: Vec::new(),
230                    sections: None,
231                };
232
233                units.insert(
234                    index,
235                    UnitEntry {
236                        target,
237                        data,
238                        sections: IndexMap::default(),
239                        rmeta_time: None,
240                        started: false,
241                    },
242                );
243            }
244            LogMessage::UnitFingerprint { status, .. } => match status {
245                FingerprintStatus::New => ctx.total_dirty += 1,
246                FingerprintStatus::Dirty => ctx.total_dirty += 1,
247                FingerprintStatus::Fresh => ctx.total_fresh += 1,
248            },
249            LogMessage::UnitStarted { index, elapsed } => {
250                units
251                    .entry(index)
252                    .and_modify(|unit| {
253                        unit.data.start = elapsed;
254                        unit.started = true;
255                    })
256                    .or_insert_with(|| {
257                        unreachable!("unit {index} must have been registered first")
258                    });
259            }
260            LogMessage::UnitRmetaFinished {
261                index,
262                elapsed,
263                unblocked,
264            } => match units.entry(index) {
265                Entry::Occupied(mut e) => {
266                    let elapsed = f64::max(elapsed - e.get().data.start, 0.0);
267                    e.get_mut().data.unblocked_rmeta_units = unblocked;
268                    e.get_mut().data.duration = elapsed;
269                    e.get_mut().rmeta_time = Some(elapsed);
270                }
271                Entry::Vacant(_) => {
272                    tracing::warn!(
273                        "section `frontend` ended, but unit {index} has no start recorded"
274                    )
275                }
276            },
277            LogMessage::UnitSectionStarted {
278                index,
279                elapsed,
280                section,
281            } => match units.entry(index) {
282                Entry::Occupied(mut e) => {
283                    let elapsed = f64::max(elapsed - e.get().data.start, 0.0);
284                    if e.get_mut()
285                        .sections
286                        .insert(
287                            section.clone(),
288                            CompilationSection {
289                                start: elapsed,
290                                end: None,
291                            },
292                        )
293                        .is_some()
294                    {
295                        tracing::warn!(
296                            "section `{section}` for unit {index} started more than once",
297                        );
298                    }
299                }
300                Entry::Vacant(_) => {
301                    tracing::warn!(
302                        "section `{section}` started, but unit {index} has no start recorded"
303                    )
304                }
305            },
306            LogMessage::UnitSectionFinished {
307                index,
308                elapsed,
309                section,
310            } => match units.entry(index) {
311                Entry::Occupied(mut e) => {
312                    let elapsed = f64::max(elapsed - e.get().data.start, 0.0);
313                    if let Some(section) = e.get_mut().sections.get_mut(&section) {
314                        section.end = Some(elapsed);
315                    } else {
316                        tracing::warn!(
317                            "section `{section}` for unit {index} ended, but section `{section}` has no start recorded"
318                        );
319                    }
320                }
321                Entry::Vacant(_) => {
322                    tracing::warn!(
323                        "section `{section}` ended, but unit {index} has no start recorded"
324                    )
325                }
326            },
327            LogMessage::UnitPeakMemory { index, bytes } => match units.entry(index) {
328                Entry::Occupied(mut e) => {
329                    e.get_mut().data.peak_memory = Some(bytes);
330                }
331                Entry::Vacant(_) => {
332                    tracing::warn!("peak memory reported, but unit {index} has no start recorded")
333                }
334            },
335            LogMessage::UnitFinished {
336                index,
337                elapsed,
338                unblocked,
339            } => match units.entry(index) {
340                Entry::Occupied(mut e) => {
341                    let elapsed = f64::max(elapsed - e.get().data.start, 0.0);
342                    e.get_mut().data.duration = elapsed;
343                    e.get_mut().data.unblocked_units = unblocked;
344                }
345                Entry::Vacant(_) => {
346                    tracing::warn!("unit {index} ended, but it has no start recorded");
347                }
348            },
349            _ => {} // skip non-timing logs
350        }
351    }
352
353    // A build can legitimately have no units at all, such as an idle
354    // `cargo test` with `test = false` and `doctest = false`,
355    // so `--timings` always renders its report.
356    // `cargo report timings` instead treats a unit-less
357    // log as corrupted or truncated and errors out.
358    // See https://github.com/rust-lang/cargo/issues/17212.
359    if error_if_no_units && units.is_empty() {
360        anyhow::bail!("no timing data found in log");
361    }
362
363    ctx.root_units = {
364        let mut root_map: IndexMap<_, Vec<_>> = IndexMap::default();
365        for index in requested_units {
366            let unit = &units[&index];
367            // Pretty much like `workspace::Target::description_named`
368            let target_desc = if unit.target.kind == "lib" {
369                "lib".to_owned()
370            } else if unit.target.kind == "build-script" {
371                "build script".to_owned()
372            } else {
373                format!(r#" {} "{}""#, unit.target.name, unit.target.kind)
374            };
375            root_map.entry(index).or_default().push(target_desc);
376        }
377        root_map
378            .into_iter()
379            .sorted_by_key(|(i, _)| *i)
380            .map(|(index, targets)| {
381                let unit = &units[&index];
382                let pkg_desc = format!("{} {}", unit.data.name, unit.data.version);
383                (pkg_desc, targets)
384            })
385            .collect()
386    };
387
388    let started: HashSet<UnitIndex> = units
389        .iter()
390        .filter(|(_, entry)| entry.started)
391        .map(|(index, _)| *index)
392        .collect();
393
394    // Units that never started, like fresh units, and
395    // units that were run outside the job queue are
396    // excluded so they don't show up as zero-duration rows.
397    // See https://github.com/rust-lang/cargo/issues/17212.
398    let unit_data: Vec<_> = units
399        .into_values()
400        .filter(|entry| entry.started)
401        .map(
402            |UnitEntry {
403                 target: _,
404                 mut data,
405                 sections,
406                 rmeta_time,
407                 started: _,
408             }| {
409                // A finished unit can unblock units that never started, like
410                // fresh units. Drop them so rows never reference units absent
411                // from the report.
412                data.unblocked_units.retain(|index| started.contains(index));
413                data.unblocked_rmeta_units
414                    .retain(|index| started.contains(index));
415
416                // Post-processing for compilation sections we've collected so far.
417                data.sections = aggregate_sections(sections, data.duration, rmeta_time);
418                data.start = round_to_centisecond(data.start);
419                data.duration = round_to_centisecond(data.duration);
420                data
421            },
422        )
423        .sorted_unstable_by(|a, b| a.start.partial_cmp(&b.start).unwrap())
424        .collect();
425
426    ctx.unit_data = unit_data;
427    ctx.concurrency = compute_concurrency(&ctx.unit_data);
428    ctx.requested_targets = platform_targets.into_iter().sorted_unstable().collect();
429
430    Ok(ctx)
431}