🐧 第八章 外部存储器
📄 一句话概括

128 MB 顺序读,块从 1 KB 到 8 MB:1 KB 要 718 毫秒(178 MB/秒),64 KB 只要 59 毫秒(2168 MB/秒)——差 12 倍。但从 64 KB 往上,再加块就不管用了,甚至略慢。两段成本交替主导,才有了【最优点】这种东西。

📝 本节和前面几节的关系

8.4 把测量器写好了,这一节用它做第一组正式实验:同样的字节数,只改每次读多少,看时间怎么变。

一、1 KB 与 64 KB 差 12 倍

Demo 82 读 128 MB,块从 1 KB 试到 8 MB。 1 KB:718 毫秒,178 MB/秒,调用 131072 次。 4 KB:258 毫秒,495 MB/秒,调用 32768 次。 64 KB:59 毫秒,2168 MB/秒,调用 2048 次。 从 1 KB 到 64 KB,调用次数除以 64,时间除以 12。这一段几乎是纯线性:每次调用的开销是固定的,调用少一次就省一次。 注意 178 MB/秒这个数——它比很多真硬盘的顺序读还慢。为什么会这样?因为这时候瓶颈根本不是硬盘,是【一秒钟只能做这么多次系统调用】。

二、再往上就不管用了

64 KB 之后,曲线平了,然后略微上翘。1 MB:114 毫秒,1121 MB/秒。8 MB:119 毫秒,1072 MB/秒。 为什么不再变快?因为固定成本已经摊薄到可以忽略:2048 次调用的开销是几十毫秒,128 次就只有一两毫秒。剩下的时间全花在【把数据从页缓存拷到你的缓冲区】上。 为什么反而变慢?因为每次 read 都要新分配一块 bytes 对象。8 MB 的分配 + 拷贝,成本比 64 KB 高得多,而且大块内存分配本身要跟操作系统打交道。 所以曲线是两条成本曲线的叠加:一条随块增大而下降(调用开销),一条随块增大而上升(拷贝与分配)。交点附近就是最优点。

三、两段成本模型

总时间 ≈ 调用次数 × 固定成本 + 总字节 ÷ 拷贝带宽。 前半段由固定成本主导:块小,调用多,时间随【1 ÷ 块大小】线性下降。 后半段由拷贝带宽主导:块大,调用少,时间趋于常数。 这个模型能解释三件事: ① 为什么小块慢:固定成本 × 巨大次数。 ② 为什么有个平台区:固定成本已经不重要了。 ③ 为什么太大又慢:模型里没写分配成本,实际里它会长起来。 工程上取 1 MB 左右是常见选择——它离平台区不远,又不会让内存压力太大。但严格说,最优点要自己量,它依赖机器与缓存状态。

四、真盘上这张表完全不同

沙箱里这些数字全是【内存带宽】,因为整份文件都在页缓存里。 真机械盘上,1 KB 与 8 MB 都能跑到接近 150 MB/秒——因为传输率是【设备给的】,块大小只影响一点点额外开销。在这个场景下,块大小的收益远没有沙箱里那么大。 而【真盘上与随机读的差距】才是数量级的:8.1 算过,接近 490 倍。所以【顺序读】这件事本身,比【块设多大】重要得多。 这也解释了为什么很多【调参优化】收益有限:它们在优化的那一项,本来就不是瓶颈。先看清瓶颈在哪一层,再决定调什么。

五、动手 Demo:顺序访问——为什么【块越大越快】会饱和

这次读 128 MB,块从 1 KB 一直试到 8 MB,把【调用次数】也一起打出来,看时间到底跟哪一个走。

import os, tempfile, time

KB, MB = 1024, 1024 * 1024
d = tempfile.mkdtemp(prefix="ch29c8_")
p = os.path.join(d, "big.bin")
size = 128 * MB
open(p, "wb").write(b"\0" * size)

def read_bs(bs, reps=3):
    r = []
    for _ in range(reps):
        t0 = time.perf_counter()
        with open(p, "rb", buffering=0) as f:
            n = 0
            while n < size:
                n += len(f.read(bs))
        r.append((time.perf_counter() - t0) * 1000)
    return min(r)

read_bs(MB, 1)            # 预热

