std :: function vs template

161

Dank C ++ 11 haben wir die std::functionFamilie der Funktor-Wrapper erhalten. Leider höre ich immer wieder nur schlechte Dinge über diese Neuzugänge. Am beliebtesten ist, dass sie schrecklich langsam sind. Ich habe es getestet und sie saugen wirklich im Vergleich zu Vorlagen.

#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 vs 1241 ms. Ich gehe davon aus, dass dies daran liegt, dass Vorlagen gut eingefügt werden können, während functions die Interna über virtuelle Anrufe abdecken.

Offensichtlich haben Vorlagen ihre Probleme, wie ich sie sehe:

  • Sie müssen als Header bereitgestellt werden, was Sie möglicherweise nicht möchten, wenn Sie Ihre Bibliothek als geschlossenen Code freigeben.
  • Sie können die Kompilierungszeit erheblich verlängern, sofern keine extern templateähnlichen Richtlinien eingeführt werden.
  • Es gibt keine (zumindest mir bekannte) saubere Möglichkeit, Anforderungen (Konzepte, irgendjemand?) einer Vorlage darzustellen, abgesehen von einem Kommentar, der beschreibt, welche Art von Funktor erwartet wird.

Kann ich daher davon ausgehen, dass functions als De-facto- Standard für vorbeifahrende Funktoren verwendet werden kann und an Orten, an denen eine hohe Leistung erwartet wird, Vorlagen verwendet werden sollten?


Bearbeiten:

Mein Compiler ist Visual Studio 2012 ohne CTP.

Rot XIII
quelle
16
Verwenden Sie diese std::functionOption nur dann, wenn Sie tatsächlich eine heterogene Sammlung aufrufbarer Objekte benötigen (dh zur Laufzeit sind keine weiteren Unterscheidungsinformationen verfügbar).
Kerrek SB
30
Sie vergleichen die falschen Dinge. In beiden Fällen werden Vorlagen verwendet - es handelt sich nicht um " std::functionoder Vorlagen". Ich denke, hier geht es einfach darum, ein Lambda std::functioneinzupacken, anstatt ein Lambda nicht einzuwickeln std::function. Im Moment ist Ihre Frage wie die Frage: "Soll ich einen Apfel oder eine Schüssel bevorzugen?"
Leichtigkeitsrennen im Orbit
7
Ob 1ns oder 10ns, beides ist nichts.
IPC
23
@ipc: 1000% ist aber nichts. Wie das OP feststellt, kümmern Sie sich darum, wann die Skalierbarkeit für einen beliebigen praktischen Zweck erreicht wird.
Leichtigkeitsrennen im Orbit
18
@ipc Es ist 10 mal langsamer, was riesig ist. Die Geschwindigkeit muss mit der Basislinie verglichen werden. Es täuscht zu glauben, dass es keine Rolle spielt, nur weil es Nanosekunden sind.
Paul Manta

Antworten:

170

Verwenden Sie im Allgemeinen Vorlagen , wenn Sie mit einer Entwurfssituation konfrontiert sind , in der Sie die Wahl haben . Ich habe das Wort Design betont, weil ich denke, dass Sie sich auf die Unterscheidung zwischen Anwendungsfällen und Vorlagen konzentrieren müssen, die ziemlich unterschiedlich sind.std::function

Im Allgemeinen ist die Auswahl von Vorlagen nur ein Beispiel für ein umfassenderes Prinzip: Versuchen Sie, beim Kompilieren so viele Einschränkungen wie möglich anzugeben . Das Grundprinzip ist einfach: Wenn Sie einen Fehler oder eine Typinkongruenz feststellen können, bevor Ihr Programm generiert wird, senden Sie Ihrem Kunden kein fehlerhaftes Programm.

Darüber hinaus werden Aufrufe von Vorlagenfunktionen, wie Sie richtig ausgeführt haben, statisch aufgelöst (dh zur Kompilierungszeit), sodass der Compiler über alle erforderlichen Informationen verfügt, um den Code zu optimieren und möglicherweise zu integrieren (was nicht möglich wäre, wenn der Aufruf über a ausgeführt würde vtable).

Ja, es stimmt, dass die Vorlagenunterstützung nicht perfekt ist und C ++ 11 immer noch keine Unterstützung für Konzepte bietet. Ich sehe jedoch nicht ein, wie std::functionSie diesbezüglich gerettet werden könnten. std::functionist keine Alternative zu Vorlagen, sondern ein Werkzeug für Entwurfssituationen, in denen Vorlagen nicht verwendet werden können.

