Ir al contenido principal

Rendimiento y optimización

Introducción

El rendimiento del código es un aspecto fundamental en el desarrollo de software, especialmente cuando trabajamos con grandes volúmenes de datos o aplicaciones que requieren respuestas en tiempo real. Python, siendo un lenguaje interpretado, tiene ciertas características que afectan a su velocidad de ejecución. Sin embargo, existen numerosas técnicas y herramientas que nos permiten optimizar nuestro código para obtener un mejor rendimiento. En este artículo, exploraremos diferentes estrategias para mejorar la eficiencia de nuestros programas en Python.

Medición del rendimiento

Antes de optimizar, necesitamos medir el rendimiento actual de nuestro código:

Módulo time

import time

inicio = time.time()
# Código a medir
resultado = sum(range(10000000))
fin = time.time()

print(f"Tiempo de ejecución: {fin - inicio:.5f} segundos")

Módulo timeit

El módulo timeit está diseñado específicamente para medir pequeños fragmentos de código:

import timeit

# Medición de una función simple
tiempo = timeit.timeit('"-".join(str(n) for n in range(100))', number=10000)
print(f"Tiempo de ejecución: {tiempo:.5f} segundos")

# Con setup y usando funciones
setup = """
def mi_funcion():
    return [i * 2 for i in range(1000)]
"""
tiempo = timeit.timeit('mi_funcion()', setup=setup, number=1000)
print(f"Tiempo de ejecución: {tiempo:.5f} segundos")

Perfilado con cProfile

Para análisis más detallados, podemos usar el módulo cProfile:

import cProfile

def funcion_costosa():
    return sum(i * i for i in range(10000000))

# Perfila la función
cProfile.run('funcion_costosa()')

Técnicas de optimización

1. Estructuras de datos adecuadas

Elegir la estructura de datos correcta puede tener un gran impacto en el rendimiento:

# Ejemplo: Búsqueda en una lista vs en un conjunto
import timeit

setup_lista = """
lista = list(range(10000))
elemento = 9999  # Peor caso
"""

setup_conjunto = """
conjunto = set(range(10000))
elemento = 9999
"""

tiempo_lista = timeit.timeit('elemento in lista', setup=setup_lista, number=1000)
tiempo_conjunto = timeit.timeit('elemento in conjunto', setup=setup_conjunto, number=1000)

print(f"Tiempo de búsqueda en lista: {tiempo_lista:.5f} segundos")
print(f"Tiempo de búsqueda en conjunto: {tiempo_conjunto:.5f} segundos")

2. Comprensiones vs bucles tradicionales

Las comprensiones suelen ser más rápidas que los bucles tradicionales:

# Comparativa de formas de crear una lista de cuadrados
setup = "N = 10000"

codigo_for = """
resultado = []
for i in range(N):
    resultado.append(i * i)
"""

codigo_comprension = """
resultado = [i * i for i in range(N)]
"""

tiempo_for = timeit.timeit(codigo_for, setup=setup, number=1000)
tiempo_comprension = timeit.timeit(codigo_comprension, setup=setup, number=1000)

print(f"Tiempo con bucle for: {tiempo_for:.5f} segundos")
print(f"Tiempo con comprensión: {tiempo_comprension:.5f} segundos")

3. Funciones integradas y operaciones vectorizadas

Las funciones integradas de Python y las operaciones vectorizadas (con NumPy) son mucho más rápidas:

import timeit
import numpy as np

setup_python = """
lista = list(range(1000000))
"""

setup_numpy = """
import numpy as np
array = np.arange(1000000)
"""

tiempo_python = timeit.timeit('sum(lista)', setup=setup_python, number=100)
tiempo_numpy = timeit.timeit('np.sum(array)', setup=setup_numpy, number=100)

print(f"Suma con Python puro: {tiempo_python:.5f} segundos")
print(f"Suma con NumPy: {tiempo_numpy:.5f} segundos")

4. Reducción de operaciones costosas

Evitar cálculos repetitivos y operaciones costosas:

# Versión no optimizada
def calcular_distancias_no_optimizado(puntos):
    distancias = []
    for i, (x1, y1) in enumerate(puntos):
        for j, (x2, y2) in enumerate(puntos):
            if i != j:
                # Calculamos la raíz cuadrada cada vez
                distancia = ((x2 - x1) ** 2 + (y2 - y1) ** 2) ** 0.5
                distancias.append(distancia)
    return distancias

