Medir el rendimiento en Python con timeit y cProfile

Medir mal es peor que no medir, porque te deja seguro de algo falso. Python trae dos herramientas que lo hacen bien y nadie tiene excusa para no usarlas.

La medición ingenua

t0 = time.perf_counter()
con_mas(piezas)
print(f"{(time.perf_counter()-t0)*1e6:.1f} µs")
salidavuelta 1:    19.1 µs
vuelta 2:    16.1 µs
vuelta 3:    14.8 µs

El mismo código, tres veces, tres cifras distintas, con un 29 % de diferencia entre la primera y la última. Una sola medida no dice nada.

timeit: repite y se queda con lo mejor

import timeit

t = timeit.Timer(lambda: con_mas(piezas))
n, _ = t.autorange()                      # cuántas repeticiones hacen falta
mejor = min(t.repeat(repeat=5, number=n)) / n
salida+=               8.97 µs   (20000 repeticiones por tanda)
join             1.75 µs   (200000 repeticiones por tanda)
lista+join       4.29 µs   (50000 repeticiones por tanda)

Tres cosas que hace timeit y tu cronómetro no: ejecuta muchas veces para que el ruido se diluya, repite la tanda varias veces, y se queda con el mínimo, no con la media. El mínimo es lo correcto aquí: las medidas altas son interferencias de otros procesos, no información sobre tu código.

Y desde la terminal, sin escribir un programa:

python3 -m timeit -s 'x=list(range(1000))' 'sum(x)'

La medida que cambia cómo escribes

lista, conj = list(range(100_000)), set(range(100_000))
99_999 in lista
99_999 in conj
salida99999 in list    759418.2 ns
99999 in set         33.5 ns
Veintidós mil veces. La lista recorre los cien mil elementos; el conjunto calcula un hash y va directo. Si tu código hace if x in lista dentro de un bucle, eso es cuadrático y es, con diferencia, el fallo de rendimiento más común en Python. Convertir la lista a set antes del bucle suele ser todo el arreglo.

Dónde se va el tiempo

import cProfile, pstats

cProfile.run("trabajo()", sort="cumulative")
# o, para controlar la salida:
pr = cProfile.Profile(); pr.enable(); trabajo(); pr.disable()
pstats.Stats(pr).sort_stats("cumulative").print_stats(5)
salidancalls  tottime  percall  cumtime  percall filename:lineno(function)
        1    0.000    0.000    0.001    0.001 Rend.py:38(trabajo)
       50    0.001    0.000    0.001    0.000 Rend.py:5(con_mas)
       50    0.000    0.000    0.000    0.000 Rend.py:9(con_join)
       50    0.000    0.000    0.000    0.000 {method 'join' of 'str' objects}
Columna Qué significa
ncalls cuántas veces se llamó
tottime tiempo dentro de la función, sin contar lo que llama
cumtime tiempo total, incluyendo lo que llama

Se ordena por cumulative para encontrar la rama cara, y por tottime para encontrar la función cara. Y la distinción clave: timeit dice cuánto tarda algo; cProfile dice qué parte de tu programa lo está tardando. Son preguntas distintas.

cProfile añade bastante sobrecarga a cada llamada, así que los tiempos absolutos que da no son fiables; las proporciones sí. Si necesitas medir con poca interferencia, hay perfiladores por muestreo como py-spy, que además se enganchan a un proceso que ya está corriendo.

El orden de las cosas

  1. Mide primero. La función que crees que es el cuello de botella casi nunca lo es.
  2. Perfila para saber dónde, con cProfile.
  3. Mide esa parte con timeit, antes y después del cambio.
  4. Y antes de optimizar nada, mira si la estructura de datos es la correcta. El salto de lista a conjunto de ahí arriba vale más que cualquier microoptimización.

Todo el código se ejecutó con Python 3.14 en un contenedor limpio antes de publicar esta página; las salidas están copiadas de esa ejecución.