Frappe 微基准测试(Microbenchmarks)实战指南:基于 pyperf 的框架核心路径性能测量
Frappe 微基准测试(Microbenchmarks)实战指南:基于 pyperf 的框架核心路径性能测量
Frappe 是使用 Python 与 JavaScript 编写的低代码 Web 框架,其框架层的单次操作开销(ORM 取文档、数据库查询、缓存读写、请求分发)直接影响所有上层应用的响应速度。frappe/tests/microbenchmarks/ 目录下这套基于 pyperf 的微基准测试套件,正是用来精确测量这些小而聚焦的框架操作的。读完本文,你将掌握如何运行与筛选微基准、如何搭建无噪声的测量环境获得可信数据,以及如何按照仓库既定规范编写自己的 bench_* 基准函数与 NanoBenchmark。
一、这套微基准测试是什么
微基准(Microbenchmark)测量的不是端到端业务功能,而是框架内部某个具体、聚焦的操作——例如"获取一个缓存文档要多久""执行一次 frappe.get_all 查询要多久""发起一次未认证的 /api/method/ping 请求包含多少固定开销"。
在 frappe/tests/microbenchmarks/README.md 中明确说明:这些基准使用 pyperf 来测量 Frappe 框架的小规模、聚焦操作。与单元测试(断言正确性)不同,基准测试关注的是时间开销;与压测/负载测试(关注吞吐与并发)也不同,它关注的是单次操作的时延。
从仓库结构看,该套件被组织为按领域划分的模块:
- bench_orm.py:ORM 层,如
get_doc、new_doc、save_doc、get_cached_doc、get_meta、get_all/get_list - bench_database.py:数据库层,如
db.get_value的各种调用形态、db.sql、事务提交/回滚 - bench_redis.py:缓存层,如
make_key、Redis set/get/delete 循环 - bench_qb.py:Query Builder(
frappe.qb) - bench_utils.py:工具函数,如
flt、cint、翻译、缓存装饰器、safe_exec - bench_web_requests.py:Web 请求链路,如 ping、登录页/桌面页渲染、列表查询、限流器
- bench_background_jobs.py:后台调度
二、如何运行微基准
2.1 前提:一个"干净"的 site
原文档强调:使用一个未被改动过的新 site。因为基准结果会受到站点数据量、缓存状态和既有定制的影响。标准流程是:
bench new-site bench.localhost
bench --site bench.localhost set-config allow_tests true
bench --site bench.localhost run-microbenchmarks
三条命令依次完成:创建全新站点 → 开启 allow_tests(基准运行需要该配置)→ 运行整个微基准套件。
在 frappe/commands/microbenchmarks.py 中可以看到 run-microbenchmarks 命令的底层实现:它会从 bench 上下文取出第一个 site,先执行 frappe.init(site) 并调用 frappe.cache.flushall() 清空缓存,然后以子进程方式调用 pyperf 的 runner 脚本(run_benchmarks.py),并把 --site 参数透传进去:
frappe.init(site)
frappe.cache.flushall()
# pyperf expects the benchmark script to be the process entry point.
subprocess.check_call([sys.executable, benchmark_runner.__file__, *benchargs])
注意两点:命令注册了 --site 参数处理,而 runner 脚本本身也要求 --site 参数(见 run_benchmarks.py)。
2.2 常用参数
原文档列出如下参数,均透传给 pyperf 的运行器(pyperf.Runner):
| 参数 | 作用 |
|---|---|
--filter benchmark_name |
只运行名称中包含该子串的基准(子串过滤) |
--help |
查看 pyperf 内置 runner 的全部选项 |
-p5 |
用 5 个 worker 进程快速跑一个粗略的基准 |
-o output.json |
将详细结果保存到 JSON 文件,供后续分析 |
例如只测 ORM 相关的基准、每个基准用 5 个进程、并把结果落盘:
bench --site bench.localhost run-microbenchmarks --filter orm -p5 -o orm_results.json
在 run_benchmarks.py 中,--filter 被注册为 benchmark_filter 参数,语义是"子串匹配":
runner.argparser.add_argument(
"--filter",
dest="benchmark_filter",
help="Apply a filter to selectively run benchmarks. This is a substring filter.",
)
为什么整套跑完会很慢?从 run_microbenchmarks 的实现看,每个基准都会被 pyperf 调度到多个 worker 进程、多次重复采样,以获取稳定的统计结果;因此原文档明确提示"Running the complete suite can take a long time",开发中建议先用 -p5 或 --filter 快速迭代。
2.3 两条最有用的 pyperf 命令
- 对比两次运行:
pyperf compare_to baseline.json changed.json——对两个结果文件逐项对比,并施加统计显著性检验(不再只是"看起来快了还是慢了",而是给出置信结论)。这非常适合在优化前后(例如改了一处 ORM 缓存逻辑)判断改动是否真正有效。 - 测量极小操作:
pyperf timeit——适合测量诸如"给对象设置一个属性"这类微秒级操作,可以直接在命令行对单条 Python 语句计时。
三、深入底层:runner 是如何工作的
要写出好的基准,先理解 run_benchmarks.py 的完整流程:
- 构造 pyperf Runner:通过
add_cmdline_args回调把--site和--filter转发给 worker 进程(否则子进程不知道连哪个站点); - 注入元数据:
get_global_metadata()用 git 记录当前 frappe 的 commit 与提交日期(frappe_commit、frappe_commit_date),存入结果文件,保证结果可溯源——这在回归对比时非常关键; - 发现基准:
discover_benchmarks()遍历 7 个bench_*.py模块,用inspect.getmembers找出所有以bench_前缀命名的成员,并把模块名拼进基准名(如orm_get_doc);若发现重名基准会直接frappe.throw报错,最终按名称排序; - 环境准备:
setup()执行frappe.init(site)、断言frappe.conf.allow_tests、frappe.connect()连接数据库,并执行random.seed(42)固定随机种子,保证结果可复现; - 分发执行:普通函数用
runner.bench_func(name, bench)注册;NanoBenchmark对象则用runner.timeit(...)注册(见下文); - 收尾:
teardown()调用frappe.destroy()。
一个值得注意的细节:discover_benchmarks 判断基准成员类型时用的是 isinstance(x, FunctionType | NanoBenchmark)(Python 3.10+ 的联合类型),也就是说一个基准要么是普通函数,要么是 NanoBenchmark 数据类,二者在 runner 中被以不同方式调度。
四、两类基准载体:普通函数与 NanoBenchmark
4.1 普通 bench_ 函数
约定:在 bench_{module}.py 中定义一个 bench_ 前缀的函数,函数体就是被测量的内容。例如 bench_orm.py 中:
def bench_get_doc():
return [frappe.get_doc("Role", r) for r in get_all_roles()]
def bench_save_doc():
# This doctype is used because it has nothing,
# so we are essentially measuring typical "overheads"
frappe.get_doc("Gender", "Other").save()
bench_save_doc 的注释揭示了一个编写技巧:故意选一个"几乎空"的 Doctype(Gender),从而测出 save() 这条链路上的典型固定开销(权限、校验、事件、索引维护等),而不是业务逻辑本身的开销。
4.2 NanoBenchmark:测亚毫秒操作的正确姿势
原文档特别提醒:当测量的操作很小(尤其低于 1ms)时,Python 函数调用的开销本身会扭曲结果。此时应使用 NanoBenchmark 而非普通函数。
NanoBenchmark 是一个 dataclass,定义如下:
@dataclass
class NanoBenchmark:
statement: str
setup: str = "pass"
teardown: str = "pass"
globals: dict[str, Any] | None = None
statement:要被反复执行的语句(字符串);setup/teardown:每次测量前后执行的环境准备/清理语句;globals:执行时注入的全局名字空间(避免每次在语句里重复 import)。
runner 中对应逻辑是(run_benchmarks.py):用 runner.timeit(name, stmt=..., setup=..., teardown=..., globals=...) 注册——这正是 pyperf timeit 风格的调度,直接执行语句本身,绕开了额外一层函数调用包装。
仓库中的典型用法:
bench_new_doc = NanoBenchmark('frappe.new_doc("Role")')
bench_doc_to_dict = NanoBenchmark("doc.as_dict()", setup='doc=frappe.get_doc("User", "Guest")')
bench_flt_typical = NanoBenchmark(
"""flt(x, 2)""",
setup="x = random.uniform(1, 10000)",
globals={"flt": flt, "random": random},
)
可以看到:把变化的数据放进 setup,把待测常量依赖放进 globals,statement 保持极简,这样测量到的就是纯操作时延。
五、编写基准的规范与要点
原文档给出的三条编写守则,逐一展开:
5.1 找到合适的 bench_{module}.py 文件
按被测领域选择模块:ORM 操作进 bench_orm.py,数据库调用进 bench_database.py,缓存进 bench_redis.py,Query Builder 进 bench_qb.py,工具函数进 bench_utils.py,Web 请求链路进 bench_web_requests.py,调度进 bench_background_jobs.py。新模块需要被 discover_benchmarks 中的 benchmark_modules 列表引用才会被发现(run_benchmarks.py)。
5.2 以 bench_ 前缀命名
发现机制(inspect.getmembers + startswith(BENCHMARK_PREFIX))意味着只有 bench_ 前缀的成员会被收集,最终基准名形如 {module_name}_{函数名}(去掉前缀),例如 orm_get_cached_doc、database_get_value_simple、redis_make_key。前缀同时也是过滤依据:--filter orm 就会命中所有 orm_* 基准。
5.3 用 NanoBenchmark 测微小操作
遵循第四节的原则:低于 1ms 的操作、热路径上的小语句,优先 NanoBenchmark;涉及多步逻辑、带循环的,用普通 bench_ 函数。
5.4 确保测到的是"预期路径"
原文档以 frappe.get_cached_doc 为例:如果目标是测量"从 Redis 取缓存文档"的开销,就必须清掉或避开本地缓存,否则测到的只是内存字典命中。
看 bench_orm.py 中的实际实现:
def bench_get_cached_doc():
docs = []
for role in get_all_roles():
doctype = "Role"
docs.append(frappe.get_cached_doc(doctype, role))
# Clear "local" cache to avoid testing basically nothing.
frappe.local.cache.clear()
return docs
注释 "Clear local cache to avoid testing basically nothing" 直白地说明了这条守则。对比同文件中 bench_get_local_cached_doc(不清理本地缓存,测的其实是本地缓存机制本身)和 bench_redis.py 中的 bench_redis_get_local_value(注释明确"after warmup all of these will be from local cache")——同一操作、不同的缓存状态,测的就是完全不同的路径。写基准前先想清楚:你究竟想测哪一层?
同样,bench_database.py 中 bench_set_value_simple 与 bench_delete_value_simple 分别用"对库无实际影响的 noop 语句"来隔离测量框架调用开销;bench_empty_transaction_cycling 则专门测空事务提交/回滚的固定成本。
5.5 结合现有基准快速定位参考
- ORM 系列:bench_get_doc(遍历 10 个 Role 取文档)、bench_get_user(复杂版 get_doc,含子表初始化)、bench_link_validation(Link 字段校验,注意
frappe.db.value_cache.clear()避免复用本地校验结果); - 工具函数系列:bench_utils.py 中的缓存装饰器基准(
site_cache/request_cache/redis_cache,涉及 frappe/utils/caching.py)、浮点/整数转换(flt/cint)、三种翻译场景(未知/无需翻译/有效翻译)、以及safe_exec脚本执行(对应 frappe/utils/safe_exec.py); - Web 请求系列:bench_web_requests.py 从无认证 ping、带认证 ping、socketio 鉴权、表单 getdoc、列表视图查询与计数,到登录页/桌面页渲染、保存文档、限流器全链路,覆盖了请求栈的主要固定开销;
- Query Builder 系列:bench_qb.py 对比
select("*")、多字段 select、get_query各形态(注意均带run=0,只测查询构建不真正执行 SQL); - 调度系列:bench_background_jobs.py 测
enqueue_events_for_site,且用lru_cache缓存 site 名,注释说明"调度器会销毁 locals"。
六、如何获得可靠的测量结果
微基准对运行环境极其敏感。原文档明确指出:本地开发机通常是一个"嘈杂"的基准环境(后台进程、电源策略、CPU 频率波动、地址随机化都会引入噪声)。要获得稳定可对比的结果,按以下步骤搭建环境:
-
使用 Linux 机器:对调度与频率控制的支持最完整;
-
停止不必要的进程:关闭浏览器、IDE 的索引服务、后台同步等一切非必要负载;
-
接通电源:笔记本务必插电,避免电池模式下的降频;
-
禁用 SMT/超线程(HyperThreading):
echo "off" | sudo tee /sys/devices/system/cpu/smt/control -
关闭 Turbo Boost(睿频):具体步骤取决于 CPU 型号与内核(如通过
intel_pstate的 sysfs 接口或 BIOS); -
使用
performanceCPU governor:# 例如 cpupower 工具 cpupower frequency-set -g performance -
禁用 ASLR(地址空间随机化):
echo 0 | sudo tee /proc/sys/kernel/randomize_va_space
原文档的说明是"These steps should make results much less noisy"——这些步骤能显著降低结果噪声,但并非每个环境都必须全部执行;在一台共享的开发机上,至少完成"关闭无关进程 + 固定 CPU 频率"通常就能获得可用于粗略对比的数据。更完整的背景可参考 ankush.dev 的可靠基准测试文章(README 中给出的延伸阅读)。
值得补充的是:即使环境噪声已压低,同一台机器上做"改动前后"对比(配合 pyperf compare_to 的显著性检验)仍然是最可信的用法;跨机器对比则要格外谨慎。
七、总结
Frappe 的微基准套件是一条从"测量"到"发现热点"再到"验证优化"的完整工具链:
- 运行:
bench --site <site> run-microbenchmarks,配合--filter、-p5、-o快速迭代与留档; - 对比:
pyperf compare_to用统计检验判断改动是否有效,pyperf timeit直测微秒级语句; - 编写:按领域放进对应
bench_{module}.py,bench_前缀命名,亚毫秒操作改NanoBenchmark,并时刻确认测的是目标路径(如get_cached_doc要清本地缓存); - 可信度:固定 CPU 状态、关停无关进程、禁用 ASLR,把环境噪声压到最低。
这套套件的设计与实现分布在 frappe/tests/microbenchmarks/ 下的 run_benchmarks.py、utils.py 及各 bench_*.py 模块中,配合 frappe/commands/microbenchmarks.py 的命令入口,形成了可复现、可追溯、可扩展的框架性能观测体系。无论是排查"为什么某个接口变慢",还是评估一次 ORM 重构的收益,都可以从这里开始。