|
| 1 | +# Plottr Performance & UX Improvements |
| 2 | + |
| 3 | +This document summarizes the changes in this PR, the profiling that motivated them, |
| 4 | +and suggestions for future work. |
| 5 | + |
| 6 | +--- |
| 7 | + |
| 8 | +## Part 1: Implemented — Pipeline Performance (datadict, nodes, gridding) |
| 9 | + |
| 10 | +### Problem |
| 11 | + |
| 12 | +Plottr's data pipeline copied data excessively as it flowed through nodes. Each node |
| 13 | +defensively deep-copied all data, and internal methods (`structure()`, `validate()`, |
| 14 | +`copy()`) added further redundant copies. For a 100x100x100 MeshgridDataDict (~38 MB), |
| 15 | +a single `copy()` took 92 ms and `validate()` took 43 ms. |
| 16 | + |
| 17 | +### What Changed |
| 18 | + |
| 19 | +**`plottr/data/datadict.py`** (core data container): |
| 20 | +- New `_copy_field()` helper with per-key copy semantics: numpy `.copy()` for arrays, |
| 21 | + `list()` for axes, `deepcopy` only for mutable metadata |
| 22 | +- Rewrote `copy(deep=True/False)` — no longer chains through `structure()` → `validate()` |
| 23 | + → `deepcopy`. New `deep=False` shares arrays (xarray-style API, backward compatible) |
| 24 | +- `_build_structure()` private helper that skips redundant validation |
| 25 | +- `MeshgridDataDict.validate()` monotonicity check: replaced `np.unique(np.sign(np.diff(...)))` |
| 26 | + with direct min/max checks — same coverage, no sort/allocate |
| 27 | +- `mask_invalid()` fast-path: skips masking entirely when data has no invalid entries |
| 28 | +- `shapes()` uses `np.shape()` instead of `np.array(...).shape` |
| 29 | +- `datasets_are_equal()` shape short-circuit + set-based comparison |
| 30 | +- `remove_invalid_entries()` fixed O(n²) `np.append` pattern + fixed crash on inhomogeneous arrays |
| 31 | +- `meshgrid_to_datadict()` / `datadict_to_dataframe()`: `ravel()` instead of `flatten()` |
| 32 | + |
| 33 | +**`plottr/utils/num.py`** (numerical utilities): |
| 34 | +- `largest_numtype()`: dtype check instead of iterating every element as Python object (~15,000× faster) |
| 35 | +- `is_invalid()`: skip zero-array allocation for non-float types |
| 36 | +- `guess_grid_from_sweep_direction()`: convert with `np.asarray()` once instead of 4× |
| 37 | +- `_find_switches()`: compute `is_invalid()` once (was 3×), single `np.percentile([lo,hi])` call |
| 38 | + (was 2 separate sorts), vectorized boolean filter, `np.nanmean` for NaN-safe sweep direction |
| 39 | + |
| 40 | +**`plottr/node/node.py`**: Defer `structure()` call to only when structure actually changes (50× faster steady-state) |
| 41 | + |
| 42 | +**`plottr/node/dim_reducer.py`**: Removed redundant `copy()` in `XYSelector.process()` |
| 43 | + |
| 44 | +**`plottr/node/grid.py`**: Pass `copy=False` to `datadict_to_meshgrid()` since gridder already copies input |
| 45 | + |
| 46 | +**`plottr/plot/base.py`**: `dataclasses.replace` instead of `deepcopy` for complex plot splitting |
| 47 | + |
| 48 | +### Bugs Fixed |
| 49 | +- `copy()` now properly deep-copies global mutable metadata (was sharing references) |
| 50 | +- `remove_invalid_entries()` no longer crashes when dependents have different numbers of invalid entries |
| 51 | + |
| 52 | +### Benchmark Results |
| 53 | + |
| 54 | +**Micro-benchmarks (key functions):** |
| 55 | + |
| 56 | +| Function | Before | After | Speedup | |
| 57 | +|---|---|---|---| |
| 58 | +| `largest_numtype` (500K float) | 29.8 ms | 0.002 ms | ~15,000× | |
| 59 | +| `mesh_500k_copy()` | 42.2 ms | 2.9 ms | 14.8× | |
| 60 | +| `node_process` (500K mesh, steady state) | 7.4 ms | 0.15 ms | 50× | |
| 61 | +| `_find_switches` (640K pts) | 80 ms | 31 ms | 2.6× | |
| 62 | +| `datadict_to_meshgrid` (640K pts) | 175 ms | 71 ms | 2.5× | |
| 63 | +| `mesh_500k_validate()` | 20.5 ms | 14.1 ms | 1.5× | |
| 64 | + |
| 65 | +**Real experimental data (large qcodes database, steady-state refresh):** |
| 66 | + |
| 67 | +| Dataset | Data Size | Before | After | Speedup | |
| 68 | +|---|---|---|---|---| |
| 69 | +| QDstability (14400×251, 16 deps) | 223 MB | 555 ms | 189 ms | 2.93× | |
| 70 | +| TopogapStage2 (41×33×5×81, 21 deps) | 152 MB | 439 ms | 161 ms | 2.73× | |
| 71 | +| QDtuning (7440×121, 16 deps) | 14 MB | 31 ms | 11 ms | 2.73× | |
| 72 | + |
| 73 | +**Interactive actions (simulated user operations on large datasets):** |
| 74 | + |
| 75 | +| Action | Before | After | Speedup | |
| 76 | +|---|---|---|---| |
| 77 | +| Toggle subtract average (15 MB 2D) | 293 ms | 29 ms | 10.2× | |
| 78 | +| Swap XY axes (18 MB 2D) | 790 ms | 241 ms | 3.3× | |
| 79 | +| Switch dependent (61 MB 1D) | 2,287 ms | 977 ms | 2.3× | |
| 80 | +| Data refresh (15 MB 2D) | 697 ms | 199 ms | 3.5× | |
| 81 | + |
| 82 | +### Tests Added |
| 83 | + |
| 84 | +221 new tests across 4 test files: |
| 85 | +- `test_datadict_copy_semantics.py` — copy isolation, edge cases, pipeline integrity |
| 86 | +- `test_pipeline_coverage.py` — per-node tests, hypothesis property-based, various dtypes |
| 87 | +- `test_round2_optimizations.py` — is_invalid, largest_numtype, remove_invalid_entries |
| 88 | +- `test_gridder_comprehensive.py` — all GridOption paths, shapes, edge cases |
| 89 | + |
| 90 | +--- |
| 91 | + |
| 92 | +## Part 2: Implemented — Inspectr Loading & UX |
| 93 | + |
| 94 | +### Problem |
| 95 | + |
| 96 | +Opening a large QCoDeS database (1496 runs) in inspectr took 15+ minutes because the |
| 97 | +`experiments()` + `data_sets()` enumeration in QCoDeS is O(N²). Clicking any dataset |
| 98 | +froze the UI for ~1 second while the snapshot (up to 6 MB of JSON) was parsed into |
| 99 | +thousands of tree widget items. |
| 100 | + |
| 101 | +### What Changed |
| 102 | + |
| 103 | +**Fast database overview** (`plottr/data/qcodes_db_overview.py`, new module): |
| 104 | +- Single SQL JOIN query fetching run metadata directly from runs + experiments tables |
| 105 | +- Skips snapshot and run_description blobs entirely |
| 106 | +- Reads `inspectr_tag` directly as a column from the runs table |
| 107 | +- Intended for eventual contribution to QCoDeS |
| 108 | + |
| 109 | +**Lazy snapshot loading** (`plottr/apps/inspectr.py`): |
| 110 | +- Snapshot tree built only when user expands the "QCoDeS Snapshot" section |
| 111 | +- Info pane sections collapsed by default |
| 112 | +- Smooth pixel-based scrolling for tall rows (e.g., exception tracebacks) |
| 113 | + |
| 114 | +**Incremental refresh**: |
| 115 | +- `refreshDB()` only loads runs newer than the last known run_id |
| 116 | +- Merges incremental results into existing dataframe |
| 117 | + |
| 118 | +**Loading UX**: |
| 119 | +- Live progress indicator: "Loading database... (142/1496 datasets)" |
| 120 | +- Contextual messages: "Select a date...", "No datasets found...", "No datasets match filter..." |
| 121 | +- Wider default window (960×640) |
| 122 | + |
| 123 | +**Fallback chain**: SQL direct → `load_by_id` loop → original `experiments()` API |
| 124 | + |
| 125 | +### Benchmark |
| 126 | + |
| 127 | +| Approach | 23 runs | 1496 runs (projected) | |
| 128 | +|---|---|---| |
| 129 | +| Old (experiments + data_sets) | 103 ms | 15+ minutes | |
| 130 | +| load_by_id loop | 90 ms | ~5 seconds | |
| 131 | +| **SQL direct** (new) | **14 ms** | **~10 ms** | |
| 132 | +| Incremental (3 new runs) | - | **~4 ms** | |
| 133 | + |
| 134 | +Snapshot click: 951 ms → 0.3 ms (3,554× faster) |
| 135 | + |
| 136 | +--- |
| 137 | + |
| 138 | +## Part 3: Implemented — Plot UI Improvements |
| 139 | + |
| 140 | +### What Changed |
| 141 | + |
| 142 | +**Grid layout for pyqtgraph subplots** (`plottr/plot/pyqtgraph/autoplot.py`): |
| 143 | +- Replaced single-column `QSplitter` with `QGridLayout` using near-square grid |
| 144 | + (same formula as matplotlib: `nrows = int(n^0.5 + 0.5)`) |
| 145 | +- Many subplots now arrange as 2×2, 2×3, 4×4 etc. instead of stacking vertically |
| 146 | + |
| 147 | +**Scrollable plot area** (both backends): |
| 148 | +- "Scrollable" checkbox + min-height spinbox in the plot toolbar |
| 149 | +- Off by default; when enabled, plot area expands and becomes scrollable |
| 150 | +- Min height per row configurable (40–2000 px, default 75 px pyqtgraph / 100 px mpl) |
| 151 | + |
| 152 | +**Plot backend selector** (`plottr/apps/inspectr.py`): |
| 153 | +- Combo box in inspectr toolbar to switch between matplotlib and pyqtgraph |
| 154 | +- Default: matplotlib. Applies to newly opened plot windows. |
| 155 | + |
| 156 | +--- |
| 157 | + |
| 158 | +## Part 4: Not Implemented — Future Suggestions |
| 159 | + |
| 160 | +These were identified during analysis but not implemented in this PR. |
| 161 | + |
| 162 | +### HDF5 Data Loading (datadict_storage.py) |
| 163 | +- Lines 274 and 305 read the **entire HDF5 dataset into memory** just to get its shape |
| 164 | +- Fix: `ds.shape` instead of `ds[:].shape` — would reduce load time by 50–80% |
| 165 | + |
| 166 | +### Signal Emission Overhead (node.py) |
| 167 | +- Up to 7 Qt signals emitted per node per data update |
| 168 | +- `dataFieldsChanged` is redundant (axes + deps) |
| 169 | +- Could consolidate to 1–2 batched signals |
| 170 | + |
| 171 | +### Fitter / Histogrammer / ScaleUnits Memoization |
| 172 | +- These nodes recompute results on every update even when inputs haven't changed |
| 173 | +- Could cache results keyed on data hash + parameters |
| 174 | + |
| 175 | +### Pipeline Change Detection |
| 176 | +- No concept of "what changed" — every update re-processes all data through all nodes |
| 177 | +- For append-only monitoring, nodes could process only new data |
| 178 | + |
| 179 | +### QCoDeS API Suggestion |
| 180 | +The ideal API for inspectr would be a single function returning lightweight run metadata |
| 181 | +for all or a range of runs without creating full DataSet objects: |
| 182 | +```python |
| 183 | +get_run_overview(conn, start_id=None, end_id=None) |
| 184 | +# Returns: [{run_id, exp_name, sample_name, name, timestamps, guid, result_counter, metadata_keys}] |
| 185 | +``` |
| 186 | +This would be a single SQL query completing in <1 ms for any database size. |
| 187 | + |
| 188 | +--- |
| 189 | + |
| 190 | +## Part 5: Profiling with Real Data (963×1001 complex RF measurement) |
| 191 | + |
| 192 | +Profiled using a real 963×1001 complex128 2D gate-gate sweep measurement |
| 193 | +(~12.5 MB on disk, ~15 MB in memory as complex128). |
| 194 | + |
| 195 | +### Timing Summary |
| 196 | + |
| 197 | +| Operation | Time (ms) | Notes | |
| 198 | +|---|---|---| |
| 199 | +| `ds_to_datadict` (first call) | 2,588 | 1,500 ms is xarray/cf_xarray import (one-time) | |
| 200 | +| `ds_to_datadict` (steady state) | 999 | qcodes SQLite → numpy deserialization | |
| 201 | +| `datadict_to_meshgrid` | 122 | `guess_grid_from_sweep_direction` dominates | |
| 202 | +| Pipeline steady state (sel+grid) | 51 | Per re-trigger with same data | |
| 203 | +| Switch dependent variable | 172 | selector + gridding + pyqtgraph `eq()` | |
| 204 | +| Complex: real only | 8.5 | `copy()` + `.real.copy()` | |
| 205 | +| Complex: real+imag | 11.6 | `copy()` + `.real` + `.imag` | |
| 206 | +| Complex: mag+phase | 30.8 | `copy()` + `np.abs()` + `np.angle()` | |
| 207 | +| `copy()` deep | 5.1 | Already fast after our optimization | |
| 208 | +| `copy()` shallow | 0.1 | Zero-copy array sharing | |
| 209 | +| `validate()` | 0.2 | Already fast | |
| 210 | +| `structure()` | 0.4 | Already fast | |
| 211 | +| `is_invalid()` on 963k complex | 44.6 | **`a == None` comparison is 44× slower than `np.isnan`** | |
| 212 | +| `np.isnan()` on 963k complex | 1.0 | What `is_invalid` should use for numeric dtypes | |
| 213 | + |
| 214 | +### Bottleneck Analysis |
| 215 | + |
| 216 | +#### 1. `is_invalid()` — 44× slower than needed (LOW-HANGING FRUIT) |
| 217 | + |
| 218 | +The current implementation does `a == None` for all arrays, which triggers Python object |
| 219 | +comparison on every element. For numeric arrays (float/complex), this is always `False` |
| 220 | +and is pure waste. Replacing with `np.isnan()` directly for numeric dtypes would cut |
| 221 | +`is_invalid` from 44.6 ms → ~1 ms. |
| 222 | + |
| 223 | +This cascades through `_find_switches()` (which calls `is_invalid` on each 963k-element |
| 224 | +axis), making `datadict_to_meshgrid` ~90 ms faster. |
| 225 | + |
| 226 | +**Fix**: In `is_invalid()`, check dtype first — if it's a numeric type, skip the `== None` |
| 227 | +check entirely and return just `np.isnan(a)`. |
| 228 | + |
| 229 | +#### 2. `ds_to_datadict()` — 999 ms steady state (MEDIUM EFFORT) |
| 230 | + |
| 231 | +The qcodes `DataSetCacheDeferred` loads data via xarray round-trip. The actual SQLite |
| 232 | +read + numpy deserialization (`_convert_array` → `numpy.read_array` → `ast.literal_eval` |
| 233 | +for headers) takes ~1 second for 963k × 3 parameters. |
| 234 | + |
| 235 | +This is largely inside qcodes, so fixes would be upstream. However, plottr could: |
| 236 | +- Cache the loaded DataDict and skip reload when the dataset hasn't changed |
| 237 | +- Use `load_by_id(...).cache.data()` directly instead of going through `ds_to_datadict` |
| 238 | + which re-wraps the data |
| 239 | +- For completed datasets (known from metadata), cache the DataDict permanently |
| 240 | + |
| 241 | +#### 3. `datadict_to_meshgrid` with `guessShape` — 122 ms (AVOIDABLE) |
| 242 | + |
| 243 | +When shape metadata exists in the QCodes `RunDescriber` (this dataset has |
| 244 | +`shapes={'rf_wrapper_ch6_Vrf_6': (1001, 1001)}`), the gridder should use |
| 245 | +`GridOption.metadataShape` and skip the expensive `guess_grid_from_sweep_direction`. |
| 246 | + |
| 247 | +The autoplot code already does this (`autoplot.py:298`), but the grid widget default |
| 248 | +is `noGrid`, so if the user starts from the widget rather than autoplot, they get |
| 249 | +`guessShape` which runs the full sweep-direction analysis on every re-trigger. |
| 250 | + |
| 251 | +**Fix**: Default the grid widget to `metadataShape` when shape metadata is available. |
| 252 | + |
| 253 | +#### 4. `np.abs()` + `np.angle()` for complex mag+phase — 30.8 ms (INHERENT) |
| 254 | + |
| 255 | +This is inherent computational cost for computing magnitude and phase of 963k complex128 |
| 256 | +values. Not much to optimize here, but could be deferred (only compute when the plot |
| 257 | +backend actually needs to render). |
| 258 | + |
| 259 | +#### 5. pyqtgraph `Terminal.setValue` → `eq()` — 12 ms per node (MEDIUM) |
| 260 | + |
| 261 | +pyqtgraph's flowchart compares old and new terminal values using a recursive `eq()` |
| 262 | +function. For large DataDicts this recurses into all arrays and does element-wise |
| 263 | +comparison. This adds ~24 ms per pipeline trigger (12 ms per node, 2 nodes). |
| 264 | + |
| 265 | +**Fix**: Override `eq()` on DataDictBase to do a cheap identity or shape check |
| 266 | +instead of element-wise comparison, or set terminal values without comparison. |
| 267 | + |
| 268 | +### Suggested Priority (remaining) |
| 269 | + |
| 270 | +Items 1, 2, and 6 have been implemented. Remaining potential improvements: |
| 271 | + |
| 272 | +1. ~~**Fix `is_invalid()`**~~ ✅ Done — 44x faster (44.6ms → 1.0ms) |
| 273 | +2. ~~**Default to `metadataShape`**~~ ✅ Done — avoids 122ms gridding when shape metadata exists |
| 274 | +3. **Cache loaded DataDict** for completed datasets — avoids 999 ms reload on each refresh |
| 275 | +4. **Override pyqtgraph `eq()`** for DataDictBase — saves ~24 ms per pipeline trigger |
| 276 | +5. **Lazy complex splitting** — compute mag/phase only when needed by the plot backend |
| 277 | +6. ~~**Fix mpl double-replot**~~ ✅ Done — ~20% faster mpl steady-state (919ms → 754ms) |
| 278 | +7. **Matplotlib artist-level updates** — Instead of `fig.clear()` + full recreation on every |
| 279 | + `setData()`, reuse existing Line2D/QuadMesh/colorbar artists and update their data. |
| 280 | + The pyqtgraph backend already does this via `clearWidget=False`; bringing the same |
| 281 | + pattern to mpl could reduce steady-state replot from ~750ms to ~200ms. |
| 282 | + |
| 283 | +### Backend Comparison After Optimizations (963×1001 complex128) |
| 284 | + |
| 285 | +| Operation | matplotlib | pyqtgraph | |
| 286 | +|---|---|---| |
| 287 | +| First plot | 1,428 ms | 175 ms | |
| 288 | +| Steady replot | 754 ms | 80 ms | |
| 289 | +| Complex real | 394 ms | 118 ms | |
| 290 | +| Complex realAndImag | 687 ms | 114 ms | |
| 291 | +| Complex magAndPhase | 730 ms | 108 ms | |
| 292 | + |
| 293 | +The pyqtgraph backend is ~10x faster for steady-state replots because it reuses |
| 294 | +plot widget objects when only data changes. The matplotlib backend's remaining |
| 295 | +cost is dominated by `fig.clear()` + subplot/artist recreation + agg rendering. |
0 commit comments