C++ `perf_event_open`:利用 Linux perf API 访问 CPU 性能计数器

哈喽,各位好!今天咱们来聊聊C++里怎么玩转Linux perf_event_open,也就是直接操纵CPU性能计数器。准备好了吗?Let’s dive in!

开场白:为啥要碰这玩意儿?

想象一下,你写了个超牛逼的C++程序,自我感觉良好,觉得速度飞起。但真相往往是残酷的:CPU可能在偷偷摸摸地打盹、缓存未命中让你欲哭无泪、分支预测错误让你心态爆炸。这时候,你就需要一个“显微镜”,能帮你观察CPU的一举一动,找到性能瓶颈。perf_event_open 就是这把显微镜的镜片!

直接使用 perf_event_open 系统调用,可以让你更精准、更灵活地收集性能数据。虽然有很多高级工具(比如 perf recordperf top)帮你做了封装,但直接用 perf_event_open 就像是自己造火箭,能深入理解底层原理,定制化测量方案。

perf_event_open:主角登场

perf_event_open 是 Linux 内核提供的一个系统调用,允许用户空间程序访问 CPU 的性能计数器。它的原型长这样:

#include <linux/perf_event.h>
#include <sys/syscall.h>
#include <unistd.h>

long perf_event_open(struct perf_event_attr *attr, pid_t pid, int cpu, int group_fd, unsigned long flags);

别怕,虽然看起来有点吓人,但其实没那么复杂。

  • attr 这是一个指向 perf_event_attr 结构体的指针,这个结构体定义了你想要监控的事件类型、配置等等。 它是灵魂人物,所有配置都在这里面。
  • pid 你想监控哪个进程?填进程ID。如果想监控所有进程,填 -1
  • cpu 你想监控哪个CPU?填CPU编号。如果想监控所有CPU,填 -1
  • group_fd 把多个事件放在一个组里,可以一起启动、停止、读取。如果不需要分组,填 -1
  • flags 一些额外的控制标志,一般填 0 就行。

返回值:成功时返回一个文件描述符,失败时返回 -1,并设置 errno

perf_event_attr:配置你的监控目标

这个结构体是关键,我们来仔细看看它有哪些重要的成员(省略了一些不常用的):

成员名 类型 描述
type __u32 事件类型,比如硬件事件、软件事件、tracepoint事件等等。常用的有 PERF_TYPE_HARDWAREPERF_TYPE_SOFTWARE
size __u32 perf_event_attr 结构体的大小,必须设置为 sizeof(struct perf_event_attr)
config __u64 事件的具体配置,取决于 type。比如,如果是硬件事件,config 可以指定要监控哪个硬件计数器(比如 PERF_COUNT_HW_CPU_CYCLESPERF_COUNT_HW_INSTRUCTIONS)。
union { ... } union 一个联合体,里面定义了几个用于更精细配置的成员。常用的有 sample_periodsample_freq,用于设置采样频率。
disabled __u32 是否禁用计数器。1 表示禁用,0 表示启用。一开始可以先禁用,配置好之后再启用。
inherit __u32 是否让子进程继承计数器。1 表示继承,0 表示不继承。
pinned __u32 是否将计数器固定在特定的硬件计数器上。这可以避免计数器在不同事件之间切换,提高精度。但硬件计数器的数量是有限的,如果所有计数器都被 pinned,可能会导致 perf_event_open 失败。
exclude_user __u32 是否排除用户空间的采样。1 表示排除,0 表示不排除。
exclude_kernel __u32 是否排除内核空间的采样。1 表示排除,0 表示不排除。
exclude_hv __u32 是否排除 Hypervisor 的采样。1 表示排除,0 表示不排除。
exclude_idle __u32 是否排除 CPU 空闲时的采样。1 表示排除,0 表示不排除。
mmap_data __u32 是否使用 mmap 映射数据页。如果设置为 1,则可以通过 mmap 读取性能数据。
mmap_instr __u32 是否使用 mmap 映射指令页。如果设置为 1,则可以通过 mmap 读取指令数据。
read_format __u64 读取格式,用于指定从文件描述符读取的数据的格式。常用的有 PERF_FORMAT_TOTAL_TIME_ENABLEDPERF_FORMAT_TOTAL_TIME_RUNNINGPERF_FORMAT_IDPERF_FORMAT_GROUP
sample_id_all __u32 是否在每次采样时都包含完整的 sample ID 信息。
inherit_stat __u32 是否让子进程继承统计信息。
enable_on_exec __u32 是否在 exec 系统调用时启用计数器。
use_clockid __u32 使用哪个时钟源。
clockid __s32 时钟源 ID。
sample_type __u64 采样类型,用于指定采样数据中包含哪些信息。常用的有 PERF_SAMPLE_IPPERF_SAMPLE_TIDPERF_SAMPLE_TIMEPERF_SAMPLE_ADDRPERF_SAMPLE_READPERF_SAMPLE_CALLCHAINPERF_SAMPLE_RAWPERF_SAMPLE_BRANCH_STACK
read_freq __u64 读取频率。
freq __u32 是否使用频率采样。1 表示使用,0 表示使用周期采样。
wakeup_events __u32 唤醒事件数量。
bp_type __u32 断点类型。
bp_addr __u64 断点地址。
bp_len __u64 断点长度。
union { ... } union 一个联合体,里面定义了几个用于指定采样周期的成员。常用的有 sample_periodsample_freq

