Saltar a contenido

Perfilado determinístico

En Python contamos con la librería cProfile que nos permite obtener estadísticas detalladas de los llamados a funciones durante la ejecución de un programa. Esta herramienta nos permite saber cuántas veces se llama cada función, cuánto tiempo consume cada una, y cómo se relacionan entre sí.

Ejemplo: Fibonacci recursivo sin optimización

from cProfile import Profile
from pstats import SortKey, Stats

def fib(n):
    """
    Implementación recursiva naive de Fibonacci.
    Tiene complejidad exponencial O(2^n) debido a llamadas redundantes.
    """
    if n <= 1:
        return n
    else:
        return fib(n - 1) + fib(n - 2)

# Crear un perfilador y ejecutar el código bajo su supervisión
with Profile() as profiler:
    print(fib(35))  # Ejecutar Fibonacci para n=35

    # Generar estadísticas ordenadas por tiempo acumulativo
    stats = Stats(profiler)
    stats.sort_stats(SortKey.CUMULATIVE)  # Ordenar por tiempo total (incluyendo subllamadas)
    stats.print_stats()  # Imprimir el reporte

Lo que nos da como resultado:

  29860712 function calls (10 primitive calls) in 4.957 seconds

En este caso tenemos aproximadamente 29 millones de llamadas a función, lo que demuestra la ineficiencia del enfoque recursivo naive para Fibonacci.

Ejemplo optimizado: Fibonacci con memoización usando LRU Cache

from cProfile import Profile
from pstats import SortKey, Stats
from functools import lru_cache

@lru_cache(maxsize=None)  # Decorador que implementa memoización automática
def fib(n):
    """
    Implementación de Fibonacci con memoización.
    Almacena resultados previos para evitar recomputación.
    Reduce la complejidad a O(n).
    """
    if n <= 1:
        return n
    else:
        return fib(n - 1) + fib(n - 2)

with Profile() as profiler:
    print(fib(35))  # Mismo valor que antes

    stats = Stats(profiler)
    stats.sort_stats(SortKey.CUMULATIVE)
    stats.print_stats()

Al hacer el profiling con memoización tenemos:

         45 function calls (10 primitive calls) in 0.000 seconds

En este caso solo tenemos 45 llamadas a función, una reducción drástica de más de 660,000 veces menos llamadas.

Conceptos teóricos

Perfilado determinístico vs. estadístico: - Perfilado determinístico (cProfile): Registra cada llamada a función, proporcionando información exacta pero con mayor overhead. - Perfilado estadístico (profile o herramientas externas): Muestrea el estado del programa a intervalos regulares, con menor overhead pero menos precisión.

Memoización: Técnica de optimización que almacena los resultados de llamadas a funciones costosas y retorna el resultado almacenado cuando se llama nuevamente con los mismos parámetros. @lru_cache implementa memoización con política LRU (Least Recently Used).

Overhead de cProfile: cProfile introduce un overhead del 30% al 45% en el tiempo de ejecución, lo que significa que los tiempos reportados son aproximadamente 1.3-1.45 veces más lentos que la ejecución normal.

Llamadas primitivas vs. no primitivas: - Llamadas primitivas: Número de llamadas únicas a funciones diferentes. - Llamadas totales: Incluye todas las invocaciones recursivas y repetidas.

Tabla de resumen

Concepto Descripción Ejemplo Impacto en rendimiento
cProfile Perfilador determinístico de Python with Profile() as profiler: Overhead 30-45%
Memoización Almacenar resultados para evitar recomputación @lru_cache(maxsize=None) Reduce O(2^n) a O(n)
LRU Cache Implementación de memoización con política LRU from functools import lru_cache Memoria adicional por velocidad
Tiempo acumulativo Tiempo total incluyendo subllamadas SortKey.CUMULATIVE Útil para identificar cuellos de botella
Llamadas primitivas Número de funciones diferentes llamadas "10 primitive calls" Indica diversidad de funciones
Llamadas totales Todas las invocaciones incluyendo recursivas "29860712 function calls" Mide carga computacional
Fibonacci naive Implementación recursiva sin optimización fib(n-1) + fib(n-2) O(2^n), exponencial
Fibonacci memoizado Con almacenamiento de resultados @lru_cache decorator O(n), lineal

Comentarios adicionales

  1. Cuándo usar cProfile: Ideal para análisis detallado durante desarrollo, especialmente para identificar funciones específicas que consumen mucho tiempo. No recomendado para producción debido al overhead.

  2. Interpretación de resultados:

  3. ncalls: Número de llamadas totales
  4. tottime: Tiempo total en la función (excluyendo subllamadas)
  5. percall: Tottime dividido por ncalls
  6. cumtime: Tiempo acumulado (incluyendo subllamadas)
  7. filename:lineno: Ubicación del código

  8. Alternativas a cProfile:

  9. profile: Versión pura de Python (más lenta, mayor overhead)
  10. py-spy: Perfilador estadístico sin overhead (requiere instalación)
  11. line_profiler: Análisis línea por línea
  12. memory_profiler: Análisis de uso de memoria

  13. Mejores prácticas:

  14. Perfilar siempre con datos representativos
  15. Comparar antes y después de optimizaciones
  16. Enfocarse en funciones con mayor cumtime
  17. Considerar el contexto de ejecución (carga del sistema, etc.)

  18. Limitaciones de cProfile:

  19. No mide consumo de memoria
  20. Overhead significativo
  21. No funciona bien con código multihilo
  22. Puede alterar el comportamiento de programas con tiempo real

  23. Optimización basada en profiling:

  24. Identificar funciones más llamadas (alto ncalls)
  25. Optimizar bucles internos (alto tottime con bajo ncalls)
  26. Considerar memoización para funciones puras
  27. Evaluar algoritmos alternativos para problemas con complejidad alta