Spaces:
Paused
Paused
File size: 11,793 Bytes
1c730d1 | 1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 21 22 23 24 25 26 27 28 29 30 31 32 33 34 35 36 37 38 39 40 41 42 43 44 45 46 47 48 49 50 51 52 53 54 55 56 57 58 59 60 61 62 63 64 65 66 67 68 69 70 71 72 73 74 75 76 77 78 79 80 81 82 83 84 85 86 87 88 89 90 91 92 93 94 95 96 97 98 99 100 101 102 103 104 105 106 107 108 109 110 111 112 113 114 115 116 117 118 119 120 121 122 123 124 125 126 127 128 129 130 131 132 133 134 135 136 137 138 139 140 141 142 143 144 145 146 147 148 149 150 151 152 153 154 155 156 157 158 159 160 161 162 163 164 165 166 167 168 169 170 171 172 173 174 175 176 177 178 179 180 181 182 183 184 185 186 187 188 189 190 191 192 193 194 195 196 197 198 199 200 201 202 203 204 205 206 207 208 209 210 211 212 213 214 215 216 217 218 219 220 221 222 223 224 225 226 227 228 229 230 231 | # 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. |
|