Hola a todos Hoy queremos compartir otra traducción preparada en la víspera del lanzamiento del curso
Python Developer . Vamos!

Usé Python con más frecuencia que cualquier otro lenguaje de programación en los últimos 4-5 años. Python es el lenguaje predominante para compilaciones bajo Firefox, pruebas y la herramienta CI. Mercurial también se escribe principalmente en Python. También escribí muchos de mis proyectos de terceros en él.
Durante mi trabajo, adquirí un poco de conocimiento sobre el rendimiento de Python y sus herramientas de optimización. En este artículo, me gustaría compartir este conocimiento.
Mi experiencia con Python está relacionada principalmente con el intérprete de CPython, especialmente CPython 2.7. No todas mis observaciones son universales para todas las distribuciones de Python, o para aquellas que tienen las mismas características en versiones similares de Python. Trataré de mencionar esto durante la narración. Tenga en cuenta que este artículo no es una descripción detallada del rendimiento de Python. Solo hablaré de lo que encontré solo.
La carga debido a las peculiaridades de iniciar e importar módulos
Iniciar el intérprete de Python e importar los módulos es un proceso bastante largo cuando se trata de milisegundos.
Si necesita iniciar cientos o miles de procesos de Python en cualquiera de sus proyectos, entonces este retraso en milisegundos se convertirá en un retraso de hasta varios segundos.
Si usa Python para proporcionar herramientas de CLI, la sobrecarga puede causar una congelación notable para el usuario. Si necesita herramientas CLI al instante, ejecutar el intérprete de Python con cada llamada hará que sea más difícil obtener esta herramienta compleja.
Ya escribí sobre este problema. Algunas de mis notas anteriores hablan de esto, por ejemplo,
en 2014 ,
en mayo de 2018 y
octubre de 2018 .
No hay muchas cosas que puede hacer para reducir el retraso de inicio: arreglar este caso se refiere a manipular el intérprete de Python, ya que es él quien controla la ejecución del código, lo que lleva demasiado tiempo. Lo mejor que puede hacer es desactivar la importación del módulo del
sitio en las llamadas para evitar ejecutar código Python adicional al inicio. Por otro lado, muchas aplicaciones usan la funcionalidad del módulo site.py, por lo que puede usar esto bajo su propio riesgo.
También debemos considerar el problema de importar módulos. ¿De qué sirve el intérprete de Python si no procesa ningún código? El hecho es que el código se pone a disposición del intérprete con mayor frecuencia mediante el uso de módulos.
Para importar módulos, debe seguir varios pasos. Y en cada uno de ellos hay una fuente potencial de cargas y retrasos.
Se produce un cierto retraso debido a la búsqueda de módulos y la lectura de sus datos. Como demostré con
PyOxidizer , al reemplazar la búsqueda y la carga de un módulo de un sistema de archivos con una solución arquitectónicamente más simple, que consiste en leer los datos del módulo de una estructura de datos en la memoria, puede importar la biblioteca Python estándar para el 70-80% del tiempo de solución inicial para esta tarea. Tener un módulo por archivo del sistema de archivos aumenta la carga en el sistema de archivos y puede ralentizar una aplicación Python durante los primeros milisegundos críticos de ejecución. Soluciones como PyOxidizer pueden ayudar a evitar esto. Espero que la comunidad de Python vea estos costos del enfoque actual y esté considerando la transición a los mecanismos de distribución de los módulos, que no dependen tanto de los archivos individuales en el módulo.
Otra fuente de costos de importación adicionales para un módulo es la ejecución de código en ese módulo durante la importación. Algunos módulos contienen partes del código en un área fuera de las funciones y clases del módulo, que se ejecuta cuando se importa el módulo. La ejecución de dicho código aumenta el costo de importación. Solución alternativa: no ejecute todo el código en el momento de la importación, solo ejecútelo si es necesario. Python 3.7 es compatible con el módulo
__getattr__
, que se llamará si no se encuentra el atributo de un módulo. Esto se puede usar para llenar perezosamente los atributos del módulo en el primer acceso.
Otra forma de deshacerse de la desaceleración de la importación es importar perezosamente el módulo. En lugar de cargar el módulo directamente durante la importación, registra un módulo de importación personalizado que devuelve un código auxiliar. Cuando accede por primera vez a este código auxiliar, cargará el módulo real y "mutará" para convertirse en este módulo.
Puede ahorrar decenas de milisegundos con aplicaciones que importan varias decenas de módulos si omite el sistema de archivos y evita ejecutar partes innecesarias del módulo (los módulos generalmente se importan globalmente, pero solo se utilizan ciertas funciones del módulo).
La importación diferida de módulos es algo frágil. Muchos módulos tienen plantillas que tienen las siguientes cosas:
try: import foo
;
except ImportError:
Un importador de módulo perezoso nunca puede lanzar un ImportError, porque si lo hace, tendrá que buscar en el sistema de archivos un módulo para ver si existe en principio. Esto agregará una carga adicional y aumentará el tiempo dedicado, por lo que los importadores perezosos no hacen esto en principio. Este problema es bastante molesto. Importador de módulos diferidos Mercurial procesa una lista de módulos que no pueden importarse de manera diferida y debe omitirlos. Otro problema es la sintaxis
from foo import x, y
, que también interrumpe la importación del módulo diferido, en los casos en que foo es un módulo (a diferencia de un paquete), ya que el módulo aún debe importarse para devolver una referencia a x e y.
PyOxidizer tiene un conjunto fijo de módulos conectados al binario, por lo que puede ser efectivo para aumentar ImportError. El módulo __getattr__ de Python 3.7 proporciona flexibilidad adicional para los importadores de módulos perezosos. Espero integrar un importador perezoso confiable en PyOxidizer para automatizar algunos procesos.
La mejor solución para evitar iniciar el intérprete y causar retrasos es iniciar el proceso en segundo plano en Python. Si inicia el proceso de Python como un proceso de demonio, digamos para un servidor web, entonces puede hacerlo. La solución que ofrece Mercurial es iniciar un proceso en segundo plano que proporcione un
protocolo de servidor de comandos . hg es el ejecutable C (o ahora Rust), que se conecta a este proceso en segundo plano y envía un comando. Para encontrar un enfoque para el servidor de comandos, debe hacer mucho trabajo, es extremadamente inestable y tiene problemas de seguridad. Estoy considerando la idea de entregar un servidor de comandos usando PyOxidizer para que el ejecutable tenga sus ventajas, y el problema del costo de la solución de software en sí se resolvió creando el proyecto PyOxidizer.
Retardo de llamada de función
Llamar a funciones en Python es un proceso relativamente lento. (Esta observación es menos aplicable a PyPy, que puede ejecutar código JIT).
Vi docenas de parches para Mercurial, que hicieron posible alinear y combinar el código de manera que se evite una carga innecesaria al llamar a las funciones. En el ciclo de desarrollo actual, se han realizado algunos esfuerzos para reducir el número de funciones llamadas al actualizar la barra de progreso. (Utilizamos barras de progreso para cualquier operación que pueda llevar algún tiempo, de modo que el usuario comprenda lo que está sucediendo). Obtener los resultados de llamadas a
funciones y evitar búsquedas simples entre
funciones ahorra decenas de cientos de milisegundos cuando se ejecuta, cuando hablamos de un millón de ejecuciones, por ejemplo.
Si tiene bucles estrechos o funciones recursivas en Python donde pueden ocurrir cientos de miles o más llamadas a funciones, debe tener en cuenta la sobrecarga de llamar a una función individual, ya que esto es de gran importancia. Tenga en cuenta las funciones integradas simples y la capacidad de combinar funciones para evitar gastos generales.
Búsqueda de atributos sobrecarga
Este problema es similar a la sobrecarga debido a una llamada de función, ya que el significado es casi el mismo.
Encontrar atributos de resolución en Python puede ser lento. (Y de nuevo, en PyPy, esto es más rápido). Sin embargo, manejar este problema es lo que solemos hacer en Mercurial.
Digamos que tiene el siguiente código:
obj = MyObject() total = 0 for i in len(obj.member): total += obj.member[i]
Omita que hay formas más eficientes de escribir este ejemplo (por ejemplo,
total = sum(obj.member)
), y tenga en cuenta que el ciclo debe definir obj.member en cada iteración. Python tiene un mecanismo relativamente sofisticado para definir
atributos . Para tipos simples, puede ser lo suficientemente rápido. Pero para los tipos complejos, este acceso de atributo puede llamar automáticamente a
__getattr__
,
__getattribute__
, varios métodos
dunder
e incluso a
@property
funciones definidas por el usuario. Esto es similar a una búsqueda rápida de un atributo que puede realizar varias llamadas a funciones, lo que conducirá a una carga adicional. Y esta carga puede agravarse si usa cosas como
obj.member1.member2.member3
, etc.
Cada definición de atributo provoca una carga adicional. Y dado que casi todo en Python es un diccionario, podemos decir que cada búsqueda de atributo es una búsqueda de diccionario. A partir de conceptos generales sobre estructuras de datos básicas, sabemos que la búsqueda de diccionario no es tan rápida como, por ejemplo, la búsqueda de índice. Sí, por supuesto, hay algunos trucos en CPython que pueden eliminar la sobrecarga debido a las búsquedas en el diccionario. Pero el tema principal que quiero tocar es que cualquier búsqueda de atributos es una posible fuga de rendimiento.
Para los bucles ajustados, especialmente aquellos que potencialmente exceden cientos de miles de iteraciones, puede evitar estos gastos generales medibles para encontrar atributos asignando un valor a una variable local. Veamos el siguiente ejemplo:
obj = MyObject() total = 0 member = obj.member for i in len(member): total += member[i]
Por supuesto, esto solo se puede hacer de manera segura si no se reemplaza en un ciclo. Si esto sucede, el iterador mantendrá un enlace al elemento antiguo y todo puede explotar.
Se puede realizar el mismo truco cuando se llama al método del objeto. En cambio
obj = MyObject() for i in range(1000000): obj.process(i)
Puedes hacer lo siguiente:
obj = MyObject() fn = obj.process for i in range(1000000:) fn(i)
También vale la pena señalar que en el caso en que la búsqueda de atributos necesita llamar a un método (como en el ejemplo anterior), Python 3.7 es relativamente
más rápido que las versiones anteriores. Pero estoy seguro de que aquí la carga excesiva está conectada, en primer lugar, con la llamada a la función, y no con la carga en la búsqueda de atributos. Por lo tanto, todo funcionará más rápido si abandona la búsqueda adicional de atributos.
Finalmente, dado que una búsqueda de atributos llama a una función para esto, se puede decir que la búsqueda de atributos generalmente es un problema menor que una carga debido a una llamada de función. Por lo general, para notar cambios significativos en la velocidad, deberá eliminar muchas búsquedas de atributos. En este caso, tan pronto como dé acceso a todos los atributos dentro del bucle, puede hablar sobre 10 o 20 atributos solo en el bucle antes de llamar a la función. Y los bucles con tan solo miles o menos de decenas de miles de iteraciones pueden proporcionar rápidamente cientos de miles o millones de búsquedas de atributos. ¡Así que ten cuidado!
Carga de objeto
Desde el punto de vista del intérprete de Python, todos los valores son objetos. En CPython, cada elemento es una estructura PyObject. Cada objeto controlado por el intérprete está en el montón y tiene su propia memoria que contiene el recuento de referencia, el tipo de objeto y otros parámetros. Cada objeto es eliminado por el recolector de basura. Esto significa que cada nuevo objeto agrega sobrecarga debido al recuento de referencias, recolección de basura, etc. (Y de nuevo, PyPy puede evitar esta carga innecesaria, ya que es más "cuidadoso" sobre la vida útil de los valores a corto plazo).
En general, mientras más valores únicos y objetos de Python crees, las cosas más lentas te funcionarán.
Digamos que iteras sobre una colección de un millón de objetos. Llama a una función para recopilar este objeto en una tupla:
for x in my_collection: a, b, c, d, e, f, g, h = process(x)
En este ejemplo,
process()
devolverá una tupla de 8 tuplas. No importa si destruimos el valor de retorno o no: esta tupla requiere crear al menos 9 valores en Python: 1 para la tupla misma y 8 para sus miembros internos. Bueno, en la vida real podría haber menos valores si
process()
devuelve una referencia a un objeto existente. O, por el contrario, puede haber más si sus tipos no son simples y requieren muchos PyObjects para representar. Solo quiero decir que bajo el capó del intérprete hay una verdadera combinación de objetos para la presentación completa de ciertas construcciones.
Desde mi propia experiencia, puedo decir que estos gastos generales son relevantes solo para operaciones que proporcionan ganancias de velocidad cuando se implementan en un idioma nativo como C o Rust. El problema es que el intérprete de CPython simplemente no puede ejecutar el código de bytes tan rápido que la carga adicional debido a la cantidad de objetos es importante. En cambio, es más probable que reduzca el rendimiento llamando a una función, o mediante cálculos engorrosos, etc. antes de que pueda notar la carga adicional debido a los objetos. Existen, por supuesto, algunas excepciones, a saber, la construcción de tuplas o diccionarios de varios valores.
Como un ejemplo concreto de sobrecarga, puede citar Mercurial con código C que analiza estructuras de datos de bajo nivel. Para una mayor velocidad de análisis, el código C ejecuta un orden de magnitud más rápido que CPython. Pero tan pronto como el código C crea PyObject para representar el resultado, la velocidad cae varias veces. En otras palabras, la carga implica crear y administrar elementos de Python para que puedan usarse en el código.
Una forma de evitar este problema es producir menos elementos en Python. Si necesita referirse a un solo elemento, inicie la función y devuélvala, y no una tupla o un diccionario de N elementos. Sin embargo, ¡no deje de monitorear la posible carga debido a llamadas a funciones!
Si tiene una gran cantidad de código que funciona lo suficientemente rápido utilizando la API de CPython C, y elementos que deben distribuirse entre diferentes módulos, no utilice tipos de Python que representen datos diferentes como estructuras C y ya haya compilado código para acceder a estas estructuras en lugar de pasar por la API de CPython C. Al evitar la API de CPython C para acceder a los datos, eliminará una gran cantidad de carga adicional.
El tratamiento de elementos como datos (en lugar de tener funciones para acceder a todo en una fila) sería el mejor enfoque para un pitonista. Otra solución para el código ya compilado es crear una instancia perezosa de PyObject. Si crea un tipo personalizado en Python (PyTypeObject) para representar elementos complejos, debe definir los
campos tp_members o
tp_getset para crear funciones C personalizadas para buscar el valor del atributo. Si, por ejemplo, escribe un analizador y sabe que los clientes solo tendrán acceso a un subconjunto de los campos analizados, puede crear rápidamente un tipo que contenga datos sin procesar, devolver este tipo y llamar a una función C para buscar atributos de Python que procesen PyObject. ¡Incluso puede retrasar el análisis hasta que se llame a la función para ahorrar recursos si el análisis nunca es necesario! Esta técnica es bastante rara, porque requiere escribir código no trivial, pero da un resultado positivo.
Determinación preliminar del tamaño de la colección.
Esto se aplica a la API de CPython C.
Al crear colecciones, como listas o diccionarios, use
PyList_New()
+
PyList_SET_ITEM()
para completar una nueva colección si su tamaño ya está definido en el momento de la creación. Esto predeterminará el tamaño de la colección para poder contener un número finito de elementos en ella. Esto ayuda a omitir la comprobación de un tamaño de colección suficiente al insertar elementos. ¡Al crear una colección de miles de artículos, esto le ahorrará algunos recursos!
Uso de copia cero en la API de C
A la API de Python C realmente le gusta crear copias de objetos en lugar de devolverles referencias. Por ejemplo,
PyBytes_FromStringAndSize () copia
char*
en la memoria reservada por Python. Si hace esto para una gran cantidad de valores o grandes datos, entonces podríamos hablar sobre gigabytes de E / S de memoria y la carga asociada en el asignador.
Si necesita escribir código de alto rendimiento sin una API C, debe familiarizarse con el
protocolo de búfer y los tipos relacionados, como
memoryview .Buffer protocol
está integrado en los tipos de Python y permite a los intérpretes emitir el tipo de / a bytes. También permite que el intérprete de código C reciba un descriptor
void*
de cierto tamaño. Esto le permite asociar cualquier dirección en memoria con PyObject. Muchas funciones que funcionan con datos binarios aceptan de manera transparente cualquier objeto que implemente el
buffer protocol
. Y si desea aceptar cualquier objeto que pueda considerarse como bytes, debe usar
unidades del formato s*
,
y*
o
w*
al recibir argumentos de función.
Usando el
buffer protocol
, le da al intérprete la mejor oportunidad disponible para usar operaciones de
zero-copy
y rechazar copiar bytes adicionales a la memoria.
Mediante el uso de tipos en Python del formulario
memoryview
, también permitirá que Python acceda a los niveles de memoria por referencia, en lugar de hacer copias.
Si tiene gigabytes de código que pasan por su programa Python, el uso perspicaz de los tipos de Python que admiten copia cero lo salvará de las diferencias de rendimiento. Una vez noté que
python-zstandard resultó ser más rápido que cualquier enlace Python LZ4 (aunque debería ser al revés), ya que usé demasiado el
buffer protocol
y evité la E / S de memoria excesiva en
python-zstandard
.
Conclusión
En este artículo, traté de hablar sobre algunas de las cosas que aprendí al optimizar mis programas de Python durante varios años. Repito y digo que no es de ninguna manera una descripción completa de los métodos de mejora del rendimiento de Python. Admito que probablemente uso Python más exigente que otros, y mis recomendaciones no se pueden aplicar a todos los programas.
De ninguna manera debe corregir masivamente su código de Python y eliminar, por ejemplo, la búsqueda de atributos después de leer este artículo . Como siempre, cuando se trata de la optimización del rendimiento, primero arregle dónde el código es especialmente lento.
py-spy Python. , , Python, . , , , !
, Python . , , Python - . Python – PyPy, . Python . , Python , . , « ». , , , Python, , , .
;-)