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

一、先判断字节块是否等长
如果列表里的每个 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 数组并没有唯一的正确答案,关键是根据数据等长与否、内存预算以及后续是否需要写操作来选择合适的路径。理解每种方案背后的拷贝行为和缓冲区类型,比记住某个固定写法更重要。