Spaces:
Paused
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: | |
| 1. **`/api/network-diagram` waited for `/api/branches`** — the frontend | |
| passed `voltageLevels.length` as a hint to `processSvg` (node/text | |
| upscaling on large grids), so the diagram call only fired after the | |
| `Promise.all([branches, voltage-levels, nominal-voltages])` settled. | |
| That ~0.8 s gap is pure serialisation cost. | |
| 2. **`XHRLoad` = 620 ms** on the 25 MB response — attributable to | |
| `JSON.parse` of 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`) | |
| ```diff | |
| - 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_success` that still asserts on `response.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` — default `format=json` still returns `{"svg": ..., "metadata": ...}`. | |
| - `test_text_format_returns_header_plus_svg` — `format=text` returns | |
| `Content-Type: text/plain`, the first line parses as JSON with all | |
| non-`svg` fields (`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 are | |
| `Content-Encoding: gzip` when the client sends `Accept-Encoding: gzip`. | |
| Frontend (`api.test.ts::getNetworkDiagram`): | |
| - Parses header + SVG correctly from the `text/plain` body. | |
| - 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 | |
| `ParseHTML` step 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. | | |