Dlaczego dodanie komentarzy w asemblerze powoduje tak radykalną zmianę w generowanym kodzie GCC?

82

Tak więc miałem ten kod:

constexpr unsigned N = 1000;
void f1(char* sum, char* a, char* b) {
    for(int i = 0; i < N; ++i) {
        sum[i] = a[i] + b[i];
    }
}

void f2(char* sum, char* a, char* b) {
    char* end = sum + N;
    while(sum != end) {
        *sum++ = *a++ + *b++;
    }
}

Chciałem zobaczyć kod, który wygeneruje GCC 4.7.2. Więc pobiegłem g++ -march=native -O3 -masm=intel -S a.c++ -std=c++11i otrzymałem następujący wynik:

        .file   "a.c++"
        .intel_syntax noprefix
        .text
        .p2align 4,,15
        .globl  _Z2f1PcS_S_
        .type   _Z2f1PcS_S_, @function
_Z2f1PcS_S_:
.LFB0:
        .cfi_startproc
        lea     rcx, [rdx+16]
        lea     rax, [rdi+16]
        cmp     rdi, rcx
        setae   r8b
        cmp     rdx, rax
        setae   cl
        or      cl, r8b
        je      .L5
        lea     rcx, [rsi+16]
        cmp     rdi, rcx
        setae   cl
        cmp     rsi, rax
        setae   al
        or      cl, al
        je      .L5
        xor     eax, eax
        .p2align 4,,10
        .p2align 3
.L3:
        movdqu  xmm0, XMMWORD PTR [rdx+rax]
        movdqu  xmm1, XMMWORD PTR [rsi+rax]
        paddb   xmm0, xmm1
        movdqu  XMMWORD PTR [rdi+rax], xmm0
        add     rax, 16
        cmp     rax, 992
        jne     .L3
        mov     ax, 8
        mov     r9d, 992
.L2:
        sub     eax, 1
        lea     rcx, [rdx+r9]
        add     rdi, r9
        lea     r8, [rax+1]
        add     rsi, r9
        xor     eax, eax
        .p2align 4,,10
        .p2align 3
.L4:
        movzx   edx, BYTE PTR [rcx+rax]
        add     dl, BYTE PTR [rsi+rax]
        mov     BYTE PTR [rdi+rax], dl
        add     rax, 1
        cmp     rax, r8
        jne     .L4
        rep
        ret
.L5:
        mov     eax, 1000
        xor     r9d, r9d
        jmp     .L2
        .cfi_endproc
.LFE0:
        .size   _Z2f1PcS_S_, .-_Z2f1PcS_S_
        .p2align 4,,15
        .globl  _Z2f2PcS_S_
        .type   _Z2f2PcS_S_, @function
_Z2f2PcS_S_:
.LFB1:
        .cfi_startproc
        lea     rcx, [rdx+16]
        lea     rax, [rdi+16]
        cmp     rdi, rcx
        setae   r8b
        cmp     rdx, rax
        setae   cl
        or      cl, r8b
        je      .L19
        lea     rcx, [rsi+16]
        cmp     rdi, rcx
        setae   cl
        cmp     rsi, rax
        setae   al
        or      cl, al
        je      .L19
        xor     eax, eax
        .p2align 4,,10
        .p2align 3
.L17:
        movdqu  xmm0, XMMWORD PTR [rdx+rax]
        movdqu  xmm1, XMMWORD PTR [rsi+rax]
        paddb   xmm0, xmm1
        movdqu  XMMWORD PTR [rdi+rax], xmm0
        add     rax, 16
        cmp     rax, 992
        jne     .L17
        add     rdi, 992
        add     rsi, 992
        add     rdx, 992
        mov     r8d, 8
.L16:
        xor     eax, eax
        .p2align 4,,10
        .p2align 3
.L18:
        movzx   ecx, BYTE PTR [rdx+rax]
        add     cl, BYTE PTR [rsi+rax]
        mov     BYTE PTR [rdi+rax], cl
        add     rax, 1
        cmp     rax, r8
        jne     .L18
        rep
        ret
.L19:
        mov     r8d, 1000
        jmp     .L16
        .cfi_endproc
.LFE1:
        .size   _Z2f2PcS_S_, .-_Z2f2PcS_S_
        .ident  "GCC: (GNU) 4.7.2"
        .section        .note.GNU-stack,"",@progbits

Ssam w czytaniu, więc postanowiłem dodać kilka markerów, aby wiedzieć, gdzie poszły korpusy pętli:

constexpr unsigned N = 1000;
void f1(char* sum, char* a, char* b) {
    for(int i = 0; i < N; ++i) {
        asm("# im in ur loop");
        sum[i] = a[i] + b[i];
    }
}

