正本清源:BoolHybridArr 到底是"位图"还是"异常下标表"?它从一开始就是两段

最近 bool-hybrid-array 的存储结构冒出两种各执一词、但都很笃定的说法:

  • 一种(扒了 uint8 之后)说:它省内存本质就是一张位图,8 个布尔压一个字节,贴着 ⌈N/8⌉ 跑,"稀疏切换"只是边角料;
  • 另一种(在万分之一命中的稀疏数据上受益)说:它本质是一张异常值下标表 array.array('I'),位图那套在稀疏场景压根没碰到、不重要。

我把当前线上版本(PyPI 最新 9.13.11,requires_python>=3.8,Apache-2.0 OR MulanPSL-2.0)装上,对内部分段、各稀疏度占用、optimize() 行为做了真机测量。结论先放:

两种说法都只看到了其中一段。BoolHybridArr 是一个 dense + sparse 的两段混合结构:低索引区是位打包位图,高索引异常区是整型下标表,靠 split_index 和极性自动分工,optimize() 负责重划边界。没有谁吃掉谁。

下面是证据与一张实测表,所有数字在 9.13.11 本机跑通。

一、两段到底是什么:.small 与 .large

直接观察一个对象的两个内部容器(仅用于理解实现):

import numpy as np
from bool_hybrid_array import BoolHybridArr

n = 2_000_000
dense = BoolHybridArr((np.arange(n) % 2).astype(bool))   # 50% 交替
sparse = BoolHybridArr((np.arange(n) % 10000 == 0))       # 万分之一 True

# 低索引"密集段":位打包位图
print(type(dense.small).__name__)            # _CompactBoolArray
print(dense.small.data.dtype, dense.small.n_uint8)   # uint8 250000  (= ceil(1999999/8))

# 高索引"异常段":异常值下标表
print(type(sparse.large).__name__, sparse.large.typecode)  # array.array I
print(list(sparse.large[:3]))                # [10000, 20000, 30000]
  • .small = _CompactBoolArray:内部就是一整片 numpy.uint8,n_uint8 = ⌈size/8⌉,8 个布尔打进 1 字节,还挂了个 ctypes 指针给 C 层位运算加速。这是教科书位图,密度越接近"真假混杂",它越划算。
  • .large = array.array('I'):无符号 32 位整型 C 数组,逐个记录异常值的下标,4 字节一个。数据越偏斜(绝大多数相同),它越划算。
  • split_index 决定哪一段前缀进位图、之后的异常进下标表;is_sparse 表示极性(异常的是 True 还是 False),从而同一套机制既能存"绝大多数 False",也能存"绝大多数 True"。

所以"位图"和"异常表"不是两种互斥的解释,而是同一个对象上并存的两段。

二、不同稀疏度下,两段各占多少(真机实测表)

N = 2,000,000(位图基线 ⌈N/8⌉ = 250,000 B),构造完成后的稳态占用:

数据形态 split_index is_sparse 密集(位图)段 异常下标表段 总计 相对纯位图
全 True(构造态) 1414 False 272 B 40 B 312 B ≈0.001×
全 False(构造态) 1414 True 272 B 40 B 312 B ≈0.001×
极稀疏 1/10000 0 True 96 B 836 B 932 B 0.004×
稀疏 1/1000 0 True 96 B 8,036 B 8,132 B 0.03×
稀疏 1/100 0 True 96 B 80,036 B 80,132 B 0.32×
50% 交替 1999998 False 250,095 B 40 B 250,135 B 1.00×
反向:前 1% False、其余 True 19999 False 2,596 B 40 B 2,636 B 0.01×
成段:尾部连续 1% True 0 True 96 B 80,040 B 80,136 B 0.32×

