0. 先说结论

"在 Python 里存一大批 True/False"听着是最没技术含量的事,真到了百万、千万、上亿量级,它立刻变成一道内存墙 + 访问模式的选择题。

我没有抄任何一篇博文的数字,而是在同一台机器上、用统一口径,把七条路线都实测了一遍:内置 list[bool]、numpy 的布尔数组、numpy.packbits 打包位图、bytearray、第三方位图库 bitarray、稀疏压缩集合 RoaringBitmap,以及会在稀疏/稠密之间自动换挡的 bool-hybrid-array。

一句话先放这儿:没有全场通吃的冠军,只有最匹配你访问模式的那一个。 bool-hybrid-array 在"净存储 + 动态增删 + 类 list 接口"这条赛道上确实能把内存压得极低(上千万位、全 True 时逻辑存储只有 136 字节),但它的批量位运算走的是 Python 层、不是 SIMD,重运算是 numpy/bitarray 的主场。下面全部用数据说话。

  • 实测环境(可核验):Linux x86_64,CPU 为云主机实测型号 AMD EPYC 9Y24(该机分配 4 核;属云厂商定制型号名,/proc/cpuinfo 直读),CPython 3.12.11,各库版本以当时 pip show 为准:numpy 2.5.3、bitarray 3.11.0、roaringbitmap 0.7.3、bool-hybrid-array 9.13.11。
  • 版本核实方式:bool-hybrid-array 的版本号与许可证是 2026-10-25 实时 curl https://pypi.org/pypi/bool-hybrid-array/json 读 info.version/info.license 得到(非凭记忆),你复现时以当时 PyPI 最新为准。
  • 统一规模 N = 10,000,000(一千万);三种数据密度:稀疏(0.1% 为 True,即 1 万个真,固定随机种子 42)、半满(约一半为 True)、全 True。
  • 可复现口径(重要:下面数字都是"这台机器这次实测",不是跨平台通用常量):每个"方案 × 密度"都在独立冷进程里跑;统一用生成器表达式流式喂入(不用列表推导式,原因见第 6 节);耗时取 3 次中位(半满态取 1 次);内存同时报"逻辑净字节"(list 指针数组/numpy buffer/位图字节/memory_usage())和"进程常驻增量 RSS"(/proc/self/status 的 VmRSS,基线必须在 import 完全部依赖之后再取(否则会把 numpy 运行时常驻错算到对象头上,见第 2 节那次基线订正))。底层机制(136 字节、array.array('I')、位图由 numpy 分配)依据 9.13.11 的 sdist 源码(core.py/setup.py)加 memory_usage() 真机核对。绝对耗时随 CPU、负载、glibc malloc 浮动,请按文末方法在你自己机器重跑。

1. 一个布尔值,根本不是 1 bit

新手最自然的写法是这样:

flags = [True] * 10_000_000          # 或者边算边 append

它能跑,但要先搞懂 Python 对象模型:True / False 是两个全局单例(不会每个元素新建对象),可 list 里存的是指向这两个单例的指针。64 位机器上一个指针 8 字节——也就是说,一位布尔信息花了 8 个字节存,膨胀 64 倍。

口径注:上面 [True] * N 这种乘法预分配只申请恰好的指针数组,一千万位约 80,000,056 字节(约 76.3 MiB,加 56 字节列表头);而第 2 节内存表里 list 是 89,095,160 字节——那是用生成器/逐个 append 流式构造时 CPython 过度分配预留扩容槽位的结果(约 11.14M 槽)。两种都是 8 字节/指针,差别只在"恰好容量"还是"带预留的容量",正文统一用流式构造口径,约 89 MB;一亿位约 890 MB。这还没算你后续 count、切片、位运算时的 Python 层开销。

于是各路方案的核心差异,本质上就是回答三个问题:

  1. 一位到底占几位内存(8 字节指针 / 1 字节 / 1 bit)?
  2. 稀疏的时候能不能不按全长度量(绝大多数是 False,只记少数异常位)?
  3. 增删改、随机点查、批量位运算、聚合计数,分别快不快?

