Dlaczego kod C++ do testowania domysłów Collatza działa szybciej niż ręcznie napisany assembly?

Napisałem te dwa rozwiązania dla projektu Euler Q14 , w assembly i w C++. Implementują one identyczne podejście brute force do testowania hipotezy Collatza. Rozwiązanie montażowe zostało zmontowane z:

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

C++ został skompilowany z:

g++ p14.cpp -o p14

Montaż, 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>

int sequence(long n) {
    int count = 1;
    while (n != 1) {
        if (n % 2 == 0)
            n /= 2;
        else
            n = 3*n + 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;
        }
    }
    std::cout << maxi << std::endl;
}

Wiem o optymalizacji kompilatora, aby poprawić szybkość i w ogóle, ale nie widzę wielu sposobów na dalszą optymalizację mojego rozwiązanie montażowe (mówiąc programowo, nie matematycznie).

Kod C++ używa modułu co drugi termin i dzielenia co drugi termin, podczas gdy kod asemblera używa tylko jednego podziału co drugi termin.

Ale montaż trwa średnio 1 sekundę dłużej niż rozwiązanie C++. Dlaczego? Pytam głównie z ciekawości.

Czas wykonania

Mój system: 64-bitowy Linux na 1,4 GHz Intel Celeron 2955U (Haswell mikroarchitektura).

  • g++ (nieoptimized): avg 1272 ms.
  • g++ -O3: avg 578 ms.
  • asm (div) (oryginał): avg 2650 ms.
  • asm (shr): avg 679 ms.
  • @johnfound asm (zmontowany z NASM): avg 501 ms.
  • @hidefromkgb asm: avg 200 ms.
  • W związku z tym, że nie jest to możliwe, nie jest to możliwe.]}
  • @Veedrac C++: avg 81 ms z -O3, 305 ms z -O0.
Author: ib., 2016-11-01

11 answers

Jeśli uważasz, że 64-bitowa Instrukcja DIV jest dobrym sposobem na dzielenie przez dwa, to nic dziwnego, że wyjście ASM kompilatora bije Twój ręcznie napisany kod, nawet z -O0 (Szybka kompilacja, bez dodatkowej optymalizacji i przechowywanie / przeładowanie do pamięci po / przed każdą instrukcją C, aby debugger mógł modyfikować zmienne).

Aby dowiedzieć się, jak pisać wydajne asm, Zobacz Przewodnik Agner Fog ' s Optimizing Assembly guide. Posiada również tabele instrukcji i przewodnik mikroarchitektury dla konkretnych procesorów. Zobacz też również x86 tag wiki dla więcej linków perf.

Zobacz także bardziej ogólne Pytanie o pokonanie kompilatora z ręcznie pisanym asm: czy język asemblera jest wolniejszy niż natywny kod C++?. TL: DR: tak, jeśli robisz to źle(jak to pytanie).

Zazwyczaj pozwalasz kompilatorowi robić swoje, szczególnie jeśli próbujesz napisać C++, który potrafi kompilować efektywnie . Zobacz też czy assembly jest szybszy niż skompilowane języki?. Jeden z odpowiedzi linki do te schludne slajdy pokazujące, jak różne kompilatory C optymalizują niektóre naprawdę proste funkcje za pomocą fajnych sztuczek. Matt Godbolt ' s CppCon2017 talk "co mój kompilator zrobił dla mnie ostatnio? W podobny sposób można rozpinać pokrywę kompilatora.


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

Na Intel Haswell, div r64 jest to 36 uops, z opóźnieniem 32-96 cykli, a przepustowość jednego na 21-74 cykle. (Plus 2 uops do Ustawienia RBX i zero RDX, ale out-of-order execution może uruchomić te wcześniej). instrukcje o wysokiej liczbie uop, takie jak DIV, są mikrokodowane, co może również powodować wąskie gardła w interfejsie. w tym przypadku opóźnienie jest najistotniejszym czynnikiem, ponieważ jest częścią łańcucha zależności przenoszonego w pętli.

shr rax, 1 wykonuje ten sam niepodpisany podział: jest to 1 uop, z opóźnieniem 1c i może działać 2 na cykl zegara.

Dla porównania, podział 32-bitowy jest szybszy, ale nadal straszny vs.przesunięcia. idiv r32 is 9 uops, 22-29C opóźnienie, i jeden na przepustowość 8-11c na Haswell.


Jak widać patrząc na wyjście gcc -O0 asm (Godbolt compiler explorer), używa on tylko instrukcji przesunięcia. clang -O0 kompiluje naiwnie, tak jak myślałeś, nawet używając 64-bitowego IDIV dwa razy. (Podczas optymalizacji Kompilatory używają obu wyjść IDV, gdy źródło wykonuje podział i moduł z tymi samymi operandami, jeśli w ogóle używają IDV)

GCC nie ma całkowicie naiwnego trybu; zawsze transformuje się przez GIMPLE, co oznacza, że niektóre "optymalizacje" nie mogą być wyłączone. W celu uniknięcia IDIV (zobacz div_by_13w powyższym linku godbolt), należy używać przesunięć (potęga 2) lub mnożnika o stałym punkcie (Nie potęga 2).

gcc -Os (optimize for size) czy używa IDIV dla podziału nie-power-of-2, niestety nawet w przypadkach, gdy multiplikatywny kod odwrotny jest tylko nieco większy, ale znacznie szybciej.


Pomoc kompilator

(Streszczenie dla tego przypadku: użyj uint64_t n)

