C++ `perf_event_open`:利用 Linux perf 事件获取硬件性能计数器

好的,各位听众,欢迎来到今天的“C++ perf_event_open: 硬件性能计数器大揭秘”讲座!今天咱们不讲那些高深莫测的理论,就聊聊如何用C++来“窥探”Linux内核,看看你的CPU到底在干啥。

开场白:CPU,你到底在忙啥?

各位有没有好奇过,你的程序跑起来,CPU到底在忙啥?是忙着加减乘除,还是忙着读写内存?或者是在拼命地处理分支预测错误?想知道这些,光靠GDB是不够的,我们需要更强大的武器——Linux perf events!

Linux perf events 是一个强大的性能分析工具,它可以让你访问CPU的各种硬件性能计数器,比如指令数、缓存命中率、分支预测错误等等。而perf_event_open 系统调用,就是打开这些“潘多拉魔盒”的钥匙。

第一部分:perf_event_open:打开性能之门

perf_event_open 是一个系统调用,它的作用是创建一个文件描述符,这个文件描述符代表一个性能计数器。通过读取这个文件描述符,你就可以获取计数器的值。

先来看看它的函数原型:

#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);

是不是觉得有点吓人?别怕,咱们慢慢来。

  • *`struct perf_event_attr attr`**: 这是最重要的参数,它是一个结构体,描述了你想要监控的事件类型、配置等等。
  • pid_t pid: 指定你要监控的进程ID。如果你想监控所有进程,可以传 -1
  • int cpu: 指定你要监控的CPU核心。如果你想监控所有CPU核心,可以传 -1
  • int group_fd: 用于将多个事件分组,这样可以同时测量它们。如果不需要分组,传 -1
  • unsigned long flags: 一些标志位,控制事件的行为。

struct perf_event_attr:性能事件的“身份证”

这个结构体定义了你想要监控的事件的各种属性。咱们挑几个常用的讲讲:

struct perf_event_attr {
    __u32 type;        /* Type of event */
    __u32 size;        /* Size of attribute structure */
    __u64 config;      /* Event-specific configuration */
    union {
        __u64 sample_period;
        __u64 sample_freq;
    };
    __u64 sample_type;
    __u64 read_format;
    __u64 disabled:1,
        inherit:1,        /* inherit across fork/exec */
        pinned:1,          /* only counts when enabled */
        exclusive:1,       /* only user-space can enable */
        exclude_user:1,    /* don't count user */
        exclude_kernel:1,  /* don't count kernel */
        exclude_hv:1,      /* don't count hypervisor */
        exclude_idle:1,    /* don't count when idle */
        mmap:1,            /* include mmap data */
        comm:1,            /* include comm data */
        freq:1,            /* use freq, not period */
        inherit_stat:1,    /* inherit counters */
        enable_on_exec:1,  /* Enable on exec */
        task:1,            /* trace individual tasks */
        watermark:1,       /* wakeup_watermark */
        precise_ip:2,      /* skid constraint */
        mmap_data:1,       /* non-executable mmap data */
        sample_id_all:1,
        exclude_host:1,    /* Don't count in the host */
        exclude_guest:1,   /* Don't count in the guest */
        exclude_callchain:1,
        exclude_missing:1,
        use_clockid:1,
        context_switch:1,
        write_backward:1,
        namespaces:1,
        ksymbol:1,
        bpf_event:1,
        aux_output:1,
        cgroup:1;
    __u32 wakeup_events;
    union {
        __u32 bp_type;
        __u64 config1;
    };
    union {
        __u64 bp_addr;
        __u64 config2;
    };
    __u64 bp_len;
    union {
        __u64 branch_sample_type;
        __u64 reserved_2;
    };
    __u64 sample_regs_user;
    __u32 sample_stack_user;
    __s32 clockid;
    __u64 sample_regs_intr;
    __u32 aux_watermark;
    __u32 sample_max_stack;
    __u32 reserved_3;
    __u64 reserved_4;
};
  • type: 指定事件的类型,比如硬件事件、软件事件、tracepoint事件等等。常见的硬件事件类型有 PERF_TYPE_HARDWARE,对应的事件定义在 perf_event.h 中。
  • size: sizeof(struct perf_event_attr),必须设置正确。
  • config: 指定具体的事件。比如,如果你想监控指令数,可以设置 config = PERF_COUNT_HW_INSTRUCTIONS
  • sample_period / sample_freq: 用于采样。sample_period 表示每隔多少个事件采样一次,sample_freq 表示每秒采样多少次。
  • disabled: 如果设置为 1,则事件一开始是禁用的,需要手动启用。
  • exclude_user / exclude_kernel: 用于排除用户空间或内核空间的事件。

第二部分:代码实战:监控指令数

光说不练假把式,咱们来写一段代码,监控程序的指令数。

#include <iostream>
#include <iomanip>
#include <fstream>
#include <vector>
#include <numeric>
#include <stdexcept>
#include <chrono>
#include <thread>
#include <random>

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

