Тест критического шага кеша процессора, дающий неожиданные результаты в зависимости от типа доступа

Вдохновленный этот недавний вопрос о SO и ответы, данные, что заставило меня чувствовать себя очень невежественным, я решил потратить некоторое время, чтобы узнать больше о Кеширование процессора и написал небольшую программу, чтобы проверить, правильно ли я все понимаю (скорее всего, нет, я боюсь). Я сначала запишу допущения это лежит в основе моих ожиданий, так что вы могли бы остановить меня здесь, если это не так. Основываясь на том, что я прочитал, в общем:

  1. nАссоциативный кэш делится на s наборы, каждый из которых содержит n строки, каждая строка имеет фиксированный размер L;
  2. Каждый адрес основной памяти A может быть сопоставлен с любой из n строки кэша один задавать;
  3. Набор на какой адрес A сопоставление можно найти, разбив адресное пространство на слоты, каждый из которых имеет размер в одну строку кэша, а затем вычислив индекс Aслот (I = A / L) и, наконец, выполнить операцию по модулю для сопоставления индекса с целевым набором T (T = I % s);
  4. Задержка чтения из кеша приводит к более высокой задержке, чем пропущенная запись в кеш, потому что ЦП с меньшей вероятностью будет зависать и бездействовать, ожидая выборки строки основной памяти.

Мой первый вопрос: эти предположения верны?


Предполагая, что они есть, я попытался немного поиграть с этими концепциями, чтобы я мог на самом деле увидеть они оказывают конкретное влияние на программу. Я написал простой тест, который выделяет буфер памяти B байты и неоднократно обращаются к местоположениям этого буфера с фиксированные приращения данного шаг с начала буфера (имеется в виду, что если B 14 и шаг 3, я неоднократно посещаю только места 0, 3, 6, 9 и 12 — и то же самое верно, если B 13, 14 или 15):

int index = 0;
for (int i = 0; i < REPS; i++)
{
index += STEP;
if (index >= B) { index = 0; }
buffer[index] = ...; // Do something here!
}

Из-за вышеизложенных предположений мои ожидания были следующими:

  1. При настройке STEP равный критический шаг (то есть размер строки кэша, умноженный на число наборов в кэше, или L * s), производительность должна быть значительно хуже чем когда STEP устанавливается, например, (L * s) + 1потому что мы будем иметь доступ только к тем областям памяти, которые отображаются в так же установить, заставляя строку кеша чаще исключаться из этого набора и приводя к более высокой частоте пропадания кеша;
  2. когда STEP равно критическому шагу, производительности не должно быть затронуто по размеру B буфера, если он не слишком мал (в противном случае будет посещено слишком мало мест и будет меньше пропусков кеша); в противном случае, производительность должны быть затронуты от Bпотому что с большим буфером у нас больше шансов получить доступ к местоположениям, которые отображаются в разных наборах (особенно если STEP не кратно 2);
  3. Производительность потеря должно быть хуже при чтении из а также писать в каждое расположение буфера чем когда только пишу в эти местоположения: запись в ячейку памяти не должна требовать ожидания соответствующей строки, поэтому факт доступа к ячейкам памяти, которые отображаются в один и тот же набор (опять же, с использованием критического шага как STEP) должен иметь незначительное влияние.

Так что я использовал RightMark Memory Analyzer чтобы выяснить параметры моего кэша данных L1 CPU, настроил размеры в моей программе и опробовал его. Вот так я написал основной цикл (onlyWriteToCache это флаг, который можно установить из командной строки):

    ...
for (int i = 0; i < REPS; i++)
{
...
if (onlyWriteToCache)
{
buffer[index] = (char)(index % 255);
}
else
{
buffer[index] = (char)(buffer[index] % 255);
}
}

исход короче:

  • Ожидания 1) и 2) подтвердились;
  • Ожидание 3) было не подтверждено.

