Почему Perf и Papi дают разные значения для ссылок и промахов кэша L3?

Я работаю над проектом, в котором мы должны реализовать алгоритм, который теоретически доказал свою совместимость с кешем. Проще говоря, если N - это вход, а B - это количество элементов, которые передаются между кешем и ОЗУ каждый раз, когда у нас происходит промах кеша, алгоритм потребует O(N/B) обращений к ОЗУ.

Я хочу показать, что это действительно так на практике. Чтобы лучше понять, как можно измерить различные аппаратные счетчики, связанные с кешем, я решил использовать разные инструменты. Один - Perf, а другой - PAPI библиотеки. К сожалению, чем больше я работаю с этими инструментами, тем меньше понимаю, что именно они делают.

Я использую процессор Intel (R) Core (TM) i5-3470 @ 3,20 ГГц с 8 ГБ ОЗУ, кэш L1 256 КБ, кеш L2 1 МБ, кеш L3 6 МБ. Размер строки кэша составляет 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

Почему такая большая разница между двумя инструментами?


person jsguy    schedule 26.09.2016    source источник
comment
Вы пробовали подсчитывать пропуски данных с помощью PAPI_L3_DCA и PAPI_L3_DCM?   -  person HazemGomaa    schedule 01.10.2016
comment
доступен только PAPI_L3_DCA и, кажется, дает примерно те же числа   -  person jsguy    schedule 03.10.2016


Ответы (1)


Вы можете просмотреть исходные файлы как perf, так и PAPI, чтобы узнать, какому счетчику производительности они фактически сопоставляют эти события, но оказывается, что они одинаковы (при условии, что здесь Intel Core i): событие 2E с umask 4F для ссылок и 41 за промахи. В Руководство разработчика архитектур Intel 64 и IA-32 эти события описаны как:

2EH 4FH LONGEST_LAT_CACHE.REFERENCE Это событие подсчитывает запросы, исходящие от ядра, которые ссылаются на строку кэша в кэше последнего уровня.

2EH 41H LONGEST_LAT_CACHE.MISS Это событие подсчитывает каждое условие промаха кэша для ссылок на кэш последнего уровня.

Вроде бы нормально. Так что проблема в другом.

Вот мои воспроизведенные числа, только то, что я увеличил длину массива в 100 раз (в противном случае я заметил большие колебания результатов по времени, и при длине 1000000 массив почти умещается в вашем кэше 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 report после perf record -e cache-references ./main1:

  31,22%  main1    [kernel]          [k] 0xffffffff813fdd87                                                                                                                                   ▒
  16,79%  main1    main1             [.] main                                                                                                                                                 ▒
   6,22%  main1    [kernel]          [k] 0xffffffff8182dd24                                                                                                                                   ▒
   5,72%  main1    [kernel]          [k] 0xffffffff811b541d                                                                                                                                   ▒
   3,11%  main1    [kernel]          [k] 0xffffffff811947e9                                                                                                                                   ▒
   1,53%  main1    [kernel]          [k] 0xffffffff811b5454                                                                                                                                   ▒
   1,28%  main1    [kernel]          [k] 0xffffffff811b638a                                              
   1,24%  main1    [kernel]          [k] 0xffffffff811b6381                                                                                                                                   ▒
   1,20%  main1    [kernel]          [k] 0xffffffff811b5417                                                                                                                                   ▒
   1,20%  main1    [kernel]          [k] 0xffffffff811947c9                                                                                                                                   ▒
   1,07%  main1    [kernel]          [k] 0xffffffff811947ab                                                                                                                                   ▒
   0,96%  main1    [kernel]          [k] 0xffffffff81194799                                                                                                                                   ▒
   0,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

Длина массива составляла 100000000 при 100 итерациях, поэтому при анализе можно было ожидать 1,250000000 промахов в кеш-памяти. Это уже довольно близко. Отклонение в основном связано с первым циклом, который загружается ядром в кеш во время очистки страницы.

С помощью PAPI можно добавить несколько дополнительных циклов разминки перед запуском счетчика, так что результат еще лучше соответствует ожиданиям:

$ ./main2 
L3 accesses: 1318699729
L3 misses: 1250684880
L3 miss/access ratio: 0.948423
person Community    schedule 03.10.2016
comment
Хм. Я тоже вижу разницу в цифрах, это правильно, но что в ядре могло вызвать такое количество промахов в кеше? Программа предназначена для манипулирования памятью в пользовательском пространстве, в моей системе она использует те же 55 системных вызовов для n из 1000000 и n из 100000000, если не считать загрузку программы, единственное, что она делает в ядре, - отображает область памяти. Может быть, ошибки страницы? Но такое большое количество только для этого? - person Roman Khimov; 03.10.2016
comment
@RomanKhimov Символ ядра, составляющий большую часть из них, - clear_page_c_e. Я думаю, это потому, что каждая страница обнуляется ядром перед передачей в пользовательское пространство. Это, вероятно, происходит не во время выделения, а скорее при первом доступе. Возможно, я ошибался. Я дополню свой ответ позже более подробным анализом. - person ; 03.10.2016
comment
Я забыл об обнулении памяти mmaped MAP_ANONYMOUS, правда, и это на самом деле все объясняет. Было бы интересно сравнить числа с ручным mmap() с использованием MAP_UNINITIALIZED, который также должен показать разницу между кешем, нагретым путем обнуления, и холодным неинициализированным кешем. - person Roman Khimov; 03.10.2016
comment
Почему ядро ​​обнуляет элементы, если это может вызвать ненужные промахи в кэше? Я всегда думал, что новая операция просто резервирует непрерывный кусок памяти и больше ничего не делает с ним, если вы специально не попросите об этом, например, через конструкторы. Можно ли включить такое поведение? - person jsguy; 03.10.2016
comment
@jsguy Это мера безопасности, потому что в противном случае вы могли бы прочитать оставшуюся память от предыдущего процесса. Это можно обойти с помощью MAP_UNINITIALIZED, но обычно он деактивируется в ядре именно по этой причине. У меня сейчас нет времени, но я отредактирую свой пост позже, чтобы показать разницу. - person ; 03.10.2016
comment
@RomanKhimov Я пробовал протестировать MAP_UNINITIALIZED, но для этого требуется отключить CONFIG_MMU в ядре. Не думаю, что у меня есть время экспериментировать с этим. Знаете ли вы о простой согласованной конфигурации ядра для x86, позволяющей MAP_UNINITIALIZED? - person ; 03.10.2016
comment
@ Eichhörnchen: Верно, это работает только для no-mmu, поэтому для x86 это не вариант. Хорошо, тогда. - person Roman Khimov; 03.10.2016
comment
@jsguy: это зависит от текущего состояния программы, в общем, конечно, это какой-то неинициализированный кусок, но в этом конкретном случае вы только что запустили программу, новые вызовы malloc () и у него недостаточно памяти в его пулах, поэтому он, в свою очередь, получает некоторую память из ядра с помощью mmap (), которая обнуляется ядром, как указано на странице руководства. - person Roman Khimov; 03.10.2016