Schweineflug oder Optimierung von Bytecode-Interpreten


"Egal wie sehr Sie es versuchen, Sie können aus einem Schwein kein Rennpferd machen. Sie können jedoch ein schnelleres Schwein machen" (Kommentar im Quellcode von Emax)

Jeder kennt die Tatsache, dass Schweine nicht fliegen. Nicht weniger beliebt ist die Meinung, dass Bytecode-Interpreter als Technik zur Ausführung von Hochsprachen ohne die Verwendung einer zeitaufwändigen dynamischen Kompilierung nicht beschleunigt werden können.


Im zweiten Teil einer Reihe von Artikeln über Bytecode-Interpreter am Beispiel einer kleinen gestapelten virtuellen FDA-Maschine (Pig Virtual Machine) werde ich versuchen zu zeigen, dass für fleißige Ferkel mit Ambitionen nicht alles verloren geht und dass es möglich ist, im Rahmen von (meistens) Standard C zu beschleunigen Die Arbeit solcher Dolmetscher ist mindestens eineinhalb Mal.


Erster Teil, Einführung
Teil zwei, Optimierung (aktuell)
Dritter Teil, Angewandt


Ferkel


Lass uns kennenlernen.


Piglet VM ist eine gewöhnliche gestapelte Maschine, die auf einem Beispiel aus dem ersten Teil einer Artikelserie basiert. Unser Schwein kennt nur einen Datentyp - ein 64-Bit-Maschinenwort, und alle (ganzzahligen) Berechnungen werden auf dem Stapel mit einer maximalen Tiefe von 256 Maschinenwörtern ausgeführt. Zusätzlich zum Stapel hat dieses Ferkel einen Arbeitsspeicher von 65.536 Maschinenwörtern. Das Ergebnis der Programmausführung - ein Maschinenwort - kann entweder in das Ergebnisregister gestellt oder einfach in die Standardausgabe (stdout) ausgegeben werden.


Der gesamte Status in der Piglet VM-Maschine wird in einer einzigen Struktur gespeichert:


static struct { /* Current instruction pointer */ uint8_t *ip; /* Fixed-size stack */ uint64_t stack[STACK_MAX]; uint64_t *stack_top; /* Operational memory */ uint64_t memory[MEMORY_SIZE]; /* A single register containing the result */ uint64_t result; } vm; 

Das oben Gesagte ermöglicht es uns, diese Maschine virtuellen Maschinen auf niedriger Ebene zuzuordnen, wobei fast der gesamte Aufwand für die Wartung des Hauptprogrammzyklus anfällt:


 interpret_result vm_interpret(uint8_t *bytecode) { vm_reset(bytecode); for (;;) { uint8_t instruction = NEXT_OP(); switch (instruction) { case OP_PUSHI: { /* get the argument, push it onto stack */ uint16_t arg = NEXT_ARG(); PUSH(arg); break; } case OP_ADD: { /* Pop 2 values, add 'em, push the result back to the stack */ uint64_t arg_right = POP(); *TOS_PTR() += arg_right; break; } /* * ... * Lots of other instruction handlers here * ... */ case OP_DONE: { return SUCCESS; } default: return ERROR_UNKNOWN_OPCODE; } } return ERROR_END_OF_STREAM; } 

Der Code zeigt, dass für jeden Opcode das Schweinchen:


  1. Rufen Sie den Opcode aus dem Anweisungsstrom ab.
  2. Stellen Sie sicher, dass der Opcode im gültigen Bereich der Opcode-Werte liegt (diese Logik wird vom C-Compiler beim Generieren des Switch-Codes hinzugefügt).
  3. Gehen Sie zu den Körperanweisungen.
  4. Extrahieren Sie Befehlsargumente aus dem Stapel oder dekodieren Sie ein Befehlsargument, das sich direkt im Bytecode befindet.
  5. Führen Sie eine Operation durch.
  6. Wenn es ein Berechnungsergebnis gibt, legen Sie es auf den Stapel.
  7. Bewegen Sie den Zeiger von der aktuellen Anweisung zur nächsten.

