普通视图

发现新文章,点击刷新页面。
昨天以前首页

Linux 时钟源之 TSC:软硬件原理、使用场景、已知问题(2024)

2024年7月28日 08:00

本文整理了一些 Linux 时钟源 tsc 相关的软硬件知识,在一些故障排查场景可能会用到。

Fig. Scaling up crystal frequency for different components of a computer. Image source Youtube

水平及维护精力所限,文中不免存在错误或过时之处,请酌情参考。 传播知识,尊重劳动,年满十八周岁,转载请注明出处



1 计算机组件的运行频率

1.1 时钟源:~20MHz 的石英晶体谐振器(quartz crystal resonator)

石英晶体谐振器是利用石英晶体(又称水晶)压电效应 来产生高精度振荡频率的一种电子器件。

  • 1880 年由雅克·居里与皮埃尔·居里发现压电效应。
  • 一战期间 保罗·朗之万首先探讨了石英谐振器在声纳上的应用。
  • 1917 第一个由晶体控制的电子式振荡器。
  • 1918 年贝尔实验室的 Alexander M. Nicholson 取得专利,虽然与同时申请专利的 Walter Guyton Cady 曾有争议。
  • 1921 年 Cady 制作了第一个石英晶体振荡器。

Wikipedia 石英晶体谐振器

现在一般长这样,焊在计算机主板上,

Fig. A miniature 16 MHz quartz crystal enclosed in a hermetically sealed HC-49/S package, used as the resonator in a crystal oscillator. Image source wikipedia

受物理特性的限制,只有几十 MHz

1.2 Clock generator:针对不同部分(内存、PCIe、CPU 等)倍频

计算机的内存、PCIe 设备、CPU 等等组件需要的工作频率不一样(主要原因之一是其他组件跟不上 CPU 的频率), 而且都远大于几十 MHz,因此需要对频率做提升。工作原理:

  1. What is a CPU clock physically?
  2. Wikipedia: Phase-locked_loop (PLL)

有个视频解释地很形象,

Fig. Scaling up crystal frequency for different components of a computer. Image source Youtube

图中的 clock generator 是个专用芯片,也是焊在主板上,一般跟晶振挨着。

1.3 CPU 频率是如何从 ~20MHz 提升到 ~3GHz

本节稍微再开展一下,看看 CPU 频率是如何提升到我们常见的 ~3GHz 这么高的。

1.3.1 传递路径:最终连接到 CPU CLK 引脚

结合上面的图,时钟信号的传递/提升路径

  1. 晶振(~20MHz
  2. 主板上的 clock generator 芯片
  3. 北桥芯片
  4. CPU

时钟信号连接到 CPU 的一个名为 CLK 的引脚。 两个具体的 CLK 引脚实物图:

  • Intel 486 处理器(1989

    Fig. Intel 486 pin mapImage Source

    这种 CPU 引脚今天看来还是很简单的,CLK 在第三行倒数第三列。

  • AMD SP3 CPU Socket (2017)

    EPYC 7001/7002/7003 系列用的这种。图太大了就不放了,见 SP3 Pin Map

1.3.2 CPU 内部:还有一个 clock generator

现代 CPU 内部一般还有一个 clock generator,可以继续提升频率, 最终达到厂商宣传里的基频(base frequency)或标称频率(nominal frequency),例如 EPYC 6543 的 2795MHz。 这跟原始晶振频率比,已经提升了上百倍。

2 x86 架构的寄存器

介绍点必要的背景知识,有基础的可跳过。

2.1 通用目的寄存器

Fig. 32-bit x86 general purpose registers [1]

计算机执行的所有代码,几乎都是经由通用寄存器完成的。 进一步了解:简明 x86 汇编指南(2017)

2.2 特殊目的寄存器

如名字所示,用于特殊目的,一般也需要配套的特殊指令读写。大致分为几类:

  • control registers
  • debug registers
  • mode-specific registers (MSR)

接下来我们主要看下 MSR 类型。

2.2.1 model-specific register (MSR)

MSR 是 x86 架构中的一组控制寄存器(control registers), 设计用于 debugging/tracing/monitoring 等等目的,以下是 AMD 的一些系统寄存器, 其中就包括了 MSR 寄存器们,来自 AMD64 Architecture Programmer’s Manual, Volume 3 (PDF)

Fig. AMD system registers, which include some MSR registers

几个相关的指令:

  • RDMSR/WRMSR 指令:读写 MSR registers;
  • CPUID 指令:检查 CPU 是否支持某些特性。

RDMSR/WRMSR 指令使用方式:

  • 需要 priviledged 权限。
  • Linux msr 内核模块创建了一个伪文件 /dev/cpu/{id}/msr,用户可以读写这个文件。还有一个 msr-tools 工具包。

2.2.2 MSR 之一:TSC

今天我们要讨论的是 MSR 中与时间有关的一个寄存器,叫 TSC (Time Stamp Counter)。

3 TSC(时间戳计数器)

3.1 本质:X86 处理器中的一个 特殊寄存器

Time Stamp Counter (TSC) 是 X86 处理器 (Intel/AMD/…)中的一个 64-bit 特殊目的 寄存器,属于 MRS 的一种。 还是 AMD 编程手册中的图,可以看到 MSR 和 TSC 的关系:

Fig. AMD system registers, which include some MSR registers

注意:在多核情况下(如今几乎都是多核了),每个物理核(processor)都有一个 TSC register, 或者说这是一个 per-processor register

3.2 作用:记录 cpu 启动以来累计的 cycles 数量

前面已经介绍过,时钟信号经过层层提升之后,最终达到 CPU 期望的高运行频率,然后就会在这个频率上工作。

这里有个 CPU cycles(指令周期)的概念: 频率没经过一个周期(1Hz),CPU cycles 就增加 1 —— TSC 记录的就是从 CPU 启动(或重置)以来的累计 cycles。 这也呼应了它的名字:时间戳计数器

3.3 实际:经常被当做(高精度)时钟用

根据以上原理,如果 CPU 频率恒定且不存在 CPU 重置的话,

  • TSC 记录的就是系统启动以来的 cycles 数量
  • cycles 可以精确换算成时间
  • 这个时间的精度还非常高!
  • 使用开销还很低(这涉及到操作系统和内核实现了)。

所以无怪乎 TSC 被大量用户空间程序当做开销地高精度的时钟

3.3.1 使用代码

本质上用户空间程序只需要一条指令(RDTSC),就能读取这个值。非常简单的几行代码:

unsigned long long rdtsc() {
    unsigned int lo, hi;
    __asm__ volatile ("rdtsc" : "=a" (lo), "=d" (hi));
    return ((unsigned long long)hi << 32) | lo;
}

就能拿到当前时刻的 cpu cycles。所以统计耗时就很直接:

    start = rdtsc();

    // business logic here

    end = rdtsc();
    elapsed_seconds = (end-start) / cycles_per_sec;

3.3.1 潜在问题

以上的假设是 TSC 恒定,随着 wall time 均匀增加。

如果 CPU 频率恒定的话(也就是没有超频、节能之类的特殊配置),cycles 就是以恒定速率增加的, 这时 TSC 确实能跟时钟保持同步,所以可以作为一种获取时间或计时的方式。 但接下来会看到,cycles 恒定这个前提条件如今已经很难满足了,内核也不推荐用 tsc 作为时间度量。

乱序执行会导致 RDTSC 的执行顺序与期望的顺序发生偏差,导致计时不准,两种解决方式:

  • 插入一个同步指令(a serializing instruction),例如 CPUID,强制前面的指令必现执行完,才能才执行 RDTSC;
  • 使用一个变种指令 RDTSCP,但这个指令只是对指令流做了部分顺序化(partial serialization of the instruction stream),并不完全可靠。

3.4 挑战:TSC 的准确性越来越难以保证

如果一台机器只有一个处理器,并且工作频率也一直是稳定的,那拿 TSC 作为计时方式倒也没什么问题。 但随着下面这些技术的引入,TSC 作为时钟就不准了:

  • 多核处理器:意味着每个核上都有一个 TSC,如何保持这些 TSC 寄存器值的严格同步;
  • 不同处理器的温度差异也会导致 TSC 偏差
  • 超线程:一个处理器上两个硬件线程(Linux 中看就是两个 CPU);
  • 超频、降频等等功耗管理功能:导致时钟不再是稳定的;
  • CPU 指令乱序执行功能:获取 TSC 的指令的执行顺序和预期的可能不一致,导致计时不准;
  • 休眠状态:恢复到运行状态时重置 TSC;

还有其他一些方面的挑战,都会导致无法保证一台机器多个 CPU 的 TSC 严格同步

3.5 改进:引入 constant/invariant TSC

解决方式之一,是一种称为恒定速率(constant rate) TSC 的技术,

  • 在 Linux 中,可以通过 cat /proc/cpuinfo | grep constant_tsc 来判断;
  • 有这个 flag 的 CPU,TSC 以 CPU 的标称频率(nominal frequency)累积;超频或功耗控制等等导致的实际 CPU 时钟频率变化,不会影响到 TSC。

较新的 Intel、AMD 处理器都支持这个特性。

但是,constant_tsc 只是表明 CPU 有提供恒定 TSC 的能力, 并不表示实际工作 TSC 就是恒定的。后面会详细介绍。

3.5 小结:计数器(counter),而非时钟(clock)

从上面的内容已经可以看出, TSC 如其名字“时间戳计数器”所说,确实本质上只是一个计数器, 记录的是 CPU 启动以来的 cpu cycles 次数

虽然在很多情况下把它当时钟用,结果也是正确的,但这个是没有保证的,因为影响它稳定性的因素太多了 —— 不稳拿它计时也就不准了。

另外,它是一个 x86 架构的特殊寄存器,换了其他 cpu 架构可能就不支持,所以依赖 TSC 的代码可移植性会变差。

4 查看和监控 TSC 相关信息

以上几节介绍的基本都是硬件问题,很好理解。接下来设计到软件部分就复杂了,一部分原因是命名导致的。

4.1 Linux 系统时钟源(clocksource)配置

我们前面提到不要把 tsc 作为时钟来看待,它只是一个计数器。但另一方面,内核确实需要一个时钟,

  • 内核自己的定时器、调度、网络收发包等等需要时钟;
  • 用户程序也需要时间功能,例如 gettimeofday() / clock_gettime()

在底层,内核肯定是要基于启动以来的计数器,这时 tsc 就成为它的备选之一(而且优先级很高)。

$ cat /sys/devices/system/clocksource/clocksource0/available_clocksource
tsc hpet acpi_pm

$ cat /sys/devices/system/clocksource/clocksource0/current_clocksource
tsc

4.1.1 tsc:优先

  • 高精度:基于 cycles,所以精度是几个 GHz,对应 ns 级别;
  • 低开销:跟内核实现有关。

4.1.2 hpet:性能开销太大

原理暂不展开,只说结论:相比 tsc,hpet 在很多场景会明显导致系统负载升高。所以能用 tsc 就不要用 hpet。

4.2 turbostat 查看实际 TSC 计数(可能不准)

前面提到用户空间程序写几行代码就能方便地获取 TSC 计数。所以对监控采集来说,还是很方便的。 我们甚至不需要自己写代码获取 TSC,一些内核的内置工具已经实现了这个功能,简单地执行一条 shell 命令就行了。

turbostat 是 Linux 内核自带的一个工具,可以查看包括 TSC 在内的很多信息。

turbostat 源码在内核源码树中:tools/power/x86/turbostat/turbostat.c

不加任何参数时,turbostat 会 5s 打印一次统计信息,内容非常丰富。 我们这里用精简模式,只打印每个 CPU 在过去 1s 的 TSC 频率和所有 CPU 的平均 TSC:

# sample 1s and only one time, print only per-CPU & average TSCs
$ turbostat --quiet --show CPU,TSC_MHz --interval 1 --num_iterations 1
CPU     TSC_MHz
-       2441
0       2445
64      2445
1       2445

turbostat 如果执行的时间非常短,比如 1s,统计到数据就不太准,偏差比较大; 持续运行一段时间后,得到的数据才比较准。

4.3 rdtsc/rdtscp 指令采集 TSC 计数

4.3.1 C 代码

完整代码:

#include <stdio.h>
#include <time.h>
#include <unistd.h>

// https://stackoverflow.com/questions/16862620/numa-get-current-node-core
unsigned long rdtscp(int *chip, int *core) {
    unsigned a, d, c;
    __asm__ volatile("rdtscp" : "=a" (a), "=d" (d), "=c" (c));

    *chip = (c & 0xFFF000)>>12;
    *core = c & 0xFFF;
    return ((unsigned long)a) | (((unsigned long)d) << 32);;
}

int main() {
    int sleep_us = 100000;
    unsigned long tsc_nominal_hz = 2795000000;
    unsigned long expected_inc = (unsigned long)(1.0 * sleep_us / 1000000 * tsc_nominal_hz);
    unsigned long low = (unsigned long)(expected_inc * 0.95);
    unsigned long high = (unsigned long)(expected_inc * 1.05);
    printf("Sleep interval: %d us, expected tsc increase range [%lu,%lu]\n", sleep_us, low, high);

    unsigned long start, delta;
    int start_chip=0, start_core=0, end_chip=0, end_core=0;

    while (1) {
        start = rdtscp(&start_chip, &start_core);
        usleep(sleep_us);
        delta = rdtscp(&end_chip, &end_core) - start;

        if (delta > high || delta < low) {
            time_t seconds = time(NULL); // seconds since Unix epoch (1970.1.1)
            struct tm t = *localtime(&seconds);
            printf("%02d-%02d %02d:%02d:%02d TSC jitter: %lu\n",
                    t.tm_mon + 1, t.tm_mday, t.tm_hour, t.tm_min, t.tm_sec, delta);
            fflush(stdout);
        }
    }

    return 0;
}

几点说明:

  1. 程序 hardcode 了预期的 TSC 频率是 2795MHz
  2. 每 100ms 采集一次 TSC 计数,如果 TSC 计数的偏差超过 +/- 5%,就将这个异常值打印出来;
  3. 在哪个 chip/cpu 上执行的,这里没打印出来,有需要可以打印;
  4. 这个程序虽然采集很频繁,但开销很小,主要是因为 rdtscp 指令的开销很小。

4.3.2 执行效果

编译运行,

$ gcc tsc-checker.c -o tsc-checker

# print to stdout and copy to a log file, using stream buffer instead of line buffers
$ stdbuf --output=L ./tsc-checker | tee tsc.log
Sleep interval: 100000 us, expected tsc increase range [265525000,293475000]
08-05 19:46:31 303640792
08-05 20:13:06 301869652
08-05 20:38:27 300751948
08-05 22:40:39 324424884
...

可以看到这台机器(真实服务器)有偶发 TSC 抖动, 能偏离正常范围 324424884/2795000000 - 1 = 16%, 也就是说 100ms 的时间它能偏离 16ms,非常离谱。TSC 短时间连续抖动时, 机器就会出现各种奇怪现象,比如 load 升高、网络超时、活跃线程数增加等等,因为内核系统因为时钟抖动乱了。

4.4 监控

用合适的采集工具把以上数据送到监控平台(例如 Prometheus/VictoriaMetrics),就能很直观地看到 TSC 的状态。

4.4.1 基于 turbostat(不推荐)

例如下面是 1 分钟采集一次,每次采集过去 1s 内的平均 TSC,得到的结果:

Fig. TSC runnning average of an AMD EPYC 7543 node

但前面提到, turbostat 如果执行的时间非常短,统计到数据就不太准,偏差比较大; 持续运行一段时间后,得到的数据才比较准。但作为采集程序,可能不方便执行太长时间。

4.4.2 基于 rdtscp

基于上面的 rdtscp 自己写代码采集,就非常准确了,例如,下面是 1 分钟采集一次得到的结果展示:

Fig. TSC jitter of an AMD EPYC 7543 node

不过,要抓一些偶发抖动导致的问题,1 分钟采集一次粒度太粗了。比如我们上一小节的 C 程序是 100ms 采集一次, 相当于 1 分钟采集 600 次,一小时采集 3.6w 次。我们 3 个小时总共 10 万多次跑下来,也才能抓到几次抖动,这已经算很幸运了。

4.4.3 基于 rdtscp + 内核模块

还是 rdtscp,但作为内核模块 + 定时器运行,应该会比用户空间程序更准,可以避免 Linux 内核调度器的调度偏差。

5 TSC 若干坑

5.1 constant_tsc: a feature, not a runtime guarantee

5.1.1 Lenovo SR645 (AMD EPYC 7543 CPU) TSC 不稳定

CPU 信息:

$ cat /proc/cpuinfo
...
processor       : 127
vendor_id       : AuthenticAMD
model name      : AMD EPYC 7543 32-Core Processor
cpu MHz         : 3717.449
flags           : fpu ... tsc msr rdtscp constant_tsc nonstop_tsc cpuid tsc_scale ...

flags 里面显式支持 constant_tscnonstop_tsc,所以按照文档的描述 TSC 应该是恒定的。

但是,看一下下面的监控,都是这款 CPU,机器来自两个不同的服务器厂商,

Fig. TSC fluctuations (delta of running average) of AMD EPYC 7543 nodes, from two server vendors

可以看到,

  • 联想和浪潮的 TSC 都有波动,
  • 联想的偶尔波动非常剧烈(相对 base 2795MHz 偏离 16% 甚至更高);
  • 浪潮的相对较小(base 2445 MHz)。

这个波动可能有几方面原因,比如各厂商的 BIOS 逻辑,或者 SMI 中断风暴。

5.1.2 原因及解决方式

最后定位到是厂商 BIOS (UEFI) 设置导致的,做如下修改之后稳定多了,

No. Option Before After
1 OperatingModes.ChooseOperatingMode Maximum Efficiency Custom Mode
2 Processors.DeterminismSlider Performance Power
3 Processors.CorePerformanceBoost Enable Enable
4 Processors.cTDP Auto Maximum
5 Processors.PackagePowerLimit Auto Maximum
6 Processors.GlobalC-stateControl Enable Enable
7 Processors.SOCP-states Auto P0
8 Processors.DFC-States Enable Disable
9 Processors.P-state1 Enable Disable
10 Processors.SMTMode Enable Enable
11 Processors.CPPC Enable Enable
12 Processors.BoostFmax Auto Manual
13 Processors.BoostFmaxManual   0
14 Power EfficiencyMode Enable Disable
15 Memory.NUMANodesperSocket NPS1 NPS0

Note:

5.2 BIOS 设置致使 TSC 不恒定

除了以上具体配置,还有一些可能会导致 TSC 不稳的场景。

5.2.1 TSC 寄存器是可写的!

TSC 可写,所以某些 BIOS 固件代码会修改 TSC 值,导致操作系统时序不同步(或者说不符合预期)。

5.2.2 BIOS SMI handler 通过修改 TSC 隐藏它们的执行

例如,2010 年内核社区的一个讨论 x86: Export tsc related information in sysfs 就提到,某些 BIOS SMI handler 会通过修改 TSC value 的方式来隐藏它们的执行

为什么要隐藏?

5.2.3 服务器厂商出于功耗控制等原因在 BIOS 修改 TSC 同步逻辑

前面提到,恒定 TSC 特性只是说处理器提供了恒定的能力,但用不用这个能力,服务器厂商有非常大的决定权。

某些厂商的固件代码会在 TSC sync 逻辑中中修改 TSC 的值。 这种修改在固件这边没什么问题,但会破坏内核层面的时序视角,例如内核调度器工作会出问题。 因此,内核最后引入了一个 patch 来处理 ACPI suspend/resume,以保证 TSC sync 机制在操作系统层面还是正常的,

x86, tsc, sched: Recompute cyc2ns_offset's during resume from sleep states

TSC's get reset after suspend/resume (even on cpu's with invariant TSC
which runs at a constant rate across ACPI P-, C- and T-states). And in
some systems BIOS seem to reinit TSC to arbitrary large value (still
sync'd across cpu's) during resume.

This leads to a scenario of scheduler rq->clock (sched_clock_cpu()) less
than rq->age_stamp (introduced in 2.6.32). This leads to a big value
returned by scale_rt_power() and the resulting big group power set by the
update_group_power() is causing improper load balancing between busy and
idle cpu's after suspend/resume.

This resulted in multi-threaded workloads (like kernel-compilation) go
slower after suspend/resume cycle on core i5 laptops.

Fix this by recomputing cyc2ns_offset's during resume, so that
sched_clock() continues from the point where it was left off during
suspend.

5.3 SMI 中断风暴导致 TSC 不稳

上一节提到,BIOS SMI handler 通过修改 TSC 隐藏它们的执行。如果有大量这种中断(可能是有 bug), 就会导致大量时间花在中断处理时,但又不会计入 TSC,最终导致系统出现卡顿等问题。

AMD 的机器比较尴尬,看不到 SMI 统计(试了几台 Intel 机器是能看到的),

$ turbostat --quiet --show CPU,TSC_MHz,SMI --interval 1 --num_iterations 1
CPU     TSC_MHz
-       2441
0       2445
64      2445
1       2445
...

5.4 VM TSC 不稳

例如

  1. https://www.phoronix.com/news/AMD-Secure-TSC-Linux-Patches
  2. http://oliveryang.net/2015/09/pitfalls-of-TSC-usage/

6 总结

本文整理了一些 TSC 相关的软硬件知识,在一些故障排查场景可能会用到。

参考资料

  1. 简明 x86 汇编指南(2017)
  2. AMD64 Architecture Programmer’s Manual, Volume 3 (PDF)
  3. Linux 服务器功耗与性能管理(一):CPU 硬件基础(2024)
  4. Pitfalls of TSC usage, 2015
  5. Wikipedia MSR
  6. Wikipedia TSC
  7. Wikipedia Clock Generator

Written by Human, Not by AI Written by Human, Not by AI

TCP Requests Stuck After Connection Established(2024)

2024年6月26日 08:00

This post describes a kernel & BPF networking problem and the trouble shooting steps, which is an interesting case for delving into Linux kernel networking intricacies.

Fig. Phenomenon of a reported issue.



1 Trouble report

1.1 Phenomenon: probabilistic health check failures

Users reported intermittent failures of their pods, despite them run as usual with no exceptions.

The health check is a very simple HTTP probe over TCP: kubelet periodically (e.g. every 5s) sends GET requests to local pods, initiating a new TCP connection with each request.

Fig. Intermittent health check failures of pods.

Users suspect this is a network problem.

1.2 Scope: specific pods on specific nodes

This reported issue is confined to a new k8s cluster, with recently introduced OS and kernel:

  • OS: AliOS (AlibabaCloud OS)
  • Kernel: cloud-kernel 5.10.134-16.al8.x86_64 (a fork of Linux, gitee.com/anolis/cloud-kernel), which includes their upstream feature backports and self-maintanined changes, for example,

    1. Intel AMX (Advanced Matrix Extensions) for AI workloads, offering a hardware acceleration alternative to GPUs in certain scenarios, such as inference for LLMs smaller than 13B. AMX support was first introduced in kernel 5.16, cloud-kernel backported the feature to its current version 5.10;
    2. cloud-kernel includes un-upstreamed modifications like new kernel structure fields and new enums/types.

Other environment info:

2 Networking fundamentals

Before starting our exploration, let’s outline our networking infra in this cluster.

2.1 Node network topology: Cilium (with BPF)

Internal networking topology of our k8s node is depicted as below:

Fig. Internal networking topology of a k8s node.

(k8s node) $ route -n
Destination  Gateway   Genmask           Use Iface
0.0.0.0      <GW-IP>   0.0.0.0           eth0
<Node-IP>    0.0.0.0   <Node-IP-Mask>    eth0
<Pod1-IP>    0.0.0.0   255.255.255.255   lxc-1
<Pod2-IP>    0.0.0.0   255.255.255.255   lxc-2
<Pod3-IP>    0.0.0.0   255.255.255.255   lxc-3

As shown in the picture and kernel routing table output, each pod has a dedicated routing entry. Consequently, all health check traffic is directed straight to the lxc device (the host-side device of the pod’s veth pair), subsequently entering the Pod. In another word, all the health check traffic is processed locally.

Cilium has a similar networking topology on AlibabaCloud as on AWS. For more information, refer to Cilium Network Topology and Traffic Path on AWS (2019), which may contain some stale information, but most of the content should still validate.

2.2 Kernel 5.10+: sockmap BPF acceleration for node2localPod traffic

2.2.1 sockops BPF: bypass kernel stack for local traffic

How to use eBPF for accelerating Cloud Native applications offers a practical example of how sockops/sockmap BPF programs work.

Chinese readers can also refer to the following for more information,

  1. (译)利用 ebpf sockmap/redirection 提升 socket 性能(2020)
  2. BPF 进阶笔记(五):几种 TCP 相关的 BPF(sockops、struct_ops、header options)

2.2.2 tcpdump: only TCP 3-way/4-way handshake packets can be captured

sockops acceleration is automatically enabled in kernel 5.10 + Cilium v1.11.10:

Fig. Socket-level acceleration in Cilium. Note that the illustration depicts local processes communicating via loopback, which differs from the scenario discussed here, just too lazy draw a new picture.

One big conceptual change is that when sockops BPF is enabled, you could not see request & response packets in tcpdump output, as in this setup, only TCP 3-way handshake and 4-way close procedure still go through kernel networking stack, all the payload will directly go through the socket-level (e.g. in tcp/udp send/receive message) methods.

A quick test to illustrate the idea: access a server in pod from the node:

(node) $ curl <pod ip>:<port>

The output of tcpdump:

(pod) $ tcpdump -nn -i eth0 host <node ip> and <port>
# TCP 3-way handshake
IP NODE_IP.36942 > POD_IP.8080: Flags [S]
IP POD_IP.8080   > NODE_IP.36942: Flags [S.]
IP NODE_IP.36942 > POD_IP.8080: Flags [.]

# requests & responses, no packets go through there, they are bypassed,
# payloads are transferred directly in socket-level TCP methods

# TCP 4-way close
IP POD_IP.8080   > NODE_IP.36942: Flags [F.]
IP NODE_IP.36942 > POD_IP.8080: Flags [.]
IP NODE_IP.36942 > POD_IP.8080: Flags [F.]
IP POD_IP.8080   > NODE_IP.36942: Flags [.]

2.3 Summary

Now we’ve got a basic undertanding about the problem and environment. It’s time to delve into practical investigation.

3 Quick narrow-down

3.1 Quick reproduction

First, check kubelet log,

$ grep "Timeout exceeded while awaiting headers" /var/log/kubernetes/kubelet.INFO
prober.go] Readiness probe for POD_XXX failed (failure):
  Get "http://POD_IP:PORT/health": context deadline exceeded (Client.Timeout exceeded while awaiting headers)
