Czy nieużywana zmienna składowa zajmuje pamięć?

92

Czy inicjowanie zmiennej składowej i brak odwoływania się do niej / używania jej dalej zajmuje pamięć RAM w czasie wykonywania, czy też kompilator po prostu ignoruje tę zmienną?

struct Foo {
    int var1;
    int var2;

    Foo() { var1 = 5; std::cout << var1; }
};

W powyższym przykładzie element członkowski „var1” pobiera wartość, która jest następnie wyświetlana w konsoli. „Var2” nie jest jednak w ogóle używany. Dlatego zapisywanie go w pamięci podczas działania byłoby stratą zasobów. Czy kompilator bierze pod uwagę tego typu sytuacje i po prostu ignoruje nieużywane zmienne, czy też obiekt Foo jest zawsze tej samej wielkości, niezależnie od tego, czy używane są jego składowe?

Chriss555888
źródło
25
Zależy to od kompilatora, architektury, systemu operacyjnego i zastosowanej optymalizacji.
Sowa
16
Istnieje tona metryczna niskopoziomowego kodu sterownika, który specjalnie dodaje elementy strukturalne, które nie robią nic, aby dopełniać w celu dopasowania rozmiarów ramek danych sprzętowych i jako sztuczka, aby uzyskać pożądane wyrównanie pamięci. Gdyby kompilator zaczął je optymalizować, nastąpiłoby wiele awarii.
Andy Brown
2
@Andy, tak naprawdę nie robią nic, ponieważ szacowany jest adres następujących członków danych. Oznacza to, że istnienie tych elementów wypełniających ma w programie obserwowalne zachowanie. Tutaj var2nie.
YSC
4
Byłbym zaskoczony, gdyby kompilator mógł go zoptymalizować, biorąc pod uwagę, że każda jednostka kompilacji adresująca taką strukturę może zostać połączona z inną jednostką kompilacji przy użyciu tej samej struktury, a kompilator nie może wiedzieć, czy oddzielna jednostka kompilacji adresuje element członkowski, czy nie.
Galik
2
@geza sizeof(Foo)nie może zmniejszać się z definicji - jeśli drukujesz sizeof(Foo), musi dać 8(na popularnych platformach). Kompilatory mogą zoptymalizować przestrzeń używaną przez var2(bez względu na to, czy przez, newczy na stosie, czy w wywołaniach funkcji ...) w dowolnym kontekście, który uznają za rozsądny, nawet bez LTO lub optymalizacji całego programu. Tam, gdzie nie jest to możliwe, nie zrobią tego, jak w przypadku każdej innej optymalizacji. Uważam, że zmiana zaakceptowanej odpowiedzi znacznie zmniejsza prawdopodobieństwo wprowadzenia jej w błąd.
Max Langhof

Odpowiedzi:

107

Złota reguła „as-if” 1 w C ++ stwierdza, że jeśli obserwowalne zachowanie programu nie zależy od istnienia nieużywanego elementu danych, kompilator może go zoptymalizować .

Czy nieużywana zmienna składowa zajmuje pamięć?

Nie (jeśli jest „naprawdę” nieużywany).


Teraz przychodzą na myśl dwa pytania:

  1. Kiedy obserwowalne zachowanie nie zależałoby od istnienia członka?
  2. Czy takie sytuacje występują w programach z życia wziętych?

Zacznijmy od przykładu.

Przykład

#include <iostream>

struct Foo1
{ int var1 = 5;           Foo1() { std::cout << var1; } };

struct Foo2
{ int var1 = 5; int var2; Foo2() { std::cout << var1; } };

void f1() { (void) Foo1{}; }
void f2() { (void) Foo2{}; }

Jeśli poprosimy gcc o skompilowanie tej jednostki tłumaczeniowej , wyświetli:

f1():
        mov     esi, 5
        mov     edi, OFFSET FLAT:_ZSt4cout
        jmp     std::basic_ostream<char, std::char_traits<char> >::operator<<(int)
f2():
        jmp     f1()

