Может ли x86 переупорядочить узкий магазин с более широкой нагрузкой, которая полностью его содержит?

Руководство разработчика программного обеспечения для разработчиков Intel® 64 и IA-32:

8.2.3.4 Нагрузки могут быть переупорядочены с более ранними магазинами в разных местах
Модель памяти Intel-64 позволяет загружать нагрузку с более ранним хранилищем в другое место. Однако нагрузки не переупорядочиваются с помощью хранилищ в том же месте.

Что относительно нагрузок, которые частично или полностью перекрывают предыдущие магазины, но не имеют одинакового начального адреса? (См. Конец этого сообщения для конкретного случая)


Предположим, что следующий C-подобный код:

// lock - pointer to an aligned int64 variable
// threadNum - integer in the range 0..7
// volatiles here just to show direct r/w of the memory as it was suggested in the comments
int TryLock(volatile INT64* lock, INT64 threadNum)
{
    if (0 != *lock)
        return 0;                           // another thread already had the lock

    ((volatile INT8*)lock)[threadNum] = 1;  // take the lock by setting our byte

    if (1LL << 8*threadNum != *lock)
    {   // another thread set its byte between our 1st and 2nd check.   unset ours
        ((volatile INT8*)lock)[threadNum] = 0;
        return 0;
    }

    return 1;
}

Или его эквивалент as64 x64:

; rcx - address of an aligned int64 variable
; rdx - integer in the range 0..7
TryLock PROC
cmp qword ptr [rcx], 0
jne @fail

mov r8, rdx
mov rax, 8
mul rdx

mov byte ptr [rcx+r8], 1

bts rdx, rax
cmp qword ptr [rcx], rdx
jz  @success

mov byte ptr [rcx+r8], 0

@fail:
mov rax, 0
ret

@success:
mov rax, 1
ret

Тогда предположим, что TryLock выполняется одновременно в двух потоках:

INT64 lock = 0;

void Thread_1() {  TryLock(&lock, 1);  }
void Thread_5() {  TryLock(&lock, 5);  }

Вопрос:

Хранилища ((INT8*)lock)[1] = 1; и ((INT8*)lock)[5] = 1; не находятся в том же месте, что и 64-разрядная загрузка lock. Тем не менее, каждая из них полностью заполнена этой нагрузкой, так же как "подсчитывается" как одно и то же местоположение? Кажется невозможным, чтобы процессор мог это сделать.

Как насчет ((INT8*)lock)[0] = 1? Адрес магазина тогда совпадает с адресом следующей загрузки. Являются ли эти операции "в одном месте", даже если предыдущий случай не был?

p.s. обратите внимание, что вопрос не о коде C/Asm, о поведении процессоров x86.

Ответ 1

Может x86 переупорядочить узкий магазин с более широкой нагрузкой, которая полностью содержит это?

Да, x86 может изменить порядок узкого хранилища с более широкой нагрузкой, которая полностью его содержит.

Вот почему ваш алгоритм блокировки сломан, shared_value не равен 800000:

См. ниже правильный пример.


Вопрос:

Блокировка ((INT8 *)) [1] = 1; и ((INT8 *)) [5] = 1; магазины не должны в том же месте, что и 64-битная загрузка блокировки. Однако каждый из них полностью сложенная этой нагрузкой, так делает это "считать" как одно и то же местоположение?

Нет, это не так.

Руководство разработчика программного обеспечения для разработчиков Intel® 64 и IA-32:

8.2.3.4 Нагрузки могут быть переупорядочены с более ранними магазинами в разных местах Модель памяти Intel-64 памяти позволяет загружать нагрузку переупорядочивается с более ранним хранилищем в другое место. Однако, нагрузки не переупорядочиваются с хранилищами в том же месте.

Это упрощенное правило для случая, когда STORE и LOAD имеют одинаковый размер.

Но общее правило заключается в том, что запись в память задерживается на какое-то время, а STORE (адрес + значение), помещенный в буфер хранилища, ожидает кэш-строку в эксклюзивном состоянии (E) - когда эта строка кэша будет быть недействительным (I) в кеше других CPU-ядер. Но вы можете использовать операцию asm MFENCE (или любую операцию с префиксом [LOCK]), чтобы принудительно ждать, пока запись не будет выполнена, и любые последующие инструкции могут быть выполнены только после того, как буфер хранилища будет очищен, а STORE будет видимым для всех процессорных ядер.

