Skip to content

Benchmark Methodology ​

This page collects the performance numbers of charturbo (Rust + WebGPU/WebAssembly) measured with the web/bench/ harness, how they were measured, the chart comparison against uPlot · ECharts · Chart.js · Plotly · ChartGPU · SciChart · Highcharts, and the 3D graph comparison against three.js · deck.gl · cosmos.gl · 3d-force-graph. The numbers come from the single machine described below and will differ on other machines · browsers · displays. You can reproduce them yourself with the same commands.

  • Latest raw results: docs/bench/results.json, results.md. The number tables in sections 4–6 below are the initial measurement record of 2026-10-26; read later library additions and corrected measurements from the raw results and the live comparison page.
  • The engine's own scenarios keep the 2026-10-26 record. Cross-library comparisons are re-measured after adapter fixes with 5 runs (+1 warm-up) and a 3-second steady state. The time · version · settings of each run are recorded in each entry's measurement, and the actual sample count in result.ttff.frame.n.
  • The product name is Charturbo. viz and vizgpu in the raw measurements and the benchmark CLI are the existing identifiers of the Charturbo CPU and hybrid GPU paths, kept so the measurement records stay linked.

Results drawn with our own SDK, and design settings ​

The comparison page web/examples/benchmarks.html reads results.json and draws horizontal bars with Charturbo.renderBarChart() on a shared CharturboDevice. It shows every scenario on one page with jump links, and a collapsible number table per scenario. The theme follows the theme selector in the examples site header (light · dark · midnight · paper). The raw tables remain even when no GPU is available.

npm run bench:charts runs the same chart code in Chrome and generates 6 light/dark SVGs with the SDK's exportChartSVG(). Only the outer title and measurement conditions are composited; bars · axes · labels are generated by the SDK. The GitHub README shows them as static images, and GitHub Pages shows them as real SDK charts with tooltips.

Time to first frame comparison drawn with Charturbo

FPS during zoom · pan and bar hover, drawn with Charturbo

FPS, physics time and time to first frame for 10k · 100k nodes

bash
cd web
npm run examples:dev           # /benchmarks.html
npm run bench:charts          # needs Chrome; set the executable with CHROME_PATH
npm run bench:charts -- bench/out/results.json --out=bench/out/report
npm run bench:report:check    # checks scenarios · themes · styles · SVG · mobile browser

Failures, unsupported and unmeasured entries are not turned into 0; their name and reason are shown. The earlier SciChart TTFF (excluding the data copy), the earlier 3d-force-graph result whose physics run was not verified, and the cosmos v2 result with duplicated physics runs are excluded from the charts and marked as needing re-measurement.

On the comparison page and the README charts the SDK appears as a single Charturbo entry. Per scenario, among the valid CPU (viz) and hybrid GPU (vizgpu) measurements, the mode with the higher FPS is selected; on a tie, the mode with the shorter physics time. TTFF · FPS · physics time and the measurement conditions all come from that same selected run. The selected mode is shown in the table · tooltip · SVG, and the full CPU/GPU record is kept in the raw JSON/Markdown. This is a display rule for measurement results and does not mean the SDK runtime picks a mode automatically.

1. How to reproduce ​

bash
cd web
npm install                  # the compared libraries are devDependencies (not included in the library bundle)
npm run bench                # full run: 5 reps (+1 warm-up), 3 s steady state, every scenario × WebGPU/WebGL2 + library comparison (~11 min)
npm run bench -- --quick     # smoke: 2 reps, 1 s, without the 10M / 5M / 4096² scenarios
npm run bench -- --filter=line-1m,scatter --backend=webgpu --no-compare
npm run bench -- --compare-only --libs=viz,uplot
npm run bench -- --shots     # screenshot of each page (web/bench/out/shots/) — to confirm a library actually drew
npm run bench -- --profile   # per-page CPU profile + top functions by self time
# smoke-test the new chart libraries only
npm run bench -- --quick --no-ours --libs=chartgpu,scichart,highcharts --out=bench/out/smoke
# smoke-test the new graph libraries only
npm run bench -- --quick --no-ours --filter=graph-10k --libs=viz,deckgl,cosmos,forcegraph3d --out=bench/out/smoke
# re-measure only the 3D graphs and merge (large data: up to 300 s per entry)
npm run bench -- --filter=graph --no-ours --merge=../docs/bench/results.json && npm run bench:charts

--merge=<results.json> merges this run's entries into an existing file (the same scenario + library/backend is replaced, everything else and meta are kept, and this run's conditions are appended to meta.partialRuns). The .md is rewritten from the merged result as well.

The runner (bench/run-bench.mjs) starts its own Vite server on a free port (cross-origin isolated: measureUserAgentSpecificMemory is available), launches headless Chrome (--headless=new --enable-unsafe-webgpu, --use-angle=metal on macOS, --js-flags=--expose-gc) with a unique profile, and opens each scenario in a new tab. Without Chrome it prints SKIPPED. If a page does not finish within 300 seconds (even when the main thread is blocked), it is recorded as FAILED: timeout together with the last reported phase, and the runner moves on.

2. Environment ​

