Hi,欢迎来到 卡瓦邦噶!我是 laixintao,现在生活在新加坡。我的工作是 SRE,喜欢在终端完成大部分工作,对各种技术都感兴趣。我从 2013 年开始写这个博客,写的内容很广泛,运维的方法论,编程的思考,工作的感悟,除了技术内容之外,还会分享一些读书感想,旅行游记,电影和音乐等。欢迎留下你的评论。

声明:本博客内容仅代表本人观点,和我的雇主无关。本博客承诺不含有 AI 生成的内容,所有内容未加说明均为博主原创,一经发布自动进入公有领域,本人放弃所有权利。转载无需本人同意。但是依然建议在转载的时候留下本博客的链接,因为这里的很多内容在发布之后会还会不断地继续更新和追加内容。 Not By AI

Traceroute the World

ICMP 一直是我最喜欢的协议,在没有机器/设备的登录权限的时候,ICMP (ping) 可以让我在 debug 的时候获得很多关键的信息:网络通不通,延迟多少,有没有环路,经过了哪些设备,等等。其中比较关键的机制,一个是 ping 工具,即 echo/reply 机制;另一个是在 TTL 耗尽的时候发回来 ICMP 错误信息1,这是 traceroute 工具实现的基础原理。

在网上经常有人贴出来一些奇奇怪怪的 traceroute 结果2,有人找到很长的 traceroute 会很兴奋。所以…… 我想我们能不能用系统的方法找到 traceroute 比较长的 IP 呢

虽然这个问题没有什么实际的意义,但是本文在探索这个问题的时候会遇到一些常见的技术,还是挺有意思的。

实验 traceroute 的起点,我们设置在新加坡。

新加坡是一个重要的交通枢纽。这是在写这篇文章的时候,新加坡附近起降的飞机(PS,新加坡的樟宜机场是我最喜欢的机场,如果你路过新加坡的话,建议给樟宜机场预留多 2 个小时的时间逛一逛)。

新加坡繁忙的空域,来源:https://planefinder.net/

也是一个重要的海上枢纽。

新加坡附近的船只。来源 https://www.vesselfinder.com/

在网络方面,新加坡也有丰富的海底光缆。

所以,预期最长的 hop 不会太高,现在世界的互联网越来越扁平了,一般一个互联网 IP 可以在 10-20 个 hop 触达。我用了一台 DigitalOcean 的机器来做这个实验,用它来 traceroute 1.1.1.1 只需要 8 跳,延迟在 1ms 左右。

mtr 1.1.1.1 只有 8 跳

回到本文的问题:找到一个 traceroute 最长的 IP,直观的方法就是 traceroute 每一个互联网的 IP。(本文只讨论 IPv4)

traceroute 对每一跳发 3 个包,超时时间 5s,最长 hop 尝试 30 跳。假设平均 15 跳完成,15 跳 × 3 探针 × ~100ms RTT = 5s,如果 IP ping 不通,那么 traceroute 可能要花 2min 以上。即使按照 5s 来算,我们 traceroute 整个互联网也需要:4,294,967,296 × 5 秒 = 21,474,836,480 秒 ≈ 680 年。如果开 1000 个并发,也需要 680 年 ÷ 1000 = 0.68 年 ≈ 248 天,大约8个月的时间。

怎样加快呢?

先来看一下现在的时间花在哪里了。traceroute 的逻辑是:对于目标 IP,发送 TTL=1 的包,等待回复,然后再重复 2 次;接下来换 TTL=2 的包,发送,等待回复,重复 3 次……

这样有两个问题:

  1. 时间都在等待回复上了,效率太低;
  2. 一个进程只能 trace 1 个目标 IP,如果开并发的话,会消耗很多 CPU 在 context switch 上,实际发出去的包非常少,CPU 使用率却很高;

对于本文的这个项目,我们是想找出来 traceroute 最长的一个 IP,而并不关心具体的路径。

要求高性能,我们换一个方式:不再使用发送——等待,我们设计两个程序,一个给所有的 IP 一起发送包,另一个监听收到的回应,如果是 ICMP 的 reply 包,就记录此 IP 可以 ping 通,如果收到其他的 ICMP 类型的包,直接丢弃即可。这样就完全没有等待时间了,而且之后两个程序,上下文切换的问题也解决了。