...

Indeed, there are many readiness probe failures.

Since the probe is very simple HTTP request, we can do it manually on the node, this should be equivalent to the kubelet probe,

$ curl <POD_IP>:<PORT>/v1/health
OK
$ curl <POD_IP>:<PORT>/v1/health
OK
$ curl <POD_IP>:<PORT>/v1/health # stuck
^C

OK, we can easily reproduce it without relying on k8s facilities.

3.2 Narrow-down the issue

Now let’s perform some quick tests to narrow-down the problem.

3.2.1 ping: OK, exclude L2/L3 problem

ping PodIP from node always succeeds.

(node) $ ping <POD_IP>

This indicates L2 & L3 (ARP table, routing table, etc) connectivity functions well.

3.2.2 telnet connection test: OK, exclude TCP connecting problem

(node) $ telnet POD_IP PORT
Trying POD_IP...
Connected to POD_IP.
Escape character is '^]'.

Again, always succeeds, and the ss output confirms the connections always enter ESTABLISHED state:

(node) $ netstat -antp | grep telnet
tcp        0      0 NODE_IP:34316    POD_IP:PORT     ESTABLISHED 2360593/telnet

3.2.3 Remote-to-localPod curl: OK, exclude pod problem & vanilla kernel stack problem

Do the same health check from a remote node, always OK:

(node2) $ curl <POD_IP>:<PORT>/v1/health
OK
...
(node2) $ curl <POD_IP>:<PORT>/v1/health
OK

This rules out issues with the pod itself and the vanilla kernel stack.

3.2.4 Local pod-to-pod: OK, exclude some node-internal problems

(pod3) $ curl <POD2_IP>:<PORT>/v1/health
OK
...
(pod3) $ curl <POD2_IP>:<PORT>/v1/health
OK

Always OK. Rule out issues with the pod itself.

3.3 Summary: only node-to-localPod TCP requests stuck probabilistically

Fig. Test cases and results.

The difference of three cases:

  1. Node-to-localPod: payload traffic is processed via sockops BPF;
  2. Local Pod-to-Pod: BPF redirection (or kernel stack, based on your kernel version)
  3. RemoteNode-to-localPod: standard kernel networking stack

Combining these information, we guess with confidence that the problem have relationships with sockops BPF and kernel (because kernel does most of the job in sockops BPF scenarios).

From these observations, it is reasonable to deduce that the issue is likely related to sockops BPF and the kernel, given the kernel’s central role in sockops BPF scenarios.

4 Dig deeper

Now let’s explore the issue in greater depth.

4.1 Linux vs. AliOS kernel

As we’ve been using kernel 5.10.56 and cilium v1.11.10 for years and haven’t met this problem before, the first reasonable assumption is that AliOS cloud-kernel 5.10.134 may introduce some incompatible changes or bugs.

So we spent some time comparing AliOS cloud-kernel with the upstream Linux.

Note: cloud-kernel is maintained on gitee.com, which restricts most read privileges (e.g. commits, blame) without logging in, so in the remaining of this post we reference the Linux repo on github.com for discussion.

4.1.1 Compare BPF features

First, compare BPF features automatically detected by cilium-agent on the node. The result is written to a local file on the node: /var/run/cilium/state/globals/bpf_features.h,

$ diff <bpf_features.h from our 5.10.56 node> <bpf_features.h from AliOS node>
59c59
< #define NO_HAVE_XSKMAP_MAP_TYPE
---
> #define HAVE_XSKMAP_MAP_TYPE
71c71
< #define NO_HAVE_TASK_STORAGE_MAP_TYPE
---
> #define HAVE_TASK_STORAGE_MAP_TYPE
243c243
< #define BPF__PROG_TYPE_socket_filter__HELPER_bpf_ktime_get_coarse_ns 0
---
> #define BPF__PROG_TYPE_socket_filter__HELPER_bpf_ktime_get_coarse_ns 1
...

There are indeed some differences, but with further investigation, we didn’t find any correlation to the observed issue.

4.1.2 AliOS cloud-kernel specific changes

Then we spent some time to check AliOS cloud-kernel self-maintained BPF and networking modifications. Such as,

  1. b578e4b8ed6e1c7608e07e03a061357fd79ac2dd ck: net: track the pid who created socks

    In this commit, they added a pid_t pid field to the struct sock data structure.

  2. ea0307caaf29700ff71467726b9617dcb7c0d084 tcp: make sure init the accept_queue’s spinlocks once

But again, we didn’t find any correlation to the problem.

4.2 Check detailed TCP connection stats

Without valuable information from code comparison, we redirected our focus to the environment, collecting some more detailed connection information.

ss (socket stats) is a powerful and convenient tool for socket/connection introspection:

  • -i/--info: show internal TCP information, including couple of TCP connection stats;
  • -e/--extended: show detailed socket information, including inode, uid, cookie.

4.2.1 Normal case: ss shows correct segs_out/segs_in

Initiate a connection with nc (netcat),

(node) $ nc POD_IP PORT

We intentionally not use telnet here, because telnet will close the connection immediately after a request is served successfully, which leaves us no time to check the connection stats in ss output. nc will leave the connection in CLOSE-WAIT state, which is good enough for us to check the connection send/receive stats.

Now the stats for this connection:

(node) $ ss -i | grep -A 1 50504
tcp    ESTAB      0         0         NODE_IP:50504          POD_IP:PORT
         cubic wscale:7,7 rto:200 rtt:0.059/0.029 mss:1448 pmtu:1500 rcvmss:536 advmss:1448 cwnd:10 bytes_acked:1 segs_out:2 segs_in:1 send 1963.4Mbps lastsnd:14641 lastrcv:14641 lastack:14641 pacing_rate 3926.8Mbps delivered:1 rcv_space:14480 rcv_ssthresh:64088 minrtt:0.059

Send & receive stats: segs_out=2, segs_in=1.

Now let’s send a request to the server: type GET /v1/health HTTP/1.1\r\n then press Enter,

Actually you can type anything and just Enter, the server will most likely send you a 400 (Bad Request) response, but for our case, this 400 indicate the TCP send/receive path is perfectly OK!

(node) $ nc POD_IP PORT
GET /v1/health HTTP/1.1\r\n
<Response Here>

We’ll get the response and the connection will just entering CLOSE-WAIT state, we have some time to check it before it vanishing:

(node) $ ss -i | grep -A 1 50504
tcp     CLOSE-WAIT   0      0        NODE_IP:50504     POD_IP:http
         cubic wscale:7,7 rto:200 rtt:0.059/0.029 ato:40 mss:1448 pmtu:1500 rcvmss:536 advmss:1448 cwnd:10 bytes_acked:1 bytes_received:1 segs_out:3 segs_in:2 send 1963.4Mbps lastsnd:24277 lastrcv:24277 lastack:4399 pacing_rate 3926.8Mbps delivered:1 rcv_space:14480 rcv_ssthresh:64088 minrtt:0.059

As expected, segs_out=3, segs_in=2.

4.2.2 Abnormal case: ss shows incorrect segs_out/segs_in

Repeat the above test to capture a failed one.

On connection established,

$ ss -i | grep -A 1 57424
tcp      ESTAB      0       0         NODE_IP:57424    POD_IP:webcache
         cubic wscale:7,7 rto:200 rtt:0.056/0.028 mss:1448 pmtu:1500 rcvmss:536 advmss:1448 cwnd:10 bytes_acked:1 segs_out:2 segs_in:1 send 2068.6Mbps lastsnd:10686 lastrcv:10686 lastack:10686 pacing_rate 4137.1Mbps delivered:1 rcv_space:14480 rcv_ssthresh:64088 minrtt:0.056

After typing the request content and stroking Enter:

(node) $ ss -i | grep -A 1 57424
tcp      ESTAB      0       0         NODE_IP:57424    POD_IP:webcache
         cubic wscale:7,7 rto:200 rtt:0.056/0.028 mss:1448 pmtu:1500 rcvmss:536 advmss:1448 cwnd:10 bytes_acked:1 segs_out:2 segs_in:1 send 2068.6Mbps lastsnd:21994 lastrcv:21994 lastack:21994 pacing_rate 4137.1Mbps delivered:1 rcv_space:14480 rcv_ssthresh:64088 minrtt:0.056

That segments sent/received stats remain unchanged (segs_out=2,segs_in=1), suggesting that the problem may reside on tcp {send,receive} message level.

4.3 Trace related call stack

Based on the above hypothesis, we captured kernel call stacks to compare failed and successful requests.

4.3.1 trace-cmd: trace kernel call stacks

Trace 10 seconds, filter by server process ID, save the calling stack graph,

# filter by process ID (PID of the server in the pod)
$ trace-cmd record -P 178501 -p function_graph sleep 10

Caution: avoid tracing in production to prevent large file generation and excessive disk IO.

During this period, send a request,

(node) $ curl POD_IP PORT

By default, it will save data to a local file in the current directory, the content looks like this:

$ trace-cmd report > report-1.graph
CPU  1 is empty
CPU  2 is empty
...
CPU 63 is empty
cpus=64
   <idle>-0     [022] 5376816.422992: funcgraph_entry:    2.441 us   |  update_acpu.constprop.0();
   <idle>-0     [022] 5376816.422994: funcgraph_entry:               |  switch_mm_irqs_off() {
   <idle>-0     [022] 5376816.422994: funcgraph_entry:    0.195 us   |    choose_new_asid();
   <idle>-0     [022] 5376816.422994: funcgraph_entry:    0.257 us   |    load_new_mm_cr3();
   <idle>-0     [022] 5376816.422995: funcgraph_entry:    0.128 us   |    switch_ldt();
   <idle>-0     [022] 5376816.422995: funcgraph_exit:     1.378 us   |  }
...

Use | as delimiter (this preserves the calling stack and the proper leading whitespaces) and save the last fields into a dedicated file:

$ awk -F'|' '{print $NF}' report-1.graph > stack-1.txt

Compare them with diff or vimdiff:

$ vimdiff stack-1.txt stack-2.txt

Here are two traces, the left is a trace of a normal request, and the right is a problematic one:

Fig. Traces (call stacks) of a normal request (left side) and a problematic request (right side).

As can be seen, for a failed request, kernel made a wrong function call: it should call tcp_bpf_recvmsg() but actually called tcp_recvmsg().

4.3.2 Locate the code: inet_recvmsg -> {tcp_bpf_recvmsg, tcp_recvmsg}

Calling into tcp_bpf_recvmsg or tcp_recvmsg from inet_recvmsg is a piece of concise code, illustrated below,

// https://github.com/torvalds/linux/blob/v5.10/net/ipv4/af_inet.c#L838
int inet_recvmsg(struct socket *sock, struct msghdr *msg, size_t size, int flags) {
    struct sock *sk = sock->sk;
    int addr_len = 0;
    int err;

    if (likely(!(flags & MSG_ERRQUEUE)))
        sock_rps_record_flow(sk);

    err = INDIRECT_CALL_2(sk->sk_prot->recvmsg, tcp_recvmsg, udp_recvmsg,
                  sk, msg, size, flags & MSG_DONTWAIT,
                  flags & ~MSG_DONTWAIT, &addr_len);
    if (err >= 0)
        msg->msg_namelen = addr_len;
    return err;
}

sk_prot ("socket protocol") contains handlers to this socket. INDIRECT_CALL_2 line can be simplified into the following pseudocode:

if sk->sk_prot->recvmsg == tcp_recvmsg: // if socket protocol handler is tcp_recvmsg
    tcp_recvmsg()
else:
    tcp_bpf_recvmsg()

This suggests that when requests fail, the sk_prot->recvmsg pointer of the socket is likely incorrect.

4.3.3 Double check with bpftrace

While trace-cmd is a powerful tool, it may contain too much details distracting us, and may run out of your disk space if set improper filter parameters.

bpftrace is a another tracing tool, and it won’t write data to local file by default. Now let’s double confirm the above results with it.

Again, run several times of curl POD_IP:PORT, capture only tcp_recvmsg and tcp_bpf_recvmsg calls, print kernel calling stack:

$ bpftrace -e 'k:tcp_recvmsg /pid==178501/ { printf("%s\n", kstack);} k:tcp_bpf_recvmsg /pid==178501/ { printf("%s\n", kstack);} '
        tcp_bpf_recvmsg+1                   # <-- correspond to a successful request
        inet_recvmsg+233
        __sys_recvfrom+362
        __x64_sys_recvfrom+37
        do_syscall_64+48
        entry_SYSCALL_64_after_hwframe+97

        tcp_bpf_recvmsg+1                   # <-- correspond to a successful request
        inet_recvmsg+233
        __sys_recvfrom+362
        __x64_sys_recvfrom+37
        do_syscall_64+48
        entry_SYSCALL_64_after_hwframe+97

        tcp_recvmsg+1                       # <-- correspond to a failed request
        inet_recvmsg+78
        __sys_recvfrom+362
        __x64_sys_recvfrom+37
        do_syscall_64+48
        entry_SYSCALL_64_after_hwframe+97

You could also filter by client program name (comm field in kernel data structure), such as,

$ bpftrace -e 'k:tcp_bpf_recvmsg /comm=="curl"/ { printf("%s", kstack); }'

As seen above, successful requests were directed to tcp_bpf_recvmsg, while failed ones were routed to tcp_recvmsg.

4.3.4 Summary

tcp_recvmsg waits messages from kernel networking stack, In the case of sockops BPF, messages bypass kernel stack, which explains why some requests fail (timeout), yet TCP connecting always OK.

We reported the above findings to the cloud-kernel team, and they did some further investigations with us.

4.4 recvmsg handler initialization in kernel stack

For short,

Fig. sockops BPF: connection establishement and socket handler initialization.

According to the above picture, recvmsg handler will be incorrectly initialized if to-be-inserted entry already exists sockmap (the end of step 3.1).

What’s the two entries of a connection looks like in BPF map:

(cilium-agent) $ bpftool map dump id 122 | grep "0a 0a 86 30" -C 2 | grep "0a 0a 65 f9" -C 2 | grep -C 2 "db 78"
0a 0a 86 30 00 00 00 00  00 00 00 00 00 00 00 00
0a 0a 65 f9 00 00 00 00  00 00 00 00 00 00 00 00
01 00 00 00 1f 90 00 00  db 78 00 00
--
key:
--
0a 0a 65 f9 00 00 00 00  00 00 00 00 00 00 00 00
0a 0a 86 30 00 00 00 00  00 00 00 00 00 00 00 00
01 00 00 00 db 78 00 00  1f 90 00 00

We’ll explain these binary data later. Now let’s first confirm our above assumption.

4.5 Confirm stale entries in sockmap

4.5.1 bpftrace tcp_bpf_get_prot(): incorrect socket handler (sk_prot)

Two sequent function calls that holding sk_port:

  • tcp_bpf_get_prot(): where sk_prot is initialized;
  • tcp_bpf_recvmsg() or tcp_recvmsg(): where sk_prot is called to receive a message;

Trace these two methods and print the sk_prot variable (pointer).

Successful case:

tcp_bpf_get_proto: src POD_IP (8080), dst NODE_IP(59500), 2232440
tcp_bpf_get_proto: 0xffffffffacc65800                                     # <-- sk_prot pointer
tcp_bpf_recvmsg: src POD_IP (8080), dst NODE_IP(59500) 0xffffffffacc65800 # <-- same pointer

Bad case:

(node) $ ./tcp_bpf_get_proto.bt 178501
Attaching 6 probes...
tcp_bpf_get_proto: src POD_IP (8080), dst NODE_IP(53904), 2231203
tcp_bpf_get_proto: 0xffffffffacc65800                                    # <-- sk_prot pointer
tcp_recvmsg: src POD_IP (8080), dst NODE_IP(53904) 0xffffffffac257300    # <-- sk_prot is modified!!!

4.5.2 bpftrace sk_psock_drop

A succesful case, calling into sk_psock_drop when requests finish and connection was normally closed:

(node) $ ./sk_psock_drop.bt 178501
tcp_bpf_get_proto: src POD_IP (8080), dst NODE_IP(59500), 2232440
tcp_bpf_get_proto: 0xffffffffacc65800                                    # <-- sk_prot pointer
sk_psock_drop: src POD_IP (8080), dst NODE_IP(44566)
    sk_psock_drop+1
    sock_map_remove_links+161
    sock_map_close+50
    inet_release+63
    sock_release+58
    sock_close+17
    fput+147
    task_work_run+89
    exit_to_user_mode_loop+285
    exit_to_user_mode_prepare+110
    syscall_exit_to_user_mode+18
    entry_SYSCALL_64_after_hwframe+97
tcp_bpf_recvmsg: src POD_IP (8080), dst NODE_IP(59500) 0xffffffffacc65800 # <-- same pointer

A failed case, calling into sk_psock_drop when the server side calls sock_map_update() and the to-be-inserted entry already exists:

(node) $ ./sk_psock_drop.bt 178501
tcp_bpf_get_proto: src POD_IP (8080), dst NODE_IP(53904), 2231203
tcp_bpf_get_proto: 0xffffffffacc65800                                    # <-- sk_prot pointer
sk_psock_drop: src POD_IP (8080), dst NODE_IP(44566)
    sk_psock_drop+1
    sock_hash_update_common+789
    bpf_sock_hash_update+98
    bpf_prog_7aa9a870410635af_bpf_sockmap+831
    _cgroup_bpf_run_filter_sock_ops+189
    tcp_init_transfer+333                       // -> bpf_skops_established -> BPF_CGROUP_RUN_PROG_SOCK_OPS -> cilium sock_ops code
    tcp_rcv_state_process+1430
    tcp_child_process+148
    tcp_v4_rcv+2491
    ...
tcp_recvmsg: src POD_IP (8080), dst NODE_IP(53904) 0xffffffffac257300    # <-- sk_prot is modified!!!
// https://github.com/torvalds/linux/blob/v6.5/net/core/sock_map.c#L464

static int sock_map_update_common(struct bpf_map *map, u32 idx, struct sock *sk, u64 flags) {
    struct bpf_stab *stab = container_of(map, struct bpf_stab, map);
    ...

    link = sk_psock_init_link();
    sock_map_link(map, sk);
    psock = sk_psock(sk);

    osk = stab->sks[idx];
    if (osk && flags == BPF_NOEXIST) {     // sockmap entries already exists
        ret = -EEXIST;
        goto out_unlock;                   // goto out_unlock, which will release psock
    } else if (!osk && flags == BPF_EXIST) {
        ret = -ENOENT;
        goto out_unlock;
    }

    sock_map_add_link(psock, link, map, &stab->sks[idx]);
    stab->sks[idx] = sk;
    if (osk)
        sock_map_unref(osk, &stab->sks[idx]);
    return 0;                              // <-- should return from here
out_unlock:                                // <-- actually hit here
    if (psock)
        sk_psock_put(sk, psock);           // --> further call sk_psock_drop
out_free:
    sk_psock_free_link(link);
    return ret;
}

This early release of psock leads to the sk->sk_prot->recvmsg to be initialized as tcp_recvmsg.

4.5.3 bpftool: confirm stale connection info in sockops map

Key and value format in the BPF map:

// https://github.com/cilium/cilium/blob/v1.11.10/pkg/maps/sockmap/sockmap.go#L20

// SockmapKey is the 5-tuple used to lookup a socket
// +k8s:deepcopy-gen=true
// +k8s:deepcopy-gen:interfaces=github.com/cilium/cilium/pkg/bpf.MapKey
type SockmapKey struct {
    DIP    types.IPv6 `align:"$union0"`
    SIP    types.IPv6 `align:"$union1"`
    Family uint8      `align:"family"`
    Pad7   uint8      `align:"pad7"`
    Pad8   uint16     `align:"pad8"`
    SPort  uint32     `align:"sport"`
    DPort  uint32     `align:"dport"`
}

// SockmapValue is the fd of a socket
// +k8s:deepcopy-gen=true
// +k8s:deepcopy-gen:interfaces=github.com/cilium/cilium/pkg/bpf.MapValue
type SockmapValue struct {
    fd uint32
}

Trip.com: Large Scale Cloud Native Networking & Security with Cilium/eBPF, 2022 shows how to decode the encoded entries of Cilium BPF map.

$ cat ip2hex.sh
echo $1 | awk -F. '{printf("%02x %02x %02x %02x\n",$1,$2,$3,$4);}'
$ cat hex2port.sh
echo $1 | awk '{printf("0x%s%s 0x%s%s\n", $1, $2, $5, $6) }' | sed 's/ /\n/g' | xargs -n1 printf '%d\n'

(node) $ ./ip2hex.sh "10.10.134.48"
0a 0a 86 30
(node) $ ./ip2hex.sh "10.10.101.249"
0a 0a 65 f9
(cilium-agent) $ bpftool map dump id 122 | grep "0a 0a 86 30" -C 2 | grep "0a 0a 65 f9" -C 2 | grep -C 2 "db 78"
0a 0a 86 30 00 00 00 00  00 00 00 00 00 00 00 00
0a 0a 65 f9 00 00 00 00  00 00 00 00 00 00 00 00
01 00 00 00 1f 90 00 00  db 78 00 00
--
key:
--
0a 0a 65 f9 00 00 00 00  00 00 00 00 00 00 00 00
0a 0a 86 30 00 00 00 00  00 00 00 00 00 00 00 00
01 00 00 00 db 78 00 00  1f 90 00 00
(node) $ ./hex2port.sh "1f 90 00 00  b6 8a 00 00"
8080
46730 # you can verify this connection in `ss` output