ItemValue
MachineApple M1 (8 cores), RAM 16 GB, macOS 15.1 (Darwin 24.1.0)
BrowserChrome 156.0.8078.48, headless (--headless=new)
WebGPU adapterapple / metal-3
WebGL2 rendererANGLE (Apple, ANGLE Metal Renderer: Apple M1)
Canvas960×540 CSS px, DPR 1 (deviceScaleFactor: 1)
Frame interval60 Hz (median idle rAF interval 16.7 ms)
Nodev22.23.3
CompareduPlot 1.6.32, ECharts 6.1.0, Chart.js 4.6.1, Plotly gl2d 4.1.2, ChartGPU 0.4.0, SciChart 6.0.14, Highcharts 13.1.1, deck.gl 9.3.11, cosmos.gl 3.5.1, 3d-force-graph 1.80.1. three.js: initial record 0.170.0 → corrected comparison 0.186.1; see each measurement.version

3. Method ​

Data ​

  • All data is generated by a seeded PRNG (mulberry32, bench/data.ts) — the same samples regardless of run · library · machine. Generation time is not measured.
  • Line: random walk (line-<N>-<S>s = N samples in total, S series). Scatter: 3 Gaussian clusters. Bar 10k, heatmap 2048² / 4096², OHLC candles 1M, treemap 100k leaves, sankey 10k nodes.
  • Streaming (stream-1m): appendSeriesData of 10k points per frame into a 1M window (maxPoints: 1_000_000).

Phases (new tab per scenario) ​

  1. Engine creation (Charturbo.create; for the compared libraries, library import + initialization) — recorded separately as initMs.
  2. TTFF: 5 full renders after 1 warm-up. Each run starts after two rAFs to align with the vsync boundary.
    • render call = duration of the synchronous render call (viz: renderXxxChart() + render(); uPlot/Chart.js: chart construction; ECharts: setOption(notMerge) → finished; Plotly: newPlot completion + 1 rAF)
    • TTFF = call start → the moment the next frame is handed to the compositor (the task after rAF). It is quantized to the 60 Hz vsync, so compare differences under 16.7 ms with the render call column.
    • pixels = render + 1 px GPU readback (until the GPU work completes) — in results.json.
  3. Memory: after two forced GCs, performance.memory.usedJSHeapSize (JS heap), measureUserAgentSpecificMemory (UA memory: the whole page, JS + wasm etc.), the wasm memory size from engine stats and GPU buffer · texture bytes (Charturbo.getRenderStats()).
  4. Steady state, 3 seconds: in a rAF loop, input is applied every frame and the rAF interval recorded. dropped = number of missed vsyncs beyond 16.7 ms / number of measured frames, work = JS+wasm time spent on input handling + render in that frame, main thread ms/frame = increase of TaskDuration from CDP Performance.getMetrics / number of frames (including deferred work · layout).
    • viz scenarios: the real SDK gesture path — wheel zoom in (35%) → drag pan (30%) → wheel zoom out (35%). Charts without navigation (bar, treemap, sankey) use a hover sweep; streaming appends every frame.
    • Library comparison: every library gets the same time-based window schedule (full → zoom in to a 1% window → pan the 1% window → zoom out to full) through its own range API (viz setXRange/setYRange, uPlot setScale, ECharts dataZoom action, Chart.js scales.min/max + update('none'), Plotly relayout). Scatter uses both x and y.
  5. Hover latency: in the reset full view, one pointer move per frame for 40 frames. hover sync = event dispatch time (handlers that hit-test on the spot), interval p95 = rAF interval during hover (including work deferred to timers / the next frame).
  6. The repetition phase and the hover phase have budgets of 30 s / 10 s, so slow libraries on 10M data are measured at least 3 times only.

Library settings (fairness) ​

Each library uses its documented settings for large data (bench/others.ts).

LibrarySettings
vizThe same SDK calls as ours.ts, lineWidth: 1, scatter pointRadius: 1.5, opacity: 0.6, WebGPU. Lines use M4 LOD (min/max per pixel column) automatically
uPlotTyped-array columns, points hidden (the line path already folds per pixel column). Scatter: mode: 2 + a custom rectangle drawer (as in the uPlot scatter demo — the built-in paths.points() builds one Path2D with 1M arcs, and screenshots confirmed Chrome draws nothing)
EChartscanvas renderer, animation: false, typed-array data, line sampling: 'lttb', scatter/bar large: true, inside dataZoom
Chart.jsparsing: false, normalized: true, animation: false, the decimation plugin for lines (min-max), pointRadius: 0 (line) / 1.5 (scatter). The {x, y} object arrays are built once before measuring
Plotlyscattergl (WebGL) trace, gl2d bundle. No bar trace, so bar-10k is excluded
ChartGPUWebGPU only, createChart + ECharts-style options, animation: false, typed-array { x, y } data. Line: type: 'line', sampling: 'lttb'. Scatter: type: 'scatter', symbolSize: 3. Bar: category axis + type: 'bar' (no zoom, hover sweep). Window moves use inside dataZoom + setZoomRange(start%, end%) — the arguments are percentages in [0, 100] (absolute x values converted to a fraction of the domain). Zoom is x-axis only, so the y window of the scatter schedule is not applied
SciChartWebAssembly + WebGL2/WebGPU. SciChartSurface.configure({ wasmUrl, wasmNoSimdUrl }), UseCommunityLicense() (watermark). Line: FastLineRenderableSeries + MinMax resampling. Scatter: XyScatterRenderableSeries, explicit x/y ranges. Line/bar y axis auto-range. Bar: FastColumnRenderableSeries. Every measured render includes the wasm data copy of dataSeries.clear() + appendRange(), then nextStateRender() → invalidateElement() → await. The backend is checked after initialization with SciChartSurfaceBase.debugWasmWebGPU()
HighchartsESM build (highcharts/esm/highcharts.js) + side-effect import of the Boost module (highcharts/esm/modules/boost.js). Data as [x, y] tuple arrays (the Boost fast path). Bars use type: 'column'. boostThreshold: 5000 (GPU above 5k points), useGPUTranslations: true, usePreallocated: true. Window moves: xAxis.setExtremes(lo, hi, redraw, false), and the same on the y axis for scatter
  • The scope of render call differs per library: viz reuses the engine and only passes the data again, uPlot / Chart.js destroy and rebuild the chart every run, and Plotly does purge + newPlot. Engine/library initialization time is separate (initMs).
  • All libraries use the same padding (left 64 / right 24 / top 36 / bottom 36 px), 1 px lines and the same colors. Tooltips are on with each library's default hover behavior.

