El otro día estaba trabajando en un guión y me estaba volviendo loco. Funcionó, claro, pero fue simplemente… lento. Realmente lento. Tuve la sensación de que esto podría ser mucho más rápido si pudiera descubrir dónde estaba el atraco.
Mi primer pensamiento fue empezar a modificar las cosas. Pude optimizar la carga de datos. ¿O reescribir ese bucle for? Pero me detuve. He caído en esa trampa antes, pasando horas “optimizando” un fragmento de código sólo para descubrir que apenas hizo ninguna diferencia en el tiempo de ejecución general. Donald Knuth tenía razón cuando dijo: "La optimización prematura es la raíz de todos los males".
Decidí adoptar un enfoque más metódico. En lugar de adivinar, iba a estar seguro. Necesitaba perfilar el código para obtener datos concretos sobre exactamente qué funciones consumían la mayoría de los ciclos de reloj.
En este artículo, lo guiaré a través del proceso exacto que utilicé. Tomaremos un script Python deliberadamente lento y usaremos dos herramientas fantásticas para identificar sus cuellos de botella con precisión quirúrgica.
La primera de estas herramientas se llama cProfile, un potente generador de perfiles integrado en Python. El otro se llama Snakeviz, una brillante herramienta que transforma la salida del generador de perfiles en un mapa visual interactivo.
Configurar un entorno de desarrollo
Antes de comenzar a codificar, configuremos nuestro entorno de desarrollo. La mejor práctica es crear un entorno Python separado donde pueda instalar cualquier software necesario y experimentar, sabiendo que nada de lo que haga afectará al resto de su sistema. Usaré conda para esto, pero puedes usar cualquier método con el que estés familiarizado.
#crea nuestro entorno de prueba conda create -n profiling_lab python=3.11 -y # Ahora actívalo conda enable profiling_lab
Ahora que tenemos nuestro entorno configurado, necesitamos instalar Snakeviz para nuestras visualizaciones y numpy para el script de ejemplo. cProfile ya está incluido con Python, por lo que no hay nada más que hacer allí. Como ejecutaremos nuestros scripts con un Jupyter Notebook, también lo instalaremos.
# Instale nuestra herramienta de visualización y numpy pip instale Snakeviz numpy jupyter
Ahora escriba jupyter notebook en su símbolo del sistema. Debería ver un cuaderno Jupyter abierto en su navegador. Si eso no sucede automáticamente, probablemente verá una pantalla llena de información después del comando jupyter notebook. Cerca de la parte inferior, habrá una URL que deberá copiar y pegar en su navegador para iniciar Jupyter Notebook.
Tu URL será diferente a la mía, pero debería verse así:
http://127.0.0.1:8888/tree?token=3b9f7bd07b6966b41b68e2350721b2d0b6f388d248cc69da
Con nuestras herramientas listas, es hora de mirar el código que vamos a arreglar.
Nuestro guión "problemático"
Para probar adecuadamente nuestras herramientas de creación de perfiles, necesitamos un script que muestre problemas de rendimiento claros. Escribí un programa simple que simula problemas de procesamiento con memoria, iteración y ciclos de CPU, lo que lo convierte en un candidato perfecto para nuestra investigación.
# run_all_systems.py importar tiempo importar matemáticas # ==================================================================== CPU_ITERACIONES = 34552942 STRING_ITERACIONES = 46658100 LOOP_ITERATIONS = 171796964 # ============================================================================= # — Tarea 1: Un cuello de botella calibrado vinculado a la CPU — def cpu_heavy_task(iterations): print(" -> Ejecutando tarea vinculada a CPU…") resultado = 0 para i en rango(iterations): resultado += math.sin(i) * math.cos(i) + math.sqrt(i) devuelve resultado # — Tarea 2: Un cuello de botella de cadena/memoria calibrada — def Memory_heavy_string_task(iterations): print(" -> Memoria en ejecución/tarea vinculada a cadena…") report = "" chunk = "report_item_abcdefg_123456789_" for i in range(iterations): report += f"|{chunk}{i}" return report # — Tarea 3: Un cuello de botella de iteración calibrado de "Mil cortes" — def simula_tiny_op(n): pasar def iteration_heavy_task(iterations): print(" -> Ejecutando tarea vinculada a iteración…") para i en rango(iteraciones): simular_tiny_op(i) return "OK" # — Orquestador principal — def run_all_systems(): print("— Iniciando FINAL SLOW Balanced Showcase —") cpu_result = cpu_heavy_task(iterations=CPU_ITERACIONES) string_result = memoria_heavy_string_task(iterations=STRING_ITERACIONES) iteration_result = iteration_heavy_task(iterations=LOOP_ITERACIONES) print("— FINAL SLOW Escaparate equilibrado terminado —")
Paso 1: recopilar los datos con cProfile
Nuestra primera herramienta, cProfile, es un generador de perfiles determinista integrado en Python. Podemos ejecutarlo desde código para ejecutar nuestro script y registrar estadísticas detalladas sobre cada llamada a función.
import cProfile, pstats, io pr = cProfile.Profile() pr.enable() # Ejecute la función que desea perfilar run_all_systems() pr.disable() # Volcar estadísticas a una cadena e imprimir las 10 principales por tiempo acumulativo s = io.StringIO() ps = pstats.Stats(pr, stream=s).sort_stats("cumtime") ps.print_stats(10) imprimir(s.getvalue())
Aquí está el resultado.
— Iniciando FINAL SLOW Balanced Showcase — -> Ejecutando tarea vinculada a CPU… -> Ejecutando tarea vinculada a memoria/cadena… -> Ejecutando tarea vinculada a iteración… — FINAL SLOW Balanced Showcase finalizado — 275455984 llamadas de función en 30,497 segundos Ordenado por: tiempo acumulativo Lista reducida de 47 a 10 debido a la restricción <10> ncalls tottime percall cumtime percall nombre de archivo:lineno(función) 2 0.000 0.000 30.520 15.260 /home/tom/.local/lib/python3.10/site-packages/IPython/core/interactiveshell.py:3541(run_code) 2 0.000 0.000 30.520 15.260 {método incorporado buildins.exec} 1 0,000 0,000 30,497 30,497 /tmp/ipykernel_173802/1743829582.py:41(run_all_systems) 1 9,652 9,652 14,394 14,394 /tmp/ipykernel_173802/1743829582.py:34(iteration_heavy_task) 1 7.232 7.232 12.211 12.211 /tmp/ipykernel_173802/1743829582.py:14(cpu_heavy_task) 171796964 4,742 0,000 4,742 0,000 /tmp/ipykernel_173802/1743829582.py:31(simulate_tiny_op) 1 3,891 3,891 3,892 3,892 /tmp/ipykernel_173802/1743829582.py:22(memory_heavy_string_task) 34552942 1,888 0,000 1,888 0,000 {método integrado math.sin} 34552942 1,820 0,000 1,820 0,000 {método integrado math.cos} 34552942 1,271 0,000 1,271 0,000 {método integrado math.sqrt}
Tenemos un montón de números que pueden ser difíciles de interpretar. Aquí es donde Snakeviz entra en juego.
Paso 2: Visualizar el cuello de botella con Snakeviz
Aquí es donde ocurre la magia. Snakeviz toma el resultado de nuestro archivo de perfil y lo convierte en un gráfico interactivo basado en navegador, lo que facilita la búsqueda de cuellos de botella.
Entonces usemos esa herramienta para visualizar lo que tenemos. Como estoy usando un Jupyter Notebook, primero debemos cargarlo.
%load_ext visualización de serpiente
Y lo ejecutamos así.
%%snakeviz principal()
El resultado se presenta en dos partes. La primera es una visualización como esta.
Lo que ve es un gráfico de "carámbano" de arriba hacia abajo. De arriba a abajo, representa la jerarquía de llamadas.
En la parte superior: Python está ejecutando nuestro script ().
Siguiente: ejecución __main__ del script (:1()). Luego la función run_all_systems. Dentro de eso, llama a dos funciones clave: iteration_heavy_task y cpu_heavy_task.
La parte de procesamiento que requiere mucha memoria no está etiquetada en el gráfico. Esto se debe a que la proporción de tiempo asociada con esta tarea es mucho menor que los tiempos asignados a las otras dos funciones intensivas. Como resultado, vemos un bloque mucho más pequeño y sin etiquetar a la derecha del bloque cpu_heavy_task.
Tenga en cuenta que, para el análisis, también existe un estilo de gráfico Snakeviz llamado gráfico Sunburst. Se parece un poco a un gráfico circular, excepto que contiene un conjunto de círculos y arcos concéntricos cada vez más grandes. La idea es que el tiempo que tardan las funciones en ejecutarse está representado por la extensión angular del tamaño del arco del círculo. La función raíz es un círculo en el medio de la visualización. La función raíz se ejecuta llamando a las subfunciones debajo de ella y así sucesivamente. No veremos ese tipo de visualización en este artículo.
Una confirmación visual como esta puede ser mucho más impactante que mirar una tabla de números. Ya no necesitaba adivinar dónde mirar; Los datos me estaban mirando directamente a la cara.
La visualización es seguida rápidamente por un bloque de texto que detalla los tiempos de varias partes de su código, muy parecido al resultado de la herramienta cprofile. Solo muestro la primera docena de líneas de esto, ya que había más de 30 en total.
ncalls tottime percall cumtime percall nombre de archivo:lineno(función) —————————————————————- 1 9.581 9.581 14.3 14.3 1062495604.py:34(iteration_heavy_task) 1 7.868 7.868 12.92 12.92 1062495604.py:14(cpu_heavy_task) 171796964 4.717 2.745e-08 4.717 2.745e-08 1062495604.py:31(simulate_tiny_op) 1 3.848 3.848 3.848 3.848 1062495604.py:22(memory_heavy_string_task) 34552942 1.91 5.527e-08 1.91 5.527e-08 ~:0() 34552942 1.836 5.313e-08 1.836 5.313e-08 ~:0() 34552942 1.305 3.778e-08 1.305 3.778e-08 ~:0() 1 0.02127 0.02127 31.09 31.09 :1() 4 0.0001764 4.409e-05 0.0001764 4.409e-05 socket.py:626(enviar) 10 0.000123 1.23e-05 0.0004568 4.568e-05 iostream.py:655(escribir) 4 4.594e-05 1.148e-05 0.0002735 6.838e-05 iostream.py:259(horario) … … …
Paso 3: la solución
Por supuesto, herramientas como cprofiler y Snakeviz no le dicen cómo solucionar sus problemas de rendimiento, pero ahora que sabía exactamente dónde estaban los problemas, podía aplicar soluciones específicas.
# final_showcase_fixed_v2.py importar tiempo importar matemáticas importar numpy como np # ============================================================================ CPU_ITERACIONES = 34552942 STRING_ITERACIONES = 46658100 LOOP_ITERATION = 171796964 # ======================================================================= # — Solución 1: Vectorización para la tarea vinculada a la CPU — def cpu_heavy_task_fixed(iterations): """ Se corrigió usando NumPy para realizar operaciones matemáticas complejas en una matriz completa a la vez, en código C altamente optimizado en lugar de un bucle de Python. """ print(" -> Ejecutando una tarea vinculada a la CPU…") # Crea una matriz de números de 0 a iteraciones-1 i = np.arange(iterations, dtype=np.float64) # El mismo cálculo, pero vectorizado, es órdenes de magnitud más rápido result_array = np.sin(i) * np.cos(i) + np.sqrt(i) return np.sum(result_array) # — Solución 2: Unión de cadenas eficiente — def Memory_heavy_string_task_fixed(iterations): """ Se solucionó mediante el uso de una lista de comprensión y una única y eficiente llamada ''.join(). Esto evita la creación de millones de objetos de cadena intermedios. """ print(" -> Running Memory/String-bound task…") chunk = "report_item_abcdefg_123456789_" # La comprensión de una lista es rápida y eficiente en memoria parts = [f"|{chunk}{i}" for i in range(iterations)] return "".join(parts) # — Solución 3: Eliminación del bucle "Mil cortes" — def iteration_heavy_task_fixed(iterations): """ Se soluciona reconociendo que la tarea puede ser una operación no operativa o masiva. En un escenario del mundo real, encontraría una manera de evitar el bucle por completo. Aquí, demostramos la solución simplemente eliminando el bucle sin sentido. El objetivo es mostrar que el costo del bucle en sí era el problema. """ print(" -> Ejecución de la tarea vinculada a la iteración…") # La solución es encontrar una operación masiva o eliminar la necesidad del bucle. # Dado que la función original no hizo nada, la solución es no hacer nada, pero más rápido. return "OK" # — Orquestador principal — def run_all_systems(): """ El orquestador principal ahora llama a las versiones FAST de las tareas. """ print("— Iniciando FINAL FAST Balanced Showcase —") cpu_result = cpu_heavy_task_fixed(iterations=CPU_ITERACIONES) string_result = Memory_heavy_string_task_fixed(iterations=STRING_ITERACIONES) iteration_result = iteration_heavy_task_fixed(iterations=LOOP_ITERACIONES) print("— FINAL FAST Exhibición equilibrada finalizada —")
Ahora podemos volver a ejecutar cprofiler en nuestro código actualizado.
import cProfile, pstats, io pr = cProfile.Profile() pr.enable() # Ejecute la función que desea perfilar run_all_systems() pr.disable() # Volcar estadísticas a una cadena e imprimir las 10 principales por tiempo acumulativo s = io.StringIO() ps = pstats.Stats(pr, stream=s).sort_stats("cumtime") ps.print_stats(10) print(s.getvalue()) # # inicio de la salida # — Iniciando FINAL FAST Balanced Showcase — -> Ejecutando tarea vinculada a la CPU… -> Ejecutando tarea vinculada a memoria/cadena… -> Ejecutando tarea vinculada a iteración… — FINAL FAST Balanced Showcase finalizado — 197 llamadas de función en 6.063 segundos Ordenado por: tiempo acumulativo Lista reducida de 52 a 10 debido a la restricción <10> ncalls tottime percall cumtime percall nombre de archivo:lineno(función) 2 0.000 0.000 6.063 3.031 /home/tom/.local/lib/python3.10/site-packages/IPython/core/interactiveshell.py:3541(run_code) 2 0.000 0.000 6.063 3.031 {método incorporado buildins.exec} 1 0,002 0,002 6,063 6,063 /tmp/ipykernel_173802/1803406806.py:1() 1 0,402 0,402 6,061 6,061 /tmp/ipykernel_173802/3782967348.py:52(run_all_systems) 1 0.000 0.000 5.152 5.152 /tmp/ipykernel_173802/3782967348.py:27(memory_heavy_string_task_fixed) 1 4.135 4.135 4.135 4.135 /tmp/ipykernel_173802/3782967348.py:35() 1 1.017 1.017 1.017 1.017 {método 'unión' de objetos 'str'} 1 0.446 0.446 0.505 0.505 /tmp/ipykernel_173802/3782967348.py:14(cpu_heavy_task_fixed) 1 0.045 0.045 0.045 0.045 {método incorporado numpy.arange} 1 0.000 0.000 0.014 0.014 <__array_function__ elementos internos>:177(suma)
Es un resultado fantástico que demuestra el poder de la elaboración de perfiles. Dedicamos nuestro esfuerzo a las partes del código que importaban. Para ser más exhaustivo, también ejecuté Snakeviz en el script fijo.
%%snakeviz run_all_systems()
El cambio más notable es la reducción del tiempo de ejecución total, de aproximadamente 30 segundos a aproximadamente 6 segundos. Se trata de una aceleración cinco veces mayor, lograda al abordar los tres principales cuellos de botella que eran visibles en el perfil "antes".
Veamos cada uno individualmente.
1. La iteración_heavy_task
Antes (el problema)
En la primera imagen, la barra grande de la izquierda, iteration_heavy_task, es el mayor cuello de botella y consume 14,3 segundos.
¿Por qué fue lento? Esta tarea fue la clásica “muerte por mil cortes”. La función simular_tiny_op no hizo casi nada, pero fue llamada millones de veces desde dentro de un bucle for puro de Python. La inmensa sobrecarga del intérprete de Python al iniciar y detener una llamada de función repetidamente fue la única fuente de la lentitud.
La solución
La versión fija, iteration_heavy_task_fixed, reconoció que el objetivo se podía lograr sin el bucle. En nuestro escaparate, esto significó eliminar por completo el bucle inútil. En una aplicación del mundo real, esto implicaría encontrar una única operación "masiva" para reemplazar la iterativa.
Después (el resultado)
En la segunda imagen, la barra iteration_heavy_task desapareció por completo. Ahora es tan rápido que su tiempo de ejecución es de una pequeña fracción de segundo y es invisible en el gráfico. Eliminamos con éxito un problema de 14,3 segundos.
2. La tarea_pesada_de_cpu
Antes (el problema)
El segundo cuello de botella importante, claramente visible como la gran barra naranja a la derecha, es cpu_heavy_task, que tardó 12,9 segundos.
¿Por qué fue lento? Al igual que la tarea de iteración, esta función también estaba limitada por la velocidad del bucle for de Python. Si bien las operaciones matemáticas internas eran rápidas, el intérprete tuvo que procesar cada uno de los millones de cálculos individualmente, lo cual es altamente ineficiente para tareas numéricas.
La solución
La solución fue la vectorización utilizando la biblioteca NumPy. En lugar de utilizar un bucle de Python, cpu_heavy_task_fixed creó una matriz NumPy y realizó todas las operaciones matemáticas (np.sqrt, np.sin, etc.) en toda la matriz simultáneamente. Estas operaciones se ejecutan en código C precompilado y altamente optimizado, evitando por completo el lento bucle del intérprete de Python.
Después (El Resultado).
Al igual que el primer cuello de botella, la barra cpu_heavy_task ha desaparecido del diagrama "después". Su tiempo de ejecución se redujo de 12,9 segundos a unos pocos milisegundos.
3. La tarea_memoria_heavy_string_task
Antes (el problema):
En el primer diagrama, se estaba ejecutando la tarea de cadena_pesada de memoria, pero su tiempo de ejecución era pequeño en comparación con los otros dos problemas más importantes, por lo que quedó relegado a la pequeña porción de espacio sin etiquetar en el extremo derecho. Fue un problema relativamente menor.
La solución
La solución para esta tarea fue reemplazar la ineficiente concatenación de cadenas report += “…” con un método mucho más eficiente: crear una lista de todas las partes de la cadena y luego llamar a “”.join() una sola vez al final.
Después (el resultado)
En el segundo diagrama vemos el resultado de nuestro éxito. Habiendo eliminado los dos cuellos de botella de más de 10 segundos, la tarea fijada con mucha memoria es ahora el nuevo cuello de botella dominante, representando 4,34 segundos del tiempo de ejecución total de 5,22 segundos.
Snakeviz incluso nos permite mirar dentro de esta función fija. El nuevo contribuyente más importante es la barra naranja etiquetada (comprensión de lista), que tarda 3,52 segundos. Esto indica que incluso en el código fijo, la parte que consume más tiempo es ahora el proceso de crear la lista extensa de cadenas en la memoria antes de que puedan unirse.
Resumen
Este artículo proporciona una guía práctica para identificar y resolver problemas de rendimiento en el código Python, argumentando que los desarrolladores deberían utilizar herramientas de creación de perfiles para medir el rendimiento en lugar de confiar en la intuición o las conjeturas para identificar el origen de las desaceleraciones.
Demostré un flujo de trabajo metódico utilizando dos herramientas clave: –
cProfile: generador de perfiles integrado de Python, utilizado para recopilar datos detallados sobre llamadas a funciones y tiempos de ejecución. Snakeviz: una herramienta de visualización que convierte los datos de cProfile en un gráfico interactivo de "carámbanos", lo que facilita la identificación visual de qué partes del código consumen más tiempo.
El artículo utiliza un estudio de caso de un guión deliberadamente lento diseñado con tres cuellos de botella distintos y significativos:
Una tarea vinculada a la iteración: una función llamada millones de veces en un bucle, que muestra el costo de rendimiento de la sobrecarga de llamadas a funciones de Python (“muerte por mil cortes”). Una tarea vinculada a la CPU: un bucle for que realiza millones de cálculos matemáticos, lo que destaca la ineficiencia de Python puro para trabajos numéricos pesados. Una tarea vinculada a la memoria: una cadena grande construida de manera ineficiente usando concatenación += repetida.
Al analizar el resultado de Snakeviz, identifiqué estos tres problemas y apliqué soluciones específicas.
El cuello de botella de la iteración se solucionó eliminando el bucle innecesario. El cuello de botella de la CPU se resolvió con vectorización utilizando NumPy, que ejecuta operaciones matemáticas en código C compilado rápido. El cuello de botella de la memoria se solucionó agregando partes de cadena a una lista y usando una única y eficiente llamada “”.join().
Estas correcciones dieron como resultado una aceleración dramática, reduciendo el tiempo de ejecución del script de más de 30 segundos a poco más de 6 segundos. Concluí demostrando que, incluso después de resolver problemas importantes, el generador de perfiles se puede utilizar nuevamente para identificar cuellos de botella nuevos y más pequeños, lo que ilustra que el ajuste del rendimiento es un proceso iterativo guiado por la medición.