是不是有点眼花缭乱?没关系,刚开始用的时候,掌握几个最常用的就够了。

实战演练:监控CPU周期

咱们来写一个简单的例子,监控 CPU 周期数:

#include <iostream>
#include <linux/perf_event.h>
#include <sys/syscall.h>
#include <unistd.h>
#include <stdint.h>
#include <stdlib.h>
#include <stdio.h>
#include <string.h>
#include <errno.h>

// 定义 perf_event_open 系统调用的宏
#define perf_event_open(hw_event, pid, cpu, group_fd, flags) 
    syscall(__NR_perf_event_open, hw_event, pid, cpu, group_fd, flags)

int main() {
    struct perf_event_attr pe;
    memset(&pe, 0, sizeof(struct perf_event_attr)); // 初始化结构体

    pe.type = PERF_TYPE_HARDWARE;              // 硬件事件
    pe.config = PERF_COUNT_HW_CPU_CYCLES;      // 监控 CPU 周期
    pe.size = sizeof(struct perf_event_attr);  // 设置结构体大小
    pe.disabled = 1;                           // 初始状态禁用,稍后启用
    pe.exclude_kernel = 1;                     // 排除内核空间
    pe.exclude_hv = 1;                         // 排除 Hypervisor
    pe.read_format = PERF_FORMAT_TOTAL_TIME_ENABLED | PERF_FORMAT_TOTAL_TIME_RUNNING;

    int fd = perf_event_open(&pe, 0, -1, -1, 0); // 监控当前进程,所有CPU,不分组
    if (fd == -1) {
        fprintf(stderr, "Error opening leader %llx: %sn", pe.config, strerror(errno));
        exit(EXIT_FAILURE);
    }

    // 启用计数器
    ioctl(fd, PERF_EVENT_IOC_RESET, 0);
    ioctl(fd, PERF_EVENT_IOC_ENABLE, 0);

    // 模拟一些工作
    long long int a = 0;
    for (int i = 0; i < 100000000; ++i) {
        a += i;
    }
    printf("Result: %lldn", a);

    // 禁用计数器
    ioctl(fd, PERF_EVENT_IOC_DISABLE, 0);

    // 读取计数器值
    long long count[3];
    read(fd, &count, sizeof(count));

    // 输出结果
    double time_enabled = (double)count[1] / 1000000000.0;
    double time_running = (double)count[2] / 1000000000.0;
    double utilization = (time_enabled > 0) ? time_running / time_enabled : 0;

    printf("CPU Cycles: %lldn", count[0]);
    printf("Time enabled: %.3f sn", time_enabled);
    printf("Time running: %.3f sn", time_running);
    printf("Utilization: %.3f %%n", utilization * 100);

    close(fd);
    return 0;
}

