Код C ++ для тестування гіпотези Collatz швидше, ніж рукописна збірка - чому?


833

Я написав ці два рішення для проекту Euler Q14 , в зборі та в C ++. Це той же самий ідентичний підхід для випробування гіпотези Колатца . Рішення для складання було зібрано з

nasm -felf64 p14.asm && gcc p14.o -o p14

С ++ було складено з

g++ p14.cpp -o p14

Асамблея, p14.asm

section .data
    fmt db "%d", 10, 0

global main
extern printf

section .text

main:
    mov rcx, 1000000
    xor rdi, rdi        ; max i
    xor rsi, rsi        ; i

l1:
    dec rcx
    xor r10, r10        ; count
    mov rax, rcx

l2:
    test rax, 1
    jpe even

    mov rbx, 3
    mul rbx
    inc rax
    jmp c1

even:
    mov rbx, 2
    xor rdx, rdx
    div rbx

c1:
    inc r10
    cmp rax, 1
    jne l2

    cmp rdi, r10
    cmovl rdi, r10
    cmovl rsi, rcx

    cmp rcx, 2
    jne l1

    mov rdi, fmt
    xor rax, rax
    call printf
    ret

C ++, p14.cpp

#include <iostream>

using namespace std;

int sequence(long n) {
    int count = 1;
    while (n != 1) {
        if (n % 2 == 0)
            n /= 2;
        else
            n = n*3 + 1;

        ++count;
    }

    return count;
}

int main() {
    int max = 0, maxi;
    for (int i = 999999; i > 0; --i) {
        int s = sequence(i);
        if (s > max) {
            max = s;
            maxi = i;
        }
    }

    cout << maxi << endl;
}

Я знаю про оптимізацію компілятора, щоб підвищити швидкість і все, але я не бачу багато способів оптимізувати моє рішення для збірки далі (кажучи програмно не математично).

Код C ++ має модуль кожного терміна і ділить кожен парний термін, де збірка - це лише одне ділення на парний термін.

Але збірка займає в середньому на 1 секунду довше, ніж рішення C ++. Чому це? Я питаю здебільшого з цікавості.

Часи виконання

Моя система: 64-бітний Linux на 1,4 ГГц Intel Celeron 2955U (мікроархітектура Haswell).


232
Ви вивчили код складання, який GCC створює для вашої програми C ++?
ruakh

69
Компілюйте, -Sщоб отримати збірку, створену компілятором. Компілятор досить розумний, щоб зрозуміти, що модуль одночасно здійснює поділ.
user3386109

267
Я думаю, що ваші варіанти 1. Ваша техніка вимірювання є недоліком, 2. Компілятор записує кращу збірку, яку ви, або 3. Компілятор використовує магію.
Галик


18
@jefferson Компілятор може використовувати швидшу грубу силу. Наприклад, можливо, з інструкціями SSE.
користувач253751

Відповіді:


1896

Якщо ви вважаєте, що 64-розрядна інструкція DIV - це хороший спосіб розділити на два, то недарма вихід ASM компілятора обіграє ваш рукописний код навіть за допомогою -O0(компілювати швидко, без додаткової оптимізації та зберігання / перезавантаження в пам'ять після / перед кожним твердженням C, тому налагоджувач може змінювати змінні).

Дивіться посібник з оптимізації збірки Agner Fog, щоб дізнатися, як написати ефективну ASM. Він також має таблиці інструкцій та посібник з мікроарха для конкретних деталей для конкретних процесорів. Див. Також Тег Вікі для отримання додаткових посилань.

Дивіться також це більш загальне питання щодо побиття компілятора рукописним 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 не може цього зробити. Він все ще сумісний з тим, щоб робити кілька скалярних ns паралельно в різних цілих реєстрах.

Отже цикл може виглядати так:

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)


4
Я випробував векторизований підхід деякий час тому, це не допомогло (тому що ви можете зробити набагато краще в скалярному коді tzcntі ви заблоковані до найдовшої послідовності серед ваших векторних елементів у векторизованому випадку).
EOF

3
@EOF: ні, я мав на увазі вихід із внутрішньої петлі, коли потрапляє будь- який з векторних елементів 1, а не тоді, коли всі вони є (легко виявити за допомогою PCMPEQ / PMOVMSK). Потім ви використовуєте PINSRQ та інше, щоб поспілкуватися з одним елементом, який закінчився (та його лічильниками), і перейдіть назад у цикл. Це може легко перетворитись на втрату, коли ви вириваєтеся з внутрішньої петлі занадто часто, але це означає, що ви завжди отримуєте 2 або 4 елементи корисної роботи, виконані під час кожної ітерації внутрішнього циклу. Хоча гарний момент щодо запам'ятовування.
Пітер Кордес

4
@jefferson Кращим, яким я керував, є godbolt.org/g/1N70Ib . Я сподівався, що зможу зробити щось розумніше, але, здається, ні.
Ведрак

87
Те, що мене дивує неймовірними відповідями, такими як знання, показані настільки детально. Я ніколи не буду знати мову чи систему до цього рівня, і я не знаю як. Молодці, сер.
camden_kid

8
Легендарна відповідь !!
Суміт Джайн

104

Стверджувати, що компілятор C ++ може створити більш оптимальний код, ніж компетентний програміст з мовної збірки, є дуже поганою помилкою. І особливо в цьому випадку. Людина завжди може зробити код кращим, ніж може компілятор, і саме ця ситуація є хорошою ілюстрацією цього твердження.

Різниця в часі, яку ви бачите, полягає в тому, що код збірки у питанні дуже далекий від оптимального у внутрішніх петлях.

(Нижче наведений код є 32-розрядним, але його можна легко перетворити на 64-розрядний)

