Escribiendo un compilador JIT en ensamblador

Escribiendo un compilador JIT en ensamblador


Escribí una máquina virtual en C que tiene un rendimiento decente para una máquina virtual que no es JIT, pero quiero aprender algo nuevo y mejorar el rendimiento. Mi implementación actual simplemente usa un interruptor para traducir el código de bytes de la VM a las instrucciones, que se compila en una tabla de salto. Como dije, un rendimiento decente por lo que es, pero me topé con una barrera que solo se puede superar con un compilador JIT.


Ya hice una pregunta similar no hace mucho sobre el código automodificable, pero me di cuenta de que no estaba haciendo la pregunta correcta.


Entonces, mi objetivo es escribir un compilador JIT para esta máquina virtual C, y quiero hacerlo en un ensamblaje x86. (Estoy usando NASM como mi ensamblador) No estoy muy seguro de cómo hacer esto. Me siento cómodo con el ensamblaje y he revisado algunos ejemplos de código automodificable, pero todavía no he descubierto cómo generar código.


Mi bloque principal hasta ahora es copiar instrucciones a una pieza de memoria ejecutable, con mis argumentos Soy consciente de que puedo etiquetar una determinada línea en NASM y copiar la línea completa de esa dirección con los argumentos estáticos, pero eso no es muy dinámico y no funciona para un compilador JIT. Necesito poder interpretar la instrucción del código de bytes, copiarla en la memoria ejecutable, interpretar el primer argumento, copiarlo en la memoria, luego interpretar el segundo argumento y copiarlo en la memoria.


Me han informado sobre varias bibliotecas que facilitarían esta tarea, como GNU lightning e incluso LLVM. Sin embargo, me gustaría escribir esto a mano primero, para entender cómo funciona, antes de usar recursos externos.


¿Hay algún recurso o ejemplo que esta comunidad pueda proporcionar para ayudarme a comenzar esta tarea? Un ejemplo simple que muestre el uso de dos o tres instrucciones como "add" y "mov" para generar código ejecutable, con argumentos, dinámicamente, en la memoria, haría maravillas.


Respuestas:


No recomendaría escribir un JIT en ensamblaje. Hay buenos argumentos para escribir los bits ejecutados con mayor frecuencia del intérprete en asamblea Para ver un ejemplo de cómo se ve esto, vea este comentario de Mike Pall, el autor de LuaJIT.


En cuanto al JIT, hay muchos niveles diferentes con una complejidad variable:



  1. Compile un bloque básico (una secuencia de instrucciones que no se bifurcan) simplemente copiando el código del intérprete. Por ejemplo, las implementaciones de algunas instrucciones de código de bytes (basadas en registros) podrían verse así:


    ; 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

    Entonces, dada la secuencia de instrucciones ADD R3, R1, R2 , SUB R3, R3, R4 un JIT simple podría copiar las partes relevantes de la implementación de los intérpretes en un nuevo fragmento de código de máquina:


        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

    Esto simplemente copia el código relevante, por lo que debemos inicializar los registros utilizados en consecuencia. Una mejor solución sería traducir esto directamente a instrucciones de máquina mov eax, [ebp + 4] , pero ahora ya tienes que codificar manualmente las instrucciones solicitadas.


    Esta técnica elimina los gastos generales de interpretación, pero por lo demás no mejora mucho la eficiencia. Si el código se ejecuta solo una o dos veces, es posible que no valga la pena traducirlo primero a código de máquina (lo que requiere vaciar al menos partes del I-cache).


  2. Si bien algunos JIT usan la técnica anterior en lugar de un intérprete, luego emplean un mecanismo de optimización más complicado para el código que se ejecuta con frecuencia. Esto implica traducir el bytecode ejecutado en una representación intermedia (IR) en la que se realizan optimizaciones adicionales.


    Según el idioma de origen y el tipo de JIT, esto puede ser muy complejo (es por eso que muchos JIT delegan esta tarea a LLVM). Un JIT basado en métodos necesita lidiar con la unión de gráficos de flujo de control, por lo que usan el formulario SSA y ejecutan varios análisis sobre eso (por ejemplo, Hotspot).


    Un JIT de seguimiento (como LuaJIT 2) solo compila código de línea recta, lo que hace que muchas cosas sean más fáciles de implementar, pero debe tener mucho cuidado con la forma en que selecciona los seguimientos y cómo vincula múltiples seguimientos de manera eficiente. Gal y Franz describen un método en este documento (PDF). Para otro método, consulte el código fuente de LuaJIT. Ambos JIT están escritos en C (o quizás C++).