Расположение пространственного кэша ЦП в итерации массива

Насколько я понимаю, кеш L1 состоит в том, что выборка из памяти загружает строку кеша. Предполагая, что размер строки кэша составляет 64 байта, если я обращаюсь к памяти по адресу p, он загружает в кеш весь блок от p до p + 64. Таким образом, лучше всего перебирать массив слева направо (а не справа налево), чтобы максимизировать локальность кеша.

Однако я написал пример кода C, который выделяет массив из 100 миллионов символов, записывает в него случайные значения и суммирует их (скопировано ниже для справки). Одна версия кода суммируется слева направо, а другая - справа налево. Когда я протестировал его, я получил очень похожие результаты (где «тактовые циклы» измеряются в единицах _ 4_. Код был скомпилирован без оптимизации.

Итак, мой вопрос: делают ли современные процессоры что-то еще, кроме «кэширования чтения + 64 байта»? Кэшируют ли они вперед и назад? Может ли компилятор «сказать» процессору, что код повторяется в обратном направлении?

Для справки, я работаю на Mac OS X 10.13.3, использую gcc-7 (Homebrew GCC 7.2.0_1) 7.2.0 и процессор Intel x86-64 с линией кэша 64 байта.

Скамьи:

$ ./a.out
Backward Iterating...took 150101 clock cycles

$ ./a.out
Forward Iterating...took 146545 clock cycles

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

Итак, я назвал это cachegrind. И частота пропусков попаданий в кеш для обоих была примерно одинаковой:

# Left to right iteration
==21773==
==21773== I   refs:      4,006,996,067
==21773== I1  misses:            5,183
==21773== LLi misses:            3,019
==21773== I1  miss rate:          0.00%
==21773== LLi miss rate:          0.00%
==21773==
==21773== D   refs:      1,802,393,260  (1,401,627,925 rd   + 400,765,335 wr)
==21773== D1  misses:        3,153,101  (    1,588,104 rd   +   1,564,997 wr)
==21773== LLd misses:        3,004,885  (    1,440,660 rd   +   1,564,225 wr)
==21773== D1  miss rate:           0.2% (          0.1%     +         0.4%  )
==21773== LLd miss rate:           0.2% (          0.1%     +         0.4%  )
==21773==
==21773== LL refs:           3,158,284  (    1,593,287 rd   +   1,564,997 wr)
==21773== LL misses:         3,007,904  (    1,443,679 rd   +   1,564,225 wr)
==21773== LL miss rate:            0.1% (          0.0%     +         0.4%  )

# Right to left iteration
==21931==
==21931== I   refs:      4,006,996,453
==21931== I1  misses:            5,198
==21931== LLi misses:            3,045
==21931== I1  miss rate:          0.00%
==21931== LLi miss rate:          0.00%
==21931==
==21931== D   refs:      1,802,393,428  (1,401,628,038 rd   + 400,765,390 wr)
==21931== D1  misses:        3,153,113  (    1,588,079 rd   +   1,565,034 wr)
==21931== LLd misses:        3,135,505  (    1,571,219 rd   +   1,564,286 wr)
==21931== D1  miss rate:           0.2% (          0.1%     +         0.4%  )
==21931== LLd miss rate:           0.2% (          0.1%     +         0.4%  )
==21931==
==21931== LL refs:           3,158,311  (    1,593,277 rd   +   1,565,034 wr)
==21931== LL misses:         3,138,550  (    1,574,264 rd   +   1,564,286 wr)
==21931== LL miss rate:            0.1% (          0.0%     +         0.4%  )

Код:

#include <stdint.h>
#include <time.h>
#include <stdio.h>
#include <stdlib.h>

#define BUF_SIZE 100000000

int main() {
  srand(time(NULL));
  uint8_t *buf1 = (uint8_t *)malloc(BUF_SIZE);
  // Fill the buf with random data
  for (size_t i = 0; i < BUF_SIZE; ++i) {
    buf1[i] = rand();
  }

#ifdef BACKWARDS
  printf("Backward Iterating...");
#else
  printf("Forward Iterating...");
#endif

  uint64_t sum = 0;
  clock_t start = clock();
#ifdef BACKWARDS
  for (size_t i = BUF_SIZE - 1; i != ~0; --i) {
#else
  for (size_t i = 0; i < BUF_SIZE; ++i) {
#endif
    sum += buf1[i];
  }
  clock_t end = clock();
  printf("took %lu clock cycles\n", end - start);
  printf("sum: %llu\n", sum);
  free(buf1);
}

person user1413793    schedule 14.02.2018    source источник
comment
В вашем расчете есть одно значение; это будет от p + 0 до p + 63 (а не p + 64), если p выровнено по 64-байтовой границе. В противном случае это от (p & 0x3F) до (p & 0x3F) + 0x3F, при условии, что p рассматривается как char * (вы не можете выполнять арифметические операции с void *, кроме GCC и компиляторов, эмулирующих GCC). А это означает, что чтение в обратном направлении загружает строку кэша, когда вы начинаете с конца и работаете в обратном направлении, поэтому производительность не будет сильно отличаться от работы вперед. То, что было бы плохим в 2D-массиве, читало бы вниз по столбцам, а не по строкам; это действительно сильно ударит по кешу.   -  person Jonathan Leffler    schedule 14.02.2018
comment
Один цикл делает 100000000 кругов, другой считает от 100000000 до нулевого цикла и до UINT_MAX. Это не обязательно то же самое, что SIZE_MAX. Не пытайтесь хитрить с поразрядными операторами. Кроме того, SIZE_MAX не обязательно ~(size_t)0, поэтому все предположения изначально неверны.   -  person Lundin    schedule 14.02.2018


Ответы (2)


Чтобы расширить предыдущий ответ:

Загрузка полной гранулярности строки кэша означает, что движение вперед или назад не имеет значения, как только вы коснетесь одной стороны строки, вы получите все это. Это, конечно, применимо только для кэшируемых нагрузок и типов памяти (+ случаи потоковой передачи, которые могут быть выполнены, пока еще находятся в буферах).

Однако это еще не все. Современные процессоры используют аппаратные средства предварительной выборки, которые очень хорошо справляются с пространственной локальностью - они увеличивают степень детализации за счет предварительной выборки дополнительных строк кэша в том же направлении, в котором вы продвигаетесь. Завершение работы с предварительными выборками зависит от конкретной архитектуры, которую вы используете, но общие из них включают следующую строку, смежную строку (+/- 1 строка), поток следующих строк или шаги на основе IP. Дополнительная информация здесь .

Эти предварительные выборки должны быть симметричными, но мы не знаем наверняка (точные детали не раскрываются), у них могут быть разные шансы или пороги для разных направлений.

Другой момент заключается в том, что cachegrind - это просто имитация кеша, она не включает такие эффекты, как предварительная выборка, и даже не моделирует точный кеш (размеры должны быть в порядке, но политика замены и другие детали микроархитектуры не гарантируются. быть таким же), поэтому вы не увидите полного эффекта. Вероятно, лучше просмотреть фактическое поведение HW с помощью счетчиков производительности.

person Leeor    schedule 14.02.2018

Если я обращаюсь к памяти по адресу p, он загружает весь блок от p до p + 64 в кеш.

Не совсем. Процессор загружает строку кэша, содержащую p. Например, если p равно 0x1234, то загружается строка кеша от 0x1200 до 0x123F. В результате сканирование в обратном направлении по массиву не будет сильно отличаться от сканирования вперед.

person user3386109    schedule 14.02.2018
comment
Следствие: последовательно (в любом направлении) = хорошо, оставаться в той же зоне = хорошо, прыгать в памяти = плохо. - person Matteo Italia; 14.02.2018