"""MultiProcessRunner - shard benchmark runs across Python processes.""" from __future__ import annotations import argparse import asyncio import dataclasses import json import math import multiprocessing as mp import os import queue as queue_mod import sys import time from dataclasses import dataclass from datetime import datetime from pathlib import Path from typing import Any, Callable from bench_env.config import RunnerConfig from bench_env.env.recorder import allocate_run_dir from bench_env.logger import add_log_file, configure_logging, get_logger from bench_env.metrics import load_jsonl, result_key, task_trial_key from bench_env.runner.base import BaseRunner, EpisodeResult from bench_env.runner.parallel import ParallelRunner logger = get_logger(__name__) @dataclass class ProgressEvent: rank: int task_id: str trial_id: int success: bool error: str | None = None kind: str = "episode" @dataclass class ShardSpec: rank: int task_ids: list[str] run_dir: Path parallel: int = 1 num_browsers: int = 0 @dataclass class ShardHandle: spec: ShardSpec process: mp.Process reported_exit: bool = False def _split_evenly(total: int, buckets: int) -> list[int]: """Distribute ``total`` across ``buckets`` so the sum equals ``total``. Front buckets receive the +1 from ``divmod`` remainder. """ if buckets <= 0: return [] base, rem = divmod(total, buckets) return [base + (1 if i < rem else 0) for i in range(buckets)] def _configure_default_executor() -> None: import concurrent.futures max_workers = int(os.environ.get("MOBILE_GYM_TO_THREAD_WORKERS", "1024")) asyncio.get_running_loop().set_default_executor( concurrent.futures.ThreadPoolExecutor( max_workers=max_workers, thread_name_prefix="bench-to-thread", ) ) def _silence_child_stdio() -> None: devnull = open(os.devnull, "w", encoding="utf-8") sys.stdout = devnull sys.stderr = devnull async def _run_shard_async( config: RunnerConfig, task_ids: list[str], rank: int, progress_queue, shard_run_dir: Path, ) -> None: _configure_default_executor() # shard_run_dir is runs//shards/pNN; shard_run_dir.parent.parent is runs/. parent_run_dir = shard_run_dir.parent.parent child_config = dataclasses.replace( config, task_id=None, task_ids=task_ids, run_dir=shard_run_dir, runs_dir=shard_run_dir.parent, trajectory_dir=parent_run_dir / "trajectory", browser_log_dir=parent_run_dir / "browser_logs", browser_log_prefix=f"p{rank:02d}_", ) def emit(result: EpisodeResult) -> None: progress_queue.put( ProgressEvent( rank=rank, task_id=result.task_id, trial_id=result.trial_id, success=result.success, error=result.error, ) ) runner = await ParallelRunner.from_config(child_config, progress_callback=emit) await runner.run() def _shard_main( config: RunnerConfig, task_ids: list[str], rank: int, progress_queue, shard_run_dir: Path, ) -> None: _silence_child_stdio() shard_run_dir = Path(shard_run_dir) shard_run_dir.mkdir(parents=True, exist_ok=True) configure_logging(quiet=False) add_log_file(shard_run_dir / "console.log") try: asyncio.run(_run_shard_async(config, task_ids, rank, progress_queue, shard_run_dir)) except BaseException as err: logger.exception(f"[p{rank:02d}] shard crashed: {type(err).__name__}: {err}") try: progress_queue.put( ProgressEvent( rank=rank, task_id="__shard__", trial_id=0, success=False, error=f"{type(err).__name__}: {err}", kind="fatal", ) ) except Exception: pass raise class MultiProcessRunner(BaseRunner): """父进程编排多个 ParallelRunner shard。""" def __init__(self, tasks: list[Any], config: RunnerConfig): self.tasks = tasks self.config = config self.verbose = not config.quiet self.run_dir: Path | None = None self._start_time: datetime | None = None self._shards: list[ShardSpec] = [] self._handles: list[ShardHandle] = [] self._progress_queue = None self._completed_keys: set[str] = set() self._success_count = 0 self._fail_count = 0 # Top-level live aggregation: parent tails each shard's results.jsonl # by tracked byte offset and appends new whole lines to run_dir/results.jsonl # (and errors.jsonl) so external tooling can watch progress in real time. self._top_results_file = None self._top_errors_file = None self._shard_offsets: dict[int, int] = {} @classmethod async def from_args(cls, args: argparse.Namespace) -> "MultiProcessRunner": config = RunnerConfig.from_args(args) return await cls.from_config(config) @classmethod async def from_config( cls, config: RunnerConfig, progress_callback: Callable[[EpisodeResult], None] | None = None, ) -> "MultiProcessRunner": from bench_env import factory if config.agent == "human": raise ValueError("Multiprocess mode does not support human agent") if config.processes < 1: raise ValueError("--processes must be >= 1") if config.parallel < 1: raise ValueError("--parallel must be >= 1") if progress_callback is not None: raise ValueError("MultiProcessRunner does not support progress_callback") tasks = factory.load_tasks(config) return cls(tasks, config) async def run(self) -> list[dict[str, Any]]: from tqdm import tqdm from bench_env.logger import tqdm_logging_redirect self._prepare_run() assert self.run_dir is not None monitor_task = self._start_monitor(self.run_dir, self.config) if self.config.monitor else None total_episodes = len(self.tasks) * self.config.repeat_n shard_parallels = [s.parallel for s in self._shards] if shard_parallels and min(shard_parallels) == max(shard_parallels): parallel_desc = str(shard_parallels[0]) else: parallel_desc = f"min={min(shard_parallels)} max={max(shard_parallels)}" if shard_parallels else "0" logger.info( f"Tasks: {len(self.tasks)}, Repeat: {self.config.repeat_n}, " f"Processes: {len(self._shards)}, Parallel(total): {sum(shard_parallels)}, " f"Parallel/shard: {parallel_desc}, Output: {self.run_dir}" ) interrupted: BaseException | None = None try: self._start_children() with tqdm_logging_redirect(): pbar = tqdm( total=total_episodes, desc="Evaluating", unit="ep", dynamic_ncols=True, disable=not self.verbose, ) try: await self._monitor_children(pbar) finally: pbar.close() except (KeyboardInterrupt, asyncio.CancelledError) as err: logger.warning("Multiprocess run interrupted, terminating child shards") self._terminate_children() interrupted = err finally: self._stop_monitor(monitor_task) results, summary = self._finalize() self._log_summary(summary) if interrupted is not None: raise interrupted return results def _effective_processes(self) -> int: if not self.tasks: return 0 effective = max(1, min(self.config.processes, self.config.parallel, len(self.tasks))) isolation = str(self.config.isolation) browser_budget = int(self.config.num_browsers or 0) if ( browser_budget > 0 and isolation in {"pages", "contexts"} and effective > browser_budget ): limited = max(1, min(browser_budget, self.config.parallel, len(self.tasks))) logger.warning( f"--processes {self.config.processes} would create {effective} shards, " f"but --browsers {browser_budget} is a total browser budget in " f"{isolation} isolation. Using {limited} processes so no shard falls " "back to browser auto-allocation." ) return limited return effective def _prepare_run(self) -> None: from bench_env import factory self._start_time = datetime.now() runs_root = Path(self.config.runs_dir).expanduser().resolve() timestamp = self._start_time.strftime("%Y%m%d_%H%M%S") self.run_dir = allocate_run_dir(runs_root, timestamp) self.run_dir.mkdir(parents=True, exist_ok=False) (self.run_dir / "shards").mkdir(exist_ok=True) # Pre-create shared dirs so child shards don't race on mkdir. if not self.config.no_save_trajectory: (self.run_dir / "trajectory").mkdir(exist_ok=True) (self.run_dir / "browser_logs").mkdir(exist_ok=True) add_log_file(self.run_dir / "console.log") self._open_live_files() self._shards = self._build_shards() meta = { "start_time": self._start_time.isoformat(), "agent": factory.get_agent_name(self.config), "model_name": self.config.model_name, "repeat_n": self.config.repeat_n, "save_trajectory": not self.config.no_save_trajectory, "coord_space": self.config.coord_space, "has_pil": True, **self.build_run_meta(self.config, self.tasks), "effective_processes": len(self._shards), "parallel_per_shard": [s.parallel for s in self._shards], "browsers_per_shard": [s.num_browsers for s in self._shards], } (self.run_dir / "meta.json").write_text( json.dumps(meta, ensure_ascii=False, indent=2, default=str), encoding="utf-8", ) def _open_live_files(self) -> None: assert self.run_dir is not None self._top_results_file = (self.run_dir / "results.jsonl").open("w", encoding="utf-8") self._top_errors_file = (self.run_dir / "errors.jsonl").open("w", encoding="utf-8") def _close_live_files(self) -> None: for attr in ("_top_results_file", "_top_errors_file"): f = getattr(self, attr, None) if f is None: continue try: f.close() finally: setattr(self, attr, None) def _tail_shard_results(self) -> None: # Pull newly-flushed whole lines from each shard's results.jsonl into # the top-level results.jsonl/errors.jsonl so `tail -f` works during the run. if self._top_results_file is None: return from bench_env.metrics import build_error_entry, result_is_error wrote = False for spec in self._shards: src = spec.run_dir / "results.jsonl" if not src.exists(): continue offset = self._shard_offsets.get(spec.rank, 0) try: size = src.stat().st_size except OSError: continue if size <= offset: continue with src.open("rb") as f: f.seek(offset) chunk = f.read(size - offset) # Only consume up to the last newline so a partially-written final # line is left for the next tick. last_nl = chunk.rfind(b"\n") if last_nl < 0: continue consumed = chunk[: last_nl + 1] for raw_line in consumed.splitlines(): if not raw_line.strip(): continue try: line_str = raw_line.decode("utf-8") row = json.loads(line_str) except (UnicodeDecodeError, json.JSONDecodeError) as err: logger.warning(f"[p{spec.rank:02d}] tail skipped malformed line: {err}") continue self._top_results_file.write(line_str + "\n") if result_is_error(row): self._top_errors_file.write( json.dumps(build_error_entry(row), ensure_ascii=False, default=str) + "\n" ) wrote = True self._shard_offsets[spec.rank] = offset + len(consumed) if wrote: self._top_results_file.flush() self._top_errors_file.flush() def _append_results_to_top_level(self, results: list[dict[str, Any]]) -> None: if self._top_results_file is None: return from bench_env.metrics import build_error_entry, result_is_error for r in results: self._top_results_file.write(json.dumps(r, ensure_ascii=False, default=str) + "\n") if result_is_error(r): self._top_errors_file.write( json.dumps(build_error_entry(r), ensure_ascii=False, default=str) + "\n" ) self._top_results_file.flush() self._top_errors_file.flush() def _build_shards(self) -> list[ShardSpec]: assert self.run_dir is not None k = self._effective_processes() chunk_size = max(1, math.ceil(len(self.tasks) / k)) chunks: list[tuple[int, list[Any]]] = [] for rank in range(k): chunk = self.tasks[rank * chunk_size:(rank + 1) * chunk_size] if not chunk: continue chunks.append((rank, chunk)) actual_k = len(chunks) # Distribute total parallel/browsers across actual shards so the sums # equal the user-requested totals (no over-allocation, no extra envs). parallel_split = _split_evenly(self.config.parallel, actual_k) if self.config.num_browsers > 0: browsers_split = _split_evenly(self.config.num_browsers, actual_k) else: browsers_split = [0] * actual_k shards: list[ShardSpec] = [] for i, (rank, chunk) in enumerate(chunks): shards.append( ShardSpec( rank=rank, task_ids=[task.id for task in chunk], run_dir=self.run_dir / "shards" / f"p{rank:02d}", parallel=max(1, parallel_split[i]), num_browsers=browsers_split[i], ) ) return shards def _shard_config(self, spec: ShardSpec) -> RunnerConfig: return dataclasses.replace( self.config, parallel=spec.parallel, num_browsers=spec.num_browsers, processes=1, monitor=False, quiet=True, run_dir=None, ) def _start_children(self) -> None: ctx = mp.get_context("spawn") self._progress_queue = ctx.Queue() stagger_s = float(os.environ.get("MOBILE_GYM_PROCESS_STAGGER_SEC", "0")) for i, spec in enumerate(self._shards): child_config = self._shard_config(spec) p = ctx.Process( target=_shard_main, args=(child_config, spec.task_ids, spec.rank, self._progress_queue, spec.run_dir), name=f"bench-shard-p{spec.rank:02d}", ) p.start() self._handles.append(ShardHandle(spec=spec, process=p)) logger.info( f"[p{spec.rank:02d}] started pid={p.pid}, tasks={len(spec.task_ids)}, " f"parallel={spec.parallel}, browsers={spec.num_browsers or 'auto'}" ) if stagger_s > 0 and i + 1 < len(self._shards): time.sleep(stagger_s) async def _monitor_children(self, pbar) -> None: while True: await self._drain_progress(pbar) self._tail_shard_results() all_done = True for handle in self._handles: p = handle.process if p.is_alive(): all_done = False continue if not handle.reported_exit: p.join(timeout=0) handle.reported_exit = True if p.exitcode: logger.error(f"[p{handle.spec.rank:02d}] exited with code {p.exitcode}") else: logger.info(f"[p{handle.spec.rank:02d}] completed") if all_done: break await asyncio.sleep(0.2) await self._drain_progress(pbar) self._tail_shard_results() async def _drain_progress(self, pbar) -> None: # Bound the inner loop so a flood of events can't block the asyncio loop. if self._progress_queue is None: return BATCH = 64 processed = 0 while True: try: event = self._progress_queue.get_nowait() except queue_mod.Empty: return if not isinstance(event, ProgressEvent): continue if event.kind == "fatal": logger.error(f"[p{event.rank:02d}] fatal: {event.error}") continue key = task_trial_key(event.task_id, event.trial_id) if key in self._completed_keys: continue self._completed_keys.add(key) if event.success: self._success_count += 1 else: self._fail_count += 1 if pbar: pbar.set_postfix_str(f"✓{self._success_count} ✗{self._fail_count}") pbar.update(1) processed += 1 if processed >= BATCH: await asyncio.sleep(0) processed = 0 def _terminate_children(self) -> None: for handle in self._handles: p = handle.process if p.is_alive(): p.terminate() for handle in self._handles: p = handle.process p.join(timeout=5) if p.is_alive() and hasattr(p, "kill"): p.kill() p.join(timeout=2) def _finalize(self) -> tuple[list[dict[str, Any]], dict[str, Any]]: assert self.run_dir is not None from bench_env.metrics import write_summary_json # Final tail catches anything children flushed between the last poll # and their exit. self._tail_shard_results() streamed = load_jsonl(self.run_dir / "results.jsonl") existing_keys = {result_key(r) for r in streamed} task_by_id = {task.id: task for task in self.tasks} missing: list[dict[str, Any]] = [] for spec in self._shards: exitcode = self._exitcode_for_rank(spec.rank) for task_id in spec.task_ids: for trial_id in range(self.config.repeat_n): key = task_trial_key(task_id, trial_id) if key in existing_keys: continue reason = ( f"Shard p{spec.rank:02d} exited with code {exitcode}" if exitcode else f"Shard p{spec.rank:02d} produced no result" ) missing.append(self._make_missing_result(task_by_id[task_id], trial_id, reason)) if missing: logger.warning(f"Adding {len(missing)} missing-result ERROR entries") self._append_results_to_top_level(missing) self._close_live_files() results = streamed + missing summary = write_summary_json( self.run_dir, results, repeat_n=self.config.repeat_n, pass_k=self.config.pass_k, start_time=self._start_time.isoformat() if self._start_time else None, ) return results, summary def _exitcode_for_rank(self, rank: int) -> int | None: for handle in self._handles: if handle.spec.rank == rank: return handle.process.exitcode return None def _make_missing_result(self, task: Any, trial_id: int, reason: str) -> dict[str, Any]: now = datetime.now().isoformat() execution = { "steps": 0, "finished": False, "truncated": False, "stop_reason": "ERROR", "agent_message": None, "agent_answer": None, "runtime_s": 0.0, "error": reason, "stopwatch_total_s": 0.0, "stopwatch_flat": {}, "stopwatch_tree": [], } result: dict[str, Any] = { "id": task.id, "task_name": getattr(task, "description", task.id), "suite": getattr(task, "suite", "unknown"), "apps": list(getattr(task, "apps", []) or []), "trial_id": trial_id, "execution": execution, "judge": None, "is_success": False, "is_error": True, "progress": 0.0, "false_complete": False, "overdue_termination": False, "max_steps": self.config.get_max_steps(task), "start_time": now, "end_time": now, } for field in ("difficulty", "scope", "objective", "composition"): value = getattr(task, field, "") if value: result[field] = value caps = list(getattr(task, "capabilities", []) or []) if caps: result["capabilities"] = caps return result def _log_summary(self, summary: dict[str, Any]) -> None: total = summary.get("total_episodes", 0) errors = summary.get("error", 0) logger.info(f"\n{'=' * 60}") logger.info(f" RESULTS SUMMARY ({total} episodes, {errors} errors)") logger.info(f"{'=' * 60}") logger.info( f" Success Rate (SR): " f"{summary.get('success', 0)}/{max(1, total - errors)} = " f"{summary.get('success_rate', 0):.1%}" ) logger.info(f" Failed: {summary.get('failed', 0)}") logger.info(f" Errors: {errors}") logger.info(f" Output: {self.run_dir}") logger.info(f"{'=' * 60}")