再来俩缩写,我也能承受!

perftune.py 是 Seastar 用来配置系统参数的工具,目标是提高 Seastar 应用的性能。ScyllaDB 的文档有 类似 manpage 的粗略介绍。笔者在理解它的过程中,发现它的实现不仅仅源于 Linux 的文档,也包含了实践的经验。这些知识和经验不仅仅对 Seastar 应用有意义,也可以推广到其他多核程序的优化。

三个模式

perftune.py 的前身是 posix_net_conf.sh。这个脚本被用来设置 IRQ 的亲和性和 RPS。它做这两件事

  1. 把 eth0 所有的 IRQ 都绑定到 CPU0,即让 CPU0 处理来自该网卡的中断请求

  2. 按照 eth0 上的 rps queue 的个数,把它分摊到机器上的所有 CPU 上。因为 CPU 的个数常常是 rps queue 的倍数。如果是 40 核 CPU,对应 8 个 queue 的网卡,那么每 5 个 CPU 核都会分到一个 rps queue。 这时,就需要配置 CPU 掩码,让每个 rps queue 上的包都能均匀分配到这个 queue 所对应的 5 个 CPU 核上。

先进的多队列网卡一般提供单独的 rx 队列配置,或者 rx 和 tx 共用的队列。后者被记为“combined”。为了简单起见,后面统称为 rx 队列。这种技术叫做 Receive Side Scaling,即 RSS。RSS 把接收到的数据包分散在多个 rx 队列里面,每个队列通过硬中断把 “有新数据来啦” 这个消息告诉负责处理这个硬中断的 CPU。请注意,这里负责处理特定中断号的 CPU 可以是多个。对于单队列网卡,只能用 RPS 队列用软件实现多队列。关于 RPS,后面 RPS 和 RFS 一节有简单的介绍。

$ ip link show
...
6: eno2: <NO-CARRIER,BROADCAST,MULTICAST,UP> mtu 1500 qdisc mq state DOWN mode DEFAULT group default qlen 1000
    link/ether 78:ef:44:03:a8:ce brd ff:ff:ff:ff:ff:ff
    altname enp25s0f1
...
$ ethtool -l eno2
Channel parameters for eno2:
Pre-set maximums:
RX:             n/a
TX:             n/a
Other:          1
Combined:       80
Current hardware settings:
RX:             n/a
TX:             n/a
Other:          1
Combined:       80
$ nproc
80

如上所示, 机器上有 80 个 PU。eno2 也正好有 80 个 rx 队列。如果让 perftune.py 自动配置这台机器上的 eno2 的 rx 队列的话,它会按照 PU 的个数设置 rx 队列的个数。即

# ethtool --set-channels eno2 combined 80

为了优化这个数据链路,围绕硬中断的分配和 RPS 的配置, perftune.py 根据 rx 队列的个数和机器上内核的数量预定义了三种模式,希望覆盖大多数场景:

multi-queue

对于支持多硬件队列的网卡,我们则需要把 CPU 核按照队列的数量分组, 通过 RPS 让每一组 CPU 核分担自己的队列来的数据包。perftune.py 把这种模式称为 "mq" 模式, 即 multi-queue 模式。

single-queue

与 mq 模式对应的就是 single-queue 模式。简称 sq 模式。它把给定网卡的所有 IRQ 都安排给 CPU0,但是用 RPS 把软中断以及它引起的 NAPI 轮询分配给其他 CPU。 这里的 CPU0 是 hwloc 说的 PU。如果 CPU 打开了超线程的话,那么就是一颗 HT 的核。

sq-split

还有一种模式叫 sq-split。它把所有 IRQ 分给 CPU0 所在的物理核。如果架构支持 SMT (symmetric multithreading),那么一般来说,一个物理核(core)上会有两个 HT 核(PU)。如果使用 sq-split 模式,那么 P#0P#1 就会被用来处理特定网卡的所有 IRQ, 剩下的核就借助 RPS 来平均分配工作。

sq-split 的设计采纳了 kernel.org 文档 中, Suggested Configuration 一节的建议。这里摘录如下:

