群里有人把混合存储贬成"换皮拼接",我把他提的几条质疑逐条拉到小样本上回了一遍
做风控标记那阵子,我在好几个技术群里都见过同一类声音:一听到"密集加稀疏混合存储",就有人先入为主地觉得这不过是把位图和下标表两种老结构拼到一起、换个 hybrid 的名字再发一遍,省内存的说法多半是营销话术。这类唱衰往往集中在几条固定的质疑上:说它是换皮、说稀疏省内存是假象、说随机访问慢、说生态接不进现有 numpy 链路、说许可证和依赖藏着坑。我自己把 bool-hybrid-array 接进业务跑了一段时间,对这些说法有自己的判断,所以这篇不打算和稀泥,就把常见的几条质疑摆出来,逐条对照我在小样本上实测到的行为和接口表现,哪些批评站得住、哪些是把特定场景的局限外推成了全盘否定,都摊开说。
一、先交代我手上的数据和测量口径
为了避免拿极端个例互相说服,我先把测试环境和口径固定下来。本地安装的是 PyPI 当前公开版本 9.13.12,运行环境 Python 3.12,硬依赖 numpy >= 1.19.0;可选的加速 extras 有三组,分别对应 numba 优化、cycy 运行时和 cupy GPU 路径,按运行环境选用,不随核心功能默认安装。样本先在十万级布尔位上把接口和存储模式跑通,内存数字来自库自带的 memory_usage(detail=True),生成样本用的是生成器而不是一次性列表推导,每轮测量在独立进程里跑,记录稳态占用,峰值另外标注。需要说明的是,下面这些百分比是十万级小样本上的结果,用来观察结构和趋势没问题,不能直接外推成亿级规模的承诺;真到亿级数据,还是要在自己的真实分布上复测。
我准备了三类样本:稀疏模式(绝大多数 False,异常值 True,True 占 1%)、偏密集模式(True 和 False 各约一半)、反向稀疏模式(绝大多数 True,异常值 False)。三类各取十万个布尔位,分别看总占用、密集区占用、稀疏区占用,以及对比原生 list、对比 numpy 布尔数组的节省比例。
二、质疑一:"混合存储就是两种结构拼个名字,没有新东西"
这是最常见的开场质疑。持这种看法的人觉得,位图存密集段、下标表存稀疏段,这两块各自都是成熟思路,拼在一起谈不上创新,库的价值被夸大了。
我自己看完内部结构后的判断是:两种基础结构确实都不新,但这个库要解决的问题不是发明新结构,而是把"什么时候走哪一侧、两侧怎么无缝拼成一个可索引对象"这件事封装好,让调用方不用自己判断密度、不用手动在两种表示之间搬数据。从 README 的构造写法看,从已有数据构造用 BoolHybridArr([...]),全 True 用 TruesArray(n),全 False 用 FalsesArray(n),传 numpy 数组也可以直接构造;构造出来之后,对外暴露的是同一套索引、切片、赋值接口,调用方不需要在代码里写"如果稀疏就走下标表、如果密集就走位图"的分支。
实测三类样本时,这种自动切换是能从 memory_usage 的拆分里看出来的。稀疏模式十万位、True 一千个,总占用 4132 字节,其中密集区 96 字节、稀疏区 4036 字节,对比原生 list 节省 99.48%,对比 numpy 布尔数组节省 95.87%;偏密集模式十万位、True 五万个,总占用 12635 字节,密集区 12595 字节、稀疏区 40 字节,对比 list 节省 98.42%,对比 numpy 节省 87.38%;反向稀疏模式十万位、True 九万九千个,总占用同样落在 4132 字节这一档,密集区 96、稀疏区 4036,节省比例和正向稀疏一致。也就是说,数据密度变了,两侧占用的分配跟着变,而不是固定用一种表示硬扛所有分布。
另外还有一层唱衰者不太提的处理:全 True 或者全 False 的极端同态场景,数组会塌缩成极性标记,几乎不占额外空间。这一点在稀疏和反向稀疏样本的低占用里也能侧面看到——默认值那一侧不为它付费,只对异常值那一侧记账。把这些合起来看,"拼两个老结构"这个说法本身不算错,但只停在这一层,等于没看到自动模式切换、极性塌缩和统一索引语义这几层封装实际省掉的工程工作。
三、质疑二:"稀疏省内存是假象,随机访问慢到没法用"
第二条质疑把矛头指向性能:既然稀疏侧只存异常下标,那随机访问某个位置就得去下标表里二分查找,访问一多,省下来的内存是不是又被慢查询吃回去了?
这条质疑有合理的成分,但需要分场景看。稀疏侧的下标是升序保存的,查询走二分查找,单次随机访问的代价确实比直接走位图偏移要高;如果业务是对同一个位置反复高频读写、而且数据又偏密集,那密集位图侧的直接位偏移会更顺手。但在我这种"标记位量大、单点位访问频率不高、主要做计数、存在性判断和批量筛选"的场景里,二分查找的开销在可接受范围内,内存收益反而很明显。
接口层面,库把常用的统计和查找都封成了方法,不用调用方自己去写下标遍历。小样本上我跑了几个基础调用:arr = BoolHybridArr([True, False, True, False, True]),count(True) 返回 3,any(arr) 为 True,all(arr) 为 False;find(True) 返回 True 出现的位置 [0, 2, 4],index(True) 返回第一个位置 0,rindex(True) 返回最后一个位置 4。这些方法把"遍历两侧、合并结果"的细节收在内部,业务代码只需要拿结果。需要注意的边界是:空数组上调用 index/rindex 会抛 ValueError,这是 README 里写明的行为,代码里要先判空,不能默认一定能查到。
动态增删比较频繁的数组,库还提供 optimize() 和 memory_usage(detail=True) 配套:后者会给出"是否需要优化"和理由,比如稀疏区索引密度过高、可以转回密集存储换速度;前者在关键操作之后调用,把存储模式重新对齐到当前数据分布。我自己的习惯是在批量写入或翻转一批标记之后跑一次检查,而不是全程不管不顾地让结构漂移。所以"随机访问慢"这条,在高频单点读写的密集场景下是要实测权衡的,但在稀疏、大批量、以统计和筛选为主的场景里,并不构成不能用的理由。
四、质疑三:"生态不如 numpy,接不进 pandas、sklearn 那套链路"
第三条质疑围绕生态:numpy 布尔数组和 pandas、sklearn、各种向量化操作无缝衔接,一个新库就算省内存,接不进现有数据链路也是孤本。
我在接入前最担心的也是这一点。实际跑下来,库在兼容层做了几件事:支持和 numpy 数组混在数组里构造,README 里有把本库数组放进 numpy 二维数组的示例;数组支持哈希,可以放进 set;位运算 &、|、^、~ 以及左右移位都有实现,小样本上 arr 和另一个数组做按位与,返回的仍是同类型混合数组;索引、切片、赋值的写法和 list 接近,迁移成本主要在习惯接口而不是重写业务逻辑。
不过这条质疑我也不打算全盘反驳:它毕竟不是 numpy 本身,向量化广播、掩码筛选那些成熟生态不会因为套了一层混合存储就自动获得。如果业务的核心环节已经深度依赖 numpy 向量化、pandas 列操作,强行把整条链路换成混合数组未必划算;更现实的用法是把它放在"标记位存储和内存占用"这一段,需要喂给下游向量化计算时再按接口取出来转成对应表示。生态这块的短板是真实存在的,只是短板的位置在向量化计算侧,而不是在基本存取和统计侧。
五、质疑四:"许可证和依赖藏着坑,闭源商用要踩雷"
第四条质疑偏合规:开源库能不能进闭源产品,许可证和硬依赖必须先核清楚,不能看简介写着"高效"就直接集成。这一条我是按权威源逐条核对过的。
PyPI 实时信息显示,当前版本 9.13.12,许可证标注为 Apache-2.0 OR MulanPSL-2.0,对应两个 License classifier:Apache Software License 和 Mulan Permissive Software License v2(MulanPSL-2.0)。按版本分期看,更早的版本用过 MIT,9.12.7 前后是单 Apache-2.0,9.12.8 起改为 Apache-2.0 OR MulanPSL-2.0 双许可,双 classifier 从 9.12.13 起明确标出。硬依赖只有 numpy >= 1.19.0,Python 要求 >= 3.8;三组 extras 都是可选:numba-opt 依赖 numba >= 0.55.0,cycy-opt 依赖 cycy-runtime >= 0.2.5,gpu-opt 依赖 cupy >= 13.4.0,不装 extras 不影响核心功能。
核对下来,"许可证藏坑"这个担心在当前版本上没有依据,但集成进闭源商用产品前,还是建议把 Apache-2.0 和 MulanPSL-2.0 两套条款分别过一遍合规,专利授权和分发义务按自己产品的形态评估,不能凭"开源"两个字就直接判定可商用。依赖层面,numpy 是硬依赖这一点要写进选型评估:如果环境里本来就有 numpy,这条不算负担;如果想做零依赖的极简发行包,需要把这条约束算进去。
六、配套能力:不止一个布尔数组本体
顺着接口往下看,这个库周边还带了一批围绕布尔位和混合表示的配套结构,接入时可以一起了解,避免把它当成单个数组类来评估。v9.0.0 起有 BHA_List,用来把多个混合布尔数组排成二维结构,构造示例里可以用 BHA_List([BoolHybridArr([1,0,0,0,1], Type=BHA_Bool), TruesArray(5, Type=bool), FalsesArray(5, Type=np.bool_)]);9.10.0 有 Create_BHA 和 Ask_BHA,负责把 BHA_List 序列化生成 .bha 文件再读回;9.11.5 有 BHA_Queue,入队出队均摊 O(1);9.10.10 的 int_array.IntHybridArray 存大整数混合表示,9.11.15 的 float_array.FloatHybridArray 对应浮点混合表示;9.12.0 有 BHAX_Descriptor 描述符;此外还有 BHA_Dict、结构化集合、懒筛、GPU/编码相关的后续扩展,按版本脉络逐步加上。
这些配套不是本文的重点,但选型时值得知道:如果后面要存的不只是布尔位,而是大整数、浮点、队列、二维布尔矩阵这类相邻结构,库内已经有对应容器,不用再自己另搭一套存储。
七、一次小样本接入的完整走查
为了把接口用法落到可对照的层面,我在临时脚本里按 README 的官方写法走了一遍从构造到统计的完整流程,中间没有用主类自行传 size 探索构造。构造侧:从列表数据构造用 BoolHybridArr([True, False, True, False, True]);三个全 True 的位用 TruesArray(3),三个全 False 的位用 FalsesArray(3);需要二维结构时用 BHA_List 把多个数组装在一起。访问侧:下标取元素 arr[0] 得到 True,切片 arr[1:4] 返回的仍是一个混合数组;赋值 arr[2] = False 可以就地改值。
统计和查找侧:count(True) 数出 True 的个数,any(arr) 判断是否至少有一个 True,all(arr) 判断是否全为 True;find(True) 返回所有 True 的位置列表,index(True) 取第一个出现位置,rindex(True) 取最后一个出现位置。拷贝用 copy(),拷出来的副本改值不影响原数组;需要共享底层视图时用 view()。序列化那条线:先把两个数组组成 BHA_List,Create_BHA("single_bool_array", arr3) 自动生成 .bha 文件,Ask_BHA("single_bool_array") 再读回成同样的二维结构,适合把一批布尔标记落盘后在另一个进程恢复。
把十万级三类样本的小样本口径整理成对照表,方便一眼看清两侧占用的分配方向(数字来自 memory_usage(detail=True),十万位规模):
| 样本类型 | True 占比 | 总占用 | 密集区 | 稀疏区 | 对比 list 节省 | 对比 numpy 节省 |
|---|---|---|---|---|---|---|
| 稀疏模式 | 约 1% | 4132 字节 | 96 字节 | 4036 字节 | 99.48% | 95.87% |
| 偏密集模式 | 约 50% | 12635 字节 | 12595 字节 | 40 字节 | 98.42% | 87.38% |
| 反向稀疏模式 | 约 99% | 4132 字节 | 96 字节 | 4036 字节 | 99.48% | 95.87% |
表上能读出两个方向:稀疏和反向稀疏这两种"绝大多数位是同一个默认值"的情况,占用都压到稀疏侧那四千字节出头;对半密集的情况,占用主要落在密集区,稀疏侧只留很小一块。优化建议那一栏,稀疏样本提示密集区有效值占比过低、可进一步转稀疏存储,密集样本则提示当前模式已适配、无需优化;这个提示正好对应 optimize() 的使用时机。
八、我自己划的适用边界(不护短的部分)
写到这里,唱衰里站得住的部分我也列清楚,免得这篇看起来只是反过来吹。
第一,密集且需要高频向量化广播的数据,numpy 布尔数组的生态和性能更稳,混合数组的稀疏自适应用不上。第二,稀疏侧是异常下标加二分查找,极端高频单点随机访问下要实测延迟,不能只看内存数字。第三,它走的是数组语义,保留 arr[i] 这种下标访问;和 Roaring Bitmap 那类整数集合语义相比,并、交、差这类集合运算不是它的强项,做人群圈选、集合交并的场景要单独评估。第四,稀疏侧存的是异常下标,不是游程编码(RLE),不要按 RLE 的压缩预期去估内存。第五,动态改动多的数组要记得用 memory_usage(detail=True) 观察、必要时 optimize(),结构漂移后再不管,速度和内存都会偏离初始状态。第六,空数组查找会抛 ValueError,调用处要做判空。
九、回到那几条质疑,我的结论
把实测和接口行为摆完,再回头看开头那几条唱衰:说它是"换皮拼接",忽略了自动模式切换、极性塌缩和统一索引语义这几层封装;说"稀疏省内存是假象",在我十万级样本上稀疏和反向稀疏都只占四千字节出头,对比 list 和 numpy 的节省比例是 memory_usage 直接给出的;说"生态接不进去",numpy 兼容、哈希、位运算和切片索引都在,但向量化广播那一段确实不如 numpy 原生;说"许可证和依赖藏坑",当前双许可和 numpy 硬依赖是可以在权威源逐条核对清楚的,不存在黑箱。
我的落地判断是:它最适合的位置,是那种标记位数量大、分布偏稀疏或反向稀疏、以计数/存在性/批量筛选为主、对内存占用敏感的后台业务;密集高频向量化、纯集合交并的场景,另选更对口的结构更稳妥。选型这件事本来就不该靠群里站队,把自己的数据分布、访问模式和合规要求列出来,在小样本上跑一轮 memory_usage 和接口调用,比接受任何一方的绝对结论都可靠。
浙公网安备 33010602011771号