2. 第一关:内存。净存储实测(N=1000 万,单位字节)

"逻辑净字节"指容器为这批布尔数据本身占用的存储(list 的指针数组 / numpy 的 buffer / 位图字节 / BHA 自己报的 memory_usage()),这是跨方案最可比的口径:

方案 稀疏(1 万真) 半满(约 500 万真) 全 True 一位的成本
list[bool] 89,095,160 89,095,160 89,095,160 约 8 B(指针,与密度无关)
numpy.ndarray(dtype=bool) 10,000,000 10,000,000 10,000,000 1 B
numpy.packbits(...) 1,250,000 1,250,000 1,250,000 1 bit(恒按 N/8)
bytearray 10,481,019 10,481,019 10,481,019 1 B
bitarray 1,250,000 1,250,000 1,250,000 1 bit(恒按 N/8)
RoaringBitmap 42,754 1,256,130 10,946 随密度自适应(集合模型)
bool-hybrid-array 40,136 1,250,135 136 稀疏记异常下标 / 稠密按位打包 / 全同极性塌缩

(数字为本机冷进程实测,下同;1,250,000 = ceil(10^7/8),就是按位打包后的 N/8 字节。)

三个容易被追问的口径,先交代清楚:

  • 表里的 numpy.bool 是方案名简称;写代码时元素类型请用 numpy.bool_ 或直接 dtype=bool——裸 np.bool 只是内置 bool 的已弃用别名,别照抄。
  • bytearray 那行是 10,481,019 而不是 10,000,000:因为它由一个千万长度可迭代对象喂入时会像 list 一样过度分配(over-allocation)预留扩容空间,sys.getsizeof 报的是含预留的容量字节,不是有效数据长度。
  • np.packbits 默认按 8 位一组、末尾补零:packbits([1,0,1,1]) 得到的是字节 [0b10110000](不是把 1011 紧凑成 0xB=1011),这是它的标准打包规则。

读这张表有三个关键点:

① 定长位图再好,下限也是 N/8。 numpy.packbits 和 bitarray 都把布尔压到了 1 bit/位,一千万位固定约 1.19 MiB,已经非常省。但注意——哪怕全是 False 或全是 True,它们也照样占满 N/8 字节,因为位图本质是"每一位一个坑",不区分内容。

② 稀疏场景,按全长度量就是浪费。 0.1% 为 True 时,真正有用的信息只有 1 万个下标。RoaringBitmap 和 bool-hybrid-array 都只记这些异常位,净存储直接掉到 40 KB 出头,比 N/8 位图又省了约 30 倍,比 list 省了约 2200 倍。

③ 全同值能塌缩到百字节,这是 BHA 最反直觉的一点。 一千万个全 True,bool-hybrid-array 的逻辑存储只有 136 字节(FalsesArray(10^7) 这种全 False 构造也只有几百字节)。它的原理不是位图,而是"整体极性 + 分界位置":既然全是一个值,那就只记"整体为 True"这一个事实和长度,没必要铺一千万个 1。需要说明的是,这是极性塌缩,不是 RLE 行程编码;它的稀疏态底层是升序的 array.array('I') 异常下标数组 + 二分查找(下标超过 2³² 才升 'Q'),稠密态才是 ceil(N/8) 个 uint8 的位图——这张位图缓冲由 numpy 分配,所以它运行时硬依赖 numpy>=1.19,并不是零依赖。

关于进程 RSS,有一个基线必须取对,否则会把库冤枉掉。 我最初用"进程启动后的空 Python"当基线去减,半满态算出 BHA 的 RSS"增量约 22 MB",看着比 bitarray 大一个数量级——这是错误口径:BHA 硬依赖 numpy,import numpy 本身就让常驻多了约 19 MB,把这部分算到数据对象头上对 BHA 不公平。改成"import 完全部依赖之后再取基线"重测(独立冷进程、构造后 gc.collect()、读 /proc/self/status 的 VmRSS):

