浏览代码

report: decode speed columns (llama-benchy tg t/s, c1/c2 at d0) from md+log perf boxes

Alexander Korolev 1 月之前
父节点
当前提交
f121f5f873
共有 6 个文件被更改,包括 195 次插入 和 29 次删除
  1. 2 0
      locales/en/main.ftl
  2. 2 0
      locales/ru/main.ftl
  3. 27 23
      src/report/mod.rs
  4. 11 2
      src/report/page.html
  5. 85 0
      src/report/parse.rs
  6. 68 4
      src/report/tests.rs

+ 2 - 0
locales/en/main.ftl

@@ -217,3 +217,5 @@ report-col-reports = Reports
 report-col-last = Last bench
 report-col-points = Points
 report-col-score = Score /100
+report-col-tps1 = Tok/s c1
+report-col-tps2 = Tok/s c2

+ 2 - 0
locales/ru/main.ftl

@@ -228,3 +228,5 @@ report-col-reports = Отчёты
 report-col-last = Последний бенч
 report-col-points = Очки
 report-col-score = Счёт /100
+report-col-tps1 = Tok/s c1
+report-col-tps2 = Tok/s c2

+ 27 - 23
src/report/mod.rs

@@ -14,6 +14,9 @@ use serde::{Deserialize, Serialize};
 use std::collections::HashSet;
 use std::path::Path;
 
+pub(crate) mod parse;
+pub(crate) use parse::{PerfCell, parse_bench_json, perf_from};
+
 const PAGE_TEMPLATE: &str = include_str!("page.html");
 
 #[derive(Serialize, Deserialize)]
@@ -37,6 +40,8 @@ pub(crate) struct ReportEntry {
     points: Option<String>,
     /// points/max as a ratio, for sorting the Points column (null when absent).
     ratio: Option<f64>,
+    /// llama-benchy decode speeds (empty when the report has no perf box).
+    perf: Vec<PerfCell>,
 }
 
 #[derive(Serialize, Deserialize)]
@@ -172,26 +177,6 @@ fn slash_pair(s: &str) -> Option<(u32, u32)> {
     None
 }
 
