Hoje publicamos a segunda parte da tradução do material sobre como o Dropbox organizou o controle de tipos em vários milhões de linhas de código Python.
→
Suporte oficial a tipos (PEP 484)
Realizamos os primeiros experimentos sérios com o mypy no Dropbox durante a Hack Week de 2014. A Hack Week é um evento realizado pelo Dropbox durante uma semana, onde os funcionários podem trabalhar em qualquer projeto que desejarem! Alguns dos mais famosos projetos tecnológicos do Dropbox começaram em eventos como este. Como resultado desse experimento, concluímos que o mypy parece promissor, embora o projeto ainda não estivesse pronto para uso generalizado.
Naquela época, havia uma ideia no ar sobre a padronização dos sistemas de sugestões de tipos em Python. Como mencionei, a partir do Python 3.0, era possível utilizar anotações de tipos para funções, mas eram apenas expressões arbitrárias, sem uma sintaxe ou semântica definidas. Durante a execução do programa, essas anotações eram, na maioria das vezes, simplesmente ignoradas. Após a Hack Week, começamos a trabalhar na padronização da semântica. Esse trabalho levou à criação (este documento foi elaborado em conjunto por Guido van Rossum, Łukasz Langa e eu).
Nossos motivos podem ser vistos de duas maneiras. Em primeiro lugar, esperávamos que toda a ecossistema Python pudesse adotar uma abordagem comum para a utilização de sugestões de tipos (type hints — termo usado em Python como equivalente a 'anotações de tipos'). Isso, considerando os riscos potenciais, seria melhor do que o uso de muitas abordagens mutuamente incompatíveis. Em segundo lugar, queríamos discutir abertamente os mecanismos de anotação de tipos com muitos membros da comunidade Python. Em parte, esse desejo surgiu do fato de que não queríamos parecer 'hereges' em relação às ideias fundamentais da linguagem aos olhos da vasta maioria dos programadores Python. Essa é uma linguagem de tipagem dinâmica, conhecida pela 'tipagem de pato'. No início, a comunidade não podia deixar de ter uma certa desconfiança em relação à ideia de tipagem estática. Mas essa atitude acabou se amenizando — depois que ficou claro que a tipagem estática não seria obrigatória (e depois que as pessoas perceberam que ela realmente é útil).
A sintaxe de anotações de tipos adotada acabou sendo muito semelhante à que era suportada pelo mypy na época. O documento PEP 484 foi publicado junto com o Python 3.5 em 2015. O Python deixou de ser uma linguagem que suportava apenas tipagem dinâmica. Gosto de pensar neste evento como um marco significativo na história do Python.
Início da migração
No final de 2015, uma equipe de três pessoas foi formada no Dropbox para trabalhar no mypy. Ela incluía Guido van Rossum, Greg Price e David Fisher. A partir deste momento, a situação começou a se desenvolver rapidamente. O primeiro obstáculo no caminho do crescimento do mypy foi o desempenho. Como mencionei anteriormente, no início do desenvolvimento do projeto, considerei a possibilidade de reescrever a implementação do mypy na linguagem C, mas essa ideia foi descartada por enquanto. Fomos obrigados a usar o interpretador CPython, que não tem a velocidade suficiente para ferramentas como o mypy. (O projeto PyPy, uma implementação alternativa do Python com compilador JIT, também não nos ajudou.)
Felizmente, algumas melhorias algorítmicas vieram em nosso socorro. O primeiro "acelerador" poderoso foi a implementação da verificação incremental. A ideia dessa melhoria era simples: se todas as dependências de um módulo desde a última execução do mypy não mudaram, podemos usar, durante o processamento das dependências, dados que foram armazenados em cache na sessão anterior. Precisávamos apenas realizar a verificação de tipos nos arquivos alterados e nos arquivos que dependiam deles. O mypy foi um pouco além: se a interface externa do módulo não mudasse, o mypy considerava que outros módulos que importavam esse módulo não precisavam ser verificados novamente.
A verificação incremental ajudou muito na anotação de grandes volumes de código existente. O fato é que esse processo normalmente envolve várias execuções iterativas do mypy, à medida que as anotações são gradualmente adicionadas ao código e melhoradas. A primeira execução do mypy ainda era muito lenta, pois exigia a verificação de muitas dependências. Para melhorar a situação, implementamos um mecanismo de cache remoto. Se o mypy detecta que o cache local provavelmente está desatualizado, ele carrega a versão atual do cache da base de código de um repositório centralizado. Em seguida, ele executa, usando essa versão, uma verificação incremental. Isso nos deu um passo significativo em direção ao aumento do desempenho do mypy.
Foi um período de adoção rápida e natural do sistema de verificação de tipos no Dropbox. Até o final de 2016, já tínhamos cerca de 420.000 linhas de código Python com anotações de tipo. Muitos usuários acolheram a verificação de tipos com entusiasmo. No Dropbox, o mypy era utilizado por cada vez mais equipes de desenvolvedores.
Naquele momento, tudo parecia bem, mas ainda havia muito a ser feito. Começamos a realizar pesquisas internas periódicas com os usuários para identificar os pontos problemáticos do projeto e entender quais questões precisavam ser resolvidas primeiro (essa prática ainda é usada na empresa hoje em dia). As duas questões mais importantes, como ficou claro, eram: primeiro, precisávamos de uma cobertura de código maior com tipos; segundo, era necessário que o mypy funcionasse mais rápido. Era evidente que nosso trabalho para acelerar o mypy e integrá-lo nos projetos da empresa ainda estava longe de ser concluído. Com plena consciência da importância dessas duas tarefas, começamos a solucioná-las.
Mais desempenho!
Verificações incrementais aceleraram o mypy, mas essa ferramenta ainda não era rápida o suficiente. Muitas verificações incrementais levavam cerca de um minuto. A causa disso eram as importações cíclicas. Isso provavelmente não surpreenderá aqueles que já trabalharam com grandes bases de código escritas em Python. Tivemos conjuntos de centenas de módulos, cada um dos quais importava indiretamente todos os outros. Se algum arquivo no ciclo de importações fosse modificado, o mypy tinha que processar todos os arquivos dentro desse ciclo, e muitas vezes, também quaisquer módulos que importavam módulos desse ciclo. Um desses ciclos era o infame “novelo de dependências”, que causou muitos problemas no Dropbox. Uma vez, essa estrutura continha várias centenas de módulos, que eram importados, direta ou indiretamente, por muitos testes, e era usada também no código de produção.
Consideramos a possibilidade de "desvincular" as dependências cíclicas, mas não dispúnhamos de recursos para isso. Havia código demais com o qual não estávamos familiarizados. No final das contas, optamos por uma abordagem alternativa. Decidimos fazer com que o mypy funcionasse rapidamente mesmo na presença de "novelos de dependências". Alcançamos esse objetivo por meio de um daemon mypy. O daemon é um processo de servidor que implementa duas funcionalidades interessantes. Primeiro — ele mantém na memória informações sobre toda a base de código. Isso significa que a cada execução do mypy, não é necessário carregar dados armazenados em cache referentes a milhares de dependências importadas. Em segundo lugar — ele analisa cuidadosamente, em nível de pequenas unidades estruturais, as dependências entre funções e outras entidades. Por exemplo, se uma função foo chama a função bar, então existe uma dependência foo de bar. Quando um arquivo muda, o daemon primeiro processa, de forma isolada, apenas o arquivo modificado. Em seguida, ele observa as alterações desse arquivo visíveis externamente, como as assinaturas de funções alteradas. O daemon utiliza informações detalhadas sobre as importações apenas para reavaliar as funções que realmente utilizam a função modificada. Normalmente, com essa abordagem, é necessário verificar apenas um pequeno número de funções.
A implementação de tudo isso não foi uma tarefa fácil, pois a implementação original do mypy estava muito focada em processar um arquivo por vez. Tivemos que enfrentar uma série de situações limites, cuja ocorrência exigia verificações repetidas sempre que algo no código era alterado. Por exemplo, isso acontece quando se atribui uma nova classe base a uma classe. Depois que conseguimos fazer o que desejávamos, conseguimos reduzir o tempo de execução da maioria das verificações incrementais para apenas alguns segundos. Para nós, isso parecia uma grande vitória.
Ainda mais desempenho!
Junto com o cache remoto que mencionei anteriormente, o daemon mypy praticamente resolveu os problemas que surgiam quando o programador frequentemente executava a verificação de tipos, fazendo mudanças em um pequeno número de arquivos. No entanto, o desempenho do sistema, na situação menos favorável de uso, ainda estava longe do ideal. Uma execução limpa do mypy poderia levar mais de 15 minutos. E isso era muito mais do que tínhamos como aceitável. A cada semana, a situação piorava, à medida que os programadores continuavam a escrever novo código e adicionar anotações ao código existente. Nossos usuários ainda ansiavam por mais desempenho, e estávamos felizes em atendê-los.
Decidimos voltar a uma das ideias iniciais sobre o mypy. Ou seja, a transformação de código Python em código C. As experiências com o Cython (um sistema que permite transcrever código escrito em Python para código C) não nos deram um aumento visível de desempenho, então decidimos ressuscitar a ideia de escrever nosso próprio compilador. Como a base de código do mypy (escrita em Python) já continha todas as anotações de tipo necessárias, parecia uma tentativa válida usar essas anotações para acelerar o funcionamento do sistema. Rapidamente criei um protótipo para testar essa ideia. Ele mostrou um aumento de desempenho em mais de 10 vezes em vários micro-benchmarks. Nossa ideia era compilar módulos Python em módulos C usando Cython, e transformar anotações de tipo em verificações de tipo realizadas em tempo de execução (normalmente, as anotações de tipo são ignoradas durante a execução dos programas e são usadas apenas por sistemas de verificação de tipos). Na verdade, planejávamos transferir a implementação do mypy de Python para uma linguagem que fosse estaticamente tipada, que se parecesse (e, em grande parte, funcionasse) exatamente como o Python. (Esse tipo de migração entre linguagens tornou-se uma espécie de tradição no projeto mypy. A implementação original do mypy foi escrita em Alore, e depois houve um híbrido sintático de Java e Python).
A orientação para a API de extensões do CPython foi fundamental para não perdermos a capacidade de gerenciar o projeto. Não precisávamos implementar uma máquina virtual ou qualquer biblioteca da qual o mypy precisasse. Além disso, ainda teríamos acesso a todo o ecossistema Python, e todas as ferramentas (como pytest) estariam disponíveis. Isso significava que poderíamos continuar usando código Python interpretável durante o desenvolvimento, o que nos permitiria trabalhar com um esquema muito ágil de edição de código e testes, em vez de esperar a compilação do código. Parecia que estávamos conseguindo, por assim dizer, nos equilibrar em dois bancos, e estávamos gostando disso.
O compilador que chamamos de mypyc (já que ele utiliza mypy para análise de tipos como frontend) se revelou um projeto bastante bem-sucedido. No geral, conseguimos uma aceleração de aproximadamente 4 vezes em execuções frequentes do mypy sem o uso de cache. O desenvolvimento do núcleo do projeto mypyc levou cerca de 4 meses, com uma pequena equipe composta por Michael Sullivan, Ivan Levkivsky, Hugh Han e eu. Esse volume de trabalho foi muito menor do que o necessário para reescrever o mypy, por exemplo, em C++ ou em Go. Além disso, as mudanças que tivemos que fazer no projeto foram muito menores do que seriam necessárias se o reescrevessemos em outra linguagem. Esperávamos também conseguir levar o mypyc a um nível onde outros desenvolvedores da Dropbox pudessem utilizá-lo para compilar e acelerar seu código.
Para alcançar esse nível de desempenho, tivemos que aplicar algumas soluções de engenharia interessantes. Assim, o compilador pode acelerar a execução de muitas operações ao utilizar construções de baixo nível em C. Por exemplo, a chamada de uma função compilada é traduzida em uma chamada de função C. E uma chamada dessa é executada muito mais rápido do que uma função interpretada. Algumas operações, como a busca em dicionários, ainda dependiam de chamadas convencionais da API C do CPython, que, após a compilação, se mostraram apenas um pouco mais rápidas. Conseguimos eliminar a sobrecarga adicional no sistema causada pela interpretação, mas isso, neste caso, resultou em um ganho de desempenho apenas modesto.
Para identificar as operações 'lentas' mais comuns, realizamos um perfilamento de código. Armados com os dados obtidos, tentamos ou ajustar o mypyc para que ele gerasse um código C mais rápido para essas 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 para determinados problemas). Reescrever o código Python frequentemente se mostrava uma solução mais fácil do que implementar a mesma transformação automática no compilador. A longo prazo, queríamos automatizar muitas dessas transformações, mas naquele momento estávamos focados em acelerar o mypy com o mínimo de esforço. E, ao nos movermos em direção a esse objetivo, cortamos algumas arestas.
Continua…
Prezados leitores! Quais foram suas impressões sobre o projeto mypy quando você soube de sua existência?
Fonte: habr.com
