Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
12 changes: 12 additions & 0 deletions CHANGELOG.md
Original file line number Diff line number Diff line change
Expand Up @@ -72,6 +72,18 @@ in the README).
- `LICENSE` (Apache-2.0), `CHANGELOG.md`, `SECURITY.md`, root `CONTRIBUTING.md`.

### Changed
- **Native PNG export compression** dropped from zlib level 9 to level 6: a
1M-point line export goes from ~298 ms to ~64 ms (reference hardware) for
~2.65% larger output. Regression tests pin the level for both truecolor
and indexed encoders.
- **Dashboard benchmark telemetry:** `bench_dashboard.py` no longer discards
metrics when Chrome evicts WebGL contexts. Partial dashboards stay
measurement rows with per-chart `webglcontextlost`/`webglcontextrestored`
events (id, phase, timestamp), creation-failure vs eviction distinction,
initial and scrolled nonblank chart IDs, live-context redraw submission,
and a stable loss-free chart-count ceiling; the report verifier
cross-checks all of it. The interaction benchmark warm-up now completes
GPU work (draw + readback) before the first timed sample.
- **Performance:** WebGL client now uses vertex-array objects (no per-frame
attribute re-binding), lazily compiles shader programs on first use, and
ships a compacted bundle (193 KB → 154 KB) that every `to_html()` inlines.
Expand Down
5 changes: 3 additions & 2 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -501,8 +501,9 @@ fails if any of those required interaction rows disappear.
`bench_workflows.py` covers contiguous/converted/strided/datetime/list/Arrow
ingestion, line append, density append followed by a real 2M+ pyramid rebuild,
and HTML/SVG/native-PNG/Chromium-PNG export. The dashboard benchmark attempts 10, 20, and 50 charts,
records JS heap and steady-redraw pacing, and reports the largest fully nonblank
count rather than assuming every attempted dashboard succeeded.
records per-chart context loss/restoration, initial and scrolled visibility, JS
heap, and redraw submission pacing, then reports the largest loss-free nonblank
count without discarding partial-dashboard metrics.

### 10M-point native benchmark

