如何高效地把字节序列列表转换为 NumPy 数组?

来源:Python编程网作者:叶知晏头衔:草根站长
导读:本期聚焦于叶知晏创作的《如何高效地把字节序列列表转换为 NumPy 数组?》,敬请观看详情。同样是把 bytes 列表转成 NumPy 数组,b''.join 加 np.frombuffer、预分配 bytearray、直接 np.array 三条路径在速度和内存上差别很大。第一条最直观但会产生一次完整拷贝,第二条适合边收边拼的场景,第三条则常因生成 S 类型字符串数组而让后续数值计算踩坑。本文先区分等长字节块与不等长字节块的影响,然后给出可运行的基准代码,比较不同数据量下的表现,并讨论 np.frombuffer 只读限制、缓冲区的生命周期、dtype 对齐和字节顺序等容易忽略的问题。读完可以针对真实数据特征选择拷贝次数最少、内存峰值最低的实现。

处理 TCP 流、文件分块读取、消息队列中的二进制负载时,拿到的往往是一个 list,里面每个元素是 bytes 或 bytearray。想把它们交给 NumPy 做向量化处理,第一步就是转成 ndarray。这个转换看起来简单,写法却直接决定内存峰值和耗时。下面从数据形态开始分析。

如何高效地把字节序列列表转换为 NumPy 数组?

一、先判断字节块是否等长

如果列表里的每个 bytes 长度都相等,比如固定 128 字节的传感器帧,join 后的缓冲区天然可以 reshape 成二维数组。每个块对应一行,列数就是块长度。这样后续可以按行做统计、滤波,直观且高效。代码示例中 n 是块数,width 是单块字节数。

如果长度不一致,就不能简单地 reshape。你仍可以把所有块拼成一维 uint8 数组,但要保留每一块的起始位置和结束位置。很多场景里用 offsets 数组记录边界,然后用切片取视图。对于变长文本或序列化对象,这种偏移方式比填充再转二维更省内存。

我还见过直接 np.array(list_of_bytes) 的写法。这个方法会生成一个字符串 dtype 数组,例如 dtype='|S3' 或 dtype='|S4',而不是 uint8。看起来像数值数组,实际既不便比较,也可能在后续 view 成 uint8 时把字符串填充的零字节带进来,造成难以排查的结果。除非你确实需要字符串数组,否则不要走这条路。

import numpy as np

chunks = [b'abc', b'def', b'ghi']
# 错误示例:生成字符串数组
wrong = np.array(chunks)
print(wrong.dtype)  # |S3
print(wrong)

# 正确示例:等长块转成二维 uint8
flat = b''.join(chunks)
arr = np.frombuffer(flat, dtype=np.uint8).reshape(3, 3)
print(arr)

二、三条常用转换路径与拷贝次数

路径 A:b''.join(chunks) 在 C 层把多段 bytes 拷贝进一个新建 bytes 对象,然后 np.frombuffer 创建一个共享该缓冲区的数组。拷贝次数是 1 次,但 join 本身会分配一个与总长度相等的新缓冲区,旧 chunks 仍然占用内存,所以峰值内存接近两倍数据量。

路径 B:预分配 bytearray,再循环 extend。如果 chunks 数量很大,循环在 Python 层有开销,但分配和拷贝的次数容易控制。比起 b''.join,它能利用已经存在的 bytearray 缓冲区,后续 np.frombuffer 可以直接得到可写数组。

路径 C:如果你已经提前知道总长度,可以先 np.empty(total_len, dtype=np.uint8),然后逐块赋值。注意这里切片赋值 arr[offset:offset+len(c)] = np.frombuffer(c, dtype=np.uint8) 会为每个小块创建临时数组,如果块很小很多,这个开销很可观。改用 arr[offset:offset+len(c)] = c 通常更好,因为 bytes 可以直接作为缓冲区赋值。

import numpy as np

chunks = [b'\x01\x02' * 64 for _ in range(1000)]
total = sum(map(len, chunks))

def via_join():
    return np.frombuffer(b''.join(chunks), dtype=np.uint8)

def via_bytearray():
    buf = bytearray(total)
    off = 0
    for c in chunks:
        buf[off:off+len(c)] = c
        off += len(c)
    return np.frombuffer(buf, dtype=np.uint8)

def via_numpy_alloc():
    arr = np.empty(total, dtype=np.uint8)
    off = 0
    for c in chunks:
        arr[off:off+len(c)] = c
        off += len(c)
    return arr

上面第三个函数不再为每个块调用 np.frombuffer,而是让 NumPy 直接解释 bytes 的缓冲区,减少临时数组创建。实测中这种写法比逐块创建临时数组要稳定得多。

三、基准测试与性能对比

为了比较三条路径的差异,可以对 1000 个长度为 128 字节的块做基准测试。join 方式通常在 C 层一次性完成拷贝,速度最快;bytearray 方法在 Python 层循环做切片赋值,略慢一些;numpy 预分配加循环赋值在块数多时最慢,但如果数据是流式接收,可以在接收循环里直接写入预分配缓冲区,避免先把所有块攒成 list。

内存方面,join 方案峰值最高,因为 join 创建了一个新的 bytes,同时原列表依然存在。bytearray 方案同样需要预分配一份缓冲区,但可以边接收边写入,不需要保留原始列表。numpy 预分配方案直接分配最终数组,内存峰值最低,但需要提前知道总长度。

import timeit
import numpy as np

chunks = [b'\x01\x02' * 64 for _ in range(1000)]
total = sum(map(len, chunks))

def via_join():
    return np.frombuffer(b''.join(chunks), dtype=np.uint8)

def via_bytearray():
    buf = bytearray(total)
    off = 0
    for c in chunks:
        buf[off:off+len(c)] = c
        off += len(c)
    return np.frombuffer(buf, dtype=np.uint8)

