std :: function vs template

161

Dzięki C ++ 11 otrzymaliśmy std::functionrodzinę wrapperów funktorów. Niestety ciągle słyszę tylko złe rzeczy o tych nowych dodatkach. Najbardziej popularne jest to, że są strasznie powolne. Przetestowałem to i naprawdę są do niczego w porównaniu z szablonami.

#include <iostream>
#include <functional>
#include <string>
#include <chrono>

template <typename F>
float calc1(F f) { return -1.0f * f(3.3f) + 666.0f; }

float calc2(std::function<float(float)> f) { return -1.0f * f(3.3f) + 666.0f; }

int main() {
    using namespace std::chrono;

    const auto tp1 = system_clock::now();
    for (int i = 0; i < 1e8; ++i) {
        calc1([](float arg){ return arg * 0.5f; });
    }
    const auto tp2 = high_resolution_clock::now();

    const auto d = duration_cast<milliseconds>(tp2 - tp1);  
    std::cout << d.count() << std::endl;
    return 0;
}

111 ms w porównaniu z 1241 ms. Zakładam, że dzieje się tak dlatego, że szablony można ładnie wstawiać, podczas functiongdy wewnętrzne funkcje obejmują wirtualne połączenia.

Oczywiście szablony mają swoje problemy, tak jak je widzę:

  • muszą być podane jako nagłówki, co nie jest czymś, czego możesz nie chcieć robić, udostępniając swoją bibliotekę jako zamknięty kod,
  • mogą znacznie wydłużyć czas kompilacji, chyba że extern templatezostanie wprowadzona taka polityka,
  • nie ma (przynajmniej mi znanego) czystego sposobu przedstawienia wymagań (pojęć, kogokolwiek?) szablonu, poza komentarzem opisującym, jakiego rodzaju funktora się oczekuje.

Czy mogę więc założyć, że functions może być de facto stosowany jako standard funktorów mijających i tam, gdzie oczekuje się wysokiej wydajności, należy używać szablonów?


Edytować:

Mój kompilator to Visual Studio 2012 bez CTP.

Czerwony XIII
źródło
16
Użyj std::functionwtedy i tylko wtedy, gdy faktycznie potrzebujesz heterogenicznego zbioru wywoływalnych obiektów (tj. Żadne dalsze informacje rozróżniające nie są dostępne w czasie wykonywania).
Kerrek SB
30
Porównujesz złe rzeczy. Szablony są używane w obu przypadkach - nie jest to „ std::functionani szablony”. Myślę, że tutaj problemem jest po prostu zawijanie lambdy, std::functiona nie zawijanie lambdy std::function. W tej chwili twoje pytanie jest jak pytanie „czy wolę jabłko czy miskę?”
Wyścigi lekkości na orbicie
7
Niezależnie od tego, czy 1ns czy 10ns, oba są niczym.
ipc
23
@ipc: 1000% to jednak nie nic. Jak identyfikuje OP, zaczynasz dbać o skalowalność w jakimkolwiek praktycznym celu.
Wyścigi lekkości na orbicie
18
@ipc Jest 10 razy wolniejsze, co jest ogromne. Szybkość należy porównać ze stanem wyjściowym; myślenie, że to nie ma znaczenia tylko dlatego, że to nanosekundy, jest mylące.
Paul Manta,

Odpowiedzi:

170

Ogólnie rzecz biorąc, jeśli masz do czynienia z sytuacją projektową, która daje wybór, użyj szablonów . Podkreśliłem słowo design, ponieważ uważam, że należy się skupić na rozróżnieniu między przypadkami użycia std::functiona szablonami, które są całkiem inne.

Ogólnie rzecz biorąc, wybór szablonów jest tylko przykładem szerszej zasady: spróbuj określić jak najwięcej ograniczeń w czasie kompilacji . Uzasadnienie jest proste: jeśli uda Ci się wykryć błąd lub niezgodność typu, nawet przed wygenerowaniem programu, nie wyślesz klientowi błędnego programu.

Ponadto, jak słusznie zauważyłeś, wywołania funkcji szablonu są rozwiązywane statycznie (tj. W czasie kompilacji), więc kompilator ma wszystkie niezbędne informacje do optymalizacji i ewentualnie wbudowania kodu (co nie byłoby możliwe, gdyby wywołanie zostało wykonane za pomocą vtable).