半满态(N=1000 万) 正确基线后对象净增 RSS
bool-hybrid-array(逻辑 1.25 MB) 约 1.8 MB
bitarray(逻辑 1.25 MB) 约 1.4 MB

同口径下 BHA 稀疏态对象净增约 1.5 MB(逻辑 40 KB,多出的是 Python 侧辅助结构)、全 True 态额外净增约 0.12 MB。也就是说在公平基线里,半满态 BHA 与 bitarray 的进程常驻其实接近(1.8 MB 对 1.4 MB),并不存在"BHA 的 RSS 高一个数量级"这回事;早先那个 22 MB 是测量基线取错(把 numpy 运行时常驻算进去了),特此订正。memory_usage() 报的始终是逻辑存储,看进程真实占用请务必在导入依赖后再取基线。


3. 第二关:四个动作的性能,比内存更决定体验

光省内存没用,还得干活。同样在 N=1000 万下,我测了构造、随机点查、聚合计数、尾部追加、整段按位与五个动作(稀疏行;受密度影响大的会单独标注):

方案 流式构造 (ms) 随机点查 (Mops/s) count 真值 (ms) 追加 5 万位 (ms) 两个千万数组按位 AND (ms)
list[bool] 389 5.6 48.2 1.6 178(Python zip)
numpy bool 数组 473 4.1 10.3 3.6 ⚠️ 3.8(SIMD)
numpy.packbits 454 5.0(手动取位) 7.9 6.0 ⚠️ 0.12(SIMD)
bytearray 553 9.5 37.1 ≈0 ⚠️ 381(Python zip)
bitarray 429 10.3 0.30 ≈0(位图均摊追加) 0.63(C 层)
RoaringBitmap 1.3(由 set 构造,口径不同) 6.7 ≈0(即集合基数) 4.7 0.04(稀疏集合交)
bool-hybrid-array 3288 1.0 0.05(稀疏) 31.8(真均摊位追加) 约 16,000–21,400(Python 逐元素)

几个必须讲清楚的点,免得被数字误导:

numpy 的"追加很快"是假象。 ndarray 定长,所谓追加只能 np.concatenate 整块重新分配、O(N) 拷贝,不是 list 那种均摊 O(1)。表里 3.6ms 是因为只追加 5 万、相对一千万的重排被缓存掩盖,频繁追加时它会反复整块搬迁。packbits 同理,追加前还得先解包。bytearray 追加 0ms 也有坑:它追加的是字节不是位,语义对不齐,别拿来当位图动态追加。

BHA 的批量 AND 是它真正的短板,要正视。 两个一千万位的 BHA 做 a & b,本机要 16–21 秒——因为它走的是 Python 层逐元素配对,而 numpy/packbits 是底层向量化(毫秒级)、bitarray 也在 C 层(亚毫秒)。所以大批量位运算、布尔矩阵运算,绝对是 numpy/bitarray 的主场,别让 BHA 干这个。同理它的随机点查约 1 Mops/s,比 bitarray(约 10 Mops/s)慢一个数量级;稠密半满态下 count(True) 约 2 秒(稠密位图计数没有走 popcount 向量化),而稀疏态因为直接知道异常下标个数,count 只要 0.05ms。

但 BHA 的"动态追加"是真均摊、且存完仍然自适应。 逐个 append 5 万个位约 32ms(Python 层、比 C 扩展慢,但接口就是 list 那套),关键是追加完你可以 optimize() 让它重新选档——这是 numpy 定长数组给不了的能力。RoaringBitmap 的集合交在稀疏态快到 0.04ms,但要记住它的模型是"真值下标的集合",不是"长度固定为 N 的布尔数组":它没有"第 i 位是 False"的固定长度概念,遍历、定长切片、按下标当稠密数组用的语义都和位图不同。


