Skip to content

Commit 0285ec4

Browse files
lguerardclaude
andcommitted
docs: πŸ“ document the pipeline changes and ship the optimized defaults
Brings the documentation in line with the eight preceding commits, and turns the optimizations on by default rather than leaving them opt-in. Docs: - skip_empty: separates the fast-but-approximate estimate_empty_tiles preview from the exact build_occupancy_map/tile_occupancy pair, and says plainly that the centred window covers 6.25% of a [16, 1024, 1024] tile. - tiling: per-axis overlap with the amplification arithmetic, and auto_overlap(voxel_size=...) for deriving it from the physical anisotropy. - gpu_distributed: corrects the "at most half the available VRAM" claim (only auto_tile_shape does that; the Cellpose estimator uses its own model), documents the headroom, the CUDA_VISIBLE_DEVICES-aware device pick, and the shared-GPU OOM retry. - merging: the algorithm is now five steps, with the offsets-from-counts step and the sequential renumber folded into the LUT. - performance: "machine-aware" was the bug β€” it is allocation-aware now. - snakemake: tiles_per_job, concurrent multi-runs, retries/attempt-scaled memory, and the merge writing level 0 in place. - api: pages for the newly public helpers. Defaults: tiles_per_job: 4 in every shipped config, so the model load is amortized out of the box; 1 restores one job per tile. Verified: pytest, ruff, markdownlint and `mkdocs build --strict` all clean, plus an end-to-end concurrent multi-config run. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
1 parent 1e747fc commit 0285ec4

14 files changed

Lines changed: 271 additions & 60 deletions

β€ŽREADME.mdβ€Ž

Lines changed: 4 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -286,9 +286,12 @@ tiles where the dask-image approach stalls.
286286
| ------------------------------ | ----------------------------------------- | ------------------------------------------------------------- |
287287
| In-process Dask client | `FutureCancelledError: lost dependencies` | Detected at startup, raises immediately with fix instructions |
288288
| 3-4Γ— fn recompute during merge | Cellpose runs 3Γ— per tile | Staging writes labels once, merge reads from disk |
289-
| O(nΒ²) sequential relabelling | Graph construction hangs at 1000+ tiles | Linear post-pass O(voxels) via `np.unique` + LUT |
289+
| O(nΒ²) sequential relabelling | Graph construction hangs at 1000+ tiles | Folded into the merge's own LUT β€” no extra pass over the volume |
290290
| Wrong overlap boundary | Output shape mismatch | Always uses `boundary="none"` |
291291
| Persisting large arrays | Worker OOM | Never persists; keeps dask graph lazy and streams |
292+
| Sizing work to the whole node | Job OOM-killed on a shared cluster node | Reads the SLURM/cgroup allocation, not `os.cpu_count()` |
293+
| Rechunking a pyramid level | Threaded scheduler stockpiles intermediates | Levels stream one source chunk per task, bounded by construction |
294+
| Isotropic halo on flat tiles | 5Γ— the voxels read and segmented, then trimmed | `overlap` takes one width per axis |
292295

293296
---
294297

β€Ždocs/api/chunks.mdβ€Ž

Lines changed: 14 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -3,3 +3,17 @@
33
::: patchworks.auto_tile_shape
44

55
::: patchworks.auto_tile_shape_cellpose
6+
7+
::: patchworks.auto_overlap
8+
9+
::: patchworks.normalize_overlap
10+
11+
## Cluster resource detection
12+
13+
On a shared node the machine's core count and free RAM say nothing about what
14+
this job was granted. These read the allocation instead, and everything that
15+
sizes a worker pool goes through them.
16+
17+
::: patchworks.cpu_allocation
18+
19+
::: patchworks.safe_worker_count

β€Ždocs/api/io.mdβ€Ž

Lines changed: 14 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -2,4 +2,18 @@
22

33
::: patchworks.load_ome_zarr
44

5+
## Deciding which tiles hold signal
6+
7+
`estimate_empty_tiles` is a fast **preview** β€” it samples a centred window per
8+
tile, so it can miss signal at a tile's edge. `build_occupancy_map` +
9+
`tile_occupancy` are **exact**: a brick maximum exceeds the threshold exactly
10+
when some voxel in that brick does. Use the latter pair when the result is
11+
used as a skip list. See [Skipping empty tiles](../guide/skip_empty.md).
12+
513
::: patchworks.estimate_empty_tiles
14+
15+
::: patchworks.build_occupancy_map
16+
17+
::: patchworks.tile_occupancy
18+
19+
::: patchworks.auto_empty_threshold

β€Ždocs/guide/gpu_distributed.mdβ€Ž

Lines changed: 25 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -73,12 +73,35 @@ FutureCancelledError: lost dependencies
7373
## GPU memory sizing
7474

