Desafios de escrever regras

Reportar um problema Ver a fonte Nightly · 8.4 · 8.3 · 8.2 · 8.1 · 8.0 · 7.6

Esta página oferece uma visão geral dos problemas e desafios específicos de escrever regras eficientes do Bazel.

Requisitos de resumo

  • Suposição: busque correção, capacidade, facilidade de uso e latência
  • Pressuposto: repositórios em grande escala
  • Suposição: linguagem de descrição semelhante a BUILD
  • Histórico: a separação rígida entre carregamento, análise e execução está desatualizada, mas ainda afeta a API
  • Intrínseco: a execução e o armazenamento em cache remotos são difíceis
  • Intrínseco: usar informações de mudança para builds incrementais corretos e rápidos requer padrões de programação incomuns
  • Intrínseco: evitar o consumo quadrático de tempo e memória é difícil

Suposições

Confira algumas proposições feitas sobre o sistema de build, como necessidade de correção, facilidade de uso, capacidade e repositórios em grande escala. As seções a seguir abordam essas proposições e oferecem diretrizes para garantir que as regras sejam escritas de maneira eficaz.

Busque correção, capacidade de processamento, facilidade de uso e baixa latência

Presumimos que o sistema de build precisa ser correto em primeiro lugar em relação aos builds incrementais. Para uma determinada árvore de origem, a saída do mesmo build sempre precisa ser a mesma, independente da aparência da árvore de saída. Na primeira aproximação, isso significa que o Bazel precisa saber cada entrada que entra em uma determinada etapa de build para poder executar novamente essa etapa se alguma das entradas mudar. O Bazel tem limites quanto à correção, já que vaza algumas informações, como data / hora do build, e ignora determinados tipos de mudanças, como alterações nos atributos de arquivo. O sandboxing ajuda a garantir a correção, impedindo leituras de arquivos de entrada não declarados. Além dos limites intrínsecos do sistema, há alguns problemas de correção conhecidos, a maioria deles relacionada ao conjunto de arquivos ou às regras de C++, que são problemas difíceis. Estamos trabalhando para corrigir esses problemas a longo prazo.

O segundo objetivo do sistema de build é ter alta capacidade de processamento. Estamos constantemente ampliando os limites do que pode ser feito na alocação de máquina atual para um serviço de execução remota. Se o serviço de execução remota ficar sobrecarregado, ninguém vai conseguir trabalhar.

Em seguida, vem a facilidade de uso. Entre várias abordagens corretas com a mesma (ou semelhante) pegada do serviço de execução remota, escolhemos a mais fácil de usar.

Latência indica o tempo que leva desde o início de um build até a obtenção do resultado pretendido, seja um registro de teste de um teste aprovado ou com falha, ou uma mensagem de erro informando que um arquivo BUILD tem um erro de digitação.

Essas metas geralmente se sobrepõem. A latência é tanto uma função da capacidade de processamento do serviço de execução remota quanto a correção é relevante para a facilidade de uso.

Repositórios em grande escala

O sistema de build precisa operar na escala de repositórios grandes, em que "grande escala" significa que ele não cabe em um único disco rígido. Portanto, é impossível fazer um checkout completo em praticamente todas as máquinas de desenvolvedores. Um build de tamanho médio precisa ler e analisar dezenas de milhares de arquivos BUILD e avaliar centenas de milhares de globs. Embora seja teoricamente possível ler todos os arquivos BUILD em uma única máquina, ainda não conseguimos fazer isso em um período de tempo e memória razoáveis. Por isso, é fundamental que os arquivos BUILD possam ser carregados e analisados de forma independente.

Linguagem de descrição semelhante a BUILD

Nesse contexto, presumimos uma linguagem de configuração que é aproximadamente semelhante aos arquivos BUILD na declaração de regras de biblioteca e binárias e suas interdependências. Os arquivos BUILD podem ser lidos e analisados de forma independente, e evitamos até mesmo olhar para os arquivos de origem sempre que possível (exceto para existência).

Histórico

Há diferenças entre as versões do Bazel que causam desafios, e algumas delas são descritas nas seções a seguir.

A separação rígida entre carregamento, análise e execução está desatualizada, mas ainda afeta a API.