f2jest tym samym, co f1i żadna pamięć nie jest nigdy używana do przechowywania rzeczywistego Foo2::var2. ( Clang robi coś podobnego ).

Dyskusja

Niektórzy mogą powiedzieć, że jest inaczej z dwóch powodów:

  1. to jest zbyt trywialny przykład,
  2. struktura jest całkowicie zoptymalizowana, nie liczy się.

Cóż, dobry program to sprytny i złożony zestaw prostych rzeczy, a nie proste zestawienie złożonych rzeczy. W rzeczywistości piszesz mnóstwo prostych funkcji przy użyciu prostych struktur, które kompilator optymalizuje. Na przykład:

bool insert(std::set<int>& set, int value)
{
    return set.insert(value).second;
}

To jest prawdziwy przykład std::pair<std::set<int>::iterator, bool>::firstnieużywanego elementu danych (tutaj ). Zgadnij co? Jest zoptymalizowany ( prostszy przykład z zestawem atrap, jeśli ten zestaw powoduje, że płaczesz).

Teraz byłby idealny czas na przeczytanie doskonałej odpowiedzi Maxa Langhofa (proszę o głos za mną). Wyjaśnia, dlaczego ostatecznie koncepcja struktury nie ma sensu na poziomie montażu kompilatora.

„Ale jeśli zrobię X, fakt, że nieużywany członek jest zoptymalizowany, jest problemem!”

Pojawiło się wiele komentarzy, które argumentowały, że ta odpowiedź musi być błędna, ponieważ jakaś operacja (taka jak assert(sizeof(Foo2) == 2*sizeof(int))) mogłaby coś zepsuć.

Jeśli X jest częścią obserwowalnego zachowania programu 2 , kompilator nie może zoptymalizować rzeczy. Istnieje wiele operacji na obiekcie zawierającym „nieużywany” element danych, które miałyby zauważalny wpływ na program. Jeśli taka operacja jest wykonywana lub kompilator nie może udowodnić, że żadna nie została wykonana, ten „nieużywany” element członkowski danych jest częścią obserwowalnego zachowania programu i nie może zostać zoptymalizowany .

Operacje wpływające na obserwowalne zachowanie obejmują między innymi:

  • przyjmowanie rozmiaru typu obiektu ( sizeof(Foo)),
  • podanie adresu członka danych zadeklarowanego po „nieużywanym”,
  • kopiowanie obiektu z funkcją typu memcpy,
  • manipulowanie reprezentacją obiektu (jak z memcmp),
  • kwalifikowanie obiektu jako ulotnego ,
  • itp .

1)

[intro.abstract]/1

Opisy semantyczne w tym dokumencie definiują sparametryzowaną niedeterministyczną maszynę abstrakcyjną. Dokument ten nie nakłada żadnych wymagań na strukturę zgodnych wdrożeń. W szczególności nie muszą kopiować ani naśladować struktury abstrakcyjnej maszyny. Raczej zgodne implementacje są wymagane do emulowania (tylko) obserwowalnego zachowania abstrakcyjnej maszyny, jak wyjaśniono poniżej.

2) Podobnie jak w przypadku stwierdzenia, że ​​przejdzie lub upadnie.

YSC
źródło
Komentarze sugerujące ulepszenia odpowiedzi zostały zarchiwizowane na czacie .
Cody Grey
1
Nawet w assert(sizeof(…)…)rzeczywistości nie ogranicza kompilatora - musi zapewnić sizeofkod, który pozwala kodowi używać rzeczy takich jak memcpydziałanie, ale to nie znaczy, że kompilator jest w jakiś sposób zobowiązany do użycia tak wielu bajtów, chyba że mogą być narażone na takie, memcpyktóre mogą i tak nie przepisuj, aby uzyskać poprawną wartość.
Davis Herring
@Davis Absolutely.
YSC
64

Ważne jest, aby zdać sobie sprawę, że kod, który tworzy kompilator, nie ma rzeczywistej wiedzy o twoich strukturach danych (ponieważ coś takiego nie istnieje na poziomie zespołu), podobnie jak optymalizator. Kompilator tworzy tylko kod dla każdej funkcji , a nie struktury danych .