Этот факт поражает меня и заставляет думать, что я не совсем правильно понял. когда B 256 МБ и STEP равно критическому шагу, тест (скомпилированный с -O3 в GCC 4.7.1) показывает, что:

  • Версия цикла только для записи страдает от среднего ~ 6х потеря производительности (6,234 с против 1,078 с);
  • Версия цикла чтения-записи страдает от среднего ~ 1.3x потеря производительности (6,671 с против 5,25 с).

Итак, мой второй вопрос: почему эта разница? Я ожидаю, что потеря производительности будет выше при чтении и записи, чем при записи.


Для полноты ниже приведена программа, которую я написал для выполнения тестов, где константы отражают аппаратные параметры моей машины: размер L1 8-way ассоциативного кеш данных это 32 кб и размер L каждой строки кэша составляет 64 байта, что дает в общей сложности 64 набора (ЦП имеет отдельный 8-канальный кэш инструкций L1 того же размера и с идентичным размером строки).

#include <iostream>
#include <ctime>
#include <cstdlib>
#include <iterator>
#include <algorithm>

using namespace std;

// Auxiliary functions

constexpr int pow(int base, int exp)
{
return ((exp == 0) ? 1 : base * pow(base, exp - 1));
}

int main(int argc, char* argv[])
{
//======================================================================
// Define behavior from command-line arguments
//======================================================================

bool useCriticalStep = false;
bool onlyWriteToCache = true;
size_t BUFFER_SIZE = pow(2, 28);
size_t REPS = pow(2, 27);

if (argc > 0)
{
for (int i = 1; i < argc; i++)
{
string option = argv[i];
if (option == "-c")
{
useCriticalStep = true;
}
else if (option == "-r")
{
onlyWriteToCache = false;
}
else if (option[1] == 's')
{
string encodedSizeInMB = option.substr(2);
size_t sizeInMB = atoi(encodedSizeInMB.c_str());
BUFFER_SIZE = sizeInMB * pow(2, 20);
}
else if (option[1] == 'f')
{
string encodedNumOfReps = option.substr(2);
size_t millionsOfReps = atoi(encodedNumOfReps.c_str());
REPS = millionsOfReps * pow(10, 6);
}
}
}

//======================================================================
// Machine parameters
//======================================================================

constexpr int CACHE_SIZE = pow(2, 15);
constexpr int CACHE_LINE_SIZE = 64;
constexpr int CACHE_LINES_PER_SET = 8;
constexpr int SET_SIZE = CACHE_LINE_SIZE * CACHE_LINES_PER_SET;
constexpr int NUM_OF_SETS = CACHE_SIZE / SET_SIZE;

//======================================================================
// Print out the machine parameters
//======================================================================

cout << "CACHE SIZE: " << CACHE_SIZE / 1024 << " KB" << endl;
cout << "CACHE LINE SIZE: " << CACHE_LINE_SIZE << " bytes" << endl;
cout << "CACHE LINES PER SET: " << CACHE_LINES_PER_SET << endl;
cout << "SET SIZE: " << SET_SIZE << " bytes" << endl;
cout << "NUMBER OF SETS: " << NUM_OF_SETS << endl;

fill_n(ostream_iterator<char>(cout), 30, '='); cout << endl;

//======================================================================
// Test parameters
//======================================================================

const int STEP = NUM_OF_SETS * CACHE_LINE_SIZE + (useCriticalStep ? 0 : 1);

//======================================================================
// Print out the machine parameters
//======================================================================

cout << "BUFFER SIZE: " << BUFFER_SIZE / pow(2, 20) << " MB" << endl;
cout << "STEP SIZE: " << STEP << " bytes" << endl;
cout << "NUMBER OF REPS: " << REPS << endl;

fill_n(ostream_iterator<char>(cout), 30, '='); cout << endl;

//======================================================================
// Start the test
//======================================================================

char* buffer = new char[BUFFER_SIZE];

clock_t t1 = clock();

int index = 0;
for (size_t i = 0; i < REPS; i++)
{
index += STEP;
if (index >= BUFFER_SIZE)
{
index = 0;
}

if (onlyWriteToCache)
{
buffer[index] = (char)(index % 255);
}
else
{
buffer[index] = (char)(buffer[index] % 255);
}
}

clock_t t2 = clock();

//======================================================================
// Print the execution time (in clock ticks) and cleanup resources
//======================================================================

float executionTime = (float)(t2 - t1) / CLOCKS_PER_SEC;
cout << "EXECUTION TIME: " << executionTime << "s" << endl;

delete[] buffer;
}