Per-cpu load can be observed using the mpstat utility, but note that on processors with hyperthreading (HT), each hyperthread is represented as a separate CPU. For interrupt handling, HT has shown no benefit in initial tests, so limit the number of queues to the number of CPU cores in the system.

— Suggested Configuration
https://www.kernel.org/doc/Documentation/networking/scaling.txt

所以在超线程的系统里面,为两个 HT 核心分别分配不同的 rx 队列,并不能提高 pps (packet per second)。这个结论也很自然,因为 SMT 只不过是让 HT 核能共享 CPU 的流水线,填充流水线气泡,提高流水线的利用率。但是对于硬中断的处理并不适用,因为硬中断处理和 NAPI 轮询是典型的单线程程序。如果两个硬中断或者 rx 队列都安排在同一个 core 上,反而会导致这个 core 手忙脚乱穷于应付,在两个 NAPI 轮询之间来回切换,提高延迟。

那么这些模式分别适用什么配置呢?我们分情况讨论。

在 aws 提供的云服务器中有一些很强大的机器,它们有很多逻辑核,不过这些机器的网卡的 rx 队列也一样多。如果按照 mq 来配置,就会导致每个 CPU 核心都会分心处理 IRQ。和专人负责负责 IRQ 的模式相比,反而性能更差。同时,这些机器的处理能力很强,根据经验数据,专门给网卡分配一个物理核才能充分发挥网卡的性能。同时避免因为均匀地分配 IRQ,导致很多核总是被硬件中断打断,造成大量的上下文切换,影响性能。当然这也是个权衡,因为把一整个物理核用来处理中断可能会有些大材小用了。好像派五虎将之一的赵云去搞街道治安一样。在 PR#949 有一些讨论, Avi 就认为这样的安排很浪费。不过如果真的整体性能下降了,我们还是可以回去使用完全对称的大锅饭 multi-queue 模式。

不过现在的处理方式仍然是启发式,或者说是经验式的:

if num_PUs <= 4:
    return PerfTunerBase.SupportedModes.mq
elif num_cores <= 4:
    return PerfTunerBase.SupportedModes.sq
else:
    return PerfTunerBase.SupportedModes.sq_split

简单说,就是如果你家里有超过 5 个物理核,那么推荐 sq-split,让一个 物理核 专司 IRQ 处理,因为我们能负担得起。倘若 HT 核超过 5 个,那么我们用 single-queue,让一个 HT 核 负责为大家处理 IRQ。要是 HT 核少于 5,CPU 资源有点紧张了,就用 mq,因为我们希望充分利用 每一个 HT 核。这三个模式可以说是从皇帝版到乞丐版。皇帝版从让赵云专门负责核酸。而乞丐版让五虎将每个人除了阵前杀敌,业余时间还需要到营房门口检查场所码。如果说 sq-split 是专业分化的典型,那么 mq 就是是人尽其用的极致了。至于 PR#949 的讨论中,为什么 IORING_SETUP_SQPOLL 能让 mq 的设置在这种配置的机器有更好的表现。笔者没有很好的解释,IORING_SETUP_SQPOLL 让内核线程轮询 ringbuffer 里新的 sqe,避免用户态程序频繁使用 io_uring_enter() 系统调用提交请求。这有点像 NAPI 的处理方式。这个设计把用户态程序从系统调用的义务中解放了出来,但是对于 IO 不是很多的应用,内核线程的轮询也仍然是一个不小的负担。当然,sq_thread_idle 可以让内核在一段时间之内没有 IO 的话,就把轮询线程停下来。不管如何,这都是 TCP/IP 四层模型之上的问题,三个模式希望解决的问题是在其之前发生的。

不过 Seastar 最近有个 新 issue,它认为应该停止使用 --mode,而开始用 --irq-cpu-mask 选项。那么什么是 --irq-cpu-mask 呢?

--irq-cpu-mask