但是这就有了一个新的问题:我们怎么知道收到的包对应的 TTL 是多少呢?仅通过收到的 ICMP Time Exceeded 包是无法知道 TTL 消耗了多少的,怎么找到最长的 TTL 呢?

为了区分出来 TTL,我们分多轮进行扫描,先对所有的 IP 发送 TTL=1 的包,接收程序如果收到了 ICMP reply 的回应,说明这些 IP 在 TTL=1 的时候就能 ping 通,由于我们要找的是 TTL 越长越好,所以这些 IP可以直接淘汰了。接下来我们把 TTL=1 不能 ping 通的包,用 TTL=2 再发送一轮,如果能收到 ICMP reply,那么也可以淘汰了…… 假设我们在 TTL=30 的时候有一些 IP 能 ping 通,但是在 TTL=31 以及之后的时候没有任何 IP 可以 ping 通,那就说明这些 TTL=30 的 IP 就是胜者。

这样需要多久呢?

DigitalOcean 页面解释:All other Droplets have a maximum network throughput limit of 2 Gbps3. 每一个 ICMP 包的大小是:Ethernet 14 + IP 20 + ICMP 8 + payload 32 = 74 bytes,所以,理论上我们可以跑到:

2 Gbps ÷ (74 bytes × 8 bits) = 2,000,000,000 ÷ 592 ≈ 3,378,378 pps ≈ 3.4M pps

Ping 一次整个互联网只需要:

4,294,967,296 (2^32,所有的 IPv4 数量) ÷ 3,400,000 ≈ 1263 秒 ≈ 21 分钟

但是为了避免 overload 接受端(大量网段在同一个区域),以及中间设备可能存在的 conntrack,我们把速度限制在 100K pps,这样,只用了全速的 2.7%,ping 一次需要的时间是:

4,294,967,296 ÷ 100,000 ≈ 42,950 秒 ≈ 11.9 小时

对于我们的场景来说,也足够了。

第一个法宝:XDP

使用我们自己的方式来发送 ping 包并且能跳过不需要的 conntrack 功能,就需要使用 kernel bypass 技术:

  • egress 使用 AF_XDP 直接发送;
  • ingress 使用 XDP,attach BPF 程序到 eth0 网卡上,直接在网络收包的最前方进行处理;

在收包程序上,直接看这个 IP 是不是一个合法的 ICMP reply,如果是,就记录 ping 通,如果不是就放通或丢弃。

但是 XDP 是运行在 kernel 的程序,如何把 ICMP reply 里面的 IP 信息记录到文件中呢?

第二个法宝:ring buffer

Ring buffer4 在网络领域是一个非常常用的数据结构,它本质上是一个 buffer,生产方可以往里面写,消费方从里面读,是两个指针。它天然适合网络的原因是,buffer 的 head 和 tail 是相接的,自然而然就可以实现「如果生产方生产的速度太快,丢弃(覆盖)最早到达并且还没有处理的包」。(对于 BPF ring buffer,如果用户态消费得不够快、buffer 没有剩余空间,新的记录会写入失败。)

使用 BPF ring buffer (BPF_MAP_TYPE_RINGBUF),作为 kernel space 和 user space 的桥梁——kernel 往这个 ring buffer 里面不断写入可以 ping 通的 IP,用户态读出来这个 IP 记录到文件中。

接下来我们看用户态的程序。怎么存储这些 IP 呢?

我们可以使用一个 txt 文件不断 append IP,但是这样查找起来的话就是 O(n) 了。(不过我们只需要最后的几个赢家,大部分情况不需要查找,所以……还好啦)。另一个方案是使用一个数据库,比如 sqlite,查找快,不过大量写入的时候就有瓶颈。

看起来用最简单的文本好一些。

如果按照一个 IP 一行的格式,xxx.xxx.xxx.xxx\n,一共是 16 bytes,16 bytes * 2^32 就是 64GiB (最坏的情况)。

ping 一次就需要 64GiB!这也太多了,作为一个 hobby project,我们的目标是使用一个 $5 的 DigitalOcean VPS 来完成这个任务,磁盘只有 25GiB,远远不够。

有哪些字符可以简化呢?乍一看,首先是每一个 IP 的一个点,以及,IP 的每一个段实际有效数据是 0-255,但是可以表示的容量是 0-999,所以有很大一部分空间浪费了。

诶等等,IP 不就是 4 个 bytes 吗?这样的话我们可以把 IP 转化成一个 32位的 int:

