Dlaczego GCC inicjuje agregację tablicy najpierw wypełniając całość zerami, w tym elementami niezerowymi?

21

Dlaczego gcc wypełnia całą tablicę zerami zamiast tylko pozostałych 96 liczb całkowitych? Wszystkie niezerowe inicjalizatory znajdują się na początku tablicy.

void *sink;
void bar() {
    int a[100]{1,2,3,4};
    sink = a;             // a escapes the function
    asm("":::"memory");   // and compiler memory barrier
    // forces the compiler to materialize a[] in memory instead of optimizing away
}

Zarówno MinGW8.1, jak i gcc9.2 tworzą asm ( eksplorator kompilatora Godbolt ).

# gcc9.2 -O3 -m32 -mno-sse
bar():
    push    edi                       # save call-preserved EDI which rep stos uses
    xor     eax, eax                  # eax=0
    mov     ecx, 100                  # repeat-count = 100
    sub     esp, 400                  # reserve 400 bytes on the stack
    mov     edi, esp                  # dst for rep stos
        mov     DWORD PTR sink, esp       # sink = a
    rep stosd                         # memset(a, 0, 400) 

    mov     DWORD PTR [esp], 1        # then store the non-zero initializers
    mov     DWORD PTR [esp+4], 2      # over the zeroed part of the array
    mov     DWORD PTR [esp+8], 3
    mov     DWORD PTR [esp+12], 4
 # memory barrier empty asm statement is here.

    add     esp, 400                  # cleanup the stack
    pop     edi                       # and restore caller's EDI
    ret

(z włączonym SSE kopiowałby wszystkie 4 inicjatory z movdqa load / store)

Dlaczego GCC nie robi lea edi, [esp+16]i nie zapisuje (z rep stosd) tylko ostatnich 96 elementów, tak jak Clang? Czy jest to pominięta optymalizacja, czy może jest to w jakiś sposób bardziej wydajne? (Clang faktycznie dzwoni memsetzamiast wstawiania rep stos)


Uwaga edytora: pytanie pierwotnie zawierało niezoptymalizowane wyjście kompilatora, które działało w ten sam sposób, ale nieefektywny kod w -O0nic nie dowodzi. Okazuje się jednak, że GCC nie dostrzega tej optymalizacji nawet przy -O3.

Przekazywanie wskaźnika do afunkcji innej niż wbudowana byłoby innym sposobem zmuszenia kompilatora do zmaterializowania się a[], ale w 32-bitowym kodzie, który prowadzi do znacznego zaśmiecenia asm. (Argumenty stosu powodują wypychanie, które zostaje wmieszane ze sklepami do stosu w celu zainicjowania tablicy).

Użycie volatile a[100]{1,2,3,4}powoduje, że GCC tworzy, a następnie kopiuje tablicę, co jest szalone. Zwykle volatiledobrze jest sprawdzić, jak kompilatory inicjują zmienne lokalne lub układają je na stosie.

Makolągwa
źródło
1
@Damien Źle zrozumiałeś moje pytanie. Pytam, dlaczego na przykład a [0] ma przypisaną wartość dwa razy, jak gdyby a[0] = 0;i wtedy a[0] = 1;.
Lassie
1
Nie jestem w stanie odczytać zestawu, ale gdzie to pokazuje, że tablica jest całkowicie wypełniona zerami?
smac89
3
Kolejny interesujący fakt: w przypadku większej liczby zainicjowanych elementów zarówno gcc, jak i clang powracają do kopiowania całej tablicy z .rodata... Nie mogę uwierzyć, że skopiowanie 400 bajtów jest szybsze niż zerowanie i ustawienie 8 elementów.
Jester
2
Wyłączyłeś optymalizację; nieefektywny kod nie jest zaskakujący, dopóki nie zweryfikujesz, że to samo dzieje się w -O3(co robi). godbolt.org/z/rh_TNF
Peter Cordes
12
Co jeszcze chcesz wiedzieć? To pominięta optymalizacja, zgłoś to w Bugzilli GCC za pomocą missed-optimizationsłowa kluczowego.
Peter Cordes

Odpowiedzi:

2

Teoretycznie inicjalizacja może wyglądać tak:

int a[100] = {
  [3] = 1,
  [5] = 42,
  [88] = 1,
};

więc może być bardziej efektywne w sensie pamięci podręcznej i optymalizacji, aby najpierw wyzerować cały blok pamięci, a następnie ustawić poszczególne wartości.

Mogą być zmiany zachowania w zależności od:

  • architektura docelowa
  • docelowy system operacyjny
  • długość tablicy
  • współczynnik inicjalizacji (jawnie zainicjowane wartości / długość)
  • pozycje zainicjowanych wartości

Oczywiście w twoim przypadku inicjalizacja jest kompaktowana na początku tablicy, a optymalizacja byłaby trywialna.

Wygląda więc na to, że gcc stosuje tutaj najbardziej ogólne podejście. Wygląda na brakującą optymalizację.

vlad_tepesch
źródło
Tak, optymalną strategią dla tego kodu prawdopodobnie byłoby wyzerowanie wszystkiego, a może po prostu wszystko, zaczynając od a[6]początku, z wczesnymi lukami wypełnionymi pojedynczymi zbiorami bezpośrednich lub zer. Zwłaszcza, jeśli celujesz w x86-64, abyś mógł używać sklepów qword do zrobienia 2 elementów jednocześnie, przy czym dolny jest niezerowy. np. mov QWORD PTR [rsp+3*4], 1zrobić elementy 3 i 4 z jednym źle dopasowanym magazynem qword.
Peter Cordes
Zachowanie może teoretycznie zależeć od docelowego systemu operacyjnego, ale w rzeczywistości GCC nie będzie i nie ma powodu. Tylko architektura docelowa (w tym opcje strojenia dla różnych mikroarchitektur, takich jak -march=skylakevs. -march=k8vs. -march=knlbyłyby ogólnie bardzo różne, i być może pod względem odpowiedniej strategii do tego.)
Peter Cordes
Czy jest to dozwolone nawet w C ++? Myślałem, że to tylko C.
Lassie
@Lassie masz rację w c ++ to nie jest dozwolone, ale pytanie to jest bardziej związane z backendem kompilatora, więc nie ma to większego znaczenia. pokazany kod może być jednocześnie oba
vlad_tepesch
Możesz nawet łatwo skonstruować przykłady, które działają tak samo w C ++, deklarując niektóre struct Bar{ int i; int a[100]; int j;} i inicjując Bar a{1,{2,3,4},4};gcc, robi to samo:
wyzeruj