Python es lento. Por qué

Recientemente, se puede observar la creciente popularidad del lenguaje de programación Python. Se utiliza en DevOps, en el análisis de datos, en el desarrollo web, en el campo de la seguridad y en otros campos. Pero aquí está la velocidad ... No hay nada de qué jactarse de este lenguaje aquí. El autor del material, cuya traducción publicamos hoy, decidió averiguar las razones de la lentitud de Python y encontrar los medios para acelerarlo.



Disposiciones generales


¿Cómo se relaciona Java, en términos de rendimiento, con C o C ++? ¿Cómo comparar C # y Python? Las respuestas a estas preguntas dependen en gran medida del tipo de aplicaciones analizadas por el investigador. No existe un punto de referencia perfecto, pero estudiar el rendimiento de los programas escritos en diferentes idiomas, The Computer Language Benchmarks Game puede ser un buen punto de partida .

Hace más de diez años que me refiero a The Computer Language Benchmarks Game. Python, en comparación con otros lenguajes, como Java, C #, Go, JavaScript, C ++, es uno de los más lentos . Esto incluye lenguajes que usan compilación JIT (C #, Java) y compilación AOT (C #, C ++), así como lenguajes interpretados como JavaScript.

Aquí me gustaría señalar que cuando digo "Python", me refiero a la implementación de referencia del intérprete de Python: CPython. En este material, tocaremos sus otras implementaciones. En realidad, aquí quiero encontrar la respuesta a la pregunta de por qué Python tarda entre 2 y 10 veces más tiempo que otros lenguajes para resolver problemas comparables, y si se puede hacer más rápido.

Aquí hay algunas teorías básicas que intentan explicar por qué Python es lento:

  • La razón de esto es el GIL (Global Interpreter Lock, Global Interpreter Lock).
  • La razón es que Python es un lenguaje interpretado en lugar de compilado.
  • La razón es la tipificación dinámica.

Analizaremos estas ideas e intentaremos encontrar la respuesta a la pregunta de qué tiene el mayor efecto en el rendimiento de las aplicaciones de Python.

Gil


Las computadoras modernas tienen procesadores de múltiples núcleos, y a veces se encuentran sistemas multiprocesador. Para utilizar toda esta potencia informática, el sistema operativo utiliza estructuras de bajo nivel llamadas subprocesos, mientras que los procesos (por ejemplo, el proceso del navegador Chrome) pueden iniciar muchos subprocesos y usarlos en consecuencia. Como resultado, por ejemplo, si un proceso necesita recursos del procesador especialmente, su ejecución puede dividirse entre varios núcleos, lo que permite que la mayoría de las aplicaciones resuelvan las tareas que enfrentan más rápidamente.

Por ejemplo, mi navegador Chrome, en el momento en que escribo esto, tiene 44 hilos abiertos. Debe tenerse en cuenta que la estructura y API del sistema para trabajar con flujos varía en los sistemas operativos basados ​​en Posix (Mac OS, Linux) y en la familia de sistemas operativos Windows. El sistema operativo también planifica subprocesos.

Si no se ha encontrado con la programación de subprocesos múltiples antes, ahora necesita familiarizarse con los llamados bloqueos (bloqueos). El significado de los bloqueos es que le permiten garantizar dicho comportamiento del sistema cuando, en un entorno de subprocesos múltiples, por ejemplo, al cambiar una determinada variable en la memoria, varios subprocesos no pueden acceder a la misma área de memoria (para leer o cambiar).

Cuando el intérprete de CPython crea las variables, asigna memoria y luego cuenta el número de referencias existentes a estas variables. Este concepto se conoce como recuento de referencias. Si el número de enlaces es igual a cero, entonces se libera la pieza de memoria correspondiente. Es por eso que, por ejemplo, la creación de variables "temporales", por ejemplo, dentro del alcance de los bucles, no conduce a un aumento excesivo en la cantidad de memoria consumida por la aplicación.