Ok, zapisuje też stałe sekcje danych i takie.

Na tej podstawie możemy już powiedzieć, że optymalizator nie „usunie” ani „wyeliminuje” elementów, ponieważ nie wyprowadza struktur danych. Wyprowadza kod , który może lub nie może korzystać z elementów członkowskich, a jednym z jego celów jest oszczędzanie pamięci lub cykli poprzez eliminację bezcelowych zastosowań (tj. Zapisywanie / odczytywanie) elementów członkowskich.


Istota tego polega na tym, że „jeśli kompilator może udowodnić w zakresie funkcji (w tym funkcji, które zostały do ​​niej wstawione), że nieużywany element członkowski nie ma znaczenia dla sposobu działania funkcji (i tego, co zwraca), to są duże szanse, że obecność członka nie powoduje kosztów ogólnych ”.

Gdy sprawisz, że interakcje funkcji ze światem zewnętrznym będą bardziej skomplikowane / niejasne dla kompilatora (pobierz / zwróć bardziej złożone struktury danych, np. A std::vector<Foo>, ukryj definicję funkcji w innej jednostce kompilacji, zabroń / zniechęcaj do wstawiania itp.) , staje się coraz bardziej prawdopodobne, że kompilator nie może udowodnić, że nieużywany element członkowski nie ma żadnego efektu.

Nie ma tutaj sztywnych reguł, ponieważ wszystko zależy od optymalizacji dokonanych przez kompilator, ale dopóki robisz trywialne rzeczy (takie jak pokazane w odpowiedzi YSC), jest bardzo prawdopodobne, że nie będzie żadnego narzutu, podczas gdy robienie skomplikowanych rzeczy (np. a std::vector<Foo>z funkcji zbyt dużej do umieszczenia w tekście) prawdopodobnie spowoduje koszty ogólne.


Aby zilustrować ten punkt, rozważ następujący przykład :

struct Foo {
    int var1 = 3;
    int var2 = 4;
    int var3 = 5;
};

int test()
{
    Foo foo;
    std::array<char, sizeof(Foo)> arr;
    std::memcpy(&arr, &foo, sizeof(Foo));
    return arr[0] + arr[4];
}

Robimy tutaj nietrywialne rzeczy (pobieramy adresy, sprawdzamy i dodajemy bajty z reprezentacji bajtów ), a mimo to optymalizator może dowiedzieć się, że wynik jest zawsze taki sam na tej platformie:

test(): # @test()
  mov eax, 7
  ret

Członkowie nie tylko Foonie zajmowali pamięci, a Foonawet nie zaistnieli! Jeśli istnieją inne zastosowania, których nie można zoptymalizować, np. sizeof(Foo)Może to mieć znaczenie - ale tylko dla tego segmentu kodu! Gdyby wszystkie zastosowania można było zoptymalizować w ten sposób, wówczas istnienie np. var3Nie wpływa na generowany kod. Ale nawet jeśli zostanie użyty w innym miejscu, test()pozostanie zoptymalizowany!

W skrócie: każde użycie Foojest optymalizowane niezależnie. Niektórzy mogą zużywać więcej pamięci z powodu niepotrzebnego członka, inni mogą nie. Więcej informacji znajdziesz w instrukcji kompilatora.

Max Langhof
źródło
6
Porzucenie mikrofonu „Więcej informacji znajdziesz w instrukcji kompilatora”. : D
YSC
22

Kompilator zoptymalizuje nieużywaną zmienną składową (szczególnie publiczną) tylko wtedy, gdy może udowodnić, że usunięcie zmiennej nie ma skutków ubocznych i że żadna część programu nie zależy od jej rozmiaru Foo.

Nie sądzę, aby jakikolwiek obecny kompilator przeprowadzał takie optymalizacje, chyba że struktura w rzeczywistości nie jest w ogóle używana. Niektóre kompilatory mogą przynajmniej ostrzegać przed nieużywanymi zmiennymi prywatnymi, ale zazwyczaj nie w przypadku publicznych.

