Skip to content

Commit bbf7da2

Browse files
lguerardclaude
andcommitted
perf(workflow): ⚡ segment a batch of tiles per job to amortize model load
Every segment job paid CUDA init plus a Cellpose weight load -- tens of seconds -- before touching its first voxel, and at ~1250 one-tile jobs that startup dominated the cheap tiles. The plugin already caches the model per process (_model_cache), but a one-tile job never got a second tile to serve it. prepare now groups the occupied tiles into batches of `tiles_per_job` and the segment rule scatters over batches instead of tiles, building the segmentation function once and looping. Tiles in a batch run sequentially, so they share the cached model and never contend for the GPU; batches still write disjoint chunks and run in parallel across GPUs. Snakemake's `group:` directive was the obvious route but is wrong here: GroupResources.basic_layered sums integer resources across jobs in one toposort layer, and all segment tiles sit in a single layer, so a group would have requested N x mem_mb / N x cpus_per_task and run N Cellpose evaluations concurrently on the one GPU it was granted. Defaults to 1, which reproduces the previous one-job-per-tile behaviour. The markers move from seg/<tile>.done to seg/<batch>.done and now carry their tile list as JSON. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
1 parent 850aca6 commit bbf7da2

9 files changed

Lines changed: 81 additions & 25 deletions

File tree

workflow/config/config.yaml

Lines changed: 7 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -36,6 +36,13 @@ overlap: 30 # halo (≈ one object diameter); scalar, or per-axis [z, y, x]
3636
# all of it wasted z. patchworks.auto_overlap(d, voxel_size=...)
3737
# computes the per-axis values for you.
3838
skip_empty: true # skip background tiles
39+
# Tiles per SLURM job. Each job pays CUDA init + a Cellpose weight load (tens
40+
# of seconds) once, then reuses the cached model for the rest of its batch, so
41+
# at 1 that startup can dominate a fast tile. Tiles in a batch run
42+
# SEQUENTIALLY, so the job's wall time is roughly N x per-tile time: size N
43+
# from a measured tile (`seff <jobid>`) and keep it inside the profile's
44+
# runtime/QOS ceiling. A failed job retries the whole batch.
45+
tiles_per_job: 1
3946
empty_threshold: null # null → Otsu; or a number
4047

4148
# ---- segmentation -----------------------------------------------------------

workflow/config/config_cilia.yaml

Lines changed: 3 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -28,6 +28,9 @@ gpu_memory_gb: null
2828
# than pushing the halo up against the tile depth.
2929
overlap: [8, 30, 30]
3030
skip_empty: true
31+
# Tiles per SLURM job; they run sequentially and share one decon/GPU context.
32+
# Raise once you know a tile's runtime -- job wall time is ~N x per-tile time.
33+
tiles_per_job: 1
3134
empty_threshold: null
3235

3336
method: "custom"

workflow/config/config_cyto.yaml

Lines changed: 3 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -20,6 +20,9 @@ gpu_memory_gb: null
2020
# z-halo only needs to cover one cell in z, not one cell in x/y.
2121
overlap: [4, 30, 30]
2222
skip_empty: true
23+
# Tiles per SLURM job; they run sequentially and share one model load. Raise
24+
# once you know a tile's runtime -- job wall time is ~N x per-tile time.
25+
tiles_per_job: 1
2326
empty_threshold: null
2427

2528
method: "cellpose"

workflow/config/config_nuclei.yaml

Lines changed: 3 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -20,6 +20,9 @@ gpu_memory_gb: null
2020
# z-halo only needs to cover one nucleus in z, not one nucleus in x/y.
2121
overlap: [4, 30, 30]
2222
skip_empty: true
23+
# Tiles per SLURM job; they run sequentially and share one model load. Raise
24+
# once you know a tile's runtime -- job wall time is ~N x per-tile time.
25+
tiles_per_job: 1
2326
empty_threshold: null
2427

2528
method: "cellpose"

workflow/rules/common.smk

Lines changed: 9 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -36,8 +36,13 @@ STEPLOG = f"{LOGS}/steps.log"
3636
MODEL_OK = f"{RUN}/model.ready"
3737

3838

39-
def occupied_done(wildcards):
40-
"""Per-tile markers for the occupied tiles (resolved after the checkpoint)."""
39+
def batch_done(wildcards):
40+
"""Per-batch markers for the segment jobs (resolved after the checkpoint).
41+
42+
prepare groups the occupied tiles into batches of ``tiles_per_job``; one
43+
marker is produced per batch, not per tile, so the fan-in shrinks with the
44+
batch size.
45+
"""
4146
tiles = checkpoints.prepare.get().output.tiles
42-
occupied = json.loads(Path(tiles).read_text())["occupied"]
43-
return [f"{RUN}/seg/{i}.done" for i in occupied]
47+
manifest = json.loads(Path(tiles).read_text())
48+
return [f"{RUN}/seg/{i}.done" for i in range(len(manifest["batches"]))]

workflow/rules/merge.smk

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -2,7 +2,7 @@
22

33
rule merge:
44
input:
5-
occupied_done,
5+
batch_done,
66
output:
77
touch(f"{RUN}/labels.done"),
88
log:

workflow/rules/segment.smk

Lines changed: 9 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -28,15 +28,21 @@ checkpoint prepare:
2828

2929

