Атомная двойная с плавающей точкой или SSE/AVX векторная загрузка / сохранение на x86_64

Здесь (и в нескольких вопросах SO) я вижу, что C++ не поддерживает что-то вроде free-free std::atomic<double> и еще не может поддерживать что-то вроде атомарного вектора AVX/SSE, потому что он зависит от процессора (хотя в настоящее время я знаю, что у процессоров ARM, AArch64 и x86_64 есть векторы).

Но есть ли поддержка на уровне сборки для атомарных операций над doubles или векторы в x86_64? Если да, то какие операции поддерживаются (например, загрузка, сохранение, сложение, вычитание, умножение)? Какие операции MSVC++2017 реализует без блокировок в atomic<double>?

2 ответа

Решение

C++ не поддерживает что-то вроде без блокировки std::atomic<double>

На самом деле, C++11 std::atomic<double> не блокируется на типичных реализациях C++ и предоставляет практически все, что вы можете сделать в asm для программирования без блокировок с float / double на x86 (например, load, store и CAS достаточно, чтобы реализовать что-либо: почему не полностью реализован atomic double). Текущие компиляторы не всегда компилируются atomic<double> хотя и эффективно.

C++ 11 std:: atomic не имеет API для расширений транзакционной памяти Intel (TSX) (для FP или целых чисел). TSX может изменить правила игры, особенно для FP / SIMD, так как он устранит все накладные расходы на передачу данных между xmm и целочисленными регистрами. Если транзакция не прерывается, то, что вы только что сделали с двойной или векторной загрузкой / сохранением, происходит атомарно.

Некоторое оборудование, отличное от x86, поддерживает атомарное добавление для float/double, и C++ p0020 является предложением добавить fetch_add а также operator+= / -= шаблонные специализации для C++ std::atomic<float> / <double>,

Аппаратные средства с атомами LL/SC вместо инструкции назначения памяти в стиле x86, такие как ARM и большинство других процессоров RISC, могут выполнять атомарные операции RMW на double а также float без CAS, но вы все равно должны получать данные из FP в целочисленные регистры, потому что LL/SC обычно доступен только для целочисленных регистров, таких как x86 cmpxchg, Тем не менее, если аппаратное обеспечение выполняет арбитраж пар LL/SC, чтобы избежать / уменьшить динамическую блокировку, это будет значительно более эффективным, чем при использовании цикла CAS в ситуациях с очень высоким уровнем конкуренции. Если вы спроектировали свои алгоритмы так, что конфликты случаются редко, возможно, разница в размере кода между небольшой повторной петлей LL/add/SC для fetch_add и циклической повторной загрузкой CAS load + add + LL/SC отсутствует.


Загрузки и хранилища с выравниванием по x86 имеют атомарный размер до 8 байт, даже x87 или SSE. (Например movsd xmm0, [some_variable] атомарный, даже в 32-битном режиме). На самом деле, gcc использует x87 fild / fistp или SSE 8B загружает / сохраняет для реализации std::atomic<int64_t> загрузить и сохранить в 32-битном коде.

По иронии судьбы, компиляторы (gcc7.1, clang4.0, ICC17, MSVC CL19) плохо выполняют работу в 64-битном коде (или 32-битном с доступным SSE2) и сбрасывают данные через целочисленные регистры вместо того, чтобы просто делать movsd загружает / хранит напрямую в / из регистров xmm ( см. это на Godbolt):

#include <atomic>
std::atomic<double> ad;

void store(double x){
    ad.store(x, std::memory_order_release);
}
//  gcc7.1 -O3 -mtune=intel:
//    movq    rax, xmm0               # ALU xmm->integer
//    mov     QWORD PTR ad[rip], rax
//    ret

double load(){
    return ad.load(std::memory_order_acquire);
}
//    mov     rax, QWORD PTR ad[rip]
//    movq    xmm0, rax
//    ret

Без -mtune=intel, gcc любит хранить / перезагружать для целого числа ->xmm. См. https://gcc.gnu.org/bugzilla/show_bug.cgi?id=80820 и связанные с ними ошибки, о которых я сообщил. Это плохой выбор даже для -mtune=generic, AMD имеет высокую задержку для movq между целочисленными и векторными регистрами, но также имеет высокую задержку для сохранения / перезагрузки. По умолчанию -mtune=generic, load() компилируется в:

//    mov     rax, QWORD PTR ad[rip]
//    mov     QWORD PTR [rsp-8], rax   # store/reload integer->xmm
//    movsd   xmm0, QWORD PTR [rsp-8]
//    ret

Перемещение данных между xmm и целочисленным регистром приводит нас к следующей теме:


Атомное чтение-изменение-запись (как fetch_add ) это другая история: есть прямая поддержка целых чисел с такими вещами, как lock xadd [mem], eax (См. может ли num++ быть атомарным для int num? для более подробной информации). Для других вещей, таких как atomic<struct> или же atomic<double>, единственный вариант на x86 - это повторный цикл с cmpxchg (или TSX).

Атомарное сравнение и замена (CAS) может использоваться в качестве стандартного блока без блокировки для любой атомарной операции RMW, вплоть до максимальной поддерживаемой аппаратно ширины CAS. На x86-64 это 16 байтов с cmpxchg16b (недоступно на некоторых AMD K8 первого поколения, поэтому для gcc вы должны использовать -mcx16 или же -march=whatever чтобы включить его).

GCC делает лучшую ассм exchange() :

double exchange(double x) {
    return ad.exchange(x); // seq_cst
}
    movq    rax, xmm0
    xchg    rax, QWORD PTR ad[rip]
    movq    xmm0, rax
    ret
  // in 32-bit code, compiles to a cmpxchg8b retry loop


void atomic_add1() {
    // ad += 1.0;           // not supported
    // ad.fetch_or(-0.0);   // not supported
    // have to implement the CAS loop ourselves:

    double desired, expected = ad.load(std::memory_order_relaxed);
    do {
        desired = expected + 1.0;
    } while( !ad.compare_exchange_weak(expected, desired) );  // seq_cst
}

    mov     rax, QWORD PTR ad[rip]
    movsd   xmm1, QWORD PTR .LC0[rip]
    mov     QWORD PTR [rsp-8], rax    # useless store
    movq    xmm0, rax
    mov     rax, QWORD PTR [rsp-8]    # and reload
.L8:
    addsd   xmm0, xmm1
    movq    rdx, xmm0
    lock cmpxchg    QWORD PTR ad[rip], rdx
    je      .L5
    mov     QWORD PTR [rsp-8], rax
    movsd   xmm0, QWORD PTR [rsp-8]
    jmp     .L8
.L5:
    ret

compare_exchange всегда выполняет побитовое сравнение, поэтому вам не нужно беспокоиться о том, что отрицательный ноль (-0.0) сравнивается равным +0.0 в семантике IEEE, или что NaN неупорядочен. Это может быть проблемой, если вы попытаетесь проверить, что desired == expected и пропустить операцию CAS, хотя. Для достаточно новых компиляторов, memcmp(&expected, &desired, sizeof(double)) == 0 может быть хорошим способом выразить побитовое сравнение значений FP в C++. Просто убедитесь, что вы избегаете ложных срабатываний; ложные негативы просто приведут к ненужному CAS.


Аппаратно-арбитражной lock or [mem], 1 определенно лучше, чем иметь несколько потоков, вращающихся на lock cmpxchg повторите петли. Каждый раз, когда ядро ​​получает доступ к строке кэша, но не cmpxchg теряется пропускная способность по сравнению с целочисленными операциями с памятью, которые всегда выполняются, как только они попадают в строку кэша.

Некоторые особые случаи для плавающих объектов IEEE могут быть реализованы с помощью целочисленных операций. например, абсолютное значение atomic<double> может быть сделано с lock and [mem], rax (где RAX имеет все биты, кроме установленного знакового бита). Или заставьте float/double быть отрицательным, ИЛИ вставляя 1 в бит знака. Или переключите его знак с помощью XOR. Вы могли бы даже атомно увеличить его величину на 1 ульпу с lock add [mem], 1, (Но только если вы можете быть уверены, что это не бесконечность, чтобы начать с... nextafter() это интересная функция, благодаря очень крутому дизайну IEEE754 с предвзятыми показателями, который делает перенос из мантиссы в показатель степени действительно действующим.)

Вероятно, нет никакого способа выразить это в C++, который позволит компиляторам делать это для вас на целях, которые используют IEEE FP. Так что если вы хотите, возможно, вам придется сделать это самостоятельно с atomic<uint64_t> или что-то еще, и проверьте, что порядковый номер FP совпадает с целочисленным порядковым номером и т. д. и т. д. (Или просто сделайте это только для x86. Большинство других целей в любом случае используют LL/SC вместо заблокированных операций назначения памяти).


пока не может поддерживать что-то вроде атомарного вектора AVX/SSE, потому что он зависит от процессора

