La escalera de velocidad del backtest: 298x en la CPU de un portátil, PnL idéntico hasta la última operación
Parte de la serie "Backtests sin ilusiones".
📄 Este artículo se convirtió en un paper de investigación. Un kernel de backtest dependiente de la trayectoria se implementa de cinco maneras — desde pandas ingenuo hasta un kernel numba paralelo — con cada peldaño verificado para producir el mismo PnL por combinación, de modo que lo único que difiere es la velocidad. Lee el paper en línea (versión interactiva + PDF) en speed-ladder.marketmaker.cc, código y datos en github.com/suenot/backtest-speed-ladder.
Setenta segundos. Eso es lo que tarda la implementación de referencia ingenua en barrer 80 combinaciones de parámetros de una estrategia de media móvil sobre 150.000 barras: pandas rolling().apply() para los indicadores, un bucle Python plano para las operaciones. Es el perfil que sigue una enorme fracción del código de investigación del mundo real, porque es el perfil que resulta de escribir la estrategia de la forma obvia.
El mismo barrido, en el mismo portátil, produciendo el mismo PnL para cada combinación hasta la última operación: 0,23 segundos.
La brecha entre esas dos cifras — un medido 298x — es el tema de este artículo. Ni un solo punto porcentual proviene de hardware nuevo. No hubo ninguna GPU involucrada (ninguna está siquiera disponible en esta máquina en el sentido CUDA). Cada peldaño de la escalera es la misma estrategia, los mismos datos, las mismas comisiones, el mismo número de operaciones, verificado por una comprobación de equivalencia que hace fallar todo el benchmark si los resultados por combinación de cualquier implementación divergen. Lo que cambió es solo cómo se expresa el trabajo: qué corre en el intérprete, qué corre compilado y qué corre en paralelo. Y como una base deliberadamente lenta puede halagar cualquier titular numérico, una cifra más por adelantado: incluso frente a una implementación numpy vectorizada competente — el código que enviaría un buen programador de numpy — el motor terminado sigue siendo unas 13x más rápido.
Cuando una búsqueda de parámetros es lenta, el reflejo es recurrir a hardware más grande — una GPU, un clúster, un presupuesto en la nube. La realidad medida de este experimento apunta a algo mucho menos glamuroso: el cuello de botella era el motor (un bucle interno interpretado que hace llamadas Python por ventana) y la orquestación (ejecutar combinaciones independientes en serie en un solo núcleo). Ambos se pueden arreglar en una tarde, en la máquina que ya tienes, sin ningún cambio en los resultados.
Aquí está toda la escalera por adelantado. Todo lo que sigue es la anatomía de cada paso.
| Peldaño | Implementación | Tiempo real | Aceleración | Combos/s |
|---|---|---|---|---|
| M0 | pandas: rolling.apply + bucle Python por barra |
69.92 s | 1.0x | 1.1 |
| M1 | numpy: WMA de ventana deslizante + operaciones vectorizadas | 3.07 s | 22.7x | 26.0 |
| M2 | numba: WMA con @njit + bucle de eventos con @njit |
1.98 s | 35.3x | 40.4 |
| M3 | numba prange: hilos entre combinaciones |
0.32 s | 217.6x | 248.9 |
| M4 | pool de procesos + numba: procesos entre combinaciones | 0.23 s | 297.9x | 340.9 |
Apple M2 Max (12 núcleos), Python 3.14.6, numpy 2.4.3, numba 0.64.0, BLAS (Accelerate) fijado a un solo hilo para que los peldaños de un solo hilo sean genuinamente de un solo núcleo. 150.000 barras × 80 combinaciones, mejor de 3 tiempos reales, calentamiento de JIT excluido. Todos los peldaños — incluida la base pandas — cronometrados por completo y verificados para producir PnL y número de operaciones idénticos por combinación en las 80 combinaciones.
Un kernel, cinco implementaciones

