Получите / выпустите семантику с временными хранилищами на x64

У меня есть что-то вроде:

if (f = acquire_load() == ) {
   ... use Foo
}

а также:

auto f = new Foo();
release_store(f)

Вы можете легко представить себе реализацию acqu_load и release_store, которая использует atomic с load(memory_order_acquire) и store(memory_order_release). Но что теперь, если release_store реализован с _mm_stream_si64, невременной записью, которая не упорядочена относительно других хранилищ на x64? Как получить такую ​​же семантику?

Я думаю, что следующий минимум необходим:

atomic<Foo*> gFoo;

Foo* acquire_load() {
    return gFoo.load(memory_order_relaxed);
}

void release_store(Foo* f) {
   _mm_stream_si64(*(Foo**)&gFoo, f);
}

И используйте это так:

// thread 1
if (f = acquire_load() == ) {
   _mm_lfence(); 
   ... use Foo
}

а также:

// thread 2
auto f = new Foo();
_mm_sfence(); // ensures Foo is constructed by the time f is published to gFoo
release_store(f)

Это верно? Я почти уверен, что здесь абсолютно необходима защита. Но как насчет жизни? Это требуется, или будет достаточно простого барьера компилятора для x64? например, asm volatile("":::"memory"). Согласно модели памяти x86, нагрузки не переупорядочиваются с другими нагрузками. Насколько я понимаю, acqu_load() должен происходить перед любой загрузкой внутри оператора if, если существует барьер компилятора.

1 ответ

Решение

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

Линус Торвальдс настоятельно рекомендует не пытаться изобретать свой собственный замок, потому что так легко ошибиться. Это скорее проблема при написании переносимого кода для ядра Linux, а не только для x86, так что я чувствую себя достаточно смелым, чтобы попытаться разобраться в x86.


Обычный способ использования хранилищ NT - это сделать их несколько в ряд, например, как часть memset или memcpy, а затем SFENCE затем обычное хранилище релизов для переменной общего флага: done_flag.store(1, std::memory_order_release),

Используя movnti сохранение в переменную синхронизации ухудшит производительность. Возможно, вы захотите использовать магазины NT в Foo это указывает на, но удаление самого указателя из кэша является извращенным. (movnt хранилища высвобождают строку кэша, если она была в кэше для начала; см. т.1 ч. 10.4.6.2. Кэширование временных и не временных данных).

Весь смысл хранилищ NT в том, что они используются с временными данными, которые больше не будут использоваться (каким-либо потоком) в течение долгого времени, если вообще когда-либо. Ожидается, что блокировки, которые контролируют доступ к совместно используемым буферам, или флаги, которые производители / потребители используют для маркировки данных как прочитанные, будут прочитаны другими ядрами.

Имена ваших функций также не отражают то, что вы делаете.

Аппаратное обеспечение x86 чрезвычайно сильно оптимизировано для обычных (не NT) хранилищ релизов, потому что каждое обычное хранилище - хранилище релизов. Аппаратное обеспечение должно быть хорошо для того, чтобы x86 работал быстро.

Для обычного хранения / загрузки требуется только отключение кэш-памяти третьего уровня, а не DRAM, для связи между потоками на процессорах Intel. Большой инклюзивный кэш-память L3 от Intel служит защитой для обеспечения когерентности трафика. Проверка тэгов L3 на пропуск одного ядра обнаружит тот факт, что другое ядро ​​имеет строку кэша в состоянии Modified или Exclusive. NT-хранилищам потребовалось бы, чтобы переменные синхронизации прошли весь путь до DRAM и вернулись к другому ядру, чтобы увидеть его.


Упорядочение памяти для потоковых хранилищ NT

movnt магазины могут быть переупорядочены с другими магазинами, но не с более старыми чтениями.

Руководство Intel x86, том 3, глава 8.2.2 (Упорядочение памяти в P6 и более поздних семействах процессоров):

  • Чтения не переупорядочиваются с другими чтениями.
  • Записи не переупорядочиваются со старыми чтениями. (обратите внимание на отсутствие исключений).
  • Записи в память не переупорядочиваются с другими записями, за следующими исключениями:
  • ... материал о clflushopt и инструкциях по забору

Обновление: также есть примечание (в 8.1.2.2 Software Controlled Bus Locking), которое гласит:

Не реализуйте семафоры, используя тип памяти WC. Не выполняйте невременные сохранения для строки кэша, содержащей местоположение, используемое для реализации семафора.

Это может быть просто предложение производительности; они не объясняют, может ли это вызвать проблему правильности. Обратите внимание, что хранилища NT не связаны с кэшем (данные могут находиться в буфере заполнения строки, даже если конфликтующие данные для той же строки присутствуют где-то еще в системе или в памяти). Возможно, вы могли бы безопасно использовать хранилища NT как хранилище релизов, которое синхронизируется с регулярными загрузками, но столкнется с проблемами с атомарными операциями RMW, такими как lock add dword [mem], 1,


Семантика релиза предотвращает переупорядочение памяти релиз-релиза с любой операцией чтения или записи, которая предшествует этому в программном порядке.

Чтобы заблокировать переупорядочение в более ранних магазинах, нам нужен SFENCE инструкция, которая является барьером StoreStore даже для магазинов NT. (И это также барьер для некоторых видов переупорядочения во время компиляции, но я не уверен, блокирует ли он более ранние нагрузки от пересечения барьера.) Обычные хранилища не нуждаются в каких-либо инструкциях барьера, чтобы быть релиз-хранилищами, поэтому вам нужно только SFENCE при использовании NT магазинов.

Для нагрузок: модель памяти x86 для WB (с обратной записью, т.е. "нормальной") памяти уже предотвращает переупорядочение LoadStore даже для слабо упорядоченных хранилищ, поэтому нам не нужно LFENCE для его эффекта барьера LoadStore, только барьер компилятора LoadStore перед хранилищем NT. В реализации GCC, по крайней мере, std::atomic_signal_fence(std::memory_order_release) является барьером компилятора даже для неатомарных загрузок / хранилищ, но atomic_thread_fence это только барьер для atomic<> грузы / магазины (в том числе mo_relaxed). Используя atomic_thread_fence по-прежнему дает компилятору больше свободы для переупорядочения загрузок / сохранений в не-общие переменные. Смотрите этот Q&A для получения дополнительной информации.

// The function can't be called release_store unless it actually is one (i.e. includes all necessary barriers)
// Your original function should be called relaxed_store
void NT_release_store(const Foo* f) {
   // _mm_lfence();  // make sure all reads from the locked region are already globally visible.  Not needed: this is already guaranteed
   std::atomic_thread_fence(std::memory_order_release);  // no insns emitted on x86 (since it assumes no NT stores), but still a compiler barrier for earlier atomic<> ops
   _mm_sfence();  // make sure all writes to the locked region are already globally visible, and don't reorder with the NT store
   _mm_stream_si64((long long int*)&gFoo, (int64_t)f);
}

Это сохраняет к атомарной переменной (обратите внимание на отсутствие разыменования &gFoo). Ваша функция хранится в Foo это указывает на то, что супер странно; ИДК какой смысл в этом был. Также обратите внимание, что он компилируется как действительный код C++11.

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


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

x86 не нуждается в каких-либо барьерных инструкциях, но указывает mo_acquire вместо mo_relaxed дает вам необходимый компилятор-барьер. В качестве бонуса эта функция переносима: вы получите все необходимые барьеры на других архитектурах:

Foo* acquire_load() {
    return gFoo.load(std::memory_order_acquire);
}

Вы ничего не сказали о хранении gFoo в слабо упорядоченной памяти WC (без кэширования при записи). Вероятно, очень трудно организовать отображение сегмента данных вашей программы в память WC... Было бы намного проще gFoo просто указывать на память WC, после того, как вы отобразите видео-память WC или что-то еще. Но если вы хотите получить загрузку из памяти WC, вам, вероятно, нужно LFENCE, ИДК. Задайте другой вопрос об этом, потому что этот ответ в основном предполагает, что вы используете память WB.