4. Results: charturbo scenarios ​

Steady state / dropped / work / hover / memory are WebGPU values (WebGL2 is fully in results.md — the steady state was nearly identical on both backends). GPU MB = allocated GPU buffer · texture size, upload/frame = average chart data upload per frame during the steady state.

ScenarioInteractionTTFF ms WebGPU / WebGL2Render call msSteady frame med / p95 / max msDroppedWork per frame med msHover sync msJS heap MBwasm MB WebGPU / WebGL2GPU MBUpload/frame
line-100k-1swheel+drag16.8 / 16.63.916.7 / 16.7 / 16.70/1801.40.68.73.1 / 4.10.7150 KB
line-100k-10swheel+drag16.7 / 17.85.816.7 / 16.7 / 16.70/1801.40.89.18.4 / 13.02.3633 KB
line-1m-1swheel+drag16.7 / 16.610.516.7 / 16.7 / 16.70/1801.70.612.16.5 / 7.60.7265 KB
line-1m-10swheel+drag17.1 / 17.216.716.7 / 16.7 / 16.70/1803.21.016.09.6 / 22.92.31.5 MB
line-10m-1swheel+drag91.8 / 92.391.416.7 / 16.7 / 33.34/1762.90.546.540.8 / 42.30.7300 KB
line-10m-10swheel+drag72.9 / 73.472.516.7 / 16.7 / 33.36/1754.81.284.678.7 / 86.92.32.6 MB
scatter-1mwheel+drag46.1 / 46.945.416.7 / 16.7 / 16.70/1801.20.915.955.7 / 113.532.7736 B
scatter-5mwheel+drag210 / 24420983.3 / 117 / 117144/371.61.346.4269.2 / 555.8128.7702 B
area-stacked-1mwheel+drag26.9 / 25.626.416.7 / 16.7 / 33.31/1795.50.916.039.4 / 64.65.34.3 MB
bar-10khover-sweep19.0 / 19.118.516.7 / 16.7 / 16.70/1801.11.211.43.6 / 5.01.2469 KB
heatmap-2048wheel+drag27.3 / 30.526.716.7 / 16.7 / 16.70/1801.20.724.333.9 / 66.416.7136 B
heatmap-4096wheel+drag92.9 / 96.492.116.7 / 16.7 / 16.70/1801.30.772.3129.9 / 258.464.7136 B
candle-1mwheel+drag33.2 / 33.332.816.7 / 16.7 / 16.70/1801.60.823.540.4 / 40.90.780 KB
stream-1mstream15.9 / 16.213.416.7 / 16.7 / 16.70/1803.20.712.129.1 / 29.70.7155 KB
treemap-100khover-sweep32.7 / 28.132.116.7 / 16.7 / 16.70/1800.70.719.315.6 / 17.61.2192 B
sankey-10khover-sweep100 / 81.099.216.7 / 16.7 / 16.70/1800.70.711.299.9 / 140.464.77.2 KB

Summary:

  • Line · area · candle · bar · heatmap · treemap up to 1M held 60 fps during zoom/pan/hover (0–1 dropped). Work per frame was 1–6 ms.
  • Line 10M: first frame 73–92 ms (copying 10M f32 into wasm + range computation + first M4 LOD), 3–5 ms of work per frame during zoom/pan, 4–6 frames dropped over 3 seconds.
  • Streaming: 60 fps while appending 10k per frame into a 1M window (work 3.2 ms/frame; the upload is only the LOD result, ~155 KB/frame).
  • Scatter 5M runs at 12–13 fps during zoom/pan (median frame interval 67–83 ms) and looks GPU-bound: JS+wasm work is 1.6 ms/frame, but rasterizing 5M point instances overruns the frame (no point LOD/density aggregation).
  • Sankey 10k: hover emphasis only rewrites the GPU fill-emphasis uniform (48 B) and the vertices of nodes · links entering or leaving the lit set, so during the hover sweep the upload dropped from ~40 MB → ~7 KB per frame and the synchronous hover handling from 14.5 ms → 0.7 ms (same on WebGPU / WebGL2; the screen is pixel-identical to before). Treemap legend emphasis changes the same uniform only (sunburst hover still re-tessellates the arcs).
  • The WebGL2 fallback has nearly the same TTFF and steady state as WebGPU, but its wasm memory peak is 1.6–2.4× larger for scatter · heatmap · multi-series (presumably extra staging copies in the GL backend path; wasm memory does not shrink, so these are peak values).

