Skip to content

perf(drift): replace the rigid solver with the cryo-EM method — 10.6x more accurate, 4x faster - #134

Draft
CSSFrancis wants to merge 17 commits into
mainfrom
feat/drift-literature-solver
Draft

perf(drift): replace the rigid solver with the cryo-EM method — 10.6x more accurate, 4x faster#134
CSSFrancis wants to merge 17 commits into
mainfrom
feat/drift-literature-solver

Conversation

@CSSFrancis

Copy link
Copy Markdown
Owner

Replaces the hand-built phase-correlation chain with the MotionCor2/Unblur-family method, per the maintainer's call that whitening is the wrong weighting for Poisson-limited data.

Result (240 × 2048² uint16, dose 40 e/px, 48 px drift)

solver device warm s frames/s rms px p95 max
new numpy 19.7 12.2 0.088 0.150 0.182
old numpy 260 0.9 0.934 1.501 1.815
new torch-cpu 6.5 37.2 0.088 0.150 0.182
old torch-cpu 26.1 9.2 0.934 1.501 1.815
new cuda 4.5 53.3 0.088 0.181 max
old cuda 5.1 47.3 0.934

10.6× more accurate, 13× / 4× / 1.13× faster. On a short small movie (24 × 512²) it is slower (0.65 s vs 0.25 s) — band=48 is all-pairs there while the chain does 23 correlations — but still 7× more accurate. Crossover is where binning applies. New benchmark_drift_translation.py produces this table.

Method

  • Plain CC (β=0) with a fixed band-pass: q^tilt · exp(-(q/lowpass)²) · (1 − exp(-(q/highpass)²)). Every leg is a function of frequency only — no bin's weight depends on its own contents, which is precisely why no _PHASE_FLOOR is needed. Peak from real(ifft2), never abs.
  • Frames mean-subtracted before windowing (not in the brief, found empirically): a fixed taper on a DC pedestal stamps the same border ramp into every frame, which correlates with itself at zero lag — 0.125 px bias whole-frame, 2 px on a tapered ROI.
  • Banded pairwise + least squares: pairs 0 < j−i ≤ band, gauge p₀=0, AᵀWA is a banded graph Laplacian solved by solveh_banded (not dense lstsq). IRLS with Tukey bisquare, seeded from the streaming median trajectory — from an unweighted start the outliers capture the fit and the 4.685σ cut rejects nothing.
  • Batched: pairs closing on a frame share its spectrum → one batched ifft2 (test asserts ≤N inverse FFTs for ≫N pairs). Correlation on a binned grid; sub-pixel via a ladder of matmul-DFT stages so binning costs no resolution. Memory bounded: band+1 binned spectra + one full-res frame.

Deleted: _PHASE_FLOOR, normalize's whitening path, the running Fourier reference and _phase_ramp, all reference modes, _accept_into_reference/_REJECT_*/reject_outliers. Kept: Guizar-Sicairos refinement, streaming source, DriftModel, warp.py, numpy↔torch parity.

Three empirical corrections to the brief

  1. band = 48, not 10–20. A banded system chains N/band links and multiplies per-pair bias: 1.46 px rms at band 12, 0.60 at 24, 0.09 at 48.
  2. Binning is not optional — at bin 1 the band-pass sits on detector noise (13.4 px unbinned vs 0.16 px at bin 2 for 256²/dose 40). Hence a bin ≥ 2 floor.
  3. The unit fixtures shift one base image, so their pixel noise is common-mode — flattering to high-q weighting. Filter defaults were tuned against independent-Poisson movies first, then checked there.

One regression that needs a decision — the ROI path

With β=0 a small ROI is materially worse: on particle_movie's half-frame box, 0.37 px (old) → ~5 px (new). Whole-frame went the other way, 0.25 → 0.03 px. Reproduced independently with skimage's own normalization=None on the same crops, so it is the estimator, not our implementation: plain CC is dominated by the brightest features, phase CC by the most numerous — the bright particles outweigh the film that actually carries the stage motion. The ROI toggle stays off by default and this is documented in the module docstring. See the question in the PR discussion: a fiducial-marker ROI may be exactly the case where β=0 is right, so this may want an ROI-specific β rather than a fix.

