利用 PAPI 接口监测 Java 程序的硬件执行特征

红薯 发布于 2010/06/17 22:34
阅读 474
收藏 2

简介: PAPI 是一组用于访问处理器硬件性能计数器的本地接口,利用这一接口对 Java 程序的硬件执行特征进行监测将有助于在计算机系统的硬件层上发现程序性能问题的根源所在。本文介绍了 PAPI 的重要概念及其常用接口,分析了将其应用于 Java 程序性能测评的要点和难点,提出一种基于 JVMTI 接口的方法实现了利用 PAPI 接口监测 Java 程序的运行时硬件执行特征。

PAPI 接口概览

在计算机系统的硬件层对 Java 程序的性能进行测评与分析,有助于发现程序性能问题的根源所在。当前的主流处理器大多设定了一类用于记录程序运行过程中的处理器行为细节的事件 (Event),同时还专门设计了硬件性能计数器(Hardware Performance Counter)对这类事件进行计数。硬件性能计数器监测到的处理器事件的发生次数能够直观地体现程序运行时的硬件执行特征,例如处理器执行完成的指令 数、L1 Cache 的失效次数等等,是测评和分析程序性能的可靠依据。

PAPI(Performance Application Programming Interface)是一组可以在多个处理器平台上对硬件性能计数器进行访问的标准接口,它的目标是方便用户在程序运行时监测和采集由硬件性能计数器记录 的处理器事件信息。

不同的处理器会根据自身的体系结构特征定义出不同的处理器事件集合,在 PAPI 中这些事件被称为原生事件(Native Event)。同时,不同的处理器也会具有不同数量的硬件性能计数器,而在任意时刻一个计数器只能对一个指定的原生事件进行监测。考虑到事件监测和性能分 析的需求,不同处理器的原生事件集合往往在功能上会有交集(例如那些和存储层次访问、Cache 一致性协议、周期和指令计数、功能单元和流水线状态等方面相关的事件),但是其对应的原生事件名称却未必相同。为了便于事件甄别,PAPI 将这些在不同处理器中存在功能共性的原生事件抽象成了 PAPI 接口专用的预制事件(Preset Event)并统一命名。PAPI 预制事件不仅仅是对单一原生事件的简单映射,根据不同体系结构中原生事件设定的差异,它也可能是由多个原生事件构成,例如记录 L1 Cache 失效次数的 PAPI 预制事件,在某些处理器上实现时就需要依赖 L1 D-Cache 失效次数和 L1 I-Cache 失效次数等两个原生事件的支持。通过定义预制事件,PAPI 接口具有了一定的可移植性,但是对于某些处理器中定义的原生事件集合,PAPI 预制事件可能无法将其完全覆盖。

PAPI 提供了两类接口访问硬件性能计数器:一类是比较简单的高层接口用于完成基本的计数测量,另一类是可编程的底层接口能够满足用户的复杂的监测需求。

PAPI 高层接口提供了一些访问硬件性能计数器所需的基本功能,例如配置计数器、启动计数、停止计数、读取计数器的数值等。高层接口只能利用 PAPI 预制事件,而不能够通过配置计数器去监测超出预制事件覆盖范围以外的处理器原生事件。不过,PAPI 高层接口能够直接返回在程序测评中最经常使用的一些性能指标,例如每个周期执行完成的指令数、每秒执行完成的浮点指令 / 浮点操作数、程序的运行时间等;另外,高层接口还能获取一些系统信息,例如处理器能够支持的硬件性能计数器的个数等。

不同于高层接口只能使用 PAPI 预制事件,PAPI 底层接口能够直接使用原生事件对程序运行时的处理器硬件行为进行监测。用户可以将一个或多个原生事件组成一个事件组(Event Set),然后通过设置硬件性能计数器对事件组中所有的原生事件同时进行监测,进而根据监测结果分析程序的性能问题,例如通过同时采集每秒执行完成的浮点 指令数和 L1 Cache 失效次数就有助于分析是否是因为 L1 Cache 的命中率不高导致了程序浮点性能的下降。需要注意的是,事件组中的原生事件个数不能够超过处理器所能支持的硬件性能计数器个数。

与高层接口相比,PAPI 底层接口的使用更加灵活,能够对处理器事件进行更全面的监测。因此,本文的主要内容是探讨如何利用 PAPI 底层接口监测和采集 Java 程序的硬件执行特征。

