把一个纯 Python 模块编成.pyd这件事,我第一次做是出于两个很现实的原因:一是某套带核心算法的小工具要交付给外部团队做二次集成,.py文件直接摆在那里,等于把实现思路全交出去了;二是其中有一段数值循环,跑一次要几十秒,用户那边已经催了三回。两个诉求叠在一起,才有了“编译成 pyd”这条路线。后来陆续在几个项目里重复过这套流程,也踩过不少坑——比如编完之后模块导入失败、调试时看不到行号、换个 Python 小版本就整包报错等等。这篇就把这套流程从原理到实操完整梳理一遍,重点讲清楚.pyd在代码保护和性能优化这两件事上到底能做到什么程度,以及哪些地方是我想当然后来被打脸的。
需要先明确一点:这篇不讨论把整个程序做成安装包那种打包方式,只聚焦“把某个 Python 模块编译成二进制扩展模块”这一件事。适合的读者是已经能写 Python、但没怎么碰过扩展模块编译的同学,也适合那些想给交付物做一层保护、又不想推翻整个技术栈的开发者。文中所有操作都可以直接照着复现,涉及具体参数的地方我会说明为什么这么选。
1. 先把 .pyd 这件事说清楚:它到底是什么东西
1.1 从 import 那一刻看扩展模块的加载路径
很多人对.pyd的第一印象是“加密过的 py 文件”,这个理解偏差挺大。它本质上是动态链接库(在 Windows 上是 DLL,只是在 Python 语境里改了后缀),里面导出一个约定好的初始化函数,名字形如PyInit_模块名。当你在代码里写import mymod时,Python 的导入系统会按一组固定的后缀顺序去目录里找文件,找到之后用对应的加载器把二进制加载进进程,然后调用那个初始化函数,把模块对象注册到sys.modules里。
这里的关键在于:解释器从头到尾没有“执行 Python 源码”这个动作。mymod.py是解释器一行行读、一行行编译成字节码再执行;mymod.pyd是操作系统把机器码映射进内存,然后直接跳转执行。所以它天然就绕开了源码分发的问题——你交付的是编译产物,不是源文件。
想要亲眼看到这套机制,可以跑一行:
import importlib.machinery print(importlib.machinery.EXTENSION_SUFFIXES)在 Windows 的 CPython 3.11 64 位环境下,输出大致是['.cp311-win_amd64.pyd', '.pyd'];在类 Unix 平台上则是['.cpython-311-x86_64-linux-gnu.so', '.abi3.so', '.so']这一类。这个列表就是导入系统认的后缀白名单,顺序也代表了优先级。
1.2 .pyd 与 .so、.dll 的关系,以及命名规则里的 ABI 秘密
把同一份代码在 Windows 上编出来叫.pyd,在类 Unix 平台上编出来叫.so,两者在文件格式层面就是各自平台的动态库格式。这也是为什么“编译一次到处运行”在这条路线上完全不成立:你不光要为目标平台重新编译,还要为目标平台上的具体 Python 小版本重新编译。
看后缀里的cp311就知道,它把 CPython 的版本号写死了。原因在于扩展模块调用的是 CPython 的 C API,而这个 API 在不同小版本之间并不保证二进制兼容——3.10 编译出来的模块拿到 3.11 上加载,通常会直接抛ImportError: Module use of python310.dll conflicts with this version of Python。有个abi3的机制可以缓解这个问题(一套产物覆盖多个 3.x 版本),但它要求只使用稳定的 API 子集,很多高级特性用不了,我一般在内部工具里不用它,除非分发场景确实需要省事。
还有一个容易被忽略的点:文件名里带 ABI tag 不是必须的。你把产物重命名成mymod.pyd也能被找到,因为后缀列表里同时存在纯.pyd这一项。带 tag 的好处是同一个目录下可以并存多个 Python 版本的产物而互不覆盖,坏处是构建脚本里如果写死了文件名,换 Python 版本就会找不到。我在 CI 里吃过这个亏:脚本里硬编码了xxx.cp38-win_amd64.pyd,升级到 3.10 之后构建“成功”但产物名对不上,部署环节直接报模块缺失,排查了半小时才发现是文件名的问题。
2. 保护诉求的真实边界:pyd 挡得住谁,挡不住谁
2.1 反编译门槛抬高了,但符号表和字符串常量仍然会漏信息
必须实话实说:.pyd不是加密,它只是把“读源码”变成了“读汇编”。用常规的逆向工具打开,函数名、常量字符串、调用关系这些信息大概率还能看到一大部分,尤其是 Cython 默认配置编出来的产物——它会把 Python 层的函数名以字符串形式保留在二进制里,方便运行时报错和反射。
我做过一次自测:拿一个编好的 pyd 直接用字符串提取工具扫一遍,结果里面出现了好几个业务参数名和一个外部服务的接口路径片段。那一刻我意识到,“保护”这个词在我脑子里的分量和实际效果之间有落差。
所以我的定位调整成了这样:.pyd的价值不是“让人看不到”,而是“让看到的成本明显高于自己重写的成本”。对于一般商业交付场景,这个门槛已经够用了;对于确定存在专业逆向对手的场景,光靠 pyd 是不够的,需要配合服务端下沉关键逻辑、结果校验、许可绑定等手段。这个判断很重要,因为它直接决定了你在编译配置上该投入多少精力。
具体能做的收敛动作有这么几条:
- 关掉签名嵌入:Cython 的
embedsignature指令会把函数签名塞进产物,方便inspect使用,但保护场景下这是主动泄露,应该设为False。 - 关掉 docstring:注释和文档字符串里往往藏着比代码更详细的业务说明,
cythonize支持--no-docstrings,我一般都会带上。 - 剥离符号:Windows 下链接时不生成调试信息,类 Unix 平台下用
strip处理产物,能去掉一批本地符号名。 - 别把敏感配置写进代码里:这条不用多说,但每年都能看到有人在常量里塞密钥然后指望编译一下就能藏住。
注意:编译优化和可调试性是一对天生的矛盾。我建议在构建流程里同时保留一份未编译的
.py版本用于本地调试和问题复现,只在发布环节产出 pyd。丢掉源码之后再去追一个线上崩溃,那种体验一次就够了。
2.2 哪些代码值得编,哪些纯属浪费编译时间
不是所有模块都值得走这条路。我的判断标准大致是三条:
第一类,对外交付且包含业务Know-how的模块。比如算法实现、定价逻辑、规则引擎的核心部分。这类模块编译收益最直接。
第二类,调用频繁、位于性能热点上的模块。这类要靠编译加类型标注才能真正提速,具体在第五节展开。
第三类,纯胶水代码。比如只是一层 HTTP 请求封装、几个工具函数、日志格式化。这类编了几乎没有收益,反而带来版本绑定、调试困难这些成本。我曾经图省事把整个 utils 包全编了,结果每次改动一个小函数都要重新走一遍构建流水线,测试同学反馈问题时报的行号我根本对不上,后来又把大部分改回了普通.py。
这里有个我自己的经验值:一个模块如果月度改动次数超过三次,就不该编。编译产物的修改成本远高于源码文件,把高频变动的模块编进去,等于给自己上了一个效率枷锁。
3. 工具选型:Cython、Nuitka、mypyc 各自适合什么场景
3.1 三条技术路线的差异
把 Python 变成二进制,市面上主流的三条路子,底层思路完全不同:
Cython走的是“超集语言”路线。它读的是 Cython 语法(Python 的超集),生成 C 源码,再交给平台 C 编译器编译成扩展模块。不写任何类型标注时,它生成的是一堆 Python C API 调用,语义保持不变,但省掉了字节码解释循环的开销;写了cdef、类型标注之后,它能把变量变成真正的 C 类型,性能提升才真正显现。
Nuitka走的是“编译器”路线。它把 Python 的 AST 完整翻译成 C 代码,还做常量折叠、循环优化、内置函数内联这些编译期优化,输出可以是可执行文件也可以是扩展模块。它的优点是几乎不用改代码,纯 Python 项目直接就能编;缺点是编译时间长,大项目动辄十几分钟。
mypyc走的是“类型驱动”路线。它要求代码本身带完整的类型注解,靠 mypy 的类型信息做静态编译。代码没注解基本没效果,注解写得越细收益越大。
还有一类是把已有的 C/C++ 代码通过 pybind11 或手写 C API 暴露成模块,但这条路是“把别的语言的东西接进 Python”,和“把 Python 编成二进制”是两回事,本文不展开。
3.2 一张对照表与我的选择逻辑
| 维度 | Cython | Nuitka | mypyc |
|---|---|---|---|
| 代码改动量 | 需要少量适配,可选类型标注 | 基本零改动 | 必须完整类型注解 |
| 编译耗时 | 中等 | 偏长 | 中等 |
| 纯 Python 代码提速 | 通常不明显,个别场景反而变慢 | 有普遍提升,一般在一到三成 | 依赖注解质量 |
| 加类型后的提速 | 显著,热点循环可达数倍 | 提升有限 | 显著 |
| 保护效果 | 好,可配置剥离信息 | 好 | 好 |
| 调试友好度 | 一般 | 一般 | 一般 |
| 常见坑 | 反射、动态特性支持不全 | 编译依赖多,兼容性问题分散 | 注解缺失时静默降级 |
我的实际选择是这样的:交付物要保护、同时有一段明确的热点需要加速,用 Cython;存量项目不想动代码、只是想整体编译一下提高一点启动速度和保护度,用 Nuitka;代码本来就带完整注解、团队已经在用静态检查,用 mypyc 最省心。
这个选择逻辑背后是一个很朴素的判断:看你的主要矛盾是“保护”还是“性能”。如果只是保护,Nuitka 的零改动优势太明显;如果既要保护又要快,Cython 是唯一能让你精确控制到什么程度编译成 C 类型的工具。混合使用也完全可行——我有个项目就是主框架用 Nuitka 整体编译,热点模块单独用 Cython 精修。
4. Cython 动手:从一个纯 Python 模块到可用的 .pyd
4.1 环境准备与编译工具链的坑
这一段是纯体力活,但坑最多。前置条件只有两个:Python 开发头文件和一套 C/C++ 编译工具链。
Windows 上最省事的做法是装一组构建工具,安装时务必勾选 C++ 生成工具和 Windows SDK。我用pip install cython setuptools装好 Cython 之后,直接跑编译,最常见的报错是error: Microsoft Visual C++ 14.0 or greater is required。这个报错信息有点误导,它不一定是你真的没装,也可能装了但当前命令行环境的 PATH 里找不到。我的处理顺序是:先确认工具链装没装,再确认位数对不对(32 位 Python 配 32 位工具链,64 位配 64 位),最后确认是不是在错误的终端环境里操作。
类 Unix 平台通常简单得多,装好build-essential之类的开发包基本就够了。有个细节:如果系统里同时存在多个 Python,务必用python -m pip和python setup.py这种方式调用,确保编译时用的解释器和你要产出的 ABI 版本是同一个。我有一次系统默认python指向 3.9,虚拟环境里是 3.11,结果产物是 3.9 的 tag,装到 3.11 环境里怎么都导入不了。
4.2 setup.py 与 cythonize 的关键参数
最小可用的构建脚本长这样:
from setuptools import setup from Cython.Build import cythonize setup( name="mymod", ext_modules=cythonize( "mymod.py", compiler_directives={ "language_level": "3", "embedsignature": False, "boundscheck": False, "wraparound": False, "cdivision": True, "initializedcheck": False, }, annotate=True, ), )然后执行:
python setup.py build_ext --inplace跑完当前目录下就会多出一个形如mymod.cp311-win_amd64.pyd的文件。
逐个说这些参数为什么这么设:
language_level="3"明确按 Python 3 语义解析。不设的话 Cython 会按默认值处理,某些字符串和除法的行为可能和你预期不一致,属于必须显式声明的一项。
embedsignature=False是保护场景的关键,前面说过,它控制是否把函数签名嵌进产物。如果你的代码依赖inspect.signature做反射(比如某些框架的参数注入),把它设成False会导致运行时报错,这种情况就得权衡。
boundscheck和wraparound关掉意味着 Cython 不再对索引做越界检查、不再支持负索引。这两个能带来实实在在的速度提升,但前提是你自己的索引逻辑是安全的。我在一个处理数组的模块里关掉它们之后,某次边界条件写错,程序不是报错而是静默读到了脏数据,排查了两天才定位到。所以这条我的建议是:先用默认值跑通、测试覆盖到位,再关掉做性能优化。
cdivision=True让除法直接走 C 语义,负数除法和取模的行为会跟 Python 不一样,涉及负数运算的代码要特别小心。
annotate=True会在同目录生成一份 HTML 报告,用颜色标出每一行有没有编成纯 C 代码。这份报告非常值得看,它是判断“我的优化到底生效没有”的直接依据——大片白色说明这行还在走 Python 对象操作,大片区是黄色说明已经编译成 C 类型了。
4.3 验证产物:怎么确认真的加载的是 pyd
编译完成不等于生效。如果原.py还在同一个目录,导入系统会按后缀优先级选择,虽然扩展模块通常排在源码之前,但依赖这个顺序是很不靠谱的做法。我的验证步骤固定三步:
第一步,删掉或移走原.py(至少在验证环境里这么做),确保没有回退的可能。
第二步,加一段自检代码打印模块的真实路径和文件类型:
import mymod print(mymod.__file__) print(type(mymod.__loader__).__name__)如果路径是.pyd(或.so)结尾、加载器是ExtensionFileLoader,说明走的是扩展模块。如果打印出来是.py,说明你加载的还是源码。
第三步,跑一遍完整的回归测试。这一步不能省,因为编译过程会改变一些微妙的运行时行为,比如异常回溯里的行号、__doc__是否为空、某些反射接口是否还能用。我一般会在测试里显式断言几个关键函数的返回值和异常类型,确保语义没变。
5. 性能提升到底有多少:分代码类型的实测思路
5.1 为什么有些模块编完几乎没变快
这是最容易被误解的一点。很多人以为.py→.pyd就自动变快,实际上默认配置下,编译带来的性能提升经常小到测不出来,个别情况甚至更慢。
原因在于:当你只是把普通的 Python 代码交给 Cython 而不加任何类型信息时,它生成的 C 代码本质上是一长串对 Python C API 的调用。原本由解释器循环做的工作变成了 C 函数调用,省掉的是字节码分发的开销,但你代码里每一次加法、每一次属性访问,依然是完整的 Python 对象操作,该有的开销一点没少。对于以对象操作和函数调用为主的代码,这点收益会被各种额外开销抵消掉。
反过来,真正能吃满编译收益的是这几类代码:密集的数值循环、大量重复的算术运算、对数组或缓冲区的逐元素访问、调用次数极多的短小函数。这些场景里,变量能不能落到 C 的栈上,差别是数量级的。
所以我现在的做法是:先 profile,再决定编什么。用标准的性能分析工具跑一遍,找出真正的热点函数,只对这几个函数做类型化改造,剩下的模块该保持源码就保持源码。全面编译是一种性价比很低的策略。
5.2 用 cdef / 类型标注把热点函数真正加速
假设热点是一个累加函数:
def total(values): s = 0.0 for v in values: s += v * 1.5 return s直接编译,提升有限。改成带类型标注的写法:
def total(double[:] values): cdef double s = 0.0 cdef Py_ssize_t i cdef Py_ssize_t n = values.shape[0] for i in range(n): s += values[i] * 1.5 return s差别在于:s变成了真正的 C 双精度浮点数,i变成了 C 的整数索引,values通过内存视图直接访问底层缓冲区,整个循环里没有产生任何 Python 对象。这种改造在数值场景下能带来非常明显的提升,具体倍数取决于运算密度,我在实际项目里见过从“几十秒”到“几秒”的变化。
但要注意,这种写法改变了接口语义。原来的total接受任何可迭代对象,改造后只接受支持缓冲区协议的对象。如果上游有调用方传 list 进来,就会直接抛类型错误。所以我一律会在外面包一层薄薄的 Python 函数做类型转换和参数校验,把带类型的实现改成内部函数:
import numpy as np def total(values): arr = np.ascontiguousarray(values, dtype=np.float64) return _total_impl(arr)外层这层壳不编译,保持.py,灵活性和可读性都在;内层做重活,编译成二进制。这个分层是我反复验证下来最舒服的结构,既拿到了性能,又不至于因为接口变窄而在某个下游调用处翻车。
还有一点经验:不要一次改太多。我的习惯是先只给循环变量和累加器加类型,测一次;再加内存视图,再测一次。每次只动一处,才有办法判断是哪一项起了作用。曾经我一次性改了十几个函数,结果整体反而变慢,回头逐个二分排查花的时间比优化本身还长。
5.3 编译策略与 profile 结合的做法
给一个我常用的落地节奏:
- 用标准库的计时工具对嫌疑函数做基准测量,记录基线数值,同时记录运行环境(Python 版本、机器配置),否则后面测出来的数据没有参照意义。
- 开
annotate=True生成报告,先肉眼扫一遍哪些行还是 Python 语义。 - 只对热点函数加类型,重新编译、重新测。
- 如果提升不明显,考虑是不是瓶颈根本不在计算上——经常出现的情况是“以为慢在循环,实际慢在 IO 或数据库查询”,那编译再怎么优化都是徒劳。
提示:基准测量时一定要跑多次取中位数,第一次运行往往包含模块导入和缓存预热,只跑一次的数据基本不可信。另外单次耗时低于毫秒级的函数,测量误差会淹没真实差异,这种函数优先考虑合并调用而不是编译优化。
6. 我在包结构上踩过的坑:init、相对导入与命名冲突
6.1 包目录里哪些文件不能编
这个坑我踩得很深。当时有个包长这样:
mypkg/ __init__.py core.py utils.py图省事,我把三个文件全编了,产物是__init__.cp311-win_amd64.pyd、core.cp311-win_amd64.pyd、utils.cp311-win_amd64.pyd,然后把.py全删了。结果导入直接失败,报错信息含糊得看不出原因。
拆开分析,问题出在__init__.py上。包初始化文件被编成扩展模块之后,它作为“包”的语义支持并不完整,包内相对导入、__path__的维护、包级属性的懒加载这些行为都可能出问题。而且一旦__init__是二进制,你在里面写任何包级别的初始化逻辑,出错时回溯信息几乎不可读。
我的结论是:__init__.py永远保持为纯 Python 文件,哪怕它只有一行空内容。它作为一个轻量的入口壳,负责把内部实现模块引入进来,实现模块再编译成二进制。这样包结构的语义是完整的,导入系统的行为也不会有意外。
改造后的结构是这样:
mypkg/ __init__.py # 保持源码,内容仅仅是 from .core import X core.cp311-win_amd64.pyd utils.cp311-win_amd64.pyd6.2 编译后模块找不到的三种典型原因
排错时我形成了一套固定的检查顺序,按命中率从高到低:
| 现象 | 最可能的原因 | 处理方式 |
|---|---|---|
ModuleNotFoundError | 产物文件名与导入名不匹配,或放错目录 | 打印__path__确认搜索目录,核对文件名前缀 |
ImportError: DLL load failed | 产物依赖的运行时库缺失,或位数不匹配 | 检查 32/64 位是否一致,检查依赖运行库 |
Module use of pythonXX.dll conflicts | Python 小版本不匹配 | 用目标版本重新编译,别指望跨版本复用 |
| 导入成功但属性缺失 | 编译时漏了某个模块,或__init__里的导出语句没更新 | 对比源码版本的dir()输出 |
第一种最常见。文件名前缀必须和模块名严格一致,core.py编出来必须是core.xxx.pyd,如果你手工改名成core_impl.pyd,那import core就找不到了。我有一次为了让产物名带版本号,改成了core_v2.pyd,结果整个包导入失败,而且报错信息完全没提文件名的事。
第二种在分发场景里很典型。你自己的机器上跑得好好的,拷到别人机器上就报 DLL 加载失败,通常是目标机器缺少某个运行库。这种情况要么把依赖一并分发,要么改用静态链接的方式编译。
6.3 与 pickle、inspect、多进程的兼容问题
这几个是编译之后行为会变化、但测试里不一定覆盖到的地方,我逐个说。
pickle:如果模块里定义了类并且这些类的实例需要序列化,编译后可能出问题。Cython 里用cdef class定义的类,默认不支持 pickle,需要显式实现__reduce__。用普通class定义的一般没问题,但涉及动态属性的情况偶尔会失效。我的做法是给所有需要跨进程序列化的类写一组序列化往返测试,编译前后都跑一遍。
inspect 和反射:inspect.getsource在编译后的模块上必然失效,这是意料之中的。麻烦的是那些依赖签名信息的框架——参数注入、依赖注入、装饰器自动包装这类。前面提到的embedsignature如果关了,inspect.signature拿不到参数列表,框架层会直接报错。遇到这种情况,我的处理是在外层保留一层纯 Python 的包装函数,让框架看到的是这层包装,真正的实现在编译模块里。
多进程:在类 Unix 平台用 fork 方式启动子进程,一般没问题;在 Windows 上用的是 spawn,子进程需要重新导入模块。这里要注意的是,如果编译模块在导入阶段做了比较重的初始化(比如加载模型文件),每个子进程都会重复一遍,启动会明显变慢。所以编译模块的导入阶段要保持“无副作用”,重活放到显式的初始化函数里调用。
dataclass 与类型注解:用@dataclass装饰的类在编译后大多能正常工作,但我遇到过一次字段被优化掉的情况——类里的注解在某些配置下会被当作纯类型提示处理,运行时不再生成对应的属性。后来我养成了一个习惯:任何用了dataclass的模块,编译后都跑一遍字段完整性断言,确认__dataclass_fields__的内容和源码版本一致。
7. 把编译放进构建流程:跨版本、跨平台的产物管理
7.1 ABI 版本与 Python 小版本绑定
前面提过,产物和 Python 小版本是硬绑定的。这件事在团队协作里会变成一个持续的成本:只要有一个人升级了 Python 版本,整个构建链就得跟着走一遍。
我的建议是把“产物矩阵”提前想清楚。如果项目要支持 3.9 到 3.12 四个版本、两个平台,那就是八份产物。这不是靠一台机器手工编能维护的,必须放进自动化流水线里做矩阵构建。构建脚本里绝对不要硬编码版本号,所有涉及 tag 的地方都从当前解释器动态取:
import sysconfig tag = sysconfig.get_config_var("EXT_SUFFIX") # 例如 .cp311-win_amd64.pyd print(tag)用这个后缀拼出产物名,换版本的时候不需要改脚本。这个习惯帮我省过好几次事——之前每次升级 Python 都要全局搜一遍写死的cp38,现在完全不用管。
还有一个现实问题:依赖第三方包的模块能不能编。答案是可以,编译产物运行时依然需要那些第三方包存在于环境中,编译带不来任何依赖打包的效果。所以如果你的交付物要给一个干净环境用,除了编 pyd,依赖清单也得一并整理好,这两件事是分开的。
7.2 产物目录组织与清理策略
构建产物和源码混在一起是个坏习惯,会带来前面说的“分不清加载的是哪个”的问题。我的目录约定是这样的:
project/ src/mypkg/ # 源码目录,只放 .py build/ # 编译中间产物,随时可删 dist_pyd/mypkg/ # 编译产物输出目录 setup_build.py构建时把.pyd输出到dist_pyd下对应的包路径里,然后在这个目录里补上必要的__init__.py、配置文件、静态资源,形成一个可以直接分发的目录。发布流程只认dist_pyd,源码目录完全不参与。这样出问题的时候,我可以立刻判断“这是源码版本的问题还是编译版本的问题”,二分排查的效率会高很多。
清理策略也值得说一句。Cython 编译会生成.c中间文件和build/目录,这些东西体积不小,而且和源码版本强相关。我一般在流水线里做完一次发布就把中间产物整个删掉,不留缓存,唯一保留的是annotate=True生成的那份 HTML 报告——它是排查“为什么这行没加速”的唯一线索,值得存档。
最后分享一个我觉得最有用的实践经验:编译这件事,只在你有明确收益的时候做,不要因为它听起来高级就做。我在项目初期曾经把大部分模块都编了,后来发现维护成本远超预期——每次改代码都要等构建,调试时看不到行号,换个环境就得重新编一遍,最后又一点点改回源码。现在我的标准是:真正需要保护的模块编,真正测出来是热点的函数做类型化,其余保持原样。这个比例在我手上的项目里大概是二八开,而那 20% 确实贡献了绝大部分的价值。