Cuándo Compensa la GPU: El Roofline del Sweep de Parámetros, Donde un Titular de 167x Es en Realidad 27x de Algoritmo por 6.2x de Hardware
Parte de la serie "Backtests sin ilusiones".
La escalera de velocidad terminó con una nota deliberadamente insatisfactoria. Habíamos llevado un sweep de parámetros de 80 combos desde 69.9 segundos de pandas hasta 0.23 segundos de numba paralelo en la CPU de un laptop — un 298x medido — y luego argumentamos que la GPU no era el peldaño que faltaba. La sección de comentarios no lo aceptó en silencio, y hacía bien en no hacerlo. "No es el peldaño que falta" es una afirmación sobre una carga de trabajo, a un tamaño. No es una ley de la naturaleza. Así que este artículo hace lo honesto y pone la GPU en el banco de pruebas.
El resultado no es un veredicto, es una curva. La ventaja de la GPU sobre la CPU no es un número único que puedas imprimir en una diapositiva; es una función de cuánto trabajo le entregas por llamada. En nuestro precómputo de indicadores multi-timeframe, el speedup de la GPU sobre el motor de CPU va desde 54.5x cuando le pedimos una combinación de parámetros a la vez hasta 359.6x cuando le pedimos 61 de una sola vez. Mismo kernel, mismos datos, mismo hardware — lo único que cambió es el batch. Un benchmark que reporta uno de esos números y esconde el otro no está midiendo la GPU, está eligiendo un titular.
Y ni siquiera el 359.6x es lo que parece. Desármalo y un gran titular multi-timeframe de 167x se descompone en 27x de algoritmo — una reescritura que también hace a la CPU 27x más rápida — por 6.2x de hardware real. El 27x no lo hizo la GPU. Lo hizo la matemática. Este artículo trata de distinguir esas dos cosas, porque confundirlas es la manera en que se termina vendiendo una tarjeta gráfica de $2,000 para resolver un problema que un git commit habría resuelto gratis.
Procedencia: todos los números de abajo están medidos en un Apple M2 Max, fp32 en la GPU Metal vía MLX contra fp64 con numba sobre doce hilos de CPU, con nuestros scripts de benchmark engine_multitf_gpu.py y bench_param_sweep.py, cada uno protegido por un chequeo de equivalencia que confirma que las rutas de GPU y CPU producen los mismos trades. Este no tiene paper complementario público — los scripts son el registro.
La pregunta es un roofline, no un benchmark

La razón por la que "cuánto más rápida es la GPU" no tiene una única respuesta es el roofline model (Williams, Waterman & Patterson, 2009). El rendimiento alcanzable de un procesador está limitado por dos techos a la vez: uno plano fijado por el cómputo pico (FLOP/s), y uno inclinado fijado por el ancho de banda de memoria multiplicado por la intensidad aritmética — el número de operaciones útiles que haces por cada byte que mueves. El trabajo barato por byte vive bajo el techo inclinado y está limitado por el ancho de banda; el trabajo rico por byte alcanza el techo plano y está limitado por el cómputo. El techo plano de la GPU se alza muy por encima del de la CPU, pero su techo inclinado y, de forma crucial, su costo fijo por llamada no escalan hacia abajo con la misma elegancia.
Para un sweep de parámetros, un tercer término domina el lado izquierdo del gráfico: el overhead de lanzamiento y transferencia. Cada llamada a la GPU paga un precio aproximadamente fijo — despachar el kernel, mover las entradas a través de la frontera de memoria (unificada en Apple Silicon, pero no gratuita), mover los resultados de vuelta. Modela el wall time de un batch de combinaciones como
donde es ese overhead fijo, es el costo marginal de la GPU por combo y el de la CPU. El speedup es entonces
Esta única fracción explica el artículo entero. Con pequeño, la del denominador aplasta la razón — pagaste por despertar a la GPU y apenas la usaste. A medida que crece, amortizas entre más combos y el speedup sube hacia su asíntota , la razón de hardware verdadera. El punto medio está en : un "punto de cresta" en el espacio de batch que te dice cuán ancho debe ser tu sweep antes de que la GPU sea siquiera la mitad de lo que puede llegar a ser. Un sweep de unas pocas docenas de combos queda muy a la izquierda de esa cresta. Un sweep de miles se asienta en la parte plana, donde la GPU por fin se gana su techo.
Así que la pregunta correcta nunca es "si la GPU es más rápida". Es "si mi sweep está a la derecha de la cresta, y si mi trabajo por combo está lo bastante limitado por cómputo como para alcanzar el techo plano cuando llegue ahí". Ambas cosas tienen que ser ciertas. El resto de este artículo mide dónde caen realmente esos umbrales.
El veredicto single-timeframe: la GPU gana por poco