Ein solcher Anwendungsfall tritt auf, wenn Sie einen Aufruf zur Laufzeit auflösen müssen, indem Sie ein aufrufbares Objekt aufrufen, das einer bestimmten Signatur entspricht, dessen konkreter Typ jedoch zur Kompilierungszeit unbekannt ist. Dies ist normalerweise der Fall, wenn Sie eine Sammlung von Rückrufen potenziell unterschiedlicher Typen haben , die Sie jedoch einheitlich aufrufen müssen . Die Art und Anzahl der registrierten Rückrufe wird zur Laufzeit basierend auf dem Status Ihres Programms und der Anwendungslogik festgelegt. Einige dieser Rückrufe könnten Funktoren sein, andere einfache Funktionen, andere das Ergebnis der Bindung anderer Funktionen an bestimmte Argumente.

std::functionund std::bindbieten auch eine natürliche Redewendung für die Aktivierung der funktionalen Programmierung in C ++, bei der Funktionen als Objekte behandelt werden und auf natürliche Weise gewürfelt und kombiniert werden, um andere Funktionen zu generieren. Obwohl diese Art der Kombination auch mit Vorlagen erreicht werden kann, geht eine ähnliche Entwurfssituation normalerweise mit Anwendungsfällen einher, bei denen der Typ der kombinierten aufrufbaren Objekte zur Laufzeit bestimmt werden muss.

Schließlich gibt es andere Situationen, in denen dies std::functionunvermeidlich ist, z. B. wenn Sie rekursive Lambdas schreiben möchten . Diese Einschränkungen werden jedoch eher von technologischen Einschränkungen als von konzeptionellen Unterscheidungen bestimmt, die ich glaube.

Zusammenfassend lässt sich sagen, dass Sie sich auf das Design konzentrieren und versuchen, die konzeptionellen Anwendungsfälle für diese beiden Konstrukte zu verstehen. Wenn Sie sie so vergleichen, wie Sie es getan haben, zwingen Sie sie in eine Arena, zu der sie wahrscheinlich nicht gehören.

Andy Prowl
quelle
23
Ich denke: "Dies ist normalerweise der Fall, wenn Sie eine Sammlung von Rückrufen potenziell unterschiedlicher Typen haben, die Sie jedoch einheitlich aufrufen müssen." ist das wichtige Stück. Meine Faustregel lautet: "Bevorzugen Sie std::functionauf der Speicherseite und Vorlage Funauf der Schnittstelle".
R. Martinho Fernandes
2
Hinweis: Die Technik zum Ausblenden konkreter Typen wird als Typlöschung bezeichnet (nicht zu verwechseln mit Typlöschung in verwalteten Sprachen). Es wird häufig in Bezug auf dynamischen Polymorphismus implementiert, ist jedoch leistungsfähiger (z. B. unique_ptr<void>Aufrufen geeigneter Destruktoren, selbst für Typen ohne virtuelle Destruktoren).
Ecatmur
2
@ecatmur: Ich stimme der Substanz zu, obwohl wir in der Terminologie etwas unausgerichtet sind. Dynamischer Polymorphismus bedeutet für mich "zur Laufzeit unterschiedliche Formen annehmen", im Gegensatz zu statischem Polymorphismus, den ich als "zur Kompilierungszeit unterschiedliche Formen annehmen" interpretiere; Letzteres kann nicht durch Vorlagen erreicht werden. Für mich ist das Löschen von Typen in Bezug auf das Design eine Art Voraussetzung, um überhaupt einen dynamischen Polymorphismus zu erzielen: Sie benötigen eine einheitliche Schnittstelle, um mit Objekten verschiedener Typen zu interagieren, und das Löschen von Typen ist eine Möglichkeit, den Typ zu abstrahieren. spezifische Informationen.
Andy Prowl
2
@ecatmur: In gewisser Weise ist dynamischer Polymorphismus das konzeptionelle Muster, während das Löschen von Typen eine Technik ist, die es ermöglicht, es zu realisieren.
Andy Prowl
2
@Downvoter: Ich wäre gespannt, was Sie in dieser Antwort falsch gefunden haben.
Andy Prowl
89

