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
46 changes: 46 additions & 0 deletions packages/chart/PERFORMANCE.md
Original file line number Diff line number Diff line change
@@ -1,5 +1,51 @@
# Performance measurements

## Order-flow backend audit — September 29, 2026

The WebGL2 backend now uses an instanced glyph atlas for dense footprint
bid×ask labels, alongside its candle/heatmap quads. The atlas caches the 15
characters needed by the compact numeric formatter, rasterizes once per pixel
ratio, and releases its texture/canvas on destroy. Missing atlas canvases,
failed texture allocation, unsupported text and unsupported colors keep the
Canvas2D label path. Axes and other text continue to use Canvas2D. Separate RGB
and alpha blend factors preserve opacity when the GL buffer is composited.

Measured in headless Chrome 154 on an Apple M3 Max with 128 GiB RAM, at
1,264 × 900 CSS pixels and 1× backing ratio. Each workload has ten warmup
operations and 90 animation-frame samples. There are 1,440 stored one-minute
candles; depth history has 50 levels per side, one snapshot per minute. The
144,000-point case retains and displays a complete 24-hour session. The harness
asserts that the requested backend and all retained snapshots are present.

CPU milliseconds: median (p95). Animation FPS is computed from rAF intervals;
it is not compositor FPS, GPU raster time or a guarantee on other hardware or
at finer depth-history resolution. The old mock backend benchmark remains a
CPU-only measurement.

| Retained depth points | Workload | Canvas2D CPU ms | WebGL2 CPU ms | Canvas2D animation FPS | WebGL2 animation FPS |
| ---: | --- | ---: | ---: | ---: | ---: |
| 1,000 | pan | 0.5 (0.6) | 0.3 (0.4) | 60.0 | 60.0 |
| 1,000 | zoom | 0.5 (0.6) | 0.3 (0.4) | 60.0 | 60.0 |
| 50,000 | pan | 15.4 (17.5) | 4.4 (4.7) | 57.4 | 60.0 |
| 50,000 | zoom | 15.1 (17.4) | 4.4 (4.5) | 58.0 | 60.0 |
| 144,000 | pan | 39.3 (42.3) | 11.9 (12.2) | 24.1 | 60.0 |
| 144,000 | zoom | 38.9 (42.1) | 12.2 (12.8) | 24.3 | 60.0 |

The full-session WebGL2 runs had zero intervals above 25 ms; Canvas2D had 89
in each run. Raw measurements, browser version and timestamp are saved in
[the backend results](bench/results/2026-09-29-backends.json).

Reproduce with Chrome installed (or set `CHART_BENCH_BROWSER`):

```sh
npm run bench:browser --workspace @bloxwap/chart -- --backends=true --frames=90 --output=/tmp/chart-backends.json
node packages/chart/bench/run-browser.mjs --glyphs=true
```

The glyph browser check compares painted bounds, RGB and alpha with Canvas2D
at 1×/2× and repeats the frame to check atlas reuse and GL errors. The library
suite passes 1,855 tests with 100% line, branch and function coverage.

## Rendering and interaction audit — September 26, 2026