Empieza con la carga de trabajo que usó la escalera de velocidad: un sweep single-timeframe de HMA/HMA3, 80 combinaciones sobre 150,000 barras. Añadimos un sexto peldaño a esa escalera — M5, indicadores en la GPU de Apple vía MLX, con los trades todavía extraídos en la CPU. Con warm-up hecho, mejor de tres, y el gate de equivalencia en verde:
| Método | Tiempo | vs pandas | Combos/s |
|---|---|---|---|
| M0 pandas + loop | 287.08s | 1.0x | 0.3 |
| M2 numba (1 núcleo) | 2.02s | 142x | 39.7 |
| M4 mp + numba (12 núcleos) | 0.33s | 883x | 245.9 |
| M5 MLX GPU (fp32) | 0.10s | 2796x | 779.2 |
Contra la baseline serial naive, la GPU parece heroica — 2,796x. Pero esa es una comparación que nadie honesto debería hacer: enfrenta una buena implementación de GPU contra la peor implementación de CPU. Alinea la GPU contra la CPU que realmente desplegarías — el mismo kernel en los doce núcleos, M4 — y la victoria se desploma a un sobrio 3.2x (779 contra 246 combos por segundo). Una tarjeta gráfica entera, corriendo el sweep completo, le gana a un pool de CPU de doce núcleos por un factor de tres.
Tres punto dos no es poca cosa. Tampoco es la razón por la que alguien compra una GPU. Y es exactamente lo que el roofline predice para un sweep así de estrecho: 80 combos están a la izquierda de la cresta. El overhead fijo de lanzamiento y transferencia sigue siendo una tajada significativa de un trabajo de 0.10 segundos, así que nunca alcanzamos la asíntota . Peor aún, parte del costo por combo es la pasada O(n) de extracción de trades, que dejamos deliberadamente en la CPU — un término que la GPU no puede acelerar en absoluto (más sobre el porqué en la siguiente sección). Para un loop de investigación single-timeframe de este tamaño, el veredicto original de la escalera de velocidad se mantiene: la GPU no es el peldaño que falta. El numba paralelo ya te dejaba en 0.23–0.33 segundos, y recortar eso a 0.10 no es lo que desbloquea a un investigador. Lo que lo desbloquea es la orquestación alrededor del sweep.
La palabra interesante de ese veredicto es este tamaño. Muévete a la derecha por el eje del batch y la historia cambia.
Dónde vive realmente el costo
Antes de escalar el batch, mira qué es lo que realmente estamos pagando, porque el roofline solo te premia si la parte cara está limitada por cómputo. Perfila el sweep y casi todo es una sola cosa: convoluciones de medias móviles ponderadas. Una HMA son tres WMA; una HMA3 son cuatro; cada combinación las vuelve a ejecutar sobre toda la serie. La extracción de trades — recorrer los dos arrays de indicadores, encontrar los cambios de signo de hma - hma3, registrar los fills — es una única pasada O(n) barata. El sweep es una carga de trabajo de convoluciones con disfraz de estrategia de trading.
Esa división corresponde exactamente a los dos regímenes del roofline:
- Las convoluciones están limitadas por cómputo. Cada precio se lee en muchas sumas de ventana superpuestas, así que la intensidad aritmética — operaciones por byte movido — es alta. Este trabajo apunta al techo plano de cómputo, y el techo plano de la GPU es el que se alza sobre todo lo demás. Mejor todavía, las ventanas se superponen entre combinaciones: una WMA de longitud 40 le sirve a docenas de combos, así que un precómputo en batch la comparte una vez en lugar de recalcularla por combo. El batching no solo amortiza el overhead de lanzamiento; eleva la intensidad aritmética al reutilizar las lecturas. Esta es la parte que pertenece a la GPU.
- La extracción de trades está limitada por ancho de banda y es ramificada. Una pasada secuencial, ramificaciones dependientes de los datos en cada cruce, esencialmente cero reutilización. Su intensidad aritmética está cerca del piso, y su flujo de control es hostil para un dispositivo SIMD. Empujarla a la GPU compraría poco y costaría mucho; se queda en la CPU. Lo que significa que es la cola serial de una división según la ley de Amdahl — un piso fijo que el speedup de la GPU nunca puede perforar, y parte de la razón por la que el número single-TF se saturó en 3.2x.
Hay una segunda lección, más afilada, escondida en la versión multi-timeframe de este kernel, y es la fuente del 27x que venimos prometiendo explicar. El motor multi-TF alinea una HMA de un timeframe superior sobre el índice base de 1 minuto sin look-ahead. Escrito de la manera obvia, eso es trabajo por barra de O(length^1.5) — recalcular las medias móviles del timeframe superior en cada barra base. Pero la HMA alineada es lineal en un buffer corto de las últimas velas cerradas del timeframe superior más el cierre en curso, así que todo el cómputo por barra colapsa en un vector de pesos fijo: un conv1d sobre la serie de velas cerradas seguido de un gather O(n). Cientos de millones de operaciones redundantes se convierten en una convolución sobre una serie mucho más corta.
Ese colapso es una victoria algorítmica, no de hardware. Es una fórmula mejor. Corre en la GPU, y corre igual de bien en la CPU — np.correlate más un gather, en fp64. Ten esto muy presente: el factor más grande del titular multi-TF es una reescritura disponible para una máquina sin GPU alguna. Cuando finalmente descompongamos el 167x, este es el 27x.
La ventaja crece con el tamaño del batch