5. Initial record: library comparison (2026-10-26, viz = WebGPU) ​

The tables and interpretation below are the record of the first 5-library comparison. For the latest full comparison of 8 libraries, see the generated table and the SDK comparison page.

line-1m-1s ​

LibraryRendererRender call msTTFF ms (p95)Steady frame med / p95 / max msfpsDroppedMain thread ms/frameHover sync ms / interval p95 msJS heap MBUA memory MB
viz 0.1.0webgpu13.315.9 (16.6)16.7 / 16.7 / 16.7600/1802.70.6 / 16.719.826.5
uplot 1.6.32canvas2d13.315.5 (16.0)16.7 / 16.7 / 16.7600/1803.00.1 / 16.720.227.4
echarts 6.1.0canvas2d22.422.8 (31.8)16.7 / 16.7 / 33.359.71/1796.50.9 / 16.780.095.3
chartjs 4.6.1canvas2d17.117.4 (20.3)16.7 / 16.7 / 16.7600/1804.00.1 / 16.757.363.5
plotly 4.1.2webgl285298 (367)16.7 / 16.7 / 16.7600/1802.50.1 / 4300132.4315.4

line-10m-1s ​

LibraryRendererRender call msTTFF ms (p95)Steady frame med / p95 / max msfpsDroppedMain thread ms/frameHover sync ms / interval p95 msJS heap MBUA memory MB
viz 0.1.0webgpu91.892.1 (92.6)16.7 / 16.7 / 33.358.74/1767.00.6 / 16.7122.8129.5
uplot 1.6.32canvas2d81.381.7 (84.2)16.7 / 33.3 / 83.352.822/1609.40.1 / 16.7123.2130.4
echarts 6.1.0canvas2d133133 (142)33.3 / 83.3 / 18330.888/9232.60.9 / 16.7526.3541.6
chartjs 4.6.1canvas2d114114 (118)16.7 / 33.3 / 100.052.124/15710.00.0 / 16.7469.4475.6
plotly 4.1.2-Failed: timeout after 300 s (page was in phase "steady")

scatter-1m ​

LibraryRendererRender call msTTFF ms (p95)Steady frame med / p95 / max msfpsDroppedMain thread ms/frameHover sync ms / interval p95 msJS heap MBUA memory MB
viz 0.1.0webgpu42.843.3 (53.6)16.7 / 16.7 / 33.359.71/1791.81.0 / 33.315.922.7
uplot 1.6.32canvas2d317319 (323)16.7 / 33.3 / 30044.649/14113.60.1 / 16.716.423.6
echarts 6.1.0canvas2d10351050 (1117)133 / 150 / 1507.8161/231340.4 / 16.791.8109.1
chartjs 4.6.1canvas2d662665 (670)283 / 617 / 6173.2193/103390.0 / 383201.8208.0
plotly 4.1.2webgl303316 (332)16.7 / 16.7 / 33.358.35/17516.20.1 / 16.7124.3307.3

bar-10k ​

LibraryRendererRender call msTTFF ms (p95)Steady frame med / p95 / max msfpsDroppedMain thread ms/frameHover sync ms / interval p95 msJS heap MBUA memory MB
viz 0.1.0webgpu19.119.6 (21.2)16.7 / 16.7 / 16.7600/1802.11.7 / 16.711.518.2
uplot 1.6.32canvas2d3.216.8 (18.6)16.7 / 16.7 / 16.7600/1801.20.1 / 16.79.216.4
echarts 6.1.0canvas2d67.483.4 (83.8)16.7 / 16.7 / 16.7600/1803.41.6 / 16.731.949.2
chartjs 4.6.1canvas2d49.150.5 (54.7)33.3 / 33.3 / 66.737.668/11326.90.0 / 16.714.020.2
plotly 4.1.2-Failed: Error: unsupported: the plotly gl2d bundle has no bar trace

