OptiVec logo 

VectorLib



Index:

OptiVec Home
MatrixLib
CMATH
Download
Bestellung / Registrierung
Update
Support

VectorLib

VectorLib ist der Teil von OptiVec, in dem die Vektor-Funktionen zusammengefasst sind. An dieser Stelle werden die Grundprinzipien der OptiVec-Bibliotheken beschrieben und ein allgemeiner Überblick über VectorLib gegeben. Das objekt-orientierte Interface VecObj wird in Kap. 3 beschrieben. An anderer Stelle finden Sie die Beschreibungen von MatrixLib und CMATH.

Inhaltsverzeichnis

1. Einführung
1.1 Warum sich vektorisierte Programmierung auf dem PC lohnt
 1.1.1 Allgemeine Optimierungs-Strategien von OptiVec
 1.1.2 Multi-Prozessor-Optimierung
2. Elemente von VectorLib-Funktionen
2.1 Synonyme für einige Datentypen
2.2 Komplexe Zahlen: Die Datentypen fComplex, dComplex, eComplex, fPolar, dPolar und ePolar
2.3 Vektoren und Arrays: Die Datentypen fVector usw.
2.4 Vektorfunktions-Präfixe
3. Nur C++: VecObj, das objekt-orientierte Interface für VectorLib
>4. VectorLib-Funktionen: Ein kurzer Überblick
4.1 Erzeugung, Initialisierung und Freigabe von Vektoren
4.2 Index-orientierte Manipulationen
4.3 Datentyp-Umwandlungen
4.4 Nähere Informationen zur Ganzzahl-Arithmetik
4.5 Grundfunktionen komplexer Vektoren
4.6 Mathematische Funktionen
4.6.1 Rundung
4.6.2 Vergleiche
4.6.3 Direkte Bit-Manipulationen
4.6.4 Arithmetische Grundfunktionen, Akkumulation
4.6.5 Geometrische Vektor-Arithmetik
4.6.6 Potenzen
4.6.7 Exponential- und Hyperbel-Funktionen
4.6.8 Logarithmen
4.6.9 Trigonometrische Funktionen
4.7 Analysis
4.8 Signalverarbeitung: Fourier-Transformations-Techniken
4.9 Statistische Funktionen und Bausteine
4.10 Daten-Anpassung
4.11 Input und Output
4.12 Graphik
5. Fehlerbehandlung
5.1 Allgemeines
5.2 Ganzzahl-Fehler
5.3 Fließkomma-Fehler
5.3.1 C/C++-spezifisch
5.3.2 Pascal/Delphi-spezifisch
5.3.3 Fehlerarten (sowohl C/C++ als auch Pascal/Delphi)
5.4 Die Behandlung nicht-normalisierter Zahlen
5.5 Fortgeschrittene Fehlerbehandlung: Meldungen in eine Datei schreiben
5.6 OptiVec-Fehlermeldungen
6. Wenn etwas schiefgeht 7. Die Include-Dateien und Units von OptiVec


1. Einführung

OptiVec bietet eine umfangreiche Bibliothek zur effizienten und genauen Verarbeitung von Daten, die in ein- oder zweidimensionalen Arrays vorliegen. Das Konzept der "vektorisierten Programmierung" wird hiermit auf sehr einfache und übersichtliche Weise für die Sprachen C/C++ und Pascal/Delphi verfügbar gemacht. Der Ersatz konventioneller Schleifen durch Vektor- und Matrix-Funktionen führt zu einer starken Vereinfachung der Schreibarbeit des Programmierers und zu einem großen Gewinn an Geschwindigkeit und Genauigkeit der Programme.

Dem Ziel der Vereinfachung dienen zwar bereits seit mehr als zwei Jahrzehnten auch die Feldfunktionen von Fortran90 und templatisierte Vektor-Klassen in C++, doch sind dies lediglich Abkürzungen, die vom Compiler wieder in Schleifen übersetzt und entsprechend ineffizient verarbeitet werden. (Ähnliches gilt für die meisten der populären BLAS-Bibliotheken für Fortran). Demgegenüber bietet OptiVec eine hochoptimierte, in Assembler geschriebene Lösung, deren Geschwindigkeit nicht mehr durch die Qualität des Compilers, sondern nur noch durch die echte Geschwindigkeit des Prozessors bestimmt wird. Gegenüber compiliertem Code ergibt sich ein durchschnittlicher Geschwindigkeitsvorteil von einem Faktor 2 bis 3 (für einige Funktionen auch bis zu 8).

Nach unserem Kenntnisstand war OptiVec beim Erscheinen 1996 die erste umfassende Vektor- und Matrix-Bibliothek für PC-Compiler, die praktisch vollständig in Maschinensprache geschrieben wurde.

OptiVec tritt in Konkurrenz zu etlichen teuren integrierten Programmsystemen für wissenschaftliche und Datenverarbeitungs-Anwendungen, ist aber eben nicht ein "geschlossenes" integriertes Paket, sondern zur Verwendung mit den gängigen Programmiersprachen bestimmt, wodurch dem OptiVec-Benutzer die Flexibilität seiner bevorzugten Programmierumgebung erhalten bleibt.

Hier einige Stichworte:

Der große Funktions-Umfang, die hohe numerische Genauigkeit und die Einfachkeit der Benutzung machen OptiVec zu einem wertvollen Programmierwerkzeug für wissenschaftlich-technische Datenverarbeitungs-Anwendungen.

1.1 Warum sich vektorisierte Programmierung auf dem PC lohnt

Um eindimensionale Datenfelder oder "Vektoren" zu verarbeiten, schreibt der Programmierer normalerweise eine Schleife über alle Vektor-Elemente. Und zwei- oder höher-dimensionale Felder ("Matrizen" oder "Tensoren") werden üblicherweise mittels verschachtelter Schleifen über die Indizes in allen Dimensionen verarbeitet. Die Alternative zu diesem klassischen Programmier-Stil sind Vektor- und Matrix-Funktionen.
Vektor-Funktionen wirken auf ganze Vektoren anstatt einzelne skalare Argumente. Sie stellen die konsequenteste Form der "Vektorisierung" dar, also der Organisation von Programm-Code (sei es durch optimierende Compiler oder durch den Programmierer selbst) mit dem Ziel der Optimierung der Behandlung von Vektoren.

