Стоимость атомных счетчиков и шпиндельных блоков на x86 (_64)

Введение

Недавно я столкнулся с некоторыми проблемами синхронизации, которые привели меня к spinlocks и атомным счетчикам. Затем я искал немного больше, как они работают, и нашел std:: memory_order и барьеры памяти (mfence, lfence и sfence).

Итак, кажется, что я использую приобретать/выпускать для спин-локов и расслабленной для счетчиков.

Некоторая ссылка

x86 MFENCE - Забор памяти
x86 LOCK - Сигнал подтверждения LOCK #

Вопрос

Что такое машинный код (отредактируйте: см. ниже) для этих трех операций (lock = test_and_set, unlock = clear, increment = operator ++= fetch_add) со значением по умолчанию (seq_cst) и с приобретением/выпуском/расслаблением (в этом порядке для этих трех операций). Какая разница (какие барьеры памяти где) и стоимость (сколько циклов процессора)?

Цель

Мне просто интересно, насколько плохо мой старый код (не указывая порядок памяти = seq_cst), и если я должен создать некоторый class atomic_counter, полученный из std::atomic, но используя расслабленное упорядочение памяти ( а также хорошую спин-блокировку с приобретением/выпуском вместо мьютексов в некоторых местах... или использовать что-то из ускорительной библиотеки - я до сих пор избегал повышения).

Мои знания

До сих пор я понимаю, что spinlocks защищает больше, чем он сам (но также и общий ресурс/память), поэтому должно быть что-то, что делает некоторый вид памяти согласованным для нескольких потоков/ядер (это будут те, которые приобретают/выпускают и заборы памяти). Атомный счетчик просто живет для себя и нуждается только в этом атомарном приращении (никакой другой памяти не задействован, и я действительно не забочусь о значении, когда я его читаю, он информативен и может быть несколько циклов старый, без проблем). Существует префикс LOCK, и некоторые инструкции, такие как xchg, неявно имеют его. Здесь мои знания заканчиваются, я не знаю, как работают кеш и шины, и что стоит (но я знаю, что современные процессоры могут изменять порядок инструкций, выполнять их параллельно и использовать кеш памяти и некоторую синхронизацию). Спасибо за объяснение.

PS: У меня есть старый 32-битный компьютер, теперь можно видеть только lock addl и простой xchg, ничего другого - все версии выглядят одинаково (кроме разблокировки), memory_order не имеет никакого значения для моего старый компьютер (кроме разблокировки, релиз использует move вместо xchg). Это будет верно для 64-битного ПК? (изменить: см. ниже) Должен ли я заботиться о порядке памяти? (ответ: нет, не много, релиз на разблокировке сохраняет несколько циклов, все).

Код:

#include <atomic>
using namespace std;

atomic_flag spinlock;
atomic<int> counter;

void inc1() {
    counter++;
}
void inc2() {
    counter.fetch_add(1, memory_order_relaxed);
}
void lock1() {
    while(spinlock.test_and_set()) ;
}
void lock2() {
    while(spinlock.test_and_set(memory_order_acquire)) ;
}
void unlock1() {
    spinlock.clear();
}
void unlock2() {
    spinlock.clear(memory_order_release);
}

int main() {
    inc1();
    inc2();
    lock1();
    unlock1();
    lock2();
    unlock2();
}

g++ -std = С++ 11 -O1 -S ( 32-разрядный Cygwin, сокращенный вывод)

__Z4inc1v:
__Z4inc2v:
    lock addl   $1, _counter    ; both seq_cst and relaxed
    ret
__Z5lock1v:
__Z5lock2v:
    movl    $1, %edx
L5:
    movl    %edx, %eax
    xchgb   _spinlock, %al      ; both seq_cst and acquire
    testb   %al, %al
    jne L5
    rep ret
__Z7unlock1v:
    movl    $0, %eax
    xchgb   _spinlock, %al      ; seq_cst
    ret
__Z7unlock2v:
    movb    $0, _spinlock       ; release
    ret

ОБНОВЛЕНИЕ для x86_64bit: (см. mfence в unlock1)