Обратите внимание, что использование указателя вместо флага создает зависимость данных. Я думаю, что вы должны быть в состоянии использовать gFoo.load(std::memory_order_consume), который не требует барьеров даже на слабо упорядоченных процессорах (кроме Alpha). Как только компиляторы достаточно продвинуты, чтобы убедиться, что они не нарушают зависимость от данных, они действительно могут создавать лучший код (вместо продвижения mo_consume в mo_acquire, Читайте об этом перед использованием mo_consume в производственном коде, и особенно. обратите внимание, что надлежащее тестирование невозможно, поскольку ожидается, что будущие компиляторы дадут более слабые гарантии, чем на практике нынешние компиляторы.


Сначала я думал, что нам нужен LFENCE, чтобы получить барьер LoadStore. ("Записи не могут передавать более ранние инструкции LFENCE, SFENCE и MFENCE". Это, в свою очередь, предотвращает прохождение (становящееся глобально видимым до) операций чтения, предшествующих LFENCE).

Обратите внимание, что LFENCE + SFENCE все еще слабее, чем полноценный MFENCE, потому что это не барьер StoreLoad. Собственная документация SFENCE гласит, что заказано в отношении LFENCE, но эта таблица модели памяти x86 из руководства Intel vol3 не упоминает об этом. Если SFENCE не может выполнить до LFENCE, то sfence / lfence на самом деле может быть медленнее, чем эквивалент mfence, но lfence / sfence / movnti дал бы семантику релиза без полного барьера. Обратите внимание, что хранилище NT может стать глобально видимым после некоторых следующих загрузок / хранилищ, в отличие от обычного строго упорядоченного хранилища x86.)


Связанные: NT загружает

В x86 каждая загрузка имеет семантику получения, кроме загрузок из памяти WC. SSE4.1 MOVNTDQA это единственная не временная инструкция загрузки, и она не упорядочена слабо при использовании в обычной (WriteBack) памяти. Так что это тоже загрузка-приёмник (при использовании в памяти WB).

Обратите внимание, что movntdq имеет только форму магазина, в то время как movntdqa имеет только форму загрузки. Но, видимо, Intel не могла просто позвонить им storentdqa а также loadntdqa, Они оба имеют требование выравнивания 16B или 32B, поэтому не a не имеет большого смысла для меня. Я думаю, что SSE1 и SSE2 уже представили некоторые NT-магазины, уже использующие mov... мнемонический movntps), но без нагрузки, пока годы спустя в SSE4.1. (2-е поколение Core2: 45-нм Penryn).

Документы говорят MOVNTDQA не меняет семантику упорядочения для типа памяти, на котором она используется.

... Реализация может также использовать невременный намек, связанный с этой инструкцией, если источником памяти является тип памяти WB (с обратной записью).

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

На практике современные процессоры Intel mainsream (Haswell, Skylake), похоже, игнорируют подсказки для загрузки PREFETCHNTA и MOVNTDQA из памяти WB. См. Поддерживают ли существующие архитектуры x86 невременные нагрузки (из "нормальной" памяти)? а также Временные нагрузки и аппаратный предварительный выбор, они работают вместе? Больше подробностей.


Кроме того, если вы используете его в памяти WC (например, копирование из видеопамяти, как в этом руководстве Intel):

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

Это не разъясняет, как это должно использоваться, все же. И я не уверен, почему они говорят MFENCE, а не LFENCE для чтения. Возможно, речь идет о ситуации записи в устройство, чтения из устройства, когда магазины должны быть упорядочены по нагрузкам (барьер StoreLoad), а не только друг с другом (барьер StoreStore).

Я искал в Vol3 для movntdqa и не получил ни одного хита (во всем PDF). 3 хита для movntdq Все обсуждения слабого порядка и типов памяти говорят только о магазинах. Обратите внимание, что LFENCE был введен задолго до SSE4.1. Предположительно это полезно для чего-то, но IDK что. Для порядка загрузки, вероятно, только с памятью WC, но я не читал, когда это было бы полезно.


LFENCE кажется, что это не просто барьер LoadLoad для слабо упорядоченных нагрузок: он также заказывает другие инструкции. (Не глобальная видимость магазинов, а только их локальное исполнение).

Из руководства Intel по insn ref:

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

Запись для rdtsc предлагает использовать LFENCE;RDTSC чтобы он не выполнялся раньше предыдущих инструкций, когда RDTSCP недоступен (и более слабая гарантия заказа в порядке: rdtscp не останавливает выполнение следующих инструкций перед этим). (CPUID это общее предложение для сериализации потока команд вокруг rdtsc).

Другие вопросы по тегам