Desarrollo de un nuevo compilador JIT para PostgreSQL
(pinaraf.info)- El motor JIT experimental pg-copyjit se enfoca en generar código “lo suficientemente rápido” incluso para consultas cortas de PostgreSQL, con un costo de generación menor que LLVM
- Las estimaciones de costo de PostgreSQL no se corresponden directamente con el tiempo real de ejecución, por lo que LLVM JIT, cuyo costo de optimización es alto, puede terminar siendo contraproducente en consultas cortas
- El enfoque copy-and-patch precompila stencils hechos en C y, en tiempo de ejecución, copia y parchea los fragmentos necesarios para ejecutarlos como una nueva función
- pg-copyjit se integra mediante la interfaz de proveedor JIT de PostgreSQL y, si encuentra un opcode no implementado, hace fallback automáticamente al intérprete de PostgreSQL
- Actualmente es una prueba de concepto que funciona en PostgreSQL 16 y AMD64; la generación de código toma del orden de cientos de microsegundos, pero el build, la documentación y el soporte todavía no están listos
El punto al que apunta pg-copyjit
- pg-copyjit es un motor JIT experimental para hacer más rápido el servidor PostgreSQL
- El código actual está más cerca de algo que podrían probar hackers especializados que de algo apto para servidores en producción
- El feedback esperado son resultados de experimentos interesantes, casos de mejoras de rendimiento e ideas de implementación; no es una etapa para arriesgar downtime en aplicaciones críticas del negocio
Por qué LLVM JIT resulta pesado en PostgreSQL
- PostgreSQL ya cuenta con un compilador JIT basado en LLVM introducido por Andres Freund
- LLVM puede generar código eficiente, pero en la forma en que se usa en PostgreSQL, el costo de compilación JIT y optimización es alto
- Si no se usa el optimizador, puede ser peor que no compilar; si se usa el optimizador, el costo puede aumentar todavía más
- Para decidir si aplicar JIT se usa una estimación general de costo de consulta
- El cost de PostgreSQL es un valor para comparar consultas entre sí, no significa tiempo real de ejecución
- Una consulta con cost 100 puede tardar 1 segundo, y una consulta con cost 1000 puede terminar en 100 ms
- Si una consulta se acelera 10 ms pero la optimización tarda 50 ms, el tiempo total de ejecución sale perdiendo
- Una forma de hacer más útil LLVM JIT sería cachear y reutilizar consultas compiladas, pero implementarlo no es una tarea menor
Estructura del enfoque copy-and-patch
- copy-and-patch es una forma de construir compiladores JIT presentada en un paper de 2021, y también se usa en el motor JIT de Python 3.13
- La unidad central es un stencil escrito en C
- Un stencil es una función con huecos
- Se precompila con clang
- El soporte para gcc todavía está pendiente
- En tiempo de compilación se concatenan los stencils necesarios, se rellenan los huecos y luego se salta a la nueva función “compilada”
- El flujo básico es simple
- Se copia el stencil a una nueva región de memoria
- Se parchean los valores necesarios
- Se ejecuta el código resultante
- También hay margen para optimizaciones adicionales
- Precalcular valores que puedan calcularse en tiempo de compilación
- Dividir loops en varios stencils para hacer unroll
- Combinar varios stencils para crear un meta-stencil que se optimice de una vez
Cómo integrarlo a PostgreSQL
- El JIT de PostgreSQL tiene una estructura de proveedores extensible
- El
.soprovee una única función_PG_jit_provider_init, y en esa función se inicializan tres callbackscompile_exprrelease_contextreset_after_error
- El callback central es
compile_expr- La entrada es un puntero de expresión
ExprState*compuesto por opcodes - Compila los opcodes de la manera deseada
- Marca el código generado como ejecutable
- Cambia
evalfuncpara que use el código generado en lugar del intérprete de PostgreSQL
- La entrada es un puntero de expresión
- Si encuentra un opcode no implementado, puede hacer fallback automático al intérprete de PostgreSQL
Procedimiento de compilación de pg-copyjit
- El algoritmo copy-and-patch de pg-copyjit es por ahora una forma simple que incluye solo algunas pequeñas optimizaciones
- Para cada opcode, el compilador revisa una colección de stencils
- Si existe un stencil correspondiente al opcode, lo agrega al código que se está generando
- Si no hay stencil, interrumpe la compilación y deja que el intérprete de PostgreSQL se encargue de la ejecución
- Después de agregar el stencil, parchea cada hueco con los valores necesarios
- El stencil del opcode
CONSTdeclaraopcomo unExprEvalStepexterno y, dentro del archivo.ocompilado, deja un hueco para la dirección deop - La colección de stencils conserva esta información de relocation, y el compilador JIT inserta la dirección de la estructura del opcode actual para crear código ejecutable
- El build primero crea los stencils en un único archivo
.o; luego extrae de allí el código assembly y las relocations, y los convierte en estructuras utilizables desde C
Estado de implementación y rendimiento
- Al principio se extrajo manualmente el código assembly para hacer funcionar los 3 opcodes necesarios para
SELECT 42; - Luego se escribió un script DirtyPython para automatizar la extracción de código assembly y, en pocas horas, se agregaron las siguientes funciones
- Llamadas a funciones
- Consultas de una sola tabla
- Tipos de datos más complejos
- Algunas optimizaciones
- El estado verificado actual es el siguiente
- Funciona en PostgreSQL 16
- Se espera que también vaya bien en releases anteriores, pero el entorno verificado es PostgreSQL 16
- La única arquitectura soportada es AMD64
- Hay planes para agregar soporte para ARM64
- Targets como POWER64 y S390x también son de interés, pero podrían requerir parches al compilador y acceso a esas máquinas
- Las cifras de rendimiento corresponden a resultados con muy poca optimización todavía
- La generación de código termina en cientos de microsegundos
- Es un nivel usable incluso para consultas cortas
- Para
SELECT 42;, no JIT tarda 0.3 ms, copyjit 0.6 ms, LLVM sin optimización 1.6 ms y LLVM con optimización 6.6 ms
- LLVM puede generar código muy rápido, pero el objetivo de pg-copyjit es generar rápidamente código lo suficientemente rápido, por lo que es difícil comparar ambas herramientas de forma directa
- Se benchmarkearon dos consultas sobre una tabla simple sin índices de 90k rows, y cuando el clause
whereincluye trabajo de CPU, el rendimiento mejora frente al intérprete - Los benchmarks se realizaron en una laptop, así que su confiabilidad es limitada; más adelante se harán benchmarks más adecuados en una desktop
- Aunque todavía hay pocos opcodes implementados, cualquier consulta puede ejecutarse
- El motor JIT emite un mensaje para las partes no implementadas
- La ejecución real queda a cargo del intérprete
Código publicado y trabajo pendiente
- El código está publicado en GitHub como pg-copyjit
- Actualmente el foco está en el código en sí, más que en limpiar el git history o documentar
- Para compilar, primero hay que ejecutar manualmente el archivo
build-stencils.sh - En el estado actual no se puede ofrecer soporte, así que todavía no hay documentación
- El trabajo pendiente es relativamente claro
- Implementar más opcodes
- Explorar optimizaciones
- Mejorar la facilidad de build
- Dejarlo en un estado apto para empaquetado
- El script de build actualmente está especializado para Debian y PostgreSQL 16
Usos esperados y expansión de arquitectura
- El objetivo es llegar a un nivel en el que pueda empaquetarse de forma segura y desplegarse en servidores de producción propios
- También existe la idea de repartir el uso de JIT según el servidor
- Usar LLVM JIT donde valga la pena pagar el costo de optimización, como en servidores GIS
- Usar pg-copyjit en bases de datos de aplicaciones web donde importan los tiempos de consultas cortas
- Portarlo a otras arquitecturas también es un objetivo serio
- La postura es que se extraña la época de arquitecturas diversas como Alpha, Itanium, Sparc y M68k, y que no se quiere contribuir al problema de la monocultura centrada en una sola arquitectura
1 comentarios
Opiniones de Hacker News
Como el compilador JIT tiene que inyectar direcciones de memoria directamente, el código generado queda ligado a esa consulta y a ese proceso.
Los planes de ejecución sufren el mismo problema, porque tampoco son estructuras de datos en memoria diseñadas para compartirse entre distintos procesos.
Las BD como MSSQL tienen una arquitectura de un solo proceso con hilos, así que no tienen este problema; también es una de las razones por las que pueden manejar más conexiones concurrentes sin un pooler externo.
MSSQL también puede serializar los planes de ejecución en una representación que no esté ligada al proceso y guardarlos en la BD, lo que se puede usar para funciones como fijar planes de ejecución.
La compilación tradicional también parece una especie de “copy-and-patch” que usa un lenguaje intermedio distinto de C.
Por ejemplo, qué pases y qué optimizaciones de backend se usan.
Según nuestra experiencia [1], si se desactivan las optimizaciones y se ajusta el pipeline de backend
-O0para minimizar el tiempo de compilación, LLVM también puede volverse bastante rápido, pero sigue siendo entre 10 y 20 veces más lento que otros enfoques.Además, nuestra experiencia es que el código generado con copy-and-patch se ejecuta bastante lento y es difícil de optimizar. Probamos varias cosas [2; Sec. 5], pero la diferencia sigue siendo grande. Para los resultados de evaluación en bases de datos, ver Fig. 3.
También me interesa saber si tienen cifras de degradación del tiempo de ejecución frente a LLVM, y si hay planes de implementar un JIT de varios niveles que cambie dinámicamente del código compilado rápido al código optimizado por LLVM.
[1]: https://home.in.tum.de/~engelke/pubs/2403-cgo.pdf
[2]: https://home.in.tum.de/~engelke/pubs/2403-cc.pdf
Alrededor de 2010, cuando estaba aprendiendo programación, en especial intérpretes, pensaba que era bien sabido que, si se tenía cuidado, se podían copiar con
memcpyfragmentos de código ejecutable generados por el compilador.La gran trampa en esa época era que el bit NX apenas empezaba a difundirse. Incluso en Linux, la mayoría todavía daba por sentadas las distribuciones de 32 bits, y había gente que se sorprendía de que su CPU soportara 64 bits.
Más tarde incluso usé una netbook que no soportaba código de 64 bits en absoluto.
Lamentablemente, invertí demasiado tiempo en el resto del código y no llegué a profundizar lo suficiente en esto como para hacer algo usable.
Lamentablemente, la convocatoria de propuestas ya terminó, pero hay una parte de “unconference”. Eso sí, los temas se deciden en el evento, así que no hay garantía.
Si existiera una caché, las consultas que se ejecutan con frecuencia podrían cachearse y optimizarse de forma más agresiva, de modo que ambos enfoques podrían encajar bien compensando el costo de compilación.
Claro que eso, por sí solo, agregaría toda una nueva complejidad y nuevos dolores de cabeza.
Ver “notes” en https://www.postgresql.org/docs/current/sql-prepare.html
Simplificando mucho, por todo el motor de ejecución se filtran punteros a partes de la consulta.
Eliminarlos requeriría una reestructuración bastante amplia del motor de ejecución, el planner y quién sabe qué más.
Incluso dentro de una sola sesión, dos consultas compiladas terminan teniendo código compilado distinto por esto. Tanto LLVM como mi copyjit tienen que inyectar en el código ensamblador las direcciones de varias estructuras.
Normalmente había que meterse directamente a agregar hints o forzar un plan de ejecución.
Incluso una consulta simple como
SELECT * FROM t WHERE x = TRUE;podía volverse una pesadilla según la distribución de los valores dexdentro de la tabla.En Postgres casi no he tenido ese problema, aunque admito que no he usado Postgres con sentencias preparadas.
Sí he visto consultas con tiempo de planificación lento (más de 100 ms), así que una caché podría haber sido útil, pero no recuerdo casos en los que la optimización fuera realmente imprescindible.
Es parecido a los stencils que se presentan aquí, aunque como el código normalmente era una sola instancia, rara vez se hacían copias.
Por ejemplo, Doom para DOS usaba este tipo de optimización. Era necesario para exprimir suficiente rendimiento de los loops de renderizado ajustados en CPUs antiguas.