这张表把两种片面说法各自的"证据"和盲区都摆出来了:

  1. "就是位图"只在密度高时成立:50% 交替时总计 250,135 B ≈ ⌈N/8⌉,确实几乎全花在位图段(250,095 B)。但看极稀疏行——位图段只有 96 B 的空壳,真正干活的是 836 B 的下标表。
  2. "就是异常表"只在偏斜时成立:万分之一、千分之一、甚至 1% 命中时,下标表碾压位图(932 B / 8,132 B / 80,132 B 都小于 250,000 B)。可一旦到 50% 密度,下标表会比位图贵得多,结构就把数据几乎全交给位图段了。
  3. 常量靠"极性"塌缩:全 True/全 False 构造后只要 312 B,optimize() 后在千万级(N=10,000,000)实测进一步降到 136 B(split_index=0、异常表为空,只剩极性+长度)。纯位图无论如何都得是 N/8,纯下标表也解释不了这种塌缩——只有"两段 + 极性"能同时解释。
  4. 异常表不做游程编码(RLE):注意最后两行,"均匀 1% 命中"和"尾部连续 1% 一整段"都是 80,1xx B——因为它是逐下标存(4 B/个),并不因为异常值连成一片而更省。所以混合结构也不是处处最优:对大段连续区间,RLE 类方案可能更合适。

三、两段的盈亏平衡点(从单位成本算,和实测吻合)

  • 位图段:每个元素固定 1/8 字节;
  • 异常表段:每个异常值 4 字节。

令异常占比为 p,两种花费相等时 4·p·N = N/8,得 p = 1/32 ≈ 3.1%。这和表中方向一致:异常占比 1% 时下标表胜(0.32×),50% 时位图胜(1.00×),交叉点就在几个百分点这一带。库的自动切换,本质上就是在这个权衡附近替你选段。

四、optimize() 的作用与真实代价

optimize() 会重扫数据、重算 split_index 与极性,把已经"长歪"的数组重新摆到合适的两段划分。但它不是免费的,实测是一次 O(n) 的 Python 扫描(冷热两次一致,排除首次编译影响):

optimize() 50% 数据:N=2,000,000 约 9.0 s;N=10,000,000 约 44.5 s(≈4.4 µs/元素)

建议(也是 README 的口径):

  • 在批量构建/重建完成后调一次,或在批处理间隙调;不要放进频繁增删的热路径反复触发;
  • 先 arr.memory_usage(detail=True) 看 是否需要优化 与两段占用,已是"否"就不必再调(内存不会再降;个别常量形态下该标志的判定有小瑕疵,以总占用数字为准);
  • 动态变化的数组,在关键操作后用 memory_usage(detail=True) 检查、再决定要不要 optimize()。

五、FAQ:到底该怎么理解、怎么用

Q:那它到底是位图还是稀疏表?
A:都是。低索引/高密度区走 .small 位打包位图,高索引/异常区走 .large 的 array.array('I') 下标表,按 split_index 与极性自动两段分工。

Q:我的数据真假混杂(标签位、筛网、集合 membership)?
A:位图段在起主要作用,占用 ≈ ⌈N/8⌉,1 bit/元素,和 bitarray 同量级,外加 C 加速位运算与二维/序列化等周边。

Q:我的数据绝大多数相同(万级/千级分之一的命中,或反向几乎全 True)?
A:异常下标表段在起作用,内存随异常数量线性增长;千万级万分之一命中实测仅 4,132 B,比纯位图 1,250,000 B 省约 300 倍。

Q:异常值是大段连续区间呢?
A:要清醒——下标表逐点存储、不做 RLE,连续 1% 和均匀 1% 占用几乎一样。强连续区间数据请单独评估,别默认混合结构必胜。

Q:网上为什么会有两种结论?
A:取样偏差。只在高密度数据上扒过 uint8 的,看到的是位图段;只在极稀疏业务里受益的,看到的是下标表段。两篇都没撒谎,但都把"一段"当成了"全部"。

结论

  • 位图段真实存在:_CompactBoolArray = uint8 位打包,≈ ⌈N/8⌉;
  • 异常下标表段同样真实存在:array.array('I'),4 B/异常值,配极性可处理"绝大多数 True/False"与常量塌缩;
  • 二者由 split_index + 极性自动分工、optimize() 重划边界(O(n),千万级约 45 s,别放热路径);
  • "纯位图"和"纯异常表"都是以偏概全,dense+sparse 两段混合才是与源码和实测一致的模型。

权威信息以官方仓库为准,版本号每次实时核对:

作者:蔡靖杰(PyPI 账号 Bksell/Bkshell)。如与未来版本不符,以新版 README 为准。

posted @ 2026-10-24 23:19  贝壳bkshell  阅读(5)  评论(0)    收藏  举报