Almost all of the following entries are stale (because this is an empty, no node-to-pod traffic unless we do manually):

(cilium-agent) $ bpftool map dump /sys/fs/bpf/cilium_sock_ops | grep "0a 0a 86 30" | wc -l
7325
(cilium-agent) $ bpftool map dump /sys/fs/bpf/cilium_sock_ops | grep "0a 0a 8c ca" | wc -l
1288
(cilium-agent) $ bpftool map dump /sys/fs/bpf/cilium_sock_ops | grep "0a 0a 8e 40" | wc -l
191

5 Technical summary

5.1 Normal sockops/sockmap BPF workflow

Fig. sockops BPF: connection establishement and socket handler initialization.

  1. Node client (e.g. kubelet) -> server: initiate TCP connection to the server
  2. Kernel (and the BPF code in kernel): on listening on connection established
    1. write two entries to sockmap
    2. link entries to bpf handlers (tcp_bpf_{sendmsg, recvmsg})
  3. Node client (e.g. kubelet) -> server: send & receive payload: BPF handlers were executed
  4. Node client (e.g. kubelet) -> server: close connection: kernel removes entries from sockmap

5.2 Direct cause

The problem arises in step 4, for an unknown reason, some entries are not deleted when connections closed. This leads to incorrect handler initialization in new connections in step 2 (or section 3.1 in the picture). When hit a stale entry,

  • sender side uses BPF message handlers for transmission;
  • server side treats the the socket as standard, and waits for message via default message handler, then stucks there as no payload goes to default handler.

5.3 Root cause

The Alibaba cloud-kernel team digged further into the issue, and thanks for their efforts, they finally found that bpf, sockmap: Remove unhash handler for BPF sockmap usage was the root cause, which was introduced in Linux 5.10.58. The AliOS kernel we were using was 5.10.134 based, so it suffered from this.

Upstream patch bpf, sockmap: Fix sk->sk_forward_alloc warn_on in sk_stream_kill_queues has already fixed it, but it was only backported to 6.x series.

5.4 Quick restoration/remediation

If the issue already happened, you can use one of the following methods to restore:

  1. Kernel restart: drain the node then restart it, thish will refresh the kernel state;
  2. Manual clean with bpftool: with caution, avoid to remove valid entries.

5.5 Another issue with similar phenomenon

There is another issue with the similar phenomenon when sockops is enabled:

  1. Local pod runs nginx (of recent versions, e.g. >= 1.18);
  2. Sending http requests from node to the local pod, with a large enough cookie length (e.g. > 1024 Byte);

TCP connection will be OK, but requests will always stuck there.

Cilium issue:

ioctl FIONREAD returning incorrect value when sockops is enabled

nginx is reading the headers from the traefik request with a default value of 1024 (client_header_buffer_size 1k;) bytes and then (seemingly) asks via the ioctl how much data is left. Since the return is 0 the request is never fully read and does not proceed further.

Community solution:

Appendix

References

  1. AliOS kernel (a Linux fork), gitee.com/anolis/cloud-kernel
  2. Cilium Network Topology and Traffic Path on AWS (2019)
  3. cilium v1.11.10, bpf_sockops.c
  4. cilium v1.11.10, bpf sockops key & value definition
  5. Differentiate three types of eBPF redirections
  6. Trip.com: Large Scale Cloud Native Networking & Security with Cilium/eBPF, 2022

Written by Human, Not by AI Written by Human, Not by AI

Linux 服务器功耗与性能管理(五):问题讨论(2024)

2024年2月15日 08:00

整理一些 Linux 服务器性能相关的 CPU 硬件基础及内核子系统知识。

水平及维护精力所限,文中不免存在错误或过时之处,请酌情参考。 传播知识,尊重劳动,年满十八周岁,转载请注明出处



1 idle=poll 的潜在风险

前面已经介绍过,idle=poll 就是强制处理器工作在 C0,保持最高性能。 但内核文档中好几个地方提示这样设置是有风险的,这里整理一下。

1.1. 5.15 内核文档 “CPU Idle Time Management”

CPU Idle Time Management

using ``idle=poll`` is somewhat drastic in many cases, as preventing idle
CPUs from saving almost any energy at all may not be the only effect of it.

For example, on Intel hardware it effectively prevents CPUs from using
P-states (see |cpufreq|) that require any number of CPUs in a package to be
idle, so it very well may hurt single-thread computations performance as well as
energy-efficiency.  Thus using it for performance reasons may not be a good idea
at all.]

这段写的比较晦涩,基于本系列前几篇的基础,尝试给大家翻译一下:

idle=poll 除了功耗高,还有其他后果;例如,

  • 在 Intel 处理器上,这会使得 p-states 功能无法正常工作, 因而无法将同属一个 PKG 的那些空闲 CPU 的功耗降低, 这不是省不省电本身的问题:空闲的 CPU 不降低功耗,其他的 CPU 可能就无法超频!
  • 所以对于单线程的任务来说(更宽泛的来说,是这样一类场景下:系统比较空闲,只有少量线程在执行), 性能反而无法达到最优,因为总功耗限制下,少量在工作的 CPU 无法超频(turbo)。

另外,这个文档是 Intel 的人写的,但看过超频原理就应该明白,这个问题不仅限于 Intel CPU。

1.2 5.15 内核文档 “NO_HZ: Reducing Scheduling-Clock Ticks”

NO_HZ: Reducing Scheduling-Clock Ticks

Known Issues
    d.    On x86 systems, use the "idle=poll" boot parameter.
        However, please note that use of this parameter can cause
        your CPU to overheat, which may cause thermal throttling
        to degrade your latencies -- and that this degradation can
        be even worse than that of dyntick-idle.  Furthermore,
        this parameter effectively disables Turbo Mode on Intel
        CPUs, which can significantly reduce maximum performance.

这是归类到了已知问题,写的比前一篇清楚多了:

  1. 导致 CPU 过热,延迟可能上升,可能比 tickless 模式(dyntick)的延迟还大;
  2. 更重要的,idle=poll effectively 禁用了 Intel Turbo Mode, 也就是无法超频到 base frequency 以上,因此峰值性能显著变差

1.3 5.15 内核文档 “AMD64 Specific Boot Options”

AMD64 Specific Boot Options

这个是启动项说明,里面以 Intel CPU 为例但问题不仅限于 Intel, AMD 的很多在用参数和功能这个文档里都没有,

Idle loop
=========

  idle=poll
    Don't do power saving in the idle loop using HLT, but poll for rescheduling
    event. This will make the CPUs eat a lot more power, but may be useful
    to get slightly better performance in multiprocessor benchmarks. It also
    makes some profiling using performance counters more accurate.
    Please note that on systems with MONITOR/MWAIT support (like Intel EM64T
    CPUs) this option has no performance advantage over the normal idle loop.
    It may also interact badly with hyperthreading.
  • idle=poll 在某些场景下能提升 multiple benchmark 的性能,也能让某些 profiling 更准确一些;
  • 在支持 MONITOR/MWAIT 的平台上,这个配置并不会带来性能提升;
  • 最后一句:与超线程的交互(兼容)可能很差。为什么?没说, 但是下面来自 Dell 的一篇白皮书做了一些进一步解释。

1.4 Dell Whitepaper: Controlling Processor C-State Usage in Linux, 2013

Dell Whitepaper: Controlling Processor C-State Usage in Linux, 服务器厂商 Dell 的技术白皮书,其中一段,

If a user wants the absolute minimum latency, kernel parameter “idle=poll” can be used to keep the
processors in C0 even when they are idle (the processors will run in a loop when idle, constantly
checking to see if they are needed). If this kernel parameter is used, it should not be necessary to
disable C-states in BIOS (or use the “idle=halt” kernel parameter).

Take care when keeping processors in C0, though--this will increase power usage considerably.
Also, hyperthreading should probably be
disabled, as keeping processors in C0 can interfere with proper operation of logical cores
(hyperthreading). (The hyperthreading hardware works best when it knows when the logical processors
are idle, and it doesn’t know that if processors are kept busy in a loop when they are not running
useful code.)
  • 可能需要关闭 hyperthreading,因为处理器保持在 C0 状态干扰逻辑核(也就是超线程)的正常功能
  • 超线程硬件的工作原理:

    • 根据逻辑核(硬件线程)的空闲状态,做出下一步判断和动作;
    • C0 会在没有任务时执行无意义代码(即前面说的“轻量级”指令流)来保持处理器处于繁忙状态, 用这种方式避免处理器进入节能状态
  • C0 模式的这种行为使得超线程硬件无法判断硬件处理器的真实状态(区分不出在执行有意义代码还是无意义代码), 因而无法有效工作。

1.5 I really don’t think you should really ever use “idle=poll” on HT-enabled hardware, Linus, 2003

用户报告 idle=poll + hyperthreading 导致并发性能显著变差, Linus 回复说, I really don’t think you should really ever use “idle=poll” on HT-enabled hardwareHT 是超线程的缩写。

1.6 小结

看起来 idle=poll 与 turbo-frequency/hyperthreading 存在工作机制的冲突。

需要一些场景和 testcase 来验证。有经验的专家大佬,欢迎交流。

2 内核大量 ACPI 日志

一台惠普机器:

$ dmesg -T
kernel: ACPI Error: SMBus/IPMI/GenericSerialBus write requires Buffer of length 66, found length 32 (20180810/exfield-393)
kernel: ACPI Error: Method parse/execution failed \_SB.PMI0._PMM, AE_AML_BUFFER_LIMIT (20180810/psparse-516)
kernel: ACPI Error: AE_AML_BUFFER_LIMIT, Evaluating _PMM (20180810/power_meter-338)
...

这是 HP 的 BIOS 实现没有遵守协议,实际上这个报错不会产生硬件性能影响之类的(但是打印的日志量可能很大,每分钟十几条,不间断)。

一台联想机器:

$ dmesg -T
kernel: power_meter ACPI000D:00: Found ACPI power meter.
kernel: power_meter ACPI000D:00: Found ACPI power meter.
...

如果是 k8s node 遇到以上问题,可能是部署了 prometheus/node_exporter 导致的 [2], 试试关闭其 hwmon collector。

参考资料

  1. Controlling Processor C-State Usage in Linux, A Dell technical white paper describing the use of C-states with Linux operating systems, 2013
  2. After PMM2 client installation kernel: ACPI Error: SMBus/IPMI/GenericSerialBus write requires Buffer of length 66, forums.percona.com, 2023

Written by Human, Not by AI Written by Human, Not by AI

Linux 服务器功耗与性能管理(四):监控、配置、调优(2024)

2024年2月15日 08:00

整理一些 Linux 服务器性能相关的 CPU 硬件基础及内核子系统知识。

水平有限,文中不免有错误或过时之处,请酌情参考。



1 sysfs 相关目录

1.1 /sys/devices/system/cpu/cpu{N}/ 目录

系统中的每个 CPU,都对应一个 /sys/devices/system/cpu/cpu<N>/cpuidle/ 目录, 其中 N 是 CPU ID,

$ tree /sys/devices/system/cpu/cpu0/
/sys/devices/system/cpu/cpu0/
├── cache
│   ├── index0
│   ├── ...
│   ├── index3
│   └── uevent
├── cpufreq -> ../cpufreq/policy0
├── cpuidle
│   ├── state0
│   │   ├── above
│   │   ├── below
│   │   ├── default_status
│   │   ├── desc
│   │   ├── disable
│   │   ├── latency
│   │   ├── name
│   │   ├── power
│   │   ├── rejected
│   │   ├── residency
│   │   ├── time
│   │   └── usage
│   └── state1
│       ├── above
│       ├── below
│       ├── default_status
│       ├── desc
│       ├── disable
│       ├── latency
│       ├── name
│       ├── power
│       ├── rejected
│       ├── residency
│       ├── time
│       └── usage
├── crash_notes
├── crash_notes_size
├── driver -> ../../../../bus/cpu/drivers/processor
├── firmware_node -> ../../../LNXSYSTM:00/LNXCPU:00
├── hotplug
│   ├── fail
│   ├── state
│   └── target
├── node0 -> ../../node/node0
├── power
│   ├── async
│   ├── autosuspend_delay_ms
│   ├── control
│   ├── pm_qos_resume_latency_us
│   ├── runtime_active_kids
│   ├── runtime_active_time
│   ├── runtime_enabled
│   ├── runtime_status
│   ├── runtime_suspended_time
│   └── runtime_usage
├── subsystem -> ../../../../bus/cpu
├── topology
│   ├── cluster_cpus
│   ├── cluster_cpus_list
│   ├── cluster_id
│   ├── core_cpus
│   ├── core_cpus_list
│   ├── core_id
│   ├── core_siblings
│   ├── core_siblings_list
│   ├── die_cpus
│   ├── die_cpus_list
│   ├── die_id
│   ├── package_cpus
│   ├── package_cpus_list
│   ├── physical_package_id
│   ├── thread_siblings
│   └── thread_siblings_list
└── uevent

里面包括了很多硬件相关的子系统信息,跟我们本次主题相关的几个:

  1. cpufreq
  2. cpuidle
  3. power:PM QoS 相关信息,可以在这里面查到
  4. topology:第一篇介绍的 PKG-CORE-CPU 拓扑,信息可以在这里面查到

下面分别看下这几个子目录。

1.1.1 /sys/devices/system/cpu/cpu<N>/cpufreq/ (p-state)

处理器执行任务时的运行频率、超频等等相关的参数,管理的是 p-state:

$ tree /sys/devices/system/cpu/cpu0/cpufreq/
/sys/devices/system/cpu/cpu0/cpufreq/
├── affected_cpus
├── cpuinfo_max_freq
├── cpuinfo_min_freq
├── cpuinfo_transition_latency
├── related_cpus
├── scaling_available_governors
├── scaling_cur_freq
├── scaling_driver
├── scaling_governor
├── scaling_max_freq
├── scaling_min_freq
└── scaling_setspeed

1.1.2 /sys/devices/system/cpu/cpu<N>/cpuidle/ (c-states)

每个 struct cpuidle_state 对象都有一个对应的 struct cpuidle_state_usage 对象(上一篇中有更新这个 usage 的相关代码),其中包含了这个 idle state 的统计信息, 也是就是我们下面看到的这些:

$ tree /sys/devices/system/cpu/cpu0/cpuidle/
/sys/devices/system/cpu/cpu0/cpuidle/
├── state0
│   ├── above
│   ├── below
│   ├── default_status
│   ├── desc
│   ├── disable
│   ├── latency
│   ├── name
│   ├── power
│   ├── rejected
│   ├── residency
│   ├── time
│   └── usage
├── state1
│   ├── above
│   ├── below
│   ├── default_status
│   ├── desc
│   ├── disable
│   ├── latency
│   ├── name
│   ├── power
│   ├── rejected
│   ├── residency
│   ├── s2idle
│   │   ├── time
│   │   └── usage
│   ├── time
│   └── usage
│...

state0state1 等目录对应 idle state 对象,也跟这个 CPU 的 c-state 对应,数字越大,c-state 越深。 文件说明,

  • desc/name:都是这个 idle state 的描述。name 比较简洁,desc 更长。除了这俩,其他字段都是整型
  • aboveidle duration < target_residency 的次数。也就是请求到了这个状态,但是 idle duration 太短,最终放弃进入这个状态。
  • belowidle duration 虽然大于 target_residency,但是大的比较多,最终找到了一个更深的 idle state 的次数。
  • disable唯一的可写字段1 表示禁用,governor 就不会在这个 CPU 上选这状态了。注意这个是 per-cpu 配置,此外还有一个全局配置。
  • default_status:default status of this state, “enabled” or “disabled”.
  • latency:这个 idle state 的 exit latency,单位 us
  • power:这个字段通常是 0,表示不支持。因为功耗的统计很复杂,这个字段的定义也不是很明确。建议不要参考这个值。
  • residency:这个 idle state 的 target residency,单位 us
  • time:内核统计的该 CPU 花在这个状态的总时间,单位 ms。这个是内核统计的,可能不够准,因此如有处理器硬件统计的类似指标,建议参考后者。
  • usage:成功进入这个 idle state 的次数。
  • rejected:被拒绝的要求进入这个 idle state 的 request 的数量。

1.1.3 /sys/devices/system/cpu/cpu<N>/power/

$ tree /sys/devices/system/cpu/cpu0/
/sys/devices/system/cpu/cpu0/
├── power
│   ├── async
│   ├── autosuspend_delay_ms
│   ├── control
│   ├── pm_qos_resume_latency_us
│   ├── runtime_active_kids
│   ├── runtime_active_time
│   ├── runtime_enabled
│   ├── runtime_status
│   ├── runtime_suspended_time
│   └── runtime_usage

1.1.4 /sys/devices/system/cpu/cpu<N>/topology/

$ tree /sys/devices/system/cpu/cpu0/
/sys/devices/system/cpu/cpu0/
├── topology
│   ├── cluster_cpus
│   ├── cluster_cpus_list
│   ├── cluster_id
│   ├── core_cpus
│   ├── core_cpus_list
│   ├── core_id
│   ├── core_siblings
│   ├── core_siblings_list
│   ├── die_cpus
│   ├── die_cpus_list
│   ├── die_id
│   ├── package_cpus
│   ├── package_cpus_list
│   ├── physical_package_id
│   ├── thread_siblings
│   └── thread_siblings_list
└── uevent

1.2 /sys/devices/system/cpu/cpuidle/governor/driver

这个目录是全局的,可以获取可用的 governor/driver 信息,也可以在运行时更改 governor。

$ ls /sys/devices/system/cpu/cpuidle/
available_governors  current_driver  current_governor  current_governor_ro

$ cat /sys/devices/system/cpu/cpuidle/available_governors
menu
$ cat /sys/devices/system/cpu/cpuidle/current_driver
acpi_idle
$ cat /sys/devices/system/cpu/cpuidle/current_governor
menu

2 内核启动项

除了 sysfs,还可以通过内核命令行参数做一些配置,可以加在 /etc/grub2.cfg 等位置。

2.1 idle loop 配置

5.15 内核启动参数文档:

// https://github.com/torvalds/linux/blob/v5.15/Documentation/admin-guide/kernel-parameters.txt

    idle=        [X86]
            Format: idle=poll, idle=halt, idle=nomwait

            1. idle=poll forces a polling idle loop that can slightly improve the performance of waking up a
               idle CPU, but will use a lot of power and make the system run hot. Not recommended.
            2. idle=halt: Halt is forced to be used for CPU idle. In such case C2/C3 won't be used again.
            3. idle=nomwait: Disable mwait for CPU C-states

2.1.1 idle=poll

CPU 空闲时,将执行一个“轻量级”的指令序列(”lightweight” sequence of instructions in a tight loop) 来防止 CPU 进入任何节能模式。

这种配置除了功耗问题,还超线程场景下可能有副作用,性能反而降低,后面单独讨论。

2.1.2 idle=halt

强制 cpuidle 子系统使用 HLT 指令 (一般会 suspend 程序的执行并使硬件进入最浅的 idle state)来实现节能。

这种配置下,最大 c-state 深度C1

2.1.3 idle=nomwait

禁用通过 MWAIT 指令来要求硬件进入 idle state。

内核文档 CPU Idle Time Management 说,在 Intel 机器上,这会禁用 intel_idle,用 acpi_idle(idle states / p-states 从 ACPI 获取)。

2.2 厂商相关的 p-state 参数

2.2.1 intel_pstate

// https://github.com/torvalds/linux/blob/v5.15/Documentation/admin-guide/kernel-parameters.txt#L1988

	intel_pstate=	[X86]
			disable
			  Do not enable intel_pstate as the default
			  scaling driver for the supported processors
			passive
			  Use intel_pstate as a scaling driver, but configure it
			  to work with generic cpufreq governors (instead of
			  enabling its internal governor).  This mode cannot be
			  used along with the hardware-managed P-states (HWP)
			  feature.
			force
			  Enable intel_pstate on systems that prohibit it by default
			  in favor of acpi-cpufreq. Forcing the intel_pstate driver
			  instead of acpi-cpufreq may disable platform features, such
			  as thermal controls and power capping, that rely on ACPI
			  P-States information being indicated to OSPM and therefore
			  should be used with caution. This option does not work with
			  processors that aren't supported by the intel_pstate driver
			  or on platforms that use pcc-cpufreq instead of acpi-cpufreq.
			no_hwp
			  Do not enable hardware P state control (HWP)
			  if available.
			hwp_only
			  Only load intel_pstate on systems which support
			  hardware P state control (HWP) if available.
			support_acpi_ppc
			  Enforce ACPI _PPC performance limits. If the Fixed ACPI
			  Description Table, specifies preferred power management
			  profile as "Enterprise Server" or "Performance Server",
			  then this feature is turned on by default.
			per_cpu_perf_limits
			  Allow per-logical-CPU P-State performance control limits using
			  cpufreq sysfs interface

2.2.2 AMD_pstat

AMD_idle.max_cstate=1 AMD_pstat=disable 等等,上面的内核文档还没收录,或者在别的地方。

2.3 *.max_cstate

  • intel_idle.max_cstate=<n>
  • AMD_idle.max_cstate=<n>
  • processor.max_cstate=<n>

这里面的 n 就是我们在 sysfs 目录中看到 /sys/devices/system/cpu/cpu0/cpuidle/state{n}

// https://github.com/torvalds/linux/blob/v5.15/Documentation/admin-guide/kernel-parameters.txt

	intel_idle.max_cstate=	[KNL,HW,ACPI,X86]
			0	disables intel_idle and fall back on acpi_idle.
			1 to 9	specify maximum depth of C-state.

	processor.max_cstate=	[HW,ACPI]
			Limit processor to maximum C-state
			max_cstate=9 overrides any DMI blacklist limit.

AMD 的没收录到这个文档中。

2.4 cpuidle.off

cpuidle.off=1 完全禁用 CPU 空闲时间管理。

加上这个配置后,

  • 空闲 CPU 上的 idle loop 仍然会运行,但不会再进入 cpuidle 子系统;
  • idle loop 通过 CPU architecture support code 使硬件进入 idle state。

不建议在生产使用。

2.5 cpuidle.governor

指定要使用的 CPUIdle 管理器。例如 cpuidle.governor=menu 强制使用 menu 管理器。

2.6 nohz

可设置 on/off,是否启用每秒 HZ 次的定时器中断。

3 监控

3.1 频率

可以从 /proc/cpuinfo 获取,

$ cat /proc/cpuinfo | awk '/cpu MHz/ { printf("cpu=%d freq=%s\n", i++, $NF)}'
cpu=0 freq=3393.622
cpu=1 freq=3393.622
cpu=2 freq=3393.622
cpu=3 freq=3393.622

某些开源组件可能已经采集了,如果没有的话自己采一下,然后送到 prometheus。 这里拿一台 base freq 2.8GHz、max freq 3.7GHz,配置了 idle=poll 测试机, 下面是各 CPU 的频率,

Fig. Per-CPU running frequency

几点说明,

  • idle=poll 禁用了节能模式(c1/c2/c3..),没有负载也会空转(执行轻量级指令),避免频率掉下去;
  • 不是所有 CPU 都能同时达到 3.7GHz 的 max/turbo freq,原因我们在第二篇解释过了;
  • 实际上,只有很少的 CPU 能同时达到 max freq。

3.2 功耗、电流

Fig. Power consumption and electic current of an empty node (no workload before and after) after setting idle=poll for test

3.3 温度等

服务器厂商一般能提供。

3.4 sysfs 详细信息

按需。

4 调优工具

除了通过 sysfs 和内核启动项,还可以通过一些更上层的工具配置功耗和性能模式。

4.1 tuned/tuned-adm

github.com/redhat-performance/tuned, 版本陆续有升级,但是好像没有 release notes,想了解版本差异只能看 diff commits:

$ tuned-adm list
Available profiles:
- balanced                    - General non-specialized tuned profile
- desktop                     - Optimize for the desktop use-case
- latency-performance         - Optimize for deterministic performance at the cost of increased power consumption
- network-latency             - Optimize for deterministic performance at the cost of increased power consumption, focused on low latency network performance
- network-throughput          - Optimize for streaming network throughput, generally only necessary on older CPUs or 40G+ networks
- powersave                   - Optimize for low power consumption
- throughput-performance      - Broadly applicable tuning that provides excellent performance across a variety of common server workloads
- virtual-guest               - Optimize for running inside a virtual guest
- virtual-host                - Optimize for running KVM guests
Current active profile: latency-performance