Alan Birtles
źródło
1
A jednak to robi: godbolt.org/z/UJKguS + żaden kompilator nie ostrzegłby o nieużywanym składniku danych.
YSC
@YSC clang ++ ostrzega o nieużywanych składowych danych i zmiennych.
Maxim Egorushkin
3
@YSC Myślę, że to trochę inna sytuacja, całkowicie zoptymalizował strukturę i po prostu drukuje bezpośrednio 5
Alan Birtles
4
@AlanBirtles Nie widzę różnicy. Kompilator zoptymalizował wszystko, począwszy od obiektu, co nie ma wpływu na obserwowalne zachowanie programu. Więc twoje pierwsze zdanie "jest bardzo mało prawdopodobne, aby kompilator zoptymalizował awau nieużywaną zmienną składową" jest błędne.
YSC
2
@YSC w prawdziwym kodzie, w którym struktura jest faktycznie używana, a nie tylko konstruowana pod kątem skutków ubocznych, prawdopodobnie jest mniej prawdopodobne, że zostanie zoptymalizowana
Alan Birtles
7

Ogólnie musisz założyć, że otrzymujesz to, o co prosiłeś, na przykład „nieużywane” zmienne składowe są tam.

Ponieważ w twoim przykładzie oba elementy członkowskie są public, kompilator nie może wiedzieć, czy jakiś kod (szczególnie z innych jednostek tłumaczeniowych = inne pliki * .cpp, które są oddzielnie kompilowane, a następnie łączone) uzyska dostęp do „nieużywanego” elementu członkowskiego.

Odpowiedź YSC daje bardzo prosty przykład, w którym typ klasy jest używany tylko jako zmienna automatycznego czasu trwania przechowywania i gdzie nie jest brany wskaźnik do tej zmiennej. Tam kompilator może wstawić cały kod, a następnie może wyeliminować cały martwy kod.

Jeśli masz interfejsy między funkcjami zdefiniowanymi w różnych jednostkach tłumaczeniowych, zwykle kompilator nic nie wie. Interfejsy są zwykle zgodne z pewnymi predefiniowanymi ABI (takimi jak ten ), tak że różne pliki obiektowe mogą być połączone ze sobą bez żadnych problemów. Zazwyczaj ABI nie mają znaczenia, czy element członkowski jest używany, czy nie. Tak więc w takich przypadkach drugi element musi fizycznie znajdować się w pamięci (chyba że zostanie później usunięty przez łącznik).

Dopóki znajdujesz się w granicach języka, nie możesz zauważyć, że ma miejsce jakakolwiek eliminacja. Jeśli zadzwonisz sizeof(Foo), dostaniesz 2*sizeof(int). Jeśli utworzysz tablicę Foos, odległość między początkami dwóch kolejnych obiektów Foowynosi zawsze sizeof(Foo)bajty.

Twój typ to standardowy typ układu , co oznacza, że ​​możesz również uzyskać dostęp do elementów członkowskich na podstawie przesunięć obliczonych w czasie kompilacji (por. offsetofMakro). Ponadto można sprawdzić reprezentację obiektu bajt po bajcie, kopiując ją do tablicy charusing std::memcpy. We wszystkich tych przypadkach można zaobserwować obecność drugiego członka.

Handy999
źródło
Komentarze nie służą do rozszerzonej dyskusji; ta rozmowa została przeniesiona do czatu .
Cody Grey
2
+1: tylko agresywna optymalizacja całego programu mogłaby ewentualnie dostosować układ danych (w tym rozmiary i przesunięcia w czasie kompilacji) w przypadkach, gdy lokalny obiekt struktury nie jest całkowicie zoptymalizowany,. gcc -fwhole-program -O3 *.cteoretycznie mógłby to zrobić, ale w praktyce prawdopodobnie nie. (np. w przypadku, gdy program przyjmuje jakieś założenia dotyczące dokładnej wartości sizeof()tego celu i ponieważ jest to naprawdę skomplikowana optymalizacja, którą programiści powinni wykonać ręcznie, jeśli chcą.)
Peter Cordes
6

