Pusta pętla jest wolniejsza od niepustej w C

Próbując dowiedzieć się, jak długo linia kodu C była używana do wykonania, zauważyłem dziwną rzecz:

int main (char argc, char * argv[]) {
    time_t begin, end;
    uint64_t i;
    double total_time, free_time;
    int A = 1;
    int B = 1;

    begin = clock();
    for (i = 0; i<(1<<31)-1; i++);
    end = clock();
    free_time = (double)(end-begin)/CLOCKS_PER_SEC;
    printf("%f\n", free_time);

    begin = clock();
    for (i = 0; i<(1<<31)-1; i++) {
        A += B%2;
    }
    end = clock();
    free_time = (double)(end-begin)/CLOCKS_PER_SEC;
    printf("%f\n", free_time);

    return(0);
}

Które po wykonaniu wyświetla:

5.873425
4.826874

Dlaczego pusta pętla zużywa więcej czasu niż druga, w której znajduje się instrukcja ? Oczywiście próbowałem wielu wariantów, ale za każdym razem pusta pętla zajmuje więcej czasu niż jedna z jedną instrukcją.

Zauważ, że próbowałem zamienić kolejność pętli i dodać jakiś kod rozgrzewki i nie zmieniło to mojego problemu w ogóle.

Używam codeblocks jako IDE z kompilatorem GNU gcc, linux ubuntu 14.04 i mam quadcore intel i5 na 2.3 GHz(próbowałem uruchomić program na jednym rdzeniu, to nie zmienia wyniku).

Author: jfs, 2014-08-01

4 answers

Faktem jest, że nowoczesne procesory są skomplikowane. Wszystkie wykonane instrukcje będą współdziałać ze sobą w skomplikowany i ciekawy sposób. Dzięki za "tamten facet" za zamieszczenie kodu.

Zarówno OP, jak i "tamten facet" najwyraźniej stwierdzili, że krótka pętla trwa 11 cykli, podczas gdy długa trwa 9 cykli. W przypadku długiej pętli 9 cykli to dużo czasu, mimo że jest wiele operacji. W przypadku krótkiej pętli, musi być jakieś opóźnienie spowodowane przez to bycie tak krótkim i dodanie nop sprawia, że pętla jest wystarczająco długa, aby uniknąć przeciągnięcia.

Jedna rzecz, która się dzieje, jeśli spojrzymy na kod:

0x00000000004005af <+50>:    addq   $0x1,-0x20(%rbp)
0x00000000004005b4 <+55>:    cmpq   $0x7fffffff,-0x20(%rbp)
0x00000000004005bc <+63>:    jb     0x4005af <main+50>

Czytamy i i odpisujemy (addq). Czytamy ją od razu ponownie i porównujemy (cmpq). A potem pętla. Ale pętla wykorzystuje przewidywanie gałęzi. Tak więc w czasie wykonywania addq, procesor nie jest pewien, czy może pisać do i (ponieważ przewidywanie gałęzi może być błędne).

Wtedy my porównaj z i. Procesor będzie starał się unikać odczytu i z pamięci, ponieważ odczyt zajmuje dużo czasu. Zamiast tego jakiś sprzęt zapamięta, że właśnie napisaliśmy do i dodając do niego, a zamiast czytać i, Instrukcja cmpq pobiera dane z instrukcji store. Niestety, w tym momencie nie jesteśmy pewni, czy zapis do i rzeczywiście się odbył, czy nie! To może wprowadzić stragan.

Problem polega na tym, że skok warunkowy, addq co prowadzi do magazynu warunkowego i cmpq, który nie jest pewien, skąd pobrać dane, są bardzo blisko siebie. Są niezwykle blisko siebie. Możliwe, że są one tak blisko siebie, że procesor nie może w tej chwili dowiedzieć się, czy wziąć i z instrukcji sklepu, czy odczytać ją z pamięci. I odczytuje go z pamięci, która jest wolniejsza, bo musi czekać na zakończenie sklepu. A dodanie tylko jednego nop daje procesorowi wystarczająco dużo czasu.

