Ile kosztuje narzut inteligentnych wskaźników w porównaniu do zwykłych wskaźników w C ++?

102

Ile kosztuje narzut inteligentnych wskaźników w porównaniu do zwykłych wskaźników w C ++ 11? Innymi słowy, czy mój kod będzie wolniejszy, jeśli użyję inteligentnych wskaźników, a jeśli tak, to o ile wolniej?

W szczególności pytam o C ++ 11 std::shared_ptri std::unique_ptr.

Oczywiście rzeczy zepchnięte w dół będą większe (przynajmniej tak mi się wydaje), ponieważ inteligentny wskaźnik musi również przechowywać swój stan wewnętrzny (liczbę referencji itp.), Pytanie naprawdę brzmi, ile to będzie wpłynąć na moje wyniki, jeśli w ogóle?

Na przykład zwracam inteligentny wskaźnik z funkcji zamiast normalnego wskaźnika:

std::shared_ptr<const Value> getValue();
// versus
const Value *getValue();

Lub, na przykład, gdy jedna z moich funkcji akceptuje inteligentny wskaźnik jako parametr zamiast zwykłego wskaźnika:

void setValue(std::shared_ptr<const Value> val);
// versus
void setValue(const Value *val);
Venemo
źródło
8
Jedynym sposobem, aby to wiedzieć, jest testowanie kodu.
Basile Starynkevitch
Który masz na myśli? std::unique_ptrczy std::shared_ptr?
stefan
10
Odpowiedź brzmi 42. (innymi słowy, kto wie, musisz sprofilować swój kod i zrozumieć swój sprzęt dla typowego obciążenia pracą).
Nim
Twoja aplikacja musi maksymalnie wykorzystywać inteligentne wskaźniki, aby była znacząca.
user2672165
Koszt użycia shared_ptr w prostej funkcji ustawiającej jest straszny i spowoduje wielokrotne 100% obciążenie.
Lothar

Odpowiedzi:

178

std::unique_ptr ma narzut pamięci tylko wtedy, gdy dostarczysz mu jakiś nietrywialny element usuwający.

std::shared_ptr zawsze ma narzut pamięci dla licznika odniesienia, chociaż jest bardzo mały.

std::unique_ptr ma narzut czasowy tylko podczas konstruktora (jeśli musi skopiować podany deleter i / lub null-zainicjować wskaźnik) i podczas destruktora (aby zniszczyć posiadany obiekt).

std::shared_ptrma narzut czasowy w konstruktorze (aby utworzyć licznik odniesienia), w destruktorze (aby zmniejszyć licznik odniesienia i ewentualnie zniszczyć obiekt) oraz w operatorze przypisania (aby zwiększyć licznik odniesienia). Ze względu na gwarancje bezpieczeństwa wątków std::shared_ptrte przyrosty / ubytki są niepodzielne, co zwiększa obciążenie.

Zauważ, że żadna z nich nie ma nadmiernego czasu związanego z dereferencją (w uzyskaniu odniesienia do posiadanego obiektu), podczas gdy ta operacja wydaje się być najbardziej powszechna dla wskaźników.

Podsumowując, jest pewien narzut, ale nie powinien spowalniać kodu, chyba że ciągle tworzysz i niszczysz inteligentne wskaźniki.

lisyarus
źródło
11
unique_ptrnie ma narzutu w destruktorze. Robi dokładnie to samo, co w przypadku surowego wskaźnika.
R. Martinho Fernandes
6
@ R.MartinhoFernandes w porównaniu do samego wskaźnika surowego, ma narzut czasowy w destruktorze, ponieważ destruktor surowego wskaźnika nic nie robi. W porównaniu do tego, jak prawdopodobnie byłby używany surowy wskaźnik, z pewnością nie ma narzutów.
lisyarus
3
Warto zauważyć, że część kosztów budowy / zniszczenia / przypisania shared_ptr wynika z bezpieczeństwa wątków
Joe
1
A co z domyślnym konstruktorem std::unique_ptr? Jeśli skonstruujesz a std::unique_ptr<int>, wewnętrzny int*zostanie zainicjalizowany, nullptrczy ci się to podoba, czy nie.
Martin Drozdik
1
@MartinDrozdik W większości sytuacji zerowałbyś również surowy wskaźnik, aby później sprawdzić jego wartość lub coś w tym rodzaju. Niemniej jednak, dodałem to do odpowiedzi, dziękuję.
lisyarus
26