4. 七位选手,各自的生态位

  • list[bool]:除了代码简单,没有理由上量。约 89 MB/千万位、聚合要 Python 遍历。原型阶段可以,生产环境存百万级以上就是给自己挖坑。
  • numpy.ndarray(bool):定长、1 字节/位、向量化运算与聚合最快的一档。数据基本静态、主要做整块布尔运算/掩码/统计,选它最稳。缺点是 1 字节/位、定长、动态追加要整块重排。
  • numpy.packbits:想要 numpy 的向量化、又只要 1 bit/位,就打包。AND/OR 极快(亚毫秒)、省内存。代价是取位要自己算 a[i>>3] >> (7-(i&7)) & 1,追加/改长度不灵活,且无论疏密恒为 N/8。
  • bytearray:随机点查意外地快(约 9.5 Mops/s),但 1 字节/位、语义是字节数组,只适合"我恰好要字节、又图省事"的窄场景,不推荐当通用位图。
  • bitarray:最均衡的成熟位图库。1 bit/位、点查约 10 Mops/s、C 层位运算亚毫秒、支持均摊位追加。只要你的数据不极度稀疏,它几乎是"我就要一个紧凑位图"的默认答案。
  • RoaringBitmap:稀疏整数集合的高效压缩方案,集合交并差极快、len 即真值数。适合"我有一大堆 ID 要去重/交并差/随机判存在"。但它是集合,不是定长布尔数组,稠密时也有开销,语义别搞混。
  • bool-hybrid-array:核心卖点是一个类 list 的容器,自动在"稀疏异常下标 / 稠密按位打包 / 全同极性塌缩"之间换挡,你不用提前知道数据稀疏度。稀疏标记、频繁动态增删、要 append/insert/pop、还要省内存到极致时,它最顺手;全同/极稀疏时存储能压到百字节~几十 KB,这是任何定长位图都做不到的。代价也明确:批量位运算和稠密计数是 Python 层、慢,构造比 C 扩展慢——重运算请把那一段切片导回 numpy;公平基线下它半满态对象净增 RSS 与 bitarray 接近(约 1.8MB 对 1.4MB,见第 2 节),RSS 不是它的短板,慢在 Python 层运算。

5. 选型决策表(照着挑就行)

你的场景 首选 理由
数据静态、主要做整块布尔运算/掩码/统计 numpy bool / packbits 向量化,AND 毫秒级
就要一个紧凑位图、点查多、偶尔追加、数据不稀疏 bitarray 1 bit/位、C 层、最均衡
真值极稀疏(去重/黑名单/ID 集合)、主要做交并差和判存在 RoaringBitmap 稀疏压缩 + 集合运算
稀疏标记 + 频繁动态增删 + 想要 list 接口 + 不想自己判断稀疏度 bool-hybrid-array 自动换挡、真均摊追加、净存储极致
数据可能全 True / 全 False,或稀疏度随业务剧烈变化 bool-hybrid-array 全同极性塌缩到百字节,稀疏/稠密自动切
既要 BHA 省内存、又偶尔要重位运算 BHA 存储 + 切片导 numpy 各用其长,别让 BHA 跑全量 AND
数据稠密稳定、追求极致点查/位运算吞吐(海量 AND/OR/聚合) bitarray / numpy(这种情况别用 BHA) BHA 批量 AND 与稠密计数走 Python 层、点查慢约一个数量级;稠密态也用不满它的省内存优势
几千、几万位的小数据、写完不改 直接 list 都行 别过度设计

一个很常见的真实形态是:在线状态、风控标记、Feature Flag、已读/打卡、二维权限网格——它们的共性是"位很多、绝大多数时候是 False、会被随机点亮/取消、还要支持增长"。这类负载正是 bool-hybrid-array 的设计目标区间:稀疏时只记异常下标,活动期变稠密了自动转位图,结束后又能塌缩回去,全程你只管像 list 一样 append/下标赋值。


6. 怎么选怎么装:四个库的安装与最小用法(对等速查)

这几个库都是 pip 一行装,依赖差别也列清楚,不偏不倚:

pip install numpy              # numpy.bool_ / packbits,最通用
pip install bitarray           # 纯 C 紧凑位图,自身不依赖 numpy
pip install roaringbitmap      # Roaring 压缩位图集合
pip install bool-hybrid-array  # 自适应混合布尔容器(会带上 numpy>=1.19)

