
Probé un PostgreSQL 19 vectorizado en un portátil: 18 a 21 % más rápido, y un ANY de 16.000 valores 948 veces más lento
Probé pg_vexec, un ejecutor vectorizado para PostgreSQL 19, en un portátil de 4 núcleos: ClickBench baja entre 18 y 21 % y todas las respuestas pasan el verificador del autor contra DuckDB. Pero la reescritura que salva al planificador de un OR gigante, pasar a = ANY, hace que elija un escaneo 948 veces más lento.
Esta semana salieron dos cosas de PostgreSQL que no tenían nada que ver entre sí. En Habr, en ruso, apareció pg_vexec: un ejecutor vectorizado para PostgreSQL 19 que entra como extensión, sin bifurcar el motor, y que su autor midió con ClickBench en un Ryzen de 16 núcleos. En Zenn, en japonés, un ingeniero contó cómo una consulta con 16.000 condiciones OR cargaba su base antes de ejecutarse: 56 segundos solo en planificar.
Quise ver las dos en mi máquina, y en una máquina modesta: un Lenovo IdeaPad con un i5-8250U de 4 núcleos y 12 GB de RAM. Al juntarlas apareció una tercera cosa que ninguno de los dos artículos cuenta.
Qué es pg_vexec
PostgreSQL ejecuta las consultas fila por fila: cada nodo del plan pide una fila al de abajo, la procesa y la entrega. Los motores analíticos, como DuckDB o ClickHouse, trabajan por lotes: toman mil filas de una columna y les aplican la misma operación de una vez, que es mucho más amable con la caché del procesador.
pg_vexec lleva esa idea a PostgreSQL 19 como un módulo que se carga con shared_preload_libraries. Se engancha en los ganchos del planificador y ofrece versiones vectorizadas de los escaneos, las agregaciones, los joins y los ordenamientos, que compiten por costo con las normales. Lo que un kernel vectorial no sabe calcular vuelve a la evaluación de PostgreSQL, y vexec.mode decide: off es PostgreSQL normal, auto elige por costo y force usa la versión vectorial siempre que puede.
Cómo lo instalé
El repositorio no trae imágenes publicadas, así que armé la mía: PostgreSQL desde la rama REL_19_STABLE (se presenta como 19beta4), sin aserciones, y pg_vexec compilado con PGXS desde su rama principal. Compiló a la primera. Para ClickBench usé el mismo subconjunto que el autor, una de cada diez filas del archivo original (9.999.750 filas, 6.866 MB según PostgreSQL), armado con su propio script, y la configuración que ClickBench calcula para esta máquina.
Dos tropiezos. El lenovo es NixOS y no tenía Docker: lo declaré en la configuración del sistema antes de empezar. Y la primera auditoría externa de mis números me hizo repetir la mitad del trabajo: había medido memoria de una forma distinta a la del artículo japonés, había mezclado tiempos medidos dentro de EXPLAIN ANALYZE con tiempos de psql, y había corrido los modos de ClickBench siempre en el mismo orden, en un portátil que se calienta. Todo eso está corregido abajo.
ClickBench: lo que promete, cumple
Corrí las 43 consultas en los tres modos, dos pasadas completas en órdenes opuestos, tres intentos por consulta y la caché del sistema vaciada antes del primero. El tiempo en caliente es el mejor del segundo y el tercer intento, como lo calcula ClickBench.
- vexec off, pasada 12.932 msPostgreSQL normal
- vexec auto, pasada 12.313 ms21 % menos
- vexec off, pasada 22.867 msPostgreSQL normal
- vexec auto, pasada 22.335 ms18,5 % menos
Medido el 8 y 9 de octubre de 2026 en un i5-8250U con 12 GB, 9.999.750 filas, PostgreSQL 19 (REL_19_STABLE) y pg_vexec 634d868e85. Pasada 1 en orden off, auto, force; pasada 2 al revés.
El autor reportó un 19 % en su Ryzen de 16 núcleos y 60 GB; en el portátil la mejora queda entre 18 y 21 %. Las mayores ganancias se repiten en las dos pasadas y coinciden con las suyas: la consulta 16 pasa de 15,2 a 3,3 segundos, la 29 mejora 3,2 veces y la 15, 2,8. Con COUNT(DISTINCT) hay de todo: la 10, la 11 y la 13 empeoran en auto (0,60 a 0,64 veces en la primera pasada y 0,52 a 0,55 en la segunda), la 4 también un poco, y la 5 y la 8 mejoran; él advierte lo mismo de las lentas. force da casi lo mismo que auto. En los primeros intentos de la primera pasada, con la caché del sistema recién vaciada, las medias de los tres modos quedan parecidas (25,4 a 26,1 segundos).
Lo que más me importaba era otra cosa: que no cambiara las respuestas. Todas las respuestas de las dos pasadas pasan el verificador del autor contra DuckDB 1.5.5, sin errores. Ese verificador es exigente con el orden y los empates, pero en las consultas cuyo LIMIT corta un grupo de filas empatadas solo compara las claves de orden. Entre los tres modos, 32 de las 43 respuestas son idénticas byte a byte en la segunda pasada (33 en la primera); las demás son justamente las que ClickBench deja abiertas: empates, un LIMIT sin ORDER BY y un orden por una columna que no se devuelve.
El OR gigante, en cinco versiones
El artículo japonés usa una tabla de inventario: 4 bodegas, 50.000 productos y 4 fechas de ingreso, 800.000 filas, con la clave primaria en las tres primeras columnas. La consulta pide el stock de un día para una lista de pares (bodega, producto), escrita como un OR de 16.000 condiciones. La reproduje igual y la corrí en PostgreSQL 15, 16, 17, 18 y 19.
El tiempo de planificar crece con el cuadrado de las condiciones en todas las versiones: 0,33 segundos con 1.000 pares, 5,1 con 4.000, 21,6 con 8.000 y 90,5 con 16.000 en PostgreSQL 15; con 16.000 pares, 16 tarda 91,0, 17 tarda 94,4, 18 tarda 96,3 y 19, 87,6. Medido como lo midió el artículo, el pico de memoria del proceso justo después de planificar queda cerca: 22, 45 y 135 MiB contra los 25, 48 y 140 MB que reporta. Ninguna versión cambió esa curva.
Con 16.000 pares la consulta ni siquiera llegó a ejecutarse en Docker: el plan paralelo pide un segmento de memoria compartida de 73 a 82 MB y el contenedor trae 64 MB de /dev/shm por defecto. Es una trampa del contenedor, no de PostgreSQL. Con 1 GB, PostgreSQL 19 la termina en 90,7 segundos, de los que 87,3 son planificación.
La salida conocida es reescribir el OR como un IN o un = ANY por bodega: cuatro condiciones en vez de 16.000. Planifica en milisegundos y la consulta completa tarda unos 70 ms.
Hay otra forma de OR que sí cambió: la que compara una sola columna contra muchos valores. PostgreSQL 18 empezó a convertirla en un ANY por dentro.
- PostgreSQL 1529,5 s
- PostgreSQL 1629,9 s
- PostgreSQL 1729,9 s
- PostgreSQL 1876 msel OR pasa a ANY
- PostgreSQL 1939 ms
Tiempo de psql, mediana de 3, misma tabla de 800.000 filas y configuración por defecto, imágenes oficiales de PostgreSQL 15.19, 16.15, 17.11 y 18.6; 19 compilado desde REL_19_STABLE.
Y una regresión chica en el camino: el = ANY de 16.000 valores sobre esa columna tarda 65 a 69 ms en PostgreSQL 15 a 17 y 225 ms en 18, que elige recorrer la clave primaria saltando su primera columna en vez de un escaneo secuencial en paralelo. PostgreSQL 19 vuelve a 71 ms.
Donde se juntan: el ANY que engaña al vectorizador
Con las dos cosas armadas, la pregunta obvia era qué hace pg_vexec con la reescritura recomendada. La respuesta me sorprendió. La medí con dos consultas. La primera pide un producto de una lista de 16.000 en cualquier bodega, product_id = ANY (...), y devuelve 64.000 filas: en modo auto pasa de 73 ms a 69,3 segundos, 948 veces más lenta. La segunda es la del artículo, los pares (bodega, producto), con sus dos reescrituras, IN y = ANY por bodega, que devuelven 16.000 filas: pasan de unos 70 ms a 16,9 y 16,8 segundos.
El plan explica la mitad. Con pg_vexec apagado, PostgreSQL busca los valores en la clave primaria con un escaneo por mapa de bits, de costo estimado 15.913. En auto, pg_vexec le pone a su escaneo secuencial vectorial un costo de 14.588, más barato que ese plan y que el escaneo secuencial normal (19.923), y lo elige.
El código explica la otra mitad. Para estimar, pg_vexec toma el costo que PostgreSQL calcula para ese = ANY, y PostgreSQL, con un array constante de 9 valores o más, lo calcula como lo ejecutaría él: arma una tabla hash una vez, con un costo por valor, y después cobra una sola búsqueda por fila. pg_vexec, en cambio, no usa tabla hash al ejecutar: compara el lote contra cada valor del array, uno por uno, mientras quede alguna fila sin decidir. Una fila que llega a esa condición y no coincide con ningún valor pasa por las 16.000 comparaciones, y en la primera consulta son dos de cada tres: la lista trae 16.000 de los 50.000 productos. El costo supone una búsqueda por fila y la ejecución, para esas filas, hace 16.000.
Lo curioso es que la primera consulta escrita como OR no cae en la trampa: pg_vexec lo cuenta como 16.001 pasos, lo encuentra caro y se queda con el índice de PostgreSQL (189 ms contra 185 con pg_vexec apagado). Es decir, la reescritura que salva al planificador es la misma que engaña al estimador del vectorizador. Y en modo force, sin la opción de elegir, el OR también se va a 31 segundos.
Lo que no puedo afirmar
Es una máquina, un subconjunto y una configuración por prueba. Dos pasadas de ClickBench no alcanzan para un intervalo de confianza: el rango de 18 a 21 % viene de esas dos. No registré cuántos trabajadores paralelos se lanzaron de verdad en cada consulta, solo cuántos planificó cada modo (4 en todas). Los tiempos de ejecución que cito de EXPLAIN ANALYZE están instrumentados; las comparaciones que importan las repetí con el cronómetro de psql. Y la lectura del código es mía: el reporte que preparé para el autor la presenta así, con las líneas citadas.
Mi opinión
pg_vexec es serio. Compiló sin pelear, pasó el verificador de ClickBench en todas las respuestas y repitió en un portátil la mejora que su autor midió en una máquina con cuatro veces más núcleos. Eso no es común en un proyecto tan nuevo.
El problema que encontré está en el desacuerdo entre lo que estima y lo que ejecuta, y decidir cuándo conviene es lo más difícil de un ejecutor así. El caso tampoco parece raro: una aplicación que convierte una lista de IDs en un = ANY con los valores escritos en la consulta arma una de esta forma. No probé arrays pasados como parámetro ni planes genéricos. El arreglo probable es evaluar el ANY con una tabla hash, como hace PostgreSQL, o cobrarlo por elemento mientras se ejecute así.
Cuándo lo usaría y cuándo no
- Sí, para consultas analíticas sobre tablas grandes con agregaciones, que es donde ClickBench muestra la ganancia, y sabiendo que es software muy nuevo sobre un PostgreSQL todavía en beta.
- Con cuidado, si tus consultas filtran con
= ANYoINde cientos o miles de valores literales: revisa el plan conEXPLAIN (VEXEC)y, si apareceVec Seq Scandonde antes había un índice, deja esa sesión envexec.mode = off. - Para el OR gigante, en cualquier versión: no armes miles de pares con OR. Agrupa por la primera columna con
INo= ANY, y si corres PostgreSQL en Docker, sube/dev/shmpor encima de los 64 MB por defecto.
Preguntas frecuentes
¿Qué es pg_vexec?
Una extensión para PostgreSQL 19 que agrega un planificador y un ejecutor vectorizados sin bifurcar PostgreSQL: procesa lotes de hasta 1.024 filas por columna en vez de una fila a la vez, y compite por costo con los nodos normales. Se activa con vexec.mode en off, auto o force.
¿Cuánto mejora en un portátil?
En ClickBench, sobre 10 millones de filas y en un i5-8250U de 4 núcleos, la media geométrica de los tiempos en caliente baja entre 18 y 21 % en modo auto, según el orden en que corran los modos. Tres consultas con COUNT(DISTINCT), la 10, la 11 y la 13, quedan bastante más lentas.
¿Devuelve los mismos resultados?
En las 43 consultas de ClickBench, dos pasadas en tres modos, todas las respuestas pasan el verificador del autor contra DuckDB. Para las consultas con empates cortados por LIMIT ese verificador solo compara las claves de orden.
¿Por qué un OR con miles de condiciones tarda tanto?
Porque el planificador arma una ruta por cada condición y el tiempo crece con el cuadrado: 16.000 pares (bodega, producto) tardan entre 88 y 96 segundos solo en planificarse, en PostgreSQL 15 a 19. Reescribirlo como IN o = ANY por bodega lo deja en milisegundos.
¿Entonces no conviene usar = ANY con pg_vexec?
En modo auto, con arrays grandes, hoy no: pg_vexec estima el ANY como si usara una tabla hash, una búsqueda por fila, pero lo ejecuta comparando cada fila con todos los valores, así que abandona el índice. Con 16.000 valores la consulta pasa de 73 ms a 69 s. Con vexec.mode en off no pasa.
¿PostgreSQL 18 arregló algo de esto?
Sí, el OR sobre una sola columna: PostgreSQL 18 lo convierte en ANY y con 4.000 valores pasa de 30 segundos a 76 ms. El OR de pares de columnas, el del artículo original, sigue igual de lento para planificar.
Fuentes
- pg_vexec en GitHub, commit 634d868e85, y su documento de diseño con las mediciones del autor (
doc/vectorized-postgresql-extension.md). El lote de 1.024 filas esVEXEC_BATCH_ROWS; el costo del ANY,plan/oracle.cjunto con el costo que toma de PostgreSQL (oracle.cyplan/cost.c), y su ejecución,expr/eval.c. - El artículo de pg_vexec en Habr (en ruso).
- El artículo del OR gigante en Zenn (en japonés).
- ClickBench, su protocolo para PostgreSQL y el conjunto de datos
hits. - El ANY en PostgreSQL 19: la tabla hash desde 9 valores y su costo.
- Las notas de PostgreSQL 18: el OR sobre una columna convertido en ANY y el escaneo que salta la primera columna del índice.
- Documentación de EXPLAIN en PostgreSQL y de
log_planner_stats. - Los scripts, los planes y los datos crudos de cada medición.
Medido el 8 y 9 de octubre de 2026 en un Lenovo IdeaPad 81F4 (Intel Core i5-8250U, 4 núcleos y 8 hilos, 12 GB de RAM) con NixOS 26.05 y Docker 29.8.1; PostgreSQL 19 desde REL_19_STABLE en 2359968b449a (19beta4), pg_vexec 634d868e8549, PostgreSQL 15.19, 16.15, 17.11 y 18.6 en sus imágenes oficiales, DuckDB 1.5.5.
Comentarios
Todavía no hay comentarios. El primero es tuyo.