From 18a01f3345ab8fb94c47f80e4d1ba225ba966309 Mon Sep 17 00:00:00 2001 From: Dmitry Prudnikov Date: Tue, 19 May 2026 14:29:55 +0300 Subject: [PATCH 1/4] feat(dashboard): level-profile chart, HTML legend, sign-gated parity band MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - Add Level Profile chart: per-level speed (MiB/s, left axis) + ratio (right axis) with filled+smoothed lines, 4 toggle checkboxes for the Rust/FFI × speed/ratio cross-product, and target/scenario/stage filters. Toggles use dataset.hidden + chart.update("none") so the chart is not recreated on every interaction. - Replace Chart.js built-in legend on both charts with a custom HTML legend: full-row click target (fixes the misaligned click box where only the colour swatch responded while wrapped labels drifted), and a scrollable container so no series is silently hidden when there are too many to fit. Click toggles dataset visibility via the same Chart.js meta.hidden mechanism, preserving the prior UX. - Sign-gate "Outside parity band" classification by metric direction: ratio regressions only when delta > BAND_HIGH (Rust produced larger output), throughput regressions only when delta < BAND_LOW (Rust slower), peak-alloc regressions only when delta > BAND_HIGH (Rust allocated more). Wins (deviation in our favour) are surfaced as a separate "notable wins not counted" tally and no longer inflate the regression count. Closes #183 --- .github/bench-dashboard/index.html | 428 ++++++++++++++++++++++++++++- 1 file changed, 419 insertions(+), 9 deletions(-) diff --git a/.github/bench-dashboard/index.html b/.github/bench-dashboard/index.html index 5490d71ac..08e291cad 100644 --- a/.github/bench-dashboard/index.html +++ b/.github/bench-dashboard/index.html @@ -78,10 +78,66 @@ margin-top: 14px; min-height: 460px; } - #chart { + #chart, #chart-profile { width: 100% !important; height: 440px !important; } + /* HTML legend replaces Chart.js built-in legend: a full-width row + is the click target (fixing the issue where only the colour swatch + registered clicks and wrapped labels drifted onto the next line), + and the container scrolls when series count exceeds the visible + height (fixing silent series hiding by Chart.js). */ + .html-legend { + margin-top: 10px; + max-height: 180px; + overflow-y: auto; + border-top: 1px solid #e4ece8; + padding-top: 8px; + } + .html-legend-item { + display: flex; + align-items: center; + gap: 8px; + padding: 4px 8px; + border-radius: 6px; + cursor: pointer; + font-size: 0.85rem; + line-height: 1.3; + user-select: none; + } + .html-legend-item:hover { + background: var(--accent-soft); + } + .html-legend-item.hidden-series .html-legend-label { + text-decoration: line-through; + opacity: 0.55; + } + .html-legend-swatch { + width: 14px; + height: 14px; + border-radius: 3px; + flex-shrink: 0; + } + .html-legend-label { + flex: 1; + overflow-wrap: anywhere; + } + .profile-toggles { + display: flex; + flex-wrap: wrap; + gap: 12px; + margin-top: 10px; + } + .profile-toggles label { + display: inline-flex; + align-items: center; + gap: 6px; + font-size: 0.88rem; + cursor: pointer; + } + .profile-toggles input[type="checkbox"] { + margin: 0; + } #status { margin-top: 8px; font-size: 0.88rem; @@ -183,9 +239,41 @@

Rust vs C FFI Relative Dashboard

+
+
+

Level Profile (speed + ratio)

+

+ Absolute throughput and compression ratio per level for a single scenario, with + Rust and FFI overlaid. Filled-area lines make horizontal divergence (level + where Rust falls behind FFI on speed or ratio) easy to spot. +

+
+ + + +
+
+ + + + +
+
+ +
+
+
+
+
Raw timings (secondary view)

@@ -228,6 +316,13 @@

Rust vs C FFI Relative Dashboard

const state = { records: [], chart: null, + profileChart: null, + profileSeriesVisibility: { + rust_speed: true, + ffi_speed: true, + rust_ratio: true, + ffi_ratio: true, + }, timeIndex: new Map(), windowInitialized: false, }; @@ -245,6 +340,12 @@

Rust vs C FFI Relative Dashboard