Die Nutzlast befindet sich hier nur im fünften Absatz, der Rest ist Overhead: Decodieren oder Abrufen von Anweisungen vom Stapel (Absatz 4), Überprüfen des Opcode-Werts (Absatz 2), wiederholtes Zurückkehren zum Anfang der Hauptschleife und dem anschließenden schwer vorhergesagten bedingten Übergang (Absatz 3).


Kurz gesagt, das Schwein hat den empfohlenen Body-Mass-Index deutlich überschritten, und wenn wir ihn in Form bringen wollen, müssen wir uns mit all diesen Exzessen auseinandersetzen.


Schwein Assemblersprache und Sieb von Eratosthenes


Lassen Sie uns zunächst die Spielregeln festlegen.


Das Schreiben von Programmen für eine virtuelle Maschine direkt in C ist eine schlechte Idee, aber das Erstellen einer Programmiersprache ist eine lange Zeit, daher haben wir uns entschlossen, uns auf eine Piggy-Assemblersprache zu beschränken.


Ein Programm, das die Summe der Zahlen von 1 bis 65.536 in diesem Assembler berechnet, sieht ungefähr so ​​aus:


 # sum numbers from 1 to 65535 # init the current sum and the index PUSHI 1 PUSHI 1 # stack s=1, i=1 STOREI 0 # stack: s=1 # routine: increment the counter, add it to the current sum incrementandadd: # check if index is too big LOADI 0 # stack: s, i ADDI 1 # stack: s, i+1 DUP # stack: s, i+1, i+1 GREATER_OR_EQUALI 65535 # stack: s, i+1, 1 or 0 JUMP_IF_TRUE done # stack: s, i+1 DUP # stack: s, i+1, i+1 STOREI 0 # stack: s, i+1 ADD # stack: s+i+1 JUMP incrementandadd done: DISCARD PRINT DONE 

Natürlich nicht Python, aber es gibt alles, was Sie für das Glück von Schweinen benötigen: Kommentare, Tags, bedingte und bedingungslose Sprünge zu ihnen, Mnemonik für Anweisungen und die Möglichkeit, direkte Argumente für Anweisungen anzugeben.


Komplett mit der Maschine "Piglet VM" sind Assembler und Disassembler, die mutig im Geist sind und viel Freizeit haben, können die Leser unabhängig im Kampf testen.


Die Zahlen summieren sich sehr schnell, daher habe ich zum Testen der Leistung ein anderes Programm geschrieben - eine naive Implementierung des Eratosthenes-Siebs .


Tatsächlich läuft das Ferkel sowieso ziemlich schnell (seine Anweisungen entsprechen denen der Maschine). Um klare Ergebnisse zu erhalten, werde ich jede Messung für hundert Programmstarts durchführen.


Die erste Version unseres nicht optimierten Schweins läuft folgendermaßen:


 > ./pigletvm runtimes test/sieve-unoptimized.bin 100 > /dev/null PROFILE: switch code finished took 545ms 

Eine halbe Sekunde! Der Vergleich ist sicherlich unehrlich, aber der gleiche Python-Algorithmus macht hundert Läufe etwas langsamer:


 > python test/sieve.py > /dev/null 4.66692185402 

4,5 Sekunden oder neunmal langsamer. Wir müssen dem Ferkel Tribut zollen - er hat die Fähigkeit! Nun wollen wir sehen, ob unser Schwein die Presse aufpumpen kann.


Übung 1: Statische Superanweisungen


Die erste Regel für schnellen Code ist, nicht zu viel Arbeit zu erledigen. Die zweite Regel für schnellen Code lautet, niemals zusätzliche Arbeit zu leisten. Welche zusätzliche Arbeit leistet Piglet VM?


Beobachtung eins: Die Profilerstellung unseres Programms zeigt, dass es Sequenzen von Anweisungen gibt, die häufiger vorkommen als andere. Wir werden unser Schwein nicht viel quälen und uns auf ein paar Anweisungen beschränken:


  1. LOADI 0, ADD - Legen Sie eine Nummer aus dem Speicher unter der Adresse 0 auf den Stapel und fügen Sie sie der Nummer oben auf dem Stapel hinzu.
  2. PUSHI 65536, GREATER_OR_EQUAL - Legen Sie eine Zahl auf den Stapel und vergleichen Sie sie mit der Zahl, die zuvor oben auf dem Stapel lag. Legen Sie das Ergebnis des Vergleichs (0 oder 1) wieder auf den Stapel.
  3. PUSHI 1, ADD - Legen Sie eine Zahl auf den Stapel, fügen Sie sie zu der Zahl hinzu, die zuvor oben auf dem Stapel lag, und legen Sie das Ergebnis der Addition wieder auf den Stapel.

