为什么Perf和Papi在L3缓存引用和缺失方面给出不同的值?

15

我正在开展一个项目,需要实现一个已被理论证明与缓存友好的算法。简单来说,如果输入为N,而B是每次出现缓存未命中时在缓存和RAM之间传输的元素数,则该算法将需要O(N/B)次访问RAM。

我想证明这确实是实际情况下的行为。为了更好地了解如何测量各种与缓存相关的硬件计数器,我决定使用不同的工具。其中一个是 Perf ,另一个是 PAPI 库。不幸的是,我使用这些工具的时间越长,我就越不清楚它们究竟做了什么。

我正在使用Intel(R) Core(TM) i5-3470 CPU @ 3.20GHz,带有8 GB的RAM,L1缓存256 KB,L2缓存1 MB,L3缓存6 MB。缓存行大小为64字节。我猜那一定是块B的大小。

让我们看下面的例子:

#include <iostream>

using namespace std;

struct node{
    int l, r;
};

int main(int argc, char* argv[]){

    int n = 1000000;

    node* A = new node[n];

    int i;
    for(i=0;i<n;i++){
        A[i].l = 1;
        A[i].r = 4;
    }

    return 0;
}

每个节点需要8字节,这意味着一个缓存行可以容纳8个节点,因此我应该期望大约1000000/8 = 125000个L3缓存未命中。

没有优化(没有-O3),这是perf的输出:

 perf stat -B -e cache-references,cache-misses ./cachetests 

 Performance counter stats for './cachetests':

       162,813      cache-references                                            
       142,247      cache-misses              #   87.368 % of all cache refs    

   0.007163021 seconds time elapsed

这相当接近我们的预期。现在假设我们使用PAPI库。

#include <iostream>
#include <papi.h>

using namespace std;

struct node{
    int l, r;
};

void handle_error(int err){
    std::cerr << "PAPI error: " << err << std::endl;
}

int main(int argc, char* argv[]){

    int numEvents = 2;
    long long values[2];
    int events[2] = {PAPI_L3_TCA,PAPI_L3_TCM};

    if (PAPI_start_counters(events, numEvents) != PAPI_OK)
        handle_error(1);

    int n = 1000000;
    node* A = new node[n];
    int i;
    for(i=0;i<n;i++){
        A[i].l = 1;
        A[i].r = 4;
    }

    if ( PAPI_stop_counters(values, numEvents) != PAPI_OK)
        handle_error(1);

    cout<<"L3 accesses: "<<values[0]<<endl;
    cout<<"L3 misses: "<<values[1]<<endl;
    cout<<"L3 miss/access ratio: "<<(double)values[1]/values[0]<<endl;

    return 0;
}

这是我的输出结果:

L3 accesses: 3335
L3 misses: 848
L3 miss/access ratio: 0.254273

为什么这两个工具之间会有如此大的差异?


你尝试过使用 PAPI_L3_DCA 和 PAPI_L3_DCM 计算数据缓存未命中吗? - HazemGomaa
只有PAPI_L3_DCA可用,看起来它给出的数字大致相同。 - jsguy
1个回答

21

您可以查看perf和PAPI的源文件,找出它们实际映射到哪些性能计数器上,但事实证明它们是相同的(假设这里使用Intel Core i):事件2E,umask为4F表示引用,umask为41表示缺失。在Intel 64和IA-32体系结构开发人员手册中,这些事件被描述为:

2EH 4FH LONGEST_LAT_CACHE.REFERENCE 该事件计算来自核心的请求,这些请求引用了最后一级缓存中的缓存行。

2EH 41H LONGEST_LAT_CACHE.MISS 该事件计算每个缓存缺失条件,这些条件是针对最后一级缓存的引用而言的。

看起来没问题。所以问题出在其他地方。

这是我的复现数字,只不过我将数组长度增加了100倍。(否则会发现时间结果波动较大,而且当数组长度达到100万时,它几乎可以适应您的L3缓存)。这里的main1是您的第一个代码示例,没有PAPI,而main2是使用PAPI的第二个示例。

$ perf stat -e cache-references,cache-misses ./main1 

 Performance counter stats for './main1':

        27.148.932      cache-references                                            
        22.233.713      cache-misses              #   81,895 % of all cache refs 

       0,885166681 seconds time elapsed

$ ./main2 
L3 accesses: 7084911
L3 misses: 2750883
L3 miss/access ratio: 0.388273

这些显然不匹配。让我们看看在哪里实际计数LLC引用。以下是perf record -e cache-references ./main1之后perf report的前几行:

  31,22%  main1    [kernel]          [k] 0xffffffff813fdd8716,79%  main1    main1             [.] main                                                                                                                                                 ▒
   6,22%  main1    [kernel]          [k] 0xffffffff8182dd245,72%  main1    [kernel]          [k] 0xffffffff811b541d3,11%  main1    [kernel]          [k] 0xffffffff811947e91,53%  main1    [kernel]          [k] 0xffffffff811b54541,28%  main1    [kernel]          [k] 0xffffffff811b638a                                              
   1,24%  main1    [kernel]          [k] 0xffffffff811b63811,20%  main1    [kernel]          [k] 0xffffffff811b54171,20%  main1    [kernel]          [k] 0xffffffff811947c91,07%  main1    [kernel]          [k] 0xffffffff811947ab0,96%  main1    [kernel]          [k] 0xffffffff811947990,87%  main1    [kernel]          [k] 0xffffffff811947dc   

