Streaming visibilities for memory efficiency #13
ProblemOn the sparse path (Interferometer.apply_sparse_operator + InversionInterferometerSparse) the inversion itself is independent of the number of visibilities, but the dataset and the fit are not: every visibility stays resident for the whole run, and every likelihood evaluation still does O(N_vis) work. Building the sparse operator by streaming the visibilities once, and carrying the handful of per-visibility sums the fit needs alongside it, makes both the resident memory and the per-likelihood cost independent of N_vis. We have this working in pyuvimage on top of autoarray's sparse inversion, verified against the in-memory sparse fit to 1e-8, and it would be a natural addition upstream. What the current sparse path holds and computes Resident for the whole fit, per visibility (as Interferometer is built today): data (complex128, 16 B), noise_map (complex128, 16 B), uv_wavelengths (2 x float64, 16 B) — 48 B/visibility minimum, ~10 GB at 2e8 visibilities, before from_fits load temporaries and the NUFFT plan. Per likelihood evaluation, all O(N_vis) in time and transient memory, even though the sparse inversion has already reduced the data to W~ and the dirty image: FitInterferometer.profile_visibilities allocates an N_vis array (Visibilities.zeros when there are no light profiles) and profile_subtracted_visibilities = data - profile_visibilities allocates another (autogalaxy fit_interferometer.py). So the sparse path removes N_vis from the linear algebra but not from the process: RSS scales with N_vis and the fit does several full passes over the visibilities per likelihood call. Proposed SolutionWhat streaming looks like Read the visibilities once in chunks (from .npz, FITS, or a memory-mapped source) and accumulate, per chunk, every quantity downstream of the data that is a sum over visibilities: quantity | accumulated as | consumer Then discard the chunk. The fit is built from InterferometerSparseOperator.from_nufft_precision_operator(W~, dirty_image) plus the two scalars; chi_squared is s^T F s - 2 s^T D + const with no visibilities touched, and the residual dirty image is dirty(data) - W~ * (M s) via the same FFT-multiply the operator already uses. Nothing per visibility survives the load. Measured in pyuvimage (peak RSS of the accumulation from a compressed .npz, 400-pixel image, 4096-visibility chunks, fresh process each): N_vis | peak RSS over baseline | resident if held (48 B/vis, autoarray minimum) Flat across 8x; the in-memory path grows linearly and, on a real 2e8-sample ALMA MFS cube, is the difference between a laptop and a node Parity: the streamed fit matches the in-memory sparse fit to 1e-8 in chi^2, model image, residual map and dirty image (W~ to 2e-16, dirty image to 5e-16). It extends directly to per-channel (cube) fits, since the MFS terms are the sum of the channel terms, and to phase-centre shifts applied chunk by chunk. Suggested shape upstream
Item 2 and 3 are independent of streaming and benefit every sparse-path user today. Reference implementation src/pyuvimage/streaming.py in https://github.com/HRSAstro/pyuvimage — TermsAccumulator / accumulate_sparse_terms (the per-chunk sums), stub_dataset_from_terms (the Interferometer built from the operator alone), and tests/test_streaming.py::test_streamed_fit_matches_the_in_memory_sparse_fit for the parity check. Alternatives ConsideredNo response |
Replies: 2 comments
|
Thanks for this — the write-up made it very easy to act on, and the pyuvimage accumulator and parity test were a big help as a reference. A first phase has now landed on Items 2 and 3 of your list (every sparse-path user):
Item 1, first half: the per-chunk sums are in as Not yet done — the array-free dataset itself. Thanks again — please shout if the API shape above doesn't fit how pyuvimage wants to feed it. |
|
Follow-up as promised: the array-free dataset is now in on What you can do now dataset = aa.Interferometer.from_stream(chunks, real_space_mask=mask, transformer_class=aa.TransformerNUFFT, chunk_size=65536)
So Measured, since you asked what it buys (laptop CPU, 400-px mask at 0.05"/pix, NUFFT, synthetic visibilities):
Streaming is linear at ~11 s per million visibilities and flat in memory, so 2e8 extrapolates to roughly 40 minutes and under 2 GB on a laptop. Two practical notes from that: chunk size matters a lot (65536 is 5× faster than the 4096 in your table, because each chunk pays a fixed NUFFT setup), and there is a further 2–3× available in the accumulator from avoiding a JAX recompile per chunk, which I'll chase in a profiling campaign. Also in since the first draft of this note
Two things we found on the way that you may hit with pyuvimage-style data: If you get a chance to point pyuvimage's real data at |
Thanks for this — the write-up made it very easy to act on, and the pyuvimage accumulator and parity test were a big help as a reference. A first phase has now landed on
mainacross the three libraries (not yet on PyPI; it will go out with the next release):Items 2 and 3 of your list (every sparse-path user):
Interferometer.apply_sparse_operatornow reducesΣ d²/σ²andΣ log 2πσ²once and caches them on the operator (InterferometerSparseOperator.data_term/.noise_normalization).fast_chi_squaredandFitInterferometer.noise_normalizationread the scalars, so neither does an O(N_vis) pass per likelihood call any more (PyAutoArray #589).