7575
When `use_gpu=True`, patchworks queries free GPU VRAM via `nvidia-ml-py`
76-
(install: `pip install "patchworks[gpu]"`). Tile size is set so each tile
77-
uses at most half the available VRAM.
76+
(install: `pip install "patchworks[gpu]"`) and keeps 20% of it as headroom,
77+
because `info.free` is a point-in-time reading of a device you usually do not
78+
own outright. `auto_tile_shape` then sizes each tile to at most half of that
79+
budget; `auto_tile_shape_cellpose` uses Cellpose's own memory model instead
80+
(roughly 20Γ— the raw tile bytes, plus ~2 GiB for the model).
81+
82+
The device is resolved from `CUDA_VISIBLE_DEVICES`. This matters on
83+
multi-GPU nodes: NVML enumerates **every** GPU regardless of `--gres=gpu:1`,
84+
so querying index 0 unconditionally would read a different card's free memory
85+
than the one your job was granted.
7886

7987
Without `nvidia-ml-py`, a conservative 8 GiB default is used with a warning.
8088
Install it for accurate sizing on large-VRAM cards (A100, H100):
8189

8290
```bash
8391
pip install "patchworks[gpu]"
8492
```
93+
94+
!!! tip "Sizing for a GPU you can't see"
95+
The Snakemake workflow plans tiles in `prepare`, which runs on a CPU
96+
node, so no GPU can be queried there. Set `gpu_memory_gb` in the config
97+
to the segment GPU's VRAM (e.g. `24` for an RTX 4090) instead of relying
98+
on the fallback.
99+
100+
## Surviving a shared GPU
101+
102+
An out-of-memory error on a shared device is often transient β€” a co-tenant
103+
job grew, not a tile that doesn't fit. patchworks retries on the GPU with a
104+
backoff rather than falling back to the CPU, where a single tile can take
105+
over an hour. Before each backoff it releases its own device memory
106+
(including any cached Cellpose model), since holding that is exactly what
107+
starves the other job. Both torch and cupy OOM errors are recognised.

β€Ždocs/guide/merging.mdβ€Ž

Lines changed: 29 additions & 8 deletions
Original file line numberDiff line numberDiff line change
@@ -38,22 +38,40 @@ tile_process calls fn once per tile β†’ staged zarr
3838
merge reads from staged zarr (no fn calls)
3939
```
4040

41-
### Step 2: boundary scan
41+
### Step 2: make the ids globally unique
42+
43+
Tiles write local `1..n`, which collide, so the boundary scan could not
44+
otherwise tell two different objects apart. If each tile's label **count** is
45+
known, this is just an exclusive cumulative sum β€” global id is
46+
`offset[tile] + local`, computed in `O(n_tiles)` with no read of the volume
47+
at all. `stage_tile` returns that count for exactly this purpose; pass the
48+
counts as `label_counts=`.
49+
50+
Without counts, the merge falls back to streaming every chunk and renumbering
51+
it in place β€” correct, but a full read **and write** of the volume.
52+
53+
### Step 3: boundary scan
4254

4355
Only the two voxels on either side of each tile boundary are read. For any
44-
pair of touching non-zero labels `(a, b)`, they must be the same object.
56+
pair of touching non-zero labels `(a, b)`, they must be the same object. The
57+
per-tile offsets are applied here, on the fly.
4558

4659
I/O cost: `O(n_boundaries Γ— face_area)`, not `O(full_volume)`.
4760

48-
### Step 3: connected components
61+
### Step 4: connected components
4962

5063
scipy sparse connected components on the touching pairs produces a relabeling
5164
lookup table. All labels that transitively touch each other are mapped to the
5265
same canonical label.
5366

5467
Cost: `O(n_touching_pairs)`.
5568

56-
### Step 4: parallel relabel
69+
With `sequential_labels=True` the contiguous renumbering is folded into this
70+
same LUT. Because the id domain is dense by construction, the surviving ids
71+
are exactly the distinct LUT values β€” a `np.unique` over an array the length
72+
of the object count, with no scan of the volume.
73+
74+
### Step 5: parallel relabel
5775

5876
The LUT is applied to every tile in parallel via `multiprocessing.Pool`. The
5977
LUT is shared via process initializer to avoid re-pickling it for every chunk
@@ -86,17 +104,20 @@ merged = merge_tile_labels(
86104

87105
## Sequential label numbering
88106

89-
By default, merged labels are globally unique but may be **gappy**
90-
(block-encoded IDs like 1, 2, 500001, 500002, …). This is fine for
91-
counting, `regionprops`, and measurement β€” the IDs just aren't consecutive.
107+
By default, merged labels are globally unique but may be **gappy** β€” boundary
108+
merging fuses ids, leaving holes where the absorbed ones were. This is fine
109+
for counting, `regionprops`, and measurement β€” the IDs just aren't
110+
consecutive.
92111

93112
For contiguous 1..N numbering, use `sequential_labels=True`:
94113

95114
```python
96115
tile_process("image.zarr", fn, write_to="labels.zarr", sequential_labels=True)
97116
```
98117

99-
This runs a cheap linear post-pass: `np.unique` + lookup-table remap, O(voxels).
118+
This is free: it composes into the relabel LUT the merge already applies, so
119+
it costs a `np.unique` over the object count rather than another pass over
120+
the volume.
100121

101122
!!! warning "Do not use dask's built-in sequential relabel"
102123
`dask_image.ndmeasure.merge_labels_across_chunk_boundaries` has a

β€Ždocs/guide/performance.mdβ€Ž

Lines changed: 12 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -3,19 +3,27 @@
33
`tile_process` is built so a run **adapts to whatever machine it lands on** and
44
can't run out of RAM/VRAM or freeze the box β€” without you tuning anything.
55

6-
## Automatic, machine-aware concurrency
6+
## Automatic, allocation-aware concurrency
77

88
The staging step (running your `fn` once per tile to a temp store) and the
9-
merge step are sized to the host automatically:
9+
merge step are sized automatically:
1010

1111
- **GPU** (`use_gpu=True`) β†’ **one tile at a time**, so concurrent evaluations
1212
can never exhaust VRAM.
1313
- **CPU** β†’ as many tiles in flight as fit **80 % of available RAM** (estimated
1414
from the tile size), and always **leaving one core free** so the machine
1515
stays responsive β€” it never pins every core.
1616

17-
The RAM figure is read live via `psutil`; without it, a conservative default is
18-
used instead of guessing high.
17+
"Available" means available **to this process**, not to the machine. On a
18+
shared cluster node those differ wildly β€” a 32-core, 128 GB job on a 128-core,
19+
512 GB node would otherwise size itself for the whole box and get OOM-killed
20+
while its own accounting said it had room. patchworks takes the smallest of
21+
`SLURM_MEM_PER_NODE`, `SLURM_MEM_PER_CPU Γ— cpus`, the cgroup limit (the one
22+
that actually triggers the kill) and `psutil`'s free RAM, and reads the core
23+
count from `SLURM_CPUS_PER_TASK` or the process' CPU affinity mask.
24+
25+
Without any of those signals, a conservative default is used instead of
26+
guessing high.
1927

2028
## Live progress dashboard (GPU runs)
2129

β€Ždocs/guide/skip_empty.mdβ€Ž

Lines changed: 34 additions & 6 deletions
Original file line numberDiff line numberDiff line change
@@ -37,7 +37,9 @@ tile_process(
3737
)
3838
```
3939

