本机实测 defaultdict 比 setdefault 快,但「默认值越贵差距越大」未复现
两段看着差不多的计数代码
分组计数通常有两种写法:用 defaultdict,或者普通字典配 setdefault。功能上等价,开销差多少得测。我按三组对照各测了一遍,顺带核了一下「默认值越贵差距越大」这个说法,本机这两组里没出现这个趋势。
计时框架
三组共用同一套框架:每轮三批、每批十五轮交错(先 A 后 B 与先 B 后 A 轮流,抵消先后偏差),批内取中位数再取三批的中位数,整轮重复七轮。
import time, statistics
from collections import defaultdict
M, ROUNDS, BATCHES, REP = 800000, 15, 3, 7
def bench(fn):
t = time.perf_counter()
fn()
return time.perf_counter() - t
def one_round(f1, f2):
res = []
for _ in range(BATCHES):
f1(); f2() # 预热
a, b = [], []
for i in range(ROUNDS): # 交错
if i % 2 == 0:
a.append(bench(f1)); b.append(bench(f2))
else:
b.append(bench(f2)); a.append(bench(f1))
res.append((statistics.median(a), statistics.median(b)))
ma = round(statistics.median([r[0] for r in res]), 4)
mb = round(statistics.median([r[1] for r in res]), 4)
return ma, mb, round(mb / ma, 3)
def report(f1, f2):
rs = [one_round(f1, f2) for _ in range(REP)]
print(" 比值 %s 中位=%.3f 区间 %.3f~%.3f 中位耗时 %.4fs / %.4fs" % (
[r[2] for r in rs], statistics.median([r[2] for r in rs]),
min(r[2] for r in rs), max(r[2] for r in rs),
statistics.median([r[0] for r in rs]), statistics.median([r[1] for r in rs])))
return [r[2] for r in rs]
第一组:整数默认值
def dd():
d = defaultdict(int)
for i in range(M):
d[i % 1000] += 1
def sd():
d = {}
for i in range(M):
d.setdefault(i % 1000, 0)
d[i % 1000] += 1
r1 = report(dd, sd)
本机 CPython 3.13.14 / win32 实测,七轮比值依次是 1.561、1.525、1.519、1.521、1.511、1.605、1.522,中位数 1.522,区间 1.511~1.605,两边中位耗时 0.1276s 与 0.1955s。
两个方向要分开说:以 defaultdict 为基准,setdefault 耗时是它的 1.52 倍;以 setdefault 为基准,defaultdict 省下约 34% 的时间。两个数字分母不同,别当成同一说法换个措辞。
第二组:默认值换成列表
def dd_list():
d = defaultdict(list)
for i in range(M):
d[i % 1000].append(1)
def sd_list():
d = {}
for i in range(M):
d.setdefault(i % 1000, []).append(1)
r2 = report(dd_list, sd_list)
七轮比值依次是 1.375、1.299、1.226、1.315、1.312、1.311、1.307,中位数 1.311,区间 1.226~1.375,中位耗时 0.0966s 与 0.1263s。比值从第一组的 1.522 降到 1.311:这次把默认值换成每轮都要新建的空列表,差距没有变大,反而变小了。流传的「默认值越贵,setdefault 白造对象的浪费越严重,差距会成倍放大」这个推断,在本机这两组里没有出现。
逐轮配对比较,七轮里组1 的比值都高于组2,无一例外;组1 的下界 1.511 也高于组2 的上界 1.375。
组2 两边绝对耗时都低于组1,原因分两侧:setdefault 侧每轮少一次字典查找和一次写入,defaultdict 侧是把读-加-写换成 append。所以两组绝对耗时不能横比,只能比比值。
第三组:显式分支
def branch():
d = {}
for i in range(M):
k = i % 1000
if k not in d:
d[k] = 0
d[k] += 1
这个函数只有一个,预热一次后连续跑十五轮取中位数,同样重复七轮,中位数 0.1495s。组3 与组1 工作量结构相同(同样是一千个键上的八十万次自增),耗时可直接比:0.1495s 落在组1 的 0.1276s 与 0.1955s 之间。
关于原因,能说的和不能说的
几处写法每轮的字典操作次数不同,setdefault 版在 d.setdefault(...) 之外还有一次 d[k] += 1,分支版多一次 k not in d 的查找,列表版是链式 append;哪一项占多少,得单独拆开测。
更要紧的是,第二组不是一次单变量实验:它同时换了默认值的成本和工作量结构(组1 每轮做一次整数自增,组2 每轮做一次列表追加),分母的工作量本身就不一样。所以两组的比值只能说明这个趋势没出现,不足以反驳那条推断本身。
默认值确实变贵了这点坐实。组1 里 setdefault 传的 0 是常量,不分配对象;组2 里 setdefault(k, []) 每轮新建一个空列表,八十万次分配,绝大多数建完就丢。defaultdict 一侧始终只有一千次工厂调用,开销可忽略。只有一侧的默认值构造开销被抬到八十万次,比值却从 1.522 降到 1.311,这仍是双变量局面,不能当成对那条推断的反驳。
单轮比值的区间会偶有重叠:同一套代码换批跑,组1 出现过 1.703 这样的高轮,组2 也出现过 1.527。所以这里不拿单轮区间说事,拿的是七轮逐轮配对的一致性。
这里没有做方差或显著性检验,也没覆盖别的键空间或别的追加方式。它够用来说明在本机这两组里没出现这个趋势,不够判断那条推断本身对不对,更不够用来证明一条普遍规律。这也是标题只写「本机未复现」的原因。
顺带一个容易写错的地方
可变默认值要留意对象是新造的还是共用的。setdefault(k, []) 每轮都新建一个空列表,键之间不共享,但每轮多一次分配;若把外部同一个列表对象反复传给多个键,改一处会牵连其他键。defaultdict(list) 只在键缺失时造新列表,每键各一份。要每键独立用 defaultdict(list),共用一份只能自己把对象传进去。
怎么选
单纯计数或分组,优先 defaultdict,语义清楚,实测也快。键是否存在要区别对待、或补默认值的逻辑跟单次操作绑得很紧时,用普通字典把分支写清楚更合适。本机三者的顺序是 defaultdict 0.1276s、显式分支 0.1495s、setdefault 0.1955s。
可复现条件:CPython 3.13.14 / win32,M 取八十万,键空间是 i % 1000,每轮三批、每批十五轮交错、批内取中位数、整轮重复七轮。未控制垃圾回收状态,批内极差能占中位数的三成到九成,但重复七轮后中位数稳定得多。微基准换个机器或版本都可能改比值。
- 点赞
- 收藏
- 关注作者
评论(0)