Tests: 116 drift · full suite 2766 passed / 25 skipped · typecheck clean · drift_wizard.spec.ts 4/4 with screenshots read (crisp corrected sum, "1.9x sharper · 6.0 px drift · 0.07 px residual", "Pair span 48"). Every changed/removed test is named with justification in the commits; no accuracy threshold was weakened.

Not measured: 4096² (synthesis stalled on disk; --size 4096 works). Known weak spot: ≤256 px frames below ~4 e/px.

Replace the phase-correlation running-reference chain with the MotionCor2 /
Unblur family method.

- Plain cross-correlation weighted by a FIXED band-pass (q**tilt Gaussian),
  not by the data's own |R|. Whitening hands the bands that are pure noise at
  low dose the same weight as the bands that carry the image, which is
  backwards for Poisson-dominated data -- and the _PHASE_FLOOR constant was
  the symptom (near-empty bins amplified to unit weight by their own rounding
  error). The peak comes from real(ifft2()), not abs(): a true match is a
  positive real peak and abs also promotes spurious imaginary structure.
- Every pair (i, j) with 0 < j-i <= band is measured and the per-frame
  positions come from an over-determined, robustly weighted least-squares
  solve with frame 0 as the gauge. The chain gave one measurement per unknown,
  so a bad registration had nothing to outvote it; band=12 gives ~12, and the
  banded normal equations are a graph Laplacian so thousands of frames stay a
  (band+1, N-1) solveh_banded call rather than a 700 MB dense lstsq.
- The band batches: the pairs closing on a frame share its spectrum, so they
  are one product and ONE inverse FFT. Correlation runs on a box-mean binned
  grid, which bounds cost and memory at any frame size and is also the low-pass
  that makes the band-pass safe -- 8 e/px unbinned is 17 px wrong, bin-4 is
  0.19 px wrong at identical filter settings.
- Sub-pixel refinement is a LADDER of matmul-DFT stages (~12 px window each)
  so the binned grid still resolves 1/upsample of a FULL pixel without a
  96-wide kernel.

Deleted: _PHASE_FLOOR, the normalize whitening path, the running Fourier
reference and its phase-ramp alignment, the reference modes, and the
sharpness-based reject_outliers gate with _REJECT_FRACTION/_REJECT_MIN_SAMPLES.

Kept: the Guizar-Sicairos upsampled matmul DFT, the streaming frame source,
DriftModel, warp.py, and the numpy/torch operator adapter.
… a residual

`reference`, `normalize` and `reject_outliers` no longer exist in the solver,
so they leave DEFAULTS, the wizard schema, _coerce, _solver_kwargs and the TSX
together -- the caret contract is that every payload key maps 1:1 to a DEFAULTS
entry and every setter has a caller, and a stale key silently coerces to a
default the backend then rejects.

The default face is now ONE toggle: "Ignore bad frames" had no switch behind it
once robustness became a property of the least-squares solve. Advanced gains
"Pair span" (`band`), which is the redundancy the robust solve spends.

The result readout reports the least-squares residual in place of a rejected-
frame count -- how far the pairwise measurements disagree with the single
trajectory fitted to them, which is the only quality signal the solve has.

The dy/dx curve is repainted with the SOLVED shifts when the run finishes: what
streams during the solve is a provisional causal estimate, because the global
solve does not exist until every pair has been measured.
Accuracy and robustness gates pass unchanged: the <0.1 px sub-pixel gate, the
synthetic recoveries, ROI-vs-full agreement, the corrupt-frame test, the
round-trip/sign tests and numpy-vs-torch parity.

