Desafios de escrever regras

Nesta página, você encontra uma visão geral dos problemas e desafios específicos da programação de regras eficientes do Bazel.

Requisitos de resumo

  • Suposição: visar correção, capacidade de processamento, facilidade de uso e latência
  • Suposição: repositórios de 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: execução remota e armazenamento em cache são difíceis
  • Intrínseco: o uso de informações de mudança para builds incrementais rápidos e corretos exige padrões de codificação incomuns
  • Intrínseco: é difícil evitar o tempo quadrático e o consumo de memória

Suposições

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

Busque precisão, capacidade de processamento, facilidade de uso e latência

Presumimos que o sistema de build precise estar, acima de tudo, correto, com relação a builds incrementais. Para uma determinada árvore de origem, a saída do mesmo build precisa ser sempre a mesma, independente da aparência da árvore de saída. Na primeira aproximação, isso significa que o Bazel precisa saber cada entrada de uma determinada etapa de build para poder executá-la novamente se alguma das entradas mudar. Há limites para a correção do Bazel, já que ele vaza algumas informações, como data / hora da build, e ignora certos tipos de mudanças, como mudanças nos atributos do arquivo. O sandbox ajuda a garantir a correção, evitando leituras em 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 do C++, que são problemas difíceis. Temos esforços de longo prazo para corrigir esses problemas.

O segundo objetivo do sistema de compilação é ter alta capacidade de processamento. Estamos expandindo permanentemente os limites do que pode ser feito dentro da 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 poderá fazer o trabalho.

A facilidade de uso vem a seguir. Das várias abordagens corretas com o mesmo espaço (ou semelhante) do serviço de execução remota, escolhemos a que é mais fácil de usar.

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

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

Repositórios de grande escala

O sistema de compilação precisa operar na escala de grandes repositórios, em que grande escala significa que ele não cabe em um único disco rígido. Por isso, é impossível fazer um checkout completo em praticamente todas as máquinas de desenvolvimento. Um build de tamanho médio precisará 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 dentro de um tempo e de memória razoáveis. Por isso, é fundamental que os arquivos BUILD possam ser carregados e analisados de maneira independente.

Linguagem de descrição semelhante a BUILD

Nesse contexto, presumimos uma linguagem de configuração quase semelhante aos arquivos BUILD na declaração de regras binárias e de bibliotecas e as interdependências delas. Os arquivos BUILD podem ser lidos e analisados de forma independente. Além disso, evitamos procurar arquivos de origem sempre que possível, exceto pela existência.

Histórico

Existem diferenças entre as versões do Bazel que causam desafios, e algumas disso estã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 a ação ser enviada para execução remota. No entanto, a base de código original do Bazel tinha uma separação estrita entre o carregamento de pacotes, a análise de regras usando uma configuração (sinalizações de linha de comando) e apenas a execução das ações. Essa distinção ainda faz parte da API de regras hoje, embora o núcleo do Bazel não exija mais isso (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 de java_library chamada "foo" gera implicitamente uma saída chamada "libfoo.jar", que pode ser referenciada em outras regras no gráfico de build.

Além disso, a análise de uma regra não pode ler nenhum arquivo de origem ou inspecionar a saída de uma ação. Em vez disso, ela precisa gerar um gráfico bipartito direcionado parcial das etapas de build e dos nomes dos arquivos de saída que é determinado somente pela regra em si e nas dependências dela.

Intrínseco

Há algumas propriedades intrínsecas que tornam as regras de gravação desafiadoras, 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 compilação em grandes repositórios em aproximadamente duas ordens de magnitude em comparação com a execução da compilação 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 lidar com um grande número de solicitações por segundo, e o protocolo evita idas e voltas desnecessárias, bem como trabalho desnecessário no lado do serviço.

No momento, o protocolo exige que o sistema de build saiba todas as entradas de uma determinada ação com antecedência. O sistema de build calcula uma impressão digital de ação exclusiva e solicita ao programador uma ocorrência em cache. Se uma ocorrência em cache for encontrada, o programador responderá com os resumos dos arquivos de saída. Os arquivos em si serão endereçados pelo 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 alterações para builds incrementais corretos e rápidos exige padrões de codificação incomuns

Explicamos que, para estar correto, o Bazel precisa saber 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 meta (como "criar //foo com essas opções") e o divide nas 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), fazendo a quantidade mínima de trabalho para restaurar a árvore de saída ao estado pretendido.

