Skip to content

Commit 3da3d0b

Browse files
lguerardclaude
andcommitted
perf(ome_zarr): ⚡ stream pyramid levels instead of rechunking through dask
This is what OOM-killed the merge job. _write_pyramid strided each level then rechunked it back *upward* -- (16,512,512) to (16,1024,1024) -- so every output chunk pulled four source chunks, and dask's threaded scheduler holds those intermediates with no backpressure and no cap tied to the SLURM allocation. Decimation needs no halo, so a level's chunks can be chosen as ceil(src_chunk / stride): one task then reads exactly one source chunk and writes exactly one output chunk, nothing shared, peak memory n_workers x one chunk however large the image is. Levels are written zarr-natively via a bounded thread pool (zarr's codecs release the GIL). Explicit chunks= or sharding still take the dask path, which genuinely needs the rechunk. The merge also stops writing the volume twice. merge_tile_labels gained output_chunks, so it writes straight into labels/<name>/0 -- register_labels already assumes that level exists -- instead of filling a scratch _merged.zarr that write_labels then copied across chunk by chunk. The cap has to divide the tile shape or concurrent workers would read-modify-write a shared chunk, so capped_output_chunks picks the largest divisor at or below the cap. A test asserts the streaming and dask routes produce byte-identical levels, including the ragged trailing chunks. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
1 parent bed0daf commit 3da3d0b

5 files changed

Lines changed: 285 additions & 21 deletions

File tree

src/patchworks/__init__.py

Lines changed: 2 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -39,7 +39,7 @@
3939
stage_tile,
4040
)
4141
from ._io import auto_empty_threshold, estimate_empty_tiles, load_ome_zarr
42-
from ._merge import merge_tile_labels
42+
from ._merge import capped_output_chunks, merge_tile_labels
4343
from ._occupancy import build_occupancy_map, occupancy_path, tile_occupancy
4444
from ._postprocess import dilate_labels
4545
from ._relabel import relabel_sequential_array, relabel_sequential_zarr
@@ -52,6 +52,7 @@
5252
__all__ = [
5353
"tile_process",
5454
"merge_tile_labels",
55+
"capped_output_chunks",
5556
"auto_overlap",
5657
"auto_tile_shape",
5758
"auto_tile_shape_cellpose",

src/patchworks/_merge.py

Lines changed: 66 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -343,6 +343,46 @@ def _make_globally_unique(arr, shape: tuple, chunk_shape: tuple) -> int:
343343
return base
344344

345345

346+
def capped_output_chunks(
347+
chunk_shape: Sequence[int], caps: Sequence[int]
348+
) -> tuple[int, ...]:
349+
"""Shrink each chunk to at most *cap*, staying an exact divisor.
350+
351+
The merge's workers write one staged chunk at a time. As long as the output
352+
chunking divides the staged chunking, each write still covers whole output
353+
chunks, so concurrent workers never read-modify-write a chunk they share.
354+
A non-divisor cap would break that, so the largest divisor at or below the
355+
cap is used instead.
356+
357+
Parameters
358+
----------
359+
chunk_shape : sequence of int
360+
Staged chunk (= tile) shape.
361+
caps : sequence of int
362+
Maximum chunk size per axis.
363+
364+
Returns
365+
-------
366+
tuple of int
367+
Chunking for the merged output.
368+
369+
Examples
370+
--------
371+
>>> capped_output_chunks((16, 2048, 2048), (16, 1024, 1024))
372+
(16, 1024, 1024)
373+
>>> capped_output_chunks((16, 1024, 1024), (16, 1024, 1024))
374+
(16, 1024, 1024)
375+
"""
376+
out = []
377+
for c, cap in zip(chunk_shape, caps):
378+
c, cap = int(c), int(cap)
379+
if c <= cap:
380+
out.append(c)
381+
continue
382+
out.append(next(d for d in range(cap, 0, -1) if c % d == 0))
383+
return tuple(out)
384+
385+
346386
def _offsets_from_counts(
347387
counts: Mapping[int, int] | Sequence[int], n_chunks: int
348388
) -> np.ndarray:
@@ -397,6 +437,7 @@ def zarr_native_merge(
397437
show_progress: bool = False,
398438
label_counts: "Mapping[int, int] | Sequence[int] | None" = None,
399439
sequential: bool = False,
440+
output_chunks: "Sequence[int] | None" = None,
400441
) -> "int | None":
401442
"""Zarr-native label merge: boundary scan → scipy CC → parallel relabel.
402443
@@ -429,6 +470,10 @@ def zarr_native_merge(
429470
domain is dense by construction, so the compaction is a ``np.unique``
430471
over the LUT (length = object count) and folds into the same lookup,
431472
costing no extra pass over the volume.
473+
output_chunks : sequence of int, optional
474+
Chunking for the merged store. Must divide the staged chunk shape, so
475+
each worker's write still covers whole chunks. ``None`` mirrors the
476+
staged chunking. Use :func:`capped_output_chunks` to derive it.
432477
433478
Returns
434479
-------
@@ -497,10 +542,23 @@ def zarr_native_merge(
497542
)
498543

499544
out_root = zarr.open_group(out_path, mode="a")
545+
out_chunks = tuple(chunk_shape)
546+
if output_chunks is not None:
547+
out_chunks = tuple(int(c) for c in output_chunks)
548+
bad = [
549+
(i, c, o)
550+
for i, (c, o) in enumerate(zip(chunk_shape, out_chunks))
551+
if o <= 0 or c % o
552+
]
553+
if bad:
554+
raise ValueError(
555+
"output_chunks must divide the staged chunk shape so workers "
556+
f"write whole chunks; axis/staged/output mismatches: {bad}"
557+
)
500558
# Match the staged dtype: ids are already compact (dense by construction,
501559
# and compacted again above when sequential), so nothing needs a wider one.
502560
_create_zarr_label_array(
503-
out_root, out_component, shape, chunk_shape, dtype=arr.dtype
561+
out_root, out_component, shape, out_chunks, dtype=arr.dtype
504562
)
505563

506564
# Row-major, matching spatial_tiles' order -- so chunk i is tile i and the
@@ -585,6 +643,7 @@ def merge_tile_labels(
585643
progress: bool = False,
586644
return_count: bool = False,
587645
label_counts: "Mapping[int, int] | Sequence[int] | None" = None,
646+
output_chunks: "Sequence[int] | None" = None,
588647
) -> Union["da.Array", tuple["da.Array", Union[int, None]]]:
589648
"""Merge per-tile labels into a globally consistent label array.
590649
@@ -627,6 +686,11 @@ def merge_tile_labels(
627686
merge derive every tile's global id range arithmetically instead of
628687
streaming the whole store to renumber it. ``None`` keeps the
629688
renumber pass.
689+
output_chunks:
690+
Chunking for the merged store; must divide the staged chunk shape.
691+
Lets the merge write straight into a store you will keep (e.g. an
692+
OME-ZARR label group's level 0) instead of a scratch store that then
693+
has to be copied. See :func:`capped_output_chunks`.
630694
n_workers:
631695
Parallel workers for the relabel step. Default ``min(4, cpu_count)``.
632696
stage_dir:
@@ -741,6 +805,7 @@ def merge_tile_labels(
741805
show_progress=progress,
742806
label_counts=label_counts,
743807
sequential=sequential_labels,
808+
output_chunks=output_chunks,
744809
)
745810

746811
# -- Cleanup temp stage (only when we created it) --

src/patchworks/plugins/ome_zarr.py

Lines changed: 139 additions & 9 deletions
Original file line numberDiff line numberDiff line change
@@ -25,8 +25,13 @@
2525
Downsampling uses strided (nearest-neighbour) subsampling — the correct,
2626
label-preserving choice — and only on **X and Y**; ``z`` (and channel/time)
2727
stay at full resolution. Every level is built by reading the *previous level
28-
back from disk* and streaming the downsampled result out through dask with
29-
bounded chunks, so the pyramid never materialises a whole volume in RAM.
28+
back from disk*, so the pyramid never materialises a whole volume in RAM.
29+
Because decimation needs no halo, levels are written zarr-natively: each
30+
level's chunks are chosen as ``ceil(src_chunk / stride)`` so one task reads
31+
exactly one source chunk and writes exactly one output chunk, making peak
32+
memory ``n_workers × one chunk`` by construction. Explicit ``chunks=`` or
33+
sharding fall back to dask, which must rechunk and therefore reads several
34+
source chunks per output chunk.
3035
3136
This module also exposes :func:`add_pyramid` (add levels to an existing store)
3237
and :func:`write_labels` (store a label image under the NGFF ``labels/`` group).
@@ -42,6 +47,8 @@
4247

4348
import logging
4449
import math
50+
from concurrent.futures import ThreadPoolExecutor
51+
from itertools import product as _iproduct
4552
from pathlib import Path
4653
from typing import Union
4754

@@ -273,6 +280,97 @@ def _shard_for(
273280
return _effective_shard(tuple(shard), chunks, shape)
274281

275282

283+
# Don't let deep pyramid levels fragment into thousands of tiny chunks.
284+
_CHUNK_FLOOR = {"y": 128, "x": 128}
285+
286+
287+
def _level_chunks(
288+
src_chunks: tuple[int, ...],
289+
strides: tuple[int, ...],
290+
axes: str,
291+
level_shape: tuple[int, ...],
292+
) -> tuple[int, ...]:
293+
"""Chunking for a strided level that keeps one source chunk per task.
294+
295+
Choosing ``ceil(src_chunk / stride)`` makes each output chunk the exact
296+
image of one source chunk, so a task reads one chunk and writes one chunk
297+
with nothing shared between tasks. Capped by :data:`_CHUNK_CAP` and floored
298+
so deep levels don't fragment.
299+
"""
300+
out = []
301+
for c, st, a, s in zip(src_chunks, strides, axes, level_shape):
302+
v = -(-c // st)
303+
v = min(v, _CHUNK_CAP.get(a, v))
304+
v = max(v, _CHUNK_FLOOR.get(a, 1))
305+
out.append(max(1, min(v, s)))
306+
return tuple(out)
307+
308+
309+
def _create_level_array(
310+
group: "zarr.Group",
311+
name: str,
312+
shape: tuple[int, ...],
313+
chunks: tuple[int, ...],
314+
dtype,
315+
) -> "zarr.Array":
316+
"""Create (replacing any existing) a pyramid level array in *group*."""
317+
if name in group:
318+
del group[name]
319+
if _ZARR_V3:
320+
return group.create_array(name, shape=shape, chunks=chunks, dtype=dtype)
321+
return group.zeros(
322+
name, shape=shape, chunks=chunks, dtype=dtype, overwrite=True
323+
)
324+
325+
326+
def _stream_strided_level(
327+
src: "zarr.Array",
328+
dst: "zarr.Array",
329+
strides: tuple[int, ...],
330+
n_workers: int = 4,
331+
) -> None:
332+
"""Write *dst* as the strided subsample of *src*, one chunk at a time.
333+
334+
Labels are downsampled by plain decimation, which needs no halo, so each
335+
output chunk depends only on the source region that maps onto it. Peak
336+
memory is therefore ``n_workers x (one source region + its subsample)``
337+
however large the image is -- unlike a dask ``rechunk``, whose threaded
338+
scheduler holds intermediates with no backpressure.
339+
340+
Parameters
341+
----------
342+
src, dst : zarr.Array
343+
Source level and the (already created) destination level.
344+
strides : tuple of int
345+
Per-axis decimation step.
346+
n_workers : int
347+
Threads used for the copy. zarr's codecs release the GIL, so threads
348+
are enough and avoid pickling a worker payload.
349+
"""
350+
grid = [-(-s // c) for s, c in zip(dst.shape, dst.chunks)]
351+
take = tuple(slice(None, None, st) for st in strides)
352+
353+
def _one(idx: tuple[int, ...]) -> None:
354+
out_sl = tuple(
355+
slice(i * c, min((i + 1) * c, s))
356+
for i, c, s in zip(idx, dst.chunks, dst.shape)
357+
)
358+
src_sl = tuple(
359+
slice(o.start * st, min(o.stop * st, s))
360+
for o, st, s in zip(out_sl, strides, src.shape)
361+
)
362+
dst[out_sl] = np.asarray(src[src_sl])[take]
363+
364+
indices = list(_iproduct(*[range(g) for g in grid]))
365+
if n_workers <= 1:
366+
for idx in indices:
367+
_one(idx)
368+
return
369+
with ThreadPoolExecutor(max_workers=n_workers) as pool:
370+
for _ in pool.map(_one, indices):
371+
pass
372+
373+
276374
def _progress_ctx(progress: bool, label: str):
277375
"""Return a progress-bar context manager.
278376
@@ -484,19 +582,51 @@ def _write_pyramid(
484582
prev_name = base_name
485583
prev_shape = arr.shape
486584
for i in range(1, n_levels):
487-
next_shape = tuple(s // st for s, st in zip(prev_shape, strides))
585+
next_shape = tuple(-(-s // st) for s, st in zip(prev_shape, strides))
488586
if min(next_shape) < 1:
489587
logger.info("stopping pyramid at level %d (next too small)", i)
490588
break
491-
src = da.from_zarr(group_path, component=prev_name)
492-
nxt = src[tuple(slice(None, None, st) for st in strides)]
493-
nxt = nxt.rechunk(chunks or _default_chunks(nxt.shape, axes))
494-
_to_zarr_level(nxt, group_path, str(i), shard, progress)
589+
if shard or chunks:
590+
# Sharding needs whole shards written atomically, and an explicit
591+
# chunking may not line up with the source chunks -- both want the
592+
# dask path.
593+
src = da.from_zarr(group_path, component=prev_name)
594+
nxt = src[tuple(slice(None, None, st) for st in strides)]
595+
nxt = nxt.rechunk(chunks or _default_chunks(nxt.shape, axes))
596+
_to_zarr_level(nxt, group_path, str(i), shard, progress)
597+
next_shape = nxt.shape
598+
else:
599+
# Stream it: decimation needs no halo, so with chunks chosen as
600+
# ceil(src_chunk / stride) each task reads exactly one source chunk
601+
# and writes exactly one output chunk. Bounded by construction --
602+
# the dask route rechunks *upward* here (e.g. (16,512,512) back to
603+
# (16,1024,1024)), pulling four source chunks per output chunk and
604+
# letting the threaded scheduler stockpile the intermediates.
605+
grp = zarr.open_group(group_path, mode="a")
606+
src_arr = grp[prev_name]
607+
out_chunks = _level_chunks(
608+
tuple(src_arr.chunks), strides, axes, next_shape
609+
)
610+
dst_arr = _create_level_array(
611+
grp, str(i), next_shape, out_chunks, src_arr.dtype
612+
)
613+
if progress:
614+
logger.info(
615+
"writing %s/%s (streaming %d chunks)",
616+
Path(group_path).name,
617+
i,
618+
int(
619+
np.prod(
620+
[-(-s // c) for s, c in zip(next_shape, out_chunks)]
621+
)
622+
),
623+
)
624+
_stream_strided_level(src_arr, dst_arr, strides)
495625
scale = [base_scale[k] * (strides[k] ** i) for k in range(len(axes))]
496626
datasets.append(_dataset(str(i), scale))
497-
logger.info("pyramid level %d: shape=%s", i, nxt.shape)
627+
logger.info("pyramid level %d: shape=%s", i, next_shape)
498628
prev_name = str(i)
499-
prev_shape = nxt.shape
629+
prev_shape = next_shape
500630
return datasets
501631

502632

tests/test_ome_zarr.py

Lines changed: 52 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -14,6 +14,58 @@
1414
)
1515

1616

17+
def test_streaming_pyramid_matches_dask(tmp_path):
18+
"""The streaming levels must be byte-identical to the dask-built ones.
19+
20+
The streaming path exists to bound memory (one source chunk per task); it
21+
must not change a single voxel of the result.
22+
"""
23+
rng = np.random.default_rng(0)
24+
data = rng.integers(0, 500, size=(9, 100, 100), dtype="uint16")
25+
26+
streamed = tmp_path / "streamed.zarr"
27+
to_ome_zarr(data, streamed, axes="zyx", n_levels=4, progress=False)
28+
# chunks= forces the dask route through the same public entry point.
29+
dasked = tmp_path / "dasked.zarr"
30+
to_ome_zarr(
31+
data,
32+
dasked,
33+
axes="zyx",
34+
n_levels=4,
35+
chunks=(16, 1024, 1024),
36+
progress=False,
37+
)
38+
39+
a_root = zarr.open_group(str(streamed), mode="r")
40+
b_root = zarr.open_group(str(dasked), mode="r")
41+
a_levels = [d["path"] for d in a_root.attrs["multiscales"][0]["datasets"]]
42+
b_levels = [d["path"] for d in b_root.attrs["multiscales"][0]["datasets"]]
43+
assert a_levels == b_levels, "both routes must produce the same levels"
44+
for lvl in a_levels:
45+
assert np.array_equal(
46+
np.asarray(a_root[lvl]), np.asarray(b_root[lvl])
47+
), f"level {lvl} differs between the streaming and dask routes"
48+
49+
50+
def test_streaming_level_reads_one_source_chunk_per_task(tmp_path):
51+
"""Each output chunk must map onto exactly one source chunk.
52+
53+
That alignment is what makes peak memory ``n_workers x one chunk`` and so
54+
is the property the OOM fix rests on -- not an incidental detail.
55+
"""
56+
from patchworks.plugins.ome_zarr import _level_chunks
57+
58+
# A (16, 1024, 1024) source at stride (1, 2, 2) -> (16, 512, 512).
59+
out = _level_chunks((16, 1024, 1024), (1, 2, 2), "zyx", (16, 4096, 4096))
60+
assert out == (16, 512, 512)
61+
# Deep levels get floored rather than fragmenting into tiny chunks.
62+
floored = _level_chunks((16, 128, 128), (1, 2, 2), "zyx", (16, 256, 256))
63+
assert floored == (16, 128, 128)
64+
# Never larger than the level itself.
65+
clamped = _level_chunks((16, 1024, 1024), (1, 2, 2), "zyx", (4, 100, 100))
66+
assert clamped == (4, 100, 100)
67+
68+
1769
def _level_scale(store, level):
1870
root = zarr.open_group(str(store), mode="r")
1971
ds = root.attrs["multiscales"][0]["datasets"][level]

0 commit comments

Comments
 (0)