Vektorisierung war schon immer die Zauberformel für Supercomputer mit ihren aus vielen einzelnen Prozessoren gebildeten Parallel-Architekturen. Auf diesen Architekturen wird versucht, die Rechenlast möglichst gleichmäßig auf alle Prozessoren zu verteilen und so die Ausführungsgeschwindigkeit zu maximieren. Die sogenanten "divide and conquer"-Algorithmen spalten kompliziertere numerische Aufgaben in kleine Schleifen über Vektorelemente auf. Hochgezüchtete Compiler finden dann den effizientesten Weg für die Verteilung der Vektor-Elemente auf die Prozessoren. Viele Compiler für Supercomputer enthalten bereits große Bibliotheken vordefinierter Vektor- und Matrixfunktionen für viele Anwendungszwecke. Diese Vektor- und Matrixfunktionen bieten den besten Weg, maximalen Datendurchsatz zu erzielen.

Es ist offensichtlich, daß die massive Parallelverarbeitung einer Cray auf den meisten PCs mit ihren eher bescheidenen 2, 4 oder allenfalls 8 Prozessor-Kernen nicht in gleicher Weise möglich ist. Auf den ersten Blick mag es daher sinnlos erscheinen, das Konzept der vektorisierten Programmierung auch auf dem PC anzuwenden. Tatsächlich aber sind auch viele vektor-spezifische Optimierungen möglich, selbst wenn nur eine CPU vorhanden ist. Viele dieser Optimierungen können von heutigen Compilern nicht direkt durchgeführt werden. Stattdessen muß der Programmierer auf Maschinensprachen-Niveau heruntergehen. Hand-optimierte, in Maschinensprache geschriebene Vektorfunktionen übertreffen compilierte Schleifen in der Geschwindigkeit durchschnittlich um einen Faktor von 2-3. Dies bedeutet, daß Vektorisierung die Mühe sehr wohl lohnen kann, auch für PC-Programme.

1.1.1 Allgemeine Optimierungs-Strategien von OptiVec

Hier sind die wichtigsten Optimierungs-Strategien, die in OptiVec zur Steigerung der Performance auf eingesetzt werden &150; unabhängig von der Zahl der Prozessor-Kerne:

Prefetch von Gruppen von Vektor-Elementen
Ab dem Pentium III stehen sehr nützliche "Prefetch"-Befehle zur Verfügung, die es erlauben, Daten schon genügen im voraus aus dem Hauptspeicher in den Prozessor zu laden, so daß sie gleich zur Verfügung stehen, wenn sie verarbeitet werden sollen.

Cache-Kontrolle
Der Pentium III+ -Befehlssatz erlaubt es, Daten als "temporär" (zur Wiederverwendung vorgesehen) oder "nicht-temporär" (nur einmal verwendet) zu markieren, wenn sie geladen oder gespeichert werden. OptiVec-Funktionen gehen generell von der Annahme aus, daß Eingabevektoren (oder -matrizen) nicht noch einmal benutzt werden, während Ausgabevektoren wahrscheinlich ihrerseits zu Eingabedaten für folgende Operationen werden. Dementsprechend wird der Cache beim Laden von Eingabedaten umgangen, während Ausgabedaten in den Cache geschrieben werden. Natürlich wird dieses Schema zusammenbrechen, wenn Vektoren gar nicht mehr in den Cache passen. Für solche Fälle ist die "Large-Vector"-Version der OptiVec-Bibliotheken gedacht, die auch beim Speichern der Ausgabedaten den Cache umgeht. Für einfache arithmetische Operationen kann hierdurch im Vergleich zur Version für kleine und mittlere Vektoren ein Geschwindigkeitsgewinn von bis zu 20% erzielt werden. Da andererseits die "Large-Vector"-Version effektiv den Cache ausschaltet, resultiert ein drastisch verschlechterter Datendurchsatz (bis zu einem Faktor von 3-4) von ihrem eventuellen Mißbrauch für kleinere Vektoren, wo der Cache hätte benutzt werden können. Bevor Sie tatsächlich die "Large-Vector"-Version einsetzen, sollten Sie daher auch prüfen, ob sich Ihr Problem nicht in kleinere Vektoren aufspalten läßt, wodurch der Cache wieder genutzt und ein enormer Geschwindigkeitsvorteil erzielt werden könnte.

Verwendung von SIMD-Befehlen
Man mag sich wundern, warum diese Strategie nicht gleich an erster Stelle genannt ist. Die SSE oder "Streaming Single-Instruction-Multiple-Data Extensions" des Pentium III und Pentium 4 bieten explizite Unterstützung für Vektor-Programmierung mit Fließkommazahlen in float / single- oder double- Genauigkeit (letztere nur für Pentium 4). Auf den ersten Blick sollten sie also die Vektor-Programmierung auf dem PC geradezu revolutionieren. Angesichts einer immer noch vorhandenen Diskrepanz zwischen Prozessor- und Datenbus-Geschwindigkeit sind aber viele der einfachen arithmetischen Operationen in ihrer Geschwindigkeit durch den Datenfluß begrenzt. Hier können SIMD-Befehle nur noch zu einem geringeren Geschwindigkeitsvorteil führen, als man eigentlich erwarten würde. Die gleichzeitige Verarbeitung von vier float-Zahlen in einem einzigen Befehl erbringt so häufig nur eine Beschleunigung um 20-30% gegenüber gutem FPU-Code, was sich natürlich immer noch lohnt. Für kompliziertere Operationen allerdings können SIMD-Befehle oft gar nicht verwendet werden, wenn nämlich entweder bedingte Verzweigungen für jedes Vektor-Element individuell erforderlich sind, oder auch dann, wenn ohne die interne extended-Genauigkeit der FPU umständlichere Algorithmen gewählt werden müßten. OptiVec macht daher von den SSE-Befehlen überall dort Gebrauch, wo ein wirklicher Geschwindigkeitsvorteil erzielt werden kann. Man beachte allerdings, dass Operationen wie Matrix-Multiplikation oder Fourier-Transformation in float-Präzision zugunsten des hier möglichen erheblichen Geschwindigkeitsgewinnes einen Genauigkeitsverlust von 2-3 Stellen in Kauf nehmen. Wer demgegenüber auf maximale Genauigkeit Wert legen muss, sollte daher stets die ausschließlich FPU-Befehle verwendende P4-Version einsetzen.