$ tuned-adm active
Current active profile: latency-performance

$ tuned-adm profile_info latency-performance
Profile name:
latency-performance

Profile summary:
Optimize for deterministic performance at the cost of increased power consumption

$ tuned-adm profile_mode
Profile selection mode: manual

4.2 turbostat:查看 turbo freq

来自 man page:

turbostat - Report processor frequency and idle statistics
turbostat  reports processor topology, frequency, idle power-state statistics, temperature and power on X86 processors.
  • –interval
  • –num_iterations

例子:

$ turbostat --quiet --hide sysfs,IRQ,SMI,CoreTmp,PkgTmp,GFX%rc6,GFXMHz,PkgWatt,CorWatt,GFXWatt
            Core CPU  Avg_MHz    Busy%     Bzy_MHz   TSC_MHz   CPU%c1    CPU%c3    CPU%c6    CPU%c7
            -    -    488        12.52     3900      3498      12.50     0.00      0.00      74.98
            0    0    5          0.13      3900      3498      99.87     0.00      0.00      0.00
            0    4    3897       99.99     3900      3498      0.01
            1    1    0          0.00      3856      3498      0.01      0.00      0.00      99.98
            1    5    0          0.00      3861      3498      0.01
            2    2    1          0.02      3889      3498      0.03      0.00      0.00      99.95
            2    6    0          0.00      3863      3498      0.05
            3    3    0          0.01      3869      3498      0.02      0.00      0.00      99.97
            3    7    0          0.00      3878      3498      0.03
  • 出于性能考虑,turbostat 以 topology order 运行,这样同属一个 CORE 的两个 hyper-thread 在输出中是相邻的。
  • Busy%C0 状态所占的时间百分比。

Note that cpu4 in this example is 99.99% busy, while the other CPUs are all under 1% busy. Notice that cpu4’s HT sibling is cpu0, which is under 1% busy, but can get into CPU%c1 only, because its cpu4’s activity on shared hardware keeps it from entering a deeper C-state.

5 排查 & 调优案例

5.1 c-state 太深导致网络收发包不及时

详见 Linux 网络栈接收数据(RX):配置调优

5.2 CPU 型号和 tuned 配置都一样,但不同厂商机器的 cstate/freq 不一样

发现在某环境中,同样的 CPU、同样的 tuned profile (cstate) 配置, 不同服务器厂商的机器运行频率差异很大。 以 CPU Intel(R) Xeon(R) Gold 5218 CPU @ 2.30GHz 服务器为例,

Fig. Per-CPU running frequency, same CPU model, but different server vendors

根据 spec

  • base 2.3GHz(晶振频率)
  • max all-core turbo 2.8GHz(所有 CORE 能同时工作在这个频率)
  • max turbo 3.9GHz(只有两个 CORE 能同时工作在这个频率)

接下来看看使用了这款 CPU 的 DELL、INSPUR、H3C 三家厂商的机器有什么配置差异。

5.2.1 tuned-adm:查看 active profile

root@dell-node: $ tuned-adm active
Current active profile: latency-performance

root@dell-node: $ tuned-adm profile_info
Profile name:
latency-performance

Profile summary:
Optimize for deterministic performance at the cost of increased power consumption

Profile description:

三家都是 latency-performance

5.2.2 cpupower:查看各 CPU 实际运行 cstate/freq

根据之前经验,latency-performance 允许的最大 cstate 应该是 C1。 通过 cpupower 看下,

root@dell-node: $ cpupower monitor
              | Nehalem                   || Mperf              || Idle_Stats
 PKG|CORE| CPU| C3   | C6   | PC3  | PC6  || C0   | Cx   | Freq || POLL | C1   | C1E  | C6
   0|   0|   0|  0.00|  0.00|  0.00|  0.00|| 86.19| 13.81|  2776||  0.02| 13.90|  0.00|  0.00
   0|   0|  32|  0.00|  0.00|  0.00|  0.00|| 84.13| 15.87|  2776||  0.01| 15.78|  0.00|  0.00
   0|   1|   4|  0.00|  0.00|  0.00|  0.00|| 11.83| 88.17|  2673||  0.03| 88.74|  0.00|  0.00
...

看着是启用了 POLL~C6 四个 cstate,与预期不符;但这个也有可能是 cpupower 这个工具的显示问题

5.2.3 cpupower idle-info:查看 cstate 配置

通过 idle-info 分别看下三家机器的 cstate 具体配置

root@dell-node: $ cpupower idle-info   | root@inspur-node $ cpupower idle-info  |  root@h3c-node $ cpupower idle-info
CPUidle driver: intel_idle             | CPUidle driver: acpi_idle              |  CPUidle driver: intel_idle
CPUidle governor: menu                 | CPUidle governor: menu                 |  CPUidle governor: menu
                                       |                                        |
Number of idle states: 4               | Number of idle states: 2               |  Number of idle states: 4
Available idle states: POLL C1 C1E C6  | Available idle states: POLL C1         |  Available idle states: POLL C1 C1E C6
                                       |                                        |
POLL:                                  | POLL:                                  |  POLL:
 Flags/Desc: CPUIDLE CORE POLL IDLE    |  Flags/Desc: CPUIDLE CORE POLL IDLE    |   Flags/Description: CPUIDLE CORE POLL IDLE
 Latency: 0                            |  Latency: 0                            |   Latency: 0
 Usage: 59890751                       |  Usage: 0                              |   Usage: 11962614826464
 Duration: 531133564                   |  Duration: 0                           |   Duration: 45675012585533
                                       |                                        |
C1:                                    | C1:                                    |  C1:
 Flags/Description: MWAIT 0x00         |  Flags/Description: ACPI HLT           |   Flags/Description: MWAIT 0x00
 Latency: 2                            |  Latency: 0                            |   Latency: 2
 Usage: 4216191666                     |  Usage: 149457505065                   |   Usage: 3923
 Duration: 828071917480                |  Duration: 30517320966628              |   Duration: 280423
                                       |                                        |
C1E:                                   |                                        |  C1E:
 Flags/Description: MWAIT 0x01         |                                        |   Flags/Description: MWAIT 0x01
 Latency: 10                           |                                        |   Latency: 10
 Usage: 9180                           |                                        |   Usage: 1922
 Duration: 8002008                     |                                        |   Duration: 593202
                                       |                                        |
C6 (DISABLED) :                        |                                        |  C6:
 Flags/Description: MWAIT 0x20         |                                        |   Flags/Description: MWAIT 0x20
 Latency: 92                           |                                        |   Latency: 133
 Usage: 0                              |                                        |   Usage: 10774
 Duration: 0                           |                                        |   Duration: 123049218

可以看到,

  1. DELL

    • profile 中虽然有 C6,但是禁用了;也说明 cpupower monitor 的输出有时不可靠
    • C1E 会用到(虽然比例很少),它的唤醒延迟是 C1 的 5倍
    • 绝大部分时间工作在 POLL/C1。
  2. INSPUR:只允许 POLL/C1;

    • 全部 idle 时间工作在 C1,没有 POLL?
  3. H3C:允许 POLL/C1/C1E/C6;

    • 绝大部分时间工作在 C0,然后是 C6,然后是 C1E 和 C1。
    • C1E 和 C6 都会用到,唤醒延迟分别是 C1 的 566.5 倍。

5.2.4 结论

Server vendor cpuidle driver tuned profile Enabled cstates
DELL (戴尔) intel_idle latency-performance POLL/C1/C1E
INSPUR (浪潮) acpi_idle latency-performance POLL/C1
H3C (华三) intel_idle latency-performance POLL/C1/C1E/C6
  1. 同样的 tuned profile,不同厂商的机器,对应的 cstate 不完全一样(应该是厂商在 BIOS 里面设置的 mapping);

    另外,运行在每个 cstate 的总时间,可以在 cpupower idle-info 的输出里看到。

  2. 不同厂商设置的 max freq 可能不一样,比如 DELL 设置到了 3.9G(max turbo), 其他两家设置到了 2.8G(all-core turbo),上面监控可以看出来;下面这个图是最大频率和能运行在这个频率的 CORE 数量的对应关系:

  3. 引入新型号 CPU/node 时,建议查看 cpupower idle-info确保启用的 cstates 列表与预期一致, 例如不要 enable 唤醒时间过大的 cstate;这个跟 BIOS 配置相关;
  4. 如果要避免厂商 BIOS 差异导致的 cstate 问题,可以在 grub 里面配置 max cstate 等内核参数。

参考资料

  1. Controlling Processor C-State Usage in Linux, A Dell technical white paper describing the use of C-states with Linux operating systems, 2013
  2. Linux 网络栈接收数据(RX):配置调优
  3. C-state tuning guide opensuse.org

Written by Human, Not by AI Written by Human, Not by AI

Linux 服务器功耗与性能管理(三):cpuidle 子系统的实现(2024)

2024年2月15日 08:00

整理一些 Linux 服务器性能相关的 CPU 硬件基础及内核子系统知识。

水平有限,文中不免有错误或过时之处,请酌情参考。



前两篇是理论,这一篇看一下内核代码:idle task 及 cpuidle 子系统的实现。

内核代码中涉及到“空闲状态”用的都是 “idle state” 术语,它基本对应于上一篇我们所讲的 c-state, 本文可能会交替使用这两个术语。

1 结构体

1.1 struct cpuidle_state

表示 CPU 空闲状态的结构体,即 Linux 中的 c-state 表示,

// include/linux/cpuidle.h

struct cpuidle_state {
    ...
    s64             exit_latency_ns;
    s64             target_residency_ns;
    unsigned int    exit_latency;     /* in US */
    unsigned int    target_residency; /* in US */

    unsigned int    flags;
    int             power_usage; /* in mW */

    int (*enter)    (struct cpuidle_device *dev, struct cpuidle_driver *drv, int index);
};

为了理解方便,移动了几个字段的顺序。 下面看几个重要字段和方法。

1.1.1 exit_latency/target_residency

有两套,单位分别是 usns

  • exit_latency:返回到 fully functional state 所需的时间;
  • target_residency:处理器进入这个空闲状态之后,所应该停留的最短时间。

这两个参数说明:进入和离开每个状态也是有开销的,如果停留时间小于某个阈值就不划算,那种情况下就没必要进入这个状态了。

1.1.2 power_usage:这个状态的功耗

CPU 在这个状态下的功耗

1.1.3 flags

定义一些比特位特性,

/* Idle State Flags */
#define CPUIDLE_FLAG_NONE           (0x00)
#define CPUIDLE_FLAG_POLLING        BIT(0) /* polling state */
#define CPUIDLE_FLAG_COUPLED        BIT(1) /* state applies to multiple cpus */
#define CPUIDLE_FLAG_TIMER_STOP     BIT(2) /* timer is stopped on this state */
#define CPUIDLE_FLAG_UNUSABLE        BIT(3) /* avoid using this state */
#define CPUIDLE_FLAG_OFF        BIT(4) /* disable this state by default */
#define CPUIDLE_FLAG_TLB_FLUSHED    BIT(5) /* idle-state flushes TLBs */
#define CPUIDLE_FLAG_RCU_IDLE        BIT(6) /* idle-state takes care of RCU */

1.1.5 enter() 方法

enter(struct cpuidle_device *dev, struct cpuidle_driver *drv, int index) 由各 idle driver 实现,后面会看到。

执行该方法会进入这个状态,需要传 CPU 设备、idle driver、state index 三个参数。

1.2 struct cpuidle_governor

// include/linux/cpuidle.h

struct cpuidle_governor {
    char                 name[CPUIDLE_NAME_LEN];
    struct list_head     governor_list;
    unsigned int         rating; // the governor's idea of how useful it is. By default, the kernel will use
                                 // the governor with the highest rating value, but the system administrator can override that choice
    int  (*select)       (struct cpuidle_driver *drv, struct cpuidle_device *dev, bool *stop_tick);
    void (*reflect)      (struct cpuidle_device *dev, int index);
};

1.2.1 select()

最重要的方法,governor 根据自己的判断,包括

  1. 定时器事件
  2. 预测的 sleep 时长、idle 时长等
  3. PM QoS latency requirements

等等,选出它认为最合适的一个 idle 状态

1.2.2 reflect()

CPU 退出这个 idle 状态时执行,governor 根据里面的 timing 信息 反思(reflect)决策的好坏

1.3 struct cpuidle_driver

struct cpuidle_driver {
    ...
    const char             *name;
    struct module          *owner;

    struct cpuidle_state    states[CPUIDLE_STATE_MAX]; /* must be ordered in decreasing power consumption */
    int                     state_count;
    int                     safe_state_index;

    struct cpumask         *cpumask; /* the driver handles the cpus in cpumask */
    const char             *governor;/* preferred governor to switch at register time */
};

1.3.1 states[]:该驱动支持的 idle states 列表

根据功耗降序排列。

1.3.2 cpuidle_register_driver():注册 idle driver

通过下面的方法注册 driver:

int cpuidle_register_driver(struct cpuidle_driver *drv);

查看有哪些地方会注册:

$ grep -R "cpuidle_register_driver" *
arch/x86/kernel/apm_32.c:               if (!cpuidle_register_driver(&apm_idle_driver))
drivers/acpi/processor_idle.c:                  retval = cpuidle_register_driver(&acpi_idle_driver);
drivers/cpuidle/cpuidle.c:      ret = cpuidle_register_driver(drv);
drivers/cpuidle/driver.c:EXPORT_SYMBOL_GPL(cpuidle_register_driver);
drivers/cpuidle/cpuidle-haltpoll.c:     ret = cpuidle_register_driver(drv);
drivers/cpuidle/cpuidle-cps.c:  err = cpuidle_register_driver(&cps_driver);
drivers/idle/intel_idle.c:      retval = cpuidle_register_driver(&intel_idle_driver);
...

1.4 struct cpuidle_device

每个 CPU 对应:

struct cpuidle_device {
    unsigned int        registered:1;
    unsigned int        enabled:1;
    unsigned int        poll_time_limit:1;
    unsigned int        cpu;
    ktime_t            next_hrtimer;

    int            last_state_idx;
    u64            last_residency_ns;
    u64            poll_limit_ns;
    u64            forced_idle_latency_limit_ns;
    struct cpuidle_state_usage    states_usage[CPUIDLE_STATE_MAX];
    struct cpuidle_state_kobj *kobjs[CPUIDLE_STATE_MAX];
    struct cpuidle_driver_kobj *kobj_driver;
    struct cpuidle_device_kobj *kobj_dev;
    struct list_head     device_list;

    cpumask_t        coupled_cpus;
    struct cpuidle_coupled    *coupled;
};

2 cpuidle governors 注册

这里就看一个最常用的:menu governor。

2.1 menu governor 注册

2.1.1 注册

// drivers/cpuidle/governors/menu.c

static struct cpuidle_governor menu_governor = {
    .name    =    "menu",
    .rating  =    20,
    .select  =    menu_select,
    .reflect =    menu_reflect,
};

static int __init init_menu(void) {
    return cpuidle_register_governor(&menu_governor);
}

postcore_initcall(init_menu);

接下来看看它的 select/reflect 方法实现。

2.1.2 select() 方法

// menu_select - selects the next idle state to enter
// @drv: cpuidle driver containing state data
// @dev: the CPU
// @stop_tick: indication on whether or not to stop the tick
static int menu_select(struct cpuidle_driver *drv, struct cpuidle_device *dev, bool *stop_tick) {
    struct menu_device *data = this_cpu_ptr(&menu_devices);
    s64 latency_req = cpuidle_governor_latency_req(dev->cpu);

    /* determine the expected residency time, round up */
    delta = tick_nohz_get_sleep_length(&delta_tick);
    data->next_timer_ns = delta;

    /* Use the lowest expected idle interval to pick the idle state. */
    predicted_ns = ...;

    // Find the idle state with the lowest power while satisfying our constraints.
    for (i = 0; i < drv->state_count; i++) {
        struct cpuidle_state *s = &drv->states[i];
        if (s->target_residency_ns > predicted_ns) {
            // Use a physical idle state, not busy polling, unless a timer is going to trigger soon enough.
            if ((drv->states[idx].flags & CPUIDLE_FLAG_POLLING) && s->exit_latency_ns <= latency_req && s->target_residency_ns <= data->next_timer_ns) {
                predicted_ns = s->target_residency_ns;
                idx = i;
                break;
            }
            if (predicted_ns < TICK_NSEC)
                break;

            if (!tick_nohz_tick_stopped()) {
                // If the state selected so far is shallow, waking up early won't hurt, so retain the
                // tick in that case and let the governor run again in the next iteration of the loop.
                predicted_ns = drv->states[idx].target_residency_ns;
                break;
            }

            // If the state selected so far is shallow and this state's target residency matches the time till the
            // closest timer event, select this one to avoid getting stuck in the shallow one for too long.
            if (drv->states[idx].target_residency_ns < TICK_NSEC && s->target_residency_ns <= delta_tick)
                idx = i;

            return idx;
        }
        if (s->exit_latency_ns > latency_req)
            break;

        idx = i;
    }

    // Don't stop the tick if the selected state is a polling one or if the
    // expected idle duration is shorter than the tick period length.
    if (((drv->states[idx].flags & CPUIDLE_FLAG_POLLING) || predicted_ns < TICK_NSEC) && !tick_nohz_tick_stopped()) {
        *stop_tick = false;

        if (idx > 0 && drv->states[idx].target_residency_ns > delta_tick) {
            // The tick is not going to be stopped and the target residency of the state to be returned is not within
            // the time until the next timer event including the tick, so try to correct that.
            for (i = idx - 1; i >= 0; i--) {
                idx = i;
                if (drv->states[i].target_residency_ns <= delta_tick)
                    break;
            }
        }
    }

    return idx;
}

2.1.3 reflect() 方法

略。

3 cpuidle drivers 注册

3.1 haltpoll driver:haltpoll governor 的 driver

// drivers/cpuidle/cpuidle-haltpoll.c

static struct cpuidle_driver haltpoll_driver = {
    .name = "haltpoll",
    .governor = "haltpoll",
    .states = {
        { /* entry 0 is for polling */ },
        {
            .enter            = default_enter_idle,
            .exit_latency     = 1,
            .target_residency = 1,
            .power_usage      = -1,
            .name            = "haltpoll idle",
            .desc            = "default architecture idle",
        },
    },
    .safe_state_index = 0,
    .state_count = 2,
};

states 是一个数组,存放了按功耗降序排列的、这个 driver 支持的 c-states, 可以看到,

  • 第一个状态是给 polling 保留的,对应 c0 状态,它的功耗也确实是最大的;
  • 第二个状态才是 haltpoll idle 状态,对应 c1 状态;
  • 没有功耗更低的 c2/c3/… 等状态,

3.1.1 注册

static int __init haltpoll_init(void) {
    struct cpuidle_driver *drv = &haltpoll_driver;

    cpuidle_poll_state_init(drv);
    cpuidle_register_driver(drv); // register driver
    haltpoll_cpuidle_devices = alloc_percpu(struct cpuidle_device);

    ret = cpuhp_setup_state(CPUHP_AP_ONLINE_DYN, "cpuidle/haltpoll:online", haltpoll_cpu_online, haltpoll_cpu_offline);
    haltpoll_hp_state = ret;
}

3.1.2 enter():调用 hlt 指令让 CPU 进入休眠状态

static int default_enter_idle(struct cpuidle_device *dev, struct cpuidle_driver *drv, int index) {
    if (current_clr_polling_and_test()) {
        local_irq_enable();
        return index;
    }
    default_idle();
    return index;
}

接下来的 x86 架构下的调用栈

default_enter_idle
  |-default_idle
     |-__cpuidle default_idle(void) // arch/x86/kernel/process.c
        |-raw_safe_halt()           // include/linux/irqflags.h
           |-raw_safe_halt()        // arch/x86/include/asm/irqflags.h
              |-native_safe_halt();
                 |-asm volatile("sti; hlt": : :"memory");

可以看到最后就是通过内联汇编执行一条指令 sti; htl,使处理器进入休眠模式, 直到下一个外部中断到来。

In the x86 computer architecture, HLT (halt) is an assembly language instruction which halts the central processing unit (CPU) until the next external interrupt is fired.

https://en.wikipedia.org/wiki/HLT_(x86_instruction)

3.1.3 事实上禁用了 cpuidle 子系统

cpuidle 的价值就是在多个 idle state 之间选一个最合适的。 对于 idle=haltpoll,因为只有一个低功耗状态 c1,没什么可选的,所以 cpuidle 子系统是不起作用的。

3.2 acpi_idle driver

上一篇看到,AMD CPU 在很多情况下用的是这个 driver。

ACPI (Advanced Configuration and Power Interface) 是一个厂商无关的高级配置和功耗管理规范, 将底层硬件以及功能上报给内核,与底层硬件的通信方式是 firmware (UEFI 或 BIOS)。

3.2.1 注册

这个 driver 的注册比较特殊,不像其他的 driver 那样静态初始化各种字段,而是根据一些条件, 在后面动态初始化,比如用哪个 enter() 方法。

// drivers/acpi/processor_idle.c

// governor/enter() 等等,都需要在后面动态初始化
struct cpuidle_driver acpi_idle_driver = {
    .name =     "acpi_idle",
    .owner =    THIS_MODULE,
};

// prepares and configures cpuidle global state data i.e. idle routines
static int acpi_processor_setup_cpuidle_states(struct acpi_processor *pr) {
    struct cpuidle_driver *drv = &acpi_idle_driver;

    if (pr->flags.has_lpi)
        return acpi_processor_setup_lpi_states(pr);

    return acpi_processor_setup_cstates(pr);
}

LPI (Low Power Idle) 模式

static int acpi_processor_setup_lpi_states(struct acpi_processor *pr) {
    struct acpi_lpi_state *lpi;
    struct cpuidle_state *state;
    struct cpuidle_driver *drv = &acpi_idle_driver;

    for (i = 0; i < pr->power.count && i < CPUIDLE_STATE_MAX; i++) {
        lpi = &pr->power.lpi_states[i];

        state = &drv->states[i];
        snprintf(state->name, CPUIDLE_NAME_LEN, "LPI-%d", i);
        strlcpy(state->desc, lpi->desc, CPUIDLE_DESC_LEN);
        state->exit_latency = lpi->wake_latency;
        state->target_residency = lpi->min_residency;
        if (lpi->arch_flags)
            state->flags |= CPUIDLE_FLAG_TIMER_STOP;
        state->enter = acpi_idle_lpi_enter;
        drv->safe_state_index = i;
    }

    drv->state_count = i;
    return 0;
}

注册的 enter() 方法是 acpi_idle_lpi_enter()

普通模式

static int acpi_processor_setup_cstates(struct acpi_processor *pr) {
    struct acpi_processor_cx *cx;
    struct cpuidle_state *state;
    struct cpuidle_driver *drv = &acpi_idle_driver;

    if (max_cstate == 0)
        max_cstate = 1;

    if (IS_ENABLED(CONFIG_ARCH_HAS_CPU_RELAX)) {
        cpuidle_poll_state_init(drv);
        count = 1;
    } else {
        count = 0;
    }

    for (i = 1; i < ACPI_PROCESSOR_MAX_POWER && i <= max_cstate; i++) {
        cx = &pr->power.states[i];

        if (!cx->valid)
            continue;

        state = &drv->states[count];
        snprintf(state->name, CPUIDLE_NAME_LEN, "C%d", i);
        strlcpy(state->desc, cx->desc, CPUIDLE_DESC_LEN);
        state->exit_latency = cx->latency;
        state->target_residency = cx->latency * latency_factor;
        state->enter = acpi_idle_enter;

        state->flags = 0;
        if (cx->type == ACPI_STATE_C1 || cx->type == ACPI_STATE_C2) {
            drv->safe_state_index = count;
        }

        count++;
        if (count == CPUIDLE_STATE_MAX)
            break;
    }

    drv->state_count = count;

    if (!count)
        return -EINVAL;

    return 0;
}

