Skip to main content

xtask/
report.rs

1//! Markdown and file:// HTML reports use the same filtered measurements.
2use crate::util;
3use anyhow::{Context, Result};
4use serde_json::{Value, json};
5use std::{fmt::Write as _, fs, path::Path};
6
7pub(crate) mod markdown;
8
9pub fn median(mut values: Vec<f64>) -> f64 {
10    values.sort_by(f64::total_cmp);
11
12    let n = values.len();
13
14    if n % 2 == 1 {
15        values[n / 2]
16    } else {
17        (values[n / 2 - 1] + values[n / 2]) / 2.0
18    }
19}
20
21pub fn text(v: &Value) -> &str {
22    v.as_str().unwrap_or("")
23}
24
25fn escape(s: &str) -> String {
26    s.replace('&', "&amp;")
27        .replace('<', "&lt;")
28        .replace('>', "&gt;")
29        .replace('"', "&quot;")
30        .replace('\'', "&#39;")
31}
32
33pub fn rows(report: &Value) -> Result<Vec<Value>> {
34    let mut rows = Vec::new();
35
36    for case in report["cases"].as_array().context("report cases")? {
37        for config in report["configurations"]
38            .as_array()
39            .context("report configurations")?
40        {
41            let matching: Vec<_> = report["runs"]
42                .as_array()
43                .context("report runs")?
44                .iter()
45                .filter(|r| {
46                    r["case"] == case["id"]
47                        && r["configuration"] == config["id"]
48                        && r["status"] == "ok"
49                })
50                .map(|r| &r["metrics"])
51                .collect();
52
53            if matching.is_empty() {
54                continue;
55            }
56
57            let memory = matching
58                .iter()
59                .filter_map(|r| r["metal_gb"].as_f64())
60                .fold(0.0, f64::max);
61            let mut row = json!({
62                "case": case["id"],
63                "kind": case["kind"].as_str().unwrap_or("decode"),
64                "configuration": config["id"],
65                "label": config["label"],
66                "runs": matching.len(),
67                "metal_gb": memory,
68            });
69
70            for key in [
71                "prompt_tokens",
72                "prefill_seconds",
73                "output_tokens",
74                "decode_seconds",
75                "load_seconds",
76                "pp_s",
77                "tg_s",
78            ] {
79                let values = matching
80                    .iter()
81                    .map(|r| r[key].as_f64().with_context(|| format!("missing {key}")))
82                    .collect::<Result<Vec<_>>>()?;
83                row[key] = json!(median(values));
84            }
85
86            rows.push(row);
87        }
88    }
89
90    Ok(rows)
91}
92
93fn number(row: &Value, key: &str) -> f64 {
94    row[key].as_f64().unwrap_or(0.0)
95}
96
97fn case_label(id: &str) -> &str {
98    match id {
99        "code" => "Linked-list code",
100        "code-lru" => "LRU cache",
101        "debug-bisect" => "Binary-search debugging",
102        "prose" => "Hash-table explanation",
103        "reasoning" => "Scheduling arithmetic",
104        "structured" => "JSON extraction",
105        "prefill-long" => "Long document",
106        "pelican" => "Pelican SVG",
107        _ => id,
108    }
109}
110
111fn config_label(config: &Value) -> &str {
112    match text(&config["id"]) {
113        "exact-4bit" => "4-bit",
114        "misses-2bit" => "4/2-bit + cut",
115        "all-3bit" => "3-bit",
116        "all-2bit" => "2-bit",
117        _ => text(&config["label"]),
118    }
119}
120
121/// One line of machine facts for reports that carry `hardware_detail`.
122pub fn hardware_line(provenance: &Value) -> Option<String> {
123    let detail = provenance.get("hardware_detail")?;
124    let profile = &detail["profile"];
125    let mut parts = Vec::new();
126
127    if !profile["chip"].is_null() {
128        parts.push(match profile["gpu_cores"].as_str() {
129            Some(cores) => format!("{} with a {cores}-core GPU", text(&profile["chip"])),
130            None => text(&profile["chip"]).to_owned(),
131        });
132    }
133
134    if let Some(bytes) = detail["memory_bytes"].as_u64() {
135        parts.push(format!("{} GiB memory", bytes >> 30));
136    } else if !profile["memory"].is_null() {
137        parts.push(format!("{} memory", text(&profile["memory"])));
138    }
139
140    if let Some(drive) = profile["nvme"].as_array().and_then(|d| d.first()) {
141        parts.push(format!(
142            "{} {}",
143            text(&drive["model"]),
144            text(&drive["size"])
145        ));
146    }
147
148    if let Some(gbps) = detail["store_read"]["gbps"].as_f64() {
149        parts.push(format!("expert store reads at {gbps:.1} GB/s"));
150    }
151
152    if let Some(version) = detail["os_version"].as_str() {
153        parts.push(
154            if detail["kernel"]
155                .as_str()
156                .is_some_and(|k| k.starts_with("Darwin"))
157            {
158                format!("macOS {version}")
159            } else {
160                version.to_owned()
161            },
162        );
163    } else if let Some(kernel) = detail["kernel"].as_str() {
164        parts.push(kernel.to_owned());
165    }
166
167    let note = provenance["note"]
168        .as_str()
169        .map(|n| format!(" Note: {n}"))
170        .unwrap_or_default();
171
172    (!parts.is_empty()).then(|| format!("Hardware: {}.{note}", parts.join(", ")))
173}
174
175pub fn write(out: &Path, report: &Value) -> Result<()> {
176    util::write_json(&out.join("report.json"), report)?;
177
178    regenerate(out, report)
179}
180
181pub fn regenerate(out: &Path, report: &Value) -> Result<()> {
182    let rows = rows(report)?;
183    let mut summary = format!(
184        "# Cherenkov benchmark\n\nSource commit: `{}`\n\n",
185        text(&report["provenance"]["commit"])
186    );
187
188    if let Some(line) = hardware_line(&report["provenance"]) {
189        summary.push_str(&line);
190        summary.push_str("\n\n");
191    }
192
193    summary.push_str("| Case | Configuration | Runs | Output tokens | Decode s | Load s | pp/s | tg/s | Metal GB |\n| --- | --- | ---: | ---: | ---: | ---: | ---: | ---: | ---: |\n");
194
195    for row in &rows {
196        writeln!(
197            summary,
198            "| {} | {} | {} | {:.0} | {:.2} | {:.2} | {:.1} | {:.2} | {:.2} |",
199            text(&row["case"]),
200            text(&row["label"]),
201            row["runs"],
202            number(row, "output_tokens"),
203            number(row, "decode_seconds"),
204            number(row, "load_seconds"),
205            number(row, "pp_s"),
206            number(row, "tg_s"),
207            number(row, "metal_gb")
208        )?;
209    }
210
211    if out.join("README.md").is_file() {
212        summary.push_str("\n[Run observations](README.md).\n");
213    }
214
215    append_failures(&mut summary, report)?;
216
217    let pelicans = markdown::drawings(report, Path::new(""))?;
218
219    if !pelicans.is_empty() {
220        summary
221            .push_str("\n## Pelicans\n\nThese are unedited model outputs from the benchmark.\n\n");
222        summary.push_str(pelicans.trim_end());
223        summary.push('\n');
224    }
225
226    summary.push_str(&markdown::answers(report)?);
227    fs::write(out.join("summary.md"), summary)?;
228    fs::write(out.join("gallery.html"), gallery(report, &rows)?)?;
229
230    Ok(())
231}
232
233fn append_failures(summary: &mut String, report: &Value) -> Result<()> {
234    let bad: Vec<_> = report["runs"]
235        .as_array()
236        .context("report runs")?
237        .iter()
238        .filter(|r| r["status"] != "ok")
239        .collect();
240
241    if !bad.is_empty() {
242        summary.push_str("\n## Incomplete or invalid runs\n\n");
243
244        for r in bad {
245            writeln!(
246                summary,
247                "- {}: {}",
248                text(&r["id"]),
249                r["error"].as_str().unwrap_or(text(&r["status"]))
250            )?;
251        }
252    }
253
254    Ok(())
255}
256
257fn table(report: &Value, rows: &[Value], kind: &str, key: &str, caption: &str) -> Result<String> {
258    let configs = report["configurations"]
259        .as_array()
260        .context("configurations")?;
261    let mut table = format!(
262        "<div class=table-scroll tabindex=0><table><caption>{caption}</caption><thead><tr><th scope=col>Workload</th>"
263    );
264
265    for c in configs {
266        write!(
267            table,
268            "<th scope=col title=\"{}\">{}</th>",
269            escape(text(&c["label"])),
270            escape(config_label(c))
271        )?;
272    }
273
274    table.push_str("</tr></thead><tbody>");
275
276    for case in report["cases"].as_array().context("cases")? {
277        if case["kind"].as_str().unwrap_or("decode") != kind {
278            continue;
279        }
280
281        write!(
282            table,
283            "<tr><th scope=row>{}</th>",
284            escape(case_label(text(&case["id"])))
285        )?;
286
287        for config in configs {
288            match rows
289                .iter()
290                .find(|r| r["case"] == case["id"] && r["configuration"] == config["id"])
291            {
292                Some(row) => write!(table, "<td>{:.2}</td>", number(row, key))?,
293                None => table.push_str("<td>Pending</td>"),
294            }
295        }
296
297        table.push_str("</tr>");
298    }
299
300    table.push_str("</tbody></table></div>");
301
302    Ok(table)
303}
304
305fn details(rows: &[Value]) -> Result<String> {
306    let fields = [
307        ("runs", "Runs"),
308        ("prompt_tokens", "Prompt tokens"),
309        ("prefill_seconds", "Prefill s"),
310        ("pp_s", "pp/s"),
311        ("output_tokens", "Output tokens"),
312        ("decode_seconds", "Decode s"),
313        ("tg_s", "tg/s"),
314        ("load_seconds", "Load s"),
315        ("metal_gb", "Metal GB"),
316    ];
317    let mut s = String::from(
318        "<details><summary>Full phase timings and answer lengths</summary><div class=table-scroll tabindex=0><table class=details-table><thead><tr><th>Workload</th><th>Configuration</th>",
319    );
320
321    for (_, label) in fields {
322        write!(s, "<th>{label}</th>")?;
323    }
324
325    s.push_str("</tr></thead><tbody>");
326
327    for row in rows {
328        write!(
329            s,
330            "<tr><th scope=row>{}</th><td class=label>{}</td>",
331            escape(case_label(text(&row["case"]))),
332            escape(text(&row["label"]))
333        )?;
334
335        for (key, _) in fields {
336            let decimals = match key {
337                "runs" | "prompt_tokens" | "output_tokens" => 0,
338                _ => 2,
339            };
340
341            write!(s, "<td>{:.*}</td>", decimals, number(row, key))?;
342        }
343
344        s.push_str("</tr>");
345    }
346
347    s.push_str("</tbody></table></div></details>");
348
349    Ok(s)
350}
351
352fn cards(report: &Value) -> Result<String> {
353    let mut cards = String::new();
354
355    for case in report["cases"].as_array().context("cases")? {
356        if case["kind"] != "svg" {
357            continue;
358        }
359
360        for config in report["configurations"]
361            .as_array()
362            .context("configurations")?
363        {
364            write!(
365                cards,
366                "<article><h3>{}</h3>",
367                escape(text(&config["label"]))
368            )?;
369
370            let run = report["runs"]
371                .as_array()
372                .context("runs")?
373                .iter()
374                .rev()
375                .find(|r| r["case"] == case["id"] && r["configuration"] == config["id"]);
376
377            if let Some(run) = run {
378                card_body(&mut cards, run)?;
379            } else {
380                cards.push_str("<p>The drawing is queued after the timing workloads.</p>");
381            }
382
383            cards.push_str("</article>");
384        }
385    }
386
387    if cards.is_empty() {
388        cards.push_str("<p>No drawing workloads selected.</p>");
389    }
390
391    Ok(cards)
392}
393
394fn card_body(card: &mut String, run: &Value) -> Result<()> {
395    if let Some(svg) = run["svg"].as_str() {
396        // Keep model SVGs passive; never insert generated markup into the page.
397        write!(
398            card,
399            "<img src=\"{}\" alt=\"Generated pelican riding a bicycle\"><p>{} elements; {} tokens</p><p>{:.2} tokens/s; {:.2} s generation</p>",
400            escape(svg),
401            run["svg_elements"],
402            run["metrics"]["output_tokens"],
403            number(&run["metrics"], "tg_s"),
404            number(&run["metrics"], "decode_seconds")
405        )?;
406    } else {
407        write!(
408            card,
409            "<p>The run produced no completed SVG: {}</p>",
410            escape(run["error"].as_str().unwrap_or(text(&run["status"])))
411        )?;
412    }
413
414    if let Some(output) = run["output"].as_str() {
415        write!(card, "<a href=\"{}\">Full output</a>", escape(output))?;
416    }
417
418    Ok(())
419}
420
421fn progress(report: &Value) -> Result<String> {
422    let runs = report["runs"].as_array().context("runs")?;
423    let configs = report["configurations"]
424        .as_array()
425        .context("configurations")?;
426    let rounds = report["signature"]["rounds"].as_u64().unwrap_or(1);
427    let samples_per_config: u64 = report["cases"]
428        .as_array()
429        .context("cases")?
430        .iter()
431        .map(|case| {
432            if case["kind"] == "svg" {
433                1
434            } else {
435                rounds.min(case["rounds"].as_u64().unwrap_or(rounds))
436            }
437        })
438        .sum();
439    let planned = configs.len() as u64 * samples_per_config;
440    let valid = runs.iter().filter(|r| r["status"] == "ok").count();
441    let drawings = runs.iter().filter(|r| r["svg"].is_string()).count();
442
443    Ok(format!(
444        "The run has {valid} of {planned} valid samples and {drawings} drawings."
445    ))
446}
447
448fn gallery(report: &Value, rows: &[Value]) -> Result<String> {
449    let mut page =
450        include_str!("../templates/gallery.html").replace("{{progress}}", &progress(report)?);
451    let tables = table(report, rows, "decode", "tg_s", "Generation: tokens/s")?
452        + &table(
453            report,
454            rows,
455            "prefill",
456            "pp_s",
457            "Prompt processing: tokens/s",
458        )?
459        + &details(rows)?;
460    page = page
461        .replace("{{timings}}", &tables)
462        .replace("{{cards}}", &cards(report)?);
463    let refresh = if report.get("utc_finished").is_none() {
464        "<meta http-equiv=\"refresh\" content=\"30\">"
465    } else {
466        ""
467    };
468
469    Ok(page.replace("{{refresh}}", refresh))
470}