Capítulo 1. Comprender el Python Performante
Este trabajo se ha traducido utilizando IA. Agradecemos tus opiniones y comentarios: translation-feedback@oreilly.com
Programar ordenadores puede concebirse como mover bits de datos y transformarlos de formas especiales para conseguir un resultado concreto. Sin embargo, estas acciones tienen un coste de tiempo. En consecuencia, la programación de alto rendimiento puede concebirse como el acto de minimizar estas operaciones, ya sea reduciendo la sobrecarga (es decir, escribiendo un código más eficiente) o cambiando la forma en que realizamos estas operaciones para que cada una tenga más sentido (es decir, encontrando un algoritmo más adecuado).
Centrémonos en reducir la sobrecarga en el código para obtener más información sobre el hardware real en el que estamos moviendo estos bits. Esto puede parecer un ejercicio inútil, ya que Python se esfuerza bastante en abstraer las interacciones directas con el hardware. Sin embargo, si comprendes tanto la mejor forma en que se pueden mover los bits en el hardware real como las formas en que las abstracciones de Python obligan a tus bits a moverse, podrás avanzar hacia la escritura de programas de alto rendimiento en Python.
El Sistema Informático Fundamental
Los componentes subyacentes que forman un ordenador pueden simplificarse en tres partes básicas: las unidades de cálculo, las unidades de memoria y las conexiones entre ellas. Además, cada una de estas unidades tiene propiedades diferentes que podemos utilizar para comprenderlas. La unidad de cálculo tiene la propiedad de cuántos cálculos puede hacer por segundo, la unidad de memoria tiene las propiedades de cuántos datos puede contener y a qué velocidad podemos leer y escribir en ella y, por último, las conexiones tienen la propiedad de la velocidad a la que pueden mover datos de un lugar a otro.
Utilizando estos bloques de construcción, podemos hablar de una estación de trabajo estándar en múltiples niveles de sofisticación. Por ejemplo, se puede pensar que la estación de trabajo estándar tiene una unidad central de procesamiento (CPU) como unidad de cálculo, conectada tanto a la memoria de acceso aleatorio (RAM) como al disco duro como dos unidades de memoria independientes (cada una con capacidades y velocidades de lectura/escritura diferentes) y, por último, un bus que proporciona las conexiones entre todas estas partes. Sin embargo, también podemos entrar en más detalles y ver que la propia CPU tiene varias unidades de memoria en su interior: la caché L1, la L2 y, a veces, incluso la L3 y la L4, que tienen pequeñas capacidades pero velocidades muy rápidas (desde varios kilobytes hasta una docena de megabytes). Además, las nuevas arquitecturas informáticas suelen venir acompañadas de nuevas configuraciones (por ejemplo, las CPU SkyLake de Intel sustituyeron el bus frontal por el Intel Ultra Path Interconnect y reestructuraron muchas conexiones). Por último, en estas dos aproximaciones de una estación de trabajo hemos descuidado la conexión de red, ¡que es en realidad una conexión muy lenta a potencialmente muchas otras unidades de cálculo y de memoria!
Para ayudar a desentrañar estos diversos entresijos, repasemos una breve descripción de estos bloques fundamentales.
Unidades de cálculo
La unidad de cálculo de un ordenador es la pieza central de su utilidad: proporciona la capacidad de transformar los bits que recibe en otros bits o de cambiar el estado del proceso en curso. Las CPU son la unidad de cálculo más utilizada; sin embargo, las unidades de procesamiento gráfico (GPU) de están ganando popularidad como unidades de cálculo auxiliares. Originalmente se utilizaban para acelerar los gráficos por ordenador, pero cada vez son más aplicables a las aplicaciones numéricas y son útiles gracias a su naturaleza intrínsecamente paralela, que permite que se realicen muchos cálculos simultáneamente. Independientemente de su tipo, una unidad de cálculo toma una serie de bits (por ejemplo, bits que representan números) y emite otra serie de bits (por ejemplo, bits que representan la suma de esos números). Además de las operaciones aritméticas básicas con números enteros y reales y de las operaciones a nivel de bits con números binarios, algunas unidades de cálculo también proporcionan operaciones muy especializadas, como la operación "suma múltiple fusionada", que toma tres números, A
, B
, y C
, y devuelve el valor A * B + C
.
Las principales propiedades de interés de una unidad de cálculo son el número de operaciones que puede realizar en un ciclo y el número de ciclos que puede realizar en un segundo. El primer valor se mide por sus instrucciones por ciclo (IPC),1 mientras que el segundo valor se mide por su velocidad de reloj . Estas dos medidas compiten siempre entre sí cuando se fabrican nuevas unidades de cálculo. Por ejemplo, la serie Core de Intel tiene un IPC muy alto pero una velocidad de reloj más baja, mientras que el chip Pentium 4 tiene lo contrario. Las GPU, por otro lado, tienen un IPC y una velocidad de reloj muy altos, pero sufren otros problemas como la lentitud de las comunicaciones que tratamos en "Capas de comunicaciones".
Además, aunque el aumento de la velocidad de reloj acelera casi inmediatamente todos los programas que se ejecutan en esa unidad de cálculo (porque son capaces de hacer más cálculos por segundo), tener un IPC más alto también puede afectar drásticamente a la computación al cambiar el nivel de vectorización que es posible.La vectorización se produce cuando una CPU recibe varios datos a la vez y es capaz de operar con todos ellos a la vez. Este tipo de instrucción de la CPU se conoce como instrucción única, datos múltiples (SIMD).
En general, las unidades informáticas han avanzado con bastante lentitud en la última década (ver Figura 1-1). Tanto la velocidad de reloj como el IPC se han estancado debido a las limitaciones físicas de hacer transistores cada vez más pequeños. Por ello, los fabricantes de chips han recurrido a otros métodos para ganar velocidad, como el multihilo simultáneo (en el que pueden ejecutarse varios hilos a la vez), la ejecución fuera de orden más inteligente y las arquitecturas multinúcleo.
Hyperthreading presenta una segunda CPU virtual al sistema operativo (SO) anfitrión, y una lógica de hardware inteligente intenta intercalar dos hilos de instrucciones en las unidades de ejecución de una única CPU. Cuando tiene éxito, se pueden conseguir ganancias de hasta un 30% respecto a un único hilo. Normalmente, esto funciona bien cuando las unidades de trabajo de ambos subprocesos utilizan diferentes tipos de unidades de ejecución, por ejemplo, una realiza operaciones de coma flotante y la otra operaciones con números enteros.
La ejecución fuera de orden permite al compilador detectar que algunas partes de la secuencia lineal de un programa no dependen de los resultados de una pieza de trabajo anterior y, por tanto, que ambas piezas de trabajo podrían producirse en cualquier orden o al mismo tiempo. Siempre que los resultados secuenciales se presenten en el momento adecuado, el programa seguirá ejecutándose correctamente, aunque las piezas de trabajo se computen fuera de su orden programado. Esto permite que algunas instrucciones se ejecuten cuando otras podrían estar bloqueadas (por ejemplo, esperando un acceso a la memoria), lo que permite una mayor utilización global de losrecursos disponibles.
Por último, y lo más importante para el programador de alto nivel, está la prevalencia de las arquitecturas multinúcleo. Estas arquitecturas incluyen varias CPU dentro de la misma unidad, lo que aumenta la capacidad total sin tropezar con barreras que impidan hacer más rápida cada unidad individual. Por eso, actualmente es difícil encontrar una máquina con menos de dos núcleos: en este caso, el ordenador tiene dos unidades de cálculo físicas conectadas entre sí. Aunque esto aumenta el número total de operaciones que pueden realizarse por segundo, ¡puede dificultar la escritura de código!
El simple hecho de añadir más núcleos a una CPU no siempre acelera el tiempo de ejecución de un programa. Esto se debe a algo conocido como Ley de Amdahl. En pocas palabras, la ley de Amdahl es la siguiente: si un programa diseñado para ejecutarse en varios núcleos tiene algunas subrutinas que deben ejecutarse en un solo núcleo, ésta será la limitación para la aceleración máxima que puede conseguirse asignando más núcleos.
Por ejemplo, si tuviéramos una encuesta que quisiéramos que rellenaran cien personas, y esa encuesta tardara 1 minuto en completarse, podríamos completar esta tarea en 100 minutos si tuviéramos a una persona haciendo las preguntas (es decir, esta persona va al participante 1, hace las preguntas, espera las respuestas y luego pasa al participante 2). Este método de que una persona haga las preguntas y espere las respuestas es similar a un proceso en serie. En los procesos en serie, tenemos operaciones que se satisfacen de una en una, y cada una espera a que se complete la operación anterior.
Sin embargo, podríamos realizar la encuesta en paralelo si tuviéramos a dos personas haciendo las preguntas, lo que nos permitiría terminar el proceso en sólo 50 minutos. Esto puede hacerse porque cada una de las personas que hacen las preguntas no necesita saber nada de la otra. Como resultado, la tarea puede dividirse fácilmente sin que haya dependencia entre los que hacen las preguntas.
Añadiendo más personas que formulen las preguntas conseguiremos más aumentos de velocidad, hasta que tengamos cien personas formulando preguntas. En este punto, el proceso duraría 1 minuto y estaría limitado simplemente por el tiempo que tarda un participante en responder a las preguntas. Aumentar el número de personas que hacen preguntas no aumentará la velocidad, porque estas personas adicionales no tendrán tareas que realizar: ¡ya se están haciendo preguntas a todos los participantes! Llegados a este punto, la única forma de reducir el tiempo total de ejecución de la encuesta es reducir el tiempo que tarda en completarse una encuesta individual, la parte en serie del problema. De forma similar, con las CPU, podemos añadir más núcleos que puedan realizar varias partes del cálculo según sea necesario hasta que lleguemos a un punto en el que el cuello de botella de sea el tiempo que tarda un núcleo específico en terminar su tarea. En otras palabras, el cuello de botella en cualquier cálculo paralelo son siempre las tareas en serie más pequeñas que se están repartiendo.
Además, un obstáculo importante para utilizar varios núcleos en Python es el uso que hace Python del bloqueo global del intérprete (GIL). El GIL se asegura de que un proceso Python sólo pueda ejecutar una instrucción a la vez, independientemente del número de núcleos que esté utilizando en ese momento. Esto significa que, aunque algún código Python tenga acceso a varios núcleos a la vez, sólo un núcleo está ejecutando una instrucción Python en un momento dado. Utilizando el ejemplo anterior de una encuesta, esto significaría que aunque tuviéramos 100 preguntadores, sólo una persona podría hacer una pregunta y escuchar una respuesta a la vez. Esto elimina de hecho cualquier tipo de ventaja de tener varios formuladores de preguntas. Aunque esto pueda parecer todo un obstáculo, especialmente si la tendencia actual en informática es tener múltiples unidades de cálculo en lugar de tener unas más rápidas, este problema puede evitarse utilizando otras herramientas de la biblioteca estándar, como multiprocessing
(Capítulo 9), tecnologías como numpy
o numexpr
(Capítulo 6), Cython(Capítulo 7), o modelos distribuidos de computación(Capítulo 10).
Nota
Python 3.2 también fue testigo de una importante reescritura de la GIL, que hizo el sistema mucho más ágil, aliviando muchas de las preocupaciones en torno al sistema para el rendimiento de un solo hilo. Aunque Python sigue bloqueado en la ejecución de una sola instrucción a la vez, el GIL ahora cambia mejor entre esas instrucciones y lo hace con menos sobrecarga.
Unidades de memoria
Las unidades de memoria de los ordenadores se utilizan para almacenar bits. Pueden ser bits que representen variables de tu programa o bits que representen los píxeles de una imagen. Por tanto, la abstracción de una unidad de memoria se aplica tanto a los registros de tu placa base como a tu memoria RAM y disco duro . La única diferencia importante entre todos estos tipos de unidades de memoria es la velocidad a la que pueden leer/escribir datos. Para complicar más las cosas, la velocidad de lectura/escritura depende en gran medida de la forma en que se leen los datos.
Por ejemplo, la mayoría de las unidades de memoria funcionan mucho mejor cuando leen un gran trozo de datos en lugar de muchos trozos pequeños (esto se conoce como lectura secuencial frente a datos aleatorios). Si pensamos en los datos de estas unidades de memoria como si fueran las páginas de un gran libro, esto significa que la mayoría de las unidades de memoria tienen mejores velocidades de lectura/escritura cuando recorren el libro página a página en lugar de pasar constantemente de una página aleatoria a otra. Aunque este hecho es generalmente cierto en todas las unidades de memoria, la medida en que esto afecta a cada tipo es drásticamente diferente.
Además de las velocidades de lectura/escritura, las unidades de memoria también tienenlatencia, que puede caracterizarse como el tiempo que tarda el dispositivo en encontrar los datos que se están utilizando. Para un disco duro giratorio, esta latencia puede ser alta porque el disco tiene que girar físicamente hasta alcanzar la velocidad y el cabezal de lectura debe moverse hasta la posición correcta. En cambio, para la RAM, esta latencia puede ser bastante pequeña porque todo es de estado sólido. He aquí una breve descripción de las distintas unidades de memoria que suelen encontrarse dentro de una estación de trabajo estándar, por orden de velocidades de lectura/escritura:2
- Disco duro giratorio
-
Almacenamiento a largo plazo que persiste incluso cuando se apaga el ordenador. Generalmente tiene velocidades de lectura/escritura lentas porque el disco debe girar y moverse físicamente. Rendimiento degradado con patrones de acceso aleatorio, pero capacidad muy grande (rango de 10 terabytes).
- Disco duro de estado sólido
-
Similar a un disco duro giratorio, con mayor velocidad de lectura/escritura pero menor capacidad (1 terabyte).
- RAM
-
Se utiliza para almacenar el código y los datos de la aplicación (como las variables que se utilicen). Tiene características de lectura/escritura rápidas y funciona bien con patrones de acceso aleatorio, pero su capacidad suele ser limitada (64 gigabytes).
- Caché L1/L2
-
Velocidades de lectura/escritura extremadamente rápidas. Los datos que van a la CPUdeben pasar por aquí. Capacidad muy pequeña (rango de megabytes).
La Figura 1-2 ofrece una representación gráfica de las diferencias entre estos tipos de unidades de memoria observando las características del hardware de consumo actualmente disponible .
Una tendencia claramente visible es que la velocidad de lectura/escritura y la capacidad son inversamente proporcionales: a medida que intentamos aumentar la velocidad, la capacidad se reduce. Por ello, muchos sistemas implementan un enfoque escalonado de la memoria: los datos comienzan en su estado completo en el disco duro, parte de ellos se mueven a la RAM y, a continuación, un subconjunto mucho más pequeño se mueve a la caché L1/L2. Este método de escalonamiento permite a los programas mantener la memoria en distintos lugares en función de los requisitos de velocidad de acceso. Cuando intentamos optimizar los patrones de memoria de un programa, simplemente estamos optimizando qué datos se colocan dónde, cómo se disponen (para aumentar el número de lecturas secuenciales) y cuántas veces se mueven entre las distintas ubicaciones. Además, métodos como la E/S asíncrona y la caché preventivaproporcionan formas de asegurarse de que los datos están siempre donde tienen que estar sin tener que perder tiempo de cálculo: ¡la mayoría de estos procesos pueden ocurrir independientemente, mientras se realizan otros cálculos!
Capas de comunicación
Por último, veamos cómo se comunican entre sí todos estos bloques fundamentales. Existen muchos modos de comunicación, pero todos son variantes de una cosa llamada bus.
El bus frontal, por ejemplo, es la conexión entre la RAM y la caché L1/L2. Mueve los datos que están listos para ser transformados por el procesador a la zona de almacenamiento para prepararlos para el cálculo, y mueve los cálculos terminados hacia fuera. También hay otros buses, como el bus externo que actúa como ruta principal desde los dispositivos de hardware (como discos duros y tarjetas de red) a la CPU y la memoria del sistema. Este bus externo suele ser más lento que el bus frontal.
De hecho, muchas de las ventajas de la caché L1/L2 son atribuibles al bus más rápido. Poder poner en cola los datos necesarios para el cálculo en grandes trozos en un bus lento (de la RAM a la caché) y luego tenerlos disponibles a velocidades muy rápidas desde las líneas de caché (de la caché a la CPU) permite a la CPU hacer más cálculos sin esperar tanto tiempo.
Del mismo modo, muchos de los inconvenientes de utilizar una GPU provienen del bus al que está conectada: como la GPU suele ser un dispositivo periférico, se comunica a través del bus PCI , que es mucho más lento que el bus frontal. Como resultado, introducir y extraer datos de la GPU puede ser una operación bastante agotadora. La llegada de la computación heterogénea, o bloques de computación que tienen tanto una CPU como una GPU en el bus frontal, pretende reducir el coste de transferencia de datos y hacer que la computación de la GPU sea una opción más disponible, incluso cuando hay que transferir muchos datos.
Además de los bloques de comunicación dentro del ordenador, la red puede considerarse como otro bloque de comunicación más. Este bloque, sin embargo, es mucho más flexible que los comentados anteriormente; un dispositivo de red puede conectarse a un dispositivo de memoria, como un dispositivo de almacenamiento conectado a la red (NAS) o a otro bloque informático, como en un nodo informático de un clúster. Sin embargo, las comunicaciones de red suelen ser mucho más lentas que los otros tipos de comunicaciones mencionados anteriormente. Mientras que el bus frontal puede transferir decenas de gigabits por segundo, la red está limitada al orden de varias decenas de megabits.
Está claro, pues, que la principal propiedad de un bus es su velocidad: cuántos datos puede mover en un tiempo determinado. Esta propiedad viene dada por la combinación de dos cantidades: cuántos datos pueden moverse en una transferencia (anchura del bus) y cuántas transferencias puede hacer el bus por segundo (frecuencia del bus). Es importante tener en cuenta que los datos que se mueven en una transferencia son siempre secuenciales: un trozo de datos se lee de la memoria y se mueve a un lugar diferente. Así pues, la velocidad de un bus se divide en estas dos cantidades porque individualmente pueden afectar a diferentes aspectos del cálculo: una gran anchura de bus puede ayudar al código vectorizado (o a cualquier código que lea secuencialmente a través de la memoria) al hacer posible mover todos los datos relevantes en una transferencia, mientras que, por otro lado, tener una anchura de bus pequeña pero una frecuencia de transferencias muy alta puede ayudar al código que debe hacer muchas lecturas de partes aleatorias de la memoria. Curiosamente, una de las formas en que los diseñadores de ordenadores modifican estas propiedades es mediante la disposición física de la placa base: cuando los chips se colocan cerca unos de otros, la longitud de los cables físicos que los unen es menor, lo que puede permitir velocidades de transferencia más rápidas. Además, el propio número de cables determina la anchura del bus (¡dando un significado físico real al término!).
Dado que las interfaces pueden ajustarse para ofrecer el rendimiento adecuado a una aplicación concreta, no es de extrañar que existan cientos de tipos. La Figura 1-3 muestra las tasas de bits de una muestra de interfaces comunes. Ten en cuenta que esto no habla en absoluto de la latencia de las conexiones, que dicta el tiempo que tarda en responderse una solicitud de datos (aunque la latencia depende mucho del ordenador, algunas limitaciones básicas son inherentes a las interfaces que se utilizan).
Reunir los elementos fundamentales
Entender los componentes básicos de un ordenador no basta para comprender plenamente los problemas de la programación de alto rendimiento. La interacción de todos estos componentes y cómo trabajan juntos para resolver un problema introduce niveles adicionales de complejidad. En esta sección exploraremos algunos problemas de juguete, ilustrando cómo funcionarían las soluciones ideales y cómo las aborda Python.
Una advertencia: esta sección puede parecer sombría: la mayoría de los comentarios de esta sección parecen decir que Python es nativamente incapaz de tratar los problemas de rendimiento. Esto es falso por dos razones. En primer lugar, entre todos los "componentes de la informática de alto rendimiento", hemos descuidado un componente muy importante: el desarrollador. Lo que a Python nativo le puede faltar en rendimiento, lo recupera enseguida con velocidad de desarrollo. Además, a lo largo del libro introduciremos módulos y filosofías que pueden ayudar a mitigar muchos de los problemas aquí descritos con relativa facilidad. Con estos dos aspectos combinados, mantendremos la mentalidad de desarrollo rápido de Python al tiempo que eliminamos muchas de las limitaciones de rendimiento.
La informática idealizada frente a la máquina virtual Python
Para comprender mejor los componentes de la programación de alto rendimiento, veamos un sencillo ejemplo de código que comprueba si un número es primo:
import
math
def
check_prime
(
number
):
sqrt_number
=
math
.
sqrt
(
number
)
for
i
in
range
(
2
,
int
(
sqrt_number
)
+
1
):
if
(
number
/
i
)
.
is_integer
():
return
False
return
True
(
f
"check_prime(10,000,000) =
{
check_prime
(
10_000_000
)
}
"
)
# check_prime(10,000,000) = False
(
f
"check_prime(10,000,019) =
{
check_prime
(
10_000_019
)
}
"
)
# check_prime(10,000,019) = True
Analicemos este código utilizando nuestro modelo abstracto de computación y luego establezcamos comparaciones con lo que ocurre cuando Python ejecuta este código. Como ocurre con cualquier abstracción, pasaremos por alto muchas de las sutilezas tanto del ordenador idealizado como de la forma en que Python ejecuta el código. Sin embargo, éste suele ser un buen ejercicio para realizar antes de resolver un problema: pensar en los componentes generales del algoritmo y en cuál sería la mejor forma de que los componentes informáticos se unieran para encontrar una solución. Comprendiendo esta situación ideal y sabiendo lo que ocurre realmente bajo el capó de Python, podemos acercar iterativamente nuestro código Python al código óptimo.
Informática idealizada
Cuando se inicia el código, tenemos el valor de number
almacenado en la RAM. Para calcular sqrt_number
, necesitamos enviar el valor de number
a la CPU. Idealmente, podríamos enviar el valor una vez; se almacenaría dentro de la caché L1/L2 de la CPU, y ésta haría los cálculos y luego enviaría los valores de nuevo a la RAM para almacenarlos. Este escenario es ideal porque hemos minimizado el número de lecturas del valor de number
desde la RAM, optando en su lugar por lecturas desde la caché L1/L2, que son mucho más rápidas. Además, hemos minimizado el número de transferencias de datos a través del bus frontal, utilizando la caché L1/L2 que está conectada directamente a la CPU.
Consejo
Este tema de mantener los datos donde se necesitan y moverlos lo menos posible es muy importante cuando se trata de optimización. El concepto de "datos pesados" se refiere al tiempo y esfuerzo necesarios para mover los datos de un lado a otro, que es algo que nos gustaría evitar.
Para el bucle del código, en lugar de enviar un valor de i
cada vez a la CPU, nos gustaría enviar tanto number
como varios valores de i
a la CPU para que los compruebe al mismo tiempo. Esto es posible porque la CPU vectoriza las operaciones sin coste de tiempo adicional, lo que significa que puede realizar múltiples cálculos independientes al mismo tiempo. Así que queremos enviar number
a la caché de la CPU, además de tantos valores de i
como pueda contener la caché. Para cada uno de los pares number
/i
, los dividiremos y comprobaremos si el resultado es un número entero; después enviaremos una señal de vuelta indicando si alguno de los valores era efectivamente un número entero. Si es así, la función termina. Si no, repetimos. De este modo, sólo necesitamos comunicar de vuelta un resultado para muchos valores de i
, en lugar de depender del bus lento para cada valor. Esto aprovecha la capacidad de una CPU paravectorizar un cálculo, o ejecutar una instrucción sobre varios datos en un ciclo de reloj.
Este concepto de vectorización se ilustra con el siguiente código:
import
math
def
check_prime
(
number
):
sqrt_number
=
math
.
sqrt
(
number
)
numbers
=
range
(
2
,
int
(
sqrt_number
)
+
1
)
for
i
in
range
(
0
,
len
(
numbers
),
5
):
# the following line is not valid Python code
result
=
(
number
/
numbers
[
i
:(
i
+
5
)])
.
is_integer
()
if
any
(
result
):
return
False
return
True
Aquí, configuramos el procesamiento de forma que la división y la comprobación de los enteros se realicen en un conjunto de cinco valores de i
a la vez. Si se vectoriza adecuadamente, la CPU puede hacer esta línea en un solo paso, en lugar de hacer un cálculo separado para cada i
. Lo ideal sería que la operación any(result)
también se realizara en la CPU sin tener que transferir los resultados a la RAM. Hablaremos más sobre la vectorización, cómo funciona y cuándo beneficia a tu código enel Capítulo 6.
La máquina virtual de Python
El intérprete de Python hace mucho trabajo para intentar abstraer los elementos informáticos subyacentes que se están utilizando. En ningún momento un programador tiene que preocuparse de asignar memoria a las matrices, de cómo organizar esa memoria o de en qué secuencia se envía a la CPU. Esta es una ventaja de Python, ya que te permite centrarte en los algoritmos que se están implementando. Sin embargo, tiene un enorme coste de rendimiento.
Es importante darse cuenta de que, en el fondo, Python ejecuta un conjunto de instrucciones muy optimizadas. El truco, sin embargo, es conseguir que Python las ejecute en la secuencia correcta para lograr un mejor rendimiento. Por ejemplo, es bastante fácil ver que, en el siguiente ejemplo, search_fast
se ejecutará más rápido que search_slow
simplemente porque se salta los cálculos innecesarios que resultan de no terminar el bucle antes de tiempo, aunque ambas soluciones tengan tiempo de ejecución O(n)
. Sin embargo, las cosas pueden complicarse cuando se trata de tipos derivados, métodos especiales de Python o módulos de terceros. Por ejemplo, ¿puedes decir inmediatamente qué función será más rápida: search_unknown1
osearch_unknown2
?
def
search_fast
(
haystack
,
needle
):
for
item
in
haystack
:
if
item
==
needle
:
return
True
return
False
def
search_slow
(
haystack
,
needle
):
return_value
=
False
for
item
in
haystack
:
if
item
==
needle
:
return_value
=
True
return
return_value
def
search_unknown1
(
haystack
,
needle
):
return
any
((
item
==
needle
for
item
in
haystack
))
def
search_unknown2
(
haystack
,
needle
):
return
any
([
item
==
needle
for
item
in
haystack
])
Identificar regiones lentas de código mediante la elaboración de perfiles y encontrar formas más eficientes de realizar los mismos cálculos es similar a encontrar estas operaciones inútiles y eliminarlas; el resultado final es el mismo, pero el número de cálculos y transferencias de datos se reduce drásticamente.
Uno de los impactos de esta capa de abstracción es que la vectorización no es inmediatamente realizable. Nuestra rutina inicial de números primos ejecutará una iteración del bucle por cada valor de i
en lugar de combinar varias iteraciones. Sin embargo, al observar el ejemplo de vectorización abstraído, vemos que no es código Python válido, ya que no podemos dividir un flotante por una lista. Las bibliotecas externas como numpy
ayudarán en esta situación añadiendo la posibilidad de realizar operaciones matemáticas vectorizadas.
Además, la abstracción de Python perjudica a cualquier optimización que dependa de mantener la caché L1/L2 llena con los datos relevantes para el siguiente cálculo. Esto se debe a muchos factores, el primero de los cuales es que los objetos de Python no se disponen de la forma más óptima en la memoria. Esto es consecuencia de que Python es un lenguaje de recolección de basura : la memoria se asigna y libera automáticamente cuando es necesario, lo que crea una fragmentación de la memoria que puede perjudicar las transferencias a las cachés de la CPU. Además, en ningún momento existe la posibilidad de cambiar la disposición de una estructura de datos directamente en la memoria, lo que significa que una transferencia en el bus puede no contener toda la información relevante para un cálculo, aunque toda ella haya cabido dentro de la anchura del bus.4
Un segundo problema, más fundamental, proviene de los tipos dinámicos de Python y de que el lenguaje no está compilado. Como muchos programadores de C han aprendido a lo largo de los años, el compilador suele ser más listo que tú. Al compilar código que es estático, el compilador puede hacer muchos trucos para cambiar la disposición de las cosas y la forma en que la CPU ejecutará determinadas instrucciones para optimizarlas. Python, sin embargo, no está compilado: para empeorar las cosas, tiene tipos dinámicos, lo que significa que inferir algorítmicamente cualquier posible oportunidad de optimización es drásticamente más difícil, ya que la funcionalidad del código puede cambiar durante el tiempo de ejecución. Hay muchas formas de mitigar este problema, siendo la principal el uso de Cython, que permite compilar el código Python en y permite al usuario crear "pistas" para el compilador sobre lo dinámico que es realmente el código.
Por último, el GIL mencionado anteriormente puede perjudicar el rendimiento si se intenta paralelizar este código. Por ejemplo, supongamos que cambiamos el código para utilizar varios núcleos de CPU de forma que cada núcleo obtenga un trozo de los números de 2 a sqrtN
. Cada núcleo puede hacer su cálculo para su trozo de números y, a continuación, cuando todos los cálculos estén hechos, los núcleos pueden comparar sus cálculos. Aunque perdemos la terminación anticipada del bucle, ya que cada núcleo no sabe si se ha encontrado una solución, podemos reducir el número de comprobaciones que tiene que hacer cada núcleo (si tuviéramos M
núcleos, cada núcleo tendría que hacersqrtN / M
comprobaciones). Sin embargo, debido al GIL, sólo se puede utilizar un núcleo a la vez. Esto significa que, en realidad, estaríamos ejecutando el mismo código que la versión sin paralelo, pero ya no tendríamos terminación anticipada. Podemos evitar este problema utilizando múltiples procesos (con el módulo multiprocessing
) en lugar de múltiples hilos, o utilizando Cython o funciones ajenas.
¿Por qué utilizar Python?
Python es muy expresivo y fácil de aprender: los nuevos programadores descubren rápidamente que pueden hacer muchas cosas en poco tiempo. Muchas bibliotecas de Python envuelven herramientas escritas en otros lenguajes para facilitar la llamada a otros sistemas; por ejemplo, el sistema de aprendizaje automático scikit-learn envuelve LIBLINEAR y LIBSVM (ambos escritos en C), y la biblioteca numpy
incluye BLAS y otras bibliotecas C y Fortran. Como resultado, el código Python que utiliza adecuadamente estos módulos puede ser tan rápido como el código C comparable.
Python se describe como "pilas incluidas", ya que muchas herramientas importantes y bibliotecas estables están incorporadas. Entre ellas se incluyen las siguientes:
unicode
ybytes
array
math
-
Operaciones matemáticas básicas, incluidas algunas estadísticas sencillas
sqlite3
-
Una envoltura alrededor del motor de almacenamiento basado en archivos SQL predominante SQLite3
collections
-
Una amplia variedad de objetos, incluyendo variantes de deque, contador y diccionario
asyncio
-
Soporte concurrente para tareas de E/S mediante sintaxis async y await
Se puede encontrar una gran variedad de bibliotecas fuera del núcleo del lenguaje, incluidas éstas:
numpy
-
Una biblioteca numérica de Python (una biblioteca fundamental para todo lo que tenga que ver con matrices)
scipy
-
Una colección muy amplia de bibliotecas científicas de confianza, a menudo envolviendo bibliotecas C y Fortran muy respetadas
pandas
-
Una biblioteca para el análisis de datos, similar a los marcos de datos de R o a una hoja de cálculo de Excel, construida sobre
scipy
ynumpy
- scikit-learn
-
Convirtiéndose rápidamente en la biblioteca de aprendizaje automático por defecto, construida sobre
scipy
tornado
-
Una biblioteca que proporciona enlaces sencillos para la concurrencia
- PyTorch y TensorFlow
-
Marcos de aprendizaje profundo de Facebook y Google con fuerte soporte de Python y GPU
NLTK
,SpaCy
, yGensim
-
Bibliotecas de procesamiento del lenguaje natural con soporte profundo de Python
- Enlaces a bases de datos
-
Para comunicarse con prácticamente todas las bases de datos, incluidas Redis, MongoDB, HDF5 y SQL
- Marcos de desarrollo web
-
Sistemas Performant para crear sitios web, como
aiohttp
,django
,pyramid
,flask
, ytornado
OpenCV
- Enlaces API
-
Para acceder fácilmente a API web populares como Google, Twitter y LinkedIn
Hay disponible una amplia selección de entornos gestionados y shells para adaptarse a diversos escenarios de implementación, entre los que se incluyen los siguientes:
-
La distribución estándar, disponible en http://python.org
-
pipenv
,pyenv
, yvirtualenv
para entornos Python sencillos, ligeros y portátiles. -
Docker para entornos sencillos de iniciar y reproducir para desarrollo o producción
-
Sage, un entorno similar a Matlab que incluye un entorno de desarrollo integrado (IDE)
-
IPython, una shell interactiva de Python muy utilizada por científicos y desarrolladores
-
Jupyter Notebook, una extensión de IPython basada en navegador, muy utilizada para la enseñanza y las demostraciones
Uno de los principales puntos fuertes de Python es que permite crear prototipos rápidos de una idea. Gracias a la gran variedad de bibliotecas de apoyo, es fácil probar si una idea es factible, aunque la primera implementación pueda ser bastante defectuosa.
Si quieres hacer más rápidas tus rutinas matemáticas, busca en numpy
. Si quieres experimentar con el aprendizaje automático, prueba scikit-learn. Si estás limpiando y manipulando datos, entonces pandas
es una buena elección.
En general, es sensato plantearse la pregunta: "Si nuestro sistema funciona más rápido, ¿a la larga, como equipo, funcionaremos más despacio?". Siempre es posible exprimir más rendimiento de un sistema si se invierten suficientes horas de trabajo, pero esto puede dar lugar a optimizaciones frágiles y mal comprendidas que, en última instancia, hagan tropezar al equipo.
Un ejemplo podría ser la introducción de Cython (ver "Cython"), un enfoque basado en compiladores para anotar código Python con tipos similares a C, de modo que el código transformado pueda compilarse utilizando un compilador C. Aunque las ganancias de velocidad pueden ser impresionantes (a menudo se alcanzan velocidades similares a C con relativamente poco esfuerzo), el coste de soportar este código aumentará. En concreto, puede resultar más difícil dar soporte a este nuevo módulo, ya que los miembros del equipo necesitarán cierta madurez en su capacidad de programación para comprender algunas de las compensaciones que se han producido al abandonar la máquina virtual Python que introdujo el aumento de rendimiento.
Cómo ser un programador de alto rendimiento
Escribir código de alto rendimiento es sólo una parte de tener un alto rendimiento con proyectos de éxito a largo plazo. La velocidad general del equipo es mucho más importante que los aumentos de velocidad y las soluciones complicadas. Varios factores son clave para ello: una buena estructura, documentación, depurabilidad y normas compartidas.
Supongamos que creas un prototipo. No lo has probado a fondo y no ha sido revisado por tu equipo. Parece ser "suficientemente bueno", y se pasa a producción. Como nunca se escribió de forma estructurada, carece de pruebas y no está documentado. De repente hay un trozo de código que causa inercia para que otra persona lo soporte, y a menudo la dirección no puede cuantificar el coste para el equipo.
Como esta solución es difícil de mantener, tiende a quedarse sin amor: nunca se reestructura, no recibe las pruebas que ayudarían al equipo a refactorizarla, y a nadie más le gusta tocarla, por lo que recae en un solo desarrollador mantenerla en funcionamiento. Esto puede causar un terrible cuello de botella en momentos de estrés y plantea un riesgo importante: ¿qué pasaría si ese desarrollador abandonara el proyecto?
Normalmente, este estilo de desarrollo se produce cuando el equipo directivo no comprende la inercia continua que provoca el código difícil de mantener. Demostrar que a largo plazo las pruebas y la documentación pueden ayudar a un equipo a seguir siendo muy productivo y a convencer a los directivos de que dediquen tiempo a "limpiar" este código prototipo.
En un entorno de investigación, es habitual crear muchos cuadernos Jupyter utilizando malas prácticas de codificación mientras se itera con ideas y diferentes conjuntos de datos. Laintención es siempre "escribirlo bien" en una fase posterior, pero esa fase posterior nunca se produce. Al final, se obtiene un resultado que funciona, pero falta la infraestructura para reproducirlo, probarlo y confiar en el resultado. Una vez más, los factores de riesgo son altos, y la confianza en el resultado será baja.
Hay un enfoque general que te servirá:
- Haz que funcione
-
Primero construye una solución suficientemente buena. Es muy sensato "construir una para tirar" que actúe como solución prototipo, permitiendo utilizar una estructura mejor para la segunda versión. Siempre es sensato hacer una planificación previa antes de codificar; de lo contrario, llegarás a pensar que "nos ahorramos una hora de pensar codificando toda la tarde". En algunos campos esto se conoce mejor como "Mide dos veces, corta una".
- Hazlo bien
-
A continuación, añade un sólido conjunto de pruebas respaldado por documentación e instrucciones claras de reproducibilidad para que otro miembro del equipo pueda hacerse cargo.
- Hazlo rápido
-
Por último, podemos centrarnos en el perfilado y la compilación o paralelización y utilizar el conjunto de pruebas existente para confirmar que la nueva solución más rápida sigue funcionando como se esperaba.
Buenas prácticas laborales
Hay algunas cosas "imprescindibles": la documentación, una buena estructura y las pruebas son fundamentales.
Un poco de documentación a nivel de proyecto te ayudará a mantener una estructura limpia. También os ayudará a ti y a tus compañeros en el futuro. Nadie te lo agradecerá (ni tú mismo) si te saltas esta parte. Escribir esto en un archivo LÉAME en el nivel superior es un punto de partida sensato; siempre puede ampliarse a una carpeta docs/más adelante si es necesario.
Explica la finalidad del proyecto, qué hay en las carpetas, de dónde proceden los datos, qué archivos son críticos y cómo ejecutarlo todo, incluido cómo ejecutar las pruebas.
Micha recomienda utilizar también Docker. Un Dockerfile de alto nivel explicará a tu futuro yo exactamente qué bibliotecas necesitas del sistema operativo para que este proyecto se ejecute con éxito. También elimina la dificultad de ejecutar este código en otras máquinas o de implementarlo en un entorno en la nube.
Añade una carpeta tests/ y añade algunas pruebas unitarias. Preferimos pytest
como ejecutor de pruebas moderno, ya que se basa en el módulo integrado de Python unittest
. Empieza con sólo un par de pruebas y luego ve acumulándolas. Progresa hasta utilizar la herramienta coverage
, que te informará de cuántas líneas de tu código están realmente cubiertas por las pruebas: te ayudará a evitar sorpresas desagradables.
Si estás heredando código heredado y carece de pruebas, una actividad de gran valor es añadir algunas pruebas por adelantado. Algunas "pruebas de integración" que comprueben el flujo general del proyecto y confirmen que con determinados datos de entrada se obtienen resultados de salida específicos te ayudarán a mantener la cordura cuando realices modificaciones posteriormente.
Cada vez que te muerda algo en el código, añade una prueba. No sirve de nada que te muerda dos veces el mismo problema.
Los docstrings en tu código para cada función, clase y módulo siempre te ayudarán. Intenta proporcionar una descripción útil de lo que se consigue con la función y, siempre que sea posible, incluye un breve ejemplo que demuestre el resultado esperado. Mira los docstrings de numpy
y scikit-learn si quieres inspirarte.
Siempre que tu código sea demasiado largo -por ejemplo, funciones más largas que una pantalla-, no dudes en refactorizarlo para hacerlo más corto. Un código más corto es más fácil de probar y de soportar.
Consejo
Cuando desarrolles tus pruebas, piensa en seguir una metodología de desarrollo dirigido por pruebas. Cuando sabes exactamente lo que tienes que desarrollar y tienes a mano ejemplos comprobables, este método resulta muy eficaz.
Escribes tus pruebas, las ejecutas, observas cómo fallan, y luego añades las funciones y la lógica mínima necesaria para soportar las pruebas que has escrito. Cuando todas tus pruebas funcionen, habrás terminado. Si averiguas de antemano la entrada y la salida esperadas de una función, la implementación de la lógica de la función te resultará relativamente sencilla.
Si no puedes definir tus pruebas con antelación, naturalmente surge la pregunta: ¿comprendes realmente lo que debe hacer tu función? Si no es así, ¿puedes escribirla correctamente de forma eficiente? Este método no funciona tan bien si estás en un proceso creativo e investigando datos que aún no entiendes bien.
Utiliza siempre el control de código : sólo te lo agradecerás cuando sobrescribas algo crítico en un momento inoportuno. Acostúmbrate a hacer commits con frecuencia (diariamente, o incluso cada 10 minutos) y a hacer push en tu repositorio todos los días.
Sigue el estándar de codificación PEP8
. Mejor aún, adopta black
(el formateador de código de opinión) en un gancho de control de código fuente previo a la confirmación, para que reescriba tu código según el estándar por ti. Utiliza flake8
para limpiar tu código y evitar otros errores.
Crear entornos aislados del sistema operativo te facilitará la vida. Ian prefiere Anaconda, mientras que Micha prefiere pipenv
junto con Docker. Ambas son soluciones sensatas y mucho mejores que utilizar el entorno global Python del sistema operativo.
Recuerda que la automatización es tu amiga. Hacer menos trabajo manual significa que hay menos posibilidades de que se cuelen errores. Los sistemas de compilación automatizados, la integración continua con ejecutores automatizados de conjuntos de pruebas y los sistemas de implementación automatizados convierten las tareas tediosas y propensas a errores en procesos estándar que cualquiera puede ejecutar y soportar.
Por último, recuerda que la legibilidad es mucho más importante que ser inteligente. Los fragmentos cortos de código complejo y difícil de leer serán difíciles de mantener para ti y tus colegas, por lo que la gente tendrá miedo de tocar ese código. En lugar de eso, escribe una función más larga y fácil de leer, y respáldala con documentación útil que muestre lo que devolverá, y compleméntala con pruebas para confirmar que funciona como esperas.
Algunas reflexiones sobre las buenas prácticas con los cuadernos
Si utilizas Jupyter Notebooks, son estupendos para la comunicación visual, pero facilitan la pereza. Si te encuentras dejando funciones largas dentro de tus Cuadernos, siéntete cómodo extrayéndolas a un módulo de Python y añadiendo después pruebas.
Considera la posibilidad de prototipar tu código en IPython o en QTConsole; convierte líneas de código en funciones en un Cuaderno y luego promuévelas fuera del Cuaderno y a un módulo complementado con pruebas. Por último, considera la posibilidad de envolver el código en una clase si la encapsulación y la ocultación de datos son útiles.
Distribuye libremente las sentencias assert
por un Cuaderno para comprobar que tus funciones se comportan como se espera de ellas. No puedes probar fácilmente el código dentro de un Cuaderno, y hasta que no hayas refactorizado tus funciones en módulos independientes, las comprobaciones de assert
son una forma sencilla de añadir cierto nivel de validación. No deberías confiar en este código hasta que lo hayas extraído a un módulo y escrito pruebas unitarias sensatas.
Utilizar declaraciones assert
para comprobar datos en tu código debería estar mal visto. Es una forma fácil de afirmar que se cumplen ciertas condiciones, pero no es idiomático de Python. Para que tu código sea más fácil de leer por otros desarrolladores, comprueba el estado esperado de los datos y luego lanza una excepción apropiada si la comprobación falla. Una excepción común sería ValueError
si una función encuentra un valor inesperado. Labiblioteca Bulwark es un ejemplo de marco de pruebas centrado en Pandas para comprobar que tus datos cumplen las restricciones especificadas.
También puedes añadir algunas comprobaciones de cordura al final de tu Cuaderno: una mezcla de comprobaciones lógicas y declaraciones raise
y print
que demuestren que acabas de generar exactamente lo que necesitabas. Cuando vuelvas a este código dentro de seis meses, ¡te darás las gracias por haber facilitado la comprobación de que ha funcionado correctamente en todo momento!
Una de las dificultades de los Cuadernos es compartir el código con los sistemas de control de código fuente.nbdime es una de las nuevas herramientas, cada vez más numerosas, que te permiten difundir tus Cuadernos. Es un salvavidas y permite la colaboración con colegas.
Devuelve la alegría a tu trabajo
La vida puede ser complicada. En los cinco años transcurridos desde que tus autores escribieron la primera edición de este libro, hemos experimentado conjuntamente, a través de amigos y familiares, una serie de situaciones vitales, como depresión, cáncer, traslados de domicilio, salidas y fracasos empresariales con éxito y cambios de dirección en la carrera profesional. Inevitablemente, estos acontecimientos externos repercuten en el trabajo y la perspectiva vital de cualquier persona.
Recuerda seguir buscando la alegría en las nuevas actividades. Siempre hay detalles o requisitos interesantes cuando empiezas a curiosear. Puedes preguntarte "¿por qué tomaron esa decisión?" y "¿cómo lo haría yo de otra manera?" y, de repente, estarás listo para iniciar una conversación sobre cómo se podrían cambiar o mejorar las cosas.
Lleva un registro de las cosas que merece la pena celebrar. Es muy fácil olvidarse de los logros y quedarse atrapado en el día a día. La gente se agota porque siempre está corriendo para mantenerse al día, y se olvida de lo mucho que ha progresado.
Te sugerimos que elabores una lista de cosas dignas de celebración y anotes cómo las celebras. Ian lleva una lista de este tipo: se sorprende felizmente cuando va a actualizarla y ve cuántas cosas geniales han sucedido (¡y que de otro modo podrían haberse olvidado!) en el último año. No deben ser sólo hitos laborales; incluye aficiones y deportes, y celebra los hitos que has conseguido. Micha se asegura de dar prioridad a su vida personal y de pasar días lejos del ordenador para trabajar en proyectos no técnicos. Es fundamental seguir desarrollando tu conjunto de habilidades, ¡pero no es necesario quemarse!
La programación, sobre todo cuando se centra en el rendimiento, se nutre del sentido de la curiosidad y de la voluntad de profundizar siempre en los detalles técnicos. Por desgracia, esta curiosidad es lo primero que desaparece cuando te quemas; así que tómate tu tiempo y asegúrate de disfrutar del viaje, y mantén la alegría y la curiosidad.
1 No confundir con la comunicación interprocesos, que comparte el mismo acrónimo; veremos ese tema en el Capítulo 9.
2 Las velocidades de esta sección son de https://oreil.ly/pToi7.
3 Los datos proceden de https://oreil.ly/7SC8d.
4 En el Capítulo 6, veremos cómo podemos recuperar este control y afinar nuestro código hasta los patrones de utilización de la memoria.
Get Python de alto rendimiento, 2ª edición now with the O’Reilly learning platform.
O’Reilly members experience books, live events, courses curated by job role, and more from O’Reilly and nearly 200 top publishers.