这样的话每 4 bytes 是一个 IP 总共是 16GiB,缩小了 4 倍!而且,4 bytes 里面每一个组合都是一个合法的 IP ,完全没有空间的浪费。

等等,「每一个组合都是一个合法的 IP」——那是不是就意味着,我们从 0 数到 2^32,每一个位置都有一个 IP?这样的话,可以让每一个位置的 bit 位,如果是 1,表示这个 IP 能够 ping 通,如果是 0,表示 ping 不通(默认,文件初始化为 0)。而这个 bit 所在的 index(比如,是文件的第 3232235521 比特位,就表示 192.168.0.1 这个 IP),就表示 IP。

第三个法宝:bitmap

恭喜我们自己!我们刚刚发明了 bitmap!

但是每次 seek 过去,把要写入的 bit 组合成一个 byte,调用 syscall write,这也太麻烦了!

第四个法宝:mmap

mmap 可以帮我们把文件映射到内存地址空间,我们只要操作内存就可以了,操作系统会在后台帮我们同步——定期把内存的修改写入到文件中。这可太方便了!

这样,我们 ping 一轮,就有一个 512 MiB(Exactly 512 MiB!) 的文件,里面存储了这一轮 ping 通的 IP。然后我们拿 bit 是 0 的 index 作为 IP,做下一轮的 ping,能通的 IP 会越来越少,直到得到一个冠军。

其实互联网大部分 IP 是 ping 不通的,尤其是我们刚开始使用比较小的 TTL。所以我们的文件的大部分内容都是 0,只有一小部分是 1,而且 1 的部分通常是连续的。因为 IP 是按连续的段分配给不通的组织,一般来说,一个段要么都可以通,要么都不通。

那么我们可以省略中间的 0 的部分吗?这样的话可以节省一大部分磁盘。

第五个法宝:Sparse file

答案是可以!这叫做 Sparse file,或者叫 file hole。比如我可以创建一个 10PB 的超大文件:

然后实际占用的空间,使用 du 查看,是 0:

使用非常简单,seek 到 EOF 以后的位置,随后再 write,中间没有写过的区域形成 hole (注意,不是 write 0)。

我们可以先用 ftruncate 调用,创建一个 sparse file,然后用 mmap 只修改需要写为 1 的部分,这样,其实只有 1 占用空间了。

将将将!

实际运行还有很多问题,比如 IP 不稳定,有时候回复 ping 有时候不回复有时候回复;有些 IP 使用另一个 IP 回复 ping;有些 IP 不减 TTL 导致产生无限环路,等等。

最后我找到 34 跳的就放弃了:

长达 34 跳的 ICMP

代码放在这里了:https://github.com/laixintao/traceroute-the-world 有兴趣的读者可以自己跑跑看。

代码是 AI 写的,但是这篇博客是我自己纯手写的。这年头能读的博客不多了,但是可以放心的是,这个博客不会有大批量 AI 生成的文字。

  1. 参考 使用 mtr 检查网络问题,以及注意事项 ↩︎
  2. 比如这里 https://www.reddit.com/r/ZiplyFiber/comments/15vvs4c/holy_traceroute_batman_this_has_to_be_a_new_record/, 以及这里 https://www.reddit.com/r/sysadmin/comments/2gxz4e/is_it_possible_to_find_the_worlds_longest_routes/ ↩︎
  3. https://docs.digitalocean.com/products/droplets/details/limits/ ↩︎
  4. https://en.wikipedia.org/wiki/Circular_buffer ↩︎
 

On Children

by Kahlil Gibran

Your children are not your children.
They are the sons and daughters of Life's longing for itself.
They come through you but not from you,
And though they are with you, yet they belong not to you.
You may give them your love but not your thoughts.
For they have their own thoughts.
You may house their bodies but not their souls,
For their souls dwell in the house of tomorrow, which you cannot visit, not even in your dreams.
You may strive to be like them, but seek not to make them like you.
For life goes not backward nor tarries with yesterday.
You are the bows from which your children as living arrows are sent forth.
The archer sees the mark upon the path of the infinite,
and He bends you with His might that His arrows may go swift and far.
Let your bending in the archer's hand be for gladness;
For even as he loves the arrow that flies, so He loves also the bow that is stable.
 

生活来在一个包里

时间过得真快,Rick and Morty 已经播出 13 年了。