常用的 PAPI 底层接口

当前的 PAPI 实现有 C 语言和 Fortran 语言两个版本。本文以 C 语言版本为例,在表 1 中列出了用户在对程序运行时产生的事件进行监测时常用的底层接口。


表 1. 常用的 PAPI 底层接口

PAPI 底层接口 接口功能简述
PAPI_library_init 初始化 PAPI 接口库
PAPI_create_eventset 创建事件组
PAPI_add_event / PAPI_add_events 向事件组中添加原生事件或者 PAPI 预制事件
PAPI_remove_event / PAPI_remove_events 从事件组中删除事件
PAPI_start 启动计数器对事件组的计数
PAPI_read 读取计数器数值
PAPI_stop 停止计数器计数并读取当前的计数器数值
PAPI_cleanup_eventset 清除事件组中的事件
PAPI_destroy_eventset 销毁事件组
PAPI_shutdown 终止使用 PAPI 并释放所有相关资源

 

清单 1 给出的代码片段简要地示意了利用 PAPI 接口监测程序的硬件执行特征的工作流程,该代码片段利用了表 1 中所列的常用的 PAPI 底层接口。


清单 1. 利用 PAPI 接口监测程序的硬件执行特征

				
#include <papi.h>
#include <stdio.h>

main() {

int EventSet;
long_long values[1], values1[1], values2[1];

/* Initialize the PAPI library */
if (PAPI_library_init(PAPI_VER_CURRENT) != PAPI_VER_CURRENT)
handle_error();

/* Create an EventSet */
EventSet = PAPI_NULL;
if (PAPI_create_eventset(&EventSet) != PAPI_OK)
handle_error();

/* Add an event about Total Instructions Executed (PAPI_TOT_INS) to EventSet */
if (PAPI_add_event(EventSet, PAPI_TOT_INS) != PAPI_OK)
handle_error();

/* Start counting events */
if (PAPI_start(EventSet) != PAPI_OK)
handle_error();

/* Read counters before workload running*/
if (PAPI_read(EventSet, values1) != PAPI_OK)
handle_error();

/* Do some computation here */

/* Stop counting events */
if (PAPI_stop(EventSet, values2) != PAPI_OK)
handle_error();

/* Get value */
values[0] = values2[0] – values1[0];

/* Clean up EventSet */
if (PAPI_cleanup_eventset(EventSet) != PAPI_OK)
handle_error();

/* Destroy the EventSet */
if (PAPI_destroy_eventset(&EventSet) != PAPI_OK)
handle_error();

/* Shutdown PAPI */
PAPI_shutdown();

}

 

如清单 1 所示,PAPI 接口库要在经过初始化后才能被使用,不过这在使用 PAPI 高层接口时并不用被给予更多的关注,因为高层接口会自动地隐式执行接口库的初始化过程。但是用户在使用 PAPI 底层接口时,就必须首先显式地调用一次 PAPI_library_init 函数或者在此之前通过调用高层接口来完成 PAPI 接口库的初始化。

在初始化 PAPI 接口库之后,用户需要调用 PAPI_create_eventset 函数创建一个内容为空的事件组(注意:用于指示事件组地址的变量必须首先被初始化为 PAPI_NULL),然后根据自己的需要向其中添加事件以被硬件性能计数器监测和计数。PAPI 底层接口中用于向事件组中添加事件的函数是 PAPI_add_event 或者 PAPI_add_events,它们的区别在于前者每次只是添加单个事件,而后者则能够通过一个事件数组一次性完成事件添加。PAPI 底层接口可以使用的事件既包括 PAPI 预制事件也包括处理器原生事件,不过在添加原生事件时用户需要对相关处理器的设计具有全面深入的了解以保证计数器的使用正确,例如在某些处理器中一些原生 事件只能由特定的计数器监测。在添加事件的过程中,用户可以使用 PAPI_remove_event 或者 PAPI_remove_events 等函数删除之前已经被添加的事件。类似的,这两个事件删除函数的区别也在于前者是删除单个事件而后者是删除一个事件数组。