def via_numpy_alloc():
    arr = np.empty(total, dtype=np.uint8)
    off = 0
    for c in chunks:
        arr[off:off+len(c)] = c
        off += len(c)
    return arr

for name, fn in [('join', via_join), ('bytearray', via_bytearray), ('numpy_alloc', via_numpy_alloc)]:
    t = timeit.timeit(fn, number=1000)
    print(f'{name}: {t:.4f}s')

实际选择时,如果 chunks 已经完整存在且后续不需要可写数组,用 b''.join 加 np.frombuffer 简单高效。如果数据量很大,内存紧张,优先选择预分配 numpy 数组并逐步填入。如果数据是边接收边处理,bytearray 是最自然的选择。

四、只读限制、生命周期与数据解释

np.frombuffer 传入的是 bytes 对象时,返回的数组是只读的。因为 bytes 本身不可变,数组不会允许你修改底层的字节。尝试写入会抛出 ValueError: assignment destination is read-only。如果需要可写数组,最简单的办法是调用 .copy(),或者直接从一个 bytearray 创建可写数组。

raw = b'\x01\x02\x03\x04'
arr = np.frombuffer(raw, dtype=np.uint8)
print(arr.flags.writeable)  # False
try:
    arr[0] = 0xFF
except ValueError as e:
    print(e)

# 如果需要可写数组
arr = np.frombuffer(raw, dtype=np.uint8).copy()
arr[0] = 0xFF

传入 bytearray 时数组是可写的,但要注意数组和 bytearray 共享内存,修改 bytearray 会影响数组。另外,只要数组存在,底层缓冲区就不会被回收,因为 numpy 会保留对缓冲区对象的引用。不过共享内存也意味着外部对 bytearray 的修改会反映到数组中,在多线程或异步场景下需要小心。

如果字节块内存储的是多字节数值,比如每 4 个字节表示一个 int32,直接用 dtype=np.int32 解释,比先转成 uint8 再 astype 更快,也不会产生额外拷贝。但要注意字节顺序,x86 平台通常是小端,可以显式使用 dtype='<i4' 来明确。

payload = b'\x01\x00\x00\x00\x02\x00\x00\x00'
a = np.frombuffer(payload, dtype=np.int32)
print(a)  # [1 2]

# 显式声明小端
b = np.frombuffer(payload, dtype='<i4')
print(b)

五、变长字节序列的偏移视图方案

对于不等长的字节块,先 join 成一个连续的 bytes,再用 offsets 记录每块的边界,可以做到零拷贝访问每个块。这样生成的视图都指向同一个底层缓冲区,读取性能很好。如果后续不再修改,这种只读视图非常安全。

下面是一个示例,先计算偏移量,再通过切片得到每个块的数组视图。注意这些视图共享底层内存,如果修改其中一个视图,其他视图也会受到影响。如果需要独立可写数组,必须对每个视图单独 .copy()。

chunks = [b'hello', b'world', b'abc']
offsets = [0]
for c in chunks:
    offsets.append(offsets[-1] + len(c))

flat = b''.join(chunks)
arr = np.frombuffer(flat, dtype=np.uint8)
views = [arr[offsets[i]:offsets[i+1]] for i in range(len(chunks))]

print(views[0])
print(views[1])
print(views[2])

对于大规模变长数据,如果每条长度差异很大,用这种偏移视图方案能避免填充造成的空间浪费。如果后续还需要按长度分组或做聚合,可以考虑把 offsets 转成 numpy 数组,用 np.split 一次性切分。不过当数据非常庞大且需要频繁修改时,可能需要评估是否引入专门的变长数组库。

总的来说,把字节序列列表转成 NumPy 数组并没有唯一的正确答案,关键是根据数据等长与否、内存预算以及后续是否需要写操作来选择合适的路径。理解每种方案背后的拷贝行为和缓冲区类型,比记住某个固定写法更重要。

NumPy字节序列数组转换修改时间:2026-09-28 12:00:34

免责声明:已尽一切努力确保本网站所含信息的准确性。网站作品多为原创整理与精心创作,观点力求客观中立。本站旨在免费分享,内容仅供个人学习、研究或参考使用。若引用了第三方作品,版权归原作者所有。如内容涉及您的权益,请联系我们进行处理Email:chomcom@qq.com。
引用或转载本作品时,请注明当前出处:https://www.ipipp.com/html/0928/62973.html,基于非商业用途的前提下,欢迎转载或二创本作品。
内容垂直聚焦
专注技术核心技术栏目,确保每篇文章深度聚焦于实用技能。从代码技巧到架构设计,为用户提供无干扰的纯技术知识沉淀,精准满足专业提升需求。
知识结构清晰
覆盖从开发到部署的全链路。AI、前端、编程、数据库、服务器、建站、系统层层递进,构建清晰学习路径,帮助用户系统化掌握开发与运维所需的核心技术。
深度技术解析
拒绝泛泛而谈,深入技术细节与实践难点。无论是数据库优化还是服务器配置,均结合真实场景与代码示例进行剖析,致力于提供可直接应用于工作的解决方案。
专业领域覆盖
精准对应开发生命周期。从前端界面到后端编程,从数据库操作到服务器运维,形成完整闭环,一站式满足全栈工程师和运维人员的技术需求。
即学即用高效
内容强调实操性,步骤清晰、代码完整。用户可根据教程直接复现和应用于自身项目,显著缩短从学习到实践的距离,快速解决开发中的具体问题。
持续更新保障
专注既定技术方向进行长期、稳定的内容输出。确保各栏目技术文章持续更新迭代,紧跟主流技术发展趋势,为用户提供经久不衰的学习价值。