El camino para verificar el tipo de 4 millones de líneas de código Python. Parte 2

Hoy publicamos la segunda parte de la traducción del material sobre cómo Dropbox organizó el control de tipos para varios millones de líneas de código Python.

El camino para verificar el tipo de 4 millones de líneas de código Python. Parte 2

leer la primera parte

Soporte de tipo oficial (PEP 484)

Realizamos nuestros primeros experimentos serios con mypy en Dropbox durante la Hack Week 2014. Hack Week es un evento de una semana organizado por Dropbox. ¡Durante este tiempo, los empleados pueden trabajar en lo que quieran! Algunos de los proyectos tecnológicos más famosos de Dropbox comenzaron en eventos como estos. Como resultado de este experimento, llegamos a la conclusión de que mypy parece prometedor, aunque el proyecto aún no está listo para su uso generalizado.

En ese momento, la idea de estandarizar los sistemas de sugerencias tipo Python estaba en el aire. Como dije, desde Python 3.0 era posible usar anotaciones de tipo para funciones, pero eran solo expresiones arbitrarias, sin sintaxis ni semántica definidas. Durante la ejecución del programa, estas anotaciones, en su mayor parte, simplemente se ignoraban. Después de la Hack Week, comenzamos a trabajar en la estandarización de la semántica. Este trabajo propició el surgimiento PEPE 484 (Guido van Rossum, Łukasz Langa y yo colaboramos en este documento).

Nuestros motivos podrían verse desde dos lados. En primer lugar, esperábamos que todo el ecosistema de Python pudiera adoptar un enfoque común para el uso de sugerencias de tipo (un término utilizado en Python como equivalente a "anotaciones de tipo"). Esto, dados los posibles riesgos, sería mejor que utilizar muchos enfoques mutuamente incompatibles. En segundo lugar, queríamos discutir abiertamente los mecanismos de anotación de tipos con muchos miembros de la comunidad Python. Este deseo fue dictado en parte por el hecho de que no queríamos parecer "apóstatas" de las ideas básicas del lenguaje a los ojos de las grandes masas de programadores de Python. Es un lenguaje escrito dinámicamente, conocido como "mecanografía pato". En la comunidad, desde el principio, no pudo evitar surgir una actitud algo sospechosa hacia la idea de la escritura estática. Pero ese sentimiento finalmente se desvaneció después de que quedó claro que la escritura estática no iba a ser obligatoria (y después de que la gente se dio cuenta de que en realidad era útil).

La sintaxis de sugerencia de tipo que finalmente se adoptó fue muy similar a la que mypy admitía en ese momento. PEP 484 se lanzó con Python 3.5 en 2015. Python ya no era un lenguaje de tipado dinámico. Me gusta pensar en este evento como un hito importante en la historia de Python.

Inicio de la migración

A finales de 2015, Dropbox creó un equipo de tres personas para trabajar en mypy. Entre ellos estaban Guido van Rossum, Greg Price y David Fisher. A partir de ese momento, la situación empezó a desarrollarse con extrema rapidez. El primer obstáculo para el crecimiento de mypy fue el rendimiento. Como insinué anteriormente, en los primeros días del proyecto pensé en traducir la implementación de mypy a C, pero esta idea fue tachada de la lista por ahora. Nos vimos obligados a ejecutar el sistema utilizando el intérprete CPython, que no es lo suficientemente rápido para herramientas como mypy. (El proyecto PyPy, una implementación alternativa de Python con un compilador JIT, tampoco nos ayudó).

Afortunadamente, algunas mejoras algorítmicas nos han ayudado aquí. El primer “acelerador” poderoso fue la implementación de la verificación incremental. La idea detrás de esta mejora era simple: si todas las dependencias del módulo no han cambiado desde la ejecución anterior de mypy, entonces podemos usar los datos almacenados en caché durante la ejecución anterior mientras trabajamos con las dependencias. Sólo necesitábamos realizar una verificación de tipos en los archivos modificados y en los archivos que dependían de ellos. Mypy incluso fue un poco más allá: si la interfaz externa de un módulo no cambiaba, mypy suponía que otros módulos que importaron este módulo no necesitaban ser verificados nuevamente.

La verificación incremental nos ha ayudado mucho a la hora de anotar grandes cantidades de código existente. El punto es que este proceso generalmente implica muchas ejecuciones iterativas de mypy a medida que se agregan y mejoran gradualmente anotaciones al código. La primera ejecución de mypy fue todavía muy lenta porque tenía muchas dependencias que verificar. Luego, para mejorar la situación, implementamos un mecanismo de almacenamiento en caché remoto. Si mypy detecta que es probable que el caché local esté desactualizado, descarga la instantánea del caché actual para todo el código base desde el repositorio centralizado. Luego realiza una verificación incremental utilizando esta instantánea. Esto nos ha dado un gran paso más para aumentar el rendimiento de mypy.

