3737
3838import argparse
3939import json
40+ import multiprocessing
4041import os
41- import signal
4242from pathlib import Path
4343
4444import 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
137128def _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
187202def 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