La parte más interesante comienza cuando varios hilos comparten las mismas variables, y el principal problema aquí es cómo exactamente CPython realiza el recuento de referencias. Aquí es donde aparece la acción del "bloqueo global del intérprete", que controla cuidadosamente la ejecución de subprocesos.

Un intérprete solo puede realizar una operación a la vez, independientemente de cuántos hilos haya en el programa.

▍¿Cómo afecta GIL el rendimiento de las aplicaciones Python?


Si tenemos una aplicación de un solo subproceso que se ejecuta en el mismo proceso de intérprete de Python, entonces el GIL no afecta el rendimiento de ninguna manera. Si, por ejemplo, nos deshacemos de GIL, no notaremos ninguna diferencia en el rendimiento.

Si, dentro del marco de un proceso de intérprete de Python, es necesario implementar el procesamiento de datos en paralelo utilizando mecanismos de subprocesamiento múltiple, y las secuencias utilizadas utilizarán intensivamente el subsistema de E / S (por ejemplo, si funcionan con una red o con un disco), entonces será posible observar las consecuencias de cómo GIL maneja los hilos. Así es como se ve en el caso de usar dos hilos, cargando procesos de forma intensiva.


Visualización de GIL (tomada de aquí )

Si tiene una aplicación web (por ejemplo, basada en el marco Django) y utiliza WSGI, cada solicitud de la aplicación web será atendida por un proceso de intérprete de Python separado, es decir, solo tenemos 1 bloqueo de solicitud. Dado que el intérprete de Python comienza lentamente, en algunas implementaciones de WSGI hay un llamado "modo demonio", cuando se utiliza el proceso de intérprete que se mantiene en condiciones de funcionamiento, lo que permite que el sistema atienda solicitudes más rápido.

▍¿Cómo se comportan otros intérpretes de Python?


PyPy tiene un GIL, generalmente es más de 3 veces más rápido que CPython.

No hay GIL en Jython, porque los hilos de Python en Jython se representan como hilos de Java. Dichos subprocesos utilizan las capacidades de administración de memoria de JVM.

▍ ¿Cómo se organiza el control de flujo en JavaScript?


Si hablamos de JavaScript, en primer lugar, debe tenerse en cuenta que todos los motores JS usan el algoritmo de recolección de basura de marcado y barrido . Como ya se mencionó, la razón principal para usar GIL es el algoritmo de administración de memoria utilizado en CPython.

JavaScript no tiene un GIL, sin embargo, JS es un lenguaje de subproceso único, por lo tanto, no necesita dicho mecanismo. En lugar de la ejecución de código paralelo, JavaScript utiliza técnicas de programación asincrónicas basadas en un bucle de eventos, promesas y devoluciones de llamadas. Python tiene algo similar provisto por el módulo asyncio .

Python - lenguaje interpretado


A menudo escuché que el bajo rendimiento de Python se debe al hecho de que es un lenguaje interpretado. Dichas declaraciones se basan en una simplificación general de cómo funciona realmente CPython. Si, en la terminal, ingresas un comando como python myscript.py , entonces CPython comenzará una larga secuencia de acciones, que consiste en leer, analizar léxicamente, analizar, compilar, interpretar y ejecutar código de script. Si está interesado en los detalles, eche un vistazo a este material.

Para nosotros, al considerar este proceso, es especialmente importante que aquí, en la etapa de compilación, se cree un archivo __pycache__/ se escriba una secuencia de __pycache__/ en el archivo en el directorio __pycache__/ , que se usa tanto en Python 3 como en Python 2)

Esto se aplica no solo a los scripts que escribimos, sino también al código importado, incluidos los módulos de terceros.

Como resultado, la mayoría de las veces (a menos que escriba código que se ejecute solo una vez), Python ejecutará el código de bytes terminado. Comparando esto con lo que sucede en Java y C #, resulta que el código de Java se compila en el "Lenguaje Intermedio", y la máquina virtual de Java lee el código de bytes y realiza su compilación JIT en el código de la máquina. El "lenguaje intermedio" .NET CIL (que es el mismo que .NET Common-Language-Runtime, CLR) usa la compilación JIT para navegar al código de máquina.

