Skip to content

Commit 8a1a3b2

Browse files
committed
fix(cellpose): 🐛 retry with backoff on CUDA OOM instead of failing tile
Shared cluster GPUs can gain a co-tenant mid-run and push an otherwise-sized tile over the memory limit hours into a job. Falling back to CPU was rejected (too slow for multi-hour 3-D tiles), so instead clear the CUDA allocator cache and retry on GPU with increasing backoff, giving contention a chance to clear before giving up.
1 parent 2f53940 commit 8a1a3b2

1 file changed

Lines changed: 81 additions & 2 deletions

File tree

src/patchworks/plugins/cellpose.py

Lines changed: 81 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -196,6 +196,82 @@ def _get_model(cellpose_dict: dict[str, Any]) -> Any:
196196
return _model_cache[key]
197197

198198

199+
def _is_cuda_oom(exc: Exception) -> bool:
200+
"""Return whether *exc* looks like a CUDA out-of-memory error.
201+
202+
Parameters
203+
----------
204+
exc : Exception
205+
Exception raised by ``model.eval``.
206+
207+
Returns
208+
-------
209+
bool
210+
True if the exception is a CUDA OOM.
211+
"""
212+
return "out of memory" in str(exc).lower()
213+
214+
215+
_OOM_RETRIES = 4
216+
_OOM_BACKOFF_SECONDS = 30
217+
218+
219+
def _eval_with_oom_fallback(
220+
model: Any, img: np.ndarray, kwargs: dict[str, Any], cellpose_dict: dict[str, Any]
221+
) -> np.ndarray:
222+
"""Run ``model.eval``, surviving transient GPU contention.
223+
224+
Cluster GPUs are often shared: another job's memory footprint can grow
225+
mid-run and push an otherwise-fine tile size over the edge. Staying on
226+
GPU matters — this tile can take well over an hour, so falling back to
227+
CPU would be far worse than waiting. Instead this clears the allocator
228+
cache (fixes fragmentation) and retries with a backoff, giving the
229+
cotenant job time to free memory, before finally giving up.
230+
231+
Parameters
232+
----------
233+
model : Any
234+
Cellpose model instance.
235+
img : np.ndarray
236+
Image to segment.
237+
kwargs : dict
238+
Keyword arguments for ``model.eval``.
239+
cellpose_dict : dict
240+
Configuration from :func:`_make_config`.
241+
242+
Returns
243+
-------
244+
np.ndarray
245+
Label array from ``model.eval``.
246+
"""
247+
import time
248+
249+
for attempt in range(_OOM_RETRIES + 1):
250+
try:
251+
return model.eval(img, **kwargs)[0]
252+
except RuntimeError as exc:
253+
if not cellpose_dict.get("gpu", False) or not _is_cuda_oom(exc):
254+
raise
255+
import torch
256+
257+
torch.cuda.empty_cache()
258+
if attempt == _OOM_RETRIES:
259+
logger.error(
260+
"CUDA OOM persisted after %d retries; giving up on tile.",
261+
_OOM_RETRIES,
262+
)
263+
raise
264+
wait = _OOM_BACKOFF_SECONDS * (attempt + 1)
265+
logger.warning(
266+
"CUDA OOM on tile (likely GPU contention); cleared cache, "
267+
"retrying in %ds (attempt %d/%d).",
268+
wait,
269+
attempt + 1,
270+
_OOM_RETRIES,
271+
)
272+
time.sleep(wait)
273+
274+
199275
def _run(block: np.ndarray, cellpose_dict: dict[str, Any]) -> np.ndarray:
200276
"""Segment one tile with a cached Cellpose model.
201277
@@ -231,12 +307,15 @@ def _run(block: np.ndarray, cellpose_dict: dict[str, Any]) -> np.ndarray:
231307

232308
if do_3D:
233309
kwargs["z_axis"] = 0
234-
return model.eval(block, **kwargs)[0].astype("int32")
310+
masks = _eval_with_oom_fallback(model, block, kwargs, cellpose_dict)
311+
return masks.astype("int32")
235312
else:
236313
# Squeeze singleton z so Cellpose gets a clean 2-D image
237314
squeeze = block.ndim == 3 and block.shape[0] == 1
238315
img = block[0] if squeeze else block
239-
masks = model.eval(img, **kwargs)[0].astype("int32")
316+
masks = _eval_with_oom_fallback(model, img, kwargs, cellpose_dict).astype(
317+
"int32"
318+
)
240319
return masks[np.newaxis] if squeeze else masks
241320

242321

0 commit comments

Comments
 (0)