Ahora la medición que el roofline nos mandó a hacer. Toma el eje caro — el precómputo de la HMA alineada de timeframe superior sobre la serie base de 1 minuto, el flujo de velas más largo que tenemos — y dale a la GPU un número creciente de combinaciones de longitudes por llamada, . La baseline de CPU aquí es el motor de producción honesto: numba con prange en los doce núcleos. Para cada batch medimos ambos y tomamos la razón.
| Batch (combos/llamada) | Speedup de la GPU vs motor CPU de 12 núcleos |
|---|---|
| 1 | 54.5x |
| 2 | 102.5x |
| 4 | 129.5x |
| 8 | 187.4x |
| 16 | 267.4x |
| 32 | 245.0x |
| 61 | 359.6x |
Esto es dibujado con mediciones reales. En la GPU ya va 54.5x por delante — porque esta comparación es contra el motor naive por barra, así que el colapso algorítmico ya viene incluido incluso con un solo combo — pero está lejísimos de su techo: el overhead fijo todavía domina una llamada de un solo combo. Duplica el batch y el speedup casi se duplica a 102.5x; en es 267.4x; en es 359.6x y visiblemente sigue subiendo. La ventaja crece con el tamaño del problema. Esa es la frase más importante que se puede decir sobre GPUs y sweeps de parámetros, y es exactamente lo contrario de cómo suelen citarse los speedups de GPU — como si fueran una propiedad constante del chip.
Dos notas de honestidad, porque esta es la serie Backtests sin ilusiones y una tabla limpia y monótona siempre merece sospecha.
Primero, la caída: marca 245.0x, por debajo de los 267.4x en . Eso no es ruido para esconder bajo la alfombra — es un artefacto de frontera de chunk. Nuestro conv1d empaqueta 32 longitudes en un chunk de kernel, así que llena exactamente un chunk sin holgura, mientras que se derrama en un segundo chunk que resulta saturar mejor el dispositivo. El sentido del roofline es la tendencia, y un dispositivo real tiene escalones de cuantización; reportamos el bamboleo en lugar de elegir los puntos a conveniencia para esquivarlo.
Segundo, y más importante: 54.5x y 359.6x son ambos contra el motor de CPU naive, y ninguno de los dos es la victoria de hardware. Ambos números todavía contienen el colapso algorítmico de 27x. Si cambiaras la baseline de CPU por el algoritmo colapsado corriendo en la CPU — la misma fórmula, fp64, ambos lados optimizados — cada fila se encogería aproximadamente por ese factor. Que es precisamente la descomposición que la siguiente sección hace exacta.
La descomposición honesta: 27x de algoritmo por 6.2x de hardware

