Python 与 NumPy 性能工程:向量化、并发与 Profiling——慢的代码没有借口

· 约 6 分钟 ·
本文目录(12 节)

一段 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 秒

真实项目的优化路径,每一步都可复用:

  1. profile 发现 70% 时间在 df.apply(parse_log)——逐行正则解析
  2. 改成 df["log"].str.extract(pattern) 向量化正则:40min → 6min
  3. 剩余的日期解析 pd.to_datetime(df["ts"], format=...) 指定格式(不指定要逐行猜格式):6min → 50s
  4. 多进程处理 32 个分片文件:50s → 8s

没换语言、没换框架,全是「理解工具的正确用法」。

踩坑排查

现象原因解法
向量化后内存爆了广播造出 N×N 中间矩阵分块计算(chunk 循环 + 内部向量化)
reshape 报错「无法推断维度」-1 只能出现一次检查形状算术,显式算好其余维度
改了切片原数组跟着变视图共享内存明确 .copy() 或接受共享语义
Pool.map 子进程拿不到大对象参数序列化成本高/失败全局变量 + initializer,或子进程自己读
Numba 函数报 TypingError用了不支持的 Python 特性改写为 NumPy 子集;对象交给外层
多线程没加速CPU 密集撞上 GIL换多进程;或让计算进 NumPy/C 层

练习

  1. 实现两两距离矩阵的三种写法(双层循环 / 单循环 / 纯广播),在 n=2000 上计时对比。
  2. 用 line_profiler 分析一段你自己的数据脚本,找出耗时 top1 行并向量化重写。
  3. 对同一 CPU 密集任务分别跑「单进程 / 8 线程 / 8 进程」,记录耗时——亲手验证 GIL 的影响。
  4. 用 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 一把梭)。工具选型也是性能优化。

相关阅读

性能工程的哲学浓缩成两句:没有 profile 就没有优化;最好的优化是别做那些工作。向量化让你「别逐元素做」,缓存让你「别重复做」,并发让你「别排队做」——先想清楚哪些工作根本不该存在。

💬 评论

这套方案在哪一步卡住了?也欢迎补充你的实现方式或踩坑经验。

正在连接评论服务,首次访问可能需要几秒…