所以你在这里看到的是,实际上只有16.79%的缓存引用是发生在用户空间的,其余都是由内核引起的。

问题就在这里。将其与PAPI测试结果进行比较是不公平的,因为默认情况下,PAPI只计算用户空间事件。然而,默认情况下,Perf会收集用户和内核空间事件。

对于Perf,我们可以轻松地将数据降低到仅收集用户空间:

$ perf stat -e cache-references:u,cache-misses:u ./main1 

 Performance counter stats for './main1':

         7.170.190      cache-references:u                                          
         2.764.248      cache-misses:u            #   38,552 % of all cache refs    

       0,658690600 seconds time elapsed

这些似乎非常匹配。

编辑:

让我们更仔细地看一下内核的操作,这次使用调试符号和缓存未命中而不是引用:

  59,64%  main1    [kernel]       [k] clear_page_c_e
  23,25%  main1    main1          [.] main
   2,71%  main1    [kernel]       [k] compaction_alloc
   2,70%  main1    [kernel]       [k] pageblock_pfn_to_page
   2,38%  main1    [kernel]       [k] get_pfnblock_flags_mask
   1,57%  main1    [kernel]       [k] _raw_spin_lock
   1,23%  main1    [kernel]       [k] clear_huge_page
   1,00%  main1    [kernel]       [k] get_page_from_freelist
   0,89%  main1    [kernel]       [k] free_pages_prepare

正如我们所看到的,大多数缓存未命中实际上发生在clear_page_c_e函数中。当我们的程序访问一个新页面时,就会调用这个函数。正如注释中所解释的那样,在允许访问之前,内核会将新页面清零,因此缓存未命中已经在这里发生。

这会对你的分析造成困扰,因为你预期的大部分缓存未命中发生在内核空间。然而,你无法保证内核在什么确切情况下实际访问内存,所以可能会与你的代码预期行为有所偏差。

为了避免这种情况,在你的数组填充循环周围建立一个额外的循环。只有内部循环的第一次迭代会产生内核开销。一旦数组中的每个页面都被访问过,就不应该再有贡献了。以下是我对外部循环重复100次的结果:

$ perf stat -e cache-references:u,cache-references:k,cache-misses:u,cache-misses:k ./main1

 Performance counter stats for './main1':

     1.327.599.357      cache-references:u                                          
        23.678.135      cache-references:k                                          
     1.242.836.730      cache-misses:u            #   93,615 % of all cache refs    
        22.572.764      cache-misses:k            #   95,332 % of all cache refs    

      38,286354681 seconds time elapsed

数组长度为1亿,循环100次,因此按照你的分析,你会期望有1,250,000,000个缓存未命中。现在非常接近了。偏差主要来自第一个循环,该循环在页面清除期间由内核加载到缓存中。
使用PAPI可以在计数器启动之前插入几个额外的预热循环,因此结果更符合预期:
$ ./main2 
L3 accesses: 1318699729
L3 misses: 1250684880
L3 miss/access ratio: 0.948423

嗯。我也看到了数字上的差异,没错,但内核中有什么原因会导致这么多的缓存未命中呢?该程序完全是在用户空间中操作内存,在我的系统上,对于 n=1000000 和 n=100000000,它使用相同的55个系统调用,如果不考虑程序加载,它在内核中所做的唯一事情就是映射一个内存区域。可能是页错误吗?但光是这个原因就会有这么多的错误吗? - Roman Khimov
2
@RomanKhimov 这些中占据最大部分的内核符号是clear_page_c_e。所以我认为这是因为内核在传递给用户空间之前将每个页面清零。虽然这可能不会在分配时发生,而是在首次访问时发生。我可能在那里错了。我稍后会更新我的答案,并进行更详细的分析。 - user4407569
1
@jsguy 这是一项安全措施,因为否则您可能能够读取先前进程的剩余内存。这可以通过MAP_UNINITIALIZED绕过,但通常出于安全原因在内核中被禁用。我现在没有时间,但稍后我会编辑我的帖子以显示区别。 - user4407569
@RomanKhimov 我试图测试MAP_UNINITIALIZED,但它要求在内核中禁用CONFIG_MMU。我认为我没有时间进行实验。您知道一个简单一致的x86内核配置允许MAP_UNINITIALIZED吗? - user4407569
1
您IP地址为143.198.54.68,由于运营成本限制,当前对于免费用户的使用频率限制为每个IP每72小时10次对话,如需解除限制,请点击左下角设置图标按钮(手机用户先点击左上角菜单按钮)。 - Roman Khimov
显示剩余4条评论

网页内容由stack overflow 提供, 点击上面的
可以查看英文原文,
原文链接