Podobnie jak w przypadku wszystkich wydajności kodu, jedynym naprawdę niezawodnym sposobem uzyskania twardych informacji jest pomiar i / lub kontrola kodu maszynowego.

To powiedziawszy, mówi to proste rozumowanie

  • Możesz spodziewać się trochę narzutu w kompilacjach debugowania, ponieważ np. operator->Musi być wykonywany jako wywołanie funkcji, abyś mógł do niego wejść (jest to z kolei z powodu ogólnego braku wsparcia dla oznaczania klas i funkcji jako niedebugowanych).

  • Ponieważ shared_ptrmożesz spodziewać się pewnego narzutu podczas początkowego tworzenia, ponieważ wiąże się to z dynamiczną alokacją bloku sterującego, a alokacja dynamiczna jest znacznie wolniejsza niż jakakolwiek inna podstawowa operacja w C ++ (używaj, make_sharedgdy jest to praktycznie możliwe, aby zminimalizować ten narzut).

  • Również ponieważ shared_ptristnieje pewien minimalny narzut związany z utrzymaniem liczby referencji, np. Podczas przekazywania shared_ptrwartości przez wartość, ale nie ma takiego narzutu w przypadku unique_ptr.

Mając na uwadze pierwszy punkt powyżej, mierząc, rób to zarówno dla kompilacji debugowania, jak i wydania.

Międzynarodowy komitet normalizacyjny C ++ opublikował raport techniczny dotyczący wydajności , ale był to rok 2006, wcześniej unique_ptri shared_ptrzostał dodany do biblioteki standardowej. Mimo to inteligentne wskazówki były wtedy starym kapeluszem, więc raport uwzględniał również to. Cytując odpowiednią część:

„Jeśli dostęp do wartości przez trywialny inteligentny wskaźnik jest znacznie wolniejszy niż dostęp do niej przez zwykły wskaźnik, kompilator nieefektywnie radzi sobie z abstrakcją. W przeszłości większość kompilatorów miała znaczące kary za abstrakcję, a kilka obecnych kompilatorów nadal to robi. Jednak zgłoszono, że co najmniej dwóch kompilatorów ma kary za abstrakcje poniżej 1%, a inny - 3%, więc wyeliminowanie tego rodzaju kosztów ogólnych jest zgodne z aktualnym stanem wiedzy ”

Zgodnie z przemyślanym przypuszczeniem, „zgodność ze stanem techniki” została osiągnięta dzięki najpopularniejszym obecnie kompilatorom, począwszy od początku 2014 r.

Pozdrawiam i hth. - Alf
źródło
Czy mógłby Pan zawrzeć w odpowiedzi kilka szczegółów dotyczących przypadków, które dodałem do mojego pytania?
Venemo,
Mogło to być prawdą 10 lub więcej lat temu, ale dziś sprawdzanie kodu maszynowego nie jest tak przydatne, jak sugeruje osoba powyżej. W zależności od tego, jak instrukcje są przetwarzane potokowo, wektoryzowane, ... i jak kompilator / procesor radzi sobie ze spekulacjami, ostatecznie zależy to od szybkości. Mniej kodu maszynowego niekoniecznie oznacza szybszy kod. Jedynym sposobem określenia wydajności jest jej sprofilowanie. Może się to zmienić w zależności od procesora, a także kompilatora.
Byron
Problem, który zauważyłem, polega na tym, że po użyciu shared_ptrs na serwerze, użycie shared_ptrs zaczyna się rozprzestrzeniać i wkrótce shared_ptrs stają się domyślną techniką zarządzania pamięcią. Więc teraz powtarzasz 1-3% kar za abstrakcję, które są przejmowane w kółko.
Nathan Doromal
Myślę, że testy porównawcze kompilacji do debugowania to kompletna i kompletna strata czasu
Paul Childs,
26

