O caminho para a verificação de tipo de 4 milhões de linhas de código Python. Parte 2

Hoje publicamos a segunda parte da tradução do material sobre como o Dropbox organizou o controle de tipo para vários milhões de linhas de código Python.

O caminho para a verificação de tipo de 4 milhões de linhas de código Python. Parte 2

Leia a primeira parte

Suporte de tipo oficial (PEP 484)

Conduzimos nossos primeiros experimentos sérios com mypy no Dropbox durante a Hack Week 2014. Hack Week é um evento de uma semana organizado pelo Dropbox. Durante esse período, os funcionários podem trabalhar no que quiserem! Alguns dos projetos tecnológicos mais famosos do Dropbox começaram em eventos como esses. Como resultado deste experimento, concluímos que o mypy parece promissor, embora o projeto ainda não esteja pronto para uso generalizado.

Na época, estava no ar a ideia de padronizar sistemas de dicas do tipo Python. Como eu disse, desde o Python 3.0 era possível usar anotações de tipo para funções, mas eram apenas expressões arbitrárias, sem sintaxe e semântica definidas. Durante a execução do programa, estas anotações foram, na sua maioria, simplesmente ignoradas. Após a Hack Week, começamos a trabalhar na padronização da semântica. Este trabalho levou ao surgimento PEP 484 (Guido van Rossum, Łukasz Langa e eu colaboramos neste documento).

Nossos motivos poderiam ser vistos de dois lados. Primeiro, esperávamos que todo o ecossistema Python pudesse adotar uma abordagem comum para o uso de dicas de tipo (um termo usado em Python como equivalente a "anotações de tipo"). Isto, dados os possíveis riscos, seria melhor do que utilizar muitas abordagens mutuamente incompatíveis. Segundo, queríamos discutir abertamente os mecanismos de anotação de tipo com muitos membros da comunidade Python. Esse desejo foi parcialmente ditado pelo fato de que não gostaríamos de parecer “apóstatas” das ideias básicas da linguagem aos olhos das grandes massas de programadores Python. É uma linguagem de digitação dinâmica, conhecida como "digitação de pato". Na comunidade, logo no início, não poderia deixar de surgir uma atitude um tanto desconfiada em relação à ideia de digitação estática. Mas esse sentimento acabou diminuindo depois que ficou claro que a digitação estática não seria obrigatória (e depois que as pessoas perceberam que ela era realmente útil).

A sintaxe da dica de tipo que acabou sendo adotada era muito semelhante à que o mypy suportava na época. PEP 484 foi lançado com Python 3.5 em 2015. Python não era mais uma linguagem de tipo dinâmico. Gosto de pensar neste evento como um marco significativo na história do Python.

Início da migração

No final de 2015, o Dropbox criou uma equipe de três pessoas para trabalhar no mypy. Eles incluíam Guido van Rossum, Greg Price e David Fisher. A partir desse momento, a situação começou a evoluir de forma extremamente rápida. O primeiro obstáculo ao crescimento do mypy foi o desempenho. Como sugeri acima, nos primeiros dias do projeto pensei em traduzir a implementação do mypy para C, mas essa ideia foi riscada da lista por enquanto. Ficamos presos à execução do sistema usando o interpretador CPython, que não é rápido o suficiente para ferramentas como o mypy. (O projeto PyPy, uma implementação alternativa do Python com um compilador JIT, também não nos ajudou.)

Felizmente, algumas melhorias algorítmicas vieram em nosso auxílio aqui. O primeiro “acelerador” poderoso foi a implementação da verificação incremental. A ideia por trás dessa melhoria era simples: se todas as dependências do módulo não mudaram desde a execução anterior do mypy, então podemos usar os dados armazenados em cache durante a execução anterior enquanto trabalhamos com dependências. Só precisávamos realizar a verificação de tipo nos arquivos modificados e nos arquivos que dependiam deles. O Mypy foi um pouco além: se a interface externa de um módulo não mudasse, o mypy presumia que os outros módulos que importaram esse módulo não precisavam ser verificados novamente.

A verificação incremental nos ajudou muito ao anotar grandes quantidades de código existente. A questão é que esse processo geralmente envolve muitas execuções iterativas do mypy à medida que as anotações são gradualmente adicionadas ao código e melhoradas gradualmente. A primeira execução do mypy ainda foi muito lenta porque tinha muitas dependências para verificar. Então, para melhorar a situação, implementamos um mecanismo de cache remoto. Se mypy detectar que o cache local provavelmente está desatualizado, ele baixa o instantâneo do cache atual para toda a base de código do repositório centralizado. Em seguida, ele executa uma verificação incremental usando esse instantâneo. Isso nos deu mais um grande passo para aumentar o desempenho do mypy.