Tecnicamente, é suficiente que uma regra conheça os arquivos de entrada e saída de uma ação pouco antes de ela ser enviada para execução remota. No entanto, a base de código original do Bazel tinha uma separação estrita de pacotes de carregamento, depois analisava regras usando uma configuração (basicamente flags de linha de comando) e só então executava ações. Essa distinção ainda faz parte da API de regras hoje, mesmo que o núcleo do Bazel não exija mais isso (mais detalhes abaixo).

Isso significa que a API Rules exige uma descrição declarativa da interface da regra (quais atributos ela tem, tipos de atributos). Há algumas exceções em que a API permite que o código personalizado seja executado durante a fase de carregamento para calcular nomes implícitos de arquivos de saída e valores implícitos de atributos. Por exemplo, uma regra java_library chamada "foo" gera implicitamente uma saída chamada "libfoo.jar", que pode ser referenciada de outras regras no gráfico de build.

Além disso, a análise de uma regra não pode ler arquivos de origem nem inspecionar a saída de uma ação. Em vez disso, ela precisa gerar um gráfico bipartido direcionado parcial de etapas de build e nomes de arquivos de saída que são determinados apenas pela regra e suas dependências.

Intrínseco

Existem algumas propriedades intrínsecas que dificultam a criação de regras, e algumas das mais comuns são descritas nas seções a seguir.

A execução e o armazenamento em cache remotos são difíceis

A execução e o armazenamento em cache remotos melhoram os tempos de build em repositórios grandes em aproximadamente duas ordens de magnitude em comparação com a execução do build em uma única máquina. No entanto, a escala em que ele precisa ser executado é impressionante: o serviço de execução remota do Google foi projetado para processar um grande número de solicitações por segundo, e o protocolo evita cuidadosamente viagens de ida e volta desnecessárias, bem como trabalho desnecessário no lado do serviço.

No momento, o protocolo exige que o sistema de build conheça todas as entradas de uma determinada ação com antecedência. Em seguida, o sistema calcula uma impressão digital de ação exclusiva e pede ao programador um acerto de cache. Se um acerto de cache for encontrado, o programador vai responder com os resumos dos arquivos de saída. Os arquivos em si serão abordados por resumo mais tarde. No entanto, isso impõe restrições às regras do Bazel, que precisam declarar todos os arquivos de entrada com antecedência.

Usar informações de mudança para builds incrementais corretos e rápidos exige padrões de programação incomuns

Acima, argumentamos que, para estar correto, o Bazel precisa conhecer todos os arquivos de entrada que entram em uma etapa de build para detectar se essa etapa ainda está atualizada. O mesmo vale para o carregamento de pacotes e a análise de regras, e projetamos o Skyframe para lidar com isso em geral. O Skyframe é uma biblioteca de gráficos e um framework de avaliação que usa um nó de objetivo (como "criar //foo com estas opções") e o divide em partes constituintes, que são avaliadas e combinadas para gerar esse resultado. Como parte desse processo, o Skyframe lê pacotes, analisa regras e executa ações.

Em cada nó, o Skyframe rastreia exatamente quais nós um determinado nó usou para calcular a própria saída, desde o nó de objetivo até os arquivos de entrada (que também são nós do Skyframe). Ter esse gráfico representado explicitamente na memória permite que o sistema de build identifique exatamente quais nós são afetados por uma determinada mudança em um arquivo de entrada (incluindo a criação ou exclusão de um arquivo de entrada), fazendo o mínimo de trabalho para restaurar a árvore de saída ao estado pretendido.

Como parte disso, cada nó realiza um processo de descoberta de dependências. Cada nó pode declarar dependências e usar o conteúdo delas para declarar ainda mais dependências. Em princípio, isso é mapeado bem para um modelo de uma linha de execução por nó. No entanto, builds de tamanho médio contêm centenas de milhares de nós do Skyframe, o que não é possível com a tecnologia Java atual. Por motivos históricos, estamos vinculados ao uso do Java, então não há threads leves nem continuações.