О переупорядочении двух строк:

((volatile INT8*)lock)[threadNum] = 1;  // STORE
if (1LL << 8*threadNum != *lock)        // LOAD
  • Если размер STORE и LOAD равен, то LOAD CPU-Core выполняет поиск в хранилище (Store-forwarding) в Store-Buffer и видит все необходимые данные - вы можете получить все фактические данные прямо сейчас, прежде чем STORE будет выполнен

  • Если размер STORE и LOAD не равен, STORE (1 байт) и LOAD (8 байт), то даже если LOAD CPU-Core выполняет поиск в Store-Buffer, тогда он видит только 1/8 требуемого данные - вы не можете получить все фактические данные прямо сейчас, прежде чем STORE будет выполнен. Здесь могут быть 2 варианта действий ЦП:

    • case-1: CPU-Core загружает другие данные из строки кэша, которые в состоянии общего доступа (S), и перекрывает 1 байт из буфера хранилища, но STORE все еще остается в буфера хранилища и ожидает получения строки кеша эксклюзивного состояния (E) для его модификации, т.е. CPU-Core считывает данные до завершения STORE - в вашем примере это расы данных (ошибка). STORE-LOAD переупорядочивается в LOAD-STORE во всем мире. - это именно то, что происходит на x86_64

    • case-2: CPU-Core ждет, когда Store-Buffer будет сброшен, STORE подождал эксклюзивное состояние (E) строки кэша и STORE, а затем CPU -Core загружает все требуемые данные из строки кеша. STORE-LOAD не переупорядочивается во всем мире. Но это то же самое, что если вы использовали MFENCE.

Заключение, вы должны использовать MFENCE после STORE в любом случае:

  • Он полностью решает проблему в case-1.
  • Это не повлияет на поведение и производительность в case-2. Явный MFENCE для пустого хранилища-буфера немедленно закончится.

Правильный пример для C и x86_64 asm:

Мы вынуждаем CPU-Core действовать как в case-2, используя MFENCE, следовательно, не является упорядочением по StoreLoad

Примечание: xchgb всегда имеет префикс LOCK, поэтому он обычно не записывается в asm или не указан в скобках.

Все остальные компиляторы могут быть выбраны вручную по ссылкам выше: PowerPC, ARM, ARM64, MIPS, MIPS64, AVR.

C-code - должен использовать последовательную согласованность для первого STORE и следующего LOAD:

#ifdef __cplusplus
#include <atomic>
using namespace std;
#else
#include <stdatomic.h>
#endif

// lock - pointer to an aligned int64 variable
// threadNum - integer in the range 0..7
// volatiles here just to show direct r/w of the memory as it was suggested in the comments
int TryLock(volatile uint64_t* lock, uint64_t threadNum)
{
  //if (0 != *lock)
  if (0 != atomic_load_explicit((atomic_uint_least64_t*)lock, memory_order_acquire)) 
    return 0;                           // another thread already had the lock

  //((volatile uint8_t*)lock)[threadNum] = 1;  // take the lock by setting our byte
  uint8_t* current_lock = ((uint8_t*)lock) + threadNum;
  atomic_store_explicit((atomic_uint_least8_t*)current_lock, (uint8_t)1, memory_order_seq_cst);

  //if (1LL << 8*threadNum != *lock)
  // You already know that this flag is set and should not have to check it.
  if ( 0 != ( (~(1LL << 8*threadNum)) & 
    atomic_load_explicit((atomic_uint_least64_t*)lock, memory_order_seq_cst) )) 
  {   // another thread set its byte between our 1st and 2nd check.   unset ours

    //((volatile uint8_t*)lock)[threadNum] = 0;
    atomic_store_explicit((atomic_uint_least8_t*)current_lock, (uint8_t)0, memory_order_release);
    return 0;
  }

  return 1;
}

GCC 6.1.0 - x88_64 asm-code - должен использовать MFENCE для первого STORE:

TryLock(unsigned long volatile*, unsigned long):
        movq    (%rdi), %rdx
        xorl    %eax, %eax
        testq   %rdx, %rdx
        je      .L7
.L1:
        rep ret