to: el("f-to"), }; + const profileSelectors = { + target: el("pf-target"), + scenario: el("pf-scenario"), + stage: el("pf-stage"), + }; + function parseTimestamp(value) { const parsed = Date.parse(String(value || "")); return Number.isFinite(parsed) ? parsed : null; @@ -500,6 +601,37 @@

Rust vs C FFI Relative Dashboard

return `${target} • ${scenario || "scenario"} • ${stage || "stage"}:${level || "level"} • ${source || "none"} • ${metric || "metric"}`; } + // Sign-gated regression classification. A value "outside the parity + // band" is only a regression when it moves AGAINST us: + // - compression_ratio: delta = rust_ratio / ffi_ratio; per emitter + // interpretation (run-benchmarks.sh), delta > 1 means Rust produced + // a LARGER compressed output than FFI — that's a ratio regression. + // delta < 1 is a win and must not count as "outside band". + // - throughput_bytes_per_sec: delta > 1 means Rust faster than FFI. + // delta < 1 (Rust slower) is the regression direction. + // - peak_alloc_bytes: delta > 1 means Rust allocated more than FFI — + // that's the regression direction. + // For any other / unknown metric, fall back to symmetric out-of-band + // detection so we don't silently drop signals from future metrics. + function isRegression(row) { + if (typeof row.delta_ratio !== "number") return false; + const metric = row.metric; + if (metric === "throughput_bytes_per_sec") { + return row.delta_ratio < BAND_LOW; + } + if (metric === "compression_ratio" || metric === "peak_alloc_bytes") { + return row.delta_ratio > BAND_HIGH; + } + return row.delta_ratio < BAND_LOW || row.delta_ratio > BAND_HIGH; + } + + function bandLabelForRow(row) { + if (typeof row.delta_ratio !== "number") return "n/a"; + if (isRegression(row)) return "outside"; + const inBand = row.delta_ratio >= BAND_LOW && row.delta_ratio <= BAND_HIGH; + return inBand ? "within" : "win"; + } + function updateStatus(filtered) { const numericRows = filtered.filter((row) => typeof row.delta_ratio === "number"); if (!numericRows.length) { @@ -522,32 +654,45 @@

Rust vs C FFI Relative Dashboard

