Python se pensó para ocultar los detalles de implementación del lenguaje y que el desarrollador pudiera centrarse en los aspectos prácticos de la resolución de problemas.
Desafortunadamente, la abstracción está reñida con el rendimiento y los mecanismos que empleamos para mantener un diseño modular, reutilizable y cohesionado, pero sin acoplamiento o dependencias fuertes, añaden una sobrecarga que puede ser evidente en ciertas situaciones. Al fin y al cabo los procesadores se diseñaron para optimizar la ejecución de código ensamblador, no Python.
Los optimizadores de código de los lenguajes compilados hacen precisamente esto: eliminan la abstracción y acoplan los subsistemas del lenguaje tomando decisiones que suelen intercambiar especio por velocidad.
Afortunadamente para nosotros, la compilación no es el único recurso. La naturaleza del problema puede arrojar pistas de mejores estrategias para resolver el mismo, sin tener que descender al mundo de los lenguajes compilados.
Para poder optimizar un problema, primero debemos caracterizarlo. Existen
algunas herramientas en el ecosistema Python para realizar este análisis y
el propio Python incluye el módulo cProfiler
para este fin.
-
Crea un fichero
profiling.py
y añade el siguiente listado:import time def improvable(): for i in range(10000000): _ = i**2 time.sleep(2) def not_improvable(): time.sleep(1) def main(): not_improvable() improvable() if __name__ == '__main__': main()
-
Ahora, desde una línea de comandos, ejecuta:
$ python -m cProfile profiling.py
El resultado tendrá una pinta similar a:
$ python -m cProfile profiling.py 8 function calls in 6.117 seconds Ordered by: standard name ncalls tottime percall cumtime percall filename:lineno(function) 1 0.000 0.000 6.117 6.117 profiling.py:1(<module>) 1 0.000 0.000 6.117 6.117 profiling.py:11(main) 1 3.108 3.108 5.112 5.112 profiling.py:3(improvable) 1 0.000 0.000 1.004 1.004 profiling.py:8(not_improvable) 1 0.000 0.000 6.117 6.117 {built-in method builtins.exec} 2 3.008 1.504 3.008 1.504 {built-in method time.sleep} 1 0.000 0.000 0.000 0.000 {method 'disable' of '_lsprof.Profiler' objects}
Donde existe una fila por cada función involucrada en la ejecución y los campos significan:
ncalls
es el número de llamadas a la función.tottime
es el tiempo total pasado en esa función, sin contar el tiempo pasado en las sub-llamadas.percall
es la media de tiempo total por llamada.cumtime
es el tiempo total empleado en esa función, incluyendo sub-llamadas.percall
es la media del tiempo acumulado por llamada.filename:lineno(function)
indica dónde se produce la inversión de tiempo.
-
El módulo
cPython
permite grabar exportar esta información:$ python -m cProfile -o data.prof profiling.py
Y utilizar SnakeViz o tuna para visualizar el resultado:
$ pip install tuna snakeviz $ snakeviz data.prof
Cuidado, porque en las interfaces gráficas puede darse una interpretación distinta a los mismos nombre. La mayoría llama "tiempo total" al tiempo invertido por una función y las sub-llamadas. Cuando esto pasa, el tiempo empleado sólo en la función se suele llamar "tiempo local" o se marca de alguna forma.
Los gráficos de llamas son útiles porque permiten visualizar fracciones de tiempo. En lenguajes interpretados, la mayor parte del fondo de las gráficas son llamadas a las funciones de la biblioteca estándar, muchas de ellas nativas y, por tanto, "inmejorables".
Conviene primero buscar las funciones que consumen más tiempo acumulado y, de entre ellas, comenzar por las que emplean más tiempo en sí mismas.
La ley de Amdahl dice:
La mejora en la latencia de un sistema está limitada por la porción de tiempo en que no obtenemos ninguna ganancia.
La ley de Amdahl se expresa matemáticamente como 1/(1 - p)
donde p
es la
fracción de tiempo en la que podemos aplicar la mejora.
Por ejemplo, supón que caracterizamos un software y descubrimos que el 80%
del tiempo lo empleamos en una función fácilmente paralelizable. Cuestión de
arrojar más núcleos al problema. Lo que dice la ley de Amdahl es que la mejora
en velocidad está limitada por ese 20% en el que no podemos aplicar la mejora.
En particular, la mejora no puede ser superior a 1/(1 - 0.8) = 5
. O lo que
es lo mismo, el sistema nunca será más de 5 veces más rápido.
La mejora real del sistema se calcula en base a la mejora local, según la
expresión S = 1/((1 - p) + p/s)
donde S
es la mejora global (global
speedup) y p
vuelve a ser la fracción del tiempo donde podemos aplicar
la mejora local s
(local speedup).
¿Permite Python ejecución paralela?
La respuesta rápida es no. Sin embargo podemos ser algo más transigentes y plantearnos otra pregunta, más general:
¿Permite Python ejecución concurrente?
En este caso la respuesta es sí. La diferencia entre paralelismo y concurrencia es sutil aunque relevante. La concurrencia se produce cuando más de una tarea progresa al mismo tiempo, sin esperar las unas a la finalización de las otras. El paralelismo se produce cuando dos tareas se ejecutan al mismo tiempo.
Python soporta múltiples tareas (threads) pero sólo una de ellas puede estar ejecutándose a la vez en el intérprete. Quien posee la ejecución en cada momento, se dice que está en posesión del GIL. Sin embargo, Python interrumpirá las tareas automáticamente y bajo ciertas condiciones para dejar que otras tareas progresen antes de que la actual termine.
Ojo, es el intérprete de Python el que no puede ejecutar más de una tarea al mismo tiempo. Si la ejecución ha salido fuera del intérprete (a una llamada del sistema nativo en C, por ejemplo), esta restricción no aplica y la ejecución depende del sistema.
- GIL: Todo lo que quisiste saber y no te atreviste a preguntar
- La asincronía ilustrada en un caso de WebVR.
-
Reemplaza el código de
profiling.py
(o crea un fichero nuevo) con este otro:from urllib import request def get(url): response = request.urlopen(url) print(f'Body at {url}:\n {response.read()}') def main(): urls = [ 'https://raw.githubusercontent.com/python/cpython/master/README.rst', 'https://raw.githubusercontent.com/rust-lang/rust/master/README.md', 'https://raw.githubusercontent.com/ruby/ruby/master/README.md'] * 80 list(map(get, urls)) if __name__ == '__main__': main()
Caracterízalo y visualiza los resultados. ¿Qué observas?
-
Ahora cambia el código para que utilice hilos:
from urllib import request from concurrent.futures import ThreadPoolExecutor def get(url): response = request.urlopen(url) print(f'Body at {url}:\n {response.read()}') def main(): urls = [ 'https://raw.githubusercontent.com/python/cpython/master/README.rst', 'https://raw.githubusercontent.com/rust-lang/rust/master/README.md', 'https://raw.githubusercontent.com/ruby/ruby/master/README.md'] * 80 with ThreadPoolExecutor(max_workers=5) as executor: executor.map(get, urls) if __name__ == '__main__': main()
Caracterízalo en un fichero de salida distinto y visualiza los resultados. Compáralos con los de antes.
-
Considera el siguiente código:
from datetime import datetime import math PRIMES = [ 112272535095293, 112582705942171, 112272535095293, 115280095190773, 115797848077099, 1099726899285419] def is_prime(n): if n % 2 == 0: return False sqrt_n = int(math.floor(math.sqrt(n))) for i in range(3, sqrt_n + 1, 2): if n % i == 0: return False return True def main(): start = datetime.now() for number, prime in zip(PRIMES, map(is_prime, PRIMES)): print('%d is prime: %s' % (number, prime)) print('Elapsed time:', datetime.now() - start) if __name__ == '__main__': main()
-
Ahora considera su versión multi-hilo:
from datetime import datetime from concurrent.futures import ThreadPoolExecutor import math PRIMES = [ 112272535095293, 112582705942171, 112272535095293, 115280095190773, 115797848077099, 1099726899285419] def is_prime(n): if n % 2 == 0: return False sqrt_n = int(math.floor(math.sqrt(n))) for i in range(3, sqrt_n + 1, 2): if n % i == 0: return False return True def main(): start = datetime.now() with ThreadPoolExecutor(max_workers=2) as executor: for number, prime in zip(PRIMES, executor.map(is_prime, PRIMES)): print('%d is prime: %s' % (number, prime)) print('Elapsed time:', datetime.now() - start) if __name__ == '__main__': main()
Caracterízala y observa las diferencias. ¿Qué ha pasado?
-
Ahora prueba con la siguiente versión multiproceso:
from datetime import datetime from concurrent.futures import ProcessPoolExecutor import math PRIMES = [ 112272535095293, 112582705942171, 112272535095293, 115280095190773, 115797848077099, 1099726899285419] def is_prime(n): if n % 2 == 0: return False sqrt_n = int(math.floor(math.sqrt(n))) for i in range(3, sqrt_n + 1, 2): if n % i == 0: return False return True def main(): start = datetime.now() with ProcessPoolExecutor(max_workers=2) as executor: for number, prime in zip(PRIMES, executor.map(is_prime, PRIMES)): print('%d is prime: %s' % (number, prime)) print('Elapsed time:', datetime.now() - start) if __name__ == '__main__': main()
No intentes caracterizarlo, no funcionará. Es una de las limitaciones de cProfile: no funciona con mutiprocesamiento.
-
Ve aumentando el parámetro
processes
desde 2 hasta que no notes ninguna mejora. ¿Por qué deja de haber mejora? Compara este número con el número de núcleos de tu ordenador.
En general, tendrás que distinguir si tus programas están constreñidos por la CPU o por la entrada y salida. Estas situaciones se conocen respectivamente como CPU Bound e I/O Bound.
Si tu aplicación es I/O Bound pasará más tiempo fuera del intérprete de Python que dentro. En este caso, cualquiera de las aproximaciones es válida aunque la concurrencia es más ligera y no implica al sistema operativo.
Si tu aplicación es CPU Bound, entonces pasará más tiempo en el intérprete de Python que fuera. Es lo que pasa con los algoritmos matemáticos de solución de problemas algebráicos o de cálculo. En este caso tienes que llevarte la gestión de la multitarea al sistema operativo con multiprocesamiento porque el intérprete sólo permitirá a un hilo operar al mismo tiempo.
Antes de problar con paralelización, sobre todo si nuestro código es de tipo "operaciones", convendría probar Numba, una biblioteca que implementa un compilador "al vuelo" compatible con la implementación de referencia, CPython.
Sencillamente importa el decorador jit
y aplícalo a tu función crítica
(Numba viene incluído en Anaconda, así que puedes usarlo desde el entorno
virtual base
que instalaste en el tema anterior):
from numba import jit
from datetime import datetime
import math
PRIMES = [
112272535095293,
112582705942171,
112272535095293,
115280095190773,
115797848077099,
1099726899285419]
@jit(nopython=True)
def is_prime(n):
if n % 2 == 0:
return False
sqrt_n = int(math.floor(math.sqrt(n)))
for i in range(3, sqrt_n + 1, 2):
if n % i == 0:
return False
return True
def main():
start = datetime.now()
for number, prime in zip(PRIMES, map(is_prime, PRIMES)):
print('%d is prime: %s' % (number, prime))
print('Elapsed time:', datetime.now() - start)
if __name__ == '__main__':
main()
El parámetro nopython=True
se pasa para que el compilador produzca un
código más rápido, sin volver a la ejecución Python, en caso de que no
consiguiera compilar el código. Es como indicar "sólo código nativo".
En el mundo real, la verdad es que Numba está muy limitado en cuanto a
compatibilidad con Python. El código optimizable por Numba no es muy
pythonico, podríamos decir. Si usas Numba, evita el uso de generadores y
listas de listas; si necesitas listas anidadas, considera usar los
ndarray
de NumPy
Numba es además capaz de paralelizar código automáticamente, compilar para SIMD e, incluso, ¡para GPUs!
Otra alternativa es utilizar una implementación de Python más rápida, como PyPy. PyPy es, actualmente, compatible con Python 3.6 y una de sus desventajas es, precisamente, que suele ir una versión por detrás del intérprete oficial y que no todo el software compatible con CPython, lo es también con PyPy.
No obstante, si puedes permitirte ir una versión por detrás y resulta que no utilizas nada incompatible con el intérprete, la ganancia vale la pena, aunque es similar a la de Numba.
-
Abre una consola y usa pyenv para instalar la última versión de PyPy:
$ pyenv install pypy3.6-7.1.0
-
Restaura la versión mono-hilo del comprobador de primos:
from datetime import datetime import math PRIMES = [ 112272535095293, 112582705942171, 112272535095293, 115280095190773, 115797848077099, 1099726899285419] def is_prime(n): if n % 2 == 0: return False sqrt_n = int(math.floor(math.sqrt(n))) for i in range(3, sqrt_n + 1, 2): if n % i == 0: return False return True def main(): start = datetime.now() for number, prime in zip(PRIMES, map(is_prime, PRIMES)): print('%d is prime: %s' % (number, prime)) print('Elapsed time:', datetime.now() - start) if __name__ == '__main__': main()
-
Lanza el script desde una terminal:
$ python profiling.py
-
Ahora instala y activa la última versión de pypy con pyenv:
$ pyenv install pypy3.6-7.1.0 $ pyenv shell pypy3.6-7.1.0
-
Lanza de nuevo el script, una vez activado pypy:
$ python profiling.py
PyPy está escrito en RPython, una versión especial de Python que puede compilarse a código específico de la plataforma. Y lo que es más, todo una cadena de herramientas (toolchain) para la creación de intérpretes super-rápidos de lenguajes dinámicos.
- Compatibilidad con los 1000 paquetes más utilizados de Python.
- Documentación de RPython.
Como su propio nombre indica, CPython está escrito en C, y es compatible con
más código C, siempre y cuando siga ciertos convenios. Las extensiones son
módulos escritos en C y luego compilados, nombrados como modulo.so
o
modulo.pyd
, en Windows. El sistema de importación de Python permite la carga
de módulos .so
/.pyd
sin que tengamos que intervenir:
```python
import modulo
```
Una forma de escribir extensiones es en C directamente. Otra forma es usar Cython: un lenguaje transpilado, distinto de Python, que es además un superconjunto de Python. En particular, Cython permite la anotación de código Python con tipos de C.
Cython está disponible con Anaconda y también es instalable a través de pip:
$ pip install cython
-
Crea una carpeta
primes
y dentro, dos ficheros:primality_test.py
yprimer.pyx
(fíjate en la extensión.pyx
del segundo módulo). -
En
primality_test.py
pon la parte del código mono-hilo que hace uso de la funciónis_prime
:import pyximport pyximport.install() from profilingnative import is_prime from datetime import datetime PRIMES = [ 112272535095293, 112582705942171, 112272535095293, 115280095190773, 115797848077099, 1099726899285419] def main(): start = datetime.now() for number, prime in zip(PRIMES, map(is_prime, PRIMES)): print('%d is prime: %s' % (number, prime)) print('Elapsed time:', datetime.now() - start) if __name__ == '__main__': main()
Fíjate en las dos primeras líneas del fichero. El modulo
pyximport
permite cargar módulos.pyx
. La utilidad compila el módulo en tiempo de importación y luego lo importa como una extensión nativa de Python. -
En el módulo
primes.pyx
deja el código mono-hilo de la implementación:import math def is_prime(n): if n % 2 == 0: return False sqrt_n = int(math.floor(math.sqrt(n))) for i in range(3, sqrt_n + 1, 2): if n % i == 0: return False return True
-
Ejecuta el módulo
primality_test.py
desde una terminal (asegúrate de que se trata del intérprete de referencia):$ python primality_test.py
-
Transforma ahora el código de
primer.pyx
, enriqueciéndolo con anotaciones en Cython:import math def is_prime(long int n): if n % 2 == 0: return False cdef long int sqrt_n = math.floor(math.sqrt(n)) cdef long int i for i in range(3, sqrt_n + 1, 2): if n % i == 0: return False return True
Lo único que has hecho es anotar los tipos de las variables que intervienen en el algoritmo
sqrt_n
,i
yn
. Para anotar las variables se utiliza la palabra clavecdef
(de "C definition") y los mismos tipos que se usarían en C.
En situaciones del mundo real, no todo es tan sencillo. El tipado de los distintos objetos es una habilidad en sí misma y conviene estar muy familiarizacon con el sistema de tipos de Cython.
Para el manejo de listas, es conveniente usar el módulo array
, que
proporciona, de manera nativa, una implementación compacta de listas homogéneas,
a lo ndarray
de NumPy, aunque menos sofisticada. De hecho, también es
perfectamente posible y recomendable utilizar los ndarray
de NumPy.
El compilador de Cython traduce a C. Se debe usar un compilador de C para generar la extensión.
-
En la carpeta
primer
, crea un ficherosetup.py
y en su interior escribe:from distutils.core import setup from Cython.Build import cythonize setup( ext_modules = cythonize("primes.pyx") )
-
Ahora, desde una terminal, ejecuta:
$ python setup.py build_ext --inplace
El resultado de la ejecución es el fichero
primes.c
con el código traducido y un ficheroprimes.<plataforma>.so
. Este es el fichero que cargará Python. -
Modifica
primality_test.py
y elimina las dos primeras líneas, que quede:from profilingnative import is_prime from datetime import datetime PRIMES = [ 112272535095293, 112582705942171, 112272535095293, 115280095190773, 115797848077099, 1099726899285419] def main(): start = datetime.now() for number, prime in zip(PRIMES, map(is_prime, PRIMES)): print('%d is prime: %s' % (number, prime)) print('Elapsed time:', datetime.now() - start) if __name__ == '__main__': main()
-
Ejecuta el programa desde la terminal:
$ python primality_test.py
Crea ahora una nueva carpeta matrix
y añade a un archivo mul_matrices.py
el siguiente código para la multiplicación de matrices (asume que las matrices
son cuadradas):
from datetime import datetime
def mul(a, b):
size = len(a)
result = [[0 for _ in range(size)] for _ in range(size)]
for a_row_index, row in enumerate(a):
for b_col_index in range(size):
col = (b[r][b_col_index] for r in range(size))
item = sum(va * vb for va, vb in zip(row, col))
result[a_row_index][b_col_index] = item
return result
def main():
SIZE = 200
a = [[i for i in range(SIZE)] for _ in range(SIZE)]
b = [[i for i in range(SIZE)] for _ in range(SIZE)]
start = datetime.now()
print(mul(a, b))
print('Elapsed time:', datetime.now() - start)
if __name__ == '__main__':
main()
Utiliza las distintas técnicas que has aprendido para mejorar el tiempo de la función. Como estrategia general, trata de seguir este plan:
Primero prueba la opción más sencilla: cambia a PyPy y mira cómo se comporta el código sin cambiar nada. Apunta los tiempos típicos de cada optimización que pruebes.
Ahora vuelve a CPython y prueba a simplificar el código. Trata de eliminar
cuantas más abstracciones puedas, mejor. Esto quiere decir que elimines
los generadores, más allá del range(x)
(aunque si puedes eliminar este,
hazlo también). Toma nota del tiempo típico.
Seguidamente, prueba a utilizar Numba. Es posible que tengas problemas
al tratar de usar listas de listas. Si es tu caso, prueba a usar el
decorador njit
en combinación con los ndarray
de NumPy.
Finalmente, prueba la opción de la extensión nativa. Mueve el código de mul()
a un módulo .pyx
e impórtalo desde el fichero principal. No te olvides de
importar pyximport
y llamar a su método install()
.
Comienza tipando los índices con los que recorres las listas para ver si hay
cambios. Cuando se te acaben los índices, pasa a las listas, que habrás de
cambiar por ndarray
de NumPy. Los ndarray
s se tipan con la sintáxis
cdef int[:, :]
, para un array de enteros de 2 dimensiones o cdef int[:]
,
para un array de enteros unidimensional.
¡Suerte!