_Z4inc1v:
_Z4inc2v:
    lock addl   $1, counter(%rip)   ; both seq_cst and relaxed
    ret
_Z5lock1v:
_Z5lock2v:
    movl    $1, %edx
.L5:
    movl    %edx, %eax
    xchgb   spinlock(%rip), %al     ; both seq_cst and acquire
    testb   %al, %al
    jne .L5
    ret
_Z7unlock1v:
    movb    $0, spinlock(%rip)
    mfence                          ; seq_cst
    ret
_Z7unlock2v:
    movb    $0, spinlock(%rip)      ; release
    ret

Ответ 1

x86 имеет в основном сильную модель памяти, все обычные магазины/нагрузки имеют раздельно/приобретают семантику неявно. Исключением являются только операции хранения невременного хранилища SSE, которые требуют, чтобы sfence заказывался, как обычно. Все инструкции read-modify-write (RMW) с префиксом LOCK подразумевают полный барьер памяти, т.е. Seq_cst.

Таким образом, на x86 имеем

  • test_and_set можно закодировать с помощью lock bts (для бит-мерных операций), lock cmpxchg или lock xchg (или просто xchg, что подразумевает LOCK). Другие реализации спин-блокировки могут использовать инструкции, такие как lock inc (или dec), если они нуждаются, например. справедливости. Невозможно реализовать try_lock с выпуском/приобретением забора (по крайней мере, вам понадобится автономный барьер памяти mfence).
  • clear закодирован с lock and (по-бит) или lock xchg, однако более эффективные реализации будут использовать обычную запись (mov) вместо заблокированной инструкции.
  • fetch_add закодирован с помощью lock add.

Удаление префикса LOCK не гарантирует атомарности для операций RMW, поэтому такие операции не могут быть интерпретированы строго как имеющие memory_order_relaxed в представлении С++. Однако на практике вам может потребоваться доступ к атомной переменной через более быструю неатомную операцию, когда она безопасна (в конструкторе, под блокировкой).

В нашем опыте на самом деле не имеет значения, какая именно атомная операция RMW выполняется, они принимают почти одинаковое количество циклов для выполнения (и mfence - около x0.5 операции блокировки). Вы можете оценить производительность алгоритмов синхронизации, подсчитав количество атомных операций (и mfences) и количество каналов памяти (промахи в кэше).

Ответ 2

Я рекомендую: x86-TSO: Яркая и полезная модель программиста для x86 мультипроцессоров.

Ваши x86 и x86_64 действительно очень "хорошо себя ведут". В частности, они не переупорядочивают операции записи (и любые спекулятивные записи отбрасываются, когда они находятся в очереди записи процессора/ядра), и они не переупорядочивают операции чтения. Тем не менее, они начнут операции чтения как можно раньше, а это значит, что чтение и запись могут быть переупорядочены. (Чтение чего-либо, сидящего в очереди записи, считывает значение в очереди, поэтому чтение/запись того же местоположения не переупорядочено.) Итак:

  • Операции
  • read-modify-write требуют LOCK, что делает их неявным образом memory_order_seq_cst.

    Итак, для этих операций вы ничего не получаете, ослабляя порядок памяти (на x86/x86_64). Общий совет - "держать его простым" и придерживаться memory_order_seq_cst, который, к счастью, не стоит ничего лишнего для x86 и x86_64.

    Для чего-либо более нового, чем Pentium, если процессор/ядро ​​уже имеет "исключительный" доступ к затронутой памяти, LOCK не влияет на другие cpus/core и может быть относительно простой операцией.

  • memory_order_acquire/_release не требуют mfence или любые другие служебные данные.

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

  • memory_order_seq_cst требует mfence...

..., который стоит понять.

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

An mfence останавливает процессор/ядро, пока все ожидающие записи не будут удалены из очереди на запись. В частности, любые операции чтения, которые следуют за mfence, не будут запускаться, пока очередь записи не будет пустой. Рассмотрим два потока:

  initial state: wa = wb = 0

  thread 'A'                    thread 'B'
    wa = 1 ;  (mov [wa] ← 1)      wb = 1 ;   (mov [wb] ← 1)
    a  = wb ; (mov ebx ← [wb])    b  = wa ;  (mov ebx ← [wa])