Preload von Fließkomma-Konstanten
Anstatt Fließkomma-Konstanten am Ende jedes Funktionsaufrufes innerhalb einer Schleife von Coprozessor-Stack zu entfernen, bleiben sie für die Verarbeitung des nächsten Vektor-Elementes geladen.

Volle XMM- und FPU-AusnutzungWo immer nötig und sinnvoll, werden alle acht XMM-Register (in der 64-bit-Version sogar sechzehn) bzw. alle acht Coprozessor-Register eingesetzt (für einen Compiler ist es schon eine hervorragende Leistung, die Buchführung für vier Coprozessor-Register zu beherrschen).

Superscalar schedulingDurch sorgfältige "Paarung" von Befehlen, deren Ergebnisse nicht voneinander abhängen, können die parallelen Integer-Pipes und fadd/fmul-Einheiten moderner Prozessoren (seit Pentium) bestmöglich ausgenutzt werden.
Ältere Prozessoren profitieren hiervon nicht, zumeist schadet es aber auch nicht.

Loop-unrolling
Wo eine optimale Ausnutzung der parallelen Prozessor-Pipes nicht für einzelne Vektor-Elemente erzielt werden kann, werden die Vektor-Elemente häufig gleich zu zweit, zu viert oder noch mehreren verarbeitet. Hierdurch wird zusätzlich der relative Anteil des Schleifen-Managements an der gesamten Ausführungszeit zurückgedrängt. Im Zusammenhang mit den oben beschriebenen "Prefetch"-Mechanismen wird die Schleifengröße möglichst an die Cache-Zeilengröße von 64 Byte (32 Byte bei älteren Prozessoren) angepaßt.

Vereinfachte Adressierung
Die Adressierung von Vektor- und erst recht von Matrix-Elementen stellt noch immer eine Hauptquelle für ineffizienten Code heutiger Compiler dar. Durch Hin- und Herschaltung zwischen Eingabe- und Ausgabe-Vektoren wird eine große Zahl redundanter Adressierungs-Operationen ausgeführt. Durch die ebenso strikte wie einfache Definition "Verarbeitung von hier bis da" können die OptiVec-Funktionen den Aufwand für die Adressierung von Array-Elementen auf das nötige Minimum reduzieren.

Ersatz von Fließkomma- durch Ganzzahl-Befehle
Eine Reihe von Fließkomma-Operationen (wie Kopieren, Austauschen, Vergleich mit Sollwerten) kann wahlweise mit Ganzzahl- oder Fließkomma-Prozessorbefehlen implementiert werden. Hier wird natürlich die jeweils schnellste Methode angewandt.

Strikte Genauigkeits-Kontrolle
C/C++-Compiler wandeln eine float-Zahl in double um – Pascal/Delphi sogar in extended – bevor sie an eine mathematische Funktion übergeben wird. Diese Behandlung war einmal sinnvoll, als Festplattenspeicher zu teuer war, um in den .LIB-Dateien separate Funktionen für alle Datentypen einzuschließen. Auf heutigen PCs ist sie schlicht ineffizient. Konsequenterweise werden in den OptiVec-Routinen keine solchen impliziten Umwandlungen durchgeführt. Hier wird eine float-Funktion auch nur in float- (also einfacher) Genauigkeit berechnet, unter Verzicht auf die soundsovielte Stelle nach dem Komma, die ohnehin sofort wieder abgeschnitten wird. Zusätzlich kann V_setFPAccuracy( 1 ); aufgerufen werden, um die FPU auf einfache Genauigkeit umzuschalten, falls man sich generell mit dieser begnügen möchte. Hierdurch kann die Ausführungsgeschwindigkeit ab dem Pentium-Prozessor etwas gesteigert werden. Seien Sie aber darauf gefasst, dass die Genauigkeit Ihrer Endergebnisse noch deutlich unter der float-Spezifikation liegen kann, wenn schon die Zwischenergebnisse nur einfach-genau berechnet werden. Details werden bei V_setFPAccuracy aufgeführt.

Inline-Coding
Alle externen Funktionsaufrufe sind aus den Schleifen eliminiert. Dadurch wird die Ausführungszeit der "call / ret"-Paare sowie die Zeit für die Übergabe der Funktionsargumente eingespart.

Cache-line-Matching lokaler Variablen
Der Level-1-Cache aktueller Prozessoren ist in Zeilen von je 64 Byte organisiert (bei den Vorgängern waren es 32 Byte). Viele OptiVec-Funktionen benötigen doppelt- oder extended-genaue Variablen auf dem Stack (vor allem für Ganzzahl/Fließkomma-Umwandlungen oder für Bereichsprüfungen). Derzeit erhältliche Compiler richten den Stack an 4-Byte-Grenzen aus. Es besteht also die Gefahr, dass die 8 Bytes einer double oder die 10 bytes einer extended beim Speichern auf dem Stack eine 64-Byte-Grenze überschreiten. Dies wiederum würde zu starken Geschwindigkeits-Einbußen durch Cache-Zeilenumbrüche führen. Um diese zu vermeiden, richten alle OptiVec-Funktionen, für die dies eine Rolle spielt, ihre lokalen Variablen an 8-Byte- (für double), 16-Byte- (für extended) bzw. 64-Byte-Grenzen aus (XMM-Werte).

Ungeschützte und bereichsreduzierte Funktionen
OptiVec bietet alternative Formen einiger mathematischer Funktionen, bei denen man zwischen der geschützten Variante mit Fehlerbehandlung und einer ungeschützten Variante ohne Fehlerdetektion währen kann. In einigen Funktionen, die ganzzahlige Potenzen ausrechnen, erlaubt die Abwesenheit der Fehlerdetektion eine viel effizientere Codierung. Ähnliches gilt für die Sinus- und Cosinus-Funktion für mit Sicherheit zwischen -2p und +2p liegenden Argumenten. In diesen Spezialfällen kann die Ausführungszeit um bis zu 40% reduziert werden, abhängig von der Hardware-Umgebung. Dieser Geschwindigkeitsgewinn wird allerdings durch erhöhtes Risiko erkauft: Falls auch nur ein einziges Vektorelement außerhalb des gültigen Bereiches liegt, stürzen die ungeschützten und bereichsreduzierten Funktionen ohne Warnung einfach ab.

1.1.2 Multi-Prozessor-Optimierung