Tak, to prawda, że ​​obsługa szablonów nie jest doskonała, a C ++ 11 wciąż nie obsługuje pojęć; jednak nie widzę, jak std::functionby cię uratować pod tym względem. std::functionnie jest alternatywą dla szablonów, ale raczej narzędziem w sytuacjach projektowych, w których nie można używać szablonów.

Jeden taki przypadek użycia pojawia się, gdy trzeba rozwiązać wywołanie w czasie wykonywania , wywołując wywoływalny obiekt, który jest zgodny z określonym podpisem, ale którego konkretny typ jest nieznany w czasie kompilacji. Dzieje się tak zazwyczaj, gdy masz kolekcję wywołań zwrotnych potencjalnie różnych typów , ale które musisz wywołać w jednolity sposób ; typ i liczba zarejestrowanych wywołań zwrotnych jest określana w czasie wykonywania na podstawie stanu programu i logiki aplikacji. Niektóre z tych wywołań zwrotnych mogą być funktorami, niektóre mogą być zwykłymi funkcjami, a niektóre mogą być wynikiem powiązania innych funkcji z określonymi argumentami.

std::functiona std::bindtakże oferują naturalny idiom umożliwiający programowanie funkcjonalne w C ++, w którym funkcje są traktowane jako obiekty i są naturalnie przetwarzane i łączone w celu wygenerowania innych funkcji. Chociaż ten rodzaj kombinacji można również osiągnąć za pomocą szablonów, podobna sytuacja projektowa zwykle występuje wraz z przypadkami użycia, które wymagają określenia typu połączonych wywoływalnych obiektów w czasie wykonywania.

Wreszcie są inne sytuacje, w których std::functionjest to nieuniknione, np. Jeśli chcesz napisać rekurencyjne lambdy ; jednak te ograniczenia są bardziej podyktowane ograniczeniami technologicznymi niż rozróżnieniami pojęciowymi, jak sądzę.

Podsumowując, skup się na projektowaniu i spróbuj zrozumieć, jakie są koncepcyjne przypadki użycia tych dwóch konstrukcji. Jeśli porównasz je tak, jak to zrobiłeś, zmuszasz ich do wejścia na arenę, do której prawdopodobnie nie należą.

Andy Prowl
źródło
23
Myślę, że „Dzieje się tak zazwyczaj, gdy masz kolekcję wywołań zwrotnych potencjalnie różnych typów, ale które musisz wywoływać jednolicie”; jest ważna. Moja praktyczna zasada brzmi: „Preferuj std::functionkońcówkę pamięci i szablon Funinterfejsu”.
R. Martinho Fernandes
2
Uwaga: technika ukrywania konkretnych typów jest nazywana kasowaniem typów (nie należy mylić jej z usuwaniem typów w językach zarządzanych). Jest często implementowany w kategoriach dynamicznego polimorfizmu, ale ma większą moc (np. unique_ptr<void>Wywoływanie odpowiednich destruktorów nawet dla typów bez wirtualnych destruktorów).
ecatmur
2
@ecatmur: Zgadzam się co do istoty, chociaż nie jesteśmy zgodni co do terminologii. Dynamiczny polimorfizm oznacza dla mnie „przyjmowanie różnych form w czasie wykonywania”, w przeciwieństwie do statycznego polimorfizmu, który interpretuję jako „przyjmujący różne formy w czasie kompilacji”; tego ostatniego nie można osiągnąć za pomocą szablonów. Dla mnie wymazywanie typów jest, z punktu widzenia projektu, pewnego rodzaju warunkiem wstępnym do osiągnięcia w ogóle dynamicznego polimorfizmu: do interakcji z obiektami różnych typów potrzebny jest jednolity interfejs, a wymazywanie typu jest sposobem na oderwanie czcionki od czcionki. szczegółowe informacje.
Andy Prowl
2
@ecatmur: Więc w pewnym sensie dynamiczny polimorfizm jest wzorcem konceptualnym, podczas gdy wymazywanie czcionek jest techniką, która pozwala na jego realizację.
Andy Prowl
2
@Downvoter: Byłbym ciekawy, co znalazłeś w tej odpowiedzi.
Andy Prowl,
89

Andy Prowl ładnie omówił problemy projektowe. Jest to oczywiście bardzo ważne, ale uważam, że pierwotne pytanie dotyczy większej liczby problemów związanych z wydajnością std::function.

