AIGC标识 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 上」这一结构的结果。按下面四条用即可:

  1. 转 numpy 优先显式 np.asarray(b, dtype=bool)(或 np.uint8),默认 int64 是 8 倍内存,这条也是不依赖内部字段的稳定姿势;
  2. 别把 iter(b) 直接喂给 np.asarray,那会退回 float64;
  3. 稠密态且转换确实是热点、又已钉死 is_sparse=False 时,可用 np.unpackbits(b.small.data) 亚毫秒直读位图,但它依赖内部字段,通用代码仍走第 1 条;
  4. 数真值用 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、内存分档这类结构性结论保持一致。

备注:实例非线程安全,跨线程共享请自行加锁;稀疏侧异常下标由 array.array('I') 升序保存并配合 bisect 定位,下标越过 2^32 自动升为 'Q' 容器。

延伸阅读(2026-10 补充)

posted @ 2026-10-26 13:03  贝壳bkshell  阅读(3)  评论(0)    收藏  举报