El bug de Apple Metal que hizo perder a una GPU vs CPU

“`html

En pocas palabras: La GPU perdía porque la constante kSlotLockMinGroups de gpudb (extensión de DuckDB para Apple Silicon) valía 1024 en vez de 131.072, 128 veces menos. En un Apple M4 Max, eso daba 25,39 ms contra 14,65 ms de un solo hilo de CPU; corregirla dio una mejora de 3,0x.

Una base de datos GPU construida sobre Apple Metal perdió contra un solo hilo de CPU: 25,39 ms de la GPU frente a 14,65 ms del procesador moviendo 10 millones de filas. El bug de rendimiento era una única constante mal calibrada, 128 veces más chica de lo que debía, y corregirla dio una mejora de 3,0x.

Una base de datos acelerada por GPU es un motor SQL que delega operaciones como agregaciones y GROUP BY a la placa gráfica mediante APIs como Metal de Apple. El mantenedor de gpudb, una extensión comunitaria de DuckDB para chips Apple Silicon, publicó en DEV Community el análisis completo del fallo: cómo lo detectó, cómo lo midió y por qué nadie lo había sufrido en producción.

En 30 segundos

  • La GPU perdía 1,7x contra un hilo: con 10 millones de filas y 1024 grupos, el camino Metal sobre un Apple M4 Max promedió 25,39 ms y un único hilo de CPU con un std::unordered_map promedió 14,65 ms.
  • El culpable fue una constante: kSlotLockMinGroups valía 1024 y el umbral real medido era 131.072, un error de 128x en el dispatcher de kernels.
  • El síntoma era un cliff, no una pendiente: entre 256 y 1024 grupos el tiempo de la GPU saltó de 6,50 ms a 25,09 ms.
  • La corrección dio hasta 3,02x: el motor volvió a ganarle al CPU en toda la banda afectada, con 96/96 checks unitarios y 72/72 tests SQL en verde.
  • Ningún usuario SQL lo sufrió: el planner externo exige 250.000 grupos esperados para usar la GPU, así que el bug era inalcanzable por el camino normal.

Para ubicarnos: gpudb es una extensión comunitaria de DuckDB que ejecuta agregaciones y GROUP BY sobre Apple Silicon usando Metal, la API gráfica de Apple. Ojo con esto: no es un producto oficial de la compañía de Cupertino. El hardware es un M4 Max y la API es Metal, pero el código con el bug lo escribió y mantiene un desarrollador independiente. Que el caso se lea como falla de Apple es cómodo para los titulares; el responsable es un repo de la comunidad. (Que igual nos sirve a todos, porque el patrón de falla le puede pasar a cualquier dispatcher.)

¿Qué fue el bug que hizo perder a la base de datos GPU de Apple Metal contra un hilo de CPU?

El autor publicó los números de su propio benchmark, gpudb-groupby-bench, con 10 millones de filas y 1024 grupos: el camino Metal promedió 25,39 ms y un solo hilo de CPU recorriendo un hash map promedió 14,65 ms. Diez millones de filas. La GPU tardaba 1,7 veces más que un hilo caminando una tabla de hash, según su relato del debugging en DEV Community.

El autor lo llamó por su nombre: una vergüenza. Nada de “oportunidad de tuning”. Y tenía razón: cuando tu hardware especializado pierde contra el camino aburrido, el problema casi nunca está en el algoritmo central, sino en alguna decisión de plomería alrededor.

¿Y cuál era el bug? Un entero. Uno solo.

¿Cómo se detecta un bug así: barrer parámetros antes de perfilar?

Se detecta barriendo el parámetro que maneja la heurística antes de abrir el profiler, porque un dato aislado no revela la forma del problema, y la forma identifica la clase de bug. Ponele que corrés el benchmark de tu propio motor y te aparece un número imposible: la tentación es leer trazas de la GPU durante horas. El autor hizo otra cosa, y es para mí la decisión más pedagógica del caso: barrió la cardinalidad de grupos, de 8 a 65.536, con cinco minutos de trabajo.

La lógica es simple. Una pendiente suave significa que el algoritmo degrada con el volumen. Un salto abrupto significa que algo en el código eligió distinto. Estos fueron los números del barrido, siempre con 10 millones de filas y mediana de 3 corridas: Complementá con el rendimiento real de Linux en Apple Silicon.

GruposCPU 1 hiloMetal (M4 Max)Resultado
814,62 ms7,37 msGana la GPU 2,0x
6414,52 ms7,07 msGana la GPU 2,1x
25615,84 ms6,50 msGana la GPU 2,4x
102415,86 ms25,09 msPierde la GPU
819227,42 ms21,23 msGana la GPU 1,3x
6553630,37 ms13,30 msGana la GPU 2,3x
bug apple metal gpu diagrama explicativo

Mirá lo que pasa entre 256 y 1024: de 6,50 ms a 25,09 ms, casi 4x de un golpe, y después una recuperación lenta. Eso no es una curva de degradación. Es un branch cambiando de opinión.

Lo mejor de todo: el dispatcher avisaba. Cada corrida imprimía en stderr qué kernel elegía, radix-opt para 256 grupos y slot-lock para 1024. El autor confesó que llevaba semanas filtrando ese stderr buscando errores y scrolleando por encima de la respuesta. (Todos hemos grepeado justo lo que no queríamos ver, admitámoslo.)

¿Cómo funciona el dispatch de kernels en Apple Metal y por qué eligió mal?

El GROUP BY sobre Metal corre con dos kernels distintos y un dispatcher que elige entre ellos según la cardinalidad esperada de grupos. El kernel slot-lock construye 4096 particiones hash fijas, cada una con una tabla de slots residente en el threadgroup, un diseño dimensionado para millones de grupos. El kernel radix-opt cubre el otro extremo, las cardinalidades bajas.

El comentario del código prometía que slot-lock le ganaba al CPU por 2,5x a 4x “at any size” en un rango de ~1K a ~3M grupos. Fijate el detalle: el rango arrancaba en 1K. Ahí estaba el error, escrito con todas las letras a la vista de todos. Le entregás 1024 grupos a ese kernel y las 4096 particiones colisionan en un puñado de slots: miles de hilos terminan serializados detrás de locks, haciendo un spin carísimo disfrazado de computación paralela.

“Beats CPU 2.5-4x at any size” es, dicho sea de paso, prosa infalsificable: sin tabla, sin hardware nombrado, sin forma de re-correrla y llevarle la contraria. El autor lo planteó sin vueltas: una tabla de mediciones con el hardware identificado es algo que la próxima persona puede ejecutar y discutir; una frase suelta, no. Más contexto en errores de configuración que tumban servicios.

¿Cuál fue la constante errada y cómo se midió el umbral real?

La constante era kSlotLockMinGroups. Valía 1024 y el umbral real medido resultó 131.072: un error de 128x que dejó a todo workload de la banda de 1K a 64K grupos con el kernel equivocado, hasta 3,1x más lento que el código que tenía al lado. El arreglo completo fue este diff:

-constexpr std::size_t kSlotLockMinGroups = 1024;
+constexpr std::size_t kSlotLockMinGroups = 131'072;

Un número. Medido, no adivinado. (Sí, en serio: todo el fix entra en dos líneas.)

Acá viene lo bueno: el dispatcher tenía un override por variable de entorno, GPUDB_METAL_GROUPBY_PATH, que existía para que alguien pudiera preguntarse si la elección automática era correcta. Nadie lo había usado jamás. Con ese switch, el autor corrió los dos kernels cabeza a cabeza en lugar de discutir con el comentario del código.

La carrera, con 10 millones de filas y mediana de 5 corridas:

Gruposradix-optslot-lockGanador
2567,32 ms27,67 msradix-opt
10247,82 ms24,59 msradix-opt (el dispatcher elegía slot-lock)
20487,33 ms23,62 msradix-opt (idem)
81927,90 ms21,13 msradix-opt (idem)
655368,17 ms13,33 msradix-opt (idem)
13107211,24 ms10,32 msslot-lock (crossover real)
52428812,74 ms7,58 msslot-lock

El crossover real apareció cerca de 131.072 grupos. Antes de commitear el valor, el autor verificó que se sostuviera a otras escalas: con 1 millón de filas el punto queda en ~131K y con 50 millones se corre hasta ~196K. Dejar la constante en 131.072 cuesta un ~8% en una sola celda del extremo alto y gana a lo grande en toda la banda problemática. Ese trade no está siquiera discutido. Y encima dejó la tabla de medición en el comentario, justo arriba de la constante, para que el próximo no tenga que re-derivarla.

¿Por qué nadie notó un bug que hacía todo hasta 3,1x más lento?

Porque era inalcanzable. Encima del dispatcher de Metal hay un segundo planner híbrido que decide CPU versus GPU antes de llegar a la elección de kernel, y su regla exige al menos 250.000 grupos esperados para abrir la puerta de la GPU. Hacé la cuenta: 250.000 es mayor que 131.072. Todo lo que llega a Metal ya cae en territorio legítimo de slot-lock, así que el bug interno quedaba enterrado bajo la cautela del planner externo. Tema relacionado: detectar bugs antes del despliegue.

De paso, esas reglas del planner te dan una idea de cuándo conviene una base de datos GPU en la práctica: los mapas chicos que entran enteros en caché se quedan en CPU, donde el hash map gana, y la GPU se reserva para cardinalidades enormes donde su paralelismo paga. La primera configuración que el autor encontró capaz de llegar a Metal fue 1 millón de filas por 1 millón de grupos pedidos, unos 632.357 grupos esperados: cómodo, en pleno territorio slot-lock.

Desde afuera, entonces, todo se veía impecable: ninguna query lenta, ninguna queja, ninguna anomalía de telemetría, porque ninguna query pasaba jamás por ahí, y el error de la heurística interna quedaba tapado del todo por la prudencia de la externa, esperando quieto algún refactor futuro que ensanchara la regla de arriba y le entregara de regalo una regresión de 3x a usuarios reales.

¿Y qué ejercitaba la banda rota, entonces? Solo el benchmark. Lo único que pisaba la región equivocada era ni más ni menos que lo que publica tus números de performance. “Dos heurísticas en serie no componen: se ocultan entre ellas”, escribió el autor, y es la lección más incómoda del caso, bastante peor que el consejo genérico de revisar tus constantes.

¿Cuánto mejoró el rendimiento después de la corrección y a quién le sirve?

La corrección produjo una mejora de 3,0x en el camino del operador y en el benchmark publicado, con los 96 checks unitarios y los 72 tests SQL pasando intactos. Mejor todavía: cada corrida del benchmark verifica los resultados contra una referencia de CPU, así que los tiempos son tiempos correctos, no solo rápidos.

El dispatch automático, antes y después del cambio, con 10 millones de filas:

GruposAntesDespuésSpeedupVs. CPU 1 hilo
102424,59 ms8,13 ms3,02xde 0,60x a 1,81x
204823,62 ms7,85 ms3,01xde 0,64x a 1,90x
819221,13 ms8,17 ms2,59xde 1,30x a 3,44x
6553613,33 ms8,19 ms1,63xde 2,32x a 3,77x

La GPU deja de perder contra el CPU. Ahora, la parte que merece respeto: el autor casi publica esto como “GROUP BY 3x más rápido” y frenó. Para un usuario SQL de hoy, la mejora es 0%, porque ninguna consulta real llega a ese camino. Distinguir un hallazgo de un comunicado de prensa es el tipo de honestidad que escasea en los posts de performance. La constante estuvo mal durante meses y no le costó nada a nadie. Todavía. Una regresión latente de 3x es una regresión con fecha diferida. Ya lo cubrimos antes en probar entornos locales sin arriesgar producción.

Errores comunes al perseguir bugs de rendimiento en GPU

Este caso deja una lista de errores que cualquiera de nosotros comete, ordenados por frecuencia más que por gravedad:

  • Perfilar antes de barrer. El profiler te muestra dónde se gasta el tiempo en un punto, no cómo cambia el comportamiento con el parámetro que maneja tu heurística. Primero el barrido, después las trazas.
  • Filtrar los logs buscando solo errores. Si grepeás “error” en el stderr, te perdés líneas informativas como la elección de kernel que el dispatcher imprime en cada corrida. Leé el log completo al menos una vez.
  • Medir un único punto de cardinalidad. El suite medía throughput en una cardinalidad fija y el bug vivía en cómo cambiaba el comportamiento con esa variable. Ningún benchmark de punto único puede verlo.
  • Escribir comentarios-promesa sin medición. “Le gana al CPU 2,5-4x” no se puede falsificar. Una tabla con hardware y condiciones, sí.
  • Anunciar impacto sin chequear alcanzabilidad. Antes de titular “3x más rápido”, preguntate si algún camino real llega al código corregido. Acá la respuesta era no, y decirlo hace la diferencia entre un hallazgo y un comunicado.

Preguntas Frecuentes

¿Qué fue el bug que hizo que la GPU con Apple Metal fuera más lenta que la CPU?

Una constante del dispatcher, kSlotLockMinGroups, valía 1024 cuando el umbral real medido era 131.072, un error de 128x. Eso hacía elegir el kernel slot-lock para workloads de 1K a 64K grupos donde radix-opt era hasta 3,1x más rápido. La corrección completa fue cambiar ese entero, y rindió una mejora de 3,0x en la banda afectada.

¿Por qué la extensión de DuckDB perdió contra la CPU con 1024 grupos?

Con 1024 grupos, el kernel slot-lock reparte el trabajo en 4096 particiones hash fijas dimensionadas para millones de grupos, así que todas colisionan en un puñado de slots y miles de hilos se serializan detrás de locks. El resultado fue 25,39 ms de la GPU contra 14,65 ms de un único hilo de CPU con 10 millones de filas.

¿Cuál es la diferencia entre radix-opt y slot-lock en Metal?

Radix-opt es el kernel para cardinalidades bajas de grupos, mientras que slot-lock usa 4096 particiones con tablas de slots por threadgroup, pensado para millones de grupos. Slot-lock le gana al CPU por 2,5x a 4x recién desde unos 131 mil grupos, y un dispatcher elige entre ambos comparando los grupos esperados contra esa frontera, que estaba mal calibrada.

¿Cómo se debuggea un cliff abrupto en un benchmark?

Barrendo el parámetro que maneja la heurística antes de perfilar: un salto brusco, como el de 6,50 ms a 25,09 ms entre 256 y 1024 grupos, delata un branch o un cambio de dispatcher, mientras que una pendiente indica degradación del algoritmo. Después conviene leer los logs completos y, si existe un override por variable de entorno, correr ambos caminos y compararlos.

¿La corrección aceleró las consultas SQL reales?

No: hoy el impacto para usuarios SQL es 0%, porque el planner externo nunca enruta hacia la GPU consultas con menos de 250.000 grupos esperados. La mejora de 3x aplica al camino del operador y al benchmark publicado. Se corrigió igual porque era una regresión latente que un futuro refactor del planner podía activar de un día para el otro.

Conclusión

Un entero mal calibrado le hizo perder a una GPU contra un hilo de CPU durante meses, y ningún usuario lo sufrió porque otra heurística lo tapaba. Esa combinación resume por qué el caso vale más que una anécdota de debugging: muestra que las decisiones apiladas fallan en silencio, que los benchmarks de punto único no ven cliffs y que un comentario sin medición es una opinión disfrazada de documentación.

Si mantenés código sensible a performance, el playbook concreto queda así: barre la cardinalidad u otro parámetro clave antes de abrir el profiler, leé el stderr completo aunque duela, usá los overrides de entorno para correr carreras entre caminos, dejá tablas de medición en los comentarios y preguntate si el bug es alcanzable antes de anunciar impacto. Y si tu heurística depende de un parámetro, benchmarkeá ese parámetro. Suena obvio escrito así; nadie lo había hecho en este proyecto hasta que un número imposible lo delató.

Fuentes

Te puede interesar...