Przede wszystkim krótka uwaga na temat techniki pomiaru: 11ms uzyskane dla calc1nie ma żadnego znaczenia. Rzeczywiście, patrząc na wygenerowany zestaw (lub debugowanie kodu asemblera), można zauważyć, że optymalizator VS2012 jest wystarczająco sprytny, aby zdać sobie sprawę, że wynik wywołania calc1jest niezależny od iteracji i przenosi wywołanie poza pętlę:

for (int i = 0; i < 1e8; ++i) {
}
calc1([](float arg){ return arg * 0.5f; });

Ponadto zdaje sobie sprawę, że wywołanie calc1nie ma widocznego efektu i całkowicie je odrzuca. Dlatego 111 ms to czas potrzebny na uruchomienie pustej pętli. (Dziwię się, że optymalizator zachował pętlę). Dlatego uważaj na pomiary czasu w pętlach. To nie jest tak proste, jak mogłoby się wydawać.

Jak już wspomniano, optymalizator ma więcej problemów ze zrozumieniem std::functioni nie przenosi wywołania z pętli. Zatem 1241 ms to rzetelna miara dla calc2.

Zauważ, że std::functionjest w stanie przechowywać różne typy wywoływalnych obiektów. W związku z tym musi wykonać jakąś magię usuwania typów dla magazynu. Ogólnie oznacza to dynamiczną alokację pamięci (domyślnie przez wywołanie new). Powszechnie wiadomo, że jest to dość kosztowna operacja.

Standard (20.8.11.2.1 / 5) koduje implementacje, aby uniknąć dynamicznej alokacji pamięci dla małych obiektów, co, na szczęście, VS2012 tak robi (w szczególności dla oryginalnego kodu).

Aby zorientować się, o ile wolniej może się to stać w przypadku alokacji pamięci, zmieniłem wyrażenie lambda na trzy floatsekundy. To sprawia, że ​​wywoływalny obiekt jest zbyt duży, aby zastosować optymalizację małych obiektów:

float a, b, c; // never mind the values
// ...
calc2([a,b,c](float arg){ return arg * 0.5f; });

W przypadku tej wersji czas wynosi około 16000 ms (w porównaniu do 1241 ms dla oryginalnego kodu).

Na koniec zwróć uwagę, że czas życia lambda obejmuje czas trwania std::function. W takim przypadku zamiast przechowywać kopię lambda, std::functionmożna zapisać do niej „odniesienie”. Przez „odniesienie” rozumiem element, std::reference_wrapperktóry można łatwo zbudować za pomocą funkcji std::refi std::cref. Dokładniej, używając:

auto func = [a,b,c](float arg){ return arg * 0.5f; };
calc2(std::cref(func));

czas zmniejsza się do około 1860 ms.

Pisałem o tym jakiś czas temu:

http://www.drdobbs.com/cpp/efficient-use-of-lambda-expressions-and/232500059

Jak powiedziałem w artykule, argumenty nie do końca dotyczą VS2010 ze względu na jego słabą obsługę C ++ 11. W momencie pisania tego tekstu dostępna była tylko wersja beta VS2012, ale jej obsługa C ++ 11 była już wystarczająco dobra w tej kwestii.

Cassio Neri
źródło
Uważam to za interesujące, ponieważ chcę udowodnić szybkość kodu za pomocą przykładów zabawek, które są optymalizowane przez kompilator, ponieważ nie mają żadnych skutków ubocznych. Powiedziałbym, że rzadko można postawić na tego typu pomiary, bez prawdziwego / produkcyjnego kodu.
Ghita
@ Ghita: W tym przykładzie, aby zapobiec optymalizacji kodu, calc1można przyjąć floatargument, który byłby wynikiem poprzedniej iteracji. Coś jak x = calc1(x, [](float arg){ return arg * 0.5f; });. Ponadto musimy zapewnić, że calc1używa x. Ale to jeszcze nie wystarczy. Musimy stworzyć efekt uboczny. Np. Po pomiarze wydruk xna ekranie. Mimo to zgadzam się, że używanie kodów zabawek do pomiarów timimg nie zawsze może dać idealne wskazanie, co się stanie z rzeczywistym / produkcyjnym kodem.
Cassio Neri
Wydaje mi się również, że benchmark konstruuje obiekt std :: function wewnątrz pętli i wywołuje w pętli calc2. Niezależnie od tego, czy kompilator może, ale nie musi, zoptymalizować to na zewnątrz (i że konstruktor mógłby być tak prosty jak przechowywanie vptr), byłbym bardziej zainteresowany przypadkiem, w którym funkcja jest konstruowana raz i przekazywana do innej funkcji, która wywołuje to w pętli. To znaczy narzut wywołania, a nie czas konstruowania (i wywołanie „f”, a nie funkcji calc2). Byłby również zainteresowany, gdyby wywołanie f w pętli (w calc2) zamiast jednorazowego, przyniosłoby korzyści z jakiegokolwiek podnoszenia.
greggo
Świetna odpowiedź. Dwie rzeczy: ładny przykład prawidłowego użycia for std::reference_wrapper(do wymuszania szablonów; nie tylko do ogólnego przechowywania) i zabawne jest widzieć, że optymalizator VS nie odrzuca pustej pętli ... jak zauważyłem z tym błędem GCC ponownievolatile .
underscore_d
37