Retired, with replacements:
  test_sequential_reference_accumulates   -> test_monotonic_drift_is_recovered_end_to_end
  test_running_reference_survives_...     -> test_survives_one_corrupt_frame
  test_outlier_rejection_can_be_disabled  -> test_a_chain_cannot_even_detect_a_bad_measurement
  test_clean_stack_rejects_nothing        -> test_clean_stack_downweights_nothing
  test_bad_reference_name_raises          -> test_zero_band_raises
  test_fixed_index_out_of_range_raises    -> (same)
  test_the_default_face_is_two_toggles    -> test_the_default_face_is_one_toggle
  test_unknown_reference_falls_back       -> test_band_is_clamped

New: TestBandedLeastSquares (over-determination, one bad pair outvoted, the
gauge, the residual as a diagnostic) and TestBatchedCorrelation (one inverse
FFT per frame not per pair, binning does not cost sub-pixel resolution,
refine_iters, trend-preserving smoothing).

test_on_shift_streams_every_frame_as_it_solves now asserts the stream AGREES
with the returned array rather than equalling it: the returned shifts come from
a global solve that has no meaningful prefix, so bit-equality would be asserting
the solver is sequential -- which is the thing that was replaced.
…th ways

DriftModel.reference and .residuals describe what the solve now produces (a
gauge and a per-frame pairwise residual in pixels) rather than the retired
reference modes and the correlation-peak sharpness.

The ROI note in drift_action carries the new measurement: the whole-frame solve
improved (0.25 -> 0.03 px on the particle movie) while the guessed-box solve got
worse (1.03 -> ~5 px), because plain correlation follows the BRIGHTEST features
and that box is mostly particles moving relative to the film.

The caret-contract test now checks both directions -- a DEFAULTS entry with no
schema key is a control no host renders and no payload carries, which is how a
removed parameter can be left behind in one place. A second test asserts every
DEFAULTS entry actually reaches the solver or the wizard.

Drops the unread per-pair quality store from _PassState.
…air bias

Found by the new benchmark, which is the reason it exists. At 2048^2 over 240
frames (0.17 px/frame) the solve came back 1.46 px rms with band=12 and the
error was a proportional SHRINK, not noise: recovered/true = 0.90.

A banded system chains N/band independent links from frame 0 to frame N, so any
systematic per-pair error multiplies along the chain -- where the old
running-reference solver, registering every frame against one reference, paid it
once. The per-pair bias itself comes from the plain-correlation peak sitting on
the shoulder of the window/content envelope, and it is proportionally largest
when the shift being measured is a small fraction of a binned pixel.

A wide band fixes both halves: the shift across it is larger, so the bias is
relatively smaller, and there are fewer links to multiply it along.

  band 12 -> 1.46 px rms      band 24 -> 0.60      band 48 -> 0.09

corr_size goes 128 -> 256 for the same reason (a coarser grid makes a slow drift
a smaller fraction of a grid pixel). Together, 2048^2/240 frames: 0.088 px rms
in 7.0 s on torch-CPU, against 26.5 s and 0.934 px for the old solver.

band=48 costs ~1.4x the time of 12 -- the per-frame read and FFT dominate, not
the pairs -- so this is 16x the accuracy for 40% more time.
The first real use of this benchmark measured band=12 while the solver shipped
48, because argparse carried its own default -- i.e. it benchmarked a
configuration nobody runs and reported it as the shipped one. None means "leave
it to solve_translation".
np.memmap(mode="w+") allocates the whole file up front, so a synthesis
interrupted half-way leaves a file of exactly the expected size whose tail is
zeros -- and the reuse check is a size check, so the next run would benchmark a
half-black movie and report it as real. Caught by interrupting a 4 GB run.
…he motion

Amplitude weighting is not a deficiency to apologise for -- it defines what
an ROI is FOR. Around a fiducial or a landmark, plain correlation locks onto
exactly that feature. The particle_movie half-frame result (~5 px vs the old
0.37) is the estimator faithfully following the bright particles instead of
the film that drifts: a mis-drawn ROI, not a broken solver.
…client