// Error handling macro
#define CHECK(x)                                                                
  do {                                                                          
    if ((x) < 0) {                                                             
      std::cerr << "ERROR: " << strerror(errno) << " in " << __FILE__ << ":"   
                << __LINE__ << " (" << #x << ")" << std::endl;               
      throw std::runtime_error(strerror(errno));                                
    }                                                                           
  } while (0)

// perf_event_open wrapper
long perf_event_open(struct perf_event_attr *hw_event, pid_t pid, int cpu,
                     int group_fd, unsigned long flags) {
  long ret = syscall(__NR_perf_event_open, hw_event, pid, cpu, group_fd, flags);
  return ret;
}

int main() {
    struct perf_event_attr pe;
    long long count;
    int fd;

    // 初始化 perf_event_attr 结构体
    memset(&pe, 0, sizeof(struct perf_event_attr));
    pe.type = PERF_TYPE_HARDWARE;
    pe.config = PERF_COUNT_HW_INSTRUCTIONS;
    pe.size = sizeof(struct perf_event_attr);
    pe.disabled = 1; // 初始状态禁用,稍后启用
    pe.exclude_kernel = 1; // 排除内核空间的计数
    pe.exclude_hv = 1; // 排除 hypervisor 的计数

    // 打开性能计数器
    fd = perf_event_open(&pe, 0, -1, -1, 0);
    if (fd == -1) {
        std::cerr << "Error opening perf event: " << strerror(errno) << std::endl;
        return 1;
    }

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

    // 执行一些代码
    volatile int sum = 0;
    for (int i = 0; i < 1000000; ++i) {
        sum += i;
    }

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

    // 读取计数器的值
    read(fd, &count, sizeof(count));

    // 关闭文件描述符
    close(fd);

    std::cout << "执行的代码的指令数: " << count << std::endl;

    return 0;
}

这段代码做了这些事情:

  1. 初始化 perf_event_attr 结构体: 设置事件类型为硬件事件,具体事件为指令数,初始状态禁用,排除内核和hypervisor的计数。
  2. 打开性能计数器: 调用 perf_event_open 创建一个文件描述符。
  3. 启用计数器: 使用 ioctl 启用计数器。
  4. 执行一些代码: 这里执行了一个简单的循环,用于产生指令。
  5. 禁用计数器: 使用 ioctl 禁用计数器。
  6. 读取计数器的值: 使用 read 读取文件描述符,获取计数器的值。
  7. 关闭文件描述符: 使用 close 关闭文件描述符。

编译和运行

保存代码为 perf_example.cpp,然后编译它:

g++ perf_example.cpp -o perf_example

运行程序:

./perf_example

你应该会看到类似这样的输出:

执行的代码的指令数: 123456789

这个数字就是你的代码执行期间产生的指令数。

第三部分:更多玩法:事件分组、采样、以及更高级的技巧

掌握了基本的用法,咱们可以玩点更高级的。

事件分组

有时候,我们需要同时测量多个事件,比如指令数和缓存命中率。这时,可以使用事件分组。

// 创建一个事件组
int group_fd = perf_event_open(&pe1, 0, -1, -1, 0);
if (group_fd == -1) {
    // 处理错误
}

// 创建第二个事件,并将其添加到事件组
pe2.type = PERF_TYPE_HARDWARE;
pe2.config = PERF_COUNT_HW_CACHE_MISSES;
pe2.size = sizeof(struct perf_event_attr);
pe2.disabled = 1;
int fd2 = perf_event_open(&pe2, 0, -1, group_fd, 0); // 注意这里使用 group_fd
if (fd2 == -1) {
    // 处理错误
}

这样,fd2 就被添加到了 group_fd 代表的事件组中。启用 group_fd 也会同时启用 fd2

采样

采样可以让你在事件发生时获取更多的信息,比如指令地址、调用栈等等。

pe.type = PERF_TYPE_HARDWARE;
pe.config = PERF_COUNT_HW_CPU_CYCLES;
pe.size = sizeof(struct perf_event_attr);
pe.sample_period = 10000; // 每 10000 个 CPU 周期采样一次
pe.sample_type = PERF_SAMPLE_IP | PERF_SAMPLE_TID | PERF_SAMPLE_CALLCHAIN;
pe.wakeup_events = 1; // 每次采样后唤醒

fd = perf_event_open(&pe, pid, -1, -1, 0);

这段代码设置了每 10000 个 CPU 周期采样一次,并且每次采样都获取指令地址、线程ID和调用栈。

读取采样数据

采样数据可以通过 mmap 映射到用户空间。

size_t page_size = sysconf(_SC_PAGE_SIZE);
size_t mmap_size = page_size * (1 + sample_buffer_pages); // 至少两页
void* mmap_buffer = mmap(NULL, mmap_size, PROT_READ | PROT_WRITE, MAP_SHARED, fd, 0);

然后,你需要解析 mmap_buffer 中的数据,才能获取采样信息。这个过程比较复杂,可以参考 perf_event.h 中的定义和相关的文档。

使用 BPF 跟踪事件

