AIGC标识 BHA_Queue 内存压测翻车现场:8.6MB → 0.13MB,罪魁祸首是一行列表推导式

本文基于 bool-hybrid-array 9.13.11,所有数字都是我本机真机实跑、脚本贴在文末可原样复现。我不是来吹这个库的——恰恰相反,我第一次压测它的时候,差点把它当"虚假宣传"删掉。这篇记录我是怎么被自己写的基准骗的,以及 BHA_Queue 到底适不适合替换 collections.deque。

0. 需求:一个百万级的布尔 FIFO 队列

场景很普通:一条流水线,每个任务只有"成功/失败"两种状态,要塞进一个 FIFO 队列,峰值在百万级上下。我第一反应是别用 collections.deque 装一堆 Python bool 对象——那玩意儿每个元素都是带引用计数的真对象,太费内存。

pip install -U bool-hybrid-array 之后,我看到它在 9.11.5 版本加了个 BHA_Queue,名字就叫"布尔混合数组队列",听着像是为这个场景生的。于是我决定压一压它的内存,再决定要不要替换。

然后我就翻车了。

1. 第一次压测:8.6MB,我以为这库"不省内存"

我写的第一版基准,大意是这样(先别抄,这一版是错的):

import tracemalloc
from bool_hybrid_array import BHA_Queue

N = 1_000_000
tracemalloc.start()
q = BHA_Queue([bool(i & 1) for i in range(N)])   # 注意这一行
cur, peak = tracemalloc.get_traced_memory()
print(cur / 1e6, peak / 1e6)

跑出来峰值 8.58MB。我当场就皱眉:百万个交替布尔,原生 deque 也才 8.25MB,你一个号称"混合压缩"的队列,怎么一点没省,还略贵一点?

差点把页面关了。

2. 坑 No.1:列表推导式先建了一个满载的临时 list

冷静下来看那行 [bool(i & 1) for i in range(N)]——问题就在这个方括号。

列表推导式在被传进 BHA_Queue 构造器之前,会先在内存里老老实实地构造出一个完整的、装了 100 万个 Python bool 对象的 list。这个临时 list 的占用,被 tracemalloc 全程算在了"被测代码"的峰值里。换句话说,8.58MB 里有一大块是我自己喂数据的方式造出来的,跟 BHA_Queue 没关系。

换成生成器再测:

q = BHA_Queue(bool(i & 1) for i in range(N))     # 圆括号,生成器,惰性产出
喂入方式 构造完成后稳态驻留 构造过程瞬时峰值
BHA_Queue([...]) 列表推导式(错) 0.131 MB 8.58 MB(被临时 list 顶高)
BHA_Queue(...生成器...)(对) 0.132 MB 6.35 MB

注意一个关键事实:两种写法构造完成后的稳态几乎一样(0.13MB),列表推导式坑的是"峰值"这一项。如果我只盯着 peak 看,就会得出"它不省内存"的错误结论;而队列真正长期占着不放的,是构造完成后的稳态。

同口径下原生容器的稳态(都是生成器喂入):

容器(100 万交替布尔) 稳态驻留
普通 list 8.45 MB
collections.deque 8.25 MB
BHA_Queue(一次性构造) 0.132 MB

约 62 倍的差距。它内部也不神秘,我打出来看了:队列底层是两个 BoolHybridArray(出队段 a 和入队段 b),一次性构造后 a 占 125135 字节、b 占 136 字节——a 就是一张把 8 个布尔打进 1 个字节的位图,100 万位 ≈ 125KB,对得上。

更夸张的是稀疏场景。如果状态分布极度不均(比如万分之一才是 True,大量失败里零星成功):

q = BHA_Queue(i % 10000 == 0 for i in range(N))

稳态只有 0.0077MB(内部 a 仅 532 字节),而同数据的 deque 依旧是 8.25MB——约 1000 倍。这才是"混合数组"四个字真正发力的地方:极稀疏时它走异常下标存储,只记那 100 个 True 的位置。

给读者的提醒 No.1:自己做内存基准时,喂大数据一律用生成器 (x for x in ...),别用列表推导式 [x for x in ...]。否则你会把"中间临时 list"的内存算到被测库头上,冤枉它。

3. 坑 No.2:别在同一段代码里连续 new 一堆大对象

第一版脚本我还有个毛病:在同一个进程、同一个 tracemalloc 会话里,先建一个百万队列、再建一个 deque、再建一个 list,想一口气对比完。

