Skip to main content

cargo/compiler/timings/
report.rs

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