Zazwyczaj myślisz, że jest PAMIĘĆ RAM i pamięć podręczna. Na nowoczesnym procesorze Intel pamięć odczytu może odczytywać z (najwolniejszego do najszybszego):

  1. pamięć (RAM)
  2. L3 cache (opcjonalnie)
  3. L2 cache
  4. L1 cache
  5. poprzednia Instrukcja sklepu, która nie została jeszcze zapisana do pamięci podręcznej L1.

Więc co procesor robi wewnętrznie w krótkiej, powolnej pętli:

  1. odczyt i z pamięci podręcznej L1
  2. dodaj 1 do i
  3. Write i to L1 cache
  4. poczekaj, aż {[2] } zostanie zapisane do pamięci podręcznej L1
  5. odczyt i z pamięci podręcznej L1
  6. Porównaj i z INT_MAX
  7. rozgałęzienie do (1), jeśli jest mniejsze.

W długiej, szybkiej pętli procesor robi:

  1. dużo rzeczy
  2. odczyt i z pamięci podręcznej L1
  3. dodaj 1 do i
  4. wykonaj instrukcję "store", która zapisze i do pamięci podręcznej L1
  5. Czytaj i bezpośrednio z "sklepu" Instrukcja bez dotykania pamięci podręcznej L1
  6. Porównaj i z INT_MAX
  7. rozgałęzienie do (1), jeśli jest mniejsze.
 45
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-05-23 11:52:31

Zakładając, że Twój kod używa 32-bitowego typu integer int (co prawdopodobnie robi Twój system), nic nie może być określone z twojego kodu. Zamiast tego wykazuje nieokreślone zachowanie.

