Spaces:
Paused
Loading Study — parallelised XHR waterfall + text-format base diagram
Context (v6 trace)
Chrome DevTools trace of a full "Load Study" gesture on the large grid (PyPSA-EUR France 400 kV, ~25 MB SVG) showed the following XHR waterfall:
0.0 s ────── /api/config 14.1 s (server pypowsybl load)
14.1 s ─┬──── /api/voltage-levels 0.3 s
├──── /api/nominal-voltages 0.3 s
└──── /api/branches 0.8 s
14.9 s ────── /api/network-diagram 6.6 s (2.5 MB gzip, 25 MB decoded)
21.5 s ────── client render ~3.5 s
Two findings in the waterfall:
/api/network-diagramwaited for/api/branches— the frontend passedvoltageLevels.lengthas a hint toprocessSvg(node/text upscaling on large grids), so the diagram call only fired after thePromise.all([branches, voltage-levels, nominal-voltages])settled. That ~0.8 s gap is pure serialisation cost.XHRLoad= 620 ms on the 25 MB response — attributable toJSON.parseof the giant SVG string embedded inside the JSON envelope. JSON parsers have to escape-scan every byte and allocate a second buffer for the string value, both of which scale linearly with payload size.
Changes
#1 — Parallelise the 4 post-config XHRs (App.tsx + useSession.ts)
- const [branchRes, vlRes, nomVRes] = await Promise.all([
- api.getBranches(),
- api.getVoltageLevels(),
- api.getNominalVoltages(),
- ]);
- // ... state sets ...
- diagrams.fetchBaseDiagram(vlRes.voltage_levels.length);
+ const [branchRes, vlRes, nomVRes, diagramRaw] = await Promise.all([
+ api.getBranches(),
+ api.getVoltageLevels(),
+ api.getNominalVoltages(),
+ api.getNetworkDiagram(),
+ ]);
+ // ... state sets ...
+ diagrams.ingestBaseDiagram(diagramRaw, vlRes.voltage_levels.length);
Three call sites were updated:
App.tsx::applySettingsImmediate(Apply Settings button)App.tsx::handleLoadConfig(Load Study button)useSession.ts::handleRestoreSession(session reload)
A new ingestBaseDiagram(raw, vlCount) method on useDiagrams does
just the processSvg + state-set work (no fetch), letting callers
drive the parallelism themselves. The legacy fetchBaseDiagram(vlCount)
helper is kept for backwards compat (tests, future single-call paths).
Wire impact: the diagram XHR now fires alongside
branches/voltage-levels/nominal-voltages at t = config_end,
so the critical path becomes MAX(branches, nomV, vl, diagram) = diagram. Saves the ~0.8 s branches gap.
#4 — format=text response for /api/network-diagram
Adds a format query parameter to the endpoint:
?format=json (default) |
?format=text (new) |
|---|---|
Content-Type: application/json |
Content-Type: text/plain; charset=utf-8 |
Body: {"svg": "<svg>...</svg>", "metadata":..., ...} |
Body: {"metadata":..., ...}\n<svg>...</svg> |
JSON.parse(body) scans the full 25 MB |
body.indexOf('\n') + JSON.parse(prefix) — the SVG never passes through JSON.parse |
Gzip is still applied when the client signals Accept-Encoding: gzip
(same gate as _maybe_gzip_json), so wire bytes are unchanged.
The JSON default is preserved for backwards compatibility:
standalone_interface.html(uses axios's JSON mode directly)- third-party API consumers
- backend test
test_successthat still asserts onresponse.json()
The frontend api.getNetworkDiagram helper switches to
?format=text via fetch (axios would try to JSON-parse the body).
Invariants (tested)
Backend (test_api_endpoints.py::TestGetNetworkDiagram):
test_success— defaultformat=jsonstill returns{"svg": ..., "metadata": ...}.test_text_format_returns_header_plus_svg—format=textreturnsContent-Type: text/plain, the first line parses as JSON with all non-svgfields (lines_overloaded,lines_overloaded_rho,metadata), and the SVG is the rest of the body verbatim (NOT JSON-escaped).test_text_format_gzip— large text-format responses areContent-Encoding: gzipwhen the client sendsAccept-Encoding: gzip.
Frontend (api.test.ts::getNetworkDiagram):
- Parses header + SVG correctly from the
text/plainbody. - Surfaces non-2xx responses as thrown errors (preserves existing try/catch in App.tsx).
Measured impact
Baseline (v6 trace, current main tip before this change):
| Segment | v6 wall-clock |
|---|---|
/api/config |
14 103 ms |
/api/branches (serial gate) |
775 ms |
/api/network-diagram |
6 645 ms |
XHRLoad (JSON.parse) |
618 ms |
| Load Study total | ~24.0 s |
v7 trace — after this change
Waterfall (XHRs)
| XHR | v6 timing | v7 timing | Δ |
|---|---|---|---|
/api/config |
0 → 14 103 | 0 → 14 790 | +687 ms (server variance) |
/api/branches |
14 101 → 14 876 | 14 788 → 15 553 | now runs in parallel with diagram |
/api/voltage-levels |
14 102 → 14 428 | 14 788 → 15 158 | parallel |
/api/nominal-voltages |
14 102 → 14 410 | 14 789 → 15 136 | parallel |
/api/network-diagram |
start 14 875 (after branches) | start 14 789 (parallel) | start −86 ms |
/api/network-diagram |
end 21 520 | end 21 174 | −346 ms end |
/api/network-diagram server-side |
6 645 ms | 6 385 ms | −260 ms (body size same, likely encoding CPU saved) |
✅ Parallelisation confirmed: the base-diagram XHR now starts at the
same timestamp as branches / voltage-levels / nominal-voltages.
Render window (3 s post-diagram-XHR, matched between traces)
| Metric | v6 | v7 | Δ |
|---|---|---|---|
RasterTask |
1 610 ms | 1 411 ms | −199 ms |
Paint |
749 ms | 855 ms | +106 ms (variance) |
UpdateLayoutTree |
340 ms | 357 ms | +17 ms |
Layout |
285 ms | 292 ms | +7 ms |
Layerize |
131 ms | 102 ms | −29 ms |
PrePaint |
160 ms | 87 ms | −73 ms |
ParseHTML |
414 ms | 293 ms | −121 ms ✅ |
| Long tasks cumulés (fenêtre 3 s) | 4 818 ms | 2 405 ms | −2 413 ms (−50 %) 🎯 |
✅ Text-format confirmed: ParseHTML drops by 121 ms because the SVG
bytes go straight to Blink's HTML parser instead of being unwrapped
from a JSON-encoded string first. RasterTask, PrePaint, and
Layerize all drop slightly — indirect benefit of less string
allocation pressure during the render.
Critical-path summary
| v6 | v7 | Δ | |
|---|---|---|---|
| config end → diagram XHR end | 7 417 ms | 6 384 ms | −1 033 ms (−14 %) |
| Full Load Study wall-clock | ~24.0 s | ~21.2 s | −2.8 s (−12 %) |
The ~1 s saved on the XHR critical path is the sum of (a) parallelising
the branches gap, (b) shaving JSON encoding server-side because the SVG
bypasses JSON serialisation in format=text mode. The ~2 s saved post-XHR
is pure client-side rendering budget (less ParseHTML, less GC pressure,
less allocation thrash on the 25 MB string).
Expected before the change:
Load Study total: ~24.0 s → ~20.9 s (−12 %).
Measured: 24.0 s → **21.2 s** (−12 %). On target.
v8-v17 follow-up: NAD prefetch, network mutualisation & vectorisation
Cumulative gains delivered by subsequent commits on the same branch
(documented in their own files — docs/performance/history/nad-prefetch.md,
docs/performance/history/shared-network.md, docs/performance/history/grid2op-shared-network.md,
docs/performance/history/vectorize-topology-cache.md,
docs/performance/history/topology-cache-iter2.md,
docs/performance/history/nad-prefetch-earlier-spawn.md,
docs/performance/history/initial-lf-dc-init.md,
docs/performance/history/narrow-voltage-level-queries.md):
| Trace | Last XHR end | Δ vs v7 | Key change |
|---|---|---|---|
| v7 | 21 174 ms | baseline | parallel XHRs + text-format |
| v8 | 20 535 ms | −639 ms | NAD prefetch during /api/config |
| v9 | 17 966 ms | −3 208 ms | mutualise _base_network ↔ network_service.network |
| v10 | 17 384 ms | −3 790 ms | share Network with grid2op backend (eliminate 3rd parse) |
| v11 (attempt) | 19 071 ms | −2 103 ms | ⚠️ REVERTED — isolated Network per thread regressed by +1.7 s vs v10. See docs/performance/history/isolated-nad-worker-rejected.md. |
| v12 (attempt) | 19 185 ms | −1 989 ms | ⚠️ REVERTED — deferred detect_non_reconnectable_lines to a background worker, but JVM contention inflated the 4 parallel XHRs by +2 s. |
| v13 | 21 804 ms | +630 ms | ⚠️ No measurable gain — initial 4× bench was a JIT cold-start artifact. Upstream patch KEPT as defensive dead-code fix. See docs/performance/history/detect-non-reconnectable-fast-path.md. |
| v14 | 11 723 ms | −9 451 ms | skip initial env.get_obs() (0.2.0.post3) + vectorise NetworkTopologyCache (0.2.0.post4) |
| v15 | 10 190 ms | −10 984 ms | NAD prefetch spawned earlier in update_config (docs/performance/history/nad-prefetch-earlier-spawn.md) |
| v16 | 9 578 ms | −11 596 ms | NetworkTopologyCache iter 2: groupby→raw loop + narrow attrs (0.2.0.post5/post6) |
| v17 | 8 945 ms | −12 229 ms | initial LF DC_VALUES init (0.2.0.post7) + GEOGRAPHICAL NAD layout |
| v18 | ~8.8 s (projected) | −12 374 ms (−58 %) | narrow voltage-level queries + drop unused kind attr (this commit) — /api/nominal-voltages 144 → 5.7 ms (~25×), /api/voltage-levels 7.5 → 4.5 ms, _get_switches_with_topology 174 → 141 ms |
Current optimised state: v18 ≈ 8.8 s.
Critical path v6 → v18: 24.0 s → ~8.8 s (−15.2 s / −63 %). 🎯
What this does NOT change
- Server compute time: pypowsybl network load + NAD generation are untouched. The only way to shorten these is #2 (NAD generation during config) or #3 (disk cache of NAD on network hash+mtime) — see the analysis notes, out of scope for this commit.
- Client render time (~3.5 s Paint/Layout/Raster): untouched. The
ParseHTMLstep that inserts the SVG into the DOM still runs — that is Blink parsing HTML, not the JSON parser. - standalone_interface.html keeps using the legacy JSON endpoint because it is a self-contained fallback UI that does not share the fetch helper. Mirroring the parsing change would require replicating the header/body split logic there too; not worth the duplication.
Files changed
| File | Change |
|---|---|
expert_backend/main.py |
Added _maybe_gzip_svg_text helper; /api/network-diagram now accepts format=text to return the header+SVG body. |
frontend/src/api.ts |
getNetworkDiagram uses fetch + ?format=text, splits body on first \n, JSON-parses the header, treats the rest as SVG. |
frontend/src/hooks/useDiagrams.ts |
New ingestBaseDiagram(raw, vlCount) method that does processSvg + state-set without the fetch. Returned alongside fetchBaseDiagram. |
frontend/src/hooks/useSession.ts |
RestoreContext gains ingestBaseDiagram; session restore fires the diagram XHR in parallel with the other 3 and ingests the raw payload. |
frontend/src/App.tsx |
applySettingsImmediate and handleLoadConfig now Promise.all 4 XHRs and call diagrams.ingestBaseDiagram instead of fetchBaseDiagram. ingestBaseDiagram wired into restoreContext. |
frontend/src/api.test.ts |
getNetworkDiagram test rewritten to assert fetch(..., '?format=text') + header/body split. Added a non-ok-status test. |
frontend/src/hooks/useSession.test.ts |
Mock api now includes getNetworkDiagram; context mock includes ingestBaseDiagram. |
expert_backend/tests/test_api_endpoints.py |
Two new tests under TestGetNetworkDiagram covering format=text body shape and gzip. |