.L7:
        leaq    (%rdi,%rsi), %r8
        leaq    0(,%rsi,8), %rcx
        movq    $-2, %rax
        movb    $1, (%r8)
        rolq    %cl, %rax
        mfence
        movq    (%rdi), %rdi
        movq    %rax, %rdx
        movl    $1, %eax
        testq   %rdi, %rdx
        je      .L1
        movb    $0, (%r8)
        xorl    %eax, %eax
        ret

Полный пример того, как это работает: http://coliru.stacked-crooked.com/a/65e3002909d8beae

shared_value = 800000

Что произойдет, если вы не используете MFENCE - Data-Races

Существует переупорядочение StoreLoad, как описано выше case-1 (т.е. если вы не используете Sequential Consistency для STORE) - asm: https://godbolt.org/g/p3j9fR

Я изменил барьер памяти для STORE с memory_order_seq_cst на memory_order_release, он удаляет MFENCE - и теперь есть расы данных - shared_value не равно 800000.

введите описание изображения здесь

Ответ 2

Может ли mov byte [rcx+r8], 1 изменить порядок с загрузкой cmp qword [rcx], rdx, которая следует за ней? Это хранилище lock[threadNum]=1 и следующая загрузка, чтобы убедиться, что никто не написал байта.

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


Оказывается, эта точная идея блокировки была предложена раньше (для ядра Linux) и Линус Торвальдс объяснил, что x86 действительно разрешает это вид переупорядочения

Несмотря на термин "отказ или сбой пересылки в хранилище" , это не значит, что данные должны зафиксировать кеш перед загрузкой может прочитать его. Фактически это можно прочитать из буфера хранилища, в то время как строка кэша все еще находится в состоянии S (MESI). (И на ядрах Atom в порядке, вы даже не получаете магазин-переадресацию.)

Реальное оборудование работает так (как показывают тесты Alex): CPU объединит данные из L1D с данными из буфера хранилища, не передавая хранилище в L1D.

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

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


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

Если x86 не разрешало это переупорядочение, пачка store/частично перекрывающаяся-перезагрузка работала бы как MFENCE: более ранние нагрузки не могут стать глобально видимыми перед загрузкой, а более ранние магазины не могут стать глобально видимыми перед хранилищем. Нагрузка должна стать глобально видимой перед любыми последующими грузами или магазинами, и это также остановит хранилище.

Учитывая это рассуждение, не совсем очевидно, почему идеально перекрывающиеся магазины не эквивалентны MFENCE. Возможно, они на самом деле есть, и x86 справляется только с быстрым выполнением spill/reload или arg-pass в стеке с помощью спекулятивного исполнения!


Схема блокировки:

Похоже, что TryLock может терпеть неудачу для обоих/всех вызывающих: все они видят, что они изначально ноль, все они записывают свой байт, тогда все они видят по крайней мере два ненулевых байта каждый. Это не идеально подходит для сильно заблокированных замков по сравнению с использованием инструкции lock ed. Существует механизм аппаратного арбитража для обработки конфликтующих lock ed insns. (TODO: найдите сообщение на форуме Intel, где инженер Intel разместил это в ответ на другой цикл повторного запуска программного обеспечения и lock ed, раздел IIRC.)

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

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

  • SnB: ~ 12 циклов дольше, чем при работе с хранилищем (~ 5c)
  • HSW: ~ 10c дольше
  • SKL: ~ 11c дольше, чем при работе с хранилищем (4c для 32 и 64-битных операндов, что на 1 c меньше, чем у предыдущих процессоров).
  • AMD K8/K10: Agner Fog не дает числа.
  • AMD Bulldozer-family: 25-26c (Steamroller)

  • Atom: "В отличие от большинства других процессоров, Atom может хранить переадресация, даже если операнд чтения больше, чем предыдущий операнд записи или по-разному выровнен ", и есть только 1 c латентность. Только сбой при пересечении границы линии кэша.

  • Silvermont: ~ 5c extra (base: 7c)
  • AMD Bobcat/Jaguar: 4-11c дополнительно (база: 8c/3c)

Таким образом, если вся схема блокировки работает, она может хорошо справляться с легкомысленными блокировками.

Я думаю, вы можете превратить его в блокировку с несколькими считывателями/одиночными писателями, используя бит 1 в каждом байте для читателей и бит 2 для писателей. TryLock_reader игнорирует биты считывателя в других байтах. TryLock_writer будет работать как оригинал, требуя нуля во всех битах в других байтах.


Кстати, для упорядочения памяти в целом, Блог Jeff Preshing превосходный.