一段 Python 代码慢,原因可能是逐元素循环,也可能是磁盘读取、网络等待或反复复制数组。优化前要先测量。下面分别讨论 NumPy 向量化、Numba 和并发适用的情况,并说明它们为什么不能互相替代。
前置阅读:建议先读 Linux + Python 环境基础、Pandas 数据分析。
第零定律:先测量,不优化
优化界的两大罪:没测就优化(优化了不占时间的代码)和猜哪里慢(人类的性能直觉几乎总是错的)。
import cProfile, pstats
cProfile.run("main()", "profile.out")
p = pstats.Stats("profile.out")
p.sort_stats("cumulative").print_stats(15) # 看累计耗时 top15
更精细的按行分析:pip install line_profiler,装饰器 @profile 标函数,kernprof -l -v script.py 跑——哪一行吃了 80% 时间一目了然。我的经验:三次优化里两次,真正的瓶颈都在我没想到的地方(比如一个隐藏在循环里的 df.append)。
第一板斧:向量化——为什么快三个数量级
import numpy as np, time
a = np.random.rand(10_000_000)
b = np.random.rand(10_000_000)
# Python 循环版
t = time.time()
c = [a[i] * b[i] + 2 for i in range(len(a))]
print(time.time() - t) # ~1.2 秒
# NumPy 向量版
t = time.time()
c = a * b + 2
print(time.time() - t) # ~0.02 秒 —— 60 倍差距
差距的根源在内存布局:NumPy 数组是连续的同类型内存块,循环在 C 层跑、有 SIMD 指令加持、CPU 缓存友好;Python 列表是一堆指针,每个元素是带类型信息的完整对象,每次 a[i] 都要解引用+类型检查。Python 循环的每个迭代都在为「灵活性」纳税。
广播:向量化的核心心法
不同形状数组运算时,NumPy 沿大小为 1 的维度「拉伸」——不复制数据就能对齐:
# 数据标准化:1000×50 矩阵,减每列均值除每列标准差
X_centered = (X - X.mean(axis=0)) / X.std(axis=0) # 无循环无拷贝
# 两两距离:1000 个点两两欧氏距离,一行搞定
# ||a-b||² = |a|² + |b|² - 2a·b
D2 = (A**2).sum(1)[:, None] + (B**2).sum(1)[None, :] - 2 * A @ B.T
广播规则:从右往左对维度,相等或其一为 1 则兼容。[:, None](即 np.newaxis)升维是核心手法。
视图 vs 拷贝:省内存就是省时间
切片 a[::2]、a[1:5] 是视图(共享内存,O(1));花式索引 a[[0,3,7]]、a[a>0] 是拷贝。大数组上意外触发拷贝,内存和时间双杀:
b = a.reshape(4, -1) # 通常视图(连续内存时)
c = a.T # 视图
d = a.flatten() # 拷贝!想要视图用 ravel()
判断:b.base is a 为 True 则 b 是 a 的视图。改视图会改原数组——这是特性也是 bug 源,共享前想清楚。
Pandas 的同款纪律
df.iterrows() 是性能死刑(逐行返回 Series 对象)。阶梯:df.apply(还是 Python 层循环)→ df[col].str.* / np.where(向量化)→ df.eval() / query()(表达式引擎)→ df.values 转 NumPy 纯向量算。数据量上百万后,逐行操作的正确写法永远是向量化重写。
第二板斧:Numba——给循环装上 JIT
有些逻辑写不成向量(递归、状态依赖、复杂分支)。Numba 的 @njit 装饰器把函数编译成机器码:
from numba import njit
@njit
def rolling_signal(prices, window):
out = np.empty(len(prices))
for i in range(window, len(prices)):
out[i] = prices[i-window:i].mean() * 0.9 + prices[i] * 0.1
return out
# 纯 Python 循环的速度 × 100+,代码一行没改
约束:只支持 Python/NumPy 子集(无 Pandas、无字符串复杂操作);首次调用有编译开销(cache=True 缓存)。不适合就考虑 Cython 或干脆换写法。
第三板斧:并发——先分清 IO 密集还是 CPU 密集
GIL(全局解释器锁)的存在让选择变得简单:
| 任务类型 | 例子 | 方案 |
|---|---|---|
| IO 密集 | 爬虫、API 调用、读文件 | asyncio / 多线程(IO 等待时释放 GIL) |
| CPU 密集 | 数值计算、图像处理 | 多进程(线程白搭,GIL 只让一个线程跑 Python) |
# CPU 密集:多进程 Pool
from multiprocessing import Pool
with Pool(8) as pool: # 8 核开 8 进程
results = pool.map(heavy_compute, chunks)
# IO 密集:asyncio 协程,单线程并发几百个请求
import asyncio, aiohttp
async def fetch_all(urls):
async with aiohttp.ClientSession() as s:
tasks = [s.get(u) for u in urls]
return await asyncio.gather(*tasks)
三个坑:多进程数据要序列化传给子进程,大数据先共享内存(shared_memory)或让子进程自己读盘;Pool.map 的分块(chunksize)影响负载均衡;多线程对 NumPy 有效——NumPy 底层计算释放 GIL,所以「多线程 + NumPy 大矩阵运算」其实能并行,这是 GIL 话题里最常见的反直觉点。
案例:一段 ETL 从 40 分钟到 40 秒
真实项目的优化路径,每一步都可复用:
- profile 发现 70% 时间在
df.apply(parse_log)——逐行正则解析 - 改成
df["log"].str.extract(pattern)向量化正则:40min → 6min - 剩余的日期解析
pd.to_datetime(df["ts"], format=...)指定格式(不指定要逐行猜格式):6min → 50s - 多进程处理 32 个分片文件:50s → 8s
没换语言、没换框架,全是「理解工具的正确用法」。
踩坑排查
| 现象 | 原因 | 解法 |
|---|---|---|
| 向量化后内存爆了 | 广播造出 N×N 中间矩阵 | 分块计算(chunk 循环 + 内部向量化) |
| reshape 报错「无法推断维度」 | -1 只能出现一次 | 检查形状算术,显式算好其余维度 |
| 改了切片原数组跟着变 | 视图共享内存 | 明确 .copy() 或接受共享语义 |
| Pool.map 子进程拿不到大对象 | 参数序列化成本高/失败 | 全局变量 + initializer,或子进程自己读 |
| Numba 函数报 TypingError | 用了不支持的 Python 特性 | 改写为 NumPy 子集;对象交给外层 |
| 多线程没加速 | CPU 密集撞上 GIL | 换多进程;或让计算进 NumPy/C 层 |
练习
- 实现两两距离矩阵的三种写法(双层循环 / 单循环 / 纯广播),在 n=2000 上计时对比。
- 用 line_profiler 分析一段你自己的数据脚本,找出耗时 top1 行并向量化重写。
- 对同一 CPU 密集任务分别跑「单进程 / 8 线程 / 8 进程」,记录耗时——亲手验证 GIL 的影响。
- 用
a.base实验判断以下哪些是视图:切片、reshape、T、flatten、ravel、花式索引。
面试常问
Q:NumPy 为什么比 Python 循环快? 四层:① 连续同构内存 vs 指针数组(缓存命中);② 循环在编译过的 C 层 vs 解释执行;③ SIMD 单指令多数据;④ 无逐元素类型检查和引用计数开销。同一份数据,Python 循环每个元素都要走一遍解释器的对象协议,NumPy 只做一次分派。
Q:GIL 是什么?为什么多线程不能加速 CPU 密集? GIL 是 CPython 解释器的全局锁,同一时刻只允许一个线程执行 Python 字节码(保护引用计数的线程安全)。CPU 密集任务的瓶颈在字节码执行,多线程被锁串行化,还多了切换开销。IO 密集时线程阻塞在系统调用上会释放 GIL,所以有效。绕法:多进程、C 扩展(NumPy 计算时释放 GIL)、或换无 GIL 实现( nogil 版 Python 3.13+ 实验性)。
Q:广播的规则和常见坑?
规则:右对齐维度,每维相等或其一为 1 则可广播,为 1 的维被拉伸。坑:两个都不想拉伸但维度意外兼容((3,1) 和 (1,4) 得到 (3,4),可能不是你想要的);广播大中间矩阵爆内存;标量优先级静默正确但可读性差——关键运算后 assert 一下形状是好习惯。
Q:多进程间怎么共享大数据?
① multiprocessing.shared_memory + np.frombuffer(零拷贝);② fork 启动方式下子进程继承父进程内存(Linux 默认,写时复制,只读数据天然共享);③ 内存映射文件 np.memmap。最忌把大数组当参数传给 Pool.map——每个任务都 pickle 一份。
Q:什么时候该放弃 Python 层优化直接上 Cython/Rust? 顺序:向量化 → Numba → 算法层优化(换个 O(n log n) 的算法常比加速 O(n²) 收益大)→ 还不够且热点集中(<5% 代码占 95% 时间)才上 Cython/扩展。另外考虑换运行时:数据处理 Polars(Rust 实现,常比 Pandas 快 10 倍)、DuckDB(SQL 一把梭)。工具选型也是性能优化。
相关阅读
- Pandas 数据分析与可视化——数据处理的主战场
- Linux + Python 环境基础——环境是性能的地基
- 数据采集与爬虫实战——asyncio 的主场
- 分布式训练入门——并发的终极形态
- SQL 与数据库实战——把计算下推到数据库层
性能工程的哲学浓缩成两句:没有 profile 就没有优化;最好的优化是别做那些工作。向量化让你「别逐元素做」,缓存让你「别重复做」,并发让你「别排队做」——先想清楚哪些工作根本不该存在。
💬 评论
这套方案在哪一步卡住了?也欢迎补充你的实现方式或踩坑经验。