Наприклад, функцію послідовності можна оптимізувати лише до 5 інструкцій:

    .seq:
        inc     esi                 ; counter
        lea     edx, [3*eax+1]      ; edx = 3*n+1
        shr     eax, 1              ; eax = n/2
        cmovc   eax, edx            ; if CF eax = edx
        jnz     .seq                ; jmp if n<>1

Весь код виглядає так:

include "%lib%/freshlib.inc"
@BinaryType console, compact
options.DebugMode = 1
include "%lib%/freshlib.asm"

start:
        InitializeAll
        mov ecx, 999999
        xor edi, edi        ; max
        xor ebx, ebx        ; max i

    .main_loop:

        xor     esi, esi
        mov     eax, ecx

    .seq:
        inc     esi                 ; counter
        lea     edx, [3*eax+1]      ; edx = 3*n+1
        shr     eax, 1              ; eax = n/2
        cmovc   eax, edx            ; if CF eax = edx
        jnz     .seq                ; jmp if n<>1

        cmp     edi, esi
        cmovb   edi, esi
        cmovb   ebx, ecx

        dec     ecx
        jnz     .main_loop

        OutputValue "Max sequence: ", edi, 10, -1
        OutputValue "Max index: ", ebx, 10, -1

        FinalizeAll
        stdcall TerminateAll, 0

Для того, щоб скласти цей код, потрібен FreshLib .

У моїх тестах (1 ГГц процесор AMD A4-1200) вищевказаний код приблизно в чотири рази швидший, ніж код C ++ з питання (при складанні з -O0: 430 мс проти 1900 мс) і більш ніж у два рази швидший (430 мс проти 830 мс), коли компілюється код C ++ -O3.

Вихід обох програм однаковий: max послідовність = 525 на i = 837799.


6
Так, це розумно. SHR встановлює ZF, лише якщо EAX був 1 (або 0). Я пропустив це під час оптимізації -O3виходу gcc , але я помітив усі інші оптимізації, які ви зробили у внутрішній цикл. (Але навіщо ви використовуєте LEA для збільшення лічильника замість INC? Це нормально, щоб прати халатики в цей момент і призвести до уповільнення нічого, крім, можливо, P4 (помилкова залежність від старих прапорів як для INC, так і для SHR). LEA може ' t запускати стільки портів, і це може призвести до того, що конфлікти з ресурсами частіше затримують критичний шлях.)
Пітер Кордес

4
О, насправді Bulldozer може стати вузьким місцем у пропускній спроможності з висновком компілятора. Він має нижчу затримку CMOV і 3-компонентний LEA, ніж Haswell (що я розглядав), тому ланцюг депо, який переносять цикл, становить лише 3 цикли у вашому коді. Він також не має інструкцій MOV з нульовою затримкою для цілочисельних регістрів, тому витрачені інструкції MOV g ++ фактично збільшують затримку критичного шляху і є великою справою для бульдозера. Так, так, оптимізація рук насправді перемагає компілятор значною мірою для процесорів, які не надто сучасні, щоб пережовувати непотрібні інструкції.
Пітер Кордес

95
" Стверджувати, що компілятор C ++ краще - це дуже погана помилка. І особливо в цьому випадку. Людина завжди може зробити код кращим, щоб ця проблема була хорошою ілюстрацією цієї претензії. " Ви можете її відмінити, і це було б так само дійсно . « Що претендує на людському краще дуже погана помилка. І особливо в цьому випадку. Людина завжди може зробити код гірше , що і цей конкретний питанням є гарною ілюстрацією цього твердження. » Так що я не думаю , що у вас є точка тут , такі узагальнення неправильні.
luk32

5
@ luk32 - Але автор питання взагалі не може бути жодним аргументом, оскільки його знання асемблерної мови близьке до нуля. Кожен аргумент про компілятор людини проти людини неявно передбачає людину, яка має хоча б якийсь середній рівень знань про АСМ. Докладніше: Теорема "Письмовий код людини завжди буде кращою або такою ж, як і згенерований компілятором код", це дуже просто формально доведено.
Johnfound

30
@ luk32: Кваліфікована людина може (і зазвичай повинна) починати з виводу компілятора. Тому поки ви орієнтуєте свої спроби переконатися, що вони насправді швидші (на цільовому апаратному забезпеченні, яке ви налаштовуєте), ви не можете зробити гірше, ніж компілятор. Але так, я повинен погодитися, що це трохи сильне твердження. Компілятори зазвичай роблять набагато краще, ніж початкові кодери Asm. Але зазвичай можна зберегти інструкцію чи дві порівняно з тим, що придумують компілятори. (Не завжди на критичному шляху, хоча, залежно від uarch). Вони дуже корисні частини складної техніки, але вони не "розумні".
Пітер Кордес

24

Для більшої продуктивності: проста зміна полягає в тому, що після n = 3n + 1 n буде парним, тому ви можете розділити їх на 2 негайно. І n не буде 1, тому вам не потрібно тестувати його. Таким чином, ви можете зберегти кілька, якщо заяви і написати:

while (n % 2 == 0) n /= 2;
if (n > 1) for (;;) {
    n = (3*n + 1) / 2;
    if (n % 2 == 0) {
        do n /= 2; while (n % 2 == 0);
        if (n == 1) break;
    }
}

Ось великий виграш: якщо ви подивитеся на найнижчі 8 біт n, всі кроки, поки ви не розділилися на два вісім разів, повністю визначаються цими вісьмома бітами. Наприклад, якщо останні вісім біт дорівнюють 0x01, тобто у двійковій формі ваш номер дорівнює ???? 0000 0001, то наступними кроками є:

3n+1 -> ???? 0000 0100
/ 2  -> ???? ?000 0010
/ 2  -> ???? ??00 0001
3n+1 -> ???? ??00 0100
/ 2  -> ???? ???0 0010
/ 2  -> ???? ???? 0001
3n+1 -> ???? ???? 0100
/ 2  -> ???? ???? ?010
/ 2  -> ???? ???? ??01
3n+1 -> ???? ???? ??00
/ 2  -> ???? ???? ???0
/ 2  -> ???? ???? ????

Тож усі ці кроки можна передбачити, і 256k + 1 буде замінено на 81k + 1. Щось подібне відбудеться для всіх комбінацій. Таким чином, ви можете зробити цикл з великим оператором переключення:

k = n / 256;
m = n % 256;

switch (m) {
    case 0: n = 1 * k + 0; break;
    case 1: n = 81 * k + 1; break; 
    case 2: n = 81 * k + 1; break; 
    ...
    case 155: n = 729 * k + 425; break;
    ...
}

Виконайте цикл до n ≤ 128, тому що в цій точці n може стати 1 з меншою кількістю восьми ділень на 2, і робити вісім і більше кроків за один раз, ви змусите пропустити точку, де ви вперше досягнете 1. Потім продовжуйте цикл "звичайного" - або підготуйте таблицю, яка розповість, скільки ще кроків потрібно, щоб досягти 1.

PS. Я дуже підозрюю, що пропозиція Пітера Кордеса зробить це ще швидше. Умовних гілок взагалі не буде, окрім однієї, і ця буде спрогнозована правильно, за винятком випадків, коли цикл насправді закінчується. Тож код був би на кшталт

static const unsigned int multipliers [256] = { ... }
static const unsigned int adders [256] = { ... }

while (n > 128) {
    size_t lastBits = n % 256;
    n = (n >> 8) * multipliers [lastBits] + adders [lastBits];
}

На практиці ви могли б виміряти, чи швидше буде обробити останні 9, 10, 11, 12 біт n із часу. Для кожного біта кількість записів у таблиці збільшиться вдвічі, і я виявляю уповільнення, коли таблиці більше не вписуються в кеш L1.

PPS. Якщо вам потрібна кількість операцій: у кожній ітерації ми робимо рівно вісім ділень на дві та змінну кількість (3n + 1) операцій, тож очевидним методом підрахунку операцій був би інший масив. Але ми можемо фактично обчислити кількість кроків (виходячи з кількості ітерацій циклу).

Ми могли б трохи визначити проблему: замініть n на (3n + 1) / 2, якщо непарне, і замініть n на n / 2, якщо парне. Тоді кожна ітерація зробить рівно 8 кроків, але ви можете врахувати, що обман :-) Тож припустимо, що було r операцій n <- 3n + 1 і s операцій n <- n / 2. Результат буде цілком точно n '= n * 3 ^ r / 2 ^ s, тому що n <- 3n + 1 означає n <- 3n * (1 + 1 / 3n). Беручи логарифм, знаходимо r = (s + log2 (n '/ n)) / log2 (3).

