""" NexQuant Parallel Runner - Run multiple factor experiments concurrently. Spawns N subprocesses, each running `nexquant.py quant` with isolated config: - 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 nexquant_parallel.py --runs 5 --api-keys 2 python nexquant_parallel.py --runs 3 --model openrouter python nexquant_parallel.py --runs 5 --model local --api-keys 1 """ import os import signal import subprocess import sys import time from datetime import datetime from pathlib import Path from dotenv import load_dotenv from rich.console import Console from rich.live import Live from rich.markdown import Markdown from rich.panel import Panel from rich.table import Table # Load environment variables from .env file load_dotenv(Path(__file__).parent.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: subprocess.Popen | None = None self.status: str = "pending" # pending, running, success, failed, stopped self.start_time: datetime | None = None self.end_time: datetime | None = None self.exit_code: int | None = None self.error_message: str | None = 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, 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.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. Parameters ---------- run_state : RunState The run state object containing run configuration Returns ------- dict Environment variables dict for subprocess """ # 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": 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 run_state.api_key_idx < len(self.api_keys): env["OPENAI_API_KEY"] = self.api_keys[run_state.api_key_idx] env["OPENAI_API_BASE"] = "https://openrouter.ai/api/v1" env["CHAT_MODEL"] = os.getenv("OPENROUTER_MODEL", "openrouter/google/gemma-4-26b-a4b-it:free") 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 nexquant quant. Parameters ---------- run_state : RunState The run state object containing run configuration Returns ------- list Command list for subprocess.Popen """ cmd = [ sys.executable, # Use same Python interpreter str(self.project_root / "nexquant.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. 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") try: # Start subprocess run_state.process = subprocess.Popen( cmd, env=env, cwd=str(self.project_root), stdout=log_f, stderr=subprocess.STDOUT, ) run_state.status = "running" run_state.start_time = datetime.now() except Exception: log_f.close() raise 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. 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: # 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="šŸ”€ NexQuant 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("[bold cyan]šŸ”€ NexQuant 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("[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="NexQuant 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(" 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)