news 2026/9/11 17:53:44

Mojo 仓库 `bench_bindings` 基准测试指南:量化 Python → Mojo FFI 每次调用的开销

作者头像

张小明

前端开发工程师

1.2k 24
文章封面图
Mojo 仓库 `bench_bindings` 基准测试指南:量化 Python → Mojo FFI 每次调用的开销

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)调用开销的微基准测试模块。它以noopadd两个极简函数为探针,通过三条绑定路径、两种 CPython 调用约定(METH_VARARGS/METH_FASTCALL)的对照实验,把「参数解包、引用计数(refcount)流量、GIL 操作、CPython 调用协议」等开销逐项归因。读完本文,你将掌握该基准的完整运行方法、结果解读口径,以及如何在此基础上为新的绑定路径新增基准变体。

背景:为什么需要一个专门的 FFI 开销基准

Mojo 提供了从 Python 调用 Mojo 扩展模块的能力,绑定声明位于 Mojo/stdlib/std/python/bindings.mojo。当 Python 调用一个 Mojo 函数时,控制流需要跨越解释器边界:

  1. CPython 解析调用表达式并进入绑定函数的 C 层入口;
  2. 绑定层把PyObject*参数解包、转换为 Mojo 侧的PythonObject
  3. 调用 Mojo 函数体;
  4. 结果再包装回PyObject*返回给 Python。

这个过程中,函数体本身的计算往往只占极短时间,真正消耗在「跨越边界」的协议开销上。为了量化并持续跟踪这部分开销,Mojo 仓库在Mojo/stdlib/benchmarks/python/bench_bindings/下维护了一个微基准,追踪上游 issue(modular/modular#6521),专门回答"从 Python 的调用表达式发出,到结果重新可在 Python 中使用,到底过去了多少墙钟时间"。

为什么用noopadd

基准故意让函数体做"接近于零"的工作:

  • noop(x) -> x:原样返回参数,函数体没有任何计算;
  • add(a, b) -> a + b:只有一次整数加法。

这样测出来的数字就完全由绑定开销主导:参数解包、refcount 增减、GIL 操作、CPython 调用协议本身。任何数字变化都可以直接归因到绑定层,而不是业务计算。

基准变体设计:一条函数、三种路径、两种调用约定

同一个noop/add语义,通过三条绑定路径暴露出来,从而把开销归因到不同环节。下表完整列出了 6 个变体(取自 README.md):

VariantBinding pathCalling conv.What's isolated
noop_defPythonModuleBuilder.def_function[...]METH_FASTCALLFull high-level dispatch — the regression target
add_defPythonModuleBuilder.def_function[...]METH_FASTCALLSame, plusInt(py=...)conversions
noop_rawPythonModuleBuilder.def_py_c_function(PyCFunction, ...)METH_VARARGSHand-written METH_VARARGS lower bound
add_rawPythonModuleBuilder.def_py_c_function(PyCFunction, ...)METH_VARARGSSame + directPyLong_AsSsize_t/PyLong_FromSsize_t
noop_raw_fastcallPythonModuleBuilder.def_py_c_function(PyCFunctionFast, ...)METH_FASTCALLHand-written METH_FASTCALL lower bound
add_raw_fastcallPythonModuleBuilder.def_py_c_function(PyCFunctionFast, ...)METH_FASTCALLSame + directPyLong_AsSsize_t/PyLong_FromSsize_t

对照组的归因逻辑

三组对比分别回答三个问题:

  • *_defvs*_raw_fastcall(同一METH_FASTCALL约定):两者共享调用约定,差距可以干净地归因为 Mojo 通用分发包装器(generic dispatch wrapper)的额外开销。*_def是绝大多数用户会走的def_function高层面路径,是回归监控的目标(regression target)。
  • *_rawMETH_VARARGS)vs*_raw_fastcallMETH_FASTCALL:差距是 CPython 跳过元组打包(tuple pack)带来的调用约定收益——即def_function落地METH_FASTCALL所争取到的"楔子收益"(wedge)。
  • *_raw与 bug 报告中的 PyO3 数字对比:PyO3 的对比数字是在METH_VARARGS形态的 Mojo 绑定上采集的,因此*_raw保留为与 PyO3 数字直接比较的基准点。

此外,还有两个纯 Python 基线(py_nooppy_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_defadd_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注册为PyCFunctionFastMETH_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.mojoPythonObject的实现)。

构建配置中的两个关键决定

从 BUILD.bazel 可以看到两个直接影响数字正确性的决定:

  1. enable_assertions = Falsemojo_shared_library默认开启断言,会注入-D ASSERT=all并打开编译进.so的所有debug_assert(特别是PythonObject.__del__中的 GIL-持有检查)。这不是用户实际发布(ship)的配置,如果保留断言,微基准数字反映的是断言流量而不是分发链路,因此必须显式关闭。
  2. 排除 sanitizertarget_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_nooppy_add)与1 + 2timeit下限在同一进程运行,便于读出宿主环境的漂移:如果基线整体上移,说明是环境问题而非绑定回归。