Para separar el algoritmo del silicio hay que medir tres rutas sobre la misma grilla multi-timeframe, no dos. Así que el benchmark corre:
- cpu-engine — el motor numba de producción, HMA alineada por barra en todos los núcleos. La baseline naive-pero-paralela.
- cpu-collapsed — los vectores de pesos colapsados,
np.correlatemás gather, fp64, en la CPU. El mismo hardware que (1), mejor algoritmo. - gpu-mlx — los pesos colapsados como un
conv1den batch en la GPU Metal, fp32. El mismo algoritmo que (2), distinto hardware.
Ponlos en fila y el titular multi-timeframe completo se factoriza limpiamente:
El factor izquierdo, 27x, es el algoritmo — el colapso de por-barra a convolución de la sección anterior. No tiene nada que ver con la GPU. Impleméntalo en numpy y la CPU de tu laptop se vuelve 27x más rápida en esta carga de trabajo al precio de un refactor. El factor derecho, 6.2x, es el hardware — la victoria honesta, de igual a igual, de la GPU Metal sobre el mismo algoritmo optimizado en doce núcleos de CPU. Ese 6.2x es la única parte para la que realmente necesitabas una GPU.
Esta es toda la moraleja del artículo expresada como aritmética. Cuando un benchmark de un proveedor, el README de una librería o un colega entusiasmado te muestre "167x en GPU", el reflejo debería ser una sola pregunta: ¿cuál era la baseline de CPU? Si la baseline era la implementación naive — y casi siempre lo es, porque una baseline lenta hace una mejor diapositiva — entonces la mayor parte del titular es una victoria algorítmica a la que la CPU también tenía derecho, y solo el residuo es hardware. Aquí el residuo es 6.2x. Una cita de 167x exagera la contribución del hardware unas 27 veces.
Y fíjate cómo el propio factor de hardware se movió con el tamaño del problema. En el sweep single-timeframe pequeño, la victoria real de GPU-contra-mejor-CPU fue 3.2x. En el precómputo multi-timeframe, más grande, fue 6.2x — los mismos dos chips, casi el doble de ventaja de hardware, puramente porque la carga de trabajo más grande empuja más arriba por el roofline hacia el techo plano de cómputo de la GPU antes de que la CPU pueda seguirle el paso. La ventaja de hardware tampoco es una constante. Es un punto sobre la misma curva ascendente, y la manera de moverte hacia la derecha en esa curva es hacer el batch más grande y el trabajo por combo más rico.
Una guía de decisión: ¿cuán ancho debe ser el sweep?