完成了事件添加,用户就可以调用 PAPI_start 函数启动硬件性能计数器对事件组中的各个事件的计数。与此相对应的是 PAPI_stop 函数,该函数在停止计数器计数的同时还会读取计数停止时刻的计数器数值。在硬件性能计数器对事件进行监测和计数的过程中,用户可以随时调用 PAPI_read 函数读取计数器当前记录的事件发生次数。如清单 1 所示,为了获得被监测程序的运行时硬件执行特征,用户需要在被监测程序执行前调用一次 PAPI_read 函数,然后再在其执行完毕后调用一次 PAPI_read 函数(清单 1 中通过调用 PAPI_stop 函数完成被监测程序执行完毕后的计数器读取操作)。最终,两次采得的计数器数值之间的差值就是被监测程序运行过程中产生的相关的处理器事件数。

在利用 PAPI 接口完成对被监测程序的硬件执行特征的监测后,用户需要进行一些收尾和整理工作,例如清单 1 先后调用了 PAPI_cleanup_eventset 和 PAPI_destroy_eventset 等函数,清除了事件组中的事件并释放了相关事件组占用的内存资源,最后再调用 PAPI_shutdown 函数终止对 PAPI 的使用。

PAPI 的安装与使用

PAPI 接口功能的实现依赖于底层的处理器和操作系统支持,因此它的安装过程在不同的系统中存在着较大差异:在有的系统中可以直接按照最普通的步骤,在 PAPI 源文件目录下先后执行 configure 和 make 命令即可完成安装;而在有的系统中就需要略微复杂的步骤。PAPI 项目开发者特别在 PAPI 源文件的根目录下提供了描述 PAPI 安装过程的说明文件 INSTALL.txt,其中针对不同的系统配置都给出了相应的安装提示。以在处理器为 IBM POWER5+、操作系统为 Linux 的系统上安装 PAPI 为例,用户就必须首先为操作系统打上相关的补丁(例如 perfctr 或者 perfmon2),以支持 PAPI 接口对处理器硬件性能计数器的访问。

在使用 PAPI 接口时,用户需要首先将 libpapi 共享库所在的路径加入到系统的环境变量 LD_LIBRARY_PATH 中,同时还需要将 PAPI 源文件所在的路径加入到系统的环境变量 PAPI_EVENTFILE_PATH 中。完成环境变量设置后,用户在使用 gcc 编译调用了 PAPI 接口函数的应用程序时,需要加入编译选项 -lpapi,使得程序可以动态链接到相应的函数实现。

利用 PAPI 接口监测 Java 程序

Java 程序的执行实体是线程,特别是多线程的 Java 程序在当前更有着广泛的应用。因为同一 Java 程序的线程共享处理器的硬件性能计数器,线程的交替执行会导致事件计数的混乱,所以如果用户要对 Java 程序的硬件执行特征进行监测就必须以 Java 线程为粒度,只有这样才能准确记录下每个线程的真实数据。因此,在利用 PAPI 接口监测 Java 程序的硬件执行特征时,除了前文表 1 列出的 PAPI 底层接口,用户还需要用到一些和程序线程监测相关的接口,如表 2 所示。


表 2. 与程序线程监测相关的 PAPI 底层接口

PAPI 底层接口 接口功能简述
PAPI_thread_init 初始化 PAPI 接口库对线程监测的支持
PAPI_thread_id 获得当前线程的线程标识符
PAPI_attach 将一个事件组绑定到指定线程上
PAPI_detach 取消事件组与相关线程之间的绑定

 

为了获得 PAPI 对程序线程监测的支持,用户需要在初始化 PAPI 接口库之后调用 PAPI_thread_init 函数。在被监测线程创建后,用户通过调用 PAPI_thread_id 可以获得线程的标识符,并利用 PAPI_attach 函数将一个已经创建好的事件组和该标识符代表的线程进行绑定,这样可以保证在该事件组上进行监测所得的事件计数都是由该线程在运行时产生的,从而准确地获 得单个线程的硬件执行特征而消除了其它线程的干扰。在线程监测完成后,用户需要通过调用 PAPI_detach 函数释放相关的资源。

用户在利用 C 语言实现的 PAPI 接口监测 Java 程序运行过程中的硬件执行特征时,最容易想到的做法就是通过 JNI 接口在 Java 程序中调用本地语言编写的接口和函数。PAPI 的开发者也试图为用户提供这样的方便,在源文件的 jni 目录下提供了本地方法的封装,但是这部分工作并没有完成,相关代码没有经过测试并且可能无法使用。另外,JNI 方法需要用户改写被监测 Java 程序的源代码,这提高了 PAPI 使用的复杂度。针对于此,本文提出了一种更便捷的基于 Java Virtual Machine Tool Interface(JVMTI)的方法利用 PAPI。