Andy Prowl hat Designprobleme gut behandelt. Dies ist natürlich sehr wichtig, aber ich glaube, dass die ursprüngliche Frage mehr Leistungsprobleme betrifft std::function.

Zunächst eine kurze Bemerkung zur Messtechnik: Die für erhaltenen 11ms haben calc1überhaupt keine Bedeutung. Wenn man sich die generierte Assembly ansieht (oder den Assemblycode debuggt), kann man feststellen, dass der Optimierer von VS2012 klug genug ist, um zu erkennen, dass das Ergebnis des Aufrufs calc1unabhängig von der Iteration ist und den Aufruf aus der Schleife verschiebt:

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

Darüber hinaus wird erkannt, dass Anrufe calc1keinen sichtbaren Effekt haben, und der Anruf wird insgesamt abgebrochen. Daher sind die 111 ms die Zeit, die die leere Schleife benötigt, um ausgeführt zu werden. (Ich bin überrascht, dass der Optimierer die Schleife beibehalten hat.) Seien Sie also vorsichtig mit Zeitmessungen in Schleifen. Dies ist nicht so einfach, wie es scheint.

Wie bereits erwähnt, hat der Optimierer mehr Probleme zu verstehen std::functionund verschiebt den Aufruf nicht aus der Schleife. 1241ms ist also ein faires Maß für calc2.

Beachten Sie, dass std::functionverschiedene Arten von aufrufbaren Objekten gespeichert werden können. Daher muss es eine Art Löschmagie für die Speicherung ausführen. Im Allgemeinen impliziert dies eine dynamische Speicherzuweisung (standardmäßig durch einen Aufruf von new). Es ist bekannt, dass dies eine ziemlich kostspielige Operation ist.

Der Standard (20.8.11.2.1 / 5) enthält Implementierungen, um die dynamische Speicherzuweisung für kleine Objekte zu vermeiden, die VS2012 zum Glück (insbesondere für den Originalcode) tut.

Um eine Vorstellung davon zu bekommen, wie viel langsamer es werden kann, wenn es um die Speicherzuweisung geht, habe ich den Lambda-Ausdruck so geändert, dass er drei floatSekunden erfasst . Dies macht das aufrufbare Objekt zu groß, um die Optimierung für kleine Objekte anzuwenden:

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

Für diese Version beträgt die Zeit ungefähr 16000 ms (im Vergleich zu 1241 ms für den Originalcode).

Beachten Sie schließlich, dass die Lebensdauer des Lambda die des std::function. In diesem Fall könnte, anstatt eine Kopie des Lambda zu std::functionspeichern, ein "Verweis" darauf gespeichert werden. Mit "Referenz" meine ich eine, std::reference_wrapperdie leicht durch Funktionen std::refund aufgebaut werden kann std::cref. Genauer gesagt, indem Sie Folgendes verwenden:

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

Die Zeit verringert sich auf ungefähr 1860 ms.

Ich habe vor einiger Zeit darüber geschrieben:

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

Wie ich in dem Artikel sagte, gelten die Argumente für VS2010 aufgrund der schlechten Unterstützung von C ++ 11 nicht ganz. Zum Zeitpunkt des Schreibens war nur eine Beta-Version von VS2012 verfügbar, aber die Unterstützung für C ++ 11 war für diese Angelegenheit bereits gut genug.