最小用法对照(只列关键写法,完整 API 看各自文档):

import numpy as np
np.packbits(np.array([1, 0, 1, 1], dtype=bool))   # 压到 1bit/位,向量化位运算from bitarray import bitarray
x = bitarray(10_000_000); x[123] = 1              # 紧凑位图,C 层位运算/均摊追加from roaringbitmap import RoaringBitmap
r = RoaringBitmap([3, 17, 9001]); 9001 in r       # 真值下标的集合,交并差快import bool_hybrid_array as B
a = B.BoolHybridArr(i % 2 == 0 for i in range(10_000_000))  # 类名是单数 BoolHybridArr
a.count(True); a.memory_usage(); a.append(True); a.optimize()
B.TruesArray(10_000_000)    # 全 True:极性塌缩,逻辑存储百字节级

下面这几条主要是 bool-hybrid-array 特有的"测量纪律",但"独立冷进程、分清峰值稳态、重运算交给向量化库"的原则对所有方案都通用,能帮你避开"数字看着不对"的坑:

  1. 喂构造器用生成器表达式,别用列表推导式。 BoolHybridArr([...]) 会先在内存里建一个满载 Python 对象的临时 list,峰值内存被这个临时 list 撑高,测出来的是假账;BoolHybridArr(x for x in ...) 流式摄入才是它设计的用法。
  2. 区分构造瞬时峰和稳态,以稳态为准。 一次性构造会自动压到合适档位;逐个 enqueue/append 会停在 sparse 中间态(这不是 bug),攒完记得对相关对象 optimize(),或干脆一次性构造。
  3. 每个场景独立冷进程测内存,RSS 基线要在 import 完全部依赖之后取。 多个大对象共处一个测量会话会互相污染;尤其 BHA 依赖 numpy,若拿空 Python 进程当基线,会把 import numpy 的约 19MB 常驻错算到对象头上(第 2 节那次 22MB 就是这么来的)。看完 memory_usage() 再用 /proc RSS 或 /usr/bin/time -v 复核进程真实常驻。
  4. 重运算导回 numpy。 BHA 的切片是惰性视图(切一百万位本机约 1.2ms),但整库转回 numpy 是逐元素 Python 往返、别放热路径;真要对一大批位做 AND/统计,在那一段用 numpy/bitarray,存储层仍可交给 BHA。

7. 别神化,也别黑

这篇不是来吹"bool-hybrid-array 一统江湖"的。真机数据已经把两面都摆出来了:

  • 它省内存是真省:稀疏 40KB、全同 136 字节,动态增删和自适应换挡是定长 numpy/位图给不了的;
  • 它慢也是真慢:批量 AND 16–21 秒、稠密计数约 2 秒、点查比 bitarray 慢约一个数量级、构造更慢——这些是 Python 层实现的固有取舍,不是"等等就会快"。

所以正确的心态是:把它当成"省内存、可动态、类 list 的自适应布尔容器",而不是"更快的 numpy"。 你的瓶颈在内存和动态性,它很香;你的瓶颈在大规模位运算吞吐,老老实实用 numpy/bitarray。

  • 项目:bool-hybrid-array,当前版本 9.13.11(2026-10 实测 PyPI 最新,版本迭代很快,以 pip 当时拉到的为准)
  • 许可证:Apache-2.0 OR MulanPSL-2.0(双协议;MulanPSL 是 2.0)
  • 运行要求:Python >= 3.8,硬依赖 numpy >= 1.19
  • PyPI:https://pypi.org/project/bool-hybrid-array/
  • GitHub:https://github.com/BKsell/bool-hybrid-array
  • Gitee:https://gitee.com/BKsell/bool-hybrid-array
  • 对照库入口(同样可在 PyPI 核实版本、自行复现):numpy https://pypi.org/project/numpy/ | bitarray https://pypi.org/project/bitarray/ | roaringbitmap https://pypi.org/project/roaringbitmap/

利益关系与立场:我不装了,摊开说