Moja odpowiedź różni się od innych i naprawdę zastanawiam się, czy kiedykolwiek sprofilowali kod.

shared_ptr ma znaczny narzut związany z tworzeniem z powodu alokacji pamięci dla bloku kontrolnego (który utrzymuje licznik ref i listę wskaźników do wszystkich słabych referencji). Ma również ogromne narzuty pamięci z powodu tego i faktu, że std :: shared_ptr jest zawsze krotką z dwoma wskaźnikami (jeden do obiektu, jeden do bloku sterującego).

Jeśli przekażesz shared_pointer do funkcji jako parametr wartości, będzie on co najmniej 10 razy wolniejszy niż normalne wywołanie i utworzy wiele kodów w segmencie kodu do rozwijania stosu. Jeśli przejdziesz to przez odniesienie, otrzymasz dodatkowe pośrednictwo, które może być również znacznie gorsze pod względem wydajności.

Dlatego nie powinieneś tego robić, chyba że funkcja jest naprawdę zaangażowana w zarządzanie własnością. W przeciwnym razie użyj „shared_ptr.get ()”. Nie jest zaprojektowany, aby upewnić się, że obiekt nie zostanie zabity podczas normalnego wywołania funkcji.

Jeśli oszalejesz i użyjesz shared_ptr na małych obiektach, takich jak abstrakcyjne drzewo składni w kompilatorze lub na małych węzłach w dowolnej innej strukturze wykresu, zobaczysz ogromny spadek wydajności i ogromny wzrost pamięci. Widziałem system parsera, który został przepisany wkrótce po wejściu C ++ 14 na rynek i zanim programista nauczył się poprawnie używać inteligentnych wskaźników. Przepisanie było o wielkość wolniejsze niż stary kod.

To nie jest srebrna kula, a surowe wskaźniki też nie są złe z definicji. Źli programiści są źli, a zły projekt jest zły. Projektuj ostrożnie, projektuj mając na uwadze jasne prawa własności i staraj się używać shared_ptr głównie na granicy API podsystemu.

Jeśli chcesz dowiedzieć się więcej, możesz obejrzeć dobrą rozmowę Nicolai M. Josuttisa o „Rzeczywistej cenie współdzielonych wskaźników w C ++” https://vimeo.com/131189627 Zagłębia
się w szczegóły implementacji i architekturę procesora dla barier zapisu, atomowej zamki itp. po wysłuchaniu nigdy nie powiesz, że ta funkcja jest tania. Jeśli chcesz mieć dowód wolniejszej wielkości, pomiń pierwsze 48 minut i zobacz, jak uruchamia przykładowy kod, który działa do 180 razy wolniej (skompilowany z -O3), gdy wszędzie używa wskaźnika współdzielonego.

Lothar
źródło
Dziękuję za odpowiedź! Na której platformie masz profil? Czy możesz poprzeć swoje roszczenia niektórymi danymi?
Venemo
Nie mam numeru do pokazania, ale można go znaleźć w rozmowie z Nico Josuttisem vimeo.com/131189627
Lothar
6
Słyszałeś kiedyś o std::make_shared()? Ponadto uważam, że demonstracje rażącego nadużycia są trochę nudne ...
Deduplicator,
2
Wszystko, co może zrobić "make_shared", to zabezpieczyć cię przed jednym dodatkowym przydziałem i dać ci trochę więcej lokalizacji pamięci podręcznej, jeśli blok kontrolny jest przydzielony przed obiektem. Nie może nic pomóc, gdy przesuwasz wskaźnik dookoła. To nie jest źródło problemów.
Lothar
14