Cassio Neri
quelle
Ich finde das in der Tat interessant, weil ich anhand von Spielzeugbeispielen, die vom Compiler optimiert werden, weil sie keine Nebenwirkungen haben, einen Beweis für die Codegeschwindigkeit erbringen möchte. Ich würde sagen, dass man selten auf solche Messungen wetten kann, ohne einen echten / Produktionscode.
Ghita
@ Ghita: In diesem Beispiel calc1könnte ein floatArgument verwendet werden, das das Ergebnis der vorherigen Iteration wäre , um zu verhindern, dass der Code wegoptimiert wird. So etwas wie x = calc1(x, [](float arg){ return arg * 0.5f; });. Darüber hinaus müssen wir sicherstellen, dass calc1Verwendungen x. Das reicht aber noch nicht. Wir müssen einen Nebeneffekt erzeugen. Zum Beispiel nach der Messung xauf dem Bildschirm drucken . Auch wenn ich damit einverstanden bin, dass die Verwendung von Spielzeugcodes für Timimg-Messungen nicht immer einen perfekten Hinweis darauf geben kann, was mit Real- / Produktionscode passieren wird.
Cassio Neri
Es scheint mir auch, dass der Benchmark das std :: function-Objekt innerhalb der Schleife erstellt und calc2 in der Schleife aufruft. Unabhängig davon, ob der Compiler dies optimieren kann oder nicht (und ob der Konstruktor so einfach wie das Speichern eines vptr sein könnte), würde mich ein Fall mehr interessieren, in dem die Funktion einmal erstellt und an eine andere Funktion übergeben wird, die sie aufruft es in einer Schleife. Dh der Aufruf-Overhead und nicht die Konstruktionszeit (und der Aufruf von 'f' und nicht von calc2). Wäre auch interessiert, wenn der Aufruf von f in einer Schleife (in calc2) anstatt einmal von einem Heben profitieren würde.
Greggo
Gute Antwort. 2 Dinge: schönes Beispiel für eine gültige Verwendung für std::reference_wrapper(zum Erzwingen von Vorlagen; nicht nur zum allgemeinen Speichern), und es ist lustig zu sehen, dass der Optimierer von VS eine leere Schleife nicht verwirft ... wie ich bei diesem GCC-Fehlervolatile festgestellt habe .
underscore_d
37

Bei Clang gibt es keinen Leistungsunterschied zwischen den beiden

Bei Verwendung von clang (3.2, Trunk 166872) (-O2 unter Linux) sind die Binärdateien aus beiden Fällen tatsächlich identisch .

-Ich komme zurück, um am Ende des Beitrags zu klirren. Aber zuerst gcc 4.7.2:

Es gibt bereits viele Erkenntnisse, aber ich möchte darauf hinweisen, dass das Ergebnis der Berechnungen von calc1 und calc2 aufgrund von In-Lining usw. nicht dasselbe ist. Vergleichen Sie zum Beispiel die Summe aller Ergebnisse:

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

mit calc2 wird das

1.71799e+10, time spent 0.14 sec

während mit calc1 wird es

6.6435e+10, time spent 5.772 sec

Das ist ein Faktor von ~ 40 in der Geschwindigkeitsdifferenz und ein Faktor von ~ 4 in den Werten. Der erste ist ein viel größerer Unterschied als das, was OP gepostet hat (mit Visual Studio). Das tatsächliche Ausdrucken des Werts a the end ist auch eine gute Idee, um zu verhindern, dass der Compiler Code ohne sichtbares Ergebnis entfernt (als ob-Regel). Cassio Neri hat dies bereits in seiner Antwort gesagt. Beachten Sie, wie unterschiedlich die Ergebnisse sind - Sie sollten vorsichtig sein, wenn Sie Geschwindigkeitsfaktoren von Codes vergleichen, die unterschiedliche Berechnungen durchführen.

Um fair zu sein, ist es vielleicht auch nicht so interessant, verschiedene Methoden zur wiederholten Berechnung von f (3.3) zu vergleichen. Wenn der Eingang konstant ist, sollte er sich nicht in einer Schleife befinden. (Für den Optimierer ist es leicht zu bemerken)

Wenn ich calc1 und 2 ein vom Benutzer angegebenes Wertargument hinzufüge, wird der Geschwindigkeitsfaktor zwischen calc1 und calc2 von 40 auf den Faktor 5 reduziert! Bei Visual Studio liegt der Unterschied nahe bei einem Faktor von 2, und bei Clang gibt es keinen Unterschied (siehe unten).

Da die Multiplikationen schnell sind, ist es oft nicht so interessant, über Verlangsamungsfaktoren zu sprechen. Eine interessantere Frage ist, wie klein Ihre Funktionen sind und ob dies den Engpass in einem echten Programm darstellt.

Clang:

Clang (ich habe 3.2 verwendet) hat tatsächlich identische Binärdateien erzeugt, als ich für den Beispielcode (siehe unten) zwischen calc1 und calc2 gewechselt habe. Mit dem ursprünglichen Beispiel in der Frage sind beide ebenfalls identisch, nehmen jedoch keine Zeit in Anspruch (die Schleifen werden wie oben beschrieben einfach vollständig entfernt). Mit meinem modifizierten Beispiel mit -O2:

Anzahl der auszuführenden Sekunden (Best of 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 

Die berechneten Ergebnisse aller Binärdateien sind gleich und alle Tests wurden auf demselben Computer ausgeführt. Es wäre interessant, wenn jemand mit tieferem Clang- oder VS-Wissen kommentieren könnte, welche Optimierungen möglicherweise vorgenommen wurden.

Mein geänderter Testcode:

#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;
}