void f2(char* sum, char* a, char* b) {
    char* end = sum + N;
    while(sum != end) {
        asm("# im in ur loop");
        *sum++ = *a++ + *b++;
    }
}

I GCC wypluł to:

    .file   "a.c++"
    .intel_syntax noprefix
    .text
    .p2align 4,,15
    .globl  _Z2f1PcS_S_
    .type   _Z2f1PcS_S_, @function
_Z2f1PcS_S_:
.LFB0:
    .cfi_startproc
    xor eax, eax
    .p2align 4,,10
    .p2align 3
.L2:
#APP
# 4 "a.c++" 1
    # im in ur loop
# 0 "" 2
#NO_APP
    movzx   ecx, BYTE PTR [rdx+rax]
    add cl, BYTE PTR [rsi+rax]
    mov BYTE PTR [rdi+rax], cl
    add rax, 1
    cmp rax, 1000
    jne .L2
    rep
    ret
    .cfi_endproc
.LFE0:
    .size   _Z2f1PcS_S_, .-_Z2f1PcS_S_
    .p2align 4,,15
    .globl  _Z2f2PcS_S_
    .type   _Z2f2PcS_S_, @function
_Z2f2PcS_S_:
.LFB1:
    .cfi_startproc
    xor eax, eax
    .p2align 4,,10
    .p2align 3
.L6:
#APP
# 12 "a.c++" 1
    # im in ur loop
# 0 "" 2
#NO_APP
    movzx   ecx, BYTE PTR [rdx+rax]
    add cl, BYTE PTR [rsi+rax]
    mov BYTE PTR [rdi+rax], cl
    add rax, 1
    cmp rax, 1000
    jne .L6
    rep
    ret
    .cfi_endproc
.LFE1:
    .size   _Z2f2PcS_S_, .-_Z2f2PcS_S_
    .ident  "GCC: (GNU) 4.7.2"
    .section    .note.GNU-stack,"",@progbits

Jest to znacznie krótsze i ma kilka istotnych różnic, takich jak brak instrukcji SIMD. Spodziewałem się tego samego wyniku, z kilkoma komentarzami gdzieś pośrodku. Czy robię tu jakieś błędne założenie? Czy optymalizator GCC jest utrudniony przez komentarze ASM?

R. Martinho Fernandes
źródło
28
Spodziewałbym się, że GCC (i większość kompilatorów) będzie traktować konstrukcje ASM jak bloki. Więc nie mogą rozumować, co dzieje się przez takie pudełko. A to hamuje wiele optymalizacji, zwłaszcza tych, które są przenoszone przez granice pętli.
Ira Baxter
10
Wypróbuj rozszerzony asmformularz z pustymi listami wyników i clobberów.
Kerrek SB
4
@ R.MartinhoFernandes: asm("# im in ur loop" : : );(patrz dokumentacja )
Mike Seymour
16
Zauważ, że możesz uzyskać nieco więcej pomocy, patrząc na wygenerowany zestaw, dodając -fverbose-asmflagę, która dodaje kilka adnotacji, aby pomóc zidentyfikować, jak rzeczy poruszają się między rejestrami.
Matthew Slattery,
1
Bardzo interesujące. Czy można go użyć, aby wybiórczo uniknąć optymalizacji w pętlach?
SChepurin,

Odpowiedzi:

62

Interakcje z optymalizacjami są wyjaśnione mniej więcej w połowie strony „Instrukcje asemblera z operandami wyrażenia C” w dokumentacji.

GCC nie próbuje zrozumieć żadnego aktualnego zestawu wewnątrz asm; jedyne, co wie o zawartości, to to, co (opcjonalnie) podasz jej w specyfikacji argumentu wyjściowego i wejściowego oraz na liście blokowania rejestru.

W szczególności zwróć uwagę:

asmInstrukcja bez żadnych argumentów wyjściowych będą traktowane identycznie lotnym asminstrukcji.

i

Słowo volatilekluczowe wskazuje, że instrukcja ma istotne skutki uboczne [...]

Zatem obecność asmwewnątrz pętli zahamowała optymalizację wektoryzacji, ponieważ GCC zakłada, że ​​ma to skutki uboczne.

