RDKit 基准测试指南:用 `Code/Bench` 量化分子处理核心路径的性能

原创2026-10-06 00:00:391,906 阅读
文章标签:科学计算科研机器学习

RDKit 基准测试指南:用 Code/Bench 量化分子处理核心路径的性能

本指南基于 RDKit 仓库中 Code/Bench/README.md 展开,介绍 RDKit 官方内置基准测试套件 bench 的构建方法、运行方式与实现原理。该套件覆盖 SMILES 解析/输出、ROMol 生命周期、分子操作、描述符、指纹、pickle 序列化、立体化学判定与子结构匹配等核心路径,是评估 RDKit C++ 层性能、验证构建产物质量以及做回归保护的重要工具。读完本文,你将掌握如何从源码构建并运行 bench、理解每个基准用例背后的代码逻辑,以及如何借助 Catch2 基准框架解读输出结果。

从源码构建并运行 bench

bench 是一个独立的 C++ 可执行目标,位于 Code/Bench 目录。官方 README 给出了完整的构建与运行流程:

mkdir build
cd build
cmake ..
cmake --build . --target bench -j "$(nproc)"
# see `./Code/Bench/bench --help` for options
export RDBASE=".."
./Code/Bench/bench

关键点说明:

  • --target bench:由于 Code/Bench/CMakeLists.txt 中 add_executable(bench EXCLUDE_FROM_ALL ...) 将该目标从默认的 all 构建中排除,直接 cmake --build . 不会编译它,必须显式指定目标。EXCLUDE_FROM_ALL 的设计意图是:基准测试耗时较长,不应在常规构建中默认执行。
  • export RDBASE="..":bench 运行时需要定位仓库根目录下的数据文件。在 substruct_match.cpp 中,relative_to_rdbase() 通过 std::getenv("RDBASE") 读取该环境变量,若未设置会直接抛出 std::runtime_error("RDBASE environment variable not set");随后将 RDBASE 与相对路径拼接,用于加载 SMARTS 示例文件。因此省略这一步会导致子结构匹配相关的基准用例失败。
  • --help:bench 基于 Catch2 测试框架(源码统一 #include <catch2/catch_all.hpp>),因此继承 Catch2 的命令行能力,可查看所有可用选项。

依赖库与条件编译

从 Code/Bench/CMakeLists.txt 可以看到 bench 的链接依赖:

  • 必选:rdkitCatch(测试基础设施)、CIPLabeler、Descriptors、Fingerprints、SmilesParse;
  • 可选:当启用了 RDK_BUILD_INCHI_SUPPORT 时,额外链接 RDInchiLib 并把 inchi.cpp 加入编译。这意味着 InChI 相关基准只有在开启 InChI 支持的构建中才会出现,这是解读运行输出时需要注意的构建差异。

基准采样分子:SAMPLES 的设计

所有基准共享同一组测试分子,定义在 bench_common.hpp 的 bench_common::SAMPLES 常量数组中,共 12 条 SMILES,覆盖三类特征:

  • 高度稠合/桥环体系(第一条,多环笼状结构,用于压力测试环系统算法);
  • 多个立体中心与立体基团(第二条,含多个 @ 手性标记与糖苷片段);
  • 随机选取的多样化分子(其余 10 条,包含芳环、卤素、杂环、离子型片段等,尽量覆盖常见化学类型)。

这些分子由 bench_common.cpp 中的 load_samples() 在运行期通过 RDKit::v2::SmilesParse::MolFromSmiles 解析为 ROMol 向量返回,解析失败会触发 REQUIRE 断言。这意味着每个基准用例的输入分子都是完全一致的,保证不同用例、不同构建之间的可比性。

此外,bench_common.hpp 还内联实现了 nth_random()——一个基于 splitmix64 的确定性伪随机数函数(源代码注释标注来源于 xoshiro 项目,因 <boost/random/splitmix64.hpp> 并非总是可用而内联于此),并用 8 个 static_assert 锁定了不同输入下的哈希值。它被 mol.cpp 的“内存压力测试”用于生成确定的随机读写位置,保证测试可复现。

基准用例全景:覆盖 RDKit 的核心调用路径

bench 由 10 个源码文件组成(CMakeLists.txt),每个文件对应一类性能关注点。以下逐一说明其测量对象:

SMILES 解析与输出(smiles.cpp)

三个基准分别测量:

  • SmilesToMol:对每条采样 SMILES 调用 v2::SmilesParse::MolFromSmiles,累加产物原子数作为返回值(防止编译器优化掉计算);
  • MolToSmiles:对预解析的分子调用 MolToSmiles,累加输出字符串长度;
  • MolToCXSmiles:调用 MolToCXSmiles 输出带 ChemAxon 扩展信息的 SMILES。

这三个用例构成"解析—输出"闭环,可对比同一套分子的正向与反向转换开销。

ROMol 生命周期与内存压力(mol.cpp)

  • ROMol copy constructor:使用 Catch2 的 BENCHMARK_ADVANCED 与 Catch::Benchmark::storage_for 精确测量拷贝构造耗时;
  • ROMol destructor:借助 Catch::Benchmark::destructable_object 单独测量析构耗时,排除构造开销的干扰;
  • memory pressure test:预分配 10400 个 ROMol 副本,然后通过 nth_random(i) 生成随机源/目标下标执行"拷贝 + move 赋值",模拟高内存压力下的随机访问与对象迁移成本;
  • ROMol::getNumHeavyAtoms:测量轻量只读查询的调用开销。

分子操作(molops.cpp)

  • MolOps::addHs:对每个采样分子的副本加氢(RWMol mol_copy(mol) 后 MolOps::addHs);
  • MolOps::FindSSR:调用 MolOps::findSSSR 计算最小环集(SSSR),这是环分析类算法的基础;
  • MolOps::getMolFrags:调用 MolOps::getMolFrags 拆分分子片段并累加各片段原子数。

描述符(descriptors.cpp)

测量 Descriptors::calcNumSpiroAtoms(螺原子计数)与 Descriptors::calcNumBridgeheadAtoms(桥头原子计数)两个环拓扑描述符的调用成本,这两个计算内部都依赖环信息,与 molops 中的环系统基准形成呼应。

指纹(fingerprint.cpp)

MorganFingerprints::getFingerprint 以 radius = 2 构造 MorganFingerprint::getMorganGenerator<uint64_t>,对每个分子生成 ExplicitBitVect 指纹并累加置位位数。这是衡量 Morgan 指纹生成吞吐量的代表性用例。

序列化(pickle.cpp)

  • MolPickler::pickleMol:把分子 pickle 序列化到 std::stringstream,返回序列化字节数;
  • MolPickler::molFromPickle:先 pickle 再通过 ROMol res(pickled) 反序列化,累加恢复分子的原子数。

两个用例互为反向操作,适合观察序列化/反序列化的吞吐差异。

立体化学(stereo.cpp)

  • Chirality::findPotentialStereo:调用 Chirality::findPotentialStereo 找出潜在立体中心,并累加 controllingAtoms 数量;循环内调用 mol.clearComputedProps() 清理缓存属性以消除跨轮次污染(源码注释注明这是针对 issue 8880 的 workaround);
  • CIPLabeler::assignCIPLabels:调用 CIPLabeler::assignCIPLabels 分配 CIP 标签;
  • MolOps::assignStereochemistry:通过 GENERATE(true, false) 分别以 legacy 立体感知(UseLegacyStereoPerceptionFixture)开/关两种模式测量 MolOps::assignStereochemistry(mol, cleanIt=true, force=true, flagPossibleStereoCenters=true) 的耗时,并累加各原子的手性标签。这个用例直接反映 RDKit 新旧立体感知实现之间的性能对比。

子结构匹配(substruct_match.cpp)

  • ROMol::GetSubstructMatch:使用一个内置的复杂 SMARTS(含芳香环、卤素、硼酸片段的 [#6,#7]1:...-#5-[#8] 查询)对采样分子逐一执行 SubstructMatch;
  • ROMol::GetSubstructMatch RLewis 与 ... patty:从 $RDBASE/Data/SmartsLib/RLewis_smarts.txt 与 Data/SmartsLib/patty_rules.txt(见 Data/SmartsLib)逐行加载全部 SMARTS 规则(跳过以 # 开头、空格开头或空白的行),用每个分子 × 每条规则的笛卡尔积做匹配。这两个用例是真实场景下"大规则库批量匹配"的典型负载,也解释了为何必须设置 RDBASE。

元基准与 InChI(meta.cpp、inchi.cpp)

  • meta.cpp 中的 bench_common::nth_random 基准本身用于验证基准基础设施("bench about benches"),并保护 nth_random 的实现不被无意改动;
  • inchi.cpp 在启用 RDK_BUILD_INCHI_SUPPORT 时编译,测量 MolToInchi、InchiToInchiKey、InchiToMol 三条 InChI 转换路径的耗时。

理解输出:Catch2 基准框架的行为

bench 的所有用例都通过 Catch2 的 BENCHMARK / BENCHMARK_ADVANCED 宏注册。运行 ./Code/Bench/bench 后,Catch2 会对每个用例执行预热(warm-up)与多轮采样,默认报告每次调用的平均耗时、样本数等统计信息,并支持按 [tag] 过滤用例(如 [smiles]、[molops]、[stereo])。各源码文件中 TEST_CASE("...", "[tag]") 的 tag 就是过滤入口。

关于采样控制,可从 Code/Bench/CMakeLists.txt 的 quickbench 测试看到两个关键选项的用法:

bench --benchmark-samples 1 --benchmark-warmup-time 0
  • --benchmark-samples 1:每个基准仅采样 1 次,大幅缩短运行时间;
  • --benchmark-warmup-time 0:跳过预热阶段。

这段配置同时揭示了 bench 的另一个重要角色——回归保护。在 RDK_BUILD_CPP_TESTS 开启时,CMake 会注册名为 quickbench 的 ctest 用例(即上述极简参数下的快速运行),并在注释中说明其目的是"protect the benchmarks from bit-rot"(防止基准代码因长期不运行而腐化失效);同时 set_target_properties(bench PROPERTIES EXCLUDE_FROM_ALL FALSE) 把 bench 加回默认构建组,使其与仓库其他单元测试保持一致。因此,运行 ctest -R quickbench 即可用极小成本验证基准套件仍能正常编译和执行。

实操建议:如何用 bench 做性能对比

结合 README 与源码实现,推荐以下使用路径:

  1. 基准构建基线:按 README 流程构建 bench,先不加任何优化相关改动直接运行,记录各用例耗时作为基线;
  2. 过滤运行:若只关心某一类路径(如指纹或立体化学),用 tag 过滤,例如 ./Code/Bench/bench "[fingerprint]",避免无关用例浪费时间;
  3. 对比验证:修改 RDKit 源码后重新构建 bench(注意 RDBASE 需指向仓库根目录),对比同一用例的耗时变化;由于采样分子固定、nth_random 确定性输出,结果具备可复现性;
  4. 快速冒烟:在 CI 或日常开发中,可用 ctest -R quickbench 快速验证基准套件未被破坏;
  5. 了解构建边界:若构建时未开启 RDK_BUILD_INCHI_SUPPORT,输出中将不包含 InChI 用例,这是预期行为而非故障。

总而言之,Code/Bench 是 RDKit 官方面向 C++ 核心路径的微基准套件:README 提供了从零开始的构建运行指引,而配套源码则展示了精心设计的固定采样分子、确定性随机数与覆盖全面的用例矩阵。无论是评估机器性能、对比不同构建,还是在修改底层实现后做性能回归验证,它都是一个直接可用的测量工具。

登录后查看全文
rdkit