这会带来顺序污染:前一个大对象如果还被某个变量引用着、或者 GC 还没回收,它占的内存就会影响后一个用例的读数;tracemalloc.start()/stop() 反复复用也不干净。代码的先后顺序本身就会改变结果,这种数据不能用。

严谨的做法是让每个用例互相隔离。我最终是这么跑的:写一个基准脚本,父进程用 subprocess 把每一个被测场景都放到独立的子进程里单独执行、各自打印一行 JSON,父进程再汇总。这样任何一个用例跑完进程就退出,内存彻底归还,谁也污染不了谁。文末附了完整脚本骨架,核心就是:

CASES = {"bha": measure_bha, "deque": measure_deque, "list": measure_list}
for name, fn in CASES.items():
    r = subprocess.run([sys.executable, __file__, "--case", name],
                       capture_output=True, text=True)
    print(name, json.loads(r.stdout))

如果你嫌开子进程麻烦,退一步也至少要在同一个进程里做到:测完一个就 del obj、gc.collect(),然后重新 tracemalloc.start(),并自检没有别的地方还持有引用。

给读者的提醒 No.2:基准的每一步顺序都要经得起追问——这个对象释放了吗?这个读数里有没有混进别的东西?拿不准就一个用例一个进程,最干净。

4. 坑 No.3:逐个 enqueue 会停在"非最优形态",记得 optimize

压测构造器很爽,但真实队列往往是"先建空队列,再一条条 enqueue"。我按这个姿势再测,数字又变了:

q = BHA_Queue([])
for i in range(N):
    q.enqueue(bool(i & 1))

100 万次 enqueue 之后,稳态停在 2.107MB,内部 a 是 2000132 字节——不是位图,而是 sparse 的"异常下标表"形态。为什么?因为逐个添加的过程中,库无法预知你后面还要塞多少、True/False 比例如何,就先维持在一个便于增量插入的中间态上;它不会替你预判"最终这是个 50% 交替、该进位图"。

解决办法是手动优化,而且队列有 a、b 两段,要分别调:

q.a.optimize()
q.b.optimize()

优化后 a 从 2000132 字节回到 125135 字节,整体稳态 0.129MB,和一次性构造完全一致。

这里又有一个要诚实交代的代价:optimize() 重排时要同时持有新旧两套结构,重排那一瞬间的峰值实测到 27MB(百万级),结束才回落到 0.13MB。所以它适合在"队列构建完成、对外服务之前"这种安全时点调用一次,而不是放在高频热路径上反复跑。

构造方式(100 万交替) 优化前稳态 optimize 瞬时峰值 optimize 后稳态
一次性生成器构造 0.132 MB(已进位图) — —
逐个 enqueue 2.107 MB(sparse 中间态) 约 27 MB 0.129 MB

给读者的提醒 No.3:逐个 enqueue 攒出来的队列可能停在非最优的 sparse 形态,要么在安全时点对 a、b 两段都 optimize(),要么能预知全量数据时就一次性构造。注意 optimize 有瞬时峰值,别在热路径上用。

5. 坑 No.4:第一次 dequeue 是 O(n) 冷启动,之后才均摊 O(1)

这是最反直觉的一个。BHA_Queue 的出队 API 叫 dequeue(),文档说"均摊 O(1)、仅首次触发转移"。我实测了不同规模下第一次出队的耗时:

队列长度 n 第一次 dequeue() 之后每次(平均)
1,000 0.005 s 约 4.2 µs
20,000 0.115 s 约 4.6 µs
200,000 1.94 s 约 6.4 µs

第一次出队的耗时随队列长度线性增长(大约每元素 9~10 微秒):20 万的队列,第一次 dequeue() 要卡将近 2 秒;而第一次之后,每次出队稳定在几微秒的 O(1)。小队列上你根本感觉不到,但如果你的用法是"攒一个超长队列、然后才开始往外取",那第一下的冷启动延迟要心里有数,别把它放在请求的关键路径第一拍。

原因还是前面那个双段结构:enqueue 进 b 段、dequeue 从 a 段取,第一次出队时要做一次和规模相关的整理/转移,之后就只是位图上的常规操作了。

给读者的提醒 No.4:dequeue() 的 O(1) 是"均摊",第一次是 O(n) 冷启动。超长队列第一次取元素前,最好预热一次,别让用户替你承担那一下卡顿。

6. 迁移注意:它不是 deque 的同名替身

内存之外,我还把它当 deque 替身试了一圈常用写法,有几个对不上的地方(都真机验证过,9.13.11):