Dzięki Clang nie ma między nimi różnicy w wydajności

Używając clang (3.2, trunk 166872) (-O2 w Linuksie), pliki binarne z dwóch przypadków są w rzeczywistości identyczne .

-Wrócę, by dzwonić na końcu postu. Ale najpierw gcc 4.7.2:

Jest już wiele wglądu, ale chcę zwrócić uwagę, że wyniki obliczeń calc1 i calc2 nie są takie same, z powodu wbudowania itp. Porównaj na przykład sumę wszystkich wyników:

float result=0;
for (int i = 0; i < 1e8; ++i) {
  result+=calc2([](float arg){ return arg * 0.5f; });
}

z calc2 staje się

1.71799e+10, time spent 0.14 sec

podczas gdy z calc1 staje się

6.6435e+10, time spent 5.772 sec

jest to współczynnik ~ 40 w przypadku różnicy prędkości i ~ 4 współczynnik w wartościach. Pierwsza to znacznie większa różnica niż opublikowana przez OP (przy użyciu programu Visual Studio). Właściwie wydrukowanie wartości a end jest również dobrym pomysłem, aby zapobiec usunięciu przez kompilator kodu bez widocznego wyniku (reguła as-if). Cassio Neri już to powiedział w swojej odpowiedzi. Zwróć uwagę, jak różne są wyniki - należy zachować ostrożność podczas porównywania współczynników szybkości kodów wykonujących różne obliczenia.

Ponadto, aby być uczciwym, porównywanie różnych sposobów wielokrotnego obliczania f (3.3) może nie jest takie interesujące. Jeśli wejście jest stałe, nie powinno być w pętli. (Optymalizator może łatwo zauważyć)

Jeśli dodam argument wartości podany przez użytkownika do funkcji calc1 i 2, współczynnik prędkości między calc1 i calc2 sprowadza się do współczynnika 5, z 40! W przypadku programu Visual Studio różnica jest zbliżona do współczynnika 2, a przy dźwiękach nie ma różnicy (patrz poniżej).

Ponieważ mnożenie jest szybkie, mówienie o czynnikach spowolnienia często nie jest tak interesujące. Bardziej interesującym pytaniem jest, jak małe są twoje funkcje i czy te wywołania są wąskim gardłem w prawdziwym programie?

Szczęk:

Clang (użyłem 3.2) faktycznie generował identyczne pliki binarne, kiedy przełączam się między calc1 i calc2 dla przykładowego kodu (zamieszczonego poniżej). W przypadku oryginalnego przykładu zamieszczonego w pytaniu oba są również identyczne, ale nie zajmują wcale czasu (pętle są po prostu całkowicie usuwane, jak opisano powyżej). W moim zmodyfikowanym przykładzie z -O2:

Liczba sekund do wykonania (najlepsza z 3):

clang:        calc1:           1.4 seconds
clang:        calc2:           1.4 seconds (identical binary)

gcc 4.7.2:    calc1:           1.1 seconds
gcc 4.7.2:    calc2:           6.0 seconds

VS2012 CTPNov calc1:           0.8 seconds 
VS2012 CTPNov calc2:           2.0 seconds 

VS2015 (14.0.23.107) calc1:    1.1 seconds 
VS2015 (14.0.23.107) calc2:    1.5 seconds 

MinGW (4.7.2) calc1:           0.9 seconds
MinGW (4.7.2) calc2:          20.5 seconds 

Obliczone wyniki wszystkich plików binarnych są takie same, a wszystkie testy zostały wykonane na tej samej maszynie. Byłoby interesujące, gdyby ktoś z głębszą wiedzą na temat clang lub VS mógł skomentować, jakie optymalizacje zostały wprowadzone.