在试播集的时候,Rick 说的这句话——「你、你的人生还长着呢,而且你的肛门现在还又紧又有弹性」——我就不理解,为什么肛门不会紧?

“Y-y-you’ve got your whole life ahead of you, and your anal cavity is still taught, yet malleable”

但 13 年之后,我理解了 Rick……

由于肛门不再紧致的问题,所以我上厕所不会带手机玩。厕所里有一块太太买的硅藻泥脚垫(非常好用),上面有一圈比较「正能量」的文字,如下:

由于已经使用半年了,看着比较脏。读者可能也不会有我这么多的时间去转着圈读。所以我把这段话抄下来:

Life comes in a package.
This package includes happiness and sorrow,
failure and success,
hope and despair.
Life is a learning process.
Experiences in life teach us new lessons and make us a better person.
With each passing day we learn to handle various situations.

平平无奇的一段话。但是我读过一百遍了,在某一天突然发现,这说的真有道理。

—— Life comes in a package.

如果没有失败,成功就没有意义。正是因为有失败的几率,成功才变得值得庆祝。

如果没有悲伤,幸福也容易被忽视。

读过一些讲如何处理情绪的书,其中共同提到的一个技巧是,不要与情绪对抗,要观察情绪,就如同对待天气,认识到情绪的变化,就如同天气会变化,让情绪过去。

但很难做到,不知道如何操作。

我海藻泥开悟之后,一旦想,Life comes in a package, 就非常容易理解情绪的变化了。那些焦虑,是因为关爱和担心;伤心,也是因为爱。

生活是一个过程,而不是一个结果。当人老了,回顾自己的一生的时候,看到的不会是最后到达的点,而是曾经走过的所有的路。

这样一想,路上无论遇到什么,都不会是遗憾了,无论遇到什么,都可以勇敢地去面对。

 

服务器高性能网络调优

这篇文章写一下服务器的网络方面的调优经验。如果网络的带宽使用在 1GE,其实没有必要做调优,默认的参数基本没有问题;如果有 10G 以上的网络带宽要求,就需要做一些优化了,比如三层网关,四层负载均衡,七层负载均衡,大流量的 Redis 服务器这种场景,在 25GE, 100GE 甚至 400GE 的网卡上,如果不做调优,可能无法跑满带宽。

本文专注于系统参数调优,不涉及代码方面的优化。(当然代码方面的优化也是非常重要的,在高性能网络方面,通过优化代码减少指令数,提高缓存命中率,性能提升也是非常显著的。)

本文提供优化思路,具体的性能差别需要设计实验来得出,虽然我们做了大量的性能测试,但是全贴出来就显得文章太长没有重点,所以实验参数就不贴了。

这是一个 TCP 的 ingress 大体的流程图。

来源:The Path of a Packet Through the Linux Kernel

Linux 网络栈的收包流程大致上可以分成这么几步:

  1. 网卡(NIC)收到包;
  2. NIC DMA 把包写入内存,更新 Ring Buffer;
  3. NIC 发送 interrupt 通知 CPU;
  4. CPU 进入 IRQ handler,调度 NAPI,触发 SoftIRQ;
  5. SoftIRQ 被触发,NAPI poll RX Ring,处理数据包;
  6. 进入内核的协议栈处理,数据包最终被用户态的程序通过 socket API 处理。这部分不是这篇文章服务器性能方面关注的重点,所以省略。

我们按照这个路线讨论需要调优的地方。

PCIe

首先,网卡是通过 PCIe 接口插在主板上,如果 PCIe 的带宽小于网卡的带宽,那么在第 1 步就出现瓶颈了。

可以用以下命令检查 PCIe 的实际带宽。

先找到网卡所在的 PCIe 地址:

然后用 lspci 查看这个地址的信息:

关键信息是第 23 行显示 LnkSta: Speed 16GT/s, Width x8

16 GT/s 是 PCIe 的单通道的速度,表示 Giga Transfers per second,PCIe Lane 每秒传输多少个符号(symbol),PCIe Gen 4 每 130 bit 有 2 bit 的编码开销,所以实际带宽是:

128b / 130b Encoding * 16G ≈ 15.754 Gbps

一共有 8 lane,15.754 Gbps * 8 = 126 Gbps.

上面的这张网卡是 25GE,所以 PCIe 的带宽是绰绰有余了。如果是 200 GE 的网卡,就必须插在 PCIe Gen 4 x 16 或者 PCIe Gen 5 x 8 以上才行了。

