Hier ist ein Beispiel aus der Praxis: Festkommamultiplikation auf alten Compilern.
Diese sind nicht nur auf Geräten ohne Fließkomma nützlich, sondern auch, wenn es um Präzision geht, da sie 32 Bit Präzision mit einem vorhersehbaren Fehler bieten (Float hat nur 23 Bit und es ist schwieriger, Präzisionsverluste vorherzusagen). z.B. uniform absolut Präzision über den gesamten Bereich, statt annähernd gleichmäßiger relativ Genauigkeit ( float
).
Moderne Compiler optimieren dieses Festkomma-Beispiel sehr gut. Für modernere Beispiele, die dennoch compilerspezifischen Code benötigen, siehe
C hat keinen Operator für die Vollmultiplikation (2N-Bit-Ergebnis aus N-Bit-Eingaben). Der übliche Weg, dies in C auszudrücken, besteht darin, die Eingänge in den breiteren Typ zu casten und zu hoffen, dass der Compiler erkennt, dass die oberen Bits der Eingänge nicht interessant sind:
// on a 32-bit machine, int can hold 32-bit fixed-point integers.
int inline FixedPointMul (int a, int b)
{
long long a_long = a; // cast to 64 bit.
long long product = a_long * b; // perform multiplication
return (int) (product >> 16); // shift by the fixed point bias
}
Das Problem mit diesem Code ist, dass wir etwas tun, das nicht direkt in der C-Sprache ausgedrückt werden kann. Wir wollen zwei 32-Bit-Zahlen multiplizieren und ein 64-Bit-Ergebnis erhalten, von dem wir die mittleren 32 Bit zurückgeben. In C gibt es diese Multiplikation jedoch nicht. Alles, was man tun kann, ist, die ganzen Zahlen auf 64 Bit zu bringen und eine 64*64 = 64 Multiplikation durchzuführen.
x86 (und ARM, MIPS und andere) können jedoch die Multiplikation in einem einzigen Befehl durchführen. Einige Compiler ignorierten diese Tatsache und erzeugten Code, der eine Bibliotheksfunktion zur Laufzeit aufrief, um die Multiplikation durchzuführen. Die Verschiebung mit 16 wird ebenfalls oft von einer Bibliotheksroutine durchgeführt (auch der x86 kann solche Verschiebungen durchführen).
Es bleiben also nur ein oder zwei Bibliotheksaufrufe für einen Multiplikator. Das hat ernste Konsequenzen. Die Verschiebung ist nicht nur langsamer, sondern die Register müssen über die Funktionsaufrufe hinweg beibehalten werden, und auch Inlining und Code-Unrolling werden dadurch nicht gefördert.
Wenn Sie denselben Code in (Inline-)Assembler umschreiben, können Sie einen erheblichen Geschwindigkeitsgewinn erzielen.
Außerdem ist die Verwendung von ASM nicht der beste Weg, um das Problem zu lösen. Die meisten Compiler erlauben es Ihnen, einige Assembler-Anweisungen in intrinsischer Form zu verwenden, wenn Sie sie nicht in C ausdrücken können. Der VS.NET2008-Compiler beispielsweise stellt die 32*32=64-Bit-Mul als __emul und die 64-Bit-Verschiebung als __ll_rshift dar.
Mit Hilfe von Intrinsics können Sie die Funktion so umschreiben, dass der C-Compiler eine Chance hat zu verstehen, was vor sich geht. Dies ermöglicht das Inlining des Codes, die Zuweisung von Registern, die Eliminierung gemeinsamer Unterausdrücke und die Weitergabe von Konstanten. Sie erhalten eine enorme Leistungsverbesserung gegenüber dem handgeschriebenen Assembler-Code.
Als Referenz: Das Endergebnis für das Festkomma-Mul für den VS.NET-Compiler ist:
int inline FixedPointMul (int a, int b)
{
return (int) __ll_rshift(__emul(a,b),16);
}
Der Leistungsunterschied bei Festkommadivisionen ist sogar noch größer. Ich hatte Verbesserungen bis zum Faktor 10 für Division schweren Festkomma-Code durch das Schreiben ein paar asm-Zeilen.
Die Verwendung von Visual C++ 2013 ergibt für beide Wege den gleichen Assemblercode.
gcc4.1 aus dem Jahr 2007 optimiert auch die reine C-Version sehr gut. (Der Godbolt-Compiler-Explorer hat keine früheren Versionen von gcc installiert, aber vermutlich können auch ältere GCC-Versionen dies ohne Intrinsics tun).
Siehe Quelle + asm für x86 (32-bit) und ARM auf der Godbolt-Compiler-Explorer%3B%0A%7D%0A%23endif%0A%0A%0A/+Intrinsics+are+more+useful+for+extended+precision%0A++when+there+isn!'t+a+wide-enough+type.%0A++e.g.+128-bit+integer+on+compilers+without+__int128%0A+/%0A'),l:'5',n:'0',o:'C%2B%2B+source+%231',t:'0')),k:32.75251522372254,l:'4',n:'0',o:'',s:0,t:'0'),(g:!((g:!((h:compiler,i:(compiler:g412,filters:(b:'0',binary:'1',commentOnly:'0',demangle:'0',directives:'0',execute:'1',intel:'1',trim:'1'),lang:c%2B%2B,libs:!(),options:'-xc+-O3+-m32++-fomit-frame-pointer',source:1),l:'5',n:'0',o:'x86-64+gcc+4.1.2+(Editor+%231,+Compiler+%231)+C%2B%2B',t:'0')),k:34.10775747948107,l:'4',m:50,n:'0',o:'',s:0,t:'0'),(g:!((h:compiler,i:(compiler:arm710,filters:(b:'0',binary:'1',commentOnly:'0',demangle:'0',directives:'0',execute:'1',intel:'0',trim:'1'),lang:c%2B%2B,libs:!(),options:'-xc+-O3+-mthumb+-mcpu%3Dcortex-m4',source:1),l:'5',n:'0',o:'ARM+gcc+7.2.1+(none)+(Editor+%231,+Compiler+%232)+C%2B%2B',t:'0')),header:(),l:'4',m:50,n:'0',o:'',s:0,t:'0')),k:33.91415144294414,l:'3',n:'0',o:'',t:'0'),(g:!((g:!((h:compiler,i:(compiler:clang30,filters:(b:'0',binary:'1',commentOnly:'0',demangle:'0',directives:'0',execute:'1',intel:'0',trim:'1'),lang:c%2B%2B,libs:!(),options:'-xc+-O3+-m32',source:1),l:'5',n:'0',o:'x86-64+clang+3.0.0+(Editor+%231,+Compiler+%233)+C%2B%2B',t:'0')),k:33.33333333333333,l:'4',m:50,n:'0',o:'',s:0,t:'0'),(g:!((h:compiler,i:(compiler:cl19_2015_u3_32,filters:(b:'0',binary:'1',commentOnly:'0',demangle:'0',directives:'0',execute:'1',intel:'0',trim:'1'),lang:c%2B%2B,libs:!(),options:'-Ox',source:1),l:'5',n:'0',o:'x86+MSVC+19+2015+U3+(Editor+%231,+Compiler+%234)+C%2B%2B',t:'0')),header:(),l:'4',m:50,n:'0',o:'',s:0,t:'0')),k:33.33333333333333,l:'3',n:'0',o:'',t:'0')),l:'2',n:'0',o:'',t:'0')),version:4) . (Leider gibt es keine Compiler, die alt genug sind, um schlechten Code aus der einfachen reinen C-Version zu erzeugen).
Moderne CPUs können Dinge tun, für die C keine Operatoren hat überhaupt , wie popcnt
oder Bit-Scan, um das erste oder letzte gesetzte Bit zu finden . (POSIX hat eine ffs()
Funktion, aber ihre Semantik entspricht nicht der x86 bsf
/ bsr
. Siehe https://en.wikipedia.org/wiki/Find_first_set ).
Einige Compiler können manchmal eine Schleife erkennen, die die Anzahl der gesetzten Bits in einer Ganzzahl zählt, und kompilieren sie zu einer popcnt
Anweisung (falls zur Kompilierzeit aktiviert), aber es ist viel zuverlässiger, die __builtin_popcnt
in GNU C, oder auf x86, wenn Sie nur Hardware mit SSE4.2 anvisieren: _mm_popcnt_u32
von <immintrin.h>
.
Oder in C++: Zuweisung an eine std::bitset<32>
und verwenden .count()
. (Dies ist ein Fall, in dem die Sprache einen Weg gefunden hat, eine optimierte Implementierung von popcount über die Standardbibliothek portabel zu machen, und zwar auf eine Weise, die immer zu etwas Korrektem kompiliert wird und die Vorteile von allem nutzen kann, was das Ziel unterstützt). Siehe auch https://en.wikipedia.org/wiki/Hamming_weight#Language_support .
Ähnlich, ntohl
kann kompiliert werden zu bswap
(x86 32-Bit-Byte-Swap für Endian-Konvertierung) auf einigen C-Implementierungen, die es haben.
Ein weiterer wichtiger Bereich für intrinsics oder handgeschriebene asm ist die manuelle Vektorisierung mit SIMD-Anweisungen. Compiler sind nicht schlecht bei einfachen Schleifen wie dst[i] += src[i] * 10.0;
, aber oft schlecht oder gar nicht automatisch vektorisieren, wenn die Dinge komplizierter werden. Es ist zum Beispiel unwahrscheinlich, dass Sie etwas wie Wie implementiert man atoi mit SIMD? automatisch vom Compiler aus skalarem Code erzeugt.
0 Stimmen
Und nun wäre eine weitere Frage angebracht: Wann ist die Tatsache, dass Assembler schneller ist als C, tatsächlich von Bedeutung?
20 Stimmen
Eigentlich ist es recht trivial, kompilierten Code zu verbessern. Jeder, der über solide Kenntnisse in Assembler und C verfügt, kann dies erkennen, indem er den erzeugten Code untersucht. Eine einfache Möglichkeit ist die erste Leistungsklippe, von der man herunterfällt, wenn man in der kompilierten Version keine freien Register mehr hat. Im Durchschnitt wird der Compiler bei einem großen Projekt weitaus besser abschneiden als ein Mensch, aber es ist nicht schwer, bei einem Projekt von angemessener Größe Leistungsprobleme im kompilierten Code zu finden.
19 Stimmen
Die kurze Antwort lautet eigentlich: Assembler ist siempre Der Grund dafür ist, dass man Assembler ohne C haben kann, aber man kann C nicht ohne Assembler haben (in der binären Form, die wir früher "Maschinencode" nannten). Das heißt, die lange Antwort ist: C-Compiler sind ziemlich gut darin, zu optimieren und über Dinge "nachzudenken", an die man normalerweise nicht denkt, also hängt es wirklich von Ihren Fähigkeiten ab, aber normalerweise können Sie den C-Compiler immer schlagen; es ist immer noch nur eine Software, die nicht denken und Ideen bekommen kann. Sie können auch portablen Assembler schreiben, wenn Sie Makros verwenden und geduldig sind.
13 Stimmen
Ich bin ganz und gar nicht der Meinung, dass Antworten auf diese Frage "meinungsbasiert" sein müssen - sie können durchaus objektiv sein - es ist nicht so, als würde man versuchen, die Leistung der Lieblingssprachen zu vergleichen, für die jede von ihnen Stärken und Schwächen hat. Hier geht es darum, zu verstehen, wie weit uns Compiler bringen können und ab welchem Punkt es besser ist, sie zu übernehmen.
0 Stimmen
Es ist nicht einmal immer der Fall, dass Sie etwas in Assembler umschreiben müssen, um die Vorteile der Assembler-Kenntnisse zu nutzen. Wenn Sie Ihren C-Algorithmus einfach in verschiedenen Formen neu kompilieren und die vom Compiler erzeugte Assemblerdatei beobachten, können Sie effizienteren Code in C schreiben.
0 Stimmen
Ein esoterisches Beispiel: Suchen Sie im Internet nach
pclmulqdq crc
. pclmulqdq ist eine spezielle Assembler-Anweisung. Die optimierten Beispiele benötigen etwa 500 Zeilen Assemblercode. Einige X86 haben auch einencrc32c
Anweisung für einen bestimmten Fall von crc32. Benchmark-Ergebnisse für die Erzeugung von crc32 über ein 256MB (256*1024*1024) Byte-Array: c-Code mit Tabelle => 0,516749 sec, Assembler mit pcmuldq => 0,0783919 sec, c-Code mit crc32 intrinsic => 0,0541801 sec.30 Stimmen
Zu Beginn meiner beruflichen Laufbahn habe ich in einer Softwarefirma viel in C und Mainframe-Assembler geschrieben. Einer meiner Kollegen war, wie ich es nennen würde, ein "Assembler-Purist" (alles musste in Assembler sein), also wettete ich mit ihm, dass ich eine bestimmte Routine schreiben könnte, die in C schneller lief als das, was er in Assembler schreiben konnte. Ich habe gewonnen. Nachdem ich gewonnen hatte, sagte ich ihm, ich wolle eine zweite Wette abschließen - dass ich etwas in Assembler schneller schreiben könne als das C-Programm, das ihn bei der ersten Wette geschlagen hatte. Auch diese Wette habe ich gewonnen, was beweist, dass es mehr auf die Fähigkeiten des Programmierers ankommt als auf alles andere.
0 Stimmen
@ValerieR Nun, Sie haben auch bewiesen, dass Ihr Assemblerprogramm schneller war als Ihr C-Programm :-) Vielleicht könnte man sagen, dass Sie unabhängig von Ihrem Kenntnisstand in der C-Programmierung wahrscheinlich ein Assembler-Programm schreiben können, das schneller ist?
1 Stimmen
@RobertF: Wir lassen bei diesen Fragen oft den Teil "zu welchem Preis" weg. Ich kann schnelles C oder Assembler schreiben - manchmal ist das C billiger zu schreiben, und manchmal ist der Assembler billiger zu schreiben. Geschwindigkeit kommt oft von zwei Seiten: bessere Algorithmen oder Ausnutzung der Low-Level-Infrastruktur - Quicksort in C wird typischerweise schneller sein als Bubble Sort in Assembler. Aber wenn Sie identische Logik in beiden implementieren, bietet Ihnen der Assembler normalerweise Möglichkeiten, die Maschinenarchitektur besser auszunutzen als der Compiler - der Compiler ist universell einsetzbar, und Sie erstellen eine spezifische Anpassung für einen einzigen Anwendungsfall.
0 Stimmen
Ich habe mehrere M68000-Ersetzungen für C-Funktionen (memset, snprintf usw.) geschrieben, die erheblich schneller (und immer noch sicherer) als ihre C-Pendants sind. Ich habe auch Testcode geschrieben, um zu überprüfen, dass es a) funktioniert und b) tatsächlich schneller ist.