Mój zmodyfikowany kod testowy:

#include <functional>
#include <chrono>
#include <iostream>

template <typename F>
float calc1(F f, float x) { 
  return 1.0f + 0.002*x+f(x*1.223) ; 
}

float calc2(std::function<float(float)> f,float x) { 
  return 1.0f + 0.002*x+f(x*1.223) ; 
}

int main() {
    using namespace std::chrono;

    const auto tp1 = high_resolution_clock::now();

    float result=0;
    for (int i = 0; i < 1e8; ++i) {
      result=calc1([](float arg){ 
          return arg * 0.5f; 
        },result);
    }
    const auto tp2 = high_resolution_clock::now();

    const auto d = duration_cast<milliseconds>(tp2 - tp1);  
    std::cout << d.count() << std::endl;
    std::cout << result<< std::endl;
    return 0;
}

Aktualizacja:

Dodano vs2015. Zauważyłem również, że istnieją konwersje double-> float w calc1, calc2. Usunięcie ich nie zmienia wniosku dla Visual Studio (oba są o wiele szybsze, ale stosunek jest mniej więcej taki sam).

Johan Lundberg
źródło
8
Co prawdopodobnie tylko pokazuje, że benchmark jest błędny. IMHO interesującym przypadkiem użycia jest sytuacja, w której kod wywołujący otrzymuje obiekt funkcji z innego miejsca, więc kompilator nie zna pochodzenia funkcji std :: podczas kompilowania wywołania. W tym przypadku kompilator dokładnie zna skład funkcji std :: podczas jej wywoływania, rozszerzając funkcję calc2 inline do main. Łatwo to naprawić, ustawiając calc2 jako „extern” we wrześniu. Plik źródłowy. Następnie porównujesz jabłka z pomarańczami; calc2 robi coś, czego Calc1 nie może. A pętla może znajdować się wewnątrz calc (wiele wywołań f); nie wokół ktora obiektu funkcji.
greggo
1
Kiedy mogę dostać się do odpowiedniego kompilatora. Na razie można powiedzieć, że (a) ctor dla rzeczywistej funkcji std :: wywołuje „new”; (b) samo wywołanie jest dość ubogie, gdy celem jest pasująca rzeczywista funkcja; (c) w przypadkach z dowiązaniem istnieje fragment kodu, który dokonuje adaptacji, wybrany przez kod ptr w funkcji obj i który pobiera dane (powiązane parametry) z funkcji obj (d) funkcja „związana” może być osadzone w tym adapterze, jeśli kompilator może to zobaczyć.
greggo
Dodano nową odpowiedź z opisaną konfiguracją.
greggo
3
BTW Benchmark nie jest zły, pytanie ("std :: function vs template") jest ważne tylko w zakresie tej samej jednostki kompilacji. Jeśli przeniesiesz funkcję do innej jednostki, szablon nie będzie już możliwy, więc nie ma z czym porównywać.
rustyx
13

Różne to nie to samo.

Jest wolniejszy, ponieważ robi rzeczy, których szablon nie może zrobić. W szczególności umożliwia wywołanie dowolnej funkcji, którą można wywołać z podanymi typami argumentów i której typ zwracany jest konwertowany na podany typ zwracany z tego samego kodu .

void eval(const std::function<int(int)>& f) {
    std::cout << f(3);
}

int f1(int i) {
    return i;
}

float f2(double d) {
    return d;
}

int main() {
    std::function<int(int)> fun(f1);
    eval(fun);
    fun = f2;
    eval(fun);
    return 0;
}

Zauważ, że ten sam obiekt funkcji,, funjest przekazywany do obu wywołań funkcji eval. Pełni dwie różne funkcje.

Jeśli nie musisz tego robić, to należy nie używać std::function.

Pete Becker
źródło
2
Chcę tylko zwrócić uwagę, że po zakończeniu 'fun = f2' obiekt 'fun' kończy się wskazaniem na ukrytą funkcję, która konwertuje int na double, wywołuje f2 i konwertuje wynik double z powrotem na int. (W rzeczywistym przykładzie , 'f2' może zostać wstawione do tej funkcji). Jeśli przypiszesz std :: bind do fun, obiekt „fun” może w końcu zawierać wartości, które mają być użyte dla powiązanych parametrów. aby wesprzeć tę elastyczność, przypisanie do „fun” (lub init of) może obejmować przydzielanie / zwalnianie pamięci i może zająć więcej czasu niż rzeczywisty narzut połączenia.
greggo
8

