Vectorización con NumPy: cuánto gana realmente frente a un bucle en Python
Casi todos los tutoriales de NumPy repiten la misma comparación: sumar dos arrays con un bucle for frente a hacerlo con a + b, seguida de una cifra de "N veces más rápido" sin más contexto. Esa cifra suele ser cierta, pero incompleta: no dice a partir de qué tamaño de array empieza a compensar, qué pasa con arrays pequeños, cuánta memoria extra cuesta encadenar operaciones, ni qué ocurre cuando los datos no son números. Este artículo mide esas cuatro cosas con timeit sobre un montaje concreto, con el código completo para que cualquiera lo repita en su propia máquina.
Qué se midió, con qué versión y en qué máquina
Las cifras de este artículo vienen de NumPy 2.5.2 (versión estable publicada el 9 de agosto de 2026), Python 3.13.2, en Windows 11 sobre una CPU x86_64 AMD. El build de NumPy usado enlaza contra OpenBLAS 0.3.34 como backend de álgebra lineal, visible con np.show_config(). Ningún dato de esta serie sale de un cálculo teórico: todos se obtuvieron ejecutando el script siguiente y leyendo su salida.
La metodología es la que documenta el propio módulo timeit de la biblioteca estándar, pero llevada un paso más allá de lo habitual en un tutorial: en vez de una sola llamada a timeit.timeit, cada caso se midió con timeit.repeat (7 trials para el punto de cruce, 5 para el resto), y se publican el mínimo y la mediana de esos trials, no un número suelto. Una sola medición no distingue una cifra real de un pico de ruido del sistema operativo; varias sí. Aun así, como se ve más abajo, algunas mediciones —sobre todo en tamaños intermedios como n=100.000— no solo variaron dentro de una misma corrida, sino entre procesos completos ejecutados por separado, sin cambiar una línea de código; se señala explícitamente dónde y con qué magnitud.
import timeit
import statistics
import tracemalloc
import numpy as np
print("NumPy version:", np.__version__)
def loop_sum_sq(a):
total = 0.0
for x in a:
total += x * x
return total
def vec_sum_sq(a):
return np.sum(a * a)
# A) Punto de cruce por tamano de array: puntos intermedios + varios trials
sizes = [10, 20, 50, 75, 100, 200, 500, 1_000, 10_000, 100_000, 1_000_000]
for n in sizes:
py_list = list(range(n))
arr = np.arange(n, dtype=np.float64)
reps = 2000 if n <= 1000 else (200 if n <= 100_000 else 20)
loop_times = [t / reps for t in timeit.repeat(lambda: loop_sum_sq(py_list), number=reps, repeat=7)]
vec_times = [t / reps for t in timeit.repeat(lambda: vec_sum_sq(arr), number=reps, repeat=7)]
loop_min, loop_med = min(loop_times), statistics.median(loop_times)
vec_min, vec_med = min(vec_times), statistics.median(vec_times)
print(n, loop_min * 1e6, loop_med * 1e6, vec_min * 1e6, vec_med * 1e6, loop_min / vec_min, loop_med / vec_med)
# B) Coste de los temporales: encadenado vs in-place
n = 5_000_000
a = np.random.rand(n).astype(np.float64)
def chained(a):
return (a * 2.0 + 3.0) ** 2
def inplace(a):
b = a.copy()
np.multiply(b, 2.0, out=b)
np.add(b, 3.0, out=b)
np.power(b, 2.0, out=b)
return b
chained_times = [t / 10 for t in timeit.repeat(lambda: chained(a), number=10, repeat=5)]
inplace_times = [t / 10 for t in timeit.repeat(lambda: inplace(a), number=10, repeat=5)]
tracemalloc.start()
_ = chained(a)
peak_chained = tracemalloc.get_traced_memory()[1]
tracemalloc.stop()
tracemalloc.start()
_ = inplace(a)
peak_inplace = tracemalloc.get_traced_memory()[1]
tracemalloc.stop()
print("chained ms min/med:", min(chained_times) * 1e3, statistics.median(chained_times) * 1e3, "peak MB:", peak_chained / 1e6)
print("inplace ms min/med:", min(inplace_times) * 1e3, statistics.median(inplace_times) * 1e3, "peak MB:", peak_inplace / 1e6)
# C) dtype=object no vectoriza de verdad -- arrays preparados FUERA del bloque cronometrado
n = 200_000
words = [f"palabra_{i}" for i in range(n)]
arr_obj = np.array(words, dtype=object)
arr_fixed_str = np.array(words, dtype=str) # conversion hecha UNA vez, no dentro del timing
def loop_upper(lst):
return [w.upper() for w in lst]
def obj_upper(arr):
return np.array([w.upper() for w in arr], dtype=object)
def strings_upper_prepped(arr_u):
return np.strings.upper(arr_u) # arr_u ya es dtype str fijo; la conversion no se mide aqui
for label, fn, arg in [
("loop", loop_upper, words),
("object", obj_upper, arr_obj),
("np.strings (prepped)", strings_upper_prepped, arr_fixed_str),
]:
times = [t / 10 for t in timeit.repeat(lambda: fn(arg), number=10, repeat=5)]
print(label, min(times) * 1e3, statistics.median(times) * 1e3, "ms")
# Coste de la conversion a dtype=str, medido por separado
conv_times = [t / 10 for t in timeit.repeat(lambda: np.array(words, dtype=str), number=10, repeat=5)]
print("conversion-only ms min/med:", min(conv_times) * 1e3, statistics.median(conv_times) * 1e3)
El punto de cruce: a partir de qué tamaño compensa vectorizar
Punto de cruce, medido con puntos intermedios y no solo en los extremos: para una suma de cuadrados sobre float64, el bucle Python y la versión vectorizada quedan prácticamente empatados en n=50 (0,94x tanto en mínimo como en mediana), y a partir de n=75 la vectorización ya gana con claridad (1,41-1,43x). El cruce real en este montaje cae entre 50 y 75 elementos: más estrecho que el "50-100" que suele citarse, aunque contenido dentro de ese rango. Por debajo de 50 elementos el bucle gana con margen porque el coste fijo de invocar una ufunc de NumPy (crear el array temporal, despachar la llamada en C, comprobar dtypes) no tiene nada que amortizar; ese coste fijo se ve directo en la tabla: el tiempo vectorizado apenas se mueve de ~2,3 µs entre n=10 y n=75.
| Tamaño (n) | Bucle Python (mín/mediana) | NumPy vectorizado (mín/mediana) | Bucle / vec (mín/mediana) |
|---|---|---|---|
| 10 | 0,46/0,47 µs | 2,35/2,37 µs | 0,20x/0,20x (gana el bucle) |
| 20 | 0,85/0,87 µs | 2,36/2,39 µs | 0,36x/0,36x (gana el bucle) |
| 50 | 2,23/2,24 µs | 2,37/2,39 µs | 0,94x/0,94x (empate) |
| 75 | 3,27/3,36 µs | 2,28/2,39 µs | 1,43x/1,41x |
| 100 | 4,32/4,47 µs | 2,26/2,29 µs | 1,91x/1,95x |
| 200 | 8,50/9,13 µs | 2,56/2,58 µs | 3,32x/3,54x |
| 500 | 22,26/22,50 µs | 2,65/2,67 µs | 8,42x/8,44x |
| 1.000 | 43,54/44,29 µs | 2,98/2,99 µs | 14,62x/14,79x |
| 10.000 | 435,6/453,6 µs | 6,79/7,36 µs | 64,2x/61,7x |
| 100.000 | 5,94/6,01 ms | 43,4/43,9 µs | 137x/137x (ver nota de inestabilidad abajo) |
| 1.000.000 | 66,0/67,3 ms | 1,99/2,09 ms | 33,2x/32,2x |
Cada celda es el mínimo y la mediana de siete trials de timeit.repeat dentro de una misma ejecución del script; hasta ahí, la dispersión es baja. Pero en n=100.000 hay algo que ni siete trials en un mismo proceso capturan: al repetir el script completo cinco veces como procesos separados, sin tocar una línea de código, el resultado fue bimodal. Cuatro de las cinco ejecuciones dieron un multiplicador entre 137x y 148x; la quinta dio 33x, con el tiempo vectorizado saltando de ~42 µs a ~187 µs sin ningún cambio de código entre medias. En n=1.000.000, en cambio, las cinco ejecuciones fueron consistentes: entre 33x y 37x. Un revisor independiente que reprodujo este benchmark reportó 26,9x en n=100.000, por debajo incluso de nuestro mínimo observado (33x), lo que confirma que el suelo real está más cerca de "por debajo de 30x" que de los 30x que se podría asumir mirando solo el promedio. La conclusión honesta no es un multiplicador único para n=100.000: en ese tamaño, con esta clase de hardware, el resultado es binario entre "rápido" (~140x) y "lento" (~30x o menos) según el estado de la caché y la carga del sistema en el instante exacto de la medición. Si tu caso de uso cae en ese rango, mide varias veces en tu propia máquina, en procesos separados, no confíes en una sola llamada a timeit.timeit() ni en el número de este artículo.
Por qué la ventaja se reduce en arrays de un millón de elementos
Un dato menos intuitivo: el multiplicador de n=100.000 a n=1.000.000 no sigue creciendo, cae, y lo hace de forma consistente en las cinco ejecuciones de proceso completo: el techo de n=1.000.000 (37x) queda muy por debajo del techo de n=100.000 (148x). La hipótesis más plausible, coherente con cómo funciona la jerarquía de memoria, es que a partir de cierto tamaño el array deja de caber en la caché L2/L3 de la CPU y la operación pasa a estar limitada por el ancho de banda de acceso a RAM en lugar de por el cómputo: tanto el bucle como la versión vectorizada empiezan a pagar ese coste, así que la ventaja relativa de NumPy se reduce aunque en términos absolutos sigue siendo mucho más rápido. Es una hipótesis razonada a partir de los datos, no un hecho documentado por NumPy; quien quiera confirmarlo en su propia CPU puede repetir el script con tamaños intermedios (300.000, 500.000, 700.000) y mirar dónde exactamente empieza la caída.
El coste de los temporales: encadenar operaciones frente a out=
Escribir (a * 2.0 + 3.0) ** 2 es legible, pero cada operador crea un array temporal nuevo del mismo tamaño que a antes de descartarlo. Con a de 5.000.000 de float64 (40 MB), sobre cinco trials de timeit.repeat, la versión encadenada tardó 24,9-25,1 ms (mín/mediana) frente a 20,5-21,4 ms de la versión con out=, y tracemalloc registró un pico de memoria de 80 MB para la encadenada frente a 40 MB para la que reutiliza el buffer con out=, tal como documenta el parámetro out de los ufuncs de NumPy:
def chained(a):
return (a * 2.0 + 3.0) ** 2
def inplace(a):
b = a.copy()
np.multiply(b, 2.0, out=b)
np.add(b, 3.0, out=b)
np.power(b, 2.0, out=b)
return bLa diferencia de tiempo (15-18%) es mucho menor que la de memoria (exactamente 2x en este caso, porque la versión encadenada mantiene temporalmente dos copias del array antes de liberar la primera). Esto importa cuando el array es grande respecto a la RAM disponible o cuando la operación se repite muchas veces dentro de un pipeline; para un cálculo puntual sobre un array que cabe cómodo en memoria, reescribir el código con out= a cambio de perder legibilidad es optimización prematura. La señal para saltar a out= no es "esto es más lento", es "esto duplica o triplica el pico de memoria de mi proceso y lo estoy notando".
Cuando vectorizar no ayuda: arrays pequeños y texto en dtype object
La tabla del punto de cruce ya deja ver el primer caso: con menos de 50-75 elementos, el bucle Python gana o empata porque el coste fijo de NumPy —en este montaje, un suelo de en torno a 2,3-2,4 µs por llamada, prácticamente constante independientemente de n— no tiene nada que amortizar. Si una función se invoca millones de veces sobre arrays de 5 o 10 elementos (típico en bucles de simulación o en llamadas por fila de un dataframe), vectorizar cada llamada individual puede ser peor que dejar el bucle y vectorizar en el nivel de arriba, sobre el conjunto completo.
El segundo caso es menos conocido: con dtype=object —el dtype que usa NumPy cuando el array contiene strings de Python en vez de números— la "vectorización" no vectoriza de verdad, porque cada elemento sigue siendo un objeto Python y NumPy solo itera el array llamando al método de cada uno, sin bajar a C. Procesando 200.000 strings con .upper(), con los arrays ya preparados fuera del bloque cronometrado (la primera versión de este benchmark medía por error la conversión a dtype=str dentro del tiempo de np.strings.upper, y eso inflaba su coste; aquí la conversión se mide aparte, en su propia fila):
| Enfoque | Tiempo medido (mín/mediana) |
|---|---|
| Lista Python + comprensión | 14,00/14,10 ms |
np.array(dtype=object) + comprensión | 20,29/20,50 ms |
np.strings.upper (operación pura, array ya en dtype str fijo) | 45,77/45,93 ms |
| Conversión previa a dtype str fijo (aparte, solo si partes de una lista) | 17,38/17,47 ms |
Incluso descontando la conversión, np.strings.upper sigue siendo la opción más lenta de las tres: 45,8 ms de operación pura frente a 14,0 ms del bucle y 20,3 ms del array object. La lista Python simple gana a las dos alternativas "vectorizadas" con margen. El array object añade el overhead del propio contenedor NumPy sin eliminar la llamada Python por elemento, así que solo se pierde tiempo. Y si el punto de partida real es una lista Python —el caso típico—, hay que sumar la conversión a la operación: 17,4 ms + 45,8 ms ≈ 63,2 ms, todavía peor que el bucle simple. np.strings.upper —el sustituto moderno del módulo legacy numpy.char, documentado para trabajar sobre str_/bytes_ de ancho fijo— no compensa aquí ni con la conversión descontada ni con ella incluida, porque de partida el array de ancho fijo exige copiar y rellenar con padding cada una de las 200.000 cadenas hasta el ancho de la más larga. Para texto de longitud variable y operaciones simples como esta, NumPy no es la herramienta; para texto que ya vive en un array NumPy de ancho fijo homogéneo —sin necesitar la conversión— sigue siendo razonable.
Tabla de decisión: vectorizar, usar out= o quedarte con el bucle
| Situación | Qué hacer | Por qué (según lo medido arriba) |
|---|---|---|
| Arrays numéricos de más de ~100-1.000 elementos | Vectorizar (a + b, np.sum, ufuncs) | El coste fijo de NumPy queda amortizado; la ganancia va de ~2x a varios órdenes de magnitud según tamaño |
| Arrays de menos de ~50 elementos, llamados muchas veces | Bucle Python, o vectorizar en el nivel superior (sobre la colección de arrays, no dentro de cada uno) | El suelo fijo de ~2 µs por llamada a NumPy no se amortiza con tan pocos elementos |
| Pipeline con arrays grandes (cientos de MB) y expresiones encadenadas | Reescribir con out= o revisar si hace falta reducir precisión (float32) | Cada operador de la cadena reserva un temporal completo; el pico de memoria puede duplicarse o triplicarse |
| Cálculo puntual, no repetido, sobre un array que cabe holgado en RAM | Dejar la expresión encadenada legible | El ahorro de out= (15-20% de tiempo en este caso) no compensa la pérdida de legibilidad si no hay presión de memoria real |
| Texto de longitud variable con operaciones simples (mayúsculas, strip, split) | Lista Python + comprensión, o pandas.Series.str si ya se trabaja con DataFrames | dtype=object no baja a C; convertir a ancho fijo para usar np.strings cuesta más que el propio bucle |
| Texto ya almacenado en arrays NumPy de ancho fijo homogéneo | np.strings.* | Aquí sí opera sobre el buffer contiguo sin pasar por objetos Python por elemento |
El patrón que atraviesa las seis filas es el mismo: la vectorización gana cuando el trabajo por elemento es pequeño y hay muchos elementos homogéneos que amortizan el coste fijo de la llamada a C. En cuanto cualquiera de esas dos condiciones falla —pocos elementos, elementos no numéricos, o memoria como recurso más escaso que CPU— conviene medir antes de asumir que "vectorizado" es sinónimo de "más rápido".