Interpretation — where it wins and where it loses ​

  • Scatter 1M: viz is the fastest. Render call 43 ms vs Plotly 303 ms · uPlot 317 ms · Chart.js 662 ms · ECharts 1035 ms; 60 fps during zoom/pan (1 dropped) vs Plotly 58 fps, uPlot 45 fps, ECharts 8 fps, Chart.js 3 fps. Memory is also the smallest (UA 22.7 MB vs Plotly 307 MB).
  • Line 10M: viz has the smoothest zoom/pan (4 dropped vs uPlot 22, Chart.js 24, ECharts 88; p95 frame 16.7 ms vs 33–83 ms). But uPlot renders the first frame faster (81 ms vs 92 ms). Plotly's zoom phase did not finish within 300 seconds at 10M and was recorded as a failure (every relayout reprocesses everything).
  • Line 1M: viz and uPlot are effectively tied (render call 13.3 ms for both, both 60 fps). Chart.js (decimation) also holds 60 fps; ECharts renders in 22 ms with 1 dropped. Plotly takes ~300 ms for the first render and its hover interval p95 is 4.3 seconds (the main thread stalls while handling hover).
  • Bar 10k: uPlot wins — render call 3.2 ms vs viz 19.1 ms (TTFF 16.8 vs 19.6 ms), hover sync 0.1 vs 1.7 ms. Both hold 60 fps. The cause on the viz side (bar layout · axes · DOM overlay) has not been analyzed yet (check with --profile). Chart.js runs at 38 fps during the hover sweep.
  • JS heap: for line 1M/10M, viz and uPlot are similar (mostly the input Float32Array itself). ECharts / Chart.js use 470–530 MB at 10M.
  • viz's GPU memory is not included in UA memory (see the GPU MB column).

6. Graph method and initial record ​

The graph-10k and graph-100k scenarios (bench/graph.ts, runGraph in main.ts). The initial number tables · interpretation below are the viz/three.js record of 2026-10-26. For the latest numbers of the 6 comparison rows split by CPU/GPU and their failure reasons, see the generated table.

Method ​

  • Data: the demo app's seeded generator (GraphDatasetTS.generateSeeded, xorshift, seed 42) — N nodes, 2N edges (80% inside the same cluster), 8 clusters, a space of radius 100. Generation is not measured. Both engines receive the same TypedArray buffers through loadRawDataset.
  • Engines: the adapters used by the demo comparison screen are used as-is (src/adapters/rust-wgpu-adapter.ts, src/adapters/three-js-adapter.ts, physics in src/three-engine/octree-physics.ts). No benchmark-specific tuning. The additional comparison adapters exist only in bench/graph.ts (implementing the EngineAdapter interface).
    • viz: Rust Barnes-Hut (wasm, single thread) + WebGPU, nodes as sphere billboard impostors (one quad), edges as lines.
    • vizgpu: the same 3D Barnes-Hut model · data · renderer. A hybrid path (stepPhysicsAsync) that computes repulsion on the GPU and reads it back to the CPU every step to update springs · integration · the spatial index. Not a GPU-resident simulation.
    • three.js: a JS Barnes-Hut octree with the same algorithm + InstancedMesh nodes (8×6-segment spheres, MeshLambertMaterial) + LineSegments edges, WebGL2 (ANGLE Metal).
    • deck.gl: ScatterplotLayer (nodes) + LineLayer (edges), OrbitView, WebGL2. No layout engine — nodes stay at the initial positions from loadRawDataset. tick() only redraws. Measures the pure GPU render cost.
    • cosmos.gl: WebGL2 GPU force-directed layout, 2D only. Drops z from the input coordinates and uses x · y only. Applies enableRenderLoop: false together with pause() and drives frames by hand with step() + renderOneFrame(). From v3, it verifies that coordinates stay unchanged when only one physics step · a finite coordinate move · a render are done. The earlier v2 also ran physics in renderOneFrame(), so it did two steps per frame. The algorithm differs from the viz/three.js 3D Barnes-Hut (GPU repulsion + springs, not d3-style).
    • 3d-force-graph: d3-force-3d layout (Barnes-Hut for the many-body repulsion; forces and integration differ from viz), three.js 0.186 + WebGL2. RAF is stopped and frames are driven by hand with the scene's ThreeForceGraph.tickFrame() + renderer().render(). The top-level public API has no tickFrame(), so the scene object is inspected, and the measurement fails if it is missing.
  • Same conditions (viz · three.js): force model and constants (repulsion 25, spring 0.08 / rest length 8, center attraction 0.03, damping 0.88, θ 0.75, softening 1.5, max velocity 60, octree max depth 10), fixed dt 0.016 per frame, camera (distance 220, yaw 0.4, pitch 0.3, fov 45), 960×540 canvas @ DPR 1, auto-sleep off.
  • deck.gl conditions: the same initial position data passed through loadRawDataset and kept fixed (no physics). rotationOrbit of OrbitView is in degrees, initial zoom 1. No camera distance concept (replaced by the zoom level).
  • cosmos.gl conditions: the same initial x · y coordinates (z dropped). spaceSize: 4096, simulationRepulsion: 1, simulationLinkSpring: 1, simulationGravity: 0.25, simulationDecay: 1000, simulationFriction: 0.85. 2D, so no camera rotation (rotateCamera is a no-op).
  • 3d-force-graph conditions: the same node · edge data, d3-force-3d default forces (different from the viz/three.js constants). cooldownTicks/Time: Infinity, d3AlphaMin: 0, warmupTicks: 0, automatic RAF stopped. Data loading waits for onFinishUpdate, so it includes scene creation · the first render. One verification step right before the steady state checks one onEngineTick call and actual node movement, recorded in physicsValidation. The verification step is excluded from the timing samples. Initial camera distance 220, yaw 0.4, pitch 0.3.
  • Phases:
    1. init = engine creation (viz creates all GPU pipelines here). cold = first data load + first frame shown on a new engine (measured once). three.js compiles shaders on the first render, so that cost goes into cold, while for viz it goes into init. Comparing cold alone therefore favors viz; init + cold is fairer (10k: 47.6 vs 123.8 ms, 100k: 86.5 vs 124.3 ms; three.js init also includes the dynamic module import).
    2. TTFF = reload of the same buffers (positions reset) + first frame shown, median of 5 after 1 warm-up. Quantized to 60 Hz.
    3. Steady state, 3 seconds: every frame tick(0.016, true) (one Barnes-Hut step + buffer sync + draw) and a camera orbit by elapsed time (half a turn in 3 seconds). physics = step time, render = sync + draw submission time (GPU execution is asynchronous and not included — it shows up in the frame interval). fps is the reference metric. When frame work slightly exceeds 16.7 ms, rAF intervals alternate between 16.7 / 33.3 ms and the median can show 16.7 ms (viz 10k).
    4. Memory: after the steady state and a forced GC, JS heap, wasm memory (viz), UA memory.

