Skip to main content

cargo/core/compiler/timings/
report.rs

1//! Render HTML report from timing tracking data.
2
3use std::borrow::Cow;
4use std::collections::HashMap;
5use std::collections::HashSet;
6use std::io::Write;
7
8use indexmap::IndexMap;
9use itertools::Itertools as _;
10
11use crate::CargoResult;
12use crate::core::compiler::UnitIndex;
13
14use super::CompilationSection;
15use super::UnitData;
16
17/// Name of an individual compilation section.
18#[derive(Clone, Hash, Eq, PartialEq)]
19pub enum SectionName {
20    Frontend,
21    Codegen,
22    Named(String),
23    Other,
24}
25
26impl SectionName {
27    /// Lower case name.
28    fn name(&self) -> Cow<'static, str> {
29        match self {
30            SectionName::Frontend => "frontend".into(),
31            SectionName::Codegen => "codegen".into(),
32            SectionName::Named(n) => n.to_lowercase().into(),
33            SectionName::Other => "other".into(),
34        }
35    }
36
37    fn capitalized_name(&self) -> String {
38        // Make the first "letter" uppercase. We could probably just assume ASCII here, but this
39        // should be Unicode compatible.
40        fn capitalize(s: &str) -> String {
41            let first_char = s
42                .chars()
43                .next()
44                .map(|c| c.to_uppercase().to_string())
45                .unwrap_or_default();
46            format!("{first_char}{}", s.chars().skip(1).collect::<String>())
47        }
48        capitalize(&self.name())
49    }
50}
51
52impl serde::ser::Serialize for SectionName {
53    fn serialize<S>(&self, serializer: S) -> Result<S::Ok, S::Error>
54    where
55        S: serde::Serializer,
56    {
57        self.name().serialize(serializer)
58    }
59}
60
61/// Postprocessed section data that has both start and an end.
62#[derive(Copy, Clone, serde::Serialize)]
63pub struct SectionData {
64    /// Start (relative to the start of the unit)
65    pub start: f64,
66    /// End (relative to the start of the unit)
67    pub end: f64,
68}
69
70impl SectionData {
71    fn duration(&self) -> f64 {
72        (self.end - self.start).max(0.0)
73    }
74}
75
76/// Concurrency tracking information.
77#[derive(serde::Serialize)]
78pub struct Concurrency {
79    /// Time as an offset in seconds from `Timings::start`.
80    t: f64,
81    /// Number of units currently running.
82    active: usize,
83    /// Number of units that could run, but are waiting for a jobserver token.
84    waiting: usize,
85    /// Number of units that are not yet ready, because they are waiting for
86    /// dependencies to finish.
87    inactive: usize,
88}
89
90pub struct RenderContext<'a> {
91    /// A rendered string of when compilation started.
92    pub start_str: String,
93    /// A summary of the root units.
94    ///
95    /// Tuples of `(package_description, target_descriptions)`.
96    pub root_units: Vec<(String, Vec<String>)>,
97    /// The build profile.
98    pub profile: String,
99    /// Total number of fresh units.
100    pub total_fresh: u32,
101    /// Total number of dirty units.
102    pub total_dirty: u32,
103    /// Time tracking for each individual unit.
104    pub unit_data: Vec<UnitData>,
105    /// Concurrency-tracking information. This is periodically updated while
106    /// compilation progresses.
107    pub concurrency: Vec<Concurrency>,
108    /// Recorded CPU states, stored as tuples. First element is when the
109    /// recording was taken and second element is percentage usage of the
110    /// system.
111    pub cpu_usage: &'a [(f64, f64)],
112    /// Compiler version info, i.e., `rustc 1.92.0-beta.2 (0a411606e 2025-10-31)`.
113    pub rustc_version: String,
114    /// The host triple (arch-platform-OS).
115    pub host: String,
116    /// The requested target platforms of compilation for this build.
117    pub requested_targets: Vec<String>,
118    /// The number of jobs specified for this build.
119    pub jobs: u32,
120    /// Available parallelism of the compilation environment.
121    pub num_cpus: Option<u64>,
122    /// Fatal error during the build.
123    pub error: &'a Option<anyhow::Error>,
124}
125
126/// Writes an HTML report.
127pub fn write_html(ctx: RenderContext<'_>, f: &mut impl Write) -> CargoResult<()> {
128    // The last concurrency record should equal to the last unit finished time.
129    let duration = ctx.concurrency.last().map(|c| c.t).unwrap_or(0.0);
130    let roots: Vec<&str> = ctx
131        .root_units
132        .iter()
133        .map(|(name, _targets)| name.as_str())
134        .collect();
135    f.write_all(HTML_TMPL.replace("{ROOTS}", &roots.join(", ")).as_bytes())?;
136    write_summary_table(&ctx, f, duration)?;
137    f.write_all(HTML_CANVAS.as_bytes())?;
138    write_unit_table(&ctx, f)?;
139    // It helps with pixel alignment to use whole numbers.
140    writeln!(
141        f,
142        "<script>\n\
143         DURATION = {};",
144        f64::ceil(duration) as u32
145    )?;
146    write_js_data(&ctx, f)?;
147    write!(
148        f,
149        "{}\n\
150         </script>\n\
151         </body>\n\
152         </html>\n\
153         ",
154        include_str!("timings.js")
155    )?;
156
157    Ok(())
158}
159
160/// Render the summary table.
161fn write_summary_table(
162    ctx: &RenderContext<'_>,
163    f: &mut impl Write,
164    duration: f64,
165) -> CargoResult<()> {
166    let targets = ctx
167        .root_units
168        .iter()
169        .map(|(name, targets)| format!("{} ({})", name, targets.join(", ")))
170        .collect::<Vec<_>>()
171        .join("<br>");
172
173    let total_units = ctx.total_fresh + ctx.total_dirty;
174
175    let time_human = if duration > 60.0 {
176        format!(" ({}m {:.1}s)", duration as u32 / 60, duration % 60.0)
177    } else {
178        "".to_string()
179    };
180    let total_time = format!("{:.1}s{}", duration, time_human);
181
182    let max_concurrency = ctx.concurrency.iter().map(|c| c.active).max().unwrap_or(0);
183    let num_cpus = ctx
184        .num_cpus
185        .map(|x| x.to_string())
186        .unwrap_or_else(|| "n/a".into());
187
188    let requested_targets = ctx.requested_targets.join(", ");
189
190    let error_msg = match ctx.error {
191        Some(e) => format!(r#"<tr><td class="error-text">Error:</td><td>{e}</td></tr>"#),
192        None => "".to_string(),
193    };
194
195    let RenderContext {
196        start_str,
197        profile,
198        total_fresh,
199        total_dirty,
200        rustc_version,
201        host,
202        jobs,
203        ..
204    } = &ctx;
205
206    write!(
207        f,
208        r#"
209<table class="my-table summary-table">
210<tr>
211<td>Targets:</td><td>{targets}</td>
212</tr>
213<tr>
214<td>Profile:</td><td>{profile}</td>
215</tr>
216<tr>
217<td>Fresh units:</td><td>{total_fresh}</td>
218</tr>
219<tr>
220<td>Dirty units:</td><td>{total_dirty}</td>
221</tr>
222<tr>
223<td>Total units:</td><td>{total_units}</td>
224</tr>
225<tr>
226<td>Max concurrency:</td><td>{max_concurrency} (jobs={jobs} ncpu={num_cpus})</td>
227</tr>
228<tr>
229<td>Build start:</td><td>{start_str}</td>
230</tr>
231<tr>
232<td>Total time:</td><td>{total_time}</td>
233</tr>
234<tr>
235<td>rustc:</td><td>{rustc_version}<br>Host: {host}<br>Target: {requested_targets}</td>
236</tr>
237{error_msg}
238</table>
239"#,
240    )?;
241    Ok(())
242}
243
244/// Write timing data in JavaScript. Primarily for `timings.js` to put data
245/// in a `<script>` HTML element to draw graphs.
246fn write_js_data(ctx: &RenderContext<'_>, f: &mut impl Write) -> CargoResult<()> {
247    writeln!(
248        f,
249        "const UNIT_DATA = {};",
250        serde_json::to_string_pretty(&ctx.unit_data)?
251    )?;
252    writeln!(
253        f,
254        "const CONCURRENCY_DATA = {};",
255        serde_json::to_string_pretty(&ctx.concurrency)?
256    )?;
257    writeln!(
258        f,
259        "const CPU_USAGE = {};",
260        serde_json::to_string_pretty(&ctx.cpu_usage)?
261    )?;
262    Ok(())
263}
264
265/// Render the table of all units.
266fn write_unit_table(ctx: &RenderContext<'_>, f: &mut impl Write) -> CargoResult<()> {
267    let mut units: Vec<_> = ctx.unit_data.iter().collect();
268    units.sort_unstable_by(|a, b| b.duration.partial_cmp(&a.duration).unwrap());
269
270    let aggregated: Vec<Option<_>> = units.iter().map(|u| u.sections.as_ref()).collect();
271
272    let headers: Vec<_> = aggregated
273        .iter()
274        .find_map(|s| s.as_ref())
275        .map(|sections| {
276            sections
277                .iter()
278                // We don't want to show the "Other" section in the table,
279                // as it is usually a tiny portion out of the entire unit.
280                .filter(|(name, _)| !matches!(name, SectionName::Other))
281                .map(|s| s.0.clone())
282                .collect()
283        })
284        .unwrap_or_default();
285
286    write!(
287        f,
288        r#"
289<table class="my-table">
290<thead>
291<tr>
292  <th></th>
293  <th>Unit</th>
294  <th>Total</th>
295  {headers}
296  <th>Features</th>
297</tr>
298</thead>
299<tbody>
300"#,
301        headers = headers
302            .iter()
303            .map(|h| format!("<th>{}</th>", h.capitalized_name()))
304            .join("\n")
305    )?;
306
307    for (i, (unit, aggregated_sections)) in units.iter().zip(aggregated).enumerate() {
308        let format_duration = |section: Option<&SectionData>| match section {
309            Some(section) => {
310                let duration = section.duration();
311                let pct = (duration / unit.duration) * 100.0;
312                format!("{duration:.1}s ({:.0}%)", pct)
313            }
314            None => "".to_string(),
315        };
316
317        // This is a bit complex, as we assume the most general option - we can have an
318        // arbitrary set of headers, and an arbitrary set of sections per unit, so we always
319        // initiate the cells to be empty, and then try to find a corresponding column for which
320        // we might have data.
321        let mut cells: HashMap<_, _> = aggregated_sections
322            .iter()
323            .flat_map(|sections| sections.into_iter().map(|s| (&s.0, &s.1)))
324            .collect();
325
326        let cells = headers
327            .iter()
328            .map(|header| format!("<td>{}</td>", format_duration(cells.remove(header))))
329            .join("\n");
330
331        let features = unit.features.join(", ");
332        write!(
333            f,
334            r#"
335<tr>
336<td>{}.</td>
337<td>{}{}</td>
338<td>{:.1}s</td>
339{cells}
340<td>{features}</td>
341</tr>
342"#,
343            i + 1,
344            format_args!("{} v{}", unit.name, unit.version),
345            unit.target,
346            unit.duration,
347        )?;
348    }
349    write!(f, "</tbody>\n</table>\n")?;
350    Ok(())
351}
352
353/// Derives concurrency information from unit timing data.
354pub fn compute_concurrency(unit_data: &[UnitData]) -> Vec<Concurrency> {
355    if unit_data.is_empty() {
356        return Vec::new();
357    }
358
359    let unit_by_index: HashMap<_, _> = unit_data.iter().map(|u| (u.i, u)).collect();
360
361    enum UnblockedBy {
362        Rmeta(UnitIndex),
363        Full(UnitIndex),
364    }
365
366    // unit_id -> unit that unblocks it.
367    let mut unblocked_by: HashMap<_, _> = HashMap::new();
368    for unit in unit_data {
369        for id in unit.unblocked_rmeta_units.iter() {
370            assert!(
371                unblocked_by
372                    .insert(*id, UnblockedBy::Rmeta(unit.i))
373                    .is_none()
374            );
375        }
376
377        for id in unit.unblocked_units.iter() {
378            assert!(
379                unblocked_by
380                    .insert(*id, UnblockedBy::Full(unit.i))
381                    .is_none()
382            );
383        }
384    }
385
386    let ready_time = |unit: &UnitData| -> Option<f64> {
387        let dep = unblocked_by.get(&unit.i)?;
388        match dep {
389            UnblockedBy::Rmeta(id) => {
390                let dep = unit_by_index.get(id)?;
391                let duration = dep.sections.iter().flatten().find_map(|(name, section)| {
392                    matches!(name, SectionName::Frontend).then_some(section.end)
393                });
394
395                Some(dep.start + duration.unwrap_or(dep.duration))
396            }
397            UnblockedBy::Full(id) => {
398                let dep = unit_by_index.get(id)?;
399                Some(dep.start + dep.duration)
400            }
401        }
402    };
403
404    #[derive(Debug, Clone, Copy, Eq, PartialEq, Ord, PartialOrd)]
405    enum State {
406        Ready,
407        Start,
408        End,
409    }
410
411    let mut events: Vec<_> = unit_data
412        .iter()
413        .flat_map(|unit| {
414            // Adding rounded numbers may cause ready > start,
415            // so cap with unit.start here to be defensive.
416            let ready = ready_time(unit).unwrap_or(unit.start).min(unit.start);
417
418            [
419                (ready, State::Ready, unit.i),
420                (unit.start, State::Start, unit.i),
421                (unit.start + unit.duration, State::End, unit.i),
422            ]
423        })
424        .collect();
425
426    events.sort_by(|a, b| {
427        a.0.partial_cmp(&b.0)
428            .unwrap()
429            .then_with(|| a.1.cmp(&b.1))
430            .then_with(|| a.2.cmp(&b.2))
431    });
432
433    let mut concurrency: Vec<Concurrency> = Vec::new();
434    let mut inactive: HashSet<UnitIndex> = unit_data.iter().map(|unit| unit.i).collect();
435    let mut waiting: HashSet<UnitIndex> = HashSet::new();
436    let mut active: HashSet<UnitIndex> = HashSet::new();
437
438    for (t, state, unit_id) in events {
439        match state {
440            State::Ready => {
441                inactive.remove(&unit_id);
442                waiting.insert(unit_id);
443                active.remove(&unit_id);
444            }
445            State::Start => {
446                inactive.remove(&unit_id);
447                waiting.remove(&unit_id);
448                active.insert(unit_id);
449            }
450            State::End => {
451                inactive.remove(&unit_id);
452                waiting.remove(&unit_id);
453                active.remove(&unit_id);
454            }
455        }
456
457        let record = Concurrency {
458            t,
459            active: active.len(),
460            waiting: waiting.len(),
461            inactive: inactive.len(),
462        };
463
464        if let Some(last) = concurrency.last_mut()
465            && last.t == t
466        {
467            // We don't want to draw long vertical lines at the same timestamp,
468            // so we keep only the latest state.
469            *last = record;
470        } else {
471            concurrency.push(record);
472        }
473    }
474
475    concurrency
476}
477
478/// Aggregates section timing information from individual compilation sections.
479///
480/// We can have a bunch of situations here.
481///
482/// - `-Zsection-timings` is enabled, and we received some custom sections,
483///   in which case we use them to determine the headers.
484/// - We have at least one rmeta time, so we hard-code Frontend and Codegen headers.
485/// - We only have total durations, so we don't add any additional headers.
486pub fn aggregate_sections(
487    sections: IndexMap<String, CompilationSection>,
488    end: f64,
489    rmeta_time: Option<f64>,
490) -> Option<Vec<(SectionName, SectionData)>> {
491    if !sections.is_empty() {
492        // We have some detailed compilation section timings, so we postprocess them
493        // Since it is possible that we do not have an end timestamp for a given compilation
494        // section, we need to iterate them and if an end is missing, we assign the end of
495        // the section to the start of the following section.
496        let mut sections = sections.into_iter().fold(
497            // The frontend section is currently implicit in rustc.
498            // It is assumed to start at compilation start and end when codegen starts,
499            // So we hard-code it here.
500            vec![(
501                SectionName::Frontend,
502                SectionData {
503                    start: 0.0,
504                    end: round_to_centisecond(end),
505                },
506            )],
507            |mut sections, (name, section)| {
508                let previous = sections.last_mut().unwrap();
509                // Setting the end of previous to the start of the current.
510                previous.1.end = section.start;
511
512                sections.push((
513                    SectionName::Named(name),
514                    SectionData {
515                        start: round_to_centisecond(section.start),
516                        end: round_to_centisecond(section.end.unwrap_or(end)),
517                    },
518                ));
519
520                sections
521            },
522        );
523
524        // We draw the sections in the pipeline graph in a way where the frontend
525        // section has the "default" build color, and then additional sections
526        // (codegen, link) are overlaid on top with a different color.
527        // However, there might be some time after the final (usually link) section,
528        // which definitely shouldn't be classified as "Frontend". We thus try to
529        // detect this situation and add a final "Other" section.
530        if let Some((_, section)) = sections.last()
531            && section.end < end
532        {
533            sections.push((
534                SectionName::Other,
535                SectionData {
536                    start: round_to_centisecond(section.end),
537                    end: round_to_centisecond(end),
538                },
539            ));
540        }
541        Some(sections)
542    } else if let Some(rmeta) = rmeta_time {
543        // We only know when the rmeta time was generated
544        Some(vec![
545            (
546                SectionName::Frontend,
547                SectionData {
548                    start: 0.0,
549                    end: round_to_centisecond(rmeta),
550                },
551            ),
552            (
553                SectionName::Codegen,
554                SectionData {
555                    start: round_to_centisecond(rmeta),
556                    end: round_to_centisecond(end),
557                },
558            ),
559        ])
560    } else {
561        // No section data provided. We only know the total duration.
562        None
563    }
564}
565
566/// Rounds seconds to 0.01s precision.
567pub fn round_to_centisecond(x: f64) -> f64 {
568    (x * 100.0).round() / 100.0
569}
570
571static HTML_TMPL: &str = r#"
572<html>
573<head>
574  <title>Cargo Build Timings — {ROOTS}</title>
575  <meta charset="utf-8">
576<style type="text/css">
577:root {
578  --error-text: #e80000;
579  --text: #000;
580  --background: #fff;
581  --h1-border-bottom: #c0c0c0;
582  --table-box-shadow: rgba(0, 0, 0, 0.1);
583  --table-th: #d5dde5;
584  --table-th-background: #1b1e24;
585  --table-th-border-bottom: #9ea7af;
586  --table-th-border-right: #343a45;
587  --table-tr-border-top: #c1c3d1;
588  --table-tr-border-bottom: #c1c3d1;
589  --table-tr-odd-background: #ebebeb;
590  --table-td-background: #ffffff;
591  --table-td-border-right: #C1C3D1;
592  --canvas-background: #f7f7f7;
593  --canvas-axes: #303030;
594  --canvas-grid: #e6e6e6;
595  --canvas-codegen: #aa95e8;
596  --canvas-link: #95e8aa;
597  --canvas-other: #e895aa;
598  --canvas-custom-build: #f0b165;
599  --canvas-not-custom-build: #95cce8;
600  --canvas-dep-line: #ddd;
601  --canvas-dep-line-highlighted: #000;
602  --canvas-cpu: rgba(250, 119, 0, 0.2);
603}
604
605@media (prefers-color-scheme: dark) {
606  :root {
607    --error-text: #e80000;
608    --text: #fff;
609    --background: #121212;
610    --h1-border-bottom: #444;
611    --table-box-shadow: rgba(255, 255, 255, 0.1);
612    --table-th: #a0a0a0;
613    --table-th-background: #2c2c2c;
614    --table-th-border-bottom: #555;
615    --table-th-border-right: #444;
616    --table-tr-border-top: #333;
617    --table-tr-border-bottom: #333;
618    --table-tr-odd-background: #1e1e1e;
619    --table-td-background: #262626;
620    --table-td-border-right: #333;
621    --canvas-background: #1a1a1a;
622    --canvas-axes: #b0b0b0;
623    --canvas-grid: #333;
624    --canvas-block: #aa95e8;
625    --canvas-custom-build: #f0b165;
626    --canvas-not-custom-build: #95cce8;
627    --canvas-dep-line: #444;
628    --canvas-dep-line-highlighted: #fff;
629    --canvas-cpu: rgba(250, 119, 0, 0.2);
630  }
631}
632
633html {
634  font-family: sans-serif;
635  color: var(--text);
636  background: var(--background);
637}
638
639.canvas-container {
640  position: relative;
641  margin-top: 5px;
642  margin-bottom: 5px;
643}
644
645.canvas-container.hidden {
646  display: none;
647}
648
649h1 {
650  border-bottom: 1px solid var(--h1-border-bottom);
651}
652
653.graph {
654  display: block;
655}
656
657.my-table {
658  margin-top: 20px;
659  margin-bottom: 20px;
660  border-collapse: collapse;
661  box-shadow: 0 5px 10px var(--table-box-shadow);
662}
663
664.my-table th {
665  color: var(--table-th);
666  background: var(--table-th-background);
667  border-bottom: 4px solid var(--table-th-border-bottom);
668  border-right: 1px solid var(--table-th-border-right);
669  font-size: 18px;
670  font-weight: 100;
671  padding: 12px;
672  text-align: left;
673  vertical-align: middle;
674}
675
676.my-table th:first-child {
677  border-top-left-radius: 3px;
678}
679
680.my-table th:last-child {
681  border-top-right-radius: 3px;
682  border-right:none;
683}
684
685.my-table tr {
686  border-top: 1px solid var(--table-tr-border-top);
687  border-bottom: 1px solid var(--table-tr-border-bottom);
688  font-size: 16px;
689  font-weight: normal;
690}
691
692.my-table tr:first-child {
693  border-top:none;
694}
695
696.my-table tr:last-child {
697  border-bottom:none;
698}
699
700.my-table tr:nth-child(odd) td {
701  background: var(--table-tr-odd-background);
702}
703
704.my-table tr:last-child td:first-child {
705  border-bottom-left-radius:3px;
706}
707
708.my-table tr:last-child td:last-child {
709  border-bottom-right-radius:3px;
710}
711
712.my-table td {
713  background: var(--table-td-background);
714  padding: 10px;
715  text-align: left;
716  vertical-align: middle;
717  font-weight: 300;
718  font-size: 14px;
719  border-right: 1px solid var(--table-td-border-right);
720}
721
722.my-table td:last-child {
723  border-right: 0px;
724}
725
726.summary-table td:first-child {
727  vertical-align: top;
728  text-align: right;
729}
730
731.input-table td {
732  text-align: center;
733}
734
735.error-text {
736  color: var(--error-text);
737}
738
739</style>
740</head>
741<body>
742
743<h1>Cargo Build Timings</h1>
744See <a href="https://doc.rust-lang.org/nightly/cargo/reference/timings.html">Documentation</a>
745"#;
746
747static HTML_CANVAS: &str = r#"
748<table class="input-table">
749  <tr>
750    <td><label for="min-unit-time">Min unit time:</label></td>
751    <td title="Scale corresponds to a number of pixels per second. It is automatically initialized based on your viewport width.">
752      <label for="scale">Scale:</label>
753    </td>
754  </tr>
755  <tr>
756    <td><input type="range" min="0" max="30" step="0.1" value="0" id="min-unit-time"></td>
757    <!--
758        The scale corresponds to some number of "pixels per second".
759        Its min, max, and initial values are automatically set by JavaScript on page load,
760        based on the client viewport.
761    -->
762    <td><input type="range" min="1" max="100" value="50" id="scale"></td>
763  </tr>
764  <tr>
765    <td><output for="min-unit-time" id="min-unit-time-output"></output></td>
766    <td><output for="scale" id="scale-output"></output></td>
767    <td></td>
768  </tr>
769</table>
770
771<div id="pipeline-container" class="canvas-container"></div>
772<div id="timing-container" class="canvas-container"></div>
773"#;