Expand Down
5 changes: 3 additions & 2 deletions benchmarks/README.md
Original file line number Diff line number Diff line change
Expand Up @@ -67,7 +67,8 @@ JSON artifacts, retain failed/over-budget rows, and label the table
- Interactive TTFR is build + HTML serialization + chart-ready time.
- Interaction browser rows are standalone client input-to-pixel-readback;
backend LOD work is in CodSpeed and workflow rows.
- Dashboard rows attempt 10/20/50 charts and publish the largest fully nonblank
count, including context-limit failures.
- Dashboard rows attempt 10/20/50 charts, retain timings for partial dashboards,
record per-chart context loss/restoration plus initial/scrolled nonblank IDs,
and publish the largest stable loss-free count.
- Density rows must include a count-conservation oracle and explicit aggregate
dimensions. A density result is not an exact-marker result.
172 changes: 146 additions & 26 deletions benchmarks/bench_dashboard.py
Original file line number Diff line number Diff line change
Expand Up @@ -116,43 +116,131 @@ def _probe_js() -> str:
(async () => {
try {
const root = document.getElementById("root");
const views = [];
const slots = [];
const contextEvents = [];
const creationFailureIds = [];
let phase = "create";
const heapBefore = performance.memory ? performance.memory.usedJSHeapSize : null;
const t0 = performance.now();
for (const payload of FC_CHARTS) {
const cell = document.createElement("div");
cell.className = "chart-cell";
cell.dataset.chartId = payload.id;
root.appendChild(cell);
const view = fastcharts.renderStandalone(cell, payload.spec, fcBytesFromB64(payload.b64));
view._drawNow();
views.push(view);
try {
const view = fastcharts.renderStandalone(cell, payload.spec, fcBytesFromB64(payload.b64));
const state = {lost: false};
view.canvas.addEventListener("webglcontextlost", () => {
state.lost = true;
contextEvents.push({id: payload.id, type: "lost", phase, at_ms: performance.now() - t0});
});
view.canvas.addEventListener("webglcontextrestored", () => {
state.lost = false;
contextEvents.push({id: payload.id, type: "restored", phase, at_ms: performance.now() - t0});
});
view._drawNow();
slots.push({id: payload.id, cell, view, state});
} catch (err) {
creationFailureIds.push(payload.id);
slots.push({id: payload.id, cell, view: null, state: {lost: true}});
}
}
await fcRaf();
let nonblank = 0;
for (const view of views) {
if (fcNonblankPixels(view) > 0) nonblank++;
// webglcontextlost dispatches as a task, so evictions triggered by the
// creation loop above only fire during this yield — keep phase "create"
// until they have drained.
await new Promise((resolve) => setTimeout(resolve, 0));
phase = "initial";

function contextLost(slot) {
if (!slot.view || !slot.view.gl) return true;
return slot.state.lost || slot.view._glLost || slot.view.gl.isContextLost();
}
function nonblankPixels(slot) {
if (contextLost(slot)) return 0;
try {
return fcNonblankPixels(slot.view);
} catch (_err) {
return 0;
}
}
function sampleNonblank() {
const lit = [];
for (const slot of slots) if (nonblankPixels(slot) > 0) lit.push(slot.id);
return lit;
}
if (nonblank !== views.length) {
throw new Error(`blank dashboard chart: ${nonblank}/${views.length} nonblank`);
function complement(ids) {
const present = new Set(ids);
return slots.filter((slot) => !present.has(slot.id)).map((slot) => slot.id);
}

const initialNonblankIds = sampleNonblank();
const renderMs = performance.now() - t0;
const navigationReadyMs = performance.now();

phase = "scroll";
const scrollStart = performance.now();
const scrollNonblankIds = [];
for (const slot of slots) {
slot.cell.scrollIntoView({block: "center", inline: "center"});
await new Promise((resolve) => setTimeout(resolve, 0));
if (nonblankPixels(slot) > 0) scrollNonblankIds.push(slot.id);
}
window.scrollTo(0, 0);
await new Promise((resolve) => setTimeout(resolve, 0));
const scrollPassMs = performance.now() - scrollStart;

phase = "redraw";
const steadyRedraws = [];
for (let i = 0; i < 12; i++) {
const redrawStart = performance.now();
for (const view of views) view._drawNow();
for (const slot of slots) if (!contextLost(slot)) slot.view._drawNow();
steadyRedraws.push(performance.now() - redrawStart);
}
await new Promise((resolve) => setTimeout(resolve, 0));
phase = "report";

const heapAfter = performance.memory ? performance.memory.usedJSHeapSize : null;
const lostEvents = contextEvents.filter((event) => event.type === "lost");
const restoredEvents = contextEvents.filter((event) => event.type === "restored");
const uniqueIds = (events) => Array.from(new Set(events.map((event) => event.id)));
const currentlyLostIds = slots
.filter((slot) => slot.view && contextLost(slot))
.map((slot) => slot.id);
const createdCharts = slots.length - creationFailureIds.length;
const fullyNonblank =
createdCharts === slots.length &&
initialNonblankIds.length === slots.length &&
scrollNonblankIds.length === slots.length &&
lostEvents.length === 0 &&
currentlyLostIds.length === 0;
fcReport("FC_DASHBOARD", {
status: "ok",
render_status: fullyNonblank ? "complete" : "partial",
fully_nonblank: fullyNonblank,
render_ms: renderMs,
navigation_ready_ms: navigationReadyMs,
scroll_pass_ms: scrollPassMs,
steady_redraw_p95_ms: fcPercentile(steadyRedraws, 95),
steady_redraw_active_charts: slots.filter((slot) => slot.view && !contextLost(slot)).length,
js_heap_before_bytes: heapBefore,
js_heap_bytes: heapAfter,
js_heap_delta_bytes: heapBefore == null || heapAfter == null ? null : heapAfter - heapBefore,
nonblank_charts: nonblank,
created_charts: createdCharts,
creation_failed_charts: creationFailureIds.length,
creation_failure_ids: creationFailureIds,
nonblank_charts: initialNonblankIds.length,
initial_nonblank_charts: initialNonblankIds.length,
initial_nonblank_chart_ids: initialNonblankIds,
initial_blank_chart_ids: complement(initialNonblankIds),
scroll_nonblank_charts: scrollNonblankIds.length,
scroll_nonblank_chart_ids: scrollNonblankIds,
scroll_blank_chart_ids: complement(scrollNonblankIds),
context_lost_events: lostEvents.length,
context_restored_events: restoredEvents.length,
context_lost_chart_ids: uniqueIds(lostEvents),
context_restored_chart_ids: uniqueIds(restoredEvents),
currently_lost_chart_ids: currentlyLostIds,
context_events: contextEvents,
});
} catch (err) {
fcFail("FC_DASHBOARD", err);
Expand Down Expand Up @@ -196,20 +284,44 @@ def run(*, chart_counts: list[int], chromium: str | None = None) -> dict[str, An
"status": result.get("status", "failed(no status)"),
}
if row["status"] == "ok":
row["render_ms"] = result.get("render_ms")
metric_keys = (
"render_status",
"fully_nonblank",
"render_ms",
"navigation_ready_ms",
"scroll_pass_ms",
"steady_redraw_p95_ms",
"steady_redraw_active_charts",
"js_heap_before_bytes",
"js_heap_bytes",
"js_heap_delta_bytes",
"created_charts",
"creation_failed_charts",
"creation_failure_ids",
"nonblank_charts",
"initial_nonblank_charts",
"initial_nonblank_chart_ids",
"initial_blank_chart_ids",
"scroll_nonblank_charts",
"scroll_nonblank_chart_ids",
"scroll_blank_chart_ids",
"context_lost_events",
"context_restored_events",
"context_lost_chart_ids",
"context_restored_chart_ids",
"currently_lost_chart_ids",
"context_events",
)
row.update({key: result.get(key) for key in metric_keys})
row["ms_per_chart"] = (
result.get("render_ms") / count if result.get("render_ms") is not None else None
)
row["nonblank_charts"] = result.get("nonblank_charts")
row["navigation_ready_ms"] = result.get("navigation_ready_ms")
row["steady_redraw_p95_ms"] = result.get("steady_redraw_p95_ms")
row["js_heap_before_bytes"] = result.get("js_heap_before_bytes")
row["js_heap_bytes"] = result.get("js_heap_bytes")
row["js_heap_delta_bytes"] = result.get("js_heap_delta_bytes")
rows.append(row)

successful_counts = [
row["chart_count"] for row in rows if str(row.get("status", "")).startswith("ok")
row["chart_count"]
for row in rows
if str(row.get("status", "")).startswith("ok") and row.get("fully_nonblank") is True
]
return {
"schema_version": SCHEMA_VERSION,
Expand Down Expand Up @@ -249,34 +361,42 @@ def to_markdown(report: dict[str, Any]) -> str:
"Tracked in this run: "
+ ", ".join(f"`{category['id']}`" for category in report["tracked_categories"]),
"",
f"Successful nonblank chart-count ceiling: `{report.get('chart_count_ceiling')}`.",
f"Stable loss-free chart-count ceiling: `{report.get('chart_count_ceiling')}`.",
"",
"## Results",
"",
"| scenario | charts | prep | navigation ready | render | redraw p95 | JS heap | payload | html | nonblank charts | status |",
"|---|---:|---:|---:|---:|---:|---:|---:|---:|---:|---|",
"| scenario | charts | prep | navigation ready | render | scroll pass | redraw submit p95 | active | JS heap | payload | html | initial/scroll nonblank | loss/restore | health |",
"|---|---:|---:|---:|---:|---:|---:|---:|---:|---:|---:|---:|---:|---|",
]
for row in report["rows"]:
lines.append(
"| {scenario} | {count} | {prep} | {navigation} | {render} | {idle} | {heap} | {payload} | {html} | {nonblank} | {status} |".format(
"| {scenario} | {count} | {prep} | {navigation} | {render} | {scroll} | {idle} | {active} | {heap} | {payload} | {html} | {nonblank}/{scroll_nonblank} | {lost}/{restored} | {health} |".format(
scenario=row["scenario"],
count=row["chart_count"],
prep=_fmt_ms(row.get("payload_prep_ms")),
navigation=_fmt_ms(row.get("navigation_ready_ms")),
render=_fmt_ms(row.get("render_ms")),
scroll=_fmt_ms(row.get("scroll_pass_ms")),
idle=_fmt_ms(row.get("steady_redraw_p95_ms")),
active=row.get("steady_redraw_active_charts", "—"),
heap=_fmt_bytes(row.get("js_heap_bytes")),
payload=_fmt_bytes(row.get("total_payload_bytes")),
html=_fmt_bytes(row.get("html_bytes")),
nonblank=row.get("nonblank_charts", "—"),
status=row.get("status", "unknown"),
scroll_nonblank=row.get("scroll_nonblank_charts", "—"),
lost=row.get("context_lost_events", "—"),
restored=row.get("context_restored_events", "—"),
health=row.get("render_status", row.get("status", "unknown")),
)
)
lines += [
"",
"Notes:",
"",
"- `render` is measured inside the page from first chart decode to post-RAF WebGL readback.",
"- `render` is measured inside the page from first chart decode through a context-event task yield and WebGL readback.",
"- `redraw submit p95` measures synchronous command submission for every currently live context; `active` states how many charts contributed.",
"- Partial rows retain startup, heap, redraw, context-event, and scrolling metrics; they do not raise the loss-free ceiling.",
"- Context event IDs and phases are retained in JSON so LRU eviction and restoration churn are directly observable.",
"- The chart mix cycles through line, scatter, histogram, grouped bar, and heatmap.",
]
return "\n".join(lines)
Expand Down
6 changes: 5 additions & 1 deletion benchmarks/bench_interaction.py
Original file line number Diff line number Diff line change
Expand Up @@ -251,7 +251,7 @@ def _probe_js(reps: int) -> str:
const warmY1 = before.y0 + (before.y1 - before.y0) * 0.65;
view._selectLocal(warmX0, warmX1, warmY0, warmY1);
view._clearSelection();
view._drawNow();
settlePixels();

let viewChanged = false;
function noteViewChanged() {{
Expand All @@ -275,6 +275,10 @@ def _probe_js(reps: int) -> str:
view._cancelViewAnimation();
view.view = target;
}}
if (view._raf) {{
cancelAnimationFrame(view._raf);
view._raf = null;
}}
const gl = view.gl;
view._drawNow();
const pixel = new Uint8Array(4);
Expand Down
4 changes: 2 additions & 2 deletions benchmarks/categories.py
Original file line number Diff line number Diff line change
Expand Up @@ -67,10 +67,10 @@
"id": "many_chart_dashboards",
"name": "Many-chart dashboards",
"why": "Plotly-class apps often fail from total page weight and many live canvases, not one chart.",
"metrics": "payload prep, navigation readiness, JS heap, steady redraw, chart count ceiling",
"metrics": "payload prep, navigation readiness, JS heap, redraw submission, scroll visibility, context loss/restore, stable chart-count ceiling",
"harness": "benchmarks/bench_dashboard.py",
"status": "tracked",
"goal": "Measure the 10-50 chart scaling curve and expose browser context ceilings.",
"goal": "Measure the 10-50 chart scaling curve and expose LRU context eviction without discarding partial-row metrics.",
},
{
"id": "interaction_smoothness",
Expand Down
Loading
Loading