Skip to main content

cargo/compiler/timings/
report.rs

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