Оставаясь на своих устройствах, x86/x86_64 может производить любой из (a = 1, b = 1), (a = 0, b = 1), (a = 1, b = 0) и (a = 0, b = 0). Последнее недействительно, если вы ожидаете memory_order_seq_cst - поскольку вы не можете получить это путем чередования операций. Причина, по которой это может произойти, заключается в том, что записи wa и wb помещаются в очередь в соответствующей очереди процессора/ядра, а чтение wa и wb может быть запланировано и может быть завершено до записи. Для достижения памяти_order_seq_cst вам понадобится mfence:

  thread 'A'                    thread 'B'
    wa = 1 ;  (mov [wa] ← 1)      wb = 1 ;   (mov [wb] ← 1)
        mfence ;                      mfence
    a  = wb ; (mov ebx ← [wb])    b  = wa ;  (mov ebx ← [wa])

Поскольку между потоками нет синхронизации, результатом может быть что угодно, кроме (a = 0, b = 0). Интересно, что mfence предназначен для самого потока, поскольку он предотвращает операцию чтения, начинающуюся до завершения записи. Единственное, что беспокоит другие темы, это порядок, в котором происходит запись, а x86/x86_64 не переупорядочивает их в любом случае.

Итак, чтобы реализовать memory_order_seq_cst atomic_load() и atomic_store(), необходимо вставить mfence после одного или нескольких хранилищ и до загрузки. Если эти операции реализованы как функции библиотеки, общим соглашением является добавление mfence во все магазины, оставив нагрузку "голым". (Логика заключается в том, что нагрузки более распространены, чем магазины, и кажется, что лучше добавить накладные расходы в хранилище.)


Для спин-замков, по крайней мере, ваш вопрос, похоже, сводится к тому, что операция с разблокировкой требует mfence, или нет, и какая разница.

C11 atomic_flag_clear(), неявно, memory_order_seq_cst, для которого требуется mfence. C11 atomic_flag_test_and_set() является не только операцией чтения-изменения-записи, но также подразумевается memory_order_seq_cst - и LOCK делает это.

C11 не предлагает прямую блокировку в библиотеке threads.h. Но вы можете использовать atomic_flag - хотя для вашей x86/x86_64 у вас есть проблема с инструкцией PAUSE. Вопрос в том, нужна ли вам память_order_seq_cst для этого, в частности для разблокировки? Я думаю, что ответ отрицательный, и что трюк должен сделать: atomic_flag_test_and_set_explicit(xxx, memory_order_acquire) и atomic_flag_clear(xxx, memory_order_release).

FWIW, glibc pthread_spin_unlock() не имеет mfence. Также не работает gcc __sync_lock_release() (который явно является операцией "release" ). Но gcc _atomic_clear() выровнена с C11 atomic_flag_clear() и принимает параметр порядка памяти.

Чем отличается mfence от разблокировки? Понятно, что это очень разрушительно для трубопровода, и поскольку это не обязательно, не нужно многого выработать точный масштаб его воздействия, который будет зависеть от обстоятельств.

Ответ 3

spinlock не использует mfence, mfence только обеспечивает сериализацию/промывку данных текущего ядра. Сам забор никоим образом не связан с атомной операцией.

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

Очень простой и немой spinlock для x86 выглядит так (мое ядро ​​использует это):

typedef volatile uint32_t _SPINLOCK __attribute__ ((aligned(16)));
static inline void _SPIN_LOCK(_SPINLOCK* lock) {
__asm (
       "cli\n"
       "lock bts %0, 0\n"
       "jnc 1f\n"
       "0:\n"
       "pause\n"
       "test %0, 1\n"
       "je 0b\n"
       "lock bts %0, 0\n"
       "jc 0b\n"
       "1:\n"
       :
       : "m"(lock)
       :
       );
}

Логика проста

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

Примечание 1. Вы также можете реализовать спин-блокировку с внутренними функциями и расширениями, это должно быть довольно похоже.

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

Примечание 3. Вы должны также рассмотреть другие вещи, такие как справедливость.

Ответ 4

Re

и стоимость (сколько циклов процессора)?

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

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