if (latestPerSeries.length === 1) { const latest = latestPerSeries[0].row; const pct = ((latest.delta_ratio - 1) * 100).toFixed(2); + const regression = isRegression(latest); const inBand = latest.delta_ratio >= BAND_LOW && latest.delta_ratio <= BAND_HIGH; + const label = regression + ? "outside parity band" + : inBand + ? "within parity band" + : "outside band (win — Rust beat FFI)"; setStatusText( - `Latest delta: ${latest.delta_ratio.toFixed(4)} (${pct}%) ${inBand ? "within" : "outside"} parity band`, - inBand ? "near-parity" : "outside-band", + `Latest delta: ${latest.delta_ratio.toFixed(4)} (${pct}%) ${label}`, + regression ? "outside-band" : "near-parity", ); return; } const perSeries = latestPerSeries.map(({ seriesId, row }) => { const pct = ((row.delta_ratio - 1) * 100).toFixed(2); + const regression = isRegression(row); const inBand = row.delta_ratio >= BAND_LOW && row.delta_ratio <= BAND_HIGH; const deviation = Math.abs(row.delta_ratio - 1); + const verdict = regression ? "outside band" : inBand ? "within band" : "win"; return { seriesId, + regression, inBand, deviation, - text: `${compactSeriesLabel(seriesId)}: ${row.delta_ratio.toFixed(4)} (${pct}%) ${inBand ? "within" : "outside"} band`, + text: `${compactSeriesLabel(seriesId)}: ${row.delta_ratio.toFixed(4)} (${pct}%) ${verdict}`, }; }); const status = el("status"); status.replaceChildren(); - const outsideBand = perSeries.filter((item) => !item.inBand); - const focus = (outsideBand.length ? outsideBand : perSeries) + const regressions = perSeries.filter((item) => item.regression); + const wins = perSeries.filter((item) => !item.regression && !item.inBand); + const focus = (regressions.length ? regressions : perSeries) .sort((a, b) => b.deviation - a.deviation) .slice(0, 8); const summary = document.createElement("div"); - summary.textContent = `Latest series: ${perSeries.length}. Outside parity band: ${outsideBand.length}. Showing top ${focus.length} by deviation.`; + summary.textContent = + `Latest series: ${perSeries.length}. Outside parity band: ${regressions.length}` + + (wins.length ? ` (notable wins not counted: ${wins.length}).` : ".") + + ` Showing top ${focus.length} by deviation.`; status.appendChild(summary); if (focus.length > 0) { const list = document.createElement("ul"); @@ -558,7 +703,46 @@

Rust vs C FFI Relative Dashboard

} status.appendChild(list); } - status.className = outsideBand.length === 0 ? "near-parity" : "outside-band"; + status.className = regressions.length === 0 ? "near-parity" : "outside-band"; + } + + // Replaces Chart.js built-in legend. Each row is a full-width click + // target (fixes the bug where only the colour swatch registered + // clicks while the wrapping label drifted to the next line), the + // container scrolls when there are too many series for the visible + // area (fixes silent series hiding), and toggling visibility uses + // the same Chart.js `meta.hidden` mechanism the built-in legend + // used so dataset rendering stays identical. + function renderHtmlLegend(chart, container) { + if (!chart || !container) return; + container.replaceChildren(); + const datasets = chart.data.datasets || []; + for (let i = 0; i < datasets.length; i++) { + const ds = datasets[i]; + const meta = chart.getDatasetMeta(i); + const item = document.createElement("div"); + item.className = "html-legend-item"; + if (meta.hidden) item.classList.add("hidden-series"); + + const swatch = document.createElement("span"); + swatch.className = "html-legend-swatch"; + swatch.style.background = String(ds.borderColor || ds.backgroundColor || "#888"); + item.appendChild(swatch); + + const label = document.createElement("span"); + label.className = "html-legend-label"; + label.textContent = ds.label || `series ${i + 1}`; + item.appendChild(label); + + item.addEventListener("click", () => { + const currentMeta = chart.getDatasetMeta(i); + currentMeta.hidden = currentMeta.hidden === null ? !chart.data.datasets[i].hidden : !currentMeta.hidden; + chart.update(); + renderHtmlLegend(chart, container); + }); + + container.appendChild(item); + } } function renderChart(filtered) { @@ -603,11 +787,234 @@

