Scrivere un compilatore JIT in assembly

Scrivere un compilatore JIT in assembly


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 dal bytecode della VM alle istruzioni, che viene compilato in una tabella di salto. Come ho detto, prestazioni decenti per quello che sono, ma ho raggiunto una barriera che può essere superata solo con un compilatore JIT.


Ho già fatto una domanda simile non molto tempo fa sull'auto-modifica del codice, 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 NASM come assemblatore) Non sono sicuro di come procedere. Sono a mio agio con l'assemblaggio e ho esaminato alcuni esempi di codice automodificanti, ma non sono ancora arrivato a capire come eseguire la generazione di codice.


Il mio blocco principale finora è copiare le istruzioni su un pezzo di memoria eseguibile, con le mie argomentazioni. Sono consapevole di poter etichettare una determinata riga 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 dal bytecode, copiarla nella memoria eseguibile, interpretare il primo argomento, copiarla in memoria, quindi interpretare il secondo argomento e copiarlo in memoria.


Sono stato informato su diverse librerie che renderebbero questo compito più semplice, come GNU lightning e persino LLVM. Tuttavia, vorrei prima scriverlo a mano, per capire come funziona, prima di utilizzare risorse esterne.


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


Risposte:


Non consiglierei affatto di scrivere un JIT in assembly. Ci sono buoni argomenti per scrivere i bit eseguiti più frequentemente dall'interprete in assemblea. Per un esempio di come appare, guarda questo commento di Mike Pall, l'autore di LuaJIT.


Per quanto riguarda la JIT, ci sono molti livelli diversi con complessità variabile:



  1. Compila un blocco di base (una sequenza di istruzioni non ramificate) semplicemente copiando il codice dell'interprete. Ad esempio, le implementazioni di alcune istruzioni bytecode (basate su registri) potrebbero essere simili a questa:


    ; 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 dell'implementazione degli interpreti in un nuovo blocco di codice macchina:


        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

    Questo copia semplicemente il codice pertinente, quindi è necessario inizializzare i registri utilizzati di conseguenza. Una soluzione migliore sarebbe tradurla direttamente in istruzioni macchina mov eax, [ebp + 4] , ma ora devi già codificare manualmente le istruzioni richieste.


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


  2. Sebbene alcuni JIT utilizzino la tecnica di cui sopra invece di un interprete, utilizzano quindi un meccanismo di ottimizzazione più complicato per il codice eseguito di frequente. Ciò comporta 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 questo compito a LLVM). Una JIT basata su metodi deve occuparsi dell'unione di grafici di flusso di controllo, quindi utilizza il modulo SSA ed esegue varie analisi su questo (ad es. Hotspot).


    Un JIT di traccia (come LuaJIT 2) compila solo codice in linea retta che rende molte cose più facili da implementare, ma devi stare molto attento a come scegli le tracce e come colleghi insieme più tracce in modo efficiente. Gal e Franz descrivono un metodo in questo documento (PDF). Per un altro metodo, vedere il codice sorgente LuaJIT. Entrambi i JIT sono scritti in C (o forse C++).