--irq-cpu-maskperftune.py 新设计的选项。它具有更细致的配置能力。前面按照硬件条件简单地把机器分为三档,分别套用一个配置模式。但是对于非常强的多核机器,比如说 48 核的机器,就算使用 sq-split 把一整个核用来处理 IRQ,可能也忙不过来。随着多核机器配置越来越强,前面的三种模式显得不够用了。况且它们没有把 NUMA 纳入考虑。所以除了要有比 sq-split 更浪费霸气的模式,我们还需要更细粒度的配置方式。前面三种模式的核心回答的是 RSS 的配置问题,即 rx 队列的分配问题。但是在多核系统中,整个数据链路上,每个环节都可以优化。我们这里仅仅关注 IRQ、RSS 和 RPS 的配置。 把它们具体化,就是

  1. IRQ 可以分配给哪些 PU

  2. 有哪些 IRQ 需要分配

  3. 这些IRQ 和用来处理 IRQ 的 PU 的对应关系如何

IRQ CPU mask

--irq-cpu-mask 就是第一个问题的答案,它允许用户自己设定 IRQ 会分配给 哪些 PU。但是也和之前一样,提供了自动配置的功能。但是为了避免之前“两刀切”的粗线条解决方式,这次 perftune.py 按照比例分配 IRQ。下面的算法用来分配处理 IRQ 的 CPU:

if num_PUs <= 4:
    return cpu_mask
elif num_cores <= 4:
    return run_hwloc_calc(['--restrict', cpu_mask, 'PU:0'])
elif num_cores <= cores_per_irq_core:
    return run_hwloc_calc(['--restrict', cpu_mask, 'core:0'])
else:
    # 竟然核数超过了每个 IRQ 指定的核心数,肯定是个很强力的机器,
    # 这样我们就可以按照比例分配 IRQ 核心了
    # num_irq_cores 是按照比例平摊之后,负责 IRQ 的总核心数
    num_irq_cores = math.ceil(num_cores / cores_per_irq_core)
    hwloc_args = []
    numa_cores_count = {n: 0 for n in numa_ids_list}
    added_cores = 0
    # 在每个 NUMA 节点上均匀地征集 core,直到凑够数为止
    while added_cores < num_irq_cores:
        for numa in numa_ids_list:
            hwloc_args.append(f"node:{numa}.core:{numa_cores_count[numa]}")
            added_cores += 1
            numa_cores_count[numa] += 1

            if added_cores >= num_irq_cores:
                break

    return run_hwloc_calc(['--restrict', cpu_mask] + hwloc_args)

其中

cpu_mask

是由用户指定可用于负责 IRQ 调优的 cpu 集合。

cores_per_irq_core

每个 IRQ 安排对应的核数,如果这个数字是 6 的话,那么每六个核心, 就会分出一个核心用来负责 IRQ。这个数字有点类似抽壮丁的比例。

这个算法和之前的“三个模式”算法类似,只不过为“强力机器”专门增加了按照 NUMA 平均成比例分配 IRQ core 的模式。原版的算法还要求制定的 cpu_mask 在 NUMA 各节点是 core 数和 PU 数是相等的。为了突出重点,在上面的代码中没有摘录。

“抽了壮丁”之后,怎么分配这些“壮丁”呢?下面说一下第二个问题。

有哪些 IRQ 需要分配

我们先了解一下中断和 rx 队列之间的对应关系:

$ cat /proc/interrupts
            CPU0       CPU1       CPU2       CPU3       CPU4       CPU5       CPU6       CPU7
...
  91:          5          0          0          0          0          0          0          0  IR-PCI-MSI 12584961-edge      eno2-TxRx-0
...

上面的输出中,

最左边一列

是 IRQ 的序号

每个 CPU 各有一列

能够告诉我们这个 IRQ 在对应的 CPU 上触发了多少次

倒数第三列

表示 IRQ 的类型。这里是一种叫 Message Signaled Interrupt 的中断。

倒数第二列

和中断控制器有关或者和中断触发的方式有关

倒数第一列

表示 IRQ 对应的设备。这里是 eno2 这块网卡的第 0 个 TxRx 队列。

要是只想知道网卡用了哪些中断,也可以用:

$ ls /sys/class/net/eno2/device/msi_irqs/
90  91  92  93  94  95  96  97  98

这个信息和之前观察 /proc/interrupts 获得的信息一般来说是一致的。不过实际使用中,也有网卡真正使用的 MSI-IRQ 只是驱动申请使用的一部分。有点像驱动申请了 10 门牌号,但是最后只有 9 个屋子用了这些门牌号。所以 perftune.py 取了两者的交集,作为需要分配的 IRQ。

