| //! Render HTML report from timing tracking data. |
| |
| use crate::util::data_structures::HashMap; |
| use crate::util::data_structures::HashSet; |
| use std::borrow::Cow; |
| use std::io::Write; |
| |
| use crate::util::data_structures::IndexMap; |
| use itertools::Itertools as _; |
| |
| use crate::CargoResult; |
| use crate::compiler::UnitIndex; |
| |
| use super::CompilationSection; |
| use super::UnitData; |
| |
| /// Name of an individual compilation section. |
| #[derive(Clone, Hash, Eq, PartialEq)] |
| pub enum SectionName { |
| Frontend, |
| Codegen, |
| Named(String), |
| Other, |
| } |
| |
| impl SectionName { |
| /// Lower case name. |
| fn name(&self) -> Cow<'static, str> { |
| match self { |
| SectionName::Frontend => "frontend".into(), |
| SectionName::Codegen => "codegen".into(), |
| SectionName::Named(n) => n.to_lowercase().into(), |
| SectionName::Other => "other".into(), |
| } |
| } |
| |
| fn capitalized_name(&self) -> String { |
| // Make the first "letter" uppercase. We could probably just assume ASCII here, but this |
| // should be Unicode compatible. |
| fn capitalize(s: &str) -> String { |
| let first_char = s |
| .chars() |
| .next() |
| .map(|c| c.to_uppercase().to_string()) |
| .unwrap_or_default(); |
| format!("{first_char}{}", s.chars().skip(1).collect::<String>()) |
| } |
| capitalize(&self.name()) |
| } |
| } |
| |
| impl serde::ser::Serialize for SectionName { |
| fn serialize<S>(&self, serializer: S) -> Result<S::Ok, S::Error> |
| where |
| S: serde::Serializer, |
| { |
| self.name().serialize(serializer) |
| } |
| } |
| |
| /// Postprocessed section data that has both start and an end. |
| #[derive(Copy, Clone, serde::Serialize)] |
| pub struct SectionData { |
| /// Start (relative to the start of the unit) |
| pub start: f64, |
| /// End (relative to the start of the unit) |
| pub end: f64, |
| } |
| |
| impl SectionData { |
| fn duration(&self) -> f64 { |
| (self.end - self.start).max(0.0) |
| } |
| } |
| |
| /// Concurrency tracking information. |
| #[derive(serde::Serialize)] |
| pub struct Concurrency { |
| /// Time as an offset in seconds from `Timings::start`. |
| t: f64, |
| /// Number of units currently running. |
| active: usize, |
| /// Number of units that could run, but are waiting for a jobserver token. |
| waiting: usize, |
| /// Number of units that are not yet ready, because they are waiting for |
| /// dependencies to finish. |
| inactive: usize, |
| } |
| |
| pub struct RenderContext<'a> { |
| /// A rendered string of when compilation started. |
| pub start_str: String, |
| /// A summary of the root units. |
| /// |
| /// Tuples of `(package_description, target_descriptions)`. |
| pub root_units: Vec<(String, Vec<String>)>, |
| /// The build profile. |
| pub profile: String, |
| /// Total number of fresh units. |
| pub total_fresh: u32, |
| /// Total number of dirty units. |
| pub total_dirty: u32, |
| /// Time tracking for each individual unit. |
| pub unit_data: Vec<UnitData>, |
| /// Concurrency-tracking information. This is periodically updated while |
| /// compilation progresses. |
| pub concurrency: Vec<Concurrency>, |
| /// Recorded CPU states, stored as tuples. First element is when the |
| /// recording was taken and second element is percentage usage of the |
| /// system. |
| pub cpu_usage: &'a [(f64, f64)], |
| /// Compiler version info, i.e., `rustc 1.92.0-beta.2 (0a411606e 2025-10-31)`. |
| pub rustc_version: String, |
| /// The host triple (arch-platform-OS). |
| pub host: String, |
| /// The requested target platforms of compilation for this build. |
| pub requested_targets: Vec<String>, |
| /// The number of jobs specified for this build. |
| pub jobs: u32, |
| /// Available parallelism of the compilation environment. |
| pub num_cpus: Option<u64>, |
| /// Fatal error during the build. |
| pub error: &'a Option<anyhow::Error>, |
| } |
| |
| /// Writes an HTML report. |
| pub fn write_html(ctx: RenderContext<'_>, f: &mut impl Write) -> CargoResult<()> { |
| // The last concurrency record should equal to the last unit finished time. |
| let duration = ctx.concurrency.last().map(|c| c.t).unwrap_or(0.0); |
| let roots: Vec<&str> = ctx |
| .root_units |
| .iter() |
| .map(|(name, _targets)| name.as_str()) |
| .collect(); |
| f.write_all(HTML_TMPL.replace("{ROOTS}", &roots.join(", ")).as_bytes())?; |
| write_summary_table(&ctx, f, duration)?; |
| f.write_all(HTML_CANVAS.as_bytes())?; |
| write_unit_table(&ctx, f)?; |
| // It helps with pixel alignment to use whole numbers. |
| writeln!( |
| f, |
| "<script>\n\ |
| DURATION = {};", |
| f64::ceil(duration) as u32 |
| )?; |
| write_js_data(&ctx, f)?; |
| write!( |
| f, |
| "{}\n\ |
| </script>\n\ |
| </body>\n\ |
| </html>\n\ |
| ", |
| include_str!("timings.js") |
| )?; |
| |
| Ok(()) |
| } |
| |
| /// Render the summary table. |
| fn write_summary_table( |
| ctx: &RenderContext<'_>, |
| f: &mut impl Write, |
| duration: f64, |
| ) -> CargoResult<()> { |
| let targets = ctx |
| .root_units |
| .iter() |
| .map(|(name, targets)| format!("{} ({})", name, targets.join(", "))) |
| .collect::<Vec<_>>() |
| .join("<br>"); |
| |
| let total_units = ctx.total_fresh + ctx.total_dirty; |
| |
| let time_human = if duration > 60.0 { |
| format!(" ({}m {:.1}s)", duration as u32 / 60, duration % 60.0) |
| } else { |
| "".to_string() |
| }; |
| let total_time = format!("{:.1}s{}", duration, time_human); |
| |
| let max_concurrency = ctx.concurrency.iter().map(|c| c.active).max().unwrap_or(0); |
| let num_cpus = ctx |
| .num_cpus |
| .map(|x| x.to_string()) |
| .unwrap_or_else(|| "n/a".into()); |
| |
| let requested_targets = ctx.requested_targets.join(", "); |
| |
| let error_msg = match ctx.error { |
| Some(e) => format!(r#"<tr><td class="error-text">Error:</td><td>{e}</td></tr>"#), |
| None => "".to_string(), |
| }; |
| |
| let RenderContext { |
| start_str, |
| profile, |
| total_fresh, |
| total_dirty, |
| rustc_version, |
| host, |
| jobs, |
| .. |
| } = &ctx; |
| |
| write!( |
| f, |
| r#" |
| <table class="my-table summary-table"> |
| <tr> |
| <td>Targets:</td><td>{targets}</td> |
| </tr> |
| <tr> |
| <td>Profile:</td><td>{profile}</td> |
| </tr> |
| <tr> |
| <td>Fresh units:</td><td>{total_fresh}</td> |
| </tr> |
| <tr> |
| <td>Dirty units:</td><td>{total_dirty}</td> |
| </tr> |
| <tr> |
| <td>Total units:</td><td>{total_units}</td> |
| </tr> |
| <tr> |
| <td>Max concurrency:</td><td>{max_concurrency} (jobs={jobs} ncpu={num_cpus})</td> |
| </tr> |
| <tr> |
| <td>Build start:</td><td>{start_str}</td> |
| </tr> |
| <tr> |
| <td>Total time:</td><td>{total_time}</td> |
| </tr> |
| <tr> |
| <td>rustc:</td><td>{rustc_version}<br>Host: {host}<br>Target: {requested_targets}</td> |
| </tr> |
| {error_msg} |
| </table> |
| "#, |
| )?; |
| Ok(()) |
| } |
| |
| /// Write timing data in JavaScript. Primarily for `timings.js` to put data |
| /// in a `<script>` HTML element to draw graphs. |
| fn write_js_data(ctx: &RenderContext<'_>, f: &mut impl Write) -> CargoResult<()> { |
| writeln!( |
| f, |
| "const UNIT_DATA = {};", |
| serde_json::to_string_pretty(&ctx.unit_data)? |
| )?; |
| writeln!( |
| f, |
| "const CONCURRENCY_DATA = {};", |
| serde_json::to_string_pretty(&ctx.concurrency)? |
| )?; |
| writeln!( |
| f, |
| "const CPU_USAGE = {};", |
| serde_json::to_string_pretty(&ctx.cpu_usage)? |
| )?; |
| Ok(()) |
| } |
| |
| /// Render the table of all units. |
| fn write_unit_table(ctx: &RenderContext<'_>, f: &mut impl Write) -> CargoResult<()> { |
| let mut units: Vec<_> = ctx.unit_data.iter().collect(); |
| units.sort_unstable_by(|a, b| b.duration.partial_cmp(&a.duration).unwrap()); |
| |
| let aggregated: Vec<Option<_>> = units.iter().map(|u| u.sections.as_ref()).collect(); |
| |
| let headers: Vec<_> = aggregated |
| .iter() |
| .find_map(|s| s.as_ref()) |
| .map(|sections| { |
| sections |
| .iter() |
| // We don't want to show the "Other" section in the table, |
| // as it is usually a tiny portion out of the entire unit. |
| .filter(|(name, _)| !matches!(name, SectionName::Other)) |
| .map(|s| s.0.clone()) |
| .collect() |
| }) |
| .unwrap_or_default(); |
| |
| write!( |
| f, |
| r#" |
| <table class="my-table"> |
| <thead> |
| <tr> |
| <th></th> |
| <th>Unit</th> |
| <th>Total</th> |
| {headers} |
| <th>Features</th> |
| </tr> |
| </thead> |
| <tbody> |
| "#, |
| headers = headers |
| .iter() |
| .map(|h| format!("<th>{}</th>", h.capitalized_name())) |
| .join("\n") |
| )?; |
| |
| for (i, (unit, aggregated_sections)) in units.iter().zip(aggregated).enumerate() { |
| let format_duration = |section: Option<&SectionData>| match section { |
| Some(section) => { |
| let duration = section.duration(); |
| let pct = (duration / unit.duration) * 100.0; |
| format!("{duration:.1}s ({:.0}%)", pct) |
| } |
| None => "".to_string(), |
| }; |
| |
| // This is a bit complex, as we assume the most general option - we can have an |
| // arbitrary set of headers, and an arbitrary set of sections per unit, so we always |
| // initiate the cells to be empty, and then try to find a corresponding column for which |
| // we might have data. |
| let mut cells: HashMap<_, _> = aggregated_sections |
| .iter() |
| .flat_map(|sections| sections.into_iter().map(|s| (&s.0, &s.1))) |
| .collect(); |
| |
| let cells = headers |
| .iter() |
| .map(|header| format!("<td>{}</td>", format_duration(cells.remove(header)))) |
| .join("\n"); |
| |
| let features = unit.features.join(", "); |
| write!( |
| f, |
| r#" |
| <tr> |
| <td>{}.</td> |
| <td>{}{}</td> |
| <td>{:.1}s</td> |
| {cells} |
| <td>{features}</td> |
| </tr> |
| "#, |
| i + 1, |
| format_args!("{} v{}", unit.name, unit.version), |
| unit.target, |
| unit.duration, |
| )?; |
| } |
| write!(f, "</tbody>\n</table>\n")?; |
| Ok(()) |
| } |
| |
| /// Derives concurrency information from unit timing data. |
| pub fn compute_concurrency(unit_data: &[UnitData]) -> Vec<Concurrency> { |
| if unit_data.is_empty() { |
| return Vec::new(); |
| } |
| |
| let unit_by_index: HashMap<_, _> = unit_data.iter().map(|u| (u.i, u)).collect(); |
| |
| enum UnblockedBy { |
| Rmeta(UnitIndex), |
| Full(UnitIndex), |
| } |
| |
| // unit_id -> unit that unblocks it. |
| let mut unblocked_by: HashMap<_, _> = HashMap::default(); |
| for unit in unit_data { |
| for id in unit.unblocked_rmeta_units.iter() { |
| assert!( |
| unblocked_by |
| .insert(*id, UnblockedBy::Rmeta(unit.i)) |
| .is_none() |
| ); |
| } |
| |
| for id in unit.unblocked_units.iter() { |
| assert!( |
| unblocked_by |
| .insert(*id, UnblockedBy::Full(unit.i)) |
| .is_none() |
| ); |
| } |
| } |
| |
| let ready_time = |unit: &UnitData| -> Option<f64> { |
| let dep = unblocked_by.get(&unit.i)?; |
| match dep { |
| UnblockedBy::Rmeta(id) => { |
| let dep = unit_by_index.get(id)?; |
| let duration = dep.sections.iter().flatten().find_map(|(name, section)| { |
| matches!(name, SectionName::Frontend).then_some(section.end) |
| }); |
| |
| Some(dep.start + duration.unwrap_or(dep.duration)) |
| } |
| UnblockedBy::Full(id) => { |
| let dep = unit_by_index.get(id)?; |
| Some(dep.start + dep.duration) |
| } |
| } |
| }; |
| |
| #[derive(Debug, Clone, Copy, Eq, PartialEq, Ord, PartialOrd)] |
| enum State { |
| Ready, |
| Start, |
| End, |
| } |
| |
| let mut events: Vec<_> = unit_data |
| .iter() |
| .flat_map(|unit| { |
| // Adding rounded numbers may cause ready > start, |
| // so cap with unit.start here to be defensive. |
| let ready = ready_time(unit).unwrap_or(unit.start).min(unit.start); |
| |
| [ |
| (ready, State::Ready, unit.i), |
| (unit.start, State::Start, unit.i), |
| (unit.start + unit.duration, State::End, unit.i), |
| ] |
| }) |
| .collect(); |
| |
| events.sort_by(|a, b| { |
| a.0.partial_cmp(&b.0) |
| .unwrap() |
| .then_with(|| a.1.cmp(&b.1)) |
| .then_with(|| a.2.cmp(&b.2)) |
| }); |
| |
| let mut concurrency: Vec<Concurrency> = Vec::new(); |
| let mut inactive: HashSet<UnitIndex> = unit_data.iter().map(|unit| unit.i).collect(); |
| let mut waiting: HashSet<UnitIndex> = HashSet::default(); |
| let mut active: HashSet<UnitIndex> = HashSet::default(); |
| |
| for (t, state, unit_id) in events { |
| match state { |
| State::Ready => { |
| inactive.remove(&unit_id); |
| waiting.insert(unit_id); |
| active.remove(&unit_id); |
| } |
| State::Start => { |
| inactive.remove(&unit_id); |
| waiting.remove(&unit_id); |
| active.insert(unit_id); |
| } |
| State::End => { |
| inactive.remove(&unit_id); |
| waiting.remove(&unit_id); |
| active.remove(&unit_id); |
| } |
| } |
| |
| let record = Concurrency { |
| t, |
| active: active.len(), |
| waiting: waiting.len(), |
| inactive: inactive.len(), |
| }; |
| |
| if let Some(last) = concurrency.last_mut() |
| && last.t == t |
| { |
| // We don't want to draw long vertical lines at the same timestamp, |
| // so we keep only the latest state. |
| *last = record; |
| } else { |
| concurrency.push(record); |
| } |
| } |
| |
| concurrency |
| } |
| |
| /// Aggregates section timing information from individual compilation sections. |
| /// |
| /// We can have a bunch of situations here. |
| /// |
| /// - `-Zsection-timings` is enabled, and we received some custom sections, |
| /// in which case we use them to determine the headers. |
| /// - We have at least one rmeta time, so we hard-code Frontend and Codegen headers. |
| /// - We only have total durations, so we don't add any additional headers. |
| pub fn aggregate_sections( |
| sections: IndexMap<String, CompilationSection>, |
| end: f64, |
| rmeta_time: Option<f64>, |
| ) -> Option<Vec<(SectionName, SectionData)>> { |
| if !sections.is_empty() { |
| // We have some detailed compilation section timings, so we postprocess them |
| // Since it is possible that we do not have an end timestamp for a given compilation |
| // section, we need to iterate them and if an end is missing, we assign the end of |
| // the section to the start of the following section. |
| let mut sections = sections.into_iter().fold( |
| // The frontend section is currently implicit in rustc. |
| // It is assumed to start at compilation start and end when codegen starts, |
| // So we hard-code it here. |
| vec![( |
| SectionName::Frontend, |
| SectionData { |
| start: 0.0, |
| end: round_to_centisecond(end), |
| }, |
| )], |
| |mut sections, (name, section)| { |
| let previous = sections.last_mut().unwrap(); |
| // Setting the end of previous to the start of the current. |
| previous.1.end = section.start; |
| |
| sections.push(( |
| SectionName::Named(name), |
| SectionData { |
| start: round_to_centisecond(section.start), |
| end: round_to_centisecond(section.end.unwrap_or(end)), |
| }, |
| )); |
| |
| sections |
| }, |
| ); |
| |
| // We draw the sections in the pipeline graph in a way where the frontend |
| // section has the "default" build color, and then additional sections |
| // (codegen, link) are overlaid on top with a different color. |
| // However, there might be some time after the final (usually link) section, |
| // which definitely shouldn't be classified as "Frontend". We thus try to |
| // detect this situation and add a final "Other" section. |
| if let Some((_, section)) = sections.last() |
| && section.end < end |
| { |
| sections.push(( |
| SectionName::Other, |
| SectionData { |
| start: round_to_centisecond(section.end), |
| end: round_to_centisecond(end), |
| }, |
| )); |
| } |
| Some(sections) |
| } else if let Some(rmeta) = rmeta_time { |
| // We only know when the rmeta time was generated |
| Some(vec![ |
| ( |
| SectionName::Frontend, |
| SectionData { |
| start: 0.0, |
| end: round_to_centisecond(rmeta), |
| }, |
| ), |
| ( |
| SectionName::Codegen, |
| SectionData { |
| start: round_to_centisecond(rmeta), |
| end: round_to_centisecond(end), |
| }, |
| ), |
| ]) |
| } else { |
| // No section data provided. We only know the total duration. |
| None |
| } |
| } |
| |
| /// Rounds seconds to 0.01s precision. |
| pub fn round_to_centisecond(x: f64) -> f64 { |
| (x * 100.0).round() / 100.0 |
| } |
| |
| static HTML_TMPL: &str = r#" |
| <html> |
| <head> |
| <title>Cargo Build Timings — {ROOTS}</title> |
| <meta charset="utf-8"> |
| <style type="text/css"> |
| :root { |
| --error-text: #e80000; |
| --text: #000; |
| --background: #fff; |
| --h1-border-bottom: #c0c0c0; |
| --table-box-shadow: rgba(0, 0, 0, 0.1); |
| --table-th: #d5dde5; |
| --table-th-background: #1b1e24; |
| --table-th-border-bottom: #9ea7af; |
| --table-th-border-right: #343a45; |
| --table-tr-border-top: #c1c3d1; |
| --table-tr-border-bottom: #c1c3d1; |
| --table-tr-odd-background: #ebebeb; |
| --table-td-background: #ffffff; |
| --table-td-border-right: #C1C3D1; |
| --canvas-background: #f7f7f7; |
| --canvas-axes: #303030; |
| --canvas-grid: #e6e6e6; |
| --canvas-codegen: #aa95e8; |
| --canvas-link: #95e8aa; |
| --canvas-other: #e895aa; |
| --canvas-custom-build: #f0b165; |
| --canvas-not-custom-build: #95cce8; |
| --canvas-dep-line: #ddd; |
| --canvas-dep-line-highlighted: #000; |
| --canvas-cpu: rgba(250, 119, 0, 0.2); |
| } |
| |
| @media (prefers-color-scheme: dark) { |
| :root { |
| --error-text: #e80000; |
| --text: #fff; |
| --background: #121212; |
| --h1-border-bottom: #444; |
| --table-box-shadow: rgba(255, 255, 255, 0.1); |
| --table-th: #a0a0a0; |
| --table-th-background: #2c2c2c; |
| --table-th-border-bottom: #555; |
| --table-th-border-right: #444; |
| --table-tr-border-top: #333; |
| --table-tr-border-bottom: #333; |
| --table-tr-odd-background: #1e1e1e; |
| --table-td-background: #262626; |
| --table-td-border-right: #333; |
| --canvas-background: #1a1a1a; |
| --canvas-axes: #b0b0b0; |
| --canvas-grid: #333; |
| --canvas-block: #aa95e8; |
| --canvas-custom-build: #f0b165; |
| --canvas-not-custom-build: #95cce8; |
| --canvas-dep-line: #444; |
| --canvas-dep-line-highlighted: #fff; |
| --canvas-cpu: rgba(250, 119, 0, 0.2); |
| } |
| } |
| |
| html { |
| font-family: sans-serif; |
| color: var(--text); |
| background: var(--background); |
| } |
| |
| .canvas-container { |
| position: relative; |
| margin-top: 5px; |
| margin-bottom: 5px; |
| } |
| |
| .canvas-container.hidden { |
| display: none; |
| } |
| |
| h1 { |
| border-bottom: 1px solid var(--h1-border-bottom); |
| } |
| |
| .graph { |
| display: block; |
| } |
| |
| .my-table { |
| margin-top: 20px; |
| margin-bottom: 20px; |
| border-collapse: collapse; |
| box-shadow: 0 5px 10px var(--table-box-shadow); |
| } |
| |
| .my-table th { |
| color: var(--table-th); |
| background: var(--table-th-background); |
| border-bottom: 4px solid var(--table-th-border-bottom); |
| border-right: 1px solid var(--table-th-border-right); |
| font-size: 18px; |
| font-weight: 100; |
| padding: 12px; |
| text-align: left; |
| vertical-align: middle; |
| } |
| |
| .my-table th:first-child { |
| border-top-left-radius: 3px; |
| } |
| |
| .my-table th:last-child { |
| border-top-right-radius: 3px; |
| border-right:none; |
| } |
| |
| .my-table tr { |
| border-top: 1px solid var(--table-tr-border-top); |
| border-bottom: 1px solid var(--table-tr-border-bottom); |
| font-size: 16px; |
| font-weight: normal; |
| } |
| |
| .my-table tr:first-child { |
| border-top:none; |
| } |
| |
| .my-table tr:last-child { |
| border-bottom:none; |
| } |
| |
| .my-table tr:nth-child(odd) td { |
| background: var(--table-tr-odd-background); |
| } |
| |
| .my-table tr:last-child td:first-child { |
| border-bottom-left-radius:3px; |
| } |
| |
| .my-table tr:last-child td:last-child { |
| border-bottom-right-radius:3px; |
| } |
| |
| .my-table td { |
| background: var(--table-td-background); |
| padding: 10px; |
| text-align: left; |
| vertical-align: middle; |
| font-weight: 300; |
| font-size: 14px; |
| border-right: 1px solid var(--table-td-border-right); |
| } |
| |
| .my-table td:last-child { |
| border-right: 0px; |
| } |
| |
| .summary-table td:first-child { |
| vertical-align: top; |
| text-align: right; |
| } |
| |
| .input-table td { |
| text-align: center; |
| } |
| |
| .error-text { |
| color: var(--error-text); |
| } |
| |
| </style> |
| </head> |
| <body> |
| |
| <h1>Cargo Build Timings</h1> |
| See <a href="https://doc.rust-lang.org/nightly/cargo/reference/timings.html">Documentation</a> |
| "#; |
| |
| static HTML_CANVAS: &str = r#" |
| <table class="input-table"> |
| <tr> |
| <td><label for="min-unit-time">Min unit time:</label></td> |
| <td title="Scale corresponds to a number of pixels per second. It is automatically initialized based on your viewport width."> |
| <label for="scale">Scale:</label> |
| </td> |
| </tr> |
| <tr> |
| <td><input type="range" min="0" max="30" step="0.1" value="0" id="min-unit-time"></td> |
| <!-- |
| The scale corresponds to some number of "pixels per second". |
| Its min, max, and initial values are automatically set by JavaScript on page load, |
| based on the client viewport. |
| --> |
| <td><input type="range" min="1" max="100" value="50" id="scale"></td> |
| </tr> |
| <tr> |
| <td><output for="min-unit-time" id="min-unit-time-output"></output></td> |
| <td><output for="scale" id="scale-output"></output></td> |
| <td></td> |
| </tr> |
| </table> |
| |
| <div id="pipeline-container" class="canvas-container"></div> |
| <div id="timing-container" class="canvas-container"></div> |
| "#; |