In the steady-state loop vizgpu waits for each asynchronous step to complete before rendering. Its physics time is the elapsed time including snapshot creation · upload · GPU completion · readback · CPU springs/integration · index rebuild. Its scope differs from the CPU execution/submission time of the other rows. The GPU compute pipelines are created lazily on the first physics call, so they are not included in the render-only init/cold/TTFF; that cost is paid in the verification step right before the steady state. reps=5 is the number of TTFF repetitions; steady-state FPS/physics time come from one 3-second run.

2026-10-27 improvement: CPU traversal + hybrid GPU ​

Same Apple M1 / Chrome 156 / 60 Hz environment, seed 42, 3-second steady-state runs. Before and after runs were measured sequentially, not as merged numbers over repeated rounds.

Nodes / edgesBefore: CPU physics ms / FPSAfter: CPU physics ms / FPSHybrid GPU physics ms / FPS
10k / 20k21.48 / 43.817.74 / 52.76.23 / 60.0
100k / 200k310.30 / 3.5285.76 / 3.741.87 / 19.6
  • The CPU caches centers of mass at rebuild time and traverses stack-free with small DFS records and escape indexes. Regression tests confirmed the traversal order and results are unchanged. A separate run right after the CPU change gave 10k 17.71 ms / 52.4 FPS and 100k 272.28 ms / 3.9 FPS.
  • Hybrid physics is about 3.4× faster than before at 10k and about 7.4× at 100k. 100k FPS is about 5.6×. The cost of CPU tree rebuild · integration, the per-step readback and the render upload remains.
  • The corrected cosmos (v3) ran at 60 FPS at 10k and 55.4 FPS at 100k. It uses a 2D force approximation and integration that differ, so this is not an equal-quality 3D comparison. cosmos's physics submission time (3.52 / 1.51 ms), which does not include GPU execution, must not be compared directly with the hybrid round-trip time.
  • Compared with the CPU on a real GPU/WASM, including nodes · pins stacked at the same position: for 64 nodes/8 steps (exact), 1k/16, 10k/8 and 100k/3, the maximum coordinate errors were 0, 0.00000382, 0.00000763 and 0.0000611. Picking after async completion, stale-result rejection, concurrent calls, dispose, sleep, shared device and the WebGL2 fallback were also verified.
  • Current results are merged into bench/results.json. The before/after raw files are the local web/bench/out/graph-before.json, graph-cpu-after.json and graph-after.json.

WARNING

The table and notes above record the 2026-10-27 hybrid path (GPU repulsion + per-step readback, then CPU springs · integration · spatial index). stepPhysicsAsync() has since become a GPU-resident layout: positions and velocities stay in GPU buffers, and the Barnes-Hut octree build · repulsion · springs · integration · auto-sleep run on the GPU. The Promise resolves at submission rather than after readback, and CPU edits during GPU work (moveNode, pinNode, styles, loadDataset, stepPhysics) are applied in order instead of being rejected. When you need current positions, for picking · export for example, await syncPhysicsPositions(). See 3D Network Graph for the current behavior.

ts
const gpuAvailable = sdk.supportsGpuPhysics();
await sdk.stepPhysicsAsync(0.016); // resolves at submission; CPU fallback on WebGL2
sdk.render();
await sdk.syncPhysicsPositions(); // bring the CPU copy up to date before picking · export

stepPhysics() stays on the CPU path. Await each asynchronous call before starting the next step (calling again while one is pending throws A physics step is already pending). If the GPU buffer/dispatch limits are exceeded, the Promise rejects (Graph exceeds GPU layout buffer/dispatch limits). It does not render automatically. The default full WASM build keeps the WebGL2 fallback; only the WebGPU-only build uses --no-default-features.

bash
cd web
npm run wasm:build
npm run bench:physics:check
npm run bench -- --filter=graph --compare-only --libs=viz,vizgpu,cosmos --backend=webgpu --reps=5 --warmup=1 --duration=3000 --out=bench/out/graph-after

graph-10k (10,000 nodes · 20,000 edges) ​

