Linux perf子系统的使用(一)——计数

刚刚入职的时候我就研究了perf_event_open()这个巨无霸级别的系统调用,还用Python封装了一层,非常便于获取计数器。然而之后由于工作上一直直接用perf命令来获取各种计数器的值,于是就有所淡忘。又后来自己的笔记本重装,代码也不见了。最近由于需要开发一款略有复杂性的工具,对性能要求很高,而且需要能够实时查询结果,所以必须使用C的接口自己开发,而不能再使用命令行。

其实关于perf_event_open()这个系统调用的一切用法,在官方手册《perf_event_open(2) - Linux manual page》上都有所涉及,虽然未提及有些编程的细节。

大致上讲,perf_event_open()有两个使用模式,一个叫做计数,一个叫做采样。所谓计数,就是测量一段时间内某个事件发生的次数,比如获取每1秒内运行的指令数。所谓采样,就是在某个时间点查看某个状态,比如我想每1秒记录下当前的IP寄存器的值。从编程的角度看,计数模式要容易理解地多,也容易实现地多。所以这篇博客先讲怎么使用perf的计数模式。

最简单的计数模式就是只监测一个计数器。比如我每一秒获取刚刚过去的那一秒内的指令数(instructions)。复杂的计数模式就是同时监测多个计数器。比如我每一秒获取刚刚过去的那一秒内的指令数(instructions)、时钟周期数(cycles)、分支指令数(branch instructions)等等。接下来就先讲单一计数器模式,而后讲多计数器模式。

=====================阶段一:单计数器=====================

就以刚刚的例子作为需求,我们要每一秒输出刚刚一秒内运行的指令数。那么代码是这样的:

single.c

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

//目前perf_event_open在glibc中没有封装,需要手工封装一下
int perf_event_open(struct perf_event_attr *attr,pid_t pid,int cpu,int group_fd,unsigned long flags)
{
    return syscall(__NR_perf_event_open,attr,pid,cpu,group_fd,flags);
}

int main()
{
    struct perf_event_attr attr;
    memset(&attr,0,sizeof(struct perf_event_attr));
    attr.size=sizeof(struct perf_event_attr);
    //监测硬件
    attr.type=PERF_TYPE_HARDWARE;
    //监测指令数
    attr.config=PERF_COUNT_HW_INSTRUCTIONS;
    //初始状态为禁用
    attr.disabled=1;
    //创建perf文件描述符,其中pid=0,cpu=-1表示监测当前进程,不论运行在那个cpu上
    int fd=perf_event_open(&attr,0,-1,-1,0);
    if(fd<0)
    {
        perror("Cannot open perf fd!");
        return 1;
    }
    //启用(开始计数)
    ioctl(fd,PERF_EVENT_IOC_ENABLE,0);
    while(1)
    {
        uint64_t instructions;
        //读取最新的计数值
        read(fd,&instructions,sizeof(instructions));
        printf("instructions=%ld\n",instructions);
        sleep(1);
    }
}

不需要任何的编译选项,直接gcc,然后运行:

gcc single.c -o single
sudo ./single

可以看到指令数不断增加。其实这个增速非常缓慢,因为循环里面除了printf什么事都没干。这个指令数是perf始能之后的累积值。那么如果想要获取每一秒内的指令数呢?一种办法就是自己在应用层计算前后两次的差分,另一种办法是告诉kernel把计数器清零:

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

//目前perf_event_open在glibc中没有封装,需要手工封装一下
int perf_event_open(struct perf_event_attr *attr,pid_t pid,int cpu,int group_fd,unsigned long flags)
{
    return syscall(__NR_perf_event_open,attr,pid,cpu,group_fd,flags);
}

int main()
{
    struct perf_event_attr attr;
    memset(&attr,0,sizeof(struct perf_event_attr));
    attr.size=sizeof(struct perf_event_attr);
    //监测硬件
    attr.type=PERF_TYPE_HARDWARE;
    //监测指令数
    attr.config=PERF_COUNT_HW_INSTRUCTIONS;
    //初始状态为禁用
    attr.disabled=1;
    //创建perf文件描述符,其中pid=0,cpu=-1表示监测当前进程,不论运行在那个cpu上
    int fd=perf_event_open(&attr,0,-1,-1,0);
    if(fd<0)
    {
        perror("Cannot open perf fd!");
        return 1;
    }
    //启用(开始计数)
    ioctl(fd,PERF_EVENT_IOC_ENABLE,0);
    while(1)
    {
        uint64_t instructions;
        //读取最新的计数值
        read(fd,&instructions,sizeof(instructions));
        //读取后清零
	ioctl(fd,PERF_EVENT_IOC_RESET,0);
        printf("instructions=%ld\n",instructions);
        sleep(1);
    }
}

