Desafios de escrever regras

Informar um problema Ver a fonte Nightly · 8.0 · 7.4 · 7.3 · 7.2 · 7.1 · 7.0  · 6.5

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

Resumo dos requisitos

  • Hipótese: buscar a correção, o throughput, a facilidade de uso e a latência
  • Hipótese: repositórios em grande escala
  • Premissa: linguagem de descrição semelhante ao 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 remota e o armazenamento em cache são difíceis
  • Intrínseco: o uso de informações de mudança para builds incrementais corretos e rápidos exige padrões de programação incomuns
  • Intrínseco: evitar o tempo quadrático e o consumo de memória é difícil

Suposições

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

Procure a correção, a capacidade de processamento, a facilidade de uso e a latência.

Consideramos que o sistema de build precisa ser correto em primeiro lugar em relação a builds incrementais. Para uma determinada árvore de origem, a saída do mesmo build precisa ser sempre a mesma, independentemente da aparência da árvore de saída. Na primeira aproximação, isso significa que o Bazel precisa conhecer cada entrada que entra em uma determinada etapa de build, para que possa ser executada novamente se alguma das entradas mudar. Há limites para a correção do Bazel, já que ele vaza algumas informações, como a data / hora do build, e ignora determinados tipos de mudanças, como mudanças nos atributos de arquivos. O sandbox 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 relacionados ao conjunto de arquivos ou às regras C++, que são problemas difíceis. Estamos trabalhando para corrigir esses problemas.

O segundo objetivo do sistema de build é ter um alto throughput. Estamos sempre avançando 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 for sobrecarregado, ninguém poderá trabalhar.

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

A latência indica o tempo que leva para iniciar um build e receber o resultado esperado, seja um registro de teste de um teste bem-sucedido ou com falha, ou uma mensagem de erro de que um arquivo BUILD tem um erro de digitação.

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

Repositórios em grande escala

O sistema de build precisa operar na escala de repositórios grandes, em que "grande" 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 desenvolvedor. 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 tempo e memória razoáveis. Por isso, é essencial que os arquivos BUILD possam ser carregados e analisados de forma independente.

Linguagem de descrição semelhante ao BUILD

Nesse contexto, presumimos uma linguagem de configuração que é semelhante aos arquivos BUILD na declaração de regras de biblioteca e binários e suas interdependências. Os arquivos BUILD podem ser lidos e analisados de forma independente, e evitamos até mesmo procurar arquivos de origem sempre que possível (exceto para verificar a 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, basta que uma regra conheça os arquivos de entrada e saída de uma ação antes que ela seja 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 (sinais de linha de comando, basicamente) e somente depois executava as ações. Essa distinção ainda faz parte da API de regras hoje, mesmo que o núcleo do Bazel não precise mais dela (mais detalhes abaixo).

Isso significa que a API de regras exige uma descrição declarativa da interface de regras (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 por outras regras no gráfico de build.

Além disso, a análise de uma regra não pode ler nenhum arquivo de origem nem inspecionar a saída de uma ação. Em vez disso, ela precisa gerar um gráfico bipartite dirigido parcial de etapas de build e nomes de arquivos de saída que é determinado apenas pela regra e pelas dependências dela.

Intrínseco

Há algumas propriedades intrínsecas que tornam a escrita de regras desafiadora, e algumas das mais comuns são descritas nas seções a seguir.

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

A execução remota e o armazenamento em cache 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. O sistema de build calcula uma impressão digital de ação única e solicita 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 são direcionados 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.

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

Acima, argumentamos que, para ser correto, o Bazel precisa conhecer todos os arquivos de entrada que entram em uma etapa de build para detectar se essa etapa de build ainda está atualizada. O mesmo vale para o carregamento de pacotes e a análise de regras. Projetamos o Skyframe para lidar com isso de modo geral. O Skyframe é uma biblioteca de gráficos e um framework de avaliação que leva um nó de meta (como "build //foo with these options") 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 meta 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ó executa um processo de descoberta de dependência. Cada nó pode declarar dependências e usar o conteúdo delas para declarar ainda mais dependências. Em princípio, isso é mapeado para um modelo de linha 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 atualmente vinculados ao uso do Java, portanto, não há linhas de execução 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ó declara uma dependência que ainda não está disponível, talvez seja necessário abortar 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 excessivamente. Um nó que declara N dependências em série pode ser reiniciado N vezes, custando O(N^2). Em vez disso, nosso objetivo é declarar em massa as dependências, o que às vezes exige reorganizar o código ou até mesmo dividir um nó em vários para limitar o número de reinicializações.

Essa tecnologia não está disponível na API Rules. Em vez disso, ela 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. Independentemente da linguagem em que o sistema de build é implementado ou em que as regras são escritas (elas não precisam ser iguais), 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, bem como qualquer forma de reflexão e qualquer biblioteca que faça isso. As bibliotecas que oferecem suporte à injeção de dependências dessas interfaces de baixo nível ainda precisam ser configuradas corretamente para o Skyframe.

Isso sugere fortemente evitar expor os autores de regras a um ambiente de execução de linguagem completo desde o início. O perigo do 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 do domínio.

É difícil evitar o tempo quadrático e o consumo de memória

Para piorar as coisas, além dos requisitos impostos pelo Skyframe, as restrições históricas do uso do Java e a desatuação da API de regras, 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ário. Há dois padrões muito comuns que introduzem consumo de memória quadrático (e, portanto, consumo de tempo quadrático).

  1. Cadeias de regras de biblioteca: considere o caso de uma cadeia de regras de biblioteca A que depende de B, que depende de C e assim por diante. Em seguida, queremos calcular uma propriedade sobre o fechamento transitivo dessas regras, como o caminho de classe do ambiente de execução Java ou o comando de vinculação C++ para cada biblioteca. De forma simplista, podemos usar uma implementação de lista padrão. No entanto, isso já introduz o 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, para um total de 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, por exemplo, se você tem várias regras de teste que testam o mesmo código de biblioteca. Digamos que, de N regras, metade delas sejam regras binárias e a outra metade seja regras de biblioteca. Agora considere que cada binário faz uma cópia de alguma propriedade calculada sobre o fechamento transitivo das regras de biblioteca, como o caminho de classe do ambiente de execução 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 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 é bastante afetado por esses dois cenários. Por isso, introduzimos um conjunto de classes de coleção personalizadas que comprimem efetivamente as informações na memória, evitando a cópia em cada etapa. Quase todas essas estruturas de dados têm semântica definida, 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 anteriormente.

Infelizmente, o uso de depsets não resolve automaticamente todos os problemas. Em particular, mesmo iterando sobre um depse em cada regra, o consumo de tempo quadrático é reintroduzido. Internamente, o NestedSets também tem alguns métodos auxiliares para facilitar a interoperabilidade com classes de coleções normais. Infelizmente, transmitir acidentalmente um NestedSet para um desses métodos leva ao comportamento de cópia e reintroduce o consumo de memória quadrático.