Po pierwsze, interesujące jest tylko spojrzenie na zoptymalizowane wyjście kompilatora. (-O3). -O0 prędkość jest w zasadzie bez znaczenia.

Spójrz na wyjście asm (na Godbolt, lub zobacz jak usunąć "szum" z wyjścia montażowego GCC/clang?). Gdy kompilator nie tworzy optymalnego kodu: pisanie źródła C / C++ w taki sposób, że prowadzi kompilator do tworzenia lepszego kodu jest zwykle najlepszym podejściem . Musisz znać asm i wiedzieć, co jest skuteczne, ale wykorzystujesz tę wiedzę pośrednio. Kompilatory są również dobrym źródłem pomysłów: czasami clang zrobi coś fajnego i możesz trzymać gcc ręcznie, aby zrobić to samo: zobacz tę odpowiedź i to, co zrobiłem z nie rozwijaną pętlą w kodzie @ Veedrac poniżej.)

To podejście jest przenośne i za 20 lat jakiś przyszły kompilator może je skompilować do tego, co jest wydajne na przyszłym sprzęcie (x86 lub nie), może używając nowego rozszerzenia ISA lub auto-wektoryzacji. Ręcznie napisany x86-64 asm sprzed 15 lat zazwyczaj nie byłby optymalnie dostrojony do Skylake. np. compare&branch makro-Fusion wtedy nie istniało. to, co jest teraz optymalne dla ręcznie tworzonego asm dla jednej mikroarchitektury, może nie być optymalne dla innych obecnych i przyszłych procesorów. komentarze do odpowiedzi @ johnfound omów główne różnice między AMD Bulldozer i Intel Haswell, które mają duży wpływ na ten kod. Ale teoretycznie, g++ -O3 -march=bdver3 i g++ -O3 -march=skylake postąpią słusznie. (Lub -march=native.) Lub -mtune=... po prostu dostroić, bez użycia instrukcji, których inne procesory mogą nie obsługiwać.

Mam wrażenie, że kierowanie kompilatora do asm, który jest dobry dla obecnego procesora, na którym ci zależy, nie powinno być problemem dla przyszłych kompilatorów. Mają nadzieję, że są lepsi od obecnych kompilatorów w znajdowaniu sposobów na transformację kodu i mogą znaleźć sposób, który działa na przyszłe Procesory. Niezależnie od tego, przyszły x86 prawdopodobnie nie będzie straszny w niczym, co jest dobre na obecnym x86, a przyszły kompilator uniknie żadnych pułapek specyficznych dla asm podczas implementacji czegoś takiego jak ruch danych ze źródła C, jeśli nie zobaczy czegoś lepszego.

Ręcznie napisany asm jest czarną skrzynką dla optymalizatora, więc propagacja stała nie działa, gdy inlining sprawia, że wejście staje się stałą kompilacji. Dotyczy to również innych optymalizacji. Czytaj https://gcc.gnu.org/wiki/DontUseInlineAsm przed użyciem asm. (I unikaj wbudowanego asm W Stylu MSVC: wejścia/wyjścia muszą przejść przez pamięć , która dodaje napowietrzne.)

W tym przypadku: Twój n mA typ signed, a gcc używa sekwencji SAR/SHR / ADD, która daje prawidłowe zaokrąglenie. (IDIV i arytmetyka-przesunięcie "okrągłe" inaczej dla ujemnych wejść, patrz SAR insn set ref manual entry ). (IDK jeśli gcc próbował i nie udowodnił, że n nie może być negatywna. Signed-overflow jest niezdefiniowanym zachowaniem, więc powinno być w stanie.)

Powinieneś użyć uint64_t n, więc może po prostu SHR. Jest więc przenośny do systemów, w których long jest tylko 32-bitowy (np. Windows x86-64).


BTW, GCC ' s optimized ASM output looks pretty good (using unsigned long n): pętla wewnętrzna, do której wkracza main(), robi to:

 # 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

Pętla wewnętrzna jest bezgałęziowa, a ścieżka krytyczna pętli-niesiona łańcuch zależności To:

  • 3-składowa LEA (3 cykle)
  • cmov (2 cykle na Haswella, 1C na Broadwella lub później).

Łącznie: 5 cykli na iterację, wąskie gardło opóźnienia . Out-of-order execution zajmuje się wszystkim innym równolegle z tym (w teorii: nie testowałem z licznikami perf, aby sprawdzić, czy naprawdę działa w 5c / iter).

Wejście FLAGS z cmov (produkowane przez TEST) jest szybsze do wytworzenia niż wejście RAX (z Lea->MOV), więc nie jest na ścieżce krytycznej.

Podobnie MOV - > SHR, który wytwarza wejście RDI CMOV, jest poza ścieżką krytyczną, ponieważ jest również szybszy niż LEA. MOV na IvyBridge i Później ma zerowe opóźnienie(obsługiwane w czasie register-rename). (Nadal wymaga uop i gniazda w potoku, więc nie jest wolny, tylko zerowe opóźnienie). Dodatkowy MOV w łańcuchu Lea dep jest częścią wąskiego gardła na innych procesorach.

Cmp / jne nie jest również częścią ścieżki krytycznej: nie jest przenoszona w pętli, ponieważ zależności kontrolne są obsługiwane za pomocą Branch prediction + speculative execution, w przeciwieństwie do zależności danych na ścieżce krytycznej.


Pokonanie kompilatora

