"Python 慢"是句被说烂的话,但真要优化时,多数人第一反应是乱试——加缓存、换数据结构、上多进程,全凭感觉。要系统地做性能优化,得先回答一个更根本的问题:Python 究竟慢在哪?把这个搞清楚,从纯 Python 调优到 Cython、C 扩展的整条路径才会有的放矢,而不是病急乱投医。

场景:慢的根源是什么

CPython 是一个栈式字节码解释器。你的源码先被编译成字节码,再由一个巨大的求值循环逐条解释执行:

1
2
3
4
5
6
7
8
9
10
import dis

def add(a, b):
return a + b

dis.dis(add)
# LOAD_FAST a
# LOAD_FAST b
# BINARY_OP +
# RETURN_VALUE

看似简单的 a + b,解释器要做:取出两个对象、查它们的类型、找到对应的 __add__ 实现、做装箱的整数运算、再分配一个新对象装结果。每个值都是堆上的 PyObject,没有 C 那种裸 int 在寄存器里加一下就完事。慢的本质是动态类型带来的间接性 + 对象装箱 + 解释循环开销,而不是某个具体函数写得烂。再叠加 GIL,CPU 密集的多线程还无法真正并行。

理解这一点就能推出优化的总方向:减少在解释器里执行的指令数,把热点交给已经编译成机器码的 C 实现

第一层:测量,别猜

优化的铁律是先 profile。凭直觉优化错点是最常见的浪费。

1
2
3
4
5
# 找出哪些函数最耗时(确定性 profiler)
python -m cProfile -s cumtime myscript.py

# 逐行定位热点(需 pip install line_profiler)
kernprof -l -v myscript.py

cProfile 告诉你时间花在哪个函数,line_profiler 精确到行。没有 profile 数据就动手优化,约等于闭眼开枪。 80% 的时间通常集中在不到 20% 的代码里,找到那 20% 再下手。

第二层:纯 Python 内功

不写一行 C,靠用对 CPython 的特性就能拿到可观提升,核心仍是"减少解释器指令数":

  • 用内置函数和 C 实现的库。 sum()map()sorted()str.join() 的循环体在 C 里跑,比 Python 层 for 快得多。"".join(parts) 永远优于循环 s += x(后者是 O(n²) 的字符串重建)。
  • 优先标准库的 C 容器。 collections.deque 的两端操作 O(1),dict/set 的哈希查找 O(1),bisectheapq 都是 C 实现。选对数据结构往往比微优化代码强一个数量级。
  • 局部变量比全局/属性快。 属性访问 obj.method 每次都要走描述符查找,循环里把它绑成局部变量 m = obj.method 能省下重复查找。
  • __slots__ 省内存又提速。 给类加 __slots__ 去掉每实例的 __dict__,属性访问从字典查找变成固定偏移,大量实例时内存和速度都受益。

这一层的关键认知:把循环往下推。能用 sum(gen) 就别手写累加循环,让计数发生在 C 层而非字节码层。

第三层:NumPy 向量化

数值计算场景,NumPy 是分水岭。它把数据存成连续的 C 数组(而非 PyObject 指针数组),运算在编译好的 C/SIMD 代码里批量完成,彻底绕开解释循环和装箱:

1
2
3
import numpy as np
a = np.arange(1_000_000)
b = a * 2 + 1 # 整个数组一条 C 级操作完成,无 Python 循环

同样的逻辑用纯 Python for 写会慢几十到上百倍。差距不在算法,而在"百万次解释器迭代 + 百万次对象分配" vs “一次 C 调用处理整块连续内存”。能向量化就别循环,是数值优化第一准则。

第四层:Cython —— 给热点加上静态类型

当算法本身无法向量化(带复杂分支的循环、自定义数据结构遍历),又确实是瓶颈时,Cython 登场。它是 Python 的超集,允许给变量加 C 类型,然后编译成 C 扩展。关键就在那几个类型声明:

1
2
3
4
5
6
7
# fib.pyx
def fib(int n): # 参数声明为 C int
cdef int i # 循环变量用 C int,不再是 PyObject
cdef long a = 0, b = 1
for i in range(n):
a, b = b, a + b
return a

加了 cdef int 后,循环里的加法直接是 C 整数运算——没有对象分配、没有类型查找、没有解释循环。同一段斐波那契,纯 Python 跑在解释器里,Cython 版本跑在编译后的机器码里,热循环往往能快一两个数量级。编译:

1
cythonize -i fib.pyx       # 生成 .c 并编译成 .so,import 即用

诀窍是只给热点的少数变量加类型。给非热点加类型收益微乎其微,反而牺牲灵活性。先 profile 找到那个吃掉大半时间的内层循环,只优化它。

第五层:原生 C 扩展与释放 GIL

最极致的场景——需要绕过 GIL 做真正的多核并行,或调用现成 C 库——可以写 C 扩展,或在 Cython 里用 nogil

1
2
3
4
5
6
7
# 在不碰 Python 对象的纯数值段释放 GIL,让多线程真正并行
cdef double heavy(double x) nogil:
cdef double s = 0
cdef int i
for i in range(1000000):
s += x * i
return s

nogil 块里不能碰任何 Python 对象(它们的引用计数需要 GIL 保护),只能玩纯 C 数据。这样配合 Cython 的 prange 就能多线程吃满多核。这是 NumPy、scipy 这些库底层并行的常用手法。

工程权衡与边界

  • 复杂度成本。 每往下一层,调试、构建、跨平台分发都变难。Cython/C 扩展要管编译工具链、wheel 打包、ABI 兼容。除非 profile 证明值得,否则停在纯 Python + NumPy 层。
  • 多进程 vs C 扩展。 CPU 密集且不便改成 C 时,multiprocessing 用多进程绕开 GIL 是更简单的选择,代价是进程间数据要序列化、内存翻倍。I/O 密集则用 asyncio/线程,根本不需要碰 C。
  • PyPy 是另一条路。 对纯 Python 代码,换 PyPy(JIT 编译)常能不改一行就提速数倍,但它对 C 扩展兼容性较弱,与 NumPy 生态有摩擦。选型要看你的依赖。

常见误区

  • 过早优化、凭感觉优化。 没 profile 就重写代码,经常优化了非瓶颈,整体毫无变化。
  • 以为多线程能加速 CPU 密集任务。 GIL 在,纯 Python 的 CPU 密集多线程不会更快,反而多了切换开销。要并行得多进程或 C 扩展 nogil
  • 微优化掩盖算法问题。 把 O(n²) 的代码用 Cython 提速 5 倍,不如换成 O(n log n) 算法。先看复杂度,再谈常数因子。

小结

Python 性能优化是一条有明确层次的路径:理解"慢在动态类型 + 装箱 + 解释循环"这个根源,然后先测量,再从纯 Python 内功 → NumPy 向量化 → Cython 静态类型 → C 扩展释放 GIL 逐层下探。每一层都是在"减少解释器执行的指令、把热点交给机器码"这一条主线上推进。记住两句话:没有 profile 不优化,能停在上层就别下沉——越往下越快,但也越重。