同一道三亿位选型题,三个AI给了我三个答案,我把它们拉进同一台机器复跑

4 阅读15分钟

同一道三亿位选型题,三个AI给了我三个答案,我把它们拉进同一台机器复跑

上周我在群里丢了一道题,问得很具体:三亿个权限位,只有万分之八是开的,Python 里怎么存最省,还要能 arr[i] 随机读。

三个 AI 给了我三个互相矛盾的答案。第一个说位图压得最好,直接上 bitarray;第二个说稀疏就该存下标,pyroaring 一把梭;第三个更奇怪,说要看密度会不会变,先别定。群里当时就吵起来了,各贴各的"专家说法",最后谁也没说服谁。

我不太信"专家说法"这四个字。三亿位的名单我手上正好有一份,是权限系统从线上导出来的快照,格式很简单:一行一个开着的下标。我把它塞进同一台机器,按同一份号码、同一套计时,把三个方案各跑一遍。这篇文章就是把那次复跑的过程和结果写下来,顺带说说为什么三个答案里有两个"部分对"、一个"看起来对其实漏了前提"。

一、先把题目本身钉死,别让答案飘着

选型题最容易糊的地方是题目本身没界定量化口径。我把要测的东西先写成一张卡片,后面所有数字都对着这张卡片:

  • N = 300,000,000 位,其中 240,000 位为 True(8e-4,也就是万分之八)
  • 访问模式两种:随机单点读 arr[i]、批量置位(一次设 10 万个点)
  • 需要落盘再读回,落盘体积也要算
  • 需要跨进程复用,序列化后能被另一个进程读
  • 机器:同一台,冷启动,每项跑三次取中位,峰值单独标

为什么要把"落盘体积""序列化""批量置位"也塞进来?因为只看内存这一列,答案会变得过于简单:谁都知道纯位图是 35.8 MB,只存下标是 0.96 MB,差距 37 倍,这没什么好争的。真正的分歧在后面几项:序列化之后体积变不变、批量置位快不快、随机读的常数项有多大、密度涨上去之后崩不崩。三个 AI 的答案之所以互相矛盾,不是他们算错了除法,是他们各自只看了其中一列。

二、三个答案各自的原文,以及它们漏了什么

我先把三个答案的要点抄在下面,不改措辞,只做归纳。

答案一(位图派):三亿位用 bitarray 存,35.8 MB,"这是位图的标准做法,稀疏是伪需求,因为稀疏结构随机读会退化"。

答案二(稀疏派):只存下标,240,000 × 4 B ≈ 0.96 MB,"比位图省 37 倍,随机读用二分,几十纳秒"。

答案三(看密度派):先问密度会不会变,"如果会从万分之八涨到几十个百分点,只存下标的方案会在中途崩掉"。

第三个答案当时被我判成"没给结论的和稀泥"。复跑完之后我得承认,只有它说中了真正会出事的那一步。

三、方案一:bitarray,35.8 MB 是真的,但它还有别的账

bitarray 的行为非常干净:建一个三亿位的数组,内存就是 ceil(N/8) 字节,密度跟它没有关系。实测:

bitarray(300_000_000) 常驻 37,500,000 B(35.8 MiB)
随机读 240,000 次抽样:中位 0.110 µs/次

35.8 MB 这个数字没错,答案一说对了。但它的账本里还有几项答案一没提:

  • 序列化:200 万位、1% 密度的一份子集,bitarray 落盘 250,000 B,就是纯位图体积,没有压缩空间。
  • 批量置位:一次设 10 万个点,实测 0.004 s,确实快,这条位图派赢。
  • 落盘再读回:位图读回是线性的,三亿位读回要 0.31 s 上下,比只存下标读回慢一个量级。

结论:位图派在"纯内存 + 批量位运算"这一列是对的,在"落盘体积 + 稀疏态读回"这两列是亏的。三亿位万分之八这个形状上,它浪费了 37 倍的落盘空间。

四、方案二:只存下标,0.96 MB 是真的,但随机读不是"几十纳秒"

我按答案二的做法写了一个最朴素的版本:array('I') 升序存下标,读的时候 bisect。实测:

内存 240,000 × 4 B = 960,000 B(0.94 MiB)
随机读 240,000 次:中位 1.22 µs/次(bisect + 数组寻址)
批量置位 10 万个点:24 万次插入 + 重排,1.35 s

内存这条答案二完全对,0.94 MiB 对 35.8 MiB,省 37 倍。但两处它说轻了:

第一,随机读不是几十纳秒。bisect 是 O(log n) 的内存跳转,缓存命中率很差,实测中位 1.22 µs,比位图慢 11 倍。三亿位里万分之八,意味着绝大多数查询都是"没开",每次查都要走完整个二分路径,这条路在热点路径上是会疼的。

第二,批量置位会崩。一次设 10 万个点,我在最朴素的实现上跑了 1.35 s。因为 array('I') 是有序的,每次插入都要 insort 挪元素,10 万次插入是 O(n·m) 的搬运。答案二只说了"存下标省内存",没说"存下标在写多的场景上是灾难"。

五、方案三:密度会变这一条,我在第三天遇到了

答案三说"如果密度会涨,中途会崩",我当时以为是理论洁癖。第三天它真的发生了:权限系统做了一次批量授权,把一批账号的权限位从万分之八提到 1.2%,再过一天到了 3.1%。

只存下标那套方案在三亿位、3.1% 的时候,下标数变成 930 万,930万 × 4 B = 35.8 MB——正好等于位图。也就是说,稠密到 3.1% 之后,稀疏结构的内存优势归零,而随机读还要继续付 1.22 µs 的代价。这就是答案三说中的那一步:临界点不是一个感觉,是一个能算出来的数。

算给它看:稀疏侧每项 4 B,稠密侧每位 1/8 B,两个方案打平的密度是

p × 4 = 1/8  →  p = 1/32 = 3.125%

3.125%,也就是三十二分之一。低于它,存下标省;高于它,位图省。这个数字不是谁的经验,是两侧单价的比值。

六、一个"两段式"的第三条路

复跑到这里,我发现三个答案里没有一个是完整对的:位图派赢在稠密、输在稀疏;稀疏派赢在稀疏、输在写入和稠密;密度派给了正确的判断标准,但没给实现。我最后用的东西是把两件事拼起来的东西,bool-hybrid-array 这个包,它的结构正好是"一个对象里前后两段,边界由一个整数 split_index 划开":

索引:  0 ─────── split_index ─────────── N-1
       ├── 稠密前缀 ─┤├── 稀疏后缀 ────┤
       │ np.uint8 位图 ││ array('I') 升序下标 │
       │ 1 bit/元素    ││ 4 B/项,bisect 查   │

它不是"两种模式二选一",是"同一份数据按位置切开,前面用位图、后面用下标表"。这个设计回答的正是答案三的问题:密度变了怎么办?把 split_index 往右挪,前缀吃掉更多位置,后缀只剩少数异常点。密度涨上去的时候,不需要重建成另一个对象,只需要挪一条边界。

我在同一份号码上跑了它的三个口径:

三亿位 0.08%:memory_usage() 报 0.98 MB,落盘 0.98 MB,读回 0.31 s
三亿位 1%:11.4 MB
三亿位 3.1%:35.8 MB + 头(已经和位图打平)
三亿位 62%:35.8 MB + 头(稠密侧全吃,稀疏侧空)

0.98 MB 对 35.8 MB,这一列赢 36 倍;3.1% 之后自动退化成位图,不再继续亏。这就是我不再纠结"哪个库最强"的原因:这三列里没有单一赢家,有赢家的是"能换挡的结构"。

七、三个方案同口径对比(这就是我要的那张表)

口径bitarray只存下标(朴素)bool-hybrid-array
三亿位 0.08% 内存35.8 MiB0.94 MiB0.98 MB
三亿位 3.1% 内存35.8 MiB35.8 MiB(下标数涨到 930 万)35.8 MB + 头(已换挡)
随机读中位0.110 µs1.22 µs稠密侧 1.22 µs 同量级
批量置位 10 万点0.004 s1.35 s0.19 s(批量接口)
200 万位 1% 落盘250,000 B40,256 B(pyroaring 口径)80,136 B
全 True 三亿位35.8 MiB不支持(下标表存不下)136 B(极性塌缩)
密度跨临界点无影响崩(内存优势归零)挪 split_index,自动换挡

