Repository navigation
perf(cli): share the transfer function across apply-inv-tf workers and read each volume once - #582
Merged
Merged
Conversation
apply_inverse_transfer_function_single_position bound the loaded transfer function into the per-time-point partial. ProcessPoolExecutor pickles every task's arguments, so the full transfer function was serialised by the parent and deserialised by a worker once per time point, and converted to tensors (with a copy) on every call. For a 3D phase transfer function of (181, 768, 794) complex64 pairs (~1.8 GB), 768 time points meant ~1.36 TB of pipe traffic per position and ~30 GB of queued pickles in the parent. Each pool worker now loads the transfer function once in an initializer, converts it to torch tensors, and passes them to every time point it runs. The serial path loads it once in-process. _to_tensor returns already-converted tensors as-is; xr.Dataset callers are unchanged. The executor type, spawn context, and thread settings are unchanged. Co-Authored-By: Claude Opus 5.5 (1M context) <[email protected]>
Loading the transfer function in every worker removed the per-time-point pickling but read the store once per worker and kept one copy per worker in RAM (16 x 3 GB for a v6 3D phase TF). The parent now loads it once, moves the tensors into shared memory with share_memory_(), and passes them as the pool initializer's argument. torch.multiprocessing's pickling reductions send a shared tensor as a handle to its memory, so every worker maps the same copy: one storage read, one copy in RAM, and nothing large through the worker pipes. A missing transfer-function key again fails in the parent, before the pool starts. Co-Authored-By: Claude Opus 5.5 (1M context) <[email protected]>
…nction The explicit share_memory_() was redundant: importing torch.multiprocessing registers pickling reductions that move a pickled CPU tensor into shared memory and send a handle to it, which already happens when the tensors are passed as the pool initializer's argument. Drop the call and document the mechanism, including why it only applies once the arrays are torch tensors. Co-Authored-By: Claude Opus 5.5 (1M context) <[email protected]>
Codecov Report❌ Patch coverage is
Additional details and impacted files@@ Coverage Diff @@
## main #582 +/- ##
==========================================
+ Coverage 55.95% 56.05% +0.10%
==========================================
Files 75 75
Lines 12618 12631 +13
==========================================
+ Hits 7060 7080 +20
+ Misses 5558 5551 -7 ☔ View full report in Codecov by Harness. 🚀 New features to boost your workflow:
|
torch.multiprocessing's pickling reductions share CPU tensors for any ForkingPickler route, including the executor's per-task call queue, so a tensor transfer function bound into each time point's partial already crosses as a small shared-memory handle. The pool initializer, the module-level worker global, and the task wrapper are no longer needed; the executor code is back to main's. _load_transfer_function_dataset becomes _load_transfer_function and returns the tensors directly instead of an xr.Dataset of numpy arrays that was converted afterwards. The public apply_inverse_transfer_function type hints and docstrings now say they accept a mapping of tensors. Co-Authored-By: Claude Opus 5.5 (1M context) <[email protected]>
apply_inverse_to_zyx_and_save called `.values` on the dask-backed input slice twice, once for the NaN/zero check and again inside the model, so every time point was read and decompressed from the store twice (0.48 GB extra per time point on a v6 A549 position). Load the slice once. _to_tensor copied every xr.Dataset variable before wrapping it. The apply-inverse models never modify the transfer function in place, so wrap it with torch.from_numpy directly and share its memory. Tests: the input is read once per time point (fails with the old code), and apply leaves an xr.Dataset transfer function unchanged while sharing its memory, for phase and fluorescence. Co-Authored-By: Claude Opus 5.5 (1M context) <[email protected]>
ieivanov
marked this pull request as ready for review
September 26, 2026 00:02
Contributor
Author
|
@srivarra this PR is ready for review too 🙏 . The key change is that the internal |
srivarra
approved these changes
Oct 7, 2026
|
|
||
| # Extract CZYX xarray slice | ||
| czyx_slice = input_data.isel(t=t_idx).sel(c=input_channel_names) | ||
| # Extract CZYX xarray slice. Load it once: input_data is dask-backed, so |
Contributor
There was a problem hiding this comment.
this is a cool find. I always thought Dask itself would be able to know where/when it needs things kept in memory.
ieivanov
added a commit
that referenced
this pull request
Oct 8, 2026
Conflicts with #582 (this branch's earlier perf commits, squash-merged) resolved in favor of this branch, which supersedes them. Drops main's test for apply_inverse_to_zyx_and_save, which this branch removed. Co-Authored-By: Claude Opus 5.5 (1M context) <[email protected]>
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
Sign up for free
to join this conversation on GitHub.
Already have an account?
Sign in to comment
Add this suggestion to a batch that can be applied as a single commit.This suggestion is invalid because no changes were made to the code.Suggestions cannot be applied while the pull request is closed.Suggestions cannot be applied while viewing a subset of changes.Only one suggestion per line can be applied in a batch.Add this suggestion to a batch that can be applied as a single commit.Applying suggestions on deleted lines is not supported.You must change the existing code in this line in order to create a valid suggestion.Outdated suggestions cannot be applied.This suggestion has been applied or marked resolved.Suggestions cannot be applied from pending reviews.Suggestions cannot be applied on multi-line comments.Suggestions cannot be applied while the pull request is queued to merge.Suggestion cannot be applied right now. Please check back later.
Merge order and dependencies
process_single_positionand adds--resume. It keeps this PR's gains: the TF is passed as torch tensors, so it crosses iohub's pool as a shared-memory handle, and each volume is read once. Merge it after this PR, then retarget it tomain.Problem
apply_inverse_transfer_function_single_positionbinds the loaded transfer function into the per-time-pointpartial.ProcessPoolExecutorpickles each task's arguments. The TF was anxr.Datasetof numpy arrays, so the full TF was serialised by the parent and sent through a pipe to a worker once per time point._to_tensorthen copied it again on every call.Observed in biahub/Nextflow runs (one position per job, 16 processes):
2026_08_14_dynatrack2026_08_11_A549_SEC61B_DENV(v6)The on-disk data is far smaller. For dynatrack, ~210 GB is read and ~261 GB written per position. The excess is T × TF size of pipe traffic, not disk I/O: 768 × 1.77 GB ≈ 1.36 TB for dynatrack, and 67 × 3.05 GB ≈ 204 GB for v6.
rchar/wchar(what the Nextflow trace reports) count every byte passed throughread()/write()system calls, including pipes. The TF is never written to storage. The cost is CPU to pickle and unpickle ~3 GB per time point, temporary memory copies in both processes, and dispatch serialised through the executor's single feeder thread.Separately, each time point's input volume was read from the store twice (see Changes).
Changes
cli/apply_inverse_transfer_function.py:_load_transfer_function_dataset→_load_transfer_function, which returns a dict of torch tensors instead of anxr.Datasetof numpy arrays. It's still bound into each time point's task as before; the executor code is unchanged frommain.torch.multiprocessingregisters pickling reductions that move a pickled CPU tensor into shared memory and send a small handle to it, not its data. That applies to anyForkingPicklerroute, including the executor's per-task call queue. So every worker maps the same single copy: one storage read, one copy in RAM, and nothing large through the pipes. Numpy arrays, or anxr.Datasetof them, pickle by value, which is whymaindidn't get this.api/_utils.py:_to_tensor/_to_singular_systemalso accept a mapping of tensors, returned as-is.xr.Datasetvariables are now wrapped withtorch.from_numpywithout the extra.copy(). None of the apply-inverse models, or the helpers they pass the TF to, write to it in place. A test checks that apply leaves anxr.DatasetTF unchanged.api/{phase,fluorescence,birefringence,birefringence_and_phase}.py: theapply_inverse_transfer_functiontype hints and docstrings say they acceptxr.Dataset | Mapping[str, torch.Tensor].cli/utils.py:apply_inverse_to_zyx_and_saveloads the dask-backed input slice once (.load()). Before, the NaN/zero check and the model each called.values, so every time point was read and decompressed twice (0.48 GB extra per time point on v6).Not changed: the executor type (
ProcessPoolExecutor, spawn context), thread settings, and the public signature ofapply_inverse_transfer_function_single_position.Commits, which trace how we got here (squash on merge is fine):
share_memory_().share_memory_(), which torch's pickling hook already does._to_tensorcopy.Benchmark
wo apply-inv-tf -j 16on one v6 position (C/2/000002). The TF was recomputed with the correct pixel sizes (0.1133 / 0.17; same shape). Each table is one SLURM job on one node (16 CPUs, 336 GB), with the versions run alternately.How the numbers were measured:
/proc/self/ioandRUSAGE_CHILDRENdeltas after reaping the command, which counts the whole process tree once.wchar − write_bytes.rchar − pipe traffic. On Lustre,write_bytesis exact, butread_bytesundercounts because readahead runs in kernel threads.peak_rssreports, would count the shared TF once per worker.Final, 16 time points (node cpu-h-15):
mainvs the previous commit vs this PR's headmain(60c3af6)main)main)Why
mainhas the lowest memory: it's limited by its pipe. The executor's single feeder thread pushes the TF pickles into worker pipes at ~0.4–0.5 GB/s, so workers mostly wait for their payload and few are busy at once. This PR runs all 16 at once. Each worker's private working set is ~11 GB (FFT buffers), plus the one shared 3 GB TF.Earlier runs, per commit (all bit-identical to
main):mainProjected for T = 100 (node cpu-d-1, from the per-time-point costs measured in the 32-time-point job; CPU and wall time are before 097ff97, which cut a further ~4% of CPU at T = 16):
mainTesting
test_apply_inv_tf_process_pool_matches_serial: a 3D phase reconstruction with 2 spawned workers gives output identical to the serial path.test_apply_inverse_to_zyx_and_save_reads_input_once: the dask-backed input is computed once per time point. It fails with2 == 1on the old code.test_apply_inverse_leaves_dataset_transfer_function_unchanged[phase|fluorescence]: apply doesn't modify anxr.DatasetTF, and the tensor shares its memory (no copy).xr.Dataset, and_to_tensorreturns tensors as-is.tests/api_tests+tests/cli_tests: 185 passed, 1 skipped, 2 xfailed.mainon real data for every commit.🤖 Generated with Claude Code