40-
## How `estimate_empty_tiles` works
40+
## Two ways to decide
41+
42+
### `estimate_empty_tiles` β€” a fast preview
4143

4244
For each tile in the grid, only a small centred **sample window** is read
4345
(default: 24Γ—256Γ—256 voxels). If the maximum value in that window exceeds
@@ -47,11 +49,37 @@ This is **bounded I/O**: the total data read is `n_tiles Γ— sample_window`,
4749
not the full image. For a 2200-tile image with the default window, this reads
4850
β‰ˆ 30 MB instead of 250 GB β€” and it runs in seconds.
4951

50-
!!! warning "Approximate"
51-
`estimate_empty_tiles` inspects only the tile centre. Signal confined to
52-
a tile's edge can be missed. The actual `tile_process` run always inspects
53-
the **full tile** max inline β€” so no objects are ever dropped in the real
54-
run, only in the preview.
52+
!!! warning "Approximate β€” a preview, not a skip list"
53+
Only the tile centre is inspected. On a `(16, 1024, 1024)` tile the
54+
default window covers **6.25% of the tile's area**, so an object in the
55+
outer ring is invisible. `tile_process` re-tests the **full tile** max
56+
inline, so nothing is dropped in a real run β€” but anything that uses this
57+
result *as* the skip list would drop those tiles for good.
58+
59+
### `build_occupancy_map` + `tile_occupancy` β€” exact
60+
61+
Reduces every `block`-sized brick of the image to its **maximum** and stores
62+
that beside `image.zarr` (about 1/16384 of the image at the default 128 px
63+
block). `tile_occupancy` then reduces the map over each tile's full footprint.
64+
65+
```python
66+
from patchworks import auto_empty_threshold, build_occupancy_map, tile_occupancy
67+
68+
build_occupancy_map("image.zarr") # once per image, all channels
69+
info = tile_occupancy(
70+
"image.zarr", TILE, channel=0, threshold=auto_empty_threshold(img, 0, 0)
71+
)
72+
```
73+
74+
This is **exact, not approximate**: `block_max > threshold` is true exactly
75+
when some voxel in that block exceeds the threshold, so comparing pooled
76+
maxima against a threshold derived from raw voxels answers the same question
77+
as scanning every voxel. Max-pooling cannot lose a bright voxel.
78+
79+
The map is built once and shared by every segmentation reading that image, so
80+
a three-config run pays for one pooling pass instead of three sampling passes.
81+
This is what the Snakemake workflow uses, and it builds the map on first use
82+
for stores converted before the map existed.
5583

5684
## Threshold selection
5785

0 commit comments

Comments
Β (0)