Aktualisieren:

Hinzugefügt vs2015. Mir ist auch aufgefallen, dass es in calc1, calc2 Double-> Float-Konvertierungen gibt. Das Entfernen ändert nichts an der Schlussfolgerung für Visual Studio (beide sind viel schneller, aber das Verhältnis ist ungefähr gleich).

Johan Lundberg
quelle
8
Was wohl nur zeigt, dass der Benchmark falsch ist. Meiner Meinung nach ist der interessante Anwendungsfall, dass der aufrufende Code ein Funktionsobjekt von einem anderen Ort empfängt, sodass der Compiler beim Kompilieren des Aufrufs den Ursprung der Funktion std :: nicht kennt. Hier kennt der Compiler die Zusammensetzung der std :: -Funktion beim Aufrufen genau, indem er calc2 inline in main erweitert. Einfach zu beheben, indem calc2 'sep' in sep. Quelldatei. Sie vergleichen dann Äpfel mit Orangen; calc2 macht etwas, was calc1 nicht kann. Und die Schleife könnte sich innerhalb von calc befinden (viele Aufrufe von f); nicht um den ctor des Funktionsobjekts.
Greggo
1
Wenn ich zu einem geeigneten Compiler komme. Kann vorerst sagen, dass (a) ctor für eine tatsächliche std :: -Funktion 'new' aufruft; (b) der Anruf selbst ist ziemlich mager, wenn das Ziel eine übereinstimmende tatsächliche Funktion ist; (c) In Fällen mit Bindung gibt es einen Codeabschnitt, der die Anpassung durchführt, ausgewählt durch einen Code ptr in der Funktion obj, und der Daten (gebundene Parameter) von der Funktion obj aufnimmt. (d) Die 'gebundene' Funktion kann in diesen Adapter eingebunden werden, wenn der Compiler ihn sehen kann.
Greggo
Neue Antwort mit dem beschriebenen Setup hinzugefügt.
Greggo
3
Übrigens ist der Benchmark nicht falsch, die Frage ("std :: function vs template") ist nur im Rahmen derselben Kompilierungseinheit gültig. Wenn Sie die Funktion auf eine andere Einheit verschieben, ist die Vorlage nicht mehr möglich, sodass nichts zu vergleichen ist.
Rustyx
13

Anders ist nicht dasselbe.

Es ist langsamer, weil es Dinge tut, die eine Vorlage nicht kann. Insbesondere können Sie damit jede Funktion aufrufen , die mit den angegebenen Argumenttypen aufgerufen werden kann und deren Rückgabetyp aus demselben Code in den angegebenen Rückgabetyp konvertierbar ist .

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;
}

Beachten Sie, dass dasselbe Funktionsobjekt funan beide Aufrufe an übergeben wird eval. Es hat zwei verschiedene Funktionen.

Wenn Sie das nicht tun müssen, sollten Sie nicht verwenden std::function.

Pete Becker
quelle
2
Ich möchte nur darauf hinweisen, dass das Objekt 'fun' nach Abschluss von 'fun = f2' auf eine versteckte Funktion verweist, die int in double konvertiert, f2 aufruft und das double-Ergebnis wieder in int konvertiert (im tatsächlichen Beispiel) , 'f2' könnte in diese Funktion eingebunden werden). Wenn Sie fun eine std :: bind zuweisen, enthält das 'fun'-Objekt möglicherweise die Werte, die für gebundene Parameter verwendet werden sollen. Um diese Flexibilität zu unterstützen, kann eine Zuweisung zu "Spaß" (oder Init von) das Zuweisen / Freigeben von Speicher beinhalten und es kann ziemlich länger dauern als der tatsächliche Anrufaufwand.
Greggo
8

Sie haben hier bereits einige gute Antworten, daher werde ich ihnen nicht widersprechen. Kurz gesagt, das Vergleichen von std :: function mit Vorlagen ist wie das Vergleichen von virtuellen Funktionen mit Funktionen. Sie sollten virtuelle Funktionen niemals Funktionen "vorziehen", sondern virtuelle Funktionen verwenden, wenn dies für das Problem geeignet ist, und Entscheidungen von der Kompilierungszeit zur Laufzeit verschieben. Die Idee ist, dass Sie das Problem nicht mit einer maßgeschneiderten Lösung (wie einer Sprungtabelle) lösen müssen, sondern etwas verwenden, das dem Compiler eine bessere Chance bietet, für Sie zu optimieren. Es hilft auch anderen Programmierern, wenn Sie eine Standardlösung verwenden.

