:性能、容错与规模化——从能跑到万级跑得稳)
生物大分子批量仿真开发教程19性能、容错与规模化——从能跑到万级跑得稳版本声明块工具/软件Python 3.10标准库 concurrent.futures、sqlite3被调度对象为第 1618 篇产出的命令行作业$SCHRODINGER/run、moebatch、gmx mdrun等语言/环境Linux 集群Slurm/LSF 队列语境 本地进程池本文目标给第 17/18 篇两条实战流水线补上调度、容错与容量规划三块地基。一句话结论万级仿真的稳态运行三件套——执行模式按千级以下本地进程池、千级以上作业阵列选择失败按拓扑/力场参数类永久错不重试、超时/节点漂移类瞬时错指数退避重试、全部落库处理铁律 10容量按墙钟时 ≈ N × t_p90 × 1.2 ÷ (并行槽 × 利用率0.6~0.8)从试跑样本外推且并行槽永远不得大于许可席位。〇、本篇要解决的认知问题批量仿真什么情况下用集群作业阵列job array什么情况下用本地并行进程批量流水线的失败怎么分类重试为什么失败原因必须逐条落库结果库如何用内容哈希做主键实现索引、去重与重跑只覆盖同键一万抗体变体的机器时怎么从几十条试跑样本外推商用软件许可license在规模化时有哪些必须提前核实的合规约束一、机制解析1.1 两种执行模式进程池与作业阵列不是竞争关系本地进程池模型 作业阵列模型Slurm/LSF ────────────── ──────────────────────── scheduler.py(你写的) sbatch --array1-N run_one.sh %a ├─ ThreadPool(S 槽) ├─ 阵列槽1 → 计算节点1 (jids[0]) ├─ subprocess.run(cmd, timeout) ├─ 阵列槽2 → 计算节点2 (jids[1]) ├─ 失败→分类→退避→本进程重试 ├─ 节点被抢占 → 队列自动重排队 └─ 结果→SQLite(单写者) └─ 每阵列任务内部仍可调本地池(S节点核数)维度本地进程池作业阵列启动开销毫秒级每作业秒分钟级排队调度容错全靠你自己本篇代码 A节点故障自动重排队断点续跑查结果库跳过同一套脚本内查库跳过 --exclude已完成索引并行上限单机核数、单份网络许可集群配额调试体验断点/日志直连跨节点翻日志经验法则单元作业 60 秒的轻量打分序列级指标永远用进程池小时级的建模与 MD第 07/11 篇上千条就上作业阵列混合形态最常见——阵列管粗粒度分片每阵列任务领 100 个 jid任务内部再用进程池吃满本节点$SCHRODINGER/run的-HOST localhost:4一类参数正属于这一层官方 intro 文档。1.2 失败分类学重试的钱只花在可恢复的错上铁律 10 说失败必须落库本篇回答怎么落、落了怎么用。分类表关键词按你站点的真实报错样本配置在 YAML/字典里不要硬编码进业务代码类别例子对应铁律 10 三分法可重试策略永久错拓扑构建失败、力场参数缺失、编号冲突铁律 1、版本断言失败铁律 2否attempts1 直接落库进待人工清单瞬时错超时、节点被杀、许可暂时占满、MD 收敛波动是指数退避抖动上限 23 次未知分类器没命中任何规则谨慎只重试 1 次仍败则升待人工禁止无限重试关键语义重试不换哈希。同一jid目录内 attempts 计数递增只有当你改了参数/上游输入内容哈希才变、才会生成新 jid 重算——这保证重试永远不可能污染幂等空间。1.3 结果库哈希主键 UPSERT 去重第 08 篇讲过用 MAE/项目表充当轻量结果库、上万行迁 SQLite/Parquet本篇给出规模化形态jobs(jid, stage)复合主键存状态与失败分类metrics(jid, stage, metric)三列联合主键存打分——重跑同一键自动ON CONFLICT DO UPDATE物理上杜绝同一个变体同一阶段两行分。所有读侧报告、排序走视图取该键最新一行写侧收敛为单写者多线程共享一个连接锁多机则升级为每机一库再合并或换 Postgres。1.4 吞吐测算从试跑到万级的一张表墙钟公式T_wall N × t_eff ÷ (S × u)。四个量各有出处N 是漏斗该级实到数量第 17 篇 L2 约 9,500、L4 约 500t_eff 用试跑样本的t_p90 而非均值MD 收敛长尾会把均值骗得很难看再乘 1.2 的长尾修正经验法则S 为并行槽且S ≤ min(节点核数, 许可席位)u 是集群利用率经验值 0.60.8。示范全部为经验估算投产前务必按第 03 节代码 C 以真实试跑重算建模 9,500 条 × t_p901.2 核·时 ÷ (64×0.7) ≈ 255 墙钟时MD 500 条 × t_p9022 GPU·时 ÷ (4×0.6) ≈ 4,580 墙钟时——MD 占掉九成预算所以第 17 篇的漏斗截断比例比任何代码优化都值钱。若升到 Schrödinger Release 2026-3Desmond 的 HMR氢质量再分配官方标称提速约 66%同一 GPU 数下 MD 墙钟可压到约 2,800 小时量级。1.5 许可合规并行度的隐形天花板网络发行版许可下同时运行的产品实例数通常受席位数约束——并发提交 200 个 Desmond 作业而席位只有 8后果不是排队而是成片超时失败再被你的调度器全记成failed第 17 篇台账被污染。三条纪律其一调度器的槽参数slots ≤ 席位数写死进配置并随版本一起哈希入库其二许可占用类失败单列fail_classlicense重试必须带退避与抖动绝不允许整点齐发重试风暴其三云端或跨站点跑商业软件的许可条款以你的合同与厂商批准为准不是技术问题——上线前找 IT 与厂商书面确认。schrodinger.job.jobcontrol与 MOE 的批处理入口本身不解决许可问题它们只是排队的人闸门在合同里。二、完整代码与逐行剖析代码一scheduler.py —— 带超时/分类重试/落库的进程池调度器# scheduler.py —— 本地调度核心换作业阵列时只替换 run_unit 的执行体账本逻辑原样复用importhashlib,random,subprocess,timefromconcurrent.futuresimportThreadPoolExecutorfromdataclassesimportdataclass,fieldfrompathlibimportPathfromrepoimportJobRepo# 代码二结果库单写者RULES{# 失败分类器关键词来自你站点真实报错样本改分类改配置不动代码topology:{kw:[topology,genion],retry:False},forcefield_param:{kw:[parameter file,missing par],retry:False},convergence:{kw:[convergen],retry:True},license:{kw:[license],retry:True},}dataclassclassUnit:stage:strcmd:list# 必须是 List[str]——与 jobcontrol.launch_job 同一纪律第 16 篇inputs:listfield(default_factorylist)timeout:int3600retries:int2# 瞬时错的额外重试次数永久错用不到它defunit_id(u:Unit)-str:hhashlib.sha256()h.update(u.stage.encode());h.update( .join(u.cmd).encode())forpinsorted(Path(x)forxinu.inputs):h.update(p.read_bytes())# 只哈希内容mtime/绝对路径/时间戳参与幂等破坏returnh.hexdigest()[:16]defclassify(log:str):# 返回 (类别, 是否可重试)forkind,rinRULES.items():ifany(k.lower()inlog.lower()forkinr[kw]):returnkind,r[retry]returnunknown,True# 未知按可重试处理但下面只给一次机会defrun_unit(u:Unit,repo:JobRepo,sw_ver:str,cfg_sha:str)-str:jidunit_id(u)ifrepo.is_done(jid,u.stage):returnskipped# 续跑凭据查库判断与第 17 篇 .done 文件等价t0,max_try,attemptstime.time(),u.retries1,0whileattemptsmax_try:attempts1try:rsubprocess.run(u.cmd,capture_outputTrue,textTrue,timeoutu.timeout)rc,logr.returncode,(r.stderror)[-4000:]exceptsubprocess.TimeoutExpired:rc,log-9,timeout# 超时归瞬时错可能只是节点忙或许可排队ifrc0:repo.finish(jid,u.stage,ok,walltime.time()-t0,attemptsattempts,sw_versw_ver,cfg_shacfg_sha)returnokkind,retryableclassify(log)repo.finish(jid,u.stage,failed,fail_classkind,fail_loglog,attemptsattempts,walltime.time()-t0,sw_versw_ver,cfg_shacfg_sha)ifnotretryableor(kindunknownandattempts2):returnfailed# 永久错不重试未知错只给一次防无限重试烧钱time.sleep(min(300,5*2**attempts)*(0.5random.random()))# 指数退避×[0.5,1.5)抖动让同一时刻被许可挡下的 N 个作业错峰重投1.5 节returnfaileddefschedule(units,repo,slots,sw_ver,cfg_sha):withThreadPoolExecutor(max_workersslots)aspool:# slots ≤ min(核数, 许可席位)foroutinpool.map(lambdau:run_unit(u,repo,sw_ver,cfg_sha),units):pass# 结果全部已在库里map 仅为背压式消费这个调度器的每个决定都能溯源unit_id不吃 mtime 是因为重新 checkout 同一份输入不该触发全量重算classify的未知错只重试一次防止分类器盲区变成无限循环repo.finish无论成败先落库再决定控制流——这就是铁律 10 的实现形态落库发生在分支之前而不是在 except 里祈祷有人记得记。代码二repo.py —— 哈希主键、UPSERT 去重、单写者结果库# repo.py —— 所有流水线共用的一个库第 17/18 篇的台账在这里合成一张可查的网importsqlite3,threading SCHEMA CREATE TABLE IF NOT EXISTS jobs( jid TEXT, stage TEXT, status TEXT CHECK(status IN (ok,failed)), fail_class TEXT, fail_log TEXT, attempts INTEGER, wall REAL, sw_version TEXT, cfg_sha TEXT, ts REAL DEFAULT (strftime(%s,now)), PRIMARY KEY(jid, stage)); -- 同一身份同一阶段永远只有一行写冲突即更新 CREATE TABLE IF NOT EXISTS metrics( jid TEXT, stage TEXT, metric TEXT, value REAL, PRIMARY KEY(jid, stage, metric)); -- (身份,阶段,指标) 三元组天然去重 CREATE VIEW IF NOT EXISTS v_best AS -- 读侧唯一入口ok 优先、其次最新尝试 SELECT j.*, m.metric, m.value FROM jobs j LEFT JOIN metrics m ON m.jidj.jid AND m.stagej.stage WHERE j.ts (SELECT MAX(k.ts) FROM jobs k WHERE k.jidj.jid AND k.stagej.stage); classJobRepo:def__init__(self,pathresults.sqlite3):self.consqlite3.connect(path,check_same_threadFalse)# 单写者整库共享一个连接self.lockthreading.Lock()# 一把锁避开database is lockedself.con.executescript(SCHEMA)defis_done(self,jid,stage):curself.con.execute(SELECT status FROM jobs WHERE jid? AND stage?,(jid,stage))returncur.fetchone()(ok,)deffinish(self,jid,stage,status,fail_class,fail_log,attempts1,wall0.0,sw_ver,cfg_sha):withself.lock:self.con.execute( INSERT INTO jobs(jid,stage,status,fail_class,fail_log,attempts, wall,sw_version,cfg_sha) VALUES(?,?,?,?,?,?,?,?,?) ON CONFLICT(jid,stage) DO UPDATE SET -- 重试/重跑覆盖同键 statusexcluded.status, fail_classexcluded.fail_class, fail_logexcluded.fail_log, attemptsexcluded.attempts, wallexcluded.wall, tsexcluded.ts,(jid,stage,status,fail_class,fail_log,attempts,wall,sw_ver,cfg_sha))self.con.commit()defput_metrics(self,rows):# rows: [(jid,stage,metric,value),...]withself.lock:self.con.executemany( INSERT INTO metrics(jid,stage,metric,value) VALUES(?,?,?,?) ON CONFLICT(jid,stage,metric) DO UPDATE SET valueexcluded.value,rows)self.con.commit()defexport(self,pathresults.parquet):# 给第 08/20 篇的报告与 BI 侧用列存快照importpandasaspd pd.read_sql(SELECT * FROM v_best,self.con).to_parquet(path)对比第 17 篇的文件系统.done文件标记适合单机漏斗SQLite 主键适合多阶段多后端汇聚后的一致性查询——两者不互斥.done是热路径快查库是事实源出现分歧以库为准。代码三estimate.py —— 试跑样本外推万级机器时# estimate.py —— 先试跑 50~500 单元再回答一万条要多久预算评审就拿这张表importsqlite3defpctl(xs,q):# 手写分位数n 小、依赖少比 import numpy 更稳xssorted(xs)returnxs[min(len(xs)-1,int(q*len(xs)))]defestimate(stage,n_target,slots,util0.7,tail1.2,seatsNone):consqlite3.connect(results.sqlite3)rows[r[0]/3600.0forrincon.execute(SELECT wall FROM jobs WHERE stage? AND statusok,(stage,))]iflen(rows)20:returnf{stage}: ok 样本仅{len(rows)}条(20)禁止外推——先扩大试跑t50,t90pctl(rows,0.50),pctl(rows,0.90)slots_effslotsifseatsisNoneelsemin(slots,seats)# 许可席位是硬闸1.5 节walln_target*t90*tail/(slots_eff*util)# 用 t90 不用均值抗长尾return(f{stage}: n试{len(rows)}t50{t50:.2f}h t90{t90:.2f}h | fN{n_target}S{slots_eff}(u{util}修正×{tail}) → 墙钟≈{wall:.0f}h ≈{wall/24:.1f}天)if__name____main__:print(estimate(modeling,9500,slots64,seatsNone))# CPU 建模段不受产品席位约束的开源路线print(estimate(md,500,slots8,util0.6,seats4))# Desmond/MOE 段席位 4 → 槽压到 4样本门槛n20 禁止外推是纪律不是建议MD 段的 t_p90 在小样本下严重低估20 条以下的外推误差经常超过 3 倍。seats参数把许可合规直接编码进容量规划——忘了传它评审通过的就是一个不可能兑现的排期。三、常见报错与排查重跑不幂等哈希目录越滚越多。根因哈希输入混入了时间戳、绝对路径或文件 mtime。判定法同一输入 checkout 两次分别计算 jid必须一致。解法哈希只吃内容阶段配置本篇unit_id示范。成片fail_classlicense超时。根因并发槽超过许可席位作业在等许可而非等计算。解法slotsmin(slots, seats)重试退避加抖动若许可被别的课题组共用把时段配额写进调度配置而不是群里喊话。收敛波动被误判永久错一夜淘汰两百个好候选。根因分类关键词太宽例如拿param这种子串匹配。解法关键词宁短忌泛、按完整报错词组匹配unknown 兜底重试一次上线前用一批已知可恢复的失败回放分类器误判率5% 不许投产。sqlite3.OperationalError: database is locked。根因多进程各自开连接抢写。解法单写者锁本篇形态或每进程写自己的分片库再 ATTACH 合并多机常态化后直接迁 Postgres第 08 篇的迁移路径。作业阵列整体吞吐反而低于本地池。根因每单元只有几十秒排队与启动开销吞掉净时或万级作业同时挂 NFS 造成 IO 风暴。解法粗粒度分片每阵列任务领 100 个 jid 内部再开池、错峰 sleep 抖动、先写本地 scratch 再回传。四、动手练习故障注入收敛测试造 100 个 mock 单元python -c import sys,random; sys.exit(...)20% 抛永久错关键词、20% 前两次抛瞬时错第三次成功。判定标准最终jobs表 ok80、永久错行attempts1、瞬时恢复行attempts≥2再原样跑一遍第二遍全部skipped零重提。外推精度对 500 个真实建模单元用estimate(modeling, 500, slots64)预测再实测。判定标准预测墙钟与实测偏差 30%若 30%检查是否均值/t90 混用或席位漏填。UPSERT 去重对同一(jid, surface, camsol)先后put_metrics两个值。判定标准SELECT COUNT(*) FROM metrics该键恰为 1 行且值为第二次写入。五、小结与下一篇预告本篇给系列补上规模化地基执行模式按单元成本分级失败按永久不重试、瞬时退避重试、未知只给一次三分且落库先于控制流铁律 10结果库以(jid, stage[, metric])主键 UPSERT 天然去重容量按 t_p90 外推并把许可席位写进公式slots_eff min(slots, seats)。第 17 篇的漏斗与第 18 篇的 YAML 流水线从这里拿走了调度器与账本。最后一篇第 20 篇做总装CLI 配置 Maestro/MOE/开源三后端适配器 结果仓库 报告输出把前 19 篇装配成一个可交付内部使用的抗体批量仿真平台。本篇认知问题回显FAQQ1批量仿真选集群作业阵列还是本地并行进程界限在哪A看单元成本秒级轻量打分用本地进程池线程subprocess 超时小时级建模/MD 上千条用 Slurm/LSF 作业阵列粗粒度分片、每阵列任务内部再开进程池断点续跑两边共用同一张结果库做跳过凭据。Q2批量仿真的失败如何分类与重试A拓扑构建、力场参数缺失、编号冲突类为永久错——attempts1 直接落库不重试超时、节点被杀、许可暂占、收敛波动类为瞬时错——指数退避乘随机抖动重试 23 次未分类错只重试一次重试不换内容哈希 jid全部结果先写库再走分支。Q3结果库怎么按内容哈希索引并去重Ajobs 表用 (jid,stage) 复合主键、metrics 表用 (jid,stage,metric) 联合主键写入一律 INSERT…ON CONFLICT DO UPDATEUPSERT重跑只覆盖同键哈希输入只含文件内容阶段配置排除 mtime/时间戳/绝对路径。Q4一万个变体的机器时怎么估算A试跑≥20 单元取 wall 的 t_p90按 墙钟N×t_p90×1.2÷(并行槽×利用率0.6~0.8) 外推并行槽取 min(核数, 许可席位)Desmond 2026-3 的 HMR 可再提速约 66% 作为杠杆项。样本20 条禁止外推。Q5批量扩到万级时商用软件许可要注意什么A并发实例数不得超过合同席位数否则成片超时被误记为计算失败许可占用失败单列 fail_classlicense 并带退避抖动重试跨站点/云端运行须事先取得厂商书面许可席位上限作为配置项参与运行哈希以便审计。