返回代码索引

sweep.py

批量训练、checkpoint 与状态调度

原文件 · 1446 行 · 56658 字节

train/sweep.py
SHA-256: 694c8588f955f82dc91251e8b94d64abb4bd97bef25b9a9d0974b3211e11d254

1"""Run declarative training sweeps as concurrent isolated processes."""23import argparse4import copy5import csv6import json7import os8import re9import subprocess10import sys11import threading12from collections import defaultdict13from contextlib import contextmanager14from concurrent.futures import (15    FIRST_COMPLETED,16    ProcessPoolExecutor,17    ThreadPoolExecutor,18    as_completed,19    wait,20)21from datetime import datetime22from pathlib import Path2324import yaml252627REPO_ROOT = Path(__file__).resolve().parents[1]282930def _gpu_memory_total_mb(gpu):31    """Best-effort physical-memory query used only to cap new submissions."""32    try:33        result = subprocess.run(34            [35                "nvidia-smi",36                f"--id={int(gpu)}",37                "--query-gpu=memory.total",38                "--format=csv,noheader,nounits",39            ],40            capture_output=True,41            text=True,42            timeout=10,43        )44        if result.returncode == 0:45            return int(result.stdout.strip().splitlines()[0])46    except (OSError, ValueError, subprocess.SubprocessError):47        pass48    return None495051def _load_yaml(path):52    with Path(path).open("r", encoding="utf-8") as file:53        return yaml.safe_load(file) or {}545556def _dump_yaml(path, value):57    with Path(path).open("w", encoding="utf-8") as file:58        yaml.safe_dump(value, file, sort_keys=False, allow_unicode=True)596061def _write_json_atomic(path, payload):62    path = Path(path)63    path.parent.mkdir(parents=True, exist_ok=True)64    temporary = path.with_name(f"{path.name}.{os.getpid()}.tmp")65    with temporary.open("w", encoding="utf-8") as file:66        json.dump(payload, file, ensure_ascii=False, indent=2)67    temporary.replace(path)686970def _safe_name(value):71    value = str(value)72    if not value or any(char not in "abcdefghijklmnopqrstuvwxyzABCDEFGHIJKLMNOPQRSTUVWXYZ0123456789._-" for char in value):73        raise ValueError(f"Unsafe name: {value!r}")74    return value757677def _next_run_dir(config, group_dir):78    run_name = (79        f"{_safe_name(config.get('algorithm_name', 'mappo'))}_"80        f"{_safe_name(config['experiment_name'])}"81    )82    return Path(group_dir) / run_name838485def _job_key(job):86    overrides = job.get("overrides", {}) or {}87    algorithm_name = job.get("algorithm_name", overrides.get("algorithm_name"))88    experiment_name = job["experiment_name"]89    if algorithm_name:90        return _safe_name(f"{algorithm_name}_{experiment_name}")91    return _safe_name(experiment_name)929394class SweepRunner:95    def __init__(self, manifest_path, run_dir, no_eval=False):96        self.manifest_path = Path(manifest_path).resolve()97        self.manifest = _load_yaml(self.manifest_path)98        self.run_dir = Path(run_dir).resolve()99        self.no_eval = no_eval100        self.lock = threading.Lock()101        self.status_path = self.run_dir / "status.json"102        self._eval_task_metadata = {}103        self._eval_task_states = {}104        self._stream_eval_executor = None105        self._stream_eval_results = []106        self._stream_eval_output_root = None107        self._stream_eval_config = None108        self._stream_eval_selectors = []109110        base_path = Path(self.manifest.get("base_config", "config.yaml"))111        if not base_path.is_absolute():112            base_path = self.manifest_path.parent / base_path113        self.base_config_path = base_path.resolve()114        self.base_config = _load_yaml(self.base_config_path)115        self.jobs = self.manifest.get("jobs", [])116        if not self.jobs:117            raise ValueError("Sweep manifest requires a non-empty jobs list.")118119        names = [_job_key(job) for job in self.jobs]120        if len(names) != len(set(names)):121            raise ValueError("algorithm_name/experiment_name job keys must be unique within a sweep.")122123        self.status = {124            "name": self.manifest.get("name", self.manifest_path.stem),125            "manifest": str(self.manifest_path),126            "base_config": str(self.base_config_path),127            "created_at": datetime.now().isoformat(timespec="seconds"),128            "state": "initializing",129            "jobs": {},130            "eval": {"state": "pending"},131        }132133    def _write_status(self):134        with self.lock:135            self._write_status_unlocked()136137    def _write_status_unlocked(self):138        temporary = self.status_path.with_suffix(".json.tmp")139        with temporary.open("w", encoding="utf-8") as file:140            json.dump(self.status, file, ensure_ascii=False, indent=2)141        temporary.replace(self.status_path)142143    def _update_job_status(self, name, values):144        with self.lock:145            self.status["jobs"][name].update(values)146            self._write_status_unlocked()147148    def _touch_eval_heartbeat(self):149        """Publish liveness while an eval subprocess/task is still running."""150        with self.lock:151            eval_status = self.status.setdefault("eval", {})152            self._refresh_eval_episode_progress_unlocked()153            eval_status["heartbeat_at"] = datetime.now().isoformat(timespec="seconds")154            eval_status["heartbeat_seq"] = int(eval_status.get("heartbeat_seq", 0)) + 1155            self._write_status_unlocked()156157    def _refresh_eval_episode_progress_unlocked(self):158        if not self._eval_task_metadata:159            return160        completed_episodes = 0161        total_episodes = 0162        active_tasks = []163        for task_id, metadata in self._eval_task_metadata.items():164            total = int(metadata["total_episodes"])165            total_episodes += total166            state = self._eval_task_states.get(task_id, "pending")167            completed = total if state == "completed" else 0168            progress_path = Path(metadata["progress_path"])169            if progress_path.is_file():170                try:171                    with progress_path.open("r", encoding="utf-8") as file:172                        payload = json.load(file)173                    completed = max(174                        completed,175                        min(total, max(0, int(payload.get("completed_episodes", 0)))),176                    )177                    if state == "pending":178                        state = str(payload.get("state", "running"))179                except (OSError, ValueError, json.JSONDecodeError):180                    pass181            completed_episodes += completed182            if state == "running" and completed < total:183                active_tasks.append(184                    {185                        "job_name": metadata["job_name"],186                        "checkpoint": metadata["checkpoint"],187                        "completed_episodes": completed,188                        "total_episodes": total,189                    }190                )191        eval_status = self.status.setdefault("eval", {})192        eval_status["completed_episodes"] = completed_episodes193        eval_status["total_episodes"] = total_episodes194        eval_status["active_tasks"] = active_tasks195196    @contextmanager197    def _eval_heartbeat(self, interval_seconds):198        interval_seconds = max(1.0, float(interval_seconds))199        stopped = threading.Event()200201        def publish():202            while not stopped.wait(interval_seconds):203                self._touch_eval_heartbeat()204205        self._touch_eval_heartbeat()206        thread = threading.Thread(207            target=publish,208            name="sweep-eval-heartbeat",209            daemon=True,210        )211        thread.start()212        try:213            yield214        finally:215            stopped.set()216            thread.join(timeout=interval_seconds + 1.0)217            self._touch_eval_heartbeat()218219    def prepare(self):220        (self.run_dir / "configs").mkdir(parents=True, exist_ok=True)221        (self.run_dir / "train_logs").mkdir(parents=True, exist_ok=True)222        prepared = []223        for raw_job in self.jobs:224            job = copy.deepcopy(raw_job)225            job_name = _job_key(job)226            experiment_name = _safe_name(job["experiment_name"])227            config = copy.deepcopy(self.base_config)228229            # 应用 sweep 级 training_eval 默认值(可被 job overrides 覆盖)230            training_eval = self.manifest.get("training_eval", {}) or {}231            for key in (232                "use_eval",233                "n_eval_rollout_threads",234                "eval_interval",235                "eval_episodes",236                "eval_seed",237                "eval_reset_mode",238                "eval_deterministic",239                "training_eval_cuda",240            ):241                if key in training_eval:242                    config[key] = training_eval[key]243244            overrides = job.get("overrides", {}) or {}245            config.update(overrides)246            if "algorithm_name" in job:247                config["algorithm_name"] = job["algorithm_name"]248            config["experiment_name"] = experiment_name249            group_name = self.manifest.get(250                "group_name", self.manifest.get("name", self.manifest_path.stem)251            )252            config.setdefault("group_name", group_name)253            config.setdefault("experiment_group", group_name)254            config["cuda"] = int(job["cuda"])255256            config_path = self.run_dir / "configs" / f"{job_name}.yaml"257            expected_run_dir = _next_run_dir(config, self.run_dir)258            config["run_dir"] = str(expected_run_dir)259            _dump_yaml(config_path, config)260            prepared_job = {261                **job,262                "job_name": job_name,263                "experiment_name": experiment_name,264                "cuda": int(job["cuda"]),265                "config": config,266                "config_path": config_path,267                "run_dir": expected_run_dir,268                "log_path": self.run_dir / "train_logs" / f"{job_name}.log",269            }270            prepared.append(prepared_job)271            self.status["jobs"][job_name] = {272                "state": "pending",273                "algorithm_name": config.get("algorithm_name"),274                "experiment_name": experiment_name,275                "cuda": int(job["cuda"]),276                "config": str(config_path),277                "run_dir": str(expected_run_dir),278                "log": str(prepared_job["log_path"]),279            }280        self.status["state"] = "training"281        self._write_status()282        return prepared283284    def _run_job(self, job):285        name = job["job_name"]286        command = [287            sys.executable,288            str(REPO_ROOT / "train" / "train.py"),289            "--config_file",290            str(job["config_path"]),291        ]292        environment = os.environ.copy()293        environment["PYTHONUNBUFFERED"] = "1"294        with job["log_path"].open("w", encoding="utf-8") as log_file:295            process = subprocess.Popen(296                command,297                cwd=REPO_ROOT,298                env=environment,299                stdin=subprocess.DEVNULL,300                stdout=log_file,301                stderr=subprocess.STDOUT,302            )303            self._update_job_status(304                name,305                {306                    "state": "running",307                    "pid": process.pid,308                    "started_at": datetime.now().isoformat(timespec="seconds"),309                },310            )311            return_code = process.wait()312313        actor_path = job["run_dir"] / "models" / "actor.pt"314        if job["config"].get("use_pcfa", False):315            state = "completed" if return_code == 0 else "failed"316        else:317            state = "completed" if return_code == 0 and actor_path.is_file() else "failed"318        self._update_job_status(319            name,320            {321                "state": state,322                "return_code": return_code,323                "actor_checkpoint": str(actor_path),324                "finished_at": datetime.now().isoformat(timespec="seconds"),325            },326        )327        return state == "completed"328329    def _assign_cuda(self, job, cuda):330        """Rewrite a not-yet-started job config when the scheduler moves it to another GPU."""331332        cuda = int(cuda)333        if int(job["config"].get("cuda", job["cuda"])) == cuda:334            return335        job["cuda"] = cuda336        job["config"]["cuda"] = cuda337        _dump_yaml(job["config_path"], job["config"])338        with self.lock:339            self.status["jobs"][job["job_name"]]["cuda"] = cuda340            self._write_status_unlocked()341342    def _run_jobs_scheduled(self, jobs, on_job_finished=None):343        """Run jobs with optional per-GPU limits and GPU0 spillover.344345        Manifest schema used here:346347        scheduling:348          gpu_limits: {0: 2, 1: 3}349          spillover:350            from: 1351            to: 0352            after_jobs: [mappo_capcoal, mappo_delivcoal]353            to_limit: 1354        """355356        scheduling = self.manifest.get("scheduling", {}) or {}357        gpu_limits = {358            int(gpu): int(limit)359            for gpu, limit in (scheduling.get("gpu_limits", {}) or {}).items()360        }361        memory_settings = {362            int(gpu): values or {}363            for gpu, values in (scheduling.get("gpu_memory", {}) or {}).items()364        }365        memory_budgets = {}366        for gpu, values in memory_settings.items():367            total = _gpu_memory_total_mb(gpu)368            if total is None:369                total = int(values.get("assumed_total_mb", 0))370            reserve = int(values.get("reserve_mb", 3072))371            memory_budgets[gpu] = max(0, total - reserve)372        spillover = scheduling.get("spillover", {}) or {}373        spill_from = int(spillover.get("from", -1))374        spill_to = int(spillover.get("to", -1))375        spill_after = set(spillover.get("after_jobs", []) or [])376        spill_to_limit = int(spillover.get("to_limit", 0))377378        pending = list(jobs)379        completed = set()380        results = []381        running = {}382        active_by_gpu = defaultdict(int)383        active_memory_by_gpu = defaultdict(int)384385        def estimated_job_memory(job, gpu):386            settings = memory_settings.get(int(gpu), {})387            return int(388                job.get(389                    "gpu_memory_mb",390                    settings.get("default_job_mb", 1200),391                )392            )393394        def memory_available(job, gpu):395            if int(gpu) not in memory_budgets:396                return True397            estimate = estimated_job_memory(job, gpu)398            return active_memory_by_gpu[int(gpu)] + estimate <= memory_budgets[int(gpu)]399400        def base_limit(gpu):401            return gpu_limits.get(int(gpu), len(jobs))402403        def spillover_ready():404            return bool(spill_after) and spill_after.issubset(completed)405406        def choose_assignment(job):407            original_cuda = int(job["cuda"])408            if (409                active_by_gpu[original_cuda] < base_limit(original_cuda)410                and memory_available(job, original_cuda)411            ):412                return original_cuda413            if (414                spillover_ready()415                and original_cuda == spill_from416                and spill_to_limit > 0417                and active_by_gpu[spill_to] < spill_to_limit418                and memory_available(job, spill_to)419            ):420                return spill_to421            return None422423        with ThreadPoolExecutor(max_workers=len(jobs)) as executor:424            while pending or running:425                launched = False426                for job in list(pending):427                    assigned_cuda = choose_assignment(job)428                    if assigned_cuda is None:429                        continue430                    self._assign_cuda(job, assigned_cuda)431                    active_by_gpu[assigned_cuda] += 1432                    active_memory_by_gpu[assigned_cuda] += estimated_job_memory(433                        job, assigned_cuda434                    )435                    future = executor.submit(self._run_job, job)436                    running[future] = (job, assigned_cuda)437                    pending.remove(job)438                    launched = True439440                if not running:441                    raise RuntimeError("Scheduler has pending jobs but no runnable slot.")442                if launched and pending:443                    continue444445                done, _ = wait(running, return_when=FIRST_COMPLETED)446                for future in done:447                    job, assigned_cuda = running.pop(future)448                    active_by_gpu[assigned_cuda] -= 1449                    active_memory_by_gpu[assigned_cuda] -= estimated_job_memory(450                        job, assigned_cuda451                    )452                    ok = future.result()453                    results.append(ok)454                    completed.add(job["job_name"])455                    if on_job_finished is not None:456                        on_job_finished(job, ok)457458        return results459460    @staticmethod461    def _eval_overrides(config):462        keys = (463            "algorithm_name",464            "trainer_algorithm_name",465            "use_recurrent_policy",466            "use_naive_recurrent_policy",467            "recurrent_N",468            # Actor architecture must be reconstructed exactly before loading469            # a checkpoint.  Omitting these silently rebuilt capacity-control470            # MAPPO checkpoints with the default 64-wide MLP at evaluation.471            "hidden_size",472            "layer_N",473            "use_feature_normalization",474            "use_orthogonal",475            "use_ReLU",476            "use_gat",477            "graph_model",478            "gat_mode",479            "gat_hidden_dim",480            "gat_out_dim",481            "hatt_hidden_dim",482            "hatt_num_heads",483            "hatt_dropout",484            "hatt_capability_scale",485            "hatt_shared_task_obs",486            "hatt_use_aa_attention",487            "hatt_use_coalition_context",488            "hatt_candidate_aa_mode",489            "hatt_aa_task_context",490            "qmix_agent_hidden_dim",491            "qmix_use_recurrent_agent",492            "qmix_transition_mode",493            "qmix_mixer_hidden_dim",494            "qmix_hypernet_hidden_dim",495            "qmix_double_q",496            "qmix_optimizer",497            "qmix_rmsprop_alpha",498            "env_name",499            "scenario_name",500            "reward_mode",501            "coalition_delivery_reward_mode",502            "explore_action_mode",503            "num_explore_actions",504            "explore_step_size",505            "explore_random_jitter",506            "sector_redecision_on_waypoint",507            "explore_event_redecision",508            "sector_min_leg_mobility_steps",509            "delivery_tasks_initially_discovered",510            "mask_explore_actions",511            "idle_time_cost_mode",512            "explore_observer_relative_geometry",513            "explore_coverage_features",514            "explore_coverage_grid_resolution",515            "reconfiguration_mode",516            "reconfiguration_interval",517            "reconfiguration_urgency_threshold",518            "reconfiguration_urgency_aggregation",519            "reconfiguration_switch_cost",520            "reconfiguration_gate_init",521            "reconfiguration_gate_floor",522            "reconfiguration_gate_parameterization",523            "reconfiguration_gate_override",524            "reconfiguration_decision_coef",525            "reconfiguration_context_features",526            "reconfiguration_context_dim",527        )528        return {key: config[key] for key in keys if key in config}529530    @staticmethod531    def _eval_suite_overrides(config):532        """Return environment settings that define an evaluation suite.533534        These values cannot live in a model manifest because ``train/eval.py``535        deliberately forbids a model from changing the shared scenario.  They536        still need to be restored from training, however, or a checkpoint may537        be evaluated against the dimensions from the sweep's base config.538        """539540        keys = (541            "map_size",542            "num_uav",543            "num_ugv",544            "uav_perception_rg",545            "ugv_perception_rg",546            "target_num",547            "max_steps",548            "episode_time_limit",549            "episode_length",550            "cap_dim",551            "rewards_config_file",552            "gymrt2a_map_size",553            "gymrt2a_num_agents",554            "gymrt2a_objects",555            "gymrt2a_view_range",556            "gymrt2a_comm_range",557            "gymrt2a_share_intention",558        )559        return {key: config[key] for key in keys if key in config}560561    @classmethod562    def _common_eval_suite_overrides(cls, jobs):563        """Require all jobs in a shared evaluation suite to use one scenario."""564565        common = None566        common_job = None567        for job in jobs:568            current = cls._eval_suite_overrides(job["config"])569            if common is None:570                common = current571                common_job = job["job_name"]572            elif current != common:573                differing = sorted(574                    key575                    for key in set(common) | set(current)576                    if common.get(key) != current.get(key)577                )578                raise ValueError(579                    "Shared evaluation requires identical suite settings; "580                    f"{common_job} and {job['job_name']} differ in {differing}"581                )582        return common or {}583584    def _run_eval(self, jobs):585        eval_settings = self.manifest.get("eval", {}) or {}586        if self.no_eval or not eval_settings.get("enabled", True):587            self.status["eval"] = {"state": "skipped"}588            self._write_status()589            return True590591        if eval_settings.get("all_checkpoints", False):592            return self._run_all_checkpoints_eval(jobs)593594        return self._run_final_actor_eval(jobs)595596    def _streaming_final_eval_enabled(self):597        """Whether selected final checkpoints should start as each job finishes."""598599        eval_settings = self.manifest.get("eval", {}) or {}600        return bool(601            not self.no_eval602            and eval_settings.get("enabled", True)603            and eval_settings.get("all_checkpoints", False)604            and eval_settings.get("checkpoint_selectors")605            and eval_settings.get("start_on_job_completion", True)606        )607608    @staticmethod609    def _selected_checkpoint_files(job, selectors):610        """Resolve a small semantic checkpoint set such as last/best/p90."""611612        models_dir = job["run_dir"] / "models"613        result = []614        for raw_selector in selectors:615            selector = str(raw_selector).lower()616            checkpoint = None617            if selector == "last":618                checkpoint = models_dir / "actor.pt"619            elif selector == "latest":620                checkpoint = models_dir / "actor_eval_latest.pt"621            elif selector == "best":622                checkpoint = models_dir / "actor_eval_best.pt"623            elif re.fullmatch(r"p\d{1,3}", selector):624                percentage = int(selector[1:])625                if not 0 <= percentage <= 100:626                    raise ValueError(f"Invalid checkpoint selector: {raw_selector!r}")627                episode_files = []628                for path in models_dir.glob("actor_ep*.pt"):629                    match = re.fullmatch(r"actor_ep(\d+)\.pt", path.name)630                    if match:631                        episode_files.append((int(match.group(1)), path))632                if episode_files:633                    final_episode = int(job["config"].get("max_train_episodes", 0))634                    if final_episode <= 0:635                        final_episode = max(item[0] for item in episode_files)636                    target = final_episode * percentage / 100.0637                    checkpoint = min(638                        episode_files, key=lambda item: (abs(item[0] - target), item[0])639                    )[1]640            else:641                raise ValueError(f"Unknown checkpoint selector: {raw_selector!r}")642            if checkpoint is not None and checkpoint.is_file():643                result.append((selector, checkpoint))644        return result645646    @staticmethod647    def _write_csv(records, path):648        """Write a list of dict records to a CSV file."""649        if not records:650            Path(path).write_text("", encoding="utf-8")651            return652        fields = []653        for record in records:654            for key in record:655                if key not in fields:656                    fields.append(key)657        with Path(path).open("w", newline="", encoding="utf-8") as f:658            writer = csv.DictWriter(f, fieldnames=fields)659            writer.writeheader()660            writer.writerows(records)661662    @staticmethod663    def _load_json_if_exists(path):664        path = Path(path)665        if not path.exists():666            return None667        with path.open("r", encoding="utf-8") as file:668            return json.load(file)669670    @staticmethod671    def _parse_training_eval_log(log_path):672        pattern = re.compile(673            r"eval:\s+episodes=(?P<episodes>\d+),\s+"674            r"success=(?P<success>[-+0-9.]+)%,\s+"675            r"reward=(?P<reward>[-+0-9.]+)[^,]*,\s+"676            r"length=(?P<length>[-+0-9.]+)[^,]*,\s+"677            r"successful_length=(?P<successful_length>[-+0-9.]+|nan|NA)"678        )679        records = []680        for line in Path(log_path).read_text(encoding="utf-8", errors="replace").splitlines():681            match = pattern.search(line)682            if not match:683                continue684            successful_length = match.group("successful_length")685            records.append(686                {687                    "eval_episodes": int(match.group("episodes")),688                    "success": float(match.group("success")) / 100.0,689                    "episode_reward": float(match.group("reward")),690                    "episode_length": float(match.group("length")),691                    "successful_length": (692                        None if successful_length in ("nan", "NA") else float(successful_length)693                    ),694                }695            )696        return records697698    @staticmethod699    def _training_eval_summary(payload):700        if not payload:701            return {}702        summary = payload.get("summary")703        if isinstance(summary, dict):704            flattened = dict(summary)705        else:706            flattened = dict(payload)707        if "total_num_steps" in payload:708            flattened["total_num_steps"] = payload["total_num_steps"]709        if "env_episode" in payload:710            flattened["env_episode"] = payload["env_episode"]711        return flattened712713    def _write_post_summary(self, jobs):714        records = []715        for job in jobs:716            eval_records = self._parse_training_eval_log(job["log_path"])717            last_eval = eval_records[-1] if eval_records else {}718            best_eval = {}719            if eval_records:720                best_eval = max(721                    eval_records,722                    key=lambda item: (723                        item.get("success", 0.0),724                        -item.get("episode_length", float("inf")),725                        item.get("episode_reward", float("-inf")),726                    ),727                )728729            eval_best = self._training_eval_summary(730                self._load_json_if_exists(job["run_dir"] / "models" / "eval_best.json")731            )732            eval_latest = self._training_eval_summary(733                self._load_json_if_exists(job["run_dir"] / "models" / "eval_latest.json")734            )735736            record = {737                "job_name": job["job_name"],738                "algorithm_name": job["config"].get("algorithm_name"),739                "experiment_name": job["experiment_name"],740                "reward_mode": job["config"].get("reward_mode"),741                "graph_model": job["config"].get("graph_model"),742                "use_gat": job["config"].get("use_gat"),743                "cuda": job["config"].get("cuda"),744                "run_dir": str(job["run_dir"]),745                "train_log": str(job["log_path"]),746            }747            for prefix, source in (748                ("last_eval", last_eval),749                ("best_log_eval", best_eval),750                ("saved_best_eval", eval_best),751                ("saved_latest_eval", eval_latest),752            ):753                for key in (754                    "success",755                    "episode_reward",756                    "episode_length",757                    "successful_length",758                    "successful_episode_length",759                    "task_completion_ratio",760                    "sent_percentage",761                    "tasks_completed",762                    "step",763                    "episode",764                    "total_num_steps",765                    "env_episode",766                ):767                    if key in source:768                        record[f"{prefix}_{key}"] = source[key]769            records.append(record)770771        csv_path = self.run_dir / "post_summary.csv"772        json_path = self.run_dir / "post_summary.json"773        self._write_csv(records, csv_path)774        with json_path.open("w", encoding="utf-8") as file:775            json.dump(records, file, ensure_ascii=False, indent=2)776        self.status["post_summary"] = {777            "csv": str(csv_path),778            "json": str(json_path),779            "written_at": datetime.now().isoformat(timespec="seconds"),780        }781        self._write_status()782        return records783784    @staticmethod785    def _run_one_checkpoint_eval(task):786        (787            task_id, job_name, ckpt_name, ckpt_path, manifest_path,788            task_output_root, eval_config_path, progress_path,789        ) = task790        _write_json_atomic(791            progress_path,792            {793                "state": "running",794                "completed_episodes": 0,795                "total_episodes": int(_load_yaml(eval_config_path).get("eval_episodes", 0)),796                "updated_at": datetime.now().astimezone().isoformat(timespec="seconds"),797            },798        )799        command = [800            sys.executable,801            str(REPO_ROOT / "train" / "eval.py"),802            "--config_file",803            str(eval_config_path),804            "--models_file",805            str(manifest_path),806            "--output_root",807            str(task_output_root),808            "--progress_file",809            str(progress_path),810        ]811        eval_config = _load_yaml(eval_config_path)812        guard = eval_config.get("gpu_thermal_guard", {}) or {}813        if guard.get("enabled", False):814            gpu = int(eval_config.get("cuda", 0))815            command = [816                sys.executable,817                str(REPO_ROOT / "scripts" / "gpu_thermal_guard.py"),818                "--gpu", str(gpu),819                "--pause-at", str(int(guard.get("pause_at_c", 93))),820                "--resume-at", str(int(guard.get("resume_at_c", 85))),821                "--poll-seconds", str(float(guard.get("poll_seconds", 2))),822                "--safe-readings", str(int(guard.get("safe_readings", 3))),823                "--query-failures", str(int(guard.get("query_failures", 2))),824                "--status-file", str(Path(progress_path).with_name("thermal_status.json")),825                "--",826                *command,827            ]828        environment = os.environ.copy()829        environment["PYTHONUNBUFFERED"] = "1"830        proc = subprocess.run(831            command,832            cwd=REPO_ROOT,833            env=environment,834            capture_output=True,835            text=True,836        )837        output_dir = None838        for line in proc.stdout.splitlines():839            if "Evaluation suite written to" in line:840                output_dir = line.split("Evaluation suite written to")[-1].strip()841        return {842            "task_id": task_id,843            "job_name": job_name,844            "ckpt_name": ckpt_name,845            "ckpt_path": ckpt_path,846            "returncode": proc.returncode,847            "output_dir": output_dir,848            "stdout": proc.stdout,849            "stderr": proc.stderr,850        }851852    def _start_streaming_final_eval(self, jobs):853        """Plan final eval up front, then accept checkpoints job by job."""854855        eval_settings = self.manifest.get("eval", {}) or {}856        selectors = [857            str(selector).lower()858            for selector in eval_settings.get("checkpoint_selectors", [])859        ]860        if len(selectors) != len(set(selectors)):861            raise ValueError("eval.checkpoint_selectors must not contain duplicates")862863        eval_config = copy.deepcopy(self.base_config)864        eval_config.update(eval_settings.get("overrides", {}) or {})865        eval_config["cuda"] = int(eval_settings.get("cuda", 0))866        output_root = (867            self.run_dir868            / "eval_results"869            / f"all-checkpoints-{datetime.now().strftime('%Y%m%d-%H%M%S')}"870        )871        output_root.mkdir(parents=True, exist_ok=True)872873        self._stream_eval_results = []874        self._stream_eval_output_root = output_root875        self._stream_eval_config = eval_config876        self._stream_eval_selectors = selectors877        eval_episodes = int(eval_config.get("eval_episodes", 0))878        self._eval_task_metadata = {}879        self._eval_task_states = {}880        for job in jobs:881            for selector in selectors:882                task_id = f"{job['job_name']}:{selector}"883                task_output_root = output_root / job["job_name"] / selector884                self._eval_task_metadata[task_id] = {885                    "job_name": job["job_name"],886                    "checkpoint": selector,887                    "progress_path": str(task_output_root / "progress.json"),888                    "total_episodes": eval_episodes,889                }890                self._eval_task_states[task_id] = "pending"891892        heartbeat_interval = float(893            eval_settings.get("heartbeat_interval_seconds", 15)894        )895        self.status["eval"] = {896            "state": "running_all_checkpoints",897            "pid": None,898            "start_policy": "on_job_completion",899            "cuda": int(eval_settings.get("cuda", 0)),900            "output_root": str(output_root),901            "log": str(self.run_dir / "eval_all_checkpoints.log"),902            "started_at": datetime.now().isoformat(timespec="seconds"),903            "total_tasks": len(self._eval_task_metadata),904            "planned_tasks": len(self._eval_task_metadata),905            "submitted_tasks": 0,906            "completed_tasks": 0,907            "failed_tasks": 0,908            "heartbeat_interval_seconds": heartbeat_interval,909        }910        self._refresh_eval_episode_progress_unlocked()911        self._write_status()912        self._stream_eval_executor = ThreadPoolExecutor(913            max_workers=max(1, int(eval_settings.get("max_workers", 4)))914        )915        return heartbeat_interval916917    def _build_streaming_eval_tasks(self, job):918        """Resolve and materialize one completed job's selected eval tasks."""919920        eval_config = copy.deepcopy(self._stream_eval_config)921        eval_config.update(self._eval_suite_overrides(job["config"]))922        eval_config_path = self.run_dir / "configs" / f"eval_{job['job_name']}.yaml"923        _dump_yaml(eval_config_path, eval_config)924        selected = dict(925            self._selected_checkpoint_files(job, self._stream_eval_selectors)926        )927        tasks = []928        for selector in self._stream_eval_selectors:929            ckpt_file = selected.get(selector)930            if ckpt_file is None:931                continue932            task_output_root = (933                self._stream_eval_output_root / job["job_name"] / selector934            )935            task_output_root.mkdir(parents=True, exist_ok=True)936            manifest_name = f"{job['job_name']}_{selector}"937            manifest = {938                "name": manifest_name,939                "output_root": str(task_output_root),940                "models": [941                    {942                        "name": manifest_name,943                        "checkpoint": str(ckpt_file),944                        "overrides": self._eval_overrides(job["config"]),945                    }946                ],947            }948            manifest_path = task_output_root / "manifest.yaml"949            _dump_yaml(manifest_path, manifest)950            tasks.append(951                (952                    f"{job['job_name']}:{selector}",953                    job["job_name"],954                    selector,955                    str(ckpt_file),956                    str(manifest_path),957                    str(task_output_root),958                    str(eval_config_path),959                    str(task_output_root / "progress.json"),960                )961            )962        return tasks963964    def _record_streaming_eval_result(self, result):965        with self.lock:966            self._stream_eval_results.append(result)967            self._eval_task_states[result["task_id"]] = (968                "completed" if result["returncode"] == 0 else "failed"969            )970            eval_status = self.status["eval"]971            eval_status["completed_tasks"] = len(self._stream_eval_results)972            eval_status["failed_tasks"] = sum(973                item["returncode"] != 0 for item in self._stream_eval_results974            )975            self._refresh_eval_episode_progress_unlocked()976            self._write_status_unlocked()977        status = "OK" if result["returncode"] == 0 else "FAILED"978        print(f"[{status}] {result['job_name']} {result['ckpt_name']}")979        if result["returncode"] != 0 and result.get("stderr"):980            print(result["stderr"][-500:])981982    def _streaming_eval_done(self, future, task):983        try:984            result = future.result()985        except Exception as error:  # keep the planned unit visible as failed986            result = {987                "task_id": task[0],988                "job_name": task[1],989                "ckpt_name": task[2],990                "ckpt_path": task[3],991                "returncode": 1,992                "output_dir": None,993                "stdout": "",994                "stderr": repr(error),995            }996        self._record_streaming_eval_result(result)997998    def _submit_streaming_final_eval(self, job, training_ok):999        """Submit this job immediately; failed/missing units remain in denominator."""10001001        tasks = self._build_streaming_eval_tasks(job) if training_ok else []1002        tasks_by_id = {task[0]: task for task in tasks}1003        for selector in self._stream_eval_selectors:1004            task_id = f"{job['job_name']}:{selector}"1005            task = tasks_by_id.get(task_id)1006            if task is None:1007                reason = (1008                    "training failed before final evaluation"1009                    if not training_ok1010                    else f"selected checkpoint {selector!r} was not found"1011                )1012                self._record_streaming_eval_result(1013                    {1014                        "task_id": task_id,1015                        "job_name": job["job_name"],1016                        "ckpt_name": selector,1017                        "ckpt_path": None,1018                        "returncode": 1,1019                        "output_dir": None,1020                        "stdout": "",1021                        "stderr": reason,1022                    }1023                )1024                continue1025            with self.lock:1026                self.status["eval"]["submitted_tasks"] += 11027                self._write_status_unlocked()1028            future = self._stream_eval_executor.submit(1029                self._run_one_checkpoint_eval, task1030            )1031            future.add_done_callback(1032                lambda completed, submitted_task=task: self._streaming_eval_done(1033                    completed, submitted_task1034                )1035            )10361037    def _finalize_checkpoint_eval_results(self, results, output_root):1038        """Aggregate completed checkpoint tasks and publish their terminal state."""10391040        per_job = defaultdict(list)1041        for result in results:1042            if result["returncode"] != 0 or not result["output_dir"]:1043                continue1044            summary_path = Path(result["output_dir"]) / "summary.json"1045            if not summary_path.exists():1046                continue1047            with summary_path.open("r", encoding="utf-8") as file:1048                summary = json.load(file)1049            for record in summary.get("models", []):1050                per_job[result["job_name"]].append(1051                    {1052                        "checkpoint": result["ckpt_name"],1053                        "checkpoint_path": result["ckpt_path"],1054                        **record,1055                    }1056                )10571058        best_records = []1059        for job_name, records in per_job.items():1060            records.sort(1061                key=lambda record: (1062                    record["success"],1063                    record["episode_reward"],1064                ),1065                reverse=True,1066            )1067            best = records[0]1068            best_records.append(best)1069            print(1070                f"Best checkpoint for {job_name}: {best['checkpoint']} "1071                f"(success={best['success']:.1%}, reward={best['episode_reward']:.2f})"1072            )10731074        aggregate_best_path = output_root / "aggregate_best.json"1075        with aggregate_best_path.open("w", encoding="utf-8") as file:1076            json.dump(best_records, file, ensure_ascii=False, indent=2)1077        aggregate_all_path = output_root / "aggregate_all.json"1078        with aggregate_all_path.open("w", encoding="utf-8") as file:1079            json.dump(dict(per_job), file, ensure_ascii=False, indent=2)1080        self._write_csv(best_records, self.run_dir / "eval_summary_best.csv")10811082        all_ok = bool(results) and all(1083            result["returncode"] == 0 for result in results1084        )1085        self.status["eval"].update(1086            {1087                "state": "completed" if all_ok else "partial_failure",1088                "return_code": 0 if all_ok else 1,1089                "aggregate_best": str(aggregate_best_path),1090                "aggregate_all": str(aggregate_all_path),1091                "finished_at": datetime.now().isoformat(timespec="seconds"),1092            }1093        )1094        self._write_status()1095        return all_ok10961097    def _finish_streaming_final_eval(self):1098        self._stream_eval_executor.shutdown(wait=True)1099        self._stream_eval_executor = None1100        return self._finalize_checkpoint_eval_results(1101            list(self._stream_eval_results), self._stream_eval_output_root1102        )11031104    def _run_all_checkpoints_eval(self, jobs):1105        eval_settings = self.manifest.get("eval", {}) or {}1106        eval_config = copy.deepcopy(self.base_config)1107        eval_config.update(eval_settings.get("overrides", {}) or {})1108        eval_config["cuda"] = int(eval_settings.get("cuda", 0))1109        max_workers = int(eval_settings.get("max_workers", 4))1110        output_root = (1111            self.run_dir1112            / "eval_results"1113            / f"all-checkpoints-{datetime.now().strftime('%Y%m%d-%H%M%S')}"1114        )1115        output_root.mkdir(parents=True, exist_ok=True)11161117        tasks = []1118        selectors = eval_settings.get("checkpoint_selectors")1119        for job in jobs:1120            # Each all-checkpoint task has its own process, so preserve that1121            # job's training scenario instead of assuming base_config matches.1122            job_eval_config = copy.deepcopy(eval_config)1123            job_eval_config.update(self._eval_suite_overrides(job["config"]))1124            eval_config_path = (1125                self.run_dir / "configs" / f"eval_{job['job_name']}.yaml"1126            )1127            _dump_yaml(eval_config_path, job_eval_config)1128            models_dir = job["run_dir"] / "models"1129            if selectors:1130                checkpoint_files = self._selected_checkpoint_files(job, selectors)1131            else:1132                checkpoint_files = [1133                    (path.stem, path) for path in sorted(models_dir.glob("actor*.pt"))1134                ]1135            for ckpt_label, ckpt_file in checkpoint_files:1136                task_output_root = output_root / job["job_name"] / ckpt_file.stem1137                task_output_root.mkdir(parents=True, exist_ok=True)1138                manifest_name = f"{job['job_name']}_{ckpt_label}"1139                manifest = {1140                    "name": manifest_name,1141                    "output_root": str(task_output_root),1142                    "models": [1143                        {1144                            "name": manifest_name,1145                            "checkpoint": str(ckpt_file),1146                            "overrides": self._eval_overrides(job["config"]),1147                        }1148                    ],1149                }1150                manifest_path = task_output_root / "manifest.yaml"1151                _dump_yaml(manifest_path, manifest)1152                tasks.append(1153                    (1154                        f"{job['job_name']}:{ckpt_label}",1155                        job["job_name"],1156                        ckpt_label,1157                        str(ckpt_file),1158                        str(manifest_path),1159                        str(task_output_root),1160                        str(eval_config_path),1161                        str(task_output_root / "progress.json"),1162                    )1163                )11641165        eval_log_path = self.run_dir / "eval_all_checkpoints.log"1166        self.status["eval"] = {1167            "state": "running_all_checkpoints",1168            "pid": None,1169            "config": str(eval_config_path),1170            "output_root": str(output_root),1171            "log": str(eval_log_path),1172            "started_at": datetime.now().isoformat(timespec="seconds"),1173            "total_tasks": len(tasks),1174            "completed_tasks": 0,1175            "heartbeat_interval_seconds": float(1176                eval_settings.get("heartbeat_interval_seconds", 15)1177            ),1178        }1179        eval_episodes = int(eval_config.get("eval_episodes", 0))1180        self._eval_task_metadata = {1181            task[0]: {1182                "job_name": task[1],1183                "checkpoint": task[2],1184                "progress_path": task[7],1185                "total_episodes": eval_episodes,1186            }1187            for task in tasks1188        }1189        self._eval_task_states = {task[0]: "pending" for task in tasks}1190        self._refresh_eval_episode_progress_unlocked()1191        self._write_status()11921193        results = []1194        heartbeat_interval = self.status["eval"]["heartbeat_interval_seconds"]1195        with self._eval_heartbeat(heartbeat_interval):1196            with ProcessPoolExecutor(max_workers=max_workers) as executor:1197                futures = {1198                    executor.submit(self._run_one_checkpoint_eval, task): task1199                    for task in tasks1200                }1201                for future in as_completed(futures):1202                    result = future.result()1203                    results.append(result)1204                    with self.lock:1205                        self._eval_task_states[result["task_id"]] = (1206                            "completed" if result["returncode"] == 0 else "failed"1207                        )1208                        self.status["eval"]["completed_tasks"] = len(results)1209                        self._refresh_eval_episode_progress_unlocked()1210                        self._write_status_unlocked()1211                    status = "OK" if result["returncode"] == 0 else "FAILED"1212                    print(f"[{status}] {result['job_name']} {result['ckpt_name']}")1213                    if result["returncode"] != 0:1214                        print(result["stderr"][-500:])12151216        return self._finalize_checkpoint_eval_results(results, output_root)12171218    def _run_final_actor_eval(self, jobs):1219        eval_settings = self.manifest.get("eval", {}) or {}1220        eval_config = copy.deepcopy(self.base_config)1221        eval_config.update(eval_settings.get("overrides", {}) or {})1222        eval_config.update(self._common_eval_suite_overrides(jobs))1223        eval_config["cuda"] = int(eval_settings.get("cuda", 0))1224        eval_config_path = self.run_dir / "configs" / "eval.yaml"1225        _dump_yaml(eval_config_path, eval_config)12261227        models = []1228        for job in jobs:1229            models.append(1230                {1231                    "name": job["job_name"],1232                    "checkpoint": str(job["run_dir"] / "models"),1233                    "overrides": self._eval_overrides(job["config"]),1234                }1235            )1236        eval_manifest = {1237            "name": f"{self.manifest.get('name', 'sweep')}-eval",1238            "output_root": str(self.run_dir / "eval_results"),1239            "models": models,1240        }1241        eval_manifest_path = self.run_dir / "configs" / "eval_models.yaml"1242        _dump_yaml(eval_manifest_path, eval_manifest)1243        eval_log_path = self.run_dir / "eval.log"1244        command = [1245            sys.executable,1246            str(REPO_ROOT / "train" / "eval.py"),1247            "--config_file",1248            str(eval_config_path),1249            "--models_file",1250            str(eval_manifest_path),1251        ]1252        environment = os.environ.copy()1253        environment["PYTHONUNBUFFERED"] = "1"1254        with eval_log_path.open("w", encoding="utf-8") as log_file:1255            process = subprocess.Popen(1256                command,1257                cwd=REPO_ROOT,1258                env=environment,1259                stdin=subprocess.DEVNULL,1260                stdout=subprocess.PIPE,1261                stderr=subprocess.STDOUT,1262                text=True,1263            )1264            output_lines = []1265            self.status["eval"] = {1266                "state": "running",1267                "pid": process.pid,1268                "config": str(eval_config_path),1269                "models": str(eval_manifest_path),1270                "log": str(eval_log_path),1271                "started_at": datetime.now().isoformat(timespec="seconds"),1272                "heartbeat_interval_seconds": float(1273                    eval_settings.get("heartbeat_interval_seconds", 15)1274                ),1275            }1276            self._write_status()1277            with self._eval_heartbeat(1278                self.status["eval"]["heartbeat_interval_seconds"]1279            ):1280                for line in process.stdout:1281                    output_lines.append(line)1282                    log_file.write(line)1283                    log_file.flush()1284                return_code = process.wait()12851286        output_dir = None1287        for line in output_lines:1288            if "Evaluation suite written to" in line:1289                output_dir = line.split("Evaluation suite written to")[-1].strip()1290        if output_dir:1291            src_csv = Path(output_dir) / "summary.csv"1292            dst_csv = self.run_dir / "eval_summary_final.csv"1293            if src_csv.exists():1294                dst_csv.write_bytes(src_csv.read_bytes())12951296        self.status["eval"].update(1297            {1298                "state": "completed" if return_code == 0 else "failed",1299                "return_code": return_code,1300                "finished_at": datetime.now().isoformat(timespec="seconds"),1301            }1302        )1303        self._write_status()1304        return return_code == 013051306    def run(self):1307        jobs = self.prepare()1308        streaming_eval = self._streaming_final_eval_enabled()1309        callback = None1310        heartbeat_interval = None1311        if streaming_eval:1312            heartbeat_interval = self._start_streaming_final_eval(jobs)1313            callback = self._submit_streaming_final_eval13141315        def run_training_jobs():1316            if self.manifest.get("scheduling"):1317                return self._run_jobs_scheduled(jobs, callback)1318            run_results = []1319            with ThreadPoolExecutor(max_workers=len(jobs)) as executor:1320                futures = {1321                    executor.submit(self._run_job, job): job for job in jobs1322                }1323                for future in as_completed(futures):1324                    job = futures[future]1325                    ok = future.result()1326                    run_results.append(ok)1327                    if callback is not None:1328                        callback(job, ok)1329            return run_results13301331        if streaming_eval:1332            with self._eval_heartbeat(heartbeat_interval):1333                results = run_training_jobs()1334                if all(results):1335                    self.status["state"] = "evaluating"1336                    self._write_status()1337                eval_ok = self._finish_streaming_final_eval()1338        else:1339            results = run_training_jobs()1340            eval_ok = None1341        if not all(results):1342            self.status["state"] = "training_failed"1343            if not streaming_eval:1344                self.status["eval"] = {"state": "blocked_by_training_failure"}1345            self.status["finished_at"] = datetime.now().isoformat(timespec="seconds")1346            self._write_status()1347            return 11348        if not streaming_eval:1349            self.status["state"] = "evaluating"1350            self._write_status()1351            eval_ok = self._run_eval(jobs)1352        self._write_post_summary(jobs)1353        self.status["state"] = "completed" if eval_ok else "eval_failed"1354        self.status["finished_at"] = datetime.now().isoformat(timespec="seconds")1355        self._write_status()1356        return 0 if eval_ok else 1135713581359def _default_run_dir(manifest_path, manifest):1360    root = Path(manifest.get("output_root", "runs"))1361    if not root.is_absolute():1362        root = REPO_ROOT / root13631364    base_path = Path(manifest.get("base_config", "config.yaml"))1365    if not base_path.is_absolute():1366        base_path = Path(manifest_path).resolve().parent / base_path1367    base_config = _load_yaml(base_path) if base_path.exists() else {}13681369    name = _safe_name(1370        manifest.get("group_name", manifest.get("name", Path(manifest_path).stem))1371    )1372    env_name = _safe_name(manifest.get("env_name", base_config.get("env_name", "MyEnv")))1373    timestamp = datetime.now().strftime("%Y%m%d-%H%M")1374    candidate = root / f"{timestamp}_{env_name}_{name}"1375    if not candidate.exists():1376        return candidate1377    for index in range(2, 1000):1378        deduped = root / f"{timestamp}_{env_name}_{name}-{index}"1379        if not deduped.exists():1380            return deduped1381    raise RuntimeError(f"Could not allocate a unique sweep directory for {candidate}")138213831384def main(args=None):1385    parser = argparse.ArgumentParser()1386    parser.add_argument("--sweep_file", required=True)1387    parser.add_argument("--run_dir")1388    parser.add_argument("--detach", action="store_true")1389    parser.add_argument("--dry_run", action="store_true")1390    parser.add_argument("--no_eval", action="store_true")1391    options = parser.parse_args(args)13921393    manifest_path = Path(options.sweep_file).resolve()1394    manifest = _load_yaml(manifest_path)1395    run_dir = (1396        Path(options.run_dir).resolve()1397        if options.run_dir1398        else _default_run_dir(manifest_path, manifest).resolve()1399    )1400    run_dir.mkdir(parents=True, exist_ok=bool(options.run_dir))14011402    if options.detach:1403        log_path = run_dir / "supervisor.log"1404        command = [1405            sys.executable,1406            str(Path(__file__).resolve()),1407            "--sweep_file",1408            str(manifest_path),1409            "--run_dir",1410            str(run_dir),1411        ]1412        if options.no_eval:1413            command.append("--no_eval")1414        with log_path.open("w", encoding="utf-8") as log_file:1415            process = subprocess.Popen(1416                command,1417                cwd=REPO_ROOT,1418                stdin=subprocess.DEVNULL,1419                stdout=log_file,1420                stderr=subprocess.STDOUT,1421                start_new_session=True,1422            )1423        (run_dir / "supervisor.pid").write_text(1424            f"{process.pid}\n", encoding="utf-8"1425        )1426        print(f"Detached sweep pid={process.pid} run_dir={run_dir}")1427        return 014281429    runner = SweepRunner(manifest_path, run_dir, no_eval=options.no_eval)1430    if options.dry_run:1431        jobs = runner.prepare()1432        for job in jobs:1433            print(1434                f"cuda={job['cuda']} job={job['job_name']} "1435                f"algorithm={job['config'].get('algorithm_name')} "1436                f"experiment={job['experiment_name']} "1437                f"run_dir={job['run_dir']}"1438            )1439        runner.status["state"] = "dry_run"1440        runner._write_status()1441        return 01442    return runner.run()144314441445if __name__ == "__main__":1446    raise SystemExit(main())