BoolHybridArr 与 numpy / 原生序列互转 API 真机速查 · 边界答疑
从一个真实疑惑开始
有读者拿到一个 BoolHybridArr 实例 b,顺手敲了一行:
import numpy as np
print(np.asarray(b))
屏幕上喷出来的不是一串整齐的 True / False,而是一长串 0 1 0 0 1 ... 一样的整数,dtype 还是 int64。他愣了一下:这库不是专门存布尔的吗?为什么转 numpy 出来是整数数组?是不是哪里接错了?
这篇速查就在本机当版环境上,把「BoolHybridArr 与 numpy / 原生序列互转」的每条路径亲手跑一遍,给出真机返回值、正确写法和适用边界。文中所有数字均由独立冷进程实测,历史口径只作对照、全部重测,不照抄任何旧结论。
常见的连带场景是:上游用 BHA 存百万级掩码,下游喂给只认 ndarray 的流程。这时候最省事的做法不是改下游,而是在交接边界上显式转一次——np.asarray(b, dtype=bool) 一行,1MB 输出、约 0.96 s,拿到的就是标准 bool ndarray,谁都能接。本文把这条交接链路的每一档代价都量出来,就是为了让交接时心里有数。
机器与版本口径
- CPython 3.12.11 / numpy 2.5.3 / bool-hybrid-array 9.13.11
- CPU:Intel(R) Xeon(R) Platinum 8457C,4 核,约 7.9GiB 内存,Linux(本文所有耗时/内存数字均在这台机器上冷进程重测;换机器请按文末脚本自测,dtype / shape / 内存分档这类结构性结论在本文 numpy 版本区间内稳定,绝对秒数随机器浮动)
- 被测对象:N = 1,000,000,构造方式
bha.BoolHybridArr((i % 7 == 0 for i in range(N)), is_sparse=False),全程 True 共 142,858 个 - 每个场景单独成脚本、用 7 个独立冷进程各跑一次(
python3 /tmp/xx.py,进程内先 1 次热身丢弃再计),耗时取这 7 个冷进程结果的中位数;构造一律用生成器表达式喂构造器,不用列表推导式,避免满载临时 list 被统计进内存峰值 - 数字本机当版可复现,不同机器以自测为准;下文每张表里的数字都能在 /tmp 脚本里找到对应冷进程输出
先对齐两个内部结构
讨论互转之前,先把对象内部长什么样对齐,后面每条结论都挂得上。
稠密侧(is_sparse=False):布尔值按位打包进 ceil(N/8) 个 numpy uint8,一个布尔占一个 bit,位图缓冲就是 np.zeros(ceil(N/8), dtype=np.uint8);内部实现再借 ctypes 指针对这块缓冲做位读写(这是实现细节、不是对外承诺的 API 契约)。对 N = 1,000,000,位图只有 125,000 字节。省内存省在按位打包这件事上,而不是拿 numpy.bool_ 每元素 1 字节去凑。真机上 b.small.data 就是这块 uint8 缓冲,b.memory_usage() 返回 125,135 字节。
稀疏侧(is_sparse=True):只把「极性异常」的下标用 array.array('I') 升序存起来,配合 bisect 定位;下标超过 2^32 才自动升为 'Q' 容器。连续全 True 或连续全 False 的区段不单独记录,由整体极性塌缩表达——对象知道「默认极性是 True 还是 False」,再用升序下标表标出极性翻转的位置,读写时靠 bisect 逐段还原。所以全 True 对象在稀疏侧几乎不占下标空间,全 False 同理;这是极性塌缩的表达,不是对连续区段做别的压缩。
入口是工厂 BoolHybridArr(lst=(), is_sparse=None, Type=None, hash_=True, split_index=None),返回实例类型为 BoolHybridArray(公开基类),另有 TruesArray / FalsesArray 工厂和 IHA / FHA / SHA 子包。is_sparse=None 时构造器按输入形态自动选稠密或稀疏;想钉死稠密就传 is_sparse=False,本文全部实测都钉在这一档,方便和位图直接对照。实例非线程安全,跨线程共享请自行加锁。
真机互转速查表
表里的耗时分两类链路,先标清楚,避免把不等价路径当成同一种「互转方案」直接比快慢:
- ① Python 序列协议转换(
np.asarray(...)、切片转换、count):从对象逐个取元素、在 Python 层产出。 - ② 直读内部位图的 C 层解包(
np.unpackbits(b.small.data)…):绕过逐元素迭代,但读的是内部字段small.data,仅在钉死稠密态、了解内部布局时适用(见第 6 条)。
| 写法(①序列 / ②直读位图) | 返回 dtype | shape | 输出字节数 | 中位耗时(每场景 5 个独立冷进程取中位) |
|---|---|---|---|---|
① np.asarray(b)(默认) |
int64 | (1000000,) | 8,000,000 B | 0.9847 s |
① np.asarray(b, dtype=bool) |
bool | (1000000,) | 1,000,000 B | 0.9749 s |
① np.asarray(b, dtype=np.uint8) |
uint8 | (1000000,) | 1,000,000 B | 0.9917 s |
① np.asarray(iter(b)) |
float64 | (1000000,) | 8,000,000 B | — |
① b.count(True) |
— | 标量 142,858 | — | 0.2352 s |
① np.asarray(b[:10000], dtype=bool) |
bool | (10000,) | — | 0.01034 s |
① np.asarray(b[:100000], dtype=bool) |
bool | (100000,) | — | 0.09815 s |
② np.unpackbits(b.small.data)[:N].astype(bool) |
bool | (1000000,) | 1,000,000 B | 首次约 0.84 ms / 热稳态约 0.10 ms |
口径脚注:② 测的是
np.unpackbits+ 切片 +astype(bool)在 C 层解包现成位图的时间,不含从对象逐个取布尔的开销;它和 ① 的np.asarray(b, …)不是等价转换链路,不能当成同类方案横向比快慢。实测 7 个独立冷进程里,每进程连续三次的耗时稳定呈「首次约 0.78–0.89 ms(含一次性冷启动)、其后约 0.09–0.16 ms(热稳态)」;无论取首次还是热稳态,对逐元素转换的约 0.98 s 都是三个数量级以上、热稳态近四个数量级的差距。这反映的是「C 层直接位解包」与「Python 逐元素迭代」的固有差距,不是同一件事两种写法的胜负;倍数随是否命中首次冷启动而变,引用时请连这个前提一起带上。
作为对照,BHA 对象自身只占 125,135 B。也就是说,默认 np.asarray(b) 吐出的 int64 数组,体积是位图本身的 64 倍,约等于把按位打包省下的内存一次性又还了回去。
把这笔内存账摊开看:存的时候,1e6 个布尔位只占 125,000 B,人均八分之一字节;要交给 numpy 处理时,显式 dtype=bool 得到 1,000,000 B(人均 1 字节,与 numpy.bool_ 持平);不显式 dtype 就得到 8,000,000 B(人均 8 字节)。三档之间差 8 倍和 64 倍,选哪一档只取决于一行参数。这也是为什么开头那个疑惑的答案不是「库有问题」,而是「转换时少传了一个参数」。
逐条答疑
1. 为什么默认转出来是 int64?—— 因为类没有自定义 __array__
真机确认:实例 BoolHybridArray 的 MRO 是 BoolHybridArray → MutableSequence → Sequence → ...,整条类树上都没有定义 __array__。numpy 在 np.asarray(b) 时走的是既定分派规则:对象若带 __array__ 就调它,把数组生产权交给对象自己;否则按序列协议逐个取元素,在 Python 层拼成 ndarray。
逐个取元素拿到的是 0/1,numpy 从纯整数推 dtype,自然推成 int64(8 字节一个元素),于是得到一个 8,000,000 B 的 int64 数组,中位耗时 0.9847 s。打印出来就是那串整数,读者最初的疑惑正源于此。
正确姿势:要布尔结果就显式传 dtype,一行的事。
a_bool = np.asarray(b, dtype=bool) # dtype=bool, 1,000,000 B, 0.9749 s
a_u8 = np.asarray(b, dtype=np.uint8) # dtype=uint8, 1,000,000 B, 0.9917 s
在 numpy 2.5.3 里 np.bool_ 仍然可用,上面两种写法结果等价。显式 dtype 之后,转换耗时和默认几乎持平(0.9749 s vs 0.9847 s),省的是 700 万字节的内存,不是 CPU。
2. 那 np.asarray(iter(b)) 为什么是 float64?
因为 __array__ 只定义在迭代器类型 BHA_Iterator 上。这不是推断,可以直接核验:hasattr(type(b), '__array__') 为 False、hasattr(type(iter(b)), '__array__') 为 True。真机打印:
hasattr(type(b), '__array__') = False
hasattr(type(iter(b)), '__array__')= True
dtype: float64 shape: (1000000,)
前 12 个值: [1. 0. 0. 0. 0. 0. 0. 1. 0. 0. 0. 0.]
它不是布尔数组,而是把位图里的 0/1 整数装进了 float64 容器,输出同样是 8,000,000 B。数值本身没错(第 0 位、第 7 位是 1,与构造时 i % 7 == 0 吻合),但 dtype 不符合直觉。所以正确姿势是:不要把 iter(b) 直接喂给 np.asarray;要布尔就走第 1 条的显式 dtype,要读整数位图就直接看 b.small.data。
3. 切片转换很便宜,不必全量转
真机:b[:10000] 转 bool 中位 0.01034 s,b[:100000] 转 bool 中位 0.09815 s。按需切片再转 numpy,成本随切片长度近似线性,日常取一段做过滤、可视化、统计都没有负担,不必为了一段数据把整个百万对象全量展开。
切片本身在库内是按位段提取的:稠密态下直接从位图缓冲里切出对应 bit 段重建对象,稀疏态下走 bisect_left 在升序下标表上定位起止。所以 b[:k] 不是「先复制全量再切」,而是「按位段构造一个小对象」,这也是它便宜的原因。拿到小切片后再 np.asarray(..., dtype=bool),整条链路的成本就只和 k 挂钩。
4. count(True) 是 O(n) 遍历,比先转数组再求和划算
真机返回 142,858(与构造时真值个数一致),中位耗时 0.2352 s,约为全量 bool 转换(0.9749 s)耗时的 24%、不到四分之一。需要统计真值数量时直接用它,比先 np.asarray(b, dtype=bool) 再 a.sum() 少花约 0.74 s、也不产生那 1MB 的中间数组。
复杂度如实说明:源码里稠密段是 for i in range(scan_n): self.small[i] == value 的 Python 层逐个读位,量级 O(scan_n)(稠密态 scan_n≈N,即 O(N)),稀疏态只需 O(K)(K 为异常下标数);它没有做 numpy 式整块 popcount,省的是不必把整份位图先展开成 N 个常驻 Python bool 对象。count(False) 同理,真值数与 N - count(True) 互为校验。只关心「有几个 True」时,这一行比转数组再求和更省,是最短路径。
5. Python 层转换的内存水位
用 tracemalloc 干净测量 np.asarray(b, dtype=bool) 一次:峰值 16,587.83 KB ≈ 16.2 MB,转换结束后持有结果的稳态 current 约 978.41 KB(多次独立冷进程完全稳定)。这里的 current 正是"持有转换结果 bool 数组"时的稳态占用——那个 1e6 的 bool ndarray 逻辑载荷为 1,000,000 B(976.56 KiB),实测 978.41 KiB 多出的零头是 numpy 数组对象与簿记;若不持有、转换后结果即释放,current 会回到接近 0。peak(约 16.2 MB)测的则是逐元素转换期间的瞬时高位,主要来自 Python 层逐元素遍历期间的临时对象,稳态才是转换落盘后真正长期占着的内存。写法上注意:测内存要单独冷进程、一次转换一个 tracemalloc 会话,不要多对象共处一个会话,也不要反复 start/stop,数字会被搅浑。另外开 tracemalloc 本身会拖慢计时,耗时测量请另开干净进程跑——本表的耗时行与这行内存数来自两套独立冷进程,不是同一次观测。
口径边界也写清,免得把这 16.2MB 当成「进程总占用」:tracemalloc 只统计 Python 分配器(pymalloc)可见的那部分内存,不等于进程 RSS 全量。本机用官方 numpy wheel(CPython 3.12 + numpy 2.5.3),结果 bool ndarray 的约 1MB 已计入上面的 current;而转换高峰期 Python 逐元素迭代产生的临时 int/bool 对象是 peak 的主要来源。若要含 C 层/系统分配器在内的真实驻留峰值,应另起独立子进程读 /proc/self/status 的 VmRSS / VmHWM(或 resource.ru_maxrss),两套口径回答的问题不同、不要混用。
6. 进阶:稠密态直读位图(高级姿势,不是通用首选)
通用业务代码的首选仍是公开 API np.asarray(b, dtype=bool):不依赖内部字段、跨版本稳定,代价是约 0.97 s 的 Python 层逐元素转换。只有当你确实钉死稠密态、且转换成为可测量的热点时,才考虑下面这条直接读位图的进阶写法——它快,但代价是依赖内部字段:
a = np.unpackbits(b.small.data)[:N].astype(bool)
# dtype=bool, shape=(1000000,), 真值数 142,858 与 b.count(True) 一致
# 每个冷进程首次约 0.84 ms(含冷启动),其后热稳态约 0.10 ms;C 层直接位解包,与逐元素转换不是等价链路
它在 C 层做位解包,热稳态约 0.10 ms(冷进程首次约 0.84 ms),对约 0.97 s 的逐元素转换是三个数量级以上、热稳态近四个数量级的差距——但如前述,这是「C 层直接读现成位图」与「Python 逐元素迭代」两种不同链路的固有差距,不是同一件事两种写法的快慢,倍数要连「首次/热稳态」前提一起看。
务必注意这是有前提的高级用法,别在通用代码里照抄:
small.data是内部字段(命名不带公开 API 承诺),库升级时可能改名、调整布局甚至移除;np.asarray(b, dtype=bool)才是稳定契约。- 只有在你明确
is_sparse=False、确认b.small.data就是底层 uint8 位图时才成立;稀疏对象没有这块同构位图,直接取会错。 - 直接回写更要自行保证形状一致。比如拿到长度相同的 uint8 掩码
other,可以np.bitwise_and(b.small.data, other, out=b.small.data)原地按位与回写、不经 Python 循环;但other长度必须与⌈N/8⌉严格相等,否则 numpy 会直接抛错,且这类原地改写绕过了库自身的封装、结果一致性由调用方负责。 - 不确定模式、要跨版本长期维护、或只是偶尔转一次,请老实走公开 API,省这不到 1 秒通常不值得绑定内部布局。
7. 反向:从 numpy 数组构造回 BHA
真机实测两条路径(源数组在计时外预先建好,是一个 1e6 的 bool ndarray,真值 142,858),各取 5 次干净冷进程中位数:
b2 = bha.BoolHybridArr(x.tolist(), is_sparse=False) # 中位 1.8399 s
b3 = bha.BoolHybridArr((bool(v) for v in x), is_sparse=False) # 中位 1.8768 s
两条路径同量级,都在 1.8 s 上下,差别很小:tolist() 稳定略快,1.8399 s 对 1.8768 s,只快约 2%(本次测量的机器浮动也有一两个百分点,所以这里应理解为「tolist 至少不慢、通常略快一点」,而不是稳定的固定增益)——tolist() 是 numpy 在 C 层批量导出 Python 列表,生成器则要在 Python 层逐个产出并 bool(v),多一层迭代。原因也清楚:不管哪条输入,当前公开构造器最终都要逐个布尔值写进 uint8 位图,逐位写入是主项,输入形态只带来这约 2% 的差别。这里也顺带说明一个边界:9.13.11 的公开构造器没有「直接吃一块打包好的 uint8 位图、零拷贝导入」的入口(实测把 125,000 字节的 uint8/bytes 直接喂进去,会被当成 125,000 个元素的序列逐元素解析,而不是 1,000,000 位);底层另有私有位操作方法,但非公开 API、不建议在业务代码里依赖。
往返一致性真机验证过:b2 前 7 位为 [T, F, F, F, F, F, F],与源数组吻合。反向构造的成本明显高于正向转 numpy(默认约 0.98 s),这是逐位写位图的既定代价,批量回写时把它放进预热阶段即可;想抠这一两个百分点,就优先用 x.tolist()。
8. 与原生序列对照:list 能打,bytes 不行
真机:list(b) 中位 0.7202 s(5 个独立冷进程中位),比默认正向转 numpy(约 0.98 s)还快一点,因为 list 元素是 Python bool 对象,构造路径短。而 bytes(b) 和 bytearray(b) 真机都会抛同一个错——OverflowError: cannot fit 'BoolHybridArray' into an index-sized integer。
这里别想当然成「它不是 0/1 整数序列所以失败」,恰恰相反,根因是分派优先级:BHA 实现了 __index__(hasattr(type(b),'__index__') 为 True),它把整块位图表达成一个超大整数(真机 b.__index__() 是一个数十位的大整数),但没有实现 __bytes__。于是 CPython 在 bytes(x) / bytearray(x) 时先命中「x 是整数 → 分配该长度」这条分支,要求该整数能放进一个机器字长的 Py_ssize_t;1e6 位对应的大整数远超这个范围,直接溢出,根本走不到「把它当 0/1 可迭代逐字节收集」的后备分支。真机也验证过:hasattr(type(b),'__bytes__') 为 False,所以没有「序列化成字节串」的协议可用。
想拿字节流,正确姿势是第 6 条那条直读位图:b.small.data 本身就是 uint8 位图缓冲,b.small.data.tobytes() 直接给出 12,500 字节(N=1e5)的原始位字节;这跟 bytes(b) 想表达的「每布尔 1 字节」不是同一种字节流,要的是按位打包的紧凑字节,而不是逐 bool 展开。
收口:这是既定分派行为,知道姿势就好
把上面几条放在一起看:np.asarray(b) 默认 int64、np.asarray(iter(b)) 退回 float64,都不是需要绕过的异常,而是 numpy 的 __array__ 分派规则,叠加「BoolHybridArray 走 MutableSequence、不在自身定义 __array__,__array__ 只在 BHA_Iterator 上」这一结构的结果。按下面四条用即可:
- 转 numpy 优先显式
np.asarray(b, dtype=bool)(或np.uint8),默认 int64 是 8 倍内存,这条也是不依赖内部字段的稳定姿势; - 别把
iter(b)直接喂给np.asarray,那会退回 float64; - 稠密态且转换确实是热点、又已钉死
is_sparse=False时,可用np.unpackbits(b.small.data)亚毫秒直读位图,但它依赖内部字段,通用代码仍走第 1 条; - 数真值用
b.count(True),片段按需取b[:k],批量反向构造优先x.tolist()(通常比生成器略快约 2%,机器有浮动)。
这些结论里,dtype、shape、内存档位属于分派层面的结构性行为;只要 numpy 的 __array__ 协议不大改、BHA 的稠密/稀疏表示不变就成立。绝对耗时则是数值层面的,随 CPU、numpy 大版本、BLAS 与编译选项会浮动,本文数字都限定在文末那台机器、CPython 3.12 + numpy 2.5.3、BHA 9.13.11;升级版本后按脚本重跑即可,不要把某台机器的具体秒数当成跨版本常量。
附:完整可跑复现脚本
# save as /tmp/repro.py, run: python3 /tmp/repro.py
# 说明:本脚本把各场景放在同一进程里,方便你一键快速复现 dtype/字节/结构结论;
# 正文表格里的耗时中位数是“每个场景独立冷进程”测得,同进程连跑会因缓存/预热略有浮动,
# 想要逐位对齐正文耗时,请把每个场景单独存成脚本、各自 python3 冷启动跑 7 次取中位
# (正文表格即按“每场景 7 个独立冷进程取中位”测得;下面 med5 只是同进程内快速参考)。
import platform, time, statistics, tracemalloc
import numpy as np
import bool_hybrid_array as bha
def cpu_name():
# 纯 Python 读取,不依赖 shell/grep;Linux 读 /proc/cpuinfo,其余平台走兜底
try:
with open("/proc/cpuinfo", "r", encoding="utf-8", errors="ignore") as f:
for line in f:
if line.lower().startswith("model name"):
return line.split(":", 1)[1].strip()
except OSError:
pass
return platform.processor() or platform.machine()
print("machine:", cpu_name(), "| py:", platform.python_version(), "| numpy:", np.__version__)
N = 1_000_000
b = bha.BoolHybridArr((i % 7 == 0 for i in range(N)), is_sparse=False)
def med5(fn):
fn() # warmup
ts = []
for _ in range(5):
t0 = time.perf_counter(); fn(); ts.append(time.perf_counter() - t0)
return statistics.median(ts)
a1 = np.asarray(b)
print("default:", a1.dtype, a1.shape, a1.nbytes, "median_s:", round(med5(lambda: np.asarray(b)), 4))
a2 = np.asarray(b, dtype=bool)
print("dtype=bool:", a2.dtype, a2.shape, a2.nbytes, "median_s:", round(med5(lambda: np.asarray(b, dtype=bool)), 4))
print("dtype=uint8:", np.asarray(b, dtype=np.uint8).dtype, "median_s:", round(med5(lambda: np.asarray(b, dtype=np.uint8)), 4))
a3 = np.asarray(iter(b))
print("iter:", a3.dtype, a3.shape, a3.nbytes, "head:", a3[:12])
print("count(True):", b.count(True), "median_s:", round(med5(lambda: b.count(True)), 4))
print("slice 1e4 -> bool median_s:", round(med5(lambda: np.asarray(b[:10000], dtype=bool)), 5))
print("slice 1e5 -> bool median_s:", round(med5(lambda: np.asarray(b[:100000], dtype=bool)), 5))
print("unpackbits median_ms:", round(med5(lambda: np.unpackbits(b.small.data)[:N].astype(bool)) * 1000, 3))
tracemalloc.start()
_held = np.asarray(b, dtype=bool)
cur, peak = tracemalloc.get_traced_memory()
print("tracemalloc peak_MB:", round(peak / 1024 / 1024, 2), "held_current_KB:", round(cur / 1024, 2))
tracemalloc.stop()
x = np.asarray(b, dtype=bool)
print("tolist construct median_s:", round(med5(lambda: bha.BoolHybridArr(x.tolist(), is_sparse=False)), 4))
print("list(b) median_s:", round(med5(lambda: list(b)), 4))
print("b.memory_usage():", b.memory_usage(), "bytes")
项目信息
本文实测环境为 CPython 3.12.11 + numpy 2.5.3 + bool-hybrid-array 9.13.11,Linux x86_64(机型见脚本首行打印)。np.bool_ 在该版本仍可用,代码里写 dtype=bool 与 dtype=np.bool_ 等价;若换到 numpy 1.x 或更新大版本,个别别名行为可能不同,以实际版本为准。欢迎在 issue 区交流互转姿势、补充你自己机器上的复测数据——本文所有数字都能用文末脚本在本机重跑,不同机型 / numpy 版本下绝对耗时会浮动一到两成,但 dtype、shape、内存分档这类结构性结论保持一致。
- 安装:
pip install bool-hybrid-array - requires_python >= 3.8;运行时硬依赖 numpy >= 1.19.0;numba 与 cycy-runtime 为可选 extra,不装不影响互转
- PyPI:https://pypi.org/project/bool-hybrid-array
- GitHub:https://github.com/BKsell/bool-hybrid-array
- Gitee:https://gitee.com/BKsell/bool-hybrid-array
- 许可证:Apache-2.0 OR MulanPSL-2.0 双许可,商用与二次分发任选其一遵守
- 作者:蔡靖杰(PyPI / GitHub 路径 BKsell)
备注:实例非线程安全,跨线程共享请自行加锁;稀疏侧异常下标由
array.array('I')升序保存并配合 bisect 定位,下标越过 2^32 自动升为 'Q' 容器。
延伸阅读(2026-10 补充)
- 常见问题 FAQ(许可证、numpy 依赖、dense/sparse、和 bitarray 怎么选,2026 真机 9.13.11):《bool-hybrid-array 常见问题 FAQ》。链接:https://www.cnblogs.com/BKbeike/p/23435178.html
- 七库横评真机对照:《百万到上亿个布尔值到底怎么存?我把 list、numpy、bitarray、RoaringBitmap、bool-hybrid-array 全压了一遍(2026 真机选型)》。链接:https://www.cnblogs.com/BKbeike/p/23434389.html
浙公网安备 33010602011771号