GCC odwaliło kawał dobrej roboty. Może zapisać jeden bajt kodu za pomocą inc edx zamiast add edx, 1, ponieważ nikt nie dba o P4 i jego fałszywe zależności Dla instrukcji częściowej modyfikacji flagi.

Może również zapisać wszystkie instrukcje MOV, a TEST: SHR ustawia CF= przesunięty bit out, więc możemy użyć cmovc zamiast 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)

Zobacz odpowiedź @ johnfound na kolejny sprytny trik: Usuń CMP przez rozgałęzianie na wyniku flagi SHR, a także użycie go dla CMOV: zero tylko jeśli N było 1 (lub 0) Na początek. (Ciekawostka: SHR z hrabią != 1 Na Nehalem lub wcześniej powoduje stall, jeśli odczytasz wyniki flagi . W ten sposób powstał single-uop. Kodowanie specjalne shift-by-1 jest jednak w porządku.)

Unikanie MOV nie pomaga w opóźnienie w ogóle na Haswell (czy MOV x86 naprawdę może być "wolny"? Dlaczego nie mogę tego odtworzyć?). To pomaga znacząco na procesorach takich jak Intel pre-IvB i AMD Bulldozer-family, gdzie MOV nie ma zerowego opóźnienia. Zmarnowane instrukcje MOV kompilatora wpływają na ścieżkę krytyczną. Złożone BD - Lea I CMOV mają mniejsze opóźnienie (odpowiednio 2C i 1C), więc jest to większy ułamek opóźnienia. Problemem stają się również wąskie gardła przepustowości, ponieważ mają one tylko dwa rury integer ALU. Zobacz odpowiedź @johnfound , gdzie ma Wyniki pomiaru czasu z procesora AMD.

Nawet w przypadku Haswella, ta wersja może nieco pomóc, unikając sporadycznych opóźnień, gdy niekrytyczny uop kradnie Port wykonania od jednego na ścieżce krytycznej, opóźniając wykonanie o 1 cykl. (Nazywa się to konfliktem zasobów). Zapisuje również rejestr, który może pomóc podczas wykonywania wielu wartości n równolegle w pętli interleaved (patrz poniżej).

LEA ' s opóźnienie zależy od trybu adresowania w procesorach z rodziny Intel SnB. 3C dla 3 składowych ([base+idx+const], co wymaga dwóch oddzielnych dodań), ale tylko 1c z 2 lub mniej składowymi (jeden add). Niektóre procesory (jak Core2) wykonują nawet 3-komponentowe LEA w jednym cyklu, ale rodzina SnB tego nie robi. co gorsza, Intel SnB-family standaryzuje opóźnienia, więc nie ma 2c UOPs , w przeciwnym razie 3-komponentowa LEA byłaby tylko 2C jak Bulldozer. (3-komponentowy LEA jest wolniejszy również na AMD, tylko nie przez dużo).

Więc lea rcx, [rax + rax*2] / inc rcx jest tylko opóźnieniem 2C, szybszym niż lea rcx, [rax + rax*2 + 1], na procesorach Intel SnB-family, takich jak Haswell. Break-even na BD, a gorzej na Core2. To kosztuje dodatkowy uop, co zwykle nie jest tego warte, aby zaoszczędzić opóźnienie 1c, ale opóźnienie jest głównym wąskim gardłem tutaj i Haswell ma wystarczająco szeroki rurociąg, aby obsłużyć dodatkową przepustowość uop.

W 1997 roku, w wyniku połączenia z SHR, firma została przekształcona w spółkę akcyjną. Głupie Kompilatory. : P są wielkie kawałki skomplikowanej maszyny, ale mądry człowiek często może pokonać je na małą skalę problemów. (Podane tysiące do milionów razy dłużej, aby myśleć o tym, oczywiście! Kompilatory nie używają wyczerpujących algorytmów do szukania każdego możliwego sposobu działania, ponieważ zajmowałoby to zbyt dużo czasu przy optymalizacji kodu inlined, co jest tym, co robią najlepiej. Nie są one również modelowane rurociągiem w docelowej mikroarchitekturze, przynajmniej nie w tych samych szczegółach coIACA lub inne narzędzia analizy statycznej; używają tylko heurystyki.)


Proste rozwijanie pętli nie pomoże; to wąskie gardła pętli na opóźnienie łańcucha zależności przenoszonego przez pętlę, a nie na napowietrzność / przepustowość pętli. Oznacza to, że poradziłby sobie z hyperthreading (lub innym rodzajem SMT), ponieważ procesor ma dużo czasu na przeplatanie instrukcji z dwóch wątków. Oznacza to równoległe połączenie pętli w main, ale jest to w porządku, ponieważ każdy wątek może po prostu sprawdzić zakres n wartości i w wyniku tego powstaje para liczb całkowitych.

Przeplatanie ręcznie w jednym wątku może być również opłacalne . Może obliczyć sekwencję dla pary liczb równolegle, ponieważ każdy z nich zajmuje tylko kilka rejestrów i wszystkie mogą aktualizować to samo max / maxi. To tworzy więcej równoległości na poziomie instrukcji .

Sztuką jest podjęcie decyzji, czy poczekać, aż wszystkie wartości n osiągną 1, zanim otrzymamy kolejną parę począwszy od wartości n, lub czy przełamać i uzyskać nowy punkt początkowy dla tylko jednego, który osiągnął warunek końcowy, bez dotykania rejestrów dla drugiej sekwencji. Prawdopodobnie najlepiej jest, aby każdy łańcuch działał na użytecznych danych, w przeciwnym razie musiałbyś warunkowo zwiększyć jego licznik.