Es gibt etwas mehr als 20 Anweisungen in der Piglet VM-Maschine, und ein ganzes Byte wird zum Codieren verwendet - 256 Werte. Das Einführen neuer Anweisungen ist kein Problem. Was wir tun werden:


 for (;;) { uint8_t instruction = NEXT_OP(); switch (instruction) { /* * Other instructions here * */ case OP_LOADADDI: { /* get immediate argument as an memory address , add it to value from the address to the top * of the stack */ uint16_t addr = NEXT_ARG(); uint64_t val = vm.memory[addr]; *TOS_PTR() += val; break; } case OP_GREATER_OR_EQUALI:{ /* get the immediate argument, compare it with the value from the address to the top of the stack */ uint64_t arg_right = NEXT_ARG(); *TOS_PTR() = PEEK() >= arg_right; break; } case OP_ADDI: { /* Add immediate value to the top of the stack */ uint16_t arg_right = NEXT_ARG(); *TOS_PTR() += arg_right; break; } /* * Other instructions here * */ } 

Nichts kompliziertes. Mal sehen, was daraus wurde:


 > ./pigletvm runtimes test/sieve.bin 100 > /dev/null PROFILE: switch code finished took 410ms 

Wow! Der Code ist nur für drei neue Anweisungen und wir haben eineinhalb hundert Millisekunden gewonnen!


Der Gewinn wird hier dadurch erzielt, dass unser Schweinchen beim Ausführen solcher Anweisungen keine unnötigen Bewegungen ausführt: Der Ausführungsthread fällt nicht in die Hauptschleife heraus, nichts wird dekodiert und die Argumente der Anweisungen durchlaufen den Stapel nicht erneut.


Dies wird als statische Superbefehle bezeichnet, da zusätzliche Anweisungen statisch definiert werden, dh vom Programmierer der virtuellen Maschine in der Entwicklungsphase. Dies ist eine einfache und effektive Technik, die alle virtuellen Maschinen von Programmiersprachen in der einen oder anderen Form verwenden.


Das Hauptproblem bei statischen Superbefehlen besteht darin, dass ohne ein bestimmtes Programm nicht festgelegt werden kann, welche Anweisungen kombiniert werden sollen. Verschiedene Programme verwenden unterschiedliche Befehlssequenzen, und Sie können diese Sequenzen erst beim Starten eines bestimmten Codes herausfinden.


Der nächste Schritt könnte die dynamische Kompilierung von Superbefehlen im Kontext eines bestimmten Programms sein, dh dynamische Superbefehle (in den 90er und frühen 2000er Jahren spielte diese Technik die Rolle einer primitiven JIT-Kompilierung).


Es ist unmöglich, im Rahmen des normalen C spontan Anweisungen zu erstellen, und unser Ferkel betrachtet dies zu Recht nicht als ehrlichen Wettbewerb. Zum Glück habe ich ein paar bessere Übungen für ihn.


Übung zwei: Überprüfen des Bereichs der Opcode-Werte


Nach unseren schnellen Code-Regeln stellen wir uns erneut die ewige Frage: Was können Sie nicht tun?


Als wir uns mit dem Gerät der Piglet VM-Maschine vertraut machten, listete ich alle Aktionen auf, die die virtuelle Maschine für jeden Opcode ausführt. Und Punkt 2 (Überprüfen des Werts des Opcodes, um in den gültigen Bereich der Schalterwerte zu passen) ist am verdächtigsten.


Schauen wir uns an, wie GCC das Switch-Konstrukt kompiliert:


  1. Eine Übergangstabelle wird erstellt, dh eine Tabelle, die den Wert des Opcodes an die Adresse des Codes anzeigt, der den Hauptteil des Befehls ausführt.
  2. Es wird ein Code eingefügt, der prüft, ob der empfangene Opcode in den Bereich aller möglichen Schalterwerte fällt, und ihn an das Standardetikett sendet, wenn kein Handler für den Opcode vorhanden ist.
  3. Der Code, der an den Handler geht, wird eingefügt.

Aber warum wird das Werteintervall für jede Anweisung überprüft? Wir glauben, dass der Opcode entweder korrekt ist - die Ausführung durch die Anweisung OP_DONE wird beendet oder falsch - und über den Bytecode hinausgeht. Das Ende des Streams von Opcodes ist mit Null markiert, und Null ist der Opcode des Befehls OP_ABORT, der die Ausführung des Bytecodes mit einem Fehler abschließt.


Es stellt sich heraus, dass diese Prüfung überhaupt nicht benötigt wird! Und das Ferkel sollte diese Idee dem Compiler vermitteln können. Versuchen wir, den Hauptschalter ein wenig zu reparieren:


 uint8_t instruction = NEXT_OP(); /* Let the compiler know that opcodes are always between 0 and 31 */ switch (instruction & 0x1f) { /* All the instructions here */ case 26 ... 0x1f: { /*Handle the remaining 5 non-existing opcodes*/ return ERROR_UNKNOWN_OPCODE; } } 

Da wir wissen, dass wir nur 26 Befehle haben, legen wir dem Opcode eine Bitmaske auf (der Oktalwert 0x1f ist eine binäre 0b11111, die den Wertebereich von 0 bis 31 abdeckt) und fügen Handler zu nicht verwendeten Werten im Bereich von 26 bis 31 hinzu.


Bitbefehle gehören zu den billigsten in der x86-Architektur, und sie sind sicherlich billiger als problematische bedingte Zweige wie der, der die Intervallprüfung verwendet. Theoretisch sollten wir mehrere Zyklen für jede ausführbare Anweisung gewinnen, wenn der Compiler unseren Hinweis versteht.


Übrigens ist die Art und Weise, den Wertebereich für den Fall anzugeben, nicht Standard C, sondern eine GCC-Erweiterung. Für unsere Zwecke ist dieser Code jedoch geeignet, zumal es nicht schwierig ist, ihn für jeden der unnötigen Werte in mehrere Handler umzuwandeln.


Wir versuchen:


 > ./pigletvm runtimes test/sieve.bin 100 > /dev/null PROFILE: switch code finished took 437ms PROFILE: switch code (no range check) finished took 383ms 

Weitere 50 Millisekunden! Ferkel, es ist, als hättest du dich in deinen Schultern gehört!


Übung Drei: Trails


Welche anderen Übungen können unserem Ferkel helfen? Die größte Zeitersparnis haben wir dank super Anweisungen bekommen. Und sie reduzieren die Anzahl der Ausgänge zum Hauptzyklus und ermöglichen es Ihnen, die entsprechenden Gemeinkosten loszuwerden.


Der zentrale Schalter ist der Hauptproblempunkt für jeden Prozessor mit einer außergewöhnlichen Ausführung von Anweisungen. Moderne Verzweigungsvorhersagen haben gelernt, selbst solche komplexen indirekten Übergänge gut vorherzusagen, aber das „Verschmieren“ von Verzweigungspunkten entlang des Codes kann dem Prozessor helfen, schnell von Befehl zu Befehl zu wechseln.


Ein weiteres Problem ist das byteweise Lesen von Befehls-Opcodes und direkten Argumenten aus dem Bytecode. Physische Maschinen arbeiten mit einem 64-Bit-Maschinenwort und mögen es nicht wirklich, wenn der Code mit niedrigeren Werten arbeitet.


Compiler arbeiten häufig mit Basisblöcken , dh Befehlssequenzen ohne Verzweigungen und Beschriftungen. Der Basisblock beginnt entweder am Anfang des Programms oder am Label und endet mit dem Ende des Programms, der bedingten Verzweigung oder einem direkten Sprung zum Label, das den nächsten Basisblock startet.


Die Arbeit mit Basiseinheiten bietet viele Vorteile, aber unser Schwein interessiert sich für die Hauptfunktion: Anweisungen innerhalb der Basiseinheit werden nacheinander ausgeführt. Es wäre großartig, diese Basisblöcke irgendwie zu isolieren und den Anweisungen darin zu folgen, ohne Zeit damit zu verschwenden, in die Hauptschleife zu gelangen.


In unserem Fall können Sie die Definition der Basiseinheit sogar auf die Spur erweitern. Die Spur in Bezug auf die Piglet VM-Maschine enthält alle nacheinander verbundenen Basisblöcke (dh unter Verwendung bedingungsloser Sprünge).


Neben der sequentiellen Ausführung von Anweisungen wäre es schön, die direkten Argumente der Anweisungen im Voraus zu dekodieren.


Das klingt alles ziemlich beängstigend und ähnelt einer dynamischen Zusammenstellung, die wir nicht verwenden wollten. Das Schwein zweifelte sogar ein wenig an seiner Stärke, aber in der Praxis stellte sich heraus, dass es nicht so schlimm war.


Lassen Sie uns zunächst darüber nachdenken, wie Sie sich die in der Spur enthaltene Anweisung vorstellen können:


 struct scode { uint64_t arg; trace_op_handler *handler; }; 

Hier ist arg das vordecodierte Argument der Anweisung, und der Handler ist ein Zeiger auf eine Funktion, die die Logik der Anweisung ausführt.


Jetzt sieht die Ansicht jeder Spur folgendermaßen aus:


 typedef scode trace[MAX_TRACE_LEN]; 

Das heißt, eine Spur ist eine Folge von S-Codes begrenzter Länge. Der Trace-Cache selbst in der virtuellen Maschine sieht folgendermaßen aus:


 trace trace_cache[MAX_CODE_LEN]; 

Dies ist nur ein Array von Traces mit einer Länge, die die mögliche Bytecode-Länge nicht überschreitet. Die Lösung ist faul. Um Speicherplatz zu sparen, ist es sinnvoll, eine Hash-Tabelle zu verwenden.


Zu Beginn des Interpreters kompiliert sich der erste Handler jedes Trace selbst:


 for (size_t trace_i = 0; trace_i < MAX_CODE_LEN; trace_i++ ) vm_trace.trace_cache[trace_i][0].handler = trace_compile_handler; 

Die Hauptinterpreterschleife sieht nun folgendermaßen aus:


 while(vm_trace.is_running) { scode *code = &vm_trace.trace_cache[vm_trace.pc][0]; code->handler(code); } 

Ein Trace-Compiler ist etwas komplizierter und führt zusätzlich zum Erstellen eines Trace ausgehend von der aktuellen Anweisung Folgendes aus:


 static void trace_compile_handler(scode *trace_head) { scode *trace_tail = trace_head; /* * Trace building here */ /* now, run the chain that has a trace_compile_handler replaced with proper instruction handler * function pointer */ trace_head->handler(trace_head); } 

Normaler Anweisungshandler:


 static void op_add_handler(scode *code) { uint64_t arg_right = POP(); *TOS_PTR() += arg_right; /* * Call the next trace handler * */ /* scodes are located in an array so we can use pointer arithmetic to get the next handler */ code++; code->handler(code); } 

Ein Handler, der am Ende der Funktion keine Aufrufe ausführt, beendet jede Ablaufverfolgung:


 static void op_done_handler(scode *code) { (void) code; vm_trace.is_running = false; vm_trace.error = SUCCESS; } 

All dies ist natürlich komplizierter als das Hinzufügen von Superanweisungen, aber mal sehen, ob es uns etwas gegeben hat:


 > ./pigletvm runtimes test/sieve.bin 100 > /dev/null PROFILE: switch code finished took 427ms PROFILE: switch code (no range check) finished took 395ms PROFILE: trace code finished took 367ms 

Hurra, noch 30 Millisekunden!


Wie so? Anstatt einfach durch Beschriftungen zu navigieren, erstellen wir Anrufketten von Anweisungshandlern, verbringen Zeit mit Aufrufen und Übergeben von Argumenten, aber unser Schweinchen läuft immer noch schneller auf den Spuren als ein einfacher Schalter mit seinen Beschriftungen.


Dieser Gewinn an Streckenleistung wird durch drei Faktoren erreicht:


  1. Das Vorhersagen von Zweigen, die an verschiedenen Stellen im Code verstreut sind, ist einfach.
  2. Die Argumente der Handler werden immer in ein vollständiges Maschinenwort vorcodiert, und dies erfolgt nur einmal - während der Kompilierung des Trace.
  3. Der Compiler wandelt die Funktionsketten in einen einzigen Aufruf der ersten Handlerfunktion um, was aufgrund der Optimierung des Tail-Aufrufs möglich ist .

Bevor wir die Ergebnisse unseres Trainings zusammenfassen, haben das Ferkel und ich beschlossen, eine andere alte Technik zur Interpretation von Programmen auszuprobieren - genähten Code.


Übung 4: "genähter" Code


Jedes Schwein, das sich für die Geschichte der Dolmetscher interessierte, hörte einen Thread-Code. Es gibt viele Optionen für diese Technik, aber alle beschränken sich darauf, anstatt ein Array von Opcodes zu durchlaufen, z. B. Zeiger auf Funktionen oder Beschriftungen, die direkt ohne Zwischen-Opcode folgen.


Das Aufrufen von Funktionen ist heutzutage ein teures und bedeutungsloses Geschäft. Die meisten anderen Versionen von genähtem Code sind im Rahmen von Standard C nicht realisierbar. Selbst die Technik, die weiter unten erläutert wird, verwendet die weit verbreiteten, aber nicht standardmäßigen Erweiterungs-C-Zeiger auf Beschriftungen.


In der Version des genähten Codes (englischer Token-Thread-Code), die ich ausgewählt habe, um unsere Schweineziele zu erreichen, speichern wir den Bytecode, aber bevor wir mit der Interpretation beginnen, erstellen wir eine Tabelle, in der die Anweisungs-Opcodes an die Adresse der Anweisungshandler-Labels angezeigt werden:


 const void *labels[] = { [OP_PUSHI] = &&op_pushi, [OP_LOADI] = &&op_loadi, [OP_LOADADDI] = &&op_loadaddi, [OP_STORE] = &&op_store, [OP_STOREI] = &&op_storei, [OP_LOAD] = &&op_load, [OP_DUP] = &&op_dup, [OP_DISCARD] = &&op_discard, [OP_ADD] = &&op_add, [OP_ADDI] = &&op_addi, [OP_SUB] = &&op_sub, [OP_DIV] = &&op_div, [OP_MUL] = &&op_mul, [OP_JUMP] = &&op_jump, [OP_JUMP_IF_TRUE] = &&op_jump_if_true, [OP_JUMP_IF_FALSE] = &&op_jump_if_false, [OP_EQUAL] = &&op_equal, [OP_LESS] = &&op_less, [OP_LESS_OR_EQUAL] = &&op_less_or_equal, [OP_GREATER] = &&op_greater, [OP_GREATER_OR_EQUAL] = &&op_greater_or_equal, [OP_GREATER_OR_EQUALI] = &&op_greater_or_equali, [OP_POP_RES] = &&op_pop_res, [OP_DONE] = &&op_done, [OP_PRINT] = &&op_print, [OP_ABORT] = &&op_abort, }; 

Achten Sie auf die Symbole && - dies sind Zeiger auf Beschriftungen mit den Anweisungen, der nicht standardmäßigen Erweiterung von GCC.


Um mit der Ausführung des Codes zu beginnen, klicken Sie einfach auf den Zeiger, der dem ersten Opcode des Programms entspricht:


 goto *labels[NEXT_OP()]; 

Hier gibt es keinen Zyklus und es wird keinen geben, jede der Anweisungen selbst macht einen Sprung zum folgenden Handler:


 op_pushi: { /* get the argument, push it onto stack */ uint16_t arg = NEXT_ARG(); PUSH(arg); /* jump to the next instruction*/ goto *labels[NEXT_OP()]; } 

Das Fehlen eines Schalters „verteilt“ Verzweigungspunkte entlang der Befehlskörper, was theoretisch dem Verzweigungsprädiktor bei außergewöhnlicher Ausführung von Befehlen helfen sollte. Es ist, als hätten wir den Schalter direkt in die Anweisungen eingebaut und manuell eine Übergangstabelle erstellt.


Das ist die ganze Technik. Sie mochte das Ferkel wegen seiner Einfachheit. Mal sehen, was in der Praxis passiert:


 > ./pigletvm runtimes test/sieve.bin 100 > /dev/null PROFILE: switch code finished took 443ms PROFILE: switch code (no range check) finished took 389ms PROFILE: threaded code finished took 477ms PROFILE: trace code finished took 364ms 

Ups! Dies ist die langsamste aller unserer Techniken! Was ist passiert? Lassen Sie uns dieselben Tests durchführen und alle GCC-Optimierungen deaktivieren:


 > ./pigletvm runtimes test/sieve.bin 100 > /dev/null PROFILE: switch code finished took 969ms PROFILE: switch code (no range check) finished took 940ms PROFILE: threaded code finished took 824ms PROFILE: trace code finished took 1169ms 

Hier ist genähter Code besser.


Drei Faktoren spielen hier eine Rolle:


  1. Der optimierende Compiler selbst erstellt eine Konvertierungstabelle, die nicht schlechter ist als unser manuelles Typenschild.
  2. Moderne Compiler verzichten bemerkenswerterweise auf zusätzliche Funktionsaufrufe.
  3. Ausgehend von der Haswell-Generation von Intel-Prozessoren haben Verzweigungsprädiktoren gelernt, Übergänge über einen einzelnen Verzweigungspunkt genau vorherzusagen.

Laut altem Speicher wird diese Technik immer noch im Code des Python VM-Interpreters verwendet, aber ehrlich gesagt ist sie heutzutage bereits Archaismus.


Lassen Sie uns abschließend die Erfolge unseres Schweins zusammenfassen und bewerten.


Nachbesprechung



Ich bin mir nicht sicher, was dies als Flug bezeichnet werden kann, aber seien wir ehrlich, unser Schweinchen hat einen langen Weg von 550 Millisekunden für hundert Läufe auf dem "Sieb" bis zu den letzten 370 Millisekunden zurückgelegt. Wir haben verschiedene Techniken verwendet: Super-Anweisungen, das Entfernen von Wertintervallen, die komplizierte Mechanik von Spuren und schließlich sogar das Nähen von Code. Gleichzeitig haben wir im Allgemeinen im Rahmen der in allen gängigen C-Compilern implementierten Dinge gehandelt. Eine Beschleunigung um das Eineinhalbfache, wie es mir scheint, ist ein gutes Ergebnis, und das Ferkel verdient eine zusätzliche Portion Kleie im Trog.


Eine der impliziten Bedingungen, die wir für uns mit dem Schwein festlegen, ist die Beibehaltung der Stapelarchitektur der Piglet VM-Maschine. Der Übergang zur Registerarchitektur reduziert in der Regel die Anzahl der Befehle, die für die Logik von Programmen erforderlich sind, und kann dementsprechend dazu beitragen, unnötige Exits zum Befehlsmanager zu vermeiden. Ich denke, weitere 10-20% der Zeit könnten abgeschnitten werden.


Unsere Hauptbedingung - das Fehlen einer dynamischen Zusammenstellung - ist ebenfalls kein Naturgesetz. Das Pumpen eines Schweins mit Steroiden in Form einer JIT-Zusammenstellung ist heutzutage sehr einfach: In Bibliotheken wie GNU Lightning oder LibJIT wurde die ganze Drecksarbeit bereits erledigt. Aber die Entwicklungszeit und die Gesamtmenge an Code, selbst wenn Bibliotheken verwendet werden, wachsen enorm.


Es gibt natürlich noch andere Tricks, bei denen unser kleines Schwein den Huf nicht erreicht hat. , — - — - . , .


PS , , , , ( https://www.instagram.com/vovazomb/ ), .


PPS , . true-grue - — PigletC . !


PPPS iliazeus : . ; . .

Source: https://habr.com/ru/post/de428878/


All Articles