""" Predix Parallel Runner - Run multiple factor experiments concurrently. Spawns N subprocesses, each running `predix.py quant` with isolated config: # nosec - Separate log files (fin_quant_run1.log, fin_quant_run2.log, etc.) - Separate result directories (results/runs/run1/, results/runs/run2/, etc.) - Separate workspace directories - API key distribution across multiple keys (round-robin) Usage: python predix_parallel.py --runs 5 --api-keys 2 python predix_parallel.py --runs 3 --model openrouter python predix_parallel.py --runs 5 --model local --api-keys 1 """ import os import signal import subprocess # nosec B404 import sys import time from datetime import datetime from pathlib import Path from typing import Dict, List, Optional from dotenv import load_dotenv from rich.console import Console from rich.live import Live from rich.panel import Panel from rich.table import Table from rich.markdown import Markdown from rich.layout import Layout from rich.progress import Progress, SpinnerColumn, TextColumn, BarColumn, TaskProgressColumn # Load environment variables from .env file load_dotenv(Path(__file__).parent / ".env") console = Console() class RunState: """Tracks the state of a single parallel run.""" def __init__(self, run_id: int, api_key_idx: int, model: str): self.run_id = run_id self.api_key_idx = api_key_idx self.model = model self.process: Optional[subprocess.Popen] = None # nosec B603 self.status: str = "pending" # pending, running, success, failed, stopped self.start_time: Optional[datetime] = None self.end_time: Optional[datetime] = None self.exit_code: Optional[int] = None self.error_message: Optional[str] = None self.log_file: str = f"fin_quant_run{run_id}.log" @property def elapsed(self) -> str: """Get elapsed time as human-readable string.""" if self.start_time is None: return "--:--:--" end = self.end_time or datetime.now() delta = end - self.start_time total_seconds = int(delta.total_seconds()) hours, remainder = divmod(total_seconds, 3600) minutes, seconds = divmod(remainder, 60) return f"{hours:02d}:{minutes:02d}:{seconds:02d}" @property def status_icon(self) -> str: """Get icon for current status.""" icons = { "pending": "ā³", "running": "šŸ”„", "success": "āœ…", "failed": "āŒ", "stopped": "ā¹ļø", } return icons.get(self.status, "ā“") class ParallelRunner: """ Manages multiple concurrent factor experiment runs. Spawns subprocesses with isolated configurations, monitors progress, # nosec and handles graceful shutdown. """ def __init__( self, num_runs: int = 5, num_api_keys: int = 2, model: str = "openrouter", ): """ Initialize parallel runner. Parameters ---------- num_runs : int Number of concurrent runs to spawn num_api_keys : int Number of API keys to distribute across (1 or 2) model : str LLM backend: 'local' (llama.cpp) or 'openrouter' (cloud) """ self.num_runs = num_runs self.num_api_keys = num_api_keys self.model = model self.runs: List[RunState] = [] self.project_root = Path(__file__).parent self._shutdown_requested = False # Read API keys from environment self.api_keys = self._load_api_keys() # Validate we have enough API keys if self.model == "openrouter" and len(self.api_keys) < num_api_keys: console.print( f"[yellow]āš ļø Requested {num_api_keys} API keys, but only {len(self.api_keys)} found in .env[/yellow]" ) console.print( f"[dim]Distributing across {len(self.api_keys)} available key(s)[/dim]" ) self.num_api_keys = len(self.api_keys) # Initialize run states for i in range(1, num_runs + 1): # Round-robin API key assignment api_key_idx = (i - 1) % max(self.num_api_keys, 1) run_state = RunState(run_id=i, api_key_idx=api_key_idx, model=model) self.runs.append(run_state) def _load_api_keys(self) -> List[str]: """Load API keys from environment variables.""" keys = [] if self.model == "openrouter": key1 = os.getenv("OPENROUTER_API_KEY", "") key2 = os.getenv("OPENROUTER_API_KEY_2", "") if key1: keys.append(key1) if key2: keys.append(key2) else: # For local mode, we just need the llama.cpp endpoint keys.append("local") if not keys or (len(keys) == 1 and keys[0] == "local"): keys = ["local"] return keys def _build_env(self, run_state: RunState) -> Dict[str, str]: """ Build isolated environment for a subprocess. # nosec Parameters ---------- run_state : RunState The run state object containing run configuration Returns ------- dict Environment variables dict for subprocess # nosec """ # Start with a copy of current environment env = os.environ.copy() # Set parallel run ID for isolation env["PARALLEL_RUN_ID"] = str(run_state.run_id) # Set workspace isolation workspace_dir = self.project_root / f"RD-Agent_workspace_run{run_state.run_id}" env["RD_AGENT_WORKSPACE"] = str(workspace_dir) # Configure API key for this run if self.model == "openrouter" and run_state.api_key_idx < len(self.api_keys): api_key = self.api_keys[run_state.api_key_idx] env["OPENAI_API_KEY"] = api_key env["OPENAI_API_BASE"] = "https://openrouter.ai/api/v1" env["CHAT_MODEL"] = os.getenv("OPENROUTER_MODEL", "openrouter/google/gemma-4-26b-a4b-it:free") # If we configured multiple API keys AND have enough keys, use load balancing if self.num_api_keys >= 2 and len(self.api_keys) >= 2: env["OPENAI_API_KEY"] = f"{self.api_keys[0]},{self.api_keys[1]}" env["LITELLM_PARALLEL_CALLS"] = "2" elif self.model == "local": env["OPENAI_API_KEY"] = os.getenv("OPENAI_API_KEY", "local") env["OPENAI_API_BASE"] = os.getenv("OPENAI_API_BASE", "http://localhost:8081/v1") env["CHAT_MODEL"] = os.getenv("CHAT_MODEL", "openai/qwen3.5-35b") return env def _build_command(self, run_state: RunState) -> List[str]: """ Build the subprocess command to run predix quant. # nosec Parameters ---------- run_state : RunState The run state object containing run configuration Returns ------- list Command list for subprocess.Popen # nosec B603 """ cmd = [ sys.executable, # Use same Python interpreter # nosec str(self.project_root / "predix.py"), "quant", "--model", run_state.model, "--run-id", str(run_state.run_id), "--log-file", run_state.log_file, ] return cmd def _start_run(self, run_state: RunState) -> None: """ Start a single run as a subprocess. # nosec Parameters ---------- run_state : RunState The run state to start """ env = self._build_env(run_state) cmd = self._build_command(run_state) # Ensure results directory exists results_dir = self.project_root / "results" / "runs" / f"run{run_state.run_id}" results_dir.mkdir(parents=True, exist_ok=True) # Open log file for appending log_path = self.project_root / run_state.log_file log_f = open(log_path, "a", encoding="utf-8") # Start subprocess # nosec run_state.process = subprocess.Popen( # nosec B603 cmd, env=env, cwd=str(self.project_root), stdout=log_f, stderr=subprocess.STDOUT, # nosec ) run_state.status = "running" run_state.start_time = datetime.now() console.print( f"[dim] ā–¶ļø Run {run_state.run_id} started (PID: {run_state.process.pid}, " f"API Key: {run_state.api_key_idx + 1}, Model: {run_state.model})[/dim]" ) def _check_run(self, run_state: RunState) -> None: """ Check if a run is still running and update status. Parameters ---------- run_state : RunState The run state to check """ if run_state.status != "running" or run_state.process is None: return poll_result = run_state.process.poll() if poll_result is not None: # Process has finished run_state.exit_code = poll_result run_state.end_time = datetime.now() if poll_result == 0: run_state.status = "success" console.print( f"[bold green] āœ… Run {run_state.run_id} completed " f"({run_state.elapsed})[/bold green]" ) else: run_state.status = "failed" run_state.error_message = f"Exit code: {poll_result}" console.print( f"[bold red] āŒ Run {run_state.run_id} failed " f"({run_state.elapsed}, exit code: {poll_result})[/bold red]" ) def _stop_run(self, run_state: RunState) -> None: """ Gracefully stop a running subprocess. # nosec Parameters ---------- run_state : RunState The run state to stop """ if run_state.process is None or run_state.status != "running": return try: # Try graceful termination first run_state.process.terminate() try: run_state.process.wait(timeout=10) except subprocess.TimeoutExpired: # nosec # Force kill if not responding run_state.process.kill() run_state.process.wait() except Exception as e: console.print(f"[yellow] āš ļø Error stopping run {run_state.run_id}: {e}[/yellow]") run_state.status = "stopped" run_state.end_time = datetime.now() def _render_dashboard(self) -> Panel: """ Render the live dashboard panel showing all run states. Returns ------- Panel Rich Panel object with dashboard content """ # Summary stats pending = sum(1 for r in self.runs if r.status == "pending") running = sum(1 for r in self.runs if r.status == "running") success = sum(1 for r in self.runs if r.status == "success") failed = sum(1 for r in self.runs if r.status == "failed") stopped = sum(1 for r in self.runs if r.status == "stopped") # Build summary table table = Table( title="šŸ”€ Predix Parallel Run Dashboard", show_header=True, header_style="bold cyan", expand=True, ) table.add_column("Run", justify="center", width=6) table.add_column("Status", justify="center", width=10) table.add_column("Elapsed", justify="center", width=10) table.add_column("API Key", justify="center", width=8) table.add_column("Model", justify="center", width=12) table.add_column("Exit", justify="center", width=6) table.add_column("Log File", justify="left") for run in self.runs: table.add_row( f"#{run.run_id}", f"{run.status_icon} {run.status}", run.elapsed, str(run.api_key_idx + 1), run.model, str(run.exit_code) if run.exit_code is not None else "--", run.log_file, ) # Summary panel total = len(self.runs) summary_text = ( f"**Summary:** {total} total | " f"{success} done | " f"{running} running | " f"{pending} pending | " f"{failed} failed" ) if self._shutdown_requested: summary_text += "\nāš ļø **Shutdown requested - stopping all runs...**" from rich.console import Group return Group(table, Panel(Markdown(summary_text), border_style="blue")) def _signal_handler(self, signum, frame) -> None: """Handle SIGINT/SIGTERM for graceful shutdown.""" if self._shutdown_requested: # Second Ctrl+C - force kill everything console.print("\n[bold red]šŸ›‘ Force killing all runs![/bold red]") for run in self.runs: if run.process and run.status == "running": run.process.kill() sys.exit(1) self._shutdown_requested = True console.print("\n[yellow]ā¹ļø Shutdown requested - gracefully stopping all runs...[/yellow]") console.print("[dim]Press Ctrl+C again to force kill[/dim]") for run in self.runs: if run.status == "running": self._stop_run(run) def run(self) -> Dict[str, int]: """ Execute all parallel runs and show live dashboard. Returns ------- dict Summary with keys: total, success, failed, stopped """ # Register signal handlers signal.signal(signal.SIGINT, self._signal_handler) signal.signal(signal.SIGTERM, self._signal_handler) console.print(f"\n[bold cyan]{'=' * 60}[/bold cyan]") console.print(f"[bold cyan]šŸ”€ Predix Parallel Runner[/bold cyan]") console.print(f"[bold cyan]{'=' * 60}[/bold cyan]") console.print(f" Runs: {self.num_runs}") console.print(f" API Keys: {self.num_api_keys} ({len(self.api_keys)} available)") console.print(f" Model: {self.model}") console.print(f" Log pattern: fin_quant_run{{1..{self.num_runs}}}.log") console.print(f" Results: results/runs/run{{1..{self.num_runs}}}/") console.print() # Start all runs for run in self.runs: if self._shutdown_requested: break self._start_run(run) # Small delay to prevent overwhelming the system time.sleep(1) # Monitor loop with live dashboard with Live(refresh_per_second=2, screen=True) as live: live.update(self._render_dashboard()) while True: if self._shutdown_requested: # Check if all runs are stopped all_stopped = all( r.status in ("success", "failed", "stopped", "pending") for r in self.runs ) if all_stopped: break # Update all run statuses for run in self.runs: self._check_run(run) # Check if all runs are complete all_done = all( r.status in ("success", "failed", "stopped") for r in self.runs ) if all_done: break live.update(self._render_dashboard()) time.sleep(0.5) # Final summary success_count = sum(1 for r in self.runs if r.status == "success") failed_count = sum(1 for r in self.runs if r.status == "failed") stopped_count = sum(1 for r in self.runs if r.status == "stopped") console.print(f"\n[bold cyan]{'=' * 60}[/bold cyan]") console.print(f"[bold cyan]šŸ“Š Parallel Run Summary[/bold cyan]") console.print(f"[bold cyan]{'=' * 60}[/bold cyan]") console.print(f" āœ… Success: {success_count}/{self.num_runs}") console.print(f" āŒ Failed: {failed_count}/{self.num_runs}") if stopped_count > 0: console.print(f" ā¹ļø Stopped: {stopped_count}/{self.num_runs}") total_time = None for run in self.runs: if run.start_time and run.end_time: delta = run.end_time - run.start_time console.print( f" Run #{run.run_id}: {run.status} ({delta.total_seconds():.0f}s)" ) return { "total": self.num_runs, "success": success_count, "failed": failed_count, "stopped": stopped_count, } def main( runs: int = 5, api_keys: int = 2, model: str = "openrouter", ) -> Dict[str, int]: """ Run multiple factor experiments in parallel. Parameters ---------- runs : int Number of concurrent runs to spawn api_keys : int Number of API keys to distribute across model : str LLM backend: 'local' (llama.cpp) or 'openrouter' (cloud) Returns ------- dict Summary with keys: total, success, failed, stopped """ runner = ParallelRunner(num_runs=runs, num_api_keys=api_keys, model=model) return runner.run() if __name__ == "__main__": import argparse parser = argparse.ArgumentParser( description="Predix Parallel Runner - Run multiple factor experiments concurrently" ) parser.add_argument( "--runs", "-n", type=int, default=5, help="Number of concurrent runs (default: 5, max recommended: 25)", ) parser.add_argument( "--api-keys", "-k", type=int, default=2, help="Number of API keys to distribute across (default: 2)", ) parser.add_argument( "--model", "-m", type=str, default="openrouter", choices=["local", "openrouter"], help="LLM backend: 'local' (llama.cpp) or 'openrouter' (cloud)", ) parser.add_argument( "--force", action="store_true", help="Skip resource warnings (allow >25 runs)", ) args = parser.parse_args() # Resource warnings for high run counts if args.runs > 50 and not args.force: console.print(f"\n[bold red]āš ļø {args.runs} runs exceeds safe limit (50)[/bold red]") console.print("[yellow]This will likely cause memory exhaustion and API throttling.[/yellow]") console.print("[yellow]Use --force to override.[/yellow]") sys.exit(1) elif args.runs > 25: console.print(f"\n[yellow]āš ļø {args.runs} runs - high resource usage expected[/yellow]") console.print(f" Estimated RAM: ~{args.runs * 0.65:.0f} GB") console.print(f" Use --force to confirm.\n") import time time.sleep(2) result = main(runs=args.runs, api_keys=args.api_keys, model=args.model) # Exit with appropriate code if result["failed"] > 0: sys.exit(1) sys.exit(0)