Masz już tutaj kilka dobrych odpowiedzi, więc nie zamierzam im zaprzeczać, w skrócie porównywanie std :: function do szablonów jest jak porównywanie funkcji wirtualnych do funkcji. Nigdy nie powinieneś „preferować” funkcji wirtualnych od funkcji, ale raczej używasz funkcji wirtualnych, gdy pasuje to do problemu, przenosząc decyzje z czasu kompilacji do czasu wykonania. Pomysł polega na tym, że zamiast rozwiązywać problem za pomocą niestandardowego rozwiązania (takiego jak tabela skoków), używasz czegoś, co daje kompilatorowi większą szansę na optymalizację dla Ciebie. Pomaga również innym programistom, jeśli używasz standardowego rozwiązania.

TheAgitator
źródło
6

Ta odpowiedź ma na celu wnieść do zestawu istniejących odpowiedzi coś, co uważam za bardziej znaczący punkt odniesienia dla kosztów wywołań std :: function w czasie wykonywania.

Mechanizm std :: function powinien być rozpoznawany ze względu na to, co zapewnia: Każda wywoływalna jednostka może zostać przekonwertowana na funkcję std :: z odpowiednim podpisem. Załóżmy, że masz bibliotekę, która dopasowuje powierzchnię do funkcji zdefiniowanej przez z = f (x, y), możesz napisać ją tak, aby akceptowała a std::function<double(double,double)>, a użytkownik biblioteki może łatwo przekonwertować dowolną wywoływalną jednostkę na tę; czy to zwykła funkcja, metoda instancji klasy, lambda, czy cokolwiek, co jest obsługiwane przez std :: bind.

W przeciwieństwie do podejść opartych na szablonach, działa to bez konieczności ponownej kompilacji funkcji biblioteki dla różnych przypadków; odpowiednio, trochę dodatkowego skompilowanego kodu jest potrzebne dla każdego dodatkowego przypadku. Zawsze było to możliwe, ale wymagało to pewnych niewygodnych mechanizmów, a użytkownik biblioteki prawdopodobnie musiałby skonstruować adapter wokół swojej funkcji, aby działała. std :: function automatycznie konstruuje dowolny adapter potrzebny do uzyskania wspólnego interfejsu wywołań w czasie wykonywania dla wszystkich przypadków, co jest nową i bardzo potężną funkcją.

Moim zdaniem jest to najważniejszy przypadek użycia std :: function, jeśli chodzi o wydajność: interesuje mnie koszt wielokrotnego wywoływania std :: function po jej skonstruowaniu i musi być sytuacją, w której kompilator nie jest w stanie zoptymalizować wywołania, znając faktycznie wywoływaną funkcję (tj. musisz ukryć implementację w innym pliku źródłowym, aby uzyskać odpowiedni test porównawczy).

Zrobiłem test poniżej, podobny do PO; ale główne zmiany to:

  1. Każdy przypadek jest zapętlony 1 miliard razy, ale obiekty std :: function są konstruowane tylko raz. Patrząc na kod wyjściowy, odkryłem, że 'operator new' jest wywoływany podczas konstruowania rzeczywistych wywołań std :: function (może nie, gdy są zoptymalizowane).
  2. Test jest podzielony na dwa pliki, aby zapobiec niepożądanej optymalizacji
  3. Moje przypadki to: (a) funkcja jest wbudowana (b) funkcja jest przekazywana przez zwykły wskaźnik funkcji (c) funkcja jest zgodną funkcją opakowaną jako std :: function (d) funkcja jest niekompatybilną funkcją zgodną ze standardem std :: bind, opakowany jako std :: function

Wyniki, które otrzymuję to:

  • case (a) (inline) 1,3 nsec

  • wszystkie inne przypadki: 3,3 ns.

Przypadek (d) wydaje się być nieco wolniejszy, ale różnica (około 0,05 ns) jest pochłaniana przez hałas.

Wniosek jest taki, że funkcja std :: jest porównywalna narzutem (w czasie wywołania) do używania wskaźnika funkcji, nawet jeśli istnieje prosta adaptacja „bind” do rzeczywistej funkcji. Inline jest 2 ns szybsze niż inne, ale jest to oczekiwany kompromis, ponieważ inline jest jedynym przypadkiem, który jest `` podłączony na stałe '' w czasie wykonywania.