MultithreadSupport
Moderne Betriebssysteme erlauben es, innerhalb eines Programmes parallel laufende Threads auf die vorhandenen Prozessorkerne zu verteilen so die Performance gegenüber Single-Thread-Verarbeitung zu vervielfachen. Hierfür muß aber sichergestellt sein, daß in parallelen Threads laufende Funktionen sich nicht gegenseitig ihre Zwischenergebnisse überschreiben. Mit sehr wenigen Ausnahmen (namentlich den Plotting-Funktionen) sind alle übrigen OptiVec-Funktionen re-entrant, also darauf ausgerichtet, parallel zueinander laufen zu können.

Bei der Entwicklung Ihrer Multi-Thread-Anwendung stehen Ihnen zwei grundsätzlich verschiedene Optionen zur Verfügung: Funktionale Parallelität und Daten-Parallelität.

Funktionelle Parallelität
Verschiedene Threads führen verschiedene Aufgaben aus – sie unterscheiden sich in ihrer Funktion. Als Beispiel denke man an eine Anwendung, bei der ein Thread Benutzer-Ein- und Ausgaben abarbeitet, während ein anderer Thread Hintergrund-Berechnungen durchführt. Selbst auf einer Ein-Kern-CPU kann diese Art des Multi-Threading durch die vom Betriebssystem bewirkte ständige Umschaltung zwischen den beiden Threads Vorteile bieten (z.B., dass das Benutzer-Interface nicht blockiert, während die Hintergrundberechnungen ausgeführt werden, sondern weiterhin Eingaben annehmen kann). Auf einem Mehr-Prozessor-Computer können die zwei (oder mehr) Threads tatsächlich gleichzeitig auf den verschiedenen Prozessor-Kernen laufen. Normalerweise ist die Lastverteilung zwischen den Prozessoren bei funktionellem Multi-Threading alles andere als perfekt: Oft läuft ein Prozessor unter Volllast, während ein anderer arbeitslos auf Eingaben wartet. Dennoch ist diese Art des Multi-Threading die beste Option für Anwendungen, die nur kleine bis mittelgroße Vektoren und Matrizen umfassen.

Daten-Parallelität
Um die Lastverteilung zwischen den vorhandenen Prozessor-Kernen zu verbessern und so den Datendurchsatz zu maximieren, kann die klassische Parallelverarbeitung angewandt werden: Die Daten-Vektoren und -Matrizen werden in kleinere Teile zerlegt, und jeder Thread arbeitet einen solchen Teil ab. Die Brauchbarkeit dieses Ansatzes wird dadurch beschränkt, dass der Aufwand für die Verteilung der Daten auf die verschiedenen Threads und für die dabei nötige Kommunikation der Threads untereinander ziemlich hoch ist. Außerdem lassen sich die Daten niemals vollständig parallelisieren; es verbleibt immer ein gewisser Teil der Aufgaben, der nur sequentiell abgearbeitet werden kann. Daher lohnt sich Daten-Parallelität nur für größere Vektoren und Matrizen. Typische Schwellen-Größen, ab denen die Leistung mehrerer Prozessoren den für die Verteilung auf sie nötigen Aufwand "zurückverdient", reichen von unter 100 (bei mathematischen Funktionen komplex-zahliger Vektoren) bis zu über 10.000 Elementen (bei den einfachen arithmetischen Funktionen). Erst wenn die Vektoren / Matrizen deutlich größer als diese Schwellenwerte sind, kommt die erhöhte Leistung voll zum Tragen. Dann erst nähert sich die Beschleunigung dem theoretischen Grenzwert einer Verdopplung, Vervierfachung usw. an.

1.1.3 Unterstützung für CUDA-Hardware

Moderne Graphik-Karten sind mit bis zu mehreren hundert Prozessorkernen bestückt, die alle parallel laufen können. In den letzten Jahren wurden Interfaces entwickelt, die es erlauben, diese geballte Rechenpower außer für Graphik- auch für allgemeine Berechnungen nutzbar zu machen. Einer dieser Ansätze ist das CUDA-Konzept von NVIDIA. Alle aktuellen NVIDIA-Graphikkarten unterstützen CUDA. Außerdem bietet NVIDIA spezielle Hochleistungs-Hardware an, die von vornherein nicht als Graphikkarten, sondern gewissermaßen als Vektor-Coprozessor gedacht ist. Mit den cudaOptiVec-Bibliotheken (gekennzeichnet durch ein "C" im Namen, z.B. OVVC8C.LIB oder OVBC64_8C.a) bietet OptiVec einen einfachen Weg, um CUDA-Hardware für Vektor-/Matrix-Berechnungen zu nutzen - ohne die Schwierigkeiten tatsächlicher CUDA-Programmierung. Es gibt einige Punkte zu beachten:

1.1.4 Auswahl der passenden OptiVec-Bibliothek

Wenn Ihre Anwendung auf einem breiten Spektrum unterstützter Prozessoren laufen sollen und wenn Ihre Vektoren / Matrizen nur von kleiner bis mittlerer Größe sind (wenige 100 bis wenige 1000 Elemente, je nach Art der durchgeführten Berechnungen), empfehlen wir die Allzweck-Bibliotheken   OVVC4.LIB  (für MS Visual C++),  VCF4W.LIB  (für Borland C++),  oder die Units in OPTIVEC\LIB4  (für Delphi). Diese Bibliotheken verbinden gute Performance mit Rück-Kompatibilität zu älterer Hardware bis hinab zu 486DX, Pentium und den frühen Modellen des Athlon. Sie alle sind Thread-sicher und unterstützen funktionelle Parallelität. Falls Sie nicht die volle Fließkomma-Genauigkeit und auch nicht dieses Ausmaß an Rückwärts-Kompatibilität benötigen, können Sie höhere Leistungen erzielen durch den Einsatz der P8-Bibliotheken für oder Core2xxx / AMD64xxx mit SSE3 (gekennzeichnet durch die Ziffer "8"), demnächst auch durch P9-Bibliotheken (Intel "Haswell", AMD "Steamroller" mit AVX und AVX2).

