用 bool-hybrid-array 的 BHA_Queue 替 deque 存百万布尔:省了 62 倍内存,但第一次出队卡了我两秒
先说结论,不吹不黑:BHA_Queue 在"长期驻留一大堆布尔标记"的场景里,内存确实碾压 collections.deque,一百万交替布尔稳态只占约 0.13 MB;但它不是免费的——构造只慢约一个数量级、坑在第一次出队,而且第一次 dequeue() 的耗时随队列长度明显超线性上涨,我第一次没注意,在一个二十万元素的队列上出队,主线程卡了将近两秒。
这篇把我踩的三个坑、以及怎么正确做基准一次讲清楚。所有数字都是在 bool-hybrid-array 9.13.11 上本机实测的(python -m uv pip install -U bool-hybrid-array),口径和复现脚本都在文末,你可以原样跑。项目地址:PyPI / GitHub。
坑零(最容易误导人):测内存千万别用列表推导式喂数据
我第一版基准是这么写的:
q = BHA_Queue([bool(i & 1) for i in range(1_000_000)])
结果一看峰值 8 MB 多,差点得出"这玩意也不省内存啊"的错误结论。这是基准写错了:[... for ...] 会先在内存里构造一个装满一百万个 Python bool 对象引用的临时列表,光这个列表本身就约 8.45 MB,tracemalloc 把它的峰值算到了整个构造过程头上。
换成生成器,调用方那个临时列表就没了:
q = BHA_Queue(bool(i & 1) for i in range(1_000_000)) # 用生成器,不要用列表推导式
给所有自己做内存基准的人提个醒(这坑和具体库无关):往任何"号称省内存"的容器里灌大数据,都用生成器表达式
(x for x in ...),不要用列表推导式[x for x in ...]。否则你测到的是"临时列表 + 容器"叠加的峰值,会把锅扣到容器头上。同时分清两个数:构造过程的瞬时峰值 peak 和 构造完成后的稳态驻留 current——省内存看的是稳态。
坑一:稳态确实省,但构造期有自己的瞬时峰值,而且构造不快
用生成器、并且每个规模都在独立的全新 Python 进程里只测一次(避免同一进程反复构造时内存池/碎片让峰值乱跳),实测交替 True/False:
| 一百万交替布尔 | 构造耗时 | 构造期峰值 | 构造后稳态驻留 |
|---|---|---|---|
collections.deque(生成器) |
约 0.044 s | 8.25 MB | 8.25 MB |
BHA_Queue(生成器) |
约 0.47 s | 约 6.4 MB | 0.134 MB |
稳态那 0.134 MB 基本就是一张位图(N/8 ≈ 125 KB)加上队列的一点元数据。作为长期常驻结构,它比 deque 省约 62 倍内存;越稀疏越夸张,每一万个里只有一个 True 时,稳态只有约 0.009 MB。
但代价也写在表里:
- 构造耗时:用生成器喂入、冷进程多次取中位,
BHA_Queue构造约 0.47 s,而collections.deque同口径约 0.044 s——只慢约一个数量级(约 10 倍),0.47 s 完全可接受,不构成使用障碍;真正要注意的不是构造,而是后面要说的首次出队。 - 构造期有瞬时峰值:实测构造期存在约 6.4 B/元素的瞬时峰值(一百万约 6.4 MB,随 N 线性),构造完成即释放到约 0.13 MB;具体内部结构本文不臆测,以官方实现为准。所以内存极度紧张、一次性灌超大数据时,要给这个瞬时峰值留余量,或者分批喂。
- 一个严谨性提醒:这个峰值必须在冷进程里测。我一开始在同一个进程里反复构造,N=100 万的峰值一会儿 6 MB 一会儿 23 MB——热进程里前一次分配留下的内存池会污染
tracemalloc的峰值读数。写基准、看基准都要留意代码先后顺序带来的测量污染。
坑二:逐个 enqueue 会停在"稀疏形态",不会自动给你压成位图
构造器一次喂入,最终会落成致密位图;但如果你是建个空队列、再一条条 enqueue,内部会先走"异常下标表"形态,并且不会自动重划:
q = BHA_Queue([])
for i in range(1_000_000):
q.enqueue(bool(i & 1))
# 此时内部仍占约 2.1 MB,而不是 0.125 MB
BHA_Queue 底层是两个布尔数组(我用 q.a / q.b 看到它们都是 BoolHybridArray,分别承担队列两段)。队列对象本身没暴露 optimize(),要省这部分得对内部那段还处于稀疏形态的数组手动优化一次(逐个入队时通常只有 a 段在涨、b 仍是约 136 B 的最小值,所以我这里只优化 a;两段都膨胀时就两段都收一次):
q.a.optimize() # 之后 a 从约 2.1 MB 压回约 0.125 MB 的位图形态
注意 optimize() 自己也不便宜:重排时新旧两份结构要短暂同时在内存里,一百万规模实测优化期间瞬时峰值冲到约 27 MB,结束才回落到约 0.128 MB。所以别在内存已经很紧的时刻做这步,放到内存宽裕的离线/初始化节点。
所以"动态一边入队一边长期跑"的用法,记得在关键节点手动收一次;指望它入队时实时自动压缩是不现实的(那会让每次入队都付出重排成本)。
坑三:第一次 dequeue 是一次昂贵的整体转移,出完第一次才是均摊 O(1)
这是最反直觉、也最容易在生产里出事的一点。README 写 dequeue "均摊 O(1),仅首次触发转移",我实测了这个"首次":
| 队列长度 n | 第一次 dequeue 耗时 | 折合(每元素) |
|---|---|---|
| 5 千 | 约 26 ms | 约 5.23 µs |
| 2 万 | 约 113 ms | 约 5.65 µs |
| 10 万 | 约 741 ms | 约 7.41 µs |
| 20 万 | 约 1.9 s | 约 9.5 µs |
| 50 万 | 约 7.9 s | 约 15.7 µs |
第一次出队的耗时随队列长度明显超线性上涨(内部要做一次两段之间的整体转移/重排),第一次之后才进入每次几微秒的稳态。它不是干净的 O(n):单位成本随 N 从约 5 µs 涨到约 16 µs,疑似带缓存/二次效应的超线性;不要按线性外拍脑袋估大队列——50 万就要约 8 秒。含义很直接:
- 不要在请求热路径上、对着一个刚建好的大队列做"第一次出队",那一下可能就是秒级卡顿;
- 如果建好后马上就要用,必须在离线/初始化阶段(而非线上请求里)先空转触发一次转移预热掉;大队列首次预热本身也很贵(50 万约 8 秒),不能在线上做。
所以到底用不用它
我的选型判断,供参考:
- 该用
BHA_Queue:队列里装的就是布尔标记(去重位、黑白名单、已处理标志、签到/打卡位、布隆式开关),数量大、要长期常驻内存,并且希望和BoolHybridArray那套位图/混合存储生态无缝互转。62 倍起步的稳态内存节省是真金白银。 - 继续用
collections.deque:元素不是布尔、是任意 Python 对象;或者你要的是纯粹的 FIFO 高并发、极低且稳定的单次延迟(承担不起任何一次 O(n) 的首次出队)。deque 始终约 8 B/引用、构造快、每次出队恒定低延迟。
它不是 deque 的"全面上位替代",而是"布尔专用、拿构造时间和首次转移换常驻内存"的特化结构。想清楚自己的队列是短命高频还是长命装布尔,再选。
复现口径
- 版本 bool-hybrid-array 9.13.11,双协议开源 Apache-2.0 OR MulanPSL-2.0,requires-python ≥ 3.8。
- 内存用
tracemalloc测 Python 层驻留(不含 Cython 原生堆),每个规模在独立冷进程里跑一次;同时记录构造期 peak 与构造后 current。 - 喂数据一律生成器;逐个 enqueue 与构造器一次喂入是两条不同路径,分开测。
- 绝对数值随机器波动,量级关系(稳态约 N/8、构造期峰值约 6.4 B/元素、
optimize()瞬时峰值约 27 MB、首次 dequeue 随 N 超线性上涨)稳定。
基准环境:Linux x86_64(4 核 / 8 GB)、CPython 3.12.11、bool-hybrid-array 9.13.11、numpy 由 pip 按 >=1.19 自动配套;主用例 N=1,000,000 交替 True/False。绝对值随机器与负载波动,本文只对倍数/量级关系负责,请以你本机冷进程复跑为准。
浙公网安备 33010602011771号