Wersja skrócona: w wielu językach programowania często zwraca się duże obiekty - takie jak wektory / tablice -. Czy ten styl jest teraz akceptowalny w C ++ 0x, jeśli klasa ma konstruktor ruchu, czy programiści C ++ uważają go za dziwny / brzydki / obrzydliwy?
Wersja długa: czy w C ++ 0x jest to nadal uważane za złą formę?
std::vector<std::string> BuildLargeVector();
...
std::vector<std::string> v = BuildLargeVector();
Tradycyjna wersja wyglądałaby tak:
void BuildLargeVector(std::vector<std::string>& result);
...
std::vector<std::string> v;
BuildLargeVector(v);
W nowszej wersji wartością zwracaną z BuildLargeVector
jest rvalue, więc v zostałoby skonstruowane przy użyciu konstruktora przenoszenia std::vector
, przy założeniu, że (N) RVO nie ma miejsca.
Nawet przed C ++ 0x pierwsza postać często byłaby „wydajna” z powodu (N) RVO. Jednak (N) RVO zależy od uznania kompilatora. Teraz, gdy mamy odniesienia do rvalue, gwarantujemy, że nie nastąpi głębokie kopiowanie.
Edycja : Pytanie tak naprawdę nie dotyczy optymalizacji. Obie przedstawione formy mają prawie identyczną wydajność w rzeczywistych programach. Podczas gdy w przeszłości pierwsza forma mogła mieć gorsze wyniki o rząd wielkości. W rezultacie pierwsza forma przez długi czas była głównym powodem zapachu kodu w programowaniu w C ++. Już nie, mam nadzieję?
Odpowiedzi:
Dave Abrahams przeprowadził dość wszechstronną analizę szybkości przekazywania / zwracania wartości .
Krótka odpowiedź, jeśli chcesz zwrócić wartość, zwróć wartość. Nie używaj referencji wyjściowych, ponieważ kompilator i tak to robi. Oczywiście są zastrzeżenia, więc powinieneś przeczytać ten artykuł.
źródło
x / 2
nax >> 1
forint
s, ale zakładasz, że tak. Norma nie mówi również nic o tym, jak kompilatory są wymagane do implementowania referencji, ale zakładasz, że są one obsługiwane wydajnie za pomocą wskaźników. Standard nie mówi również nic o tabelach v, więc nie możesz być również pewien, czy wywołania funkcji wirtualnych są wydajne. Zasadniczo czasami trzeba trochę wierzyć w kompilator.Przynajmniej IMO, to zwykle kiepski pomysł, ale nie ze względu na wydajność. To kiepski pomysł, ponieważ omawiana funkcja powinna być zwykle zapisana jako ogólny algorytm, który generuje dane wyjściowe za pośrednictwem iteratora. Prawie każdy kod, który akceptuje lub zwraca kontener zamiast działać na iteratorach, powinien zostać uznany za podejrzany.
Nie zrozum mnie źle: czasami ma sens przekazywanie obiektów typu kolekcji (np. Ciągów znaków), ale w przytoczonym przykładzie uważam, że przekazanie lub zwrócenie wektora jest kiepskim pomysłem.
źródło
Istota jest taka:
Kopiuj Elision i RVO mogą uniknąć "przerażających kopii" (kompilator nie jest wymagany do implementacji tych optymalizacji, aw niektórych sytuacjach nie można go zastosować)
Odwołania C ++ 0x RValue pozwalają na implementacje ciągów / wektorów, które to gwarantują .
Jeśli możesz porzucić starsze kompilatory / implementacje STL, zwróć wektory swobodnie (i upewnij się, że twoje własne obiekty również je obsługują). Jeśli twój kod musi obsługiwać "mniejsze" kompilatory, trzymaj się starego stylu.
Niestety ma to duży wpływ na twoje interfejsy. Jeśli C ++ 0x nie jest opcją i potrzebujesz gwarancji, w niektórych scenariuszach możesz zamiast tego użyć obiektów liczonych jako odwołania lub kopiowanych przy zapisie. Mają jednak wady związane z wielowątkowością.
(Chciałbym, żeby tylko jedna odpowiedź w C ++ była prosta, nieskomplikowana i bezwarunkowa).
źródło
Rzeczywiście, od C ++ 11, koszt kopiowania
std::vector
zniknął w większości przypadków.Należy jednak pamiętać, że koszt skonstruowania nowego wektora (a następnie zniszczenia go) nadal istnieje, a użycie parametrów wyjściowych zamiast zwracania wartości według wartości jest nadal przydatne, gdy chcesz ponownie wykorzystać pojemność wektora. Jest to udokumentowane jako wyjątek w F.20 podstawowych wytycznych C ++.
Porównajmy:
z:
Załóżmy teraz, że musimy wywołać te metody
numIter
razy w ścisłej pętli i wykonać jakąś akcję. Na przykład obliczmy sumę wszystkich elementów.Używając
BuildLargeVector1
, zrobiłbyś:Używając
BuildLargeVector2
, zrobiłbyś:W pierwszym przykładzie występuje wiele niepotrzebnych dynamicznych alokacji / zwalniania alokacji, którym zapobiega się w drugim przykładzie poprzez użycie parametru wyjściowego w stary sposób, ponownie wykorzystując już przydzieloną pamięć. To, czy ta optymalizacja jest warta wykonania, zależy od względnego kosztu alokacji / cofnięcia alokacji w porównaniu z kosztem obliczania / mutowania wartości.
Reper
Pobawmy się wartościami
vecSize
inumIter
. Zachowamy stałą vecSize * numIter, aby "w teorii" zajęło to tyle samo czasu (= jest taka sama liczba przypisań i dodatków, z dokładnie tymi samymi wartościami), a różnica czasu może pochodzić tylko z kosztu alokacje, cofanie przydziałów i lepsze wykorzystanie pamięci podręcznej.Mówiąc dokładniej, użyjmy vecSize * numIter = 2 ^ 31 = 2147483648, ponieważ mam 16 GB pamięci RAM i ta liczba zapewnia, że przydzielono nie więcej niż 8 GB (sizeof (int) = 4), zapewniając, że nie przełączam się na dysk ( wszystkie inne programy były zamknięte, podczas testu miałem ~ 15 GB wolnego miejsca).
Oto kod:
A oto wynik:
(Intel i7-7700K @ 4,20 GHz; 16 GB DDR4 2400 MHz; Kubuntu 18.04)
Notacja: mem (v) = v.size () * sizeof (int) = v.size () * 4 na mojej platformie.
Nic dziwnego, że gdy
numIter = 1
(tj. Mem (v) = 8 GB), czasy są idealnie identyczne. Rzeczywiście, w obu przypadkach przydzielamy tylko raz ogromny wektor o wielkości 8 GB w pamięci. Dowodzi to również, że żadna kopia się nie wydarzyła podczas korzystania z BuildLargeVector1 (): nie miałbym wystarczająco dużo pamięci RAM, aby wykonać kopię!Kiedy
numIter = 2
ponowne wykorzystanie pojemności wektora zamiast ponownego przydzielania drugiego wektora jest 1,37x szybsze.Kiedy
numIter = 256
ponowne użycie pojemności wektora (zamiast przydzielania / cofania alokacji wektora w kółko 256 razy ...) jest 2,45x szybsze :)Możemy zauważyć, że czas1 jest prawie stały od
numIter = 1
donumIter = 256
, co oznacza, że przydzielenie jednego ogromnego wektora o wielkości 8 GB jest prawie tak samo kosztowne, jak przydzielenie 256 wektorów o pojemności 32 MB. Jednak przydzielenie jednego ogromnego wektora o wielkości 8 GB jest zdecydowanie droższe niż przydzielenie jednego wektora o wielkości 32 MB, więc ponowne wykorzystanie pojemności wektora zapewnia wzrost wydajności.Od
numIter = 512
(mem (v) = 16MB) donumIter = 8M
(mem (v) = 1kB) to najlepszy punkt: obie metody są dokładnie tak samo szybkie i szybsze niż wszystkie inne kombinacje numIter i vecSize. Prawdopodobnie ma to związek z faktem, że rozmiar pamięci podręcznej L3 mojego procesora wynosi 8 MB, więc wektor prawie całkowicie mieści się w pamięci podręcznej. Naprawdę nie wyjaśniam, dlaczego nagły skoktime1
dotyczy mem (v) = 16 MB, wydaje się, że bardziej logiczne byłoby zdarzenie zaraz po, gdy mem (v) = 8 MB. Zwróć uwagę, że, co zaskakujące, w tym słodkim miejscu, brak możliwości ponownego wykorzystania jest w rzeczywistości nieco szybszy! Naprawdę tego nie wyjaśniam.Kiedy
numIter > 8M
robi się brzydko. Obie metody działają wolniej, ale zwracanie wektora według wartości jest jeszcze wolniejsze. W najgorszym przypadku, gdy wektor zawiera tylko jeden pojedynczyint
, ponowne użycie pojemności zamiast zwracania wartości jest 3,3 razy szybsze. Przypuszczalnie wynika to ze stałych kosztów malloc (), które zaczynają dominować.Zwróć uwagę, że krzywa dla czasu2 jest gładsza niż krzywa dla czasu1: nie tylko ponowne wykorzystanie pojemności wektorów jest generalnie szybsze, ale co ważniejsze, jest bardziej przewidywalne .
Zwróć również uwagę, że w najlepszym miejscu byliśmy w stanie wykonać 2 miliardy dodań 64-bitowych liczb całkowitych w ~ 0,5 s, co jest całkiem optymalne na 64-bitowym procesorze 4,2 GHz. Moglibyśmy zrobić lepiej, zrównoleglenie obliczeń w celu wykorzystania wszystkich 8 rdzeni (powyższy test wykorzystuje tylko jeden rdzeń na raz, co zweryfikowałem, ponownie uruchamiając test podczas monitorowania użycia procesora). Najlepszą wydajność osiąga się, gdy mem (v) = 16kB, co jest rzędem wielkości pamięci podręcznej L1 (pamięć podręczna danych L1 dla i7-7700K to 4x32kB).
Oczywiście różnice stają się coraz mniej istotne, im więcej obliczeń trzeba wykonać na danych. Poniżej znajdują się wyniki jeśli zastąpimy
sum = std::accumulate(v.begin(), v.end(), sum);
przezfor (int k : v) sum += std::sqrt(2.0*k);
:Wnioski
Wyniki mogą się różnić na innych platformach. Jak zwykle, jeśli liczy się wydajność, napisz testy porównawcze dla konkretnego przypadku użycia.
źródło
Nadal uważam, że to zła praktyka, ale warto zauważyć, że mój zespół korzysta z MSVC 2008 i GCC 4.1, więc nie używamy najnowszych kompilatorów.
Wcześniej wiele hotspotów wyświetlanych w vtune z MSVC 2008 sprowadzało się do kopiowania ciągów. Mieliśmy taki kod:
... zauważ, że użyliśmy naszego własnego typu String (było to wymagane, ponieważ dostarczamy zestaw programistyczny, w którym twórcy wtyczek mogą używać różnych kompilatorów, a tym samym różnych, niekompatybilnych implementacji std :: string / std :: wstring).
Wprowadziłem prostą zmianę w odpowiedzi na sesję profilowania próbkowania wykresu wywołań pokazującą, że String :: String (const String &) zajmuje znaczną ilość czasu. Metody takie jak w powyższym przykładzie były największymi współtwórcami (w rzeczywistości sesja profilowania wykazała, że alokacja i cofanie alokacji pamięci jest jednym z największych hotspotów, a konstruktor kopiujący String był głównym współtwórcą alokacji).
Zmiana, którą wprowadziłem, była prosta:
Jednak to zrobiło wielką różnicę! Hotspot zniknął w kolejnych sesjach profilera, a oprócz tego wykonujemy wiele dokładnych testów jednostkowych, aby śledzić wydajność naszej aplikacji. Po tych prostych zmianach czasy testów wydajności wszelkiego rodzaju znacznie się skróciły.
Wniosek: nie używamy absolutnie najnowszych kompilatorów, ale nadal nie możemy polegać na kompilatorze optymalizującym kopiowanie w celu niezawodnego zwracania przez wartość (przynajmniej nie we wszystkich przypadkach). Może tak nie być w przypadku tych, którzy używają nowszych kompilatorów, takich jak MSVC 2010. Nie mogę się doczekać, kiedy będziemy mogli użyć C ++ 0x i po prostu użyć referencji rvalue i nigdy nie będziemy musieli się martwić, że pesymizujemy nasz kod, zwracając złożone klasy według wartości.
[Edytuj] Jak zauważył Nate, RVO dotyczy zwracania tymczasowych utworzonych wewnątrz funkcji. W moim przypadku nie było takich tymczasowych (poza nieprawidłową gałęzią, w której konstruujemy pusty ciąg), a zatem RVO nie miałby zastosowania.
źródło
<::
lub??!
z operatorem warunkowym?:
(czasami nazywany operatorem potrójny ).Żeby trochę poszukać: w wielu językach programowania nie jest powszechne zwracanie tablic z funkcji. W większości z nich zwracane jest odwołanie do tablicy. W C ++ powracałaby najbliższa analogia
boost::shared_array
źródło
shared_ptr
i nazwij go dniem.Jeśli wydajność jest prawdziwym problemem, powinieneś zdać sobie sprawę, że semantyka przenoszenia nie zawsze jest szybsza niż kopiowanie. Na przykład, jeśli masz ciąg, który używa optymalizacji małych ciągów, wówczas dla małych ciągów konstruktor przenoszenia musi wykonać dokładnie taką samą pracę, jak zwykły konstruktor kopiujący.
źródło