Przykłady innych odpowiedzi na to pytanie, które elide var2opierają się na jednej technice optymalizacji: ciągłej propagacji, a następnie elizji całej struktury (a nie elizji sprawiedliwej var2). Jest to prosty przypadek i optymalizujące kompilatory go implementują.

W przypadku niezarządzanych kodów C / C ++ odpowiedź jest taka, że ​​kompilator generalnie nie pominie var2. O ile wiem, nie ma wsparcia dla takiej transformacji struktury C / C ++ w debugowaniu informacji, a jeśli struktura jest dostępna jako zmienna w debugerze, var2nie można jej usunąć. O ile wiem, żaden obecny kompilator C / C ++ nie może specjalizować funkcji zgodnie z elision of var2, więc jeśli struktura jest przekazywana lub zwracana z funkcji var2niewymienionej, nie można jej usunąć.

W przypadku języków zarządzanych, takich jak C # / Java z kompilatorem JIT, kompilator może być w stanie bezpiecznie usunąć, var2ponieważ może dokładnie śledzić, czy jest używany i czy przechodzi do niezarządzanego kodu. Fizyczny rozmiar struktury w językach zarządzanych może różnić się od rozmiaru zgłaszanego programiście.

Kompilatory języka C / C ++ roku 2019 nie mogą zostać usunięte var2ze struktury, chyba że cała zmienna struktury zostanie usunięta. Dla interesujących przypadków elizji var2ze struktury odpowiedź brzmi: Nie.

Niektóre przyszłe kompilatory C / C ++ będą mogły odejść var2od struktury, a ekosystem zbudowany wokół kompilatorów będzie musiał dostosować się do przetwarzania informacji generowanych przez kompilatory.

symbol atomu
źródło
1
Twój akapit dotyczący informacji o debugowaniu sprowadza się do stwierdzenia: „Nie możemy ich zoptymalizować, jeśli utrudniłoby to debugowanie”, co jest po prostu błędne. Albo źle czytam. Czy mógłbyś wyjaśnić?
Max Langhof
Jeśli kompilator emituje informacje debugowania dotyczące struktury, nie może usunąć zmiennej var2. Dostępne opcje: (1) Nie emituj informacji debugowania, jeśli nie odpowiadają one fizycznej reprezentacji struktury, (2) Wspieraj elizję członka struktury w informacjach debugowania i emituj informacje debugowania
symbol atomu
Być może bardziej ogólne jest odniesienie się do skalarnej zamiany agregatów (a następnie elizji martwych zapasów itp .).
Davis Herring
4

Zależy to od Twojego kompilatora i jego poziomu optymalizacji.

W gcc, jeśli określisz -O, włączy następujące flagi optymalizacji :

-fauto-inc-dec 
-fbranch-count-reg 
-fcombine-stack-adjustments 
-fcompare-elim 
-fcprop-registers 
-fdce
-fdefer-pop
...

-fdceoznacza eliminację martwego kodu .

Możesz użyć, __attribute__((used))aby uniemożliwić gcc wyeliminowanie nieużywanej zmiennej z pamięcią statyczną:

Ten atrybut, dołączony do zmiennej z pamięcią statyczną, oznacza, że ​​zmienna musi zostać wyemitowana, nawet jeśli wydaje się, że nie ma do niej odwołania.

W przypadku zastosowania do statycznego elementu członkowskiego szablonu klasy C ++ atrybut oznacza również, że element członkowski jest tworzony, jeśli tworzona jest instancja samej klasy.

wonter
źródło
Dotyczy to statycznych elementów członkowskich danych, a nie nieużywanych elementów członkowskich na wystąpienie (które nie są optymalizowane, chyba że robi się to cały obiekt). Ale tak, myślę, że to się liczy. BTW, eliminacja nieużywanych zmiennych statycznych nie jest eliminacją martwego kodu , chyba że GCC nagnie termin.
Peter Cordes