Für mittlere bis große Vektoren und Matrizen auf Mehrkern-Maschinen bietet sich die Verwendung der multi-core-optimierten Bibliotheken an. Diese verteilen für jede einzelne Funktion die Arbeitslast über die vorhandenen Prozessor-Kerne (Auto-Threading). Sie werden durch den Buchstaben "M" gekennzeichnet, also z.B. OVVC8M.LIB  (für MS Visual C++ mit SSE3-Verwendung),  VCF4M.LIB  (für Embarcadero/Borland C++ mit maximaler Rückwärts-Kompatibilität),  oder die Units in OPTIVEC\LIB8M  (für Delphi). Diese Bibliotheken sind für Multiprozessor-Computer wie AMD64 X2, Intel i5, Core2Duo oder Workstations mit mehreren Chips auf mind. Pentium 4+-Level gedacht.
Die CUDA-Bibliotheks-Versionen basieren auf den "M"-Bibliotheken und lagern die Verarbeitung nur für sehr große Vektoren auf die Graphik-Karte aus. Sie sind durch den Buchstaben "C" markiert, z.B.  OVVC8C.LIB.
Die "M"- und "C"- Bibliotheken laufen immer noch auf Ein-Kern-Computern. Durch die "Bürokratie-Verluste" beim Thread-Management sind sie hier aber deutlich langsamer als die Allzweck-Bibliotheken. Obwohl die "M"-Bibliotheken im Hinblick auf mittlere bis größere Vektoren entwickelt wurden, sind die Einbußen bei Verwendung mit kleinen Vektoren nicht sehr hoch, da die OptiVec Thread-Engine eine Funktion automatisch in einem einzelnen Thread ausführt, wenn die Vektor-Größe nicht ausreicht, um den Verteilungs-Aufwand durch die Parallel-Ausführung (oder gar durch die Auslagerung auf den Graphik-Prozessor) wieder aufzuholen.
Wenn Sie die "M"- oder "C"-Bibliotheken verwenden, muss Ihr Programm zu Beginn V_initMT aufrufen.

Zurück zum VectorLib-Inhaltsverzeichnis     OptiVec Home


2. Elemente von VectorLib-Funktionen

2.1 Synonyme einiger Datentypen

Um größtmögliche Flexibilität und Vollständigkeit von OptiVec zu gewährleisten, wurden zusätzliche Datentypen in <VecLib.h> bzw. der Unit VecLib eingeführt:

a) nur C/C++:

Der Datentyp ui (kurz für "unsigned index") wird für die Indizierung von Arrays benutzt und ist in <VecLib.h> als Synonym für size_t definiert, also für Win32 als unsigned int bzw. für Win64 als unsigned __int64.

64-bit-Integers (__int64 in BC++ Builder und MS Visual C++, Int64 in Delphi, Comp in Turbo Pascal) werden in OptiVec als quad (für "quadruple integer", also Vierfach-Integer) bezeichnet.
Der Datentyp quad ist in 32-bit immer vorzeichenbehaftet; nur für Win64 bietet OptiVec den Datentyp uquad als vorzeichenlosen 64-bit Ganzzahltyp.

Der Pascal/Delphi-Benutzern wohlbekannte Datentyp extended wird in der Borland C++-Version von OptiVec als Synonym für long double verwendet. Da Visual C++ 80-bit-Fließkommazahlen nicht unterstützt, ist extended hier als double definiert.
Der Grund für die Einführung des Typs extended ist, daß alle OptiVec-Funktionen identische Namen in C/C++ und Pascal/Delphi haben sollen. Die Funktions-Präfixe aber sind vom Datentyp der verarbeiteten Vektoren abgeleitet (s.u.). Der Buchstabe "L" (der vielleicht für long double stehen könnte) ist bereits durch long int und unsigned long überbelegt. So bietet sich der Buchstabe "E" für extended an, was den zusätzlichen Vorteil der Nähe zu den Buchstaben "D" für double und "F" für float hat (vielleicht erfreut uns die Zukunft ja auch noch mit hochgenauen 128-bit-und 256-bit-Fließkommazahlen, die als "great" und "hyper" ihren Platz ebenfalls in alphabetischer Nachbarschaft finden könnten).

b) nur Pascal/Delphi:

Der Datentyp Float wird von C/C++ als Synonym für Single übernommen. Wir ziehen es vor, die Buchstaben, die die Fließkomma-Datentypen bezeichnen, in alphabetischer Nachbarschaft zu haben: "D" für Double, "E" für Extended und "F" für Float. Wie oben erwähnt, können künftige 128-bit- und 256-bit-Fließkommazahlen ihren Platz in dieser Reihe als "G" für Great und "H" für Hyper finden.

Aus "historischen" Gründen weisen die Ganzzahl-Datentypen eine etwas konfuse Nomenklatur in Pascal/Delphi auf. Um die vom Datentyp abgeleiteten Präfixe mit der C/C++-Version von OptiVec kompatibel zu machen, definieren wir eine Anzahl von Synonymen, wie in der folgenden Tabelle beschrieben:

typePascal/Delphi-NameSynonymabgeleitetes Präfix
8 bit signedShortIntByteIntVBI_
8 bit unsignedByteUByteVUB_
16 bit signed SmallInt VSI_
16 bit unsigned WordUSmallVUS_
32 bit signed LongInt VLI_
32 bit unsigned  ULongVUL_
64 bit signed Int64QuadIntVQI_
64 bit unsigned UInt64UQuadVUQ_
16/32 bit signedInteger VI_
16/32 bit unsignedCardinalUIntVU_

UQuads existieren nur in der 64-bit-Version. Für Win32 gibt es nur den vorzeichenbehafteten Typ Quad.

Um einen Bool'schen Datentyp derselben Größe wie Integer zur Verfügung zu haben, definieren wir den Typ IntBool. Er ist äquivalent mit LongBool. Man findet den Typ IntBool vor allem als Rückgabewert vieler mathematischer VectorLib-Funktionen.

2.2 Komplexe Zahlen:
Die Datentypen fComplex, dComplex, eComplex, fPolar, dPolar und ePolar

Bezüglich der Unterstützung komplexer Zahlen herrscht ein gewisses Durcheinander in den gebräuchlichen Programmier-Sprachen. Der ANSI-Standard von C bietet lediglich eine Struktur complex (für aus doubles bestehende Real- und Imaginärteile). Borland C fügt dem eine Struktur _complexl für aus long doubles bestehende komplexe Zahlen hinzu. Real- und Imaginärteil werden dabei als x und y bezeichnet. Die einzige vorhandene Funktion für komplexe Zahlen ist die Bildung des Absolutwertes.
Schon seit frühen Versionen bot Borland C++ die Klasse complex, die mit doubles arbeitet. Hier sind Real- und Imaginärteil nur über die Funktionen real und imag zugänglich. Die Klasse complex bietet eine ganze Reihe arithmetischer Operationen und mathematischer Funktionen.
Erst die Standard C++ Library definierte komplexe Klassen für alle drei Genauigkeiten.
Die neueren Versionen von Delphi bieten eine Unit Complex, die komplexe Zahlen als Varianten-Typen führt – mit allen dadurch verursachten Ineffizienzen.
Komplexe Funktionen in Polarkoordinaten werden bislang von keinem dieser Produkte geboten.
In den meisten Compilern sind die komplexen Operationen sehr ineffizient und vor allem ungenau implementiert (nur die Lehrbuchformel einer komplexen Funktion hinzuschreiben, wie es meist geschieht, wird nur für einen sehr begrenzten Bereich von Argumenten brauchbare Ergebnisse liefern!).

