Skip to content

Commit ca42d12

Browse files
authored
Merge pull request #441 from toolsforexperiments/perf/datadict-copy-optimization
perf+feat: pipeline optimization, inspectr fast loading, plot UI improvements
2 parents 7069abf + cf6b07d commit ca42d12

29 files changed

Lines changed: 4934 additions & 219 deletions

PERFORMANCE_PLAN.md

Lines changed: 295 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,295 @@
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

Comments
 (0)