Посмотрите на этот фрагмент:
#include <atomic>
#include <thread>
typedef volatile unsigned char Type;
// typedef std::atomic_uchar Type;
void fn(Type *p) {
for (int i=0; i<500000000; i++) {
(*p)++;
}
}
int main() {
const int N = 4;
std::thread thr[N];
alignas(64) Type buffer[N*64];
for (int i=0; i<N; i++) {
thr[i] = std::thread(&fn, &buffer[i*1]);
}
for (int i=0; i<N; i++) {
thr[i].join();
}
}
Эта небольшая программа многократно увеличивает четыре соседних байта из четырех разных потоков. Раньше я использовал правило: не используйте одну и ту же строку кэша из разных потоков, так как совместное использование строк в кеше плохо. Поэтому я ожидал, что версия с четырьмя потоками (N=4
) намного медленнее, чем версия одного потока (N=1
).
Однако это мои измерения (на процессоре Haswell):
- N = 1:1 с
- N = 4: 1,2 с
Итак, N=4
не намного медленнее. Если я использую разные строки кэша (замените *1
на *64
), тогда N=4
станет немного быстрее: 1,1 с.
Те же измерения для атомного доступа (свопинг комментариев в typedef
), в той же строке кэша:
- N = 1: 3,1 с
- N = 4: 48 с
Итак, случай N=4
намного медленнее (как я и ожидал). Если используются разные строки кэша, то N=4
имеет схожую производительность как N=1
: 3,3 с.
Я не понимаю причины этих результатов. Почему бы мне не получить серьезное замедление в неатомном случае N=4
? У четырех ядер есть одинаковая память в их кэшах, поэтому они должны каким-то образом синхронизировать их, не так ли? Как они могут работать почти идеально параллельно? Почему просто атомный случай серьезно замедляется?
Я думаю, мне нужно понять, как память обновляется в этом случае. Вначале в своих кешах нет ядер buffer
. После одной итерации for
(в fn
) все 4 ядра имеют buffer
в своих кеш-строках, но каждое ядро записывает другой байт. Как синхронизировать эти строки кэша (в неатомном случае)? Как знает кеш, какой байт грязный? Или есть какой-то другой механизм для обработки этого случая? Почему этот механизм намного дешевле (фактически, он почти свободен), чем атомный?