Unsere Ziele sind

Hierfür wurde die Bibliothek CMATH geschaffen und wird mit OptiVec ausgeliefert. Sie wird in der Datei CMATHD.HTM näher beschrieben. Wenn Sie irgendeine der nicht-vektorisierten Funktionen von CMATH mit C/C++ benutzen, müssen Sie <newcplx.h> (für C++-Module) oder <cmath.h> (für einfache C-Module) vor (!) den übrigen OptiVec-Include-Dateien einschließen.
Auch ohne explizite Einbindung von CMATH stellt OptiVec die grundlegenden Datentypen und Initialisierungs-Möglichkeiten in <VecLib.h> bzw. der Unit VecLib zur Verfügung. Falls Sie nur diese verwenden, brauchen Sie CMATH nicht explizit einzuschließen.
Die für C/C++ in <VecLib.h> definierten komplexen Typen lauten:
typedef struct { float Re, Im; } fComplex;
typedef struct { double Re, Im; } dComplex;
typedef struct { extended Re, Im; } eComplex;
typedef struct { float Mag, Arg; } fPolar;
typedef struct { double Mag, Arg; } dPolar;
typedef struct { extended Mag, Arg; } ePolar;

(Der Datentyp extended wird als Synonym für long double verwendet, s. oben.)

Die entsprechenden Definitionen für Pascal/Delphi sind in der Unit VecLib enthalten:
type fComplex = record Re, Im: Float; end;
type dComplex = record Re, Im: Double; end;
type eComplex = record Re, Im: Extended; end;
type fPolar = record Mag, Arg: Float; end;
type dPolar = record Mag, Arg: Double; end;
type ePolar = record Mag, Arg: Extended; end;

Komplexe Zahlen werden initialisiert, indem ihrem Real- und Imaginärteil bzw. ihrem Mag- und Arg-Teil die gewünschten Werte zugewiesen werden, z.B.:
z.Re  = 3.0; z.Im  = 5.7;
p.Mag = 8.8; p.Arg = 3.14;

(Für Pascal/Delphi muß der Zuweisungs-Operator natürlich ":=" geschrieben werden).
Alternativ kann die Initialisierung auch mittels der Funktionen fcplx oder fpolr durchgeführt werden:
C/C++:
z = fcplx( 3.0, 5.7 );
p = fpolr( 4.0, 0.7 );

Pascal/Delphi:
fcplx( z, 3.0, 5.7 );
fpolr( p, 3.0, 5.7 );

Für doppelt-genaue komplexe Zahlen gebrauche man dcplx und dpolr, für extended-genaue ecplx und epolr.
Zeiger auf komplexe Felder oder Vektoren werden mithilfe der Datentypen cfVector, cdVector und ceVector (für cartesisch-komplexe Vektoren) sowie pfVector, pdVector und peVector (für Vektoren komplexer Zahlen in Polarkoordinaten) definiert, wie unten beschrieben.

2.3 Vektoren und Arrays:
Die Datentypen fVector, dVector, eVector,
cfVector, cdVector, ceVector, pfVector, pdVector, peVector,
iVector, biVector, siVector, liVector, qiVector,
uVector, ubVector, usVector, ulVector und uiVector

Wie üblich definieren wir einen "Vektor" als ein eindimensionales Daten-Feld (oder Array), das aus mindestens einem Element besteht(!) und dessen Elemente alle demselben Datentyp angehören. Etwas mathematischer definiert ist ein Vektor ein Tensor vom Rang 1. Ein zweidimensionales Feld (also ein Tensor vom Rang 2) wird hier als "Matrix" bezeichnet, höher-dimensionale Felder generell als Tensoren.
Im Unterschied zu anderen Ansätzen erlaubt VectorLib keine Vektoren der Länge 0!

Die Basis aller VectorLib-Funktionen bilden die Vektor-Datentypen, die in <VecLib.h> bzw. der Unit VecLib definiert und unten aufgelistet sind. Im Unterschied zu den statischen Arrays, die immer eine beim Compilieren festgelegte Größe besitzen, arbeiten die VectorLib-Typen mit dynamischer Speicherzuweisung und daher mit variablen Größen. Wegen dieser Flexibilität empfehlen wir den vorzugsweisen Gebrauch der letzteren. Hier sind sie also:
 

C/C++
typedeffloat *fVector
typedefdouble *dVector
typedefextended *eVector
typedeffComplex *cfVector
typedefdComplex *cdVector
typedefeComplex *ceVector
typedeffPolar *pfVector
typedefdPolar *pdVector
typedefePolar *peVector
typedefint *iVector
typedefbyte *biVector
typedefshort *siVector
typedeflong *liVector
typedefquad *qiVector
typedefunsigned *uVector
typedefunsigned byte *ubVector
typedefunsigned short *usVector
typedefunsigned long *ulVector
typedefuquad *uqVector
typedefui *uiVector
  Pascal/Delphi
typefVector= ^Float;
typedVector= ^Double;
typeeVector= ^Extended;
typecfVector= ^fComplex;
typecdVector= ^dComplex;
typeceVector= ^eComplex;
typepfVector= ^fPolar;
typepdVector= ^dPolar;
typepeVector= ^ePolar
typeiVector= ^Integer;
typebiVector= ^ByteInt;
typesiVector= ^SmallInt;
typeliVector= ^LongInt;
typeqiVector= ^QuadInt;
typeuVector= ^UInt;
typeubVector= ^UByte;
typeusVector= ^USmall;
typeulVector= ^ULong;
typeuqVector= ^UQuad;

Intern handelt es sich also bei einem Datentyp wie fVector um einen "Zeiger auf float". Man sollte ihn sich allerdings lieber als "float-Vector" vorstellen.
 

