Якщо ви вважаєте, що 64-розрядна інструкція DIV - це хороший спосіб розділити на два, то недарма вихід ASM компілятора обіграє ваш рукописний код навіть за допомогою -O0
(компілювати швидко, без додаткової оптимізації та зберігання / перезавантаження в пам'ять після / перед кожним твердженням C, тому налагоджувач може змінювати змінні).
Дивіться посібник з оптимізації збірки Agner Fog, щоб дізнатися, як написати ефективну ASM. Він також має таблиці інструкцій та посібник з мікроарха для конкретних деталей для конкретних процесорів. Див. Такожx86 Тег Вікі для отримання додаткових посилань.
Дивіться також це більш загальне питання щодо побиття компілятора рукописним ASM: Чи вбудована мова складання вбудована повільніше, ніж рідний код C ++? . TL: DR: так, якщо ви зробите це неправильно (як це питання).
Зазвичай ви добре дозволяєте компілятору робити все, особливо якщо ви намагаєтеся написати C ++, який може ефективно компілювати . Також бачите, чи збірка швидша, ніж компільовані мови? . Один з посилань на відповіді на ці акуратні слайди, який показує, як різні компілятори C оптимізують деякі дійсно прості функції за допомогою крутих хитрощів. Розмова Метта Годболта про CppCon2017 " Що мені зробив останній час? Зняття кришки компілятора »є в подібному руслі.
even:
mov rbx, 2
xor rdx, rdx
div rbx
У Intel Haswell div r64
це 36 уп, з затримкою 32-96 циклів і пропускною здатністю один на 21-74 циклів. (Плюс 2 уп для встановлення RBX і нульової RDX, але виконання поза замовленням може запустити їх рано). Інструкції з високим загальним підрахунком, такі як DIV, мікрокодуються, що також може спричинити вузькі місця. У цьому випадку затримка є найбільш релевантним фактором, оскільки вона є частиною ланцюга залежностей, перенесених циклом.
shr rax, 1
робить той самий неподписаний поділ: це 1 взагалі, з затримкою 1с , і може працювати 2 за тактовий цикл.
Для порівняння, 32-розрядний поділ швидший, але все ще жахливий проти зрушень. idiv r32
це 9 уп, затримка 22-29 ° С і одна на пропускну здатність 8-11с на Хасвелл.
Як видно з перегляду -O0
виходу ASM gcc ( провідник компілятора Godbolt ), він використовує лише інструкції зсуву . clang -O0
компілює наївно, як ви думали, навіть використовуючи 64-розрядний IDIV двічі. (Під час оптимізації компілятори використовують обидва виходи IDIV, коли джерело робить поділ і модуль з тими ж операндами, якщо вони взагалі використовують IDIV)
GCC не має повністю наївного режиму; він завжди перетворюється через GIMPLE, що означає, що деякі "оптимізації" неможливо відключити . Сюди входить розпізнавання поділу на постійну та використання зрушень (потужність 2) або фіксованого мультипликативного зворотного (не потужність 2), щоб уникнути ІДІВ (див. div_by_13
У вищенаведеному посиланні боболиста).
gcc -Os
(Оптимізують для розміру) робить використання IDIV для неенергетичних-оф-2 поділу, до жаль , навіть в тих випадках , коли мультиплікативний зворотний код є лише трохи більше , але набагато швидше.
Допомога компілятору
(резюме для цього випадку: використання uint64_t n
)
По-перше, цікаво лише подивитися на оптимізований вихід компілятора. ( -O3
). -O0
швидкість в основному безглузда.
Подивіться на ваш вихід ASM (на Godbolt, або див. Як видалити "шум" з виходу збірки GCC / clang? ). Коли компілятор в першу чергу не робить оптимального коду: Написання джерела C / C ++ таким чином, що керує компілятором на створення кращого коду, як правило, найкращий підхід . Ви повинні знати asm та знати, що ефективно, але ви застосовуєте ці знання опосередковано. Компілятори - це також гарне джерело ідей: іноді кланг зробить щось круте, і ви можете перенести gcc на те, щоб зробити те саме: дивіться цю відповідь і що я зробив із непрокрученим циклом у коді @ Ведрака нижче.)
Цей підхід є портативним, і через 20 років якийсь майбутній компілятор може скомпілювати його до того, що є ефективним для майбутнього обладнання (x86 чи ні), можливо, використовуючи нове розширення ISA або авто-векторизацію. Рукописний x86-64 asm від 15 років тому, як правило, не буде оптимально налаштований на Skylake. наприклад, порівняти і відгалужити макро-синтез тоді ще не існував. Що зараз оптимально для виготовленої вручну ASM для однієї мікроархітектури, можливо, не буде оптимальним для інших поточних та майбутніх процесорів. У коментарях до відповіді @ johnfound обговорюються основні відмінності між AMD Bulldozer та Intel Haswell, які мають великий вплив на цей код. Але теоретично g++ -O3 -march=bdver3
і g++ -O3 -march=skylake
зробить правильно. (Або -march=native
.) Або -mtune=...
просто налаштувати, не використовуючи інструкцій, які інші процесори можуть не підтримувати.
Я відчуваю, що орієнтація компілятора на підсилення, що добре для поточного процесора, про який ви дбаєте, не повинно бути проблемою для майбутніх компіляторів. Вони сподіваються, що краще, ніж нинішні компілятори, у пошуку способів трансформації коду, і можуть знайти спосіб, який працює для майбутніх процесорів. Незважаючи на це, майбутній x86, ймовірно, не буде жахливим у тому, що є хорошим для поточного x86, і майбутній компілятор уникне будь-яких підказок, що стосуються ASM, реалізуючи щось подібне до руху даних з вашого джерела C, якщо він не бачить щось краще.
Рукописний ASM - це чорна скринька для оптимізатора, тому постійне розповсюдження не працює, коли вбудоване введення робить константу часу компіляції. Інші оптимізації також впливають. Прочитайте https://gcc.gnu.org/wiki/DontUseInlineAsm перед тим, як використовувати asm. (І уникайте вбудованої ASM-стилі MSVC: входи / виходи повинні проходити через пам'ять, яка додає накладні витрати .)
У цьому випадку : ваш n
має підписаний тип, а gcc використовує послідовність SAR / SHR / ADD, яка дає правильне округлення. (ІДІВ та арифметичний зсув «круглі» по-різному для негативних входів, див. SAR в наборі ручного запису ). (IDK, якщо gcc намагався і не спроміг довести, що n
це не може бути негативним, або що.
Ви повинні були використовувати uint64_t n
, так що це може просто SHR. І тому він портативний для систем, де long
лише 32-розрядні (наприклад, x86-64 Windows).
До речі, оптимізований вихід ASM від gcc виглядає досить непогано (використовуючи )unsigned long n
: внутрішній цикл, який він вводить, main()
робить це:
# from gcc5.4 -O3 plus my comments
# edx= count=1
# rax= uint64_t n
.L9: # do{
lea rcx, [rax+1+rax*2] # rcx = 3*n + 1
mov rdi, rax
shr rdi # rdi = n>>1;
test al, 1 # set flags based on n%2 (aka n&1)
mov rax, rcx
cmove rax, rdi # n= (n%2) ? 3*n+1 : n/2;
add edx, 1 # ++count;
cmp rax, 1
jne .L9 #}while(n!=1)
cmp/branch to update max and maxi, and then do the next n
Внутрішня петля є безрозгалуженою, а критичний шлях ланцюга залежної від циклу:
- 3-компонентний LEA (3 цикли)
- cmov (2 цикли на Хасвелл, 1с на Бродвелл або пізніші).
Всього: 5 циклів за ітерацію, затримка вузького місця . Виконання поза замовленнями піклується про все інше паралельно з цим (теоретично: я не перевіряв Perf лічильники, щоб побачити, чи дійсно він працює на 5c / iter).
Вхід FLAGS cmov
(вироблений TEST) швидше виробляти, ніж вхід RAX (від LEA-> MOV), тому він не знаходиться на критичному шляху.
Аналогічно, MOV-> SHR, що виробляє вхід RDI CMOV, відходить від критичного шляху, оскільки це також швидше, ніж LEA. MOV на IvyBridge і пізніших версіях має нульову затримку (обробляється під час реєстрації-перейменування). (Це все ще займає взагалі і проріз в трубопроводі, тому це не безкоштовно, просто нульова затримка). Додатковий MOV у ланцюзі LEA dep є частиною вузького місця на інших процесорах.
Cmp / jne також не є частиною критичного шляху: він не переноситься циклом, оскільки залежність управління обробляється за допомогою передбачення гілок + спекулятивного виконання, на відміну від залежностей даних про критичний шлях.
Побиття компілятора
GCC зробив тут досить непогану роботу. Він може зберегти один байт коду, використовуючи inc edx
замість цьогоadd edx, 1
, оскільки ніхто не піклується про P4 та його помилкові залежності для інструкцій, що змінюють частковий прапор.
Це також може зберегти всі інструкції MOV, і TEST: SHR встановлює CF = біт зміщений, тому ми можемо використовувати cmovc
замість test
/ cmovz
.
### Hand-optimized version of what gcc does
.L9: #do{
lea rcx, [rax+1+rax*2] # rcx = 3*n + 1
shr rax, 1 # n>>=1; CF = n&1 = n%2
cmovc rax, rcx # n= (n&1) ? 3*n+1 : n/2;
inc edx # ++count;
cmp rax, 1
jne .L9 #}while(n!=1)
Дивіться відповідь @ johnfound для ще одного хитрого фокусу: видаліть CMP шляхом розгалуження на результат прапора SHR, а також використовуйте його для CMOV: нуль, лише якщо n було 1 (або 0) для початку. (Факт забави: SHR з count! = 1 на Nehalem або раніше викликає затримку, якщо ви читаєте результати прапора . Ось так вони зробили це одноосібним. Хоча спеціальне кодування shift-by-1 чудово.)
Уникнення MOV зовсім не допомагає із затримкою на Haswell ( Чи може MOV x86 справді бути "вільним"? Чому я взагалі не можу це відтворити? ). Це дійсно допомагає істотно на процесорах Intel , як попередньо IVB, і AMD Bulldozer сім'ї, де МОВ не нульовий затримкою. Втрачені інструкції MOV компілятора впливають на критичний шлях. BD-комплекс LEA та CMOV мають обидві нижчі затримки (2c та 1c відповідно), тому це більша частка затримки. Крім того, проблеми з пропускною спроможністю стають проблемою, оскільки вона має лише дві цілі ALU-труби. Дивіться відповідь @ johnfound , де у нього є результати синхронізації процесора AMD.
Навіть у Haswell ця версія може трохи допомогти, уникаючи випадкових затримок, коли некритичний взагалі викрадає порт виконання з одного на критичному шляху, затримуючи виконання на 1 цикл. (Це називається ресурсним конфліктом). Він також зберігає реєстр, що може допомогти при виконанні декількох n
значень паралельно в перемежованому циклі (див. Нижче).
Затримка LEA залежить від режиму адресації , процесорів сімейства Intel SnB. 3c для 3 компонентів ( [base+idx+const]
що займає два окремих додавання), але лише 1c з 2 або меншими компонентами (одне додавання). Деякі процесори (наприклад, Core2) роблять навіть трикомпонентні LEA за один цикл, але сімейство SnB цього не робить. Гірше, що сімейство Intel SnB стандартизує затримки, щоб не було 2-уп , інакше 3-компонентний LEA був би лише 2с, як у бульдозера. (3-х компонентний LEA також повільніше на AMD, тільки не на стільки).
Отже lea rcx, [rax + rax*2]
/ inc rcx
затримка лише на 2с, швидше, ніж lea rcx, [rax + rax*2 + 1]
на процесорах сімейства Intel SnB, таких як Haswell. Беззбитковість на BD і гірше на Core2. Це коштує додатково взагалі, що зазвичай не варто економити затримку 1с, але затримка є головним вузьким місцем тут, і Haswell має достатньо широкий конвеєр для обробки додаткової загальної пропускної здатності.
Ні gcc, icc, ні clang (на godbolt) не використовували вихід CF CF SHR, завжди використовуючи AND або TEST . Нерозумні компілятори. : P Вони - чудові частини складної техніки, але розумна людина часто може їх перемогти в дрібних проблемах. (Звичайно, зважаючи на тисячі-мільйони разів довше, щоб думати про це! Компілятори не використовують вичерпних алгоритмів для пошуку всіх можливих способів вчинити, тому що це займе занадто багато часу, коли оптимізувати багато вкладеного коду, ось що Вони також не моделюють трубопровід у цільовій мікроархітектурі, принаймні, не з такою ж деталлю, як IACA або інші інструменти статичного аналізу; вони просто використовують певну евристику.)
Просте розкручування циклу не допоможе ; це вузькі вузькі місця на затримку ланцюга залежності, що переноситься циклом, а не на накладну / пропускну здатність циклу. Це означає, що це було б добре з гіперпереборкою (або будь-яким іншим типом SMT), оскільки у процесора є багато часу для переплетення інструкцій з двох потоків. Це означало б паралелізацію циклу main
, але це добре, тому що кожен потік може просто перевірити діапазон n
значень і отримати в результаті пару цілих чисел.
Переплетення вручну в межах однієї нитки теж може бути життєздатним . Можливо, обчислити послідовність для пари чисел паралельно, оскільки кожне займає лише пару регістрів, і всі вони можуть оновлювати те саме max
/ maxi
. Це створює більше паралелізму на рівні інструкцій .
Трюк полягає у вирішенні питання, чи варто чекати, поки всі n
значення досягнуті, 1
перш ніж отримати ще одну пару вихідних n
значень, або вийти з нього та отримати нову початкову точку лише для однієї, яка досягла кінцевої умови, не торкаючись регістрів для іншої послідовності. Напевно, краще, щоб кожен ланцюжок працював над корисними даними, інакше вам доведеться умовно збільшувати його лічильник.
Ви можете, навіть, зробити це за допомогою пакунків SSE-упаковки-порівняння, щоб умовно збільшити лічильник для векторних елементів, до яких n
ще не дійшли 1
. А потім, щоб приховати ще більшу затримку реалізації умовного приросту SIMD, вам потрібно буде тримати n
в повітрі більше векторів значень. Можливо, варто лише з вектором 256b (4x uint64_t
).
Я думаю, що найкраща стратегія виявлення 1
"липкого" - це маскування вектора всіх, які ви додаєте для збільшення лічильника. Отже, після того, як ви побачили 1
елемент у елементі, векторний приріст матиме нуль, а + = 0 - неоперативний.
Неперевірена ідея для ручної векторизації
# starting with YMM0 = [ n_d, n_c, n_b, n_a ] (64-bit elements)
# ymm4 = _mm256_set1_epi64x(1): increment vector
# ymm5 = all-zeros: count vector
.inner_loop:
vpaddq ymm1, ymm0, xmm0
vpaddq ymm1, ymm1, xmm0
vpaddq ymm1, ymm1, set1_epi64(1) # ymm1= 3*n + 1. Maybe could do this more efficiently?
vprllq ymm3, ymm0, 63 # shift bit 1 to the sign bit
vpsrlq ymm0, ymm0, 1 # n /= 2
# FP blend between integer insns may cost extra bypass latency, but integer blends don't have 1 bit controlling a whole qword.
vpblendvpd ymm0, ymm0, ymm1, ymm3 # variable blend controlled by the sign bit of each 64-bit element. I might have the source operands backwards, I always have to look this up.
# ymm0 = updated n in each element.
vpcmpeqq ymm1, ymm0, set1_epi64(1)
vpandn ymm4, ymm1, ymm4 # zero out elements of ymm4 where the compare was true
vpaddq ymm5, ymm5, ymm4 # count++ in elements where n has never been == 1
vptest ymm4, ymm4
jnz .inner_loop
# Fall through when all the n values have reached 1 at some point, and our increment vector is all-zero
vextracti128 ymm0, ymm5, 1
vpmaxq .... crap this doesn't exist
# Actually just delay doing a horizontal max until the very very end. But you need some way to record max and maxi.
Ви можете та повинні реалізувати це за допомогою внутрішніх текстів замість рукописного asm.
Алгоритмічне / вдосконалення впровадження:
Окрім того, що просто реалізувати ту саму логіку з більш ефективною ASM, шукайте способи спростити логіку або уникнути зайвої роботи. наприклад, запам'ятати для виявлення загальних закінчень послідовностей. Або ще краще, подивіться одразу на 8 біт (відповідь гнашера)
@EOF вказує, що tzcnt
(або bsf
) можна використовувати для виконання декількох n/=2
ітерацій за один крок. Це, мабуть, краще, ніж векторизація SIMD; жодна інструкція SSE або AVX не може цього зробити. Він все ще сумісний з тим, щоб робити кілька скалярних n
s паралельно в різних цілих реєстрах.
Отже цикл може виглядати так:
goto loop_entry; // C++ structured like the asm, for illustration only
do {
n = n*3 + 1;
loop_entry:
shift = _tzcnt_u64(n);
n >>= shift;
count += shift;
} while(n != 1);
Це може зробити значно менше ітерацій, але зміна підрахунку змінних повільно відбувається на процесорах сімейства Intel SnB без BMI2. 3 уп, затримка 2с. (Вони мають вхідну залежність від FLAGS, тому що count = 0 означає, що прапори немодифіковані. Вони обробляють це як залежність від даних і приймають декілька Uops, оскільки взагалі може бути лише 2 входи (до HSW / BDW все одно)). Це такий вид, до якого звертаються люди, які скаржаться на божевільний CISC дизайн x86. Це робить процесори x86 повільніше, ніж вони були б, якби ISA розроблялася з нуля сьогодні, навіть здебільшого подібним чином. (тобто це частина "податку x86", який витрачає на швидкість / потужність.) SHRX / SHLX / SARX (BMI2) - це велика виграш (1 взагалі / 1c затримка).
Він також ставить tzcnt (3c за Хасвеллом і пізніше) на критичний шлях, тому він значно подовжує загальну затримку ланцюга залежності, перенесеної циклом. Однак це видаляє будь-яку потребу в CMOV або в підготовці реєстру n>>1
. @ Відповідь Ведрака долає все це, відкладаючи tzcnt / shift для декількох ітерацій, що є високоефективним (див. Нижче).
Ми можемо безпечно використовувати взаємозамінні BSF або TZCNT , тому n
що в цій точці ніколи не може бути нуль. Машиновий код TZCNT декодує як BSF на процесорах, які не підтримують BMI1. (Безглузді префікси ігноруються, тому REP BSF працює як BSF).
TZCNT працює набагато краще, ніж BSF на процесорах AMD, які його підтримують, тому це може бути хорошою ідеєю для використання REP BSF
, навіть якщо вам не байдуже налаштування ZF, якщо вхід дорівнює нулю, а не вихід. Деякі компілятори роблять це, коли ви __builtin_ctzll
навіть використовуєте -mno-bmi
.
Вони виконують те саме на процесорах Intel, тому просто збережіть байт, якщо це все, що має значення. TZCNT для Intel (pre-Skylake) все ще має помилкову залежність від нібито видаваного операнду, який є лише записом, як і BSF, щоб підтримати незадокументовану поведінку, що BSF з введенням = 0 залишає призначення не зміненим. Тож вам потрібно обійти це питання, якщо не оптимізувати лише для Skylake, тому нічого зайвого від байта REP немає. (Intel часто виходить за рамки того, що вимагає посібник з ISA x86, щоб уникнути зламу широко використовуваного коду, який залежить від того, чого він не повинен, або що заборонено заборонено. Наприклад, Windows 9x не передбачає спекулятивного попереднього вибору записів TLB , що було безпечним коли код був написаний, перш ніж Intel оновила правила управління TLB .)
У будь-якому випадку, LZCNT / TZCNT у Haswell мають те саме помилкове зображення, що і POPCNT: див. Цю запитання . Ось чому у виведенні asm gcc для коду @ Veedrac ви бачите, як він розриває ланцюг dep з xor-нулюванням у регістрі, який збирається використовувати як пункт призначення TZCNT, коли він не використовує dst = src. Оскільки TZCNT / LZCNT / POPCNT ніколи не залишають місця призначення невизначеним або незміненим, ця помилкова залежність від виходу на процесори Intel є помилкою / обмеженням продуктивності. Імовірно, варто деяким транзисторам / потужності, щоб вони поводилися так, як інші упи, які йдуть до тієї ж одиниці виконання. Єдиний перф назустріч - це взаємодія з іншим обмеженням uarch: вони можуть мікроплавити операнд пам'яті в режимі індексованої адреси на Haswell, але на Skylake, де Intel вилучило помилкове зображення для LZCNT / TZCNT, вони "відшаровують" режими індексованого адресації, тоді як POPCNT все ще може мікро-запобіжник будь-якого режиму додавання.
Удосконалення ідей / коду з інших відповідей:
@ hidfromkgb відповідь приємне зауваження, що ви гарантовано зможете зробити один правильний зсув після 3n + 1. Ви можете обчислити це ще ефективніше, ніж просто виключати перевірки між кроками. Реалізація asm у цій відповіді порушена (хоча це залежить від OF, який не визначено після SHRD з рахунком> 1), і повільний: ROR rdi,2
швидше SHRD rdi,rdi,2
, а використання двох інструкцій CMOV на критичному шляху повільніше, ніж додатковий тест що може працювати паралельно.
Я поставив прибраний / вдосконалений C (який керує компілятором для отримання кращої ASM) і перевірив + працюючи швидше ASM (у коментарях нижче C) вгору на Godbolt: дивіться посилання у відповіді @ hidfromkgb . (Ця відповідь досягає граничної позначки в 30 кб із великих URL-адрес Godbolt, але шорт-посилання можуть загнивати і в будь-якому випадку занадто довгі для goo.gl.)
Також покращено друк вихідних даних, щоб перетворити на рядок і зробити один write()
замість того, щоб писати одну таблицю за один раз. Це мінімізує вплив на присвоєння часу всій програмі perf stat ./collatz
(для запису лічильників результативності), і я знеструмив деякі некритичні зори.
@ Код Ведрака
Я отримав незначне прискорення з правого переміщення настільки, наскільки ми знаємо, що потрібно робити, і перевірку, щоб продовжити цикл. З 7,5s для ліміту = 1e8 до 7,275s для Core2Duo (Merom) з коефіцієнтом розгортання 16.
код + коментарі до Godbolt . Не використовуйте цю версію з клангом; це робить щось нерозумно з відкладною петлею. Використовуючи лічильник tmp, k
а потім додаючи його, щоб count
пізніше змінити, що робить кланг, але це трохи шкодить gcc.
Дивіться обговорення в коментарях: Код Веедрака відмінний для процесорів з BMI1 (тобто не Celeron / Pentium)