Możesz to zrobić nawet z SSE packed-porównaj rzeczy, aby warunkowo zwiększyć licznik dla elementów wektorowych, gdzie n Nie osiągnął jeszcze 1. A potem ukryć nawet dłuższe opóźnienie implementacji warunkowego przyrostu SIMD, trzeba by trzymać więcej wektorów n wartości w powietrzu. Może warto tylko z wektorem 256b (4x uint64_t).

Myślę, że najlepszą strategią, aby wykryć 1 "lepki" jest zamaskowanie wektora wszystkich tych, które dodajesz, aby zwiększyć licznik. W przypadku, gdy wektor Przyrostowy ma wartość zero, a + = 0 jest liczbą zerową.]}

Nieprzetestowany pomysł na podręcznik wektoryzacja

# 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.

Możesz i powinieneś zaimplementować to z wewnętrznymi, zamiast ręcznie pisanego asm.


Algorytmiczne / usprawnienie implementacji:

Poza implementacją tej samej logiki z bardziej wydajnym asm, poszukaj sposobów na uproszczenie logiki lub uniknięcie zbędnej pracy. np. memoize w celu wykrycia wspólnych zakończeń sekwencji. W przeciwieństwie do poprzednich wersji, nie jest to możliwe.]}

@EOF zwraca uwagę, że tzcnt (lub bsf) może być służy do wykonywania wielu iteracji n/=2 w jednym kroku. To chyba lepsze niż wektoryzacja SIMD; żadna Instrukcja SSE czy AVX tego nie potrafi. Jest jednak nadal kompatybilny z wykonywaniem wielu skalarów ns równolegle w różnych rejestrach całkowitych.

Więc pętla może wyglądać tak:

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);

Może to zrobić znacznie mniej iteracji, ale zmiany liczby zmiennych są powolne w procesorach Intel SnB z rodziny bez BMI2. 3 uops, 2C latency. (Mają zależność od FLAG ponieważ count = 0 oznacza, że flagi są niezmodyfikowane. Obsługują to jako zależność od danych i pobierają wiele uops, ponieważ uop może mieć tylko 2 wejścia (Pre-HSW/BDW anyway)). O tym właśnie mówią ludzie narzekający na szaloną konstrukcję x86. To sprawia, że procesory x86 są wolniejsze niż gdyby ISA został zaprojektowany od podstaw dzisiaj, nawet w bardzo podobny sposób. (tj. jest to część "podatku x86", który kosztuje prędkość / moc.) SHRX / SHLX / SARX (BMI2) to duża wygrana (1 uop / 1c latencji).

Stawia również tzcnt (3C na Haswella i później) na ścieżce krytycznej, dzięki czemu znacznie wydłuża całkowite opóźnienie łańcucha zależności przenoszonego pętlą. Nie wymaga jednak użycia CMOV, ani przygotowania rejestru [54]}. @Veedrac ' s answer pokonuje to wszystko przez odroczenie tzcnt / shift dla wielu iteracji, co jest bardzo skuteczne(patrz poniżej).

Możemy bezpiecznie używać BSF lub TZCNT zamiennie, ponieważ n nigdy nie może być zero w tym momencie. Kod maszynowy TSCNT dekoduje się jako BSF na procesorach, które nie obsługują BMI1. (Bezsensowne prefiksy są ignorowane, więc REP BSF działa jako BSF).

TZCNT działa znacznie lepiej niż BSF na procesorach AMD, które go obsługują, więc dobrym pomysłem może być użycie REP BSF, nawet jeśli nie zależy ci na ustawieniu ZF, jeśli wejście jest zerowe, a nie wyjście. Niektóre Kompilatory robią to, gdy używasz __builtin_ctzll nawet z -mno-bmi.

Wykonują to samo na procesorach Intela, więc po prostu zapisz bajt, jeśli tylko to się liczy. TSCNT na Intel (pre-Skylake) nadal ma fałszywą zależność od rzekomo tylko do zapisu operandu wyjściowego, podobnie jak BSF, aby wspierać nieudokumentowane zachowanie, które BSF z input = 0 pozostawia swoje miejsce docelowe niezmodyfikowane. Więc musisz obejść to, chyba że optymalizacja tylko dla Skylake, więc nie ma nic do zyskania z dodatkowego bajtu REP. (Intel często wychodzi poza to, czego wymaga podręcznik x86 ISA, aby uniknąć łamania powszechnie używanego kodu, który zależy od coś, czego nie powinno, lub co jest z mocą wsteczną niedozwolone. na przykład System Windows 9x nie zakłada spekulacyjnego prefetchingu wpisów TLB, co było bezpieczne, gdy kod został napisany, zanim Intel zaktualizował reguły zarządzania TLB.)

W każdym razie, LZCNT/TZCNT na Haswell mają ten sam fałszywy dep co POPCNT: zobacz to pytanie i odpowiedź . To dlatego w wyjściu GCC asm dla kodu @ Veedrac, widzisz to łamanie łańcucha dep za pomocą zerowania xor w rejestrze. używa jako miejsca docelowego TZCNT, gdy nie używa DST = src. Ponieważ TSCNT / LZCNT / POPCNT nigdy nie zostawia miejsca docelowego niezdefiniowanego lub niezmodyfikowanego, ta fałszywa zależność od wyjścia procesorów Intel jest błędem / ograniczeniem wydajności. Prawdopodobnie warto mieć kilka tranzystorów / mocy, aby zachowywały się jak inne UOP, które idą do tej samej jednostki wykonawczej. Jedynym plusem perf jest interakcja z innym ograniczeniem uarch: mogą mikro-fuse operand pamięci z trybem adresowania indeksowanego na Haswell, ale na Skylake gdzie Intel usunął fałszywy dep dla LZCNT / TZCNT oni "un-laminate" indeksowane tryby adresowania, podczas gdy POPCNT może nadal mikro-fuse dowolny tryb addr.