注册的 enter() 方法是 acpi_idle_enter()

3.2.2 enter() 方法

普通模式

static int acpi_idle_enter(struct cpuidle_device *dev, struct cpuidle_driver *drv, int index) {
    struct acpi_processor_cx *cx = per_cpu(acpi_cstate[index], dev->cpu);
    struct acpi_processor *pr;

    pr = __this_cpu_read(processors);
    if (unlikely(!pr))
        return -EINVAL;

    if (cx->type != ACPI_STATE_C1) {
        if (cx->type == ACPI_STATE_C3 && pr->flags.bm_check)
            return acpi_idle_enter_bm(drv, pr, cx, index);

        /* C2 to C1 demotion. */
        if (acpi_idle_fallback_to_c1(pr) && num_online_cpus() > 1) {
            index = ACPI_IDLE_STATE_START;
            cx = per_cpu(acpi_cstate[index], dev->cpu);
        }
    }

    if (cx->type == ACPI_STATE_C3)
        ACPI_FLUSH_CPU_CACHE();

    acpi_idle_do_entry(cx);

    return index;
}

// acpi_idle_do_entry - enter idle state using the appropriate method
// @cx: cstate data
//
// Caller disables interrupt before call and enables interrupt after return.
static void __cpuidle acpi_idle_do_entry(struct acpi_processor_cx *cx) {
    if (cx->entry_method == ACPI_CSTATE_FFH) {
        /* Call into architectural FFH based C-state */
        acpi_processor_ffh_cstate_enter(cx);
    } else if (cx->entry_method == ACPI_CSTATE_HALT) {
        acpi_safe_halt();
    } else {
        /* IO port based C-state */
        inb(cx->address);
        wait_for_freeze();
    }
}

// Callers should disable interrupts before the call and enable interrupts after return.
static void __cpuidle acpi_safe_halt(void) {
    if (!tif_need_resched()) {
        safe_halt();
        local_irq_disable();
    }
}

#define safe_halt()                \
    do {                    \
        trace_hardirqs_on();        \
        raw_safe_halt();        \
    } while (0)

LPI (Low Power Idle) 模式

/**
 * acpi_idle_lpi_enter - enters an ACPI any LPI state
 * @dev: the target CPU
 * @drv: cpuidle driver containing cpuidle state info
 * @index: index of target state
 *
 * Return: 0 for success or negative value for error
 */
static int acpi_idle_lpi_enter(struct cpuidle_device *dev, struct cpuidle_driver *drv, int index) {
    struct acpi_processor *pr;
    struct acpi_lpi_state *lpi;

    pr = __this_cpu_read(processors);

    lpi = &pr->power.lpi_states[index];
    if (lpi->entry_method == ACPI_CSTATE_FFH)
        return acpi_processor_ffh_lpi_enter(lpi);

    return -EINVAL;
}

3.3 intel_idle driver

Intel 的 c-states 多到吐了,注册列表见 drivers/idle/intel_idle.c

Intel CPU node 并不一定就用这个 driver,也可能用 acpi_idle,取决于用户自己的配置。 比如同一服务器厂商不同批次的机器,配置可能就不一样。

下面挑一个 idle state 来看看。

3.3.1 举例 nehalem idle states

// States are indexed by the cstate number, which is also the index into the MWAIT hint array.
// Thus C0 is a dummy.
static struct cpuidle_state nehalem_cstates[] __initdata = {
    {
        .name = "C1",
        .desc = "MWAIT 0x00",
        .flags = MWAIT2flg(0x00),
        .exit_latency = 3,
        .target_residency = 6,
        .enter = &intel_idle,
        .enter_s2idle = intel_idle_s2idle, },
    {
        .name = "C1E",
        .desc = "MWAIT 0x01",
        .flags = MWAIT2flg(0x01) | CPUIDLE_FLAG_ALWAYS_ENABLE,
        .exit_latency = 10,
        .target_residency = 20,
        .enter = &intel_idle,
        .enter_s2idle = intel_idle_s2idle, },
    {
        .name = "C3",
        .desc = "MWAIT 0x10",
        .flags = MWAIT2flg(0x10) | CPUIDLE_FLAG_TLB_FLUSHED,
        .exit_latency = 20,
        .target_residency = 80,
        .enter = &intel_idle,
        .enter_s2idle = intel_idle_s2idle, },
    {
        .name = "C6",
        .desc = "MWAIT 0x20",
        .flags = MWAIT2flg(0x20) | CPUIDLE_FLAG_TLB_FLUSHED,
        .exit_latency = 200,
        .target_residency = 800,
        .enter = &intel_idle,
        .enter_s2idle = intel_idle_s2idle, },
    {
        .enter = NULL }
};

这个 c-state 列表注册了 4 个状态,相同点:

  • enter 函数相同,都是由 intel_idle 来处理状态进入;对我们来说是好事,只需要看一个函数就行了。

不同点:

  • name:对应 intel 定义的 cstate,从 C1C6 不等;
  • flags:比如前两个是浅睡眠;后两个是深度睡眠状态,再唤醒时会冲掉 TLB 缓存
  • 延迟不同

    • exit_latency3~200us,差了 70 倍;
    • target_residency6~800us,差了 100 多倍。

这些状态会显示在 cpupower 里面:

# On an intel-cpu node
$ cpupower monitor
              | Nehalem                   || SandyBridge        || Mperf              || Idle_Stats
 PKG|CORE| CPU| C3   | C6   | PC3  | PC6   || C7   | PC2  | PC7   || C0   | Cx   | Freq  || POLL | C1
   0|   0|   0|  0.00|  0.00|  0.00|  0.00||  0.00|  0.00|  0.00||  1.83| 98.17|  2493||  0.00| 99.81
   0|   0|  24|  0.00|  0.00|  0.00|  0.00||  0.00|  0.00|  0.00||  1.72| 98.28|  2494||  0.00| 99.82
  ...

3.3.2 enter() 方法:intel_idle()

/**
 * intel_idle - Ask the processor to enter the given idle state.
 * @dev: cpuidle device of the target CPU.
 * @drv: cpuidle driver (assumed to point to intel_idle_driver).
 * @index: Target idle state index.
 *
 * Use the MWAIT instruction to notify the processor that the CPU represented by
 * @dev is idle and it can try to enter the idle state corresponding to @index.
 *
 * If the local APIC timer is not known to be reliable in the target idle state,
 * enable one-shot tick broadcasting for the target CPU before executing MWAIT.
 *
 * Optionally call leave_mm() for the target CPU upfront to avoid wakeups due to flushing user TLBs.
 */
static __cpuidle int intel_idle(struct cpuidle_device *dev, struct cpuidle_driver *drv, int index) {
    struct cpuidle_state *state = &drv->states[index];
    unsigned long eax = flg2MWAIT(state->flags);
    unsigned long ecx = 1; /* break on interrupt flag */

    mwait_idle_with_hints(eax, ecx);
    return index;
}

/*
 * MWAIT takes an 8-bit "hint" in EAX "suggesting"
 * the C-state (top nibble) and sub-state (bottom nibble)
 * 0x00 means "MWAIT(C1)", 0x10 means "MWAIT(C2)" etc.
 *
 * We store the hint at the top of our "flags" for each state.
 */
#define flg2MWAIT(flags) (((flags) >> 24) & 0xFF)

/*
 * This uses new MONITOR/MWAIT instructions on P4 processors with PNI,
 * which can obviate IPI to trigger checking of need_resched.
 * We execute MONITOR against need_resched and enter optimized wait state
 * through MWAIT. Whenever someone changes need_resched, we would be woken
 * up from MWAIT (without an IPI).
 *
 * New with Core Duo processors, MWAIT can take some hints based on CPU capability.
 */
static inline void mwait_idle_with_hints(unsigned long eax, unsigned long ecx) {
    if (static_cpu_has_bug(X86_BUG_MONITOR) || !current_set_polling_and_test()) {
        if (static_cpu_has_bug(X86_BUG_CLFLUSH_MONITOR)) {
            mb();
            clflush((void *)&current_thread_info()->flags);
            mb();
        }

        __monitor((void *)&current_thread_info()->flags, 0, 0);
        if (!need_resched())
            __mwait(eax, ecx);
    }
    current_clr_polling();
}

3.3.3 intel_idle 和 acpi_idle 的先后关系

intel_idle 不依赖 firmware/BIOS 就能有足够的信息来控制 c-states。 这个 driver 基本上会忽略 BIOS 设置和内核启动参数。如果你想自己控制 c-states, 就用 intel_idle.max_cstate=0 来禁用这个 driver。

禁用 intel_idle driver 之后,内核就会用 acpi_idle 来控制 C-states。 系统固件(BIOS)会通过 ACPI table 向内核提供一个可用的 c-states 列表。 用户可以通过 BIOS 设置来修改这个 c-states table。

Disabling C-states in this way will typically result in Linux using the C1 state for idle processors, which is fairly fast. If BIOS doesn’t allow C-states to be disabled, C-states can also be limited to C1 with the kernel parameter “idle=halt” (kernel parameter “idle=halt” should automatically disable cpuidle, including intel_idle, in newer kernels).

Controlling Processor C-State Usage in Linux, A Dell technical white paper describing the use of C-states with Linux operating systems, 2013

4 idle task:进入 c-state 的过程

接下来看一下 Linux 切 c-state 的代码流程。 较新版本的内核,idle task 对应的是 do_idle() 函数,我们从这里开始。

4.1 idle task: do_idle()

//  https://github.com/torvalds/linux/blob/v5.15/kernel/sched/idle.c#L261

// Generic idle loop implementation. Called with polling cleared.
static void do_idle(void) {
    int cpu = smp_processor_id();
    nohz_run_idle_balance(cpu); // Check if we need to update blocked load

    // If the arch has a polling bit, we maintain an invariant:
    //
    // Our polling bit is clear if we're not scheduled (i.e. if rq->curr != rq->idle). This means that, 
    // if rq->idle has the polling bit set, then setting need_resched is guaranteed to cause the CPU to reschedule.
    __current_set_polling();
 +- tick_nohz_idle_enter();
 |
 |  while (!need_resched()) {
 |      local_irq_disable();
 |   +- arch_cpu_idle_enter();
 |   |
 |   |  // In poll mode we reenable interrupts and spin. Also if we detected in the wakeup from idle path that the tick
 |   |  // broadcast device expired for us, we don't want to go deep idle as we know that the IPI is going to arrive right away.
 |   |  if (cpu_idle_force_poll || tick_check_broadcast_expired()) {
 |   |      tick_nohz_idle_restart_tick();
 |   |      cpu_idle_poll();
 |   |  } else {
 |   |      cpuidle_idle_call(); // --> CALLING INTO cpuidle subsystem, governor+driver
 |   |  }
 |   +- arch_cpu_idle_exit();
 |  }
 |
 |  // Since we fell out of the loop above, we know TIF_NEED_RESCHED must be set, propagate it into PREEMPT_NEED_RESCHED.
 |  // This is required because for polling idle loops we will not have had an IPI to fold the state for us.
 |  preempt_set_need_resched();
 +- tick_nohz_idle_exit();
    __current_clr_polling();

    schedule_idle();
}

里面调用到 cpuidle_idle_call(),进入 cpuidle 子系统。

4.2 do_idle() -> cpuidle_idle_call() -> call_cpuidle(driver)

/**
 * cpuidle_idle_call - the main idle function
 *
 * On architectures that support TIF_POLLING_NRFLAG, is called with polling
 * set, and it returns with polling set.  If it ever stops polling, it must clear the polling bit.
 */
static void cpuidle_idle_call(void) {
    struct cpuidle_device *dev = cpuidle_get_device();
    struct cpuidle_driver *drv = cpuidle_get_cpu_driver(dev);

    if (idle_should_enter_s2idle() || dev->forced_idle_latency_limit_ns) {
        ...
    } else {
        // Ask the cpuidle framework to choose a convenient idle state.
        bool stop_tick = true;
        next_state = cpuidle_select(drv, dev, &stop_tick);

        if (stop_tick || tick_nohz_tick_stopped())
            tick_nohz_idle_stop_tick();
        else
            tick_nohz_idle_retain_tick();

        entered_state = call_cpuidle(drv, dev, next_state);
        cpuidle_reflect(dev, entered_state); // Give the governor an opportunity to reflect on the outcome
    }

exit_idle:
    __current_set_polling();
}

idle loop 每次执行时,主要做两件事情。

4.2.1 cpuidle_select(drv, dev, &stop_tick)选择 c-state

调用 governor找到最适合当前条件的 idle state

这个过程在上一节介绍 governor enter() 方法是时候已经大致看过了, 接下来看 state 选好之后,如何切换到这个状态。

4.2.2 call_cpuidle(drv, dev, next_state):要求处理器进入 c-state

调用 driver,要求 processor hardware 进入选择的 idle state

接下来看进入这个 idle state 的调用链路。

4.3 call_cpuidle(driver) -> cpuidle_enter(drv, dev, next_state)

static int call_cpuidle(struct cpuidle_driver *drv, struct cpuidle_device *dev, int next_state) {
    // This function will block until an interrupt occurs and will take care of re-enabling the local interrupts
    return cpuidle_enter(drv, dev, next_state);
}

4.4 cpuidle_enter(drv, dev, next_state) -> cpuidle_enter_state(dev, drv, index)

// drivers/cpuidle/cpuidle.c

/**
 * cpuidle_enter - enter into the specified idle state
 *
 * @drv:   the cpuidle driver tied with the cpu
 * @dev:   the cpuidle device
 * @index: the index in the idle state table
 *
 * Returns the index in the idle state, < 0 in case of error.
 * The error code depends on the backend driver
 */
int cpuidle_enter(struct cpuidle_driver *drv, struct cpuidle_device *dev, int index) {
    /*
     * Store the next hrtimer, which becomes either next tick or the next
     * timer event, whatever expires first. Additionally, to make this data
     * useful for consumers outside cpuidle, we rely on that the governor's
     * ->select() callback have decided, whether to stop the tick or not.
     */
    WRITE_ONCE(dev->next_hrtimer, tick_nohz_get_next_hrtimer());

    if (cpuidle_state_is_coupled(drv, index))
        ret = cpuidle_enter_state_coupled(dev, drv, index);
    else
        ret = cpuidle_enter_state(dev, drv, index);

    WRITE_ONCE(dev->next_hrtimer, 0);
    return ret;
}

4.5 cpuidle_enter_state(dev, drv, index) -> target_state->enter()

// drivers/cpuidle/cpuidle.c

/**
 * cpuidle_enter_state - enter the state and update stats
 * @dev: cpuidle device for this cpu
 * @drv: cpuidle driver for this cpu
 * @index: index into the states table in @drv of the state to enter
 */
int cpuidle_enter_state(struct cpuidle_device *dev, struct cpuidle_driver *drv, int index) {
    struct cpuidle_state *target_state = &drv->states[index];
    bool broadcast = !!(target_state->flags & CPUIDLE_FLAG_TIMER_STOP);

    /*
     * Tell the time framework to switch to a broadcast timer because our
     * local timer will be shut down.  If a local timer is used from another
     * CPU as a broadcast timer, this call may fail if it is not available.
     */
    if (broadcast && tick_broadcast_enter()) {
        index = find_deepest_state(drv, dev, target_state->exit_latency_ns, CPUIDLE_FLAG_TIMER_STOP, false);
        if (index < 0) {
            default_idle_call();
            return -EBUSY;
        }
        target_state = &drv->states[index];
        broadcast = false;
    }

    if (target_state->flags & CPUIDLE_FLAG_TLB_FLUSHED)
        leave_mm(dev->cpu);

    sched_idle_set_state(target_state); /* Take note of the planned idle state. */

    time_start = ns_to_ktime(local_clock());
    int entered_state = target_state->enter(dev, drv, index);
    sched_clock_idle_wakeup_event();
    time_end = ns_to_ktime(local_clock());

    sched_idle_set_state(NULL); /* The cpu is no longer idle or about to enter idle. */

    if (broadcast) {
        if (WARN_ON_ONCE(!irqs_disabled()))
            local_irq_disable();

        tick_broadcast_exit();
    }

    if (!cpuidle_state_is_coupled(drv, index))
        local_irq_enable();

    if (entered_state >= 0) {
        s64 diff, delay = drv->states[entered_state].exit_latency_ns;

        // Update cpuidle counters
        diff = ktime_sub(time_end, time_start);
        dev->last_residency_ns = diff;
        dev->states_usage[entered_state].time_ns += diff;
        dev->states_usage[entered_state].usage++;

        if (diff < drv->states[entered_state].target_residency_ns) {
            for (i = entered_state - 1; i >= 0; i--) {
                if (dev->states_usage[i].disable)
                    continue;

                dev->states_usage[entered_state].above++; /* Shallower states are enabled, so update. */
                break;
            }
        } else if (diff > delay) {
            for (i = entered_state + 1; i < drv->state_count; i++) {
                if (dev->states_usage[i].disable)
                    continue;

                // Update if a deeper state would have been a better match for the observed idle duration.
                if (diff - delay >= drv->states[i].target_residency_ns)
                    dev->states_usage[entered_state].below++;

                break;
            }
        }
    } else {
        dev->last_residency_ns = 0;
        dev->states_usage[index].rejected++;
    }

    return entered_state;
}

这一步会调用 c-state 的 enter() 方法, 也就是我们上一节 driver 注册部分看到的,比如,

  • htl_idle
  • acpi_idle
  • intel_idle

4.6 idle state enter() 回调方法:以 hltpoll c1 state 为例

// https://github.com/torvalds/linux/blob/v5.15/arch/x86/include/asm/irqflags.h#L54

static inline __cpuidle void native_halt(void) {
    mds_idle_clear_cpu_buffers();
    asm volatile("hlt": : :"memory");
}

这条指令会让 CPU 进入 halted 状态,直到下一个中断事件到来。

5 快速确认调用路径:跟踪内核调用栈

代码中有很多分支,有时候想确定在特定机器(归根结底是特定配置)上走的是哪个逻辑。 这里简单介绍两种 trace 工具,可以比较快的确定。

5.1 bpftrace

模糊搜索可 trace 的内核函数,

$ bpftrace -l '*cpuidle*' # 或 bpftrace -l | grep cpuidle
...

跟着某个内核函数,看有没有调用到这里,并打印调用到这个函数时前面的调用栈:

$ bpftrace -e 'kprobe:cpuidle_enter_state {printf("%s\n",kstack);}'
        cpuidle_enter_state+1
        cpuidle_enter+41
        cpuidle_idle_call+300
        do_idle+123
        cpu_startup_entry+25
        secondary_startup_64_no_verify+194

$ bpftrace -e 'kprobe:intel_idle {printf("%s\n",kstack);}'
        intel_idle+1
        cpuidle_enter_state+137
        cpuidle_enter+41
        cpuidle_idle_call+300
        do_idle+123
        cpu_startup_entry+25
        secondary_startup_64_no_verify+194

更多使用方式可参考 Linux tracing/profiling 基础:符号表、调用栈、perf/bpftrace 示例等(2022)

5.2 trace-cmd

老牌 trace 工具,功能跟 bpftrace 类似,使用方式跟 perf 有点类似,

