В чем разница между барьером памяти и ограждением только для соответствия требованиям

#c #gcc #processor #memory-barriers

#c #gcc #процессор #барьеры памяти

Вопрос:

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

Они одинаковые? Если нет, то в чем разница между ними?

Ответ №1:

В качестве конкретного примера рассмотрим следующий код:

 int x = 0, y = 0;

void foo() {
    x = 10;
    y = 20;
}
 

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

 STORE [y], 20
STORE [x], 10
 

Если вы вставляете ограждение только для компилятора между x = 10; и y = 20; , компилятору запрещено это делать, и вместо этого он должен выдавать

 STORE [x], 10
STORE [y], 20
 

Однако предположим, что у нас есть другой наблюдатель, просматривающий значения x и y в памяти, например, аппаратное устройство с отображением памяти или другой поток, который собирается выполнить

 void observe() {
    std::cout << x << ", ";
    std::cout << y << std::endl;
}
 

(Предположим для простоты, что загрузки из x и y в observe() не переупорядочиваются каким-либо образом, и что загрузка и хранение в int этой системе являются атомарными.) В зависимости от того, когда происходит его загрузка по отношению к хранилищам foo() , мы можем видеть, что он может распечатать 0, 0 или 10, 0 или 10, 20 . Может показаться, что 0, 20 это было бы невозможно, но на самом деле это не так в целом.

Несмотря на то, что инструкции foo хранятся x в таком порядке и y в таком порядке, на некоторых архитектурах без строгого упорядочения хранилищ, это не гарантирует, что эти хранилища станут видимыми observe() в том же порядке. Возможно, из-за неупорядоченного выполнения ядро, выполняющее foo() фактически, выполнило хранилище y до хранилища x . (Скажем, если строка кэша, содержащая y уже была в кэше L1, но строка кэша for x не была; процессор мог бы также продолжить и выполнить хранилище y , а не останавливаться, возможно, на сотни циклов, пока загружается строка кэша for x .) Или хранилища могут храниться в буфере хранилища и, возможно, сбрасываться в кэш L1 в обратном порядке. В любом случае, возможно, что observe() распечатывается 0, 20 .

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

 STORE [x], 10
BARRIER
STORE [y], 20
 

В этом случае вы можете быть уверены, что observe() будет напечатано либо 0, 0 или 10, 0 , либо 10, 20 , но никогда 0, 20 .

(Пожалуйста, обратите внимание, что здесь было сделано много упрощающих предположений. Если вы пытаетесь написать это на реальном C , вам нужно будет использовать std::atomic типы и какой-то аналогичный барьер, observe() чтобы гарантировать, что его нагрузки не были переупорядочены.)

Комментарии:

1. Все, что вы объяснили memory barrier . О чем compiler-only fence ?

2. @Hovin: Это третье предложение. Ограждение только для компилятора просто сообщает компилятору не изменять порядок инструкций хранилища.

3. @Hovin: Это могло бы быть полезно само по себе, если бы вы знали, что для вашей конкретной ситуации процессор не будет выполнять никаких изменений порядка. Например, возможно, ваши переменные x,y находятся в пространстве памяти какого-либо аппаратного устройства с отображением памяти, и вы случайно знаете, что ваш процессор настроен так, чтобы никогда не переупорядочивать хранилища в памяти устройства. Тогда инструкция барьера была бы ненужной, и было бы достаточно ограждения только для компилятора.

4. @Hovin: Другой момент заключается в том, что любой механизм на языке высокого уровня, который должен вставлять барьер памяти, должен также подразумевать ограничение компилятора в той же точке. Барьер памяти без ограждения компилятора бесполезен — вам бесполезно иметь барьер, если вы не знаете, какие инструкции находятся по какую сторону от него.

5. конкретные примеры: atomic_thread_fence(seq_cst) обычно должен быть полный барьер памяти, но atomic_signal_fence должен быть только барьер компилятора. Или asm("" :::"memory") является барьером компилятора, asm("mfence" ::: "memory) также является полным барьером времени выполнения. (Где становится сложнее, asm("" ::: "memory") это допустимая реализация atomic_thread_fence(acq_rel) на x86, потому что HW уже гарантирует достаточный порядок, который блокирует переупорядочение во время компиляции — это все, что вам нужно сделать. Но вы все равно можете назвать это надлежащим барьером памяти, который просто оказывается дешевым на x86: P)

Ответ №2:

Барьер памяти реализован в аппаратном обеспечении и не позволяет самому процессору переупорядочивать инструкции.

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

Комментарии:

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

2. @Hovin Барьер памяти не позволит процессору переупорядочивать операции базовой сборки, в то время как с помощью ограждения компилятора процессор может переупорядочивать по своему усмотрению.

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

4. @PeterCordes Это имеет смысл.