排排坐分果果

完整的说法是,PU 排排坐,分 IRQ 果果。把 IRQ 分给多个 PU 处理,目标还是提高 PPS,也就让并发更高,延迟更小。这些是目标,除了 PU 和 IRQ 本身,还有哪些约束条件和考量呢?关于 IRQ 和 PU 亲和性比较权威的参考资料仍然来自 kernel.org。前面摘录的建议仍然适用,所以我们不会用让多个 PU 分担同一个中断,而选择用一对一的映射。如果刚才得到的 CPU mask 是 0xffffffff,那么我们可以用下面的命令分配 IRQ:

$ hwloc-distrib 9 --single --restrict 0xffffffff
0x00000001
0x00000004
0x00000040
0x00000100
0x00001000
0x00000002
0x00000020
0x00000200
0x00002000

其中,每一行的掩码制定一组 CPU。每一组 CPU 负责对应的要分配的元素。比如说,第一行中 0x00000001 就用来处理第一个 IRQ,即前面列出的 IRQ 90。

9

指定需要分配的元素个数

--single

每个元素对应一个 CPU。否则如果 CPU 供应充足的话,若是不指定 --singlehwloc-distrib 返回的掩码会含有多个 CPU。

--restrict

指定分配的 CPU set。

所以在给出的 32 个 PU 中,再选出了 9 个幸运儿。现在再分别给每个 IRQ 指定这些选出来的 PU:

$ echo 00000001 > /proc/irq/90/smp_affinity

前面把网卡的所有 IRQ 都不加区别地分给了所有凑出来的壮丁 PU,如果网卡有多个 rx 队列,那么 perftune.py 还有更细致的考虑。它会分两次。第一次把负责 rx 队列的 IRQ 均匀分布在壮丁 PU 中,第二次再把剩下的 IRQ 分布在同一个 PU 集合中。和大锅饭的统一分配相比,这样确保 rx 队列对应的 IRQ 能有更均匀的分布。

RPS 和 RFS

RPS 是 Receive Packet Steering 的缩写。RPS 和 RSS 类似,目标都是希望让 CPU 核分担处理接受到数据包的工作,以提高性能。但是 RPS 工作在纯软件的层面。所以它更灵活,可以由软件设置它分配数据包的算法。但是它也带来了 CPU 核心之间的中断,即 IPI (inter-processor interrupts)。缺省 RPS 是关掉的,即谁通过网卡 IRQ 收到的包,谁负责处理。但是要知道,接收数据包本身就意味着处理硬中断,处理软中断,执行 NAPI 轮询收包,以及把数据包在协议栈逐级向上传递。这还不包括用户态程序从 socket 读出数据后的处理。由于硬件队列的数量往往小于 CPU 的核心数,这样就会出现一核干活,七核加油的景象。为了让另外七个核心也能帮忙处理硬件队列发来的数据包,我们需要告诉操作系统,让它把第一个核从硬件队列收下来的包分配给那七个核心。另外,在 LWN 也有一篇 介绍,可供参考。如果需要更深入的阅读,一定要看一下 kernel.org 上的文档

因为 RPS 工作在软件的层面,我们为 RPS 分配 PU 的时候顾虑就少一些。在 perftune.py 里面,它把所有的 PU 分给每一个 RPS 队列:

$ echo 0xffffffff > /sys/class/net/eno2/queues/rx-0/rps_cpus

kernel.org 有诗云:

For a multi-queue system, if RSS is configured so that a hardware receive queue is mapped to each CPU, then RPS is probably redundant and unnecessary. If there are fewer hardware queues than CPUs, then RPS might be beneficial if the rps_cpus for each queue are the ones that share the same memory domain as the interrupting CPU for that queue.

— Suggested Configuration
https://www.kernel.org/doc/Documentation/networking/scaling.txt