frames.py held a raw dask array and called `.compute()` per frame with no
scheduler. In the running app the context holds a process-global
distributed.Client, so every frame became a cluster round-trip: graph out,
worker reads, frame back over IPC -- for data this process consumes locally
anyway.

Measured on a real lazy .hspy movie, 2048^2, 1 frame/chunk
(spyde/tests/repro_drift_frame_read.py):

  local threaded scheduler      45.2 ms/frame
  ambient distributed client   111.9 ms/frame     2.5x

The drift caret reads 64 frames for its check image, ~20 for the ROI preview
and N for the solve, so one unqualified .compute() taxes three separate
user-visible waits at once: the check image alone goes 7.2s -> 2.9s on a
24-frame fixture.

This is the same failure the navigator already fixed one layer down -- CLAUDE.md
Live-Display §3 pins CachedDaskArray._client = None for exactly this reason and
records it as a silent perf-only bug that survived a long time. This module
bypassed that machinery by holding a raw array, and so re-acquired the bug.

The guard is behavioural, not a timing assertion: with no cluster in the test
process both paths are fast, which is precisely why this kept coming back. The
new test installs a scheduler that raises if it is ever consulted and reads a
frame. Verified to fail on the previous code.

A graph whose data genuinely lives on the workers still falls back, with a
one-shot warning that says why the solve is about to be slow.
"The ROI drift check takes 60 seconds" and "Correct Drift does nothing" are
both unanswerable from a green functional suite: drift_wizard.spec.ts passes in
35s on a 96x112 fixture where every stage is fast for reasons that do not
survive a real movie. Nothing in the project could see time at the ACTION level.

ActionProfile is NavProfile one layer up -- same idiom deliberately, so there is
one way to profile rather than two. One INFO line per invocation with the stage
breakdown, gated on SPYDE_ACTION_PROFILE=1 or the live `action_profile` debug
flag, every method short-circuiting on a single boolean when off.

It LOGS rather than emits: backend emit() goes down the PLOTAPP line protocol
and the main process echoes only a tiny allowlist, so a spec waiting on a
profile MESSAGE waits forever. An INFO record reaches stderr, the Log panel and
a terminal at once.

The renderer stamps _t_click into drift payloads so the line carries `queued=`
-- everything between the press and the handler starting. That is the
difference between "the compute is slow" and "the button was dead first", which
is exactly the distinction the Apply report turns on. _coerce builds its dict
from DEFAULTS, so the stamp cannot leak into solver parameters.

Instruments drift_open (frames/sum_read/check_window/roi_widget),
drift_preview (crop_read/solve/sums/paint), drift_run (trace_window/solve/
corrected_sum/paint) and drift_commit (add_transformation/show_node), plus
benchmark_drift_latency.py to drive them headless.
…lve 22.2s -> 7.9s

_SUM_MAX_FRAMES was a pure frame COUNT, so it was size-blind: 64 frames of the
96x112 test fixture is nothing, 64 frames of a 2048^2 movie is half a gigabyte
of reads. The ROI preview next door already budgeted in bytes; the check sums
never did.

Measured with SPYDE_ACTION_PROFILE=1, 60-frame 2048^2 lazy movie
(spyde/tests/benchmark_drift_latency.py), before -> after:

  drift_open     11713ms -> 1822ms   sum_read 11309 -> 1455   (frames 60 -> 8)
  drift_run      22237ms -> 7922ms   corrected_sum 15587 -> 2233
  drift_preview   3058ms -> 4847ms   (unchanged; now the dominant open cost)
  drift_commit      65ms ->   71ms

Two things the profile made visible that inspection had not:

  - 97% of drift_open was sum_read, for a picture whose only job is to look
    blurry beside a sharper one.
  - 70% of "Correct Drift" was corrected_sum, a SECOND 60-frame pass run AFTER
    the solve finished -- the progress bar completes, then the caret sits dead
    for 15s. That is the likeliest source of "the button seems to do nothing".
    It shares sum_indices with the check image, so the byte budget fixes both.