TheAgitator
quelle
6

Diese Antwort soll zu den vorhandenen Antworten einen meiner Meinung nach aussagekräftigeren Maßstab für die Laufzeitkosten von std :: function-Aufrufen beitragen.

Der std :: -Funktionsmechanismus sollte für das erkannt werden, was er bietet: Jede aufrufbare Entität kann in eine std :: -Funktion mit entsprechender Signatur konvertiert werden. Angenommen, Sie haben eine Bibliothek, die eine Oberfläche an eine durch z = f (x, y) definierte Funktion anpasst. Sie können sie schreiben, um a zu akzeptieren std::function<double(double,double)>, und der Benutzer der Bibliothek kann jede aufrufbare Entität problemlos in diese konvertieren. Sei es eine gewöhnliche Funktion, eine Methode einer Klasseninstanz oder ein Lambda oder alles, was von std :: bind unterstützt wird.

Im Gegensatz zu Vorlagenansätzen funktioniert dies, ohne dass die Bibliotheksfunktion für verschiedene Fälle neu kompiliert werden muss. Dementsprechend wird für jeden weiteren Fall wenig zusätzlicher kompilierter Code benötigt. Es war immer möglich, dies zu erreichen, aber früher waren einige umständliche Mechanismen erforderlich, und der Benutzer der Bibliothek musste wahrscheinlich einen Adapter um seine Funktion herum erstellen, damit es funktioniert. Die Funktion std :: erstellt automatisch den Adapter, der benötigt wird, um eine gemeinsame Laufzeitaufrufschnittstelle für alle Fälle zu erhalten. Dies ist eine neue und sehr leistungsstarke Funktion.

Meiner Ansicht nach ist dies der wichtigste Anwendungsfall für die std :: -Funktion in Bezug auf die Leistung: Ich bin an den Kosten interessiert, die entstehen, wenn eine std :: -Funktion viele Male aufgerufen wird, nachdem sie einmal erstellt wurde, und das muss Es kann vorkommen, dass der Compiler den Aufruf nicht optimieren kann, indem er die tatsächlich aufgerufene Funktion kennt (dh Sie müssen die Implementierung in einer anderen Quelldatei ausblenden, um einen geeigneten Benchmark zu erhalten).

Ich habe den folgenden Test durchgeführt, ähnlich wie bei den OPs. aber die wichtigsten Änderungen sind:

  1. Jeder Fall wird 1 Milliarde Mal wiederholt, aber die std :: function-Objekte werden nur einmal erstellt. Ich habe anhand des Ausgabecodes festgestellt, dass beim Erstellen tatsächlicher std :: -Funktionsaufrufe 'operator new' aufgerufen wird (möglicherweise nicht, wenn sie optimiert sind).
  2. Der Test ist in zwei Dateien aufgeteilt, um unerwünschte Optimierungen zu vermeiden
  3. Meine Fälle sind: (a) Funktion ist inline (b) Funktion wird von einem gewöhnlichen Funktionszeiger übergeben (c) Funktion ist eine kompatible Funktion, die als std :: Funktion umschlossen ist (d) Funktion ist eine inkompatible Funktion, die mit einem std :: kompatibel gemacht wurde binden, als std :: function verpackt

Die Ergebnisse, die ich bekomme, sind:

  • Fall (a) (Inline) 1,3 ns

  • alle anderen Fälle: 3,3 ns.

Fall (d) ist tendenziell etwas langsamer, aber die Differenz (etwa 0,05 ns) wird im Rauschen absorbiert.

Die Schlussfolgerung ist, dass die std :: -Funktion (zum Zeitpunkt des Aufrufs) mit der Verwendung eines Funktionszeigers vergleichbar ist, selbst wenn eine einfache Anpassung an die eigentliche Funktion erfolgt. Die Inline ist 2 ns schneller als die anderen, aber das ist ein erwarteter Kompromiss, da die Inline der einzige Fall ist, der zur Laufzeit fest verdrahtet ist.

