3242 字
16 分钟
Python 性能优化实战指南:cProfile 瓶颈定位 + __slots__ 内存优化 + NumPy 向量化 + functools.cache
Python 以开发效率著称,但在计算密集型任务中,不经过优化的代码往往比 C/Java 慢几十倍。好消息是:通过一套系统的优化方法,大多数 Python 代码可以在不改变语言的前提下提速 10~100 倍。
优化第一原则:先测量,再优化。 凭直觉猜测瓶颈是性能优化最常见的陷阱。
本文覆盖 Python 性能优化的完整工具链:
cProfile+line_profiler:精准定位瓶颈__slots__:减少内存占用- NumPy 向量化:替代 for 循环做数值计算
functools.cache/lru_cache:函数级缓存- 字符串与列表优化:日常高频操作的最优写法
- 生成器(Generator):惰性求值节省内存
一、性能分析工具:先找到瓶颈
1.1 cProfile:函数级性能剖析
cProfile 是 Python 内置的性能分析器,无需安装,可以统计每个函数的调用次数和累计耗时。
基础用法(命令行):
# -s cumulative:按累计时间降序排列(最慢的函数排最前面)python -m cProfile -s cumulative your_script.py
# 保存结果到文件,再用 pstats 分析python -m cProfile -o profile.stats your_script.pypython -m pstats profile.stats# 在 pstats 交互模式中:# sort cumulative → 按累计时间排序# stats 10 → 显示前 10 条代码内嵌 cProfile:
import cProfileimport pstatsimport io
def slow_function(): result = [] for i in range(100_000): result.append(i ** 2) return result
# 方法一:直接 runcProfile.run('slow_function()', sort='cumulative')
# 方法二:更精细的控制def profile_this(func, *args, **kwargs): pr = cProfile.Profile() pr.enable() result = func(*args, **kwargs) pr.disable()
stream = io.StringIO() ps = pstats.Stats(pr, stream=stream).sort_stats('cumulative') ps.print_stats(15) # 只显示最慢的 15 个函数 print(stream.getvalue()) return result
profile_this(slow_function)cProfile 输出解读:
200003 function calls in 0.087 seconds
Ordered by: cumulative time
ncalls tottime percall cumtime percall filename:lineno(function) 1 0.000 0.000 0.087 0.087 <string>:1(<module>) 1 0.041 0.041 0.087 0.087 script.py:4(slow_function) 100000 0.046 0.000 0.046 0.000 {method 'append' of 'list'}| 列名 | 含义 |
|---|---|
ncalls | 被调用次数 |
tottime | 函数自身耗时(不含子调用) |
cumtime | 累计耗时(含子调用),重点关注 |
percall | 平均每次调用耗时 |
排查规则:cumtime 最大的函数就是瓶颈入口,重点优化。
1.2 line_profiler:行级精准分析
cProfile 只到函数级别,找到慢函数后,用 line_profiler 逐行分析:
pip install line_profiler# 用 @profile 装饰器标记要分析的函数(line_profiler 注入)@profiledef process_data(data): result = [] for item in data: # ← 想知道这行占多少时间? cleaned = item.strip() if cleaned: result.append(cleaned.upper()) return result
data = [f" item_{i} " for i in range(100_000)]process_data(data)# 运行并查看逐行耗时kernprof -l -v your_script.py输出示例:
Line # Hits Time Per Hit % Time Line Contents================================================ 5 100000 15234.0 0.2 18.3 for item in data: 6 100000 22187.0 0.2 26.6 cleaned = item.strip() 7 100000 9823.0 0.1 11.8 if cleaned: 8 100000 36247.0 0.4 43.4 result.append(cleaned.upper())→ 发现 .upper() + append 最耗时,可以用列表推导式替代。
1.3 timeit:快速对比两种写法
import timeit
# 对比两种字符串连接方式code_a = """result = ""for i in range(1000): result += str(i)"""
code_b = """result = "".join(str(i) for i in range(1000))"""
time_a = timeit.timeit(code_a, number=1000)time_b = timeit.timeit(code_b, number=1000)
print(f"字符串 += :{time_a:.4f}s")print(f"join() :{time_b:.4f}s")print(f"join() 快了 {time_a / time_b:.1f}x")典型输出:
字符串 += :0.2847sjoin() :0.0521sjoin() 快了 5.5x二、__slots__:减少内存占用
2.1 为什么普通类对象这么占内存?
每个普通 Python 对象实例都维护一个 __dict__ 字典存储属性,字典本身有相当大的开销:
import sys
class Point: def __init__(self, x, y, z): self.x = x self.y = y self.z = z
p = Point(1.0, 2.0, 3.0)print(sys.getsizeof(p)) # 约 48 字节(对象本身)print(sys.getsizeof(p.__dict__)) # 约 232 字节(属性字典!)# 合计:约 280 字节/实例2.2 使用 __slots__ 消除 __dict__
class PointSlots: __slots__ = ('x', 'y', 'z') # 声明允许的属性名
def __init__(self, x, y, z): self.x = x self.y = y self.z = z
ps = PointSlots(1.0, 2.0, 3.0)print(sys.getsizeof(ps)) # 约 72 字节# 无 __dict__!节省约 75%内存对比实测(100 万个实例):
import tracemalloc
def benchmark_memory(cls, n=1_000_000): tracemalloc.start() objects = [cls(i * 1.0, i * 2.0, i * 3.0) for i in range(n)] current, peak = tracemalloc.get_traced_memory() tracemalloc.stop() return peak / 1024 / 1024 # 转换为 MB
print(f"普通 class:{benchmark_memory(Point):.1f} MB")print(f"__slots__ :{benchmark_memory(PointSlots):.1f} MB")普通 class:258.4 MB__slots__ :76.3 MB # 节省 70%+2.3 __slots__ 的限制与注意事项
# ❌ 不能动态添加属性ps = PointSlots(1, 2, 3)ps.w = 4 # → AttributeError: 'PointSlots' object has no attribute 'w'
# ❌ 继承时需要子类也声明 __slots__,否则子类会恢复 __dict__class ColorPoint(PointSlots): __slots__ = ('color',) # 继承类也要声明! def __init__(self, x, y, z, color): super().__init__(x, y, z) self.color = color
# ✅ 适用场景:大量创建的小数据对象(记录、坐标、事件等)# ❌ 不适合:需要动态属性、pickle 序列化复杂场景、多重继承dataclass + __slots__(Python 3.10+):
from dataclasses import dataclass
@dataclass(slots=True) # Python 3.10+ 一行搞定class Point3D: x: float y: float z: float三、NumPy 向量化:替代 for 循环
3.1 为什么向量化快?
Python for 循环: 每次迭代 → 解释器调度字节码 → 类型检查 → 装箱/拆箱 → 内存分配 100 万次迭代 = 100 万次以上的解释器操作
NumPy 向量化: 一次 C 函数调用 → SIMD 指令批量处理 → 连续内存读写 无 Python 解释器参与,数据在 C 层批量处理3.2 基础向量化替换
import numpy as npimport time
N = 1_000_000
# ── 场景 1:计算平方和 ─────────────────────────────────────────def pure_python_sum_squares(n): return sum(i ** 2 for i in range(n))
def numpy_sum_squares(n): arr = np.arange(n, dtype=np.float64) return np.sum(arr ** 2)
# 性能对比t0 = time.perf_counter()result_py = pure_python_sum_squares(N)t1 = time.perf_counter()result_np = numpy_sum_squares(N)t2 = time.perf_counter()
print(f"纯 Python:{t1 - t0:.4f}s")print(f"NumPy :{t2 - t1:.4f}s")print(f"加速比 :{(t1 - t0) / (t2 - t1):.1f}x")# 典型输出:NumPy 快约 50-80x3.3 常见向量化模式
import numpy as np
# ── 条件筛选(替代 if-for 组合)──────────────────────────────data = np.random.randn(1_000_000)
# ❌ 慢:Python for + ifresult = [x for x in data if x > 0]
# ✅ 快:布尔索引result = data[data > 0]
# ── 逐元素运算(替代 map/for)────────────────────────────────# ❌ 慢prices = [100.0, 200.0, 150.0, 300.0]discounted = [p * 0.9 for p in prices]
# ✅ 快prices_np = np.array(prices)discounted_np = prices_np * 0.9 # 广播运算,一次完成
# ── 矩阵运算(替代嵌套 for 循环)────────────────────────────# ❌ 极慢:O(n³) 嵌套循环def matrix_mult_python(A, B): n = len(A) C = [[0.0] * n for _ in range(n)] for i in range(n): for j in range(n): for k in range(n): C[i][j] += A[i][k] * B[k][j] return C
# ✅ 极快:NumPy 调用 BLAS 底层A = np.random.rand(500, 500)B = np.random.rand(500, 500)C = A @ B # 或 np.matmul(A, B)# 500x500 矩阵:Python ≈ 60s,NumPy ≈ 0.01s,快约 6000x
# ── 字符串/分类数据:用 np.where 替代 if-else ────────────────scores = np.array([85, 42, 96, 60, 73])grades = np.where(scores >= 60, 'pass', 'fail')
# ── 聚合统计(替代 statistics 模块)──────────────────────────data = np.random.randn(1_000_000)mean = np.mean(data) # 均值std = np.std(data) # 标准差median = np.median(data) # 中位数pct95 = np.percentile(data, 95) # 95 分位数3.4 避免 NumPy 中的常见性能陷阱
import numpy as np
# ── 陷阱1:在循环中访问 NumPy 数组元素(退化为 Python 速度)──arr = np.arange(1_000_000, dtype=np.float64)
# ❌ 错误:逐元素访问,失去向量化优势total = 0for x in arr: # 每次访问都有 Python 对象转换开销 total += x
# ✅ 正确:一次向量化调用total = arr.sum()
# ── 陷阱2:不必要的数组拷贝 ──────────────────────────────────a = np.arange(1_000_000)
# ❌ 切片后修改,某些操作会触发拷贝b = a[::2] # 视图(view),不拷贝b += 1 # 修改 b 同时修改 a(共享内存)
c = a[::2].copy() # 显式拷贝,独立数据c += 1 # 不影响 a
# 检查是否是视图print(b.base is a) # True → 视图print(c.base is a) # False → 独立拷贝
# ── 陷阱3:dtype 选择不当导致精度损失或内存浪费 ──────────────# 如果数值范围在 0-255,用 uint8 而不是 float64images = np.zeros((1000, 224, 224, 3), dtype=np.float64) # 1.2 GBimages = np.zeros((1000, 224, 224, 3), dtype=np.uint8) # 150 MB四、functools.cache / lru_cache:函数级缓存
4.1 基础使用
对于相同输入总返回相同输出(纯函数)的计算密集型函数,缓存是最简单有效的优化手段:
from functools import cache, lru_cacheimport time
# ── 无缓存的递归斐波那契(指数级复杂度)──────────────────────def fib_naive(n): if n <= 1: return n return fib_naive(n - 1) + fib_naive(n - 2)
# ── @cache:无限制缓存(Python 3.9+)─────────────────────────@cachedef fib_cached(n): if n <= 1: return n return fib_cached(n - 1) + fib_cached(n - 2)
# ── @lru_cache:有 maxsize 限制的 LRU 缓存 ──────────────────@lru_cache(maxsize=256) # 最多缓存 256 个不同参数的结果def fib_lru(n): if n <= 1: return n return fib_lru(n - 1) + fib_lru(n - 2)
# 性能对比t0 = time.perf_counter(); fib_naive(35); t1 = time.perf_counter()t2 = time.perf_counter(); fib_cached(35); t3 = time.perf_counter()
print(f"无缓存 fib(35):{t1 - t0:.4f}s")print(f"@cache fib(35):{t3 - t2:.6f}s")# 无缓存:约 3.5s;@cache:约 0.00001s,快 350,000x4.2 cache vs lru_cache 如何选?
| 对比项 | @cache | @lru_cache(maxsize=N) |
|---|---|---|
| Python 版本 | 3.9+ | 3.2+ |
| 缓存大小 | 无限制(慎用,可能内存泄漏) | 有限制,超出 LRU 淘汰 |
| 适用场景 | 输入空间有限的函数(如递归 DP) | 需要控制内存的场景 |
| 性能 | 略快(无 LRU 链表维护开销) | 略慢 |
# @lru_cache 的缓存统计from functools import lru_cache
@lru_cache(maxsize=128)def compute(n): return sum(range(n))
for i in range(200): compute(i % 100) # 循环访问,触发 LRU
info = compute.cache_info()print(f"命中:{info.hits},未命中:{info.misses},当前缓存量:{info.currsize}")# 输出:命中:100,未命中:100,当前缓存量:100
# 手动清除缓存(如数据更新后)compute.cache_clear()4.3 实战场景:缓存数据库查询结果
from functools import lru_cacheimport time
# 模拟慢速数据库查询def db_query(user_id: int) -> dict: time.sleep(0.1) # 模拟 100ms 数据库延迟 return {"id": user_id, "name": f"User{user_id}", "score": user_id * 10}
# ✅ 缓存热点用户查询(maxsize 根据内存预算和用户活跃度调整)@lru_cache(maxsize=1000)def get_user_cached(user_id: int) -> dict: return db_query(user_id)
# 首次查询(冷缓存)t0 = time.perf_counter()user = get_user_cached(42)print(f"首次查询:{time.perf_counter() - t0:.3f}s") # 约 0.100s
# 再次查询(缓存命中)t0 = time.perf_counter()user = get_user_cached(42)print(f"缓存查询:{time.perf_counter() - t0:.6f}s") # 约 0.000001s五、字符串优化
5.1 字符串拼接:用 join() 替代 +=
import timeit
n = 10_000
# ❌ 慢:+= 每次创建新字符串对象def concat_plus(n): s = "" for i in range(n): s += str(i) return s
# ✅ 快:join 一次性拼接def concat_join(n): return "".join(str(i) for i in range(n))
# ✅ 更快:先 map 再 join(避免生成器开销)def concat_join_map(n): return "".join(map(str, range(n)))
t_plus = timeit.timeit(lambda: concat_plus(n), number=100)t_join = timeit.timeit(lambda: concat_join(n), number=100)t_join_map = timeit.timeit(lambda: concat_join_map(n), number=100)
print(f"+= : {t_plus:.4f}s")print(f"join : {t_join:.4f}s ({t_plus/t_join:.1f}x 提速)")print(f"join+map : {t_join_map:.4f}s ({t_plus/t_join_map:.1f}x 提速)")# +=:约 0.45s;join+map:约 0.06s,快约 7x5.2 f-string vs format vs %
name, value = "World", 42
# 性能(由快到慢):result_f = f"Hello {name}, value={value}" # ✅ 最快(Python 3.6+)result_fmt = "Hello {}, value={}".format(name, value) # 中等result_pct = "Hello %s, value=%d" % (name, value) # 最慢(古老写法)
# f-string 比 .format() 快约 35-40%,是推荐写法六、生成器(Generator):惰性求值节省内存
当处理大数据集时,生成器可以避免一次性将所有数据加载进内存:
import sys
# ── 对比:列表 vs 生成器 ──────────────────────────────────────# 列表:一次性创建所有元素,全部驻留内存squares_list = [i ** 2 for i in range(1_000_000)]print(f"列表内存:{sys.getsizeof(squares_list) / 1024 / 1024:.1f} MB")# 约 8 MB
# 生成器:按需生成,内存占用极低squares_gen = (i ** 2 for i in range(1_000_000))print(f"生成器内存:{sys.getsizeof(squares_gen)} 字节")# 约 200 字节!
# 求和结果相同print(sum(squares_list) == sum(squares_gen)) # True
# ── 实战:逐行处理大文件 ──────────────────────────────────────def process_large_file(filepath): """生成器版:逐行读取,内存恒定""" with open(filepath) as f: for line in f: # 文件对象本身就是生成器 stripped = line.strip() if stripped and not stripped.startswith('#'): yield stripped
# 即使文件有 10GB,内存占用也几乎不变for line in process_large_file('huge_log.txt'): process(line) # 每次只有一行在内存中
# ── yield from:委托子生成器 ──────────────────────────────────def flatten(nested): """将嵌套列表展平,惰性返回""" for item in nested: if isinstance(item, list): yield from flatten(item) # 委托给子生成器 else: yield item
nested = [1, [2, [3, 4], 5], [6, 7]]print(list(flatten(nested))) # [1, 2, 3, 4, 5, 6, 7]七、整体优化流程总结
1. 测量(Profile) ↓ cProfile 找到最慢函数 ↓ line_profiler 找到最慢的行
2. 分析瓶颈类型 ├── CPU 密集型数值计算 → NumPy 向量化 ├── 重复计算(纯函数) → @cache / @lru_cache ├── 字符串拼接 → join() / f-string ├── 大量小对象创建 → __slots__ / dataclass(slots=True) └── 大数据集遍历 → 生成器
3. 优化 → 再次测量(验证效果) ↓ timeit 对比优化前后 ↓ 确认加速比符合预期
4. 如果以上还不够快: ├── I/O 密集型 → asyncio 异步(参考并发编程指南) ├── CPU 密集型(多核) → multiprocessing / concurrent.futures └── 极致性能要求 → Cython / Numba JIT 编译各优化手段加速效果速查
| 优化手段 | 典型加速比 | 适用场景 | 改动成本 |
|---|---|---|---|
@cache / @lru_cache | 100x ~ ∞ | 重复调用的纯函数 | ⭐(一行代码) |
| NumPy 向量化 | 10x ~ 100x | 数值计算、数组操作 | ⭐⭐⭐(需重写逻辑) |
join() 替代 += | 3x ~ 10x | 字符串拼接 | ⭐(简单替换) |
__slots__ | 内存节省 50-75% | 大量小对象 | ⭐⭐(类定义修改) |
| 生成器替代列表 | 内存节省 90%+ | 大数据集遍历 | ⭐⭐(逻辑调整) |
| f-string | 1.3x ~ 1.5x | 字符串格式化 | ⭐(简单替换) |
相关文章:
- Python 并发编程完全指南:GIL 原理、threading、multiprocessing
- Python asyncio 异步编程完全指南
- Python 装饰器进阶完全指南:带参数的装饰器 / 类装饰器 / functools
- Python 类型提示完全指南:Type Hints 实战
本文所有代码均在 Python 3.11 环境下测试,性能数据为实测结果,因硬件环境不同可能有差异。
Python 性能优化实战指南:cProfile 瓶颈定位 + __slots__ 内存优化 + NumPy 向量化 + functools.cache
https://971918.xyz/posts/python-guide/python-performance-optimization/