O camiño para verificar 4 millóns de liñas de código Python. Parte 2

Hoxe publicamos a segunda parte da tradución de material sobre como Dropbox organizou o control de tipos para varios millóns de liñas de código Python.

O camiño para verificar 4 millóns de liñas de código Python. Parte 2

Ler a primeira parte

Soporte de tipo oficial (PEP 484)

Levamos a cabo os nosos primeiros experimentos serios con mypy en Dropbox durante a Hack Week de 2014. A Hack Week é un evento dunha semana organizado por Dropbox. Durante este tempo, os empregados poden traballar no que queiran! Algúns dos proxectos tecnolóxicos máis famosos de Dropbox comezaron en eventos coma estes. Como resultado deste experimento, chegamos á conclusión de que mypy parece prometedor, aínda que o proxecto aínda non está preparado para un uso xeneralizado.

Nese momento, a idea de estandarizar os sistemas de indicación de tipo Python estaba no aire. Como dixen, desde Python 3.0 era posible usar anotacións de tipo para funcións, pero estas eran só expresións arbitrarias, sen sintaxe e semántica definidas. Durante a execución do programa, estas anotacións foron, na súa maior parte, simplemente ignoradas. Despois de Hack Week, comezamos a traballar na estandarización da semántica. Este traballo provocou a aparición PEP 484 (Guido van Rossum, Łukasz Langa e eu colaboramos neste documento).

Os nosos motivos podían verse desde dous lados. En primeiro lugar, esperabamos que todo o ecosistema de Python puidese adoptar un enfoque común para o uso de suxestións de tipo (un termo usado en Python como o equivalente de "anotacións de tipo"). Isto, dados os posibles riscos, sería mellor que usar moitos enfoques mutuamente incompatibles. En segundo lugar, queriamos discutir abertamente os mecanismos de anotación de tipos con moitos membros da comunidade de Python. Este desexo foi en parte ditado polo feito de que non queremos parecer "apóstatas" das ideas básicas da linguaxe aos ollos das amplas masas de programadores de Python. É unha linguaxe de tipo dinámico, coñecida como "duck typing". Na comunidade, ao principio, unha actitude algo sospeitosa cara á idea de escribir estática non puido evitar que xurdiu. Pero ese sentimento finalmente diminuíu despois de que quedou claro que a escritura estática non ía ser obrigatoria (e despois de que a xente se decatase de que era realmente útil).

A sintaxe de suxestión de tipo que finalmente se adoptou foi moi similar á que mypy admitía nese momento. PEP 484 foi lanzado con Python 3.5 en 2015. Python xa non era unha linguaxe de tipo dinámico. Gústame pensar neste evento como un fito significativo na historia de Python.

Inicio da migración

A finais de 2015, Dropbox creou un equipo de tres persoas para traballar en mypy. Entre eles Guido van Rossum, Greg Price e David Fisher. A partir dese momento, a situación comezou a desenvolverse moi rápido. O primeiro obstáculo para o crecemento de mypy foi o rendemento. Como dixen anteriormente, nos primeiros días do proxecto pensei en traducir a implementación de mypy a C, pero esta idea foi tachada da lista polo de agora. Estivemos atascados coa execución do sistema usando o intérprete CPython, que non é o suficientemente rápido para ferramentas como mypy. (O proxecto PyPy, unha implementación alternativa de Python cun compilador JIT, tampouco nos axudou).

Afortunadamente, aquí nos axudaron algunhas melloras algorítmicas. O primeiro "acelerador" poderoso foi a implementación da comprobación incremental. A idea detrás desta mellora era sinxela: se todas as dependencias do módulo non cambiaron desde a execución anterior de mypy, entón podemos usar os datos almacenados na caché durante a execución anterior mentres traballamos con dependencias. Só necesitabamos realizar a comprobación de tipo nos ficheiros modificados e nos ficheiros que dependían deles. Mypy mesmo foi un pouco máis aló: se a interface externa dun módulo non cambiaba, mypy asumiu que non era necesario revisar de novo outros módulos que importaban este módulo.

A comprobación incremental axudounos moito á hora de anotar grandes cantidades de código existente. A cuestión é que este proceso adoita implicar moitas execucións iterativas de mypy a medida que as anotacións vanse engadindo gradualmente ao código e mellorando gradualmente. A primeira execución de mypy aínda foi moi lenta porque tiña moitas dependencias que comprobar. Despois, para mellorar a situación, implementamos un mecanismo de caché remoto. Se mypy detecta que é probable que a caché local estea desactualizada, descarga a instantánea da caché actual para toda a base de código desde o repositorio centralizado. Despois realiza unha comprobación incremental usando esta instantánea. Isto levounos un gran paso máis para aumentar o rendemento de mypy.