Заранее спасибо, если вам удалось прочитать этот длинный вопрос.

13

Решение

Что касается вашего ожидания № 3, вы правы. Это как вы могли ожидать. пожалуйста, проверьте «Что каждый программист должен знать о памяти» Больше подробностей. Это отличная серия статей, объясняющих иерархию памяти.

Так почему трудно подтвердить номер 3: есть две основные причины. Одним из них является выделение памяти, а другим — трансляция виртуальных физических адресов.

Выделение памяти

Нет строгой гарантии того, каков фактический физический адрес выделенной области памяти. Если вы хотите протестировать кэш процессора, я всегда рекомендую использовать posix_memalign заставить распределение к определенной границе. В противном случае вы, вероятно, увидите странное поведение.

Перевод адреса

Как работает адресный перевод, хорошо объяснено в статье, которую я упомянул. И чтобы подтвердить свои предположения, вы должны попытаться точно определить ожидаемое поведение. Самый простой способ сделать это заключается в следующем:

эксперимент

Выделите набор k большие области памяти (что-то вроде 512 МБ) в виде int массивы и выровнять их все по границе страницы 4096b. Теперь переберите все элементы в области памяти и постепенно добавляйте больше областей k в ваш эксперимент. Измерьте время и нормализуйте по количеству прочитанных элементов.

Код может выглядеть так:

#define N 10000000
for(size_t i=0; i < k; ++i) {

size_t sum=0;
clock_t t1= clock();
for(size_t j=0; j < N; ++j) {
for(size_t u=0; u<i; ++u) {
sum += data[u][j];
}
}

clock_t t2= clock();

}

Так что будет. Все большие области памяти выровнены по 4k и на основе предыдущего предположения все элементы одной строки будут отображаться в один и тот же набор кеша. Когда количество спроецированных областей памяти в цикле больше, чем ассоциативность кэша, при любом доступе произойдет промах кэша, и среднее время обработки на элемент увеличится.

Обновить

То, как обрабатываются записи, зависит от того, как используется строка кэша и процессор. Современные процессоры применяют МЭСИ протокол для обработки записей в строки кэша, чтобы гарантировать, что все стороны имеют одинаковое представление о памяти (когерентность кэша). Как правило, прежде чем вы сможете записать в строку кэша, строка кэша должна быть прочитана, а затем записана обратно. Если вы распознаете обратную запись или нет, зависит от того, как вы получаете доступ к данным. Если вы снова перечитаете строку кэша, вы, вероятно, не заметите разницы.

Однако, хотя программист, как правило, не влияет на то, как данные хранятся в кэшах ЦП, при записи существует небольшая разница. Можно выполнять так называемые потоковые записи, которые не загрязняют кэш, а записываются непосредственно в память. Эти записи также называются Non-Temporal пишет.

2

Другие решения

Прежде всего, есть небольшое пояснение, которое необходимо сделать — в большинстве случаев при записи по-прежнему требуется извлекать строку в локальный кэш, поскольку строки обычно составляют 64 байт, и ваша запись может изменить только частичную часть этого — слияние будет произведено в кеш.
Даже если бы вы записали всю строку за один раз (что в некоторых случаях теоретически могло бы быть возможным), вам все равно пришлось бы ждать доступа, чтобы получить право владения строкой, прежде чем писать в нее — этот протокол называется RFO (читай для владельца), и он может быть довольно длинным, особенно если у вас система с несколькими сокетами или что-то со сложной иерархией памяти.