N.B.: In der Windows-Programmierung wird häufig der Buchstabe l" oder L" eingesetzt, um long int-Variablen zu bezeichnen. Um Verwechslungen vorzubeugen, wird hier für long int stets das aus zwei Buchstaben bestehende Kürzel "li" oder "LI" verwendet und für unsigned long das Kürzel "ul" oder "UL". Konflikte mit den Präfixen für long double-Vektoren werden durch Ableitung deren Kürzel vom Alias-Namen "extended" und den Gebrauch von "e", "ce", "E" und "CE" umgangen, wie bereits oben und auch in den folgenden Abschnitten beschrieben.
 
C/C++-spezifisch:
Um auf Vektor-Elemente zuzugreifen, wird wie für statische Arrays der Operator [] verwendet, z.B. VA[375] = 1.234;
Alternativ können die typenspezifischen Funktionen VF_element (gibt den Wert des gewünschten Elementes zurück, der mit dieser Funktion aber nicht überschrieben werden kann) und VF_Pelement (gibt einen Zeiger auf das gewünschte Element zurück) verwendet werden. Insbesondere einige ältere Borland C-Versionen haben einen Fehler in der Pointer-Arithmetik, der die Verwendung von VF_Pelement anstelle der Schreibweise X+n für einen Zeiger auf das n-te Element nötig macht. VF_Pelement kann zur Zuweisung einzelner Vektor-Elemente verwendet werden, z.B.:
*VF_Pelement( X, 3 ) = 5.7;
In Ihren Programmen können Sie die dynamischen OptiVec-Vektoren mit klassischen statischen C-Arrays mischen.
Beispiel:
float a[100]; /* klassischer statischer Array */
fVector b=VF_vector(100); /* VectorLib-Vektor */
VF_equ1( a, 100 ); /* setze die ersten 100 Elemente von a = 1.0 */
VF_equC( b, 100, 3.7 ); /* setze die ersten 100 Elemente von b = 3.7 */

Pascal/Delphi-spezifisch:
Der Zugriff auf einzelne Elemente dynamisch erzeugter Vektoren ist bei Pascal/Delphi nicht mit dem Operator [] möglich, sondern nur über die typenspezifischen Funktionen VF_element (gibt den Wert des gewünschten Elementes zurück, der mit dieser Funktion aber nicht überschrieben werden kann) und VF_Pelement (gibt einen Zeiger auf das gewünschte Element zurück). VF_Pelement kann zur Zuweisung einzelner Vektor-Elemente verwendet werden, z.B.:
VF_Pelement( X, 3 )^ := 5.7;
Wie in C/C++ können die VectorLib-Vektortypen mit statischen Arrays des klassischen Pascal-Stils gemischt werden. Statische Arrays müssen mit Hilfe des Adress-Operators an OptiVec-Routinen übergeben werden. Hier lautet das oben für C/C++ gegebene Beispiel:
a: array[0..99] of Single; (* klassischer statischer Array *)
b: fVector;(* VectorLib-Vektor *)
b := VF_vector(100);
VF_equ1( @a, 100 ); (* setze die ersten 100 Elemente von a = 1.0 *)
VF_equC( b, 100, 3.7 ); (* setze die ersten 100 Elemente von b = 3.7 *)

Delphi bietet zusätzlich auch dynamisch allozierte Arrays, die ebenfalls als Argumente an OptiVec-Funktionen übergeben werden können. Die folgende Tabelle vergleicht die Zeiger-basierten Vektoren von VectorLib mit den verschiedenen Array-Typen von Pascal/Delphi:
 

 OptiVec-VektorenPascal/Delphi-Arrays (statisch/dynamisch)
Ausrichtung des ersten Elementsan 32-byte-Grenze für optimale Cache-Zeilen-Anpassung2- oder 4-byte-Grenze (kann Zeilenumbruchs-Strafzyklen für double, QuadInt zur Folge haben)
Ausrichtung folgender Elementegepackt (d.h. keine Dummy-Bytes zwischen Elementen, auch nicht für 10- und 20-bit-Typen)Arrays müssen in Delphi als "packed" deklariert werden, um kompatibel mit OptiVec zu sein
Index-Bereichsprüfungkeineautomatisch mittels eingebauter Größeninformation
dynamische Speicherzuweisungfunction VF_vectorVF_vector0procedure SetLength (nur Delphi)
Initialisierung mit 0optional durch Aufruf von VF_vector0immer (nur Delphi)
Freigabefunction V_freeV_freeAllprocedure Finalize (nur Delphi)
einzelne Elemente lesenfunction VF_element:
a := VF_element(X,5);
nur Delphi: typecast in Array ebenfalls möglich:
a := fArray(X)[5];
Index in eckigen Klammern:
a := X[5];
einzelne Elemente schreibenfunction VF_Pelement:
VF_Pelement(X,5)^ := a;
nur Delphi: typecast in Array ebenfalls möglich:
fArray(X)[5] := a;
Index in eckigen Klammern:
X[5] := a;
Übergabe an OptiVec-Funktiondirekt:
VF_equ1( X, sz );
Adress-Operator:
VF_equ1( @X, sz );
Übergabe von Subvektor an OptiVec-Funktionfunction VF_Pelement:
VF_equC( VF_Pelement(X,10), sz-10, 3.7);
Adress-Operator:
VF_equC( @X[10], sz-10, 3.7 );
 
Zusammenfassend läßt sich sagen, daß die Pascal/Delphi-Arrays etwas bequemer zu verwenden und durch die Index-Bereichsprüfung auch sicherer sind, während die Zeiger-basierten OptiVec-Vektoren schneller verarbeitet werden können (durch die bessere Speicherausrichtung und den Fortfall der Index-Bereichsüberprüfung).

Zurück zum VectorLib-Inhaltsverzeichnis     OptiVec Home

2.4 Vektorfunktions-Präfixe

Jede OptiVec-Vektor-Funktion hat ein Präfix, das den Datentyp anzeigt, mit dem diese Funktion arbeitet. (Die präfix-losen überladenen C++-Versionen aller Funktionen sind im objekt-orientierten Interface VecObj definiert.)
 
