用 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。绝对值随机器与负载波动,本文只对倍数/量级关系负责,请以你本机冷进程复跑为准。
posted @ 2026-10-25 10:08  贝壳bkshell  阅读(3)  评论(0)    收藏  举报