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;
13
14use super::CompilationSection;
15use super::UnitData;
16use super::UnitTime;
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 triple (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    write!(
288        f,
289        r#"
290<table class="my-table">
291<thead>
292<tr>
293  <th></th>
294  <th>Unit</th>
295  <th>Total</th>
296  {headers}
297  <th>Features</th>
298</tr>
299</thead>
300<tbody>
301"#,
302        headers = headers
303            .iter()
304            .map(|h| format!("<th>{}</th>", h.capitalized_name()))
305            .join("\n")
306    )?;
307
308    for (i, (unit, aggregated_sections)) in units.iter().zip(aggregated).enumerate() {
309        let format_duration = |section: Option<&SectionData>| match section {
310            Some(section) => {
311                let duration = section.duration();
312                let pct = (duration / unit.duration) * 100.0;
313                format!("{duration:.1}s ({:.0}%)", pct)
314            }
315            None => "".to_string(),
316        };
317
318        // This is a bit complex, as we assume the most general option - we can have an
319        // arbitrary set of headers, and an arbitrary set of sections per unit, so we always
320        // initiate the cells to be empty, and then try to find a corresponding column for which
321        // we might have data.
322        let mut cells: HashMap<_, _> = aggregated_sections
323            .iter()
324            .flat_map(|sections| sections.into_iter().map(|s| (&s.0, &s.1)))
325            .collect();
326
327        let cells = headers
328            .iter()
329            .map(|header| format!("<td>{}</td>", format_duration(cells.remove(header))))
330            .join("\n");
331
332        let features = unit.features.join(", ");
333        write!(
334            f,
335            r#"
336<tr>
337<td>{}.</td>
338<td>{}{}</td>
339<td>{:.1}s</td>
340{cells}
341<td>{features}</td>
342</tr>
343"#,
344            i + 1,
345            format_args!("{} v{}", unit.name, unit.version),
346            unit.target,
347            unit.duration,
348        )?;
349    }
350    write!(f, "</tbody>\n</table>\n")?;
351    Ok(())
352}
353
354pub(super) fn to_unit_data(
355    unit_times: &[UnitTime],
356    unit_map: &HashMap<Unit, u64>,
357) -> Vec<UnitData> {
358    unit_times
359        .iter()
360        .map(|ut| (unit_map[&ut.unit], ut))
361        .map(|(i, ut)| {
362            let mode = if ut.unit.mode.is_run_custom_build() {
363                "run-custom-build"
364            } else {
365                "todo"
366            }
367            .to_string();
368            // These filter on the unlocked units because not all unlocked
369            // units are actually "built". For example, Doctest mode units
370            // don't actually generate artifacts.
371            let unblocked_units = ut
372                .unblocked_units
373                .iter()
374                .filter_map(|unit| unit_map.get(unit).copied())
375                .collect();
376            let unblocked_rmeta_units = ut
377                .unblocked_rmeta_units
378                .iter()
379                .filter_map(|unit| unit_map.get(unit).copied())
380                .collect();
381            let sections = aggregate_sections(ut.sections.clone(), ut.duration, ut.rmeta_time);
382
383            UnitData {
384                i,
385                name: ut.unit.pkg.name().to_string(),
386                version: ut.unit.pkg.version().to_string(),
387                mode,
388                target: ut.target.clone(),
389                features: ut.unit.features.iter().map(|f| f.to_string()).collect(),
390                start: round_to_centisecond(ut.start),
391                duration: round_to_centisecond(ut.duration),
392                unblocked_units,
393                unblocked_rmeta_units,
394                sections,
395            }
396        })
397        .collect()
398}
399
400/// Derives concurrency information from unit timing data.
401pub fn compute_concurrency(unit_data: &[UnitData]) -> Vec<Concurrency> {
402    if unit_data.is_empty() {
403        return Vec::new();
404    }
405
406    let unit_by_index: HashMap<_, _> = unit_data.iter().map(|u| (u.i, u)).collect();
407
408    enum UnblockedBy {
409        Rmeta(u64),
410        Full(u64),
411    }
412
413    // unit_id -> unit that unblocks it.
414    let mut unblocked_by: HashMap<_, _> = HashMap::new();
415    for unit in unit_data {
416        for id in unit.unblocked_rmeta_units.iter() {
417            assert!(
418                unblocked_by
419                    .insert(*id, UnblockedBy::Rmeta(unit.i))
420                    .is_none()
421            );
422        }
423
424        for id in unit.unblocked_units.iter() {
425            assert!(
426                unblocked_by
427                    .insert(*id, UnblockedBy::Full(unit.i))
428                    .is_none()
429            );
430        }
431    }
432
433    let ready_time = |unit: &UnitData| -> Option<f64> {
434        let dep = unblocked_by.get(&unit.i)?;
435        match dep {
436            UnblockedBy::Rmeta(id) => {
437                let dep = unit_by_index.get(id)?;
438                let duration = dep.sections.iter().flatten().find_map(|(name, section)| {
439                    matches!(name, SectionName::Frontend).then_some(section.end)
440                });
441
442                Some(dep.start + duration.unwrap_or(dep.duration))
443            }
444            UnblockedBy::Full(id) => {
445                let dep = unit_by_index.get(id)?;
446                Some(dep.start + dep.duration)
447            }
448        }
449    };
450
451    #[derive(Debug, Clone, Copy, Eq, PartialEq, Ord, PartialOrd)]
452    enum State {
453        Ready,
454        Start,
455        End,
456    }
457
458    let mut events: Vec<_> = unit_data
459        .iter()
460        .flat_map(|unit| {
461            // Adding rounded numbers may cause ready > start,
462            // so cap with unit.start here to be defensive.
463            let ready = ready_time(unit).unwrap_or(unit.start).min(unit.start);
464
465            [
466                (ready, State::Ready, unit.i),
467                (unit.start, State::Start, unit.i),
468                (unit.start + unit.duration, State::End, unit.i),
469            ]
470        })
471        .collect();
472
473    events.sort_by(|a, b| {
474        a.0.partial_cmp(&b.0)
475            .unwrap()
476            .then_with(|| a.1.cmp(&b.1))
477            .then_with(|| a.2.cmp(&b.2))
478    });
479
480    let mut concurrency: Vec<Concurrency> = Vec::new();
481    let mut inactive: HashSet<u64> = unit_data.iter().map(|unit| unit.i).collect();
482    let mut waiting: HashSet<u64> = HashSet::new();
483    let mut active: HashSet<u64> = HashSet::new();
484
485    for (t, state, unit_id) in events {
486        match state {
487            State::Ready => {
488                inactive.remove(&unit_id);
489                waiting.insert(unit_id);
490                active.remove(&unit_id);
491            }
492            State::Start => {
493                inactive.remove(&unit_id);
494                waiting.remove(&unit_id);
495                active.insert(unit_id);
496            }
497            State::End => {
498                inactive.remove(&unit_id);
499                waiting.remove(&unit_id);
500                active.remove(&unit_id);
501            }
502        }
503
504        let record = Concurrency {
505            t,
506            active: active.len(),
507            waiting: waiting.len(),
508            inactive: inactive.len(),
509        };
510
511        if let Some(last) = concurrency.last_mut()
512            && last.t == t
513        {
514            // We don't want to draw long vertical lines at the same timestamp,
515            // so we keep only the latest state.
516            *last = record;
517        } else {
518            concurrency.push(record);
519        }
520    }
521
522    concurrency
523}
524
525/// Aggregates section timing information from individual compilation sections.
526///
527/// We can have a bunch of situations here.
528///
529/// - `-Zsection-timings` is enabled, and we received some custom sections,
530///   in which case we use them to determine the headers.
531/// - We have at least one rmeta time, so we hard-code Frontend and Codegen headers.
532/// - We only have total durations, so we don't add any additional headers.
533pub fn aggregate_sections(
534    sections: IndexMap<String, CompilationSection>,
535    end: f64,
536    rmeta_time: Option<f64>,
537) -> Option<Vec<(SectionName, SectionData)>> {
538    if !sections.is_empty() {
539        // We have some detailed compilation section timings, so we postprocess them
540        // Since it is possible that we do not have an end timestamp for a given compilation
541        // section, we need to iterate them and if an end is missing, we assign the end of
542        // the section to the start of the following section.
543        let mut sections = sections.into_iter().fold(
544            // The frontend section is currently implicit in rustc.
545            // It is assumed to start at compilation start and end when codegen starts,
546            // So we hard-code it here.
547            vec![(
548                SectionName::Frontend,
549                SectionData {
550                    start: 0.0,
551                    end: round_to_centisecond(end),
552                },
553            )],
554            |mut sections, (name, section)| {
555                let previous = sections.last_mut().unwrap();
556                // Setting the end of previous to the start of the current.
557                previous.1.end = section.start;
558
559                sections.push((
560                    SectionName::Named(name),
561                    SectionData {
562                        start: round_to_centisecond(section.start),
563                        end: round_to_centisecond(section.end.unwrap_or(end)),
564                    },
565                ));
566
567                sections
568            },
569        );
570
571        // We draw the sections in the pipeline graph in a way where the frontend
572        // section has the "default" build color, and then additional sections
573        // (codegen, link) are overlaid on top with a different color.
574        // However, there might be some time after the final (usually link) section,
575        // which definitely shouldn't be classified as "Frontend". We thus try to
576        // detect this situation and add a final "Other" section.
577        if let Some((_, section)) = sections.last()
578            && section.end < end
579        {
580            sections.push((
581                SectionName::Other,
582                SectionData {
583                    start: round_to_centisecond(section.end),
584                    end: round_to_centisecond(end),
585                },
586            ));
587        }
588        Some(sections)
589    } else if let Some(rmeta) = rmeta_time {
590        // We only know when the rmeta time was generated
591        Some(vec![
592            (
593                SectionName::Frontend,
594                SectionData {
595                    start: 0.0,
596                    end: round_to_centisecond(rmeta),
597                },
598            ),
599            (
600                SectionName::Codegen,
601                SectionData {
602                    start: round_to_centisecond(rmeta),
603                    end: round_to_centisecond(end),
604                },
605            ),
606        ])
607    } else {
608        // No section data provided. We only know the total duration.
609        None
610    }
611}
612
613/// Rounds seconds to 0.01s precision.
614pub fn round_to_centisecond(x: f64) -> f64 {
615    (x * 100.0).round() / 100.0
616}
617
618static HTML_TMPL: &str = r#"
619<html>
620<head>
621  <title>Cargo Build Timings — {ROOTS}</title>
622  <meta charset="utf-8">
623<style type="text/css">
624:root {
625  --error-text: #e80000;
626  --text: #000;
627  --background: #fff;
628  --h1-border-bottom: #c0c0c0;
629  --table-box-shadow: rgba(0, 0, 0, 0.1);
630  --table-th: #d5dde5;
631  --table-th-background: #1b1e24;
632  --table-th-border-bottom: #9ea7af;
633  --table-th-border-right: #343a45;
634  --table-tr-border-top: #c1c3d1;
635  --table-tr-border-bottom: #c1c3d1;
636  --table-tr-odd-background: #ebebeb;
637  --table-td-background: #ffffff;
638  --table-td-border-right: #C1C3D1;
639  --canvas-background: #f7f7f7;
640  --canvas-axes: #303030;
641  --canvas-grid: #e6e6e6;
642  --canvas-codegen: #aa95e8;
643  --canvas-link: #95e8aa;
644  --canvas-other: #e895aa;
645  --canvas-custom-build: #f0b165;
646  --canvas-not-custom-build: #95cce8;
647  --canvas-dep-line: #ddd;
648  --canvas-dep-line-highlighted: #000;
649  --canvas-cpu: rgba(250, 119, 0, 0.2);
650}
651
652@media (prefers-color-scheme: dark) {
653  :root {
654    --error-text: #e80000;
655    --text: #fff;
656    --background: #121212;
657    --h1-border-bottom: #444;
658    --table-box-shadow: rgba(255, 255, 255, 0.1);
659    --table-th: #a0a0a0;
660    --table-th-background: #2c2c2c;
661    --table-th-border-bottom: #555;
662    --table-th-border-right: #444;
663    --table-tr-border-top: #333;
664    --table-tr-border-bottom: #333;
665    --table-tr-odd-background: #1e1e1e;
666    --table-td-background: #262626;
667    --table-td-border-right: #333;
668    --canvas-background: #1a1a1a;
669    --canvas-axes: #b0b0b0;
670    --canvas-grid: #333;
671    --canvas-block: #aa95e8;
672    --canvas-custom-build: #f0b165;
673    --canvas-not-custom-build: #95cce8;
674    --canvas-dep-line: #444;
675    --canvas-dep-line-highlighted: #fff;
676    --canvas-cpu: rgba(250, 119, 0, 0.2);
677  }
678}
679
680html {
681  font-family: sans-serif;
682  color: var(--text);
683  background: var(--background);
684}
685
686.canvas-container {
687  position: relative;
688  margin-top: 5px;
689  margin-bottom: 5px;
690}
691
692h1 {
693  border-bottom: 1px solid var(--h1-border-bottom);
694}
695
696.graph {
697  display: block;
698}
699
700.my-table {
701  margin-top: 20px;
702  margin-bottom: 20px;
703  border-collapse: collapse;
704  box-shadow: 0 5px 10px var(--table-box-shadow);
705}
706
707.my-table th {
708  color: var(--table-th);
709  background: var(--table-th-background);
710  border-bottom: 4px solid var(--table-th-border-bottom);
711  border-right: 1px solid var(--table-th-border-right);
712  font-size: 18px;
713  font-weight: 100;
714  padding: 12px;
715  text-align: left;
716  vertical-align: middle;
717}
718
719.my-table th:first-child {
720  border-top-left-radius: 3px;
721}
722
723.my-table th:last-child {
724  border-top-right-radius: 3px;
725  border-right:none;
726}
727
728.my-table tr {
729  border-top: 1px solid var(--table-tr-border-top);
730  border-bottom: 1px solid var(--table-tr-border-bottom);
731  font-size: 16px;
732  font-weight: normal;
733}
734
735.my-table tr:first-child {
736  border-top:none;
737}
738
739.my-table tr:last-child {
740  border-bottom:none;
741}
742
743.my-table tr:nth-child(odd) td {
744  background: var(--table-tr-odd-background);
745}
746
747.my-table tr:last-child td:first-child {
748  border-bottom-left-radius:3px;
749}
750
751.my-table tr:last-child td:last-child {
752  border-bottom-right-radius:3px;
753}
754
755.my-table td {
756  background: var(--table-td-background);
757  padding: 10px;
758  text-align: left;
759  vertical-align: middle;
760  font-weight: 300;
761  font-size: 14px;
762  border-right: 1px solid var(--table-td-border-right);
763}
764
765.my-table td:last-child {
766  border-right: 0px;
767}
768
769.summary-table td:first-child {
770  vertical-align: top;
771  text-align: right;
772}
773
774.input-table td {
775  text-align: center;
776}
777
778.error-text {
779  color: var(--error-text);
780}
781
782</style>
783</head>
784<body>
785
786<h1>Cargo Build Timings</h1>
787See <a href="https://doc.rust-lang.org/nightly/cargo/reference/timings.html">Documentation</a>
788"#;
789
790static HTML_CANVAS: &str = r#"
791<table class="input-table">
792  <tr>
793    <td><label for="min-unit-time">Min unit time:</label></td>
794    <td title="Scale corresponds to a number of pixels per second. It is automatically initialized based on your viewport width.">
795      <label for="scale">Scale:</label>
796    </td>
797  </tr>
798  <tr>
799    <td><input type="range" min="0" max="30" step="0.1" value="0" id="min-unit-time"></td>
800    <!--
801        The scale corresponds to some number of "pixels per second".
802        Its min, max, and initial values are automatically set by JavaScript on page load,
803        based on the client viewport.
804    -->
805    <td><input type="range" min="1" max="100" value="50" id="scale"></td>
806  </tr>
807  <tr>
808    <td><output for="min-unit-time" id="min-unit-time-output"></output></td>
809    <td><output for="scale" id="scale-output"></output></td>
810  </tr>
811</table>
812
813<div id="pipeline-container" class="canvas-container">
814 <canvas id="pipeline-graph" class="graph" style="position: absolute; left: 0; top: 0; z-index: 0;"></canvas>
815 <canvas id="pipeline-graph-lines" style="position: absolute; left: 0; top: 0; z-index: 1; pointer-events:none;"></canvas>
816</div>
817<div class="canvas-container">
818  <canvas id="timing-graph" class="graph"></canvas>
819</div>
820"#;