既然 RSS 都把所有的 PU 都占满了,也没有必要再上 RPS 了。但是,这个还不是问题的最终答案。因为我们还有 Receive Flow Steering,即 RFS。RPS 是按照包的地址和端口算出来的 hash 决定这个包会发往哪个队列,最后由负责这个队列的 CPU 处理。这些都是 Linux 内核的事情。但是绝大多数时候,最后处理数据包的还是用户态程序,那么怎么确保这个数据包的收件地址就是着这个数据包的用户态程序,即将被调度到的 CPU 核呢?换句话说,我们需要解决一个 hash 到 CPU 的问题。那么 CPU 怎么选呢?内核认为上次处理这个流中,上一个数据包的 CPU 是更可能被调度到处理下一个数据包的。就像一个浪漫的爱情故事里面,男主和女主在地铁上邂逅,那么男主要想再见到她,十有八九会再去同一趟地铁碰碰运气。虽然女主可能下次坐的地铁可能和上次不一样。但是这种惯性还是很可靠的。所以内核为这种重逢专门记录了一个数组,数组中的元素类似

struct rps_sock_flow_entry {
  unsigned cpu : 6;
  unsigned flow_hash_hi : 26;
};
struct rps_sock_flow_table {
  int32_t mask;
  rps_sock_flow_entry[];
}

当然,cpuhi_flow_hash 合起来是个 32 位,它们分别占用多少 bit 是根据系统里面内核数来决定的。上面的代码最多就能支持 64 个核。内核里每当发现 女主的身影 有读写网络的操作发生,都会更新 rps_sock_flow_table,记录下最新的 hash → cpu 的映射关系,以备查找。所以在 Linux 内核里:

/* First check into global flow table if there is a match */
ident = sock_flow_table->ents[hash & sock_flow_table->mask];
if ((ident ^ hash) & ~rps_cpu_mask)
  goto try_rps;
next_cpu = ident & rps_cpu_mask;

所以,上面的代码中,sock_flow_table 的下标是 hash 的低位。如果查出来的 flow table entry 和 hash 不吻合,那么就转而使用 RPS 来决定送到哪个 CPU。否则就取出表项中 CPU 的部分,作为包的目的地。

perftune.py 的设置基本按照 kernel.org 的建议设置

在网络的数据链路上,除了 IRQ 亲和性,RSS、RPS 和 RFS 的设置,还有 aRFS 和 XPS 的设置。这里限于篇幅,就不再赘述了。建议大家仔细研读 kernel.org 上的文档,以及相关的内核代码。

lstopo

另外,为了更好的理解系统架构和多核,hwloc 提供的工具是个好帮手,它能帮助我们理解系统的拓扑情况。比如在笔者的 Apple M1 Pro 上,就有一个 package,四个 core,八个超线程 PU。

$ lstopo
Machine (3484MB total)
  Package L#0
    NUMANode L#0 (P#0 3484MB)
    L2 L#0 (4096KB)
      L1d L#0 (64KB) + L1i L#0 (128KB) + Core L#0 + PU L#0 (P#0)
      L1d L#1 (64KB) + L1i L#1 (128KB) + Core L#1 + PU L#1 (P#1)
    L2 L#1 (4096KB)
      L1d L#2 (64KB) + L1i L#2 (128KB) + Core L#2 + PU L#2 (P#2)
      L1d L#3 (64KB) + L1i L#3 (128KB) + Core L#3 + PU L#3 (P#3)
    L2 L#2 (4096KB)
      L1d L#4 (64KB) + L1i L#4 (128KB) + Core L#4 + PU L#4 (P#4)
      L1d L#5 (64KB) + L1i L#5 (128KB) + Core L#5 + PU L#5 (P#5)
    L2 L#3 (4096KB)
      L1d L#6 (64KB) + L1i L#6 (128KB) + Core L#6 + PU L#6 (P#6)
      L1d L#7 (64KB) + L1i L#7 (128KB) + Core L#7 + PU L#7 (P#7)
  CoProc(OpenCL) "opencl0d0"

同时,也建议使用终端的读者尝试一下下面的命令,获得更炫酷的视觉体验:

$ lstopo -.ascii
在 RockyLinux 9 上,lstopo 的名字叫做 lstopo-no-graphics, 因为后者不能输出图形的格式,对于软件包的维护者来说,编译时和运行时的依赖更容易解决。 如果嫌麻烦的话,也可以直接用 hwloc-ls。它是 lstopo-no-graphics 的软链接。