3030
rule segment:
31-
"""Segment one tile on a GPU and write it into the stage store."""
31+
"""Segment one batch of tiles on a GPU and write them into the stage store.
32+
33+
A batch is `tiles_per_job` tiles (see config), processed sequentially in
34+
one process so they share a single CUDA init and a single Cellpose model
35+
load. Batches write disjoint chunks, so any number of them run in
36+
parallel across GPUs.
37+
"""
3238
input:
3339
tiles=TILES,
3440
stage=STAGE_OK,
3541
image=IMAGE_OK,
3642
model=MODEL_OK,
3743
output:
38-
f"{RUN}/seg/{{index}}.done",
44+
f"{RUN}/seg/{{batch}}.done",
3945
log:
40-
f"{LOGS}/segment/{{index}}.log",
46+
f"{LOGS}/segment/{{batch}}.log",
4147
script:
4248
"../scripts/segment_tile.py"

workflow/scripts/prepare_tiles.py

Lines changed: 16 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -102,6 +102,16 @@
102102
occ = info["occupancy"].ravel() # row-major, matches spatial_tiles
103103
occupied = [i for i in range(len(tiles)) if occ[i]]
104104

105+
# One SLURM job per tile means every tile pays CUDA init + a model load before
106+
# its first voxel. Batching amortizes that over `tiles_per_job` tiles, which
107+
# run sequentially in one process so they share the cached model and never
108+
# contend for the GPU. 1 = the old one-job-per-tile behaviour.
109+
tiles_per_job = max(1, int(cfg.get("tiles_per_job", 1)))
110+
batches = [
111+
occupied[i : i + tiles_per_job]
112+
for i in range(0, len(occupied), tiles_per_job)
113+
]
114+
105115
create_stage(stage_path(work_dir, label_name), image.shape, tile_shape)
106116

107117
Path(work_dir, label_name, "tiles.json").write_text(
@@ -111,8 +121,13 @@
111121
"overlap": list(overlap),
112122
"n_tiles": len(tiles),
113123
"occupied": occupied,
124+
"tiles_per_job": tiles_per_job,
125+
"batches": batches,
114126
},
115127
indent=2,
116128
)
117129
)
118-
print(f"[patchworks] {len(occupied)}/{len(tiles)} tiles to segment")
130+
print(
131+
f"[patchworks] {len(occupied)}/{len(tiles)} tiles to segment "
132+
f"in {len(batches)} job(s) of up to {tiles_per_job}"
133+
)

workflow/scripts/segment_tile.py

Lines changed: 30 additions & 16 deletions
Original file line numberDiff line numberDiff line change
@@ -1,31 +1,45 @@
1-
"""Snakemake script: segment ONE tile and write it to the shared stage.
1+
"""Snakemake script: segment one BATCH of tiles into the shared stage.
22
3-
Scattered over tile indices, so each tile is its own SLURM job and many GPUs
4-
run in parallel. Each job writes a disjoint chunk of the stage store.
3+
Scattered over batches, so several batches run as separate SLURM jobs and many
4+
GPUs work in parallel. Within a batch the tiles run sequentially in this one
5+
process: CUDA is initialised once and the Cellpose model is loaded once (see
6+
``patchworks.plugins.cellpose._model_cache``), instead of once per tile. Each
7+
batch writes disjoint chunks of the stage store, so batches never collide.
58
"""
69

10+
import json
11+
712
from patchworks import stage_tile
813

914
from _pw import build_fn, load_tiles_json, open_image, stage_path, start_log
1015

1116
start_log(snakemake.log[0]) # noqa: F821
1217
cfg = snakemake.config # noqa: F821
13-
index = int(snakemake.wildcards.index) # noqa: F821
18+
batch = int(snakemake.wildcards.batch) # noqa: F821
1419
work_dir = cfg["work_dir"]
1520
label_name = cfg.get("label_name", "labels")
1621

1722
manifest = load_tiles_json(snakemake.input.tiles) # noqa: F821
1823
image = open_image(work_dir, cfg["channel"], cfg["level"])
24+
indices = manifest["batches"][batch]
25+
26+
# Built once for the whole batch: this is what makes the model load amortize.
27+
fn = build_fn(cfg)
28+
stage = stage_path(work_dir, label_name)
29+
tile_shape = tuple(manifest["tile_shape"])
30+
31+
for index in indices:
32+
stage_tile(
33+
image,
34+
fn,
35+
stage,
36+
index,
37+
tile_shape=tile_shape,
38+
# Scalar (older manifests) or per-axis list; stage_tile normalizes both.
39+
overlap=manifest["overlap"],
40+
)
41+
print(f"[patchworks] segmented tile {index}")
1942

20-
stage_tile(
21-
image,
22-
build_fn(cfg),
23-
stage_path(work_dir, label_name),
24-
index,
25-
tile_shape=tuple(manifest["tile_shape"]),
26-
# Scalar (older manifests) or per-axis list; stage_tile normalizes both.
27-
overlap=manifest["overlap"],
28-
)
29-
30-
open(snakemake.output[0], "w").close() # noqa: F821
31-
print(f"[patchworks] segmented tile {index}")
43+
with open(snakemake.output[0], "w") as fh: # noqa: F821
44+
json.dump({"batch": batch, "tiles": indices}, fh)
45+
print(f"[patchworks] batch {batch}: {len(indices)} tile(s) done")

0 commit comments

Comments
 (0)