Este foi un período de adopción rápida e natural da comprobación de tipos en Dropbox. A finais de 2016, xa tiñamos aproximadamente 420000 liñas de código Python con anotacións de tipo. Moitos usuarios estaban entusiasmados coa comprobación de tipos. Cada vez máis equipos de desenvolvemento usaban Dropbox mypy.

Daquela todo parecía ben, pero aínda nos quedaba moito por facer. Comezamos a realizar enquisas internas periódicas aos usuarios para identificar as áreas problemáticas do proxecto e comprender cales son os problemas que hai que resolver primeiro (esta práctica aínda se usa na empresa na actualidade). O máis importante, como quedou claro, foron dúas tarefas. En primeiro lugar, necesitabamos máis cobertura de tipo do código, segundo, necesitabamos que mypy funcione máis rápido. Estaba absolutamente claro que o noso traballo para acelerar mypy e implementalo nos proxectos da empresa aínda estaba lonxe de completarse. Nós, plenamente conscientes da importancia destas dúas tarefas, puxémonos a resolvelos.

Máis produtividade!

As comprobacións incrementais fixeron que mypy fose máis rápido, pero a ferramenta aínda non era o suficientemente rápida. Moitas comprobacións incrementais duraron aproximadamente un minuto. A razón diso foron as importacións cíclicas. Isto probablemente non sorprenderá a ninguén que teña traballado con grandes bases de código escritas en Python. Tiñamos conxuntos de centos de módulos, cada un dos cales importaba indirectamente todos os demais. Se se modificaba algún ficheiro nun bucle de importación, mypy tiña que procesar todos os ficheiros nese bucle e, a miúdo, calquera módulo que importase módulos dese bucle. Un destes ciclos foi o infame "enredo de dependencia" que causou moitos problemas en Dropbox. Unha vez que esta estrutura contiña varios centos de módulos, mentres se importaban, directa ou indirectamente, moitas probas, tamén se utilizou no código de produción.

Consideramos a posibilidade de “desenredar” dependencias circulares, pero non tiñamos recursos para facelo. Había demasiado código co que non estabamos familiarizados. Como resultado, creamos un enfoque alternativo. Decidimos facer que mypy funcione rapidamente mesmo en presenza de "enredos de dependencia". Conseguimos este obxectivo usando o daemon mypy. Un daemon é un proceso de servidor que implementa dúas características interesantes. En primeiro lugar, almacena información sobre toda a base de código na memoria. Isto significa que cada vez que executas mypy, non tes que cargar datos almacenados na caché relacionados con miles de dependencias importadas. En segundo lugar, analiza con atención, a nivel de pequenas unidades estruturais, as dependencias entre funcións e outras entidades. Por exemplo, se a función foo chama unha función bar, entón hai unha dependencia foo de bar. Cando un ficheiro cambia, o daemon primeiro procesa, de forma illada, só o ficheiro modificado. A continuación, analiza os cambios visibles externamente nese ficheiro, como as sinaturas de funcións modificadas. O daemon usa información detallada sobre as importacións só para comprobar as funcións que realmente usan a función modificada. Normalmente, con este enfoque, tes que comprobar moi poucas funcións.

Implementar todo isto non foi doado, xa que a implementación orixinal de mypy estaba moi centrada en procesar un ficheiro á vez. Tivemos que facer fronte a moitas situacións límite, cuxa aparición requiriu comprobacións repetidas nos casos en que algo cambiase no código. Por exemplo, isto ocorre cando a unha clase se lle asigna unha nova clase base. Unha vez que fixemos o que queriamos, puidemos reducir o tempo de execución da maioría das comprobacións incrementais a só uns segundos. Isto pareceunos unha gran vitoria.

Aínda máis produtividade!

Xunto co caché remoto que comentei anteriormente, o daemon mypy resolveu case por completo os problemas que xorden cando un programador executa con frecuencia a comprobación de tipos, facendo cambios nun pequeno número de ficheiros. Non obstante, o rendemento do sistema no caso de uso menos favorable aínda estaba lonxe de ser óptimo. Un inicio limpo de mypy pode levar máis de 15 minutos. E isto foi moito máis do que estariamos contentos. Cada semana a situación empeoraba xa que os programadores seguían escribindo código novo e engadindo anotacións ao código existente. Os nosos usuarios aínda tiñan fame de máis rendemento, pero estivemos encantados de coñecelos a medio camiño.