-/// Parse the tool-eval-bench `--json-file` output (authoritative for
-/// `sm121 bench` runs): `scores.final_score` (0–100), `scores.total_points`
-/// / `max_points` (hardmode). Returns `(pct, points, ratio)`; `points` is
-/// suppressed when max is 100 (already normalized, same rule as the text
-/// parsers).
-fn parse_bench_json(content: &str) -> Option<(f64, Option<String>, Option<f64>)> {
-    let v: serde_json::Value = serde_json::from_str(content).ok()?;
-    let s = v.get("scores")?;
-    let pct = s.get("final_score").and_then(|x| x.as_f64())?;
-    let tp = s.get("total_points").and_then(|x| x.as_u64());
-    let mp = s.get("max_points").and_then(|x| x.as_u64());
-    let (points, ratio) = match (tp, mp) {
-        (Some(p), Some(m)) if m > 0 && m != 100 => {
-            (Some(format!("{p}/{m}")), Some(p as f64 / m as f64))
-        }
-        _ => (None, None),
-    };
-    Some((pct, points, ratio))
-}
-
 fn scan_reports(recipe_dir: &Path) -> Vec<ReportEntry> {
     let reports = recipe_dir.join("reports");
     let Ok(rd) = std::fs::read_dir(&reports) else {
@@ -251,6 +236,7 @@ fn scan_reports(recipe_dir: &Path) -> Vec<ReportEntry> {
             .as_deref()
             .and_then(extract_time)
             .or_else(|| log.as_deref().and_then(extract_time));
+        let perf = perf_from(md.as_deref(), log.as_deref());
         let file = files
             .iter()
             .find(|f| f.ends_with(".md"))
@@ -267,6 +253,7 @@ fn scan_reports(recipe_dir: &Path) -> Vec<ReportEntry> {
             pct,
             points,
             ratio,
+            perf,
         });
     }
     out.sort_by(|a, b| (&a.date, &a.label).cmp(&(&b.date, &b.label)));
@@ -328,6 +315,13 @@ fn latest_score(r: &ReportRow) -> Option<&ReportEntry> {
         .find(|e| e.pct.is_some() || e.points.is_some())
 }
 
+fn latest_perf(r: &ReportRow) -> Option<(&PerfCell, Option<&PerfCell>)> {
+    r.reports.iter().rev().find_map(|e| {
+        let c1 = e.perf.iter().find(|p| p.d == 0 && p.c == 1);
+        c1.map(|c1| (c1, e.perf.iter().find(|p| p.d == 0 && p.c == 2)))
+    })
+}
+
 /// Normalized score, whole numbers without decimals (81, not 81.0).
 fn fmt_pct(pct: f64) -> String {
     if (pct - pct.trunc()).abs() < 1e-9 {
@@ -337,13 +331,18 @@ fn fmt_pct(pct: f64) -> String {
     }
 }
 
+fn fmt_tps(c: Option<&PerfCell>) -> String {
+    c.map(|c| format!("{:.1}", c.tg))
+        .unwrap_or_else(|| "—".into())
+}
+
 fn markdown(rows: &[ReportRow]) -> String {
     let mut s = format!(
         "# {}\n\n",
         fluent_i18n::t!("report-title", { "count" => rows.len() })
     );
     s.push_str(&format!(
-        "| {} | {} | {} | {} | {} | {} | {} | {} | {} |\n",
+        "| {} | {} | {} | {} | {} | {} | {} | {} | {} | {} | {} |\n",
         t("report-col-model"),
         t("report-col-quant"),
         t("report-col-recipe"),
@@ -353,13 +352,16 @@ fn markdown(rows: &[ReportRow]) -> String {
         t("report-col-last"),
         t("report-col-points"),
         t("report-col-score"),
+        t("report-col-tps1"),
+        t("report-col-tps2"),
     ));
-    s.push_str("|---|---|---|---|---|---|---|---|---|\n");
+    s.push_str("|---|---|---|---|---|---|---|---|---|---|---|\n");
     for r in rows {
         let last = r.reports.last();
         let latest = latest_score(r);
+        let perf = latest_perf(r);
         s.push_str(&format!(
-            "| {} | {} | {} | {} | {} | {} | {} | {} | {} |\n",
+            "| {} | {} | {} | {} | {} | {} | {} | {} | {} | {} | {} |\n",
             r.model,
             r.quant,
             r.name,
@@ -380,6 +382,8 @@ fn markdown(rows: &[ReportRow]) -> String {
                 .and_then(|e| e.pct)
                 .map(fmt_pct)
                 .unwrap_or_else(|| "—".into()),
+            fmt_tps(perf.map(|(a, _)| a)),
+            fmt_tps(perf.and_then(|(_, b)| b)),
         ));
     }
     s

+ 11 - 2
src/report/page.html

@@ -56,8 +56,12 @@ const COLS = [
   ["engine", "Engine"], ["image", "Image"], ["nodes", "Nodes"], ["tp", "TP"],
   ["deploy", "Deploy"], ["port", "Port"], ["n", "Reports"], ["last", "Last bench"],
   ["points", "Points"], ["pct", "Score /100"],
+  ["tps1", "Tok/s c1"], ["tps2", "Tok/s c2"],
 ];
-const NUM = new Set(["nodes", "tp", "port", "n", "pct"]);
+const NUM = new Set(["nodes", "tp", "port", "n", "pct", "tps1", "tps2"]);
+
+// decode speed (tg t/s) from a run's llama-benchy perf cells
+function tg(e, d, c) { const p = e && e.perf ? e.perf.find(p => p.d === d && p.c === c) : null; return p ? p.tg : null; }
 
 // latest report matching a predicate, for the points / score columns
 function latest(r, f) { for (let i = r.reports.length - 1; i >= 0; i--) if (f(r.reports[i])) return r.reports[i]; return null; }
@@ -66,6 +70,7 @@ function cell(r, k) {
   if (k === "last") return r.reports.length ? r.reports[r.reports.length - 1].date : "";
   if (k === "points") { const e = latest(r, e => e.points); return e ? e.points : ""; }
   if (k === "pct") { const e = latest(r, e => e.pct != null); return e ? e.pct : ""; }
+  if (k === "tps1" || k === "tps2") { const e = latest(r, e => e.perf && e.perf.length); return tg(e, 0, k === "tps1" ? 1 : 2) ?? ""; }
   return r[k];
 }
 
@@ -118,6 +123,7 @@ function sorted(rows) {
   const k = S.sort, d = S.dir;
   const val = r => {
     if (k === "points") { const e = latest(r, e => e.ratio != null); return e ? e.ratio : -1; }
+    if (k === "tps1" || k === "tps2") { const e = latest(r, e => e.perf && e.perf.length); return tg(e, 0, k === "tps1" ? 1 : 2) ?? -1; }
     const v = cell(r, k); return NUM.has(k) || k === "last" ? v : String(v).toLowerCase();
   };
   return rows.slice().sort((a, b) => {
@@ -183,9 +189,12 @@ function render() {
     if (S.openRow === r.name)
       out.push(`<tr class="hist"><td colspan="${vis.length}">${r.reports.length ? r.reports.map(e => {
         const fl = e.files && e.files.length ? e.files : [e.file];
+        const ts = (e.perf && e.perf.length)
+          ? ` <span title="${esc(e.perf.map(p => "d" + p.d + " c" + p.c + ": " + p.tg + " t/s").join("  ·  "))}"> ${tg(e, 0, 1) ?? "—"} / ${tg(e, 0, 2) ?? "—"} t/s</span>`
+          : "";
         return `<span>${fl.map(f =>
           `<a class="open" href="/r/${encodeURIComponent(r.name)}/${encodeURIComponent(f)}" target="_blank" title="open ${esc(f)}">${esc(f.split('.').pop())}</a>`).join(" ")}</span> ` +
-        `${esc(e.date)}${e.time ? "·" + esc(e.time) : ""} ${esc(e.label)} — ${e.pct != null ? esc(e.pct) + " /100" : '<span class="dim">no score</span>'}${e.points ? " (" + esc(e.points) + ")" : ""}`;
+        `${esc(e.date)}${e.time ? "·" + esc(e.time) : ""} ${esc(e.label)} — ${e.pct != null ? esc(e.pct) + " /100" : '<span class="dim">no score</span>'}${e.points ? " (" + esc(e.points) + ")" : ""}${ts}`;
       }).join("<br>") : "no reports"}</td></tr>`);
   }
   document.querySelector("tbody").innerHTML = out.join("");

+ 85 - 0
src/report/parse.rs

@@ -0,0 +1,85 @@
+//! Parsers for benchmark report files: the tool-eval-bench `--json-file`
+//! output (authoritative score) and the llama-benchy perf tables found in
+//! the `.md`/`.log` reports (generation speed).
+
+use serde::{Deserialize, Serialize};
+
+/// One llama-benchy perf cell: decode speed at a context depth and
+/// concurrency.
+#[derive(Serialize, Deserialize, Clone, Copy, PartialEq, Debug)]
+pub(crate) struct PerfCell {
+    pub(crate) d: u32,  // context depth (0, 4096, 8192)
+    pub(crate) c: u32,  // concurrency (1, 2, …)
+    pub(crate) tg: f64, // decode tokens/s
+}
+
+/// Parse the tool-eval-bench `--json-file` output (authoritative for
+/// `sm121 bench` runs): `scores.final_score` (0–100), `scores.total_points`
+/// / `max_points` (hardmode). Returns `(pct, points, ratio)`; `points` is
+/// suppressed when max is 100 (already normalized, same rule as the text
+/// parsers).
+pub(crate) fn parse_bench_json(content: &str) -> Option<(f64, Option<String>, Option<f64>)> {
+    let v: serde_json::Value = serde_json::from_str(content).ok()?;
+    let s = v.get("scores")?;
+    let pct = s.get("final_score").and_then(|x| x.as_f64())?;
+    let tp = s.get("total_points").and_then(|x| x.as_u64());
+    let mp = s.get("max_points").and_then(|x| x.as_u64());
+    let (points, ratio) = match (tp, mp) {
+        (Some(p), Some(m)) if m > 0 && m != 100 => {
+            (Some(format!("{p}/{m}")), Some(p as f64 / m as f64))
+        }
+        _ => (None, None),
+    };
+    Some((pct, points, ratio))
+}
+
+/// First non-empty perf box among the given contents (md first, then log —
+/// older reports carry the box in the .md, `sm121 bench` runs in the .log).
+pub(crate) fn perf_from(md: Option<&str>, log: Option<&str>) -> Vec<PerfCell> {
+    for c in [md, log] {
+        let v = c.map(parse_perf_box).unwrap_or_default();
+        if !v.is_empty() {
+            return v;
+        }
+    }
+    Vec::new()
+}
+
+fn split_tokens(line: &str) -> impl Iterator<Item = &str> + '_ {
+    line.split(|ch: char| ch.is_whitespace() || ch == '|' || ch == '│')
+}
+
+/// Parse llama-benchy perf table rows into `(depth, concurrency, tg t/s)`
+/// cells. Two known shapes:
+///   box: `│ pp2048 tg128 @ d0    │ c1 │ 1,500 │ 27.4 │ 1,398 │ 5,959 │ 2048+128`
+///   md:  `| pp2048 tg128 @ d0 c2 | 1,728 | 36.0 | 2,097 | 8,802 | 2048+128`
+/// The `c` token (concurrency column or label suffix) is optional — absent
+/// means c1. Header/box-chrome lines have no `@ d<depth>` marker and are
+/// skipped.
+pub(crate) fn parse_perf_box(content: &str) -> Vec<PerfCell> {
+    let mut out = Vec::new();
+    for line in content.lines() {
+        let Some(at) = line.find("@ d") else {
+            continue;
+        };
+        let rest = &line[at + 3..];
+        let dd = rest
+            .find(|ch: char| !ch.is_ascii_digit())
+            .unwrap_or(rest.len());
+        let d: u32 = match rest[..dd].parse() {
+            Ok(x) => x,
+            Err(_) => continue,
+        };
+        let c: u32 = split_tokens(line)
+            .find_map(|tok| tok.strip_prefix("c").and_then(|n| n.parse::<u32>().ok()))
+            .unwrap_or(1);
+        // numbers from the label onward: pp t/s, tg t/s, TTFT, total, 2048, 128
+        let nums: Vec<f64> = split_tokens(&line[at..])
+            .filter_map(|tok| tok.replace(',', "").parse::<f64>().ok())
+            .collect();
+        if nums.len() >= 4 {
+            out.push(PerfCell { d, c, tg: nums[1] });
+        }
+    }
+    out
+}

+ 68 - 4
src/report/tests.rs

@@ -117,7 +117,9 @@ fn fixture() -> PathBuf {
     .unwrap();
     fs::write(
         r.join("reports/2026-01-04_roce-run.log"),
-        "noise\n│    Score:  92 / 100 │\n",
+        "noise\n│    Score:  92 / 100 │\n\n\
+│ pp2048 tg128 @ d0    │ c1 │   1,500 │    27.4 │    1,398 │    5,959 │ 2048+128\n\
+│ pp2048 tg128 @ d0    │ c2 │   1,346 │    33.3 │    1,999 │    8,653 │ 2048+128\n",
     )
     .unwrap();
     dir
@@ -214,6 +216,22 @@ fn bench_triplet_groups_into_one_entry_with_json_authority() {
     );
     // time comes from the md Run ID (11:08)
     assert_eq!(e.time, Some("11:08".into()));
+    // decode speeds parsed from the log perf box
+    assert_eq!(
+        e.perf,
+        vec![
+            super::PerfCell {
+                d: 0,
+                c: 1,
+                tg: 27.4
+            },
+            super::PerfCell {
+                d: 0,
+                c: 2,
+                tg: 33.3
+            },
+        ]
+    );
 }
 
 #[test]
@@ -232,9 +250,53 @@ fn parse_bench_json_reads_scores_block() {
     assert_eq!(super::parse_bench_json(r#"{"scores": {}}"#), None);
 }
 
+#[test]
+fn parse_perf_box_reads_both_table_shapes() {
+    // modern box rows (concurrency as its own column)
+    let box_log = "\u{2502} pp2048 tg128 @ d0    \u{2502} c1 \u{2502}   1,500 \u{2502}    27.4 \u{2502}    1,398 \u{2502}    5,959 \u{2502} 2048+128\n\
+\u{2502} pp2048 tg128 @ d4096 \u{2502} c2 \u{2502}   1,642 \u{2502}    30.6 \u{2502}    6,040 \u{2502}   12,822 \u{2502} 2048+128\n";
+    assert_eq!(
+        super::parse::parse_perf_box(box_log),
+        vec![
+            super::PerfCell {
+                d: 0,
+                c: 1,
+                tg: 27.4
+            },
+            super::PerfCell {
+                d: 4096,
+                c: 2,
+                tg: 30.6
+            },
+        ]
+    );
+    // legacy markdown rows (concurrency in the label, absent = c1)
+    let md = "| pp2048 tg128 @ d0 | 1,861 | 24.5 | 1,236 | 6,228 | 2048+128 |\n\
+| pp2048 tg128 @ d0 c2 | 1,728 | 36.0 | 2,097 | 8,802 | 2048+128 |\n\
+| Test | pp t/s | tg t/s | TTFT (ms) | Total (ms) | Tokens |\n";
+    assert_eq!(
+        super::parse::parse_perf_box(md),
+        vec![
+            super::PerfCell {
+                d: 0,
+                c: 1,
+                tg: 24.5
+            },
+            super::PerfCell {
+                d: 0,
+                c: 2,
+                tg: 36.0
+            },
+        ]
+    );
+    // header chrome and prose must not produce cells
+    assert!(super::parse::parse_perf_box("pp=[2048] tg=[128] depth=[0, 4096]").is_empty());
+    assert!(super::parse::parse_perf_box("no perf here").is_empty());
+}
+
 fn row(name: &str, model: &str, quant: &str, score: Option<serde_json::Value>) -> ReportRow {
     let reports = score.unwrap_or(
-        serde_json::json!([{"file": "f.md", "files": ["f.md"], "date": "", "label": ""}]),
+        serde_json::json!([{"file": "f.md", "files": ["f.md"], "perf": [], "date": "", "label": ""}]),
     );
     serde_json::from_value(serde_json::json!({
         "name": name, "title": name, "model": model, "quant": quant,
@@ -251,11 +313,13 @@ fn markdown_lists_rows_and_scores() {
         "m/one",
         "NVFP4",
         Some(serde_json::json!([
-            {"file": "f.md", "files": ["f.md"], "date": "2026-01-02", "label": "l", "pct": 90.4, "points": "159/176", "ratio": 0.9034},
+            {"file": "f.md", "files": ["f.md"], "perf": [{"d": 0, "c": 1, "tg": 27.4}, {"d": 0, "c": 2, "tg": 33.3}], "date": "2026-01-02", "label": "l", "pct": 90.4, "points": "159/176", "ratio": 0.9034},
         ])),
     )];
     let md = markdown(&rows);
-    assert!(md.contains("| m/one | NVFP4 | r-a | vllm | 1×1 | 1 | 2026-01-02 | 159/176 | 90.4 |"));
+    assert!(md.contains(
+        "| m/one | NVFP4 | r-a | vllm | 1×1 | 1 | 2026-01-02 | 159/176 | 90.4 | 27.4 | 33.3 |"
+    ));
     let empty = markdown(&[]);
     assert!(empty.contains(fluent_i18n::t!("report-title", { "count" => 0 }).as_str()));
 }