Kiedy uruchamiam kod Johana-Lundberga na tej samej maszynie, widzę około 39 nsec na pętlę, ale w pętli jest o wiele więcej, w tym rzeczywisty konstruktor i destruktor funkcji std ::, która jest prawdopodobnie dość wysoka ponieważ obejmuje nowe i usuń.

-O2 gcc 4.8.1, do celu x86_64 (core i5).

Uwaga, kod jest podzielony na dwa pliki, aby uniemożliwić kompilatorowi rozszerzenie funkcji, w których są wywoływane (z wyjątkiem jednego przypadku, w którym jest przeznaczony).

----- pierwszy plik źródłowy --------------

#include <functional>


// simple funct
float func_half( float x ) { return x * 0.5; }

// func we can bind
float mul_by( float x, float scale ) { return x * scale; }

//
// func to call another func a zillion times.
//
float test_stdfunc( std::function<float(float)> const & func, int nloops ) {
    float x = 1.0;
    float y = 0.0;
    for(int i =0; i < nloops; i++ ){
        y += x;
        x = func(x);
    }
    return y;
}

// same thing with a function pointer
float test_funcptr( float (*func)(float), int nloops ) {
    float x = 1.0;
    float y = 0.0;
    for(int i =0; i < nloops; i++ ){
        y += x;
        x = func(x);
    }
    return y;
}

// same thing with inline function
float test_inline(  int nloops ) {
    float x = 1.0;
    float y = 0.0;
    for(int i =0; i < nloops; i++ ){
        y += x;
        x = func_half(x);
    }
    return y;
}

----- drugi plik źródłowy -------------

#include <iostream>
#include <functional>
#include <chrono>

extern float func_half( float x );
extern float mul_by( float x, float scale );
extern float test_inline(  int nloops );
extern float test_stdfunc( std::function<float(float)> const & func, int nloops );
extern float test_funcptr( float (*func)(float), int nloops );

int main() {
    using namespace std::chrono;


    for(int icase = 0; icase < 4; icase ++ ){
        const auto tp1 = system_clock::now();

        float result;
        switch( icase ){
         case 0:
            result = test_inline( 1e9);
            break;
         case 1:
            result = test_funcptr( func_half, 1e9);
            break;
         case 2:
            result = test_stdfunc( func_half, 1e9);
            break;
         case 3:
            result = test_stdfunc( std::bind( mul_by, std::placeholders::_1, 0.5), 1e9);
            break;
        }
        const auto tp2 = high_resolution_clock::now();

        const auto d = duration_cast<milliseconds>(tp2 - tp1);  
        std::cout << d.count() << std::endl;
        std::cout << result<< std::endl;
    }
    return 0;
}

Dla zainteresowanych, oto adapter, który kompilator zbudował tak, aby „mul_by” wyglądał jak float (float) - jest to „nazywane”, gdy wywoływana jest funkcja utworzona jako bind (mul_by, _1,0.5):

movq    (%rdi), %rax                ; get the std::func data
movsd   8(%rax), %xmm1              ; get the bound value (0.5)
movq    (%rax), %rdx                ; get the function to call (mul_by)
cvtpd2ps    %xmm1, %xmm1        ; convert 0.5 to 0.5f
jmp *%rdx                       ; jump to the func

(więc mogłoby być trochę szybciej, gdybym napisał 0.5f w bind ...) Zauważ, że parametr 'x' pojawia się w% xmm0 i po prostu tam pozostaje.

Oto kod w obszarze, w którym konstruowana jest funkcja, przed wywołaniem testu_stdfunc - uruchom przez c ++ filt:

movl    $16, %edi
movq    $0, 32(%rsp)
call    operator new(unsigned long)      ; get 16 bytes for std::function
movsd   .LC0(%rip), %xmm1                ; get 0.5
leaq    16(%rsp), %rdi                   ; (1st parm to test_stdfunc) 
movq    mul_by(float, float), (%rax)     ; store &mul_by  in std::function
movl    $1000000000, %esi                ; (2nd parm to test_stdfunc)
movsd   %xmm1, 8(%rax)                   ; store 0.5 in std::function
movq    %rax, 16(%rsp)                   ; save ptr to allocated mem

   ;; the next two ops store pointers to generated code related to the std::function.
   ;; the first one points to the adaptor I showed above.