Innymi słowy, czy mój kod będzie wolniejszy, jeśli użyję inteligentnych wskaźników, a jeśli tak, to o ile wolniej?

Wolniej? Najprawdopodobniej nie, chyba że tworzysz ogromny indeks za pomocą shared_ptrs i nie masz wystarczającej ilości pamięci do tego stopnia, że ​​komputer zaczyna marszczyć się, jak starsza pani spadająca na ziemię z daleka przez nieznośną siłę.

To, co spowolniłoby Twój kod, to powolne wyszukiwania, niepotrzebne przetwarzanie w pętli, ogromne kopie danych i wiele operacji zapisu na dysku (np. Setki).

Wszystkie zalety inteligentnego wskaźnika są związane z zarządzaniem. Ale czy koszty ogólne są konieczne? To zależy od implementacji. Powiedzmy, że wykonujesz iterację po tablicy 3 faz, z których każda ma tablicę 1024 elementów. Tworzenie smart_ptrdla tego procesu może być przesadą, ponieważ po zakończeniu iteracji będziesz wiedział, że musisz go usunąć. Dzięki temu możesz zyskać dodatkową pamięć, nie używając smart_ptr...

Ale czy naprawdę chcesz to zrobić?

Pojedynczy wyciek pamięci może spowodować, że twój produkt będzie miał punkt awarii w czasie (powiedzmy, że twój program wycieka 4 megabajty na godzinę, uszkodzenie komputera zajęłoby miesiące, niemniej jednak zepsuje się, wiesz o tym, ponieważ wyciek tam jest) .

To tak, jakby powiedzieć „Twoje oprogramowanie jest objęte gwarancją przez 3 miesiące, więc zadzwoń do mnie po serwis”.

Więc ostatecznie to naprawdę kwestia ... czy poradzisz sobie z tym ryzykiem? czy używanie surowego wskaźnika do obsługi indeksowania setek różnych obiektów jest warte utraty kontroli nad pamięcią.

Jeśli odpowiedź brzmi tak, użyj surowego wskaźnika.

Jeśli nawet nie chcesz się nad tym zastanawiać, smart_ptrjest to dobre, realne i niesamowite rozwiązanie.

Claudiordgz
źródło
4
ok, ale valgrind jest dobry w sprawdzaniu ewentualnych wycieków pamięci, więc dopóki go używasz, powinieneś być bezpieczny ™
graywolf
@Paladin Tak, jeśli radzisz sobie z pamięcią, smart_ptrsą naprawdę przydatne dla dużych zespołów
Claudiordgz
3
Używam unique_ptr, upraszcza wiele rzeczy, ale nie lubię shared_ptr, liczenie referencji nie jest zbyt wydajne GC i też nie jest doskonałe
graywolf
1
@Paladin Próbuję używać wskaźników surowych, jeśli mogę wszystko hermetyzować. Jeśli jest to coś, co będę rozrzucał po całym miejscu jak argument, może rozważę smart_ptr. Większość moich unique_ptrs jest używanych w dużej implementacji, takiej jak metoda main lub run
Claudiordgz
@Lothar Widzę, że sparafrazowałeś jedną z rzeczy, które powiedziałem w twojej odpowiedzi: Thats why you should not do this unless the function is really involved in ownership management... świetna odpowiedź, dziękuję, głosowano
Claudiordgz
0

Dla uogólnienia i tylko dla []operatora jest ~ 5X wolniejszy niż surowy wskaźnik, jak pokazano w poniższym kodzie, który został skompilowany przy użyciu gcc -lstdc++ -std=c++14 -O0i wyprowadził następujący wynik:

malloc []:     414252610                                                 
unique []  is: 2062494135                                                
uq get []  is: 238801500                                                 
uq.get()[] is: 1505169542
new is:        241049490 

Zaczynam się uczyć c ++, mam to na myśli: zawsze musisz wiedzieć, co robisz i poświęcić więcej czasu, aby dowiedzieć się, co inni zrobili w twoim c ++.

EDYTOWAĆ

Zgodnie z zaleceniem @Mohan Kumar podałem więcej szczegółów. Wersja gcc to 7.4.0 (Ubuntu 7.4.0-1ubuntu1~14.04~ppa1), Powyższy wynik został uzyskany -O0, gdy używam flagi '-O2', otrzymałem to:

malloc []:     223
unique []  is: 105586217
uq get []  is: 71129461
uq.get()[] is: 69246502
new is:        9683

Następnie przesunięte do clang version 3.9.0, -O0było:

malloc []:     409765889
unique []  is: 1351714189
uq get []  is: 256090843
uq.get()[] is: 1026846852
new is:        255421307

-O2 był:

malloc []:     150
unique []  is: 124
uq get []  is: 83
uq.get()[] is: 83
new is:        54

Rezultat brzęku -O2jest niesamowity.

#include <memory>
#include <iostream>
#include <chrono>
#include <thread>

uint32_t n = 100000000;
void t_m(void){
    auto a  = (char*) malloc(n*sizeof(char));
    for(uint32_t i=0; i<n; i++) a[i] = 'A';
}
void t_u(void){
    auto a = std::unique_ptr<char[]>(new char[n]);
    for(uint32_t i=0; i<n; i++) a[i] = 'A';
}

void t_u2(void){
    auto a = std::unique_ptr<char[]>(new char[n]);
    auto tmp = a.get();
    for(uint32_t i=0; i<n; i++) tmp[i] = 'A';
}
void t_u3(void){
    auto a = std::unique_ptr<char[]>(new char[n]);
    for(uint32_t i=0; i<n; i++) a.get()[i] = 'A';
}
void t_new(void){
    auto a = new char[n];
    for(uint32_t i=0; i<n; i++) a[i] = 'A';
}

int main(){
    auto start = std::chrono::high_resolution_clock::now();
    t_m();
    auto end1 = std::chrono::high_resolution_clock::now();
    t_u();
    auto end2 = std::chrono::high_resolution_clock::now();
    t_u2();
    auto end3 = std::chrono::high_resolution_clock::now();
    t_u3();
    auto end4 = std::chrono::high_resolution_clock::now();
    t_new();
    auto end5 = std::chrono::high_resolution_clock::now();
    std::cout << "malloc []:     " <<  (end1 - start).count() << std::endl;
    std::cout << "unique []  is: " << (end2 - end1).count() << std::endl;
    std::cout << "uq get []  is: " << (end3 - end2).count() << std::endl;
    std::cout << "uq.get()[] is: " << (end4 - end3).count() << std::endl;
    std::cout << "new is:        " << (end5 - end4).count() << std::endl;
}
liqg3
źródło
Przetestowałem kod teraz, jest tylko 10% wolny, gdy używam unikalnego wskaźnika.
Mohan Kumar
8
nigdy przenigdy nie -O0testuj kodu ani nie debuguj go. Wynik będzie bardzo nieefektywny . Zawsze należy stosować co najmniej -O2(albo -O3w dzisiejszych czasach, ponieważ niektóre wektoryzacja nie są wykonywane w -O2)
phuclv
1
Jeśli masz czas i chcesz zrobić sobie przerwę na kawę, weź -O4, aby uzyskać optymalizację czasu łącza, a wszystkie małe funkcje abstrakcji zostaną wbudowane i znikną.
Lothar
Powinieneś uwzględnić freewywołanie w teście malloc i delete[]dla new (lub uczynić zmienną astatyczną), ponieważ unique_ptrs wołają delete[]pod maską, w swoich destruktorach.
RnMss