Este foi um período de adoção rápida e natural da verificação de tipos no Dropbox. Ao final de 2016, já tínhamos aproximadamente 420000 mil linhas de código Python com anotações de tipo. Muitos usuários ficaram entusiasmados com a verificação de tipo. Cada vez mais equipes de desenvolvimento usavam o Dropbox mypy.

Tudo parecia bem então, mas ainda tínhamos muito que fazer. Passamos a realizar pesquisas periódicas com usuários internos para identificar áreas problemáticas do projeto e entender quais questões precisam ser resolvidas primeiro (essa prática ainda é utilizada na empresa até hoje). As mais importantes, como ficou claro, eram duas tarefas. Primeiro, precisávamos de mais cobertura de tipo do código e, segundo, precisávamos que o mypy funcionasse mais rápido. Ficou absolutamente claro que nosso trabalho para acelerar o mypy e implementá-lo nos projetos da empresa ainda estava longe de ser concluído. Nós, plenamente conscientes da importância destas duas tarefas, decidimos resolvê-las.

Mais produtividade!

As verificações incrementais tornaram o mypy mais rápido, mas a ferramenta ainda não era rápida o suficiente. Muitas verificações incrementais duraram cerca de um minuto. A razão para isso foram as importações cíclicas. Isso provavelmente não surpreenderá ninguém que tenha trabalhado com grandes bases de código escritas em Python. Tínhamos conjuntos de centenas de módulos, cada um dos quais importava indiretamente todos os outros. Se algum arquivo em um loop de importação fosse alterado, mypy teria que processar todos os arquivos desse loop e, muitas vezes, quaisquer módulos que importassem módulos desse loop. Um desses ciclos foi o infame “emaranhado de dependências” que causou muitos problemas no Dropbox. Como esta estrutura continha várias centenas de módulos, embora importasse, direta ou indiretamente, muitos testes, ela também foi utilizada no código de produção.

Consideramos a possibilidade de “desenredar” dependências circulares, mas não tínhamos recursos para isso. Havia muito código com o qual não estávamos familiarizados. Como resultado, criamos uma abordagem alternativa. Decidimos fazer o mypy funcionar rapidamente, mesmo na presença de “emaranhados de dependência”. Alcançamos esse objetivo usando o daemon mypy. Um daemon é um processo de servidor que implementa dois recursos interessantes. Em primeiro lugar, armazena informações sobre toda a base de código na memória. Isso significa que toda vez que você executa o mypy, você não precisa carregar dados em cache relacionados a milhares de dependências importadas. Em segundo lugar, analisa cuidadosamente, ao nível das pequenas unidades estruturais, as dependências entre funções e outras entidades. Por exemplo, se a função foo chama uma função bar, então há uma dependência foo de bar. Quando um arquivo é alterado, o daemon primeiro, isoladamente, processa apenas o arquivo alterado. Em seguida, ele analisa as alterações visíveis externamente nesse arquivo, como assinaturas de funções alteradas. O daemon usa informações detalhadas sobre importações apenas para verificar novamente as funções que realmente usam a função modificada. Normalmente, com essa abordagem, você precisa verificar poucas funções.

Implementar tudo isso não foi fácil, já que a implementação original do mypy estava fortemente focada no processamento de um arquivo por vez. Tivemos que lidar com muitas situações limítrofes, cuja ocorrência exigia verificações repetidas nos casos em que algo mudava no código. Por exemplo, isso acontece quando uma classe recebe uma nova classe base. Depois de fazer o que queríamos, conseguimos reduzir o tempo de execução da maioria das verificações incrementais para apenas alguns segundos. Isso pareceu uma grande vitória para nós.

Ainda mais produtividade!

Junto com o cache remoto que discuti acima, o daemon mypy resolveu quase completamente os problemas que surgem quando um programador executa frequentemente a verificação de tipo, fazendo alterações em um pequeno número de arquivos. No entanto, o desempenho do sistema no caso de utilização menos favorável ainda estava longe do ideal. Uma inicialização limpa do mypy pode levar mais de 15 minutos. E isso foi muito mais do que gostaríamos. A cada semana a situação piorava à medida que os programadores continuavam a escrever novos códigos e a adicionar anotações ao código existente. Nossos usuários ainda queriam mais desempenho, mas ficamos felizes em encontrá-los no meio do caminho.