Este fue un período de adopción rápida y natural de la verificación de tipos en Dropbox. A finales de 2016, ya teníamos aproximadamente 420000 líneas de código Python con anotaciones de tipo. Muchos usuarios se mostraron entusiasmados con la verificación de tipos. Cada vez más equipos de desarrollo utilizaban Dropbox mypy.

Todo parecía bien entonces, pero aún nos quedaba mucho por hacer. Comenzamos a realizar encuestas periódicas a los usuarios internos para identificar áreas problemáticas del proyecto y comprender qué problemas deben resolverse primero (esta práctica todavía se utiliza en la empresa hoy). Las más importantes, como quedó claro, eran dos tareas. En primer lugar, necesitábamos más cobertura de tipos del código y, en segundo lugar, necesitábamos que mypy funcionara más rápido. Estaba absolutamente claro que nuestro trabajo para acelerar mypy e implementarlo en los proyectos de la empresa aún estaba lejos de estar completo. Nosotros, plenamente conscientes de la importancia de estas dos tareas, nos propusimos resolverlas.

¡Más productividad!

Las comprobaciones incrementales hicieron que mypy fuera más rápido, pero la herramienta aún no era lo suficientemente rápida. Muchas comprobaciones incrementales duraron aproximadamente un minuto. La razón de esto fueron las importaciones cíclicas. Probablemente esto no sorprenda a nadie que haya trabajado con grandes bases de código escritas en Python. Teníamos conjuntos de cientos de módulos, cada uno de los cuales importaba indirectamente a todos los demás. Si se cambiaba algún archivo en un bucle de importación, mypy tenía que procesar todos los archivos en ese bucle y, a menudo, cualquier módulo que importara módulos de ese bucle. Uno de esos ciclos fue el infame "enredo de dependencia" que causó muchos problemas en Dropbox. Si bien esta estructura contenía varios cientos de módulos, aunque se importaban, directa o indirectamente, muchas pruebas, también se utilizaba en el código de producción.

Consideramos la posibilidad de "desenredar" las dependencias circulares, pero no teníamos los recursos para hacerlo. Había demasiado código con el que no estábamos familiarizados. Como resultado, se nos ocurrió un enfoque alternativo. Decidimos hacer que mypy funcione rápidamente incluso en presencia de "enredos de dependencia". Logramos este objetivo utilizando el demonio mypy. Un demonio es un proceso de servidor que implementa dos características interesantes. En primer lugar, almacena información sobre todo el código base en la memoria. Esto significa que cada vez que ejecuta mypy, no tiene que cargar datos en caché relacionados con miles de dependencias importadas. En segundo lugar, analiza cuidadosamente, a nivel de pequeñas unidades estructurales, las dependencias entre funciones y otras entidades. Por ejemplo, si la función foo llama a una función bar, entonces hay una dependencia foo de bar. Cuando un archivo cambia, el demonio primero, de forma aislada, procesa solo el archivo modificado. Luego analiza los cambios visibles externamente en ese archivo, como las firmas de funciones modificadas. El demonio usa información detallada sobre las importaciones solo para verificar aquellas funciones que realmente usan la función modificada. Normalmente, con este enfoque, hay que marcar muy pocas funciones.

Implementar todo esto no fue fácil, ya que la implementación original de mypy se centraba en gran medida en procesar un archivo a la vez. Tuvimos que lidiar con muchas situaciones límite, cuya aparición requería comprobaciones repetidas en los casos en que algo cambiaba en el código. Por ejemplo, esto sucede cuando a una clase se le asigna una nueva clase base. Una vez que hicimos lo que queríamos, pudimos reducir el tiempo de ejecución de la mayoría de las comprobaciones incrementales a solo unos segundos. Esto nos pareció una gran victoria.

¡Aún más productividad!

Junto con el almacenamiento en caché remoto que mencioné anteriormente, el demonio mypy resolvió casi por completo los problemas que surgen cuando un programador ejecuta con frecuencia la verificación de tipos, realizando cambios en una pequeña cantidad de archivos. Sin embargo, el rendimiento del sistema en el caso de uso menos favorable todavía estaba lejos de ser óptimo. Un inicio limpio de mypy podría tardar más de 15 minutos. Y esto fue mucho más de lo que nos hubiera gustado. Cada semana la situación empeoraba a medida que los programadores continuaban escribiendo código nuevo y añadiendo anotaciones al código existente. Nuestros usuarios todavía ansiaban más rendimiento, pero estábamos felices de encontrarlos a mitad de camino.

