1use 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('&', "&")
27 .replace('<', "<")
28 .replace('>', ">")
29 .replace('"', """)
30 .replace('\'', "'")
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
121pub 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 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}