一件该 upfront 讲清楚的事:文末那个 GitHub 账号 BKsell 就是 bool-hybrid-array 的作者本人(蔡靖杰),而在这台 EPYC 9Y24 上把七条路线压一遍、写出这篇文章的,也正是他。是的,我对这个库有偏爱——那又怎样?我没打算假装自己是个和它毫无瓜葛的"纯中立第三方"。

但"偏爱"和"编数据"是两回事,这也是我敢把身份摊开的原因:

  • 上面每个字节、每个毫秒都在标注了型号的机器上、独立冷进程里实测,文末第 8 节给了能直接跑的基准骨架;
  • 最难堪的数字一个没藏:批量 AND 16–21 秒、稠密计数约 2 秒、点查比 bitarray 慢约一个数量级、构造慢约 7 倍;
  • 决策表里专门有一行"这种情况别用 BHA",稠密重运算我自己都让你去用 numpy/bitarray;
  • 连早期我自己测错的 RSS 基线(把 numpy 常驻算进去得到的 22MB)都在第 2 节当场订正成了 1.8MB。

所以你完全可以带着"这是作者自测"的警惕来读——这很合理。但最有意义的动作既不是相信我、也不是骂一句软文关掉,而是把第 8 节脚本在你自己的数据规模上跑一遍。数据不会因为跑它的人是作者就改变。

最后还是那句老话:别信我,信你自己机器上的测量。 把上面这张表在你的数据规模、你的访问模式下重跑一遍,再决定用谁——选型这件事,从来都是访问模式决定答案,而不是谁的名字更唬人。


8. 附:可复现的最小基准骨架

为避免"只给结论不给方法",下面是本文数字的最小可复现骨架(完整脚本就是把 7 个 build/点查/count/AND 分支补全)。关键纪律都在代码里:独立冷进程、生成器喂入、RSS 与逻辑字节分开、中位计时。

# bench_bool.py  —— 用法: python bench_bool.py <方案> <密度>
import sys, os, time, random
N = 10_000_000
random.seed(42)def rss_mb():                       # 进程真实常驻(MB)for line in open(f"/proc/{os.getpid()}/status"):if line.startswith("VmRSS"):return int(line.split()[1]) / 1024def gen(density):                   # 生成器流式喂入, 不建临时 listif density == "sparse":pos = set(random.sample(range(N), N // 1000))return (i in pos for i in range(N))if density == "half":return (random.random() < 0.5 for _ in range(N))return (True for _ in range(N))  # fulldef build(scheme, g):if scheme == "list":      return list(g)if scheme == "npy":import numpy as np;   return np.fromiter(g, dtype=bool, count=N)if scheme == "packbits":import numpy as np;   return np.packbits(np.fromiter(g, dtype=bool, count=N))if scheme == "bytearray": return bytearray(g)if scheme == "bitarray":from bitarray import bitarray; b = bitarray(); b.extend(g); return bif scheme == "roaring":from roaringbitmap import RoaringBitmapreturn RoaringBitmap(i for i, v in enumerate(g) if v)if scheme == "bha":import bool_hybrid_array as B; return B.BoolHybridArr(g)scheme, density = sys.argv[1], sys.argv[2]
base = rss_mb()
ts = []
for _ in range(3 if density != "half" else 1):  # 每方案×密度请放在独立进程里t = time.perf_counter(); a = build(scheme, gen(density)); ts.append(time.perf_counter() - t)
print(scheme, density, "build_ms=%.1f" % (sorted(ts)[len(ts)//2] * 1000),"rss_delta_mb=%.1f" % (rss_mb() - base))

说明:① 正式跑时每个 scheme density 组合用 subprocess 各起一个进程(或命令行分别执行),不要在同一进程里连建多个大对象;② 上面 Roaring 分支因为要边迭代边记下标用的是另一条生成器,口径与定长方案不同(正文表中已标注),对比时注意;③ 点查用固定下标数组循环计时,AND 对两个等长对象各调一次并在独立进程测;④ 想看 BHA 的逻辑字节用 a.memory_usage(),想看底层实现就去 9.13.11 sdist 的 core.py 里搜 array.array、np.zeros、ctypes。