Prefix Argumente und Rückgabewert
VF_fVector und float
VD_dVector und double
VE_eVector und extended (long double) 
VCF_cfVector und fComplex
VCD_cdVector und dComplex 
VCE_ceVector und eComplex
VPF_pfVector und fPolar
VPD_ pdVector und dPolar
VPE_peVector und ePolar
VI_iVector und int / Integer
VBI_biVector und byte / ByteInt
VSI_siVector und short int / SmallInt
VLI_liVector und long int / LongInt
VQI_qiVector und quad / QuadInt
VU_uVector und unsigned / UInt
VUB_ubVector und unsigned char / UByte
VUS_usVector und unsigned short / USmall
VUL_ulVector und unsigned long / ULong
VUQ_uqVector und uquad / UQuad (nur für Win64 !)
VUI_uiVector und ui
V_(Datentyp-Umwandlungen wie V_FtoD sowie Datentyp-unabhängige Funktionen wie V_initPlot)
 

Zurück zum VectorLib-Inhaltsverzeichnis     OptiVec Home


3. Nur C++: VecObj, das objekt-orientierte Interface für VectorLib

VecObj, das objekt-orientierte C++-Interface für die Vektorfunktionen von OptiVec, wurde von Brian Dale, Case Western Reserve University, geschrieben. Die Erweiterung hiervon für Matrizen ist als MatObj ebenfalls vorhanden.
VecObj bietet u.a. die folgenden Vorzüge: Es gibt allerdings auch einige wenige Nachteile, die wir nicht verschweigen möchten: VecObj ist in den Include-Dateien <VecObj.h>, <fVecObj.h>, <dVecObj.h> etc. enthalten mit einer Include-Datei für jeden der in OptiVec unterstützten Datentypen.
Um das gesamte Interface (für alle Datentypen zusammen) zu laden, deklariere man
#include <OptiVec.h>.
Um irgendeine der Graphik-Funktionen von VectorLib zu verwenden, sollte stets <OptiVec.h> eingeschlossen werden.

MS Visual C++ und Borland C++ Builder (nicht aber frühere Borland C++-Versionen): Die Direktive
"using namespace OptiVec;"
sollte entweder im Funktionskörper jeder ein tVecObj verwendenden Funktion oder im globalen Deklarationsteil eines Programmes auftauchen. Der Platz in den einzelnen Funktionskörpern ist sicherer, da er potentielle Namespace-Konflikte mit anderen Funktionen vermeidet.
Die Vektor-Objekte werden als classes vector<T> implementiert, die die Vektor-Adresse (den Zeiger) und seine Größe size kapseln.
Für einfachere Verwendung wurden diesen Klassen Alias-Namen zugewiesen als fVecObj, dVecObj usw., wobei der Datentyp wie sonst in OptiVec durch den ersten oder die ersten beiden Buchstaben des Klassennamens angezeigt wird.

Alle VectorLib für einen bestimmten Datentyp definierten Funktionen sind als Member-Funktionen der betreffenden class tVecObj enthalten.
Die Konstruktoren können vier Formen annehmen:
vector(); // kein Speicher zugewiesen; size auf 0 gesetzt
vector( ui size ); // Vektor von size Elementen erzeugt
vector( ui size, T fill ); // desgleichen, aber mit "fill" initialisiert
vector( vector<T> init ); // erzeugt eine Kopie des Vektors "init"

Für alle Vektor-Klassen sind die arithmetischen Operatoren
+    -    *    /    +=    -=    *=    /=
definiert, mit der Ausnahme, daß für die polar-komplexen Vektor-Klassen nur Multiplikationen und Divisionen, nicht aber Addition und Subtraktion unterstützt werden. Diese Operatoren stellen den einzigen Fall dar, in dem das Ergebnis einer Berechnung direkt einem Vektor-Objekt zugewiesen werden kann, wie z.B.
fVecObj Z = X + Y; oder
fVecObj Z = X * 3.5;
Man beachte aber, daß die Syntax-Regeln von C++ eine wirklich effiziente Implementierung dieser Operatoren nicht zulassen. Die arithmetischen Member-Funktionen sind wesentlich schneller. Wenn es auf Rechengeschwindigkeit ankommt, benutze man daher die letzteren anstelle der Operatoren-Syntax:
fVecObj Z.addV( X, Y ); oder
fVecObj Z.mulC( X, 3.5 );

Der Operator * bedeutet Multiplikation der einzelnen Elemente miteinander und nicht das Skalarprodukt zwier Vektoren.

Alle übrigen arithmetischen und mathematischen Funktionen können nur als Member-Funktion des betreffenden Ausgabe-Vektors aufgerufen werden, wie z.B. Y.exp(X). Obwohl es sicher logischer wäre, auch diese Funktionen so zu definieren, daß man stattdessen "Y = exp(X)" schreiben könnte, wurde die Syntax der Member-Funktionen gewählt, da sie wesentlich effizienter implementiert werden kann: Der einzige Weg, die zweite Variante zu implementieren, besteht darin, das Ergebnis der jeweiligen Funktion in einem temporären Vektor zwischenzuspeichern, der anschließend in Y kopiert wird. Hierdurch werden Rechenaufwand und Speicheranforderungen erhöht. Wir sind aber an Ihrer Meinung interessiert: Würden Sie trotzdem die Syntax "Y = func(X);" gegenüber der Member-Funktions-Syntax "Y.func(X);" vorziehen und ihre Nachteile in Kauf nehmen wollen? Bitte senden Sie uns Ihren Kommentar an support@optivec.de. Diese Syntax könnte in späteren Versionen von VecObj zur Verfügung gestellt werden.

Während die meisten VecObj-Funktionen Member-Funktionen des Ausgabe-Vektors sind, gibt es einige Funktionen, die gar keinen Ausgabe-Vektor haben. In diesen Fällen sind die Funktionen Member-Funktionen eines Eingabe-Vektors.
Beispiel: s = X.mean();.

Sollten Sie einmal in die Lage kommen, ein VecObj-Vektorobjekt mit einer "klassischen" C-VectorLib-Funktion verarbeiten zu wollen (z.B., um nur einen Teil zu verarbeiten), rufen Sie bitte die Member-Funktionen
getSize() für die Vektorlänge,
getVector() für den Zeiger (vom Typ tVector)  oder
Pelement( n ), um einen Zeiger auf das n'te Element zu bekommen.

Fortsetzung: Kap. 4. VectorLib-Funktionen: Ein kurzer Überblick
Zurück zum VectorLib-Inhaltsverzeichnis     OptiVec Home      OptiVec Home 

Copyright © 1998-2016 OptiCode – Dr. Martin Sander Software Development

Letzte Aktualisierung: 18. Oktober 2016