PCIe 物理插口的宽度

这里需要格外注意的一点是,我这张网卡虽然是 25GE,但是上面是有 2 个 25GE 的接口的,做了 bonding1,所以实际这张网卡的带宽是 50G。以此类推,如果是 100GE 双网口的网卡,PCIe Gen 4 x 8 是不够的。

可以用 ethtool 查看在用网卡的实际 PCI bus 地址,都是 31:00,说明是同一个 bus,同一个 device。

我们再来看第 2 步,网卡把数据包的信息 DMA 到内存。

内存

现代网卡得益于 DMA 机制,在网卡收到包到写入内存的时候完全不需要 CPU 参与工作。

——其实这也是一个优化的基本思路,CPU 本身就不适合处理单一的、大批量的任务,应该尽量减少 CPU 的工作。

IOMMU

Linux 里面每一个进程都有一个虚拟的,独立的内存空间,CPU 读取内存的时候,需要 MMU 把虚拟地址转换成物理内存地址。

PCIe 设备直接 DMA 内存也是一样,不过用的是是 IOMMU。简单来说,设备 DMA 的路径如下:

NIC -> IOVA -> IOMMU Page Table -> Physical Address

IOMMU 在这里负责将设备访问的 I/O 地址(IOVA)转换为物理地址,并限制设备只能访问被授权的内存。但是这里也有一层虚拟地址到物理地址的映射,会带来 overhead。

如果关闭,就可以节省这层 overhead,让设备直接写物理内存。但是这样会带来风险——如果网卡存在 bug,就可以写到其他内存地址,是比较危险的。

也可以使用 pass though 模式,让 IOMMU 使用直通模式运行,这样也没有每次内存写入的翻译成本,但是对内存有保护。配置方式是 GRUB 的启动命令添加 intel_iommu=on iommu=pt. 在 pt 模式下,设备通过 IOMMU 的开销很小,而且只能访问特定范围的内存,不能越界。

Page Pool

如果每次 DMA 的时候都申请内存,会有不小的开销。所以比较好的方式是事先规划好一部分内存,这部分内存就不回收了,直接给网卡 DMA 用。

这个功能不需要配置,但是需要网卡驱动和 kernel 支持。

可以通过 debugfs 查看实际网卡有没有使用 page pool:cat /sys/kernel/debug/page_pool/1-0x00000000abcd1234/stats

(如果没有,也不一定就是每次分配内存,可能用了别的内存优化机制)

NUMA

网卡把包写入哪一块内存也有说法。到这里就不得不说一下现代服务器的架构。

现代的服务器一般是双路服务器,里面有 2 颗 CPU,也叫双子星服务器。注意,这里说的是 2 颗 CPU,2 Socket,而不是双核 CPU。

下面是浪潮 NF5180M6 服务器的物理俯视图:

图中有2个 CPU

厂商一般会提供服务器的手册2,里面重要的一部分就是逻辑图。

NF5180M6 服务器的逻辑图

图中的一个重要信息是:内存也是由两部分组成,一半连接 cpu0,另一半连接 cpu1. 这意味着,物理上 cpu0 访问左边的内存比较快,如果要访问右边的内存,就要走 UPI 去另一个 CPU,比较慢。对于 cpu1 来说反之。

numactl 可以看到内存布局:

NUMA 的意思是 Non-Uniform Memory Access,意思是 CPU 访问不同的内存,速度是不一样的。从上面的输出中可以得知,逻辑的 CPU 0,2,4,6…62 号访问 node 0 的内存较快,访问 node 1 的内存较慢,CPU 1,3,5…63 号访问 node 1 的内存较快,访问 node 0 的内存较慢。

这就启发我们,所有内存访问,都应该尽量走 local,避免走 remote:

  • 网卡应该 DMA 到 local node 的内存;
  • 应该让 local node 的 CPU 来处理数据包;
  • 用户态程序应该绑定到 local node 的 CPU 执行;

这样性能才是最优的。

从逻辑图可以看出,一个 PCI 设备只连接到一个 CPU。我们可以用 sysfs 来查看这个 PCI 设备连接的 CPU 所在的 NUMA 节点:

注意:这个文件的信息本质上是 ACPI 提供的,数据来源于硬件的上报,但是有些不守规矩的厂商可能没有上报正确的数据。最准确的信息其实是上面厂商提供的逻辑图3

