Im Blog-Beitrag vom August 2014 haben wir die Modularisierung von Bildverarbeitungsalgorithmen mit der Template-basierten Policy im Vergleich zum dynamischen Polymorphismus basierten Strategy-Muster in Bezug auf Geschwindigkeit verglichen. Wir werden erneut die Laufzeit von (fast) denselben pixelweisen Operationen auf Bildern vergleichen. Diesmal betrachten wir jedoch verschiedene funktionale Modularisierungstechniken. Wir werden Funktionszeiger, Lambdas und abgeleitete Funktoren an Algorithmen übergeben und verschiedene Argumenttypen wie std::function
oder generische Typen verwenden.
Hinweis
Diesen Artikel habe ich ursprünglich auf englisch geschrieben und im August 2023 mit ChatGpts Hilfe ins Deutsche übersetzt. Das ist eine seltene und alte Ausnahme. Üblicherweise schreibe ich Artikel auf deutsch und die Übersetzung ins Englische passiert in Kollaboration mit einer Maschine.
Unser Algorithmus ist eine binäre Operation auf zwei Bildern. Er durchläuft die beiden Eingangsbilder und ein Ausgangsbild. Dabei wird eine pixelweise binäre Operation auf den Eingangsbildern angewendet und das Ergebnis im Ausgangsbild gespeichert. Die pixelweise binäre Operation wird als First-Class-Citizen-Funktion als verschiedene Argumenttypen übergeben:
std::function
- template Argument
- Funktionszeiger
- Basisklasse von Funktoren mit dynamischer Polymorphie
Als Eingabe für die Funktionsargumente verwenden wir Lambda-Funktionen, Funktionszeiger und abgeleitete Funktoren.
Funktionale Modularisierung
In diesem Abschnitt beschreiben wir sehr kurz, wie man Modularisierung durch das Übergeben von Funktionen anwendet. Zeitmessungen sind Gegenstand des nächsten Abschnitts.
Der einfache Bildtraversierungsalgorithmus ist im folgenden Beispiel dargestellt:
void binary(SomeFuncArgType op, const Image& im_in1, const Image& im_in2,
Image& im_out, unsigned width, unsigned height)
{
for (unsigned y = 0u; y < height; y++)
for (unsigned x = 0u; x < width; x++)
im_out[x + y * width] = op(im_in1[x + y * width],
im_in2[x + y * width]);
}
wobei SomeFuncArgType
ein Platzhalter für die nachfolgenden Typen und die Klasse Image
ein Vektor von Gleitkommazahlen ist, d.h.
using Image = std::vector<float>;
Im Folgenden zeigen wir nur die unterschiedlichen Funktionssignaturen, da die Implementierungen identisch sind.
std::function
Der Typ std::function
ist ein generischer Funktionswrapper. Man kann std::function
verwenden, um Funktionszeiger oder Funktoren/Lambdas zu speichern. Die Verwendung von std::function
zur Modularisierung des Algorithmus ist in der folgenden Signatur dargestellt.
void binary_func(std::function<float(float, float)> op,
const Image& im_in1, const Image& im_in2,
Image& im_out, unsigned width, unsigned height);
Template-Argument
Wie bei std::function
kann man Funktoren/Lambdas und Funktionszeiger an den Algorithmus mittels Template-basierter Signatur übergeben.
template <typename F>
void binary_template(F op, const Image& im_in1, const Image& im_in2,
Image& im_out, unsigned width, unsigned height);
Roher Zeiger
Die rohe Zeiger-Signatur ist C-kompatibel.
void binary_pointer(float (*op)(float, float), const Image& im_in1,
const Image& im_in2, Image& im_out, unsigned width,
unsigned height)
Man kann Funktionszeiger von freien Funktionen entweder per Adresse oder direkt übergeben, d.h.
binary_pointer(&free_func, ...);
binary_pointer(free_func, ...);
Auch Lambdas, wenn die keine Variablien aus ihrer Umgebung erfassen, können als Funktionszeiger übergeben werden. Diese letzte Möglichkeit wird in unseren Experimenten ignoriert.
Basisklasse von Funktoren mit Dynamische Polymorphie
Mit der abstrakten Basisklasse BaseFunctor
ergibt sich folgende Signatur.
void binary_dyn_poly(const BaseFunctor& op, const Image& im_in1,
const Image& im_in2, Image& im_out, unsigned width,
unsigned height);
Hinweis
Die Algorithmen der Standardbibliothek erhalten ihr Prädikat per Wert. Daher ist dieser Fall der einzige, bei dem wir for-Schleifen nicht durch
std::transform
ersetzen konnten. Die Ausführungsgeschwindigkeit ist jedoch in allen anderen Fällen für beide Varianten ähnlich.
Binäre Pixelweise-Operationen
Für zwei Additionen (eine gewöhnliche und eine unnötig komplizierte) und eine Maximum-Operation verwenden wir Lambdas,
auto add_lambda = [](float a, float b) { return a + b; };
auto add_complicated_lambda = [](float a, float b)
{
return std::sqrt(a * a) + std::sqrt(b * b);
};
auto maximum_lambda = [](float a, float b) { return std::max(a,b); };
Funktionszeiger von freien Funktionen
auto add_func (float a, float b) { return a + b; }
auto add_complicated_func(float a, float b)
{
return std::sqrt(a * a) + std::sqrt(b * b);
}
auto maximum_func(float a, float b) { return std::max
(a, b); }
und abgeleitete Funktoren von der abstrakten Basisklasse
struct AddFunctor : public BaseFunctor
{
virtual float operator()(float a, float b) const override
{
return a + b;
}
};
struct AddComplicatedFunctor : public BaseFunctor
{
virtual float operator()(float a, float b) const override
{
return std::sqrt(a*a) + std::sqrt(b*b); }
};
struct MaxFunctor : public BaseFunctor
{
virtual float operator()(float a, float b) const override
{
return std::max(a, b);
}
};
Geschwindigkeitsmessungen
Wir wenden die pixelweisen binären Operationen mit gleichen Operanden auf einen std::vector<float>
von 1024x1024 Nullen 100 Mal mit jedem Ansatz an. Als Benchmark verwenden wir die minimale Laufzeit in Mikrosekunden ($\mu{\rm s}$) über die 100 Durchläufe. Wir verwenden einen Intel Core i7-6700HQ CPU @ 2,60 GHz Mobilprozessor. Als Compiler verwenden wir GCC 5.4 und Clang 3.9 unter Ubuntu 16.04 sowie Visual Studio 2017 unter Windows 10 (alle 64 Bit). Wir haben im Release-Modus kompiliert.
Wir werden 8 Paare von Argumenttypen (Signatur) und übergebenen Typen berücksichtigen:
- Zeiger, der als
std::function
übergeben wird - Lambda, die als
std::function
übergeben wird - abgeleiteter Funktor, der als
std::function
übergeben wird - Lambda, die an ein templatbasiertes Argument übergeben wird
- Zeiger, der an ein templatbasiertes Argument übergeben wird
- abgeleiteter Funktor, der an ein templatbasiertes Argument übergeben wird
- Zeiger, der als Zeiger übergeben wird
- abgeleiteter Funktor, der als Basisklasse von Funktoren übergeben wird
Für jede pixelweise binäre Operation werden wir die Paare nach einem Straf-Score für den Vergleich über Compiler sortieren. Hierfür ordnen wir die Paare nach ihren Laufzeiten. Jedem Paar wird ein Straf-Score von $(t_{\rm cur} - t_{\rm prev}) // 100$ zugeordnet, wobei $t_{\rm cur}$ die Laufzeit des aktuellen Paares ist, $t_{\rm prev}$ die Laufzeit des vorherigen besseren Paares ist und $//$ die Ganzzahldivision bezeichnet. Das endgültige Ranking ergibt sich aus der Summe der Straf-Scores über die verschiedenen Compiler und wird in den folgenden Tabellen dargestellt. Zusätzlich enthalten die Tabellen die minimale Laufzeit in Mikrosekunden für jeden Compiler.
add
Rang | Argumenttyp (Signatur) | Übergebener Typ | Straf-Score | gcc $\mu$s | clang $\mu$s | vs $\mu$s |
---|---|---|---|---|---|---|
1 | template | Lambda | 0 | 1549 | 827 | 819 |
2 | Zeiger | Zeiger | 11 | 2168 | 827 | 1552 |
3 | template | Zeiger | 14 | 2147 | 829 | 1866 |
4 | template | abgeleiteter Funktor | 25 | 1881 | 2172 | 1868 |
5 | Basisklasse von Funktor | abgeleiteter Funktor | 26 | 2064 | 2175 | 1890 |
6 | std::function |
Lambda | 49 | 4137 | 2021 | 2464 |
7 | std::function |
abgeleiteter Funktor | 54 | 4016 | 2567 | 2721 |
8 | std::function |
Zeiger | 55 | 3974 | 2480 | 2879 |
komplizierte Addition
Rang | Argumenttyp (Signatur) | Übergebener Typ | Straf-Score | gcc $\mu$s | clang $\mu$s | vs $\mu$s |
---|---|---|---|---|---|---|
1 | template | Lambda | 0 | 7460 | 5918 | 846 |
2 | Zeiger | Zeiger | 63 | 8264 | 5948 | 6494 |
3 | template | Zeiger | 65 | 8249 | 5946 | 6731 |
4 | template | abgeleiteter Funktor | 94 | 8230 | 8677 | 6947 |
4 | Basisklasse von Funktor | abgeleiteter Funktor | 94 | 8239 | 8673 | 6964 |
6 | std::function |
Lambda | 102 | 8546 | 8740 | 7628 |
7 | std::function |
Zeiger | 104 | 8535 | 8854 | 7773 |
8 | std::function |
abgeleiteter Funktor | 105 | 8527 | 8866 | 7907 |
Maximum
Rang | Argumenttyp (Signatur) | Übergebener Typ | Straf-Score | gcc $\mu$s | clang $\mu$s | vs $\mu$s |
---|---|---|---|---|---|---|
1 | template | Lambda | 0 | 1631 | 834 | 904 |
2 | template | Zeiger | 15 | 2312 | 837 | 1922 |
2 | Zeiger | Zeiger | 15 | 2556 | 834 | 1605 |
4 | template | abgeleiteter Funktor | 27 | 2340 | 2180 | 1872 |
5 | Basisklasse von Funktor | abgeleiteter Funktor | 29 | 2351 | 2429 | 1865 |
6 | std::function |
Lambda | 45 | 3905 | 2018 | 2415 |
7 | std::function |
abgeleiteter Funktor | 60 | 4642 | 2586 | 2828 |
8 | std::function |
Zeiger | 63 | 5000 | 2552 | 2835 |
Besonderheit
Visual Studio 2017 ist unglaublich schnell für den komplizierten Additionsfall, wenn die Lambda-Funktion an die Template-basierte Algorithmusimplementierung übergeben wird. Dies erfordert weitere Untersuchungen und könnte zu einem weiteren Blog-Beitrag führen.