Matthew Slattery
źródło
1
Zwróć uwagę, że skutki uboczne instrukcji Basic Asm nie mogą obejmować modyfikujących rejestrów ani żadnej pamięci, którą Twój kod C ++ kiedykolwiek odczytuje / zapisuje. Ale tak, asminstrukcja musi być uruchamiana raz na zawsze, gdy byłaby wykonywana na abstrakcyjnej maszynie C ++, a GCC decyduje się nie wektoryzować, a następnie emituje asm 16 razy z rzędu na paddb. Myślę, że to jest legalne, ponieważ dostęp do postaci nie jest volatile. (W przeciwieństwie do rozszerzonego oświadczenia asm z "memory"uderzeniem)
Peter Cordes
1
Zobacz gcc.gnu.org/wiki/ConvertBasicAsmToExtended, aby dowiedzieć się, z jakich powodów nie należy używać instrukcji GNU C Basic Asm. Chociaż ten przypadek użycia (tylko znacznik komentarza) jest jednym z niewielu, w których wypróbowanie go nie jest nierozsądne.
Peter Cordes
23

Zauważ, że gcc zwektoryzował kod, dzieląc ciało pętli na dwie części, przy czym pierwsza przetwarza po 16 elementów, a druga wykonuje resztę później.

Jak skomentował Ira, kompilator nie parsuje bloku asm, więc nie wie, że to tylko komentarz. Nawet gdyby tak było, nie ma możliwości dowiedzenia się, co zamierzałeś. Zoptymalizowane pętle mają podwojony korpus, czy powinien umieścić twój asm w każdej? Czy chciałbyś, aby nie był wykonywany 1000 razy? Nie wie, więc idzie bezpieczną trasą i wraca do prostej pojedynczej pętli.

Błazen
źródło
3

Nie zgadzam się z „gcc nie rozumie, co jest w asm()bloku”. Na przykład gcc radzi sobie całkiem dobrze z optymalizacją parametrów, a nawet z ponownym rozmieszczeniem asm()bloków tak, aby przeplatały się z wygenerowanym kodem C. Dlatego, jeśli spojrzysz na asembler wbudowany, na przykład w jądrze Linuksa, prawie zawsze jest on poprzedzony przedrostkiem, __volatile__aby zapewnić, że kompilator „nie przenosi kodu”. Kazałem gcc przesunąć moje "rdtsc", co pozwoliło mi zmierzyć czas potrzebny do zrobienia określonej rzeczy.

Jak zostało udokumentowane, gcc traktuje pewne typy asm()bloków jako „specjalne” i dlatego nie optymalizuje kodu po żadnej ze stron bloku.

Nie oznacza to, że gcc czasami nie będzie zdezorientowany przez wbudowane bloki asemblera lub po prostu zdecyduje się zrezygnować z jakiejś szczególnej optymalizacji, ponieważ nie może podążać za konsekwencjami kodu asemblera itp. często można się pomylić z powodu braku tagów clobber - więc jeśli masz jakieś instrukcje, takie jakcpuidto zmienia wartość EAX-EDX, ale napisałeś kod tak, że używa tylko EAX, kompilator może przechowywać rzeczy w EBX, ECX i EDX, a wtedy twój kod działa bardzo dziwnie, gdy te rejestry są nadpisywane ... Jeśli masz szczęście, zawiesza się natychmiast - wtedy łatwo zorientować się, co się dzieje. Ale jeśli masz pecha, to się zawiesza ... Kolejną trudną rzeczą jest instrukcja dzielenia, która daje drugi wynik w edx. Jeśli nie przejmujesz się modulo, łatwo zapomnieć, że EDX został zmieniony.

Mats Petersson
źródło
1
gcc naprawdę nie rozumie, co jest w bloku asm - musisz to powiedzieć za pomocą rozszerzonej instrukcji asm. bez tych dodatkowych informacji gcc nie będzie się poruszać po takich blokach. gcc również nie jest zdezorientowany w przypadkach, które podasz - po prostu popełniłeś błąd programistyczny, mówiąc gcc, że może używać tych rejestrów, podczas gdy w rzeczywistości twój kod je przebija.
Pamiętaj o Monice,
Spóźniona odpowiedź, ale myślę, że warto powiedzieć. volatile asminformuje GCC, że kod może mieć „ważne skutki uboczne” i zajmie się nim ze szczególną uwagą. Może nadal zostać usunięty w ramach optymalizacji martwego kodu lub przeniesiony. Interakcja z kodem C musi zakładać taki (rzadki) przypadek i narzucać ścisłą sekwencyjną ocenę (np. Poprzez tworzenie zależności w asm).
edmz
GNU C Basic asm (bez ograniczeń operandów, takich jak OP asm("")) jest niejawnie niestabilny, tak jak rozszerzony asm bez argumentów wyjściowych. GCC nie rozumie łańcucha szablonu asm, tylko ograniczenia; Dlatego jest to niezbędne , aby dokładnie i całkowicie opisać swój asm do kompilatora stosując ograniczeń. Podstawianie operandów do ciągu szablonu nie wymaga więcej zrozumienia niż printfużycie ciągu formatu. TL: DR: nie używaj GNU C Basic asm do niczego, może z wyjątkiem przypadków użycia takich jak ten z czystymi komentarzami.
Peter Cordes
-2