$ trace-cmd record -l 'cpuidle_enter_state' -p function_graph
$ trace-cmd report
          ...
          <idle>-0     [007] 699714.113701: funcgraph_entry:                   |  cpuidle_enter_state() {
          <idle>-0     [002] 699714.113703: funcgraph_entry:                   |  cpuidle_enter_state() {

参考资料

  1. The cpuidle subsystem, lwn.net, 2013
  2. Linux tracing/profiling 基础:符号表、调用栈、perf/bpftrace 示例等(2022)

Written by Human, Not by AI Written by Human, Not by AI

Linux 服务器功耗与性能管理(二):几个内核子系统的设计(2024)

2024年2月15日 08:00

整理一些 Linux 服务器性能相关的 CPU 硬件基础及内核子系统知识。

水平有限,文中不免有错误或过时之处,请酌情参考。



1 CPU 相关的内核子系统

1.1 调度器:时分复用 + 任务调度 —— sched

如下图所示,内核给每个 CPU 创建一个任务队列或称运行队列(run queue), 根据算法(例如 CFS)将 runnable 任务依次放到 CPU 上执行,这个过程就称为调度。

Linux kernel scheduler: CFS Image source

本质上就是一个时分复用系统。

更多信息:Linux CFS 调度器:原理、设计与内核实现(2023)

1.3 有任务:用哪个频率执行任务?—— cpufreq

CPU 有任务需要执行时,该以哪个频率/电压来执行呢? 这里就需要一个管理组件,它的主要功能就是管理 CPU 执行任务时所用的频率/电压, 回忆上一篇,这个功能其实就是为 CPU 选择一个合适的 p-state

Linux 内核中,对应的就是 cpufreq 子系统。

1.4 无任务:执行轻量级占坑程序 —— idle task

事实证明,什么都不做,比大家想象中要复杂地多 (Doing nothing, it turns out, is more complicated than one might think) [5].

如果 run queue 中没有 runnable tasks,CPU 无事可做,内核调度器该做什么?

  • 从原理来说,非常简单。产品经理:什么都不做
  • 从实现来说,非常模糊。程序员:“什么都不做”的代码怎么写?
  • 开发 leader 理解一下需求,从中翻译一下:

    • 先保证一个目标:有任务变成 runnable 时(比如等到了键盘输入),能够恢复调度执行 —— 这决定了内核不能完全退出,比如至少要能主动或被动的响应系统事件;
    • 在保证以上目标的前提下,内核做的事情越少越好 —— 节能减排,延迟处理器使用寿命,降本增效。

最终方案:引入一个特殊任务 idle task(很多资料中也叫 idle loop), 没有其他任务可调度时,就调度执行它。

  • 从功能上来说,可以认为是一个优先级最低的占坑任务。
  • 从实现来说,idle task 里面做什么都可以 —— 反正这时候这个 CPU 上没有任何其他 runnable tasks。 根据目的的不同,具体实现可以分为两大类:

    • 节能;
    • 低延迟。

区分 "idle task""task idle"

idle task task idle
一个特殊进程(任务) 普通进程的一种特殊状态(例如在等待 IO),在这种状态下不需要 CPU 来执行

在 Linux 中,如果除了 "idle task" 已经没有其他任务可运行时, 这个 CPU 就是空闲的,即 idle CPU

1.4.1 直接降低电压和频率,节能

这是主流行为,idle task 里面实现某种降低功耗的逻辑,避免 CPU 空转,节能。 典型配置如 Linux 内核启动项 idle=halt

这种方式的缺点是从较低功耗(某种程度的睡眠状态)唤醒时有一定的延迟。

1.4.2 仍然全速运行,保持最低唤醒延迟

这类场景比较特殊,比如追求极低延迟的高频交易场景。 没有任务时仍然让 CPU 保持电压和频率空转,不要降压降频, 这样有任务变成 runnable 时可以立即切换执行,延迟最低。 在 Linux 启动项中,对应 idle=poll 配置,后面几篇我们还会多次看到(尤其是这种配置的潜在风险)。

1.4.3 动态降低电压和频率,节能 —— cpuidlec-states

通过一个单独的子系统(cpuidle)来实现不同级别的节能(c-states)。

这里注意和 turbo freq 的区别:

  • turbo 是部分 CORE 空闲时,有任务在运行的 CORE 可以动态超频, 目的是提高这几个有任务在运行的 CORE 的性能;
  • cpuidle/c-states 是当前 CORE/CPU 没有任务要运行(空闲 CPU),通过动态降频来节能。

1.5 idle loop 模式之三:空闲时间管理 —— cpuidle

再稍微展开介绍下上面第三种: 队列中如果没有 runnable task,比如所有任务都在等待 IO 事件。 这时候是没有任务需要 CPU 的,因此称为 CPU 空闲状态(idle states)。

空闲状态的下 CPU 该怎么管理,也是一门学问,因此内核又引入了另外一个子系统: cpu 空闲时间管理子系统 cpudile。具体工作内容后面介绍。

1.6 cpuidle + 响应延迟保证:电源管理服务等级 —— PM QoS

如果没有任务时 cpuidle 选择进入某种低电压/低频率的节能模式,当有任务到来时, 它的唤醒时间可能无法满足要求。针对这种情况,内核又引入了功耗管理或称电源管理 服务等级 (PM QoS)子系统。

PM QoS 允许应用注册一个最大 latency,内核确保唤醒时间不会高于这个阈值, 在尽量节能的同时实现快速响应。 具体原理也在后面单独章节介绍。

1.7 小结:各子系统的关系图

最后用一张图梳理一下前面涉及到的各内核子系统:

Fig. Relationship of some CPU-related kernel subsystems and tasks

接下来深入到几个子系统的内部看看。

2 CPU 频率管理子系统(cpufreq):调节运行任务时的 p-state

2.1 原理:CPU performance/frequency scaling

处理器硬件有接口暴露给内核,可以设置 CPU 的运行 frequency/voltage,或者说选择不同的 P-state.

一般来说,

  • 内核调度器会在一些重要事件发生时(例如新建或销毁进程), 或者定期(every iteration of the scheduler tick)回调 cpufreq update 方法,更新 cpufreq 状态。
  • cpufreq 根据状态状态信息,可以动态调整 p-state 级别。

这个功能称为 CPU performance scaling or CPU frequency scaling。

内核文档 CPU Performance Scaling [9] 有详细介绍。

2.2 架构:governor+driver

代码分为三块:

  1. the core:模型实现和状态维护;
  2. scaling governors:不同的管理算法;
  3. scaling drivers:与硬件对接的驱动。

governors

几个比较重要的:

  1. performance:性能优先
  2. powersave:节能优先
  3. userspace:折中

drivers

  1. acpi-cpufreq
  2. intel_pstate

2.3 配置

在 sysfs 目录,每个 CPU 一个目录 /sys/devices/system/cpu/cpu{id}/cpufreq/

$ ls /sys/devices/system/cpu/cpu0/cpufreq/
affected_cpus                cpuinfo_min_freq             related_cpus                 scaling_cur_freq             scaling_governor             scaling_min_freq
cpuinfo_max_freq             cpuinfo_transition_latency   scaling_available_governors  scaling_driver               scaling_max_freq             scaling_setspeed

2.4 查看两台具体 node

2.4.1 Intel CPU node

先来看一台 Intel CPU 的机器,

(intel node) $ cpupower frequency-info
analyzing CPU 0:
  driver: intel_pstate                                                # 驱动,源码在内核树
  CPUs which run at the same hardware frequency: 0
  CPUs which need to have their frequency coordinated by software: 0
  maximum transition latency:  Cannot determine or is not supported.
  hardware limits: 800 MHz - 3.40 GHz                                 # 硬件支持的频率范围
  available cpufreq governors: performance powersave
  current policy: frequency should be within 800 MHz and 3.40 GHz.
                  The governor "performance" may decide which speed to use within this range.
  current CPU frequency: Unable to call hardware
  current CPU frequency: 2.60 GHz (asserted by call to kernel)
  boost state support:
    Supported: yes
    Active: yes
  • driver:intel_pstate,这个 driver 比较特殊,它绕过了 governor layer,直接在驱动里实现了频率调整算法 [9]。
  • CPU 频率范围硬限制:800MHz - 3.4GHz
  • 可用 cpufreq governors:performance powersave
  • 正在使用的 cpufreq governor:performance
  • 当前策略
    • 频率范围运行在 800MHz - 3.4GHz 之间;
    • 具体频率由 performance governor 决定。
  • 当前 CPU 的频率:
    • 从硬件未获取到;
    • 从内核获取到的是 2.6GHz
  • 是否支持 boost,即 turbo frequency
    • 支持
    • 当前已经开启

2.4.2 AMD CPU node

再看一个 AMD CPU node:

(amd node) $ cpupower frequency-info
analyzing CPU 0:
  driver: acpi-cpufreq                                                # 驱动,源码在内核树
  CPUs which run at the same hardware frequency: 0
  CPUs which need to have their frequency coordinated by software: 0
  maximum transition latency:  Cannot determine or is not supported.
  hardware limits: 1.50 GHz - 3.74 GHz                                # 硬件支持的频率范围
  available frequency steps:  2.80 GHz, 2.10 GHz, 1.50 GHz
  available cpufreq governors: conservative ondemand userspace powersave performance schedutil
  current policy: frequency should be within 1.50 GHz and 2.80 GHz.
                  The governor "performance" may decide which speed to use within this range.
  current CPU frequency: 2.80 GHz (asserted by call to hardware)
  boost state support:
    Supported: yes
    Active: yes
    Boost States: 0
    Total States: 3
    Pstate-P0:  2800MHz
    Pstate-P1:  2100MHz
    Pstate-P2:  1500MHz
  • driver:acpi-cpufreq
  • CPU 频率范围硬限制:1.5GHz - 3.74GHz
  • 可用的频率步长:1.5G 2.1G 2.8G
  • 可用 cpufreq governors:conservative ondemand userspace powersave performance schedutil
  • 正在使用的 cpufreq governor:performance
  • 当前策略
    • 频率范围运行在 1.5GHz - 2.8GHz 之间;
    • 具体频率由 performance governor 决定。
  • 当前 CPU 的频率:
    • 从硬件获取到 2.8GHz;
    • 从内核获取到的是 2.6GHz
  • 是否支持 boost,即 turbo
    • 支持
    • 当前已经开启
    • 支持的 p-state 频率
      • Pstate-P0: 2800MHz
      • Pstate-P1: 2100MHz
      • Pstate-P2: 1500MHz

3 idle task:没有 runnable tasks 时占坑

如果调度队列(rq)为空,或者队列中的所有任务都处于 non runnable 状态,我们就称这个 CPU 是空闲的, 接下来就可以进入某个 c-state 以便降低功耗。从设计上来说,这里可以有两种选择:

  1. 将进入 c-state 的逻辑直接暴露给调度器,由调度器直接控制;
  2. 将进入 c-state 的逻辑封装成一个标准的任务(task),放到调度队列里,作为优先级最低的任务。 如果没有任何其他任务可执行,就调度执行这个特殊任务。

Linux 选择的第二种,引入的特殊任务称为 "idle task"

严格来说,

  • 早期系统中真的是进程(线程),优先级最低;干掉这个线程可能是搞垮小型机最简单的方式之一 [3];
  • 现代系统中,比如 Linux 内核中,已经是更加轻量级的实现(ps 搜索 idle 等字样看不到这些进程) 。

一般都是在 CPU 无事可做时通过某种形式的 wait 指令让 CPU 降低功耗。

3.1 idle task 历史

处理器比大多数人想象中要空闲的多。

  1. Unix

    Unix 似乎一直都有一个某种形式的 idle loop(但不一定是一个真正的 idle task)。 比如在 V1 中,它使用了一个 WAIT 指令, 实现了让处理器停止工作,直到中断触发,处理器重新开始执行。

  2. DOS、OS/2、早期 Windows

    包括 DOS、IBM OS/2、早期 Windows 等操作系统, 都使用 busy loops 实现 idle task。

3.2 Linux idle task 设计

为了保证设计的一致性,Linux 引入了一个特殊的进程 idle task,没有其他 task 可调度时,就执行它。

  • 复用标准进程结构 struct task,将“无事可做时做什么”的逻辑封装为 idle task;
  • 为每个 CPU 创建一个这样的进程(idle task),只会在这个 CPU 上运行;
  • 无事可做时就调度这个 task 执行(因此优先级最低),所花的时间记录在 top 等命令的 idle 字段里。
$ top
top - 09:38:34 up 22 days, 22:46,  8 users,  load average: 0.24, 0.14, 0.10
Tasks: 168 total,   1 running, 165 sleeping,   2 stopped,   0 zombie

#            user    system             idle     wait              softirq
%Cpu0  :  0.0 us,  0.0 sy,  0.0 ni,100.0 id,  0.0 wa,  0.0 hi,  0.0 si,  0.0 st
%Cpu1  :  0.0 us,  3.1 sy,  0.0 ni, 96.9 id,  0.0 wa,  0.0 hi,  0.0 si,  0.0 st
...

3.3 实现:idle loop

这里只是很简单的看一下,下一篇专门介绍内核实现。

简化之后,

while (1) {
    while(!need_resched()) {
        cpuidle_idle_call();
    }

    /*
      [Note: Switch to a different task. We will return to this loop when the
      idle task is again selected to run.]
    */
    schedule_preempt_disabled();
}

如果没有其他任务,就执行 idle。从累积时间来说,idle 函数可能是人类历史上执行时间最长的函数。 [2]

3.4 运行时

3.4.1 1 号进程(PID=1

我们经常能在教科书或网上看到说,系统启动之后的第一个进程是 init 进程, 它的 PID 是 1,所有其他进程都是这个进程的(N 代)子进程。这句话也不算错,但 init 其实是一个逻辑概念, 真正的 1 号进程名字可能并不叫 “init”。

  1. 查看一台 ubuntu 机器:

     (ubuntu) $ cat /proc/1/cmdline | tr '\0' ' '
     /sbin/init splash
        
     # /sbin/init is a symlink
     $ ls -ahl /sbin/init
     lrwxrwxrwx 1 root root /sbin/init -> /lib/systemd/systemd
    

    可以看到最终是执行的 systemd

  2. 再来看一台 CentOS 机器,

     (centos) $ cat /proc/1/cmdline | tr '\0' ' '
     /usr/lib/systemd/systemd --switched-root --system --deserialize 28
    

    直接执行的 systemd

pstree 可以直接看到从 PID 1 开始的进程树

$ pstree -p | head
systemd(1)-+-agetty(13499)
           |-atd(9614)
           |-auditd(9442)---{auditd}(9443)
           |-chronyd(9665)
           |-containerd(10324)-+-containerd-shim(14126)-+-pause(14200)
           |                   |                        |-{containerd-shim}(14127)
           ...

PID=1 进程是谁创建出来的?有没有可能存在 PID=0 的进程?

3.4.2 0 号进程(PID=0

ps 查看所有进程,指定 -a 按 PID 升序排列,

$ ps -eaf | head
UID    PID   PPID   CMD
root     1      0   /usr/lib/systemd/systemd --switched-root --system --deserialize 28
root     2      0   [kthreadd] # kernel thread daemon. All kthreads are forked from this thread
root     3      2   [rcu_gp]
root     4      2   [rcu_par_gp]
..

还真能看到 0 号进程,不过它只出现在父进程 PPID 列,是两个进程的父进程:

  1. systemdPID=1,前面介绍过了,
  2. kthreaddPID=2,这是所有内核进程/线程的父进程

    • 内核进程是 ps 输出中用中括号 [] 括起来的进程,比如上面看到的 [rcu_gp]

这也说明上一节我们关于 init 的说明也不太准确,更准确来说,系统启动后

  1. 所有系统进程(操作系统服务)和用户进程都是从 PID=1init 进程直接或间接 fork 出来的;
  2. 所有内核进程都是从 PID=2kthreadd 进程 fork 出来的;

回到我们的问题,PID=0 是什么呢wikipedia.org/wiki/Process_identifier 中有定义,本文不深入,简单把它理解成内核本身(内核最最骨干的执行逻辑), 在所有进程之上,能管理左膀(PID=1)和右臂(PID=2)。

3.4.3 idle task:0 号进程的一部分

做了以上那么多关于 PID 的铺垫,这里回到正题:idle task,是几号进程呢?

  • 它的优先级最低,没有其他进程可调度时才会调度到它;
  • 它叫“进程”(任务,task),但不是普通进程,而是 0 号进程的一部分,或者说是内核的一部分。

从执行上来说,它是直接在内核本身内执行,而并不是切换到某个进程执行。

3.5 从 idle task 进入 c-state 管理逻辑

内核切换到 idle task 代码之后,接下来怎么选择 c-state 以及怎么切换过去, 就是算法、架构和对接特定处理器的实现问题了。 我们下面一节来讨论。

4 CPU 空闲管理子系统(cpudile):空闲时如何节能(c-state

如果队列中没有任务,或者任务都是 wait 状态, 内核调度器该做什么呢?取决于处理器提供的能力,

  • 如果处理器本身非常简单(特定场景的低成本处理器),没有什么功耗控制能力,调度器就只能执行一些无意义的指令来让处理器空转了;
  • 现代处理器都有功耗控制能力,一般都是关闭部分 processor,进入到某种程度的 sleep 状态,实现节能。 但是,中断控制器(interrupt controller)必现保持开启状态。外设触发中断时,能给处理器发信号,唤醒处理器。

实际上,现代处理器有着非常复杂的电源/能耗管理系统。 OS 能预测处理器停留在 idle mode 的时长,选择不同的 low-power modes. 每个 mode 功耗高低、进出耗时、频率等等都是不同的。

4.1 区分不同的 CPU 空闲级别:引入 c-state (idle state)

为了增强灵活性,引入了 c-state,在处理器 idle 时节能 [7]。

p-states (optimization of the voltage and CPU frequency during operation) and c-states (optimization of the power consumption if a core does not have to execute any instructions) Image Source

4.1.1 ACPI p-states & c-states

ACPI 定义了处理器电源管理的规范,里面有两种状态,

  1. Power performance states (ACPI P states)

    不同厂商、不同处理器的 p-states 一般都不同。

  2. Processor idle sleep states (ACPI C states)

    关闭 CPU 部分功能。不同处理器支持的 C-states 可能不同,区别在于能关掉哪些部分。 数字越大,关掉的功能越多,约省电。

4.1.2 C-State 定义

Mode Definition
C0 Operational state. CPU fully turned on.
C1 First idle state. Stops CPU main internal clocks via software. Bus interface unit and APIC are kept running at full speed.
C2 Stops CPU main internal clocks via hardware. State in which the processor maintains all software-visible states, but may take longer to wake up through interrupts.
C3 Stops all CPU internal clocks. The processor does not need to keep its cache coherent, but maintains other states. Some processors have variations of the C3 state that differ in how long it takes to wake the processor through interrupts.
  • c0 比较特殊,是工作状态; 但是工作在什么频率/电压,或者工作在哪个 p-state,是可以配置的,比如为了省电工作在较低的频率和电压;
  • c1 是第一个空闲状态,表示 cpu 无事可干时,进入这个状态比 c0 省电。
  • c2 c3 … 可选的更低功耗 idle 状态,唤醒延迟相应也越大。较深的睡眠状态唤醒时还可能会破坏 L2 cache 数据。

4.1.3 和 p-state 的区别

区别:

  • c-state 独立于厂商和处理器,p-state 跟厂商和具体处理器直接相关
  • 要想运行在某个 p-state,处理器必现工作在 C0 状态,也就是说处理器得进入工作状态,而不是空闲状态;

C-States vs. P-States

4.1.4 定义不同 idle 状态 / c-states 的决定因素

每个 idle state 考虑两个参数来描述,

  1. target residency(目标驻留时间)

    硬件能够保证的在这个状态下的最短时间,包括进入该状态所需的时间(可能很长)。

  2. (最坏情况下的)exit latency(退出延迟)

    从该状态唤醒后开始执行第一条指令所需的最长时间。

4.2 如何选择 c-state:governor + driver

cpufreq 子系统类似,将管理部分封装为一个 governor,有结构体和方法, 通过 driver 实现 governor 的一些方法。

使得架构独立于厂商和处理器。

四种 cpuidle governor:menu, TEO, ladder, haltpoll

4.2.1 为什么会有这么多 governors?

有两类信息可以影响 governor 的决策。

下一个事件何时到来。分为两种情况:

  1. 定时器事件。这个是确定的,因为内核控制着定时器(the kernel programs timers),所以 governor 知道何时触发。 在下一个定时器到来之前,就是这个 CPU 所在硬件可以在 idle state 下花费的最长时间,包括进入和退出所需的时间。

  2. 非定时器事件。CPU 可能随时被非定时器事件唤醒,而且通常不可预测。 governor 只能在 CPU 被唤醒后看到 CPU 实际上空闲了多长时间(这段时间将被称为idle duration),

governor 可以基于以上两种时间,来估计未来的 idle duration。 如何使用这些信息取决于算法,这也是为什么有多个 governor 的主要原因。

4.2.2 governor

menu governor

menu governor 是 tickless 系统的默认 cpuidle governor。 非常复杂,但基本原理很简单:预测 idle duration,使用预测值进行 c-state 选择。

haltpoll

ladder

teo (Timer Events Oriented)

用于 tickless systems。 跟 menu 一样,永远寻找最深的 idle state。 但算法不同。

kernel-doc: drivers/cpuidle/governors/teo.c

4.2.3 driver

用哪个 cpuidle driver 通常取决于内核运行的平台,例如,有大多数 Intel 平台都支持两种驱动:

  • intel_idle hardcode 了一些 idle state 信息;
  • acpi_idle 从系统的 ACPI 表中读取 idle state 信息。

4.3 实地查看两台 Linux node

下面的信息跟服务器的配置直接相关,我们这里只是随便挑两台看下, 不代表任何配置建议。

4.3.1 Intel CPU node

$ cpupower idle-info
CPUidle driver: intel_idle
CPUidle governor: menu
analyzing CPU 0:

Number of idle states: 4
Available idle states: POLL C1 C1E C6
  POLL:
    Flags/Description: CPUIDLE CORE POLL IDLE
    Latency: 0
    Usage: 4927634
    Duration: 49239413
  C1:
    Flags/Description: MWAIT 0x00
    Latency: 2
    Usage: 954516883
    Duration: 1185768447670
  C1E:
    Flags/Description: MWAIT 0x01
    Latency: 10
    Usage: 7804
    Duration: 7491626
  C6 (DISABLED) :
    Flags/Description: MWAIT 0x20
    Latency: 92
    Usage: 0
    Duration: 0

可以看到,

  • cpuidle driver:intel_idle
  • cpuidle governor: menu
  • 支持的 idle states 种类:4 种

    1. POLL:即 C0,无事可做时执行一个轻量级线程,避免处理器进入 C1 状态;
    2. C1
    3. C1E
    4. C6:未启用

    此外还提供了每种 idle 状态的延迟、使用率、累积时长等等统计信息。

还可以通过 cpupower monitor 查看每个 CPU 的具体状态分布:

$ cpupower monitor
              | Nehalem                   || Mperf              || Idle_Stats
 PKG|CORE| CPU| C3   | C6   | PC3  | PC6   || C0   | Cx   | Freq  || POLL | C1   | C1E  | C6
   0|   0|   0|  0.00|  0.00|  0.00|  0.00||  3.10| 96.90|  2692||  0.00| 96.96|  0.00|  0.00
   0|   0|  20|  0.00|  0.00|  0.00|  0.00||  2.05| 97.95|  2692||  0.00| 98.04|  0.00|  0.00
   0|   1|   4|  0.00|  0.00|  0.00|  0.00||  0.80| 99.20|  2692||  0.00| 99.23|  0.00|  0.00

4.3.2 AMD CPU node

第一台 node:

$ cpupower idle-info
CPUidle driver: none   # 没有 driver
CPUidle governor: menu
analyzing CPU 0:

CPU 0: No idle states  # 没有 idle state,CPU 工作在 idle=poll 模式

第二台 node:

$ cpupower idle-info
CPUidle driver: acpi_idle      # acpi_idle driver
CPUidle governor: menu
analyzing CPU 0:

Number of idle states: 2
Available idle states: POLL C1 # 最大睡眠深度 C1
  POLL:
    Flags/Description: CPUIDLE CORE POLL IDLE
    Latency: 0
    Usage: 11905032
    Duration: 88450207
  C1:
    Flags/Description: ACPI FFH MWAIT 0x0
    Latency: 1
    Usage: 3238141749
    Duration: 994766079630

$ cpupower monitor | head
              | Mperf              || Idle_Stats
 PKG|CORE| CPU| C0   | Cx   | Freq  || POLL | C1
   0|   0|   0| 18.29| 81.71|  2394||  0.01| 81.69
   0|   0|  64| 13.88| 86.12|  2394||  0.01| 86.12

第三台 node:

$ cpupower idle-info
CPUidle driver: acpi_idle         # acpi_idle driver
CPUidle governor: menu
analyzing CPU 0:

Number of idle states: 3
Available idle states: POLL C1 C2 # 最大睡眠深度 C2
  POLL:
    Flags/Description: CPUIDLE CORE POLL IDLE
    Latency: 0
    Usage: 281497562
    Duration: 1622947419
  C1:
    Flags/Description: ACPI FFH MWAIT 0x0
    Latency: 1
    Usage: 59069668293
    Duration: 21144523673762
  C2:
    Flags/Description: ACPI IOPORT 0x814
    Latency: 30
    Usage: 9864
    Duration: 16089926

$ cpupower monitor | head
              | Mperf              || Idle_Stats
 PKG|CORE| CPU| C0   | Cx   | Freq  || POLL | C1   | C2
   0|   0|   0| 24.83| 75.17|  1886||  0.00| 75.32|  0.00
   0|   0|  64| 22.04| 77.96|  1890||  0.00| 78.03|  0.00

4.3.3 内核启动日志

内核启动日志说可以看到一些 idle task 相关的信息:

$ dmesg | grep idle
[    0.018727] clocksource: refined-jiffies: mask: 0xffffffff max_cycles: 0xffffffff, max_idle_ns: 1910969940391419 ns
[    0.177760] clocksource: hpet: mask: 0xffffffff max_cycles: 0xffffffff, max_idle_ns: 79635855245 ns
[    0.189880] clocksource: tsc-early: mask: 0xffffffffffffffff max_cycles: 0x1fb633008a4, max_idle_ns: 440795292230 ns
[    0.227518] process: using mwait in idle threads
[    0.555478] clocksource: jiffies: mask: 0xffffffff max_cycles: 0xffffffff, max_idle_ns: 1911260446275000 ns
[    0.558415] cpuidle: using governor menu
[    1.139909] clocksource: acpi_pm: mask: 0xffffff max_cycles: 0xffffff, max_idle_ns: 2085701024 ns
[    1.194196] ACPI: \_SB_.SCK0.CP00: Found 1 idle states
[    2.194148] clocksource: tsc: mask: 0xffffffffffffffff max_cycles: 0x1fa32b623c0, max_idle_ns: 440795289684 ns

5 CPU 功耗管理(PM)QoS:pm_qos,保证响应时间

5.1 解决的问题

c-state 引入的一个问题是:当有任务到来时,从低功耗状态切回运行状态会有一定的延迟, 对于某些应用来说可能无法接受。为了解决这个问题,应用可以通过 Power Management Quality of Service (PM QoS) 接口。

这是一个内核框架,允许 kernel code and user space processes 向内核声明延迟需求,避免性能过低。

5.2 原理

系统会在节能的前提下,尽量模拟 idle=poll processor.max_cstate=1 的效果,

  • idle=poll 会阻止处理器进入 idle state;
  • processor.max_cstate=1 阻止处理器进入较深的 C-states。

使用方式:

  • 应用程序打开 /dev/cpu_dma_latency , 写入能接受的最大响应时间,这是一个 int32 类型,单位是 us
  • 注意:保持这个文件处于 open 状态;关闭这个文件后,PM QoS 就停止工作了。

5.3 例子

来自 RedHat Controlling power management transitions:

import os
import os.path
import signal
import sys
if not os.path.exists('/dev/cpu_dma_latency'):
 	print("no PM QOS interface on this system!")
 	sys.exit(1)
fd = os.open('/dev/cpu_dma_latency', os.O_WRONLY)
 	 os.write(fd, b'\0\0\0\0')
 	 print("Press ^C to close /dev/cpu_dma_latency and exit")
    signal.pause()
except KeyboardInterrupt:
    print("closing /dev/cpu_dma_latency")
    os.close(fd)
    sys.exit(0)

这里写入的是 0,表示完全禁用 c-states

此外,也可以读写 /sys/devices/system/cpu/cpu<N>/power/pm_qos_resume_latency_us

6 系统定时器(timer)对空闲管理的影响

最后,我们看一个影响空闲管理性能的东西:timer。

如果我们很关心一件事情的进展,但是出于某些原因,对方不会或无法向我们主动同步进展, 我们该怎么办呢?—— 定期主动去问进展,

  • 翻译成计算机术语:轮询;
  • 具体到内核,依赖的底层机制:定时器(timer)。

6.1 经典方式:scheduler tick(固定 HZ

如果一个 CPU 上有多个 runnable task,从公平角度考虑 [12],应该让它们轮流执行。 实现轮流的底层机制就是定时器。

  • scheduler tick 是一个定时器,定期触发,不管 CPU 上有没有任务执行;
  • timer 触发之后,停止当前任务的执行,根据 scheduling class、优先级等的因素,选出下一个任务放到 CPU 上执行。

Image source

Image source

scheduler tick 的触发频率就是系统 HZ,这个是内核编译时指定的,范围是 100~1000

$ grep 'CONFIG_HZ=' /boot/config-$(uname -r)
CONFIG_HZ=250

即这台机器每秒都要中断 250 次, 从 CPU 空闲时间管理的角度来看,如果 CPU 很空闲,这样频繁触发就很浪费, 增加处理开销。

6.2 改进:tickless mode (nohz)

tickless 模式,也叫 dynamic tick 模式,见内核文档 Documentation/timers/no_hz.rst

基本原理:CPU 空闲时, 如果内核知道下一个任务何时到来(例如,一个进程设置了 5s 的定时器), 就 关闭或延迟 timer interrupt。

好处是更节能。

编译设置 CONFIG_NO_HZ_IDLE 或启动命令行 nohz=off

6.3 再改进 adaptive tick

内核文档 NO_HZ: Reducing Scheduling-Clock Ticks

如果是一个 CPU 密集型的,那跟完全 idle 是类似的,都不希望每 4ms 被打扰一次。

参考资料

  1. CPU Idle Time Management, cpuidle kernel doc, 5.15
  2. What Does An Idle CPU Do, manybutfinite.com, 2014
  3. what-does-an-idle-cpu-process-do, stackexchange.com
  4. no hz, kernel doc, 5.15
  5. The cpuidle subsystem, lwn.net, 2013
  6. Linux CFS 调度器:原理、设计与内核实现(2023)
  7. System Analysis and Tuning Guide: Power Management, opensuse.org
  8. Processor P-states and C-states, thomas-krenn.com
  9. CPU Performance Scaling, cpufreq kernel doc, 5.15
  10. Improvements in CPU frequency management, lwn.net, 2016
  11. CPU idle power saving methods for real-time workloads, wiki.linuxfoundation.org
  12. Linux CFS 调度器:原理、设计与内核实现(2023)

Written by Human, Not by AI Written by Human, Not by AI

Linux 服务器功耗与性能管理(一):CPU 硬件基础(2024)

2024年2月15日 08:00

整理一些 Linux 服务器性能相关的 CPU 硬件基础及内核子系统知识。

水平有限,文中不免有错误或过时之处,请酌情参考。



对于 Linux 机器,可以用 lscpucat /proc/info 等命令查看它的 CPU 信息, 比如下面这台机器,

$ lscpu
Architecture:          x86_64
CPU(s):                48
On-line CPU(s) list:   0-47
Thread(s) per core:    2
Core(s) per socket:    12
Socket(s):             2
NUMA node(s):          2
Model:                 63
Model name:            Intel(R) Xeon(R) CPU E5-2680 v3 @ 2.50GHz
NUMA node0 CPU(s):     0-11,24-35
NUMA node1 CPU(s):     12-23,36-47
...

看到有 48 个 CPU。 要理解这些 CPU 在物理上是怎么分布的(layout),需要先熟悉几个概念。

1 拓扑

1.1 Package

如下图,package(直译为“封装”)是我们能直接在主板上看到的一个东西,

Fig. CPU package Image source

里面封装一个或多个处理器核心(称为 core 或 processor)。

1.2 Core (processor)

本文的 “core/processor” 都是指硬件核心/硬件处理器。一个 package 里面可能会包含多个处理器,如下图所示,

Fig. Cores/processors in a package Image source

或者从芯片视图看:

Fig. Cores/processors in a package Image source

1.3 超线程(Hyper-threading)/硬件线程(hardware thread)

Fig. Hyper-threading Image source

大部分 X86 处理器都支持超线程,也叫硬件线程。 如果一个 CORE 支持 2 个硬件线程, 那么启用超线程后, 这个 CORE 上面就有 2 个在大部分情况下都能独立执行的指令流(这 2 个硬件线程共享 L1 cache 等), 操作系统能看到的 CPU 数量会翻倍(相比 CORE 的数量), 每个 CPU 对应的不是一个 CORE,而是一个硬件线程/超线程(hyper-thread)。

1.4 (Logical) CPU

以上提到的 package、core/processor、hyper-threading/hardware-thread,都是硬件概念

在任务调度的语境中,我们所说的 “CPU” 其实是一个逻辑概念。 例如,内核的任务调度是基于逻辑 CPU 来的,

  • 为每个逻辑 CPU 分配一个任务队列(run queue),独立调度;
  • 为每个逻辑 CPU 能独立加载指令并执行。

逻辑 CPU 的数量和分布跟 package/core/hyper-threading 有直接关系, 一个逻辑 CPU 不一定对应一个独立的硬件处理器

下面通过一个具体例子来看下四者之间的关系。

1.5 Linux node 实探:cpupower/hwloc/lstopo 查看三者的关系

还是本文最开始那台 Intel CPU 机器,Thread(s) per core: 2 说明它启用了超线程/硬件线程。另外,我们通过工具 cpupower 来看下它的 CPU 分布,

$ cpupower monitor
              | Mperf              
 PKG|CORE| CPU| C0   | Cx   | Freq 
   0|   0|   0|  2.66| 97.34|  2494
   0|   0|  24|  1.89| 98.11|  2493
   0|   1|   1|  2.09| 97.91|  2494
   0|   1|  25|  1.77| 98.23|  2494
   ...
   0|  13|  11|  1.95| 98.05|  2493
   0|  13|  35|  2.30| 97.70|  2492
   1|   0|  12|  1.65| 98.35|  2493
   1|   0|  36|  1.58| 98.42|  2494
   ...
   1|  13|  23|  1.78| 98.22|  2494
   1|  13|  47|  5.07| 94.93|  2493

前三列:

  1. PKG:package,

    2 个独立的 CPU package0~1),对应上面的 NUMA;

  2. CORE物理核心/物理处理器

    每个 package 里 14 个 CORE0~13);

  3. CPU:用户看到的 CPU,即我们上面所说的逻辑 CPU

    这台机器启用了超线程(hyperthreading),每个 CORE 对应两个 hardware thread, 每个 hardware thread 最终呈现为一个用户看到的 CPU,因此最终是 48 个 CPU(0~47)。

也可以通过 hw-loc 查看硬件拓扑,里面能详细到不同 CPU 的 L1/L2 cache 关系:

$ hwloc-ls
Machine (251GB total)
  NUMANode L#0 (P#0 125GB)
    Package L#0 + L3 L#0 (30MB)                                    # <-- PKG 0
      L2 L#0 (256KB) + L1d L#0 (32KB) + L1i L#0 (32KB) + Core L#0  #   <-- CORE 0
        PU L#0 (P#0)                                               #     <-- Logical CPU 0  对应到这里
        PU L#1 (P#24)                                              #     <-- Logical CPU 24 对应到这里
      L2 L#1 (256KB) + L1d L#1 (32KB) + L1i L#1 (32KB) + Core L#1  #   <-- CORE 1
        PU L#2 (P#1)                                               #     <-- Logical CPU 1  对应到这里
        PU L#3 (P#25)                                              #     <-- Logical CPU 25 对应到这里
  ...
  NUMANode L#1 (P#1 126GB) + Package L#1 + L3 L#1 (30MB)
    L2 L#12 (256KB) + L1d L#12 (32KB) + L1i L#12 (32KB) + Core L#12
      PU L#24 (P#12)
      PU L#25 (P#36)
    ...
    L2 L#23 (256KB) + L1d L#23 (32KB) + L1i L#23 (32KB) + Core L#23
      PU L#46 (P#23)
      PU L#47 (P#47)

如无特殊说明,本文接下来的 “CPU” 都是指逻辑 CPU, 也就是 Linux 内核看到的 CPU。

2 频率

2.1 P-State (processor performance state):处理器支持的 voltage-freq 列表

处理器可以工作在不同的频率,对应不同的电压(最终体现为功耗)。这些 voltage-frequency 组合就称为 P-State(处理器性能状态)。 比如下面这个 P-State Table

Voltage Frequency
1.21 V 2.8 GHz (HFM)
1.18 V 2.4 GHz
1.05 V 2.0 GHz
0.96 V 1.6 GHz
0.93 V 1.3 GHz
0.86 V 600 MHz (LFM)

这个 table 会保存在一个名为 MSR (model specific register) 的 read-only 寄存器中

2.2 LFM/HFM (low/high freq mode):p-state 中的最低和最高频率

p-state table 中,

  • 最低频率模式称为 Low Frequency Mode (LFM),工作频率和电压不能比这个更低了。
  • 最高频率模式称为 High Frequency mode (HFM),工作频率和电压不能比这个更高了。

2.3 基频(base frequency):市场宣传术,其实就是 p-state 中的最高频率

上面介绍了根据 p-state 的定义,处理器的最低(LF)和最高(HF)频率,这些都是很好理解的技术术语。

但在市场宣传中,厂商将 HF —— p-state 中的上限频率 —— 称为基础频率或基频(Base Frequency),给技术人造成了极大的困惑。

2.4 超频(overclocking):运行在比基频更高的频率

既然敢将 HF 称为基频,那处理器(至少在某些场景下)肯定能工作在更高的频率。 根据 wikipedia

  • 处理器厂商出于功耗、散热、稳定性等方面的原因,会给出一个官方认证的最高稳定频率 (clock rate certified by the manufacturer)但这个频率可能不是处理器的物理极限(最高)频率。 厂商承诺在这个最高稳定频率及以下可以长时间稳定运行,但超出这个频率, 有的厂商提供有限保证,有的厂商完全不保证。
  • 工作在比处理器厂商认证的频率更高的频率上,就称为超频(overclocking);

结合我们前面的术语,这里说的“官方认证的最高稳定频率”就是基频(HF), 工作在基频以上的场景,就称为超频。比如基频是 2.8GHz,超频到 3.0GHz。

2.5 Intel Turbo(睿频) 或 AMD PowerTune:动态超频

Turbo 是 Intel 的技术方案,其他家也都有类似方案,基本原理都一样:根据负载动态调整频率 —— 但这句话其实只说对了一半 —— 这项技术的场景也非常明确,但宣传中经常被有意或无意忽略: 在部分处理器空闲的情况下,另外那部分处理器才可能动态超频

所在官方文档说,我们会看到它一般都是写“能支持的最大单核频率”(maximum single-core frequency) 叫 Max Turbo Frequency,因为它们在设计上就不支持所有核同时运行在这个最大频率

原因其实也很简单: 频率越高,功耗越高,散热越大。整个系统软硬件主要是围绕基频(HF)设计和调优的, 出厂给出的也是和基频对应的功耗(TDP,后面会介绍)。 另外,TDP 也是数据中心设计时的主要参考指标之一,所以大规模长时间持续运行在 TDP 之上, 考验的不止是处理器、主板、散热片这些局部的东西,数据中心全局基础设施都得跟上。

下面看个具体处理器 turbo 的例子。

2.5.1 Turbo 频率越高,能同时工作在这个频率的 CORE 数量越少

下面是一个 Intel 处理器官方参数,

Turbo Freq and corresponding Active Cores Image source

解释一下,

  • 基频是 3.6GHz
  • 超频到 5GHz 时,最多只有 2 个核能工作在这个频率;
  • 超频到 4.8GHz 时,最多只有 4 个核能工作在这个频率;
  • 超频到 4.7GHz 时,最多只有 8 个核能工作在这个频率;

2.5.1 Turbo 高低跟 workload 使用的指令集(SSE/AVX/...)也有关系

能超到多少,跟跑的业务类型(或者说使用的指令集)也有关系,使用的指令集不同,能达到的最高频率也不同。 比如

Mode Example Workload Absolute Guaranteed
Lowest Frequency
Absolute
Highest Frequency
Non-AVX SSE, light AVX2 Integer Vector (non-MUL), All regular instruction Base Frequency Turbo Frequency
AVX2 Heavy All AVX2 operations, light AVX-512 (non-FP, Int Vect non-MUL) AVX2 Base AVX2 Turbo
AVX-512 Heavy All heavy AVX-512 operations AVX-512 Base AVX-512 Turbo

另外,在一些 CPU data sheet 中,还有一个所谓的 all-core turbo: 这是所有 core 同时超到同一个频率时,所能达到的最高频率。这个频率可能比 base 高一些, 但肯定比 max turbo frequency 低。例如,Xeon Gold 6150

  • base 2.7 GHz
  • all-core turbo 3.4 GHz
  • turbo max 3.7GHz

2.5.3 p-state vs. freq 直观展示

Image Source

2.6 Linux node lscpu/procinfo 实际查看各种频率

老版本的 lscpu 能看到三个频率指标:

$ lscpu
...
Model name:            Intel(R) Xeon(R) CPU E5-2680 v3 @ 2.50GHz
CPU MHz:               2494.374   # 实际运行频率,但不准,因为每个 CORE 可能都运行不同频率
CPU max MHz:           2500.0000  # max turbo freq
CPU min MHz:           1200.0000  # p-state low-freq

新版本的 lscpu 去掉了 CPU MHz 这个字段,每个 CORE 都可能工作在不同频率, 这种情况下这个字段没什么意义。要看每个 CORE/CPU 的实时工作频率,

# CPU info: Intel(R) Xeon(R) Gold 5318Y CPU @ 2.10GHz
$ cat /proc/cpuinfo | egrep '(processor|cpu MHz)'
processor       : 0
cpu MHz         : 2100.000
processor       : 1
cpu MHz         : 2100.000
processor       : 2
cpu MHz         : 2100.000
processor       : 3
cpu MHz         : 2600.000
...
processor       : 51
cpu MHz         : 2100.000
...
  • 一共 96 个 CPU,回忆前面,这里的 CPU 本质上都是硬件线程(超线程),并不是独立 CORE;
  • 从这台机器的输出看,同属一个 CORE 的两个硬件线程(CPU 3 & CPU 51)可以工作在不同频率

3 功耗

3.1 TDP (Thermal Design Power):Base Freq 下的额定功耗

TDP 表示的处理器运行在基频时的平均功耗(average power)。

这就是说,超频或 turbo 之后,处理器的功耗会比 TDP 更大。 具体到实际,需要关注功耗、电压、电流、出风口温度等等指标。 这些内容后面再专门讨论。

3.2 Turbo 和功耗控制架构

以 AMD 的 turbo 技术为例:

Fig. Architecture of the PowerTune version Image source

4 BIOS

服务器启动过程中的硬件初始化,可以配置一些硬件特性。运行在内核启动之前。


Written by Human, Not by AI Written by Human, Not by AI

Linux Load Average:算法、实现与实用指南(2023)

2023年10月3日 08:00

借着遇到的一个问题,研究下 loadavg 的算法和实现。



查看一台 Linux 机器在过去一段时间的负载 (准确来说是“负载平均”,load average)有很多命令,比如 topuptimeprocinfow 等等, 它们底层都是从 procfs 读取的数据:

$ cat /proc/loadavg
0.25 0.23 0.14 1/239 1116826

这是排查 Linux 性能问题时的重要参考指标之一。 proc man page 中有关于它的定义和每一列表示什么的解释:

$ man 5 proc
...
/proc/loadavg
    The first three fields in this file are load average figures giving the number of jobs in the run queue (state R) or waiting for disk I/O (state D) averaged over 1, 5, and 15 minutes.  They are the same as the load average numbers given by uptime(1) and other programs.
    The fourth field consists of two numbers separated by a slash (/).  The first of these is the number of currently runnable kernel scheduling entities (processes, threads).  The value after the slash is the number of kernel scheduling entities that currently exist on the system.
    The fifth field is the PID of the process that was most recently created on the system.

前 3 列分别表示这台机器在过去 1、5、15 分内的负载平均,为方便起见,下文分别用 load1/load5/load15 表示。load1 的精度是这三个里面最高的, 因此本文接下来将主要关注 load1(后面将看到,load5/load15 算法 load1 一样,只是时间尺寸不同)。

需要注意的是,loadavg 是机器的所有 CPU 上所有任务的总负载,因此跟机器的 CPU 数量有直接关系。 CPU 数量不一样的机器,直接比较 loadavg 是没有意义的。为了不同机器之间能够直接对比, 可以将 load1 除以机器的 CPU 数量,得到的指标用 load1_per_core 表示。

有了 load/loadavg 和 load1_per_core 的概念,接下来看一个具体问题。

1 一次 load spike 问题排查

我们的监控程序会采集每台机器的 load1_per_core 指标。 平时排查问题时经常用这个指标作为性能参考。

1.1 现象

为了定位某个问题,我们对部分 k8s node 加了个 load 告警,比如 load1_per_core > 1.0 就告警出来。加上后前几天平安无事,但某天突然收到了一台机器的告警,它的 load1_per_core 曲线如下:

Fig. Load (loadavg1-per-core) spikes of a k8s node

  • 对于这些 node,我们预期 load1_per_core 正常不会超过 1,超过 3 就更夸张了;
  • 另外,多个业务的 pod 混部在这台机器,根据之前的经验,load 这么高肯定有业务报障, 但这次却没有;
  • Load spike 非常有规律,预示着比较容易定位直接原因。

基于以上信息,接下来排查一下。

1.2 排查

1.2.1 宿主机监控:load 和 running 线程数量趋势一致

宿主机基础监控除了 load、cpu、io 等等指标外,我们还采集了系统上的进程/线程、上下文、中断等统计信息。 快速过了一遍这些看板之后发现,load 变化和 node 上总的 running 状态进程数量趋势和时间段都一致:

Fig. Node load (load1_per_core) and threads on the node

可以看到 running 进程数量在几十到几百之间剧烈波动。这里的 running 和 blocked 数量采集自:

$ cat /proc/stat
...
procs_running 8
procs_blocked 1

Load 高低和活跃进程数量及 IO 等因素正相关, 因此看到这个监控时,我们首先猜测可能是某些主机进程或 pod 进程在周期性创建和销毁大量进程(线程),或者切换进程(线程)状态

Linux 调度模块里,实际上是以线程为调度单位(struct se,schedule entity),并没有进程的概念。

接下来就是找到这个进程或 pod。

1.2.2 定位到进程(Pod)

非常规律的飙升意味着很容易抓现场。登录到 node 先用 top 看了几分钟, 确认系统 loadavg 会周期性从几十飙升到几百,

(node) $ top
#                                                   最近    1      5      15  分钟内的平均总负载
#                                                           |     |      |
top - 16:03:08 up 114 days, 18:03,  1 user,  load average: 17.01, 79.96, 90.27
Tasks: 1137 total,   1 running, 844 sleeping,   0 stopped,   0 zombie
%Cpu(s): 23.3 us,  3.5 sy,  0.0 ni, 72.8 id,  0.0 wa,  0.0 hi,  0.4 si,  0.0 st
...
   PID USER      PR  NI    VIRT    RES    SHR S  %CPU %MEM     TIME+ COMMAND # <-- 进程详情列表
719488 1004      20   0   36.8g   8.5g  35896 S 786.6  3.4  19332:17 java
...

再结合进程详情列表和 %CPU 这一列,很容易确定是哪个进程(PID)引起的, 进而可以根据 PID 找到 Pod。

1.2.3 Pod 监控:大量线程周期性状态切换

找到是哪个 Pod 之后,再跳转到 Pod 监控页面。 我们的监控项中,有一项是 pod 内的任务(线程)数量:

Fig. Task states of the specific Pod

这是在容器内收集的各种状态的线程数量。时间及趋势都和 node 对得上

再把时间范围拉短了看一下,

Fig. Task states of the specific Pod

running 线程增多时 sleeping 减少,running 减少时 sleeping 增多,数量对得上。 所以像是 600 来个线程不断在 sleeping/running 状态切换

1.2.4 交叉验证

计算 loadavg 会用到 Running 和 Uninterrupptable 状态的线程数量, 在 top -H(display individual threads) 里面是 RD 状态,每 5s 统计一次这种线程的数量:

(node) $ for n in `seq 1 30`; do top -b -H -n 1 | egrep "(\sD\s|\sR\s)" | wc -l; sleep 5; done
672
349
106
467
128
378
138
50
453
152
254
701
527
660
695
677
185
32
...

线程数量周期性在 30~700 之间变化,跟监控看到的 load 趋势一致。

其他现象:

  • mpstat -P ALL 1 看了一会,在 load 飙升时只有几个核的 CPU 利用率飙升, 单核最高到 100%;其他 CPU 的利用率都在正常水平;
  • 每次 load 飙升时,哪些 CPU 的利用率会飙升起来不固定; 这是因为该 Pod 并不是 cpuset 类型,内核会根据 CPU 空闲情况迁移线程;
  • IO 没什么变化;

也可以用下面的命令过滤掉那些不会用来计算 load 的线程 (S,T,t,Z,I etc):

$ ps -e -o s,user,cmd | grep ^[RD]
...

1.3 进一步排查方向

根据以上排查,初步总结如下:

  • 宿主机 Load 升高是由于 Pod 大量线程进入 running 状态导致的;
  • 整体 CPU/IO 等利用率都不高,因此其他业务实际上受影响不大;

进一步排查就要联系业务看看他们的 pod 在干什么了,这个不展开。

1.4 疑问

作为基础设施研发,这个 case 留给我们几个疑问:

  1. load 到底是怎么计算的?
  2. load 是否适合作为告警指标?

弄懂了第一个问题,第二个问题也就有答案了。 因此接下来我们研究下第一个问题。

2 loadavg:算法与内核实现

接下来的代码基于内核 5.10。

2.1 原理与算法

loadavg 算法本质上很简单,但 Linux 内核为了减少计算开销、适配不同处理器平台等等,做了很多工程优化, 所以现在很难快速看懂了。算法实现主要在 kernel/sched/loadavg.c [4], 上来就是官方吐槽:

This file contains the magic bits required to compute the global loadavg figure. Its a silly number but people think its important. We go through great pains to make it work on big machines and tickless kernels.

吐槽结束之后是算法描述:

The global load average is an exponentially decaying average of nr_running + nr_uninterruptible.

Once every LOAD_FREQ:

  nr_active = 0;
  for_each_possible_cpu(cpu)
      nr_active += cpu_of(cpu)->nr_running + cpu_of(cpu)->nr_uninterruptible;

  avenrun[n] = avenrun[0] * exp_n + nr_active * (1 - exp_n)

其中的 avenrun[] 就是 loadavg。这段话说,

  1. loadavg 是 nr_running + nr_uninterruptible 状态线程数量的一个指数衰减平均
  2. 算法每隔 5sLOAD_FREQ)根据公式计算一次过去 n 分钟内的 loadavg,其中 n 有三个取值:1/5/15;
  3. 对于 1 分钟粒度的 loadavg,公式简化为 avenrun[n] = avenrun[0] * exp_1 + nr_active * (1 - exp_1)

根据 [3],最近 1 分钟的 load 计算公式可以进一步精确为:

load(t) = load(t-1)e-5/60 + nr_active * (1-e-5/60) 公式 (1)

其中的参数:

  • t:时刻;
  • nr_active:所有 CPU 上 nr_running + nr_uninterruptible 的 task 数量;

根据 nr_active 的取值,又分为两种情况,下面分别来看。

2.1.1 有活跃线程:load 指数增长

nr_active > 0 表示此时(至少一个 CPU 上)有活跃线程。此时(尤其是活跃线程比较多时), 公式 1 的后面一部分占主,前面的衰减部分可以忽略,公式简化为:

load(tT) = nr_active * load(t0)(1-e-5t/60) 公式 (2)

t0 是初始时刻,tTT 时刻。 可以看出,这是一个与 nr_active 呈近似线性的单调递增曲线。

2.1.2 无活跃线程:load 指数衰减

nr_active = 0 表示所有 CPU 上都没有活跃线性,即整个系统处于空闲状态。 此时,公式简化为:

load(tT) = load(t0)e-5t/60 公式 (3)

是一个标准的指数衰减。也就是说如果从此刻开始, 后面都没有任务运行了,那系统 load 将指数衰减下去。

2.1.3 Load 测试与小结

我们来快速验证下。

在一台 48 核机器上,创建 44 个线程(留下 4 个核跑系统任务,比如监控采集程序)压测 10 分钟:

# spawn 44 workers spinning on malloc()/free(), run for 10 minutes
$ stress -m 44 --timeout 600s

下面是 load1_per_core 的监控:

Fig. Load test on a 48-Core physical server. stress -m 44 --timeout 600s

Load 曲线明显分为两个阶段:

  • 15:50~16:00,一直有 44 个活跃线性,因此 load 一个单调递增曲线,前半部分近似线性(后面为什么越来越平了?);
  • 16:00~16:10,没有活跃线性(除了一些开销很小的系统线程),因此 load 是一个比较标准的指数衰减曲线;

注意:

  1. 图中纵轴的单位是 load1_per_core,乘以这台机器的核数 48 才是 loadavg,也就是公式 1-3 中的 load。 不过由于二者就差一个固定倍数,因此曲线走势是一模一样的。
  2. 后面会看到,内核每 5s 计算一次 load,而这里的监控是每 60s 采集一个点,所以曲线略显粗糙;如果有精度更高的采集(例如,也是 5s 采集一次),会看到一条更漂亮的曲线。
  3. 另外注意注意到 load1_per_core 最大值已经超过 1 了,因此它并不是一个 <= 1.0 (100%) 的指标, 活跃线程数量越多,load 就越大。如果压测时创建更多的活跃线性,就会看到 load 达到一个更大的平稳值。 它跟 CPU 利用率(最大 100%)并不是一个概念

有了这样初步的感性认识之后,接下来看看内核实现。

2.2 内核基础

要大致看懂代码,需要一些内核基础。

2.2.1 运行/调度队列 struct rq

内核在每个 CPU 上都有一个调度队列,叫 runqueue(运行队列), 对应的结构体是 struct rq。 这是一个通用调度队列,里面包含了大量与调度相关的字段,比如 完全公平调度器 struct cfs_rq cfs

本文主要关注的是与计算 load 有关的几个字段,

// kernel/sched/sched.h

// This is the main, per-CPU runqueue data structure.
struct rq {
    unsigned int         nr_running;        // running task 数量
    struct   cfs_rq      cfs;               // 完全公平调度器 CFS

    unsigned long        nr_uninterruptible;// 不可中断状态的 task 数量

    // 与 load 计算相关的字段
    unsigned long        calc_load_update;  // 上次计算 load 的时刻
    long                 calc_load_active;  // 上次计算 load 时的 nr_active (running+uninterruptible)
    ...
};

如上,有两个 calc_load_ 前缀的变量,表明这是计算 load 用的, 后缀表示变量的用途,

  • calc_load_update:上次计算 load 的时刻;
  • calc_load_tasks:上次计算 load 时的 running+uninterruptible 状态的 task 数量(内核代码中 task 表示的是一个进程或一个线程);

代码中看到这俩变量时,可能觉得更像是函数名而不是变量。如果更倾向于可读性,这俩变量可以改为:

  • prev_load_calc_time
  • prev_load_calc_active_tasks 或 prev_load_calc_nr_active

2.2.2 Load 计算相关的全局变量

计算 load 用的几个全局变量:

// kernel/sched/loadavg.c

atomic_long_t calc_load_tasks;  // CPU 上 threads 数量
unsigned long calc_load_update; // 时间戳
unsigned long avenrun[3];
  • 前两个跟 runqueue 里面的字段对应,但计算的是所有 CPU 上所有 runqueue 对应字段的总和, 因为 load 表示是系统负载,不是单个 CPU 的负载。
  • 第三个变量 avenrun[3] 前面也看到了,表示的是过去 1、5、15 分钟内的 loadavg。

2.2.3 内核时间基础:HZ/tick/jiffies/uptime

Linux 内核的周期性事件基于 timer interrupt 触发,计时基于 HZ,这是一个编译常量,通常与 CPU 架构相关。 例如,对于最常见的 X86 架构,

  1. 默认 HZ 是 1000,也就是 1s 内触发 1000 次 timer interrupt,interrupt 间隔是 1s/HZ = 1ms; 这和处理器的晶振频率(例如 2.1G HZ)并不是一个概念,别搞混了;
  2. 在计时上,一次 timer interrupt 也称为一次 tick

    • tick 的间隔称为 tick period,因此有 tick period = 1s/HZ
    • 在内核中有两个相关变量
     // kernel/time/tick-common.c
        
     // Tick next event: keeps track of the tick time
     ktime_t tick_next_period;
     ktime_t tick_period;
        
     // Setup the tick device
     static void tick_setup_device(struct tick_device *td, struct clock_event_device *newdev, int cpu, const struct cpumask *cpumask) {
         tick_next_period = ktime_get();
         tick_period = NSEC_PER_SEC / HZ; // 内部用 ns 表示
     }
    
  3. 系统启动以来的 tick 次数记录在全局变量 jiffies 里面,

     // linux/jiffies.h
        
     extern unsigned long volatile jiffies;
    

    系统启动时初始化为 0,每次 timer interrupt 加 1。由于 timer interrupt 是 1ms, 因此 jiffies 就是以 1ms 为单位的系统启动以来的时间。 jiffies_64 是 64bit 版本的 jiffies。

  4. jiffies 和 uptime 的关系:

    uptime_in_seconds = jiffies / HZ

    以及

    jiffies = uptime_in_seconds * HZ

有了以上基础,接下来可以看算法实现了。

2.3 算法实现

2.3.1 调用栈

每次 timer interrupt 之后,会执行到 tick_handle_periodic(),接下来的调用栈:

tick_handle_periodic // kernel/time/tick-common.c
  |-cpu = smp_processor_id();
  |-tick_periodic(cpu)
      |-if (tick_do_timer_cpu == cpu) { // 只有一个 CPU 负责计算 load,不然就乱套了
      |   tick_next_period = ktime_add(tick_next_period, tick_period); // 更新全局变量
      |   do_timer(ticks=1)
      |   | |-jiffies_64 += ticks; // 都是 ns 表示的
      |   | |-calc_global_load();
      |   |     | // update the avenrun load estimates 10 ticks after the CPUs have updated calc_load_tasks.
      |   |     |-if jiffies < calc_load_update + 10
      |   |     |   return;
      |   |     |
      |   |     |-active = atomic_long_read(&calc_load_tasks);
      |   |     |-active = active > 0 ? active * FIXED_1 : 0;
      |   |     |-avenrun[0] = calc_load(load=avenrun[0], exp=EXP_1, active);
      |   |     |                |-newload = avenrun[0] * EXP_1 + active * (FIXED_1 - EXP_1)
      |   |     |                |-if (active >= avenrun[0])
      |   |     |                |     newload += FIXED_1-1;
      |   |     |                | 
      |   |     |                |-return newload / FIXED_1;
      |   |     |
      |   |     |-avenrun[1] = calc_load(avenrun[1], EXP_5, active);
      |   |     |-avenrun[2] = calc_load(avenrun[2], EXP_15, active);
      |   |     |-WRITE_ONCE(calc_load_update, sample_window + LOAD_FREQ); // 下一次计算 loadavg 的时间:5s 后
      |   |     |
      |   |     |-calc_global_nohz(); // In case we went to NO_HZ for multiple LOAD_FREQ intervals catch up in bulk.
      |   |         |-calc_load_n
      |   |             |-calc_load
      |   |
      |   update_wall_time()
      |-}
      |-update_process_times(user_mode(get_irq_regs()));
      |-profile_tick(CPU_PROFILING);

timer 中断之后,执行 tick_handle_periodic(),它首先 获取程序当前所在的 CPU ID,然后执行 tick_periodic(cpu)。

接下来的大致步骤:

  1. 判断是不是当前 CPU 负责计算 load,是的话才继续;否则只更新一些进程 timer 信息就返回了;
  2. 如果是当前 CPU 负责计算,则更新下次 tick 时间戳 tick_next_period,也就是在当前时间基础上加上 tick_period (1/HZ 秒,x86 默认是 1ms);
  3. 调用 do_timer(ticks=1) 尝试计算一次 load;这里说尝试是因为不一定真的会计算,可能会提前返回;
  4. 更新 jiffies_64;然后计算 load,

    如果 jiffies 比上次计算 load 的时间戳 + 10 要小,就不计算; 否则,调用 calc_load() 开始计算 loadavg。

计算 load 的算法就是我们上一节介绍过的了:

The global load average is an exponentially decaying average of nr_running + nr_uninterruptible.

Once every LOAD_FREQ5 秒):

  nr_active = 0;
  for cpu in cpus:
      nr_active += cpu->nr_running + cpu->nr_uninterruptible;

  avenrun[n] = avenrun[0] * exp_n + nr_active * (1 - exp_n)

2.3.2 一些实现细节

runqueue load 字段初始化

static void sched_rq_cpu_starting(unsigned int cpu) {
    struct rq *rq = cpu_rq(cpu);

    rq->calc_load_update = calc_load_update;
    update_max_interval();
}

void __init sched_init(void) {
    for_each_possible_cpu(i) {
        struct rq *rq;

        rq = cpu_rq(i);
        raw_spin_lock_init(&rq->lock);
        rq->nr_running = 0;
        rq->calc_load_active = 0;
        rq->calc_load_update = jiffies + LOAD_FREQ;
    }
}

判断是否由当前 CPU 执行 load 计算

// kernel/time/tick-common.c

/*
 * tick_do_timer_cpu is a timer core internal variable which holds the CPU NR
 * which is responsible for calling do_timer(), i.e. the timekeeping stuff. This
 * variable has two functions:
 *
 * 1) Prevent a thundering herd issue of a gazillion of CPUs trying to grab the
 *    timekeeping lock all at once. Only the CPU which is assigned to do the
 *    update is handling it.
 *
 * 2) Hand off the duty in the NOHZ idle case by setting the value to
 *    TICK_DO_TIMER_NONE, i.e. a non existing CPU. So the next cpu which looks
 *    at it will take over and keep the time keeping alive.  The handover
 *    procedure also covers cpu hotplug.
 */
int tick_do_timer_cpu __read_mostly = TICK_DO_TIMER_BOOT;

do_timer()

// kernel/time/tick-common.c

// kernel/time/timekeeping.c
void do_timer(unsigned long ticks) {
    jiffies_64 += ticks;
    calc_global_load();
}

calc_global_load() -> calc_load()

// kernel/time/tick-common.c

/*
 * calc_load - update the avenrun load estimates 10 ticks after the
 * CPUs have updated calc_load_tasks.
 *
 * Called from the global timer code.
 */
void calc_global_load(void) {
    unsigned long sample_window;
    long active, delta;

    sample_window = READ_ONCE(calc_load_update);
    if (time_before(jiffies, sample_window + 10))
        return;

    /*
     * Fold the 'old' NO_HZ-delta to include all NO_HZ CPUs.
     */
    delta = calc_load_nohz_read();
    if (delta)
        atomic_long_add(delta, &calc_load_tasks);

    active = atomic_long_read(&calc_load_tasks);
    active = active > 0 ? active * FIXED_1 : 0;

    avenrun[0] = calc_load(avenrun[0], EXP_1, active);
    avenrun[1] = calc_load(avenrun[1], EXP_5, active);
    avenrun[2] = calc_load(avenrun[2], EXP_15, active);

    WRITE_ONCE(calc_load_update, sample_window + LOAD_FREQ);

    /*
     * In case we went to NO_HZ for multiple LOAD_FREQ intervals
     * catch up in bulk.
     */
    calc_global_nohz();
}

/*
 * a1 = a0 * e + a * (1 - e)
 */
static inline unsigned long
calc_load(unsigned long load, unsigned long exp, unsigned long active) {
    unsigned long newload = load * exp + active * (FIXED_1 - exp);
    if (active >= load)
        newload += FIXED_1-1;

    return newload / FIXED_1;
}

2.4 考古

[2] 中对 Linux loadavg 算法演变做了一些考古。 主要是关于 Linux 为什么计算 loadavg 时需要考虑到 uninterruptible sleep 线程数量, 以及 Linux 的 loadavg 和其他操作系统的 loadavg 有什么区别。

2.4.1 计入不可中断 sleep

Uninterruptible Sleep (D) 状态通常是同步 disk IO 导致的 sleeping;

  • 这种状态的 task 不受中断信号影响,例如阻塞在 disk IO 和某些 lock 上的 task;
  • 将这种状态的线性引入 loadavg 计算的 patch

      From: Matthias Urlichs <urlichs@smurf.sub.org>
      Subject: Load average broken ?
      Date: Fri, 29 Oct 1993 11:37:23 +0200
        
      The kernel only counts "runnable" processes when computing the load average. 
      I don't like that; the problem is that processes which are swapping or 
      waiting on "fast", i.e. noninterruptible, I/O, also consume resources.
        
      It seems somewhat nonintuitive that the load average goes down when you 
      replace your fast swap disk with a slow swap disk...
        
      Anyway, the following patch seems to make the load average much more 
      consistent WRT the subjective speed of the system. And, most important, the 
      load is still zero when nobody is doing anything. ;-)
        
      --- kernel/sched.c.orig	Fri Oct 29 10:31:11 1993
      +++ kernel/sched.c	Fri Oct 29 10:32:51 1993
      @@ -414,7 +414,9 @@
       	unsigned long nr = 0;
        
       	for(p = &LAST_TASK; p > &FIRST_TASK; --p)
      -		if (*p && (*p)->state == TASK_RUNNING)
      +		if (*p && ((*p)->state == TASK_RUNNING) ||
      +		           (*p)->state == TASK_UNINTERRUPTIBLE) ||
      +		           (*p)->state == TASK_SWAPPING))
       			nr += FIXED_1;
       	return nr;
       }
    
  • 使得 Linux 系统 loadavg 表示的不再是 “CPU load averages”,而是 “system load averages”。

