Skip to content
Draft
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
103 changes: 103 additions & 0 deletions benchmarks/README.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,103 @@
<!--
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/.
-->

# 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.
65 changes: 65 additions & 0 deletions benchmarks/nad-geometry-precision-bench.mjs
Original file line number Diff line number Diff line change
@@ -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.');
132 changes: 132 additions & 0 deletions benchmarks/nad-lazy-mount-bench.mjs
Original file line number Diff line number Diff line change
@@ -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=<viewers> (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);
});
Loading
Loading