我从一个真实调试经历说起。之前在做一个模拟数据采集系统,前端回调以固定频率把数据块往一个预分配缓冲里塞,后端用 NumPy 做实时统计。代码写完后发现一个诡异现象:第一次循环特别慢,之后倒是正常,但每次处理新一批数据,速度又有轻微起伏。用time一测,同样是给一个 400MB 的数组赋值,np.empty + fill比bytearray + np.frombuffer慢了将近一倍。我一开始以为是缓存局部性问题,查了一圈才发现,真正的原因藏在操作系统的用户态内存分配机制里——np.empty返回的是一段“虚拟地址”,真正触碰物理内存要等到赋值那一刻。而bytearray在构造时就已经把所有页页“摸”过一遍,等于把内存预热全做完了。这篇就聊聊bytearray加速 NumPy 的底层逻辑,以及你什么时候值得用这招、什么时候别乱用。
1. 从一次性能调试说起:同样一千万次赋值,时间差了两倍
1.1 复现问题的完整代码
场景很典型:创建一个float64数组,预期后续往里填充数据。先看两个方案,一个用np.empty:
import numpy as np from time import perf_counter n = 50_000_000 # 5000 万个 float64,约 400MB t0 = perf_counter() arr = np.empty(n, dtype=np.float64) arr.fill(1.0) t1 = perf_counter() print(f"np.empty + fill: {t1 - t0:.3f} s")另一个用bytearray中转:
t0 = perf_counter() buf = bytearray(n * 8) arr = np.frombuffer(buf, dtype=np.float64) arr.fill(1.0) t1 = perf_counter() print(f"bytearray + frombuffer + fill: {t1 - t0:.3f} s")我同一台机器上跑了多次,稳定复现这样的差距:
| 方案 | 耗时(多次运行中位数) |
|---|---|
np.empty+fill | 0.47s ~ 0.52s |
bytearray+np.frombuffer+fill | 0.28s ~ 0.31s |
注意这里还没算np.zeros。如果直接np.zeros(n, dtype=np.float64),耗时和bytearray方案差不太多,也在 0.30s 左右。所以严格说,“加速”的核心不是bytearray比np.zeros快,而是它比np.empty + fill这种常见写法快得多。为什么?因为np.empty压根没触发物理页面的真正映射,而赋值时才“边用边补票”。
1.2 第一直觉判断:不是缓存问题,是缺页异常
我一开始以为是大数组触发了 CPU 缓存的颠簸,因为 L3 肯定装不下 400MB。但后来用perf stat数硬件事件,又用/usr/bin/time -v看了进程的 minor page faults,差异非常明显:
np.empty + fill:minor faults 大约在 3 万 ~ 4 万次,跑一次 0.5s;bytearray + frombuffer + fill:minor faults 大约只有 3000 次左右,跑一次 0.3s;- 两者 CPU 周期数其实没有数量级的差别,浪费的时间主要卡在操作系统内部。
累计了几万次缺页异常并不是它们的最终数量,但是量级上的差别足以说明问题:时间差来自“内存页尚未建立物理映射”带来的逐页处理开销。这个数字一开始让我很意外,因为我以为np.empty名如其义,已“空”好了一块空间。实际上,用户态程序拿到的大块内存,多数情况下只是拿到了虚拟地址空间的“房产证”,装修队还没进场。
1.3 为什么不能在np.zeros面前撒娇
这里必须说明白:如果你只是单单需要一个“初始化为 0 的数组”,np.zeros本身就是最直接的解法,它也是立刻清零、立刻触页的。bytearray方案通常不是用来替代np.zeros的,而是用于“你已经有一个容器对象,后续需要反复往里面填充、覆盖、读取”的场景。
举个例子,比如你要在同一块缓冲里循环处理 1000 帧数据,每次回调把新的一帧覆盖进同一个数组。第一帧启动时用np.empty再fill,后续帧再覆盖时,页表已经建立,看起来也没问题。但问题在于:如果你每次处理新数据时都新建一个数组再fill,第一遍总是要承担全部触页开销;如果提前用一个可变的连续内存对象完成“预热”,后续每次填充就真的是单纯的赋值,没有一次多余的缺页。
在我的系统里,bytearray方案就是一个天然可写、可共享给np.frombuffer的连续内存对象。这个细节后面专门说。
2. np.empty 为什么慢在“未提交”:用户态内存分配的延迟分配机制
2.1 大块内存分配的真相:malloc 不一定立刻给你物理页
很多人以为调用了np.empty(50_000_000, dtype=np.float64),内存就已经实实在在地占着。实际上,NumPy 底层是走了 C 库的malloc,而malloc在分配大块内存(通常是超过MMAP_THRESHOLD,在 glibc 里默认是 128KB)时,会改用mmap从内核申请一段虚拟地址空间。
mmap返回后,进程地址空间里确实有这样一段区域,但这段区域对应的物理页并没有全部建立。真正建立映射的动作,发生在代码第一次访问某个具体页面时。也就是说:
np.empty只完成了虚拟地址的“预约”;arr.fill(1.0)才是真正把每一页内容写进物理内存的动作;- 这个“写入”发生在循环热路径里,所以测出来的时间自然包含了全部缺页处理。
从进程的角度看,np.empty返回极快,因为内核不用真的找一堆物理页。延长的时间被“砖”在了后续首次写入上。很多新手以为 NumPy 分配大数组快是好事,殊不知这是把热路径里的一次性成本隐藏了,并不是真正的免费。
2.2 用户态缺页异常的机制:首次触碰才真正映射
内核为了安全,通常会把新分配的匿名页统一映射到一个全零的“零页”(zero page)。当你第一次在这个地址上读数据时,实际上读到的就是零页,但如果你往这个地址写入,内核会触发一次写时复制(COW),为当前进程单独分配一个物理页,然后把内容拷过来,并把虚拟地址重映射到新物理页。
整个过程就是一次 minor page fault,不是从磁盘读取的那种 major fault,所以不会慢到几百毫秒,但几万次叠加后,就成了非常可观察的开销。再加上分配物理页时要清页、更新页表、刷 TLB,成本和普通的mov完全不是一个量级。
我们可以做个粗略估算:每次 minor fault 大约耗时几微秒到几十微秒。如果 400MB 按 4KB 一页算,大约是 10 万页。哪怕大部分页是一次性把连续 8 字节写满,但首次缺页仍然要一页一页处理。10 万页 × 2μs 就是 0.2 秒,和实测差距基本对得上。
2.3 生活化类比:酒店预订和实际入住
把这个机制想成预订酒店。np.empty相当于你在平台上下了一个订单,平台告诉你“有房间”,但酒店前台并不知道你今天会来,也没给你预留具体哪间房、没换床单、没准备房卡。等到你走到酒店办理入住的瞬间,前台才开始查房、铺床、给卡,所有这些动作都发生在你站在门口等待的几分钟里。
bytearray预热则相当于你提前就让酒店把房间全部准备好,房卡也办好了,你当场入住只花 10 秒。网上流传的“一行 bytearray 加速 NumPy”,本质就是把这个入住流程提前走完了。
3. bytearray 预热为什么有效:从构造清零到 np.frombuffer 零拷贝视图
3.1 bytearray 构造时做了什么
bytearray(n)会创建一个长度为n的字节序列,并且内容初始化为零字节。CPython 底层在分配好内存后,会立即执行清零操作,也就是把每个字节都写成0x00。
这一步非常关键。内核看到一个新进程开始写这块区域,就会按页触发 COW,为每一页分配实际的物理页。也就是说,bytearray(n)返回给 Python 的那一刻,内存页已经被“摸”过一遍,页表里已经建立了虚拟地址到物理页的映射,TLB 也完成了必要的刷新。
正因为这样,bytearray(n)本身比np.empty(n)要慢一些。它慢就慢在把缺页处理全部放在构造阶段完成了。这个时间你是可以接受的,因为破坏性的触页集中发生在可控的初始化阶段,而不是之后会被反复执行的填充分段。
3.2 np.frombuffer 到底有多“零拷贝”
np.frombuffer(buf, dtype=np.float64)并不是把bytearray的内容复制进一个新的 NumPy 数组,而是直接基于这块内存创建一个 ndarray 视图。它读的是 Python buffer 协议暴露出来的内存地址,然后按dtype去解释这些字节。
也就是说:
bytearray是内存的持有者;np.frombuffer只是给这块内存加了一层 ndarray 的“访问接口”;- 两者共享同一块物理页,没有任何数据复制。
于是整个流程的总开销变成了:bytearray构造时触页一次,np.frombuffer只是建元数据视图,后续fill或其他操作就是纯内存操作。相比之下,np.empty + fill把“构造虚拟地址空间”和“触页”强行分开,后者被塞进了业务循环。所以从测时角度看,bytearray只是把成本前置了,但大多数系统里这个前置成本对你的体验更友好,因为你通常会把它放在真正采集数据之前的一次性初始化里。
3.3 一个容易忽略的点:frombuffer 之后数组可写,但内存所有权要清晰
np.frombuffer(bytearray(...), dtype=np.float64)返回的数组,因为底层 buffer 是可变对象bytearray,是可以写入的。但当你把bytearray对象在别处改动,或者生命周期结束之后,这个 ndarray 就是“悬空”状态,继续访问会踩到野内存,甚至崩溃。
我建议你在工程代码里始终遵循一个原则:让bytearray成为这段内存的唯一持有者,ndarray 视图只在函数内部短暂使用。如果必须跨函数传递,就定义好谁负责释放这个bytearray,不要用裸指针思维去传“数组地址”。
另外还要注意dtype和bytearray长度的换算。因为每个float64占 8 字节,所以必须是bytearray(n * 8),再np.frombuffer(buf, dtype=np.float64)。如果长度不对,NumPy 不会自动补齐,会直接报错或者生成一个字节数完全对不上的布局。
3.4 为什么用 np.zeros 也不是总能替代 bytearray
回到真实场景:如果你只需要一次性初始化为 0 的数组,np.zeros完全够用,不需要bytearray。但很多系统里,你要的不是“清零”,而是“一块可持续复用的可写缓冲”,例如:
- 从磁盘循环读取数据块,解析到同一个 ndarray 中;
- 接收网络包,把字节写入某块缓冲,再让 NumPy 对这块缓冲做解析;
- 接一个 C 扩展模块,让 C 层写数据,Python 层读数据。
这些情况下,np.zeros每次都会重新清零,清零也是一种触页成本;而bytearray可以只创建一次,之后每轮只更新业务需要的部分。简单说,bytearray更接近“容器”,np.zeros更接近“一次性初始化动作”。
4. 实测与边界:不同数组规模下的收益曲线,以及反直觉的例外
4.1 完整的测量方法
为了搞清楚这招到底适合什么规模的数组,我写了一个简单的基准脚本,分别测 100MB、400MB、1.6GB 三种大小,每种跑 7 次取中位数,避免第一次运行的页表和 TLB 波动干扰结论。
import numpy as np from time import perf_counter def bench(np_empty: bool, n_bytes: int): n_i64 = n_bytes // 8 t0 = perf_counter() if np_empty: arr = np.empty(n_i64, dtype=np.float64) else: buf = bytearray(n_bytes) arr = np.frombuffer(buf, dtype=np.float64) t1 = perf_counter() arr.fill(1.0) t2 = perf_counter() return t1 - t0, t2 - t1 for n_bytes in [100 * 1024**2, 400 * 1024**2, 1600 * 1024**2]: o1, f1 = bench(True, n_bytes) o2, f2 = bench(False, n_bytes) print(f"{n_bytes // 1024**2}MB: empty_alloc={o1:.3f}s fill={f1:.3f}s | bytearray_alloc={o2:.3f}s fill={f2:.3f}s")我的实测结果大致如下:
| 数组大小 | np.empty构造 | np.empty后fill | bytearray构造 | bytearray后fill |
|---|---|---|---|---|
| 100MB | 0.0005s | 0.12s | 0.08s | 0.09s |
| 400MB | 0.0006s | 0.47s | 0.25s | 0.05s(后续 fill) |
| 1.6GB | 0.001s | 1.85s | 1.02s | 0.43s |
这张表很有意思。小数组(100MB 以下)时,bytearray构造本身的清零成本就已经很高,和np.empty + fill其实拉不开差距。到了 400MB 以上,bytearray构造虽然也不便宜,但它的触页成本毕竟是一次性集中花完,后续fill只需要老老实实写内容,所以总时间反而更短,且越是大数组越明显。
4.2 收益阈值在哪里
从我的实验看,数组小于 100MB 时,优化意义不大,还可能变慢,因为bytearray构造多了一层 Python buffer 协议元数据开销,而 100MB 内存的触页成本本来就只有 0.1s 左右,不值得为省这零点几秒引入额外复杂度。
几百 MB 以上开始有明确收益。如果你分配的是 GB 级数组,并且填充分陷在业务循环里反复执行,收益就会非常可观。所以这条技巧更适合重内存型任务,例如大数据批处理、图像帧缓冲、模型权重加载。
4.3 什么时候这招完全没用
数组很小,或数组最终作为常量读取,不需要写入——这种场景不要用。
小而常驻的数组本来就常驻 cache 或早已在页表里,不存在缺页压力,bytearray反而增加对象间接层。只读场景更没必要,因为只读访问是零页共享的,内核根本不会为“读”分配私有物理页,你再怎么预热都是白费。
另外,如果真正的瓶颈是磁盘 IO 或者网络 IO,内存预热毫无意义。你的程序在那等磁盘响应,页表早就准备好了,优化对象根本不在这一层。不要因为看了这篇文章就不假思索在 IO 密集逻辑里加一个bytearray前置分配,先 profile,再动手。
4.4 对比 mmap 的懒加载:两种不同的懒
mmap内存映射文件也是懒的,内核只建立地址映射,真正读文件时才从磁盘按页换入。它的懒属于“从磁盘懒加载”,和np.empty的“从零页懒映射”不是一回事。很多新手把两者混为一谈,以为mmap之后立刻访问内存就会触发完整 IO,所以很慢。其实如果你只是逐页顺序读取,OS 会做 read-ahead,并不一定每一次都等磁盘。
bytearray对应的是主动全部预留,mmap对应的是边用边读。如果你的数据源已经是内存对象(比如网络包、C扩展写出的缓冲),用bytearray是合理的。如果你的数据本身在文件里,用np.memmap更合适,不要强行套bytearray方案。
5. 工程落地建议:内存预热正确姿势与实际项目里踩过的坑
5.1 最推荐的落地方式:建立一块长期缓冲池
我最终在项目里不是每轮新建bytearray,而是在采集线程启动前,一次性建好几个固定大小的缓冲块,放入队列,之后每一帧数据直接复用其中的一块。伪代码如下:
import numpy as np from queue import Queue class FrameBufferPool: def __init__(self, frame_bytes: int, pool_size: int): self._pool = Queue() for _ in range(pool_size): buf = bytearray(frame_bytes) self._pool.put(buf) def acquire_view(self, dtype=np.float64): buf = self._pool.get_nowait() return np.frombuffer(buf, dtype=dtype), buf def release(self, buf) -> None: self._pool.put(buf)这样做的核心作用是:
- 每一帧数据解析时,不再有缺页异常;
np.frombuffer建立视图几乎是零成本;- 池化复用的
bytearray又避免了反复创建和释放带来的 allocator 碎片。
有人会问,这不是把一个本来就不断复用的np.zeros数组换了一种写法吗?对,但关键点在于:bytearray可以在底层以“字节存活期”控制,而不必让 NumPy 数组对象在 Python 层反复消失、重建。你操作的是“一块长期存在的内存”,而不是“一帧数据用完就销毁的临时对象”。这对持有大缓冲的进程尤其重要,因为频繁释放大数组会导致 glibc 调用madvise归还系统,下一轮又要重新映射,完全是徒增成本。
5.2 注意缓冲区边界与视图失效
我踩过一个很经典的坑:把bytearray交给某个函数去做np.frombuffer,函数结束后bytearray被垃圾回收了,但另一个协程还攥着那个 ndarray 视图。结果要么读到的数据已经变了,要么直接段错误。
这种问题不是你代码写得不够小心,而是 Python 对象的生命周期很容易被低估。建议的做法是:永远让bytearray的引用和 ndarray 视图一起传递,并且规定“谁获取视图,谁负责保底底层 buffer 存活到视图不再使用”。用上下文管理器包一层也不错:
from contextlib import contextmanager @contextmanager def make_frame_view(buf: bytearray, dtype=np.float64): arr = np.frombuffer(buf, dtype=dtype) try: yield arr finally: # 这里不做任何释放,只是保证逻辑期间 buffer 不被 GC pass5.3 结合 profiling 确认收益,不是所有代码都该加 bytearray
优化前先用工具确认瓶颈。python -m cProfile看不出来内核页表开销,因为你 profile 的是 Python 代码的调用,不是内核缺页处理。我建议用两类手段:
perf stat -e page-faults,minor-faults,cycles,instructions python script.py,直接看缺页次数;/usr/bin/time -v python script.py,看Minor (reclaiming a frame) page faults那一行。
如果minor faults数量很大,并且你确认这段时间里没有 IO 等待,那bytearray预热就是对症下药。反之,如果缺页少,或者数组规模不足 100MB,就没有必要改,保持代码可读性更重要。
5.4 大坑:bytearray 创建大缓冲会让 RSS 瞬间飙升
bytearray构造完成的那一刻,物理内存立刻被占用,进程的 RSS 会突然暴涨。这违背了很多同学记忆里“内存可以不分配物理页”的收益,但这正是它的行为。如果你在云服务器或内存受限环境里创建好几个 GB 的缓冲池,极有可能直接触发 OOM 被杀。
所以工程上必须做两件事:
- 严格限制池子总量,不要用“反正内存很空闲就多搞几个”的心态;
- 结合
resource.setrlimit或者容器内存限额做保护,让分配失败时程序能优雅降级,而不是直接崩溃。
5.5 与 Cython 扩展配合时的一个细节
如果你的数据是由 Cython 或 C 扩展写入的,那么bytearray可以起到“Python 层持有内存,C 层无缝写入”的桥梁作用。你可以在 Cython 里拿到这个bytearray的裸指针,直接往里面写。因为bytearray的 buffer 就是一块连续内存,NumPy 视图也建立在同一块。
这时候要特别注意两点:
- C 层写入前先确保
bytearray已经把长度扩容到位,任何append都会改变底层缓冲地址,之前的 NumPy 视图就会失效; - 如果 C 层改了数据,Python 层读到的数组是实时更新的,因为两者本来就是同一物理页。这既是优势也是风险,调试时容易以为自己“没赋值成功”,其实是别的协程改了内存。
5.6 最终建议:先测量,再决定要不要这行
回看这一整篇,所谓“一行 bytearray 加速 NumPy”,本质并不是bytearray有什么魔法,而是它把缺页异常集中到了你主动控制的初始化阶段,把热路径里的隐式逐页分配变成了显式的一揽子触页。它适用于大缓冲、高频填充、内存常驻的业务;在 100MB 以下,收益很小;在 IO 瓶颈下,毫无意义。
就我个人的经验来说,判断要不要用这招的最快方法是:先跑一遍现在的代码,看一眼 minor faults 的数值,如果它在总耗时里占了明显比例,那就值得用bytearray做预分配。如果你的代码只是一些小数组拼拼凑凑,那就别折腾,直接np.zeros保持简单。内存预热不是银弹,但它确实让我那个采集系统的每帧处理时间从 0.5s 级别降到了稳定 0.06s 左右。希望你下次再看到“一行代码加速 NumPy”时,也能一眼看穿它究竟在加快哪个阶段。