Decidimos volver a una de las ideas anteriores sobre mypy. Es decir, convertir código Python en código C. Experimentar con Cython (un sistema que permite traducir código escrito en Python a código C) no nos dio ninguna aceleración visible, por lo que decidimos revivir la idea de escribir nuestro propio compilador. Dado que el código base mypy (escrito en Python) ya contenía todas las anotaciones de tipo necesarias, pensamos que valdría la pena intentar utilizar estas anotaciones para acelerar el sistema. Rápidamente creé un prototipo para probar esta idea. Mostró un aumento de más de 10 veces en el rendimiento en varios micro-puntos de referencia. Nuestra idea era compilar módulos Python en módulos C usando Cython y convertir las anotaciones de tipo en verificaciones de tipo en tiempo de ejecución (generalmente las anotaciones de tipo se ignoran en tiempo de ejecución y solo las usan los sistemas de verificación de tipos). De hecho, planeamos traducir la implementación mypy de Python a un lenguaje diseñado para escribirse estáticamente, que se vería (y, en su mayor parte, funcionaría) exactamente como Python. (Este tipo de migración entre idiomas se ha convertido en una especie de tradición del proyecto mypy. La implementación original de mypy se escribió en Alore, luego surgió un híbrido sintáctico de Java y Python).

Centrarse en la API de la extensión CPython fue clave para no perder capacidades de gestión de proyectos. No necesitábamos implementar una máquina virtual ni ninguna biblioteca que mypy necesitara. Además, seguiríamos teniendo acceso a todo el ecosistema Python y a todas las herramientas (como pytest). Esto significaba que podíamos seguir usando código Python interpretado durante el desarrollo, lo que nos permitía seguir trabajando con un patrón muy rápido para realizar cambios en el código y probarlo, en lugar de esperar a que se compilara el código. Parecía que estábamos haciendo un gran trabajo al sentarnos en dos sillas, por así decirlo, y nos encantó.

El compilador, al que llamamos mypyc (ya que utiliza mypy como interfaz para analizar tipos), resultó ser un proyecto muy exitoso. En general, logramos una aceleración de aproximadamente 4 veces para ejecuciones frecuentes de mypy sin almacenamiento en caché. Desarrollar el núcleo del proyecto mypyc nos llevó a un pequeño equipo formado por Michael Sullivan, Ivan Levkivsky, Hugh Hahn y yo aproximadamente 4 meses calendario. Esta cantidad de trabajo fue mucho menor de lo que se habría necesitado para reescribir mypy, por ejemplo, en C++ o Go. Y tuvimos que hacer muchos menos cambios en el proyecto de los que hubiéramos tenido que hacer al reescribirlo en otro idioma. También esperábamos poder llevar mypyc a un nivel tal que otros programadores de Dropbox pudieran usarlo para compilar y acelerar su código.

Para lograr este nivel de rendimiento, tuvimos que aplicar algunas soluciones de ingeniería interesantes. Por lo tanto, el compilador puede acelerar muchas operaciones mediante el uso de construcciones rápidas de bajo nivel en C. Por ejemplo, una llamada a función compilada se traduce en una llamada a función en C. Y esa llamada es mucho más rápida que llamar a una función interpretada. Algunas operaciones, como las búsquedas en diccionarios, todavía implicaban el uso de llamadas C-API regulares desde CPython, que eran solo un poco más rápidas cuando se compilaban. Pudimos eliminar la carga adicional en el sistema creada por la interpretación, pero esto en este caso solo dio una pequeña ganancia en términos de rendimiento.

Para identificar las operaciones "lentas" más comunes, realizamos perfiles de código. Armados con estos datos, intentamos modificar mypyc para que generara código C más rápido para tales operaciones, o reescribir el código Python correspondiente usando operaciones más rápidas (y a veces simplemente no teníamos una solución lo suficientemente simple para ese u otro problema). . Reescribir el código Python era a menudo una solución más fácil al problema que hacer que el compilador realizara automáticamente la misma transformación. A largo plazo, queríamos automatizar muchas de estas transformaciones, pero en ese momento estábamos enfocados en acelerar mypy con un mínimo esfuerzo. Y para avanzar hacia este objetivo, tomamos varios atajos.

To be continued ...

Estimados lectores! ¿Cuáles fueron tus impresiones sobre el proyecto mypy cuando supiste de su existencia?

El camino para verificar el tipo de 4 millones de líneas de código Python. Parte 2
El camino para verificar el tipo de 4 millones de líneas de código Python. Parte 2

Fuente: habr.com

Añadir un comentario