for bs in (KB, 4 * KB, 16 * KB, 64 * KB, MB, 8 * MB):
    ms = read_bs(bs)
    print("%5d KB  调用 %6d 次  %8.2f 毫秒  %6.0f MB/秒"
          % (bs // KB, size // bs, ms, size / MB / (ms / 1000)))

实跑输出:

--------------------------------------------------------------------
顺序访问——为什么【块越大越快】会饱和
--------------------------------------------------------------------
① 128 MB 顺序读,块从 1 KB 到 8 MB

  每次读     调用次数     耗时            带宽
  ------------------------------------------------------
  1 KB       131072       718.01 毫秒     178 MB/秒
  4 KB       32768        258.40 毫秒     495 MB/秒
  16 KB      8192         94.06 毫秒      1361 MB/秒
  64 KB      2048         59.04 毫秒      2168 MB/秒
  1 MB       128          114.17 毫秒     1121 MB/秒
  8 MB       16           119.35 毫秒     1072 MB/秒

  **1 KB 是最慢的,64 KB 是最快的,差 12.2 倍。**
  **但从最快那一档往上,再加块就不管用了,甚至略慢。**
  **说明瓶颈已经从【调用次数】换成了【拷内存的速度】。**

② 为什么会饱和

  **每次 `read` 有两段成本**:
  **一段是固定的(进内核、查页、返回),一段是随字节数长的(拷到用户缓冲区)。**
  **调用次数多的时候,第一段是瓶颈,所以块越大越快、并且接近线性。**
  **块大到一定程度,第一段摊薄了,第二段成了瓶颈,再加块也没用。**

  **模型**:**总时间 ≈ 调用次数 × 固定成本 + 总字节 ÷ 拷贝带宽。**
  **前半段由固定成本主导,后半段由拷贝带宽主导。**
  **所以【块大小】有一个最优区间**:**太小是调用开销压死,太大是内存占用与首字节延迟变差。**
  **1 MB 左右是常见选择。**

③ 在真机械盘上,这张表会长得很不一样

  **沙箱里这些数字全是【内存带宽】**,**因为整份文件都在页缓存里。**
  **真机械盘上,1 KB 与 8 MB 都能跑到接近 150 MB/秒**,
  **因为传输率是设备给的,块大小只影响一点点额外开销。**
  **而【真盘上与随机读的差距】才是数量级的**(8.1 算过,接近 490 倍)。

  📋 **Linux 上对比**:`dd if=大文件 of=/dev/null bs=4k` 与 `bs=1M` 各跑一次;
  **再 `echo 3 | sudo tee /proc/sys/vm/drop_caches` 清缓存后重跑**,**两组数会完全不同。**

看【调用次数】那一列:从 131072 掉到 16,降了 8000 倍;而时间只降了 6 倍。说明调用开销只是成本的一部分。 1 KB 那档的 178 MB/秒不是硬盘速度,是【系统调用速度】。这是最容易读错的地方。 64 KB 附近是最好的区间:调用次数已经不多,单次分配还不算大。 1 MB 之后反而慢一点,因为每次 read 都要新分配一块大内存并拷进去。

📋 Linux 上对比:dd if=大文件 of=/dev/null bs=4k 与 bs=1M 各跑一次; 📋 再 echo 3 | sudo tee /proc/sys/vm/drop_caches 清缓存后重跑,两组数会完全不同。

30 秒实验

  1. 把 8 MB 那档去掉,加上 128 B,看会不会更慢。
  2. python -c "import os;print(os.cpu_count())",想一想这和块大小有没有关系。
  3. 改用 f.readinto(bytearray) 代替 f.read(),看大块那档能不能救回来。

常见误区

  1. 【1 KB 那档 178 MB/秒说明这台机器内存很慢】。它说明的是【系统调用很慢】,数据搬运速度远高于此(64 KB 那档 2168 MB/秒才是)。
  2. 【最优点是一个固定值,记下来就行】。最优点随机器、缓存状态、是否直读而变,要用自己的场景量一遍。
  3. 【块大小调到最优,程序就快了】。如果真正的瓶颈是磁盘随机寻道(8.6),调块大小收益极其有限。
自测三问(点开对答案) 1. 为什么 1 KB 那档只有 178 MB/秒,而 64 KB 能到 2168 MB/秒? —— **因为 1 KB 那档的瓶颈是【系统调用次数】**:**131072 次调用的固定开销加起来,比搬数据的时间还长。****64 KB 那档调用只有 2048 次,开销被摊薄了。** 2. 为什么块大到 1 MB 之后反而变慢? —— **因为每次 `read` 都要新分配一块 bytes 并拷贝**,**块越大,这一笔分配与拷贝越贵,****大到一定程度就压过了省下的调用开销。** 3. 在真机械盘上,块大小的收益为什么远没有沙箱里大? —— **因为真盘的顺序读速度由【设备传输率】决定(约 150 MB/秒)**,**块大小只影响一点点额外开销。****沙箱里快的那些倍率,来自【省掉系统调用】,不是【硬盘变快】。**

相关笔记