Якщо ми зробимо цикл до n ≤ 1,000,000 і маємо заздалегідь обчислену таблицю, скільки ітерацій потрібно з будь-якої стартової точки n ≤ 1,000,000, то обчислення r як вище, округлене до найближчого цілого числа, дасть правильний результат, якщо s справді не велике.


2
Або складіть таблиці пошуку даних для множення і додайте константи замість комутатора. Індексація двох таблиць 256 записів швидше, ніж таблиця стрибків, і компілятори, ймовірно, не шукають такого перетворення.
Пітер Кордес

1
Хм, я на хвилину подумав, що це спостереження може довести гіпотезу Колатца, але ні, звичайно, ні. Для всіх можливих кінцевих 8 біт існує обмежена кількість кроків, поки всі вони не пройдуть. Але деякі з цих 8-бітових моделей подовжуватимуть решту бітових рядків більш ніж на 8, тому це не виключає необмеженого росту чи циклу, що повторюється.
Пітер Кордес

Для оновлення countпотрібен третій масив, правда? adders[]не говорить вам, скільки правових змін було зроблено.
Пітер Кордес

Для більших таблиць варто використовувати більш вузькі типи для збільшення щільності кешу. У більшості архітектур навантаження, що розширює нуль, uint16_tє дуже дешевою. На x86 це так само дешево, як нульове розширення від 32-бітного unsigned intдо uint64_t. (MOVZX з пам'яті на процесорах Intel потрібно тільки моп навантаження порту, але процесори AMD дійсно потребують АЛУ , а також.) Ах До речі, чому ви використовуєте size_tдля lastBits? Це 32-розрядний тип з -m32, і рівним -mx32(довгий режим з 32-бітовими вказівниками). Це, безумовно, неправильний тип n. Просто використовуйте unsigned.
Пітер Кордес

20

На досить непов’язану ноту: більше хакерських показників!

  • [перша «здогадка» остаточно розвінчана @ShreevatsaR; вилучено]

  • Під час проходження послідовності ми можемо отримати лише 3 можливі випадки у 2-сусідстві поточного елемента N(показано першим):

    1. [навіть дивно]
    2. [дивно навіть]
    3. [навіть] [навіть]

    Пропустити повз ці 2 елементи означає обчислити (N >> 1) + N + 1, ((N << 1) + N + 1) >> 1і N >> 2, відповідно.

    Давайте довести , що в обох випадках (1) і (2) можна використовувати першу формулу, (N >> 1) + N + 1.

    Випадок (1) очевидний. Випадок (2) передбачає (N & 1) == 1, тож якщо припустити (без втрати загальності), що N є 2-бітним і його біти baвід більшості до найменш значущої, тоді a = 1і наступне:

    (N << 1) + N + 1:     (N >> 1) + N + 1:
    
            b10                    b1
             b1                     b
           +  1                   + 1
           ----                   ---
           bBb0                   bBb

    де B = !b. Правильний зсув першого результату дає нам саме те, що ми хочемо.

    КЕД: (N & 1) == 1 ⇒ (N >> 1) + N + 1 == ((N << 1) + N + 1) >> 1.

    Як доведено, ми можемо перетинати послідовно 2 елементи одночасно, використовуючи одну потрійну операцію. Ще 2 × скорочення часу.

Отриманий алгоритм виглядає приблизно так:

uint64_t sequence(uint64_t size, uint64_t *path) {
    uint64_t n, i, c, maxi = 0, maxc = 0;

    for (n = i = (size - 1) | 1; i > 2; n = i -= 2) {
        c = 2;
        while ((n = ((n & 3)? (n >> 1) + n + 1 : (n >> 2))) > 2)
            c += 2;
        if (n == 2)
            c++;
        if (c > maxc) {
            maxi = i;
            maxc = c;
        }
    }
    *path = maxc;
    return maxi;
}

int main() {
    uint64_t maxi, maxc;

    maxi = sequence(1000000, &maxc);
    printf("%llu, %llu\n", maxi, maxc);
    return 0;
}

Тут ми порівнюємо, n > 2оскільки процес може зупинитися на 2 замість 1, якщо загальна довжина послідовності непарна.

[EDIT:]

Давайте переведемо це на збірку!

MOV RCX, 1000000;



DEC RCX;
AND RCX, -2;
XOR RAX, RAX;
MOV RBX, RAX;

@main:
  XOR RSI, RSI;
  LEA RDI, [RCX + 1];

  @loop:
    ADD RSI, 2;
    LEA RDX, [RDI + RDI*2 + 2];
    SHR RDX, 1;
    SHRD RDI, RDI, 2;    ror rdi,2   would do the same thing
    CMOVL RDI, RDX;      Note that SHRD leaves OF = undefined with count>1, and this doesn't work on all CPUs.
    CMOVS RDI, RDX;
    CMP RDI, 2;
  JA @loop;

  LEA RDX, [RSI + 1];
  CMOVE RSI, RDX;

  CMP RAX, RSI;
  CMOVB RAX, RSI;
  CMOVB RBX, RCX;

  SUB RCX, 2;
JA @main;



MOV RDI, RCX;
ADD RCX, 10;
PUSH RDI;
PUSH RCX;

@itoa:
  XOR RDX, RDX;
  DIV RCX;
  ADD RDX, '0';
  PUSH RDX;
  TEST RAX, RAX;
JNE @itoa;

  PUSH RCX;
  LEA RAX, [RBX + 1];
  TEST RBX, RBX;
  MOV RBX, RDI;
JNE @itoa;

POP RCX;
INC RDI;
MOV RDX, RDI;

@outp:
  MOV RSI, RSP;
  MOV RAX, RDI;
  SYSCALL;
  POP RAX;
  TEST RAX, RAX;
JNE @outp;

LEA RAX, [RDI + 59];
DEC RDI;
SYSCALL;

Використовуйте ці команди для компіляції:

nasm -f elf64 file.asm
ld -o file file.o

Дивіться С та вдосконалену / виправлену помилку версію asm Пітера Кордеса на Godbolt . (Примітка редактора: Вибачте за те, що я додав ваші речі до вашої відповіді, але моя відповідь перевищила обмеження 30 к.с. із посилань на Godbolt + текст!)


2
Інтегралу Qтакого немає 12 = 3Q + 1. Ваш перший пункт невірний, міркує.
Ведрак

1
@Veedrac: Ви з цим вирішувались: це може бути реалізовано з кращою задачею, ніж реалізація в цій відповіді, використовуючи ROR / TEST та лише одну CMOV. Цей код ASM нескінченно циклів на моєму процесорі, оскільки він, мабуть, покладається на OF, який не визначено після SHRD або ROR з кількістю> 1. Він також намагається уникнути mov reg, imm32, очевидно, щоб зберегти байти, але тоді він використовує 64-розрядна версія реєструється скрізь, навіть для xor rax, rax, тому в ній багато непотрібних префіксів REX. Очевидно, нам потрібен лише REX на регрес, що nзнаходиться у внутрішній петлі, щоб уникнути переповнення.
Пітер Кордес

1
Результати часу (від Core2Duo E6600: Merom 2,4 ГГц. Складний-LEA = 1c затримка, CMOV = 2c) . Найкраща реалізація внутрішньої петлі asm (від Johnfound): 111 мс за цикл цього циклу @main. Вихід компілятора з моєї знеціненої версії цього C (з деякими параметрами tmp): clang3.8 -O3 -march=core2: 96ms. gcc5.2: 108ms. З моєї вдосконаленої версії внутрішнього циклу зоні Кланг: 92 мс (слід побачити набагато більше покращення для сімейства SnB, де складний LEA становить 3c, а не 1c). З моєї вдосконаленої + робочої версії цього циклу asm (використовуючи ROR + TEST, а не SHRD): 87ms. Міряється 5 повтореннями перед друком
Пітер Кордес

2
Ось перші 66 записувачів (A006877 на OEIS); Я виділив жирні шрифти: 2, 3, 6, 7, 9, 18, 25, 27, 54, 73, 97, 129, 171, 231, 313, 327, 649, 703, 871, 1161, 2223, 2463, 2919, 3711, 6171, 10971, 13255, 17647, 23529, 26623, 34239, 35655, 52527, 77031, 106239, 142587, 156159, 216367, 230631, 410011, 511935, 626331, 837799, 1117065, 15013535 1723519, 2298025, 3064033, 3542887, 3732423, 5649499, 6649279, 8400511, 11200681, 14934241, 15733191, 31466382, 36791535, 63728127, 127456254, 169941673, 226588897, 268549803, 537099606, 670617279, 1341234558
ShreevatsaR

1
@hidefromkgb Чудово! І я ще більше оцінюю ваш інший пункт: 4k + 2 → 2k + 1 → 6k + 4 = (4k + 2) + (2k + 1) + 1, і 2k + 1 → 6k + 4 → 3k + 2 = ( 2k + 1) + (k) + 1. Приємне спостереження!
ShreevatsaR

6

Програми C ++ переводяться на складові програми під час генерації машинного коду з вихідного коду. Буде практично неправильно сказати, що складання повільніше, ніж C ++. Більше того, генерований двійковий код відрізняється від компілятора до компілятора. Тож розумний компілятор C ++ може створити двійковий код більш оптимальним та ефективним, ніж немовший код асемблера.

Однак я вважаю, що ваша методологія профілювання має певні вади. Нижче наведено загальні вказівки щодо профілювання:

  1. Переконайтесь, що ваша система знаходиться в нормальному / непрацюючому стані. Зупиніть усі запущені процеси (програми), які ви розпочали, або які інтенсивно використовують процесор (або опитування по мережі).
  2. Ваш розмір даних повинен бути більшим за розміром.
  3. Ваш тест повинен тривати щось більше 5-10 секунд.
  4. Не покладайтеся лише на один зразок. Виконайте тест N разів. Зберіть результати та обчисліть середню чи медіану результату.

Так, я не робив жодного офіційного профілювання, але я запускав їх обидва рази і вмію говорити 2 секунди за 3 секунди. Все одно дякую за відповідь. Я вже тут зібрав багато інформації
син Jeffer

9
Це, мабуть, не просто помилка вимірювання, рукописний код ASM використовує 64-бітну інструкцію DIV замість правого зсуву. Дивіться мою відповідь. Але так, правильне вимірювання теж важливо.
Пітер Кордес

7
Точки кулі є більш відповідним форматуванням, ніж блок коду. Будь ласка, перестаньте вводити текст у блок коду, оскільки це не код і не має користі від одношарового шрифту.
Пітер Кордес

16
Я не бачу, як це відповідає на питання. Це не розпливчасте питання про те, чи може бути збірний чи C ++ код швидшим --- це дуже специфічне питання про фактичний код , який він корисно надав у самому питанні. Ваша відповідь навіть не згадує жодного з цих кодів або будь-якого типу порівняння. Звичайно, ваші поради щодо тестування базування є правильними, але недостатньо для того, щоб реально відповісти.
Коді Грей

6

Для проблеми Collatz ви можете досягти значного підвищення продуктивності, кешуючи "хвости". Це компенсація часу / пам’яті. Див.: Пам ятка ( https://en.wikipedia.org/wiki/Переміщення ). Ви також можете розглянути динамічні рішення для програмування для інших компромісів часу та пам'яті.

Приклад реалізації пітона:

import sys

inner_loop = 0

def collatz_sequence(N, cache):
    global inner_loop

    l = [ ]
    stop = False
    n = N

    tails = [ ]

    while not stop:
        inner_loop += 1
        tmp = n
        l.append(n)
        if n <= 1:
            stop = True  
        elif n in cache:
            stop = True
        elif n % 2:
            n = 3*n + 1
        else:
            n = n // 2
        tails.append((tmp, len(l)))

    for key, offset in tails:
        if not key in cache:
            cache[key] = l[offset:]

    return l

def gen_sequence(l, cache):
    for elem in l:
        yield elem
        if elem in cache:
            yield from gen_sequence(cache[elem], cache)
            raise StopIteration

if __name__ == "__main__":
    le_cache = {}

    for n in range(1, 4711, 5):
        l = collatz_sequence(n, le_cache)
        print("{}: {}".format(n, len(list(gen_sequence(l, le_cache)))))

    print("inner_loop = {}".format(inner_loop))

1
Відповідь gnasher показує, що ви можете зробити набагато більше, ніж просто кешувати хвости: високі біти не впливають на те, що станеться далі, а додавання / муль лише розповсюджує перенесення вліво, тому високі біти не впливають на те, що відбувається з низькими бітами. тобто ви можете використовувати пошук LUT, щоб пройти 8 (або будь-яке число) бітів одночасно, помножити та додати константи, що застосовуються до решти бітів. запам'ятовування хвостів, безумовно, корисно для багатьох подібних проблем, і для цієї проблеми, коли ви ще не думали про кращий підхід або не довели це правильність.
Пітер Кордес

2
Якщо я правильно розумію ідею гнашера, я вважаю, що пам'ять хвоста є ортогональною оптимізацією. Так ви могли б зробити і те, і інше. Було б цікаво дослідити, скільки ви могли б отримати від додавання запам'ятовування до алгоритму gnasher.
Емануель Ландегольм

2
Ми можемо зробити запам'ятовування дешевшим, зберігаючи лише частину результатів. Встановіть верхню межу на N, а вище, навіть не перевіряйте пам’ять. Нижче, використовуйте хеш (N) -> N в якості хеш-функції, тому key = положення в масиві, і його не потрібно зберігати. Введення 0засобів ще немає. Ми можемо додатково оптимізувати, зберігаючи непарні N у таблиці, тому хеш-функція полягає в тому n>>1, щоб відкинути 1. Напишіть крок-код, щоб завжди закінчувався символом a n>>tzcnt(n)або що-небудь, щоб переконатися, що це непарно.
Пітер Кордес

1
Це ґрунтується на моїй (неперевіреній) ідеї, що дуже великі N значення в середині послідовності рідше є спільними для декількох послідовностей, тому ми не пропускаємо занадто багато, щоб не запам'ятати їх. Крім того, що N з розумним розміром буде частиною багатьох довгих послідовностей, навіть тих, що починаються з дуже великої N. (Це може бути бажаним думкою; якщо це неправильно, тоді лише кешування щільного діапазону послідовних N може втратити проти хешу таблицю, в якій можна зберігати довільні ключі.) Чи проводили ви якісь тести на швидкість потрапляння, щоб дізнатись, чи має поблизу початковий N, як правило, схожість у значеннях їх послідовності?
Пітер Кордес

2
Ви можете просто зберігати заздалегідь обчислені результати для всіх n <N, для деяких великих N. Так що вам не потрібні накладні витрати хеш-таблиці. Дані в цій таблиці з часом будуть використані для кожного початкового значення. Якщо ви просто хочете підтвердити, що послідовність Collatz завжди закінчується в (1, 4, 2, 1, 4, 2, ...): Це може бути доведено як рівнозначне доказуванню, що для n> 1 послідовність з часом бути меншим за початковий n. І для цього кешування хвостів не допоможе.
gnasher729

5

З коментарів:

Але цей код ніколи не припиняється (через ціле переповнення)!?! Ів Дауст

Для багатьох номерів він не переповниться.

Якщо він буде переливатися - одне з тих невдачливих початкових насіння, переповнене число буде досить імовірно , сходиться до 1 без іншого переповнення.

Все ж це ставить цікаве запитання, чи є якась кількість пересівного циклічного насіння?

Будь-яка проста кінцева збіжна серія починається з потужності двох значень (достатньо очевидно?).

2 ^ 64 переповниться до нуля, що є невизначеним нескінченним циклом згідно алгоритму (закінчується лише 1), але найбільш оптимальне рішення у відповідь закінчиться завдяки shr raxвиробленню ZF = 1.

Чи можемо ми отримати 2 ^ 64? Якщо початкове число - 0x5555555555555555це непарне число, наступне число - 3n + 1, тобто 0xFFFFFFFFFFFFFFFF + 1= 0. Теоретично в невизначеному стані алгоритму, але оптимізована відповідь johnfound відновиться шляхом виходу на ZF = 1. cmp rax,1Пітера Корда закінчиться в нескінченному циклі (КЕД варіант 1, «Снеаре» через невизначене 0число).

Як щодо більш складного числа, яке створить цикл без 0? Чесно кажучи, я не впевнений, моя теорія математики занадто туманна, щоб отримати будь-яку серйозну думку, як серйозно поводитися з цим. Але інтуїтивно я б сказав, що ряд зблизиться до 1 для кожного числа: 0 <число, оскільки формула 3n + 1 повільно чи рано перетворює кожен не-2 простий коефіцієнт вихідного числа (або проміжного) на деяку потужність 2 . Тому нам не потрібно турбуватися про нескінченну петлю для оригінальних серій, лише переповнення може перешкодити нам.

Тому я просто поклав кілька аркушів в аркуш і переглянув 8-бітні усічені числа.

Є три значення , що виходить за межами , щоб 0: 227, 170і 85( 85відбувається безпосередньо 0, інші два прогресуючі в стороні 85).

Але немає значення створювати циклічне насіння переповнення.

Як не дивно, я зробив перевірку, яка є першим номером, який страждає від 8-бітового усікання, і вже 27впливає! Це дійсне значення 9232для правильного несеченого ряду (перше усічене значення знаходиться 322на 12-му кроці), а максимальне значення, досягнуте для будь-якого з 2-255 вхідних чисел несеченим способом, є 13120(для 255себе), максимальна кількість кроків для зближення до 1приблизно 128(+ -2, не впевнений, чи слід рахувати "1" тощо).

Цікаво, що (для мене) кількість 9232є максимальним для багатьох інших вихідних номерів, що ж у ньому такого особливого? : -O 9232= 0x2410... хммм .. поняття не маю.

На жаль, я не можу глибоко зрозуміти цю серію, чому вона конвергується та які наслідки обрізання їх до k бітів, але, cmp number,1припиняючи умову, безумовно, можна поставити алгоритм у нескінченний цикл із конкретним вхідним значенням, що закінчується як 0після усічення.

Але значення, що 27переповнюється для 8-бітового регістру, є свого роду попередженням. Це виглядає так, що якщо підрахувати кількість кроків до досягнення значення 1, ви отримаєте неправильний результат для більшості чисел із загального набору k-бітів цілих чисел. Для 8-бітових цілих чисел 146 від 256 постраждали від урізання рядів (деякі з них все-таки можуть випадково потрапити на правильну кількість кроків, можливо, я лінивий перевірити).


"номер перекидання дуже ймовірно сходиться до 1 без іншого переповнення": код ніколи не припиняється. (Це здогадка, оскільки я не можу дочекатися кінця разів, щоб бути впевненим ...)
Ів Дауст

@YvesDaoust о, але так? ... наприклад, 27серія з 8b усіченням виглядає приблизно так: 82 41 124 62 31 94 47 142 71 214 107 66 (усічена) 33 100 50 25 76 38 19 58 29 88 44 22 11 34 17 52 26 13 40 20 10 5 16 8 4 2 1 (решта працює без усікання). Я не розумію тебе, вибач. Він ніколи не зупиниться, якщо усічене значення буде рівним деякому раніше досягнутому в поточній серії, і я не можу знайти таке значення проти k-бітового усікання (але я не можу зрозуміти теорію математики позаду, чому це стосується 16/32/64 біт усічення, просто інтуїтивно я думаю, що це працює).
Пед7г

1
Я повинен був перевірити початковий опис проблеми раніше: "Хоча це ще не було доведено (проблема Колаца), вважається, що всі початкові числа закінчуються на рівні 1". ... добре, не дивно , що я не можу зрозуміти його з моїми обмеженим туманною Math знаннями ...: D А з моїх листових експериментів я можу запевнити вас , що робить сходитися для кожних 2- 255чисел, або без усічення (до 1), або з 8-бітовим усіченням (або передбачуваним, 1або 0для трьох чисел).
Пед7г

Хем, коли я кажу, що це ніколи не зупиняється, я маю на увазі ... що не зупиняється. Даний код працює назавжди, якщо вам зручніше.
Ів Дауст

1
Запропоновано для аналізу того, що відбувається при переливі. Цикл на основі CMP може використовувати cmp rax,1 / jna(тобто do{}while(n>1)) для завершення на нулі. Я думав про створення інструментальної версії циклу, що записує максимум nбаченого, щоб дати уявлення про те, наскільки близько ми підходимо до переповнення.
Пітер Кордес

5

Ви не опублікували код, згенерований компілятором, тому тут є деякі здогадки, але навіть не побачивши цього, можна сказати, що це:

test rax, 1
jpe even

... є 50% шансу неправильно передбачити галузь, і це вийде дорого.

Компілятор майже напевно робить обидва обчислення (що коштує зневажливо дорожче, оскільки div / mod досить тривалий затримки, тому множення-додавання є "безкоштовним") і веде за допомогою CMOV. Що, звичайно, має нульовий відсоток шансу бути непередбачуваним.


1
Існує деяка закономірність розгалуження; наприклад, непарне число завжди супроводжується парним числом. Але іноді 3n + 1 залишає кілька кратних нульових бітів, і саме тоді це буде неправильно передбачати. Я почав писати про поділ у своїй відповіді, і не звертався до цього іншого великого червоного прапора в коді ОП. (Зауважте також, що використання умови паритету дійсно дивно, порівняно лише з JZ або CMOVZ. Це також гірше для процесора, оскільки процесори Intel можуть макрозмикати TEST / JZ, але не TEST / JPE. Agner Fog каже, що AMD може запобігти будь-якому TEST / CMP з будь-яким СКК, тож у цьому випадку це лише гірше для людських читачів)
Пітер Кордес

5

Навіть не дивлячись на збірку, найбільш очевидною причиною є те, що /= 2, ймовірно, оптимізовано, оскільки >>=1багато процесори мають дуже швидку роботу в зміну. Але навіть якщо процесор не має операції зсуву, ціле ділення швидше, ніж поділ з плаваючою комою.

Редагувати: ваш пробіг може змінюватися залежно від заяви "ціле ділення швидше, ніж поділ з плаваючою комою". У коментарях нижче видно, що сучасні процесори віддали перевагу оптимізації поділу fp перед цілим поділом. Тож якщо хтось шукав найімовірнішу причину прискорення, про яке задається питання цього потоку, то компілятор, оптимізуючи /=2як >>=1було б найкраще перше місце.


На непов’язаній ноті , якщо nвона непарна, вираз n*3+1завжди буде парним. Тож не потрібно перевіряти. Ви можете змінити цю гілку на

{
   n = (n*3+1) >> 1;
   count += 2;
}

Тож вся заява була б тоді

if (n & 1)
{
    n = (n*3 + 1) >> 1;
    count += 2;
}
else
{
    n >>= 1;
    ++count;
}

4
Ділення цілочисень насправді не швидше, ніж поділ FP на сучасних процесорах x86. Я думаю, що це пов’язано з тим, що Intel / AMD витрачає більше транзисторів на свої подільники FP, адже це важливіша операція. (Ціле ділення на константи можна оптимізувати до множення на модульну обернену). Перевірте таблиці невідповідності Agner Fog та порівняйте DIVSD (плаваюча подвійна точність) із DIV r32(32-бітним цілим числом без підпису) або DIV r64(набагато повільнішим 64-бітним цілим числом без підпису). Особливо для пропускної здатності поділ ПС набагато швидший (одинарний взагалі замість мікрокодированного та частково конвеєрного), але затримка також краща.
Пітер Кордес

1
наприклад, на процесорі Haswell ОП: DIVSD - це 1, затримка 10-20 циклів, один на пропускну здатність 8-14с. div r64це 36 уп, затримка 32-96 ° С і одна на пропускну здатність 21-74 ° С. Skylake має навіть більш швидку пропускну здатність FP поділу (конвеєрний одиниця на 4c з не набагато кращою затримкою), але не набагато швидший цілий діл. Речі схожі на AMD-сімейство бульдозерів: DIVSD - 1 М-оп, затримка 9-27 ° С, одна на пропускну здатність 4,5-11с. div r64становить 16 М-оп, затримка 16-75 ° С, одна на пропускну здатність 16-75 ° С.
Пітер Кордес

1
Чи не поділ ПП в основному такий самий, як цілі чисельні віднімання, цілі-ділити мантіси, виявляти деннормали? І ці 3 кроки можна зробити паралельно.
MSalters

2
@MSalters: так, це звучить правильно, але з кроком нормалізації в кінці відміни бітів між експонентом і мантією. doubleмає 53-бітну мантісу, але вона все ще значно повільніше, ніж div r32у Haswell. Тож, безумовно, лише питання про те, скільки апаратних процесів Intel / AMD кидають на проблему, оскільки вони не використовують однакові транзистори як для цілих, так і для fp-роздільників. Ціле число є скалярним (немає поділу на ціле число-SIMD), а вектор один обробляє вектори 128b (а не 256b, як інші векторні АЛУ). Велика річ у тому, що цілі діви мають багато Uops, великий вплив на навколишній код.
Пітер Кордес

Помилка, не зміщуйте біти між мантісою і експонентом, а нормалізуйте мантісу зі зсувом і додайте кількість зсуву до експонента.
Пітер Кордес

4

Як загальна відповідь, спеціально не спрямована на це завдання: У багатьох випадках ви можете значно прискорити будь-яку програму, вдосконаливши її на високому рівні. Як і обчислення даних один раз замість декількох разів, уникнення непотрібної роботи повністю, використання кешів найкращим чином тощо. Ці речі набагато простіше зробити мовою високого рівня.

Написавши код асемблера, можна покращити те, що робить компілятор, що оптимізує, але це важка робота. І як тільки це буде зроблено, ваш код набагато важче змінити, тому додати алгоритмічні вдосконалення набагато складніше. Іноді в процесорі є функціонал, який ви не можете використовувати з мови високого рівня, вбудована вбудована програма часто корисна в цих випадках і все ж дозволяє використовувати мову високого рівня.

У проблемах Ейлера в більшості випадків ви досягаєте успіху, будуючи щось, знаходячи, чому це повільно, будувати щось краще, знаходити, чому це повільно, і так далі, і так далі. Це дуже і дуже важко за допомогою асемблера. Кращий алгоритм на половині можливої ​​швидкості зазвичай перемагає гірший алгоритм на повній швидкості, і отримання повної швидкості в асемблері не є тривіальним.


2
Повністю згоден з цим. gcc -O3зробив код, який був у межах 20% від оптимального для Хасвелла, саме для цього алгоритму. (Отримання цих прискорень було головним фокусом моєї відповіді лише тому, що саме на це питання було задано питання, і вона має цікаву відповідь, а не тому, що це правильний підхід.) Набагато більші прискорення були отримані за допомогою перетворень, на які компілятор не міг би шукати. , як, наприклад, відміняти правильні зміни або робити 2 кроки одночасно. Набагато більші прискорення, ніж це можна зробити з таблиць запам'ятовування / пошуку. Досі вичерпне випробування, але не чиста груба сила.
Пітер Кордес

2
Але, маючи просту реалізацію, очевидно правильну, надзвичайно корисно для тестування інших реалізацій. Що я б робив - це, мабуть, просто подивитися на вихід ASM, щоб побачити, чи gcc зробив це без гілок, як я очікував (здебільшого з цікавості), а потім перейти до алгоритмічних удосконалень.
Пітер Кордес

-2

Проста відповідь:

  • робити MOV RBX, 3 та MUL RBX дорого; просто додати RBX, RBX двічі

  • ADD 1, ймовірно, швидше, ніж INC тут

  • MOV 2 і DIV дуже дорогі; просто змініть праворуч

  • 64-бітний код зазвичай помітно повільніше, ніж 32-розрядний код, а питання вирівнювання складніші; з такими невеликими програмами, як ви, вам доведеться їх упакувати, щоб ви робили паралельні обчислення, щоб мати шанс бути швидшим, ніж 32-розрядний код

Якщо ви генеруєте список складання для вашої програми C ++, ви можете бачити, чим він відрізняється від вашої збірки.


4
1): додавання в 3 рази було б німим в порівнянні з LEA. Також mul rbxна процесорі Haswell OP є 2 упи з 3c затримкою (і 1 на тактову пропускну здатність). imul rcx, rbx, 3становить лише 1 узагалі, з такою ж затримкою 3с. Дві інструкції ADD будуть 2 уп з затримкою 2c.
Пітер Кордес

5
2) ADD 1, ймовірно, швидше, ніж INC тут . Ні, OP не використовує Pentium4 . Ваш пункт 3) - єдина правильна частина цієї відповіді.
Пітер Кордес

