Поддерживают ли текущие архитектуры x86 невременные нагрузки (из "нормальной" памяти)?

Я знаю несколько вопросов по этой теме, однако я не видел никаких четких ответов и каких-либо контрольных измерений. Таким образом, я создал простую программу, которая работает с двумя массивами целых чисел. Первый массив 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 перед циклом: (

Ответы

Ответ 1

Чтобы ответить конкретно на заголовок вопроса:

Да, последние 1 основные процессоры Intel поддерживают невременные нагрузки на обычную память 2, но только "опосредованно" с помощью инструкций без временной предвыборки, а не напрямую с помощью инструкций без временной нагрузки, таких как movntdqa. Это контрастирует с невременными магазинами, где вы можете просто напрямую использовать соответствующие инструкции 3 временного хранилища.

Основная идея заключается в том, что вы prefetchnta в строку кэша перед любыми нормальными нагрузками, а затем выдаете нагрузки как обычно. Если строка еще не была в кеше, она будет загружена невременно. Точный смысл невременной моды зависит от архитектуры, но общий шаблон заключается в том, что строка загружается, по крайней мере, в L1 и, возможно, в несколько более высоких уровней кеша. Действительно, для предварительной выборки, которая должна быть использована, она должна заставить строку загружаться по крайней мере на некоторый уровень кэша для потребления посредством более поздней загрузки. Линия также может быть обработана специально в кеше, например, помещая ее как высокий приоритет для выселения или ограничивая способы ее размещения.

Результатом всего этого является то, что в то время как не временные нагрузки поддерживаются в некотором смысле, они действительно только частично не временные, в отличие от магазинов, где вы действительно не оставляете следов линии на любом из уровней кеша. Невременные нагрузки вызывают некоторое загрязнение кэша, но обычно меньше обычных нагрузок. Точные детали являются специфичными для архитектуры, и я включил некоторые детали ниже для современной Intel (в этом ответе вы можете найти немного более длинную запись).

Клиент Skylake

Основываясь на тестах в этом ответе, кажется, что поведение для prefetchnta Skylake заключается в том, чтобы нормально загружаться в кеш L1, чтобы полностью пропустить L2 и выборочно входить в кеш L3 (возможно, в 1 или 2 пути, общая сумма L3, доступная для nta prefetches, ограничена).

Это было протестировано на клиенте Skylake, но я считаю, что это основное поведение, вероятно, простирается назад, вероятно, до Sandy Bridge и ранее (на основе формулировки в руководстве по оптимизации Intel), а также направляется в Kaby Lake и более поздние архитектуры на основе клиента Skylake. Поэтому, если вы не используете часть Skylake-SP или Skylake-X или чрезвычайно старый процессор, это, вероятно, поведение, которое вы можете ожидать от prefetchnta.

Сервер Skylake

Единственный недавний чип Intel, который, как известно, имеет другое поведение, - это сервер Skylake (используемый в Skylake-X, Skylake-SP и еще несколько строк). Это значительно изменило архитектуру L2 и L3, а L3 больше не включает гораздо больший L2. Для этого чипа кажется, что prefetchnta пропускает как кеши L2, так и L3, поэтому в этом кэше архитектуры ограничение ограничено L1.

Такое поведение было сообщено пользователем Mysticial в комментарии. Недостаток, как отмечалось в этих комментариях, заключается в том, что это делает prefetchnta гораздо более хрупким: если вы получите неправильное выборку или выбор времени предварительной выборки (особенно легко, когда задействовано гиперпоточность и активное ядро sibling), а данные выселяются из L1 до вы используете, вы возвращаетесь в основную память, а не на L3 на более ранних архитектурах.


1 Недавнее здесь, вероятно, означает что-либо за последнее десятилетие или около того, но я не хочу подразумевать, что ранее аппаратные средства не поддерживали невременную предварительную выборку: возможно, поддержка вернулась к внедрению prefetchnta но я не иметь аппаратное обеспечение, чтобы проверить это и не может найти существующий надежный источник информации о нем.

2 Нормальный здесь означает только память WB (writeback), которая является памятью на уровне приложения в подавляющем большинстве случаев.

3 В частности, инструкции хранилища NT являются movnti для регистров общего назначения и семейств movntd* и movntp* для регистров SIMD.

Ответ 2

Я отвечаю на свой вопрос, так как нашел следующий пост от Intel Developer Forum, что имеет смысл для меня. Джон МакКальпин написал:

Результаты для основных процессоров не удивительны - при отсутствии истинной памяти "блокнот" неясно, что можно спроектировать реализацию "невременного" поведения, которое не подлежит неприятным сюрпризам. Двумя подходами, которые использовались в прошлом, являются (1) загрузка строки кэша, но маркировка LRU вместо MRU и (2) загрузка строки кэша в один конкретный "набор" ассоциативно-ассоциативного кеша. В любом случае относительно легко создавать ситуации, в которых кеш падает на данные до того, как процессор завершит их чтение.

Оба этих подхода подвержены ухудшению производительности в случаях, работающих на более чем небольшом числе массивов, и их значительно сложнее реализовать без "gotchas" при рассмотрении HyperThreading.

В других контекстах я выступал за реализацию инструкций "load multiple", которые гарантировали бы, что все содержимое строки кэша будет скопировано для регистров атомарно. Мое рассуждение состоит в том, что аппаратное обеспечение абсолютно гарантирует, что линия кэша будет перемещаться атомарно и что время, необходимое для копирования оставшейся части строки кэша в регистры, было настолько маленьким (дополнительные 1-3 цикла, в зависимости от поколения процессора), которые могли бы быть безопасно реализованы как атомная операция.

Начиная с Haswell, ядро может считывать 64 байта за один цикл (2 256-битных выровненных AVX-чтения), поэтому воздействие непредвиденных побочных эффектов становится еще ниже.

Начиная с KNL нагрузки с полной кеш-строкой (выравниваемые) должны быть "естественно" атомарными, поскольку передачи из кэша данных L1 в ядро являются полными строками кэша, и все данные помещаются в целевой регистр AVX-512. (Это не означает, что Intel гарантирует атомарность в реализации! У нас нет видимости в ужасных угловых случаях, которые разработчики должны учитывать, но разумно сделать вывод, что большую часть времени выровняли 512-битные нагрузки атомарно.) С этой "естественной" 64-байтовой атомарностью некоторые из трюков, использованных в прошлом для уменьшения загрязнения кэша из-за "невременных" нагрузок, могут заслуживать другого взгляда....


Инструкция MOVNTDQA предназначена в первую очередь для чтения из диапазонов адресов, которые отображаются как "Write-Combining" (WC), а не для чтения из обычной системной памяти, которая отображается "Write-Back" (WB). Описание в томе 2 SWDM говорит, что реализация "может" делать что-то особенное с MOVNTDQA для регионов WB, но основное внимание уделяется поведению для типа памяти WC.

Тип памяти "Write-Combining" почти никогда не используется для "реальной" памяти - он используется почти исключительно для областей ввода-вывода с памятью.

См. Здесь весь пост: https://software.intel.com/en-us/forums/intel-isa-extensions/topic/597075