Para que una comparación de velocidad signifique algo, lo que se está calculando debe fijarse con exactitud, y cada implementación debe demostrar que lo calcula. Así que el experimento fija un kernel de estrategia y lo mantiene constante en los cinco peldaños.
El kernel es un cruce HMA/HMA3 — un sistema de detener y revertir sobre dos medias móviles estilo Hull. El bloque de construcción es la media móvil ponderada:
La Hull Moving Average compone tres de ellas para reducir el retraso:
y HMA3 es una hermana más suave construida a partir de WMAs en torno a , y , suavizada una vez más. Por combinación de parámetros eso son siete pasadas de WMA sobre seis longitudes de ventana distintas — una pila de indicadores real, no un juguete.
La regla de trading es deliberada y útilmente con estado: la dirección es larga cuando HMA está por debajo de HMA3 y corta en caso contrario; abre una posición en la primera dirección definida; en cada cruce, cierra la posición, registra el PnL menos una comisión de ida y vuelta del 0,09%, y revierte. La posición se lleva de una barra a otra — lo que haces en la barra depende del estado acumulado desde el último cruce. Esta dependencia de la trayectoria es el punto central del experimento: es la propiedad que hace que los backtests sean diferentes de los pipelines genéricos de dataframes, y (como mediremos) complica la cuestión de la GPU — aunque no, resulta, de la forma en que dice el folclore.
El resto de la configuración, para que puedas juzgar las cifras:
- Datos: 150.000 barras de movimiento browniano geométrico sintético, con semilla (
seed=42). El rendimiento aquí está limitado por el tamaño del array y las longitudes de ventana, no por qué serie de precios le des — y una serie sintética hace que todo el experimento sea determinista y reproducible por cualquiera. - Grilla: 80 longitudes HMA distintas repartidas sobre — de modo que el barrido contiene tanto combinaciones baratas de ventana corta como caras de ventana larga, como lo hace una grilla real.
- Cronometraje: tiempo real, mejor de 3 por peldaño, con la compilación JIT calentada fuera del cronómetro y los workers del pool calentados antes de que arranque el reloj. Cada peldaño — incluida la base pandas — se cronometra por completo en las 80 combinaciones. BLAS (Accelerate de Apple) está fijado a un solo hilo, así que los peldaños de un solo hilo son genuinamente de un solo núcleo: el peldaño numpy no está multihilando silenciosamente sus matvecs a espaldas de la comparación.
- Comprobación de equivalencia: después del cronometraje, el vector (PnL, número de operaciones) por combinación de cada peldaño se compara con la referencia — el número de operaciones debe coincidir exactamente, el PnL con una tolerancia absoluta de puntos porcentuales. La ejecución registrada reporta
all_ok: truepara cada peldaño, incluida la base pandas, en las 80 combinaciones. Si esta comprobación falla, no hay benchmark — hay simplemente cinco programas calculando cinco cosas distintas a cinco velocidades distintas, que es cómo funcionan silenciosamente muchas afirmaciones de "nuestro motor es 100x más rápido".
Una cifra del bloque de equivalencia merece un momento de honestidad: la huella dactilar de la primera combinación es un PnL de −5165,58 puntos porcentuales en 57.029 operaciones. Ese no es un resultado de estrategia del que avergonzarse — es la longitud HMA más corta (6) volteándose en casi cada oscilación de un paseo aleatorio y pagando 0,09% cada vez, exactamente como debería. Es una huella de corrección, no un backtest negociable. No leas alfa en ella; lee determinismo en ella — cinco implementaciones aterrizando en las mismas 57.029 operaciones y el mismo PnL hasta seis decimales es lo que significa "idéntico" aquí.
Con eso establecido, cada aceleración de abajo es velocidad pura. Nada se aproximó fuera.
Peldaño M0: el perfil pandas ingenuo — 69,9 s

