还能这么跑?不用改代码,性能飙升50倍
有段 脚本,平时跑一批对账文件要二十多分钟。
代码没改,SQL 没改,机器也没换,只把启动命令从:
python check_bill.py换成:
pypy3 check_bill.py第二次跑完,我第一反应不是高兴,是不太信。
这般性能提升实在是离谱至极, 离谱得我通常会率先怀疑三件事情: 其一, 是否数据未曾读取完备;其二, 是不是逻辑有所提前;其三, 会不会是缓存把结果给糊弄过去了。
所以我先加了几行很土的日志:
import time import os started = time.perf_counter rows = 0 bad_rows = 0 with open("bill_202406.log", "r", encoding="utf-8") as f: for line in f: rows += 1 parts = line.rstrip("\n").split("|") if len(parts) != 6: bad_rows += 1 continue user_id, order_no, amount, channel, status, sign = parts # 模拟线上老脚本里的校验逻辑,纯 Python 字符串和整数计算 seed = 17 for ch in order_no + channel + amount: seed = (seed * 131 + ord(ch)) & 0xFFFFFFFF if sign != hex(seed)[2:]: bad_rows += 1 cost = time.perf_counter - started print({ "pid": os.getpid, "rows": rows, "bad_rows": bad_rows, "cost": round(cost, 3), })日志能对上:
{'pid': 31872, 'rows': 3000000, 'bad_rows': 1847, 'cost': 1268.442}换 PyPy 后:
{'pid': 31940, 'rows': 3000000, 'bad_rows': 1847, 'cost': 24.931}这地方就有意思了。
代码未曾变动, 结果照样相同, 所耗费的时间, 从原本二十多分钟, 大幅下降到二十五秒上下, 大约降低了五十倍。
这并非是, 忽然间就开窍了, 也不是说 PyPy 对于全部代码而言都这般厉害。它所适用的是一种极为特定的场景, 即存在大量纯粹的循环, 有关字符串的处理, 还有整数的计算, 逻辑会反复地执行, 并且代码路径比较稳定。
我以前见过不少 慢脚本,慢得很冤。
比如这种:
defpick_risk_orders(lines): risky = for line in lines: cols = line.strip.split(",") if len(cols) < 5: continue order_id = cols[0] user_level = cols[2] amount = int(cols[3]) city = cols[4] score = 0 if amount > 5000: score += 40 if user_level in ("new", "guest"): score += 20 for c in city: score += ord(c) % 7 if score >= 55: risky.append(order_id) return risky这种代码在 里跑,慢不奇怪。
它的每一行, 都在进行对于执行的逐个解析, 每次进入循环, 都需要去查找变量, 调用函数, 拆解字符串, 转换为整数, 一旦业务数量越来越多, 数据规模越来越大, CPU 表面上呈现出忙碌的状态, 可实际上, 大量的时间均耗费在了解释器自身所产生的开销之上。
PyPy 不一样。
它具备 JIT, 代码运行一会儿之后, 它察觉到某一 段循环格外热, 就会将这段逻辑编译成更趋近于机器能直接去执行的事物。热循环越显著, 收益也就越大。
我这类脚本一般不先上来就改算法。
先看三个东西。
第一,慢在哪里。
python -m cProfile -s cumtime check_bill.py | head -30如果前面全是业务函数,比如:
ncalls tottime cumtime function 3000000 411.23 913.54 parse_line 3000000 302.18 302.18 calc_sign 3000000 166.91 166.91 normalize_channel这种就值得拿 PyPy 试一下。
第二,依赖干不干净。
PyPy 对于单纯的情况是很友善的, 然而要是你的项目当中存在大量的 C 扩展, 就像某些科学计算方面的库、图像相关的库、加密类的库, 那收益就未必如此了。并非是不能够运行, 而是需要先去进行验证。
我一般先这么扫一眼:
python - <<'PY' import pkg_resources suspects = for dist in pkg_resources.working_set: name = dist.project_name.lower if name in {"numpy", "pandas", "cryptography", "pillow", "lxml"}: suspects.append(dist.project_name) print("native-like packages:", suspects) PY有这些包,不代表 PyPy 没戏,只是别拍脑袋上线。
第三,脚本是不是跑得够久。
PyPy存在提前预热所需的成本, 对于一个在0.3秒就结束运行的小脚本而言, 倘若换成它, 或许反而会变得更慢, 因为在JIT还没能够来得及开展工作的时候, 程序就已经完成运行并结束了。
所以我喜欢用这种方式做对比,别只跑一次:
import subprocess import time cmds = [ ("cpython", ["python", "check_bill.py"]), ("pypy", ["pypy3", "check_bill.py"]), ] for name, cmd in cmds: costs = for i in range(3): t1 = time.perf_counter subprocess.run(cmd, check=True) costs.append(round(time.perf_counter - t1, 3)) print(name, costs)看第二次、第三次更有意义。
这里有个坑,我得单独说一下。
可别将“换 PyPy”视作性能优化的那种能解决一切问题的万能药。接口运行速度迟缓, 数据库运行速度迟缓, 网络传输速度迟缓, 文件 IO 操作进行得缓慢, 它可解决不了多少这类状况。
比如这种代码:
defsync_user(conn, api): users = conn.query("select id, mobile from user_pending limit 10000") for user in users: resp = api.post("/user/check", json={"mobile": user["mobile"]}) if resp["ok"]: conn.execute( "update user_pending set checked=1 where id=%s", (user["id"],) )这个慢,多半不是 慢。
第一眼, 我会先查看接口耗时, 再看数据库执行计划, 接着看连接池, 然后轮到批量提交, 不会首先去更换解释器。你将它丢给PyPy, 也就是把循环那微乎其微的开销节省掉, 然而真正占据大头的部分还在外面呢。
但是, 要是属于这种离线清洗的情况, 还有规则匹配, 以及日志扫描, 再加上批量校验, 甚至是协议解析, PyPy极有可能会有惊喜出现。
再贴一个更接近线上小工具的写法。
defscan_nginx_error(path): stat = {} with open(path, "r", encoding="utf-8", errors="ignore") as f: for line in f: if" upstream timed out "notin line: continue api = "-" pos = line.find('"GET ') if pos < 0: pos = line.find('"POST ') if pos >= 0: left = line.find(" ", pos + 1) right = line.find(" ", left + 1) if left > 0and right > left: api = line[left + 1:right].split("?")[0] stat[api] = stat.get(api, 0) + 1 return sorted(stat.items, key=lambda x: x[1], reverse=True)[:20] if __name__ == "__main__": for api, cnt in scan_nginx_error("access.log"): print(cnt, api)这种脚本, 写着顺手,但文件一到几十 GB,就开始磨人。
当然, 那你能够改成为awk, 可以借助Rust重新进行编写, 并且也能够运用Spark。
然而不少情形下仅仅是进行临时的排查, 实在是没什么必要将事情弄得那般严重。首先去换 PyPy 运行一次, 这成本最为低廉。
安装也不复杂。
# Ubuntu sudo apt install pypy3 # macOS brew install pypy3跑之前看下版本:
pypy3 -V虚拟环境可以单独建,别和原来的 环境混着来:
pypy3 -m venv .venv-pypy source .venv-pypy/bin/activate pip install -r requirements.txt然后启动命令改一下:
pypy3 check_bill.py严格说,这已经算“换运行时”了,不是玄学优化。
代码依旧是那一份代码, 然而运行它的解释器发生了更换。这恰似同一条 SQL, 于不同的数据库引擎之下, 在不同的执行计划之中, 其表现或许全然就不是同一个样子了。
我现在处理 慢脚本,大概会按这个顺序来:
先确认结果一致。
再用 看是不是纯 热循环。
然后用 PyPy 跑一版。
如果提升明显,就继续压依赖兼容性、内存、启动耗时。
如果提升不明显,再回头改算法、批处理、并发、IO。
不要一上来就重构。
有不少运行速度迟缓的代码并非编写水准欠佳, 仅仅是运行的方式存在问题。特别是那些年代久远的脚本, 没有人敢于去改动, 哪怕改动一行都担心会对账目产生影响。在这样的时候, “不更改代码”反倒是最为稳妥的着手点。
不过那句“性能飙升 50 倍”,别乱贴到所有 项目上。
适用于它的情况是, 处理CPU密集型任务, 执行单纯的循环操作, 运行执行时间较长, 所依赖之要素并不复杂。
若命中了这几个条件, PyPy着实会把人吓得不轻。要是没命中, 那就别强行吹嘘。去运行一下, 日志便能道出实情。