EngineRendererInit mscold msTTFF ms (p95)fpsSteady frame med / p95 / max msDroppedPhysics med (p95) msRender med (p95) msJS heap MBwasm MBUA memory MB
viz 0.1.0webgpu3017.616.7 (17.4)41.916.7 / 33.3 / 50.055/12622.3 (23.1)1.0 (1.2)8.77.115.4
three.js 0.170.0webgl28736.816.8 (17.7)16.366.7 / 83.3 / 100134/4956.4 (81.4)0.9 (2.1)25.8-40.5

graph-100k (100,000 nodes · 200,000 edges) ​

EngineRendererInit mscold msTTFF ms (p95)fpsSteady frame med / p95 / max msDroppedPhysics med (p95) msRender med (p95) msJS heap MBwasm MBUA memory MB
viz 0.1.0webgpu2363.523.5 (38.9)3.0367 / 383 / 383190/9361 (381)5.9 (7.9)13.663.020.4
three.js 0.170.0webgl23787.354.5 (60.4)0.81833 / 1833 / 1833218/21799 (1818)30.0 (30.7)114.2-128.8

Interpretation ​

  • The physics step accounts for most of the difference: Rust/wasm runs the same Barnes-Hut 2.5× faster than JS at 10k (22 vs 56 ms) and 5× faster at 100k (361 vs 1799 ms). So fps during simulation + rotation at 10k is 41.9 vs 16.3.
  • Neither is real-time at 100k. Running physics every frame, viz also drops to 3 fps (three.js 0.8 fps). Use a 100k graph by laying it out in advance with warmup() and then only rendering, or by running physics once every few frames (render-only fps was not measured in this scenario; render submission time per frame is 5.9 vs 30 ms).
  • First frame: at 10k both fit in one frame (16.7 vs 16.8 ms, effectively tied due to vsync quantization). At 100k it is 23.5 vs 54.5 ms, and cold is 63.5 vs 87.3 ms.
  • Memory: the three.js side has a 114 MB JS heap at 100k because of its data structure (GraphDatasetTS) that keeps a JS object per node. viz has a 13.6 MB JS heap + 63 MB wasm memory (wasm memory does not shrink, so this is the peak including repeated loads). GPU memory is not included in the tables above for either side (viz engine-reported GPU buffers 3.1 / 20.6 MB; three.js has no byte counter).

Fairness notes (known handicaps of the three.js implementation) ​

The three.js side uses the demo comparison screen's implementation untouched. It follows the usual three.js setup for large graphs (InstancedMesh nodes + LineSegments edges, the same Barnes-Hut), but some parts leave room for more optimization.

  • Physics (octree-physics.ts): nodes are kept as an array of objects (AoS); for every node computeRepulsion allocates a new [fx, fy, fz] array and a traversal stack array, and the octree cells are rebuilt as objects every step (GC pressure). Switching to flat TypedArrays + a reused stack could reduce the JS physics time. What was measured is "a plain JS implementation of the same algorithm", not an optimized JS implementation.
  • Render (three-js-adapter.ts syncBuffers): every frame rewrites setColorAt for every node and the edge color buffer even when colors do not change (part of the 30 ms render at 100k).
  • Node geometry differs: viz uses a quad impostor, three.js an 80-triangle sphere mesh (SphereGeometry(1, 8, 6), Lambert lighting). At 100k, three.js sends more triangles to the GPU. Frame time here is dominated by physics, so the effect is small, but for a render-only comparison THREE.Points + a sprite shader would be closer.
  • Algorithm details (negligible performance impact): Rust applies damping as damping^(dt·60) (0.88^0.96 at dt 0.016), JS multiplies by 0.88 every step. The Rust octree keeps nodes stacked at the maximum depth in a linked list, JS drops them. The three.js adapter's tick includes an O(N) kinetic energy sum in the physics time (about 1 ms at 100k).
  • The on-screen colors differ (three.js sRGB output conversion and Lambert lighting, background color). Unrelated to performance.
  • Synchronous viz physics is single-threaded wasm (no SIMD · threads). Only the optional vizgpu uses GPU compute for repulsion.

7. Bottleneck fixes in this work (before → after) ​

Before = the source before these fixes + a build with only the stats counters added; after = the current source. Before/after were run alternately for 3 rounds on the same machine (WebGPU, 5 reps, 2-second steady state); values are medians of the samples merged across rounds. Command per round: npm run bench -- --no-compare --backend=webgpu --filter=line-1m-1s,line-10m,area-stacked-1m,candle-1m,scatter-1m,stream-1m --reps=5 --duration=2000.