La base no es un hombre de paja. Es el código que obtienes al escribir una WMA como sugiere la documentación de pandas y el bucle de eventos como lo describe la estrategia:
def pd_wma(s: pd.Series, period: int) -> np.ndarray:
w = np.arange(1, period + 1, dtype=np.float64)
w /= w.sum()
return s.rolling(period).apply(lambda x: np.dot(x, w), raw=True).to_numpy()
def run_pandas_one(close, length):
h, h3 = pd_hma(close, length), pd_hma3(close, length) # 7 rolling.apply WMAs
total, ntr, prev_dir, entry, pos = 0.0, 0, 0, 0.0, 0
for i in range(len(close)): # Python bar loop
if np.isnan(h[i]) or np.isnan(h3[i]):
continue
d = 1 if h[i] < h3[i] else -1
if prev_dir == 0:
prev_dir, pos, entry = d, d, close[i]
continue
if d != prev_dir: # cross: close + reverse
pnl = ((close[i] - entry) if pos == 1
else (entry - close[i])) / entry * 100 - FEE
total += pnl
ntr += 1
pos, entry, prev_dir = d, close[i], d
return total, ntr
¿Por qué es lento esto? No porque pandas sea "malo" — por dónde vive la iteración. rolling(period).apply(lambda ...) es un bucle a nivel de Python vestido de disfraz vectorizado. Para cada una de las 150.000 barras, pandas materializa una ventana, cruza la frontera C/Python, invoca un llamable Python y empaqueta el resultado. Incluso con raw=True (que al menos entrega a la lambda un ndarray puro en lugar de una Series), la sobrecarga del intérprete por llamada eclipsa las decenas o cientos de FLOPs que la ventana realmente necesita. Multiplica por siete pasadas de WMA por combinación, y solo la pila de indicadores son millones de idas y vueltas del intérprete. Luego el bucle de barras corre otras 150.000 iteraciones interpretadas por combinación, cada una haciendo indexación con verificación de límites sobre escalares numpy, empaquetando floats y despachando dinámicamente sobre tipos que el intérprete redescubre cada vez.
El resultado: 69,92 s para el barrido, unos 0,87 s por combinación, un rendimiento de 1,1 combinaciones por segundo. En una grilla de 80 combinaciones te encoges de hombros y esperas un minuto. El problema es que nadie ejecuta grillas de 80 combinaciones por mucho tiempo — y este costo escala linealmente para siempre. Volveremos a eso.
Peldaño M1: numpy — deja de llamar a Python en un bucle — 3,07 s, 22,7x
El primer peldaño hacia arriba elimina ambos bucles del intérprete de una vez, y vale la pena separar los dos trucos porque tienen generalidades muy diferentes.
El lado del indicador es el fácil y totalmente general. Una media móvil ponderada sobre todas las ventanas es solo un producto matriz-vector contra una vista con strides de la entrada — sin copias, una llamada a BLAS:
def vec_wma(x: np.ndarray, period: int) -> np.ndarray:
w = np.arange(1, period + 1, dtype=np.float64)
win = np.lib.stride_tricks.sliding_window_view(x, period) # zero-copy view
out = np.full(len(x), np.nan)
out[period - 1:] = win @ w / w.sum() # one matvec
return out
sliding_window_view construye una vista de (n − p + 1, p) de la misma memoria, y win @ w calcula el producto punto de cada ventana en código compilado. El millón de invocaciones de lambda se convierte en una llamada a biblioteca.
El lado de las operaciones es el interesante, porque el bucle de eventos tiene estado — y aun así, para este kernel, se vectoriza. La idea es que la posición en cualquier barra depende solo del signo de HMA − HMA3, no de ningún resultado de operación. El estado nunca retroalimenta las decisiones. Así que todo el bucle colapsa en "encontrar los cambios de signo, recoger precios en esos índices":
d = np.where(h[idx] < h3[idx], 1, -1) # direction per valid bar
flips = np.flatnonzero(np.diff(d) != 0) + 1 # bars where it crosses
cross = idx[np.concatenate(([0], flips))] # entry/exit indices
side = d[np.concatenate(([0], flips))]
entries, exits, s = close[cross[:-1]], close[cross[1:]], side[:-1]
pnl = np.where(s == 1, (exits - entries) / entries,
(entries - exits) / entries) * 100 - FEE
return float(pnl.sum()), int(pnl.size)
3,07 s, una aceleración de 22,7x, 26,0 combinaciones por segundo — en un solo núcleo, con BLAS fijado a un solo hilo. Este peldaño merece una etiqueta: es la base competente, la implementación que un buen programador de numpy enviaría, y la vara de medir justa para todo lo que está por encima. Pero dos advertencias honestas viajan con este peldaño.
Primero, esta vectorización es una reescritura analítica específica de la estrategia, no una transformación mecánica. Existe porque el kernel es de detener y revertir sin stops, sin salidas con trailing, sin dimensionamiento de posición que dependa del PnL acumulado. Añade un stop-loss — la característica más ordinaria imaginable — y la salida en la barra cambia qué entrada existe en la barra , el estado retroalimenta la trayectoria y la forma cerrada se evapora. La mayoría de los kernels de producción viven en el lado equivocado de esa línea.
Segundo, este es el peldaño donde la corrección va a morir. La contabilidad de índices de cambio (+1 aquí, [:-1] allá, la siembra de la primera dirección) es exactamente el tipo de código que produce bugs de ejecución de tipo off-by-one — la misma especie de bug que nuestra taxonomía de look-ahead mostró que puede fabricar un Sharpe de 15 a partir de ruido. La comprobación de equivalencia no es una formalidad en este peldaño; es la única razón para confiar en él. Reescrituras vectorizadas ingeniosas sin una comprobación de equivalencia frente a una implementación de referencia tonta son cómo los motores se alejan a la deriva de la estrategia que dicen probar.
Peldaño M2: numba — compila el bucle que realmente quieres escribir — 1,98 s, 35,3x