# Versión optimizada
def calcular_distancias_optimizado(puntos):
    distancias = []
    for i, (x1, y1) in enumerate(puntos):
        for j, (x2, y2) in enumerate(puntos[i+1:], i+1):
            # Evitamos cálculos duplicados y la raíz hasta el final
            distancia_cuadrado = (x2 - x1) ** 2 + (y2 - y1) ** 2
            distancias.append(distancia_cuadrado)
    
    # Calculamos todas las raíces juntas al final
    return [d ** 0.5 for d in distancias]

5. Almacenamiento en caché y memoización

La memoización almacena resultados previos para evitar recálculos:

# Fibonacci sin memoización
def fibonacci(n):
    if n <= 1:
        return n
    return fibonacci(n-1) + fibonacci(n-2)

# Fibonacci con memoización
def fibonacci_memo(n, memo={}):
    if n in memo:
        return memo[n]
    if n <= 1:
        resultado = n
    else:
        resultado = fibonacci_memo(n-1, memo) + fibonacci_memo(n-2, memo)
    memo[n] = resultado
    return resultado

# Con el decorador functools.lru_cache
from functools import lru_cache

@lru_cache(maxsize=None)
def fibonacci_cache(n):
    if n <= 1:
        return n
    return fibonacci_cache(n-1) + fibonacci_cache(n-2)

6. Uso de bibliotecas compiladas

Utilizar bibliotecas como NumPy, Pandas, o módulos en C:

import numpy as np

# Operación vectorizada con NumPy (mucho más rápida)
def multiplicar_matrices_numpy(a, b):
    return np.dot(a, b)

# Comparación con implementación pura de Python
def multiplicar_matrices_python(a, b):
    filas_a = len(a)
    cols_a = len(a[0])
    cols_b = len(b[0])
    
    c = [[0 for _ in range(cols_b)] for _ in range(filas_a)]
    
    for i in range(filas_a):
        for j in range(cols_b):
            for k in range(cols_a):
                c[i][j] += a[i][k] * b[k][j]
    
    return c

7. Generadores para grandes conjuntos de datos

Los generadores consumen menos memoria que las listas:

# Uso de memoria con listas
def procesar_numeros_lista(n):
    numeros = [i * i for i in range(n)]  # Almacena todos los valores en memoria
    for numero in numeros:
        yield numero

# Uso de memoria con generadores
def procesar_numeros_generador(n):
    for i in range(n):
        yield i * i  # Genera valores sobre la marcha

# El generador consume mucha menos memoria para n grande

8. Multiprocesamiento y concurrencia

Para tareas intensivas en CPU o E/S:

import concurrent.futures
import time

# Función a ejecutar en paralelo
def procesar_dato(dato):
    # Simulamos una tarea intensiva en CPU
    time.sleep(1)  # Simulación de trabajo
    return dato * dato

datos = list(range(10))

# Ejecución secuencial
inicio = time.time()
resultados_secuencial = [procesar_dato(dato) for dato in datos]
fin = time.time()
print(f"Tiempo secuencial: {fin - inicio:.2f} segundos")

# Ejecución en paralelo
inicio = time.time()
with concurrent.futures.ProcessPoolExecutor() as executor:
    resultados_paralelo = list(executor.map(procesar_dato, datos))
fin = time.time()
print(f"Tiempo en paralelo: {fin - inicio:.2f} segundos")

Consejos adicionales para optimización

  1. Evitar la creación excesiva de objetos: Reutiliza objetos cuando sea posible.
  2. Reducir el uso de funciones recursivas: Python tiene un límite de recursión y puede ser lento.
  3. Usar operadores in-place: Operadores como +=, *= son más eficientes.
  4. Localización de variables: El acceso a variables locales es más rápido que a globales.
  5. Evitar excepciones en el flujo principal: Las excepciones tienen un coste de rendimiento.
  6. Optimización selectiva: Concentrarse en optimizar el código que realmente lo necesita (regla del 80/20).

Resumen

La optimización del rendimiento en Python requiere un enfoque metodológico: primero medir, luego identificar cuellos de botella, y finalmente aplicar técnicas específicas. Hemos visto cómo elegir estructuras de datos adecuadas, aprovechar las características del lenguaje, utilizar bibliotecas optimizadas y aplicar técnicas avanzadas como la memoización y el multiprocesamiento pueden mejorar significativamente el rendimiento de nuestras aplicaciones. Recuerda siempre que la optimización prematura es la raíz de muchos problemas, por lo que es recomendable escribir código claro y legible primero, y optimizar solo cuando sea necesario y esté justificado por mediciones concretas de rendimiento.