Oglądam przemówienie Chandlera Carrutha w CppCon 2019:
Brak abstrakcyjnych kosztów zerowych
podaje w nim przykład tego, jak był zaskoczony tym, ile ponosisz koszty ogólne, używając std::unique_ptr<int>
ponad int*
; ten segment zaczyna się mniej więcej w punkcie czasowym 17:25.
Możesz rzucić okiem na wyniki kompilacji jego przykładowej pary fragmentów (godbolt.org) - aby być świadkiem, że rzeczywiście wydaje się, że kompilator nie jest skłonny przekazać wartości unikatowej_ptr - która w rzeczywistości w dolnej linii tylko adres - w rejestrze, tylko w prostej pamięci.
Jednym z punktów, które pan Carruth przytacza około 27:00, jest to, że C ++ ABI wymaga parametrów wartości (niektórych, ale nie wszystkich; być może - typów nieprymitywnych? Typów nieskrywalnych?) Do przekazania do pamięci zamiast w rejestrze.
Moje pytania:
- Czy to rzeczywiście wymóg ABI na niektórych platformach? (który?) A może to tylko pesymizacja w niektórych scenariuszach?
- Dlaczego taki jest ABI? To znaczy, jeśli pola struktury / klasy mieszczą się w rejestrach, a nawet w jednym rejestrze - dlaczego nie mielibyśmy być w stanie przekazać go w tym rejestrze?
- Czy komitet normalizacyjny C ++ omawiał ten punkt w ostatnich latach, czy kiedykolwiek?
PS - Aby nie pozostawiać tego pytania bez kodu:
Zwykły wskaźnik:
void bar(int* ptr) noexcept;
void baz(int* ptr) noexcept;
void foo(int* ptr) noexcept {
if (*ptr > 42) {
bar(ptr);
*ptr = 42;
}
baz(ptr);
}
Unikalny wskaźnik:
using std::unique_ptr;
void bar(int* ptr) noexcept;
void baz(unique_ptr<int> ptr) noexcept;
void foo(unique_ptr<int> ptr) noexcept {
if (*ptr > 42) {
bar(ptr.get());
*ptr = 42;
}
baz(std::move(ptr));
}
źródło
this
wskaźnika wskazującego prawidłową lokalizację.unique_ptr
ma to. Rozlanie rejestru w tym celu mogłoby w pewnym sensie negować całą optymalizację „przejścia do rejestru”.Odpowiedzi:
Jednym z przykładów jest dodatek binarnego interfejsu systemu V do aplikacji procesora architektury AMD64 . Ten ABI jest przeznaczony dla 64-bitowych procesorów kompatybilnych z x86 (architektura Linux x86_64). Jest on śledzony w systemach Solaris, Linux, FreeBSD, macOS, Windows Subsystem dla Linux:
Zauważ, że tylko 2 rejestry ogólnego przeznaczenia mogą być używane do przekazywania 1 obiektu za pomocą trywialnego konstruktora kopiowania i trywialnego destruktora, tzn
sizeof
. W rejestrach można przekazywać tylko wartości obiektów o wartości nie większej niż 16. Zobacz Konwencje wywoływane przez Agner Fog, aby uzyskać szczegółowe informacje na temat konwencji wywoływania, w szczególności § 7.1. Przekazywanie i zwracanie przedmiotów. Istnieją odrębne konwencje wywoływania dla przekazywania typów SIMD w rejestrach.Istnieją różne ABI dla innych architektur CPU.
Jest to szczegół implementacji, ale gdy obsługiwany jest wyjątek, podczas odwijania stosu zniszczone obiekty z automatycznym czasem przechowywania muszą być adresowalne względem ramki stosu funkcji, ponieważ rejestry zostały do tego czasu zablokowane. Kod rozwijania stosu wymaga adresów obiektów, aby wywoływać ich destruktory, ale obiekty w rejestrach nie mają adresu.
Pedantycznie niszczyciele działają na obiektach :
a obiekt nie może istnieć w C ++, jeśli nie jest dla niego przydzielana pamięć adresowalna , ponieważ tożsamością obiektu jest jego adres .
Gdy potrzebny jest adres obiektu z trywialnym konstruktorem kopii przechowywanym w rejestrach, kompilator może po prostu zapisać obiekt w pamięci i uzyskać adres. Jeśli konstruktor kopiowania nie jest trywialny, z drugiej strony kompilator nie może po prostu zapisać go w pamięci, musi raczej wywołać konstruktor kopiowania, który pobiera odwołanie, a zatem wymaga adresu obiektu w rejestrach. Konwencja wywoływania prawdopodobnie nie może zależeć od tego, czy konstruktor kopii został wstawiony w odbierającym, czy nie.
Innym sposobem myślenia o tym jest to, że w przypadku typów , które można łatwo kopiować, kompilator przenosi wartość obiektu w rejestrach, z którego obiekt można odzyskać w zwykłych magazynach pamięci, jeśli to konieczne. Na przykład:
na x86_64 z systemem V ABI kompiluje się w:
W swoim skłaniającym do myślenia przemówieniu Chandler Carruth wspomina, że przełomowa zmiana ABI może być konieczna (między innymi) do wdrożenia niszczycielskiego ruchu, który może poprawić sytuację. IMO, zmiana ABI mogłaby być niezniszczalna, gdyby funkcje korzystające z nowego ABI jawnie wyraziły zgodę na nowe połączenie, np. Zadeklarowały je w
extern "C++20" {}
bloku (ewentualnie w nowej wbudowanej przestrzeni nazw do migracji istniejących interfejsów API). Aby tylko kod skompilowany na podstawie nowych deklaracji funkcji z nowym łącznikiem mógł używać nowego ABI.Zauważ, że ABI nie ma zastosowania, gdy wywołana funkcja została wstawiona. Oprócz generowania kodu czasu łącza kompilator może wstawiać funkcje zdefiniowane w innych jednostkach tłumaczeniowych lub stosować niestandardowe konwencje wywoływania.
źródło
W przypadku typowych ABI nietrywialny destruktor -> nie może przekazać rejestrów
(Ilustracja punktu w odpowiedzi @ MaximEgorushkin na przykładzie @ Harolda w komentarzu; poprawiona zgodnie z komentarzem @ Yakk.)
Jeśli skompilujesz:
dostajesz:
tzn.
Foo
obiekt jest przekazywany dotest
rejestru (edi
), a także zwracany do rejestru (eax
).Gdy destruktor nie jest trywialny (jak na
std::unique_ptr
przykład OP) - typowe ABI wymagają umieszczenia na stosie. Dzieje się tak nawet wtedy, gdy destruktor w ogóle nie używa adresu obiektu.Tak więc, nawet w skrajnym przypadku destruktora, który nic nie robi, jeśli skompilujesz:
dostajesz:
z bezużytecznym załadunkiem i przechowywaniem.
źródło
std::unique_ptr
do rejestru niezgodne.register
kluczowe miało sprawić, że fizyczna maszyna będzie przechowywać coś w rejestrze, blokując rzeczy, które praktycznie utrudniają „brak adresu” na maszynie fizycznej.Jeśli coś jest widoczne na granicy jednostki kompilacji, to niezależnie od tego, czy jest zdefiniowane pośrednio czy jawnie, staje się częścią ABI.
Podstawowym problemem jest to, że rejestry są zapisywane i przywracane przez cały czas, gdy poruszasz się w dół i w górę stosu wywołań. Nie jest więc praktyczne, aby mieć do nich odniesienie lub wskaźnik.
In-lineing i wynikające z niego optymalizacje są miłe, kiedy to się dzieje, ale projektant ABI nie może na tym polegać. Muszą zaprojektować ABI w najgorszym przypadku. Nie sądzę, żeby programiści byli bardzo zadowoleni z kompilatora, w którym ABI zmieniał się w zależności od poziomu optymalizacji.
Trywialnie kopiowalny typ może być przekazywany do rejestrów, ponieważ operację kopiowania logicznego można podzielić na dwie części. Parametry są kopiowane do rejestrów używanych do przekazywania parametrów przez dzwoniącego, a następnie kopiowane do zmiennej lokalnej przez odbiorcę. To, czy zmienna lokalna ma miejsce w pamięci, czy nie, jest zatem wyłącznie sprawą osoby odbierającej.
Typ, w którym z drugiej strony musi być użyty konstruktor kopiowania lub przenoszenia, nie może mieć takiego podziału operacji kopiowania, więc musi zostać przekazany do pamięci.
Nie mam pojęcia, czy organy normalizacyjne wzięły to pod uwagę.
Oczywistym rozwiązaniem byłoby dla mnie dodanie do langauge właściwego ruchu niszczącego (zamiast obecnego domu w połowie „prawidłowego, ale poza tym nieokreślonego stanu”), a następnie wprowadzenie sposobu oznaczenia typu jako pozwalającego na „trywialne ruchy niszczące” „nawet jeśli nie pozwala na trywialne kopie.
ale takie rozwiązanie MUSI wymagać złamania ABI istniejącego kodu w celu zaimplementowania dla istniejących typów, co może przynieść spory opór (chociaż ABI pęka w wyniku nowych standardowych wersji C ++ nie jest bezprecedensowe, na przykład zmiany std :: string w C ++ 11 spowodowało przerwanie ABI ..
źródło
unique_ptr
ishared_ptr
semantyka:shared_ptr<T>
pozwala dostarczyć ctor 1) ptr x do obiektu pochodnego U, który ma zostać usunięty z typem statycznym U z wyrażeniemdelete x;
(więc nie potrzebujesz tutaj wirtualnego dtor) 2) lub nawet niestandardowa funkcja czyszczenia. Oznacza to, że stan środowiska wykonawczego jest używany wshared_ptr
bloku kontrolnym do kodowania tych informacji. OTOHunique_ptr
nie ma takiej funkcjonalności i nie koduje stanu usuwania; jedynym sposobem na dostosowanie czyszczenia jest utworzenie kolejnego instanazji szablonu (inny typ klasy).Najpierw musimy wrócić do tego, co oznacza przekazywanie wartości i odniesienie.
W przypadku języków takich jak Java i SML przekazywanie według wartości jest proste (i nie ma przekazywania przez odwołanie), podobnie jak kopiowanie wartości zmiennej, ponieważ wszystkie zmienne są tylko skalarami i mają wbudowane kopiowanie semantyczne: są one tym, co liczy się jako arytmetyka wpisz C ++ lub „referencje” (wskaźniki o innej nazwie i składni).
W C mamy typy skalarne i zdefiniowane przez użytkownika:
W C ++ typy zdefiniowane przez użytkownika mogą mieć zdefiniowane przez użytkownika semantyczne kopie, które umożliwiają prawdziwie „obiektowe” programowanie z obiektami posiadającymi ich zasoby i operacje „głębokiego kopiowania”. W takim przypadku operacja kopiowania jest tak naprawdę wywołaniem funkcji, która może prawie wykonywać dowolne operacje.
W przypadku struktur C skompilowanych jako C ++ „kopiowanie” jest nadal definiowane jako wywoływanie operacji kopiowania zdefiniowanej przez użytkownika (konstruktora lub operatora przypisania), które są domyślnie generowane przez kompilator. Oznacza to, że semantyka wspólnego programu podzbiorów C / C ++ jest różna w C i C ++: w C kopiowany jest cały typ agregatu, w C ++ wywoływana jest domyślnie funkcja kopiowania w celu skopiowania każdego elementu; końcowy wynik jest taki, że w każdym przypadku każdy członek jest kopiowany.
(Sądzę, że istnieje wyjątek, gdy kopiowana jest struktura wewnątrz związku).
Zatem dla typu klasy jedynym sposobem (poza kopiami unii) na utworzenie nowej instancji jest użycie konstruktora (nawet dla tych z trywialnymi konstruktorami generowanymi przez kompilator).
Nie możesz pobrać adresu wartości za pośrednictwem jednoargumentowego operatora,
&
ale to nie znaczy, że nie ma obiektu wartości; a obiekt z definicji ma adres ; a ten adres jest nawet reprezentowany przez konstrukcję składni: obiekt typu klasy może być utworzony tylko przez konstruktor i mathis
wskaźnik; ale dla trywialnych typów nie ma konstruktora napisanego przez użytkownika, więc nie ma miejsca na umieszczeniethis
dopóki kopia nie zostanie zbudowana i nazwana.Dla typu skalarnego wartością obiektu jest wartość obiektu, czysta wartość matematyczna przechowywana w obiekcie.
W przypadku typu klasy jedynym pojęciem wartości obiektu jest kolejna kopia obiektu, którą może wykonać tylko konstruktor kopii, prawdziwa funkcja (chociaż w przypadku typów trywialnych funkcja ta jest tak trywialna, że czasami może być utworzony bez wywoływania konstruktora). Oznacza to, że wartość obiektu jest wynikiem zmiany stanu programu globalnego przez wykonanie . Nie ma dostępu matematycznego.
Więc przekazanie przez wartość tak naprawdę nie jest rzeczą: przekazanie przez wywołanie konstruktora kopiowania , które jest mniej ładne. Oczekuje się, że konstruktor kopiowania wykona rozsądną operację „kopiowania” zgodnie z odpowiednią semantią typu obiektu, z poszanowaniem jego wewnętrznych niezmienników (które są abstrakcyjnymi właściwościami użytkownika, a nie wewnętrznymi właściwościami C ++).
Przekazywanie przez wartość obiektu klasy oznacza:
Zauważ, że problem nie ma nic wspólnego z tym, czy sama kopia jest obiektem o adresie: wszystkie parametry funkcji są obiektami i mają adres (na poziomie semantycznym języka).
Problem polega na tym, czy:
W przypadku trywialnego typu klasy nadal możesz zdefiniować element członkowski kopii oryginału, dzięki czemu możesz zdefiniować czystą wartość oryginału z powodu trywialności operacji kopiowania (konstruktor kopii i przypisanie). Nie jest tak w przypadku dowolnych specjalnych funkcji użytkownika: wartością oryginału musi być skonstruowana kopia.
Obiekt klasy musi zbudować obiekt wywołujący; konstruktor formalnie ma
this
wskaźnik, ale formalizm nie ma tutaj znaczenia: wszystkie obiekty formalnie mają adres, ale tylko te, które faktycznie używają swojego adresu w sposób nie tylko lokalny (w przeciwieństwie*&i = 1;
do tego, w jaki sposób adresowanie jest wyłącznie lokalne), muszą mieć dobrze zdefiniowane adres.Obiekt musi być absolutnie przekazany przez adres, jeśli wydaje się, że ma adres w obu tych osobno skompilowanych funkcjach:
Tutaj, nawet jeśli
something(address)
jest to czysta funkcja lub makro lub cokolwiek (jakprintf("%p",arg)
), które nie może przechowywać adresu lub komunikować się z innym bytem, musimy przekazać adres, ponieważ adres musi być dobrze zdefiniowany dla unikalnego obiektuint
który ma unikalny obiekt tożsamość.Nie wiemy, czy funkcja zewnętrzna będzie „czysta” pod względem przekazywanych do niej adresów.
W tym przypadku możliwość rzeczywistego wykorzystania adresu zarówno w trywialnym konstruktorze, jak i destruktorze po stronie dzwoniącego jest prawdopodobnie powodem podjęcia bezpiecznej, uproszczonej trasy i nadania obiektowi tożsamości w dzwoniącym i przekazania jego adresu, ponieważ powoduje upewnij się, że każde nietrywialne użycie jego adresu w konstruktorze, po budowie i w destruktorze jest spójne :
this
musi wydawać się takie samo w całym istnieniu obiektu.Nietrywialny konstruktor lub niszczyciel, jak każda inna funkcja, może używać
this
wskaźnika w sposób, który wymaga spójności jego wartości, nawet jeśli niektóre obiekty z nieistotnymi funkcjami mogą nie:Zauważ, że w takim przypadku, pomimo jawnego użycia wskaźnika (jawnej składni
this->
), tożsamość obiektu jest nieistotna: kompilator mógłby dobrze użyć bitowego kopiowania obiektu, aby go przenieść i wykonać „kopiowanie elision”. Jest to oparte na poziomie „czystości” użyciathis
w specjalnych funkcjach członkowskich (adres nie ucieka).Ale czystość nie jest atrybutem dostępnym na standardowym poziomie deklaracji (istnieją rozszerzenia kompilatora, które dodają opis czystości w deklaracji funkcji niewbudowanej), więc nie można zdefiniować ABI na podstawie czystości kodu, który może nie być dostępny (kod może lub mogą nie być wbudowane i dostępne do analizy).
Czystość mierzy się jako „z pewnością czystą” lub „nieczystą lub nieznaną”. Wspólna podstawa, czyli górna granica semantyki (właściwie maksimum), lub LCM (najmniejsza wspólna wielokrotność) jest „nieznana”. Więc ABI decyduje się na nieznane.
Podsumowanie:
Możliwe przyszłe prace:
Czy adnotacja czystości jest wystarczająca do uogólnienia i wystandaryzowania?
źródło
void foo(unique_ptr<int> ptr)
przyjmuje obiekt klasy według wartości . Ten obiekt ma element wskaźnikowy, ale mówimy o tym, że sam obiekt klasy jest przekazywany przez referencję. (Ponieważ nie jest to trywialne kopiowanie, więc jego konstruktor / destruktor potrzebuje spójnościthis
.) To jest prawdziwy argument i nie jest powiązany z pierwszym przykładem jawnego przekazania przez referencję ; w takim przypadku wskaźnik jest przekazywany do rejestru.int
: napisałem przykład „inteligentnego fileno”, który pokazuje, że „własność” nie ma nic wspólnego z „przenoszeniem ptr”.unique_ptr<T*>
ten ma taki sam rozmiar i układ jakT*
i wpisuje się w rejestrze. Obiekty klasy, które można kopiować, mogą być przekazywane przez wartości do rejestrów w systemie x86-64 System V, podobnie jak większość konwencji wywoływania. To sprawia, że kopię tegounique_ptr
obiektu, w przeciwieństwie do swojejint
przykład gdy wywoływany użytkownika&i
jest adres osoby dzwoniąceji
, ponieważ przekazywane przez referencję na poziomie C ++ , a nie tylko jako szczegółach realizacji ASM.unique_ptr
obiektu; używa,std::move
więc można go bezpiecznie skopiować, ponieważ nie da to 2 kopii tego samegounique_ptr
. Ale dla typu, który można w prosty sposób skopiować, tak, kopiuje on cały obiekt agregujący. Jeśli jest to pojedynczy element członkowski, dobre konwencje wywoływania traktują to tak samo jak skalar tego typu.struct{}
jest strukturą C ++. Być może powinieneś powiedzieć „zwykłe struktury” lub „w przeciwieństwie do C”. Ponieważ tak, jest różnica. Jeśli użyjeszatomic_int
jako członka struktury, C skopiuje go nieatomowo, błąd C ++ na usuniętym konstruktorze kopiowania. Zapominam, co C ++ robi na strukturach zvolatile
członkami. C pozwoli cistruct tmp = volatile_struct;
skopiować całą rzecz (przydatne dla SeqLock); C ++ nie.