Como resultado, tanto en Java como en C # se usa algún "lenguaje intermedio" y existen mecanismos similares. ¿Por qué, entonces, Python muestra puntos de referencia mucho peores que Java y C # si todos estos lenguajes usan máquinas virtuales y algún tipo de código de bytes? En primer lugar, debido al hecho de que la compilación JIT se usa en .NET y Java.

La compilación JIT (compilación Just In Time, compilación sobre la marcha o justo a tiempo) requiere un lenguaje intermedio para permitir la división del código en fragmentos (marcos). Los sistemas de compilación AOT (compilación anticipada, compilación antes de la ejecución) están diseñados de tal manera que garanticen la funcionalidad completa del código antes de que comience la interacción de este código con el sistema.

Por sí solo, el uso de JIT no acelera la ejecución del código, ya que algunos fragmentos de código de bytes entran en ejecución, como en Python. Sin embargo, JIT le permite realizar optimizaciones de código durante la ejecución. Un buen optimizador JIT es capaz de identificar las partes más cargadas de la aplicación (esta parte de la aplicación se llama "zona activa") y optimizar los fragmentos de código correspondientes, reemplazándolos con opciones optimizadas y más productivas que las que se utilizaron anteriormente.

Esto significa que cuando una determinada aplicación realiza ciertas acciones una y otra vez, dicha optimización puede acelerar significativamente la ejecución de tales acciones. Además, tenga en cuenta que Java y C # son lenguajes fuertemente tipados, por lo que el optimizador puede hacer más suposiciones sobre el código que pueden ayudar a mejorar el rendimiento del programa.

Hay un compilador JIT en PyPy, y, como ya se mencionó, esta implementación del intérprete de Python es mucho más rápida que CPython. Se puede encontrar información sobre cómo comparar diferentes intérpretes de Python en este artículo.

▍ ¿Por qué CPython no usa un compilador JIT?


Los compiladores JIT también tienen desventajas. Uno de ellos es el tiempo de lanzamiento. CPython ya comienza relativamente lento, y PyPy es 2-3 veces más lento que CPython. El largo tiempo de ejecución de la JVM también es un hecho conocido. CLR .NET evita este problema al iniciarse durante el arranque del sistema, pero debe tenerse en cuenta que tanto el CLR como el sistema operativo que ejecuta el CLR son desarrollados por la misma compañía.

Si tiene un proceso de Python que se ha estado ejecutando durante mucho tiempo, mientras que en dicho proceso hay un código que se puede optimizar, ya que contiene secciones muy utilizadas, entonces debería considerar seriamente un intérprete que tenga un compilador JIT.

Sin embargo, CPython es una implementación del intérprete de Python de propósito general. Por lo tanto, si está desarrollando, utilizando Python, una aplicación de línea de comandos, entonces la necesidad de una larga espera para que el compilador JIT se inicie cada vez que se inicie esta aplicación disminuirá considerablemente el trabajo.

CPython está tratando de proporcionar soporte para tantos casos de uso de Python como sea posible. Por ejemplo, existe la posibilidad de conectar el compilador JIT a Python, sin embargo, el proyecto que implementa esta idea no se está desarrollando de manera muy activa.

Como resultado, podemos decir que si está utilizando Python para escribir un programa cuyo rendimiento puede mejorar al usar el compilador JIT, use el intérprete PyPy.

Python es un lenguaje de tipo dinámico


En los idiomas de tipo estático, al declarar variables, debe especificar sus tipos. Entre estos lenguajes se pueden observar C, C ++, Java, C #, Go.

En lenguajes de tipo dinámico, el concepto de un tipo de datos tiene el mismo significado, pero el tipo de una variable es dinámico.

 a = 1 a = "foo" 

En este ejemplo más simple, Python crea primero la primera variable a , luego la segunda con el mismo nombre de tipo str , y libera la memoria que se asignó a la primera variable a .