Ulepszenia pomysłów / kodu z innych odpowiedzi:

@odpowiedź hidefromkgb ma miłą obserwację, że masz gwarancję, że będziesz w stanie zrobić jedną prawą zmianę po 3N+1. Można to obliczyć bardziej efektywnie niż pominięcie kontroli między krokami. Wdrożenie asm w tej odpowiedzi jest jednak złamana (zależy od OF, która jest niezdefiniowana po SHRD z liczbą > 1), i powolna: ROR rdi,2 jest szybsza niż SHRD rdi,rdi,2, a użycie dwóch instrukcji CMOV na ścieżce krytycznej jest wolniejsze niż dodatkowy TEST, który może działać równolegle.

Umieściłem tidied / improved C (który prowadzi kompilator do tworzenia lepszego asm), i przetestowałem + działa szybciej asm (w komentarzach pod C) na Godbolt: zobacz link w @hidefromkgb odpowiedź . (Ta odpowiedź trafia w limit 30K char z dużych adresów Godbolt, ale shortlinki mogą gnić i były zbyt długie na goo.gl w każdym razie.)

Poprawiono również drukowanie wyjściowe, aby przekonwertować na ciąg znaków i utworzyć jeden write() zamiast zapisywać jeden znak na raz. To minimalizuje wpływ na czas całego programu za pomocą perf stat ./collatz (aby rejestrować liczniki wydajności), a ja usunąłem niektóre niekrytyczne asm.


@Kod Veedraca

Mam małe przyspieszenie od prawej zmiany biegów tak samo jak my know needs doing, and checking to continue the loop. Od 7,5 s dla limitu=1E8 do 7,275 s, na Core2Duo (Merom), przy współczynniku rozwinięcia 16.

Code + comments on Godbolt . Nie używaj tej wersji z clang; robi coś głupiego z defer-loop. Użycie licznika tmp k, a następnie dodanie go do {[64] } później zmienia to, co robi clang, ale to nieco rani gcc.

Zobacz dyskusję w komentarzach: Kod Veedraca jest excellent na procesorach z BMI1 (tzn. nie Celeron/Pentium)

 1939
Author: Peter Cordes,
Warning: date(): Invalid date.timezone value 'Europe/Kyiv', we selected the timezone 'UTC' for now. in /var/www/agent_stack/data/www/doraprojects.net/template/agent.layouts/content.php on line 54
2020-06-20 09:12:55

Twierdzenie, że kompilator C++ może produkować bardziej optymalny kod niż Kompetentny programista języka asemblera, jest bardzo złym błędem. A zwłaszcza w tym przypadku. Człowiek zawsze może uczynić kod lepszym niż kompilator, a ta szczególna sytuacja jest dobrą ilustracją tego twierdzenia.

Różnica czasu, którą widzisz, wynika z tego, że kod złożenia w pytaniu jest bardzo daleki od optymalnego w pętlach wewnętrznych.

(poniższy kod jest 32-bitowy, ale może być łatwo konwersja na 64-bit)

Na przykład, funkcja sekwencji może być zoptymalizowana tylko do 5 instrukcji:

    .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

Cały kod wygląda następująco:

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

Aby skompilować ten kod, FreshLib jest potrzebny.

W moich testach (procesor AMD A4-1200 1 GHz) powyższy kod jest około cztery razy szybszy niż kod C++ Z pytania (przy kompilacji z -O0 : 430 ms vs. 1900 ms) i ponad dwa razy szybszy (430 ms vs. 830 ms), gdy kod C++ jest kompilowany z -O3.

Wyjście obu programów jest takie samo: Max Sekwencja = 525 na i = 837799.

 106
Author: johnfound,
Warning: date(): Invalid date.timezone value 'Europe/Kyiv', we selected the timezone 'UTC' for now. in /var/www/agent_stack/data/www/doraprojects.net/template/agent.layouts/content.php on line 54
2020-12-29 12:59:34

Dla większej wydajności: prostą zmianą jest obserwacja, że po n = 3N+1, n będzie parzyste, więc można natychmiast podzielić przez 2. A n nie będzie 1, więc nie musisz tego testować. Możesz więc zapisać kilka wypowiedzi if i napisać:

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;
    }
}

Otowielkie zwycięstwo: jeśli spojrzysz na najniższe 8 bitów n, wszystkie kroki, dopóki nie podzielisz przez 2 osiem razy, są całkowicie określone przez te osiem bitów. Na przykład, jeśli ostatnie osiem bitów to 0x01, to znaczy, że w binarnym jest Twój numer ???? 0000 0001 następnie kolejne kroki to:

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  -> ???? ???? ????

Tak więc wszystkie te kroki można przewidzieć, a 256k + 1 zastępuje się 81k + 1. Coś podobnego stanie się dla wszystkich kombinacji. Można więc utworzyć pętlę z dużą instrukcją switch:

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;
    ...
}

