本节目标:讲清 Cython 把
.pyx编译成 C 扩展的完整链路,用cython -a看懂「为什么慢」,实测cdef类型声明、内存视图、with nogil的加速与 GIL 释放,并解释一个反直觉的编译优化现象。
适用版本:Python 3.12+(实测 3.14.6;Cython 3.3.0,NumPy 2.5.3,Apple clang 16.0.0 / arm64)
9.2 Cython 与 NumPy 加速
专题 Python C 扩展与 FFI
给过一张 Cython 加速表,但没交代数字怎么来的、也没说清「加了类型为什么就快」。本节把链路拆开:从 .pyx 到 .c 到 .so 每一步都实跑,用 cython -a 把「慢在哪一行」量化出来,加速比全部在本机重测,并揭示一个会让基准测试彻底失真的编译器行为。
9.2.1 .pyx 怎么变成扩展模块
Cython 不是解释器,是一个源码到源码的编译器:把 .pyx 翻译成一份 C 文件,再由系统 C 编译器编成扩展模块。整条链路只有两步:
# 1) .pyx -> .c
python -m cython -3 sumsq.pyx -o sumsq.c
# 2) .c -> .so(本机无 setuptools,直接调 cc)
INC=/opt/homebrew/opt/python@3.14/Frameworks/Python.framework/Versions/3.14/include/python3.14
cc -bundle -undefined dynamic_lookup -O2 -I"$INC" sumsq.c \
-o sumsq.cpython-314-darwin.so
生成物的大小很能说明问题:
34 sumsq.pyx
30826 sumsq.c
34 行 .pyx 膨胀成 3 万多行 C——多出来的是每个 Python 对象的引用计数管理、异常检查、类型转换样板。Cython 的默认语义是「Python 语义」:def 里的变量默认还是 PyObject*,每步运算都走 PyNumber_Multiply 之类的 C-API,和纯 Python 一样慢。真正的加速来自你主动加类型,把 PyObject* 换成 C 原生类型。
上面第二步用的是手动 cc,只因为本机没有 setuptools。正常项目的标准路径是让 setuptools + cythonize 一步到位(标准写法,本机缺 setuptools,未实测):
# setup.py
from setuptools import setup
from Cython.Build import cythonize
setup(
ext_modules=cythonize(
"sumsq.pyx",
compiler_directives={"language_level": "3"},
),
)
python setup.py build_ext --inplace # 编译到源码目录,便于直接 import
本节实测用的完整 sumsq.pyx(顶部指令关掉边界检查):
# cython: language_level=3, boundscheck=False, wraparound=False
def sumsq_pure(n):
total = 0
for i in range(n):
total += i * i
return total
def sumsq_typed(long n):
cdef long long total = 0
cdef long i
for i in range(n):
total += i * i
return total
def sumsq_mv(double[::1] arr):
cdef Py_ssize_t i, n = arr.shape[0]
cdef double total = 0.0
for i in range(n):
total += arr[i] * arr[i]
return total
def sumsq_mv_nogil(double[::1] arr):
cdef Py_ssize_t i, n = arr.shape[0]
cdef double total = 0.0
with nogil:
for i in range(n):
total += arr[i] * arr[i]
return total
9.2.2 用 cython -a 看穿「为什么慢」
Cython 自带一个注释工具:cython -a 会生成一份 HTML,把每一行源码按它触发的 C-API 调用次数染色——白色是「这一行几乎不碰 Python 对象」,越黄说明这一行每执行一次就要调用越多次 C-API。这是定位「为什么这段 Cython 没变快」最直接的手段。对本节 sumsq.pyx 实测(数字是该行每次执行的 C-API 调用密度):
密度 | 源码行
42 | def sumsq_pure(n): # 无类型
25 | for i in range(n): # 每次迭代 25 次 C-API
6 | total += i * i # 每次迭代 6 次 C-API
2 | return total
45 | def sumsq_typed(long n): # 全 cdef 类型
0 | for i in range(n): # 每次迭代 0 次 C-API
0 | total += i * i
40 | def sumsq_mv(double[::1] arr):
0 | for i in range(n): # 迭代内 0 次
0 | total += arr[i] * arr[i]
对照非常清楚:无类型版的循环体每迭代要打 25 + 6 = 31 次 C-API(for 每轮建一次迭代器、+= 每轮做一次 PyNumber_Add 加引用计数);全 cdef 类型版的循环体是 0 次——迭代内只有 C 的整数乘加,完全没有 Python 对象参与。那 44 倍的差距,就藏在这「31 → 0」里。函数头那行 def 的密度(42/45/40)是一次性的调用样板,不进循环,可以忽略。
用法建议:任何一段「编译了但没变快」的 Cython,先用 cython -a 打开,凡是循环体内还带黄色(密度 > 0)的行,就是还没加类型的地方。
9.2.3 cdef 类型声明带来的加速
被编译的 sumsq.pyx(顶部指令 boundscheck=False, wraparound=False)里放了无类型的 sumsq_pure、全 cdef 类型的 sumsq_typed,以及用内存视图的 sumsq_mv。在同一进程里和纯 Python 对照,n = 5_000_000,timeit 取 5 轮最小值:
def sumsq_py(n): # 纯 Python 对照
total = 0
for i in range(n):
total += i * i
return total
实测结果:
| 实现 | 单次耗时 | 相对纯 Python |
|---|---|---|
纯 Python for 循环 | 205 ms | 1x |
Cython 无类型 def | 182 ms | 1.1x |
Cython cdef long long(-O0 真实循环) | 4.68 ms | 44x |
Cython double[::1] 内存视图 | 4.87 ms | 42x |
NumPy 向量化 arr @ arr | 0.81 ms | 253x |
两个结论。第一,「用 Cython」本身不加速:sumsq_pure 一个类型都没加,只比纯 Python 快 10%,正好对应上一节它循环体那 31 次 C-API。第二,加速全部来自 cdef:把 total 声明成 cdef long long、循环变量 i 声明成 cdef long 后,循环体退化成纯 C 整数乘加,直接快 44 倍。
对应的 .pyx 只差几行:
def sumsq_typed(long n):
cdef long long total = 0
cdef long i
for i in range(n):
total += i * i
return total
def / cdef / cpdef 三种函数声明要分清:def 是「Python 调用、Python 语义」(参数和返回都是 PyObject,本地变量默认也是);cdef 是「只能在 C 内调用、全 C 语义」(最快,但 Python 调不到);cpdef 是「两者都行」——生成一个 C 版本和一个 Python 包装,兼顾速度和可调用性。这里用 def 是为了能从 Python 直接调用测试。
注意 long 的溢出边界:long long 是 64 位有符号,n = 5_000_000 时平方和已超过 2⁶³,实测返回值是回绕后的负数(-4529445843202100544)——Cython 不会替你检查溢出,这正是它快的原因,也是类型声明必须自己负责的地方。
9.2.4 一个会让基准失真的现象:-O2 把循环闭式化
上面 sumsq_typed 的 44x 用的是 -O0 编译。换成 -O2(生产默认),同一函数变得几乎不耗时:
-O0 sumsq_typed(1000000) 0.961 ms
-O0 sumsq_typed(5000000) 4.917 ms
-O2 sumsq_typed(1000000) 0.002 ms
-O2 sumsq_typed(5000000) 0.001 ms
-O2 下无论 n 多大都是 0.001 ms,且返回值与 -O0 逐位一致(含溢出)。原因不是测量错误:clang -O2 识别出「i 从 0 到 n-1 累加 i*i」是一个有闭式解的循环,直接替换成算术表达式,5 百万次迭代塌缩成几条指令。
实践教训:用 Cython 做基准对比时,如果累加是纯算术、循环上界是运行时变量,优化器可能把整段循环消掉,测出来的「加速比」是假的。 判断方法是 -O0 / -O2 对照——真实循环耗时随 n 线性增长,被优化掉的是一条水平线。要避免被优化掉,让循环带上数据依赖或不可约简的运算,比如下面遍历真实数组的内存视图版本。
9.2.5 内存视图 double[::1]:和 NumPy 零拷贝配合
把 NumPy 数组喂给 Cython 的正确姿势是类型化内存视图,而不是 object:
def sumsq_mv(double[::1] arr):
cdef Py_ssize_t i, n = arr.shape[0]
cdef double total = 0.0
for i in range(n):
total += arr[i] * arr[i]
return total
double[::1] 表示「一维、连续、元素是 C double」的缓冲区。调用时把 np.arange(N, dtype=np.float64) 直接传进来:Cython 会校验连续性并拿到数据指针,全程零拷贝,循环里 arr[i] 直接是 C 的 double 访存。它相对「在 Python 里逐元素遍历 NumPy」的提升极大(n = 2_000_000 实测):
| 写法 | 单次耗时 |
|---|---|
Python 里 for x in arr: total += x*x(NumPy 数组) | 149 ms |
Python 里 for x in lst: total += x*x(普通 list) | 71 ms |
Cython double[::1] 标量循环 | 1.86 ms |
NumPy 向量化 arr @ arr | 0.34 ms |
在 Python 层逐元素遍历 NumPy 数组要 149 ms——比遍历同样规模的普通 list(71 ms)还慢一倍,因为每个元素都要现拆成 numpy.float64 标量对象。换成 Cython 内存视图后降到 1.86 ms(80x),因为迭代内直接读写 C double。但比 NumPy 自身的 arr @ arr(0.34 ms,用了 BLAS 的 SIMD 与分块)仍慢——手写标量循环比不过成熟的向量化内核,内存视图的价值在于「需要自定义、向量化表达不了的计算」。
要点:
- 必须连续。
double[::1]只接受 C 连续数组;传arr[::2]这类非连续切片会抛ValueError(除非声明成double[:]并放弃::1的连续保证,此时访问带步长、更慢)。 boundscheck=False/wraparound=False是性能关键指令:关掉后 Cython 不再插入下标越界检查和负索引换算。代价是越界访问不再抛IndexError,而是未定义行为——只在自己保证下标正确时开。- 类型不匹配(比如传
float32数组给double[::1])会在边界处报错,不会静默重解释,这是内存视图相对裸指针的安全之处。
9.2.6 with nogil:让 Cython 扩展真正并行
Cython 默认在整个函数执行期间持 GIL。计算密集的循环可以放进 with nogil: 块,在块内释放 GIL,让多个线程真正并行:
def sumsq_mv_nogil(double[::1] arr):
cdef Py_ssize_t i, n = arr.shape[0]
cdef double total = 0.0
with nogil:
for i in range(n):
total += arr[i] * arr[i]
return total
用 4 线程各跑一次 4M 元素数组,对比「持 GIL」与「nogil」两个版本:
sumsq_mv (持 GIL) 4-thread wall = 0.017s
sumsq_mv_nogil (nogil) 4-thread wall = 0.005s
单次调用 single = 0.004s
持 GIL 版 4 线程耗时 0.017s ≈ 单次的 4 倍,说明被串行化;nogil 版 4 线程 0.005s ≈ 单次 0.004s,说明真正并行。注意 nogil 块内不能碰任何 Python 对象(列表、字典、print 都不行),只能操作 C 类型和内存视图——这是释放 GIL 的代价,也是它的前提。上面 cython -a 的注释里,with nogil: 那行密度 14、而块内循环密度 0,正说明「放锁动作本身有开销,块内必须足够干净才划算」。
9.2.7 什么时候不该用 Cython
Cython 不是银弹,下面几种情况绕开它更省事:
- 纯数值计算:
arr @ arr(0.34 ms)远快于手写内存视图循环(1.86 ms)。能用 NumPy 向量化表达的计算,别用 Cython 手写循环——让 BLAS 干。 - 字符串/IO 密集:瓶颈在系统调用或网络,编译掉 Python 解释器开销也快不了多少,反而增加构建复杂度。
- 只在 Python 里调用一次:单次调用的
def包装样板(cython -a里函数头那行密度 40+)就够抵掉循环省下的时间。 - 团队不接受编译产物:引入
.pyx意味着构建链多了 C 编译器、多了「源码与二进制不同步」的风险;纯 Python + PyPy / 向量化往往是更轻的选择。
反过来说,Cython 最合适的场景是**「一段已有 Python 代码里、循环体占了大头、且逻辑复杂到 NumPy 表达不了」**——先 cython -a 看热点行,给那几行加 cdef,收益最直接。
小结
- Cython 是源码到源码编译器:
.pyx→.c→.so;34 行.pyx生成 3 万多行 C,膨胀的是引用计数与异常样板。 cython -a用颜色标出每行的 C-API 调用密度:无类型循环体每迭代 31 次,全cdef类型是 0 次——这就是 44x 的来源。- 加速来自
cdef类型声明,不是来自「用了 Cython」:无类型def只快 1.1x,全cdef类型实测 44x(-O0)。 clang -O2会把纯算术累加循环闭式化,耗时与n无关;做基准必须-O0/-O2对照,否则加速比是假的。- 与 NumPy 配合要用连续内存视图
double[::1](零拷贝):Python 层逐元素遍历 149 ms,内存视图 1.86 ms,arr @ arr0.34 ms。 with nogil:可释放 GIL:实测 4 线程从 0.017s 降到 0.005s;代价是块内不能操作 Python 对象。
「调用 C」和「编译 Python」都讲完了。下一节进入第三条路线:用 Rust 写扩展模块——它既能编译、又有内存安全保证,代价是全新的工具链。本机没有 Rust,下一节会明确区分哪些是实测、哪些是伪代码。
阅读导航:上一节:ctypes 与 cffi 调用 C · 下一节:PyO3 与 Rust 扩展 。
继续阅读
探索更多技术文章
浏览归档,发现更多关于系统设计、工具链和工程实践的内容。