Decidimos retornar a uma das ideias anteriores sobre mypy. Ou seja, para converter código Python em código C. Experimentar o Cython (um sistema que permite traduzir código escrito em Python para código C) não nos deu nenhuma aceleração visível, então decidimos reviver a ideia de escrever nosso próprio compilador. Como a base de código mypy (escrita em Python) já continha todas as anotações de tipo necessárias, pensamos que valeria a pena tentar usar essas anotações para acelerar o sistema. Rapidamente criei um protótipo para testar essa ideia. Ele mostrou um aumento de mais de 10 vezes no desempenho em vários micro-benchmarks. Nossa idéia era compilar módulos Python para módulos C usando Cython e transformar anotações de tipo em verificações de tipo em tempo de execução (geralmente as anotações de tipo são ignoradas em tempo de execução e usadas apenas por sistemas de verificação de tipo). Na verdade, planejamos traduzir a implementação mypy do Python para uma linguagem que foi projetada para ser digitada estaticamente, que pareceria (e, na maior parte, funcionaria) exatamente como Python. (Esse tipo de migração entre idiomas tornou-se uma espécie de tradição do projeto mypy. A implementação original do mypy foi escrita em Alore, então houve um híbrido sintático de Java e Python).

Focar na API de extensão CPython foi fundamental para não perder recursos de gerenciamento de projetos. Não precisamos implementar uma máquina virtual ou quaisquer bibliotecas que o mypy precisasse. Além disso, ainda teríamos acesso a todo o ecossistema Python e a todas as ferramentas (como o pytest). Isso significava que poderíamos continuar a usar código Python interpretado durante o desenvolvimento, permitindo-nos continuar trabalhando com um padrão muito rápido de fazer alterações no código e testá-lo, em vez de esperar a compilação do código. Parecia que estávamos fazendo um ótimo trabalho sentados em duas cadeiras, por assim dizer, e adoramos.

O compilador, que chamamos de mypyc (já que usa mypy como front-end para análise de tipos), acabou sendo um projeto de muito sucesso. No geral, alcançamos uma aceleração de aproximadamente 4x para execuções frequentes de mypy sem armazenamento em cache. O desenvolvimento do núcleo do projeto mypyc levou cerca de 4 meses para uma pequena equipe composta por Michael Sullivan, Ivan Levkivsky, Hugh Hahn e eu. Essa quantidade de trabalho foi muito menor do que seria necessário para reescrever mypy, por exemplo, em C++ ou Go. E tivemos que fazer muito menos alterações no projeto do que teríamos que fazer ao reescrevê-lo em outro idioma. Também esperávamos poder levar o mypyc a um nível tal que outros programadores do Dropbox pudessem usá-lo para compilar e acelerar seu código.

Para atingir este nível de desempenho, tivemos que aplicar algumas soluções de engenharia interessantes. Assim, o compilador pode acelerar muitas operações usando construções rápidas e de baixo nível em C. Por exemplo, uma chamada de função compilada é traduzida em uma chamada de função C. E tal chamada é muito mais rápida do que chamar uma função interpretada. Algumas operações, como pesquisas de dicionário, ainda envolviam o uso de chamadas C-API regulares do CPython, que eram apenas um pouco mais rápidas quando compiladas. Conseguimos eliminar a carga adicional no sistema criada pela interpretação, mas neste caso isto proporcionou apenas um pequeno ganho em termos de desempenho.

Para identificar as operações “lentas” mais comuns, realizamos o perfil do código. Armados com esses dados, tentamos ajustar o mypyc para que ele gerasse código C mais rápido para tais operações ou reescrever o código Python correspondente usando operações mais rápidas (e às vezes simplesmente não tínhamos uma solução simples o suficiente para um ou outro problema) . Reescrever o código Python costumava ser uma solução mais fácil para o problema do que fazer com que o compilador executasse automaticamente a mesma transformação. No longo prazo, queríamos automatizar muitas dessas transformações, mas na época estávamos focados em acelerar o mypy com o mínimo de esforço. E ao avançarmos em direção a esse objetivo, cortamos vários atalhos.

Para ser continuado ...

Caros leitores! Quais foram suas impressões sobre o projeto mypy quando soube de sua existência?

O caminho para a verificação de tipo de 4 milhões de linhas de código Python. Parte 2
O caminho para a verificação de tipo de 4 milhões de linhas de código Python. Parte 2

Fonte: habr.com

Adicionar um comentário