eth2 这个网卡是连接到 node 0 上的。那么网卡驱动在初始化的时候,应该从 node 0 申请内存,这样,后续的 DMA 都使用 node 0 的内存。这部分一般不需要调试,驱动的内存初始化一般都是 NUMA aware 的。

网卡 DMA 到内存之后,接着是 CPU 的 softirq 来处理这个包,我们希望 node 0 的 CPU 来处理,而不是 node 1 的 CPU 来处理。

首先,找到中断号。(这里,我们以其中一个 queue 1 为例,多 queue 我们后面继续讨论)

由于这个网卡有 2 个接口,每一个接口都有一个 1 号 queue,所以 grep 出来了 2 个。以第一个为例,它的中断号码是 115 号。我们就可以对 115 中断进行 CPU affinity 绑定:对 /proc/irq/115/smp_affinity_list 写入 2,就表示由 cpu2 来处理这个中断,即 cpu2 来处理网卡 1号 queue 的数据包。

数据包最后会通过 socket API 来给用户程序消费。(如果是 XDP 实现的 L3 或者 L4 网关,就没有这一步了),用户程序尽量放在 node 0 来执行,性能会更高。用户态程序绑定 NUMA 的方式很多,比如可以用 numactl --cpunodebind=0 --membind=0 ./app 来让程序尽量使用 node 0 的 cpu 和内存;或者在程序内部使用 pthread_setaffinity_np()sched_setaffinity() 绑定 CPU。

在 200G/400G 的网卡上,内存带宽依然可能成为瓶颈,可以用 pcm-memoryperf 来检查瓶颈是否在内存。

说到了 CPU,我们接下来继续聊一聊 CPU 有关的优化。

CPU

首先是网卡 queue 的数量。有一次我们发现服务器的带宽使用上不去,流量一高就会有丢包。一个 CPU 的使用率 100%,其他的 CPU 空闲。这是一个比较常见的问题,原因是网卡只开了一个 queue。网卡把所有收到的包都 DMA 到这个 queue 的 ring buffer,所以只有一个 CPU 来处理这个 ring buffer 的包。一个 CPU 是无法处理 10GE 的网卡带来的流量的。

这种情况就如下图所示:

Single NIC Queue

解决的办法就是让更多的 CPU 来干活。

但是我们不能让更多的 CPU 来处理这个 queue 的数据,原因是:

  1. 我们不希望多个 CPU 之间有竞争关系,竞争条件意味着效率下降;
  2. 我们不希望让一个 flow 的包走多个 CPU,这样会造成乱序4,乱序对 TCP 来说意味着性能下降。拿 TCP 来说,对于一个 src ip, src port, dst ip, dst port 的 TCP 流来说,我们希望保证这个流经过的交换机,路由器,网卡接口,处理它的 CPU,都是一样的,走一样的路就会降低乱序。

那就只能开更多的 queue,每一个 cpu 处理一个 queue。

这样,我们就需要网卡支持把收到的流量放到多个 queue,但是依然要保持 flow -> queue hash 的一致性。这个功能叫做 RSS,Receive Side Scaling,是在网卡里实现的。虽然 CPU 也支持对收到的包分流(RPS/XPS),但是这个工作也要在一个 CPU 上执行,而且太慢了,一般不用。

RSS

开多个 queue

开多个 queue 很简单,可以通过 ethtool -l eth2 查看当前 queue 的数量。

通过 ethtool -L eth2 combined 8 修改 queue 的数量为 8。

Queue 的数量越多越好吗?也不是,我们的目标是尽可能利用多的 CPU,如果 queue 的数量超过了 CPU 的数量其实也没有意义。

CPU 数量和 Queue 的数量

以下面我这台机器为例:

在 node0 显示有 32 个 CPU,那么应该开 32 个 queue 绑定到这些 CPU 吗?

这就错了,其实最优的方案是用 16 个 CPU。因为 Thread(s) per core: 2,即这是一个 Hyper-Threading 的 CPU,每一个物理 core 有 2 个 thread。

如果看下 CPU 的分布,会发现 cpu0 和 cpu 32 是同一个物理 core,2 和 34 是同一个,以此类推。实际每一个 socket 只有 16 个物理 core,算上超线程才是 32 个 core。

0 和 32,2 和 34,它们的 node, socket, core 都是一模一样。CPU 的计算单元是 share 的。做网络处理,我们的瓶颈不在等待内存,而是在 cpu 的计算。超线程并不会增加 cpu 的计算能力。(不要被 Intel 骗了!)