El peldaño M2 adopta la filosofía opuesta: en lugar de contorsionar el algoritmo para que quepa en primitivas vectorizadas, escribe los bucles ingenuos — y compílalos. Numba (Lam, Pitrou & Seibert, 2015) compila JIT un subconjunto numérico de Python a través de LLVM en código máquina:
@njit(cache=True)
def nb_wma(x, period):
n = x.shape[0]
out = np.full(n, np.nan)
wsum = period * (period + 1) / 2.0
for i in range(period - 1, n): # the "slow" loop, now machine code
s = 0.0
for j in range(period):
s += x[i - period + 1 + j] * (j + 1)
out[i] = s / wsum
return out
@njit(cache=True)
def nb_sweep(close, half, full, sq, p3, p2, pi, fee):
h = nb_wma(2.0 * nb_wma(close, half) - nb_wma(close, full), sq)
a = 3.0 * nb_wma(close, p3) - nb_wma(close, p2) - nb_wma(close, pi)
h3 = nb_wma(a, pi)
El bucle de eventos dentro de nb_sweep es textualmente el bucle de M0. Ramas, continue, estado guardado en variables locales — todo eso. Bajo @njit esas variables locales viven en registros, las ramas son instrucciones de salto reales, y el costo por iteración cae de microsegundos de despacho del intérprete a nanosegundos.
1,98 s — 35,3x sobre pandas, pero solo alrededor de 1,6x sobre numpy (derivado: 3,07/1,98). Ese paso modesto es en sí mismo instructivo: los bucles internos de numpy ya estaban compilados, así que la ganancia de numba en las matemáticas de las features está limitada a saltarse la materialización de ventanas y los arrays intermedios. La parte transformadora está en otro lugar:
- El bucle de eventos ahora es gratis — y "gratis" está medido, no es retórica. M1 gastó su ingenio en hacer que la lógica de operaciones fuera vectorizable. M2 hace innecesario ese ingenio — el bucle ingenuo, auditable y fácil de modificar corre a velocidad de máquina. Cronometrar la etapa de features por separado del bucle de operaciones dentro de este kernel compilado atribuye 99,3% de su tiempo a las matemáticas de la WMA de features y solo 0,7% al bucle de eventos con estado. Puedes añadir un stop-loss mañana sin un proyecto de investigación — y guarda esa división; redecide el argumento de la GPU más abajo.
- Desbloquea los dos peldaños siguientes. Un kernel compilado, que libera el GIL y con poca asignación de memoria es la unidad de trabajo que necesita la orquestación paralela. No puedes paralelizar productivamente M0 — doce copias de lento siguen siendo lentas, solo más calientes.
Una nota metodológica: numba compila en la primera llamada, y esa compilación (cientos de milisegundos) no debe estar dentro del cronómetro — el arnés calienta el JIT en un fragmento de 500 barras antes de medir, y cache=True persiste los kernels compilados entre lanzamientos de proceso. Los benchmarks que "olvidan" este detalle producen números de numba que son injustamente malos (compilación en frío incluida) o irreproducibles.
Peldaño M3: prange — el paralelismo que ya tenías — 0,32 s, 217,6x

