re 缓存实测:不预编译不会每次重编译,模式多过 512 个才真退化
一句流传很广的提醒
写正则时总有人叮嘱:循环里别直接用 re.search,每次都会重新编译,慢得要命,一定要先 compile。这话在很多入门文章里反复出现,可"每次都会重新编译"这个说法本身经不起实测。我在 Python 3.13.14 上跑了一组对照,结论有点两头不靠。
三十万次调用的实测
同一个模式、同一段目标串,循环三十万次,一边直接传模式串,一边用预先编译好的对象:
import re, time, statistics
pat = r"^(\d{4})-(\d{2})-(\d{2})$"
s = "2026-10-31"
N = 300000
def bench(fn):
t0 = time.perf_counter()
for _ in range(N):
fn()
return time.perf_counter() - t0
cp = re.compile(pat)
for _ in range(20000): # 预热,别让首次进入的冷启动算进来
re.search(pat, s)
cp.search(s)
raws, comps = [], []
for i in range(15): # 两种写法交错跑,最后取中位数
if i % 2 == 0:
raws.append(bench(lambda: re.search(pat, s)))
comps.append(bench(lambda: cp.search(s)))
else:
comps.append(bench(lambda: cp.search(s)))
raws.append(bench(lambda: re.search(pat, s)))
mr = statistics.median(raws)
mc = statistics.median(comps)
print("raw=%.3fs compiled=%.3fs ratio=%.2f" % (mr, mc, mr / mc))
print("saved=%.1f%%" % ((mr - mc) / mr * 100))
当天实测输出:
raw=0.285s compiled=0.162s ratio=1.76
saved=43.3%
预编译确实更快,省掉四成多时间。但请注意,如果真像传言说的每次都重新编译,直接调用该慢出一个明显的倍数(后面模式数量超过 512 时单次测到两万多纳秒,是这里的几十倍),而不是只差 1.8 倍。这个比值本身就说明,重复编译并没有发生。这类微基准在不同轮次会有几个百分点的浮动,别把小数点后两位当成常数。
缓存确实存在,而且只多了一条
re 模块内部维护着缓存表,键是一个三元组:模式的类型、模式串本身、标志位。re.search 这类便捷函数拿到模式串后,先查表,命中就直接用现成的对象。
把缓存清空,再跑一千次自己的模式:
import re
re.purge() # 清空全部缓存
print(len(re._cache)) # 0
pat = r"^(\d{4})-(\d{2})-(\d{2})$"
for _ in range(1000):
re.search(pat, "2026-10-31")
print(len(re._cache)) # 1
跑了一千次,表里只多了 1 条,就是这个模式本身。所以不预编译就会每次重编译是错的:同一个模式串反复用,拿到的都是同一个已编译对象。
顺带一提,清空之前表里并不是空的。一个刚起起来的解释器里有 11 条记录,那是启动时导入 json、string 这类模块顺带编译进去的;用 python -S 跳过 site 处理,起步就是 0 条。当天在这台机器上两种启动方式都实测过。这个数字取决于环境,不是 Python 本身的固定属性。
差距到底在哪
既然没有重复编译,多出来的那四成时间花在哪了?在每次调用都要多走的那一趟上。
按上面的三十万次结果摊到单次调用:
re.search(pat, s) 951 ns
cp.search(s) 539 ns
差距 411 ns
把这 411 纳秒拆开测:单次 dict.get 的净开销约 89 纳秒,构造键三元组约 91 纳秒,而调用 re._compile 这整个函数的净开销约 335 纳秒。这些数字同样是微基准,当天实测与上一轮记录有几十纳秒的浮动,但量级关系稳定。要注意前面两项是 re._compile 内部的子步骤,已经包含在 335 里,不能拿来和它并列相加去凑 411;能确定的是,光是这一层函数调用就占了差距的八成左右,查表加构造键都算进去也只占其中一部分。预编译省掉的正是这一层调用与参数传递,直接拿着对象调方法。
比值跟调用次数无关
有个说法是循环次数越多,预编译的相对优势越明显。实测下来不是这样。同一个模式跑不同的次数,每档都交错多轮取中位数:
调用 1 次 比值 1.50
调用 10 次 比值 1.64
调用 100 次 比值 1.70
调用 1000 次 比值 1.76
调用 10000 次 比值 1.65
调用 100000 次 比值 1.80
调用 300000 次 比值 1.77
比值一直在 1.5 到 1.8 之间晃,不随次数上升。道理也直白:每次调用摊的那份固定成本是个常数,调用总数放大的时候,两边的耗时按同样比例放大,比值自然不跟着变。
那比值到底受什么影响?换个模式确实会变,但方向不像传闻里那么规整。当天用同一段目标串测了几个不同模式,比值落在 1.3 到 2.2 之间,纯字面量和带分支的模式各自偏高偏低都能测到,换一轮测法顺序也可能换过来。所以别把「模式越简单预编译收益越大」当成固定规律,遇到具体模式自己量一次更靠得住。
模式一多才会真重编译
上面说的都是模式固定的情况。如果模式是运行时拼出来的,缓存不一定失效,关键看拼出来的模式有多少个。
按不同数量试一遍,每档都清空缓存、两万次调用、交错多轮取中位数:
10 个不同模式 1527 ns
100 个不同模式 1736 ns
256 个不同模式 1788 ns
257 个不同模式 3029 ns
512 个不同模式 3229 ns
513 个不同模式 24706 ns
5000 个不同模式 25545 ns
台阶要看位置而非绝对值(不同机器、测法,纳秒数会整体浮动):256 以内和固定模式同一量级;257 起翻到约两倍,是第一级台阶、还算不上反复编译;超过 512 后飙到两万多纳秒,约十几倍,与单独编译一个模式的开销同量级,那才是缓存兜不住、频繁触发重新编译的区间。
所以判断标准不是"模式是不是动态生成",而是"模式数量会不会超过缓存能装的量",真正的断崖在 512 之后,不是刚过 256。
淘汰顺序并不是严格的最近最少使用
缓存满了会淘汰旧条目,但淘汰顺序并不是「谁最久没用就先删谁」。这一点可以只用黑盒实测来证明,不必依赖对内部结构的具体猜测:
灌入 256 个模式,用其中一个跑十万次,再灌 300 个
被用了十万次的那个 已被淘汰
一个刚被高频使用过的模式照样被淘汰,已经足够说明它不是严格的最近最少使用。当天实测还顺带看到,最初那 256 个模式里仍有 212 个留在表里,被淘汰的并不只是"最久没用"的那批。至于内部到底分几张表、各按什么策略淘汰,属于版本相关的实现细节,这里不做断言;想手动清干净,用 re.purge() 即可。
怎么做更合适
模式固定且要在热路径里反复用,就提到外面编译一次,清晰又快。模式零散、绝对耗时很小或根本不在热路径上,直接传字符串完全没问题,不必为一个想当然的理由增加成员变量。真正该警惕的是在循环里动态构造大量模式:数量越过几百这一档会先慢一倍左右,超过约 512 后才会频繁触发重新编译。
这类性能传闻最好都当场测一下,传言往往抓住了结论的一半,丢掉的是让结论成立的前提。
- 点赞
- 收藏
- 关注作者
评论(0)