drift_commit was never the problem: 65ms before, and it returns a node. Whatever
"Apply does nothing" is, it is not commit latency.
Measured on a 60-frame 2048^2 real .mrc (memmap, no decode -- the backing a real
in-situ movie has; an earlier pass measured .hspy and was reporting HDF5 DECODE
cost, which inflated every read stage and pointed the fix at the wrong thing).

  stage            before      after
  drift_open      11713ms    135.9ms      86x
    sum_io                     33.9ms
    sum_acc                    15.3ms
    check_window               61.3ms   (panels 27.3  html 22.7  emit 9.1)
  drift_run       22237ms   2061.9ms      11x  (corrected_sum 15587 -> 35.9)
  drift_preview    3058ms   6039.7ms      SLOWER -- see below
  drift_commit       65ms     41.4ms

Three changes, each aimed at a stage the profile named:

1. Check sums use TWO frames (first and last -- maximum drift between them, so
   the raw sum is at its blurriest and the comparison is at its strongest;
   interior sampling shows less blur, so bigger n is weaker AND slower).

2. Check images are THUMBNAILS, decimated on read to <=512 px. Four of them live
   in a 340x300 window, so building them at 2048^2 was paying on four stages at
   once: accumulate, panel build, HTML size, emit. The disk was only 11% of
   drift_open; the rest was resolution nobody can see. html 5.6MB -> 1.4MB.

3. Slice before reading, not after -- both the ROI crop and the stride now go
   down to the reader. On a memmap a frame IS a slice, so reading a whole 2048^2
   frame to keep a 1024^2 box or every 4th pixel pays full price for a fraction
   of the data. ROI crop_read 1838 -> 263ms.

APPLY WAS NEVER SLOW, and this closes that question: drift_commit is 41ms and
returns a node. What read as "the button does nothing" was finding #2 -- 15.6s
of post-solve dead time in corrected_sum, after the progress bar had completed.
That is fixed; do not re-investigate commit latency.

drift_preview is now the long pole at 6.0s, and it got worse rather than better:
crop_read fell 1838 -> 263ms but the SOLVE on 20 crops of 1024^2 is 4373ms and
now dominates. Named, not fixed.

--assert-budget turns this into a CI gate: it reads the [ACTION-PROFILE] lines
the app itself emits (rather than re-timing independently, which would drift
away from what the app reports) and exits non-zero over budget.
… on drag

The preview was a 6 s batch measurement you committed to a box and waited for.
It is now a fixed 512x512 square you drag, updating per drag frame with the
gain readout tracking the cursor.

  drift_preview   6040 ms (per settle)  ->  13.2 ms median / 23.0 ms p95 per step

Reuses the protected machinery rather than adding a fast path beside it:
_PreviewDriver goes onto the SHARED base_selector._nav_dispatcher, which is
duck-typed on _run_update, so it is a first-class submitter there and the file
is untouched. That buys exactly what a drag needs and what CLAUDE.md
Live-Display §2 forbids rebuilding -- one serial lane, latest-position-wins
coalescing, no lock. The 250 ms settle timer on the drag path is gone; a box the
user has already dragged past is now dropped BEFORE it is computed rather than
computed and discarded.

What made a per-step update affordable, measured not guessed:

  - The MOVIE does not change during a drag, only the box. Frames are read once
    into _preview_cache (2 frames, ~16 MB at 2048^2), so a step is arithmetic on
    resident pixels with no I/O.
  - The box is FIXED at 512. Fixed size is what bounds the compute; a resizable
    ROI makes the per-step cost a function of user input.
  - Two frames, sampled at the ENDS of the movie -- adjacent frames have drifted
    by almost nothing and would report a flattering gain for every box.
  - Solve at full 512 (7 ms), sums and gain at 256 (46 ms -> 12 ms). The split is
    measured: decimating the SOLVE is not an option, at stride 3 it stops
    recovering the shift at all (31 px against a true 3.4).

--assert-budget now gates the drag step at 33 ms p95 (a frame at 30 fps, p95
because that is the one a user feels), alongside caret-open at 200 ms.