Puede parecer que escribir en idiomas con escritura dinámica es más conveniente y más simple que en idiomas con escritura estática, sin embargo, dichos idiomas no se crearon por capricho de alguien. Durante su desarrollo, se han tenido en cuenta las características de los sistemas informáticos. Todo lo que está escrito en el texto del programa, al final, se reduce a las instrucciones del procesador. Esto significa que los datos utilizados por el programa, por ejemplo, en forma de objetos u otros tipos de datos, también se convierten en estructuras de bajo nivel.

Python realiza tales transformaciones automáticamente, el programador no ve estos procesos y no necesita encargarse de tales transformaciones.

No tener que especificar el tipo de una variable al declararla no es una característica del lenguaje que hace que Python sea lento. La arquitectura del lenguaje permite hacer casi cualquier cosa dinámica. Por ejemplo, en tiempo de ejecución, puede reemplazar los métodos de objeto. Nuevamente, durante la ejecución del programa, puede usar la técnica de "parche de mono" como se aplica a las llamadas de sistema de bajo nivel. En Python, casi todo es posible.

Es la arquitectura Python la que hace que la optimización sea extremadamente difícil.

Para ilustrar esta idea, voy a usar una herramienta para rastrear llamadas del sistema en MacOS llamada DTrace.

No hay mecanismos de soporte de DTrace en la distribución de CPython finalizada, por lo que CPython deberá volver a compilarse con la configuración adecuada. Aquí se usa la versión 3.6.6. Entonces, usamos la siguiente secuencia de acciones:

 wget https://github.com/python/cpython/archive/v3.6.6.zip unzip v3.6.6.zip cd v3.6.6 ./configure --with-dtrace make 

Ahora, usando python.exe , puede usar DTRace para rastrear el código. Lea sobre el uso de DTrace con Python aquí . Y aquí puede encontrar secuencias de comandos para medir diversos indicadores de rendimiento de los programas Python con DTrace. Entre ellos se encuentran parámetros para funciones de llamada, tiempo de ejecución de programas, tiempo de uso del procesador, información sobre llamadas al sistema, etc. Aquí se explica cómo usar el comando dtrace :

 sudo dtrace -s toolkit/<tracer>.d -c '../cpython/python.exe script.py' 

Y así es como el py_callflow rastreo py_callflow muestra las llamadas a funciones en la aplicación.


Rastreo usando DTrace

Ahora respondamos a la pregunta de si la escritura dinámica afecta el rendimiento de Python. Aquí hay algunos pensamientos sobre esto:

  • La verificación de tipos y la conversión son operaciones pesadas. Cada vez que se accede a una variable, se lee o se escribe, se realiza una verificación de tipo.
  • Un lenguaje con tanta flexibilidad es difícil de optimizar. La razón por la que otros lenguajes son mucho más rápidos que Python es que se comprometen al elegir entre flexibilidad y rendimiento.
  • El proyecto Cython combina Python y escritura estática, que, por ejemplo, como se muestra en este artículo , conduce a mejoras de rendimiento de 84 veces sobre Python normal. Echa un vistazo a este proyecto si necesitas velocidad.

Resumen


La razón del bajo rendimiento de Python es su naturaleza dinámica y versatilidad. Se puede usar como una herramienta para resolver una variedad de tareas. Para lograr los mismos objetivos, puede intentar buscar herramientas más productivas y mejor optimizadas. Quizás puedan encontrar, quizás no.

Las aplicaciones escritas en Python pueden optimizarse utilizando las capacidades de ejecución de código asíncrono, herramientas de creación de perfiles y - eligiendo el intérprete adecuado. Por lo tanto, para optimizar la velocidad de las aplicaciones cuyo tiempo de inicio no es importante y cuyo rendimiento puede beneficiarse del uso del compilador JIT, considere usar PyPy. Si necesita el máximo rendimiento y está listo para las limitaciones de la escritura estática, eche un vistazo a Cython.

Estimados lectores! ¿Cómo se resuelven los problemas de bajo rendimiento de Python?

Source: https://habr.com/ru/post/es418823/


All Articles