Wenn ich den Code von johan-lundberg auf demselben Computer ausführe, werden ungefähr 39 ns pro Schleife angezeigt, aber in der Schleife befindet sich noch viel mehr, einschließlich des tatsächlichen Konstruktors und Destruktors der Funktion std ::, der wahrscheinlich ziemlich hoch ist da es sich um eine neue und löschen handelt.

-O2 gcc 4.8.1 zum x86_64-Ziel (Kern i5).

Beachten Sie, dass der Code in zwei Dateien aufgeteilt ist, um zu verhindern, dass der Compiler die Funktionen dort erweitert, wo sie aufgerufen werden (außer in dem einen Fall, in dem dies beabsichtigt ist).

----- erste Quelldatei --------------

#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;
}

----- zweite Quelldatei -------------

#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;
}

Für Interessenten ist hier der Adapter, den der Compiler erstellt hat, damit 'mul_by' wie ein float (float) aussieht - dies wird 'aufgerufen', wenn die als bind (mul_by, _1,0.5) erstellte Funktion aufgerufen wird:

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

(Es wäre also vielleicht etwas schneller gewesen, wenn ich 0,5 f in die Bindung geschrieben hätte ...) Beachten Sie, dass der Parameter 'x' in% xmm0 ankommt und einfach dort bleibt.

Hier ist der Code in dem Bereich, in dem die Funktion erstellt wurde, bevor test_stdfunc aufgerufen wird - führen Sie c ++ filt aus:

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
quelle
1
Mit Clang 3.4.1 x 64 sind die Ergebnisse: (a) 1.0, (b) 0.95, (c) 2.0, (d) 5.0.
Rustyx
4

Ich fand Ihre Ergebnisse sehr interessant, also habe ich ein bisschen gegraben, um zu verstehen, was los ist. Zunächst einmal, wie viele andere gesagt haben, ohne die Ergebnisse des Berechnungseffekts zu haben, wird der Status des Programms vom Compiler nur optimiert. Zweitens vermute ich, dass eine Konstante von 3,3 als Bewaffnung für den Rückruf gegeben wird, dass weitere Optimierungen stattfinden werden. In diesem Sinne habe ich Ihren Benchmark-Code ein wenig geändert.

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();
}

Angesichts dieser Änderung des Codes habe ich mit gcc 4.8 -O3 kompiliert und eine Zeit von 330 ms für calc1 und 2702 für calc2 erhalten. Die Verwendung der Vorlage war also 8-mal schneller, diese Zahl sah für mich verdächtig aus. Eine Geschwindigkeit von 8 zeigt oft an, dass der Compiler etwas vektorisiert hat. Als ich mir den generierten Code für die Vorlagenversion ansah, war er eindeutig vektorisiert

.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)

Wobei wie die std :: function version nicht war. Dies ist für mich sinnvoll, da der Compiler mit der Vorlage sicher weiß, dass sich die Funktion während der gesamten Schleife niemals ändern wird, aber wenn die darin übergebene std :: -Funktion sich ändern könnte, kann sie daher nicht vektorisiert werden.

Dies veranlasste mich, etwas anderes zu versuchen, um zu sehen, ob ich den Compiler dazu bringen konnte, dieselbe Optimierung für die std :: function-Version durchzuführen. Anstatt eine Funktion zu übergeben, erstelle ich eine std :: -Funktion als globale Variable und lasse diese aufrufen.

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();
}

Mit dieser Version sehen wir, dass der Compiler den Code jetzt auf die gleiche Weise vektorisiert hat und ich die gleichen Benchmark-Ergebnisse erhalte.

  • Vorlage: 330ms
  • std :: function: 2702ms
  • globale std :: Funktion: 330ms

Mein Fazit ist also, dass die Geschwindigkeit einer std :: -Funktion gegenüber einem Template-Funktor ziemlich gleich ist. Dies erschwert jedoch die Arbeit des Optimierers erheblich.

Joshua Ritterman
quelle
1
Der springende Punkt ist, einen Funktor als Parameter zu übergeben. Ihr calc3Fall macht keinen Sinn; calc3 ist jetzt fest codiert, um f2 aufzurufen. Das kann natürlich optimiert werden.
Rustyx
in der Tat ist es das, was ich zeigen wollte. Dieser calc3 entspricht der Vorlage und ist in dieser Situation genau wie eine Vorlage ein Konstrukt zur Kompilierungszeit.
Joshua Ritterman