代码解读:

  1. 包含头文件: 包含了必要的头文件,比如定义 perf_event_attr<linux/perf_event.h>,以及系统调用相关的 <sys/syscall.h><unistd.h>
  2. 定义系统调用宏: 因为 perf_event_open 不是标准的 POSIX 函数,所以需要手动定义系统调用宏。
  3. 初始化 perf_event_attr 创建一个 perf_event_attr 结构体,并用 memset 初始化为 0。
  4. 设置事件类型和配置: 设置 typePERF_TYPE_HARDWAREconfigPERF_COUNT_HW_CPU_CYCLES,表示要监控硬件事件中的 CPU 周期数。
  5. 设置结构体大小: size 必须设置为 sizeof(struct perf_event_attr)
  6. 禁用计数器: disabled 设置为 1,表示初始状态禁用计数器。
  7. 排除内核和 Hypervisor: exclude_kernelexclude_hv 设置为 1,表示排除内核空间和 Hypervisor 的采样。
  8. 设置读取格式: read_format 设置为 PERF_FORMAT_TOTAL_TIME_ENABLED | PERF_FORMAT_TOTAL_TIME_RUNNING,表示读取的数据包含计数器启用时间和运行时间。
  9. 调用 perf_event_open 调用 perf_event_open 创建性能计数器,pid 设置为 0 表示监控当前进程,cpu 设置为 -1 表示监控所有 CPU,group_fd 设置为 -1 表示不分组。
  10. 错误处理: 检查 perf_event_open 的返回值,如果失败则输出错误信息并退出。
  11. 启用计数器: 使用 ioctl 启用计数器,PERF_EVENT_IOC_RESET 用于重置计数器,PERF_EVENT_IOC_ENABLE 用于启用计数器。
  12. 模拟工作: 执行一段简单的循环,模拟一些 CPU 工作。
  13. 禁用计数器: 使用 ioctl 禁用计数器,PERF_EVENT_IOC_DISABLE 用于禁用计数器。
  14. 读取计数器值: 使用 read 从文件描述符读取计数器值。由于 read_format 设置了 PERF_FORMAT_TOTAL_TIME_ENABLED | PERF_FORMAT_TOTAL_TIME_RUNNING,所以读取的数据包含三个 long long int 类型的值:计数器值、启用时间和运行时间。
  15. 输出结果: 将读取到的计数器值和时间信息输出到控制台。
  16. 关闭文件描述符: 使用 close 关闭文件描述符。

编译运行:

保存为 cpu_cycles.cpp,然后编译:

g++ cpu_cycles.cpp -o cpu_cycles

运行:

sudo ./cpu_cycles

注意:需要 sudo 权限,因为访问性能计数器通常需要 root 权限。

输出结果:

你可能会看到类似这样的输出:

Result: 4999999950000000
CPU Cycles: 1234567890
Time enabled: 1.234 s
Time running: 1.000 s
Utilization: 81.030 %

这表示程序在运行期间,CPU 消耗了 1234567890 个周期,启用时间为 1.234 秒,运行时间为 1.000 秒,CPU 利用率为 81.030%。

更进一步:采样

上面的例子只是简单地统计了 CPU 周期数。perf_event_open 还可以进行采样,即在满足一定条件(比如每隔一定数量的事件发生)时,记录 CPU 的状态(比如指令指针、调用栈等)。

要进行采样,需要设置 sample_periodsample_freq,以及 sample_type

  • sample_period 每隔多少个事件发生时采样一次。
  • sample_freq 每秒采样多少次。
  • sample_type 指定采样数据中包含哪些信息。

例如,要每隔 10000 个 CPU 周期采样一次,并记录指令指针和线程ID,可以这样设置:

pe.sample_period = 10000;
pe.sample_type = PERF_SAMPLE_IP | PERF_SAMPLE_TID;
pe.mmap_data = 1; // 启用 mmap

然后,需要使用 mmap 将性能数据映射到用户空间,并解析采样数据。这部分代码比较复杂,就不在这里详细展开了。可以参考 Linux 内核文档和一些开源工具的源码。

高级技巧:事件分组

可以将多个事件放在一个组里,一起启动、停止、读取。这可以方便地计算一些比例,比如 CPI(Cycles Per Instruction)。

要将事件分组,需要:

  1. 创建一个 leader 事件,作为组的代表。
  2. 创建 follower 事件,并将 group_fd 设置为 leader 事件的文件描述符。
  3. 设置 read_formatPERF_FORMAT_GROUP,以便读取整个组的数据。

注意事项:

  • 权限: 访问性能计数器通常需要 root 权限。
  • 资源限制: 硬件计数器的数量是有限的,如果同时监控的事件太多,可能会导致 perf_event_open 失败。
  • 性能影响: 监控性能会对程序本身产生一定的性能影响,尤其是采样时。
  • 内核版本: 不同的内核版本可能支持不同的事件类型和配置。

总结:

perf_event_open 是一个强大的工具,可以让你深入了解 CPU 的运行状态,找到性能瓶颈。虽然直接使用它比较复杂,但可以让你更好地理解底层原理,定制化测量方案。希望今天的讲解能让你对 perf_event_open 有一个初步的了解。

下次再见!

发表回复

您的邮箱地址不会被公开。 必填项已用 * 标注