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::Unit;
13use crate::core::compiler::UnitIndex;
14
15use super::CompilationSection;
16use super::UnitData;
17use super::UnitTime;
18
19/// Name of an individual compilation section.
20#[derive(Clone, Hash, Eq, PartialEq)]
21pub enum SectionName {
22    Frontend,
23    Codegen,
24    Named(String),
25    Other,
26}
27
28impl SectionName {
29    /// Lower case name.
30    fn name(&self) -> Cow<'static, str> {
31        match self {
32            SectionName::Frontend => "frontend".into(),
33            SectionName::Codegen => "codegen".into(),
34            SectionName::Named(n) => n.to_lowercase().into(),
35            SectionName::Other => "other".into(),
36        }
37    }
38
39    fn capitalized_name(&self) -> String {
40        // Make the first "letter" uppercase. We could probably just assume ASCII here, but this
41        // should be Unicode compatible.
42        fn capitalize(s: &str) -> String {
43            let first_char = s
44                .chars()
45                .next()
46                .map(|c| c.to_uppercase().to_string())
47                .unwrap_or_default();
48            format!("{first_char}{}", s.chars().skip(1).collect::<String>())
49        }
50        capitalize(&self.name())
51    }
52}
53
54impl serde::ser::Serialize for SectionName {
55    fn serialize<S>(&self, serializer: S) -> Result<S::Ok, S::Error>
56    where
57        S: serde::Serializer,
58    {
59        self.name().serialize(serializer)
60    }
61}
62
63/// Postprocessed section data that has both start and an end.
64#[derive(Copy, Clone, serde::Serialize)]
65pub struct SectionData {
66    /// Start (relative to the start of the unit)
67    pub start: f64,
68    /// End (relative to the start of the unit)
69    pub end: f64,
70}
71
72impl SectionData {
73    fn duration(&self) -> f64 {
74        (self.end - self.start).max(0.0)
75    }
76}
77
78/// Concurrency tracking information.
79#[derive(serde::Serialize)]
80pub struct Concurrency {
81    /// Time as an offset in seconds from `Timings::start`.
82    t: f64,
83    /// Number of units currently running.
84    active: usize,
85    /// Number of units that could run, but are waiting for a jobserver token.
86    waiting: usize,
87    /// Number of units that are not yet ready, because they are waiting for
88    /// dependencies to finish.
89    inactive: usize,
90}
91
92pub struct RenderContext<'a> {
93    /// A rendered string of when compilation started.
94    pub start_str: String,
95    /// A summary of the root units.
96    ///
97    /// Tuples of `(package_description, target_descriptions)`.
98    pub root_units: Vec<(String, Vec<String>)>,
99    /// The build profile.
100    pub profile: String,
101    /// Total number of fresh units.
102    pub total_fresh: u32,
103    /// Total number of dirty units.
104    pub total_dirty: u32,
105    /// Time tracking for each individual unit.
106    pub unit_data: Vec<UnitData>,
107    /// Concurrency-tracking information. This is periodically updated while
108    /// compilation progresses.
109    pub concurrency: Vec<Concurrency>,
110    /// Recorded CPU states, stored as tuples. First element is when the
111    /// recording was taken and second element is percentage usage of the
112    /// system.
113    pub cpu_usage: &'a [(f64, f64)],
114    /// Compiler version info, i.e., `rustc 1.92.0-beta.2 (0a411606e 2025-10-31)`.
115    pub rustc_version: String,
116    /// The host triple (arch-platform-OS).
117    pub host: String,
118    /// The requested target platforms of compilation for this build.
119    pub requested_targets: Vec<String>,
120    /// The number of jobs specified for this build.
121    pub jobs: u32,
122    /// Available parallelism of the compilation environment.
123    pub num_cpus: Option<u64>,
124    /// Fatal error during the build.
125    pub error: &'a Option<anyhow::Error>,
126}
127
128/// Writes an HTML report.
129pub fn write_html(ctx: RenderContext<'_>, f: &mut impl Write) -> CargoResult<()> {
130    // The last concurrency record should equal to the last unit finished time.
131    let duration = ctx.concurrency.last().map(|c| c.t).unwrap_or(0.0);
132    let roots: Vec<&str> = ctx
133        .root_units
134        .iter()
135        .map(|(name, _targets)| name.as_str())
136        .collect();
137    f.write_all(HTML_TMPL.replace("{ROOTS}", &roots.join(", ")).as_bytes())?;
138    write_summary_table(&ctx, f, duration)?;
139    f.write_all(HTML_CANVAS.as_bytes())?;
140    write_unit_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
355pub(super) fn to_unit_data(
356    unit_times: &[UnitTime],
357    unit_map: &HashMap<Unit, UnitIndex>,
358) -> Vec<UnitData> {
359    unit_times
360        .iter()
361        .map(|ut| (unit_map[&ut.unit], ut))
362        .map(|(i, ut)| {
363            let mode = if ut.unit.mode.is_run_custom_build() {
364                "run-custom-build"
365            } else {
366                "todo"
367            }
368            .to_string();
369            // These filter on the unlocked units because not all unlocked
370            // units are actually "built". For example, Doctest mode units
371            // don't actually generate artifacts.
372            let unblocked_units = ut
373                .unblocked_units
374                .iter()
375                .filter_map(|unit| unit_map.get(unit).copied())
376                .collect();
377            let unblocked_rmeta_units = ut
378                .unblocked_rmeta_units
379                .iter()
380                .filter_map(|unit| unit_map.get(unit).copied())
381                .collect();
382            let sections = aggregate_sections(ut.sections.clone(), ut.duration, ut.rmeta_time);
383
384            UnitData {
385                i,
386                name: ut.unit.pkg.name().to_string(),
387                version: ut.unit.pkg.version().to_string(),
388                mode,
389                target: ut.target.clone(),
390                features: ut.unit.features.iter().map(|f| f.to_string()).collect(),
391                start: round_to_centisecond(ut.start),
392                duration: round_to_centisecond(ut.duration),
393                unblocked_units,
394                unblocked_rmeta_units,
395                sections,
396            }
397        })
398        .collect()
399}
400
401/// Derives concurrency information from unit timing data.
402pub fn compute_concurrency(unit_data: &[UnitData]) -> Vec<Concurrency> {
403    if unit_data.is_empty() {
404        return Vec::new();
405    }
406
407    let unit_by_index: HashMap<_, _> = unit_data.iter().map(|u| (u.i, u)).collect();
408
409    enum UnblockedBy {
410        Rmeta(UnitIndex),
411        Full(UnitIndex),
412    }
413
414    // unit_id -> unit that unblocks it.
415    let mut unblocked_by: HashMap<_, _> = HashMap::new();
416    for unit in unit_data {
417        for id in unit.unblocked_rmeta_units.iter() {
418            assert!(
419                unblocked_by
420                    .insert(*id, UnblockedBy::Rmeta(unit.i))
421                    .is_none()
422            );
423        }
424
425        for id in unit.unblocked_units.iter() {
426            assert!(
427                unblocked_by
428                    .insert(*id, UnblockedBy::Full(unit.i))
429                    .is_none()
430            );
431        }
432    }
433
434    let ready_time = |unit: &UnitData| -> Option<f64> {
435        let dep = unblocked_by.get(&unit.i)?;
436        match dep {
437            UnblockedBy::Rmeta(id) => {
438                let dep = unit_by_index.get(id)?;
439                let duration = dep.sections.iter().flatten().find_map(|(name, section)| {
440                    matches!(name, SectionName::Frontend).then_some(section.end)
441                });
442
443                Some(dep.start + duration.unwrap_or(dep.duration))
444            }
445            UnblockedBy::Full(id) => {
446                let dep = unit_by_index.get(id)?;
447                Some(dep.start + dep.duration)
448            }
449        }
450    };
451
452    #[derive(Debug, Clone, Copy, Eq, PartialEq, Ord, PartialOrd)]
453    enum State {
454        Ready,
455        Start,
456        End,
457    }
458
459    let mut events: Vec<_> = unit_data
460        .iter()
461        .flat_map(|unit| {
462            // Adding rounded numbers may cause ready > start,
463            // so cap with unit.start here to be defensive.
464            let ready = ready_time(unit).unwrap_or(unit.start).min(unit.start);
465
466            [
467                (ready, State::Ready, unit.i),
468                (unit.start, State::Start, unit.i),
469                (unit.start + unit.duration, State::End, unit.i),
470            ]
471        })
472        .collect();
473
474    events.sort_by(|a, b| {
475        a.0.partial_cmp(&b.0)
476            .unwrap()
477            .then_with(|| a.1.cmp(&b.1))
478            .then_with(|| a.2.cmp(&b.2))
479    });
480
481    let mut concurrency: Vec<Concurrency> = Vec::new();
482    let mut inactive: HashSet<UnitIndex> = unit_data.iter().map(|unit| unit.i).collect();
483    let mut waiting: HashSet<UnitIndex> = HashSet::new();
484    let mut active: HashSet<UnitIndex> = HashSet::new();
485
486    for (t, state, unit_id) in events {
487        match state {
488            State::Ready => {
489                inactive.remove(&unit_id);
490                waiting.insert(unit_id);
491                active.remove(&unit_id);
492            }
493            State::Start => {
494                inactive.remove(&unit_id);
495                waiting.remove(&unit_id);
496                active.insert(unit_id);
497            }
498            State::End => {
499                inactive.remove(&unit_id);
500                waiting.remove(&unit_id);
501                active.remove(&unit_id);
502            }
503        }
504
505        let record = Concurrency {
506            t,
507            active: active.len(),
508            waiting: waiting.len(),
509            inactive: inactive.len(),
510        };
511
512        if let Some(last) = concurrency.last_mut()
513            && last.t == t
514        {
515            // We don't want to draw long vertical lines at the same timestamp,
516            // so we keep only the latest state.
517            *last = record;
518        } else {
519            concurrency.push(record);
520        }
521    }
522
523    concurrency
524}
525
526/// Aggregates section timing information from individual compilation sections.
527///
528/// We can have a bunch of situations here.
529///
530/// - `-Zsection-timings` is enabled, and we received some custom sections,
531///   in which case we use them to determine the headers.
532/// - We have at least one rmeta time, so we hard-code Frontend and Codegen headers.
533/// - We only have total durations, so we don't add any additional headers.
534pub fn aggregate_sections(
535    sections: IndexMap<String, CompilationSection>,
536    end: f64,
537    rmeta_time: Option<f64>,
538) -> Option<Vec<(SectionName, SectionData)>> {
539    if !sections.is_empty() {
540        // We have some detailed compilation section timings, so we postprocess them
541        // Since it is possible that we do not have an end timestamp for a given compilation
542        // section, we need to iterate them and if an end is missing, we assign the end of
543        // the section to the start of the following section.
544        let mut sections = sections.into_iter().fold(
545            // The frontend section is currently implicit in rustc.
546            // It is assumed to start at compilation start and end when codegen starts,
547            // So we hard-code it here.
548            vec![(
549                SectionName::Frontend,
550                SectionData {
551                    start: 0.0,
552                    end: round_to_centisecond(end),
553                },
554            )],
555            |mut sections, (name, section)| {
556                let previous = sections.last_mut().unwrap();
557                // Setting the end of previous to the start of the current.
558                previous.1.end = section.start;
559
560                sections.push((
561                    SectionName::Named(name),
562                    SectionData {
563                        start: round_to_centisecond(section.start),
564                        end: round_to_centisecond(section.end.unwrap_or(end)),
565                    },
566                ));
567
568                sections
569            },
570        );
571
572        // We draw the sections in the pipeline graph in a way where the frontend
573        // section has the "default" build color, and then additional sections
574        // (codegen, link) are overlaid on top with a different color.
575        // However, there might be some time after the final (usually link) section,
576        // which definitely shouldn't be classified as "Frontend". We thus try to
577        // detect this situation and add a final "Other" section.
578        if let Some((_, section)) = sections.last()
579            && section.end < end
580        {
581            sections.push((
582                SectionName::Other,
583                SectionData {
584                    start: round_to_centisecond(section.end),
585                    end: round_to_centisecond(end),
586                },
587            ));
588        }
589        Some(sections)
590    } else if let Some(rmeta) = rmeta_time {
591        // We only know when the rmeta time was generated
592        Some(vec![
593            (
594                SectionName::Frontend,
595                SectionData {
596                    start: 0.0,
597                    end: round_to_centisecond(rmeta),
598                },
599            ),
600            (
601                SectionName::Codegen,
602                SectionData {
603                    start: round_to_centisecond(rmeta),
604                    end: round_to_centisecond(end),
605                },
606            ),
607        ])
608    } else {
609        // No section data provided. We only know the total duration.
610        None
611    }
612}
613
614/// Rounds seconds to 0.01s precision.
615pub fn round_to_centisecond(x: f64) -> f64 {
616    (x * 100.0).round() / 100.0
617}
618
619static HTML_TMPL: &str = r#"
620<html>
621<head>
622  <title>Cargo Build Timings — {ROOTS}</title>
623  <meta charset="utf-8">
624<style type="text/css">
625:root {
626  --error-text: #e80000;
627  --text: #000;
628  --background: #fff;
629  --h1-border-bottom: #c0c0c0;
630  --table-box-shadow: rgba(0, 0, 0, 0.1);
631  --table-th: #d5dde5;
632  --table-th-background: #1b1e24;
633  --table-th-border-bottom: #9ea7af;
634  --table-th-border-right: #343a45;
635  --table-tr-border-top: #c1c3d1;
636  --table-tr-border-bottom: #c1c3d1;
637  --table-tr-odd-background: #ebebeb;
638  --table-td-background: #ffffff;
639  --table-td-border-right: #C1C3D1;
640  --canvas-background: #f7f7f7;
641  --canvas-axes: #303030;
642  --canvas-grid: #e6e6e6;
643  --canvas-codegen: #aa95e8;
644  --canvas-link: #95e8aa;
645  --canvas-other: #e895aa;
646  --canvas-custom-build: #f0b165;
647  --canvas-not-custom-build: #95cce8;
648  --canvas-dep-line: #ddd;
649  --canvas-dep-line-highlighted: #000;
650  --canvas-cpu: rgba(250, 119, 0, 0.2);
651}
652
653@media (prefers-color-scheme: dark) {
654  :root {
655    --error-text: #e80000;
656    --text: #fff;
657    --background: #121212;
658    --h1-border-bottom: #444;
659    --table-box-shadow: rgba(255, 255, 255, 0.1);
660    --table-th: #a0a0a0;
661    --table-th-background: #2c2c2c;
662    --table-th-border-bottom: #555;
663    --table-th-border-right: #444;
664    --table-tr-border-top: #333;
665    --table-tr-border-bottom: #333;
666    --table-tr-odd-background: #1e1e1e;
667    --table-td-background: #262626;
668    --table-td-border-right: #333;
669    --canvas-background: #1a1a1a;
670    --canvas-axes: #b0b0b0;
671    --canvas-grid: #333;
672    --canvas-block: #aa95e8;
673    --canvas-custom-build: #f0b165;
674    --canvas-not-custom-build: #95cce8;
675    --canvas-dep-line: #444;
676    --canvas-dep-line-highlighted: #fff;
677    --canvas-cpu: rgba(250, 119, 0, 0.2);
678  }
679}
680
681html {
682  font-family: sans-serif;
683  color: var(--text);
684  background: var(--background);
685}
686
687.canvas-container {
688  position: relative;
689  margin-top: 5px;
690  margin-bottom: 5px;
691}
692
693.canvas-container.hidden {
694  display: none;
695}
696
697h1 {
698  border-bottom: 1px solid var(--h1-border-bottom);
699}
700
701.graph {
702  display: block;
703}
704
705.my-table {
706  margin-top: 20px;
707  margin-bottom: 20px;
708  border-collapse: collapse;
709  box-shadow: 0 5px 10px var(--table-box-shadow);
710}
711
712.my-table th {
713  color: var(--table-th);
714  background: var(--table-th-background);
715  border-bottom: 4px solid var(--table-th-border-bottom);
716  border-right: 1px solid var(--table-th-border-right);
717  font-size: 18px;
718  font-weight: 100;
719  padding: 12px;
720  text-align: left;
721  vertical-align: middle;
722}
723
724.my-table th:first-child {
725  border-top-left-radius: 3px;
726}
727
728.my-table th:last-child {
729  border-top-right-radius: 3px;
730  border-right:none;
731}
732
733.my-table tr {
734  border-top: 1px solid var(--table-tr-border-top);
735  border-bottom: 1px solid var(--table-tr-border-bottom);
736  font-size: 16px;
737  font-weight: normal;
738}
739
740.my-table tr:first-child {
741  border-top:none;
742}
743
744.my-table tr:last-child {
745  border-bottom:none;
746}
747
748.my-table tr:nth-child(odd) td {
749  background: var(--table-tr-odd-background);
750}
751
752.my-table tr:last-child td:first-child {
753  border-bottom-left-radius:3px;
754}
755
756.my-table tr:last-child td:last-child {
757  border-bottom-right-radius:3px;
758}
759
760.my-table td {
761  background: var(--table-td-background);
762  padding: 10px;
763  text-align: left;
764  vertical-align: middle;
765  font-weight: 300;
766  font-size: 14px;
767  border-right: 1px solid var(--table-td-border-right);
768}
769
770.my-table td:last-child {
771  border-right: 0px;
772}
773
774.summary-table td:first-child {
775  vertical-align: top;
776  text-align: right;
777}
778
779.input-table td {
780  text-align: center;
781}
782
783.error-text {
784  color: var(--error-text);
785}
786
787</style>
788</head>
789<body>
790
791<h1>Cargo Build Timings</h1>
792See <a href="https://doc.rust-lang.org/nightly/cargo/reference/timings.html">Documentation</a>
793"#;
794
795static HTML_CANVAS: &str = r#"
796<table class="input-table">
797  <tr>
798    <td><label for="min-unit-time">Min unit time:</label></td>
799    <td title="Scale corresponds to a number of pixels per second. It is automatically initialized based on your viewport width.">
800      <label for="scale">Scale:</label>
801    </td>
802    <td>Renderer:</td>
803  </tr>
804  <tr>
805    <td><input type="range" min="0" max="30" step="0.1" value="0" id="min-unit-time"></td>
806    <!--
807        The scale corresponds to some number of "pixels per second".
808        Its min, max, and initial values are automatically set by JavaScript on page load,
809        based on the client viewport.
810    -->
811    <td><input type="range" min="1" max="100" value="50" id="scale"></td>
812    <td>
813        <label>
814            <input type="radio" name="renderer" value="canvas" checked />
815            Canvas
816        </label>
817        <label>
818            <input type="radio" name="renderer" value="svg" />
819            SVG
820        </label>
821    </td>
822  </tr>
823  <tr>
824    <td><output for="min-unit-time" id="min-unit-time-output"></output></td>
825    <td><output for="scale" id="scale-output"></output></td>
826    <td></td>
827  </tr>
828</table>
829
830<div id="pipeline-container" class="canvas-container" part="canvas">
831 <canvas id="pipeline-graph" class="graph" style="position: absolute; left: 0; top: 0; z-index: 0;"></canvas>
832 <canvas id="pipeline-graph-lines" style="position: absolute; left: 0; top: 0; z-index: 1; pointer-events:none;"></canvas>
833</div>
834<div class="canvas-container" part="canvas">
835  <canvas id="timing-graph" class="graph"></canvas>
836</div>
837<div id="pipeline-container-svg" class="canvas-container" part="svg"></div>
838<div id="timing-container-svg" class="canvas-container" part="svg"></div>
839"#;