holy fuking shit
This commit is contained in:
+382
-107
@@ -8,13 +8,31 @@
|
||||
当前 HTTP 执行路径仍由高层宏动作终点决定,不把八方向推演当正式收益。
|
||||
|
||||
落盘保证(长训必需):
|
||||
- ``--checkpoint-every`` 每 N 个环境步在 ``<模型目录>/checkpoints/`` 落一个可续训
|
||||
的 checkpoint(zip + 同名 json 元数据),只保留最近 ``--checkpoint-keep`` 个;
|
||||
- 训练前端的「停止」是对**进程组**发 SIGTERM,因此 subproc worker 在 :func:`_make_env`
|
||||
里显式忽略 SIGTERM/SIGINT,主进程收到信号后在 step 边界收尾并保存模型,
|
||||
不会被 worker 先行退出打断;
|
||||
- 任何异常(含被强杀之外的失败)都会先落一份 ``*_emergency.zip`` 再抛出,
|
||||
不再出现"跑了很久什么都没有"。
|
||||
- **急停可恢复**:``--recovery-every``(默认 2000 环境步)把一份滚动恢复点
|
||||
``<模型目录>/checkpoints/<stem>_latest.zip`` 原子覆盖写;训练开始前先写一份
|
||||
起步基线。因此即使进程被 SIGKILL / ``taskkill /F`` 强杀(没有任何 Python
|
||||
收尾机会),也**最多只丢 ``--recovery-every`` 个环境步**,且磁盘上始终有一份
|
||||
完整、可加载、可 ``--resume`` 的产物;
|
||||
- **原子落盘**:模型 zip、旁挂 json 与 ``<run_dir>/resume.json`` 都是"临时文件 +
|
||||
``os.replace``",保存途中被杀不会留下半截文件,也不会破坏上一份完整快照;
|
||||
- ``--checkpoint-every`` 每 N 个环境步在 ``<模型目录>/checkpoints/`` 另留一份历史
|
||||
checkpoint(``<stem>_<步数>.zip``),只保留最近 ``--checkpoint-keep`` 个,供
|
||||
返祖对比与多档恢复;
|
||||
- ``<run_dir>/resume.json`` 是恢复的**权威指针**:无论上一刻是滚动恢复点、历史
|
||||
checkpoint、优雅收尾还是异常兜底,它总指向一份可续训产物,并记录增量语义
|
||||
(``timesteps`` 是追加步数)与生效过的 PPO 超参;
|
||||
- ``--control-file`` 是训练前端的**跨平台**控制通道:前端原子写 JSON
|
||||
(``paused`` / ``stop``),本脚本每个 step 轮询。暂停时训练在 step 边界挂起,
|
||||
停止时先保存模型与元数据再退出;
|
||||
- 信号(SIGTERM/SIGINT,Windows 上另有 SIGBREAK)仍可用:直接 kill 本脚本或
|
||||
前端发进程组信号时同样走优雅收尾;subproc worker 在 :func:`_make_env` 里忽略
|
||||
这些信号,保证它们活到主进程 ``vec_env.close()`` 之后再退出;
|
||||
- 任何异常都会先落一份 ``*_emergency.zip`` + ``resume.json`` 再抛出,不再出现
|
||||
"跑了很久什么都没有"。
|
||||
|
||||
为什么暂停/停止不靠信号:Windows 没有 SIGSTOP,且 ``os.kill(pid, SIGTERM)`` 会
|
||||
退化成 ``TerminateProcess`` 硬杀——收尾分支根本不会执行。控制文件两端都只是
|
||||
普通文件 IO,POSIX 与 Windows 行为一致(见 :mod:`mcm26b.process_control`)。
|
||||
|
||||
P1 smoke 示例:
|
||||
python scripts/train_hrl_high.py \
|
||||
@@ -29,6 +47,7 @@ from __future__ import annotations
|
||||
import argparse
|
||||
import hashlib
|
||||
import json
|
||||
import math
|
||||
import os
|
||||
import platform
|
||||
import random
|
||||
@@ -39,9 +58,10 @@ from pathlib import Path
|
||||
from typing import Any
|
||||
|
||||
from mcm26b.config import ProblemConfig
|
||||
from mcm26b.hrl.curriculum import STAGES, training_config
|
||||
from mcm26b.hrl.curriculum import STAGES
|
||||
from mcm26b.hrl.constants import (ACTION_LAYOUT_VERSION, FEATURE_VERSION,
|
||||
HIGH_OBS_DIM, N_CANDIDATES, REWARD_VERSION)
|
||||
from mcm26b.process_control import ControlState, read_control
|
||||
|
||||
|
||||
def _stage_from_index(index: int):
|
||||
@@ -68,24 +88,86 @@ def _reward_from_profile(profile: str):
|
||||
raise ValueError(f"未知 reward-profile: {profile}")
|
||||
|
||||
|
||||
def _ignore_stop_signals_in_worker() -> None:
|
||||
"""subproc worker 忽略 SIGTERM/SIGINT,保证主进程能优雅收尾落盘。
|
||||
def _stop_signals() -> list[int]:
|
||||
"""本平台可用于"请求收尾"的信号集合。
|
||||
|
||||
训练前端的「停止」向整个进程组发 SIGTERM;若 worker 同时退出,主进程会在
|
||||
保存模型之前就拿到断管/EOF 错误,整局训练白跑。worker 活在管道后面,主进程
|
||||
收尾时通过 ``vec_env.close()`` 的 ``close`` 指令正常结束它们。
|
||||
Windows 上没有 SIGTERM 的软语义(``os.kill`` 直接 TerminateProcess),但
|
||||
``SIGBREAK`` 可以由 ``CTRL_BREAK_EVENT`` 触发并进入 Python 处理器;POSIX 上
|
||||
SIGTERM/SIGINT 均可捕获。控制文件才是主通道,这里只是让"直接 kill 脚本"
|
||||
也尽量走优雅收尾。
|
||||
"""
|
||||
signals = [signal.SIGTERM, signal.SIGINT]
|
||||
sigbreak = getattr(signal, "SIGBREAK", None)
|
||||
if sigbreak is not None:
|
||||
signals.append(sigbreak)
|
||||
return signals
|
||||
|
||||
|
||||
def _ignore_stop_signals_in_worker() -> None:
|
||||
"""subproc worker 忽略收尾信号,保证主进程能优雅落盘。
|
||||
|
||||
无论信号来自进程组 SIGTERM 还是 Windows ``CTRL_BREAK_EVENT``,若 worker
|
||||
同时退出,主进程会在保存模型之前就拿到断管/EOF 错误,整局训练白跑。worker
|
||||
活在管道后面,主进程收尾时通过 ``vec_env.close()`` 的 ``close`` 指令正常
|
||||
结束它们。
|
||||
"""
|
||||
try:
|
||||
import multiprocessing
|
||||
if multiprocessing.current_process().name == "MainProcess":
|
||||
return
|
||||
for signum in (signal.SIGTERM, signal.SIGINT):
|
||||
for signum in _stop_signals():
|
||||
signal.signal(signum, signal.SIG_IGN)
|
||||
except (ImportError, ValueError, OSError): # 非主线程/平台不支持:不影响训练
|
||||
return
|
||||
|
||||
|
||||
def _make_env(problem: int, stage_index: int, seed: int,
|
||||
class _ControlPoll:
|
||||
"""控制文件轮询状态机(纯标准库,可脱离 SB3 单测)。
|
||||
|
||||
``poll()`` 每 ``poll_every`` 次调用才真正读盘,避免每个 env step 都做一次
|
||||
文件 IO;``wait_while_paused()`` 阻塞到暂停解除或收到 stop,并累计暂停秒数
|
||||
(``--max-wall-s`` 要把这段扣掉,否则"挂起一小时回来就判超时")。
|
||||
"""
|
||||
|
||||
def __init__(self, path: Path, *, poll_every: int = 32,
|
||||
poll_s: float = 0.2):
|
||||
self.path = Path(path)
|
||||
self.poll_every = max(1, int(poll_every))
|
||||
self.poll_s = max(0.02, float(poll_s))
|
||||
self.paused_total_s = 0.0
|
||||
self._calls = 0
|
||||
self._state = ControlState()
|
||||
|
||||
def poll(self) -> ControlState:
|
||||
"""按抽样间隔返回最近一次控制状态。"""
|
||||
self._calls += 1
|
||||
if self._calls % self.poll_every:
|
||||
return self._state
|
||||
self._state = read_control(self.path)
|
||||
return self._state
|
||||
|
||||
def wait_while_paused(self, on_pause, on_resume) -> bool:
|
||||
"""阻塞到继续或停止;返回 True 表示期间收到了 stop。
|
||||
|
||||
``on_pause`` / ``on_resume`` 用来向前端回显事件(训练脚本的 JSON 行)。
|
||||
"""
|
||||
on_pause()
|
||||
started = time.monotonic()
|
||||
stopped = False
|
||||
while True:
|
||||
time.sleep(self.poll_s)
|
||||
self._state = read_control(self.path)
|
||||
if self._state.stop:
|
||||
stopped = True
|
||||
break
|
||||
if not self._state.paused:
|
||||
break
|
||||
self.paused_total_s += time.monotonic() - started
|
||||
on_resume()
|
||||
return stopped
|
||||
|
||||
|
||||
def _make_env(problem: int, stage_index: int,
|
||||
monitor_path: str | None, profile: str,
|
||||
reward_profile: str):
|
||||
"""顶层工厂:供 DummyVecEnv / SubprocVecEnv 调用(必须是可 pickle 的)。"""
|
||||
@@ -112,7 +194,7 @@ def _build_vec_env(args, monitor_paths: list[str]):
|
||||
from functools import partial
|
||||
|
||||
factories = [
|
||||
partial(_make_env, args.problem, args.stage, args.seed + rank,
|
||||
partial(_make_env, args.problem, args.stage,
|
||||
monitor_paths[rank], args.config_profile, args.reward_profile)
|
||||
for rank in range(args.envs)
|
||||
]
|
||||
@@ -161,6 +243,52 @@ def _policy_hyperparams(model) -> dict[str, Any]:
|
||||
}
|
||||
|
||||
|
||||
def _progress_payload(model, start: int) -> dict[str, Any]:
|
||||
"""组装 ``--progress-json`` 的单行负载。
|
||||
|
||||
纯函数(只读 model 的 logger / ep_info_buffer),便于脱离训练循环测试;
|
||||
NaN / ±inf 一律折算成 None,保证 ``json.dumps(allow_nan=False)`` 不会炸。
|
||||
"""
|
||||
values = getattr(model.logger, "name_to_value", {}) or {}
|
||||
|
||||
def _num(key, default=None):
|
||||
value = values.get(key)
|
||||
if value is None:
|
||||
return default
|
||||
try:
|
||||
value = float(value)
|
||||
except (TypeError, ValueError):
|
||||
return default
|
||||
if not math.isfinite(value):
|
||||
return default
|
||||
return value
|
||||
|
||||
ep_rewards: list[float] = []
|
||||
ep_lengths: list[float] = []
|
||||
for item in getattr(model, "ep_info_buffer", []) or []:
|
||||
try:
|
||||
ep_rewards.append(float(item.get("r")))
|
||||
ep_lengths.append(float(item.get("l")))
|
||||
except (TypeError, ValueError, AttributeError):
|
||||
continue
|
||||
return {
|
||||
"event": "progress",
|
||||
"timesteps": int(model.num_timesteps),
|
||||
"start_timesteps": int(start),
|
||||
"n_updates": int(getattr(model, "_n_updates", 0) or 0),
|
||||
"ep_rew_mean": (sum(ep_rewards) / len(ep_rewards)
|
||||
if ep_rewards else None),
|
||||
"ep_len_mean": (sum(ep_lengths) / len(ep_lengths)
|
||||
if ep_lengths else None),
|
||||
"value_loss": _num("train/value_loss"),
|
||||
"policy_gradient_loss": _num("train/policy_gradient_loss"),
|
||||
"approx_kl": _num("train/approx_kl"),
|
||||
"entropy_loss": _num("train/entropy_loss"),
|
||||
"explained_variance": _num("train/explained_variance"),
|
||||
"fps": _num("time/fps"),
|
||||
}
|
||||
|
||||
|
||||
def _model_metadata(*, args, wall_s: float, n_envs: int,
|
||||
actual_timesteps: int) -> dict[str, Any]:
|
||||
import numpy
|
||||
@@ -190,12 +318,12 @@ def _model_metadata(*, args, wall_s: float, n_envs: int,
|
||||
"config_profile": args.config_profile,
|
||||
"fidelity": _profile_fidelity(args.config_profile),
|
||||
"reward_profile": args.reward_profile,
|
||||
"reward_version": REWARD_VERSION,
|
||||
"local_selector": args.local_selector,
|
||||
"local_model": args.local_model,
|
||||
"checkpoint_every": int(getattr(args, "checkpoint_every", 0) or 0),
|
||||
"checkpoint_keep": int(getattr(args, "checkpoint_keep", 0) or 0),
|
||||
"checkpoint_dir": getattr(args, "checkpoint_dir", None),
|
||||
"recovery_every": int(getattr(args, "recovery_every", 0) or 0),
|
||||
"device": args.device,
|
||||
"wall_seconds": round(wall_s, 3),
|
||||
"max_wall_s": float(args.max_wall_s),
|
||||
@@ -211,6 +339,68 @@ def _model_metadata(*, args, wall_s: float, n_envs: int,
|
||||
}
|
||||
|
||||
|
||||
def _write_json_atomic(path: Path, payload: dict[str, Any]) -> None:
|
||||
"""原子写 JSON:同目录 ``.tmp-`` 前缀临时文件 + ``os.replace``。
|
||||
|
||||
急停(SIGKILL / ``taskkill /F``)会绕过所有 Python 清理逻辑,保存途中被杀
|
||||
是完全可能的。原子替换保证**真实文件名下要么是旧的完整文件、要么是新的
|
||||
完整文件**,不会出现半截 JSON 让续训读到一半就崩。
|
||||
"""
|
||||
path = Path(path)
|
||||
path.parent.mkdir(parents=True, exist_ok=True)
|
||||
tmp = path.with_name(f".tmp-{path.name}")
|
||||
tmp.write_text(json.dumps(payload, ensure_ascii=False, indent=2),
|
||||
encoding="utf-8")
|
||||
os.replace(tmp, path)
|
||||
|
||||
|
||||
def _save_model_atomic(model, target: Path) -> Path:
|
||||
"""把 SB3 模型原子地写到 ``target``,返回实际落盘路径。
|
||||
|
||||
先写 ``.tmp-<name>``(同样以 ``.zip`` 结尾,SB3 不会再加后缀),完整写完后
|
||||
``os.replace`` 覆盖目标。于是:
|
||||
|
||||
- 保存途中被强杀 → 目标路径仍是上一份**完整**的快照,可继续复训;
|
||||
- 保存成功 → 目标路径立即变成新快照,不存在"看起来存在但打不开"的窗口。
|
||||
|
||||
临时名带 ``.tmp-`` 前缀,因此不会被 ``*_ckpt_*.zip`` 的清理逻辑误当成正式
|
||||
checkpoint。
|
||||
"""
|
||||
target = Path(target)
|
||||
target.parent.mkdir(parents=True, exist_ok=True)
|
||||
tmp = target.with_name(f".tmp-{target.name}")
|
||||
model.save(str(tmp))
|
||||
if not tmp.exists(): # SB3 对未知后缀可能自行补 .zip
|
||||
candidate = tmp.with_name(tmp.name + ".zip")
|
||||
if candidate.exists():
|
||||
tmp = candidate
|
||||
os.replace(tmp, target)
|
||||
return target
|
||||
|
||||
|
||||
def _write_resume_hint(*, run_dir: Path, model_path: Path, timesteps: int,
|
||||
reason: str, model=None) -> dict[str, Any]:
|
||||
"""把"从哪继续"写进 ``<run_dir>/resume.json``。
|
||||
|
||||
这是急停恢复的**权威指针**:无论上一刻是周期 checkpoint、滚动恢复点、
|
||||
优雅收尾还是异常兜底,这个文件总指向一份完整、可 ``--resume`` 的产物,
|
||||
并带上续训语义(``timesteps`` 是增量步数)与生效过的 PPO 超参。
|
||||
"""
|
||||
hint: dict[str, Any] = {
|
||||
"event": "resume_hint",
|
||||
"model": str(model_path),
|
||||
"timesteps": int(timesteps),
|
||||
"timesteps_semantics": "additional",
|
||||
"reason": str(reason),
|
||||
"created_unix_s": time.time(),
|
||||
"resume_args": ["--resume", "--model", str(model_path)],
|
||||
}
|
||||
if model is not None:
|
||||
hint["ppo_hyperparams"] = _policy_hyperparams(model)
|
||||
_write_json_atomic(run_dir / "resume.json", hint)
|
||||
return hint
|
||||
|
||||
|
||||
def _write_metadata(*, model_path: Path, args, wall_s: float, n_envs: int,
|
||||
actual_timesteps: int, monitor_paths: list[str],
|
||||
model=None,
|
||||
@@ -227,14 +417,18 @@ def _write_metadata(*, model_path: Path, args, wall_s: float, n_envs: int,
|
||||
"reward_profile=prompt 使用原始 §5.2 塑形。模型元数据已固化口径。")
|
||||
if extra:
|
||||
metadata.update(extra)
|
||||
model_path.with_suffix(".json").write_text(
|
||||
json.dumps(metadata, ensure_ascii=False, indent=2), encoding="utf-8")
|
||||
_write_json_atomic(model_path.with_suffix(".json"), metadata)
|
||||
return metadata
|
||||
|
||||
|
||||
def _prune_checkpoints(directory: Path, stem: str, keep: int) -> list[str]:
|
||||
"""只保留最近 ``keep`` 个 checkpoint(zip+json 同删),返回被删的 zip 路径。"""
|
||||
files = sorted(directory.glob(f"{stem}_*.zip"),
|
||||
"""只保留最近 ``keep`` 个 checkpoint(zip+json 同删),返回被删的 zip 路径。
|
||||
|
||||
只匹配 ``<stem>_<步数>.zip``;滚动恢复点 ``<stem>_latest.zip`` 与临时文件
|
||||
``.tmp-*.zip`` 都不匹配,因此永远不会被历史清理误删。
|
||||
"""
|
||||
files = sorted((path for path in directory.glob(f"{stem}_[0-9]*.zip")
|
||||
if path.stem.rsplit("_", 1)[-1].isdigit()),
|
||||
key=lambda path: path.stat().st_mtime, reverse=True)
|
||||
removed: list[str] = []
|
||||
for stale in files[max(1, int(keep)):]:
|
||||
@@ -267,7 +461,7 @@ def _evaluate_env(model, *, problem: int, stage_index: int,
|
||||
while not done:
|
||||
action, _state = model.predict(
|
||||
obs, action_masks=env.action_masks(), deterministic=True)
|
||||
obs, reward, terminated, truncated, info = env.step(int(action))
|
||||
obs, _, terminated, truncated, info = env.step(int(action))
|
||||
done = bool(terminated or truncated)
|
||||
steps += 1
|
||||
# 课程阶段的小场景只需证明全部频道 accounted;完整阶段至少应清掉
|
||||
@@ -299,7 +493,7 @@ def _evaluate_env(model, *, problem: int, stage_index: int,
|
||||
|
||||
|
||||
def _evaluate_manifest(model_path: Path, manifest_path: str, *,
|
||||
limit: int, problem: int) -> dict[str, Any]:
|
||||
limit: int) -> dict[str, Any]:
|
||||
"""可选:用 benchmark_hrl 的同一套真值隔离评测在固定 manifest 上跑已训模型。"""
|
||||
scripts_dir = Path(__file__).resolve().parent
|
||||
if str(scripts_dir) not in sys.path:
|
||||
@@ -404,26 +598,72 @@ def run_training(args) -> int:
|
||||
"命令行 rollout-steps/batch-size/n-epochs 被忽略")},
|
||||
ensure_ascii=False), file=sys.stderr, flush=True)
|
||||
|
||||
# ------------------------------------------------------------ 优雅停止
|
||||
# 训练前端「停止」= killpg(SIGTERM):worker 已在 _make_env 里忽略该信号,
|
||||
# 主进程在此装处理器,等当前 step 结束后收尾落盘。
|
||||
# ------------------------------------------------------ 优雅暂停 / 停止
|
||||
# 主通道是控制文件(跨平台);信号是兜底:直接 kill 本进程时也走同一段收尾。
|
||||
stop_state: dict[str, Any] = {"requested": False, "signal": None}
|
||||
previous_handlers: dict[int, Any] = {}
|
||||
|
||||
def _on_stop_signal(signum, _frame):
|
||||
def _request_stop(source: str) -> None:
|
||||
if stop_state["requested"]:
|
||||
return
|
||||
stop_state["requested"] = True
|
||||
stop_state["signal"] = signal.Signals(signum).name
|
||||
print(json.dumps({"event": "stop_requested",
|
||||
"signal": signal.Signals(signum).name}), flush=True)
|
||||
stop_state["signal"] = source
|
||||
print(json.dumps({"event": "stop_requested", "signal": source}), flush=True)
|
||||
|
||||
for _signum in (signal.SIGTERM, signal.SIGINT):
|
||||
def _on_stop_signal(signum, _frame):
|
||||
_request_stop(signal.Signals(signum).name)
|
||||
|
||||
for _signum in _stop_signals():
|
||||
try:
|
||||
previous_handlers[_signum] = signal.signal(_signum, _on_stop_signal)
|
||||
except (ValueError, OSError): # 非主线程/平台不支持:不阻塞训练
|
||||
pass
|
||||
|
||||
class _ControlChannel(BaseCallback):
|
||||
"""把 :class:`_ControlPoll` 接到 SB3 的 step 回调上。
|
||||
|
||||
为什么不用信号:Windows 没有 SIGSTOP,且 ``os.kill(SIGTERM)`` 会退化成
|
||||
``TerminateProcess`` 硬杀,收尾分支根本不会执行。控制文件在 POSIX 与
|
||||
Windows 上都只是普通文件 IO,语义完全一致。
|
||||
|
||||
暂停是**协作式**的:在 ``_on_step`` 内阻塞轮询,训练因此停在 step 边界,
|
||||
进程仍存活、显存仍占用,但不消耗算力;收到 stop 立刻转入收尾落盘。
|
||||
"""
|
||||
|
||||
def __init__(self, path: Path, *, poll_every: int = 32,
|
||||
poll_s: float = 0.2):
|
||||
super().__init__()
|
||||
self.poller = _ControlPoll(path, poll_every=poll_every,
|
||||
poll_s=poll_s)
|
||||
|
||||
def paused_seconds(self) -> float:
|
||||
return self.poller.paused_total_s
|
||||
|
||||
def _event(self, name: str) -> None:
|
||||
print(json.dumps({"event": name,
|
||||
"timesteps": int(self.model.num_timesteps)}),
|
||||
flush=True)
|
||||
|
||||
def _on_step(self) -> bool:
|
||||
state = self.poller.poll()
|
||||
if state.stop:
|
||||
_request_stop("control_file")
|
||||
return False
|
||||
if state.paused:
|
||||
stopped = self.poller.wait_while_paused(
|
||||
lambda: self._event("paused"),
|
||||
lambda: self._event("resumed"))
|
||||
if stopped:
|
||||
_request_stop("control_file")
|
||||
return False
|
||||
return not stop_state["requested"]
|
||||
return True
|
||||
|
||||
control_path = getattr(args, "control_file", None)
|
||||
control_cb = _ControlChannel(Path(control_path)) if control_path else None
|
||||
|
||||
class _GracefulStop(BaseCallback):
|
||||
"""收到停止信号后在最近一次 step 边界结束 rollout,让模型能落盘。"""
|
||||
"""收到停止请求后在最近一次 step 边界结束 rollout,让模型能落盘。"""
|
||||
|
||||
def _on_step(self) -> bool:
|
||||
if stop_state["requested"]:
|
||||
@@ -435,15 +675,22 @@ def run_training(args) -> int:
|
||||
return True
|
||||
|
||||
class _WallClockStop(BaseCallback):
|
||||
def __init__(self, max_wall_s: float):
|
||||
"""墙钟硬上限;暂停时间不计入,避免"挂起一小时回来就判超时"。"""
|
||||
|
||||
def __init__(self, max_wall_s: float, paused_clock=None):
|
||||
super().__init__()
|
||||
self.max_wall_s = max_wall_s
|
||||
self.start = time.monotonic()
|
||||
self.paused_clock = paused_clock
|
||||
|
||||
def paused_seconds(self) -> float:
|
||||
return float(self.paused_clock()) if self.paused_clock else 0.0
|
||||
|
||||
def _on_step(self) -> bool:
|
||||
if time.monotonic() - self.start >= self.max_wall_s:
|
||||
elapsed = time.monotonic() - self.start - self.paused_seconds()
|
||||
if elapsed >= self.max_wall_s:
|
||||
print(json.dumps({"event": "wall_clock_stop",
|
||||
"elapsed_s": time.monotonic() - self.start}))
|
||||
"elapsed_s": elapsed}))
|
||||
return False
|
||||
return True
|
||||
|
||||
@@ -473,44 +720,7 @@ def run_training(args) -> int:
|
||||
return True
|
||||
self.last_emit = current
|
||||
self.emitted += 1
|
||||
values = getattr(self.model.logger, "name_to_value", {}) or {}
|
||||
|
||||
def _num(key, default=None):
|
||||
value = values.get(key)
|
||||
if value is None:
|
||||
return default
|
||||
try:
|
||||
value = float(value)
|
||||
except (TypeError, ValueError):
|
||||
return default
|
||||
if value != value or value in (float("inf"), float("-inf")):
|
||||
return default
|
||||
return value
|
||||
|
||||
ep_rewards = []
|
||||
ep_lengths = []
|
||||
for item in getattr(self.model, "ep_info_buffer", []) or []:
|
||||
try:
|
||||
ep_rewards.append(float(item.get("r")))
|
||||
ep_lengths.append(float(item.get("l")))
|
||||
except (TypeError, ValueError, AttributeError):
|
||||
continue
|
||||
payload = {
|
||||
"event": "progress",
|
||||
"timesteps": current,
|
||||
"start_timesteps": self.start,
|
||||
"n_updates": int(getattr(self.model, "_n_updates", 0) or 0),
|
||||
"ep_rew_mean": (sum(ep_rewards) / len(ep_rewards)
|
||||
if ep_rewards else None),
|
||||
"ep_len_mean": (sum(ep_lengths) / len(ep_lengths)
|
||||
if ep_lengths else None),
|
||||
"value_loss": _num("train/value_loss"),
|
||||
"policy_gradient_loss": _num("train/policy_gradient_loss"),
|
||||
"approx_kl": _num("train/approx_kl"),
|
||||
"entropy_loss": _num("train/entropy_loss"),
|
||||
"explained_variance": _num("train/explained_variance"),
|
||||
"fps": _num("time/fps"),
|
||||
}
|
||||
payload = _progress_payload(self.model, self.start)
|
||||
print(json.dumps(payload, ensure_ascii=False, allow_nan=False),
|
||||
flush=True)
|
||||
return True
|
||||
@@ -521,38 +731,79 @@ def run_training(args) -> int:
|
||||
checkpoint_stem = f"{model_path.stem}_ckpt"
|
||||
|
||||
class _CheckpointJSON(BaseCallback):
|
||||
"""每 N 个环境步落一个可续训 checkpoint(zip + 同名元数据)。
|
||||
"""可续训落盘:滚动恢复点 + 周期 checkpoint + resume.json 指针。
|
||||
|
||||
只保留最近 ``keep`` 个;checkpoint 与最终模型共用同名 json 契约,
|
||||
因此 ``--resume <checkpoint.zip>`` 直接可用。
|
||||
- **滚动恢复点** ``<stem>_latest.zip``:每 ``recovery_every`` 个环境步
|
||||
原子覆盖写一次,训练开始前也先写一份。它保证"急停"(SIGKILL /
|
||||
``taskkill /F``,没有任何 Python 清理机会)之后**最多只丢
|
||||
``recovery_every`` 个环境步**,而且文件始终完整可加载。
|
||||
- **周期 checkpoint** ``<stem>_<步数>.zip``:每 ``every_steps`` 步留一份,
|
||||
只保留最近 ``keep`` 个,用于返祖对比与多档恢复。
|
||||
- 每次落盘都刷新 ``<run_dir>/resume.json``:无论怎么停,都有一份权威的
|
||||
"从哪继续"指针(含增量语义与生效过的 PPO 超参)。
|
||||
"""
|
||||
|
||||
def __init__(self, every_steps: int, keep: int):
|
||||
def __init__(self, every_steps: int, keep: int, recovery_every: int):
|
||||
super().__init__()
|
||||
self.every_steps = max(1, int(every_steps))
|
||||
self.every_steps = max(0, int(every_steps))
|
||||
self.keep = max(1, int(keep))
|
||||
self.recovery_every = max(0, int(recovery_every))
|
||||
self.last_saved = int(model.num_timesteps)
|
||||
self.last_recovery = int(model.num_timesteps)
|
||||
self.saved: list[str] = []
|
||||
self.latest_path = checkpoints_dir / f"{checkpoint_stem}_latest.zip"
|
||||
|
||||
def write_recovery(self, reason: str) -> None:
|
||||
"""立刻写一份滚动恢复点;训练开始前调用即"起步基线"。
|
||||
|
||||
用闭包里的 ``model`` 而不是 ``self.model``:``BaseCallback.model``
|
||||
要等 ``learn()`` 时才被赋值,而起步基线恰恰在 ``learn()`` 之前写。
|
||||
"""
|
||||
current = int(model.num_timesteps)
|
||||
self.last_recovery = current
|
||||
self._write_recovery(current, reason)
|
||||
|
||||
def _on_step(self) -> bool:
|
||||
current = int(self.model.num_timesteps)
|
||||
if current - self.last_saved < self.every_steps:
|
||||
return True
|
||||
self.last_saved = current
|
||||
self._write(current)
|
||||
current = int(model.num_timesteps)
|
||||
if (self.recovery_every > 0
|
||||
and current - self.last_recovery >= self.recovery_every):
|
||||
self.last_recovery = current
|
||||
self._write_recovery(current, "interval")
|
||||
if (self.every_steps > 0
|
||||
and current - self.last_saved >= self.every_steps):
|
||||
self.last_saved = current
|
||||
self._write_interval(current)
|
||||
return True
|
||||
|
||||
def _write(self, current: int) -> None:
|
||||
checkpoints_dir.mkdir(parents=True, exist_ok=True)
|
||||
target = checkpoints_dir / f"{checkpoint_stem}_{current:09d}.zip"
|
||||
self.model.save(str(target))
|
||||
def _persist(self, target: Path, current: int, reason: str,
|
||||
extra: dict[str, Any]) -> None:
|
||||
"""原子落盘 zip + 元数据,并刷新 resume.json 指针。"""
|
||||
_save_model_atomic(model, target)
|
||||
_write_metadata(
|
||||
model_path=target, args=args,
|
||||
wall_s=time.monotonic() - started, n_envs=args.envs,
|
||||
actual_timesteps=current, monitor_paths=monitor_paths,
|
||||
model=self.model,
|
||||
extra={"checkpoint": True, "checkpoint_timesteps": current,
|
||||
"checkpoint_of": str(model_path)})
|
||||
model=model,
|
||||
extra={"checkpoint_timesteps": current,
|
||||
"checkpoint_reason": reason,
|
||||
"checkpoint_of": str(model_path), **extra})
|
||||
_write_resume_hint(run_dir=run_dir, model_path=target,
|
||||
timesteps=current, reason=reason,
|
||||
model=model)
|
||||
|
||||
def _write_recovery(self, current: int, reason: str) -> None:
|
||||
checkpoints_dir.mkdir(parents=True, exist_ok=True)
|
||||
self._persist(self.latest_path, current, reason,
|
||||
{"recovery": True})
|
||||
print(json.dumps({"event": "recovery_checkpoint",
|
||||
"reason": reason, "timesteps": current,
|
||||
"model": str(self.latest_path)},
|
||||
ensure_ascii=False), flush=True)
|
||||
|
||||
def _write_interval(self, current: int) -> None:
|
||||
checkpoints_dir.mkdir(parents=True, exist_ok=True)
|
||||
target = checkpoints_dir / f"{checkpoint_stem}_{current:09d}.zip"
|
||||
self._persist(target, current, "interval", {"checkpoint": True})
|
||||
removed = _prune_checkpoints(checkpoints_dir, checkpoint_stem,
|
||||
self.keep)
|
||||
self.saved.append(str(target))
|
||||
@@ -562,15 +813,24 @@ def run_training(args) -> int:
|
||||
|
||||
progress_every = (args.progress_every if args.progress_every > 0
|
||||
else args.rollout_steps * args.envs)
|
||||
checkpoint_cb = None
|
||||
if args.checkpoint_every > 0:
|
||||
checkpoint_cb = _CheckpointJSON(args.checkpoint_every,
|
||||
args.checkpoint_keep)
|
||||
callbacks = [_GracefulStop(), _WallClockStop(args.max_wall_s)]
|
||||
if checkpoint_cb is not None:
|
||||
callbacks.append(checkpoint_cb)
|
||||
checkpoint_cb = _CheckpointJSON(args.checkpoint_every, args.checkpoint_keep,
|
||||
args.recovery_every)
|
||||
if args.recovery_every > 0:
|
||||
# 起步基线:训练还没跑就急停,也至少有一份可加载、可续训的产物
|
||||
checkpoint_cb.write_recovery("baseline")
|
||||
# 顺序有语义:控制通道最先(暂停/停止要在本步立刻生效),进度次之,
|
||||
# 墙钟上限要能读到控制通道累计的暂停秒数,checkpoint 放最后。
|
||||
callbacks: list[Any] = []
|
||||
if control_cb is not None:
|
||||
callbacks.append(control_cb)
|
||||
if args.progress_json:
|
||||
callbacks.insert(0, _ProgressJSON(progress_every))
|
||||
callbacks.append(_ProgressJSON(progress_every))
|
||||
callbacks.append(_GracefulStop())
|
||||
callbacks.append(_WallClockStop(
|
||||
args.max_wall_s,
|
||||
paused_clock=(control_cb.paused_seconds if control_cb is not None
|
||||
else None)))
|
||||
callbacks.append(checkpoint_cb)
|
||||
|
||||
actual_timesteps = int(model.num_timesteps)
|
||||
try:
|
||||
@@ -587,7 +847,7 @@ def run_training(args) -> int:
|
||||
}
|
||||
emergency = run_dir / f"{model_path.stem}_emergency.zip"
|
||||
try:
|
||||
model.save(str(emergency))
|
||||
_save_model_atomic(model, emergency)
|
||||
_write_metadata(model_path=emergency, args=args,
|
||||
wall_s=time.monotonic() - started,
|
||||
n_envs=args.envs,
|
||||
@@ -595,27 +855,34 @@ def run_training(args) -> int:
|
||||
monitor_paths=monitor_paths, model=model,
|
||||
extra={"emergency": True,
|
||||
"error": failure["error"]})
|
||||
_write_resume_hint(run_dir=run_dir, model_path=emergency,
|
||||
timesteps=int(model.num_timesteps),
|
||||
reason="emergency", model=model)
|
||||
failure["emergency_model"] = str(emergency)
|
||||
except Exception as save_exc: # 落盘失败不能再掩盖原始异常
|
||||
failure["emergency_save_error"] = (
|
||||
f"{type(save_exc).__name__}: {save_exc}")
|
||||
(run_dir / "training_failure.json").write_text(
|
||||
json.dumps(failure, ensure_ascii=False, indent=2), encoding="utf-8")
|
||||
_write_json_atomic(run_dir / "training_failure.json", failure)
|
||||
print(json.dumps(failure, ensure_ascii=False), file=sys.stderr,
|
||||
flush=True)
|
||||
raise
|
||||
wall_s = time.monotonic() - started
|
||||
actual_timesteps = int(model.num_timesteps)
|
||||
stopped_by_signal = bool(stop_state["requested"])
|
||||
model.save(str(model_path))
|
||||
_save_model_atomic(model, model_path)
|
||||
metadata = _write_metadata(
|
||||
model_path=model_path, args=args, wall_s=wall_s, n_envs=args.envs,
|
||||
actual_timesteps=actual_timesteps, monitor_paths=monitor_paths,
|
||||
model=model,
|
||||
extra={"stopped_by_signal": stopped_by_signal,
|
||||
"stop_signal": stop_state["signal"],
|
||||
"checkpoint_files": (checkpoint_cb.saved
|
||||
if checkpoint_cb is not None else [])})
|
||||
"checkpoint_files": checkpoint_cb.saved,
|
||||
"recovery_model": str(checkpoint_cb.latest_path)})
|
||||
# 最终模型也刷新一次 resume.json:优雅收尾后"从哪继续"指向最终模型
|
||||
_write_resume_hint(run_dir=run_dir, model_path=model_path,
|
||||
timesteps=actual_timesteps,
|
||||
reason=("stopped" if stopped_by_signal else "finished"),
|
||||
model=model)
|
||||
metadata_path = model_path.with_suffix(".json")
|
||||
vec_env.close()
|
||||
# 收尾阶段恢复默认处理器:再次 Ctrl+C / 停止可立即强杀,不再等待评估。
|
||||
@@ -637,8 +904,7 @@ def run_training(args) -> int:
|
||||
if args.eval_manifest:
|
||||
try:
|
||||
manifest_eval = _evaluate_manifest(
|
||||
model_path, args.eval_manifest, limit=args.eval_manifest_limit,
|
||||
problem=args.problem)
|
||||
model_path, args.eval_manifest, limit=args.eval_manifest_limit)
|
||||
except Exception as exc:
|
||||
manifest_eval = {"error": f"{type(exc).__name__}: {exc}"}
|
||||
metadata["evaluation"] = evaluation
|
||||
@@ -685,10 +951,17 @@ def main(argv=None) -> int:
|
||||
help="time=P0.4 纯时间口径;prompt=原始 §5.2 塑形对照")
|
||||
parser.add_argument("--progress-json", action="store_true",
|
||||
help="向训练前端输出单行 JSON 进度(UI 自动启用)")
|
||||
parser.add_argument("--control-file", default=None,
|
||||
help="训练前端控制文件(JSON:paused/stop);"
|
||||
"跨平台暂停与优雅停止的主通道")
|
||||
parser.add_argument("--progress-every", type=int, default=0,
|
||||
help="JSON 进度间隔环境步数;0=每个 rollout")
|
||||
parser.add_argument("--checkpoint-every", type=int, default=20000,
|
||||
help="每 N 个环境步落一个 checkpoint;0=关闭(默认 20000)")
|
||||
help="每 N 个环境步留一个历史 checkpoint;0=关闭(默认 20000)")
|
||||
parser.add_argument("--recovery-every", type=int, default=2000,
|
||||
help="每 N 个环境步原子覆盖写一份滚动恢复点 "
|
||||
"<模型目录>/checkpoints/<stem>_latest.zip;"
|
||||
"急停后最多丢 N 步。0=关闭(默认 2000)")
|
||||
parser.add_argument("--checkpoint-keep", type=int, default=3,
|
||||
help="checkpoint 保留最近 K 个(默认 3)")
|
||||
parser.add_argument("--checkpoint-dir", default=None,
|
||||
@@ -703,6 +976,8 @@ def main(argv=None) -> int:
|
||||
parser.error("eval-episodes 不能为负")
|
||||
if args.checkpoint_every < 0:
|
||||
parser.error("checkpoint-every 不能为负")
|
||||
if args.recovery_every < 0:
|
||||
parser.error("recovery-every 不能为负")
|
||||
if args.checkpoint_keep < 1:
|
||||
parser.error("checkpoint-keep 至少为 1")
|
||||
return run_training(args)
|
||||
|
||||
Reference in New Issue
Block a user