foo.c:5:5: error: first parameter of 'main' (argument count) must be of type 'int'
int main (char argc, char * argv[]) {
    ^
foo.c:13:26: warning: overflow in expression; result is 2147483647 with type 'int' [-Winteger-overflow]
    for (i = 0; i<(1<<31)-1; i++);
                         ^
foo.c:19:26: warning: overflow in expression; result is 2147483647 with type 'int' [-Winteger-overflow]
    for (i = 0; i<(1<<31)-1; i++) {
                         ^

Spróbujmy to naprawić:

#include <stdint.h>
#include <stdio.h>
#include <time.h>
#include <limits.h>

int main (int argc, char * argv[]) {
    time_t begin, end;
    uint64_t i;
    double total_time, free_time;
    int A = 1;
    int B = 1;

    begin = clock();
    for (i = 0; i<INT_MAX; i++);
    end = clock();
    free_time = (double)(end-begin)/CLOCKS_PER_SEC;
    printf("%f\n", free_time);

    begin = clock();
    for (i = 0; i<INT_MAX; i++) {
        A += B%2;
    }
    end = clock();
    free_time = (double)(end-begin)/CLOCKS_PER_SEC;
    printf("%f\n", free_time);

    return(0);
}

Spójrzmy teraz na wyjście asemblera tego kodu. Osobiście uważam, że montaż wewnętrzny LLVM jest bardzo czytelny, więc zamierzam to pokazać. Wyprodukuję go przez running:

clang -O3 foo.c -S -emit-llvm -std=gnu99

Oto odpowiednia część wyjścia (główny funkcja):

define i32 @main(i32 %argc, i8** nocapture readnone %argv) #0 {
  %1 = tail call i64 @"\01_clock"() #3
  %2 = tail call i64 @"\01_clock"() #3
  %3 = sub nsw i64 %2, %1
  %4 = sitofp i64 %3 to double
  %5 = fdiv double %4, 1.000000e+06
  %6 = tail call i32 (i8*, ...)* @printf(i8* getelementptr inbounds ([4 x i8]* @.str, i64 0, i64 0), double %5) #3
  %7 = tail call i64 @"\01_clock"() #3
  %8 = tail call i64 @"\01_clock"() #3
  %9 = sub nsw i64 %8, %7
  %10 = sitofp i64 %9 to double
  %11 = fdiv double %10, 1.000000e+06
  %12 = tail call i32 (i8*, ...)* @printf(i8* getelementptr inbounds ([4 x i8]* @.str, i64 0, i64 0), double %11) #3
  ret i32 0
}

Zauważ, że nie mażadnych operacji pomiędzy wywołaniami clock() dlaw obu przypadkach . Więc oba są skompilowane do dokładnie tego samego .

 78
Author: Bill Lynch,
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
2014-07-31 20:25:33

ta odpowiedź zakłada, że już zrozumiałeś i zająłeś się doskonałymi punktami dotyczącymi niezdefiniowanego zachowania sharth w swojej odpowiedzi . Zwraca również uwagę na triki, które kompilator może wykonywać na Twoim kodzie. Należy podjąć kroki, aby upewnić się, że kompilator nie rozpoznaje całej pętli jako bezużytecznej. Na przykład zmiana deklaracji iteratora na volatile uint64_t i; uniemożliwi usunięcie pętli, a volatile int A; zapewni, że druga pętla faktycznie wykona więcej pracy niż najpierw. Ale nawet jeśli zrobisz to wszystko, nadal możesz odkryć, że:

Kod póĺşniej w programie moĹźe wykonaÄ ‡ szybciej niĹź wczeĹ " niejszy kod.

Funkcja biblioteki clock() mogła spowodować błąd icache po odczytaniu timera i przed powrotem. Spowodowałoby to dodatkowy czas w pierwszym mierzonym przedziale. (W przypadku późniejszych wywołań kod jest już w pamięci podręcznej). Jednak efekt ten będzie malutki, z pewnością za mały, aby clock() zmierzyć, nawet jeśli był to błąd strony / align = "left"/ Losowe przełączniki kontekstowe mogą dodawać do każdego przedziału czasowego.

Co ważniejsze, masz procesor i5, który ma dynamiczne Taktowanie. Kiedy program rozpoczyna wykonywanie, częstotliwość taktowania jest najprawdopodobniej niska, ponieważ procesor był bezczynny. Samo uruchomienie programu sprawia, że procesor nie jest już bezczynny, więc po krótkim opóźnieniu Prędkość zegara wzrośnie. Stosunek częstotliwości zegara procesora bezczynnego do Turboboostowanego może być znaczący. (Na moim ultrabooku Haswell i5-4200U, pierwszy mnożnik wynosi 8, a drugi 26, dzięki czemu kod startowy działa mniej niż 30% tak szybko, jak późniejszy kod! "Skalibrowane" pętle do implementacji opóźnień to straszny pomysł na nowoczesnych komputerach!)

Włączenie fazy rozgrzewki (wielokrotne uruchamianie benchmarka i wyrzucanie pierwszego wyniku) dla bardziej precyzyjnego pomiaru czasu jest nie tylko dla zarządzanych frameworków z kompilatorami JIT!

 30
Author: Ben Voigt,
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-05-23 11:52:31

Jestem w stanie odtworzyć to z GCC 4.8.2-19ubuntu1 bez optymalizacji:

$ ./a.out 
4.780179
3.762356

Oto pusta pętla:

0x00000000004005af <+50>:    addq   $0x1,-0x20(%rbp)
0x00000000004005b4 <+55>:    cmpq   $0x7fffffff,-0x20(%rbp)
0x00000000004005bc <+63>:    jb     0x4005af <main+50>

A oto niepuste:

0x000000000040061a <+157>:   mov    -0x24(%rbp),%eax
0x000000000040061d <+160>:   cltd   
0x000000000040061e <+161>:   shr    $0x1f,%edx
0x0000000000400621 <+164>:   add    %edx,%eax
0x0000000000400623 <+166>:   and    $0x1,%eax
0x0000000000400626 <+169>:   sub    %edx,%eax
0x0000000000400628 <+171>:   add    %eax,-0x28(%rbp)
0x000000000040062b <+174>:   addq   $0x1,-0x20(%rbp)
0x0000000000400630 <+179>:   cmpq   $0x7fffffff,-0x20(%rbp)
0x0000000000400638 <+187>:   jb     0x40061a <main+157>

Wstawmy nop w pustej pętli:

0x00000000004005af <+50>:    nop
0x00000000004005b0 <+51>:    addq   $0x1,-0x20(%rbp)
0x00000000004005b5 <+56>:    cmpq   $0x7fffffff,-0x20(%rbp)
0x00000000004005bd <+64>:    jb     0x4005af <main+50>

Teraz biegną równie szybko:

$ ./a.out 
3.846031
3.705035

Wyobrażam sobie, że to pokazuje znaczenie wyrównania, ale obawiam się, że nie mogę dokładnie powiedzieć, jak: /

 27
Author: that other guy,
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
2014-07-31 22:19:22