2.4.2 Linux vs. 其他 OS:loadavg 区别

Linux load 与其他操作系统 load 的区别: 几点重要信息:

操作系统 load average 概念和内涵 优点
Linux 准确说是 system load average,衡量的是系统整体资源,而非 CPU 这一种资源。包括了正在运行和等待(CPU, disk, uninterruptible locks 等资源)运行的所有线程数量,换句话说,统计所有非完全空闲的线程(threads that aren’t completely idle)数量 考虑到了 CPU 之外的其他资源
其他操作系统 指的就是 CPU load average,衡量所有 CPU 上 running+runnable 线程的数量 理解简单,也更容易推测 CPU 资源的使用情况

3 讨论

3.1 Load 很高,所有进程都会受影响吗?

不一定。

Load 是根据所有 CPUrunqueue 状态综合算出的一个数字,load 很高并不能代表每个 CPU 都过载了

例如,少数几个 CPU 上有大量或持续活跃线程,就足以把系统 load 打到很高, 但这种情况下,其他 CPU 上的任务并不受影响。下面来模拟一下。

3.1.1 模拟:单个 CPU 把系统 load 打高上百倍

下面是一台 4C 空闲机器上,

$ cat /proc/cpuinfo | grep processor
processor       : 0
processor       : 1
processor       : 2
processor       : 3

