Mojo 仓库bench_bindings基准测试指南:量化 Python → Mojo FFI 每次调用的开销
【免费下载链接】mojoThe Modular Platform (includes MAX & Mojo)项目地址: https://gitcode.com/GitHub_Trending/mo/mojo
bench_bindings是 Mojo 标准库中专门用于量化 Python 调用 Mojo 扩展函数时单次跨语言(FFI)调用开销的微基准测试模块。它以noop和add两个极简函数为探针,通过三条绑定路径、两种 CPython 调用约定(METH_VARARGS/METH_FASTCALL)的对照实验,把「参数解包、引用计数(refcount)流量、GIL 操作、CPython 调用协议」等开销逐项归因。读完本文,你将掌握该基准的完整运行方法、结果解读口径,以及如何在此基础上为新的绑定路径新增基准变体。
背景:为什么需要一个专门的 FFI 开销基准
Mojo 提供了从 Python 调用 Mojo 扩展模块的能力,绑定声明位于 Mojo/stdlib/std/python/bindings.mojo。当 Python 调用一个 Mojo 函数时,控制流需要跨越解释器边界:
- CPython 解析调用表达式并进入绑定函数的 C 层入口;
- 绑定层把
PyObject*参数解包、转换为 Mojo 侧的PythonObject; - 调用 Mojo 函数体;
- 结果再包装回
PyObject*返回给 Python。
这个过程中,函数体本身的计算往往只占极短时间,真正消耗在「跨越边界」的协议开销上。为了量化并持续跟踪这部分开销,Mojo 仓库在Mojo/stdlib/benchmarks/python/bench_bindings/下维护了一个微基准,追踪上游 issue(modular/modular#6521),专门回答"从 Python 的调用表达式发出,到结果重新可在 Python 中使用,到底过去了多少墙钟时间"。
为什么用noop和add
基准故意让函数体做"接近于零"的工作:
noop(x) -> x:原样返回参数,函数体没有任何计算;add(a, b) -> a + b:只有一次整数加法。
这样测出来的数字就完全由绑定开销主导:参数解包、refcount 增减、GIL 操作、CPython 调用协议本身。任何数字变化都可以直接归因到绑定层,而不是业务计算。
基准变体设计:一条函数、三种路径、两种调用约定
同一个noop/add语义,通过三条绑定路径暴露出来,从而把开销归因到不同环节。下表完整列出了 6 个变体(取自 README.md):
| Variant | Binding path | Calling conv. | What's isolated |
|---|---|---|---|
noop_def | PythonModuleBuilder.def_function[...] | METH_FASTCALL | Full high-level dispatch — the regression target |
add_def | PythonModuleBuilder.def_function[...] | METH_FASTCALL | Same, plusInt(py=...)conversions |
noop_raw | PythonModuleBuilder.def_py_c_function(PyCFunction, ...) | METH_VARARGS | Hand-written METH_VARARGS lower bound |
add_raw | PythonModuleBuilder.def_py_c_function(PyCFunction, ...) | METH_VARARGS | Same + directPyLong_AsSsize_t/PyLong_FromSsize_t |
noop_raw_fastcall | PythonModuleBuilder.def_py_c_function(PyCFunctionFast, ...) | METH_FASTCALL | Hand-written METH_FASTCALL lower bound |
add_raw_fastcall | PythonModuleBuilder.def_py_c_function(PyCFunctionFast, ...) | METH_FASTCALL | Same + directPyLong_AsSsize_t/PyLong_FromSsize_t |
对照组的归因逻辑
三组对比分别回答三个问题:
*_defvs*_raw_fastcall(同一METH_FASTCALL约定):两者共享调用约定,差距可以干净地归因为 Mojo 通用分发包装器(generic dispatch wrapper)的额外开销。*_def是绝大多数用户会走的def_function高层面路径,是回归监控的目标(regression target)。*_raw(METH_VARARGS)vs*_raw_fastcall(METH_FASTCALL):差距是 CPython 跳过元组打包(tuple pack)带来的调用约定收益——即def_function落地METH_FASTCALL所争取到的"楔子收益"(wedge)。*_raw与 bug 报告中的 PyO3 数字对比:PyO3 的对比数字是在METH_VARARGS形态的 Mojo 绑定上采集的,因此*_raw保留为与 PyO3 数字直接比较的基准点。
此外,还有两个纯 Python 基线(py_noop、py_add)和timeit下限(1 + 2,完全没有调用),在同一个进程中运行,用于暴露宿主漂移(CPython 升级、频率缩放、调度噪声等)。
源码解析:三条路径在mojo_module.mojo中如何落地
基准的 Mojo 侧实现位于 mojo_module.mojo,入口是导出的PyInit_mojo_module:
@export def PyInit_mojo_module() abi("C") -> PythonObject: try: var b = PythonModuleBuilder("mojo_module") # High-level `def_function` path (the regression target). b.def_functionnoop_def b.def_functionadd_def # Low-level `def_py_c_function` path. b.def_py_c_function(noop_raw, "noop_raw") b.def_py_c_function(add_raw, "add_raw") b.def_py_c_function(noop_raw_fastcall, "noop_raw_fastcall") b.def_py_c_function(add_raw_fastcall, "add_raw_fastcall") return b.finalize() except e: abort(String("failed to create Python module: ", e))高层面路径:def_function
noop_def与add_def使用PythonModuleBuilder.def_function,这是用户日常会用的 API:
def noop_def(x: PythonObject) raises -> PythonObject: return x def add_def(a: PythonObject, b: PythonObject) raises -> PythonObject: var ai = Int(py=a) var bi = Int(py=b) return PythonObject(ai + bi)注意add_def中使用了Int(py=...)转换构造器,这就是表格中add_def一行标注"plusInt(py=...)conversions"的来源——它比noop_def多了参数类型转换的开销。
从 bindings.mojo 的实现可以看到,非 kwargs 的def_function最终通过_py_function_fastcall_wrapper注册为PyCFunctionFast(METH_FASTCALL),而带**kwargs的函数则走METH_VARARGS | METH_KEYWORDS分发路径。fastcall 包装器直接读取 CPython 传入的PyObject* const*数组,从不构造元组,这正是它比METH_VARARGS快的原因之一。
低层面路径:def_py_c_function
*_raw变体是手工编写的 C 形态函数,绕过 Mojo 的通用分发逻辑:
@export def noop_raw(py_self: PyObjectPtr, args: PyObjectPtr) abi("C") -> PyObjectPtr: ref cpy = Python().cpython() # PyTuple_GetItem returns a borrowed reference; we must IncRef before # returning, since the caller expects a new (owned) reference. var item = cpy.PyTuple_GetItem(args, 0) return cpy.Py_NewRef(item)这里体现了 CPython 的引用计数所有权规则:PyTuple_GetItem返回借用的引用(borrowed reference),返回给调用者前必须Py_NewRef转成新的(owned)引用。add_raw则直接使用PyLong_AsSsize_t/PyLong_FromSsize_t做 C 级整数转换,完全没有 Mojo 的PythonObject包装与泛型分发:
@export def add_raw(py_self: PyObjectPtr, args: PyObjectPtr) abi("C") -> PyObjectPtr: ref cpy = Python().cpython() var a = cpy.PyTuple_GetItem(args, 0) var b = cpy.PyTuple_GetItem(args, 1) var ai = cpy.PyLong_AsSsize_t(a) var bi = cpy.PyLong_AsSsize_t(b) return cpy.PyLong_FromSsize_t(ai + bi)METH_FASTCALL形态的手写下限
*_raw_fastcall对应 CPython 的PyCFunctionFast签名:参数是一个借用的 C 数组args: Pointer[PyObjectPtr, MutUntrackedOrigin]加长度nargs: Py_ssize_t,直接从数组下标取参:
@export def add_raw_fastcall( py_self: PyObjectPtr, args: Pointer[PyObjectPtr, MutUntrackedOrigin], nargs: Py_ssize_t, ) abi("C") -> PyObjectPtr: ref cpy = Python().cpython() # Read directly from the borrowed `PyObject *const *` array, matching the # METH_FASTCALL convention CPython uses to invoke the callee. var ai = cpy.PyLong_AsSsize_t(args[]) var bi = cpy.PyLong_AsSsize_t(args[unsafe_offset=1]) return cpy.PyLong_FromSsize_t(ai + bi)从 bindings.mojo 的实现注释可以看出,CPython 的 vectorcall 协议(PEP 590)保证METH_FASTCALL调用时args永不为空(即使nargs == 0也会传入指向缓存空元组的指针),因此 fastcall 分发路径可以直接接受普通Pointer而无需OptionalPointer。
关于 GIL 的一个关键实现细节
在bindings.mojo的_py_c_function_wrapper中有段重要注释:CPython 在扩展函数调用期间始终持有 GIL(PEP 311),调用协议保证进入时 GIL 已被持有,因此 Mojo 侧不会再调用PyGILState_Ensure/Release——那只会为每次调用徒增一次往返开销。这也是基准中"GIL operations"开销存在的真实背景:GIL 成本来自 CPython 调用协议本身,而非 Mojo 绑定层重复加锁。
运行基准:从冒烟测试到稳定数字
第一步:正确性冒烟测试
每个//...全量测试扫描都会运行正确性冒烟测试,确保基准表面不会腐化(bitrot):
./bazelw test //Mojo/stdlib/benchmarks/python/bench_bindings:test_module该测试由 test_module.py 实现,验证 6 个变体函数在 Python 侧的返回值是否正确。它覆盖了整数与字符串参数、正负数加法,例如:
def test_noop_def_returns_argument() -> None: assert mojo_module.noop_def(1) == 1 assert mojo_module.noop_def("foo") == "foo" def test_add_def_sums_ints() -> None: assert mojo_module.add_def(1, 2) == 3 assert mojo_module.add_def(-5, 10) == 5这个前置校验保证后续bench.py打印出的数字反映的是性能而非绑定接线错误——基准驱动的_sanity_check()也做了同样的断言,作为失败即退出(fail fast)的防线。
第二步:完整基准
完整基准是手动(manual)目标,会打印结果表格:
./bazelw test \ //Mojo/stdlib/benchmarks/python/bench_bindings:bench_bindings \ --test_output=all第三步:稳定数字的推荐姿势
微基准对调度噪声极其敏感。README 推荐的 Linux 稳定做法是把进程固定到单个核心:
taskset -c 2 ./bazelw test \ //Mojo/stdlib/benchmarks/python/bench_bindings:bench_bindings \ --test_output=all为什么基准不进入//...全量扫描
基准目标被打上了两个 tag:
manual://...全量扫描会跳过它,只有显式指定时才运行;stdlib-benchmark:可以通过 tag 过滤与标准库其余基准一起选择运行。
按 README 的说明,当你修改了以下任何文件时,应当显式运行该基准:
stdlib/std/python/bindings.mojo(绑定基础设施本身);_python_func.mojo(泛型分发模板);python_object.mojo(PythonObject的实现)。
构建配置中的两个关键决定
从 BUILD.bazel 可以看到两个直接影响数字正确性的决定:
enable_assertions = False:mojo_shared_library默认开启断言,会注入-D ASSERT=all并打开编译进.so的所有debug_assert(特别是PythonObject.__del__中的 GIL-持有检查)。这不是用户实际发布(ship)的配置,如果保留断言,微基准数字反映的是断言流量而不是分发链路,因此必须显式关闭。- 排除 sanitizer:
target_compatible_with对//:asan、//:tsan、//:ubsan标记为@platforms//:incompatible,与仓库中其他 Python 扩展模块保持一致(mojo_shared_library使用宿主工具链构建)。
测量方法论:为什么取最小值而不是均值
基准驱动 bench.py 完全复刻了 bug 报告的方法论,使数字可以直接与报告中捕获的 PyO3 结果对比:
ITERATIONS = 2_000_000 REPEATS = 7- 每个变体执行
timeit.repeat(stmt, number=2_000_000, repeat=7); - 报告跨 repeat 的每调用最小时间(minimum time-per-call)。在标准的微基准汇总统计量中,最小值是真实单调用成本的最接近估计;均值会被瞬时调度器 / 缓存 / 其他进程噪声污染;
- 最大值(max)也一并打印,作为本次运行噪声程度的 sanity check;
- 测量前先对绑定输出做断言校验,确保绝不发布来自损坏模块的数字。
输出格式示例(以bench.py的实际打印逻辑为准):
# Per-call FFI overhead (ITERATIONS=2,000,000, REPEATS=7, min of repeats) Variant min max -------------------------------------------------------------- Python -> Mojo noop_def(x) [def_function/FASTCALL] ... Python -> Mojo add_def(1, 2) [def_function/FASTCALL] ... Python -> Mojo noop_raw(x) [def_py_c_function/VARARGS] ... Python -> Mojo add_raw(1, 2) [def_py_c_function/VARARGS] ... Python -> Mojo noop_raw_fastcall(x) [def_py_c_function/FASTCALL] ... Python -> Mojo add_raw_fastcall(1, 2) [def_py_c_function/FASTCALL] ... Python -> Python py_noop(x) ... Python -> Python py_add(1, 2) ... Python builtin: 1 + 2 (no call) ...两个纯 Python 基线(py_noop、py_add)与1 + 2的timeit下限在同一进程运行,便于读出宿主环境的漂移:如果基线整体上移,说明是环境问题而非绑定回归。
如何新增基准变体
当一条新的绑定路径落地时(例如新的METH_FASTCALL注册辅助函数,或def_function的整型快速路径),按以下步骤接入基准:
- 在 mojo_module.mojo 中以清晰的命名注册新入口点(如
noop_fastcall、add_int_typed等); - 在 bench.py 的测量列表中添加一行对应的
_measure(...)调用; - 在 test_module.py 中添加相应的正确性断言;
- 保留旧变体不动,这样每个 PR 都能在同一硬件上引用 before/after 数字。
解读结果:四组关键差值
拿到表格后,重点解读以下差值:
| 差值 | 含义 |
|---|---|
noop_raw_fastcall−noop_raw | CPython 跳过元组打包(tuple pack)带来的调用约定收益 |
add_raw_fastcall−add_raw | 同上,在带参数转换场景下的收益 |
noop_def−noop_raw_fastcall | Mojo 通用分发包装器的额外开销(同一调用约定下的干净归因) |
add_def−add_raw_fastcall | 通用分发 +Int(py=...)转换的叠加开销 |
其中noop_def是回归目标:作为大多数用户实际走的绑定路径,它的数字必须持续跟踪,任何一次上升都意味着分发链路引入了回归。
适用前提与注意事项
- 本基准面向CPython 绑定路径的性能跟踪,涉及
METH_FASTCALL/METH_VARARGS等 CPython C API 调用约定,理解这些约定有助于解读数字; - 数字必须在单核固定(
taskset)条件下采集才具备跨 run 可比性;不同硬件、不同 CPython 版本之间的绝对数字没有直接可比性,重点是同一环境下 before/after 的差值; - 基准目标
bench_bindings是manual目标,不会随//...全量运行,需要显式指定; - 仓库中该基准当前以 Bazel(
./bazelw test)为官方运行方式,直接以python3 bench.py运行仅适用于模块已由构建系统产出.so到脚本同目录的场景(脚本通过sys.path.insert保证两种方式都能导入mojo_module)。
延伸阅读
- 绑定基础设施实现:Mojo/stdlib/std/python/bindings.mojo(
PythonModuleBuilder、PythonTypeBuilder、各类PyCFunction包装器) - 基准源码:mojo_module.mojo、bench.py、test_module.py
- 构建与测试接入:BUILD.bazel
【免费下载链接】mojoThe Modular Platform (includes MAX & Mojo)项目地址: https://gitcode.com/GitHub_Trending/mo/mojo
创作声明:本文部分内容由AI辅助生成(AIGC),仅供参考