先测量,再优化
一个跑四个小时的回测,或一个每秒被调用一百万次的定价函数,会让每个人都想提速,而几乎所有人都猜错时间花在哪里。看起来很重的循环可能根本不算什么,看似无害的一行可能就是整个运行时间。性能分析(profiling)就是在改动任何东西之前,测量程序把时间花在哪里。规则很简单:没测量过的行不要优化,改动之后再测一次,看看是否真的有帮助。
# The simplest measurement: read a clock before and after
import time
def slow_sum(n):
total = 0
for i in range(n):
total += i * i
return total
start = time.perf_counter()
result = slow_sum(200_000)
elapsed = time.perf_counter() - start
print(result) # 2666646666700000
print(elapsed > 0) # True a real duration; the exact value changes every run噪声、重复与最小值
单次计时不可靠:操作系统、其他程序、CPU 缓存以及 Python 自身的启动都会带来噪声,而且它们只会增加时间。所以要把函数计时多次并取最小值,也就是受干扰最小的那次运行;timeit 做的正是这件事。使用 time.perf_counter()(高分辨率单调时钟),不要用 time.time()。用真实规模的输入来测量,因为十行时很快的函数在一千万行时可能很慢;并先预热,避免一次性开销(导入、缓存)被算到被测代码头上。
# Repeat and take the minimum: the least disturbed run
import time
def work():
return sum(i * i for i in range(50_000))
def best_of(func, repeats):
best = None
for _ in range(repeats):
start = time.perf_counter()
func()
elapsed = time.perf_counter() - start
if best is None or elapsed < best:
best = elapsed
return best
runs = [best_of(work, 1) for _ in range(5)]
print(best_of(work, 5) <= max(runs)) # True the minimum is never above the worst single run
print(min(runs) > 0) # True时间去了哪里:分析器与阿姆达尔定律
计时器告诉你多久,分析器告诉你在哪里。标准库的 cProfile 记录每次函数调用,并为每个函数报告调用次数、函数自身耗时和包含其调用对象的累计耗时;python -m cProfile -s cumtime script.py 会把最耗时的排在前面。每个函数的占比就是它的耗时除以总耗时。修复一个函数的收益受阿姆达尔定律限制:把运行时间中占比 f 的部分加速 s 倍,总体加速为 1 / ((1 - f) + f / s)。把 60% 的运行时间加速十倍,总体只快 2.2 倍,即便一半时间被加速到无穷快,收益上限也只有 2 倍。先攻最大的热点。
# Shares of the total and the payoff of fixing the biggest one (the timings are made-up numbers)
timings = {"load_prices": 6.0, "price_options": 3.0, "write_report": 1.0}
total = sum(timings.values())
for name, seconds in sorted(timings.items(), key=lambda item: -item[1]):
print(f"{name:14s} {seconds / total:5.1%}") # load_prices 60.0%, price_options 30.0%, write_report 10.0%
def amdahl_gain(fraction, speedup):
return 1 / ((1 - fraction) + fraction / speedup)
print(round(amdahl_gain(0.6, 10), 2)) # 2.17 ten times faster on 60% of the run
print(round(amdahl_gain(0.1, 1000), 2)) # 1.11 a huge win on the smallest part barely helps# cProfile records every call; the function that sleeps shows up as the hotspot
import cProfile
import time
def slow_part():
time.sleep(0.02)
def fast_part():
return sum(range(10))
def run_all():
slow_part()
for _ in range(100):
fast_part()
profiler = cProfile.Profile()
profiler.runcall(run_all)
entries = {str(entry.code): entry for entry in profiler.getstats()}
slow = next(entry for name, entry in entries.items() if "slow_part" in name)
fast = next(entry for name, entry in entries.items() if "fast_part" in name)
print(fast.callcount, slow.callcount) # 100 1 calls are counted exactly
print(slow.totaltime > fast.totaltime) # True one slow call outweighs 100 fast ones在有意义的条件下做性能分析分析器会拖慢它所监视的程序,并扭曲非常小的函数,所以要相信大局(哪个函数占主导)而不是精确的秒数,并用简单的计时器确认改进。用真实规模的数据来分析,而不是玩具数据。每次修改后都要检查结果是否仍然正确,因为又快又错的答案代价最大。并且在代码足够快时就停下:花在已经不重要的热点上的时间,就是没花在策略上的时间。
- 改动前先测量;用
perf_counter计时,重复并取最小值,使用真实规模的输入。 - 分析器(
cProfile)显示时间去向:每个函数的调用次数、自身耗时和累计耗时;各自占比等于其耗时除以总耗时。 - 阿姆达尔定律:把占比 f 的部分加速 s 倍,总体为 1 / ((1 - f) + f / s);先修最大的热点,每次改动后重新测量。
下一节:优化热点循环,让分析器指出的代码变快的各种改动。
实验:评分内容
- 用
time_call给一次调用计时25 分 - 用
best_of取多次运行中的最佳值25 分 - 用
hotspots找出时间花在哪里25 分 - 用
amdahl_gain预测收益25 分