在 Python 中调用 Mojo:基于 PythonModuleBuilder 构建高性能扩展模块完整指南
【免费下载链接】mojoThe Modular Platform (includes MAX & Mojo)项目地址: https://gitcode.com/GitHub_Trending/mo/mojo
如果你已经拥有一个 Python 项目,并且希望用 Mojo 的高性能计算能力加速其中关键的性能瓶颈,并不需要把整个项目用 Mojo 重写。Mojo 提供了一套内建的 Python 扩展模块机制,让你只需要把性能关键的部分用 Mojo 编写,然后像普通 Python 模块一样import并调用。本篇指南以 Mojo 官方手册中「Calling Mojo from Python」章节(Mojo/docs/site/manual/python/mojo-from-python.mdx)为核心,结合仓库内对应的可运行示例(Mojo/docs/site/code/manual/python/mojo-from-python)与 stdlib 的PythonModuleBuilder源码实现,完整讲解从最小可运行示例、类型绑定、对象构造、方法暴露、关键字/可变参数,到将 Python 代码移植为 Mojo 的实战策略,帮助你快速掌握在 Python 中调用 Mojo 的全部技术要点。
注意:从 Python 调用 Mojo 目前属于Beta 实验特性,处于早期开发阶段,API 与使用体验预计会有较多变化,文档也在持续完善中。文末列出了当前已知限制,请在使用时留意。
最小可运行示例:从 Python 导入 Mojo 模块
项目结构与最小代码
考虑如下项目结构:main.py是 Python 入口程序,mojo_module.mojo是包含供 Python 调用的函数的 Mojo 源码:
project ├── 🐍 main.py └── 🔥 mojo_module.mojo假设我们想要一个接受 Python 值作为参数的 Mojo 函数,比如计算阶乘。Mojo 一侧的初始写法为:
def factorial(py_obj: PythonObject) raises -> PythonObject: var n = Int(py=py_obj) return std.math.factorial(n)注意:在使用PythonObject前需要导入相关模块。仓库中的完整示例文件(Mojo/docs/site/code/manual/python/mojo-from-python/mojo_module.mojo)实际是这样导入的:
import std.math from std.os import abort from std.python import PythonObject from std.python.bindings import PythonModuleBuilder在 Python 一侧,我们期望这样调用:
import mojo_module print(mojo_module.factorial(5))为什么必须声明 PyInit_ 入口函数
在从 Python 调用 Mojo 函数之前,必须先让 Python 知道这个模块的存在。Python 加载mojo_module时,会寻找名为PyInit_mojo_module()的函数(如果文件叫foo.mojo,则对应寻找PyInit_foo())。在PyInit_<module>()内部,必须使用PythonModuleBuilder声明所有可从 Python 调用的 Mojo 函数与类型。
因此,完整的 Mojo 模块代码是:
from std.python import PythonObject from std.python.bindings import PythonModuleBuilder from std import math from std.os import abort @export def PyInit_mojo_module() abi("C") -> PythonObject: try: var m = PythonModuleBuilder("mojo_module") m.def_functionfactorial return m.finalize() except e: abort(String("error creating Python Mojo module:", e)) def factorial(py_obj: PythonObject) raises -> PythonObject: # 若 `py_obj` 无法转换为 Mojo `Int`,这里会抛出异常 var n = Int(py=py_obj) return math.factorial(n)在 Python 一侧,需要把包含mojo_module.mojo的目录加入 Python 路径,然后先导入mojo.importer,再用普通的import语句加载 Mojo 代码:
import mojo.importer import mojo_module print(mojo_module.factorial(5))运行:
python main.py输出:
120仓库中对应的可运行示例正是这种结构:main.py中先import mojo.importer再import mojo_module(见 main.py),并通过 Bazel 定义了modular_py_binary(名为factorial)与对应的modular_run_binary_test(factorial_test)来执行这个例子(见 BUILD.bazel)。
背后的工作原理
Python 支持一种标准的「Python 扩展模块」机制,使得编译型语言(如 Mojo、C、C++、Rust)能够以直观的方式被 Python 调用。具体来说,Python 扩展模块就是一个定义了合适的PyInit_*()函数的动态库。Mojo 内建了定义 Python 扩展模块的功能,而真正“神奇”的部分发生在导入的mojo.importer模块中。
导入 Mojo 代码后观察文件系统,会发现多了一个__mojocache__目录,内部有一个动态库(.so)文件:
project ├── main.py ├── mojo_module.mojo └── __mojocache__ └── mojo_module.hash-ABC123.so加载mojo.importer会注册 Python 的 Mojo import hook:它在后台查找与导入模块名匹配的.mojo文件,如果找到,就使用mojo build --emit shared-lib将其编译为动态库,产物存放在__mojocache__中,并且只在缓存过期时(通常是 Mojo 源文件发生变化)才重新编译。
提示:清理缓存构建产物
__mojocache__目录中应该只包含派生产物,删除其中的内容是安全的。下次导入 Mojo 模块时,需要的产物会自动重建。
导出函数的 abi 约定
@export函数必须用显式的abi效果声明其调用约定。在 Python 扩展模块中,唯一需要导出的是PyInit_<module>入口点,并且它必须使用abi("C"):
@export def PyInit_mojo_module() abi("C") -> PythonObject: ...这是因为 CPython 运行时会直接跨 C 边界定位并调用PyInit_<module>,所以它必须暴露 C 调用约定。abi("C")函数不能标记为raises,这也是为什么上面的例子在函数体内捕获所有错误并通过abort处理而不是向上传播。
相比之下,你用模块构建器注册的函数、方法和初始化器(def_function、def_method、def_py_init等)完全不需要@export:你通过引用把它们传给构建器,Mojo 会自动生成 CPython 真正调用的 C 包装器。这个包装器以 Mojo 调用约定调用你的函数,并把任何抛出的错误转换为 Python 异常,所以像factorial这样的注册函数可以放心地标记为raises。
从 stdlib 源码(bindings.mojo)可以看到,def_function要求函数签名满足:参数类型均为PythonObject(或末尾带var **kwargs: PythonObject),返回类型必须是PythonObject或None;不带关键字参数的函数通过 CPython 的METH_FASTCALL约定注册,接受关键字参数的函数则使用METH_VARARGS | METH_KEYWORDS。
Bindings 特性:把 Mojo 类型与函数暴露给 Python
绑定 Mojo 类型
使用PythonModuleBuilder可以把任意 Mojo 类型绑定给 Python 使用。例如:
@fieldwise_init struct Person(Movable, Writable): var name: String var age: Int @export def PyInit_person_module() abi("C") -> PythonObject: try: var mb = PythonModuleBuilder("person_module") var person_type = mb.add_typePerson except e: abort("error creating Mojo module")调用add_type()会返回一个PythonTypeBuilder,随后可以用它绑定类型构造函数(见下文「在 Python 中构造 Mojo 对象」)和方法。
任何通过PythonTypeBuilder绑定的 Mojo 类型,其对应的 Pythontype对象都会被全局注册,从而启用两个特性:
- 用
PythonObject(alloc=Person(..))构造包装 Mojo 值的 Python 对象,供 Python 侧使用; - 用
python_obj.downcast_value_ptr[Person]()进行向下转换。
注意要被绑定到 Python 使用,Mojo 类型必须实现
Writable。特定绑定特性还需要额外 trait:自定义初始化器(def_py_init)要求Movable;默认初始化器(def_init_defaultable)则同时要求Defaultable与Movable。
仅绑定类型还不够,还需要告诉 Python 如何与 Mojo 类型交互——先从在 Python 中构造 Mojo 对象实例开始。
在 Python 中构造 Mojo 对象
可以通过def_py_init()把 Mojo 初始化器声明为 Python 兼容的对象初始化器。例如:
@export def PyInit_person_module() abi("C") -> PythonObject: try: var mb = PythonModuleBuilder("person_module") # highlight-start _ = mb.add_typePerson.def_py_init[Person.py_init]() # highlight-end return mb.finalize() except e: abort(String("error creating Python Mojo module:", e)) @fieldwise_init struct Person(Movable, Writable): var name: String var age: Int # highlight-start @staticmethod def py_init( out self: Person, args: PythonObject, kwargs: PythonObject ) raises: # 校验参数个数 if len(args) != 2: raise Error("Person() takes exactly 2 arguments") # 把 Python 参数转换为 Mojo 类型 var name = String(args[0]) var age = Int(args[1]) self = Self(name, age) # highlight-end有了这个绑定,就可以在 Python 中创建Person实例:
person = person_module.Person("Sarah", 32) print(person)输出:
Person(name=Sarah, age=32)对于支持默认构造的类型,可以使用更简单的def_init_defaultable():
var counter_type = m.add_typeCounter counter_type.def_init_defaultable[Counter]()这使 Python 代码可以无参数创建实例:
counter = counter_module.Counter() # 创建 Counter()「构造器」与「初始化器」的区别在 Python 中,对象构造横跨
__new__()与__init__()两个方法,__init__()严格来说是属性初始化器。但 Mojo struct 中没有__new__()方法,所以我们始终把__init__()称为初始化器。
将 Mojo 对象返回给 Python
从 Python 调用的 Mojo 函数,不仅需要能接受PythonObject参数,还需要能返回新值;有时甚至需要把 Mojo 原生值返回给 Python。这可以通过PythonObject(alloc=<value>)构造器实现:
def create_person() -> PythonObject: var person = Person("Sarah", 32) return PythonObject(alloc=person^)警告如果所提供的 Mojo 对象类型之前没有通过
PythonModuleBuilder.add_type()注册,PythonObject(alloc=...)会抛出异常。
从 PythonObject 转换为 Mojo 值
在处理PythonObject的 Mojo 代码中(尤其是从 Python 调用的 Mojo 函数内),通常期望参数是某个特定类型。把PythonObject变成 Mojo 原生值有两种方式:
- 转换(Converting):把 Python 对象转换为一个逻辑值相同的新构造的 Mojo 值,由
ConvertibleFromPythontrait 处理; - 向下转换(Downcasting):把持有 Mojo 原生值的 Python 对象,转换为指向该内部值的指针,由
PythonObject.downcast_value_ptr()处理。
PythonObject 转换
许多 Mojo 类型通过ConvertibleFromPythontrait 支持从等价 Python 类型直接转换:
# 给定一个 person,克隆它并换一个名字 def create_person( name_obj: PythonObject, age_obj: PythonObject ) raises -> PythonObject: # 转换失败时会抛出异常 var name = String(name_obj) var age = Int(age_obj) return PythonObject(alloc=Person(name, age))从 Python 调用:
person = mojo_module.create_person("John Smith")传入非法参数会导致运行时参数错误:
person = mojo_module.create_person(42)从源码搜索可见,ConvertibleFromPython目前已在若干基础类型上实现,包括Int、Bool、SIMD与String等(参见 int.mojo、bool.mojo、simd.mojo 与 string.mojo),不过很多 stdlib 类型尚未实现该 trait。
PythonObject 向下转换
从PythonObject值向下转换到内部 Mojo 值:
def print_age(person_obj: PythonObject) raises: # 若 `obj` 不含 Mojo `Person` 类型的实例则抛出异常 var person = person_obj.downcast_value_ptr[Person]() print("Person is", person[].age, "years old")也支持通过向下转换进行不安全的修改。用户需要自行确保这个可变指针不与 Mojo 内部指向同一对象的其他指针发生别名:
def birthday(person_obj: PythonObject): var person = person_obj.downcast_value_ptr[Person]() person[].age += 1完全不做类型检查的向下转换可以用unchecked_downcast_value_ptr:
def get_person(person_obj: PythonObject): var person = person_obj.unchecked_downcast_value_ptr[Person]()不检查类型的向下转换可以消除类型检查的开销,适合在已通过基准测试确认类型检查是瓶颈的紧内循环中使用。
绑定方法
绑定 Mojo 对象供 Python 使用时,可以使用PythonTypeBuilder.def_method()把选定的方法暴露给 Python。
目前,暴露给 Python 的 Mojo 方法需要相对普通 Mojo 方法做一点修改:必须是@staticmethod,且接收py_self: PythonObject或self_ptr: Pointer[Self]:
from std.python import PythonObject from std.python.bindings import PythonModuleBuilder from std.os import abort @export def PyInit_mojo_module() abi("C") -> PythonObject: try: var mb = PythonModuleBuilder("mojo_module") # highlight-start _ = mb.add_typePerson .def_methodPerson.get_name .def_methodPerson.set_age # highlight-end return mb.finalize() except e: abort("error creating Mojo module") struct Person(Writable): var name: String var age: Int # highlight-start @staticmethod def get_name(py_self: PythonObject) raises -> PythonObject: var self_ptr = py_self.downcast_value_ptr[Self]() return self_ptr[].name @staticmethod def set_age( self_ptr: Pointer[mut=True, Self], new_age: PythonObject, ) raises: self_ptr[].age = Int(new_age) # highlight-end def write_to(self, mut writer: Some[Writer]): t"Person({self.name}, {self.age})".write_to(writer)使用py_self: PythonObject可以访问 Mojo 对象实例所存放的完整PythonObject分配;而一般情况下,如果方法只是需要访问对象的字段,使用self_ptr: Pointer[Self]可以减少样板代码。从 Python 调用的 Mojo 方法目前要求非标准的 self 类型,这是受当前实现的限制,未来版本的 Python Mojo bindings 会解除。
绑定静态方法
Python Mojo bindings 支持暴露 Python 风格的@staticmethod,通过PythonTypeBuilder.def_staticmethod()绑定。用def_staticmethod()声明的函数在 Python 中可以作为类型上的静态方法调用,无需对象实例:
from std.python import PythonObject from std.python.bindings import PythonModuleBuilder from std.os import abort @export def PyInit_mojo_module() abi("C") -> PythonObject: try: var mb = PythonModuleBuilder("mojo_module") # highlight-start mb.add_typePerson .def_staticmethodPerson.is_valid_age # highlight-end return mb.finalize() except e: abort("error creating Mojo module") struct Person(Writable): var name: String var age: Int # highlight-start @staticmethod def is_valid_age(age_obj: PythonObject) raises -> PythonObject: var age = Int(age_obj) return 0 <= age <= 130 # highlight-end def write_to(self, mut writer: Some[Writer]): t"Person({self.name}, {self.age})".write_to(writer)在 Python 中调用绑定为静态方法的 Mojo 函数,就是典型的 Python 静态方法调用:
from mojo_module import Person print(Person.is_valid_age(45)) # 输出 'True' print(Person.is_valid_age(-1)) # 输出 'False'关键字参数
Mojo 的关键字参数有两种形式:
- 仅关键字参数:
def foo(*, x: Int)—— 目前 Python Mojo bindings不支持; - 可变关键字参数:
def foo(var **kwargs: Int)—— 在 bindings 中支持,但需要使用去糖形式def foo(kwargs: StringDict)(**kwargs语法限制将在未来移除)。
可以用StringDict[PythonObject]作为最后一个参数来定义接受可变关键字参数的 Mojo 函数。简单示例:
import mojo_module result = mojo_module.sum_kwargs_ints(a=10, b=20, c=30) # 返回 60from std.collections import StringDict def sum_kwargs_ints(kwargs: StringDict[PythonObject]) raises -> PythonObject: var total = 0 for entry in kwargs.items(): total += Int(entry.value) return PythonObject(total)关键字参数也支持跟在普通位置参数之后;同时,获取特定关键字参数就是对StringDict做字典查找:
from std.collections import StringDict def duration_in_seconds( hours_obj: PythonObject, minutes_obj: PythonObject, kwargs: StringDict[PythonObject] ) raises -> PythonObject: var hours = Int(hours_obj) var minutes = Int(minutes_obj) var seconds = Int(kwargs["seconds"]) return hours * 3600 + minutes * 60 + seconds在这个例子中,如果调用duration_in_seconds()时缺少必需的"seconds"命名参数,会触发运行时异常:
from mojo_module import duration_in_seconds # 传入 hours 和 minutes,缺少 "seconds" duration_in_seconds(4, 5) # ERROR: KeyError关键字参数在绑定顶层函数、方法和静态方法时均受支持。
可变参数
Python 与 Mojo 的可变参数通常写成:
def foo(*args: Int): ...但这个语法目前还不被 Python/Mojo bindings 支持,因为用def_function()绑定的函数只支持固定元数(fixed-arity)。
作为变通方案,可以使用更底层的def_py_function()接口,把接受可变数量参数的 Mojo 函数暴露给 Python,参数个数校验由用户自己负责:
@export def PyInit_mojo_module() abi("C") -> PythonObject: try: var b = PythonModuleBuilder("mojo_module") b.def_py_functioncount_args b.def_py_functionsum_args b.def_py_functionlookup def count_args(py_self: PythonObject, args_tuple: PythonObject) raises: return len(args_tuple) def sum_args(py_self: PythonObject, args_tuple: PythonObject) raises: var total = args_tuple[0] for i in range(1, len(args_tuple)): total += args_tuple[i] return total def lookup(py_self: PythonObject, args_tuple: PythonObject) raises: if len(args_tuple) != 2 and len(args_tuple) != 3: raise Error("lookup() expects 2 or 3 arguments") var collection = args_tuple[0] var key = args_tuple[1] try: return collection[key] except e: if len(args) == 3: return args_tuple[2] else: raise e从 stdlib 源码可以看到,def_py_function支持两类函数签名:PyFunctionRaising与PyFunctionWithKeywordsRaising(见 bindings.mojo),最终都通过PyCFunction/PyCFunctionFast等 CPython 调用约定注册到模块中。
将 Python 移植到 Mojo 的策略:写出 Pythonic 的 Mojo
在这种绑定思路下,我们拥抱 Python 的灵活性,不试图把PythonObject参数强制塞进 Mojo 强类型系统那个狭窄受限的空间,而是直接写代码,如果写错了就让它运行时抛异常。PythonObject的灵活性带来了一种独特的编程风格:Python 代码几乎可以原样「移植」到 Mojo。
def foo(x, y, z): x[y] = int(z) x = y + z经验法则:任何 Python 内建函数,在 Mojo 中都应该可以通过Python.<builtin>()访问。
def foo(x: PythonObject, y: PythonObject, z: PythonObject) -> PythonObject: x[y] = Python.int(z) x = y + z构建 Mojo 扩展模块的两种方式
你可以通过以下方式创建并分发供 Python 使用的 Mojo 模块:
以源码文件形式分发,通过 Python Mojo importer hook 按需编译。这种方式的优点是上手容易、项目结构简单,并且编辑后导入的 Mojo 代码总是最新的。
以预编译的 Python 扩展模块
.so动态库分发,使用命令编译:mojo build mojo_module.mojo --emit shared-lib -o mojo_module.so这种方式的优点是可以手动指定其他任何必要的构建选项(优化或调试标志、导入路径等),为高级用户提供了绕过 Mojo import hook 抽象层的「逃生舱」。
已知限制
Python 与 Mojo 的互操作目标远大——Mojo 希望成为扩展 Python 的最佳方式——但该特性仍处于早期且活跃的开发阶段,存在以下需要注意的限制,未来会逐步解除:
关键字参数语法。目前,从 Python 调用的 Mojo 函数只有在使用末尾的
kwargs: StringDict[PythonObject]参数时才接受关键字参数;原生**kwargs语法支持将在未来加入。Mojo 包依赖。依赖除 Mojo stdlib 之外其他包(如 Modular Community 包渠道中的包)的 Mojo 代码,目前只在手动构建 Mojo 扩展模块时受支持,因为 Mojo import hook 目前不支持为 Mojo 包依赖指定导入路径。
属性(Properties)。计算属性的 getter 与 setter 目前不受支持。
预期的类型转换。少数 Mojo 标准库类型通过实现
ConvertibleFromPythontrait,可以直接从等价的 Python 内建对象类型构造;但许多 Mojo 标准库类型尚未实现该 trait,如有需要可能得编写手动转换逻辑。
参考资料
- Mojo 手册章节原文:Calling Mojo from Python
- 可运行示例目录:Mojo/docs/site/code/manual/python/mojo-from-python(含 mojo_module.mojo、main.py 与 BUILD.bazel)
- stdlib 绑定实现:bindings.mojo(
PythonModuleBuilder/PythonTypeBuilder) PythonObject实现:python_object.mojo- 转换 trait:conversions.mojo
【免费下载链接】mojoThe Modular Platform (includes MAX & Mojo)项目地址: https://gitcode.com/GitHub_Trending/mo/mojo
创作声明:本文部分内容由AI辅助生成(AIGC),仅供参考