JVMTI 提供了一种编程接口,允许用户创建代理程序(Agent)以监测和控制虚拟机和 Java 应用程序。代理程序可以向运行时的虚拟机订阅其感兴趣的事件,例如虚拟机和 Java 应用程序执行状态的改变等等。这些虚拟机事件在其发生时会以调用事件回调函数的方式激活代理程序并被回调函数进行相应的处理。代理程序可以使用任何支持 C 语言标准的本地语言编写,它以动态链接库的方式存在并在 Java 应用程序启动时被加载。

采用基于 JVMTI 的方法利用 PAPI 接口监测 Java 程序的硬件执行特征,在设计代理程序时至少需要关注虚拟机启动、虚拟机停止、应用线程启动、应用线程结束等虚拟机事件并在事件对应的回调函数中调用相关的 PAPI 接口函数。一般的,在实现过程中,各个虚拟机事件对应的回调函数功能如表 3 所示。


表 3. 虚拟机事件对应的回调函数功能

虚拟机事件 回调函数中调用的 PAPI 函数
虚拟机启动 PAPI_library_init
PAPI_thread_init
虚拟机停止 PAPI_shutdown
应用线程启动 PAPI_create_eventset
PAPI_add_event / PAPI_add_events
PAPI_remove_event / PAPI_remove_events
PAPI_attach
PAPI_start
PAPI_read
应用线程结束 PAPI_stop
PAPI_detach
PAPI_cleanup_eventset
PAPI_destroy_eventset

如表 3 所示,创建、绑定事件集和向其中添加事件都在每个线程启动事件的回调函数中进行,这样每个线程就可以拥有自己独立的事件集资源。同时,代理程序需要借助一 个全局变量,记录下各个线程对应的事件集,以备在不同的虚拟机事件回调函数(主要是应用线程启动和应用线程结束)中被相关的 PAPI 接口函数调用。

在本文附带的样例代码中,实现了一个在处理器为 IBM POWER5+、操作系统为 Linux 的系统中,使用基于 JVMTI 的方法调用 PAPI 接口并对 Java 程序的硬件执行特征进行监测的例子。其中,被监测的 Java 程序创建了一个子线程计算 Fibonacci 数列,而用 C 语言编写的代理程序则负责 JVMTI 所需的代理初始化以及实现表 3 中所列的各个虚拟机事件对应的回调函数。

为了方便用户区分那些被监测到的从属于不同 Java 线程的硬件计数器数值,本文附带的样例代码利用 JVMTI 接口提供的 GetThreadInfo 方法获得线程名称,如清单 2 所示。


清单 2. 利用 JVMTI 接口获得线程名称

				
void
printThreadName(jvmtiEnv *jvmti_env, JNIEnv* jni_env, jthread thread)
{
jvmtiThreadInfo info;
jvmtiError err;

if (thread == NULL) {
printf("Warning: NULL thread\n");
return;
}

err = (*jvmti_env)->GetThreadInfo(jvmti_env, thread, &info);
if (err != JVMTI_ERROR_NONE) {
printf("Error: GetThreadInfo, Error code = %d\n", err);
return;
}

printf("Thread name: %s\n", (char *)info.name);

return;
}

 

在设置被监测的处理器事件时,用户需要查阅相关处理器的手册以获得更详尽的信息。以 IBM POWER 处理器为例,其生产厂商提供了多个事件簇(Event Group)给用户使用,其中每一个事件簇都是若干处理器原生事件的集合。一个事件簇中包含的事件个数与处理器拥有的硬件性能计数器个数相同,这些事件在 计数器的使用上不存在冲突,它们将在程序运行时同时被各自对应的计数器监测。这些事件的产生往往存在着功能或者逻辑上的关联,以事件簇的方式对它们同时进 行监测有助于用户对程序运行过程中的硬件行为获得更全面的认识。在本文附带的样例代码中,被添加到事件组中的被监测事件就同属于 IBM POWER5+ 的一个事件簇,其具体描述如表 4 所示。


表 4. 样例代码所用 IBM POWER5+ 处理器事件的描述