每个计数器本身是有很多选项的,比如可以指定监测的进程号(pid)、cpu号,也可以设置只监测用户态的事件,或只监测内核态的事件等等,具体可以查看手册。另外,计数器可以监测的事件也有很多,有硬件的,有软件的。这里截取了手册上一些常用的:

=======================阶段二:多计数器====================

如果你说多个计数器简单,创建多个perf文件描述符,然后每个都用read去读嘛。额,确实可以!但是呢,当监测的事件很多,而且读取频率很高时,那么read()调用的开销就不可再忽略了。那么如果能够把多个计数器的值通过一次read()调用获取,性能上能够提高不少。perf提供了“组”的概念,这也是多计数器perf的核心内容。

读读手册的这一段:

所以呢,第一个perf fd还是和本来一样的方法创建(除了要多设置一个read_format),而后面的perf_event_open()中的参数group_fd就传入第一个perf fd。这样他们就成为了一个组,并且用第一个perf fd代表整个组。

下面代码演示了如何同时监测指令数和时钟周期数。

multi.c

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

//目前perf_event_open在glibc中没有封装,需要手工封装一下
int perf_event_open(struct perf_event_attr *attr,pid_t pid,int cpu,int group_fd,unsigned long flags)
{
    return syscall(__NR_perf_event_open,attr,pid,cpu,group_fd,flags);
}

//每次read()得到的结构体
struct read_format
{
    //计数器数量(为2)
    uint64_t nr;
    //两个计数器的值
    uint64_t values[2];
};

int main()
{
    struct perf_event_attr attr;
    memset(&attr,0,sizeof(struct perf_event_attr));
    attr.size=sizeof(struct perf_event_attr);
    //监测硬件
    attr.type=PERF_TYPE_HARDWARE;
    //监测指令数
    attr.config=PERF_COUNT_HW_INSTRUCTIONS;
    //初始状态为禁用
    attr.disabled=1;
    //每次读取一个组
    attr.read_format=PERF_FORMAT_GROUP;
    //创建perf文件描述符,其中pid=0,cpu=-1表示监测当前进程,不论运行在那个cpu上
    int fd=perf_event_open(&attr,0,-1,-1,0);
    if(fd<0)
    {
        perror("Cannot open perf fd!");
        return 1;
    }
    //接下来创建第二个计数器
    memset(&attr,0,sizeof(struct perf_event_attr));
    attr.size=sizeof(struct perf_event_attr);
    //监测
    attr.type=PERF_TYPE_HARDWARE;
    //监测时钟周期数
    attr.config=PERF_COUNT_HW_CPU_CYCLES;
    //初始状态为禁用
    attr.disabled=1;
    //创建perf文件描述符
    int fd2=perf_event_open(&attr,0,-1,fd,0);
    if(fd2<0)
    {
        perror("Cannot open perf fd2!");
        return 1;
    }
    //启用(开始计数),注意PERF_IOC_FLAG_GROUP标志
    ioctl(fd,PERF_EVENT_IOC_ENABLE,PERF_IOC_FLAG_GROUP);
    while(1)
    {
        struct read_format aread;
        //读取最新的计数值,每次读取一个结构体
        read(fd,&aread,sizeof(struct read_format));
        printf("instructions=%ld,cycles=%ld\n",aread.values[0],aread.values[1]);
        sleep(1);
    }
}

可以注意到,最大的变化就是数据的读取。当使用了“组”的形式之后,那么每次read()就是读取一个特定的结构体。这个结构体struct read_format不是固定的,会根据组内计数器数量和struct perf_event_attr的read_format字段的设置而变化。上面的代码用了最简单的方法,把struct perf_event_attr的read_format设置为PERF_FORMAT_GROUP,那么每次读取的结构体其实就是1+nr个64位整数,其中第一个整数nr就是计数器数量,后面nr个整数就是每一个计数器的值,顺序和加入组的顺序相同。

第二大变化就是ioctl()的第三个参数由0变为了PERF_IOC_FLAG_GROUP。这个标志表明操作是对组进行的,可以理解为kernel帮我们把ioctl依次作用在了组的每一个计数器上。所以呢,如果每次读取后,要把组内所有计数器都清空,需要使用:

ioctl(fd,PERF_EVENT_IOC_RESET,PERF_IOC_FLAG_GROUP);