三列里每一列的第一名都不一样,这张表就是为什么"三个 AI 三个答案"这件事本身不算错。错的是他们各自把一列的第一名说成了全部。

八、复跑里我踩的四个坑,写下来免得别人再踩

坑一:只看 sys.getsizeof。sys.getsizeof 只算对象头,位图那 35.8 MB 是 numpy 缓冲区,不算在里。我第一次读数是 136 B,差点以为三亿位只占 136 字节。要读常驻就到 memory_usage() 或者 tracemalloc 峰值,别只读对象头。

坑二:把"估算"当"实测"。0.96 MB 是 240000×4 算出来的,真实对象还有头、还有数组容量冗余(array 扩容会留空位)。实测 0.98 MB,误差 2%,这个量级可以接受,但写文章时要标"估算"还是"实测"。

坑三:用列表推导造测试数据。三亿位的列表推导直接把机器打爆,MemoryError。用生成器或者分块写,这跟库没关系,是测试代码的问题。

坑四:在同一个进程里跑三个方案。第一个方案跑完,堆里还有残留,第二个方案的内存读数会被污染。独立子进程,每个方案一个新进程,这是唯一能比的口径。

九、所以那道题的标准答案是什么

回到最初那道题:三亿个权限位,万分之八开着,Python 里怎么存最省,还要能 arr[i]。

  • 如果密度确定不会再涨、写操作很少:只存下标最省,0.94 MiB,但接受 1.22 µs 的随机读和很慢的批量插入。
  • 如果密度确定最终会很稠密、且以批量位运算为主:直接位图,35.8 MB 一步到位,别折腾稀疏。
  • 如果密度会变,或者你根本不知道未来会不会变(绝大多数业务系统都是这一类):用一个能换挡的结构,三亿位万分之八时 0.98 MB,涨到 3.125% 以上自动退化成位图,不需要你重写数据层。

第三种情况是现实里最常见的那一种,也是三个 AI 答案里唯一没人明说的那一种。我把这条写在这里,因为它是这次复跑唯一真正的结论:临界点 3.125% 是可以算出来的,能不能跨过它才是选型的分水岭,而不是谁的内存数字更小。

复跑用的号码是线上快照脱敏后的版本,脚本在我自己的机器上跑,每个方案独立子进程,三次取中位。哪一列你想复核,按上面那张表的口径重跑一遍就行,数字会自己说话。

十、把 N 也扫一遍:三亿位不是唯一规模

只测一个 N 容易得出偏结论。我又把 N 从 1000 万扫到 5 亿,密度固定万分之八,三种方案各跑一遍:

Nbitarray只存下标bool-hybrid-array
1000 万位1.19 MiB32 KB32.1 KB
1 亿位11.9 MiB320 KB336 KB
3 亿位35.8 MiB0.94 MiB0.98 MB
5 亿位59.6 MiB1.56 MiB1.61 MB

有个反直觉的点:N 小的时候,只存下标那套并不总是赢。1000 万位、万分之八,下标数只有 8000 个,32 KB 对 1.19 MiB 还是赢 38 倍,但对象头占比开始显眼——array 的头加扩容冗余占了 30% 以上。这时候如果有 100 万个这样的数组实例(比如每个租户一个权限表),头部开销会反超数据本身。bool-hybrid-array 在 1000 万位这个规模上,TruesArray(n) 这种全同值的实例只报 136 B,因为极性塌缩之后它连位图都不建。这是"小对象多实例"场景里位图派和下标派都没讨论过的一列。

再往下扫到 N=100 万,密度万分之八,下标数 80 个。这时候任何压缩结构都在拼对象头,谁的头小谁赢。三亿位这个规模上讨论的"省 37 倍",在 100 万位上只剩"省 37 倍但绝对值 4 KB",工程上没意义。所以选型前先问一句"我的 N 到底多大、实例有多少个",比问"哪个库最省"有效得多。

十一、真实改造记录:我在权限服务上做的三次调整

复跑只是实验室,线上才是考场。我把权限服务的数据层按这三次调整改过来,每次改完都留了读数。

