Repository navigation
Reconstruct drives its own write loop, so it cannot resume interrupted work #574
Description
Summary
apply_inverse_transfer_function_single_position drives its own per-timepoint
write loop through apply_inverse_to_zyx_and_save (waveorder/cli/utils.py)
rather than iohub's apply_transform_to_tczyx_and_save /
process_single_position. The two do nearly the same job, but the iohub pair is
gaining per-unit resume — see czbiohub-sf/iohub#455 — and reconstruct cannot
use it while it owns its own loop.
Concretely: when a reconstruct job is killed part-way through a position (Slurm
preemption on our preempted partition, or a walltime kill), the retry
recomputes every timepoint from scratch. Every other per-position step in the
biahub pipeline — flat-field, deskew, assemble — now recomputes only the
timepoints that had not finished.
What reconstruct already gets, and what it misses
czbiohub-sf/iohub#455 has two parts, and reconstruct gets one of them for free:
- Torn-shard repair — already covered. A job killed mid-write leaves a
truncated shard, and because our shard grid is rounded up past the array bound,
every later write to that shard has to read it back first and dies on its
checksum (RuntimeError: the checksum is invalid, RuntimeError: the checksum is invalid when writing zarr chunks via zarrs codec on Lustre czbiohub-sf/iohub#415). In
Optimize reconstructions #455,Position.write_xarrayclears the shards a write owns before writing, so
the truncated file is replaced rather than read back. Since
apply_inverse_to_zyx_and_savewrites throughwrite_xarray, reconstruct is
protected without any waveorder change. Nothing here is urgent for
correctness. - Resume — not covered. Skipping already-finished work needs a record of what
finished, and that record is written byapply_transform_to_tczyx_and_save,
which reconstruct does not call.write_xarraydeliberately writes no records,
because a bare array write has no notion of a resumable unit of work.
So this issue is about wasted compute on retries, not about data integrity.
Why the loops differ
apply_inverse_to_zyx_and_save does five things that iohub's equivalent does not,
and each is a small piece of work to reconcile:
# waveorder/cli/utils.py
czyx_slice = input_data.isel(t=t_idx).sel(c=input_channel_names) # 1. xarray in
if _check_nan_n_zeros(czyx_slice.values):
return
output_czyx = func(czyx_slice, **kwargs) # 2. xarray out
t_coord = input_data.coords["t"].values[t_idx : t_idx + 1] # 3. writes by coord
output_xa = output_czyx.expand_dims(dim={"t": t_coord}, axis=0)
with open_ome_zarr(output_path, mode="r+") as output_position:
output_position.write_xarray(output_xa) # 4. sets metadata per writeversus iohub, which reads a numpy CZYX, calls func(czyx, **kwargs) expecting
numpy back, and writes by index into a plate whose metadata was set once at
creation.
Where to make the changes: iohub is fair game
To be explicit, since it shapes the cheapest path — we are not looking to bend
waveorder to fit iohub's current API. iohub is ours to change too, and this
mismatch is as much a gap in iohub (it can only hand a plain array to func) as
it is in waveorder. Three ways to reconcile:
Option A — all in waveorder. waveorder wraps its model function in a numpy
adapter and rebuilds the xarray coords itself. No iohub change. The wart is that
waveorder has to reconstruct coords that iohub already knows, which is exactly
where a subtle bug would live: coords drifting from the store they came from.
Option B — all in iohub. iohub grows an opt-in labeled hand-off, so func
receives an xr.DataArray and may return one. This turns out to be cheap, because
iohub already has the pieces:
Position.to_xarray()already exists (iohub/ngff/nodes.py) and builds the
labeled TCZYX array with coords from the store.xarray>=2024.1.1is already a hard dependency, so no new dependency._apply_transform_to_czyxalready opens the position to read the slice, so the
hand-off is a few lines at a boundary that already exists:
# iohub/ngff/utils.py, sketch
with open_ome_zarr(input_position_path, layout="fov", mode="r") as input_dataset:
if labeled: # new, opt-in
czyx = input_dataset.to_xarray().isel(t=input_time_index, c=input_channel_indices)
else:
czyx = input_dataset.data.oindex[input_time_index, input_channel_indices]Option C — both, split by concern. Option B for the interchange type, plus the
waveorder-side work (deleting the loop, moving metadata, plumbing resume) that has
to happen either way.
Recommendation: Option C
Do the labeled hand-off in iohub and the loop removal in waveorder.
- It is the smaller total change. Option A's adapter is roughly the same number
of lines as Option B's hand-off, but Option A pays them per-caller: any future
library with labeled-array functions writes the same adapter again. - The coords come from the same code that reads the data, so they cannot drift.
Under Option A they are reconstructed frominput_dataset.scalein a second
place. waveorder.api.*signatures stay exactly as they are — they keep taking and
returningxr.DataArray. That is the part worth protecting; it is public API and
it is the more useful shape.- It keeps the write side simple: iohub should accept an
xr.DataArrayback from
funcand take its values, but still write by index, with scale/translation
set once at plate creation. Preserving coords on every write is what made
write_xarrayheavier than it needs to be, and index-based writes are what the
per-unit records are keyed on.
Concretely that means one small PR to iohub (an opt-in labeled=/array_type=
argument threaded through process_single_position →
apply_transform_to_tczyx_and_save → _apply_transform_to_czyx), and then item 1
below disappears from the waveorder side.
If the iohub change turns out to be contentious, Option A still works and is
entirely inside waveorder — so this is not a blocker, just the tidier order.
Changes required to switch
1. Adapt the data interchange type — not needed if we take Option C above.
The waveorder.api.* model functions take and return xr.DataArray (e.g.
fluorescence.apply_inverse_transfer_function(czyx_data: xr.DataArray | list[xr.DataArray], ...)),
while iohub hands func a numpy CZYX and expects numpy back. With the labeled
hand-off in iohub, the model function is passed through unchanged. Without it, the
fallback is an adapter closure in waveorder:
def _numpy_adapter(model_fn, coords, **kwargs):
def wrapped(czyx: np.ndarray) -> np.ndarray:
xa = xr.DataArray(czyx, dims=("c", "z", "y", "x"), coords=coords)
return model_fn(xa, **kwargs).values
return wrappedThe coords need building once from the input store, since the model functions
select channels by label and may read z/y/x spacing from coords — and having to
rebuild them here, rather than getting them from the code that read the data, is
the argument for doing this in iohub instead. Changing the public api.*
signatures to accept numpy is the one option to avoid outright.
2. Resolve channels from names to indices. waveorder selects by label
(.sel(c=input_channel_names)); iohub takes indices. Resolve once, and pass all
input channels as a single group so the model still receives one CZYX:
input_channel_indices = [[input_dataset.channel_names.index(n) for n in settings.input_channel_names]]
output_channel_indices = [list(range(len(output_channel_names)))]3. Move per-write metadata to plate creation. write_xarray sets axes,
scale/translation transforms and iohub.xarray_attrs on every write;
write_oindex sets none of them. create_empty_plate already receives scale
from get_reconstruction_output_metadata, so scale is covered — but
translation and the DataArray-attrs round-trip are not. This is the one item
worth checking carefully before switching, since it is the only place where
behaviour could silently change.
4. Address timepoints by index rather than coordinate. Today the write goes in
by t coordinate, and write_xarray maps it back to an index using the output's
scale and translation. iohub takes explicit indices:
time_indices = resolve_time_indices(settings.time_indices, T)
# output plate is created with the full input T, so indices coincide
input_time_indices = output_time_indices = time_indicesWorth confirming this is equivalent when time_indices is a subset, and when the
input's t coords are offset or non-uniform.
5. Replace the pool with process_single_position. The loop becomes:
process_single_position(
func=_numpy_adapter(apply_inverse_model_function, coords, **apply_inverse_args),
input_position_path=input_position_dirpath,
output_position_path=output_position_dirpath,
input_channel_indices=input_channel_indices,
output_channel_indices=output_channel_indices,
input_time_indices=time_indices,
output_time_indices=time_indices,
num_workers=num_processes,
resume=resume,
resume_token=settings_fingerprint(settings),
)and apply_inverse_to_zyx_and_save can be deleted, along with the mp.Pool /
itertools.product block.
The main risk is here: fork → spawn. waveorder currently uses
torch.multiprocessing.Pool, which forks on Linux, so apply_inverse_args —
including the transfer-function xr.Dataset and any torch tensors — is inherited
rather than pickled. iohub uses ProcessPoolExecutor with an explicit spawn
context (deliberately: fork is unsafe with tensorstore's internal threads, and
ProcessPoolExecutor surfaces a cgroup OOM-kill as BrokenProcessPool instead of
hanging). Under spawn, func and every kwarg must pickle. That needs testing on a
GPU reconstruction before this is considered done — fork with CUDA is unsafe
anyway, so this is likely an improvement, but it is the part most likely to bite.
6. Plumb resume through. Add it to
apply_inverse_transfer_function_single_position and the CLI so biahub can pass
--resume, as it now does for flat-field, deskew and concatenate
(czbiohub-sf/biahub#299). resume_token should fingerprint the reconstruction
settings, and ideally the transfer function's identity too, so a record from a
different transfer function is not reused.
Incidental benefits
- iohub batches timepoints that share a shard into one write. Reconstruct writes
one timepoint at a time today, so where a shard spans several timepoints this
turns N partial-shard writes into one full-shard write. - One write path for the whole pipeline instead of two, so future work on writes
(record-count reduction, atomic writes) lands everywhere at once. _check_nan_n_zerosand the "skipped because all zeros" handling already exist
in iohub, so that logic is deleted rather than duplicated.
Not required by this issue
The repair half is already in effect via write_xarray, so reconstruct will not
hit the deterministic torn-shard failure either way. If the switch turns out to be
awkward — most likely because of spawn-pickling of the transfer function — an
acceptable middle ground is to keep apply_inverse_to_zyx_and_save and have it
write records itself via iohub's write-unit helpers. That is more coupling to
iohub internals for less benefit, so it is a fallback rather than the plan.
References
- Repair torn shards and resume interrupted writes per unit czbiohub-sf/iohub#455 — repair + per-unit resume (the branch this depends on)
- Feat/resume interrupted positions czbiohub-sf/biahub#299 — the biahub side, which passes
--resumeper step - RuntimeError: the checksum is invalid when writing zarr chunks via zarrs codec on Lustre czbiohub-sf/iohub#415, create_empty_plate shard/chunk geometry forces read-modify-write, making torn shards recur czbiohub-sf/iohub#442 — the torn-shard failure and the
shard geometry behind it
Activity
ieivanov commentedon Aug 5, 2026
I went through this against the current code in waveorder, iohub
feat/interrupt-safe-writes, and biahubfeat/resume-interrupted-positions. The conclusion is that Option C is the right call and is cheaper than the issue estimates — the headline risk is already retired and item 3 turns out to be a no-op. But two of the sketches would change behaviour if taken literally, and one of them silently. Details, then a proposed path for deletingapply_inverse_to_zyx_and_save.Caveat up front: this is all from reading code, not from running a reconstruction. The verification checklist at the end is the part that needs an actual run.
The fork → spawn risk is already paid
The issue calls this "the main risk … the part most likely to bite", on the premise that reconstruct uses
torch.multiprocessing.Pooland forks. That was true when the issue was written but is not true onmain:507a8af("Use ProcessPoolExecutor with spawn for apply-inv-tf workers", #558) landed after #555, andapply_inverse_transfer_function_single_positionnow useswith the same tensorstore-fork-safety and
BrokenProcessPoolreasoning iohub carries. So reconstruct already runs under exactly the executor and start methodprocess_single_positionuses.The premise does not hold for the pre-
507a8afcode either.multiprocessing.Pool.starmapserializes its task payload through_ForkingPicklerinPool._handle_tasksregardless of start method, soinput_xaand the whole transfer-functionxr.Datasetwere already being pickled once per task under fork. Fork was sharing the parent's imports and pre-pool state, not the arguments.And the transfer function specifically cannot fail to pickle.
_load_transfer_function_datasetmaterialises every array eagerly —np.array(transfer_function_dataset[key][idx])— andtransfer_function_dataset.close()runs before the pool is created, so there is no lazily-opened handle anywhere in the payload. Nothing inapply_inverse_argsis a lambda, a closure, a bound method, or a torch tensor; tensors are created inside the worker by_to_tensor'storch.from_numpy(...). There is no GPU state in the payload.What spawn actually costs is a full torch import per worker and the loss of copy-on-write. That is startup overhead, not a correctness risk, and it is already being paid today.
Adopting iohub's loop does not change resume granularity
Worth knowing before weighing the benefits, because it cuts one of them.
iohub's unit is a shard-aligned batch of timepoints for one channel group, batched on
output_shards[0]in_indices_to_shard_aligned_batches. Reconstruct's output plate goes throughcreate_empty_platewith nochunksand noshards_ratio—get_reconstruction_output_metadatareturns only shape, scale, channel names, dtype, plate metadata and version — so it gets_default_shards_ratio, whose docstring is "Shards ratio yielding a shard of (1, 1, Z, Y, X)". The shard extent along T is 1, so each unit is a single timepoint, which is already the granularity ofp.submit(partial, t_idx).Two consequences. The resume this buys is unit-for-unit what instrumenting the existing loop would give, so the win here is genuinely the maintenance one — one loop instead of two — rather than a finer resume. And "iohub batches timepoints that share a shard into one write" is a no-op at this geometry; it only starts paying if something later passes an explicit
shards_ratiowith a T extent above 1.Item 3 (per-write metadata) is a no-op, not a risk
The issue flags this as "the one item worth checking carefully before switching, since it is the only place where behaviour could silently change". Reading
write_xarrayagainst the way reconstruct calls it, nothing persistent is lost:translationtransform is never written.transformsis only passed tocreate_zeros, insideif image not in self:. Reconstruct pre-creates the plate, so that branch is skipped and the derived translation is discarded. The output keeps only thescaletransformcreate_empty_platewrote.self.axes = [...]shadows acached_property;write_xarraydoes not calldump_meta(), andclose()only closes the store. The coord-attrs-derived units never reach disk (and match_DEFAULT_AXESanyway).iohub.xarray_attrsis never written, becausedata_array.attrsis always empty._build_output_xarrayconstructs the output withoutattrs=, and nothing inwaveorder/api/ever assigns.attrsto a DataArray.So there is nothing to move to plate creation. An index-based write loses nothing.
Item 4 is more than a confirmation — it may be a live bug
The issue says "worth confirming this is equivalent when
time_indicesis a subset, and when the input's t coords are offset or non-uniform." It isn't equivalent.write_xarrayrecovers the index aswhere
t_coordscome from the input store (to_xarraybuilds them asarange(size) * scale[i] + translation[i]) whiletranslationis read from the output array — whichcreate_empty_plategave no translation transform at all, since it only ever writes ascale. That works out toZero in the mantis pipeline, where every store comes from
create_empty_plate. But standalonewaveorder reconstructon a raw acquisition store is the ordinary single-user path, and if any of those carry a nonzero T translation, every timepoint is written to a shifted or out-of-bounds index, silently. This is worth checking against a real acquisition store independently of this issue. Addressing by index fixes it by construction, which I would promote to a stated motivation for the switch rather than a box to tick.The interchange: Option A works, and the drift argument dissolves
The case made for changing iohub is that Option A would have waveorder rebuild coords that iohub already knows, "which is exactly where a subtle bug would live: coords drifting from the store they came from." Checking what the model functions actually read from the labeled array, that risk is smaller than it looks:
apply_inverse_transfer_functionin any of the four api modules reads coords at all. They touch the input only through.valuesand.shape(torch.tensor(czyx_data.values, ...), or.values[0]for the phase/fluorescence single-channel paths). All pixel sizes come from the pydanticsettings.transfer_function, never from coords.coords["c"]appears only incompute_transfer_function(birefringence.py:184,birefringence_and_phase.py:274), as a fallback for counting polarization states wheninput_channel_namesis not passed — and reconstruct always passes it.waveorder/models/never see an xarray object at all._build_output_xarray(waveorder/api/_utils.py) copying z/y/x coord values and attrs onto the output DataArray — whose coords the write then discards, per the section above.xr.Datasetis used purely as a string-keyed bag of arrays;_named_dataarraydeliberately gives it meaningless synthetic dim names.So the coords the adapter has to supply exist only to keep
_build_output_xarrayfrom raising, and their values have no effect on stored data. More to the point, the adapter does not have to reconstruct them frominput_dataset.scale— it can take them fromto_xarray()once in the parent and pass them down as plain(dims, values, attrs)tuples. They then come from the same code that reads the data, which is the property Option B was there to guarantee.That removes the reason to add a
labeled=/array_type=switch to iohub. I would rather not put a public two-path flag intoprocess_single_positionthat every future feature in that loop has to honour, in exchange for six lines in one caller.One trap in the sketch, and one real behaviour change
Output channel indices must be resolved by name. The sketch has
write_xarraycurrently resolves them throughget_channel_index(ch). Those differ whenever the reconstructed channels do not start at index 0 —create_empty_plateappends channels to an existing plate, and #547 namespaced the settings by output channel names precisely so several configs can write different channel groups into one plate. Keep the name lookup againstoutput_dataset.channel_namesor this silently overwrites the wrong channels. The same applies on the input side: build the index list in config order, not sorted, sinceoindexrespects the order given and that is what.sel(c=input_channel_names)guarantees today.The all-zero/NaN predicate changes. This is the one genuine behaviour change and it isn't in the issue's list. waveorder's check is over the whole CZYX block:
iohub's, for a 4-D block, is per channel with
any:iohub skips a strict superset. A timepoint where one polarization state is blank but the others carry signal is reconstructed today and would be skipped after the switch, leaving zeros in the output. For reconstruction that is arguably the better semantics — a missing state makes the result meaningless — but it needs to be a deliberate decision and a changelog note, not a side effect. If waveorder's laxer semantics have to be preserved, that is an argument for making the predicate injectable in iohub, since it has to run before
functo be worth anything.Proposed path for deleting
apply_inverse_to_zyx_and_saveStep 1 — a module-level numpy adapter. It must be module-level, not a closure:
process_single_positionsubmits the partial to a spawn pool, so a locally-defined function would not pickle. This also matches biahub's_<verb>_czyx()convention.czyx_coordsis built once in the parent from the coords iohub already produced, so nothing is recomputed fromscale:model_functionstays one of the four module-levelwaveorder.api.*functions, so the payload is still picklable andwaveorder.api.*signatures are untouched.Step 2 — resolve channels to indices, in config order, as a single group.
The nesting matters: a flat list makes iohub treat each channel as its own task, which is the
biahub concatenatecase. One group keeps the model receiving one CZYX.Step 3 — address timepoints by index. The output plate is allocated with the full input
T, so input and output indices coincide even for a subset:passed as both
input_time_indicesandoutput_time_indices.Step 4 — replace the pool.
Everything after
resume_tokenrides through**kwargsto the adapter.num_workersis additionally clamped by iohub's_available_cpus(), which honoursSLURM_CPUS_PER_TASK— a small improvement over the currentmp.cpu_count()bound inprocesses_option.use_threads=Trueis available if #566/#552 lands, so this composes with either outcome.Step 5 — the resume token must cover the transfer function. The issue says "ideally"; I would make it required. biahub's
settings_fingerprinthashes only the resolved settings, so recomputing a transfer function and re-running apply-inverse into the same output store yields the same token and silently reuses timepoints computed with the old transfer function. That is a data-correctness bug, not wasted compute. The store already records what is needed —compute_transfer_functionwritesoutput_dataset.zattrs["settings"] = settings.model_dump()— so:Note this has to be computed before
transfer_function_dataset.close().Step 6 — delete.
apply_inverse_to_zyx_and_saveand_check_nan_n_zerosfromwaveorder/cli/utils.py, and thepartialplus executor block fromapply_inverse_transfer_function.py.input_dataset.to_xarray()shrinks to a coords-only read. Kept as is: the settings and channel validation,resolve_time_indices, the transfer-function load, and the post-poolzattrs["waveorder"]write.Sequencing. Two waveorder PRs — the loop swap, then
--resume— is worth it for reviewability, but both should target the same iohub, because the releasedprocess_single_positiontakesnum_processes/num_threadswhilefeat/interrupt-safe-writesrenamed them tonum_workers/use_threads. Splitting across versions means writing the call twice. Pin the branch for review and repoint at the release when it merges, which is what biahub#299 already does. Then the biahub side: pass--resumefrombiahub/apply_inverse_transfer_function.py, add it to the reconstruct Nextflow module (which has none today, unlike deskew and flat-field), and document@resume()in biahub's CLI-consistency canon, which is still one step behind that branch.Verification checklist
main, for each of the four reconstruction modes. This is the main gate on step 1–4 being a pure refactor.range(len(...))would break).time_indicessubset, and a store with a nonzero T translation if you can find or fabricate one — the second is the case the current coord round-trip gets wrong.get_reconstruction_output_metadatapropagates the input plate's version andtracking_availableis False for Zarr v2 — resume degrades to a warning plus full recompute there, which is fine but worth echoing from the CLI rather than only warning from inside iohub.One thing this path does not cover, worth noting since it argues for keeping iohub's write-unit helpers importable rather than internal:
virtual_stainandtrackalso drive their own per-timepoint writes (biahub/virtual_stain.py,biahub/track.py) and cannot adoptprocess_single_position— virtual_stain runs a torch model with its own batching. iohub#455 already lists virtual_stain as getting neither repair nor resume. Unifying reconstruct fixes reconstruct; those two would still needplan_write_unit/unit_is_complete/progress_dir_forto be public before they can record anything.ieivanov commentedon Aug 6, 2026
@talonchandler @srivarra Sorry for the wall of text, I'll try to go through it and decide what actually makes sense. Let me know if you are generally opposed to the changes suggested here - getting rid of
apply_inverse_to_zyx_and_saveand relying on iohub utils instead.talonchandler commentedon Aug 6, 2026
Makes sense to me! Thanks @ieivanov.
refactor(cli)!: run apply-inv-tf through iohub's process_single_posit…