Uruchom pętlę do N ≤ 128, ponieważ w tym punkcie n może stać się 1 z mniej niż ośmioma podziałami przez 2, a wykonanie ośmiu lub więcej kroków na raz sprawi, że przegapisz punkt, w którym osiągniesz 1 po raz pierwszy. Następnie kontynuuj pętlę "normal" - albo przygotuj tabelę, która powie Ci, ile jeszcze kroków trzeba wykonać, aby osiągnąć 1.

PS. Podejrzewam, że sugestia Petera Cordesa uczyni to jeszcze szybszym. Nie będzie żadnych gałęzi warunkowych z wyjątkiem jednej, a ta będzie prawidłowo przewidywana, chyba że pętla faktycznie się skończy. Więc kod byłby czymś w stylu

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];
}

W praktyce mierzyłbyś, czy przetwarzanie ostatnich 9, 10, 11, 12 bitów n na raz byłoby szybsze. Dla każdego bitu, liczba wpisy w tabeli podwoi, i wykręcić spowolnienie, gdy tabele nie mieszczą się w pamięci podręcznej L1 już.

PPS. Jeśli potrzebujesz liczby operacji: w każdej iteracji robimy dokładnie osiem podziałów przez dwa, a zmienną liczbę operacji (3n + 1), więc oczywistą metodą liczenia operacji byłaby inna tablica. Ale możemy faktycznie obliczyć liczbę kroków (na podstawie liczby iteracji pętli).

Moglibyśmy nieco przedefiniować problem: Zastąp n przez (3n + 1) / 2, jeśli Nieparzyste, i zastąpić n n / 2, jeśli parzyste. Wtedy każda iteracja zrobi dokładnie 8 kroków, ale można to uznać za oszustwo : -) więc załóżmy, że były operacje r n

Jeśli wykonamy pętlę do n ≤ 1,000,000 i mamy wstępnie obliczoną tabelę, ile iteracji jest potrzebnych od dowolnego początku punkt n ≤ 1,000,000 następnie obliczenie r jak powyżej, zaokrąglone do najbliższej liczby całkowitej, da właściwy wynik, chyba że s jest naprawdę duży.

 26
Author: gnasher729,
Warning: date(): Invalid date.timezone value 'Europe/Kyiv', we selected the timezone 'UTC' for now. in /var/www/agent_stack/data/www/doraprojects.net/template/agent.layouts/content.php on line 54
2017-01-18 11:30:52

Na dość niepowiązanej uwadze: więcej hacków wydajności!

  • [pierwszy "domysł" został ostatecznie obalony przez @ShreevatsaR; usunięty]

  • Podczas przechodzenia sekwencji możemy uzyskać tylko 3 możliwe przypadki w 2-sąsiedztwie bieżącego elementu N (pokazane jako pierwsze):

    1. [parzyste] [nieparzyste]
    2. [nieparzyste] [parzyste]
    3. [parzyste] [parzyste]

    Przeskoczyć obok tych 2 elementów oznacza obliczenie (N >> 1) + N + 1, ((N << 1) + N + 1) >> 1 oraz N >> 2, odpowiednio.

    Udowodnijmy, że dla obu przypadków (1) i (2)możliwe jest użycie pierwszego wzoru, (N >> 1) + N + 1.

    Przypadek (1) jest oczywisty. Case (2) implikuje (N & 1) == 1, więc jeśli założymy (bez utraty ogólności), że N jest 2-bitowe długie, a jego bity są ba od najbardziej-do najmniej-znaczących, to a = 1, A następujący zapis:

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

    Gdzie B = !b. Przesunięcie w prawo pierwszy wynik daje nam dokładnie to, czego chcemy.

    Q. E. D.: (N & 1) == 1 ⇒ (N >> 1) + N + 1 == ((N << 1) + N + 1) >> 1.

    Jak udowodniono, możemy przemierzaj sekwencję 2 elementy na raz, używając pojedynczej operacji trójskładnikowej. Kolejne 2-krotne skrócenie czasu.

Otrzymany algorytm wygląda następująco:

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;
}

Tutaj porównujemy n > 2 ponieważ proces może zatrzymać się na 2 zamiast 1, jeśli całkowita długość sekwencji jest nieparzysta.

[EDIT:]

Przetłumaczmy to na assembly!

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;

Użyj tych poleceń do kompilacji:

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

Zobacz C i poprawioną / poprawioną wersję z asm przez Petera Cordesa na Godbolt. (Uwaga wydawcy: Sorry za umieszczenie moich rzeczy w odpowiedzi, ale moja odpowiedź hit 30K char limit z Godbolt linki + tekst!)

 20
Author: hidefromkgb,
Warning: date(): Invalid date.timezone value 'Europe/Kyiv', we selected the timezone 'UTC' for now. in /var/www/agent_stack/data/www/doraprojects.net/template/agent.layouts/content.php on line 54
2016-11-04 06:42:08

Programy C++ są tłumaczone na programy assembly podczas generowania kodu maszynowego z kodu źródłowego. Błędem byłoby stwierdzenie, że assembly jest wolniejszy od C++. Co więcej, wygenerowany kod binarny różni się w zależności od kompilatora. Tak więc inteligentny kompilator C++ może produkować kod binarny bardziej optymalny i wydajny niż głupi kod asemblera.