Tests updated for the fixed box, each pinning the same claim as before:
  test_the_preview_uses_the_box_even_with_the_toggle_off  - size now from
      box_size(), position still from the widget
  test_the_box_is_read_in_image_pixels_as_y0_x0_h_w       - reframed on a 1024^2
      movie so the fixed box is not degenerately clamped to the whole frame
  test_use_roi_feeds_the_box_to_the_solver                - same claim, fixed size
  test_a_superseded_preview_does_not_paint  ->  ..._drag_step_is_never_computed
      the mechanism moved from a generation guard to dispatcher coalescing, and
      the new assertion is stronger: superseded steps are never COMPUTED.
…efaults

drift_live_preview.spec.ts drags the box without releasing and asserts the gain
readout CHANGES mid-drag -- a settle-on-release design holds one value there, so
this is the assertion that separates live from dead. Headless timings cannot:
a step that is fast and never fires, or fires and paints the same image, gives
the same 14 ms.

Reading the screenshots caught a bug no test could: the readout said "over 12
frames" when the backend default is 2. The caret ships its OWN defaults and sends
them on every action, so a renderer value that drifts from the backend one
silently WINS and the backend default becomes dead code. previewFrames was still
20, so the live preview was doing 10x the work it was designed for. `band` had
done the same thing earlier (12 vs 48).

So test_the_caret_defaults_match_the_backend_defaults parses DriftWizard.tsx's
DEFAULTS block and compares it to drift_action.DEFAULTS, mapping camelCase to
snake_case. Parsed rather than duplicated so the test cannot itself go stale.
Verified it fails on the old value (assert 20 == 2).

The spec runs on the PARTICLE movie, not test_data_movie: the latter has no real
drift (its frames differ by a moving index band, not a translation), so a correct
solve returns zero shift and the gain is exactly 1.000 for every box -- which
makes a live preview indistinguishable from a dead one. Sized 1024^2 so the fixed
512 box has room to move over different content.

Verified on the screenshots: the box moves, the ROI raw/aligned panels re-render
with the new region's content, and the readout tracks 1.0x -> 0.9x.

Final gated numbers, 60 x 2048^2 .mrc:
  drag step p95   17.9 ms   budget 33
  drift_open     153.0 ms   budget 200
  drift_commit    20.5 ms   budget 500
  drift_run     2119.0 ms   budget 30000
…ng on real drift

CORRECTNESS BUG, found only at real scale (245 x 4096^2 uint8 lazy).

max_shift defaulted to a fixed 32 px. That number was chosen when the solver was
a CHAIN, where max_shift bounded the shift between CONSECUTIVE frames and was
always small. The banded solve bounds the shift across a pair up to `band` (48)
frames apart, and the ROI preview compares the movie's two ENDS -- the entire
excursion. So on any movie with more than 32 px of drift the true correlation
peak fell OUTSIDE the mask and the solve returned a spurious one.

Measured on frames 0 and 244 of a 245 x 4096^2 movie whose true shift is
(-40, 20):

  max_shift=32  ->  (-18.9, 1.4)   gain 0.92
  max_shift=64  ->  (-40.0, 20.0)  gain 2.10

A gain below 1 means the "corrected" sum is WORSE than the raw one. That is what
a user sees and reports as the feature being broken, and it is what the
maintainer reported (0.3x on their data).

max_shift=0 (or None) now means AUTO: a quarter of the correlated region's
shorter edge. That keeps the lattice guard the parameter exists for while
admitting real drift. DEFAULTS, the caret schema and the TSX all move to 0
together -- the caret sends its own value on every action, so a stale default
there silently wins.

Verified at real scale in the running app (drift_real_scale.spec.ts, screenshots
read):
  preview  gain 2.103, max_abs_shift 40.00 px (exact)
  full solve  max err 1.91 px, rms 1.09 px vs ground truth
  ROI raw / ROI aligned / corrected sum: all have content, nan 0%
  result: "1.8x sharper . 38.1 px drift . 0.13 px residual"

