Предполагая, что есть три части данных, которые мне нужны из строки кэша с высокой конкуренцией, есть ли способ загрузить все три вещи «атомарно», чтобы избежать более одного обращения к любому другому ядру?
На самом деле я не использую нужно как гарантию правильности атомарности для моментального снимка всех трех элементов, просто в обычном случае, когда все три элемента считываются за один и тот же тактовый цикл. Я хочу избежать случая, когда строка кэша поступает, но затем приходит запрос на недействительность до того, как все 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, но если есть метод или встроенный компилятор, который позволяет делать что-то вроде этого с максимальной переносимостью, это также было бы здорово.
@alterigel Извините. В этом контексте под атомарным я подразумевал что-то, что обслуживается за одну поездку туда и обратно/RFO. Я обновлю вопрос, чтобы уточнить
@alterigel обновлен, спасибо
@ Любопытно, вы проверили спецификации x86? насколько я знаю, есть некоторые инструкции процессора для предварительной выборки памяти в кеш, прежде чем вы захотите ее использовать.
@Raxvan, я с ними не знаком. Ты случайно не про __builtin_prefetch
?
@Raxvan: Предварительная выборка программного обеспечения, вероятно, здесь не полезна. Строка является высококонкурентной, поэтому она не будет просто сидеть в нашем кеше L1d, пока ее не прочитают фактические нагрузки. Если есть какое-то время после поступления предварительной выборки до загрузки, RFO от другого ядра, вероятно, сделает ее недействительной. Если вы выполняете предварительную выборку пары инструкций перед обычной загрузкой, это также бесполезно (если только это не предварительная выборка записи с prefetchw
); загрузка по требованию также запрашивает всю строку кэша.
SW PF может помочь, если вы можете выполнить предварительную выборку маленький перед чтением, чтобы загрузка (и) по запросу была load_hit_pre.sw_pf
(событие производительности) и скрывала немного межъядерной задержки. Но настроить это сложно и зависит от того, какие другие остановки обычно происходят между PF и фактической нагрузкой; чем раньше вы это сделаете, тем больше вероятность того, что это «слишком рано»; намного хуже, чем обычная проблема с настройкой SW PF, где есть окно приличного размера между «достаточно рано» (чтобы получить попадания L1d) и «слишком рано» (выселено перед использованием из-за конфликтов кеша, а не инвалидации). Вот это склон с обрывом.
Но в любом случае, SW PF выполняет ничего, чтобы помочь выполнить все 3 загрузки одновременно; это одинаково полезно независимо от того, используете ли вы идею векторной нагрузки Хади или нет.
Пока размер std::atomic<uint64_t>
не превышает 16 байт (что имеет место во всех основных компиляторах), общий размер one
, two
и three
не превышает 32 байта. Следовательно, вы можете определить объединение __m256i
и Something
, где поле Something
выровнено по 32 байтам, чтобы гарантировать, что оно полностью содержится в одной 64-байтовой строке кэша. Чтобы загрузить все три значения одновременно, вы можете использовать одну 32-байтовую операцию загрузки AVX. Соответствующая встроенная функция компилятора — _mm256_load_si256
, которая заставляет компилятор выдавать инструкцию VMOVDQA ymm1, m256
. Эта инструкция поддерживается декодированием с одной загрузкой uop на Intel Haswell и более поздних версиях.
Выравнивание по 32 байта на самом деле необходимо только для того, чтобы гарантировать, что все поля содержатся в 64-байтовой строке кэша. Однако _mm256_load_si256
требует, чтобы указанный адрес памяти был выровнен по 32 байтам. В качестве альтернативы можно использовать _mm256_loadu_si256
, если адрес не выровнен по 32 байтам.
Ах интересно. Здесь важно отметить, что первая переменная должна быть атомарной. Является ли встроенный компилятор и соответствующая инструкция атомарной и налагает ли он хотя бы порядок получения памяти?
@Curious x86 ISA гарантирует атомарность только для 8-байтовых обращений, которые не пересекают строку кэша (см.: Атомарность на x86). Хотя в руководстве не говорится, что выровненный и кэшируемый VMOVDQA ymm1, m256
является атомарным, он является атомарным в Haswell и более поздних версиях на практике. Однако сложность здесь в том, что семантика std::atomic<uint64_t>
не сохраняется при доступе к нему через поле __m256i
.
@Curious и Hadi: см. также Поэлементная атомарность векторной загрузки/хранения и сбора/разброса? - на практике мы знаем, что это нормально, но в руководствах по x86 нет письменного гарантия, что более широкие векторные нагрузки никогда не будут разрываться внутри фрагмента с выравниванием по 8 байтам.
@Hadi: вы определенно хотите выравнивания; Процессоры AMD могут вводить разрывы через границы, меньшие, чем полная строка кэша. (По крайней мере, K8/K10 может, вероятно, все еще семейство Bulldozer) Конечно, Bulldozer/Ryzen в любом случае будут декодировать 32-байтную загрузку как две 16-битные половины, но, надеюсь, они обе могут выполняться в одном и том же тактовом цикле, когда поступает строка кэша. , и избегайте повторного запроса, если в следующем цикле будет получено недействительное значение.
Не могли бы вы объяснить сложность немного больше? Я не думаю, что понимаю.. Также спасибо @PeterCordes
@Curious: std::atomic
имеет специальные правила, которые четко определяют чтение, пока другой поток пишет его. например на практике нельзя предположить, что загрузка одного и того же указателя дважды даст одно и то же значение. _mm_load_si256
похож на обычное разыменование, и делает ли это нет. На практике разыменование указателя volatile __m256i*
должен быть эквивалентен memory_order_relaxed
нагрузке на x86. Для более сильного заказа вам также понадобится atomic_thread_fence
. (Это может быть деталь реализации, как заборы влияют на изменчивые, а не только std::atomic
, объекты.)
Спасибо за ответ! Если бы я мог принять два ответа, я бы это сделал.
терминология: нагрузка не будет генерировать 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, а не на ассемблерном языке, поэтому нам нужно позаботиться о некоторых других вещах, которые делает std::atomic
с mo_relaxed
: в частности, повторные загрузки с одного и того же адреса могут не дать одинакового значения. Вероятно, вам нужно разыменовать volatile __m256i*
для имитации того, что load(mo_relaxed)
.
Вы можете использовать atomic_thread_fence()
, если хотите более строгого порядка; Я думаю, что на практике компиляторы С++ 11, поддерживающие встроенные функции Intel, будут заказывать изменчивые разыменования относительно. заборы так же, как std::atomic
загружает / хранит. В ISO C++ объекты volatile
по-прежнему подвержены гонке данных UB, но в реальных реализациях, которые могут, например, компилировать ядро Linux, volatile
можно использовать для многопоточности. (Linux использует собственные атомарные вычисления с помощью volatile
и встроенного ассемблера, и я думаю, что это считается поддерживаемым поведением gcc/clang.) Учитывая, что на самом деле делает volatile
(объект в памяти соответствует абстрактной машине C++), он просто автоматически работает, несмотря ни на что. rules-lawyer опасается, что технически это 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-я загрузочная операция будет запущена в цикле после этого, оставляя очень маленькое окно для недействительности, которая вызовет проблему.
Или на процессорах с загрузкой только 1 за такт, при этом все 3 загрузки соседствуют в сборке, что уменьшает окно для аннулирования.
Но если one == 0
встречается редко, то three
часто вообще не нужен, поэтому безусловная загрузка несет в себе риск ненужных запросов на него. Таким образом, вы должны учитывать этот компромисс при настройке, если вы не можете охватить все данные за одну SIMD-загрузку.
Как обсуждалось в комментариях, программная предварительная выборка потенциально может помочь скрыть некоторую задержку между ядрами.
Но вы должны выполнять предварительную выборку намного позже, чем для обычного массива, поэтому поиск мест в вашем коде, которые часто выполняются от ~ 50 до ~ 100 циклов до вызова f1()
, является сложной проблемой и может «заразить» много другого кода с помощью детали, не связанные с его нормальной работой. И вам нужен указатель на правильную строку кэша.
Вам нужно, чтобы PF опаздывал достаточно, чтобы загрузка по требованию происходила через несколько (десятков) циклов до, когда предварительно выбранные данные действительно поступают. Это противоположно обычному варианту использования, где L1d является буфером для предварительной выборки и хранения данных из завершенных предварительных выборок до того, как к ним попадет нагрузка по требованию. Но вы хочуload_hit_pre.sw_pf
perf-события (предварительная выборка загрузки), потому что это означает, что загрузка по запросу произошла, когда данные все еще находились в полете, до того, как есть шанс, что они будут признаны недействительными.
Это означает, что настройка еще более ломка и сложна, чем обычно, потому что вместо почти плоской точки наибольшего удовольствия для расстояния предварительной выборки, где раньше или позже не повредит, раньше скрывает большую задержку вплоть до того момента, когда она допускает недействительность, так что это спускаться до самого обрыва. (И любые слишком ранние предварительные выборки только усугубляют общую конкуренцию.)
Спасибо за подробный ответ! Всегда интересно читать ваши ответы и комментарии :) У меня есть несколько дополнений. Первый касается этой части — Конечно для чтения из L1d в неупорядоченный бэкенд Что такое неисправный бэкенд?
@ Любопытно: компиляторы буду выполняют if-преобразование в безветвящиеся, если решат, что оно того стоит. Здесь это допустимо, потому что указатель на class
не может указывать на частичный объект в конце страницы (за которым следует несопоставленная страница). И потому, что x86 asm не заботится об одновременном чтении, если другой поток пишет. Но обратите внимание, что абстрактная машина C++ не вообще считывает three
, если one
не равно нулю. Это преобразование на уровне источник может вызвать гонку данных UB, если !one
означает, что его может писать другой поток.
В любом случае, обычно компиляторы помещают условную работу внутри условных ветвей. Если code-gen является разветвленным, мы можем захотеть обратного из-за этого особого обстоятельства, так что нет, это не то, что вы когда-либо ожидали от компилятора. Ложное совместное использование и высокая конкуренция — это очень и очень плохо, поэтому компиляторы оптимизируют для нормального случая, когда нет необходимости очень плотно группировать нагрузки (и в этом есть кое-что, что можно получить: вообще не выполнять нагрузку в другой ветке).
@ Любопытно: неупорядоченный бэкэнд = механизм, который выполняет неупорядоченное выполнение в ядре ЦП. Интерфейсная часть извлекает/декодирует инструкции для передачи на серверную часть. Я говорил там о получении данных загрузки в физические регистры (и в сеть обхода пересылки для инструкций, использующих результат).
Бэкэнд не заботится о ветках? Я не думаю, что я полностью глуп :(
@ Любопытно: какой «другой код»? Вы пишете функцию, которая принимает указатель. Он может указывать на что угодно, и любое количество других неизвестных потоков также может иметь указатель на него и выполнять неизвестный код. В любом случае, как я уже сказал, правило «как если» по-прежнему допускает преобразование (поскольку x86 asm не имеет UB гонки данных), но компиляторы могут быть устойчивы к нему. И нет, он не будет анализировать всю программу, чтобы узнать больше об указателях, переданных вашим функциям потока, и о том, что еще может иметь ссылки на них.
three
загрузиться на много циклов позже.
Исходя из этого описания, я предположил, что вы объясняете, как загрузка до трех может происходить одновременно с двумя другими. Не могли бы вы привести пример того, как это приведет к тому, что загрузка для трех произойдет намного позже? Если интерфейс предсказывает, что ветвь, ведущая к загрузке, фактически не выполняется?
@Curious: Broadwell могу загружает всю строку кэша за 1 цикл, используя две загрузки по 32 байта. У вас нет гарантии, что обе загрузки действительно выполняются в одном и том же цикле, но это вероятно, если они обе застряли в ожидании одной и той же строки кэша. Вы можете прочесть что угодно о семействе микроархитектур Sandybridge, чтобы узнать, что оно имеет загрузочные исполнительные блоки на 2 порта, по сравнению с 1 в Nehalem. например realworldtech.com/haswell-процессор — это глубокое погружение в Haswell (в основном то же самое, что и Broadwell), а на последней странице есть полная блок-схема.
Но также таблицы инструкций Агнера Фога, uops.info, instlatx64.atw.hu и собственные документы Intel (руководство по оптимизации и даже полуполезные таблицы задержки/пропускной способности в их встроенных средствах поиска) говорят вам, что нагрузки имеют пропускную способность 2 в такт на семействе Sandybridge. . Таким образом, в основном любая информация о производительности будет включать это. Также en.wikichip.org/wiki/intel/microarchitectures/….
@ Любопытно: в своем ответе я уже приводил пример того, как загрузка three
может быть отложена: если это происходит после условного перехода, который неверно прогнозирует, он выполняется позже, по крайней мере, на дюжину циклов, ветвь неправильно прогнозирует штраф. Неверный прогноз не может быть обнаружен до тех пор, пока не завершится загрузка one
.
Спасибо за обсуждение! Кажется, у меня есть ответ :)
Простите, если я неправильно понимаю, но разве одна строка кэша не всегда загружается атомарно? Вы хотите загружать строки кэша множественный, смежный для монопольного доступа атомарно?