Я шукав найшвидший шлях до popcount
великих масивів даних. У мене виник дуже дивний ефект: зміна змінної циклу з unsigned
на uint64_t
зменшення продуктивності на моєму ПК.
Орієнтир
#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 для виконання попконтану. Щоб отримати більш точний результат, ми робимо попконт в 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 скласти мій показник. Він отримав подібні результати, тому, здається, це не Хасвелл. Оскільки два компілятори дають тут дивні результати, це також не здається помилкою компілятора. У нас немає процесора AMD, тому ми могли тестувати лише Intel.
Більше божевілля, будь ласка!
Візьміть перший приклад (той, що має atol(argv[1])
) і поставте a 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 / не-const 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 / non-const 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
зміну , як ми бачили при вставці ключового слова перед змінною розміру! В майбутньому я завжди перевіряю різні альтернативи на різних компіляторах, коли пишу дійсно тісні та гарячі цикли, які мають вирішальне значення для продуктивності системи.
Цікавим є також те, що різниця в продуктивності все ще така велика, хоча я вже чотири рази розкрутив цикл. Тож навіть якщо ви розмотаєтесь, ви все одно можете потрапити під великі відхилення від продуктивності. Доволі цікаво.