Frappe 微基准测试(Microbenchmarks)实战指南:基于 pyperf 的框架核心路径性能测量

原创2026-09-15 13:52:541,635 阅读
文章标签:后端Web框架低代码前端认证鉴权

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 框架的小规模、聚焦操作。与单元测试(断言正确性)不同,基准测试关注的是时间开销;与压测/负载测试(关注吞吐与并发)也不同,它关注的是单次操作的时延。

从仓库结构看,该套件被组织为按领域划分的模块:

二、如何运行微基准

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 的完整流程:

  1. 构造 pyperf Runner:通过 add_cmdline_args 回调把 --site 和 --filter 转发给 worker 进程(否则子进程不知道连哪个站点);
  2. 注入元数据:get_global_metadata() 用 git 记录当前 frappe 的 commit 与提交日期(frappe_commit、frappe_commit_date),存入结果文件,保证结果可溯源——这在回归对比时非常关键;
  3. 发现基准:discover_benchmarks() 遍历 7 个 bench_*.py 模块,用 inspect.getmembers 找出所有以 bench_ 前缀命名的成员,并把模块名拼进基准名(如 orm_get_doc);若发现重名基准会直接 frappe.throw 报错,最终按名称排序;
  4. 环境准备:setup() 执行 frappe.init(site)、断言 frappe.conf.allow_tests、frappe.connect() 连接数据库,并执行 random.seed(42) 固定随机种子,保证结果可复现;
  5. 分发执行:普通函数用 runner.bench_func(name, bench) 注册;NanoBenchmark 对象则用 runner.timeit(...) 注册(见下文);
  6. 收尾: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 频率波动、地址随机化都会引入噪声)。要获得稳定可对比的结果,按以下步骤搭建环境:

  1. 使用 Linux 机器:对调度与频率控制的支持最完整;

  2. 停止不必要的进程:关闭浏览器、IDE 的索引服务、后台同步等一切非必要负载;

  3. 接通电源:笔记本务必插电,避免电池模式下的降频;

  4. 禁用 SMT/超线程(HyperThreading):

    echo "off" | sudo tee /sys/devices/system/cpu/smt/control
    
  5. 关闭 Turbo Boost(睿频):具体步骤取决于 CPU 型号与内核(如通过 intel_pstate 的 sysfs 接口或 BIOS);

  6. 使用 performance CPU governor:

    # 例如 cpupower 工具
    cpupower frequency-set -g performance
    
  7. 禁用 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 重构的收益,都可以从这里开始。

登录后查看全文
frappe