Jednak uważam, że Twoja metodologia profilowania ma pewne wady. Poniżej przedstawiono ogólne wytyczne dotyczące profilowanie:

  1. upewnij się, że Twój system jest w stanie normalnym/bezczynnym. Zatrzymaj wszystkie uruchomione procesy (aplikacje), które intensywnie korzystają z procesora (lub ankiety przez sieć).
  2. twój datasize musi być większy rozmiar.
  3. twój test musi trwać dłużej niż 5-10 sekund.
  4. nie polegaj tylko na jednej próbce. Wykonaj Test N razy. Zbierz wyniki i Oblicz średnią lub medianę wyniku.
 6
Author: Mangu Singh Rajpurohit,
Warning: date(): Invalid date.timezone value 'Europe/Kyiv', we selected the timezone 'UTC' for now. in /var/www/agent_stack/data/www/doraprojects.net/template/agent.layouts/content.php on line 54
2016-11-01 13:45:19

W przypadku problemu z Collatzem można znacznie zwiększyć wydajność, buforując "ogony". To jest kompromis czas / pamięć. Zobacz: memoizacja ( https://en.wikipedia.org/wiki/Memoization ). Możesz również przyjrzeć się dynamicznym rozwiązaniom programistycznym dla innych kompromisów w zakresie czasu/pamięci.

Przykładowa implementacja Pythona:

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))
 6
Author: Emanuel Landeholm,
Warning: date(): Invalid date.timezone value 'Europe/Kyiv', we selected the timezone 'UTC' for now. in /var/www/agent_stack/data/www/doraprojects.net/template/agent.layouts/content.php on line 54
2016-11-05 19:00:35

From comments:

Ale ten kod nigdy się nie zatrzymuje (z powodu przepełnienia liczb całkowitych) !?! Yves Daoust

Dla wielu liczb będzie to, a nie przepełnienie.

If it will overflow - dla jednego z tych pechowych początkowych nasion, liczba Overflow najprawdopodobniej zbliży się do 1 bez kolejnego przepełnienia.

Wciąż to stawia interesujące pytanie, czy jest jakaś liczba nasion o przelotnym cyklu?

Dowolne proste końcowe zbieżne seria zaczyna się od potęgi dwóch wartości (wystarczająco oczywistej?).

2^64 przepełni się do zera, co jest nieokreśloną pętlą nieskończoną zgodnie z algorytmem (kończy się tylko 1), ale najbardziej optymalne rozwiązanie w odpowiedzi zakończy się dzięki shr rax wytwarzaniu ZF=1.

Możemy wyprodukować 2^64? Jeśli liczba początkowa to 0x5555555555555555, jest liczbą nieparzystą, następna liczba to 3n+1, czyli 0xFFFFFFFFFFFFFFFF + 1 = 0. Teoretycznie w nieokreślonym stanie algorytmu, ale zoptymalizowana odpowiedź johnfound odzyska poprzez wyjście na ZF=1. cmp rax,1 of Peter Cordes zakończy się nieskończoną pętlą (QED wariant 1," cheapo " przez niezdefiniowaną liczbę 0).

Może jakaś bardziej złożona liczba, która stworzy cykl bez 0? Szczerze mówiąc, nie jestem pewien, czy moja teoria matematyczna jest zbyt mglista, aby uzyskać jakiś poważny pomysł, jak sobie z tym poradzić w poważny sposób. Ale intuicyjnie powiedziałbym, że szereg będzie zbieżny do 1 dla każdej liczby: 0

Więc po prostu włożyłem kilka liczb do arkusza i przyjrzałem się 8 bitowym liczbom obciętym.

Istnieją trzy wartości0: 227, 170 oraz 85 (85 idąc bezpośrednio do 0, pozostałe dwa postępują w kierunku 85).

Ale nie ma wartości tworzącej cykliczne zalążki przelewowe.

Śmiesznie wystarczy, że sprawdziłam, która jest pierwszą liczbą, która cierpi na 8-bitowe okrojenie,i już 27 jest dotknięta! Wartość 9232 osiąga wartość 9232 w prawidłowym nie-okrojonym szeregu (pierwsza okrojona wartość to 322 W 12 kroku), a maksymalna wartość osiągnięta dla dowolnej z 2-255 liczb wejściowych w nie-okrojonym trybie to 13120 (dla samej 255), Maksymalna liczba kroków do zbieżności do 1 wynosi około 128 (+-2, Nie wiem, czy "1" ma się liczyć, itd...).

Co ciekawe (jak dla mnie) liczba 9232 jest maksymalna dla wielu innych liczb źródłowych, co w niej takiego wyjątkowego? :- O 9232 = 0x2410 ... hmmm.. nie mam pojęcia.

Niestety nie mogę zrozumieć tego szeregu, dlaczego się zbiega i jakie są konsekwencje ich obcinania do K bitów, ale przy warunku zakończenia cmp number,1 z pewnością można umieścić algorytm w nieskończonej pętli z określoną wartością wejściową kończącą się jako 0 po obcięciu.

Ale wartość 27 przepełniona dla 8-bitowych przypadków jest to rodzaj alertowania, wygląda na to, że jeśli policzysz liczbę kroków do osiągnięcia wartości 1, otrzymasz błędny wynik dla większości liczb z całkowitego K-bitowego zestawu liczb całkowitych. Dla 8 bitowych liczb całkowitych 146 z 256 miało wpływ na serię przez okrojenie(niektóre z nich mogą jeszcze trafić prawidłową liczbę kroków przez przypadek, może jestem zbyt leniwy, aby to sprawdzić).

 5