BPF (Berkeley Packet Filter) 是一种强大的内核技术,它可以让你在内核中运行用户定义的代码。你可以使用 BPF 程序来过滤和处理 perf events。

// 创建 BPF 程序
int bpf_fd = bpf_load_program(BPF_PROG_TYPE_KPROBE, ...);

// 将 BPF 程序附加到 perf event
ioctl(fd, PERF_EVENT_IOC_SET_BPF, bpf_fd);

这样,每次 perf event 发生时,都会执行你定义的 BPF 程序。

第四部分:注意事项和最佳实践

  • 权限问题: 通常需要 root 权限才能访问 perf events。
  • 性能开销: 启用 perf events 会带来一定的性能开销,特别是在高采样率的情况下。
  • 事件选择: 选择合适的事件对于分析结果至关重要。
  • 错误处理: perf_event_open 可能会失败,需要检查返回值并处理错误。
  • 文档阅读: perf_event.h 中包含了大量的定义和说明,是学习 perf events 的重要资源。
  • 资源释放: 记得关闭文件描述符和释放 mmap 的内存。

第五部分:总结

Linux perf events 是一个强大的性能分析工具,可以让你深入了解程序的运行状态。perf_event_open 系统调用是访问这些性能计数器的关键。通过合理地使用 perf events,你可以发现程序中的性能瓶颈,并进行优化。

希望今天的讲座对大家有所帮助。如果有什么问题,欢迎提问!

附加部分:高级技巧与坑点避雷

  1. read_format 的妙用:一次性读取多个计数器

    如果你想同时读取多个事件的值,不必多次调用 read,可以通过 read_format 来一次性读取。例如,你可以设置 read_formatPERF_FORMAT_GROUP | PERF_FORMAT_ID,这样 read 返回的数据会包含事件组中每个事件的ID和计数。

    pe.read_format = PERF_FORMAT_GROUP | PERF_FORMAT_ID;
    
    // 启用事件组后,读取数据
    struct read_format {
        uint64_t nr;             /* Number of values */
        uint64_t values[];       /* Values */
    };
    
    read(fd, &rf, sizeof(rf));  // 假设 rf 是 read_format 结构体
    
    // 遍历事件组中的每个事件
    for (int i = 0; i < rf.nr; ++i) {
        uint64_t value = rf.values[i * 2];
        uint64_t id    = rf.values[i * 2 + 1];
        // 根据 id 找到对应的事件,并处理 value
    }
  2. ioctl 的进阶用法:动态调整计数器

    ioctl 除了用于启用和禁用计数器,还可以用于动态调整计数器的配置。例如,你可以使用 PERF_EVENT_IOC_SET_OUTPUT 将计数器的输出重定向到另一个文件描述符,或者使用 PERF_EVENT_IOC_PERIOD 动态调整采样周期。

  3. 线程级别的监控:pid 参数的奥秘

    perf_event_openpid 参数不仅可以用于监控单个进程,还可以用于监控单个线程。只需要将 pid 设置为线程ID即可。注意,线程ID 在 Linux 中也是一个进程ID。

  4. cpu 参数的精确定位:监控特定 CPU 核心

    如果你想监控特定 CPU 核心的性能,可以将 cpu 参数设置为对应的核心ID。这在多核 CPU 上进行性能分析时非常有用。

  5. flags 参数的隐藏技能:控制事件行为

    flags 参数可以控制事件的各种行为,例如,你可以使用 PERF_FLAG_FD_CLOEXEC 标志来确保在 exec 系统调用后,文件描述符会被自动关闭。

  6. 坑点避雷:PERF_COUNT_HW_REF_CPU_CYCLES 的陷阱

    PERF_COUNT_HW_REF_CPU_CYCLES 看起来很美好,似乎可以提供精确的 CPU 周期数,但实际上它受到 CPU 频率变化的影响。如果 CPU 频率发生变化,PERF_COUNT_HW_REF_CPU_CYCLES 的值可能会不准确。

  7. 内存屏障的重要性:避免数据竞争

    在多线程环境下,读取 perf events 计数器时,需要使用内存屏障来确保数据的一致性。可以使用 std::atomic 或者 std::memory_order_seq_cst 来实现内存屏障。

  8. 性能测试的艺术:避免干扰

    在进行性能测试时,要尽量避免其他程序的干扰。可以使用 taskset 命令将测试程序绑定到特定的 CPU 核心,以减少干扰。

  9. 持续学习:关注内核更新

    Linux 内核不断更新,perf events 也在不断发展。要保持对内核更新的关注,及时了解新的特性和改进。

  10. perf 命令的辅助:双剑合璧,天下无敌

    perf 命令是一个强大的命令行工具,可以用于分析 perf events 数据。可以将 perf 命令与 C++ 代码结合使用,以实现更强大的性能分析功能。例如,可以使用 C++ 代码来收集 perf events 数据,然后使用 perf report 命令来生成报告。

希望这些高级技巧和坑点避雷能够帮助大家更好地使用 Linux perf events。记住,性能分析是一门艺术,需要不断学习和实践才能掌握。

祝大家编程愉快!

发表回复

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