Я работаю над проектом, в котором мы должны реализовать алгоритм, который теоретически доказал свою пригодность для кеширования. Проще говоря, если N
это вход и B
количество элементов, которые передаются между кешем и оперативной памятью каждый раз, когда мы пропускаем кеш, алгоритм потребует O(N/B)
доступ к ОЗУ.
Я хотел бы показать, что это действительно так на практике. Чтобы лучше понять, как можно измерять различные аппаратные счетчики, связанные с кэшем, я решил использовать разные инструменты. Один Perf а другой PAPI библиотека. К сожалению, чем больше я работаю с этими инструментами, тем меньше понимаю, что именно они делают.
Я использую процессор Intel® Core 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
Почему такая большая разница между этими двумя инструментами?
Вы можете просмотреть исходные файлы как perf, так и PAPI, чтобы узнать, на какой счетчик производительности они на самом деле отображают эти события, но оказывается, что они одинаковы (при условии, что Intel Core i здесь): Событие 2E
с масками 4F
для справок и 41
за промахи. в Intel 64 и IA-32 Архитектура Руководство разработчика эти события описаны как:
2EH 4FH LONGEST_LAT_CACHE.REFERENCE Это событие подсчитывает запросы, исходящие от ядра, которые ссылаются на строку кэша в кэше последнего уровня.
2EH 41H LONGEST_LAT_CACHE.MISS Это событие подсчитывает каждое условие пропуска кэша для ссылок на кэш последнего уровня.
Кажется, все в порядке. Так что проблема в другом.
Вот мои воспроизведенные числа, только из-за того, что я увеличил длину массива в 100 раз (в противном случае я заметил большие колебания результатов синхронизации, и при длине в 1 000 000 массив почти все еще помещается в кэш 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 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
Длина массива составляла 100 000 000 с 100 итерациями, и, следовательно, вы ожидали, что ваш анализ приведет к 1 250 000 000 пропаданий кэша. Это довольно близко сейчас. Отклонение происходит в основном от первого цикла, который загружается в кеш ядром во время очистки страницы.
С PAPI несколько дополнительных циклов прогрева могут быть вставлены перед запуском счетчика, поэтому результат еще лучше соответствует ожиданиям:
$ ./main2
L3 accesses: 1318699729
L3 misses: 1250684880
L3 miss/access ratio: 0.948423
Других решений пока нет …