11from __future__ import annotations
22
33import json
4- import time
54from collections .abc import Callable , Iterable , Iterator
65from dataclasses import asdict , dataclass , field
76from pathlib import Path
@@ -212,7 +211,7 @@ def _run_serial(
212211 record (_failure_result (inst , params , "error" , f"{ type (e ).__name__ } : { e } " ))
213212
214213
215- def _run_parallel ( # noqa: C901 — pool-shape branching + multi-failure-mode drain do not factor cleanly
214+ def _run_parallel (
216215 pending : list [BenchmarkInstance ],
217216 eval_fn : EvalFn ,
218217 params : RunParams ,
@@ -221,94 +220,83 @@ def _run_parallel( # noqa: C901 — pool-shape branching + multi-failure-mode d
221220 record : Callable [[EvalResult ], None ],
222221 pool : object | None = None ,
223222) -> None :
224- import multiprocessing as mp
225- from concurrent .futures import (
226- ProcessPoolExecutor ,
227- as_completed ,
228- )
229- from concurrent .futures import (
230- TimeoutError as FuturesTimeoutError ,
231- )
223+ """Pebble-based parallel drain.
224+
225+ Why pebble instead of `concurrent.futures.ProcessPoolExecutor`:
226+ our kill switch (in `benchmarks/diffctx_eval_fn.py`) uses
227+ `os._exit(137)` to bound the diffctx call. `ProcessPoolExecutor`
228+ permanently brick's its pool when a worker dies via os._exit
229+ (documented Python behavior — `BrokenProcessPool` is terminal).
230+ pebble's `ProcessPool` instead respawns the dead worker
231+ transparently, so a single timeout no longer cascades into
232+ pool-wide failure. The `pool` arg (a foreign pool from a long-
233+ running calibrator) is ignored by this code path; it is kept in
234+ the signature for API stability with `run_eval_set`.
235+ """
236+ from concurrent .futures import TimeoutError as FuturesTimeoutError
232237
233- def _drain (active_pool : ProcessPoolExecutor ) -> None : # noqa: C901
234- from concurrent .futures import CancelledError
235- from concurrent .futures .process import BrokenProcessPool
238+ from pebble import ProcessExpired , ProcessPool
236239
237- futures : dict = {}
238- submit_times : dict [str , float ] = {}
239- submit_failed : list [BenchmarkInstance ] = []
240- for inst in pending :
241- for attempt in range (3 ):
242- try :
243- submit_times [inst .instance_id ] = time .monotonic ()
244- futures [active_pool .submit (eval_fn , inst , params )] = inst
245- break
246- except BrokenProcessPool :
247- # A previous task's kill switch left the worker
248- # mid-respawn; ProcessPoolExecutor spawns a
249- # replacement on the next submit cycle. Brief sleep +
250- # retry beats forcing a full pool rebuild.
251- if attempt == 2 :
252- submit_failed .append (inst )
253- break
254- time .sleep (0.1 )
255- outer_deadline = time .monotonic () + timeout_per_instance * max (1 , (len (pending ) + workers - 1 ) // workers )
256- completed : set [str ] = set ()
257- try :
258- for future in as_completed (futures , timeout = max (0.0 , outer_deadline - time .monotonic ())):
259- inst = futures [future ]
260- try :
261- r = future .result (timeout = 0 )
262- except (FuturesTimeoutError , CancelledError ):
263- r = _failure_result (inst , params , "timeout" , f"after { timeout_per_instance } s" )
264- except BrokenProcessPool :
265- # The kill switch arms `os._exit(137)` at the deadline,
266- # which surfaces here as BrokenProcessPool. Distinguish
267- # timeout-induced death from a transient pool failure
268- # by elapsed wall-clock — within 90% of the deadline
269- # the likely cause is our timer; persist as "timeout"
270- # so the checkpoint records it. Otherwise mark as a
271- # transient error (NOT persisted) so the resume loop
272- # gets another shot.
273- elapsed = time .monotonic () - submit_times .get (inst .instance_id , 0.0 )
274- if elapsed >= timeout_per_instance * 0.9 :
275- r = _failure_result (
276- inst ,
277- params ,
278- "timeout" ,
279- f"killed after { timeout_per_instance } s (elapsed { elapsed :.1f} s)" ,
280- )
281- else :
282- r = _failure_result (inst , params , "error" , "BrokenProcessPool: worker died" )
283- except Exception as e :
284- r = _failure_result (inst , params , "error" , f"{ type (e ).__name__ } : { e } " )
285- completed .add (inst .instance_id )
286- record (r )
287- except FuturesTimeoutError :
288- for inst in futures .values ():
289- if inst .instance_id not in completed :
290- record (_failure_result (inst , params , "timeout" , "exceeded global deadline" ))
291- except BrokenProcessPool :
292- # Self-healing pool — replacement workers spawn on next submit;
293- # per-future BPP handler above already recorded dead instances.
294- for inst in futures .values ():
295- if inst .instance_id not in completed :
296- record (_failure_result (inst , params , "error" , "BrokenProcessPool: worker died" ))
297- for inst in submit_failed :
298- record (_failure_result (inst , params , "error" , "BrokenProcessPool: submit failed" ))
240+ from benchmarks .common import _init_worker
299241
242+ # `pool` is the legacy ProcessPoolExecutor foreign-pool path.
243+ # Calibration's evaluate_grid_cached owns its own pebble pool now;
244+ # this branch should not be reachable from updated callers but is
245+ # preserved to surface a clear error if a stale caller passes a
246+ # ProcessPoolExecutor-shaped pool.
300247 if pool is not None :
301- _drain (pool ) # type: ignore[arg-type]
302- return
248+ raise NotImplementedError (
249+ "run_eval_set received a foreign `pool` arg; pebble migration "
250+ "expects callers to pass `pool=None` and let _run_parallel own "
251+ "the pebble.ProcessPool."
252+ )
303253
304- from benchmarks .common import _init_worker
254+ # Per-task wall-clock deadline. Generous safety net: covers
255+ # ensure_repo + apply_as_commit + diffctx + N selections. The
256+ # narrow 20s budget on the algorithm itself is enforced inside
257+ # eval_fn via threading.Timer + os._exit(137); this outer pebble
258+ # timeout is the upper bound for git ops on huge repos.
259+ pebble_timeout = max (timeout_per_instance + 30.0 , 60.0 )
305260
306- ctx = mp .get_context ("spawn" )
307- with ProcessPoolExecutor (
261+ with ProcessPool (
308262 max_workers = workers ,
309- mp_context = ctx ,
310- max_tasks_per_child = 50 ,
263+ max_tasks = 50 ,
311264 initializer = _init_worker ,
312- ) as owned :
313- _drain (owned )
314- owned .shutdown (wait = False , cancel_futures = True )
265+ ) as pp :
266+ futures : dict = {}
267+ for inst in pending :
268+ future = pp .schedule (eval_fn , args = (inst , params ), timeout = pebble_timeout )
269+ futures [future ] = inst
270+
271+ for future , inst in futures .items ():
272+ try :
273+ r = future .result ()
274+ except FuturesTimeoutError :
275+ r = _failure_result (
276+ inst ,
277+ params ,
278+ "timeout" ,
279+ f"pebble killed after { pebble_timeout :.0f} s" ,
280+ )
281+ except ProcessExpired as e :
282+ # exitcode 137 == os._exit(137) from the narrow algorithm
283+ # kill switch in eval_fn. Persist as timeout so the
284+ # checkpoint records it; otherwise treat as a genuine
285+ # crash (transient — not persisted by upstream _record).
286+ if e .exitcode == 137 :
287+ r = _failure_result (
288+ inst ,
289+ params ,
290+ "timeout" ,
291+ f"diffctx exceeded { timeout_per_instance :.0f} s budget" ,
292+ )
293+ else :
294+ r = _failure_result (
295+ inst ,
296+ params ,
297+ "error" ,
298+ f"ProcessExpired exitcode={ e .exitcode } " ,
299+ )
300+ except Exception as e :
301+ r = _failure_result (inst , params , "error" , f"{ type (e ).__name__ } : { e } " )
302+ record (r )
0 commit comments