re 缓存实测:不预编译不会每次重编译,模式多过 512 个才真退化

举报
yd_232353574 发表于 2026/11/10 13:19:22 2026/11/10
【摘要】 三十万次调用当天实测:直接传模式串 0.285 秒、预编译后 0.162 秒,比值 1.76、省 43.3%,差距是每次多走的 411 纳秒。re 内部确有缓存,同一模式跑一千次只新增 1 条,所以不会每次重编译;差距大头是那层函数调用,不是查表本身。比值不随调用次数上升(1 次到 30 万次都在 1.5 到 1.8 之间);模式数量在 257 起先慢一倍、超过 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 后才会频繁触发重新编译。

这类性能传闻最好都当场测一下,传言往往抓住了结论的一半,丢掉的是让结论成立的前提。

【声明】本内容来自华为云开发者社区博主,不代表华为云及华为云开发者社区的观点和立场。转载时必须标注文章的来源(华为云社区)、文章链接、文章作者等基本信息,否则作者和本社区有权追究责任。如果您发现本社区中有涉嫌抄袭的内容,欢迎发送邮件进行举报,并提供相关证据,一经查实,本社区将立刻删除涉嫌侵权内容,举报邮箱: cloudbbs@huaweicloud.com
  • 点赞
  • 收藏
  • 关注作者

评论(0)

0/1000
抱歉,系统识别当前为高风险访问,暂不支持该操作

全部回复

上滑加载中

设置昵称

在此一键设置昵称,即可参与社区互动!

*长度不超过10个汉字或20个英文字符,设置后3个月内不可修改。

*长度不超过10个汉字或20个英文字符,设置后3个月内不可修改。