С учетом вышесказанного, ваше 4-е предположение может все еще быть верным в некоторых случаях, так как операция загрузки действительно потребует выборки данных до того, как программа продвинется, в то время как хранилище может быть буферизовано для записи позже, когда это возможно. Тем не менее, загрузка остановит программу только в том случае, если она находится на каком-то критическом пути (это означает, что какая-то другая операция ожидает своего результата), поведение, которое ваша тестовая программа не выполняет. Поскольку большинство современных процессоров предлагают выполнение вне очереди, следующие независимые инструкции могут выполняться без ожидания завершения загрузки.
В вашей программе нет зависимости между циклами, за исключением простого продвижения по индексу (которое может легко выполняться вперед), поэтому вы в основном не ограничены задержкой памяти, а скорее пропускной способностью памяти, что является совершенно другой вещью.
Кстати, чтобы добавить такую ​​зависимость, вы можете эмулировать обход связанного списка или, что еще проще, убедиться, что массив инициализирован в ноль (и переключить записи только в нули), и добавить содержимое каждого значения чтения в индекс на каждой итерации (в дополнение к приращению) — это создаст зависимость без изменения самих адресов. В качестве альтернативы, сделайте что-нибудь неприятное, как это (предполагая, что компилятор не достаточно умен, чтобы отбросить это …):

    if (onlyWriteToCache)
{
buffer[index] = (char)(index % 255);
}
else
{
buffer[index] = (char)(buffer[index] % 255);
index += buffer[index];
index -= buffer[index];
}

Теперь о результатах, кажется, что запись против чтения + запись ведут себя одинаково, когда вы переходите на критический шаг, как и ожидалось (так как чтение не сильно отличается от RFO, который все равно будет выдан записью ). Однако для некритического шага операция чтения + записи намного медленнее. Сейчас трудно сказать, не зная точной системы, но это может произойти из-за того факта, что загрузка (чтение) и сохранение (запись) не выполняются на одном и том же этапе в течение срока действия инструкции — это означает, что между загрузкой и в магазине, который следует, вы, возможно, уже выселили строку и вам нужно получить ее снова во второй раз. Я не слишком уверен в этом, но если вы хотите проверить, возможно, вы могли бы добавить инструкцию сборки sfence между итерациями (хотя это значительно замедлило бы вас).

И последнее замечание — когда у вас ограниченная пропускная способность, запись может немного замедлить вас из-за другого требования — когда вы записываете в память, вы извлекаете строку в кэш и изменяете ее. Модифицированные строки должны быть записаны обратно в память (хотя в действительности существует целый набор кэшей более низкого уровня), которые требуют ресурсов и могут забить вашу машину. Попробуйте цикл «только для чтения» и посмотрите, как он работает.

1

Я также попытался наступить на быстрый рейк, когда прочитал о механизме кэша в Optimization C ++ Агнера Фрога.

Согласно этим книгам ваше второе предположение неверно, потому что адрес памяти всегда принадлежит определенной строке кэша в наборе. Таким образом, каждый байт может кэшироваться одними и теми же строками кэша разными «способами».

Моя первая попытка сделать это в пространстве пользователя не удалась. (У меня процессор i5-4200).

Total size 128kb cache set size 8kb => time 18ms; 568000000
Total size 256kb cache set size 16kb => time 13ms; 120000000
Total size 384kb cache set size 24kb => time 12ms; 688000000
Total size 512kb cache set size 32kb => time 14ms; 240000000

$ g ++ -std = c ++ 11 -march = native -O3 hit-stride.cpp -o hit-stride

#include<iostream>
#include<chrono>

using namespace std::chrono;
using namespace std;