计数器 事件名称 事件编码 事件简述
0 PM_1PLUS_PPC_CMPL 0x40000002 有至少一条 PPC 指令被执行完成的周期
1 PM_GCT_EMPTY_CYC 0x4000017A 处理器流水线为空的周期
2 PM_GRP_CMPL 0x400001AB 有指令组被执行完成的周期
3 PM_CYC 0x40000011 处理器周期
4 PM_RUN_INST_CMPL 0x400001E2 有指令被处理器执行完成
5 PM_RUN_CYC 0x40000138 处理器处于 RUN 状态的周期

 

如表 4 所示,在 IBM POWER5+ 处理器中,共有六个硬件性能计数器,因此一共可以有六个处理器事件被同时监测。不过,六个计数器中的 0 号到 3 号计数器是可编程的,可以监测多种不同的事件;而 4 号和 5 号计数器则是专用计数器,只能够对应地监测 PM_RUN_INST_CMPL 和 PM_RUN_CYC 事件。通过在程序运行过程中对这个事件簇中的事件发生次数进行监测,用户能够获得程序的 CPI(Cycles per Instruction)、流水线正常运行的周期比例、流水线为空的周期比例等性能指标。

以本文附带的样例代码为例,利用 PAPI 接口监测 Java 程序的硬件执行特征,用户首先要按照常规方法编译被监测的 Java 应用程序。

 javac Fibonacci.java 

 

然后,将 C 语言编写的代理程序编译为动态链接库。

gcc -shared -lpapi -lpthread -o libPAPI_Agent.so -I $JAVA_HOME/include/ PAPI_Agent.c 

 

注意,因为本文在代理程序中使用了 pthread 库函数,所以要在编译选项中增加 -lpthread 参数。另外,还需要保证在编译时已经包含了定义有 JVMTI 接口函数的头文件。

最后,在执行被监测 Java 程序的时候,通过下面的指令加载代理程序并对 Java 程序进行运行时的监测。

 java -agentlib:PAPI_Agent Fibonacci 

 

在 Java 程序的运行过程中,JVMTI 代理程序将对表 3 所列的虚拟机事件进行监控并调用与之相对应的回调函数。回调函数通过 PAPI 接口访问处理器的硬件性能计数器,并在被监测程序运行完成时获得程序中各个线程的硬件执行特征,其输出如下。

Thread name: main 
Counter value[0] = [22140488]
Counter value[1] = [6640502]
Counter value[2] = [22418555]
Counter value[3] = [70891030]
Counter value[4] = [57302729]
Counter value[5] = [70891030]
Thread name: DestroyJavaVM helper thread
Counter value[0] = [2168]
Counter value[1] = [4504]
Counter value[2] = [2183]
Counter value[3] = [14284]
Counter value[4] = [5774]
Counter value[5] = [14284]
Thread name: Thread-1
Counter value[0] = [7259]
Counter value[1] = [7774]
Counter value[2] = [7367]
Counter value[3] = [51694]
Counter value[4] = [20038]
Counter value[5] = [51694]

 

通过分析上面的输出信息,用户可以发现在被监测的 Java 程序运行过程中有三个线程先后被启动,其中名为 Thread-1 的线程是用于进行 Fibonacci 数列计算的应用线程。根据表 4 的处理器事件描述,用户可以算得该应用线程的 CPI 为 2.58,在程序运行过程中在 14% 的时间里流水线工作正常,而在另外 15% 的时间里流水线为空载。如果对其它更多的处理器事件进行监测,用户就可以获得更全面的程序运行时的硬件执行特征进而开展更深入的分析,以发现程序性能问题 的根源所在。关于如何利用程序的硬件执行特征分析程序性能,请参阅参考资源中所列的相关网页链接。

总结

PAPI 是一组用于访问处理器硬件性能计数器的本地接口,用户可以利用它获得程序运行过程中产生的各种处理器事件的发生次数,进而将这些数据作为评估和分析程序性 能的可靠依据,有助于更准确地发现程序性能问题的根源。本文在深入分析 PAPI 接口功能的基础上,结合 Java 程序的执行特征,提出了一种基于 JVMTI 的方法,该方法能够有效地利用 PAPI 接口对 Java 线程的运行时硬件特征进行监测。

加载中
返回顶部
顶部