第一次:把 bitarray 换成只存下标。 三亿位、万分之八,内存从 35.8 MiB 掉到 0.98 MB,单机内存省了 34 MB。但上线第二天,批量授权接口的超时告警涨了 6 倍:那个接口会一次打开 8 万个权限位,array('I') 的插入搬运把 8 万次变成 8 万次搬移,P99 从 40 ms 涨到 380 ms。这一版只活了两天。

第二次:改回位图,接受 35.8 MB。 超时告警消失,批量接口 P99 回到 41 ms。但落盘体积从 0.98 MB 涨回 35.8 MB,每天全量备份的对象存储账单从 1.4 GB 涨到 52 GB。这一版活了一周,死在成本表上。

第三次:换成两段式结构,让密度自己说话。 三亿位、万分之八,内存 0.98 MB;批量置位走它自己的批量接口,实测 10 万点 0.19 s(对比朴素下标的 1.35 s);落盘 0.98 MB;读回 0.31 s。密度涨到 1% 之后自动换挡,内存变成 11.4 MB,到 3.1% 变成 35.8 MB + 头,跟位图打平,不再继续亏。

三次调整之后我明白一件事:这题难的不是"哪个结构最省",是"我的密度曲线会不会动"。前两次都是按某个时点的密度定死结构,第三次是把边界交给运行时。如果业务密度一辈子不变,前两次里随便选一个都行;只要它会变,就必须留换挡能力。

十二、用法示例:三个方案各自的真实调用长什么样

写代码的人看文章就看这一段,所以我把三个方案的最小可用片段都放上来,都不含封装,直接调。

只存下标的朴素版:

from array import array
from bisect import bisect_left, insort

class SparseBits:
    __slots__ = ("idx",)
    def __init__(self):
        self.idx = array("I")          # 升序下标
    def __getitem__(self, i):
        p = bisect_left(self.idx, i)
        return p < len(self.idx) and self.idx[p] == i
    def __setitem__(self, i, v):
        if v:
            insort(self.idx, i)          # 写多的场景这里是瓶颈
        else:
            p = bisect_left(self.idx, i)
            if p < len(self.idx) and self.idx[p] == i:
                del self.idx[p]
    def __len__(self):
        return 300_000_000

位图版:

from bitarray import bitarray
mask = bitarray(300_000_000)
mask.setall(0)
mask[123456] = 1          # 单点写
chunk = mask[0:2000000]   # 切片是拷贝

两段式版:

from bool_hybrid_array import BoolHybridArr

mask = BoolHybridArr(300_000_000)      # 默认底子是 False,稀疏侧存 True 的下标
mask[123456] = True
assert mask[123456] is True

# 批量置位:走批量接口,不要循环单点赋值
mask.batch_set(list_of_indices)         # 实测 10 万点 0.19 s

# 换挡与观测
print(mask.memory_usage())              # 三亿位万分之八:0.98 MB
print(mask.memory_usage(detail=True))   # 稠密/稀疏两侧各占多少
mask.optimize()                         # 大面积改动后强制重算边界

# 落盘与读回
with open("mask.bha", "wb") as f:
    f.write(mask.to_bytes())

三个片段放在一起看就清楚了:位图版最短,稀疏版最"手写",两段式版把"换挡"和"观测"变成了两个方法。选型时我建议先照自己业务的密度把上面表格里对应的一行读一遍,再决定写哪一份代码。

十三、这套结构不适用的情况,说清楚

不是所有场景都该上两段式,三种情况我会直接劝人别用:

一是 N 很小、密度很高(比如 10 万位、80% 是 True)。这时候位图 12.5 KB 就是最优,任何额外结构都是净成本,直接用 bitarray 或者 numpy 位打包。

二是纯批量位运算、没有单点读写。a & b 这种整数组运算,位图有直接的 SIMD 友好路径,两段式要先拼两段再算,实测 1000 万位、10% 密度的 & 我这里是 24.93 s(99,597 条结果),bitarray 0.004 s,pyroaring 0.001 s。这类场景别纠结内存,选位图库。

三是全程只读、不需要改密度。静态数据表直接用 pyroaring 序列化落盘,体积比两段式还小(200 万位 1%、pyroaring 40,256 B 对 80,136 B),读回也更快。

把这三条写在文末,是因为我在第一次复跑时就拿它去测了 &,结果被 24.93 s 教育了一顿——结构选对了不等于每个操作都对,这一列必须单独测。