Rust vs C FFI Relative Dashboard

}, }, plugins: { - legend: { position: "bottom" }, + legend: { display: false }, + }, + maintainAspectRatio: false, + }, + }); + renderHtmlLegend(state.chart, el("legend-delta")); + } + + function profileFilter() { + return { + target: profileSelectors.target.value, + scenario: profileSelectors.scenario.value, + stage: profileSelectors.stage.value, + }; + } + + // Pick the latest record per (level, metric) for the chosen target / + // scenario / stage. We deliberately collapse the time axis here: + // the Level Profile view answers "how do we compare to FFI right + // now across every level" — historical drift is the delta chart's + // job above. + function buildProfileData(filter) { + const rows = state.records.filter((r) => + (r.target || "unknown-target") === filter.target && + r.scenario === filter.scenario && + r.stage === filter.stage, + ); + const byLevelMetric = new Map(); + for (const row of rows) { + if (typeof row.rust_value !== "number" || typeof row.ffi_value !== "number") continue; + const key = `${row.level}|${row.metric}`; + const existing = byLevelMetric.get(key); + if (!existing || compareByGeneratedAt(row, existing) > 0) { + byLevelMetric.set(key, row); + } + } + + const levelSet = new Set(); + for (const row of byLevelMetric.values()) levelSet.add(row.level); + const levels = Array.from(levelSet) + .map((value) => ({ value, parsed: parseLevelId(value) })) + .sort((a, b) => { + if (a.parsed && b.parsed) return a.parsed.numeric - b.parsed.numeric; + if (a.parsed) return -1; + if (b.parsed) return 1; + return a.value.localeCompare(b.value); + }); + + const levelLabels = levels.map((entry) => + entry.parsed ? entry.parsed.label : entry.value, + ); + + const lookup = (level, metric, side) => { + const row = byLevelMetric.get(`${level}|${metric}`); + if (!row) return null; + // Speed records carry bytes/sec, divide to MiB/s for axis legibility. + if (metric === "throughput_bytes_per_sec") { + return side === "rust" ? row.rust_value / (1024 * 1024) : row.ffi_value / (1024 * 1024); + } + return side === "rust" ? row.rust_value : row.ffi_value; + }; + + const rustSpeed = levels.map((e) => lookup(e.value, "throughput_bytes_per_sec", "rust")); + const ffiSpeed = levels.map((e) => lookup(e.value, "throughput_bytes_per_sec", "ffi")); + const rustRatio = levels.map((e) => lookup(e.value, "compression_ratio", "rust")); + const ffiRatio = levels.map((e) => lookup(e.value, "compression_ratio", "ffi")); + + return { + levels: levelLabels, + levelCount: levels.length, + datasets: [ + { + seriesKey: "rust_speed", + label: "Rust speed (MiB/s)", + data: rustSpeed, + yAxisID: "y", + borderColor: "hsl(150 65% 38%)", + backgroundColor: "hsla(150 65% 38% / 0.3)", + }, + { + seriesKey: "ffi_speed", + label: "FFI speed (MiB/s)", + data: ffiSpeed, + yAxisID: "y", + borderColor: "hsl(205 65% 45%)", + backgroundColor: "hsla(205 65% 45% / 0.3)", }, + { + seriesKey: "rust_ratio", + label: "Rust ratio", + data: rustRatio, + yAxisID: "y1", + borderColor: "hsl(30 75% 45%)", + backgroundColor: "hsla(30 75% 45% / 0.3)", + borderDash: [6, 4], + }, + { + seriesKey: "ffi_ratio", + label: "FFI ratio", + data: ffiRatio, + yAxisID: "y1", + borderColor: "hsl(340 65% 50%)", + backgroundColor: "hsla(340 65% 50% / 0.3)", + borderDash: [6, 4], + }, + ], + }; + } + + function renderProfileChart() { + const filter = profileFilter(); + if (!filter.target || !filter.scenario || !filter.stage) { + el("profile-status").textContent = "Select target, scenario and stage to render the level profile."; + return; + } + const { levels, levelCount, datasets } = buildProfileData(filter); + if (!levelCount) { + if (state.profileChart) { + state.profileChart.destroy(); + state.profileChart = null; + } + el("legend-profile").replaceChildren(); + el("profile-status").textContent = `No level points for target=${filter.target}, scenario=${filter.scenario}, stage=${filter.stage}.`; + return; + } + el("profile-status").textContent = + `Showing ${levelCount} level point(s) for scenario=${filter.scenario}, stage=${filter.stage}.`; + + const chartDatasets = datasets.map((ds) => ({ + label: ds.label, + data: ds.data, + yAxisID: ds.yAxisID, + borderColor: ds.borderColor, + backgroundColor: ds.backgroundColor, + borderDash: ds.borderDash, + borderWidth: 2, + pointRadius: 3, + spanGaps: true, + tension: 0.35, + fill: "origin", + hidden: !state.profileSeriesVisibility[ds.seriesKey], + _seriesKey: ds.seriesKey, + })); + + if (state.profileChart) state.profileChart.destroy(); + state.profileChart = new Chart(el("chart-profile"), { + type: "line", + data: { labels: levels, datasets: chartDatasets }, + options: { + responsive: true, maintainAspectRatio: false, + interaction: { mode: "index", intersect: false }, + scales: { + x: { + title: { display: true, text: "Compression level" }, + ticks: { autoSkip: false, maxRotation: 60, minRotation: 30 }, + }, + y: { + type: "linear", + position: "left", + title: { display: true, text: "Throughput (MiB/s)" }, + beginAtZero: true, + }, + y1: { + type: "linear", + position: "right", + title: { display: true, text: "Compression ratio" }, + grid: { drawOnChartArea: false }, + }, + }, + plugins: { + legend: { display: false }, + tooltip: { mode: "index", intersect: false }, + }, }, }); + renderHtmlLegend(state.profileChart, el("legend-profile")); + } + + function applyProfileToggles() { + if (!state.profileChart) return; + const datasets = state.profileChart.data.datasets || []; + for (let i = 0; i < datasets.length; i++) { + const key = datasets[i]._seriesKey; + if (!key) continue; + const meta = state.profileChart.getDatasetMeta(i); + meta.hidden = !state.profileSeriesVisibility[key]; + } + state.profileChart.update("none"); + renderHtmlLegend(state.profileChart, el("legend-profile")); + } + + function bindProfileControls() { + for (const control of Object.values(profileSelectors)) { + control.addEventListener("change", renderProfileChart); + } + for (const cb of document.querySelectorAll(".profile-toggles input[type=checkbox]")) { + cb.addEventListener("change", (e) => { + const key = e.currentTarget.dataset.series; + if (!key) return; + state.profileSeriesVisibility[key] = e.currentTarget.checked; + applyProfileToggles(); + }); + } + } + + function renderProfileSelectors() { + const targets = uniq(state.records.map((r) => r.target || "unknown-target")); + const scenarios = uniq(state.records.map((r) => r.scenario).filter(Boolean)); + const stages = uniq(state.records.map((r) => r.stage).filter(Boolean)); + + const populate = (sel, values, preferred) => { + const fragment = document.createDocumentFragment(); + for (const value of values) { + const opt = document.createElement("option"); + opt.value = String(value); + opt.textContent = String(value); + fragment.appendChild(opt); + } + sel.replaceChildren(fragment); + if (preferred && values.includes(preferred)) sel.value = preferred; + }; + + populate(profileSelectors.target, targets, targets[0]); + populate(profileSelectors.scenario, scenarios, scenarios[0]); + // "compress" is the canonical stage for level profile (level + // sweeps only meaningfully exist on the compression side). + populate(profileSelectors.stage, stages, stages.includes("compress") ? "compress" : stages[0]); } function currentFilter() { @@ -729,6 +1136,9 @@

