Skip to content

Commit 2dc3108

Browse files
committed
fix(bench): migrate runner._run_parallel to pebble.ProcessPool
Same root cause as the calibrate.py migration: ProcessPoolExecutor permanently breaks (BrokenProcessPool, terminal) when a worker dies via os._exit(137) — and that is exactly what our threading.Timer kill switch does on every per-instance timeout. Net effect on full test runs (run_eval / select_final / run_final_eval): a single slow instance bricks the pool, all queued tasks behind it cascade into fake-timeouts, and the run reports 100% timeout on whichever benchmarks happened to be late in the submission order. Pebble's ProcessPool kills only the offending worker via SIGTERM/ SIGKILL and respawns transparently. exitcode 137 distinguishes our narrow algorithm kill from a genuine crash so the persisted status is correct ("timeout" vs "error"). The legacy `pool` arg is now unused — calibrate.py owns its own pebble pool. Pass through raises NotImplementedError to surface stale callers loudly.
1 parent 149d953 commit 2dc3108

1 file changed

Lines changed: 73 additions & 85 deletions

File tree

benchmarks/adapters/runner.py

Lines changed: 73 additions & 85 deletions
Original file line numberDiff line numberDiff line change
@@ -1,7 +1,6 @@
11
from __future__ import annotations
22

33
import json
4-
import time
54
from collections.abc import Callable, Iterable, Iterator
65
from dataclasses import asdict, dataclass, field
76
from 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

Comments
 (0)