q = BHA_Queue([True, False, True, False, True])

len(q)          # 5                ✅ 支持
for x in q: ... # 可正常迭代         ✅
q.is_empty()    # False            ✅
q.get()         # True,且会真的出队(长度 5→4)——get/put 是队列式别名,不是"看一眼队首"
q.put(True)     # 从队尾入队(等价 enqueue)
q.append(True); q.popleft()         ✅ deque 风格的 append/popleft 别名也在

q[0]            # ❌ TypeError: 'BHA_Queue' object is not subscriptable
q[-1]           # ❌ 同上
q[1:3]          # ❌ 同上,不支持切片

两个容易踩的点:一是没有非破坏式的"看队首",get() 名字像 peek,实际是会弹出元素的出队操作(和 put() 配成一对队列式接口);二是不支持下标随机访问和切片——连 q[0] 都会直接抛 TypeError。注意这和 deque 也不完全一样:deque 至少允许 q[0](虽然是 O(n) 顺过去找),而 BHA_Queue 是压根不可下标。它的设计取向很明确:只让你按 FIFO 顺序 enqueue/dequeue(外加判空 is_empty()、迭代和 get/put 别名),不鼓励随机掏中间元素。

所以如果你的老代码里有 q[0]、q[-1]、q[a:b]、或想用 q[0] 非破坏式看队首,都不能无脑把 deque 全局替换成 BHA_Queue,会报错或改变语义;要么改成顺序出队,要么保留 deque。语义上它更接近"一个只暴露队列操作的布尔专用队列",而不是"接口完全兼容 deque 的平替"。

7. 所以到底要不要用 BHA_Queue 替 deque?

压完这几轮,我的结论很明确,不吹不黑:

适合用 BHA_Queue 的情况:

  • 队列里装的就是布尔语义(成功/失败、是/否、命中/未命中),且规模大;
  • 关心长期内存占用,尤其是状态分布稀疏(大量同值里零星异值)时,它能从 8MB 压到几 KB;
  • 你的上下游本来就在 bool-hybrid-array 生态里(BoolHybridArray/BHA_List/位图运算),零转换衔接;
  • 能接受"构建完 optimize 一次""首次出队预热一次"这种前置动作。

老老实实用 collections.deque 的情况:

  • 元素不是纯布尔,或队列很短——deque 的 8MB 在百万级以下根本不是事;
  • 追求入队/出队全程稳定低延迟、不能接受首次 dequeue 的 O(n) 冷启动毛刺;
  • 只是想要一个通用 FIFO,不需要位图、稀疏压缩这些能力。

它不是 deque 的"全面上位替代",而是一个针对布尔大队列、尤其稀疏布尔队列做内存优化的专用结构,附带两个需要使用者理解的工程代价(非最优中间态要 optimize、首次出队冷启动)。想清楚自己是不是它的目标场景,再换。

8. 一张自查清单(也是给我自己的教训)

这次踩坑最大的收获,其实跟库无关,是做基准的纪律:

  1. 喂数据用生成器,不用列表推导式——别把临时 list 的账算到库头上;
  2. 看稳态驻留,别只看瞬时峰值,两者都要报、口径分开;
  3. 多个用例隔离测量(独立子进程最稳),注意对象释放与代码先后顺序,别互相污染;
  4. 逐个构建的结构查一下有没有"中间态"和对应的 optimize,并诚实记下 optimize 自身的瞬时代价;
  5. 凡文档写"均摊 O(1)"都要单独测第一次,冷启动成本常常就藏在这儿;
  6. 所有数字标清机器、版本、口径,多次跑取中位数,能复现才算数。

  • 库:bool-hybrid-array(PyPI: https://pypi.org/project/bool-hybrid-array ,GitHub: https://github.com/BKsell/bool-hybrid-array ,Gitee: https://gitee.com/BKsell/bool-hybrid-array )
  • 安装/更新:python -m uv pip install -U bool-hybrid-array(或 pip install -U bool-hybrid-array)
  • 本文版本:9.13.11,requires-python ≥ 3.8;开源协议 Apache-2.0 OR MulanPSL-2.0(双协议,不是 MIT)
  • 文中内存为 tracemalloc 的 Python 层口径(独立子进程、生成器喂入、构造完成后稳态),未计入 Cython 原生层;耗时为多次运行中位水平,绝对数值随机器波动,量级稳定。
posted @ 2026-10-25 12:14  贝壳bkshell  阅读(6)  评论(0)    收藏  举报