Como parte disso, cada nó executa um processo de descoberta de dependências. Cada nó pode declarar dependências e usar o conteúdo delas para declarar outras dependências. Em princípio, isso é bem mapeado para um modelo de 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 é facilmente possível com a tecnologia Java atual. Por motivos históricos, estamos vinculados ao uso de Java no momento, 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ó declarar uma dependência que ainda não está disponível, talvez seja necessário cancelar essa avaliação e reiniciá-la (possivelmente em outra linha de execução), quando a dependência estiver disponível. Isso, por sua vez, 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 é fazer a declaração de dependências em massa, o que às vezes exige a reorganização do código ou até mesmo a divisão de um nó em vários para limitar o número de reinicializações.

Observe que essa tecnologia não está disponível atualmente na API de regras. Em vez disso, a API de regras 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 (não precisam ser iguais), os autores de regras não podem usar bibliotecas padrão ou padrões que ignoram o Skyframe. Para Java, isso significa evitar o java.io.File, além de qualquer forma de reflexão e qualquer biblioteca que realize alguma dessas ações. As bibliotecas com suporte à injeção de dependências dessas interfaces de baixo nível ainda precisam ser configuradas corretamente para o Skyframe.

Isso sugere evitar a exposição dos autores de regras a um ambiente de execução de linguagem completo. O perigo do uso acidental dessas APIs é muito grande. No passado, vários bugs do Bazel eram causados por regras que usavam APIs não seguras, mesmo que as regras foram escritas pela equipe do Bazel ou por outros especialistas no domínio.

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

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

  1. Cadeias de regras de biblioteca: considere o caso de uma cadeia de regras de biblioteca A depende de B, depende de C e assim por diante. Em seguida, vamos calcular alguma propriedade sobre o fechamento transitivo dessas regras, como o caminho de classe do ambiente de execução do Java ou o comando do vinculador C++ para cada biblioteca. Podemos usar uma implementação de lista padrão. No entanto, isso já introduz o consumo de memória quadrática: a primeira biblioteca contém uma entrada no caminho de classe, a segunda, a terceira, três e assim por diante, para um total de 1+2+3+...+N = O(N^2).

  2. Regras binárias que dependem das mesmas regras de biblioteca: considere o caso em que um conjunto de binários que dependem das mesmas regras de biblioteca, por exemplo, se você tiver várias regras de teste que testam o mesmo código de biblioteca. Digamos que, de N regras, metade delas são regras binárias e a outra metade regras da biblioteca. Agora, considere que cada binário faz uma cópia de alguma propriedade calculada no fechamento transitivo das regras da biblioteca, como o caminho de classe do ambiente de execução do Java ou a linha de comando do vinculador C++. Por exemplo, ele poderia expandir a representação da string de linha de comando da ação de link C++. N/2 cópias de elementos N/2 são memória O(N^2).

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

Ele é muito afetado por esses dois cenários. Por isso, introduzimos um conjunto de classes de coleções personalizadas que compactam efetivamente as informações na memória evitando a cópia em cada etapa. Quase todas essas estruturas de dados têm semânticas definidas, por isso a chamamos de depset (também conhecida como NestedSet na implementação interna). A maioria das mudanças para reduzir o consumo de memória do Bazel nos últimos anos foram mudanças para usar dependências em vez do que era usado anteriormente.

Infelizmente, o uso de descontinuações não resolve todos os problemas automaticamente. Em particular, até mesmo a simples iteração de um desconto em cada regra reintroduz o consumo de tempo quadrático. Internamente, a 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 a um comportamento de cópia e reintroduz o consumo de memória quadrática.