ScenarioRender call ms (before → after)TTFF msSteady work med msSteady work p95 msMain thread ms/frameDropped frames (sum of 3)wasm MB
line-1m-1s21.5 → 13 (-40%)22 → 15.32 → 24.7 → 53 → 30 → 09.5 → 6.5
line-10m-1s109 → 91.8 (-16%)109 → 92.25 → 4.819.3 → 20.48.5 → 8.59 → 1178.1 → 40.8
line-10m-10s125 → 74.8 (-40%)125 → 75.39.4 → 6.725.8 → 21.413.3 → 10.236 → 1378.7 → 78.7
scatter-1m46 → 39.9 (-13%)46.5 → 40.41.1 → 11.9 → 1.71.8 → 1.60 → 055.7 → 55.7
area-stacked-1m36.3 → 26.5 (-27%)36.8 → 26.97.4 → 6.513.4 → 119 → 8.10 → 037.8 → 39.4
candle-1m58.3 → 35 (-40%)58.7 → 35.43.2 → 2.913 → 7.75.6 → 4.31 → 040.4 → 40.4
stream-1m22.8 → 13.6 (-40%)23.2 → 16.43.9 → 3.28.2 → 6.14.9 → 4.40 → 032.5 → 29.1

What changed (all results identical — compared against the previous implementation in unit tests):

  1. min/max without NaN handling (chart/finite.rs, line.rs, candle.rs, scatter.rs): f32::min/max fall back to a slow sequence on wasm32 for every comparison because of NaN operand handling. Values that already passed an is_finite check use a plain compare-select (2–3× faster).
  2. Single-pass line extents (line.rs extents): finite min/max and positive min/max (for log axes) went from 2 passes to 1 pass per series.
  3. M4 LOD inner loop (line.rs push_m4): the current min/max values are kept in registers (previously re-read by index for every sample), with the same keep-first-index-on-tie rule. This is the hot loop that walks every visible sample on every zoom/pan frame.
  4. No copy for a single series (charturbo.ts, lib.rs): one series + Float32Array (and Float64Array x) is passed as-is without building a new concatenated array, and the Rust side owns the Vec that wasm-bindgen already copied instead of copying a second time → wasm memory for a 10M single series 78 → 41 MB.
  5. Shared O(n) pass for accessibility summaries (chart-a11y.ts withPassMemo): the summary and the description used to compute the same series statistics separately within one update; they are now memoized only within that update (if the data changes in place, the next update computes them again).
  6. Render stats API (renderer/stats.rs, lib_stats.rs, chart-stats.ts, Charturbo.getRenderStats()): GPU buffer/texture bytes, chart upload bytes per frame, mark counts per layer, wasm memory size. For benchmarks and diagnostics (no change to rendering).

Not improved: for line-10m-1s, steady work p95 and the dropped count are unchanged or within noise (9 → 11). Most of the remaining 10M first-render time is the JS → wasm copy and the first LOD.

8. Limits and caveats ​

  • These are results from one machine (Apple M1, headless Chrome 156, 60 Hz). Headless Chrome may produce frames differently from real display compositing, and numbers will differ on 120 Hz displays · other GPUs · Windows/Linux · Firefox/Safari.
  • TTFF and frame intervals are quantized to vsync (16.7 ms). For small data, "16.7 ms" means "within one frame", and the render call column is more precise.
  • GPU execution time is not measured directly (no timestamp queries). pixels (1 px readback) and frame intervals show GPU-bound situations indirectly (e.g. scatter-5m).
  • Synthetic input (dispatched Pointer/Wheel events) and a time-based schedule are used. Real user input latency (input → screen) is not measured.
  • The render call scope differs between libraries in the comparison (section 3 above). Each library uses its documented large-data settings, but not every possible tuning was tried (e.g. a quadtree for uPlot scatter, tuning ECharts progressive, no decimation for Chart.js scatter). Improvement suggestions can go into bench/others.ts.
  • Plotly line-10m failed with the 300-second timeout, and bar-10k was excluded because the gl2d bundle has no bar trace.
  • The 3D graph comparison compares whole engines (data structures + physics + render). It is not a comparison of three.js's own render performance; the handicaps of the three.js implementation are listed in section 6. The three.js steady state at graph-100k has only 2 frames in 3 seconds, so fps 0.8 is a rough value.
  • The additional graph libraries do not do the same work as viz/three.js: deck.gl only renders without physics (physics 0 ms), cosmos.gl is a 2D GPU layout (z dropped, no camera rotation), 3d-force-graph uses d3-force-3d (a different force model and constants). Do not compare fps directly; look at the physics / render columns separately.
  • ChartGPU is WebGPU-only and its zoom supports the x axis only, so the scatter y window is missing. The SciChart community license draws a watermark, and the actually selected backend is recorded in the results. Highcharts has a commercial license, and its Boost threshold is 5,000.
  • Measurement corrections: the earlier SciChart ~33 ms TTFF measured only the redraw after the data copy had finished during initialization. The earlier 3d-force-graph ~55 FPS / 0.01 ms physics came from only calling d3ReheatSimulation() on a stopped simulation. The earlier description that read these as correct data-load · physics performance was wrong. The corrected adapters record methodologyVersion: 2 and are replaced by re-measured results.
  • The top-level meta of a merged file is the environment of the first run. Check each new comparison entry's own measurement. When older records are fed in, mixed conditions · unverified results are flagged, and a uniform 5 runs · 3 seconds is not assumed.
  • Memory: the JS heap is the post-GC value; wasm memory does not shrink, so it is the peak including warm-up. GPU bytes are the capacity allocated by the viz engine and do not include driver overhead.
  • Noise in the before/after comparison: render call variance between repeats of the same scenario is roughly ±5%, dropped frames ± a few frames.

See also ​

MIT License