Kod C++ do testowania domysłów Collatza szybciej niż ręcznie pisany assembly-dlaczego?

Napisałem te dwa rozwiązania dla projektu Euler Q14 , W assembly i w C++. Są to 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>

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

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

Kod C++ ma moduł każdy term i dzielenie każdy term parzysty, gdzie assembly jest tylko jednym dzieleniem na parzysty term.

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 bit Linux na 1,4 GHz Intel Celeron 2955U (Haswell microarchitecture).

Author: einpoklum, 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 także tag wiki x86 aby uzyskać 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 linkuje do tych schludnych slajdów pokazujących, jak różne kompilatory C optymalizują niektóre naprawdę proste funkcje za pomocą fajnych sztuczek.


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, aby skonfigurować 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ć front-end wąskie gardła. 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. [[8]} to 9 uops, opóźnienie 22-29C i jedna na przepustowość 8-11c w systemie Haswell.


Jak widać patrząc na wyjście gcc -O0 asm (Godbolt compiler explorer ), używa 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 przekształca się przez GIMPLE, co oznacza, że niektóre "optymalizacje" nie mogą być wyłączone. Obejmuje to rozpoznawanie podział przez stałą i wykorzystanie przesunięć (potęga 2) lub mnożnika stałego (Nie potęga 2), aby uniknąć IDIV(zobacz div_by_13 w powyższym linku godbolt).

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


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 sposób, który prowadzi kompilatora do tworzenia lepszego kodu jest zwykle najlepszym podejściem . Musisz znać asm i wiedzieć, co jest wydajny, 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 skompilować je 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ówcie 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 pułapek specyficznych dla asm podczas wdrażania czegoś takiego jak ruch danych ze źródła C, jeśli nie widzi 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óry dodaje overhead .)

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ć negatywny, czy co. 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 bez rozgałęzień, a ścieżka krytyczna łańcucha zależności przenoszonego przez pętlę wynosi:]}
  • 3-składowa LEA (3 cykle)
  • cmov (2 cykle na Haswell, 1C na Broadwell 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 wyprodukowania niż wejście RAX (z Lea - > MOV), więc nie znajduje się na ścieżce krytycznej.

Podobnie MOV - > SHR, który wytwarza wejście RDI CMOV, jest poza ścieżką krytyczną, bo jest też 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 również nie jest 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 od ścieżka krytyczna.


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= bit przesunięty na zewnątrz, 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 trick: 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 z opóźnieniem w ogóle na Haswell (czy MOV x86 naprawdę może być "wolny"? Dlaczego nie mogę tego odtworzyć?). Tak. pomoc 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ż ma tylko dwie rury 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).

Opóźnienie LEA zależy od trybu adresowania , w procesorach z rodziny Intel SnB. 3c dla 3 składników ([base+idx+const], co zajmuje dwa osobne dodatki), ale tylko 1c z 2 lub mniej składnikami (jeden dodatek). 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 aż tak bardzo).

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 on 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 to wielkie kawałki skomplikowanych maszyn, ale mądry człowiek często może ich pokonać w małych problemach. (Podane tysiące do milionów razy dłużej, aby o tym pomyśleć, 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 modelują również potoku w docelowej mikroarchitekturze, przynajmniej nie w tym samym detalu co IACA lub inne narzędzia do analizy statycznej; po prostu używają heurystyki.)


Proste rozwijanie pętli nie pomoże ; ta pętla wąskie gardła opóźnień ł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łoby to równoległość pętli w main, ale jest to w porządku, ponieważ każdy wątek może po prostu sprawdzić zakres wartości n i w rezultacie wytworzyć parę liczb całkowitych.

Przeplatanie ręcznie w obrębie jednego wątku może być również realne . 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 przed otrzymaniem kolejnej pary wartości początkowych n, czy też czy przełamać się i uzyskać nowy punkt startowy dla tylko jednego, który osiągnął stan końcowy, bez dotykając 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, aby ukryć jeszcze dłuższe opóźnienie implementacji warunkowego przyrostu SIMD, trzeba trzymać więcej wektorów n wartości w górę powietrze. 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ą.]}

Niesprawdzony pomysł na ręczną wektoryzację

# 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

    # There may be a better way to do this blend, avoiding the bypass delay for an FP blend between integer insns, not sure.  Probably worth it
    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.


Algorytmiczny / poprawa 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 wskazuje, że tzcnt (lub bsf) może być użyty do wykonania wielu iteracji n/=2 w jednym kroku. To chyba lepsze niż wektoryzacja SIMD, bo ż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. (Są zależne od FLAG, ponieważ count = 0 oznacza, że flagi są niezmodyfikowane. Obsługują to jako zależność od danych i pobierają wiele UOP, ponieważ UOP może mieć tylko 2 wejścia (pre-HSW/BDW)). 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 latency).

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ńcuch zależności przenoszony przez 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 punkcie. 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.

Działają tak samo na procesorach Intela, więc zachowaj bajt, jeśli tylko to się liczy. TSCNT na Intel (pre-Skylake) nadal ma fałszywą zależność od rzekomo tylko do zapisu operand, podobnie jak BSF, 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 wykracza poza to, czego wymaga podręcznik x86 ISA, aby uniknąć łamania powszechnie używanego kodu, który zależy od czegoś, czego nie powinien, lub który jest wstecznie zabroniony. np. Windows 9x nie zakłada spekulacyjnego prefetchingu TLB wpisy , które były bezpieczne podczas pisania kodu, 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 asm gcc dla kodu @Veedrac widzisz, że łamie łańcuch dep za pomocą zerowania xor w rejestrze, który ma być używany jako miejsce docelowe TZCNT, gdy nie używa DST=src. Ponieważ TSCNT / LZCNT / POPCNT nigdy nie opuszczają miejsca docelowego niezmodyfikowana, ta fałszywa zależność od wyjścia procesorów Intela jest wyłącznie 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. Na Haswella, ale na Skylake, gdzie Intel usunął fałszywą zależność dla LZCNT/TZCNT, udało im się uzyskać dostęp do danych, które nie były w pełni kompatybilne z innymi procesorami, np. z Windows XP, Windows Vista, Windows Vista, Windows 7, Windows 7, Windows 7, Windows 7, Windows 7, Windows 7, Windows 7, Windows 7, Windows 7, Windows 7, Windows 7, Windows 7, Windows 7, Windows 7, Windows 7, Windows 7, Windows 7, Windows 7, Windows 7, Windows 7, Windows 7, Windows 7, Windows 7, Windows 7, Windows 7, Windows 7, "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. Implementacja asm w tej odpowiedzi jest jednak zepsuta (zależy od tego, która jest niezdefiniowana po SHRD z liczbą > 1) i slow: ROR rdi,2 jest szybszy 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ź uderza w limit znaków 30K z dużych adresów Godbolt, ale shortlinki mogą gnić i były too long for 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 bardzo małe przyspieszenie od przesunięcia w prawo tyle, ile wiemy wymaga zrobienia i sprawdzenia, aby kontynuować pętlę. 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 doskonały na procesorach z BMI1 (tzn. nie Celeron/Pentium)

 1761
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
2018-08-08 11:12:23

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 skompilowane z -O3.

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

 92
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
2016-11-01 18:52:54

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.

 20
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!)

 17
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.
 5
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

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ć).

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

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

 3
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

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

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