Aquí está la observación que hace especial a la búsqueda masiva de parámetros: las 80 combinaciones son completamente independientes. Sin estado compartido, sin orden, sin comunicación. Este es trabajo embarazosamente paralelo que los peldaños M0–M2 estaban ejecutando en un solo núcleo de doce, por pura costumbre.
Numba hace el arreglo casi sintáctico — cambia el range del bucle de combinaciones por prange:
@njit(parallel=True, cache=True)
def nb_sweep_all(close, params, fee):
N = params.shape[0]
totals = np.empty(N, dtype=np.float64)
ntrs = np.empty(N, dtype=np.int64)
for k in prange(N): # threads across combos
t, ntr = nb_sweep(close, params[k, 0], params[k, 1], params[k, 2],
params[k, 3], params[k, 4], params[k, 5], fee)
totals[k] = t
ntrs[k] = ntr
return totals, ntrs
Como nb_sweep está compilado en modo nopython, no retiene el GIL, y la capa de hilos de numba distribuye las iteraciones entre los 12 núcleos. El array close de solo lectura es compartido por todos los hilos a costo cero.
0,32 s — 217,6x sobre pandas, 248,9 combinaciones por segundo. El paso sobre M2 de un solo hilo es de unos 6,2x en 12 núcleos (derivado: 1,98/0,32), y la brecha respecto al "12x ideal" merece honestidad en lugar de ocultarse: los 12 núcleos del M2 Max son 8 de rendimiento + 4 de eficiencia, así que el techo nominal nunca fue 12x; las 80 combinaciones tienen costos enormemente desiguales (una HMA de longitud 6 es mucho más barata que una de longitud 200), así que los hilos terminan de forma desigual; y cada llamada al kernel asigna sus arrays intermedios desde un asignador compartido. Las aceleraciones paralelas en máquinas reales se ven así. Cualquiera que cite un Nx limpio sobre N núcleos para tareas heterogéneas está midiendo algo sintético.
Peldaño M4: un pool de procesos para el último tercio — 0,23 s, 297,9x
El peldaño final reemplaza hilos por procesos — el mismo kernel compilado, orquestado por un ProcessPoolExecutor:
with ProcessPoolExecutor(max_workers=12, initializer=_init_worker,
initargs=(close,)) as ex: # ship data ONCE
list(ex.map(_warmup_worker, range(12 * 3))) # JIT-warm every worker
results = list(ex.map(_run_one_combo, grid, chunksize=1))
0,23 s — 297,9x sobre pandas, 340,9 combinaciones por segundo. Lee ese rendimiento de nuevo: este portátil ahora está ejecutando aproximadamente 340 backtests completos de 150.000 barras por segundo, cada uno calculando siete medias móviles ponderadas y simulando decenas de miles de operaciones con estado.
La ventaja sobre prange es real pero modesta — cerca de 1,4x (derivado: 0,32/0,23) — y la mecánica plausible es la programación y el aislamiento de memoria: con chunksize=1 el pool reparte las combinaciones de una en una, así que la mezcla desigual de ventanas baratas y caras se balancea dinámicamente en los núcleos asimétricos, y cada proceso worker obtiene su propio asignador, evitando la contención en los temporales por combinación. Reportamos esto como mecánica consistente con la medición, no como hechos probados por separado.
Los procesos no son gratis, y el arnés paga sus costos honestamente fuera del cronómetro donde son costos únicos (arranque de workers, enviar close a cada worker vía el inicializador, calentamiento del JIT por worker) — porque en una búsqueda real esos costos se amortizan sobre miles de combinaciones, no ochenta. La guía honesta general: prange es más simple y usualmente suficiente; un pool de procesos gana cuando las tareas son voluminosas, la grilla es grande, o tu trabajo por combinación retiene el GIL en algún lugar que numba no puede alcanzar.
Y con eso, la escalera se factoriza en un resumen limpio. De M0 a M2 — el motor: 35,3x en un solo núcleo, por mover la iteración fuera del intérprete. De M2 a M4 — la orquestación: otro 8,4x (derivado: 1,98/0,23), por usar los núcleos que ya estaban ahí. Multiplicado: 298x. Sin hardware nuevo, resultados idénticos. Y medido desde la base competente M1 en lugar de la ingenua, el motor terminado aún se mantiene unas 13x más alto (derivado: 3,07/0,23) — la escalera no es un artefacto de elegir un punto de partida lento.
Por qué no una GPU — la versión honesta