Decidimos volver a unha das ideas anteriores sobre mypy. É dicir, converter código Python en código C. Experimentar con Cython (un sistema que permite traducir código escrito en Python a código C) non nos deu ningunha aceleración visible, polo que decidimos revivir a idea de escribir o noso propio compilador. Dado que a base de código mypy (escrita en Python) xa contiña todas as anotacións de tipo necesarias, pensamos que pagaría a pena tentar utilizar estas anotacións para acelerar o sistema. Rápidamente creei un prototipo para probar esta idea. Mostrou un aumento de máis de 10 veces no rendemento en varios micro-benchmarks. A nosa idea era compilar módulos de Python en módulos C usando Cython, e converter as anotacións de tipo en comprobacións de tipo en tempo de execución (normalmente, as anotacións de tipo son ignoradas en tempo de execución e só usan os sistemas de verificación de tipos). En realidade, planeamos traducir a implementación de mypy de Python a unha linguaxe que foi deseñada para escribirse de forma estática, que se vería (e, na súa maior parte, funcionaría) exactamente como Python. (Este tipo de migración entre linguas converteuse nunha especie de tradición do proxecto mypy. A implementación orixinal de mypy foi escrita en Alore, despois houbo un híbrido sintáctico de Java e Python).

Centrarse na API de extensión CPython foi clave para non perder as capacidades de xestión de proxectos. Non necesitamos implementar unha máquina virtual nin ningunha biblioteca que mypy precisase. Ademais, aínda teríamos acceso a todo o ecosistema de Python e a todas as ferramentas (como pytest). Isto significaba que podíamos seguir usando código Python interpretado durante o desenvolvemento, o que nos permitiu seguir traballando cun patrón moi rápido de facer cambios de código e probalo, en lugar de esperar a que se compilase o código. Parecía que estabamos facendo un gran traballo sentados en dúas cadeiras, por así dicilo, e encantounos.

O compilador, que chamamos mypyc (xa que usa mypy como frontend para analizar tipos), resultou ser un proxecto moi exitoso. En xeral, conseguimos unha aceleración de aproximadamente 4 veces para as execucións frecuentes de mypy sen caché. O desenvolvemento do núcleo do proxecto mypyc levou un pequeno equipo de Michael Sullivan, Ivan Levkivsky, Hugh Hahn e eu uns 4 meses naturais. Esta cantidade de traballo era moito menor do que sería necesario para reescribir mypy, por exemplo, en C++ ou Go. E tivemos que facer moitos menos cambios no proxecto dos que teriamos que facer ao reescribilo noutro idioma. Tamén esperabamos que puidésemos levar mypyc a un nivel tal que outros programadores de Dropbox puidesen utilizalo para compilar e acelerar o seu código.

Para acadar este nivel de rendemento, tivemos que aplicar algunhas solucións de enxeñería interesantes. Así, o compilador pode acelerar moitas operacións usando construcións rápidas e de baixo nivel C. Por exemplo, unha chamada de función compilada tradúcese nunha chamada de función C. E tal chamada é moito máis rápida que chamar a unha función interpretada. Algunhas operacións, como as procuras de dicionario, aínda implicaban o uso de chamadas C-API regulares desde CPython, que só eran lixeiramente máis rápidas cando se compilaban. Puidemos eliminar a carga adicional no sistema creada pola interpretación, pero isto neste caso deu só unha pequena ganancia en termos de rendemento.

Para identificar as operacións "lentas" máis comúns, realizamos un perfil de código. Armados con estes datos, tentamos axustar mypyc para que xerase código C máis rápido para tales operacións, ou reescribir o código Python correspondente usando operacións máis rápidas (e ás veces simplemente non tiñamos unha solución o suficientemente sinxela para ese ou outro problema) . Reescribir o código de Python adoita ser unha solución máis sinxela ao problema que que o compilador realice automaticamente a mesma transformación. A longo prazo, queriamos automatizar moitas destas transformacións, pero naquel momento centrámonos en acelerar mypy cun mínimo esforzo. E para avanzar cara a este obxectivo, cortamos varias esquinas.

Continuar ...

Queridos lectores! Cales foron as túas impresións sobre o proxecto mypy cando soubeses da súa existencia?

O camiño para verificar 4 millóns de liñas de código Python. Parte 2
O camiño para verificar 4 millóns de liñas de código Python. Parte 2

Fonte: www.habr.com

Engadir un comentario