Загрузка всей строки кэша за раз, чтобы избежать конкуренции за несколько ее элементов - PullRequest
5 голосов
/ 31 мая 2019

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

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

Ответы [ 2 ]

3 голосов
/ 31 мая 2019

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

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


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

мы знаем, что _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; из ветки ;неправильный прогноз ветки дает ядру гораздо больше времени для аннулирования строки перед третьей загрузкой.

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

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

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

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

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

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


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

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

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

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

3 голосов
/ 31 мая 2019

Пока размер 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 байта.

...