AI力场二次开发教程(18):高吞吐虚拟筛选——批量分子 AI 参数化自动化
适用版本与技术栈(以官方文档为准)
- Python 3.9+(标准库
concurrent.futures/multiprocessing)- espaloma 0.3.2(conda-forge)
- openff-toolkit 0.19.0
- 具体 GPU 吞吐、单分子耗时一律写合理范围,以实际硬件为准;GPU batch 语义以 espaloma 官方为准
一句话结论:高吞吐虚拟筛选的瓶颈从"单分子参数化速度"转移到"任务编排效率",用concurrent.futures对大量 SMILES 调用esp.Graph+get_model+openmm_system_from_graph可把批量参数化拆成可并发、可超时、可缓存、可统计的成功/失败流水线,最终落成 CSV 报告。
〇、认知问题
- 为什么"批量成功率"比"单分子算得快"更能决定虚拟筛选产能?(认知)
- 并行(parallel)、批处理(batch)、GPU batch、队列(queue)、缓存(cache)各自解决什么问题、怎么分工?(认知)
- 用 concurrent.futures 对 100 个 SMILES 批量调用
esp.Graph + get_model + openmm_system_from_graph的最小可靠骨架怎么写?(方法) - 如何给批处理加工时(timeout)与异常捕获,使"一个坏分子"不至于拖垮整批?(方法)
一、机制解析
1.1 虚拟筛选的算力结构
虚拟筛选流程通常分四段:
SMILES 库 │ (段1)图构建 / 化学校验 ← 大量 I/O 与 rdkit/openff 工作 ▼ 参数化(力场生成 System) ← espaloma GNN(段2) │ ▼ 快速模拟 / 打分(能量、RMSD、对接) ← OpenMM(段3) │ ▼ 结果落 CSV / 后续精选 ← 汇总统计(段4)对 AI 力场(espaloma)来说,段2原本极快(GNN 推断是近瞬时量级),但一旦你逐分子串行加载模型、前向、组装 System,I/O 与 Python 层开销就开始主导。因此高吞吐筛选的真问题是任务编排而非单点计算。
1.2 并行 vs 批处理 vs 队列 vs 缓存:分工
表:并发术语的架构分工
| 术语 | 解决什么 | 典型实现 |
|---|---|---|
| 并行(parallel) | 多核/多进程同时干独立任务 | concurrent.futures.ProcessPoolExecutor |
| 并发(concurrent) | 单进程内多任务交错推进 | ThreadPoolExecutor(适合多 I/O) |
| 批处理(batch) | 一次提交多个输入减少调度开销 | 把 SMILES 列表分块喂给模型 |
| GPU batch | 一张卡上把多个分子的推算合并张量 | 由模型 forward 张量维度决定,语义以官方为准 |
| 队列(queue) | 限流、分片、断点续跑 | 轻量自实现分块 + 进度偏移量 |
| 缓存(cache) | 幂等,避免重复参数化同一分子 | 以规范化 SMILES 为 key 的文件/内存缓存 |
关键认知:CPU 并行适合"每次调用各起物理进程",GPU 却常同一卡单进程内用 batch 维度加速;两者用在同一管线时要把 executors 配合好,否则容易出现"多进程抢卡"而瓶颈在 PCIe 带宽而不是算力。
1.3 缓存是吞吐的"隐性倍数器"
真实库里有大量重复骨架或重复 SMILES。按规范 SMILES(canonical SMILES)去重 + 落缓存,可以让后续重复分子 O(1) 命中,把有效吞吐抬升数倍到数量级。这也是"幂等设计"的核心。
1.4 成功率是工程指标
筛选库来源五花八门(外部库、生成库、偶有错价态),必然有少数分子参数化失败。工程上要的是:健康地跳过它们、统计它们、报告它们,而不是让整个批死掉。这引出下面的 timeout 与异常捕获设计。
二、完整代码与逐行剖析
给出两段真实可运行骨架:第一段是单 worker 函数(含超时与异常捕获),第二段是用concurrent.futures编排 100 个 SMILES 的批量主逻辑并产出 CSV 报告。
2.1 代码一:单分子 worker(含异常与结果结构化)
# 文件:parametrize_one.py# 思路:把"一个 SMILES → 参数化 System → 元数据"封装成纯函数,便于并行与测试。fromopenff.toolkit.topologyimportMoleculeimportespalomaasespfromespaloma.graphs.deployimportopenmm_system_from_graph# 模型一次性加载,避免每个分子重复 get_model(import 缓存)_MODEL_CACHE={}def_get_model():"""懒加载单例模型,减少重复加载开销。"""if"latest"notin_MODEL_CACHE:_MODEL_CACHE["latest"]=esp.get_model("latest")return_MODEL_CACHE["latest"]defparam_one(smiles,timeout_s=10):""" 返回 (smiles, ok, n_atoms, err_msg)。 任何异常都被捕获并转为结构化结果,绝不让单分子错误向外抛。 """try:molecule=Molecule.from_smiles(smiles)# 化学校验/去氢规范化g=esp.Graph(molecule)# 建异质图model=_get_model()# 加载(缓存)模型model(g.heterograph)# GNN 前向system=openmm_system_from_graph(g)# 组装 OpenMM Systemreturnsmiles,True,molecule.n_atoms,""exceptExceptionasexc:# 捕获所有参数化异常returnsmiles,False,0,f"{type(exc).__name__}:{exc}"if__name__=="__main__":# 单测:一个正常分子 + 一个人为非法 SMILESprint(param_one("CN1C=NC2=C1C(=O)N(C(=O)N2C)C"))print(param_one("not_a_real_smiles"))逐行剖析:_MODEL_CACHE是进程级单例缓存,_get_model()确保 100 个分子只get_model("latest")一次(否则吞吐被重复拉权重拖垮)。param_one返回四元组,把ok/err_msg显式带出,异常在函数内就地消化。这里留了timeout_s参数位,配合下方主逻辑的as_completed超时读法使用。
2.2 代码二:concurrent.futures 批量编排 + CSV 报告
# 文件:batch_parametrize.py# 思路:对 100 个 SMILES 用线程池并发参数化,# 统计成功/失败计数并写出 CSV,每分子带运行状态。importcsvimportsysfromconcurrent.futuresimportThreadPoolExecutor,as_completedfromparametrize_oneimportparam_onedefload_smiles(path,n=100):"""从纯文本/CSV 读入最多 n 个 SMILES(示例实现)。"""out=[]withopen(path,"r",encoding="utf-8")asf:forlineinf:line=line.strip()iflineandnotline.startswith("#"):out.append(line)iflen(out)>=n:breakreturnoutdefmain(smiles_list,out_csv,max_workers=8,per_call_timeout=15):results=[]withThreadPoolExecutor(max_workers=max_workers)asex:# 提交全部任务(ThreadPool 内部维护队列)future_map={ex.submit(param_one,s):sforsinsmiles_list}forfutinas_completed(future_map,timeout=None):smiles=future_map[fut]try:ans=fut.result(timeout=per_call_timeout)# worker 内超时读法exceptTimeoutError:ans=(smiles,False,0,"TimeoutError: worker 超时")exceptExceptionasexc:ans=(smiles,False,0,f"{type(exc).__name__}:{exc}")results.append(ans)# 统计成功/失败ok=sum(1forrinresultsifr[1])fail=len(results)-okprint(f"成功{ok}/{len(results)},失败{fail},成功率{100*ok/max(1,len(results)):.1f}%")# 写 CSV 报告withopen(out_csv,"w",newline="",encoding="utf-8")asf:w=csv.writer(f)w.writerow(["smiles","ok","n_atoms","error"])w.writerows(results)print("报告已写入:",out_csv)defmake_sample(n=100):"""生成 n 个示例 SMILES(因需真实,这里用合法骨架批量拼接示意)。"""base="CN1C=NC2=C1C(=O)N(C(=O)N2C)C"return[base]*n# 练习时请换成真实多样的中性分子列表if__name__=="__main__":# 演练入口:无外部文件时用示例列表;有文件时从文件读iflen(sys.argv)>1:smis=load_smiles(sys.argv[1])else:smis=make_sample()main(smis,out_csv="batch_report.csv",max_workers=8)逐行剖析:ThreadPoolExecutor维护内部任务队列,submit入队、as_completed就绪即收;fut.result(timeout=...)是本批的"超时防线"——某 worker 卡死不会拖死收集循环。成功/失败用生成器表达式汇总,再csv.writer落盘。make_sample故意用重复合法骨架顶位,提醒你真实应用时应替换为多样化的中性分子列表(这正是本章练习)。
三、常见报错与排查
表:批量参数化常见问题
| 现象 / 报错 | 可能原因 | 处理思路 |
|---|---|---|
| 召回率骤降(大量失败) | 库中混入非法/阴阳离子价态 | 预处理(规范 SMILES、中性化),失败走高亮日志 |
| 进程全忙但 GPU 利用率低 | 多进程各起小 batch,争抢 I/O | 减少进程数,改用单进程内 GPU batch,以官方为准 |
TimeoutError集中出现 | 候选分子过大或模型输入异常 | 加大 timeout,按分子大小分层设置 |
| CSV 里 n_atoms 全为 0 | 失败分支填充占位 | 检查失败分子误差列以定位根因 |
| 重复分子反复算 | 没做缓存 | 加规范化 SMILES 为 key 的幂等缓存 |
排查策略:把失败样例单独导出,逐条用param_one串行复现,根据err_msg归类;再做一次"坏分子冒烟测试"验证 timeout 与异常路径确实生效。
四、动手练习
练习 1(必做):准备一份 100 个中性 SMILES 列表(可用 RDKit 从常见药物骨架构造),运行 2.2 段主逻辑,统计成功率并查看 CSV。故意混入 2–3 个非法 SMILES,验证它们被健康跳过而非中断整批。
练习 2(进阶):对比两种并发策略(ThreadPoolExecutorvsProcessPoolExecutor)在同一批数据上的耗时,画一张"耗时 vs max_workers"小表,讨论为何多 I/O 任务线程池更划算。
练习 3(设计):给param_one加一个朴素幂等缓存(内存 dict 以 canonical SMILES 为 key),再次运行同一批,记录命中率与省下的时间。
五、小结与下一篇预告
本篇拆解了高吞吐虚拟筛选的工程结构:并行/批处理/GPU batch/队列/缓存各司其职,并用concurrent.futures交付了一个带超时、异常捕获、成功/失败统计与 CSV 报告的批量参数化骨架。核心要点:吞吐的敌人是糟糕的任务编排而非单分子速度,健康统计 + 幂等缓存才是产能的底座。
下一篇(第 19 篇)将从"跑得快"走向"算得准":性能与精度调优——步长/约束/HMR/GPU 对速度与稳定性影响,以及能量守恒与相对能差这类验证标准,把 AI 力场与传统力场放在同一把标尺下对照。
本篇认知问题回显(FAQ)
为什么批量成功率比单分子算得快更能决定虚拟筛选产能?
因为真实库含大量失败候选,健康的失败跳过与统计可让整批不中断;能持续产出高成功率报告,才能稳定支撑后续精选,单分子再快也无法弥补编排崩溃。并行、批处理、GPU batch、队列、缓存怎么分工?
并行用多核同时跑独立任务,批处理用分块减少调度,GPU batch 在单卡上合并张量加速,队列负责限流与断点续跑,缓存用 key 幂等去重避免重复参数化。concurrent.futures 批量调用 espaloma 的最小可靠骨架怎么写?
把单分子封装成param_one(smiles)->(smiles,ok,n_atoms,err),ThreadPoolExecutor.submit入队、as_completed收集,配fut.result(timeout=...)超时防线,最后按ok统计并csv.writer落盘。如何处理批处理中的超时与异常使坏分子不拖垮整批?
在 worker 内 try/except 消化所有参数化异常,收集循环里用fut.result(timeout=param)控制等待上界,任一超时/失败只标记该分子为失败,批内其余任务继续完成。
查看第 18 篇教程