benchmark_drift_real_scale.py builds the 245 x 4096^2 uint8 fixture and dumps the
CONTENT of every array that reaches a panel (NaN fraction, min/max, constancy),
because a blank panel is a fact about the array and no timing can see it. Its own
ground-truth comparison had a sign error at first -- frame_i = roll(base,
+truth[i]) so the correction is -truth[i] -- which reported ~2x the drift as
error for a perfect solve; that is now spelled out in the code.
…budget by frame size

1. THE BLACK PANEL. The "Corrected sum" panel was initialised to zeros, so it
   rendered solid black from the moment the window opened until a solve finished
   -- and a solid black panel beside a real image is indistinguishable from a
   broken feature. That is a failure state per CLAUDE.md and it is how this was
   reported ("nothing visibly happens").

   It now seeds with a COPY OF THE RAW SUM, titled "Corrected sum — press Correct
   Drift" until a correction exists. The window is then honest at every instant:
   the panels are a before/after pair, and before you have corrected anything the
   honest "after" IS the before. They start identical and one changes the moment
   the solve lands, which is the comparison the window exists to make. The title
   carries the state so "identical" cannot be misread as "the correction did
   nothing". Verified on pixels at 245 x 4096^2: all four panels have content on
   open.

2. THE PREVIEW SOLVE IS PINNED TO torch-CPU. Measured on the 512^2 preview crops:
   cpu 6.8 ms warm, cuda 7.3 ms, numpy 18.4 ms. CUDA buys nothing at this size and
   pinning to CPU keeps a per-drag-frame job off the device the full solve wants.

   NB the ~3 s the first preview cost in the harness is torch IMPORT+INIT, which
   is process-wide and not about device choice -- the session already prewarms it
   on a background thread, and the benchmark was racing that prewarm by opening
   the caret immediately after load.

3. THE drift_open BUDGET IS NOW A FUNCTION OF FRAME SIZE, restated openly rather
   than widened silently. It scales with frame bytes, so a flat number is either
   slack at 2048^2 or a false failure at 4096^2. Measured floor at 4096^2: two
   full-frame reads (145 ms) plus figure construction (99 ms) = ~244 ms against
   278 ms observed; at 2048^2 the same terms are ~100 ms against 133-153 ms.
   120 + 10/Mpx fits both -- a fitted line through two points, not a model.

   The flat 200 ms IS reachable at 4096^2, but only by reading the check-sum
   frames through the memmap rather than dask (11.6 ms/frame vs 77). Not done.
…the dispatcher

The first preview cost 4.4s in a cold process. 3.99s of that was torch's IMPORT,
paid by whichever thread called solve_translation first -- and that thread was
the SHARED nav dispatcher, so it blocked every navigator update too.

heavy_imports gains wait_for_torch()/torch_imported(): an Event set when the
prewarm thread finishes, whether or not a GPU was found (a torch-CPU consumer
cares that the import is done, not that CUDA exists). Set in `finally` and on
the pytest skip path, so a waiter can never hang.

drift_open now opens the check window first, then absorbs the wait on a WORKER
before submitting the first preview. The preview step itself gates on
torch_imported() and uses numpy (18ms warm) until torch is resident, so the
dispatcher never stalls on an import under any ordering.

Measured, 245 x 4096^2 uint8 lazy:

                        cold process    normal load (prewarm done)
  drift_open              241.8 ms        236.4 ms
  first_preview torch_wait 3986.2 ms        0.0 ms   <- on a worker either way
  drift_preview_step       165.8 ms       212.6 ms   (was 4357.7 before this)
  open -> first preview      4.6 s          0.5 s

The normal-load number is the one a user sees: they open a 3.8 GiB movie, look
at it, and by the time they reach for the caret the background prewarm finished
long ago. The cold figure is a harness artefact of opening the caret
microseconds after the load -- worth having, but it is not the user's number,
and reporting it as one is what --normal-load exists to stop.
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

1 participant