百万布尔随机点查:最快约44ns的是bitarray,BHA最省内存(停车场实测)
先说一个可能反直觉的结论:在 Python 里做百万级布尔数组的"随机点查",最快的不是 bool-hybrid-array,而是朴素的 bitarray——单次中位只要约 44 纳秒。 但别急着下结论。点查延迟只是选型的一个轴,内存、稀疏度、动态增删、批量位运算各有各的账。这篇我用独立冷进程、同一台机器、同一套口径,把 list[bool]、bytearray、bitarray 和 bool-hybrid-array 的 BoolHybridArr(dense / 1% 稀疏 / 0.1% 稀疏三种形态)放到一张桌上,逐操作实测给你看。
一、为什么要测这个:一个停车场的真实问题
我在做一个城市级停车聚合:几十万个路侧泊位和商场车位,每个泊位一个布尔位——"当前是否被占用"。地磁感应和道闸抬起会随机翻转某一位,用户 App 打开页面要随机查某一个车位空不空,运营后台偶尔要"统计当前占用数"。
这个负载有三个特征:
- 随机点查为主:查的永远是"某一个车位",不是整表扫描;
- 随机写低频但实时:一次进出场翻一位;
- 稀疏度是会变的:深夜占用率可能不到 1%,高峰期能到 60% 以上。
前两条决定我关心单次 x[i] / x[i] = True 的微秒级延迟,第三条决定我没法靠拍脑袋选一个固定结构。于是有了下面这组测试。
二、测试环境与方法(数字可复现,先把口径讲清)
- 机器:AMD EPYC 9Y24,4 vCPU,8 GB 内存,Linux;
- 解释器:CPython 3.12.11;numpy 2.5.3;bitarray 3.11.0;bool-hybrid-array 9.13.11(写作时 PyPI 最新版,
pip install -U bool-hybrid-array即可); - 规模 N = 1,000,000;每个"结构 × 形态"都在独立冷子进程里测,互不污染;
- 耗时每个动作连续 9 轮、整个冷进程连跑 3 遍,取中位;测延迟时把整段 20 万次访问放进同一个
perf_counter()区间再除以次数(摊薄计时本身的开销),先跑 2 轮预热、用固定随机种子取同一批下标,因此表里的"单次纳秒"是整段摊销值、不是只测一次下标; - 喂构造器一律用生成器表达式,不用列表推导式(否则会先建一个满载 Python 对象的临时 list,峰值内存被算进被测对象,结论就花了);
- 内存给三个口径:库自身逻辑记账(
sys.getsizeof/ BHA 的memory_usage())、tracemalloc的稳态 current 与构造瞬时 peak、以及进程 RSS; - 稀疏数据用"每隔 step 个一个 True"构造:step=2 约 50%(BHA 走 dense 按位段),step=100 约 1%,step=1000 约 0.1%(后两者走稀疏异常下标段)。
三、单次随机点查:朴素位数组确实最快
随机抽 20 万个下标,循环读 x[i],摊到每次的中位延迟:
| 存法 | 内部形态 | 单次随机读 |
|---|---|---|
list[bool] |
指针数组(8 B/槽) | 约 49 ns |
bytearray |
1 字节/个 | 约 50 ns |
bitarray |
1 bit/个,C 扩展 | 约 44 ns |
BoolHybridArr |
dense 按位打包 | 约 0.76 μs |
BoolHybridArr |
稀疏 1% | 约 0.91 μs |
BoolHybridArr |
稀疏 0.1% | 约 0.86 μs |
朴素三兄弟都在 45~50 纳秒量级:list/bytearray 是一次数组下标寻址,bitarray 多一步"取字节再按位与",但整套在 C 里,反而最快。
BoolHybridArr 慢在它是纯 Python 的访问包装:dense 段要经过 accessor、再用 ctypes 去 numpy 的 uint8 位图里按位读写;稀疏段还要先在有序异常下标数组里做一次 bisect 二分,再判断这个下标是"异常的 True"还是"落在默认极性里"。所以端到端落在 0.76~0.91 微秒,比朴素结构慢约 15~20 倍。
但这个差距在业务里意味着什么? 0.9 微秒 vs 0.05 微秒,绝对值都不到 1 微秒。停车场一次查询要走蓝牙道闸、移动网络、后端接口,预算普遍是几十到几百毫秒——点查这点差异占比连万分之一都不到。只有当你在一个紧密内层循环里连点几百万、上千万次时,它才会累积成可感知的开销。给个本机实测量级:连续 100 万次随机读,bitarray 约 19 ms、bytearray 约 16 ms,而 BHA dense 约 0.59 s、稀疏 0.1% 约 0.73 s(约 30~40 倍)——这就是为什么下面反复强调"无 I/O 的紧密内层循环别用它",不是只在单次倍率上吓人。
四、随机翻转写:稀疏段要搬数据,但仍是微秒级
随机挑位置做 x[i] = True / x[i] = False(各占一半,每轮结束反向恢复,保证形态恒定):
| 存法 | 单次随机写(置位/清零中位) |
|---|---|
list[bool] |
约 17 ns |
bytearray |
约 25 ns |
bitarray |
约 22 ns |
BoolHybridArr dense |
约 0.37 μs |
BoolHybridArr 稀疏 1% |
约 1.21 μs |
BoolHybridArr 稀疏 0.1% |
约 1.19 μs |
dense 段写就是"改一个 bit",0.37 微秒。稀疏段贵一些,因为它不是改 bit,而是在 array.array('I') 这个有序异常下标数组里做插入或删除——要二分定位、再搬移后面的元素,是 O(k) 的数组挪腾,所以约 1.2 微秒。
注意它没有用 RLE 行程编码:稀疏段就是"升序的异常下标 + bisect",下标超过 2³² 才会把 'I' 升成 'Q'。这套设计换来的是类 list 的动态增删和随稀疏度自适应,代价就是稀疏段的随机写比"原地改一个字节"略重。对停车场这种一次进出场才翻一位的负载,1.2 微秒同样可以忽略。
五、遍历与全量统计:这里要分开看
逐元素 for x in obj 扫完一百万位:
| 存法 | 纯遍历 | Python sum(obj) |
自带 count() 聚合 |
|---|---|---|---|
list[bool] |
16.7 ms | 3.48 ms | 2.58 ms |
bytearray |
17.9 ms | 3.71 ms | 0.96 ms |
bitarray |
18.7 ms | 4.78 ms | 0.034 ms |
BoolHybridArr dense |
720 ms | 697 ms | 206 ms |
BoolHybridArr 稀疏 1% |
816 ms | 795 ms | 约 0.001 ms |
BoolHybridArr 稀疏 0.1% |
780 ms | 793 ms | 约 0.001 ms |
这组要诚实拆开讲:
- Python 层逐元素迭代,BHA 明显慢。每个元素都要过一遍前面说的 accessor 包装,扫完整表约 0.7~0.8 秒(折合约 0.7~0.8 微秒/次)。如果你的核心负载是"反复全表 Python 级扫描",bitarray / bytearray 更合适,这不是 BHA 的主场。
- 但全量计数有捷径。bitarray 的
count()是 C 级 popcount,0.034 ms;而 BHA 一旦处在稀疏段,"True 的个数"就是异常下标数组的长度,count(True)直接返回,约 0.001 毫秒,近乎免费——深夜停车场要数"当前有几辆车",几乎不花钱。 - dense 段 BHA 的
count(True)约 206 ms,这条路径目前不是 popcount 加速。所以真要在高密度下反复做全量 popcount,bitarray / numpy 依旧是更专的工具。
一句话:BHA 的 sparse 段在"统计异常个数"上白送,dense 段别指望全表聚合最快。
六、内存账:这才是它真正拉开差距的地方
N=100 万,对象本体的稳态逻辑占用(生成器构造,非峰值):
| 存法 | 稳态逻辑内存 | 相对 list |
|---|---|---|
list[bool] |
8.06 MiB(8,448,728 B 指针槽) | 1× |
bytearray |
1.07 MiB | 约 1/8 |
bitarray |
126 KiB(ceil(N/8) 位) | 约 1/66 |
BoolHybridArr dense |
122 KiB(ceil(N/8) 个 numpy uint8 + ctypes 位读写) | 约 1/68 |
BoolHybridArr 稀疏 1% |
39 KiB(1 万个异常下标 ×4 B) | 约 1/216 |
BoolHybridArr 稀疏 0.1% |
4 KiB(1 千个异常下标 ×4 B) | 约 1/2045 |
几个必须讲清的点,免得数字被误读:
- dense 段不是"零依赖 / 不依赖 numpy"。那 122 KiB 位图缓冲是
np.zeros(ceil(N/8), dtype=uint8),靠 ctypes 按位访问;BHA 运行时硬依赖 numpy>=1.19(setup.py 的 install_requires 写死)。省内存来自"按位打包 N/8",不是来自每个 bool 1 字节的numpy.bool_。 - 进程 RSS 有一块约 39 MB 的"地板",那是 import numpy 带来的固定运行时开销,任何用 numpy 的进程都有,跟你建几个布尔数组无关。对象的边际占用才看上面那张表。
- 构造比朴素结构慢:一次性喂生成器建百万数组,list/bytearray/bitarray 约 0.2~0.3 秒,BHA 约 5.7~7.4 秒,因为它要逐元素决策形态、按位写或二分插入。构造期
tracemalloc瞬时峰值约 5~6 MB,稳态回落到表里的几 KB~一百多 KB。这是一次性成本——正确姿势是一次性批量构造(或对 a、b 两段都optimize()),别在热路径里逐个 append。
七、回到停车场,到底怎么选
把延迟和内存摆在一起,选型其实很清楚:
- 固定长度、密度高、追求极致点查/全表 popcount:
bitarray是最锋利的那把刀,44 ns 点查、0.034 ms 计数、126 KiB。 - 不想引第三方 C 扩展、只用标准库:
bytearray,1 B/个、50 ns,简单可靠。 - 稀疏标记 + 频繁动态增删 + 想"一个数组自适应":这是
BoolHybridArr的主场。深夜 0.1% 占用时它只占 4 KiB,高峰期占用率涨到 50%~60% 时它已切到表里的 dense 形态、也才 122 KiB(所以这不是只挑深夜稀疏场景说话,高峰高密度那行就是 dense),中间换挡它自己来,不用你重写两套结构;类 list 的 append/insert/翻转都能直接用,还带批量位运算。 - 停车场点查 0.9 μs、翻转 1.2 μs,相对动辄上百毫秒的网络预算完全可以忽略;换来的是稀疏时省上千倍内存、且不用人工判断"现在该用位图还是下标表"。
最小上手:
pip install -U bool-hybrid-array
from bool_hybrid_array import BoolHybridArr
# 注意:类名是单数 BoolHybridArr;用生成器喂,别用列表推导式
occupied = BoolHybridArr(i % 1000 == 0 for i in range(1_000_000)) # 0.1% 占用
print(occupied[5000]) # 随机点查 -> True
occupied[123456] = True # 车辆进场,翻转一位
occupied[1000] = False # 车辆出场
print(occupied.count(True)) # 稀疏段计数,近乎免费
print(occupied.memory_usage()) # 逻辑记账,单位字节
八、几条避坑提示
- 用生成器,别用列表推导式喂构造器;
- 看稳态内存,别只看构造瞬时峰值;逐个添加会停在 sparse 中间态,要省内存就一次性构造,或对两段都
optimize(); - 分清"自动选形"和"自动回缩":构造和日常增写时,它会按当前稀疏度自动选 sparse / dense;但一旦先冲密成了位图、再大批把位改回稀疏,那块已建好的位图不会在你赋值时自动缩回去(否则一次轻量赋值可能偷偷触发整表 O(N) 重建),要等活动结束后离线
optimize()一次、或按结果用生成器重造一个。日常点查、翻转写入都不需要调optimize(); - 测内存最好独立进程,或者测前彻底
del+gc.collect()再重开tracemalloc,别让多个大对象共处一个记账会话; - BHA 硬依赖 numpy>=1.19,不能宣传成"零依赖";
- 高密度全表 popcount、纯内层循环点查几百万次的场景,bitarray 仍然更快——按访问模式选,而不是默认一个通吃。
九、几个一定会被问到的问题
Q1:点查慢十几倍,高并发热点还能用吗?
看的是绝对延迟和你的预算,不是倍率。0.9 微秒意味着单核理论上每秒能扛上百万次点查,真正的瓶颈几乎一定在网络、数据库和锁上,不在这一位的读取。只有当你把它放进无 I/O 的紧密内层循环、连续点几百万次时,才需要换成 bitarray 或直接用位图。
Q2:那我干脆全用 bitarray 不就行了?
固定长度、密度高,这么选完全正确。但 bitarray 是定长按位结构:哪怕只有 0.1% 的位是 True,它照样占满 N/8(百万位约 126 KiB);要在中间动态插入、删除一位而保持"类列表"语义,也得自己处理后续位的搬移。BHA 多付一点访问包装的开销,换的是稀疏时按异常下标收缩到几 KB、以及开箱即用的类 list 增删和自适应换挡。
Q3:numpy 也能存布尔,为什么还要它?
numpy.bool_ 每个元素占 1 字节,百万个就是约 1 MB,且 numpy 的主场是定长张量级运算,不是频繁的单条 append/insert/delete。BHA 的 dense 位图按位压到 N/8,sparse 段再按异常下标压缩,并提供类 list 的动态接口——解决的是"变长、稀疏、要频繁改"这一类 numpy 不那么顺手的需求。
Q4:所以停车场到底选谁?
泊位状态这张主表用 BoolHybridArr:深夜稀疏省内存、白天涨成 dense 也只占一百多 KB,进出场翻转、查空位都在微秒级;如果另有"离线批量统计全部历史占用率"这种高密度全表 popcount 任务,把那段数据交给 bitarray/numpy。让每种结构待在它擅长的环节,而不是指望一个库通吃。
项目信息:bool-hybrid-array 当前版本 9.13.11,许可证 Apache-2.0 OR MulanPSL-2.0(注意 MulanPSL 是 2.0),要求 Python >=3.8。
它不是"什么都最快"的冠军库:纯点查它跑不过朴素位数组,dense 全表聚合也不是 popcount 路径。它真正解决的是"稀疏度会变、还要动态增删、又想把内存压到 N/8 甚至更小"这一类问题,并且让你不用自己在位图和异常下标表之间来回切换。知道它的边界、用对姿势,它就是一把趁手的工具;至于你那条路径到底值不值得,别信我,信你自己机器上的测量。
延伸阅读
- 想看更全的横向对比(list / numpy / bitarray / RoaringBitmap / bool-hybrid-array 等,含内存与随机点查):百万到上亿个布尔值到底怎么存?我把 list、numpy、bitarray、RoaringBitmap、bool-hybrid-array 全压了一遍(2026 真机选型)
- 许可证双许可、numpy 硬依赖、dense/sparse 取舍、和 bitarray 怎么选等集中解答:bool-hybrid-array 常见问题 FAQ:许可证、numpy 依赖、dense/sparse、和 bitarray 怎么选(2026 真机 9.13.11)
浙公网安备 33010602011771号