$ uptime
 16:52:13 up  3:15,  7 users,  load average: 0.22, 0.18, 0.18

系统 loadavg1 只有 0.22。接下来创建 30 个 stress 任务并固定到 CPU 3 上执行,

# spawn 30 workers spinning on sqrt()
$ taskset -c 3 stress -c 30 --timeout 120s

然后通过 top 命令 5s 查看一次 loadavg1

$ for n in `seq 1 120`; do top -b -n1 | head -n1 | awk '{print $(NF-2)}' | sed 's/,//'; sleep 5; done
0.24
0.22
0.26
2.64
4.84
6.85
8.70
10.41
11.98
13.42
14.75
15.97
17.09
18.13
19.08
19.95
20.76
21.50
22.18
22.81
23.38
23.91
24.40
24.85
25.26
25.64
25.99
23.91

可以看到,loadavg 最高到了 25 以上,比我们压测之前高了 100 多倍。 看一下压测期间的 CPU 利用率

$ mpstat -p ALL 1
Average:  CPU    %usr  %nice   %sys %iowait   %irq  %soft  %steal  %guest  %gnice   %idle
Average:  all   25.02   0.00   0.17    0.00   0.00   0.00    0.00    0.00    0.00   74.81
Average:    0    0.00   0.00   0.33    0.00   0.00   0.00    0.00    0.00    0.00   99.67 # -+
Average:    1    0.00   0.00   0.33    0.00   0.00   0.00    0.00    0.00    0.00   99.67 #  | CPU 0-2 are idle
Average:    2    0.00   0.00   0.00    0.00   0.00   0.00    0.00    0.00    0.00  100.00 # -+
Average:    3  100.00   0.00   0.00    0.00   0.00   0.00    0.00    0.00    0.00    0.00
           #      |
           # CPU 3 is 100% busy

可以看到只有 CPU 3 维持在 100%,其他几个 CPU 都是绝对空闲的。

3.1.2 cpuset vs. cpu quota

假如我们的压测程序是跑在一个 pod 里,这种情况对应的就是 cpuset 模式 —— 独占几个固定的 CPU。这种情况下除了占用这些 CPU 的 Pod 自身,其他 Pod 是不受影响的 (单就 CPU 这一种资源来说。实际上 pod 还共享其他资源,例如宿主机的总网络带宽、IO 等)。

非 cpuset 的 pod 没有独占 CPU,系统通过 cgroup/cfs 来分配给它们 CPU 份额, 线程也会在 CPU 之间迁移,因此影响其他 pod 的可能性大一些。一些相关内容:

  1. Linux CFS 调度器:原理、设计与内核实现(2023)
  2. k8s 基于 cgroup 的资源限额(capacity enforcement):模型设计与代码实现(2023)

3.2 僵尸进程

loadavg 也会计入僵尸进程(Z 状态),因此如果有大量僵尸进程,也会看到系统的 load 很高。

3.3 Load != CPU 利用率

loadavg 衡量系统整体在过去一段时间内的负载状态,

  • 每 5s 对所有 CPU 上所有 runqueue 采样一次,本质上表示的是 runqueue length,并不是 CPU 利用率;
  • 是一种 指数衰减移动平均(exponentially-damped moving average);

CPU 利用率统计的是 CPU 繁忙的时间占比,比如对于单个 CPU,

  • 在 1s 的周期内有 0.5s 在执行任务,剩下的时间是空闲的,那 CPU 利用率就是 50%;
  • 利用率上限是 100%;

3.4 Load 是否是一个很好的告警指标?

根据以上讨论,load 高并不一定表示每个 CPU 都繁忙。极端情况下,单核或单个应用的线程太多就能导致 load 飙升成百上千倍, 但这时可能除了少数几个核,其他核上的应用都不受影响,所以用 load 来告警并不合适; 更准确的是每个 CPU 独立计算 load,即 per-core-loadavg,但目前并没有这个指标。

但另一方面,load 趋势变化在实际排障中还是很有参加价值的, 比如之前 loadavg 是 0.5,现在突然变成 0.8,那说明系统任务数量或状态还是有较大变化的, 为进一步排查指明了一个方向。

4 实用指南

既然 load 只能用来看趋势和相对大小,判断是否可能有问题,而无法及衡量问题的严重程度及进一步定位问题根源, 那怎么进一步排查和定位问题呢?下面是一些参考。

4.1 USE (Used-frequency, Saturation, Errors) 方法论

《Systems Performance: Enterprise and the Cloud》(中文版名为《性能之巅》)一书提出了 衡量一种资源使用状况的 3 个维度的指标 [5]:

  • 利用率(Utilization): 例如,CPU 利用率 90%(有 90% 的时间内,这个 CPU 有被使用到);
  • 饱和度(Saturation): 用 wait-queue length 衡量,例如,CPU 的平均 runqueue length 是 4;
  • 错误数(Errors): 例如网卡有 50 个丢包

但 [7] 中已经分析过,USE 术语给 “Utilization” 这个词带来了相当大的歧义,因为大家说到 “utilization”(“利用率”) 时,普遍指的是一种资源总共被使用了多少 —— 比如我有 4 个 CPU,其中 3 个 100% 在运行,那 “utilization” 就是 75% —— 这对应的其实是 USE 里面的 “saturation”(“饱和度”)概念。作者重新将 “Utilization” 定义为“使用频率” —— 在采样周期内,有多长时间这种资源有被使用到(不管使用了多少) —— 给一个已经普遍接受的术语重新定义概念,造成很大的理解和交流障碍。 为此,本文把 USE 中的 “U” 解释成 “Used-frequency”。方法论没有任何变化,只是改个术语来减少混淆。

举例,对于一片 100 核的 GPU,

  • Used-frequency:在采样周期内,这种资源被使用到的时间比例;例如采样周期是 1s, 其中 0.8s 的时间内有至少有一个核在执行计算,那 U 就是 80%; 实际用了几个核,从这个指标推测不出来
  • Saturation:100% * used_cores / total_cores;这个指标可以推测平均用了几个核
  • Errors:(一般是 Saturation 超过一定阈值之后,)这个 GPU 的报错数量。

4.2 指标

进一步排查,可以参考下列 U 和 S 指标。

4.2.1 Used-frequency 指标

定义 workload 行为特征方面比较有用,

  • per-CPU used-frequency:mpstat -P ALL 1
  • per-process CPU used-frequency: eg, top, pidstat 1

4.2.2 Saturation 指标

瓶颈分析方面比较有用:

  1. Per-CPU 调度延迟(CPU run queue latency

    • /proc/schedstat

        $ cat /proc/schedstat
        version 15
        timestamp 4300445966
        cpu0 0 0 0 0 0 0 1251984973307 142560271674 30375313
        cpu1 0 0 0 0 0 0 1423130608498 155128353435 40480095
        cpu2 0 0 0 0 0 0 1612417112675 603370909483 43658159
        cpu3 0 0 0 0 0 0 1763144199179 4220860238053 31154491
      
    • perf sched
    • bcc runqlat 脚本
  2. 全局调度队列长度(CPU run queue length): 这个指标能看出有没有问题,但是很难估计问题的严重程度。

    • vmstat 1,看 r 列的数据;

        $ vmstat 1
        procs -----------memory---------- ---swap-- -----io---- -system-- ------cpu-----
         r  b   swpd   free   buff  cache   si   so    bi    bo   in   cs us sy id wa st
         2  0 220532  69004  56416 1305500    1   10   387   370  900 1411  9  2 89  0  0
         0  0 220532  69004  56416 1305700    0    0     0     0  774 1289  0  1 99  0  0
        ...
      
    • bcc runqlen 脚本

  3. Per-thread run queue (scheduler) latency: 这是最好的 CPU 饱和度指标,它表示 task/thread 已经 runnable 了,但是还没有等到它的时间片; 可以计算出一个线程花在 scheduler latency 上的时间百分比。这个比例很容易量化,看出问题的严重程度。 具体指标:

    • /proc/PID/schedstats
    • delaystats
    • perf sched

5 结束语

本文从遇到的具体问题出发,研究了一些 Linux load 相关的内容。 一些内容和理解都还比较粗糙,后面有机会再完善。

References

  1. High System Load with Low CPU Utilization on Linux?, tanelpoder.com, 2020
  2. Linux Load Averages: Solving the Mystery, brendangregg.com, 2017
  3. UNIX Load Average Part 1: How It Works, fortra.com
  4. kernel/sched/loadavg.c, 2021
  5. (笔记)《Systems Performance: Enterprise and the Cloud》(Prentice Hall, 2013), 2020
  6. Linux Trouble Shooting Cheat Sheet, 2020
  7. Understanding NVIDIA GPU Performance: Utilization vs. Saturation (2023)
❌
❌