5
4) звучить як тотальна нісенітниця. 64-бітний код може бути повільнішим для важких структур даних, оскільки більші вказівники означають більший слід кешу. Але цей код працює лише в регістрах, а проблеми вирівнювання коду однакові у 32 та 64 бітовому режимі. (Так виникають проблеми з вирівнюванням даних. Немає поняття, про що ви говорите, якщо вирівнювання є більшою проблемою для x86-64). У будь-якому випадку код навіть не торкається пам'яті всередині циклу.
Пітер Кордес

Коментоване поняття не має, про що йдеться. Зробити MOV + MUL на 64-бітному процесорі буде приблизно втричі повільніше, ніж додати реєстр до себе вдвічі. Інші його зауваження однаково неправильні.
Тайлер Дерден

6
Ну MOV + MUL, безумовно, німий, але MOV + ADD + ADD все ще нерозумно (насправді робити ADD RBX, RBXдва рази було б помножено на 4, а не на 3). На сьогодні найкращий спосіб lea rax, [rbx + rbx*2]. Або ціною, щоб зробити його 3-компонентним LEA, зробіть +1 також з lea rax, [rbx + rbx*2 + 1] (3с затримки на HSW замість 1, як я пояснив у своїй відповіді). Моя думка полягала в тому, що множення 64 біт не дуже дороге останні процесори Intel, оскільки вони мають божевільно швидкі цілочисельні множинні одиниці (навіть порівняно з AMD, де MUL r64це 6c затримка, одна на 4c пропускна здатність: навіть не повністю конвеєрована.
Peter Cordes
Використовуючи наш веб-сайт, ви визнаєте, що прочитали та зрозуміли наші Політику щодо файлів cookie та Політику конфіденційності.
Licensed under cc by-sa 3.0 with attribution required.