Em vez disso, o Bazel usa um pool de linhas de execução de tamanho fixo. No entanto, isso significa que, se um nó declarar uma dependência que ainda não está disponível, talvez seja necessário interromper essa avaliação e reiniciá-la (possivelmente em outra linha de execução) quando a dependência estiver disponível. Isso significa que os nós não devem fazer isso em excesso. Um nó que declara N dependências em série pode ser reiniciado N vezes, custando O(N^2) de tempo. Em vez disso, buscamos uma declaração em massa antecipada de dependências, o que às vezes exige reorganizar o código ou até mesmo dividir um nó em vários nós para limitar o número de reinicializações.

Essa tecnologia ainda não está disponível na API Rules. Em vez disso, a API Rules ainda é definida usando os conceitos legados de fases de carregamento, análise e execução. No entanto, uma restrição fundamental é que todos os acessos a outros nós precisam passar pelo framework para que ele possa rastrear as dependências correspondentes. Independente da linguagem em que o sistema de build é implementado ou em que as regras são escritas (não precisam ser as mesmas), os autores de regras não podem usar bibliotecas ou padrões padrão que ignoram o Skyframe. Para Java, isso significa evitar java.io.File, qualquer forma de reflexão e qualquer biblioteca que faça isso. As bibliotecas que oferecem suporte à injeção de dependência dessas interfaces de baixo nível ainda precisam ser configuradas corretamente para o Skyframe.

Isso sugere fortemente que se evite expor os autores de regras a um tempo de execução de linguagem completo. O risco de uso acidental dessas APIs é muito grande. Vários bugs do Bazel no passado foram causados por regras que usavam APIs não seguras, mesmo que as regras tenham sido escritas pela equipe do Bazel ou por outros especialistas no assunto.

Evitar o consumo quadrático de tempo e memória é difícil

Para piorar, além dos requisitos impostos pelo Skyframe, das restrições históricas do uso do Java e da desatualização da API Rules, a introdução acidental de tempo quadrático ou consumo de memória é um problema fundamental em qualquer sistema de build baseado em regras de biblioteca e binárias. Há dois padrões muito comuns que introduzem consumo quadrático de memória (e, portanto, de tempo).

  1. Cadeias de regras de biblioteca: considere o caso de uma cadeia de regras de biblioteca em que A depende de B, que depende de C e assim por diante. Em seguida, queremos calcular alguma propriedade sobre o fechamento transitivo dessas regras, como o classpath de tempo de execução do Java ou o comando do vinculador C++ para cada biblioteca. De maneira ingênua, podemos usar uma implementação de lista padrão. No entanto, isso já introduz um consumo de memória quadrático: a primeira biblioteca contém uma entrada no classpath, a segunda duas, a terceira três e assim por diante, totalizando 1+2+3+...+N = O(N^2) entradas.

  2. Regras binárias que dependem das mesmas regras de biblioteca: considere o caso em que um conjunto de binários depende das mesmas regras de biblioteca, como se você tivesse várias regras de teste que testam o mesmo código de biblioteca. Digamos que, de N regras, metade seja binária e a outra metade de biblioteca. Agora considere que cada binário faz uma cópia de alguma propriedade calculada na conclusão transitiva das regras da biblioteca, como o classpath de tempo de execução do Java ou a linha de comando do vinculador C++. Por exemplo, ele pode expandir a representação de string da linha de comando da ação de vinculação do C++. N/2 cópias de N/2 elementos é O(N^2) de memória.

Classes de coleções personalizadas para evitar complexidade quadrática

O Bazel é muito afetado por esses dois cenários. Por isso, introduzimos um conjunto de classes de coleta personalizadas que compactam as informações na memória, evitando a cópia em cada etapa. Quase todas essas estruturas de dados têm semântica de conjunto, então chamamos de depset, também conhecido como NestedSet na implementação interna. A maioria das mudanças para reduzir o consumo de memória do Bazel nos últimos anos foi para usar depsets em vez do que era usado antes.

Infelizmente, o uso de conjuntos de dependências não resolve automaticamente todos os problemas. Em particular, mesmo apenas iterar em um conjunto de dependências em cada regra reintroduz o consumo de tempo quadrático. Internamente, o NestedSets também tem alguns métodos auxiliares para facilitar a interoperabilidade com classes de coleções normais. Infelizmente, passar acidentalmente um NestedSet para um desses métodos leva a um comportamento de cópia e reintroduz o consumo quadrático de memória.