Co-Study4Grid / docs /performance /history /loading-parallel.md
github-actions[bot]
Deploy f7b8bf6
1c730d1
|
Raw
History Blame Contribute Delete
11.8 kB
# 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. |