2011-02-17 13 views
22

Ho scritto una macchina virtuale in C che ha prestazioni decenti per una VM non JIT, ma voglio imparare qualcosa di nuovo e migliorare le prestazioni. La mia attuale implementazione utilizza semplicemente un interruttore per tradurre da bytecode VM alle istruzioni, che viene compilato su una tabella di jump. Come ho detto, prestazioni decenti per quello che è, ma ho colpito una barriera che può essere superata solo con un compilatore JIT.Scrivere un compilatore JIT nell'assemblaggio

Ho già fatto una domanda simile non molto tempo fa sul codice auto-modificante, ma mi sono reso conto che non stavo facendo la domanda giusta.

Quindi il mio obiettivo è scrivere un compilatore JIT per questa macchina virtuale C, e voglio farlo in assembly x86. (Sto usando la NASM come mio assemblatore) Non sono abbastanza sicuro di come fare per farlo. Sono a mio agio con il montaggio e ho esaminato alcuni esempi di codice auto-modificanti, ma non sono ancora arrivato a capire come fare la generazione di codice.

Il mio blocco principale finora è la copia delle istruzioni per un pezzo di memoria eseguibile, con i miei argomenti. Sono consapevole di poter etichettare una determinata linea in NASM e copiare l'intera riga da quell'indirizzo con gli argomenti statici, ma non è molto dinamico e non funziona per un compilatore JIT. Devo essere in grado di interpretare l'istruzione da bytecode, copiarla in memoria eseguibile, interpretare il primo argomento, copiarlo in memoria, quindi interpretare il secondo argomento e copiarlo in memoria.

Sono stato informato di diverse librerie che renderebbero questo compito più semplice, come il fulmine GNU e persino LLVM. Tuttavia, mi piacerebbe scriverlo a mano prima, per capire come funziona, prima di usare risorse esterne.

Esistono risorse o esempi che questa comunità potrebbe fornire per aiutarmi a iniziare questa attività? Un semplice esempio che mostra due o tre istruzioni come "aggiungi" e "mov" utilizzate per generare codice eseguibile, con argomenti, dinamicamente, in memoria, farebbe miracoli.

+10

Proprio perché un jitter genera codice macchina * non * significa che esso stesso deve essere scritto in assembly. Non ha senso farlo. –

+0

Un passo intermedio da provare è la distribuzione con thread utilizzando l'estensione goto calcolata di GCC (usando 'void * optable [] = {&& op_add, && op_subtract, ...}' e ciascun operando è 'op_add: ... goto * optable [* ip ++] ; '). Ho visto grandi guadagni in interpreti commutati come descrivi tu. –

risposta

18

Non raccomanderei affatto di scrivere una JIT in assemblaggio. Esistono buoni argomenti per scrivere i bit più frequentemente eseguiti dell'interprete in assembly. Per un esempio di come appare vedi questo comment from Mike Pall, l'autore di LuaJIT.

Per quanto riguarda il JIT, ci sono molti livelli diversi con diversa complessità:

  1. compilare un blocco di base (una sequenza di istruzioni non ramificazione) semplicemente copiando il codice dell'interprete. Per esempio, le implementazioni di alcuni (registro-based) istruzioni bytecode potrebbero apparire così:

    ; ebp points to virtual register 0 on the stack 
    instr_ADD: 
        <decode instruction> 
        mov eax, [ebp + ecx * 4] ; load first operand from stack 
        add eax, [ebp + edx * 4] ; add second operand from stack 
        mov [ebp + ebx * 4], eax ; write back result 
        <dispatch next instruction> 
    instr_SUB: 
        ... ; similar 
    

    Quindi, data la sequenza di istruzioni ADD R3, R1, R2, SUB R3, R3, R4 un semplice JIT potrebbe copiare le parti rilevanti della realizzazione interpreti in un nuovo codice macchina pezzo:

    mov ecx, 1 
        mov edx, 2 
        mov ebx, 3 
        mov eax, [ebp + ecx * 4] ; load first operand from stack 
        add eax, [ebp + edx * 4] ; add second operand from stack 
        mov [ebp + ebx * 4], eax ; write back result 
        mov ecx, 3 
        mov edx, 4 
        mov ebx, 3 
        mov eax, [ebp + ecx * 4] ; load first operand from stack 
        sub eax, [ebp + edx * 4] ; add second operand from stack 
        mov [ebp + ebx * 4], eax ; write back result 
    

    Questa copia semplicemente il codice in questione, quindi abbiamo bisogno di inizializzare i registri utilizzati di conseguenza. Una soluzione migliore sarebbe quella di tradurre questo direttamente nelle istruzioni della macchina mov eax, [ebp + 4], ma ora è già necessario codificare manualmente le istruzioni richieste.

    Questa tecnica rimuove i costi generali di interpretazione, ma altrimenti non migliora l'efficienza molto.Se il codice viene eseguito solo per una o due volte, allora potrebbe non valere la pena di prima tradurlo in codice macchina (che richiede lo svuotamento di almeno parti della cache I).

  2. Mentre alcune JIT utilizzano la tecnica precedente anziché un interprete, impiegano quindi un meccanismo di ottimizzazione più complicato per codice eseguito di frequente. Ciò implica la traduzione del bytecode eseguito in una rappresentazione intermedia (IR) su cui vengono eseguite ulteriori ottimizzazioni.

    A seconda della lingua di origine e del tipo di JIT, questo può essere molto complesso (motivo per cui molte JIT delegano questa attività a LLVM). Un JIT basato su metodo deve occuparsi di unire i grafici di controllo-flusso, quindi usano il modulo SSA ed eseguono varie analisi su tale (ad esempio, Hotspot).

    Un JIT di tracciamento (come LuaJIT 2) compila solo un codice di linea che rende molte cose più facili da implementare, ma bisogna stare molto attenti a come si selezionano le tracce e come si collegano più tracce insieme in modo efficiente. Gal e Franz descrivono un metodo in this paper (PDF). Per un altro metodo, vedere il codice sorgente LuaJIT. Entrambi i JIT sono scritti in C (o forse in C++).

+0

E i blocchi di codice che usano la ramificazione? – jakogut

+1

A destra, alla fine di ogni blocco di base è necessario ricorrere a una routine che decide dove diramare. Ciò comporta quindi un nuovo indirizzo bytecode che deve essere associato a un indirizzo del codice macchina corrispondente. Esiste una tecnica chiamata [context threading (PDF)] (http://www.cs.toronto.edu/syslab/pubs/zaleski_shapes2005.pdf) che consente un'integrazione più agevole di interprete e JIT. L'idea principale è di tradurre i rami in istruzioni macchina reali in modo che il predittore di ramo possa vederli. – nominolo

7

Ti suggerisco di guardare il progetto http://code.google.com/p/asmjit/. Utilizzando la struttura fornita, è possibile risparmiare un sacco di energia. Se vuoi scrivere tutte le cose a mano, leggi la fonte e riscrivi tu stesso, penso che non sia molto difficile.