Skip to content

Commit 7088715

Browse files
committed
Replace signal-based timeout with pool-restart watchdog for hung pairs
1 parent c8efe77 commit 7088715

1 file changed

Lines changed: 83 additions & 72 deletions

File tree

tools/compute_mces_exact_1020.py

Lines changed: 83 additions & 72 deletions
Original file line numberDiff line numberDiff line change
@@ -37,8 +37,8 @@
3737

3838
import argparse
3939
import json
40+
import multiprocessing
4041
import os
41-
import signal
4242
from pathlib import Path
4343

4444
import numpy as np
@@ -123,65 +123,80 @@ def prepare(output_dir: Path, n_blocks: int) -> None:
123123

124124

125125
_original_mces_worker = None
126-
_worker_timeout_seconds: float | None = None
127-
128-
129-
class _WorkerTimeout(Exception):
130-
pass
131-
132-
133-
def _alarm_handler(signum, frame):
134-
raise _WorkerTimeout()
135126

136127

137128
def _safe_mces_worker(args):
138-
"""Top-level (picklable) stand-in for ``_mces_worker`` — must live at
139-
module scope so multiprocessing.Pool can pickle it by reference; a
140-
closure defined inside another function cannot be pickled at all.
141-
142-
Guards against two failure modes seen in practice:
143-
- a quick exception (e.g. "unknown ILP status: Not Solved")
144-
- a pair that just hangs — some are pathologically slow and never
145-
raise at all. A per-pair SIGALRM inside this same worker process
146-
bounds that, without spawning a new process per pair (which is
147-
what MCESDistance's own --timeout path does, at a large throughput
148-
cost from its chunk-boundary barriers).
129+
"""Top-level (picklable) stand-in for the library's ``_mces_worker`` —
130+
must live at module scope so multiprocessing.Pool can pickle it by
131+
reference; a closure defined inside another function cannot be pickled
132+
at all.
133+
134+
Catches a pair that fails *fast* (e.g. "unknown ILP status: Not
135+
Solved") and returns -1 instead of raising. Does NOT protect against a
136+
hang — a pair stuck deep in the ILP solver's native code never returns
137+
control to the Python interpreter, so no Python-level exception
138+
(including a signal-handler-raised one) can interrupt it. See
139+
``_dispatch_with_watchdog`` for how hangs are actually handled: by
140+
killing the whole worker process, which works regardless of what
141+
native code it's running.
149142
"""
150-
if _worker_timeout_seconds is not None:
151-
signal.signal(signal.SIGALRM, _alarm_handler)
152-
signal.alarm(int(_worker_timeout_seconds))
153143
try:
154144
return _original_mces_worker(args)
155145
except Exception:
156146
return -1.0
157-
finally:
158-
if _worker_timeout_seconds is not None:
159-
signal.alarm(0)
160-
161147

162-
def _patch_mces_worker_for_safety(timeout_seconds: float | None) -> None:
163-
"""Make a single unsolvable OR hanging pair return -1 instead of killing
164-
the block or the whole run.
165148

166-
``_dispatch`` (used when ``timeout=None`` is passed to ``MCESDistance``)
167-
resolves ``_mces_worker`` as a plain global at call time via
168-
``pool.imap(_mces_worker, worker_args)``. Overwriting the module
169-
attribute here — before any ``MCESDistance`` call — makes every
170-
subsequent dispatch pick up the wrapped version, with no change to the
171-
fast Pool.imap dynamic dispatch (no chunking, no barriers).
172-
"""
173-
import metabo_depthcharge.chem.similarities as sim
149+
def _dispatch_with_watchdog(worker_args, n_jobs, per_pair_timeout, show_progress):
150+
"""Compute one result per entry in ``worker_args``, with a hard per-pair
151+
wall-clock deadline.
174152
175-
global _original_mces_worker, _worker_timeout_seconds
153+
Uses the same fast ``Pool.imap`` dynamic dispatch as MCESDistance's own
154+
no-timeout path for the overwhelming majority of pairs. If a single
155+
result doesn't arrive within ``per_pair_timeout`` seconds (checked via
156+
the iterator's own ``.next(timeout=...)``, not a signal), the whole
157+
pool is ``terminate()``d — a hard kill of every worker process,
158+
including whichever one is stuck in native code — the stalled pair is
159+
recorded as -1, and the remaining pairs resume in a fresh pool.
176160
177-
_worker_timeout_seconds = timeout_seconds
178-
179-
if getattr(sim, "_patched_for_safety", False):
180-
return
181-
182-
_original_mces_worker = sim._mces_worker
183-
sim._mces_worker = _safe_mces_worker
184-
sim._patched_for_safety = True
161+
A signal-based per-pair timeout was tried first and does not work: the
162+
ILP solver spends its time inside a C extension that never yields back
163+
to the Python interpreter, so a SIGALRM just queues until that call
164+
finishes on its own. Terminating the OS process is the only thing that
165+
reliably stops it.
166+
"""
167+
from tqdm.auto import tqdm
168+
169+
n = len(worker_args)
170+
results = [None] * n
171+
todo = list(range(n))
172+
bar = tqdm(total=n, disable=not show_progress, unit="pair")
173+
174+
while todo:
175+
pool = multiprocessing.Pool(n_jobs)
176+
it = pool.imap(_safe_mces_worker, (worker_args[i] for i in todo))
177+
stuck_at = None
178+
try:
179+
for pos, idx in enumerate(todo):
180+
try:
181+
results[idx] = it.next(timeout=per_pair_timeout)
182+
except multiprocessing.TimeoutError:
183+
stuck_at = pos
184+
break
185+
bar.update(1)
186+
finally:
187+
if stuck_at is not None:
188+
pool.terminate()
189+
pool.join()
190+
results[todo[stuck_at]] = -1.0
191+
bar.update(1)
192+
todo = todo[stuck_at + 1 :]
193+
else:
194+
pool.close()
195+
pool.join()
196+
todo = []
197+
198+
bar.close()
199+
return results
185200

186201

187202
def compute_block(
@@ -191,9 +206,11 @@ def compute_block(
191206
timeout: float | None,
192207
) -> None:
193208
"""Compute exact MCES for one block of pairs and write the result."""
194-
from metabo_depthcharge.chem.similarities import MCESDistance
209+
global _original_mces_worker
210+
if _original_mces_worker is None:
211+
from metabo_depthcharge.chem.similarities import _mces_worker
195212

196-
_patch_mces_worker_for_safety(timeout)
213+
_original_mces_worker = _mces_worker
197214

198215
meta = json.loads((output_dir / "meta.json").read_text())
199216
n_blocks = meta["n_blocks"]
@@ -216,38 +233,32 @@ def compute_block(
216233

217234
smiles = Path(meta["smiles_path"]).read_text().splitlines()
218235
pairs = np.load(meta["pairs_path"], mmap_mode="r")[i0:i1]
219-
smiles_a = np.array([smiles[i] for i in pairs[:, 0]])
220-
smiles_b = np.array([smiles[j] for j in pairs[:, 1]])
236+
smiles_a = [smiles[i] for i in pairs[:, 0]]
237+
smiles_b = [smiles[j] for j in pairs[:, 1]]
221238

222239
if n_jobs <= 0:
223240
n_jobs = os.cpu_count() or 1
241+
per_pair_timeout = timeout if timeout else 300.0
224242
print(
225-
f" MCESDistance(threshold={THRESHOLD}, always_stronger_bound=True,"
226-
f" n_jobs={n_jobs}, per-pair timeout={timeout}s via SIGALRM)"
243+
f" MCES(threshold={THRESHOLD}, always_stronger_bound=True,"
244+
f" n_jobs={n_jobs}, per-pair timeout={per_pair_timeout}s, pool-restart watchdog)"
227245
)
228246

229-
# timeout=None here always — MCESDistance's own --timeout path spawns a
230-
# fresh Process per pair in chunks of n_jobs with a barrier between
231-
# chunks, which we measured at ~6-7x slower than Pool.imap. The per-pair
232-
# deadline is instead enforced inside _safe_mces_worker via SIGALRM,
233-
# preserving Pool.imap's dynamic work-stealing dispatch.
234-
mces = MCESDistance(
235-
threshold=THRESHOLD,
236-
always_stronger_bound=True,
237-
n_jobs=n_jobs,
238-
solver_options={"msg": 0},
239-
timeout=None,
240-
progress=True,
247+
worker_args = [
248+
(a, b, THRESHOLD, True, {"msg": 0}, "HiGHS") for a, b in zip(smiles_a, smiles_b)
249+
]
250+
result = np.asarray(
251+
_dispatch_with_watchdog(worker_args, n_jobs, per_pair_timeout, show_progress=True),
252+
dtype=np.float32,
241253
)
242-
result = np.asarray(mces(smiles_a, smiles_b), dtype=np.float32)
243254

244-
# A pair fails to solve (e.g. "unknown ILP status: Not Solved") or hangs
245-
# past the per-pair timeout; _safe_mces_worker converts either into -1
246-
# directly so one bad pair never loses an entire block. np.isnan check
247-
# kept as a defensive fallback in case any NaN ever surfaces regardless.
255+
# A pair either fails to solve (e.g. "unknown ILP status: Not Solved")
256+
# or hangs past the per-pair timeout; both come back as -1 so one bad
257+
# pair never loses an entire block. np.isnan check kept as a defensive
258+
# fallback in case any NaN ever surfaces regardless.
248259
n_failed = int((result == -1.0).sum() + np.isnan(result).sum())
249260
if n_failed:
250-
print(f" {n_failed:,} / {n_pair:,} pairs failed to solve — recorded as -1.")
261+
print(f" {n_failed:,} / {n_pair:,} pairs failed to solve or timed out — recorded as -1.")
251262
result = np.where(np.isnan(result), -1.0, result).astype(np.float32)
252263

253264
np.save(out_npy, result)

0 commit comments

Comments
 (0)