相反,如果使用了这些 cpu,反而会造成 cache 命中率下降,网络处理极度依赖 cache 提前把下一个要处理的包 load 进来,cache 命中率下降意味着吞吐大幅度下降。

所以我对 IRQ 绑核的时候,只绑 2 个超线程核心中的一个。

或者在 GRUB 启动的时候添加 nosmt,这样超线程的功能直接被关闭了,系统启动之后只能看到物理 core。这样比开启 SMT 但是我们只用超线程中的一个逻辑 CPU 而言,会节省 5% 左右的 SMT 上下文管理成本。

话说,如果只使用 numa node0 的 CPU,那 node1 的 CPU 不是都浪费了吗?是的。

如果要低延迟的网络,推荐只用网卡的 PCIe 所在的 node 的 cpu。但是如果要更多的带宽处理能力,可以多开 16 个 queue,绑定到 node 1 的 cpu。这里是带宽和延迟的 trade off,在网络调优方面,很多地方都都是在对吞吐(带宽)和延迟做取舍。

包到 CPU 的全路径

网卡收到包之后,会先通过包的 5元组 得到一个 hash 值,然后根据 RETA(Redirection Table)选择一个 queue。

通过 ethtool -x 可以查看 RETA:

可以看出 hash 的方法是 toeplitz,一个有 255 个 bucket。举例来说,如果 hash 的 value 是 9,那么选择的 queue 就是第 2 行的第 2 个,即 10 号 queue。

RETA 也可以通过 ethtool -X eth2 equal 16 (平均分到 16 个 queue)指定。

可以发现,即使 ethtool -l 显示有 32 个 queue,也不是说实际使用的就是 32,如果我们在 RETA 里面平均分配到 16 个 queue 的话,实际只有 16 个 queue 会有流量 (IRQ)。

选中一个 queue 之后,NIC 会 DMA 到这个 queue 对应的内存。接下来我们要绑定这个 queue 对应的 CPU。

通过上面提到过的 /proc/interrupts 文件,可以查看这个 queue 对应的中断号。然后通过 /proc/irq/115/smp_affinity_list 来绑定 cpu 号。要对所有使用的 queue 都进行 cpu 绑定。同时,要注意只绑定物理 core,不要让一个物理 core 去处理多个 irq。

我用 Intel,博通,和 Mellanox 的网卡的 RSS 都没有遇到问题,RPS/XPS 可以关闭了。这个功能可以理解是 Linux 软件实现的 RSS,ingress 和 egress 方向。我们已经设置了 RSS,就不希望内核代码再次调度 cpu,造成 cache miss。

关闭 RPS 很简单,把每个 RX Queue 的 rps_cpus 设置为 0 即可。

Ring Buffer Size

开启了多个 queue,每一个 queue 都有一个 ringbuffer,这个 buffer 的大小可以用 ethtool -g eth0 来查看。也可以用 ethtool 来调整。

这个值其实不会提升性能,因为它不影响处理速度。但是把它增大可以提升稳定性,在流量突增的时候,有更多 buffer 空间,可以减少丢包。

但是 buffer 越大就越好吗?肯定不是。

首先大的 buffer 会带来延迟。比如在 10Gbps 下,buffer 是 8196,如果 buffer 满了,流量稳定 10Gbps,那么每一个包进来之后都要在 buffer 中排队约 8196 才能被处理,latency 高达几 ms。如果 buffer 只有 512,那么徒增流量会直接被丢弃,但是每一个被处理的包都只有不到 1ms 的延迟。

另外,512 的 ringbuffer 很小,所有的 descriptor 都可以放到 CPU 的 L1 cache 里面,大大提高处理速度。

这是稳定性与延迟之间的 trade off。

隔离 CPU

为了让用户程序不占用处理 irq 的 CPU,造成延迟不稳定,我们可以让用户程序只在特定的 CPU 执行,跑 irq 的 CPU 不跑用户程序。

方式可以是通过 taskset 设置每一个程序的 CPU 绑定,也可以在 GRUB 设置启动参数,内核参数添加 isolcpus=0-19,这样普通程序默认就不使用 0-19 CPU 了。

这样做对一些管理程序也有好处。比如 LACP,ARP,SSH 不进入隔离的 CPU,即使网卡流量打满了,SSH 依然可以工作。(当然,更稳的方式是用带外管理)