"Simplemente pórtalo a una GPU" es la respuesta más común a un barrido de parámetros lento, así que este experimento mide los dos números con los que esa conversación debería empezar — y ninguno respalda la versión perezosa de ninguna de las dos respuestas.
El modelo roofline (Williams, Waterman & Patterson, 2009) clasifica un kernel por su intensidad aritmética — FLOPs por byte movido. Para la pila de features WMA en este barrido, contando FLOPs por barra por ventana de longitud contra una lectura de 8 bytes por barra, todo el barrido de 80 combinaciones resulta en unos 6,2 GFLOP sobre 576 MB transmitidos:
(Ese es el conteo idealizado sobre las seis ventanas WMA distintas por combinación; contando las siete pasadas tal como se ejecutan realmente da 11,07 FLOP/byte. Misma conclusión de cualquier forma.)
Ese número importa por lo que descarta: la afirmación popular de que las matemáticas de backtest están "limitadas por memoria, así que las GPUs no pueden ayudar" es falsa aquí. A ~10,8 FLOP/byte las matemáticas de features son decididamente de tipo cómputo — bien pasado el punto de cresta donde el hardware típico deja de estar limitado por ancho de banda. Una GPU absolutamente podría agrupar 80 combinaciones × 7 pasadas WMA en un puñado de kernels grandes y devorar la aritmética. Si la pila de features fuera todo el problema, el caso de la GPU sería respetable.
El segundo número medido mata la otra respuesta perezosa — la que nosotros mismos habríamos elegido. Cronometrar la etapa de features por separado del bucle de operaciones dentro del kernel compilado da una división de 99,3% features, 0,7% bucle de eventos. El argumento tentador — "los backtests tienen un bucle de eventos con estado y ramificado, y eso es lo que bloquea la GPU" — es cuantitativamente incorrecto aquí: la CPU gasta esencialmente todo su tiempo exactamente en la parte que una GPU podría agrupar por lotes. Reformula 80 combinaciones × 7 pasadas WMA como convoluciones grandes en lote y tienes una carga de trabajo tensorial perfectamente razonable. Así que la pregunta honesta no es si el trabajo podría ir a una GPU — la mayor parte podría. La pregunta es si el viaje vale la pena, y para este barrido no vale, por dos razones específicas:
1. El ancho explotable es de 80 combinaciones — y una GPU es una máquina de ancho. El único eje honesto de paralelismo en un barrido de parámetros es la grilla misma: dentro de una combinación, la trayectoria de 150.000 barras es secuencial. Una GPU quiere decenas de miles de ítems de trabajo independientes para llenar sus carriles y ocultar la latencia; este barrido ofrece ochenta. Doce núcleos de CPU ya saturan ese ancho — eso es literalmente lo que midieron los peldaños M3–M4. Para los conteos de combinaciones donde el ancho de una GPU siquiera empezaría a entrar en juego, la escalera de CPU ya entrega cientos de backtests completos por segundo.
2. Todo el trabajo dura 0,23 segundos. A velocidad M4 una combinación cuesta unos 2,9 ms (derivado: 0,23 s / 80). Contra ese presupuesto, las latencias de lanzamiento de kernel y los puntos de sincronización de dispositivo no son errores de redondeo amortizables — son una fracción material del trabajo. (En esta máquina Apple de memoria unificada, la transferencia host-a-device es una preocupación menor; en una caja CUDA de GPU discreta también se suma a la cuenta.) La victoria clásica de la GPU amortiza los costos fijos sobre lotes enormes de trabajo; un barrido de menos de un segundo nunca produce uno.
¿Y el bucle de eventos? Es la única parte que no se agruparía por lotes — secuencial, ramificado, dependiente de la trayectoria, una dependencia acarreada por el bucle de 150.000 barras de longitud que ningún hardware puede paralelizar dentro de una combinación, con exactamente las ramas divergentes que los carriles SIMT odian. Un port a GPU lo dejaría en la CPU o lo ejecutaría un carril por combinación. Pero al 0,7% del kernel, es un término de Amdahl demasiado pequeño para decidir nada. Es la parte que no iría; no es la razón para no ir. (Recuerda del peldaño M1 que para kernels sin retroalimentación el bucle incluso puede vectorizarse analíticamente — la reescritura que pierdes en el momento en que la estrategia crece un stop.)
Una nota de plataforma para completar: en esta máquina (Apple Silicon) la ruta de GPU sería MLX o PyTorch-MPS, no CUDA — cupy y el ecosistema CUDA simplemente no aplican — y cualquiera de las dos requeriría reescribir la ruta caliente en un dialecto tensorial solo para intentar el experimento. Ese es un costo real con, según el análisis anterior, ningún beneficio identificado para la forma de este barrido. La discusión sobre GPU aquí es analítica, fundamentada en la intensidad aritmética medida y la división feature/bucle medida, y la etiquetamos como tal: no se realizó ninguna ejecución CUDA porque ninguna era posible en el hardware divulgado.
La frase resumen que defenderíamos en una revisión: casi todo este trabajo podría ir a una GPU; este barrido es demasiado estrecho y demasiado corto para que el viaje pague la pena. Y léela en ambas direcciones — no es un descarte. La reformulación de "gran matriz" por lotes — replantear el barrido como grandes operaciones tensoriales sobre miles de combinaciones a la vez, o un kernel genuinamente sin retroalimentación que agrupe de extremo a extremo — es una dirección real y prometedora que merece un estudio dedicado, no un descarte. A 80 combinaciones y 0,23 segundos, simplemente aún no se ha ganado el boleto. Si tu carga de trabajo tiene ese ancho, la aritmética cambia, y deberías rehacerla tú, no citarnos a nosotros.
Dónde está el verdadero cuello de botella: motor y orquestación