Ta odpowiedź jest teraz zmodyfikowana: pierwotnie została napisana z myślą o rozważaniu inline Basic Asm jako dość silnie sprecyzowanego narzędzia, ale w GCC to nic takiego. Podstawowy Asm jest słaby, więc odpowiedź została zredagowana.

Każdy komentarz zespołu działa jako punkt przerwania.

EDYCJA: Ale zepsuty, ponieważ używasz Basic Asm. Inline asm( asminstrukcja wewnątrz ciała funkcji) bez jawnej listy clobber jest słabo określoną funkcją w GCC i jej zachowanie jest trudne do zdefiniowania. Nie wydaje się (nie rozumiem w pełni jego gwarancji) związanego z czymkolwiek konkretnym, więc chociaż kod asemblera musi zostać uruchomiony w pewnym momencie, jeśli funkcja jest uruchomiona, nie jest jasne, kiedy jest uruchamiana dla dowolnego trywialny poziom optymalizacji . Punkt przerwania, który można zmienić za pomocą sąsiedniej instrukcji, nie jest zbyt użytecznym „punktem przerwania”. KONIEC EDYCJI

Możesz uruchomić swój program w interpretatorze, który przerywa każdy komentarz i wypisuje stan każdej zmiennej (używając informacji debugowania). Punkty te muszą istnieć, abyś obserwował otoczenie (stan rejestrów i pamięci).

Bez komentarza nie istnieje żaden punkt obserwacyjny, a pętla jest kompilowana jako pojedyncza funkcja matematyczna przyjmująca środowisko i tworząca zmodyfikowane środowisko.

Chcesz poznać odpowiedź na bezsensowne pytanie: chcesz wiedzieć, jak jest kompilowana każda instrukcja (lub może blok, a może zakres instrukcji), ale żadna pojedyncza izolowana instrukcja (lub blok) nie jest kompilowana; całość jest kompilowana jako całość.

Lepszym pytaniem byłoby:

Witam GCC. Dlaczego uważasz, że to wyjście asm implementuje kod źródłowy? Prosimy o wyjaśnienie krok po kroku, przy każdym założeniu.

Ale wtedy nie chciałbyś czytać dowodu dłuższego niż wyjście asm, napisane w ramach wewnętrznej reprezentacji GCC.

ciekawy facet
źródło
1
Punkty te muszą istnieć, abyś obserwował otoczenie (stan rejestrów i pamięci). - może to dotyczyć niezoptymalizowanego kodu. Po włączeniu optymalizacji całe funkcje mogą zniknąć z pliku binarnego. Mowa tutaj o kodzie zoptymalizowanym.
Bartek Banachewicz
1
Mówimy o złożeniu wygenerowanym w wyniku kompilacji z włączonymi optymalizacjami. Dlatego mylisz się twierdząc, że coś musi istnieć.
Bartek Banachewicz
1
Tak, IDK, dlaczego ktokolwiek by to zrobił, i zgódź się, że nikt nigdy nie powinien. Jak wyjaśnia link w moim ostatnim komentarzu, nikt nigdy nie powinien, i była debata na temat wzmocnienia go (np. Za pomocą ukrytego "memory"clobbera) jako bandaid dla istniejącego błędnego kodu, który z pewnością istnieje. Nawet w przypadku takich instrukcji, asm("cli")które wpływają tylko na część stanu architektury, której kod wygenerowany przez kompilator nie dotyka, nadal potrzebujesz uporządkowanego wrt. ładunki / magazyny generowane przez kompilator (np. jeśli wyłączasz przerwania wokół krytycznej sekcji).
Peter Cordes
1
Ponieważ przebicie czerwonej strefy nie jest bezpieczne, nawet nieefektywne ręczne zapisywanie / przywracanie rejestrów (za pomocą push / pop) wewnątrz instrukcji asm nie jest bezpieczne, chyba że add rsp, -128najpierw. Ale robienie tego jest po prostu oczywistym zamroczeniem.
Peter Cordes
1
Obecnie GCC traktuje Basic Asm dokładnie tak samo, jak asm("" :::)(niejawnie niestabilny, ponieważ nie ma danych wyjściowych, ale nie jest powiązany z resztą kodu przez zależności wejścia lub wyjścia "memory". I oczywiście nie dokonuje %operandzamiany w łańcuchu szablonu, więc %nie trzeba uciekać jako %%. Tak więc, zgodziliśmy się, wycofanie Basic Asm poza __attribute__((naked))funkcje i zakres globalny byłoby dobrym pomysłem.
Peter Cordes