Pliega el roofline de vuelta en una decisión que puedas tomar antes de gastar dinero. La GPU compensa cuando las dos condiciones del roofline se cumplen a la vez: tu sweep está a la derecha de la cresta de batch (, de modo que el overhead fijo de lanzamiento y transferencia queda amortizado), y tu trabajo por combo está limitado por cómputo (intensidad aritmética lo bastante rica para alcanzar el techo plano, no una pasada O(n) delgada). En concreto, a partir de lo que medimos:
- Unas pocas docenas de combos de una estrategia single-timeframe: sáltate la GPU. Estás a la izquierda de la cresta; la victoria honesta sobre numba paralelo es ~3.2x en un trabajo que ya toma una décima de segundo. El cuello de botella no es el kernel, es todo lo que lo rodea.
- Miles de combos, o un precómputo genuinamente multi-timeframe / multi-indicador: la GPU se gana su lugar. El overhead se amortiza, las convoluciones compartidas elevan la intensidad aritmética, y la victoria de hardware sube a 6.2x y sigue creciendo con el batch. Este es el régimen donde una GPU convierte un sweep que corría toda la noche en una pausa para el café.
- Sube primero la escalera de la CPU — es más barata y va primero. El 298x en la CPU y el colapso algorítmico de 27x son gratis o casi, y son prerrequisitos, no alternativas: el 6.2x de la GPU va encima del algoritmo colapsado, que de todos modos tenías que escribir. Una GPU atornillada a un pipeline naive mide sobre todo la ingenuidad.
También hay un impuesto en la rama de la GPU que no tiene nada que ver con la velocidad, y debes incluirlo en el precio: la GPU Metal de Apple no tiene fp64 en absoluto. Todo corre en fp32, ~1.2e-7 de precisión relativa. Eso mata el truco de libro de texto para medias móviles rápidas — la WMA O(n) por suma de prefijos — porque a una escala de precios cercana a 30,000 sobre 150k barras las sumas acumuladas alcanzan ~1e14, siete órdenes de magnitud más allá del rango entero seguro de fp32; medimos errores relativos de hasta ~2e2 (un factor de doscientos, no un dos por ciento). La formulación que funciona es la convolución directa por ventanas, donde cada suma de ventana es un número acotado de términos de tamaño comparable y fp32 se mantiene preciso hasta ~8e-7. Incluso así, una estrategia que decide según el signo de hma - hma3 ocasionalmente volteará un cruce en una barra límite donde las dos curvas casi se tocan, porque el redondeo de fp32 inclina un casi-empate. Por eso la ruta de GPU se entrega con un gate de equivalencia que mide cuánto divergieron los trades — delta de PnL en puntos básicos, desplazamiento relativo en el conteo de trades — en lugar de afirmar una salida bit a bit idéntica que nunca podrá entregar. En nuestra corrida esa divergencia fue de 90 fills desplazados sobre 479,016 (0.019%), bien dentro de la tolerancia, pero la carga es real: irse a la GPU significa hacerse cargo de una historia de paridad numérica, no solo de un reloj más rápido. Ese costo de ingeniería también es parte del punto de equilibrio.
Los números tienen forma de Apple; la curva no
Cada cifra de arriba es un Apple M2 Max: un dispositivo de memoria unificada donde la GPU y la CPU comparten un mismo pool, y una GPU solo-fp32 sin doble precisión. Una tarjeta discreta NVIDIA o AMD cambia las constantes, y vale la pena ser explícito sobre hacia dónde se mueve cada una, porque la forma del argumento sobrevive aunque los números no.
- El overhead de transferencia se pone peor, no mejor. Una tarjeta discreta vive detrás de PCIe, así que las entradas y los resultados hacen una copia real a través del bus que la memoria unificada evita. Eso empuja la cresta de batch hacia la derecha — necesitas un sweep todavía más ancho antes de que una GPU discreta amortice su lanzamiento. El borde izquierdo del roofline es más empinado en un dispositivo PCIe, no más suave.
- El techo plano se pone más alto. Una GPU de centro de datos tiene muchos más FLOP/s y ancho de banda que una integrada, así que la victoria de hardware asintótica en un sweep que satura es mayor que nuestro 6.2x. La recompensa por llegar al lado derecho de la curva crece; el peaje por quedarse sentado en el izquierdo también.
- fp64 regresa, y con él el truco de la suma de prefijos. En una tarjeta con doble precisión real, la WMA O(n) por suma de prefijos vuelve a ser viable y el gate de paridad puede apretarse hacia la exactitud bit a bit. El impuesto fp32 específico que pagamos — convolución directa en lugar de sumas de prefijos, un gate que mide divergencia en lugar de un assert — es un detalle de Apple Silicon, no una ley.
Nada de esto cambia la tesis. En cualquier dispositivo, : un overhead fijo que debes amortizar, una asíntota a la que solo te acercas desde la derecha. Las constantes son hardware; la curva es aritmética. Mide tus propios , y con un barrido de batch de cinco líneas antes de confiar en el titular de nadie — incluido el nuestro.
Dónde conecta esto
Esta es la cuarta medición de una pequeña subserie sobre de dónde sale realmente la velocidad de un backtest, y las piezas encajan como un único argumento sobre qué optimizar antes de gastar:
- La escalera de velocidad subió de pandas a numba paralelo para un 298x solo en la CPU, y dejó la GPU como pregunta abierta. Este artículo la responde: la GPU es un quinto peldaño real pero condicional, que vale 3.2x–6.2x sobre el peldaño de CPU más alto, y solo una vez que el sweep es lo bastante ancho para trepar el roofline.
- El impuesto IPC hizo el mismo movimiento en la otra dirección — midiendo lo que cuesta salir del proceso — y llegó a la misma forma de conclusión: la frontera (un socket, un lanzamiento de GPU) es barata; el impuesto está en con qué frecuencia y con cuánta cháchara la cruzas. Agrupa tus llamadas a la GPU en batches por la misma razón por la que agrupas tu IPC: para amortizar un costo fijo por cruce.
- La caché agregada de parquet es la versión del lado CPU de lo que hace el precómputo en GPU — computar los indicadores compartidos una vez y reutilizarlos en cada combo. La GPU solo lleva ese principio de reutilizar-y-agrupar hasta el silicio.
- Y el gate de paridad fp32 es un problema de paridad backtest-live en miniatura: en el momento en que tu ruta rápida computa algo ligeramente distinto de tu referencia, debes una explicación cuantificada de la divergencia, no un gesto vago con la mano.
La disciplina que lo conecta todo es la misma que empuja esta serie entera: mide la cosa que realmente te están vendiendo. Un speedup es una razón, y una razón tiene numerador y denominador. La mayor parte de la decepción con las GPU viene de un denominador — la baseline de CPU — elegido para halagar, y la mayor parte del desperdicio con las GPU viene de correr un sweep demasiado pequeño para salir del borde izquierdo del roofline.
Puntos clave
- Un speedup de GPU es una curva, no un número. En nuestro precómputo multi-timeframe la ventaja sobre la CPU fue desde 54.5x con un combo por llamada hasta 359.6x con sesenta y uno — mismo chip, mismos datos. Cualquier cifra única es un punto de esa curva; pregunta a qué tamaño de batch se midió.
- Interroga siempre la baseline de CPU. El titular multi-timeframe de 167x se factoriza limpiamente en 27x de algoritmo (un colapso de por-barra a convolución, que acelera a la CPU exactamente igual) por 6.2x de hardware real. La victoria justa de GPU-contra-mejor-CPU aquí es 3.2x en single-timeframe, 6.2x en multi — no 167x.
- La ventaja crece con el tamaño del problema, y el factor de hardware también. Un batch más grande y un trabajo por combo más rico te empujan hacia arriba por el roofline: la propia victoria honesta de hardware subió de 3.2x a 6.2x puramente por hacer la carga de trabajo más grande. Los sweeps pequeños quedan a la izquierda de la cresta y apenas se benefician.
- Arregla el algoritmo y sube primero la escalera de la CPU — la victoria de la GPU se apoya encima de ellos, no los reemplaza. El 6.2x está medido contra el algoritmo colapsado que de todos modos tenías que escribir. Atornilla una GPU a un pipeline naive y la mayor parte de lo que mides es la ingenuidad, no el silicio.
- Irse a la GPU significa hacerse cargo de una historia de paridad numérica. No hay fp64 en Metal, el truco de la WMA por suma de prefijos muere a escala de precios (error relativo ~2e2), y las estrategias de signo-del-cruce se voltean en barras límite. Entrega un gate de equivalencia que cuantifique la divergencia en puntos básicos; cuenta ese costo de ingeniería en tu punto de equilibrio.
Cuando alguien te dice que la GPU hizo su backtest cien veces más rápido, no te ha dicho casi nada. Pregúntale el tamaño del batch y la baseline de CPU, y el cien suele resolverse en una victoria de hardware de un solo dígito envuelta alrededor de una algorítmica que podría haber tenido gratis — que vale la pena tener, en un sweep lo bastante grande, exactamente por las razones que dice el roofline y ni una más.
Authors
Trading-systems engineer
Trading-systems engineer building bots since 2017: cross-exchange arbitrage (connected up to 30 venues), cointegration-based pairs arbitrage across spot and futures, scalping, news and sentiment-driven strategies, trend algorithms, and portfolio management and balancing algorithms. Also builds sub-millisecond order execution, big-data warehouses, backtesting engines, AI agents, and trading interfaces (incl. open-source profitmaker.cc). Stack: JS/TS, Python, Rust/Zig/Go, DevOps, backend, frontend, architecture.