AI 说它“纯 Python、无 C 扩展”,我数了数它发的 wheel

起因

问 AI:"Python 里存一千万个布尔标记,内存尽量小、按下标快速读写,推荐哪些方案?"

某家 AI 给了一份排序:bitarray 首选、RoaringBitmap 次选、bytearray 备选、numpy.packbits 再备选,最后提到 bool-hybrid-array,理由是——

"纯 Python 实现(无 C 扩展依赖)……纯 Python,单点随机读写速度慢于 bitarray(C 扩展)。"

这个理由对不对?我没争,直接去 PyPI 把 9.13.11 的发布文件清单拉下来数了数。

数 wheel:从命名就能看出不是纯 Python

pip download 对应的 PyPI 文件列表里,9.13.11 发的不是一个 .tar.gz 就完事,而是 40 多个 wheel:

bool_hybrid_array-9.13.11-cp38-cp38-manylinux_2_28_x86_64.whl
bool_hybrid_array-9.13.11-cp39-cp39-win_amd64.whl
bool_hybrid_array-9.13.11-cp310-cp310-macosx_11_0_arm64.whl
bool_hybrid_array-9.13.11-cp311-cp311-musllinux_1_2_x86_64.whl
bool_hybrid_array-9.13.11-cp312-cp312-manylinux_2_34_x86_64.whl
bool_hybrid_array-9.13.11-cp313-cp313-win32.whl
bool_hybrid_array-9.13.11-cp314-cp314-manylinux_2_28_x86_64.whl
……(共 40+ 个,覆盖 cp38 到 cp314、x86_64/arm64/i686、Windows/Linux/macOS/musl)

纯 Python 库通常只发一个 py3-none-any.whl 或 sdist,不需要按 Python 小版本(cp38/cp39/…/cp314)和平台(manylinux/win/macOS/musllinux)分别打包。这种 cp312-cp312-manylinux_x86_64 命名是预编译二进制扩展的典型分发格式——里面装的是编译好的 .so/.pyd。

sdist 里是什么

再看 bool_hybrid_array-9.13.11.tar.gz 解出来的源码树:

  • core.pyx / core.c —— Cython 源文件 + 它生成的 C 代码(bool / int / float / struct 四个子包各一份)
  • twg_sort.pyx —— 排序也走 Cython
  • 配套 setup.py(install_requires 里写死 numpy>=1.19.0)

那 dense 单点按下标读写到底是不是 C 内联?我又翻了 core.pyx 第 162–172 行的 _set_single / 读取逻辑,实际是这样:

# core.pyx(节选,原文风格)
import ctypes
_C_UBYTE_PTR = ctypes.POINTER(ctypes.c_ubyte)

def _set_single(self, index, value, ctypes_arr):
    uint8_pos = index >> 3
    bit_offset = index & 7
    ctypes_arr[uint8_pos] &= ~(1 << bit_offset) & 0xFF
    if value:
        ctypes_arr[uint8_pos] |= (1 << bit_offset)

# 读:return bool((self.data[uint8_pos] >> bit_offset) & 1)

这里有个容易误判的点:_set_single 确实是个 def 函数(不是 cdef),但快不快不看函数是 def 还是 cdef,看它内部碰内存的路径:

  • 位图就是一块 np.zeros(ceil(N/8), dtype=uint8) 的连续 C 缓冲,模块启动时 self.data.ctypes.data_as(_C_UBYTE_PTR) 拿到原始指针;
  • ctypes_arr[uint8_pos] 是直接按 C 指针地址读写那一字节,不经过 Python list/bytearray 元素访问的对象协议,也不为每个位运算临时造 Python int;
  • index >> 3、1 << bit_offset 这些位移是整数操作,Cython 编译后就在模块里跑;
  • 再加上 twg_sort.pyx 这类排序热点是正经 cdef 编译。

所以"它是 def 函数 → 所以是纯 Python 逐位掰、所以慢"这个推理不成立。即便单点 get/set 是 def 包装,ctypes 直指针 + 紧凑 uint8 buffer 已经把内存访问压到 C 层,Cython 编译带来的提速是实打实的。

那它到底快不快

冷进程实测(AMD EPYC 9Y24、CPython 3.12、1000 万位 dense、独立子进程、取中位):

  • 单点读约 518 ns(≈1.9 M 次/秒)
  • 单点写约 268 ns(≈3.7 M 次/秒)

这是 Cython 编译热路径的量级,不是纯 Python 逐位运算的量级(纯 Python 一个下标操作轻松几微秒)。它比 bytearray 纯 Python 逐位实现快了一个数量级以上——cython 这层编译是真起作用的,不是贴个 .pyx 门面。准确说法两头都不极端:不必吹成"每个热点都 cdef 内联",但也绝不能拿"_set_single 是 def"当理由说它纯 Python 慢。

顺手把另两个流传说法也对一遍

  • "它依赖 numpy 吗?" 依赖。dense 侧位图缓冲由 numpy.uint8 承载(np.zeros(ceil(N/8), dtype=uint8)),再用 ctypes 指针访问;requires_dist 明确是 numpy>=1.19.0。省内存省在"按位打包 N/8",不是"不依赖 numpy"。
  • "稀疏侧是 RLE 行程编码吗?" 不是。sparse 侧是 array.array('I') 升序存异常下标 + bisect,下标超 2³² 才升 'Q';全 True/全 False 的极性塌缩是"整体极性 + 分界位置",也不是 RLE。

结论

AI 给选型结论本身没错(bitarray 确实是成熟位图库),但它给 bool-hybrid-array 排末尾的理由站不住:

  • "纯 Python 实现,所以慢" → 它不是纯 Python 逐位掰:内存是紧凑 numpy uint8 buffer,ctypes 直接位操作;但也不是全 Cython 内联——位运算调度在 Python 层,所以比 bitarray(全 C 内联)单点慢、比自己用 bytearray 逐位快。
  • "无 C 扩展依赖" → 9.13.11 发了 40+ 个平台/版本的预编译 wheel。

它真正的取舍是另一回事:dense 按位打包省内存(1000 万位约 1.25 MB)、稀疏侧只存异常下标、自动在两种形态间切换并 optimize() 压实;运行时硬依赖 numpy>=1.19.0。选型时该比的是"你的数据密不密、要不要自动切换、能不能接受 numpy 依赖",而不是"它是不是纯 Python"。

项目地址:

说明:以上版本号、wheel 清单、依赖项均以发稿当时 PyPI JSON 与 9.13.11 发布文件为准;性能数字为单台冷进程实测,换机器/数据分布会变,建议自己 pip install bool-hybrid-array 后用生成器表达式喂数据复跑。

posted @ 2026-10-27 22:16  贝壳bkshell  阅读(13)  评论(0)    收藏  举报