Files

215 lines
11 KiB
Python
Raw Permalink Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
"""流水线状态与互斥锁(单一写者:所有对 ml/runs/pipeline.json / pipeline.lock 的写都经过本模块,原子替换)。
pipeline.sh 自动调用,一般不需要手动运行:
python -m ml.pstate reset <阶段1,...> [--fresh] # `all` 开始:保留 rc=0 的已完成阶段(续跑);参数变了只作废受影响的阶段;--fresh 全部作废
python -m ml.pstate todo # 打印计划中尚未完成的阶段(空格分隔)
python -m ml.pstate begin <阶段> [键=值 ...] # 阶段开始
python -m ml.pstate set <阶段> 键=值 ... # 阶段中途更新
python -m ml.pstate get <阶段> <键> # 读值;不存在则非零退出(不静默回空)
python -m ml.pstate end <阶段> <返回码>
python -m ml.pstate timeout <阶段> # 该阶段超时秒数(估时 ×3,TIMEOUT_SCALE 可再乘)
python -m ml.pstate lock acquire <所有者> [pid]|release|beat|show
2026-10-18 第五轮:
- 加锁(审查 N-7):网页 / 终端 / `all` 都走同一把锁 ml/runs/pipeline.lock(pid + 心跳),已有一条在跑时立即拒绝;
心跳超过 LOCK_STALE_S 未刷新视为陈旧(进程被强杀),可接管。
- 续跑(缺口 1):reset 默认保留已完成阶段;参数变了只作废受它影响的阶段(AFFECTS),其产物移到 ml/archive/,避免新旧参数混用。
- 阶段表 STAGES 是估时 / 超时 / 网页进度权重的唯一来源(审查 N-14:网页权重与耗时表同源)。
- 停止也走本模块(审查 N-12:此前 monitor 直写 pipeline.json,与子进程竞态)。"""
import glob, json, os, shutil, socket, sys, time
ROOT = os.path.dirname(os.path.dirname(os.path.abspath(__file__)))
P = os.path.join(ROOT, 'ml', 'runs', 'pipeline.json')
LOCK = os.path.join(ROOT, 'ml', 'runs', 'pipeline.lock')
LOCK_STALE_S = 300 # 心跳每 60 s 一次;5 分钟没刷新 = 持锁进程已死
# 阶段顺序、名称、估时(小时,按 README §耗时表的共识分支上界;网页进度权重即此值)
STAGES = [
('setup', '安装依赖', 0.2), ('kin', '对拍', 0.2), ('r4', '推理延迟', 0.2), ('gate1', '一致性门禁', 0.5),
('gen', '生成老师轨迹', 5.0), ('prep', '打包样本', 0.5), ('train', '预训练', 3.0), ('m5', '头门禁', 0.5),
('apg', 'APG 微调', 4.0), ('mine', '挖错循环', 9.0), ('export', '导出', 0.1), ('bench', '部署评测', 1.0),
('accept', '验收汇总', 0.5)]
ORDER = [s[0] for s in STAGES]
EST_H = {s[0]: s[2] for s in STAGES}
# m5 不过时流程内自动训 WTA 头(训练 3 h + 门禁),超时按分支后的总量给
TIMEOUT_H = {k: max(1.0, 3 * v) for k, v in EST_H.items()}
TIMEOUT_H['m5'] = 3 * (EST_H['train'] + 2 * EST_H['m5'])
# 参数 → 受它影响的阶段(这些阶段即使上次完成也要重跑,其产物先挪进 ml/archive/)。只改挖错轮数不会让 5 小时的生成作废。
_from = lambda st: ORDER[ORDER.index(st):]
AFFECTS = {**{k: _from('gen') for k in ('N_TASKS', 'NPROC', 'TARGET_SAMPLES', 'CONSENSUS', 'MIN_GAP_MM')},
'CFG': ['r4'] + _from('train'), **{k: _from('mine') for k in ('ROUNDS', 'APG_STEPS', 'MINE_N')}}
PARAM_KEYS = list(AFFECTS)
# 各阶段产物(相对仓库根,可含通配);作废 / 从头重跑时整体移到 ml/archive/<时间>/,不删除
PRODUCTS = {
'r4': ['ml/runs/r4', 'ml/runs/r4.json'], 'gate1': ['ml/data/consistency.json'],
'gen': ['ml/data/shard_*', 'ml/data/log_*', 'ml/data/stats.json'], 'prep': ['ml/data/samples', 'ml/data/r2.json'],
'train': ['ml/runs/base', 'ml/runs/wta', 'ml/runs/small', 'ml/runs/bench_*'],
'm5': ['ml/runs/m5_*', 'ml/runs/selected_ckpt'], 'apg': ['ml/runs/apg', 'ml/runs/current.pt'],
'mine': ['ml/runs/mine', 'ml/runs/apg_r*', 'ml/data/e2', 'ml/data/samples_e2'],
'export': ['webapp/models/traj_policy.onnx', 'webapp/models/traj_policy.meta.json'],
'bench': ['ml/runs/policy_bench.json'], 'accept': ['ml/runs/acceptance.json']}
def archive(stages):
"""把这些阶段的产物移到 ml/archive/<时间>/(保持相对路径),返回目标目录或 None。"""
# 2026-10-18:同一秒内多次归档会撞目录、覆盖旧文件,加序号后缀保证唯一
base = os.path.join(ROOT, 'ml', 'archive', time.strftime('%Y%m%d_%H%M%S')); dst, n = base, 1
while os.path.exists(dst): dst = '%s_%d' % (base, n); n += 1
moved = 0
for st in stages:
for pat in PRODUCTS.get(st, []):
for f in glob.glob(os.path.join(ROOT, pat)):
rel = os.path.relpath(f, ROOT); os.makedirs(os.path.dirname(os.path.join(dst, rel)), exist_ok=True)
shutil.move(f, os.path.join(dst, rel)); moved += 1
return os.path.relpath(dst, ROOT) if moved else None
def load():
try:
with open(P, encoding='utf-8') as f: return json.load(f)
except Exception:
return {'started': time.time(), 'plan': None, 'stages': {}, 'current': None, 'params': {}}
def save(d):
os.makedirs(os.path.dirname(P), exist_ok=True)
tmp = P + '.%d.tmp' % os.getpid()
with open(tmp, 'w', encoding='utf-8') as f: json.dump(d, f, ensure_ascii=False, indent=1)
os.replace(tmp, P)
def kv(args):
out = {}
for a in args:
k, _, v = a.partition('=')
try: v = float(v) if '.' in v else int(v)
except ValueError: pass
out[k] = v
return out
def params_now():
return {k: os.environ.get(k, '') for k in PARAM_KEYS}
# ---------------------------------------------------------------- 锁
def lock_read():
try:
with open(LOCK, encoding='utf-8') as f: return json.load(f)
except Exception:
return None
def lock_alive(lk=None):
lk = lk if lk is not None else lock_read()
return bool(lk) and time.time() - lk.get('beat', 0) < LOCK_STALE_S
def lock_acquire(owner, pid=None):
"""原子创建锁文件;已有活锁则返回 (False, 锁内容)。陈旧锁直接接管。"""
os.makedirs(os.path.dirname(LOCK), exist_ok=True)
now = time.time()
rec = {'pid': pid or os.getppid(), 'owner': owner, 'start': now, 'beat': now, 'host': socket.gethostname()}
for _ in range(2):
try:
fd = os.open(LOCK, os.O_CREAT | os.O_EXCL | os.O_WRONLY)
with os.fdopen(fd, 'w', encoding='utf-8') as f: json.dump(rec, f)
try: os.remove(os.path.join(ROOT, 'ml', 'runs', 'STOP')) # 上次的停止请求不能让这次一开训就退出
except FileNotFoundError: pass
return True, rec
except FileExistsError:
lk = lock_read()
if lk is not None and lock_alive(lk): return False, lk
try: os.remove(LOCK) # 陈旧锁(或刚好被写了一半):接管
except FileNotFoundError: pass
return False, lock_read()
def lock_beat():
lk = lock_read()
if not lk: return
lk['beat'] = time.time()
tmp = LOCK + '.%d.tmp' % os.getpid()
with open(tmp, 'w', encoding='utf-8') as f: json.dump(lk, f)
os.replace(tmp, LOCK)
def lock_release():
try: os.remove(LOCK)
except FileNotFoundError: pass
# ---------------------------------------------------------------- 状态
def todo(d=None):
d = d or load()
return [s for s in (d.get('plan') or []) if (d['stages'].get(s) or {}).get('rc') != 0]
def mark_stopped(rc=-15):
"""网页「停止」后调用:当前阶段记为被停止,释放锁。"""
d = load(); st = d.get('current')
if st and st in d['stages'] and d['stages'][st].get('end') is None:
d['stages'][st].update(end=time.time(), rc=rc)
d['current'] = None
save(d); lock_release()
def main():
cmd, *rest = sys.argv[1:] or ['?']
if cmd == 'lock':
sub = rest[0] if rest else 'show'
if sub == 'acquire':
ok, lk = lock_acquire(rest[1] if len(rest) > 1 else 'terminal', int(rest[2]) if len(rest) > 2 else None)
if not ok:
age = time.time() - lk.get('beat', 0)
print(f"已有一条流水线在运行({lk.get('owner')},pid {lk.get('pid')},{age:.0f} s 前心跳)。"
f"锁文件 {os.path.relpath(LOCK, ROOT)};确认它已经不在了,等 {LOCK_STALE_S // 60} 分钟自动失效或手动删除该文件。", file=sys.stderr)
sys.exit(9)
elif sub == 'beat': lock_beat()
elif sub == 'release': lock_release()
else: print(json.dumps(lock_read()))
return
if cmd == 'timeout':
print(int(TIMEOUT_H.get(rest[0], 3.0) * 3600 * float(os.environ.get('TIMEOUT_SCALE') or 1))); return
if cmd == 'get':
v = (load()['stages'].get(rest[0]) or {}).get('info', {}).get(rest[1])
if v is None: print(f'pipeline.json 里没有 {rest[0]}.{rest[1]}', file=sys.stderr); sys.exit(1)
print(v); return
if cmd == 'todo':
print(' '.join(todo())); return
d = load(); now = time.time()
if cmd == 'reset':
plan = rest[0].split(',') if rest and rest[0] else []
pn = params_now(); prev = d.get('params') or {}
if '--fresh' in rest: bad = set(ORDER)
else:
changed = [k for k in PARAM_KEYS if prev and prev.get(k) != pn[k]]
bad = {st for k in changed for st in AFFECTS[k]}
if changed: print('参数与上次不同:' + json.dumps({k: (prev.get(k), pn[k]) for k in changed}, ensure_ascii=False)
+ ';受影响的阶段重跑:' + ' '.join(st for st in ORDER if st in bad), file=sys.stderr)
keep = {k: v for k, v in d.get('stages', {}).items() if v.get('rc') == 0 and k not in bad}
# 受影响 / 未完成的阶段:旧产物挪走,免得续训、跳过 shard 时把旧参数的东西接上
arc = archive([st for st in ORDER if st in bad]) if bad else None
if arc: print(f'旧产物已移到 {arc}/(未删除)', file=sys.stderr)
d = {'started': now, 'plan': plan, 'stages': keep, 'current': None, 'params': pn}
done = [s for s in plan if s in keep]
if done: print('跳过已完成的 %d 个阶段:%s' % (len(done), ' '.join(done)), file=sys.stderr)
save(d); return
elif cmd == 'begin':
st = rest[0]
d['stages'][st] = {'start': now, 'end': None, 'rc': None, 'info': kv(rest[1:]), 'timeout_h': TIMEOUT_H.get(st)}
d['current'] = st
elif cmd == 'set':
s = d['stages'].setdefault(rest[0], {'start': now, 'end': None, 'rc': None, 'info': {}})
s['info'].update(kv(rest[1:]))
elif cmd == 'end':
st, rc = rest[0], int(rest[1]) if len(rest) > 1 else 0
s = d['stages'].setdefault(st, {'start': now, 'info': {}})
s['end'], s['rc'] = now, rc
if d.get('current') == st: d['current'] = None
else:
print(__doc__); return
save(d)
if __name__ == '__main__':
main()