import argparse import os import signal import subprocess import sys from datetime import datetime class ProcessManager: """Manager for subprocess execution with proper signal handling.""" def __init__(self): self.process = None self.original_handlers = {} def __enter__(self): """Context manager entry - setup signal handlers""" # Save original signal handlers self.original_handlers[signal.SIGINT] = signal.getsignal(signal.SIGINT) self.original_handlers[signal.SIGTERM] = signal.getsignal(signal.SIGTERM) # Register new signal handlers signal.signal(signal.SIGINT, self._signal_handler) signal.signal(signal.SIGTERM, self._signal_handler) return self def __exit__(self, exc_type, exc_val, exc_tb): """Context manager exit - restore original signal handlers""" # Restore original signal handlers for sig, handler in self.original_handlers.items(): signal.signal(sig, handler) def _signal_handler(self, sig, frame): """Handle termination signals.""" signal_name = 'SIGINT' if sig == signal.SIGINT else 'SIGTERM' print(f'\nReceived {signal_name}, cleaning up subprocess...') self.cleanup() sys.exit(0) def start_process(self, cmd): self.process = subprocess.Popen(cmd) return self.process def cleanup(self): if self.process and self.process.poll() is None: print('Terminating subprocess...') self.process.terminate() try: self.process.wait(timeout=5) print('Subprocess terminated successfully') except subprocess.TimeoutExpired: print('Subprocess did not terminate normally, forcing kill...') self.process.kill() self.process.wait() print('Subprocess killed') def read_config(): """Get configuration content from config file in script directory. Returns: str: Configuration file content, returns None if reading fails """ script_dir = os.path.dirname(os.path.abspath(__file__)) config_path = os.path.join(script_dir, 'config.py') # Read config file content try: with open(config_path, encoding='utf-8') as f: config_content = f.read() return config_content except FileNotFoundError: print(f'Error: Config file not found at {config_path}') return None except Exception as e: print(f'Error reading config file: {e}') return None def update_datasets(config, datasets): """Update datasets part in config according to datasets list. Args: config (str): Original configuration content datasets (list[str]): List of dataset names to include Returns: str: Updated configuration content """ datasets = list(datasets) if 'all' in datasets: # datasets part of the config file specifies all datasets, no need to update return config selected_datasets = [] expanded_datasets = [] for dataset in datasets: if dataset == 'aime': expanded_datasets.extend(['aime2024', 'aime2025']) else: expanded_datasets.append(dataset) datasets = [] for dataset in expanded_datasets: if dataset not in datasets: datasets.append(dataset) if 'code' in datasets: selected_datasets.append('[LCBCodeGeneration_dataset]') datasets = [dataset for dataset in datasets if dataset != 'code'] dataset_var_aliases = { 'qmsum': 'LongBench_qmsum_datasets', 'longbench_qmsum': 'LongBench_qmsum_datasets', 'samsum': 'LongBench_samsum_datasets', 'longbench_samsum': 'LongBench_samsum_datasets', 'leval_meetingsumm': 'LEval_meetingsumm_datasets', 'meetingsumm': 'LEval_meetingsumm_datasets', 'meeting_summ': 'LEval_meetingsumm_datasets', 'meetingbank': 'meetingbank_10k_datasets', 'meetingbank_10k': 'meetingbank_10k_datasets', } for d in datasets: if d == 'arena': d = 'arenahard' selected_datasets.append(dataset_var_aliases.get(d, f'{d}_datasets')) selected_datasets = ' + '.join(selected_datasets) selected_datasets = f'datasets = {selected_datasets}' # replace datasets part in config start_tag = '# ' end_tag = '# ' start_index = config.find(start_tag) end_index = config.find(end_tag) if start_index == -1 or end_index == -1: raise ValueError('replace tag not found in config file') end_index += len(end_tag) replacement = f'{start_tag}\n{selected_datasets}\n{end_tag}' result = config[:start_index] + replacement + config[end_index:] return result def get_model_name_from_server(server: str, tag: str) -> str: from openai import OpenAI try: client = OpenAI(api_key='YOUR_API_KEY', base_url=f'{server}/v1') model_name = client.models.list().data[0].id return model_name except Exception as e: raise RuntimeError(f'Failed to get model name from {tag}_server {server}: {e}') def save_config(work_dir: str, config: str): """Save configuration content to a file in the specified directory. Args: work_dir (str): Directory to save the configuration file config (str): Configuration content to save """ if not work_dir: return os.makedirs(work_dir, exist_ok=True) output_file = os.path.join(work_dir, 'config.py') with open(output_file, 'w', encoding='utf-8') as f: f.write(config) print(f'Config written to {output_file}') def apply_runtime_overrides(config: str) -> str: """Apply environment-driven scalar overrides before OpenCompass parses config.""" import re env_to_name = { 'OPENCOMPASS_TARGET_TEMPERATURE': 'TARGET_TEMPERATURE', 'OPENCOMPASS_TARGET_MAX_OUT_LEN': 'TARGET_MAX_OUT_LEN', 'OPENCOMPASS_TARGET_QPS': 'TARGET_QPS', 'OPENCOMPASS_TARGET_BATCH_SIZE': 'TARGET_BATCH_SIZE', 'OPENCOMPASS_INFER_NUM_WORKER': 'INFER_NUM_WORKER', 'OPENCOMPASS_INFER_MIN_TASK_SIZE': 'INFER_MIN_TASK_SIZE', 'OPENCOMPASS_INFER_MAX_WORKERS': 'INFER_MAX_WORKERS', 'OPENCOMPASS_EVAL_MAX_WORKERS': 'EVAL_MAX_WORKERS', } for env_name, config_name in env_to_name.items(): value = os.environ.get(env_name) if value is None or value == '': continue if config_name in {'TARGET_TEMPERATURE', 'TARGET_QPS'}: literal = str(float(value)) else: literal = str(int(value)) pattern = rf'^{config_name}\s*=\s*.+$' replacement = f'{config_name} = {literal}' config, n = re.subn(pattern, replacement, config, count=1, flags=re.MULTILINE) if n == 0: raise ValueError(f'Could not apply OpenCompass runtime override for {config_name}') subjective_value = os.environ.get('OPENCOMPASS_SUBJECTIVE', '').strip().lower() if subjective_value: literal = 'True' if subjective_value in {'1', 'true', 'yes', 'on'} else 'False' pattern = r'^OPENCOMPASS_SUBJECTIVE\s*=\s*.+$' config, n = re.subn(pattern, f'OPENCOMPASS_SUBJECTIVE = {literal}', config, count=1, flags=re.MULTILINE) if n == 0: raise ValueError('Could not apply OpenCompass runtime override for OPENCOMPASS_SUBJECTIVE') ifeval_path = os.environ.get('OPENCOMPASS_IFEVAL_PATH', '').strip() if ifeval_path: config = config.replace("path='data/ifeval/input_data.jsonl'", f"path={ifeval_path!r}") config = config.replace('path="data/ifeval/input_data.jsonl"', f"path={ifeval_path!r}") config += ( '\n\n# Runtime IFEval path override from lmdeploy/eval/eval.py\n' f'_runtime_ifeval_path = {ifeval_path!r}\n' 'for _runtime_dataset in datasets:\n' " if str(_runtime_dataset.get('abbr', '')).lower() == 'ifeval':\n" " _runtime_dataset['path'] = _runtime_ifeval_path\n" ) arena_hard_path = os.environ.get('OPENCOMPASS_ARENA_HARD_PATH', '').strip() if arena_hard_path: config = config.replace("path='./data/subjective/arena_hard'", f"path={arena_hard_path!r}") config = config.replace('path="./data/subjective/arena_hard"', f"path={arena_hard_path!r}") config += ( '\n\n# Runtime Arena-Hard path override from lmdeploy/eval/eval.py\n' f'_runtime_arena_hard_path = {arena_hard_path!r}\n' 'for _runtime_dataset in datasets:\n' " if str(_runtime_dataset.get('abbr', '')).lower() == 'arenahard':\n" " _runtime_dataset['path'] = _runtime_arena_hard_path\n" ) test_range = os.environ.get('OPENCOMPASS_TEST_RANGE', '').strip() max_samples = os.environ.get('OPENCOMPASS_MAX_SAMPLES', '').strip() if test_range and max_samples: raise ValueError('Set only one of OPENCOMPASS_TEST_RANGE or OPENCOMPASS_MAX_SAMPLES') if max_samples: sample_count = int(max_samples) if sample_count <= 0: raise ValueError('OPENCOMPASS_MAX_SAMPLES must be positive') test_range = f'[:{sample_count}]' if test_range: config += ( '\n\n# Runtime dataset range override from lmdeploy/eval/eval.py\n' f'_runtime_test_range = {test_range!r}\n' 'for _runtime_dataset in datasets:\n' " _runtime_dataset.setdefault('reader_cfg', {})['test_range'] = _runtime_test_range\n" ) return config def perform_evaluation(config, api_server, judger_server, mode, work_dir, reuse): """Perform model evaluation by opencompass. Args: config (str): Configuration content api_server (str): API server address for inference judger_server (str): Judger server address for evaluation mode (str): Running mode selection, options: infer, eval, all, config work_dir (str): Output directory for evaluation results. If not specified, config will not be saved and execution will not be performed. reuse (str): Whether to reuse existing results """ direct_served_model = os.environ.get('OPENCOMPASS_SERVED_MODEL', '').strip() if mode in ['infer', 'all', 'config'] and api_server: served_model_name = direct_served_model or get_model_name_from_server(api_server, 'api') config = config.replace("SERVED_MODEL_PATH = ''", f"SERVED_MODEL_PATH = '{served_model_name}'") elif mode in ['infer', 'all']: raise ValueError('api-server is required when mode is infer or all') require_judger = os.environ.get('OPENCOMPASS_REQUIRE_JUDGER', '1').strip().lower() not in { '0', 'false', 'no', 'off' } direct_judger_model = os.environ.get('OPENCOMPASS_JUDGER_MODEL', '').strip() direct_judger_base = ( os.environ.get('OPENCOMPASS_JUDGER_BASE_URL') or os.environ.get('OPENAI_BASE_URL') or 'https://api.openai.com' ).strip() if direct_judger_base.endswith('/v1'): direct_judger_base = direct_judger_base[:-3].rstrip('/') if mode in ['eval', 'all', 'config'] and judger_server: judger_model_name = get_model_name_from_server(judger_server, 'judger') config = config.replace("JUDGER_MODEL_PATH = ''", f"JUDGER_MODEL_PATH = '{judger_model_name}'") elif mode in ['eval', 'all', 'config'] and direct_judger_model: config = config.replace("JUDGER_MODEL_PATH = ''", f"JUDGER_MODEL_PATH = '{direct_judger_model}'") config = config.replace( "JUDGER_ADDR = 'http://'", f"JUDGER_ADDR = '{direct_judger_base}'", ) elif mode in ['eval', 'all'] and require_judger: raise ValueError('judger-server is required when mode is eval or all') judger_api_key = os.environ.get('OPENCOMPASS_JUDGER_API_KEY') or os.environ.get('OPENAI_API_KEY') if judger_api_key: config = config.replace("key='YOUR_API_KEY'", f"key={judger_api_key!r}") config = apply_runtime_overrides(config) # write updated config to work_dir if work_dir: save_config(work_dir, config) if mode == 'config': return else: print(config) return # execute opencompass command cmd = ['opencompass', f'{work_dir}/config.py', '-m', mode, '-w', work_dir] if reuse: # reuse previous outputs & results. If reuse is a string, it indicates a specific timestamp. if reuse == 'latest': cmd.extend(['-r', str(reuse)]) else: try: datetime.strptime(reuse, '%Y%m%d_%H%M%S') cmd.extend(['-r', str(reuse)]) except ValueError as e: print(e) raise ValueError(f'Invalid reuse timestamp format: {reuse}. Expected format: YYYYMMDD_HHMMSS') from e try: print(f'Executing command: {" ".join(cmd)}') # result = subprocess.run(cmd, text=True, check=True) # return result with ProcessManager() as manager: process = manager.start_process(cmd) result = process.wait() if result != 0: raise subprocess.CalledProcessError(result, cmd) return subprocess.CompletedProcess(cmd, result) except Exception as e: print(f'Executing commanded failed with {e}') return def main(): parser = argparse.ArgumentParser(description='Perform model evaluation') parser.add_argument('task_name', type=str, help='The name of an evaluation task') parser.add_argument('-a', '--api-server', type=str, default='', help='API server address for inference') parser.add_argument('-j', '--judger-server', type=str, default='', help='Judger server address for evaluation') dataset_choices = [ 'aime', 'aime2024', 'aime2025', 'gpqa', 'gpqa_llada', 'humaneval', 'mbpp', 'ifeval', 'qmsum', 'longbench_qmsum', 'samsum', 'longbench_samsum', 'leval_meetingsumm', 'meetingsumm', 'meeting_summ', 'meetingbank', 'meetingbank_10k', 'arenahard', 'arena', 'code', 'mmlu_pro', 'hle', 'all', ] parser.add_argument('-d', '--datasets', nargs='+', choices=dataset_choices, default=['all'], help=f"List of datasets. Available options: {', '.join(dataset_choices)}. " 'Use "all" to include all datasets.') parser.add_argument('-w', '--work-dir', type=str, default='', help='Output directory of evaluation. If not specified, outputs will not be saved.') parser.add_argument('-r', '--reuse', nargs='?', type=str, const='latest', help='Reuse previous outputs & results, and run any missing jobs presented in the config. ' 'If its argument is not specified, the latest results in the work_dir will be reused. ' 'The argument should also be a specific timestamp, e.g. 20230516_144254') parser.add_argument('-m', '--mode', type=str, help='Running mode selection. ' 'all: complete pipeline including both inference and evaluation (default). ' 'infer: only perform model inference to generate results. ' 'eval: only evaluate previously generated results. ' 'config: generate configuration files without execution.', choices=['all', 'infer', 'eval', 'config'], default='all') args = parser.parse_args() task_name = args.task_name api_server = args.api_server judger_server = args.judger_server datasets = args.datasets mode = args.mode work_dir = args.work_dir # Process server addresses if api_server and not api_server.startswith('http'): api_server = f'http://{api_server}' if judger_server and not judger_server.startswith('http'): judger_server = f'http://{judger_server}' # read config file config = read_config() # update task name in config config = config.replace("TASK_TAG = ''", f"TASK_TAG = '{task_name}'") # update datasets part of config according to args.datasets config = update_datasets(config, datasets) # update api_server part of config according to args.api_server if api_server: config = config.replace("API_SERVER_ADDR = 'http://'", f"API_SERVER_ADDR = '{api_server}'") if judger_server: # update judger_server part of config according to args.judger_server config = config.replace("JUDGER_ADDR = 'http://'", f"JUDGER_ADDR = '{judger_server}'") # perform evaluation perform_evaluation(config, api_server, judger_server, mode, work_dir, args.reuse) if __name__ == '__main__': main()