引言
np.packbits是 NumPy 内置的"布尔按位打包"工具:一整块bool数组喂进去,出来是ceil(N/8)字节的uint8向量,1 个布尔只占 1 bit。对一次性、定长、以只读/统计为主的场景,它是标准做法——1000 万个布尔从 10 MB 降到 1.25 MB,一行调用完成。
但有一类负载它不直接覆盖:下标空间百万到千万级,标记集合在运行中频繁追加、中间插入、中间删除,偶尔还会从稀疏变稠密。典型场景包括:
- IM 已读位图随消息到达不断追加
- 风控黑名单随审批流动态加人/移除
- 爬虫去重集合在分批抓取中扩展
- A/B 实验分桶标记在灰度放量中调整
这时packbits产出的是一块定长、静态打包的uint8 ndarray——NumPy 数组本身长度不可变。你要在末尾加一位,要么自己维护一个位游标、每次跨字节时np.concatenate(整块重分配),要么把它解包回布尔序列再packbits一次;要在中间插一位或删一位,整段尾部位都要平移。
很多人第一次用packbits时的直觉是:"我先把它 pack 成 1/8,后面慢慢 append 就行。"但packbits返回的是一块长度固定的uint8ndarray——它的字节内容可以原地修改,却没有append这类变长接口,也不会替你维护位偏移。你要么保留原始布尔数组(内存优势瞬间消失),要么在位级别自己维护可变结构(容易出跨字节位偏移 bug)。
本文在同一台 4 vCPU / 约 8 GB 的小机上,用冷进程实测np.packbits、bytearray、np.bool_ndarray、bitarray与bool-hybrid-array的BoolHybridArr在这一类负载下的真实内存与耗时,给出"什么时候该换、什么时候别换"的分界。
环境与基准口径
- CPU:AMD EPYC 9Y24(4 vCPU),内存约 8 GB
- CPython 3.12.11,numpy 2.5.3,bitarray 3.11.0,bool-hybrid-array9.13.11
- 所有基准在独立子进程中跑,测前
gc.collect(),耗时取 3 次time.perf_counter中位 - 规模上限 N=10^7(避免 8 GB 小机被
list[bool]拖垮) - BHA 构造器一律用生成器表达式喂入,避免临时
list被 tracemalloc 计入 - 内存分两个口径:容器自身字节(
ndarray.nbytes/bitarray位数 /BHA.memory_usage())与 tracemalloc 稳态current;前者只计底层存储缓冲(不含容器自身的 Python 对象头与簿记),后者在缓冲之外还计入 Python 包装对象开销,两者口径不同、不直接等同 - packbits 的"动态追加"分三种实现分别测:
- 向量化重建(
np.unpackbits整包 →np.concatenate追 5 万 bool →np.packbits) - 预分配 uint8 缓冲 + 位游标、新位先
packbits再 bulk 拷入 - 朴素逐字节
np.concatenate(每满 8 位扩一次,近似平方次拷贝,仅作反模式参照)
- 向量化重建(
- "中间插入/删除"走
np.unpackbits→ 拼接 →np.packbits重建 - 点查同时测 Python 逐位循环与向量化批量取位两种口径
第一组:稳态内存(N=10^7,半满,500 万 True)
| 容器 | 容器自身字节(真机) | tracemalloc 稳态 current(参考) |
|---|---|---|
bytearray(1 字节/布尔) | 10,000,057(约 10.0 MB) | 10,000,133 |
np.bool_ndarray | 10,000,000(10.0 MB) | 10,000,128 |
np.packbitsuint8 | 1,250,000(约 1.19 MiB / 1.25 MB) | 1,250,096(已剔除源 bool 数组) |
bitarray3.11.0 | 10,000,000 bit → 1,250,000 字节(约 1.25 MB) | 1,250,140 |
BoolHybridArr9.13.11(稠密态) | memory_usage()=1,250,135(位图本体 N/8≈1.19 MiB + 约 135 字节簿记) | 以memory_usage()为权威(tracemalloc 会重复计入其内部 numpy 缓冲,不单列) |
结论很直白:稠密、半满时,按位打包的三家(packbits / bitarray / BHA)位图本体都在 1.25 MB 量级,BHA 在稠密侧就是把布尔按位打包进ceil(N/8)个 uint8,和 packbits 同量级;memory_usage()=1,250,135 比理论下界 1,250,000 多出的约 135 字节是 Python 对象/头部簿记,不是额外的位图数据。
第二组:动态增删耗时(N=10^7 基线,中位 3 次)
| 操作 | np.packbits | bitarray 3.11.0 | BoolHybridArr 9.13.11 |
|---|---|---|---|
| 末尾追加 5 万位·向量化重建 | 约 17.7 ms(unpack→concat→repack) | — | — |
| 末尾追加 5 万位·预分配缓冲+游标 | 约 0.0007 ms(bulk 拷入,<0.01 ms) | — | — |
| 末尾追加 5 万位·朴素逐段 concat(反模式) | 约 1201 ms ≈ 1.2 s(每 8 位整块重分配) | — | — |
| 末尾追加 5 万位(原生接口) | — | 0.02 ms(extend) | 约 31 ms(逐元素append) |
| 中间插入 1 位 @ 500 万 | 约 17.3 ms(unpack+repack,全量重建) | 0.061 ms | 3.17 ms |
| 中间删除 1 位 @ 500 万 | 约 16.4 ms(unpack+repack,全量重建) | 0.068 ms | 0.23 ms(pop(5_000_000)) |
| 10 万次点查·Python 逐位循环 | 约 17 ms | 约 2 ms | 约 60 ms |
| 10 万次点查·向量化批量取位 | 约 0.24 ms | —(C 层批量取位另算) | 不适用(BHA 未提供批量取位原语) |
几个值得拆开看的点:
packbits 追加 5 万位到底多快,取决于你怎么写。预分配缓冲 bulk 拷入几乎免费(实测约 0.0007 ms),向量化重建约 17.7 ms——后者仍比 BHA 逐元素
append的约 31 ms 快。真正慢的是"每满 8 位就np.concatenate一次不断增长的打包数组"这种朴素写法(实测约 1.2 s),那是近似平方次拷贝的反模式,不是 packbits 的固有成本。但无论哪种写法,定长 ndarray 都没有内建的可变
append/insert/pop:你要么自己维护位游标、跨字节对齐、预分配冗余字节,要么整包 unpack→repack。这些胶水代码不复杂,但要写对、写稳、处理位偏移和边界,是一笔持续的心智开销。BHA 的价值不是"让追加快 100 倍",而是"把这些胶水收进类 list 接口里,你直接a.append(v)就行"。bitarray 在纯稠密吞吐上确实最快:
extend5 万位约 0.02 ms,中间插入/删除约 0.06–0.07 ms,10 万次 Python 层点查约 2 ms。它本来就是 C 实现的可变位数组,内部按机器字对齐做位级平移。如果你确认数据始终稠密、且下标空间不会爆炸,bitarray 就是最优解,没必要换。BHA 的逐元素
append约 31 ms,比 bitarray 的 C 层extend慢三个数量级、也比 packbits 一次性向量化重建(17.7 ms)慢约 1.8 倍——这是逐元素 Python/Cython 边界调用的固有开销。中间插入约 3.17 ms、中间删除pop(5_000_000)(N=1000 万的中位位置)约 0.23 ms,10 万次 Python 逐位点查约 60 ms。不要指望它在稠密点查或批量吞吐上跑赢 bitarray(点查约慢 30 倍)或 NumPy 向量化取位(后者仅约 0.24 ms)。BHA 的价值不在稠密吞吐,而在下一组:当下标空间巨大但实际 True 很稀疏时,packbits 和 bitarray 都得为整个下标空间付位图租金,BHA 却只存异常下标。
第三组:稀疏场景(大下标空间、极少 True)
当逻辑下标空间是 10^7、但实际只有 1000 个 True(占比 0.01%)时:
np.packbits:仍然必须先有覆盖 10^7 下标的bool源数组,打包后输出1,250,000 字节(约 1.25 MB / 1.19 MiB)——不管 True 多稀疏,位图长度由下标空间决定。bitarray:预分配 10^7 位,同样1,250,000 字节(约 1.25 MB)。BoolHybridArr(稀疏态):实测在 N=10^6、把 100 个 True分散在不同位置(每隔约 1 万下标一个)时,用生成器喂构造器,稳态memory_usage()只有532 字节,构造耗时约270 ms(冷进程三次中位)。稀疏侧用的是array.array('I')升序存储异常下标 +bisect查找,每个 True 仅 4 字节(uint32),没有任何 RLE 行程编码。
按"每个异常下标 4 字节 + 固定簿记"做线性理论估算(N=10^7/1000 个分散 True 这一档未逐项跑实测,实测锚点是上面 N=10^6/100 个分散 True 的 532 字节):BHA 稳态约 4 KB 量级(1000×4 + 簿记),相对 packbits/bitarray 的 1,250,000 字节约 1.25×10^6 ÷ 4000 ≈ 312 倍,量级上约 300 倍。
提示:若 100 个 True 集中在下标 0–99 的连续前缀,实测会进一步塌缩到 148 字节的极性/分界形态;本文取更有代表性的"分散点"口径。
这正是 BHA 与定长位图的差别所在:它在同一容器内前段按位打包(1 bit/布尔)、后段只存异常下标(4 字节/个),并按数据分布选最优分割(机制见下一节)。packbits的产物是整块定长位图,bitarray本身也是稠密位数组(要稀疏集合得另建索引)——二者都不在同一容器内按密度自动换挡:它们的位图缓冲随下标空间增长,而 BHA 的后段随异常值数量增长。于是 10^7 下标只标 1000 个位置时,packbits/bitarray 的位图缓冲仍是 1.25 MB,BHA 后段按异常下标计约 4 KB(理论估算)。这部分差额会进入冷启动内存、常驻进程 RSS 与序列化体积的口径里。
需要诚实说明两点。
其一,稀疏态点查走bisect在升序array.array('I')上二分,单次 O(log N_TRUE),比稠密态 O(1) 位读取慢。
其二,它没有一个写死的"密度低于百分之几就切换"阈值:构造器(9.13.11core.py)是沿每个候选分割点s算总开销 = 前段位图ceil(s/8)+ 后段异常下标 ×4 字节,取全局最小值(is_sparse未指定时还会分别按"存异常 True / 存异常 False"两种极性取更省的那个)。粗略看,全位图 N/8 与全下标 k×4 的打平点在 k≈N/32(约 3.1%)量级,但真实落点还会随分布与那个最优分割点变化,不是硬切的固定比例;另外N/16(size>>4,并与 √N 取小)这个数字只用于TruesArray/FalsesArray构造"全同极性"数组时的位图前缀长度,和上面的密度切换不是一回事,这里特别说明以免误读。这个最优点由构造器自动算出,不需要手调。
进阶:稠密⇄稀疏自动切换与流式中间态
BHA 在构造时会自动判定该走稠密还是稀疏:从生成器一次性构造时,它会扫描整段、算最优切换点,直接落到稳态。但如果你在循环里逐个append,过程中会停在一个"半稠密半稀疏"的中间态——既没有完全按位打包,也没有完全收敛到稀疏下标表。
实测对比(10,000 位、交替 True/False):
- 逐个
append10,000 次后不调optimize():memory_usage()=20,136 字节(5000 个 True 被当成 uint32 异常下标存着,比稠密打包的约 1,385 字节大 14 倍多) - 紧接着调
optimize():降到1,385 字节,耗时约53.9 ms - 直接用生成器一次性构造:约19.1 ms直接落到 1,385 字节,比"流式 append 1 万次 + optimize"那条路(append 本身另计)省去整段重排
所以工程上有两条正确姿势:
- 一次性批量构造:能拿到整段数据时,直接
BoolHybridArr(x for x in source),让构造器自己选最优形态,不要先 append 再 optimize。 - 流式追加后再切查询:如果只能边算边 append,查询前对同一个实例调一次
a.optimize()收敛形态;不要在每次 append 后都 optimize(那会把 O(1) 追加退化成 O(N))。
另外提醒:BHA 稠密侧运行时硬依赖numpy>=1.19.0,不是"零依赖";稀疏侧是array.array('I')+bisect,下标值达到 2^32 才升级'Q';非线程安全;当前版本双许可Apache-2.0 OR MulanPSL-2.0(9.12.7 单 Apache,更早为 MIT)。
集成 BHA 的写法
pipinstallbool-hybrid-arrayfrombool_hybrid_arrayimportBoolHybridArr# 1) 一次性批量构造(推荐,直接进稳态)a=BoolHybridArr(xforxin[True,False,True,True,False])# 2) 类 list 的动态接口a.append(False)# 尾部追加a.insert(2,True)# 中间插入a.pop()# 尾部弹出a.pop(0)# 头部弹出(头部/中间删除会触发存储重排,高频这类操作请看上面的耗时表,尾部 pop 更轻)a[1]=False# 下标赋值print(a[0],a[2])# 点查print(a.count(True))# 统计 True 个数# 3) 流式追加后收敛形态forvinmy_stream():a.append(v)a.optimize()# 查询前调一次# 4) 看内部形态print(a.memory_usage())# 字节数print(a)# 类似 BoolHybridArray(split_index=..., size=..., is_sparse=...)类名是BoolHybridArr(单数 Arr;实例type名显示为BoolHybridArray)。它的后段是"记少数派":is_sparse=True表示以 False 为基底极性、把少数的True下标存进array.array('I');is_sparse=False表示以 True 为基底、把少数的False下标存进array.array('I')。不传时构造器按两种极性谁更省自动选,也可显式传is_sparse=强制。
分场景选型结论
| 场景 | 推荐 |
|---|---|
| 定长、批量、只读/统计为主,N 已知 | np.packbits或bitarray,不必换 |
| 稠密、频繁点查、批量吞吐优先 | bitarray,C 实现最快 |
| 下标空间巨大(千万级+)、常态稀疏(True 占比极低)、需要类 list 动态增删 | BoolHybridArr,稀疏侧只存异常下标 |
| 常态稀疏但偶尔变稠密、想要同一接口无缝切换 | BoolHybridArr,构造/查询前optimize()即可 |
| 要求纯 NumPy 生态、不引入额外依赖 | np.packbits,但要自己承担动态增删的重排成本 |
一句话:np.packbits没有错,它就是为"一次性定长打包"设计的;预分配缓冲 + 一次packbits+ 一次 bulk 拷贝追加 5 万位实测约 0.0007 ms(0.7 微秒,不含下标/位游标维护),即便整包向量化重建也只要约 17.7 ms;bitarray 在稠密可变场景下同样极快(extend约 0.02 ms)。
BHA 也不是万能,它在稠密点查和批量吞吐上比 bitarray 和 NumPy 向量化都慢。它与前两者的差异集中在两点:
- 不用自己写位游标/跨字节对齐/重排代码,直接提供类 list 的可变接口;
- 后段按异常值数量而不是整个下标空间计内存,并在同一容器内按数据分布选最优的前段位图/后段下标分割。
因此在"下标空间巨大 + 常态稀疏或高度极性化 + 频繁动态增删 + 偶尔变稠密 + 需要类 list 接口"这组约束同时成立时,它在内存模型与接口上更匹配;约束不满足时,用 packbits 或 bitarray 更合适。
FAQ
Q1:np.packbits之后还能直接 append 吗?
A:不能。np.packbits返回的是uint8ndarray,长度固定,没有append方法。要追加有三条路:
- 向量化重建(
np.unpackbits→np.concatenate→np.packbits,实测 5 万位约 17.7 ms); - 预分配 uint8 缓冲 + 自管位游标、新位先
packbits再 bulk 拷入(实测约 0.0007 ms,几乎免费); - 朴素逐字节
np.concatenate(每满 8 位整块重分配,实测约 1.2 s,是反模式)。
前两条都不慢,但代价是你要自己写、自己维护位偏移和跨字节对齐胶水代码。BHA 的a.append(v)把这些收进类 list 接口,不是因为它比 NumPy 向量化更快,而是因为你不用再写这些胶水。
Q2:bitarray 不香吗,为什么还要 BHA?
A:稠密点查和批量吞吐上 bitarray 确实最快(实测extend5 万位约 0.02 ms、中间插入/删除约 0.06–0.07 ms、10 万次 Python 层点查约 2 ms,均显著优于 BHA)。bitarray 是成熟的 C 层可变位数组。但 bitarray 的内存随下标空间走——1000 万位就是约 1.25 MB,哪怕只有 100 个 True;BHA 在稀疏侧只存异常下标(实测 100 个分散 True 约 532 字节),并且自动在稠密/稀疏间切换。如果你确认数据始终稠密、下标空间不爆炸,用 bitarray 就对了。
Q3:要不要每次 append 都调 optimize()?
A:不要。optimize()是把整段重排到最优形态,对 1 万位约 53.9 ms,对千万级更贵。正确姿势是:流式 append 阶段不调,查询前或批量切稳态时调一次;能拿到整段数据时直接用生成器喂构造器,根本不需要手动 optimize。
Q4:BHA 是零依赖吗?
A:不是。稠密位图侧运行时硬依赖numpy>=1.19.0。另有两个可选 extras(不装也能正常用核心功能):extras 名numba-opt对应安装numba>=0.55.0,extras 名cycy-opt对应安装运行包cycy-runtime>=0.2.5——extras 名(cycy-opt)和它装入的运行包名(cycy-runtime)不是同一个字符串。稀疏侧只用标准库array+bisect。
Q5:BHA 是线程安全的吗?
A:不是。它是单进程单线程的可变容器,多线程并发写需要自己加锁;跨进程序列化/反序列化走它自带的__reduce__,但不要把它当共享内存用。
Q6:BHA 适合替代np.packbits做特征工程序列化吗?
A:多数静态特征场景不必。特征工程的典型路径是"一次性构造、批量落盘、下游只读",这正是np.packbits+npz/parquet的主场。需要区分数据分布:如果特征位图本身稠密、半满,那么构造完不再改时 BHA 的稠密位图本体与 packbits 同处 1.25 MB 量级(实测),还多一层 Python 对象,没有内存优势;但如果特征高度偏置/极稀疏(绝大多数是同一值、只有少量异常位),BHA 会落在稀疏形态、按异常值数量计内存,这时即便只读也可能明显更小。另外,只有当特征位图需要在服务进程里持续接收增量标记(比如实时更新的用户标签位)时,它的类 list 可变接口才有意义。
项目地址:PyPI | GitHub | Gitee(作者:蔡靖杰 / BKsell,许可:Apache-2.0 OR MulanPSL-2.0)
标签建议:Python布尔数组数据结构内存优化numpy性能优化