int main(int argc, char** argv) {
unsigned int cacheSetSizes[] = { 8, 16, 24, 32 };
const int ways = 8;

for (unsigned int i = 0; i < sizeof(cacheSetSizes) / sizeof(int); ++i) {
const unsigned int setSize = cacheSetSizes[i] * 1024;
const unsigned int size = setSize * ways * 2;
char* buffer = new char[size];
for (int k = 0; k < size; ++k) {
buffer[k] = k % 127;
}
const auto started = steady_clock::now();
int sum = 0;
for (int j = 0; j < 1000000; ++j) {
for (int k = 0; k < size; k += setSize) {
sum += buffer[k];
}
}
const auto ended = steady_clock::now();
cout << "Total size " << (size >> 10) << "kb cache set size " << cacheSetSizes[i]
<< "kb => time " << duration_cast<milliseconds>(ended - started).count()
<< "ms; " << sum << endl;
delete buffer;
}
return 0;
}

«Тот же» код, заключенный в модуль ядра, выглядит как хиты L2:
Я понял, что мне нужно сделать память физически смежной.
Это можно сделать только в режиме ядра. Мой кеш L1 размером 32кб. В тесте я прохожу область памяти длиннее, чем количество способов (8) с шагом, равным размеру кэша. Таким образом, я получаю заметное замедление на 32 КБ (последняя строка).

Apr 26 11:13:54 diehard kernel: [24992.943076] Memory 512 kb is allocated
Apr 26 11:13:54 diehard kernel: [24992.969814] Duration  23524369 ns for cache set size         8 kb; sum = 568000000
Apr 26 11:13:54 diehard kernel: [24992.990886] Duration  21076036 ns for cache set size        16 kb; sum = 120000000
Apr 26 11:13:54 diehard kernel: [24993.013832] Duration  22950526 ns for cache set size        24 kb; sum = 688000000
Apr 26 11:13:54 diehard kernel: [24993.045584] Duration  31760368 ns for cache set size        32 kb; sum = 240000000

$ make && sudo insmod hello.ko && спать 1 && tail -n 100 / var / log / syslog

#include <linux/module.h>   /* Needed by all modules */
#include <linux/kernel.h>   /* Needed for KERN_INFO */
#include <linux/time.h>

static unsigned long p = 0;
static struct timespec started, ended;
static unsigned int cacheSetSizes[] = { 8, 16, 24, 32 };
static const u32 ways = 8;
static const u32 m = 2;
static char* buffer;
static unsigned int setSize;
static unsigned int size;
static unsigned int i, j, k;
static int sum;

int init_module(void) {
s64 st, en, duration;
u32 max = 1*1024*1024;
printk(KERN_INFO "Hello world 1.\n");
p = __get_free_pages(GFP_DMA, get_order(max));
printk(KERN_INFO "Memory %u kb is allocated\n", ways * m * 32);
buffer = (char*) p;

for (k = 0; k < max; ++k) {
buffer[k] = k % 127;
}

for (i = 0; i < sizeof(cacheSetSizes) / sizeof(int); ++i) {
setSize = cacheSetSizes[i] * 1024;
size = setSize * ways * m;
if (size > max) {
printk(KERN_INFO "size %u is more that %u", size, max);
return 0;
}
getnstimeofday(&started);
st = timespec_to_ns(&started);

sum = 0;
for (j = 0; j < 1000000; ++j) {
for (k = 0; k < size; k += setSize) {
sum += buffer[k];
}
}

getnstimeofday(&ended);
en = timespec_to_ns(&ended);
duration = en - st;
printk(KERN_INFO "Duration %9lld ns for cache set size %9u kb; sum = %9d\n",
duration, cacheSetSizes[i], sum);
}
return 0;
}

void cleanup_module(void) {
printk(KERN_INFO "Goodbye world 1.\n");
free_pages(p, get_order(1*1024*1024));
printk(KERN_INFO "Memory is free\n");
}
0
По вопросам рекламы [email protected]