Rust vs C FFI Relative Dashboard

selectors.metric.value = availableMetrics.includes(defaultMetric) ? defaultMetric : (availableMetrics[0] || ALWAYS); bindFilters(); rerender(); + renderProfileSelectors(); + bindProfileControls(); + renderProfileChart(); renderRawSummary(); } From b73ad2044a203aaee069a53df8ebbdbac0310f70 Mon Sep 17 00:00:00 2001 From: Dmitry Prudnikov Date: Tue, 19 May 2026 14:42:17 +0300 Subject: [PATCH 2/4] fix(dashboard): in-place profile chart updates + correct legend hidden state MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - renderProfileChart now patches labels + datasets in place and calls chart.update("none") on every selector change. The previous path destroyed and recreated the chart on each switch, contradicting the "repopulate without recreating the chart" acceptance criterion and dropping internal Chart.js state (animation continuity, scale state) between scenario/stage/target switches. - Custom HTML legend now uses chart.isDatasetVisible(i) to decide whether to apply the hidden-series style. The previous check on meta.hidden missed datasets constructed with dataset.hidden = true (where Chart.js leaves meta.hidden === null), so the legend row rendered as visible even when the series was hidden. - Profile chart legend clicks now flow through profileSeriesVisibility and sync the matching checkbox, so toggling a series via the legend no longer produces a checkbox/series UI mismatch and survives the next scenario switch. Delta-chart legend keeps the simpler direct toggle path. - Removed bandLabelForRow — was unused. --- .github/bench-dashboard/index.html | 187 +++++++++++++++++++---------- 1 file changed, 123 insertions(+), 64 deletions(-) diff --git a/.github/bench-dashboard/index.html b/.github/bench-dashboard/index.html index 08e291cad..291220b75 100644 --- a/.github/bench-dashboard/index.html +++ b/.github/bench-dashboard/index.html @@ -625,13 +625,6 @@

Level Profile (speed + ratio)