Ochenta combinaciones es una grilla de demostración. La búsqueda de parámetros real es donde estos factores dejan de ser académicos, porque las grillas crecen multiplicativamente: cuatro parámetros con diez valores cada uno son combinaciones; añade validación walk-forward con una docena de folds y estás en backtests completos antes de haber explorado nada. Esta es la maldición de la dimensionalidad, y es por eso que la estrategia de búsqueda — Optuna, descenso por coordenadas, Sobol — recibe tanta atención: una búsqueda más inteligente visita menos puntos.
Pero la escalera expone la otra mitad, menos discutida, de la ecuación: el costo por punto visitado. Extrapolando linealmente los rendimientos medidos (las combinaciones son independientes, así que esto es aritmética, no modelado):
| Tamaño de grilla | A M0 (1,1 combos/s) | A M4 (340,9 combos/s) |
|---|---|---|
| 10.000 combos | ~2,4 horas | ~30 segundos |
| 100.000 combos | ~24 horas | ~5 minutos |
El mismo experimento que es un trabajo por lotes de toda la noche en el motor ingenuo es una consulta interactiva en el afinado. Esa diferencia se acumula de una forma que las tablas de tiempo real subestiman: a 5 minutos por barrido iteras — vuelves a ejecutar con una fuga corregida, añades un fold, amplías la grilla, pruebas la idea que se te ocurrió en el almuerzo. A 24 horas por barrido, no lo haces. La velocidad del motor marca el tempo del bucle de investigación, y el tempo del bucle de investigación es el producto real.
También hay una lectura según la ley de Amdahl de toda la escalera:
Acelerar cualquier etapa individual por un factor está limitado por todo lo demás que dejaste lento. La escalera respetó ese orden: la ganancia del motor de 35,3x atacó el término que dominaba (iteración interpretada, tanto en la pila de features como en el bucle), y la ganancia de orquestación de 8,4x atacó el término que dominaba después de eso (once núcleos inactivos). La división feature/bucle es la misma lección en miniatura — no podríamos haber nombrado la forma real del argumento de la GPU sin medir a dónde iba realmente el tiempo. Perfilar, luego optimizar — en ese orden. La misma lógica gobierna la capa de datos aguas arriba del motor: nuestros benchmarks de Polars vs pandas encontraron el patrón idéntico (10–3500x en pipelines rolling agrupados) para la mitad de carga y transformación de la pila, y la misma conclusión híbrida — motores columnares para el pipeline, un kernel compilado para la simulación dependiente de la trayectoria.
Dos notas de honestidad para cerrar el bucle sobre generalidad. Primero, este experimento es deliberadamente autocontenido y sintético — datos con semilla, un kernel, una máquina divulgada — así que cualquiera puede reproducir el fenómeno de forma determinista; los números de tiempo real diferirán en tu hardware, pero la equivalencia y la dirección de la escalera no. Segundo, el fenómeno no es un artefacto de la configuración sintética: el benchmark de nuestro motor HMA de producción (bench_param_sweep.py, ejecutado sobre datos reales de exchange con el modelo completo de comisiones y llenado de producción) muestra la misma forma de escalera, con la ruta numba aterrizando aproximadamente 100–200x por encima del perfil pandas ingenuo. El experimento autocontenido existe para que no tengas que tomar nuestros números de producción bajo fe.
Conclusiones
- La escalera es 298x, y se factoriza: 35,3x motor × 8,4x orquestación. Mover la iteración fuera del intérprete (pandas → numba) y distribuir combinaciones independientes entre núcleos (uno → doce) se multiplicó en una aceleración cercana a tres órdenes de magnitud en un portátil sin cambios. 69,92 s → 0,23 s; 1,1 → 340,9 combos/s. Y no es un artefacto de base lenta: frente a la implementación numpy vectorizada competente, el motor terminado sigue siendo ~13x.
- Exige equivalencia antes de admirar la velocidad. Cada peldaño aquí produce PnL y número de operaciones idénticos por combinación, verificado automáticamente en las 80 combinaciones (tolerancia absoluta de en PnL, exacta en operaciones). Un motor rápido que calcula algo sutilmente diferente no es rápido — está equivocado a alto rendimiento, y las reescrituras vectorizadas son donde suele colarse ese error.
@njitsupera a la vectorización ingeniosa para lógica con estado. El peldaño numpy necesitaba una forma cerrada específica de la estrategia que muere en el momento en que añades un stop-loss. El peldaño numba compila el bucle ingenuo y auditable — misma clase de velocidad, ninguna de la fragilidad, y es la unidad que se paraleliza.- La respuesta de la GPU es "no para este barrido" — por razones que deberías poder nombrar. Las matemáticas de features son de tipo cómputo (10,78 FLOP/byte) y son el 99,3% del kernel compilado, así que ni "los backtests están limitados por memoria" ni "el bucle con estado domina" sobreviven a la medición. Las razones honestas son ancho y presupuesto: 80 combinaciones de paralelismo explotable que 12 núcleos de CPU ya saturan, y un trabajo total de 0,23 s que el lanzamiento y la sincronización devorarían. La reformulación de gran matriz por lotes a ancho real sigue siendo una dirección prometedora, no una refutada.
- La velocidad del motor es el tempo de investigación. Al rendimiento del motor ingenuo, una búsqueda de 100.000 backtests es un día; al rendimiento del tope de la escalera son cinco minutos. Antes de comprar hardware o alquilar un clúster, comprueba si tu cuello de botella siquiera es silicio. El nuestro era un
lambdadentro derolling.applyy once núcleos inactivos.
El experimento completo — las cinco implementaciones, el arnés de equivalencia, el cómputo roofline y cada número de este artículo regenerable a partir de un script determinista — está en el paper acompañante en speed-ladder.marketmaker.cc, con código y datos en github.com/suenot/backtest-speed-ladder.
El barrido que tardaba setenta segundos tarda un cuarto de uno. Las mismas operaciones, el mismo PnL, el mismo portátil. La GPU que estabas a punto de solicitar puede esperar; el bucle de intérprete que estabas a punto de enviar no puede.
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.