Я знаю несколько вопросов по этой теме, однако я не видел никаких четких ответов и каких-либо контрольных измерений. Таким образом, я создал простую программу, которая работает с двумя массивами целых чисел. Первый массив a
очень велик (64 МБ), а второй массив b
мал, чтобы вписаться в кеш L1. Программа выполняет итерацию над a
и добавляет ее элементы к соответствующим элементам b
в модульном смысле (когда достигнут конец b
, программа начинается с ее начала снова). Измеренные числа промахов кэша L1 для разных размеров b
следующие:
Измерения проводились на процессоре Xeon E5 2680v3 Haswell типа с кэшем данных L1 объемом 32 kiB. Поэтому во всех случаях b
устанавливается в кеш L1. Однако количество промахов значительно увеличилось примерно на 16 килобайт от b
объема памяти. Это можно было бы ожидать, так как нагрузки как a
, так и b
приводят к недействительности строк кэша с начала b
в этот момент.
Нет никаких оснований хранить элементы a
в кеше, они используются только один раз. Поэтому я запускаю программный вариант с невременными нагрузками данных a
, но количество промахов не изменилось. Я также запускаю вариант с невременной предварительной выборкой данных a
, но все же с теми же результатами.
Мой контрольный код выглядит следующим образом (вариант без показания предварительной выборки):
int main(int argc, char* argv[])
{
uint64_t* a;
const uint64_t a_bytes = 64 * 1024 * 1024;
const uint64_t a_count = a_bytes / sizeof(uint64_t);
posix_memalign((void**)(&a), 64, a_bytes);
uint64_t* b;
const uint64_t b_bytes = atol(argv[1]) * 1024;
const uint64_t b_count = b_bytes / sizeof(uint64_t);
posix_memalign((void**)(&b), 64, b_bytes);
__m256i ones = _mm256_set1_epi64x(1UL);
for (long i = 0; i < a_count; i += 4)
_mm256_stream_si256((__m256i*)(a + i), ones);
// load b into L1 cache
for (long i = 0; i < b_count; i++)
b[i] = 0;
int papi_events[1] = { PAPI_L1_DCM };
long long papi_values[1];
PAPI_start_counters(papi_events, 1);
uint64_t* a_ptr = a;
const uint64_t* a_ptr_end = a + a_count;
uint64_t* b_ptr = b;
const uint64_t* b_ptr_end = b + b_count;
while (a_ptr < a_ptr_end) {
#ifndef NTLOAD
__m256i aa = _mm256_load_si256((__m256i*)a_ptr);
#else
__m256i aa = _mm256_stream_load_si256((__m256i*)a_ptr);
#endif
__m256i bb = _mm256_load_si256((__m256i*)b_ptr);
bb = _mm256_add_epi64(aa, bb);
_mm256_store_si256((__m256i*)b_ptr, bb);
a_ptr += 4;
b_ptr += 4;
if (b_ptr >= b_ptr_end)
b_ptr = b;
}
PAPI_stop_counters(papi_values, 1);
std::cout << "L1 cache misses: " << papi_values[0] << std::endl;
free(a);
free(b);
}
Интересно, поддерживают ли процессоры ЦП или поддерживают невременную загрузку/предварительную выборку или каким-либо другим способом, как маркировать некоторые данные как несобственные в кеше (например, маркировать их как LRU). Бывают ситуации, например, в HPC, где подобные сценарии распространены на практике. Например, в разреженных итеративных линейных решателях /eigensolvers матричные данные обычно очень большие (больше емкости кэша), но векторы иногда достаточно малы, чтобы вписаться в L3 или даже кэш L2. Тогда мы хотели бы сохранить их там любой ценой. К сожалению, загрузка матричных данных может привести к недействительности особенно строк x-векторного кэша, хотя в каждой итерации решателя матричные элементы используются только один раз, и нет причин держать их в кеше после их обработки.
UPDATE
Я только что сделал аналогичный эксперимент на Intel Xeon Phi KNC, измеряя время выполнения вместо промахов L1 (я не нашел способа их надежного измерения, PAPI и VTune дали странные показатели). Результаты здесь:
Оранжевая кривая представляет собой обычные нагрузки и имеет ожидаемую форму. Синяя кривая представляет нагрузку с подсказкой выключения вызова (EH), установленной в префиксе инструкций, а серая кривая представляет случай, когда каждая строка кэша a
была выведена вручную; оба этих трюка, включенные KNC, очевидно, работали так, как мы хотели для b
более 16 kiB. Код измеряемого контура выглядит следующим образом:
while (a_ptr < a_ptr_end) {
#ifdef NTLOAD
__m512i aa = _mm512_extload_epi64((__m512i*)a_ptr,
_MM_UPCONV_EPI64_NONE, _MM_BROADCAST64_NONE, _MM_HINT_NT);
#else
__m512i aa = _mm512_load_epi64((__m512i*)a_ptr);
#endif
__m512i bb = _mm512_load_epi64((__m512i*)b_ptr);
bb = _mm512_or_epi64(aa, bb);
_mm512_store_epi64((__m512i*)b_ptr, bb);
#ifdef EVICT
_mm_clevict(a_ptr, _MM_HINT_T0);
#endif
a_ptr += 8;
b_ptr += 8;
if (b_ptr >= b_ptr_end)
b_ptr = b;
}
ОБНОВЛЕНИЕ 2
В Xeon Phi, icpc
, сгенерированном для варианта стандартной загрузки (оранжевая кривая) для a_ptr
:
400e93: 62 d1 78 08 18 4c 24 vprefetch0 [r12+0x80]
Когда я вручную (путем hex-редактирования исполняемого файла) изменил это значение на:
400e93: 62 d1 78 08 18 44 24 vprefetchnta [r12+0x80]
Я получил желаемые результаты, даже лучше, чем синие/серые кривые. Тем не менее, я не смог заставить компилятор генерировать для вас невременную предварительную подготовку, даже используя #pragma prefetch a_ptr:_MM_HINT_NTA
перед циклом: (