movq    std::_Function_handler<float (float), std::_Bind<float (*(std::_Placeholder<1>, double))(float, float)> >::_M_invoke(std::_Any_data const&, float), 40(%rsp)
movq    std::_Function_base::_Base_manager<std::_Bind<float (*(std::_Placeholder<1>, double))(float, float)> >::_M_manager(std::_Any_data&, std::_Any_data const&, std::_Manager_operation), 32(%rsp)


call    test_stdfunc(std::function<float (float)> const&, int)
greggo
źródło
1
Z clang 3.4.1 x64 wyniki to: (a) 1,0, (b) 0,95, (c) 2,0, (d) 5,0.
rustyx
4

Wyniki są dla mnie bardzo interesujące, więc trochę poszperałem, aby zrozumieć, co się dzieje. Po pierwsze, jak powiedziało wielu innych, bez wpływu wyników obliczeń na stan programu, który kompilator po prostu zoptymalizuje. Po drugie, mając stałą wartość 3,3 podaną jako uzbrojenie do wywołania zwrotnego, podejrzewam, że będą miały miejsce inne optymalizacje. Mając to na uwadze, zmieniłem trochę kod twojego testu porównawczego.

template <typename F>
float calc1(F f, float i) { return -1.0f * f(i) + 666.0f; }
float calc2(std::function<float(float)> f, float i) { return -1.0f * f(i) + 666.0f; }
int main() {
    const auto tp1 = system_clock::now();
    for (int i = 0; i < 1e8; ++i) {
        t += calc2([&](float arg){ return arg * 0.5f + t; }, i);
    }
    const auto tp2 = high_resolution_clock::now();
}

Biorąc pod uwagę tę zmianę w kodzie, skompilowałem z gcc 4.8 -O3 i otrzymałem czas 330 ms dla calc1 i 2702 dla calc2. Więc użycie szablonu było 8 razy szybsze, ta liczba wydawała mi się podejrzana, prędkość o potędze 8 często wskazuje, że kompilator coś zwektoryzował. kiedy spojrzałem na wygenerowany kod dla wersji szablonów, był on wyraźnie zwektoryzowany

.L34:
cvtsi2ss        %edx, %xmm0
addl    $1, %edx
movaps  %xmm3, %xmm5
mulss   %xmm4, %xmm0
addss   %xmm1, %xmm0
subss   %xmm0, %xmm5
movaps  %xmm5, %xmm0
addss   %xmm1, %xmm0
cvtsi2sd        %edx, %xmm1
ucomisd %xmm1, %xmm2
ja      .L37
movss   %xmm0, 16(%rsp)

Gdzie nie było wersji std :: function. Ma to dla mnie sens, ponieważ z szablonem kompilator wie na pewno, że funkcja nigdy się nie zmieni w pętli, ale przy przekazaniu funkcji std :: może się zmienić, dlatego nie można jej wektoryzować.

To skłoniło mnie do wypróbowania czegoś innego, aby sprawdzić, czy uda mi się zmusić kompilator do wykonania tej samej optymalizacji w wersji std :: function. Zamiast przekazywać funkcję, tworzę std :: function jako zmienną globalną i wywołuję to.

float calc3(float i) {  return -1.0f * f2(i) + 666.0f; }
std::function<float(float)> f2 = [](float arg){ return arg * 0.5f; };

int main() {
    const auto tp1 = system_clock::now();
    for (int i = 0; i < 1e8; ++i) {
        t += calc3([&](float arg){ return arg * 0.5f + t; }, i);
    }
    const auto tp2 = high_resolution_clock::now();
}

W tej wersji widzimy, że kompilator teraz zwektoryzował kod w ten sam sposób i otrzymuję te same wyniki testów porównawczych.

  • szablon: 330 ms
  • std :: function: 2702 ms
  • globalna funkcja std ::: 330 ms

Tak więc mój wniosek jest taki, że surowa prędkość funkcji std :: function w porównaniu z funktorem szablonowym jest prawie taka sama. Jednak to znacznie utrudnia pracę optymalizatora.

Joshua Ritterman
źródło
1
Chodzi o to, aby jako parametr przekazać funktor. Twoja calc3sprawa nie ma sensu; Calc3 jest teraz zakodowany na stałe do wywołania f2. Oczywiście można to zoptymalizować.
rustyx
w istocie to właśnie starałem się pokazać. Ten calc3 jest odpowiednikiem szablonu iw tej sytuacji jest konstrukcją w czasie kompilacji, podobnie jak szablon.
Joshua Ritterman