Compared commit `9acee8d` with this working tree on the same Apple M3 Max
Expand Down
62 changes: 62 additions & 0 deletions packages/chart/bench/backends.bench.mjs
Original file line number Diff line number Diff line change
@@ -0,0 +1,62 @@
/** Real Canvas2D/WebGL2 pan/zoom, with a full day of time-bucketed depth. */
export async function runBackends({ frames = 90 } = {}) {
if (!Number.isInteger(frames) || frames < 3) throw new Error('frames must be an integer >= 3');
const { createChart, attachHeatmap, DepthHistory } = await import('/dist/index.js');
const data = Array.from({ length: 1440 }, (_, i) => ({
time: 1700000040 + i * 60, open: 100, high: 113, low: 87,
close: 100 + Math.sin(i / 10), volume: 100,
}));
const summarize = values => {
const sorted = [...values].sort((a, b) => a - b);
return { p50: sorted[Math.floor(sorted.length * .5)], p95: sorted[Math.floor(sorted.length * .95)], max: sorted.at(-1) };
};
const results = [];
for (const buckets of [10, 500, 1440]) for (const renderer of ['canvas2d', 'webgl2']) {
const canvas = document.createElement('canvas');
canvas.width = 1264; canvas.height = 900;
document.body.append(canvas);
const chart = createChart({ container: canvas, renderer, pixelRatio: 1,
config: { data, wasm: false, heatmap: { enabled: false, bucketMs: 60000, maxBuckets: buckets, maxLevels: 50 } } });
const history = new DepthHistory({ bucketMs: 60000, maxBuckets: buckets, maxLevels: 50 });
const heatmap = attachHeatmap(chart, { history });
try {
if (chart.renderBackend !== renderer) throw new Error(`Requested ${renderer}, got ${chart.renderBackend}`);
chart.batch(() => {
for (let i = 0; i < buckets; i++) {
const bids = [], asks = [];
for (let j = 0; j < 50; j++) {
bids.push([99.75 - j * .25, 1 + (i * 7 + j * 11) % 100]);
asks.push([100.25 + j * .25, 1 + (i * 11 + j * 7) % 100]);
}
const book = { time: data[data.length - buckets + i].time * 1000, bids, asks };
history.record(book);
chart.setDepth(book);
}
});
chart.scale.zoomToRange(data.length - buckets, data.length - 1);
if (history.length !== buckets) throw new Error('Incomplete retained depth history');
for (const [workload, run] of Object.entries({
pan: i => chart.scale.scrollBy(i % 2 ? 1 : -1),
zoom: i => chart.scale.zoom(i % 2 ? 1.01 : 1 / 1.01, 600),
})) {
for (let i = 0; i < 10; i++) run(i);
const cpu = [], intervals = [];
let previous;
for (let i = 0; i < frames; i++) await new Promise(resolve => requestAnimationFrame(time => {
if (previous !== undefined) intervals.push(time - previous);
previous = time;
const start = performance.now(); run(i); cpu.push(performance.now() - start);
resolve();
}));
const frameIntervalMs = summarize(intervals);
results.push({ renderer, workload, retainedDepthPoints: buckets * 100, buckets,
bucketMs: 60000, sessionHours: buckets / 60, visibleBars: chart.scale.visibleRange().to - chart.scale.visibleRange().from,
cpuMs: summarize(cpu), frameIntervalMs,
animationFps: 1000 / (intervals.reduce((sum, ms) => sum + ms, 0) / intervals.length),
framesOver25Ms: intervals.filter(ms => ms > 25).length });
}
} finally { heatmap.remove(); chart.destroy(); canvas.remove(); }
}
return { userAgent: navigator.userAgent, frames, pixelRatio: 1,
measurement: 'CPU submission and animation-frame cadence; not compositor FPS or GPU raster time', results };
}
266 changes: 266 additions & 0 deletions packages/chart/bench/results/2026-09-29-backends.json
Original file line number Diff line number Diff line change
@@ -0,0 +1,266 @@
{
"measuredAt": "2026-09-30T02:30:40.091Z",
"headless": true,
"userAgent": "Mozilla/5.0 (Macintosh; Intel Mac OS X 10_15_7) AppleWebKit/537.36 (KHTML, like Gecko) HeadlessChrome/154.0.0.0 Safari/537.36",
"frames": 90,
"pixelRatio": 1,
"measurement": "CPU submission and animation-frame cadence; not compositor FPS or GPU raster time",
"results": [
{
"renderer": "canvas2d",
"workload": "pan",
"retainedDepthPoints": 1000,
"buckets": 10,
"bucketMs": 60000,
"sessionHours": 0.16666666666666666,
"visibleBars": 11,
"cpuMs": {
"p50": 0.5,
"p95": 0.6000000238418579,
"max": 0.7000000476837158
},
"frameIntervalMs": {
"p50": 16.699999999999932,
"p95": 16.799999999999955,
"max": 16.80000000000001
},
"animationFps": 60.00539374325782,
"framesOver25Ms": 0
},
{
"renderer": "canvas2d",
"workload": "zoom",
"retainedDepthPoints": 1000,
"buckets": 10,
"bucketMs": 60000,
"sessionHours": 0.16666666666666666,
"visibleBars": 12,
"cpuMs": {
"p50": 0.5,
"p95": 0.6000000238418579,
"max": 1
},
"frameIntervalMs": {
"p50": 16.699999999999818,
"p95": 16.799999999999955,
"max": 16.800000000000182
},
"animationFps": 60.00539374325783,
"framesOver25Ms": 0
},
{
"renderer": "webgl2",
"workload": "pan",
"retainedDepthPoints": 1000,
"buckets": 10,
"bucketMs": 60000,
"sessionHours": 0.16666666666666666,
"visibleBars": 11,
"cpuMs": {
"p50": 0.30000007152557373,
"p95": 0.40000009536743164,
"max": 0.6000000238418579
},
"frameIntervalMs": {
"p50": 16.699999999999818,
"p95": 16.700000000000728,
"max": 16.800000000000182
},
"animationFps": 60.001348344906624,
"framesOver25Ms": 0
},
{
"renderer": "webgl2",
"workload": "zoom",
"retainedDepthPoints": 1000,
"buckets": 10,
"bucketMs": 60000,
"sessionHours": 0.16666666666666666,
"visibleBars": 12,
"cpuMs": {
"p50": 0.30000007152557373,
"p95": 0.3999999761581421,
"max": 0.40000009536743164
},
"frameIntervalMs": {
"p50": 16.699999999999818,
"p95": 16.800000000000182,
"max": 16.800000000000182
},
"animationFps": 59.997303491977874,
"framesOver25Ms": 0
},
{
"renderer": "canvas2d",
"workload": "pan",
"retainedDepthPoints": 50000,
"buckets": 500,
"bucketMs": 60000,
"sessionHours": 8.333333333333334,
"visibleBars": 501,
"cpuMs": {
"p50": 15.399999976158142,
"p95": 17.5,
"max": 20.100000023841858
},
"frameIntervalMs": {
"p50": 16.699999999999818,
"p95": 16.800000000000182,
"max": 33.399999999999636
},
"animationFps": 57.4230595522292,
"framesOver25Ms": 4
},
{
"renderer": "canvas2d",
"workload": "zoom",
"retainedDepthPoints": 50000,
"buckets": 500,
"bucketMs": 60000,
"sessionHours": 8.333333333333334,
"visibleBars": 501,
"cpuMs": {
"p50": 15.100000023841858,
"p95": 17.399999976158142,
"max": 19.09999990463257
},
"frameIntervalMs": {
"p50": 16.69999999999891,
"p95": 16.799999999999272,
"max": 33.399999999999636
},
"animationFps": 58.04474010304575,
"framesOver25Ms": 3
},
{
"renderer": "webgl2",
"workload": "pan",
"retainedDepthPoints": 50000,
"buckets": 500,
"bucketMs": 60000,
"sessionHours": 8.333333333333334,
"visibleBars": 501,
"cpuMs": {
"p50": 4.400000095367432,
"p95": 4.700000047683716,
"max": 4.899999976158142
},
"frameIntervalMs": {
"p50": 16.69999999999891,
"p95": 16.700000000000728,
"max": 16.80000000000109
},
"animationFps": 60.00539374325787,
"framesOver25Ms": 0
},
{
"renderer": "webgl2",
"workload": "zoom",
"retainedDepthPoints": 50000,
"buckets": 500,
"bucketMs": 60000,
"sessionHours": 8.333333333333334,
"visibleBars": 501,
"cpuMs": {
"p50": 4.399999976158142,
"p95": 4.5,
"max": 4.699999928474426
},
"frameIntervalMs": {
"p50": 16.69999999999891,
"p95": 16.700000000000728,
"max": 16.80000000000109
},
"animationFps": 60.00134834490666,
"framesOver25Ms": 0
},
{
"renderer": "canvas2d",
"workload": "pan",
"retainedDepthPoints": 144000,
"buckets": 1440,
"bucketMs": 60000,
"sessionHours": 24,
"visibleBars": 1440,
"cpuMs": {
"p50": 39.299999952316284,
"p95": 42.299999952316284,
"max": 42.799999952316284
},
"frameIntervalMs": {
"p50": 33.400000000001455,
"p95": 50,
"max": 50.10000000000218
},
"animationFps": 24.05535434347802,
"framesOver25Ms": 89
},
{
"renderer": "canvas2d",
"workload": "zoom",
"retainedDepthPoints": 144000,
"buckets": 1440,
"bucketMs": 60000,
"sessionHours": 24,
"visibleBars": 1440,
"cpuMs": {
"p50": 38.90000009536743,
"p95": 42.10000002384186,
"max": 42.60000002384186
},
"frameIntervalMs": {
"p50": 33.400000000001455,
"p95": 50.10000000000218,
"max": 50.10000000000218
},
"animationFps": 24.27383062866494,
"framesOver25Ms": 89
},
{
"renderer": "webgl2",
"workload": "pan",
"retainedDepthPoints": 144000,
"buckets": 1440,
"bucketMs": 60000,
"sessionHours": 24,
"visibleBars": 1440,
"cpuMs": {
"p50": 11.899999976158142,
"p95": 12.200000047683716,
"max": 12.5
},
"frameIntervalMs": {
"p50": 16.69999999999709,
"p95": 16.700000000000728,
"max": 16.799999999999272
},
"animationFps": 60.00134834490666,
"framesOver25Ms": 0
},
{
"renderer": "webgl2",
"workload": "zoom",
"retainedDepthPoints": 144000,
"buckets": 1440,
"bucketMs": 60000,
"sessionHours": 24,
"visibleBars": 1440,
"cpuMs": {
"p50": 12.200000047683716,
"p95": 12.800000071525574,
"max": 13.099999904632568
},
"frameIntervalMs": {
"p50": 16.700000000000728,
"p95": 16.799999999999272,
"max": 16.80000000000291
},
"animationFps": 60.00134834490666,
"framesOver25Ms": 0
}
],
"hardware": {
"cpu": "Apple M3 Max",
"memoryGiB": 128
}
}
Loading
Loading