Неожиданная сборка x64 для __atomic_fetch_or с gcc 7.3
Я пытаюсь использовать 64-битный интеграл в качестве растрового изображения и получать/освобождать право собственности на отдельные биты атомарно.
С этой целью я написал следующий код без блокировки:
#include <cstdint>
#include <atomic>
static constexpr std::uint64_t NO_INDEX = ~std::uint64_t(0);
class AtomicBitMap {
public:
static constexpr std::uint64_t occupied() noexcept {
return ~std::uint64_t(0);
}
std::uint64_t acquire() noexcept {
while (true) {
auto map = mData.load(std::memory_order_relaxed);
if (map == occupied()) {
return NO_INDEX;
}
std::uint64_t index = __builtin_ctzl(~map);
auto previous =
mData.fetch_or(bit(index), std::memory_order_relaxed);
if ((previous & bit(index)) == 0) {
return index;
}
}
}
private:
static constexpr std::uint64_t bit(std::uint64_t index) noexcept {
return std::uint64_t(1) << index;
}
std::atomic_uint64_t mData{ 0 };
};
int main() {
AtomicBitMap map;
return map.acquire();
}
Который, на godbolt, дает следующую сборку в изоляции:
main:
mov QWORD PTR [rsp-8], 0
jmp .L3
.L10:
not rax
rep bsf rax, rax
mov edx, eax
mov eax, eax
lock bts QWORD PTR [rsp-8], rax
jnc .L9
.L3:
mov rax, QWORD PTR [rsp-8]
cmp rax, -1
jne .L10
ret
.L9:
movsx rax, edx
ret
Это именно то, что я ожидал 1.
@Jester героически сумел сократить мой 97- релейный ресивер до более простого 44-полосного репродуктора, который я еще уменьшил до 35 строк:
using u64 = unsigned long long;
struct Bucket {
u64 mLeaves[16] = {};
};
struct BucketMap {
u64 acquire() noexcept {
while (true) {
u64 map = mData;
u64 index = (map & 1) ? 1 : 0;
auto mask = u64(1) << index;
auto previous =
__atomic_fetch_or(&mData, mask, __ATOMIC_SEQ_CST);
if ((previous & mask) == 0) {
return index;
}
}
}
__attribute__((noinline)) Bucket acquireBucket() noexcept {
acquire();
return Bucket();
}
volatile u64 mData = 1;
};
int main() {
BucketMap map;
map.acquireBucket();
return 0;
}
Что генерирует следующую сборку:
BucketMap::acquireBucket():
mov r8, rdi
mov rdx, rsi
.L2:
mov rax, QWORD PTR [rsi]
xor eax, eax
lock bts QWORD PTR [rdx], rax
setc al
jc .L2
mov rdi, r8
mov ecx, 16
rep stosq
mov rax, r8
ret
main:
sub rsp, 152
lea rsi, [rsp+8]
lea rdi, [rsp+16]
mov QWORD PTR [rsp+8], 1
call BucketMap::acquireBucket()
xor eax, eax
add rsp, 152
ret
xor eax,eax
означает, что сборка здесь всегда пытается получить индекс 0... в результате получается бесконечный цикл.
Я могу видеть только два объяснения этой сборки:
- Я каким-то образом вызвал Undefined Behavior.
- В gcc есть ошибка генерации кода.
И я исчерпал все свои идеи относительно того, что может вызвать UB.
Может ли кто-нибудь объяснить, почему gcc будет генерировать этот xor eax,eax
?
Примечание: предварительно указывается gcc как https://gcc.gnu.org/bugzilla/show_bug.cgi?id=86314.
Используемая версия компилятора:
$ gcc --version
gcc (GCC) 7.3.0
Copyright (C) 2017 Free Software Foundation, Inc.
This is free software; see the source for copying conditions. There is
NO warranty; not even for MERCHANTABILITY or FITNESS FOR A PARTICULAR
PURPOSE.
Флаги компилятора:
-Wall -Wextra -Werror -Wduplicated-cond -Wnon-virtual-dtor -Wvla
-rdynamic -Wno-deprecated-declarations -Wno-type-limits
-Wno-unused-parameter -Wno-unused-local-typedefs -Wno-unused-value
-Wno-aligned-new -Wno-implicit-fallthrough -Wno-deprecated
-Wno-noexcept-type -Wno-register -ggdb -fno-strict-aliasing
-std=c++17 -Wl,--no-undefined -Wno-sign-compare
-g -O3 -mpopcnt
1 На самом деле, это лучше, чем я ожидал, компилятор понимает, что fetch_or(bit(index))
за которым следует previous & bit(index)
является эквивалентом использования bts
и проверка флага CF является чистым золотом.
Ответы
Ответ 1
Это ошибка оптимизации peephole в gcc, см. # 86413, затрагивающие версии 7.1, 7.2, 7.3 и 8.1. Исправление уже включено и будет доставлено соответственно в версии 7.4 и 8.2.
Короткий ответ заключается в том, что конкретная последовательность кода (результат проверки fetch_or
+) генерирует setcc
(устанавливается условное, aka на основе состояния флагов), за которым следует movzbl
(перемещение и нуль-расширение); в 7.x была введена оптимизация, которая преобразует setcc
за которой следует movzbl
в xor
за которым следует setcc
, однако в этой оптимизации отсутствовали некоторые проверки, в результате чего xor
мог сбивать регистр, который все еще нужен (в этом случае eax
).
Более длинный ответ заключается в том, что fetch_or
может быть реализован либо как cmpxchg
для полной общности, либо, если установить только один бит, как bts
(бит тест и набор). В качестве другой оптимизации, введенной в 7.x, теперь gcc генерирует здесь bts
(gcc 6.4 все еще генерирует cmpxchg
). bts
устанавливает флаг переноса (CF
) в предыдущее значение бит.
То есть, auto previous = a.fetch_or(bit); auto n = previous & bit;
auto previous = a.fetch_or(bit); auto n = previous & bit;
будет генерировать:
-
lock bts QWORD PTR [<address of a>], <bit index>
чтобы установить бит и записать его предыдущее значение, -
setc <n>l
чтобы установить нижние 8 бит r<n>x
в значение флага переноса (CF
), -
movzx e<n>x, <n>l
для нулевых верхних бит r<n>x
.
И тогда будет применена оптимизация глазок, которая все испортит.
gcc trunk теперь генерирует правильную сборку:
BucketMap::acquireBucket():
mov rdx, rdi
mov rcx, rsi
.L2:
mov rax, QWORD PTR [rsi]
and eax, 1
lock bts QWORD PTR [rcx], rax
setc al
movzx eax, al
jc .L2
mov rdi, rdx
mov ecx, 16
rep stosq
mov rax, rdx
ret
main:
sub rsp, 152
lea rsi, [rsp+8]
lea rdi, [rsp+16]
mov QWORD PTR [rsp+8], 1
call BucketMap::acquireBucket()
xor eax, eax
add rsp, 152
ret
Хотя, к сожалению, оптимизация больше не применяется, поэтому мы остаемся с setc
+ mov
вместо xor
+ setc
... но по крайней мере это правильно!
Ответ 2
В качестве побочной заметки вы можете найти самый низкий бит 0 бит с прямой манипуляцией бит:
template<class T>
T find_lowest_0_bit_mask(T value) {
T t = value + 1;
return (t ^ value) & t;
}
Возвращает битовую маску, а не бит.
Предсказания: T
должен быть без знака, value
должно содержать не менее 1 бит нуля.
mData.load
должен синхронизироваться с mData.fetch_or
, поэтому он должен быть
mData.load(std::memory_order_acquire)
а также
mData.fetch_or(..., std::memory_order_release)
И, ИМО, есть что-то в этих бит-атаках, которые заставляют его генерировать неправильную сборку с помощью clang
, см. .LBB0_5
который явно ошибочен, потому что он продолжает пытаться установить один и тот же бит, а не перерасчитывать другой бит для установки. Версия, которая генерирует правильную сборку:
#include <cstdint>
#include <atomic>
static constexpr int NO_INDEX = -1;
template<class T>
T find_lowest_0_bit_mask(T value) {
T t = value + 1;
return (t ^ value) & t;
}
class AtomicBitMap {
public:
static constexpr std::uint64_t occupied() noexcept { return ~std::uint64_t(0); }
int acquire() noexcept {
auto map = mData.load(std::memory_order_acquire);
while(map != occupied()) {
std::uint64_t mask = find_lowest_0_bit_mask(map);
if(mData.compare_exchange_weak(map, map | mask, std::memory_order_release))
return __builtin_ffsl(mask) - 1;
}
return NO_INDEX;
}
void release(int i) noexcept {
mData.fetch_and(~bit(i), std::memory_order_release);
}
private:
static constexpr std::uint64_t bit(int index) noexcept {
return std::uint64_t(1) << index;
}
std::atomic_uint64_t mData{ 0 };
};
Ответ 3
xor-zero
/set flags/setcc
как правило, лучший способ создать 32-разрядное целое число 0/1.
Очевидно, что это безопасно, только если у вас есть запасной регистр на xor
-zero, не уничтожая никаких входов в инструкции по установке флага, так что это довольно явно ошибка.
(В противном случае вы можете setcc dl
/movzx eax,dl
. Предпочтительны отдельные регистры, поэтому movzx может быть нулевой латентностью (mov-elim) на некоторых процессорах, но на критическом пути на других ЦП, так что xor/set-flags/setcc идиома предпочтительнее, поскольку меньшее количество инструкций находится на критическом пути.)
IDK, почему gcc создает целочисленное значение (previous & mask) == 0
в регистре вообще; что, вероятно, часть ошибки.