CPU 频率设定

CPU 有 scaling governor 设置,目的是在 CPU 空闲的时候可以进入低频模式运行来省电,对我们来说性能和稳定性更重要,所以可以改为 performance 模式。

CPU boost 需要管理。临时的性能提升不如稳定的性能,boost 可能会升高温度带来降频。

IRQ 和 NAPI 参数

我们可以设置 softirq 的处理预算,让一次 softirq 处理更多的包。让 CPU 花在更多的时间在处理 softirq 上。

  • net.core.dev_weight=600 设置一次 NAPI 最多处理 600 个包,默认 64;
  • net.core.netdev_budget=3000 设置一次 softirq 触发最多处理多少个包(与上面的区别是,如果 3 个 queue 对应到一个 cpu,那么这两个的设置是,一次 softirq 触发之后,每一个 queue 最多处理 600 个包,总共处理不超过 3000 个包。)
  • net.core.netdev_budget_usecs=10000 让 softirq 一次最多运行 10ms,默认是 2000;
  • echo 2 > /sys/class/net/<iface>/napi_defer_hard_irqs 设置 NAPI 在连续 2 次空转之后再退出。默认情况下 NAPI poll 到没有包处理就退出了。这个设置让它空转 2 次。本质上也是让 cpu 多花时间在处理网络上;
  • echo 200000 > /sys/class/net/<iface>/gro_flush_timeout 这个让 GRO 等待更多的时间再 flush,这样可以把更多的小包合成一个大包,节省后续网络栈处理的包,提高 PPS。(但是对于 XDP转发 程序来说作用不到,XDP 工作在 driver,在内核的 GRO 前面,不会执行到 GRO)。

Interrupt Coalescing

最后来到了操作系统的 IRQ 处理网络栈。

如果每次来一个包,都触发一次 CPU 中断,这样中断的数量就太多了。

我们可以告诉网卡:收到包之后,先不要中断给 CPU,如果一共攒了 64 个包,或者时间等了 32 usecs,再发送一次中断:

这就是中断合并。

这个值的设置也是 trade-off——如果设置得太大,那么延迟会升高,但是可以节省 CPU 资源,吞吐增加,适合存储类服务;如果设置地太小,延迟就很低,但是资源占用就比较高,适合低延迟网络,如量化交易,游戏等等。

——如果我们让网卡在包比较少的情况下使用低的中断合并,在包多的时候使用高的中断合并,不就完美了吗?

这就是自适应的中断合并功能,在上面的网卡设置中,可以看到 Adaptive RX: on TX: on,网卡就会根据实际的网络流量来自动调节中断合并的参数。

NIC Offload

CPU 的资源很宝贵,所以硬件优化的思路就是尽可能的把工作交给网卡来做,网卡的 ASIC 芯片很适合重复劳动。

比如:

  • checksum offload – 网卡算好了,CPU 就不需要检查一遍 checksum 了;
  • TSO/GRO – 网卡把多个包合并成一个大包交给 CPU 处理,同样的 PPS 可以带来更大的带宽;
  • VLAN offloading;
  • VXLAN offloading;

网卡支持的 offloading 越来越多,根据使用的协议可以看网卡是否支持。

总结

参数调优,首先要明确调优的目标:低延迟优先,高吞吐优先,稳定不丢包优先,还是成本最小(比如省电)优先?所有参数调整都是在做 trade off,不然的话这些参数没道理默认不是最优的。

提高性能的思路无非就是:

  • 节省计算资源,把 CPU 的工作交给其他硬件去做;
  • 了解整个路径,缩短这个路径,比如一些直通的技术,NUMA 使用 local node;
  • 了解整个路径,在路径上做加速,比如加快 IRQ 的处理速度,加快内存的分配速度;

可观测性也很重要,无法观测每一个路径的成本,也就无法优化。所以不要盲目优化,先找到瓶颈在哪里,再去解决瓶颈。

  1. 数据中心网络高可用技术之从服务器到交换机:802.3 ad ↩︎
  2. 浪潮这款服务器的白皮书:https://www.inspur.com/eportal/fileDir/en/resource/cms/2022/04/2022040215480066931.pdf ↩︎
  3. https://dshcherb.github.io/2019/02/02/interpreting-pcie-device-to-cpu-locality-information.html ↩︎
  4. 可以参考 数据中心网络高可用技术:ECMP ↩︎