Author: Ped7g,
Warning: date(): Invalid date.timezone value 'Europe/Kyiv', we selected the timezone 'UTC' for now. in /var/www/agent_stack/data/www/doraprojects.net/template/agent.layouts/content.php on line 54
2016-11-01 17:18:35

Nie wysłałeś kodu wygenerowanego przez kompilator, więc nie ma tu żadnych domysłów, ale nawet nie widząc go, można powiedzieć, że to:

test rax, 1
jpe even

... ma 50% szans na błędną interpretację oddziału, a to będzie kosztowne.

Kompilator prawie na pewno wykonuje oba obliczenia (co kosztuje zdecydowanie więcej, ponieważ div / mod ma dość duże opóźnienie, więc multiply-add jest "wolny") i kontynuuje z CMOV. Co oczywiście ma zero procent szans na być źle zinterpretowanym.

 5
Author: Damon,
Warning: date(): Invalid date.timezone value 'Europe/Kyiv', we selected the timezone 'UTC' for now. in /var/www/agent_stack/data/www/doraprojects.net/template/agent.layouts/content.php on line 54
2016-11-01 19:50:03

Nawet bez patrzenia na assembly, najbardziej oczywistym powodem jest to, że /= 2 jest prawdopodobnie zoptymalizowany jako >>=1 i wiele procesorów ma bardzo szybkie działanie shift. Ale nawet jeśli procesor nie ma operacji shift, podział liczb całkowitych jest szybszy niż podział zmiennoprzecinkowy.

Edit: twój przebieg może się różnić w zależności od powyższej instrukcji" dzielenie liczb całkowitych jest szybsze niż dzielenie zmiennoprzecinkowe". Poniższe komentarze pokazują, że nowoczesne procesory mają priorytet optymalizacji podział fp na podział liczb całkowitych. Jeśli więc ktoś szuka najbardziej prawdopodobnego powodu przyspieszenia, o którym zadaje pytanie w tym wątku, to optymalizacja kompilatora /=2 jako >>=1 byłaby najlepszym pierwszym miejscem do szukania.


Na niezwiązanej notatce, Jeśli n jest nieparzyste, wyrażenie n*3+1 będzie zawsze parzyste. Nie ma więc potrzeby sprawdzania. Możesz zmienić tę gałąź na

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

Więc całe stwierdzenie będzie wtedy

if (n & 1)
{
    n = (n*3 + 1) >> 1;
    count += 2;
}
else
{
    n >>= 1;
    ++count;
}
 5
Author: Dmitry Rubanovich,
Warning: date(): Invalid date.timezone value 'Europe/Kyiv', we selected the timezone 'UTC' for now. in /var/www/agent_stack/data/www/doraprojects.net/template/agent.layouts/content.php on line 54
2016-11-29 17:35:03

Jako ogólna odpowiedź, a nie konkretnie skierowana na to zadanie: w wielu przypadkach można znacznie przyspieszyć dowolny program, wprowadzając ulepszenia na wysokim poziomie. Jak obliczanie danych raz zamiast wiele razy, unikanie całkowicie niepotrzebnej pracy, używanie pamięci podręcznej w najlepszy sposób i tak dalej. Te rzeczy są znacznie łatwiejsze do zrobienia w języku na wysokim poziomie.

Pisanie kodu asemblera, jest możliwe , Aby poprawić to, co robi kompilator optymalizujący, ale jest to ciężka praca. I raz zrobione, Twój kod jest znacznie trudniejszy do modyfikacji, więc o wiele trudniej jest dodać ulepszenia algorytmiczne. Czasami procesor ma funkcjonalność, której nie można używać z języka wysokiego poziomu, wbudowany montaż jest często przydatny w takich przypadkach i nadal pozwala używać języka wysokiego poziomu.

W problemach Eulera, w większości przypadków udaje ci się coś zbudować, dowiedzieć, dlaczego to jest powolne, zbudować coś lepszego, dowiedzieć się, dlaczego to jest powolne, i tak dalej i tak dalej. To bardzo, bardzo trudne. używając asemblera. Lepszy algorytm przy połowie możliwej prędkości Zwykle pokonuje gorszy algorytm przy pełnej prędkości, a uzyskanie pełnej prędkości w asemblerze nie jest trywialne.

 4
Author: gnasher729,
Warning: date(): Invalid date.timezone value 'Europe/Kyiv', we selected the timezone 'UTC' for now. in /var/www/agent_stack/data/www/doraprojects.net/template/agent.layouts/content.php on line 54
2016-11-04 17:15:18

Prosta odpowiedź:

  • Robienie MOV RBX, 3 i MUL RBX jest drogie; wystarczy dodać RBX, RBX dwa razy

  • Dodaj 1 jest prawdopodobnie szybszy niż INC tutaj

  • MOV 2 I DIV są bardzo drogie; wystarczy przesunąć w prawo

  • 64-kod bitowy jest zwykle zauważalnie wolniejszy niż kod 32-bitowy, a problemy z wyrównaniem są bardziej skomplikowane; w przypadku małych programów, takich jak ten, musisz je spakować, więc wykonujesz obliczenia równoległe, aby mieć szansę na szybsze działanie kod 32-bitowy

Jeśli wygenerujesz listę assembly dla swojego programu C++, możesz zobaczyć, czym różni się on od Twojego assembly.

 -2
Author: Tyler Durden,
Warning: date(): Invalid date.timezone value 'Europe/Kyiv', we selected the timezone 'UTC' for now. in /var/www/agent_stack/data/www/doraprojects.net/template/agent.layouts/content.php on line 54
2016-11-07 17:36:19