Я искал самый быстрый способ для popcount
больших массивов данных. Я обнаружил очень странное действие: Изменение переменного цикла из unsigned
к uint64_t
высказанному падению производительности на 50% по сравнению с ПК.
Бенчмарк
#include <iostream>
#include <chrono>
#include <x86intrin.h>
int main(int argc, char* argv[]) {
using namespace std;
if (argc != 2) {
cerr << "usage: array_size in MB" << endl;
return -1;
}
uint64_t size = atol(argv[1])<<20;
uint64_t* buffer = new uint64_t[size/8];
char* charbuffer = reinterpret_cast<char*>(buffer);
for (unsigned i=0; i<size; ++i)
charbuffer[i] = rand()%256;
uint64_t count,duration;
chrono::time_point<chrono::system_clock> startP,endP;
{
startP = chrono::system_clock::now();
count = 0;
for( unsigned k = 0; k < 10000; k++){
// Tight unrolled loop with unsigned
for (unsigned i=0; i<size/8; i+=4) {
count += _mm_popcnt_u64(buffer[i]);
count += _mm_popcnt_u64(buffer[i+1]);
count += _mm_popcnt_u64(buffer[i+2]);
count += _mm_popcnt_u64(buffer[i+3]);
}
}
endP = chrono::system_clock::now();
duration = chrono::duration_cast<std::chrono::nanoseconds>(endP-startP).count();
cout << "unsigned\t" << count << '\t' << (duration/1.0E9) << " sec \t"
<< (10000.0*size)/(duration) << " GB/s" << endl;
}
{
startP = chrono::system_clock::now();
count=0;
for( unsigned k = 0; k < 10000; k++){
// Tight unrolled loop with uint64_t
for (uint64_t i=0;i<size/8;i+=4) {
count += _mm_popcnt_u64(buffer[i]);
count += _mm_popcnt_u64(buffer[i+1]);
count += _mm_popcnt_u64(buffer[i+2]);
count += _mm_popcnt_u64(buffer[i+3]);
}
}
endP = chrono::system_clock::now();
duration = chrono::duration_cast<std::chrono::nanoseconds>(endP-startP).count();
cout << "uint64_t\t" << count << '\t' << (duration/1.0E9) << " sec \t"
<< (10000.0*size)/(duration) << " GB/s" << endl;
}
free(charbuffer);
}
Как видите, мы создаем буфер случайных данных размером в x
мегабайты, x
который читается из командной строки. После этого мы выполняем итерацию по буферу и используем развернутую версию popcount
встроенной функции x86 для выполнения popcount. Чтобы получить более точный результат, мы делаем поп-счет 10000 раз. Мы измеряем время для попконта. В верхнем регистре переменная внутреннего цикла - это unsigned
, в нижнем регистре - переменная внутреннего цикла uint64_t
. Я думал, что это не должно иметь никакого значения, но дело обстоит наоборот.
(Абсолютно безумные) результаты
Я скомпилирую это так (версия g ++: Ubuntu 4.8.2-19ubuntu1):
g++ -O3 -march=native -std=c++11 test.cpp -o test
Вот результаты работы моего процессора Haswell Core i7-4770K с тактовой частотой 3,50 ГГц test 1
(так, случайные данные 1 МБ):
- без знака 41959360000 0,401554 с 26,113 ГБ / с
- uint64_t 41959360000 0,759822 с 13,8003 ГБ / с
Как видите, пропускная способность uint64_t
версии составляет лишь половину от unsigned
версии! Кажется, проблема в том, что генерируются разные сборки, но почему? Сначала я подумал об ошибке компилятора и попытался clang++
(Ubuntu Clang версии 3.4-1ubuntu3):
clang++ -O3 -march=native -std=c++11 teest.cpp -o test
Результат: test 1
- без знака 41959360000 0,398293 с 26,3267 ГБ / с
- uint64_t 41959360000 0,680954 с, 15,3986 ГБ / с
Таким образом, это почти тот же результат и все еще странный. Но теперь это становится супер странным. Я заменяю размер буфера, который был прочитан из ввода, константой 1
, поэтому я изменяю:
uint64_t size = atol(argv[1]) << 20;
в
uint64_t size = 1 << 20;
Таким образом, компилятор теперь знает размер буфера во время компиляции. Может быть, это может добавить некоторые оптимизации! Вот цифры для g++
:
- без знака 41959360000 0,509156 с 20,5944 ГБ / с
- uint64_t 41959360000 0,508673 с 20,6139 ГБ / с
Теперь обе версии одинаково быстрые. Тем не менее, unsigned
стал еще медленнее ! Он упал с 26
до 20 GB/s
, тем самым заменив непостоянную на постоянную величину, что приведет к деоптимизации . Серьезно, я понятия не имею, что здесь происходит! Но теперь clang++
с новой версией:
- без знака 41959360000 0,677009 с 15,4884 ГБ / с
- uint64_t 41959360000 0,676909 с 15,4906 ГБ / с
Чего ждать? Теперь обе версии опустились до медленного 15 ГБ / с. Таким образом, замена непостоянной константы даже приводит к медленному коду в обоих случаях для Clang!
Я попросил коллегу с процессором Ivy Bridge скомпилировать мой тест. Он получил аналогичные результаты, так что, похоже, это не Haswell. Поскольку два компилятора дают здесь странные результаты, это также не является ошибкой компилятора. У нас здесь нет процессора AMD, поэтому мы могли тестировать только с Intel.
Больше безумия, пожалуйста!
Возьмите первый пример (тот, что с atol(argv[1])
) и поместите static
перед переменной, то есть:
static uint64_t size=atol(argv[1])<<20;
Вот мои результаты в g ++:
- без знака 41959360000 0,396728 с 26,4306 ГБ / с
- uint64_t 41959360000 0,509484 с 20,5811 ГБ / с
Ууу, еще одна альтернатива . У нас все еще есть быстрые 26 ГБ / с u32
, но нам удалось получить u64
как минимум от 13 ГБ / с до версии 20 ГБ / с! На компьютере моего коллеги u64
версия стала даже быстрее, чем u32
версия, что дало самый быстрый результат из всех. К сожалению, это работает только для g++
, clang++
кажется, не заботится static
.
Мой вопрос
Можете ли вы объяснить эти результаты? Особенно:
- Как может быть такая разница между
u32
иu64
? - Как замена непостоянного постоянным размером буфера может привести к менее оптимальному коду ?
- Как вставка
static
ключевого слова может сделатьu64
цикл быстрее? Даже быстрее, чем оригинальный код на компьютере моего коллеги!
Я знаю, что оптимизация - это сложная территория, однако я никогда не думал, что такие небольшие изменения могут привести к 100% -ной разнице во времени выполнения и что небольшие факторы, такие как постоянный размер буфера, могут снова полностью смешивать результаты. Конечно, я всегда хочу иметь версию, способную подсчитывать 26 ГБ / с. Единственный надежный способ, который я могу придумать, это скопировать и вставить сборку для этого случая и использовать встроенную сборку. Это единственный способ избавиться от компиляторов, которые, кажется, сходят с ума от небольших изменений. Что вы думаете? Есть ли другой способ надежно получить код с большей производительностью?
Разборка
Вот разборка для различных результатов:
Версия 26 ГБ / с из g ++ / u32 / неконстантного размера bufsize :
0x400af8:
lea 0x1(%rdx),%eax
popcnt (%rbx,%rax,8),%r9
lea 0x2(%rdx),%edi
popcnt (%rbx,%rcx,8),%rax
lea 0x3(%rdx),%esi
add %r9,%rax
popcnt (%rbx,%rdi,8),%rcx
add $0x4,%edx
add %rcx,%rax
popcnt (%rbx,%rsi,8),%rcx
add %rcx,%rax
mov %edx,%ecx
add %rax,%r14
cmp %rbp,%rcx
jb 0x400af8
Версия 13 ГБ / с из g ++ / u64 / неконстантного размера bufsize :
0x400c00:
popcnt 0x8(%rbx,%rdx,8),%rcx
popcnt (%rbx,%rdx,8),%rax
add %rcx,%rax
popcnt 0x10(%rbx,%rdx,8),%rcx
add %rcx,%rax
popcnt 0x18(%rbx,%rdx,8),%rcx
add $0x4,%rdx
add %rcx,%rax
add %rax,%r12
cmp %rbp,%rdx
jb 0x400c00
Версия 15 ГБ / с из clang ++ / u64 / non-const bufsize :
0x400e50:
popcnt (%r15,%rcx,8),%rdx
add %rbx,%rdx
popcnt 0x8(%r15,%rcx,8),%rsi
add %rdx,%rsi
popcnt 0x10(%r15,%rcx,8),%rdx
add %rsi,%rdx
popcnt 0x18(%r15,%rcx,8),%rbx
add %rdx,%rbx
add $0x4,%rcx
cmp %rbp,%rcx
jb 0x400e50
Версия 20 ГБ / с из g ++ / u32 & u64 / const bufsize :
0x400a68:
popcnt (%rbx,%rdx,1),%rax
popcnt 0x8(%rbx,%rdx,1),%rcx
add %rax,%rcx
popcnt 0x10(%rbx,%rdx,1),%rax
add %rax,%rcx
popcnt 0x18(%rbx,%rdx,1),%rsi
add $0x20,%rdx
add %rsi,%rcx
add %rcx,%rbp
cmp $0x100000,%rdx
jne 0x400a68
Версия 15 ГБ / с из clang ++ / u32 & u64 / const bufsize :
0x400dd0:
popcnt (%r14,%rcx,8),%rdx
add %rbx,%rdx
popcnt 0x8(%r14,%rcx,8),%rsi
add %rdx,%rsi
popcnt 0x10(%r14,%rcx,8),%rdx
add %rsi,%rdx
popcnt 0x18(%r14,%rcx,8),%rbx
add %rdx,%rbx
add $0x4,%rcx
cmp $0x20000,%rcx
jb 0x400dd0
Интересно, что самая быстрая (26 ГБ / с) версия тоже самая длинная! Кажется, это единственное решение, которое использует lea
. Некоторые версии используют jb
для перехода, другие используют jne
. Но кроме этого все версии кажутся сопоставимыми. Я не понимаю, откуда может возникнуть разрыв в производительности на 100%, но я не слишком разбираюсь в расшифровке сборки. Самая медленная (13 ГБ / с) версия выглядит даже очень коротко и хорошо. Кто-нибудь может объяснить это?
Уроки выучены
Неважно, каким будет ответ на этот вопрос; Я узнал, что в действительно горячих циклах каждая деталь может иметь значение, даже детали, которые, кажется, не связаны с горячим кодом . Я никогда не задумывался о том, какой тип использовать для переменной цикла, но, как вы видите, такое незначительное изменение может иметь значение на 100% ! Даже тип хранилища буфера может иметь огромное значение, как мы видели при вставке static
ключевого слова перед переменной размера! В будущем я всегда буду тестировать различные альтернативы на разных компиляторах, когда пишу действительно сжатые и горячие циклы, которые имеют решающее значение для производительности системы.
Интересно также то, что разница в производительности все еще так велика, хотя я уже развернул цикл четыре раза. Так что даже если вы развернетесь, вы все равно можете столкнуться с серьезными отклонениями производительности. Довольно интересно.