BAND_HIGH; } - function bandLabelForRow(row) { - if (typeof row.delta_ratio !== "number") return "n/a"; - if (isRegression(row)) return "outside"; - const inBand = row.delta_ratio >= BAND_LOW && row.delta_ratio <= BAND_HIGH; - return inBand ? "within" : "win"; - } - function updateStatus(filtered) { const numericRows = filtered.filter((row) => typeof row.delta_ratio === "number"); if (!numericRows.length) { @@ -713,16 +706,24 @@

Level Profile (speed + ratio)

Level Profile (speed + ratio) { - const currentMeta = chart.getDatasetMeta(i); - currentMeta.hidden = currentMeta.hidden === null ? !chart.data.datasets[i].hidden : !currentMeta.hidden; + const nextVisible = !chart.isDatasetVisible(i); + if (typeof onToggle === "function") { + onToggle(i, ds, nextVisible); + return; + } + chart.setDatasetVisibility(i, nextVisible); chart.update(); - renderHtmlLegend(chart, container); + renderHtmlLegend(chart, container, onToggle); }); container.appendChild(item); @@ -896,6 +901,54 @@

Level Profile (speed + ratio)

({ + label: ds.label, + data: ds.data, + yAxisID: ds.yAxisID, + borderColor: ds.borderColor, + backgroundColor: ds.backgroundColor, + borderDash: ds.borderDash, + borderWidth: 2, + pointRadius: 3, + spanGaps: true, + tension: 0.35, + fill: "origin", + hidden: !state.profileSeriesVisibility[ds.seriesKey], + _seriesKey: ds.seriesKey, + })); + } + + function onProfileLegendToggle(index, ds, nextVisible) { + const key = ds._seriesKey; + if (key) { + // Persist visibility across re-renders and keep the checkbox UI + // in sync — without this, clicking the legend produced a state + // mismatch with the checkbox, and a subsequent scenario switch + // would silently restore the hidden series. + state.profileSeriesVisibility[key] = nextVisible; + const checkbox = document.querySelector( + `.profile-toggles input[type=checkbox][data-series="${key}"]`, + ); + if (checkbox) checkbox.checked = nextVisible; + } + if (state.profileChart) { + state.profileChart.setDatasetVisibility(index, nextVisible); + state.profileChart.update("none"); + renderHtmlLegend( + state.profileChart, + el("legend-profile"), + onProfileLegendToggle, + ); + } + } + function renderProfileChart() { const filter = profileFilter(); if (!filter.target || !filter.scenario || !filter.stage) { @@ -905,8 +958,12 @@

Level Profile (speed + ratio)

Level Profile (speed + ratio) ({ - label: ds.label, - data: ds.data, - yAxisID: ds.yAxisID, - borderColor: ds.borderColor, - backgroundColor: ds.backgroundColor, - borderDash: ds.borderDash, - borderWidth: 2, - pointRadius: 3, - spanGaps: true, - tension: 0.35, - fill: "origin", - hidden: !state.profileSeriesVisibility[ds.seriesKey], - _seriesKey: ds.seriesKey, - })); - - if (state.profileChart) state.profileChart.destroy(); - state.profileChart = new Chart(el("chart-profile"), { - type: "line", - data: { labels: levels, datasets: chartDatasets }, - options: { - responsive: true, - maintainAspectRatio: false, - interaction: { mode: "index", intersect: false }, - scales: { - x: { - title: { display: true, text: "Compression level" }, - ticks: { autoSkip: false, maxRotation: 60, minRotation: 30 }, + const chartDatasets = buildProfileDatasets(datasets); + + if (state.profileChart) { + // In-place update path: swap labels + datasets, then ask + // Chart.js to redraw without animation. This preserves the + // chart instance (tooltips, scales, animation state) across + // every target/scenario/stage switch. + state.profileChart.data.labels = levels; + state.profileChart.data.datasets = chartDatasets; + state.profileChart.update("none"); + } else { + state.profileChart = new Chart(el("chart-profile"), { + type: "line", + data: { labels: levels, datasets: chartDatasets }, + options: { + responsive: true, + maintainAspectRatio: false, + interaction: { mode: "index", intersect: false }, + scales: { + x: { + title: { display: true, text: "Compression level" }, + ticks: { autoSkip: false, maxRotation: 60, minRotation: 30 }, + }, + y: { + type: "linear", + position: "left", + title: { display: true, text: "Throughput (MiB/s)" }, + beginAtZero: true, + }, + y1: { + type: "linear", + position: "right", + title: { display: true, text: "Compression ratio" }, + grid: { drawOnChartArea: false }, + }, }, - y: { - type: "linear", - position: "left", - title: { display: true, text: "Throughput (MiB/s)" }, - beginAtZero: true, + plugins: { + legend: { display: false }, + tooltip: { mode: "index", intersect: false }, }, - y1: { - type: "linear", - position: "right", - title: { display: true, text: "Compression ratio" }, - grid: { drawOnChartArea: false }, - }, - }, - plugins: { - legend: { display: false }, - tooltip: { mode: "index", intersect: false }, }, - }, - }); - renderHtmlLegend(state.profileChart, el("legend-profile")); + }); + } + renderHtmlLegend( + state.profileChart, + el("legend-profile"), + onProfileLegendToggle, + ); } function applyProfileToggles() { @@ -972,11 +1028,14 @@

Level Profile (speed + ratio)

Date: Tue, 19 May 2026 14:49:25 +0300 Subject: [PATCH 3/4] feat(dashboard): add snapshot selector to Level Profile chart MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Adds a fourth dropdown ("Snapshot") that pins the 4 profile lines to a single benchmark run rather than always picking the latest record per (level, metric). Default value is "latest" (previous behaviour). The dropdown lists every generated_at value (or commit_sha when timestamp is missing) that produced data for the currently chosen target / scenario / stage, and repopulates when those upstream filters change while preserving the active selection where possible. Use case: compare two specific commits side-by-side by opening the dashboard in two browser tabs and pinning each tab to a different snapshot — the 4 smoothed lines then describe exactly one commit per view, with no implicit "newest wins" merging across runs. --- .github/bench-dashboard/index.html | 105 +++++++++++++++++++++++++---- 1 file changed, 92 insertions(+), 13 deletions(-) diff --git a/.github/bench-dashboard/index.html b/.github/bench-dashboard/index.html index 291220b75..a273b9004 100644 --- a/.github/bench-dashboard/index.html +++ b/.github/bench-dashboard/index.html @@ -260,6 +260,9 @@

Level Profile (speed + ratio)

Stage +
@@ -344,8 +347,16 @@

Level Profile (speed + ratio)

Level Profile (speed + ratio) - (r.target || "unknown-target") === filter.target && - r.scenario === filter.scenario && - r.stage === filter.stage, - ); + const rows = state.records.filter((r) => { + if ((r.target || "unknown-target") !== filter.target) return false; + if (r.scenario !== filter.scenario) return false; + if (r.stage !== filter.stage) return false; + if (filter.snapshot !== PROFILE_LATEST) { + const label = r.generated_at || r.commit_sha || "local-run"; + if (label !== filter.snapshot) return false; + } + return true; + }); const byLevelMetric = new Map(); for (const row of rows) { if (typeof row.rust_value !== "number" || typeof row.ffi_value !== "number") continue; @@ -969,8 +989,11 @@

Level Profile (speed + ratio)

Level Profile (speed + ratio) { + repopulateSnapshotOptions(); + renderProfileChart(); + }); + } else { + control.addEventListener("change", renderProfileChart); + } } for (const cb of document.querySelectorAll(".profile-toggles input[type=checkbox]")) { cb.addEventListener("change", (e) => { @@ -1052,6 +1086,50 @@

Level Profile (speed + ratio)

+ (r.target || "unknown-target") === filter.target && + r.scenario === filter.scenario && + r.stage === filter.stage, + ) + .map(snapshotLabel), + ); + const previous = profileSelectors.snapshot.value; + const fragment = document.createDocumentFragment(); + const latestOpt = document.createElement("option"); + latestOpt.value = PROFILE_LATEST; + latestOpt.textContent = "latest"; + fragment.appendChild(latestOpt); + for (const label of labels) { + const opt = document.createElement("option"); + opt.value = String(label); + opt.textContent = String(label); + fragment.appendChild(opt); + } + profileSelectors.snapshot.replaceChildren(fragment); + profileSelectors.snapshot.value = labels.includes(previous) || previous === PROFILE_LATEST + ? previous + : PROFILE_LATEST; + } + function renderProfileSelectors() { const targets = uniq(state.records.map((r) => r.target || "unknown-target")); const scenarios = uniq(state.records.map((r) => r.scenario).filter(Boolean)); @@ -1074,6 +1152,7 @@

Level Profile (speed + ratio)

Date: Tue, 19 May 2026 14:54:24 +0300 Subject: [PATCH 4/4] fix(dashboard): source filter for profile + a11y legend buttons MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - Add a Source dropdown to the Level Profile chart, mirroring the delta chart's Source filter. Without it, stages that have multiple source values (e.g. decompress benches emitting rust_stream and c_stream) would silently collide on the same level keys and render a mixed/incorrect profile. The source value is now part of the filter cascade (target/scenario/stage → source → snapshot), and the row-selection key includes source so decompress profiles are unambiguous. - Render custom HTML legend rows as