如何新增基准变体

当一条新的绑定路径落地时(例如新的METH_FASTCALL注册辅助函数,或def_function的整型快速路径),按以下步骤接入基准:

  1. 在 mojo_module.mojo 中以清晰的命名注册新入口点(如noop_fastcalladd_int_typed等);
  2. 在 bench.py 的测量列表中添加一行对应的_measure(...)调用;
  3. 在 test_module.py 中添加相应的正确性断言;
  4. 保留旧变体不动,这样每个 PR 都能在同一硬件上引用 before/after 数字。

解读结果:四组关键差值

拿到表格后,重点解读以下差值:

差值含义
noop_raw_fastcallnoop_rawCPython 跳过元组打包(tuple pack)带来的调用约定收益
add_raw_fastcalladd_raw同上,在带参数转换场景下的收益
noop_defnoop_raw_fastcallMojo 通用分发包装器的额外开销(同一调用约定下的干净归因)
add_defadd_raw_fastcall通用分发 +Int(py=...)转换的叠加开销

其中noop_def回归目标:作为大多数用户实际走的绑定路径,它的数字必须持续跟踪,任何一次上升都意味着分发链路引入了回归。

适用前提与注意事项

  • 本基准面向CPython 绑定路径的性能跟踪,涉及METH_FASTCALL/METH_VARARGS等 CPython C API 调用约定,理解这些约定有助于解读数字;
  • 数字必须在单核固定taskset)条件下采集才具备跨 run 可比性;不同硬件、不同 CPython 版本之间的绝对数字没有直接可比性,重点是同一环境下 before/after 的差值;
  • 基准目标bench_bindingsmanual目标,不会随//...全量运行,需要显式指定;
  • 仓库中该基准当前以 Bazel(./bazelw test)为官方运行方式,直接以python3 bench.py运行仅适用于模块已由构建系统产出.so到脚本同目录的场景(脚本通过sys.path.insert保证两种方式都能导入mojo_module)。

延伸阅读

  • 绑定基础设施实现:Mojo/stdlib/std/python/bindings.mojo(PythonModuleBuilderPythonTypeBuilder、各类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),仅供参考

版权声明: 本文来自互联网用户投稿,该文观点仅代表作者本人,不代表本站立场。本站仅提供信息存储空间服务,不拥有所有权,不承担相关法律责任。如若内容造成侵权/违法违规/事实不符,请联系邮箱:809451989@qq.com进行投诉反馈,一经查实,立即删除!
网站建设 2026/9/11 17:51:40

基金对关联强度建模:时序特征工程与树模型实战

简介:本资源是面向高校机器学习课程学生的完整大作业解决方案,基于CCF-BDCI官方赛题“基金相关性预测”训练赛设计,覆盖从数据建模、特征工程到模型评估的全流程实践,特别适合课程设计、期末大作业及竞赛入门学习。压缩包共5个文件…

作者头像 李华
网站建设 2026/9/11 17:49:21

如何把 ETE 3 系统发育分析代码迁移到 ETE 4

如何把 ETE 3 系统发育分析代码迁移到 ETE 4 【免费下载链接】scientific-agent-skills Turn any AI agent into an AI Scientist. The #1 Agent Skills library for science, used by 190,000 scientists worldwide. 165 ready-to-use validated skills plus 100 scientific d…

作者头像 李华
网站建设 2026/9/11 17:45:45

Linux行业盒子芯片选型:RK3588、S922X与S905X3实战决策指南

1. 项目概述:为什么“行业定制盒子”的芯片选型,比你想象中更像一场精密的工业手术 最近半年,我跑了七家做Linux行业定制盒子的源头工厂,从深圳华强北的方案商小作坊,到东莞松山湖的ODM大厂产线,再到浙江慈…

作者头像 李华
网站建设 2026/9/11 17:44:59

基于YOLOv8的农田虫情测报灯系统:目标检测与部署实践

简介:面向毕业设计与课程设计的基于YOLOv8的农田智能虫情测报灯害虫种类识别系统,完整覆盖数据准备、模型训练、视频检测与可视化界面展示全流程,旨在解决农田虫害监测场景中的目标检测需求;项目代码经测试可运行,适合…

作者头像 李华
网站建设 2026/9/11 17:41:45

丝状真菌的制剂加工——为什么它们“不耐加工“?

一、从发酵罐到制剂车间:一个被低估的"断点"一个事实被行业低估了:丝状真菌的产业化瓶颈,不只在发酵端,更在制剂端。前四篇文章分别聊了开篇、菌种制备、液体发酵、固体发酵,把"怎么把菌养好、把孢子产…

作者头像 李华