Правильный. Невозможно определить, когда хранилище или загрузка 128b или 256b атомарны на всем пути через систему когерентности кэша. ( https://gcc.gnu.org/bugzilla/show_bug.cgi?id=70490). Даже система с атомарными передачами между L1D и исполнительными блоками может разрываться между блоками 8B при передаче строк кеша между кешами по узкому протоколу. Реальный пример: многосекционная Opteron K10 с межсоединениями HyperTransport, кажется, имеет атомные 16B загрузки / сохранения в одном сокете, но потоки на разных сокетах могут наблюдать разрыв.

Но если у вас есть общий массив выровненных double s, вы должны быть в состоянии использовать векторные нагрузки / хранилища на них без риска "порвать" внутри любого данного double,

Атомность каждого элемента векторной загрузки / хранения и сбора / разброса?

Я думаю, можно с уверенностью предположить, что согласованная загрузка / хранение 32B выполняется с неперекрывающимися 8B или более широкими загрузками / хранилищами, хотя Intel не гарантирует этого. Для невыровненных операций, вероятно, небезопасно предполагать что-либо.

Если вам нужна атомная нагрузка 16В, единственный вариант - lock cmpxchg16b , с desired=expected, Если это удается, он заменяет существующее значение на себя. Если не получится, вы получите старое содержимое. (Угловой случай: это "загрузка" сбоев в памяти только для чтения, поэтому будьте осторожны, какие указатели вы передаете функции, которая делает это.) Кроме того, производительность, конечно, ужасна по сравнению с реальными нагрузками только для чтения, которые могут оставить строка кэша в состоянии общего доступа, и это не является полным барьером памяти.

Атомный магазин 16B и RMW могут использовать оба lock cmpxchg16b очевидный путь. Это делает чистые магазины намного дороже, чем обычные векторные магазины, особенно если cmpxchg16b приходится повторять несколько раз, но атомное RMW уже дорого.

Дополнительные инструкции для перемещения векторных данных в / из целочисленных регистров не бесплатны, но и не дороги по сравнению с lock cmpxchg16b,

# xmm0 -> rdx:rax, using SSE4
movq   rax, xmm0
pextrq rdx, xmm0, 1


# rdx:rax -> xmm0, again using SSE4
movq   xmm0, rax
pinsrq xmm0, rdx, 1

В С ++ 11 термины:

atomic<__m128d> будет медленным даже для операций только для чтения или только для записи (используя cmpxchg16b), даже если реализовано оптимально. atomic<__m256d> не может быть даже без блокировки.

alignas(64) atomic<double> shared_buffer[1024]; теоретически разрешит автоматическую векторизацию для кода, который читает или записывает его, только нужно movq rax, xmm0 а потом xchg или же cmpxchg для атомного RMW на double, (В 32-битном режиме cmpxchg8b будет работать.) Вы почти наверняка не получите хороший ассемблер для этого, хотя!


Вы можете атомарно обновить объект 16B, но атомарно читать половинки 8B отдельно. (Я думаю, что это безопасно в отношении упорядочения памяти в x86: см. Мои рассуждения по адресу https://gcc.gnu.org/bugzilla/show_bug.cgi?id=80835).

Тем не менее, компиляторы не предоставляют какой-либо чистый способ выразить это. Я взломал штуковину типа объединения, которая работает для gcc/clang: Как я могу реализовать счетчик ABA с C++11 CAS?, Но gcc7 и позже не будут встроены cmpxchg16b потому что они пересматривают, должны ли объекты 16B действительно представляться как "свободные от блокировки". ( https://gcc.gnu.org/ml/gcc-patches/2017-01/msg02344.html).

На x86-64 атомарные операции реализуются через префикс LOCK. В Руководстве разработчика программного обеспечения Intel (том 2, Справочник по наборам инструкций) говорится

Префикс LOCK может добавляться только к следующим инструкциям и только к тем формам инструкций, где операндом-адресатом является операнд памяти: ADD, ADC, AND, BTC, BTR, BTS, CMPXCHG, CMPXCH8B, CMPXCHG16B, DEC, INC, NEG, NOT, OR, SBB, SUB, XOR, XADD и XCHG.

Ни одна из этих инструкций не работает с регистрами с плавающей запятой (например, с регистрами XMM, YMM или FPU).

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

Как отметил Питер Кордес в комментариях, префикс LOCK не требуется для загрузок и хранилищ, поскольку они всегда атомарны на x86-64. Однако Intel SDM (том 3, Руководство по системному программированию) гарантирует только то, что следующие загрузки / хранилища являются атомарными:

  • Инструкции, которые читают или записывают один байт.
  • Инструкции, которые читают или пишут слово (2 байта), адрес которого выровнен по границе 2 байта.
  • Инструкции, которые читают или пишут двойное слово (4 байта), адрес которого выровнен по границе 4 байта.
  • Инструкции, которые читают или пишут четырехзначное слово (8 байтов), адрес которого выровнен на границе 8 байтов.

В частности, атомарность загрузок / хранилищ из / в больших векторных регистров XMM и YMM не гарантируется.

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