diff --git a/benchmarks/README.md b/benchmarks/README.md new file mode 100644 index 00000000..74160bb3 --- /dev/null +++ b/benchmarks/README.md @@ -0,0 +1,103 @@ + + +# NAD adaptive-zoom writer benchmarks + +Reproducible benchmarks for the three performance changes stacked on the +adaptive-zoom SVG-writer branch (`integration/nad_create_svg_adaptive_zoom`, PR +#423 upstream): + +| bench | measures | PR | +| ---------------------------------- | ----------------------------------------------------------- | ------------------------------- | +| `nad-metadata-index-bench.mjs` | writer metadata-lookup cost, `find`/`filter` vs Map index | O(1) metadata lookups | +| `nad-geometry-precision-bench.mjs` | emitted coordinate-text size at each precision | configurable geometry precision | +| `nad-lazy-mount-bench.mjs` | eager vs lazy off-screen viewer construction (real browser) | lazyMount off-screen gate | + +All three use the `case1354pegase` demo diagram (1312 nodes / 1991 edges / 1354 +bus nodes). + +## Running + +The two node benches are self-contained (no build, no server): + +```bash +node benchmarks/nad-metadata-index-bench.mjs +node benchmarks/nad-geometry-precision-bench.mjs +``` + +The lazy-mount bench drives a real headless Chromium and needs the demo dev +server plus `playwright-core`: + +```bash +npm install --no-save playwright-core # if not already present +npm run start & # serves the demo on :5173 +node benchmarks/nad-lazy-mount-bench.mjs # N=8 viewers, raw-SVG path +N=16 node benchmarks/nad-lazy-mount-bench.mjs # more off-screen viewers +CREATE_SVG_FROM_METADATA=1 node benchmarks/nad-lazy-mount-bench.mjs # exercise the #423 client writer +``` + +Env vars: `N` (viewer count, default 8), `CREATE_SVG_FROM_METADATA=1` (build via +the client SVG writer instead of injecting the raw SVG), `PW_CHROMIUM`, +`REPO_ROOT`. + +## Recorded results + +Headless Chromium, dev mode, one shared container. Absolute times are +machine- and run-dependent (dev-mode GC/JIT); the **ratios** are the stable, +portable figures. + +### Metadata-lookup indexing + +Replays the writer's per-element access pattern (per node: bus-node + node-edge +lookups; per edge: two node lookups): + +| diagram | old (`find`/`filter`) | indexed | speedup | +| ---------- | --------------------- | ----------- | --------- | +| N = 500 | 9.7 ms | 0.76 ms | 13× | +| N = 1000 | 24.1 ms | 0.49 ms | 50× | +| N = 2000 | 71.7 ms | 1.23 ms | 58× | +| N = 4000 | 325.7 ms | 2.73 ms | 119× | +| **pegase** | **33.2 ms** | **0.31 ms** | **~100×** | + +Old time roughly quadruples per doubling of N (O(n²)); indexed grows ~linearly. +On real metadata the index is built once and reused across every redraw. + +### Geometry precision (coordinate text) + +Size of the emitted coordinate text on real pegase geometry: + +| precision | raw | vs p2 | gzip | vs p2 | +| --------------- | ------- | ------ | ------- | ---------- | +| 3 | 64.8 KB | +13.5% | 10.8 KB | +4.0% | +| **2 (default)** | 57.1 KB | — | 10.4 KB | — | +| 1 | 49.4 KB | −13.5% | 8.7 KB | **−16.5%** | +| 0 | 34.0 KB | −40.4% | 6.6 KB | **−36.2%** | + +These are the **coordinate-text** bytes. Measured on the **whole generated SVG** +by the writer's own size test (on the geometry-precision branch), the document +is 1093 KB raw / 168.5 KB gzip at precision 2, dropping to 156.0 KB gzip +(−7.4%) at precision 1 and 140.8 KB gzip (−16.4%) at precision 0 — smaller +percentages because coordinates are only part of the document, but the same +direction, and gzip savings exceed raw in both views. + +### Lazy off-screen construction (real browser) + +N `case1354pegase` viewers stacked in a one-viewport scroll container, eager +(build all on load) vs lazy (`IntersectionObserver`, build on view): + +| path | N | per-viewer | eager on load | lazy on load | saved | ratio | +| ------------------ | --- | ---------- | ------------- | ---------------- | ---------- | -------- | +| raw SVG inject | 8 | ~41 ms | 326 ms | 81 ms (2 built) | ~244 ms | **4.0×** | +| raw SVG inject | 16 | ~41 ms | 658 ms | 82 ms (2 built) | ~575 ms | **8.0×** | +| #423 client writer | 8 | ~373 ms | 2985 ms | 746 ms (2 built) | **~2.2 s** | **4.0×** | + +- The ratio is `N / visible` (≈2 visible here), so a page showing ~1 of N + approaches an **N×** reduction in initial-load construction work. +- After scrolling, all N viewers are built on demand (correctness confirmed). +- The client-writer path costs ~9× more per viewer than raw injection because it + builds the SVG DOM element-by-element in JS — which is exactly where the + metadata-lookup indexing above pays off. diff --git a/benchmarks/nad-geometry-precision-bench.mjs b/benchmarks/nad-geometry-precision-bench.mjs new file mode 100644 index 00000000..1e461f1f --- /dev/null +++ b/benchmarks/nad-geometry-precision-bench.mjs @@ -0,0 +1,65 @@ +/** + * Copyright (c) 2026, RTE (http://www.rte-france.com) + * This Source Code Form is subject to the terms of the Mozilla Public + * License, v. 2.0. If a copy of the MPL was not distributed with this + * file, You can obtain one at http://mozilla.org/MPL/2.0/. + * + * Benchmark for the configurable geometry precision (PR "configurable geometry + * precision in the client SVG writer"). + * + * The client SVG writer formats every coordinate with toFixed(precision). This + * script measures the size of the emitted coordinate text at several precisions + * on the real case1354pegase geometry (node positions, edge bending points and + * text-node shifts), raw and gzipped. It is self-contained (no app import). + * + * The full generated-SVG figures (raw + gzip of the whole document) are + * produced by the writer's own size test on the geometry-precision branch and + * are recorded in benchmarks/README.md. Coordinate text is the part this lever + * acts on, so the percentages here track those closely. Run: + * + * node benchmarks/nad-geometry-precision-bench.mjs + */ +import { readFileSync } from 'node:fs'; +import { gzipSync } from 'node:zlib'; + +const ROOT = globalThis.process.env.REPO_ROOT ?? '/home/user/powsybl-network-viewer'; +const PEGASE = `${ROOT}/demo/src/diagram-viewers/data/case1354pegase_metadata.json`; + +const m = JSON.parse(readFileSync(PEGASE, 'utf8')); + +// collect the geometry coordinate values the writer emits from the metadata +function collectCoords() { + const v = []; + for (const n of m.nodes ?? []) v.push(n.x, n.y); + for (const e of m.edges ?? []) for (const p of e.bendingPoints ?? []) v.push(p.x, p.y); + for (const t of m.textNodes ?? []) v.push(t.shiftX, t.shiftY, t.connectionShiftX, t.connectionShiftY); + return v.filter((x) => typeof x === 'number'); +} + +const coords = collectCoords(); +// emulate how the coordinates appear in the SVG: "x,y x,y ..." pairs +function renderAt(precision) { + let s = ''; + for (let i = 0; i < coords.length; i += 2) { + s += coords[i].toFixed(precision) + ',' + (coords[i + 1] ?? 0).toFixed(precision) + ' '; + } + return s; +} + +const enc = (s) => new TextEncoder().encode(s); +const base = enc(renderAt(2)).length; +const baseGz = gzipSync(enc(renderAt(2))).length; + +console.log(`=== geometry-precision benchmark (pegase coordinate text, ${coords.length} values) ===`); +console.log('precision raw vs p2 gzip vs p2'); +for (const p of [3, 2, 1, 0]) { + const s = renderAt(p); + const raw = enc(s).length; + const gz = gzipSync(enc(s)).length; + const dRaw = (((raw - base) / base) * 100).toFixed(1); + const dGz = (((gz - baseGz) / baseGz) * 100).toFixed(1); + console.log( + `${String(p).padEnd(11)} ${(raw / 1024).toFixed(1).padStart(6)} KB ${(dRaw + '%').padStart(7)} ${(gz / 1024).toFixed(1).padStart(6)} KB ${(dGz + '%').padStart(7)}` + ); +} +console.log('\nGzip savings exceed raw: fewer decimal digits lower symbol entropy, which compresses better.'); diff --git a/benchmarks/nad-lazy-mount-bench.mjs b/benchmarks/nad-lazy-mount-bench.mjs new file mode 100644 index 00000000..d8646daa --- /dev/null +++ b/benchmarks/nad-lazy-mount-bench.mjs @@ -0,0 +1,132 @@ +/** + * Copyright (c) 2026, RTE (http://www.rte-france.com) + * This Source Code Form is subject to the terms of the Mozilla Public + * License, v. 2.0. If a copy of the MPL was not distributed with this + * file, You can obtain one at http://mozilla.org/MPL/2.0/. + * + * Benchmark for the lazyMount off-screen gate (PR "lazyMount helper to defer + * off-screen viewer construction"). + * + * Stacks N case1354pegase viewers in a viewport-sized scroll container and + * compares eager construction (all on load) vs lazy construction (each built + * only when its slot enters the viewport, via IntersectionObserver — the same + * mechanism lazyMount packages). Reports the initial-load construction work + * saved and confirms deferred viewers build on demand while scrolling. + * + * Requires the demo dev server running (npm run start) and playwright-core. + * Imports the viewer source + data through Vite's /@fs, so it reflects the + * currently checked-out source. + * + * Env: N= (default 8), CREATE_SVG_FROM_METADATA=1 to exercise the + * #423 client SVG-writer construction path instead of injecting the raw SVG. + */ +import { chromium } from 'playwright-core'; + +const EXE = globalThis.process.env.PW_CHROMIUM ?? '/opt/pw-browsers/chromium-1194/chrome-linux/chrome'; +const ROOT = globalThis.process.env.REPO_ROOT ?? '/home/user/powsybl-network-viewer'; +const N = Number(globalThis.process.env.N ?? 8); +const CREATE_SVG_FROM_METADATA = globalThis.process.env.CREATE_SVG_FROM_METADATA === '1'; +const P = `/@fs${ROOT}/packages/network-viewer-core/src/network-area-diagram-viewer/network-area-diagram-viewer.ts`; +const SVG = `/@fs${ROOT}/demo/src/diagram-viewers/data/case1354pegase.svg?raw`; +const META = `/@fs${ROOT}/demo/src/diagram-viewers/data/case1354pegase_metadata.json`; + +async function main() { + const browser = await chromium.launch({ executablePath: EXE, headless: true }); + const page = await browser.newPage({ viewport: { width: 1300, height: 900 } }); + page.on('pageerror', (e) => console.error('[pageerror]', e.message)); + await page.goto('http://localhost:5173/', { waitUntil: 'load', timeout: 60000 }); + + const result = await page.evaluate( + async ([modUrl, svgUrl, metaUrl, n, createSvgFromMetadata]) => { + const [mod, svgMod, metaMod] = await Promise.all([import(modUrl), import(svgUrl), import(metaUrl)]); + const NAD = mod.NetworkAreaDiagramViewer; + const svg = svgMod.default; + const meta = metaMod.default; + const params = { enableDragInteraction: true, addButtons: true, createSvgFromMetadata }; + const nextFrame = () => new Promise((r) => requestAnimationFrame(() => r())); + const sleep = (ms) => new Promise((r) => setTimeout(r, ms)); + + const makeScroller = () => { + const s = document.createElement('div'); + s.style.cssText = 'position:absolute;left:0;top:0;width:1250px;height:900px;overflow:auto;'; + const slots = []; + for (let i = 0; i < n; i++) { + const slot = document.createElement('div'); + slot.style.cssText = 'width:1200px;height:900px;box-sizing:border-box;'; + s.appendChild(slot); + slots.push(slot); + } + document.body.appendChild(s); + return { s, slots }; + }; + + // ---- EAGER: construct every viewer immediately (current demo behaviour) ---- + const eager = makeScroller(); + const tE = performance.now(); + for (const slot of eager.slots) { + new NAD(slot, svg, meta, params); + } + const eagerMs = performance.now() - tE; + eager.s.remove(); + + // ---- LAZY: construct on first intersection ---- + const lazy = makeScroller(); + let built = 0; + let firstBuildMs = 0; + const tL0 = performance.now(); + const observer = new IntersectionObserver( + (entries) => { + for (const entry of entries) { + if (entry.isIntersecting && !entry.target.dataset.built) { + entry.target.dataset.built = '1'; + new NAD(entry.target, svg, meta, params); + built++; + if (built === 1) firstBuildMs = performance.now() - tL0; + observer.unobserve(entry.target); + } + } + }, + { root: lazy.s, rootMargin: '0px', threshold: 0 } + ); + lazy.slots.forEach((slot) => observer.observe(slot)); + + // let the observer fire for what's visible at the top (no scroll yet) + await nextFrame(); + await sleep(50); + const builtOnLoad = built; + + // now scroll through to confirm the rest build on demand + for (let y = 0; y <= lazy.s.scrollHeight; y += 900) { + lazy.s.scrollTop = y; + await nextFrame(); + await sleep(40); + } + await sleep(50); + const builtAfterScroll = built; + observer.disconnect(); + lazy.s.remove(); + + return { n, eagerMs, firstBuildMs, builtOnLoad, builtAfterScroll, perViewerMs: eagerMs / n }; + }, + [P, SVG, META, N, CREATE_SVG_FROM_METADATA] + ); + + const r = result; + console.log(`\n=== Phase 1 prototype: lazy off-screen NAD construction (case1354pegase x ${r.n}) ===`); + console.log(`per-viewer construction: ~${r.perViewerMs.toFixed(1)} ms`); + console.log(`EAGER build all ${r.n} on load: ${r.eagerMs.toFixed(0)} ms of main-thread work`); + console.log( + `LAZY built on load (visible): ${r.builtOnLoad} (~${(r.builtOnLoad * r.perViewerMs).toFixed(0)} ms; first ready in ${r.firstBuildMs.toFixed(0)} ms)` + ); + console.log(`LAZY built after scrolling: ${r.builtAfterScroll} / ${r.n} (deferred ones built on demand)`); + const saved = r.eagerMs - r.builtOnLoad * r.perViewerMs; + console.log( + `initial-load main-thread work saved: ~${saved.toFixed(0)} ms (${(r.n / Math.max(1, r.builtOnLoad)).toFixed(1)}x fewer constructions)` + ); + await browser.close(); +} + +main().catch((e) => { + console.error(e); + globalThis.process.exit(1); +}); diff --git a/benchmarks/nad-metadata-index-bench.mjs b/benchmarks/nad-metadata-index-bench.mjs new file mode 100644 index 00000000..0f8eaffb --- /dev/null +++ b/benchmarks/nad-metadata-index-bench.mjs @@ -0,0 +1,156 @@ +/** + * Copyright (c) 2026, RTE (http://www.rte-france.com) + * This Source Code Form is subject to the terms of the Mozilla Public + * License, v. 2.0. If a copy of the MPL was not distributed with this + * file, You can obtain one at http://mozilla.org/MPL/2.0/. + * + * Benchmark for the O(1) metadata-lookup indexing (PR "O(1) metadata lookups + * for the adaptive-zoom SVG writer"). + * + * The client SVG writer resolves metadata per element while (re)drawing a + * diagram: for every node it looks up its bus nodes and its edges, for every + * edge its two end nodes. With Array.find/filter that is O(n) per lookup and + * O(n^2) over the whole diagram; with per-array Map indices it is O(1). + * + * This script is self-contained (no app import): it replays that exact access + * pattern with both implementations. The "indexed" functions mirror the ones + * shipped in metadata-utils.ts (verified byte-identical in behaviour by the + * metadata-utils unit tests). Run: + * + * node benchmarks/nad-metadata-index-bench.mjs + */ +import { readFileSync } from 'node:fs'; + +const ROOT = globalThis.process.env.REPO_ROOT ?? '/home/user/powsybl-network-viewer'; +const PEGASE = `${ROOT}/demo/src/diagram-viewers/data/case1354pegase_metadata.json`; + +// ---------- OLD: linear scans (pre-index) ---------- +const oldGetNode = (id, m) => m.nodes.find((n) => n.svgId == id); +const oldGetBusNodes = (id, busNodes) => busNodes.filter((b) => b.vlNode === id); +const oldGetNodeEdges = (id, edges) => edges.filter((e) => e.node1 == id || e.node2 == id); + +// ---------- NEW: Map indices built once per array (mirror of metadata-utils.ts) ---------- +const nodesById = new WeakMap(); +const busByVl = new WeakMap(); +const edgesByNode = new WeakMap(); +function indexById(arr) { + let m = nodesById.get(arr); + if (!m) { + m = new Map(); + for (const n of arr) if (!m.has(n.svgId)) m.set(n.svgId, n); + nodesById.set(arr, m); + } + return m; +} +function indexBusByVl(arr) { + let m = busByVl.get(arr); + if (!m) { + m = new Map(); + for (const b of arr) { + const g = m.get(b.vlNode); + if (g) g.push(b); + else m.set(b.vlNode, [b]); + } + busByVl.set(arr, m); + } + return m; +} +function indexEdgesByNode(arr) { + let m = edgesByNode.get(arr); + if (!m) { + m = new Map(); + const add = (k, e) => { + const g = m.get(k); + if (g) g.push(e); + else m.set(k, [e]); + }; + for (const e of arr) { + add(e.node1, e); + if (e.node2 !== e.node1) add(e.node2, e); + } + edgesByNode.set(arr, m); + } + return m; +} +const newGetNode = (id, m) => indexById(m.nodes).get(id); +const newGetBusNodes = (id, busNodes) => indexBusByVl(busNodes).get(id) ?? []; +const newGetNodeEdges = (id, edges) => indexEdgesByNode(edges).get(id) ?? []; + +// reproduce the writer's per-element metadata access pattern +function runPattern(m, useNew) { + let acc = 0; + for (const node of m.nodes) { + const buses = useNew ? newGetBusNodes(node.svgId, m.busNodes) : oldGetBusNodes(node.svgId, m.busNodes); + const edges = useNew ? newGetNodeEdges(node.svgId, m.edges) : oldGetNodeEdges(node.svgId, m.edges); + acc += buses.length + edges.length; + } + for (const edge of m.edges) { + const n1 = useNew ? newGetNode(edge.node1, m) : oldGetNode(edge.node1, m); + const n2 = useNew ? newGetNode(edge.node2, m) : oldGetNode(edge.node2, m); + acc += (n1 ? 1 : 0) + (n2 ? 1 : 0); + } + return acc; +} + +const median = (xs) => [...xs].sort((a, b) => a - b)[Math.floor(xs.length / 2)]; +function timeOnce(m, useNew) { + const t = performance.now(); + runPattern(m, useNew); + return performance.now() - t; +} + +function buildSynthetic(nNodes) { + const nodes = []; + const busNodes = []; + for (let i = 0; i < nNodes; i++) { + nodes.push({ svgId: String(i), equipmentId: 'eq' + i, x: i, y: i }); + busNodes.push({ svgId: 'b' + i, equipmentId: 'be' + i, nbNeighbours: 0, index: 0, vlNode: String(i) }); + } + const edges = []; + const nEdges = Math.floor(nNodes * 1.5); + for (let i = 0; i < nEdges; i++) { + const a = (i * 7) % nNodes; + const b = (i * 13 + 1) % nNodes; + edges.push({ + svgId: 'e' + i, + node1: String(a), + node2: String(b), + busNode1: 'b' + a, + busNode2: 'b' + b, + type: 'LineEdge', + }); + } + return { nodes, busNodes, edges, textNodes: [] }; +} + +console.log('=== metadata-lookup benchmark (writer access pattern) ==='); +console.log('diagram old (find/filter) indexed speedup'); +for (const n of [500, 1000, 2000, 4000]) { + const oldT = []; + const newT = []; + for (let r = 0; r < 5; r++) { + oldT.push(timeOnce(buildSynthetic(n), false)); // fresh arrays -> index rebuilt each run + newT.push(timeOnce(buildSynthetic(n), true)); + } + const o = median(oldT); + const nw = median(newT); + console.log( + `N=${String(n).padEnd(28)} ${o.toFixed(2).padStart(8)} ms ${nw.toFixed(3).padStart(8)} ms ${(o / nw).toFixed(0)}x` + ); +} + +const pegase = JSON.parse(readFileSync(PEGASE, 'utf8')); +const oldT = []; +const newT = []; +for (let r = 0; r < 7; r++) { + oldT.push(timeOnce(pegase, false)); + newT.push(timeOnce(pegase, true)); // same object across runs -> index built once, reused (real usage) +} +const o = median(oldT); +const nw = median(newT); +const label = `pegase (${pegase.nodes.length}n/${pegase.edges.length}e/${pegase.busNodes.length}b)`; +console.log( + `${label.padEnd(28)} ${o.toFixed(2).padStart(8)} ms ${nw.toFixed(3).padStart(8)} ms ${(o / nw).toFixed(0)}x` +); +console.log('\nOld grows ~quadratically (~4x per doubling of N); indexed grows ~linearly.'); +console.log('On real metadata the index is built once and reused across every redraw.');