Предполагая, что мне нужны три порции данных из сильно загруженной строки кэша, есть ли способ загрузить все три вещи «атомарно», чтобы избежать более чем одного обращения к любому другому ядру?

На самом деле мне не нужно гарантировать правильность атомарности для моментального снимка всех трех членов, просто в обычном случае, когда все три элемента читаются в одном и том же цикле тактовой частоты. Я хочу избежать случая, когда строка кэша прибывает, но затем приходит запрос на аннулирование прежде, чем все 3 объекта будут прочитаны. Это приведет к тому, что третьему доступу потребуется отправить еще один запрос на совместное использование линии, что еще больше усугубит конфликт.

Например,

class alignas(std::hardware_destructive_interference_size) Something {
    std::atomic<uint64_t> one;
    std::uint64_t two;
    std::uint64_t three;
};

void bar(std::uint64_t, std::uint64_t, std::uint64_t);

void f1(Something& something) {
    auto one = something.one.load(std::memory_order_relaxed);
    auto two = something.two;
    if (one == 0) {
        bar(one, two, something.three);
    } else {
        bar(one, two, 0);
    }

}

void f2(Something& something) {
    while (true) {
        baz(something.a.exchange(...));
    }
}

Могу ли я как-то убедиться, что one, two и three будут загружены вместе без нескольких RFO в условиях сильной конкуренции (предположим, что f1 и f2 работают одновременно)?

Целевой архитектурой / платформой для целей этого вопроса является Intel x86 Broadwell, но если есть методика или встроенная компилятор, которая позволяет делать что-то лучшее из этого, что-то переносимое, это также было бы здорово.

5
Curious 31 Май 2019 в 00:21

2 ответа

Лучший ответ

Терминология: загрузка не будет генерировать RFO, ей не нужно владение . Он только отправляет запрос на обмен данными. Несколько ядер могут считывать данные с одного и того же физического адреса параллельно, каждое из которых имеет горячую копию в своем кэше L1d.

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


Загрузка SIMD в Hadi - это хорошая идея, чтобы собрать все данные одной инструкцией.

Насколько нам известно, _mm_load_si128() на практике является атомарным для своих 8-байтовых кусков, поэтому он может безопасно заменить .load(mo_relaxed) атомарного. Но посмотрите атомарность каждого элемента векторной загрузки / хранения и сбора / разброса? - нет явной письменной гарантии этого.

Если вы использовали _mm256_loadu_si256(), остерегайтесь настройки GCC по умолчанию -mavx256-split-unaligned-load: Почему gcc не разрешает _mm256_loadu_pd как один vmovupd? Так что это еще одна веская причина для использования выровненной загрузки, помимо необходимости избегать разбиения строки кэша.

Но мы пишем на C, а не на asm, поэтому нам нужно беспокоиться о некоторых других вещах, которые std::atomic делает с mo_relaxed: в частности, повторяющиеся загрузки с одного и того же адреса могут не давать одинаковое значение , Возможно, вам нужно разыменовать volatile __m256i* , чтобы имитировать, что load(mo_relaxed).

Вы можете использовать atomic_thread_fence(), если хотите более сильный порядок; Я думаю, что на практике компиляторы C ++ 11, которые поддерживают встроенные функции Intel, упорядочат изменчивые разыменования по отношению к ним. ограждает так же, как std::atomic загружает / хранит. В ISO C ++ объекты volatile по-прежнему подвержены гонкам данных UB, но в реальных реализациях, которые, например, могут компилировать ядро Linux, volatile могут использоваться для многопоточности. (Linux катит свою собственную атомарность с volatile и встроенным asm, и это, я думаю, считается поддерживаемым поведением gcc / clang.) Учитывая, что на самом деле делает volatile (объект в памяти соответствует абстрактной машине C ++), это в основном просто автоматически работает, несмотря на любые опасения юриста правил, что это технически UB. Это UB, о котором компиляторы не могут знать или заботиться, потому что в этом весь смысл volatile.

На практике есть веские основания полагать, что все выровненные 32-байтовые загрузки / хранилища на Haswell и более поздних являются атомарными. Конечно, для чтения из L1d в неупорядоченный бэкэнд, но также и для передачи строк кэша между ядрами. (например, мульти-сокет K10 может разрываться на 8-байтовых границах с HyperTransport, так что это действительно отдельная проблема). Единственной проблемой для его использования является отсутствие какой-либо письменной гарантии или одобренного поставщиком процессора способа обнаружить эту «функцию».


Кроме этого, для переносимого кода это может помочь поднять auto three = something.three; из ветви ; неправильный прогноз ветки дает ядру гораздо больше времени для аннулирования линии до 3-й загрузки.

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

    bar(one, two, one == 0 ? something.three : 0);

Broadwell может запускать 2 загрузки за такт (как и все основные x86 начиная с Sandybridge и K8); мопы обычно выполняются в порядке «самый старый-готов-первый-первый», поэтому вполне вероятно (если этой загрузке пришлось ждать данных из другого ядра), что наши 2 загрузочных мопа будут выполняться в первом возможном цикле после поступления данных ,

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

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

Но если one == 0 встречается редко, то three часто вообще не требуется, поэтому безусловная загрузка создает риск ненужных запросов на него. Поэтому вы должны учитывать этот компромисс при настройке, если вы не можете покрыть все данные одной SIMD загрузкой.


Как обсуждалось в комментариях, программная предварительная выборка может потенциально помочь скрыть некоторую задержку между ядрами.

Но вы должны выполнить предварительную выборку намного позже, чем для обычного массива, поэтому поиск мест в вашем коде, которые часто выполняют от ~ 50 до ~ 100 циклов перед вызовом f1(), является сложной проблемой и может "заразить" много другого кода с деталями, не связанными с его нормальной работой. И вам нужен указатель на правильную строку кэша.

Вам нужно, чтобы PF был достаточно поздним, чтобы загрузка по требованию происходила за несколько (десятки) циклов до фактического поступления предварительно выбранных данных. Это противоположно обычному сценарию использования, где L1d является буфером для предварительной выборки и хранения данных из завершенных предварительных выборок до того, как нагрузка до них доберется до них. Но вы хотите load_hit_pre.sw_pf perf-события (предварительная выборка попадания при загрузке), потому что это означает, что загрузка по требованию произошла, пока данные еще находились в полете, до того, как есть вероятность их аннулирования.

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

3
Peter Cordes 31 Май 2019 в 02:23

Пока размер std::atomic<uint64_t> составляет не более 16 байтов (что имеет место во всех основных компиляторах), общий размер one, two и three не превышать 32 байта. Следовательно, вы можете определить объединение __m256i и Something, где поле Something выровнено по 32 байтам, чтобы убедиться, что оно полностью содержится в одной 64-байтовой строке кэша. Чтобы загрузить все три значения одновременно, вы можете использовать один 32-байтовый загрузчик AVX. Соответствующая внутренняя функция компилятора - _mm256_load_si256, в результате чего компилятор испускает инструкцию VMOVDQA ymm1, m256. Эта инструкция поддерживается декодированием с одной загрузкой в Intel Haswell и более поздних версиях.

32-байтовое выравнивание действительно необходимо только для того, чтобы все поля содержались в 64-байтовой строке кэша. Однако _mm256_load_si256 требует, чтобы указанный адрес памяти был выровнен по 32 байта. В качестве альтернативы можно использовать _mm256_loadu_si256 в случае, если адрес не выровнен по 32 байта.

3
Hadi Brais 30 Май 2019 в 23:19