在4 GB的数据堆中找针:Go语言性能从0.75 GB/s提升到49 GB/s

Lobsters Hottest 新闻

摘要

一位开发者详细介绍了将Go文件搜索从0.75 GB/s优化到49 GB/s的过程,利用SIMD等技术并理解内存层次结构,包括Go 1.26新推出的`simd/archsimd`包。

<p><a href="https://lobste.rs/s/2lhx8n/finding_needle_4_gb_haystack_from_0_75_gb_s">评论</a></p>
查看原文
查看缓存全文

缓存时间: 2026/07/07 22:21

# 在 4 GB 的草堆中找一根针:Go 语言从 0.75 GB/s 到 49 GB/s 的优化之旅 来源:https://segflow.github.io/post/fast-file-search-go/ 我有一个 4 GiB 的文件,几乎全是零,恰好**一个**非零的 `int64` 藏在偏移量 `Size - 8`(最后一个对齐的位置)。任务:在 Go(Linux)中以最快速度找到那个偏移量。这是一个故意设计的蠢问题。没有解析、没有索引、算法层面没有任何巧妙之处。唯一衡量的是每秒我们能通过 CPU 推送多少数据。正是那种能暴露整个技术栈每个层次的微任务:Go 运行时、标准库、内核、页面缓存、内存层次结构、SIMD,包括 Go 1.26 全新的 `simd/archsimd` 包,让你能用纯 Go 编写 AVX-512 指令。 从最明显的 `os.ReadFile` + `for range` 开始,我们得到 **0.75 GB/s**。经过十三个变种,我们达到了 **49 GB/s**,加速了 66 倍,并且我们将确切知道我们撞上了哪堵墙以及为什么。 ## 测试环境 测试机器: - AMD Ryzen 5 9600X(Zen 5, 6c/12t, 支持 AVX2*和*AVX-512) - 15 GiB DDR5 - WSL2 / Linux 6.6 / ext4 / NVMe SSD - Go 1.26 草堆正好是 4 GiB(`4 << 30` 字节)。我使用 `pwrite` 将零写入整个文件一次,以便块真正被分配(否则稀疏文件的读取是免费且无意义的),然后在偏移量 `Size - 8` 处植入一个固定的魔法 `int64`。**针从不移动**,每次运行和每次程序调用都是相同的位置、相同的值,因此没有运气因素,并且页面缓存状态在迭代之间不受干扰。 对于每个变种,我运行 5 次计时迭代,去掉最快和最慢的,报告剩余三次的平均值。所有主要测量值都是**热缓存**(4 GiB 文件完全适合 RAM,并且在每个变种之前预先读取一次)。在最后,我还会展示使用 `posix_fadvise(POSIX_FADV_DONTNEED)` 的冷缓存数据。 所有变种和基准测试框架的完整源代码可在 GitHub 上浏览 (https://github.com/Segflow/segflow.github.io/tree/main/content-assets/fast-file-search-go/code)。 ## V1:最天真的做法 将整个文件读入内存并逐字节遍历: ```go func (S) Search(path string) (int64, error) { data, err := os.ReadFile(path) if err != nil { return -1, err } for i, b := range data { if b != 0 { return int64(i) &^ 7, nil } } return -1, nil } ``` `os.ReadFile` 分配一个 4 GiB 的 `[]byte` 并将整个文件 `copy_to_user` 到其中。然后一个紧凑的 Go 循环遍历 40 亿个字节,寻找第一个非零字节。 v1-naive-readall 的 CPU 火焰图 (https://segflow.github.io/content-assets/fast-file-search-go/flamegraphs/v1-naive-readall.svg) 执行时间大约 72% 在扫描循环内部,28% 在 `os.ReadFile` 内部(主要是 `runtime.makeslice` 将 4 GiB 堆内存初始化为零)。天真的代码做了两倍的工作:分配、复制,然后扫描。 **结果:749 MB/s。** 大部分时间花在了分配和内核复制上:每次运行让 Go 运行时增长 4 GiB 堆可不是免费的,一旦工作集超过 L3 缓存,分配器的压力就清晰可见。这是基线。 ## V2:`bufio`(教科书答案) 每个 Stack Overflow 上“如何在 Go 中读取大文件”的答案都是这样的: ```go r := bufio.NewReaderSize(f, 1<<16) // 64 KiB for { b, err := r.ReadByte() ... } ``` v2-bufio-byte 的 CPU 火焰图 (https://segflow.github.io/content-assets/fast-file-search-go/flamegraphs/v2-bufio-byte.svg) **约 77% 的 CPU 时间单独花在了 `bufio.(*Reader).ReadByte`** 上。二十亿次调用,二十亿次边界检查,二十亿次偏移量递增。实际的字节比较反而是便宜的部分。 **结果:755 MB/s,基本上与天真的 `os.ReadFile`(#v1)打平。** `bufio.Reader.ReadByte` 是一个 Go 函数调用,每个字节一次。四十亿次调用。编译器无法内联,调用开销压过了查看字节的开销。**天真的 `os.ReadFile`(#v1)** 预先支付了巨大的分配税;bufio 则支付了巨大的函数调用税。最终总时间几乎相同。教训:**如果可能,避免为每个字节支付函数调用开销。** ## V3:更大的块,一次扫描 8 个字节 我们将文件以 1 MiB 的块流式读入一个可复用的缓冲区,并将每个块作为 `[]uint64` 处理: ```go buf := make([]byte, 1<<20) for { n, err := io.ReadFull(f, buf) words := unsafe.Slice((*uint64)(unsafe.Pointer(&buf[0])), n/8) for i, w := range words { if w != 0 { return off + int64(i)*8, nil } } off += int64(n) if err != nil { break } } ``` 两处改变: 1. **每兆字节一次系统调用**,而不是每字节一次。内核通过单次 `copy_to_user` 传输 256 个页面缓存页。 2. **内层循环每次迭代处理 8 个字节**。与**天真的 `os.ReadFile`(#v1)** 相同的分支和比较次数,但每次迭代的工作量是原来的 8 倍。 v3-chunked-uint64 的 CPU 火焰图 (https://segflow.github.io/content-assets/fast-file-search-go/flamegraphs/v3-chunked-uint64.svg) 约 60% 的时间花在 `internal/poll.(*FD).Read`(读取系统调用 + `copy_to_user`),40% 在扫描循环中。我们干净地摊销了系统调用开销;内核复制现在是主要成本。 **结果:13.7 GB/s,提升了 18 倍。** 这已经很不错了。大多数人会就此止步。我们不会。 ## V4:mmap,显而易见的下一步 当内核可以直接将页面缓存窗口交给我们时,为什么还要从内核空间复制字节到用户空间? ```go data, _ := unix.Mmap(int(f.Fd()), 0, size, unix.PROT_READ, unix.MAP_SHARED) defer unix.Munmap(data) words := unsafe.Slice((*uint64)(unsafe.Pointer(&data[0])), size/8) for i, w := range words { if w != 0 { return int64(i) * 8, nil } } ``` 假设:与**分块 `uint64`(#v3)** 相同的扫描循环,但去掉了巨大的 `copy_to_user`。应该更快。 v4-mmap 的 CPU 火焰图 (https://segflow.github.io/content-assets/fast-file-search-go/flamegraphs/v4-mmap.svg) 几乎 100% 的时间都在我们的单个 `S.Search` 帧内。但看似“扫描时间”的大部分实际上是**次要页面错误**,当循环触及每个新的 4 KiB 页面时懒加载发生。pprof 将时间归因于触发陷阱的用户态函数,隐藏了内核端的成本。 **结果:13.2 GB/s,*略慢于*分块 `uint64`(#v3)。** 令人惊讶。原因是**次要页面错误**。mmap 实际上并不映射页面——它只设置地址空间元数据。当用户态循环首次触及每个 4 KiB 页面时,CPU 陷入内核,内核随后找到已缓存的页面并更新页表。对于一个 4 GiB 的文件,这超过 **100 万次页面错误**。每次大约一微秒。仅错误开销就超过一秒,而循环本可以全速扫描。所以 mmap 节省了内核到用户的复制,但增加了每页的税。 ## V5:给内核提示:`madvise` 也许内核只需要知道我们在做什么: ```go _ = unix.Madvise(data, unix.MADV_SEQUENTIAL) _ = unix.Madvise(data, unix.MADV_WILLNEED) ``` - `MADV_SEQUENTIAL`:“我正在按顺序扫描,积极预取,并且可以随意丢弃我身后的页面。” - `MADV_WILLNEED`:“现在就把这些页面加载进来;不要让我在冷读取时等待。” v5-mmap-madvise 的 CPU 火焰图 (https://segflow.github.io/content-assets/fast-file-search-go/flamegraphs/v5-mmap-madvise.svg) 与 **mmap(#v4)** 无法区分:火焰图是一个高栈,位于 `S.Search` 之上。`madvise` 调用在左侧显示为一小条,但并未带来任何收益,因为缓存已经是热的。 **结果:13.2 GB/s,基本上与 mmap(#v4)相同。** 为什么?因为在热缓存中,没有什么需要预取的,页面已经在 RAM 中。当所有内容都已缓存时,`WILLNEED` 是空操作,而 `SEQUENTIAL` 的预读仅对冷读取有帮助。瓶颈不是 I/O,而是 100 万+ 的页面错误,`madvise` 对此无能为力。(`madvise` *确实*对冷缓存读取很好,我们将在最后看到。) ## V6:并行化 mmap 扫描 单个 goroutine 进行标量 uint64 加载在 DDR5 单线程带宽附近达到顶峰。使用多个核心,我们可以发出更多并行的缓存行请求,并在飞行中提供更多 DRAM 通道: ```go nWorkers := runtime.NumCPU() // 此机器为 12 per := (totalWords + nWorkers - 1) / nWorkers var found atomic.Int64; found.Store(-1) var wg sync.WaitGroup for w := range nWorkers { start, end := ... // 分片边界 wg.Add(1) go func() { defer wg.Done() words := unsafe.Slice(..., end-start) for i, x := range words { if x != 0 { // CAS 更新最小找到的偏移量 ... return } } }() } wg.Wait() ``` v6-mmap-parallel 的 CPU 火焰图 (https://segflow.github.io/content-assets/fast-file-search-go/flamegraphs/v6-mmap-parallel.svg) 约 90% 的时间分布在每个 goroutine 的 `Search.func1` 帧(进行扫描)。有趣的小条是**约 8% 在 `runtime.asyncPreempt`**,运行时在循环中间停止 goroutine 以进行调度。由于 12 个热 goroutine 争夺 12 个逻辑 CPU,抢占成本变得可见。 **结果:28.8 GB/s,是 mmap + madvise(#v5)的 2.2 倍,是天真的 `os.ReadFile`(#v1)的 38 倍。** **扩展故事**:从 11 GB/s(1 个 worker)翻倍到 6 个核心时的 27 GB/s(6 倍核心获得 2.4 倍加速),然后*平台化*。超过 6 个 worker 后,添加更多 goroutine 无济于事,有时反而有害。上限不是 CPU 或内存带宽,而是当十二个 goroutine 同时引入新的 4 KiB 页面时,每个进程的页面错误机制的竞争。v6-mmap-parallel。十二个 goroutine 也意味着十二个并发的页面错误流,因此错误开销也跨核心分摊。 ## V7:等等,如果回到 `pread` 呢? mmap 唯一的弱点是页面错误。如果我们采用**分块 `uint64`(#v3)** 的方法(读入缓冲区)但将其并行化呢?每个 goroutine 有自己的文件描述符、自己的 1 MiB 缓冲区,并使用 `pread` 读取自己的分片: ```go go func(start, end int64) { f, _ := os.Open(path) defer f.Close() buf := make([]byte, 1<<20) for off := start; off < end; { n, _ := f.ReadAt(buf, off) words := unsafe.Slice((*uint64)(unsafe.Pointer(&buf[0])), n/8) for i, x := range words { if x != 0 { /* 报告 off + i*8, 停止 */ } } off += int64(n) } }(start, end) ``` 权衡: - mmap:零复制,但有页面错误。 - pread:从页面缓存复制到用户缓冲区(额外的内存流量),但缓冲区适合 L2(1 MiB),因此扫描从 L2 而不是 DRAM 读取,并且完全没有错误。 v7-pread-parallel 的 CPU 火焰图 (https://segflow.github.io/content-assets/fast-file-search-go/flamegraphs/v7-pread-parallel.svg) **现在 75% 的时间花在 `Syscall6`**,即 `pread` 系统调用本身。只有约 21% 在用户端扫描循环。我们翻转了成本中心:瓶颈不再是 Go 代码,而是内核到用户的复制。 **结果:45.4 GB/s,是 mmap 并行(#v6)的 1.6 倍,是天真的 `os.ReadFile`(#v1)的 61 倍。** 新冠军。 **扩展故事**:这个比较不寻常。它在超过 `NumCPU` 后仍能获得吞吐量:从 12 个 worker 时的 34 GB/s 到 24 个 worker 时的 41 GB/s,然后平台化。每个 worker 在 `pread` 复制期间在内核中花费一部分时间,额外的 goroutine 让运行时可以将一个 worker 的系统调用与另一个 worker 的用户端扫描重叠。v7-pread-parallel。直觉上违反直觉:复制数据比错误加载更*便宜*,因为内核复制是每个兆字节一次紧凑的 `rep movsq`,预取器喜欢它,而页面错误是一次串行的陷入内核的舞蹈。 ## V8:使用手写汇编的 SIMD 内层循环每次迭代处理一个 `uint64`:1 次加载 + 1 次比较 + 1 次分支 = 每 8 字节约 3 个 μop。现代 x86 可以做得更好:AVX2 让我们每次指令处理 32 字节,而 `vptest` 将 OR + 分支合并为一个操作。一个最小的 AVX2 内核(Go 汇编): ```asm loop128: VMOVDQU (SI)(AX*1), Y0 VMOVDQU 32(SI)(AX*1), Y1 VMOVDQU 64(SI)(AX*1), Y2 VMOVDQU 96(SI)(AX*1), Y3 VPOR Y1, Y0, Y0 VPOR Y3, Y2, Y2 VPOR Y2, Y0, Y0 VPTEST Y0, Y0 // ZF=1 iff Y0 全零 JNZ found128 ADDQ $128, AX JMP loop128 ``` 每次迭代 128 字节,约 10 个 μop。扫描的 CPU 端基本上是免费的。 v8-simd-avx2 的 CPU 火焰图 (https://segflow.github.io/content-assets/fast-file-search-go/flamegraphs/v8-simd-avx2.svg) **约 96% 的 CPU 时间在 `firstNonZeroAVX2`**,我们的手写汇编内核。其他一切(mmap、madvise、调度)都微不足道。热循环正好是每 32 字节两个 `vmovdqu` 加一个 `vptest`。 **结果(单线程):14.6 GB/s,而标量 mmap(#v4)为 13.2 GB/s。** 大约 10% 的提升。不算大,因为即使是单核的标量循环也已经达到了大部分内存带宽。SIMD 使 CPU 端更快,但缓存行传输仍然是实际瓶颈。 ## V9:并行 mmap + AVX2 如果单线程 SIMD 带来了 10% 的提升,那么并行 SIMD 应该会大杀四方,对吧? v9-parallel-simd 的 CPU 火焰图 (https://segflow.github.io/content-assets/fast-file-search-go/flamegraphs/v9-parallel-simd.svg) **约 98% 在 `firstNonZeroAVX2`**,分布到所有 goroutine。与**AVX2 汇编(#v8)** 相同的内核,只是分片了。注意没有系统调用列:一切都是 mmap 支持的内存,整个墙钟时间就是向量循环在 DRAM 中吞噬数据。 **结果:29.7 GB/s,在噪音范围内与标量 mmap 并行(#v6)(28.8 GB/s)相当。** **扩展故事**:在 4 个 worker 时升至 28 GB/s,然后持平。与没有 SIMD 的标量 mmap 并行(#v6)的极限完全相同,这是关键见解:瓶颈是 mmap 端的页面错误串行化,因此更快的内循环什么也改变不了。CPU 在等待错误解决时空闲。v9-parallel-simd。 这是整个实验中最具信息量的零结果。当 12 个核心并行访问 DRAM 时,我们完全受带宽限制。CPU 大部分时间在等待缓存行。让 CPU 端快 8 倍也无济于事,因为 CPU 有一半时间本来就是空闲的。 ## V10:`MAP_POPULATE`(这是个陷阱) Linux mmap 有一个标志 `MAP_POPULATE`,它告诉内核在 `mmap()` 调用本身期间预错误所有页面。这肯定会解决页面错误问题吧? ```go data, _ := unix.Mmap(int(f.Fd()), 0, size, unix.PROT_READ, unix.MAP_SHARED|unix.MAP_POPULATE) ``` v10-populate-parallel-simd 的 CPU 火焰图 (https://segflow.github.io/content-assets/fast-file-search-go/flamegraphs/v10-populate-parallel-simd.svg) 仍然约 97% 在 `firstNonZeroAVX2`,但*墙钟*更差:火焰图中“缺失”的时间是在 **MAP_POPULATE** 序言期间 `mmap()` 系统调用内部单线程页面错误遍历所花费的,pprof 不会采样,因为阻塞的系统调用不会积累 CPU 样本。 **结果:18.3 GB/s,*比*未预填充的版本(29.7 GB/s)更差。** `MAP_POPULATE` 在 `mmap` 调用内部从单个线程遍历页表。因此它将所有错误工作串行化到一个 CPU 上,而 **mmap 并行 + AVX2(#v9)** 则将其分布到 12 个 goroutine 上。总错误工作相似;但并行性丢失了。 **真正教训:内核手册中的一个提示并不总是胜利。** ## V11:并行 `pread` + AVX2 汇编 将 **pread 并行(#v7)** 的 I/O 策略与 **AVX2 汇编(#v8)** 的扫描内核结合起来。 v11-pread-simd 的 CPU 火焰图 (https://segflow.github.io/content-assets/fast-file-search-go/flamegraphs/v11-pread-simd.svg) **约 93% 的时间花在 `pread`**。AVX2 扫描内核几乎不显示(<1%),因为数据被消费的速度和内核能提供的速度一样快。这就是火焰图中“带宽受限”的样子:系统调用填满了整个画布。 **结果:48.6 GB/s。** 比预读并行(#v7)略有改进。我们显然非常接近极限了。 **扩展故事**:实验中曲线最干净。在达到 6 个核心之前,每次 worker 翻倍,吞吐量也翻倍(17 -> 41 -> 47 GB/s),在大约 48 GB/s 时饱和,然后稳定下来。超过 NumCPU 后,SIMD 内核对于 I/O 端来说太快了,额外的 goroutine 无事可做。v11-pread-simd。 ## V12:在*pure Go*中使用 AVX-512

相似文章

优化CPU密集型Go热路径的笔记

Hacker News Top

本文讨论了CPU密集型Go代码的性能优化技术,指出了泛型和接口抽象因无法内联而产生的局限性,并主张在热路径中使用代码复制。文章通过一个Brotli移植示例和深入基准测试进行了说明。

amd64 微架构级别对 Go 有多大帮助?

Lobsters Hottest

使用 Roaring Bitmap 库对不同 amd64 微架构级别(GOAMD64)编译的 Go 程序进行性能评估,结果表明启用诸如 popcnt (v2) 或 AVX-512 等新指令集可以显著提升性能。