Desafios de escrever regras

Informar um problema Acessar a origem

Nesta página, você encontrará uma visão geral de alto nível dos problemas e desafios específicos ao escrever regras eficientes do Bazel.

Resumo dos requisitos

  • Suposição: meta de precisã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 corretos e rápidos requer padrões de programação incomuns.
  • Intrínseco: é difícil evitar o consumo de memória e tempo quadrático

Suposições

Aqui estão algumas suposições feitas sobre o sistema de compilação, como 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 precisa ser o primeiro e mais importante em relação a builds incrementais. Para determinada árvore de origem, a saída do mesmo build precisa ser sempre a mesma, seja qual for a aparência da árvore de saída. Na primeira aproximação, isso significa que o Bazel precisa conhecer cada entrada que entra em determinada etapa de build, para que ele possa executar essa etapa de novo se alguma entrada for alterada. Há limites sobre como o Bazel pode ser corrigido, já que ele vaza algumas informações, como data e hora do build, e ignora determinados tipos de mudanças, como atributos de arquivo. O sandbox ajuda a garantir a precisã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 Fileset ou às regras C++, que são problemas difíceis. Temos esforços a longo prazo para corrigir esses problemas.

O segundo objetivo do sistema de compilação é ter alta capacidade. Estamos estendendo permanentemente os limites do que pode ser feito dentro da alocação atual de máquinas de um serviço de execução remota. Se o serviço de execução remota ficar sobrecarregado, ninguém pode trabalhar.

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

A latência indica o tempo que leva entre o início de uma versão e o recebimento do resultado pretendido, seja um registro de teste de aprovação ou falha ou uma mensagem de erro informando que um arquivo BUILD tem um erro de digitação.

Muitas vezes, essas metas se sobrepõem. A latência é uma função da capacidade do serviço de execução remota e a precisão é relevante para facilitar o 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 do 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 uma quantidade razoável de tempo e memória. Assim, é 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 binárias e de biblioteca e as interdependências delas. Os arquivos BUILD podem ser lidos e analisados de forma independente, e evitamos até mesmo examinar os 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 dessas são descritas nas seções a seguir.

A dura separação 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 carregamento de pacotes, depois análise de regras usando uma configuração (flags de linha de comando, basicamente) e somente depois executando qualquer ação. Essa distinção ainda faz parte da API de regras atualmente, embora o núcleo do Bazel não precise mais dela (mais detalhes abaixo).

Isso significa que a API de regras requer 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 de outras regras no gráfico de compilação.

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 bipartido direcionado parcialmente das etapas de build e dos nomes de arquivos de saída que são determinados apenas pela regra e pelas dependências dela.

Intrínseco

Existem algumas propriedades intrínsecas que dificultam a criação de regras, e algumas das mais comuns estã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 do build em uma única máquina. No entanto, a escala com 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 cuidadosamente idas e voltas desnecessárias, assim como trabalhos desnecessários no lado do serviço.

Nesse momento, o protocolo exige que o sistema de compilação conheça todas as entradas de uma determinada ação com antecedência. Em seguida, o sistema de compilação 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 resolvidos pelo resumo posteriormente. 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 codificação incomuns

Acima, argumentamos que, para estar correto, o Bazel precisa conhecer todos os arquivos de entrada que passam 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. 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 "build //foo with these options") e o divide em partes constituintes, que são avaliadas e combinadas para produzir 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ó da meta até os arquivos de entrada (que também são nós do Skyframe). Ter esse gráfico explicitamente representado 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, em seguida, usar o conteúdo delas para declarar dependências ainda mais. Em princípio, isso é bem mapeado para um modelo de linha de execução por nó. No entanto, os 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 do Java, então não há linhas de execução leves nem continuação.

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, o que custa tempo O(N^2). Em vez disso, visamos a declaração em massa de dependências antecipadamente, o que às vezes exige a reorganização do código ou até mesmo a divisão de um nó em vários nós para limitar o número de reinicializações.

No momento, essa tecnologia não está disponível na API de regras. Em vez disso, a API de regras ainda é definida usando os conceitos legados das 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 compilação é 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, bem como 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 evitar a exposição dos autores de regras a um ambiente de execução de linguagem completo. O perigo de 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 elas tivessem sido escritas pela equipe do Bazel ou outros especialistas da área.

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

Para piorar as coisas, além dos requisitos impostos pelo Skyframe, das restrições históricas do uso de Java e da falta de atualização da API de regras, a introdução acidental do consumo de tempo quadrático ou 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ático (e, portanto, o consumo de tempo quadrático).

  1. Regras de cadeias 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, queremos calcular alguma propriedade acima do fechamento transitivo dessas regras, como o caminho de classe do ambiente de execução Java ou o comando do vinculador C++ para cada biblioteca. Podemos considerar simples 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) entradas.

  2. Regras binárias dependendo das mesmas regras da biblioteca: considere o caso em que um conjunto de binários que dependem 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 delas sejam binárias e a outra metade seja regras de biblioteca. Agora considere que cada binário faz uma cópia de alguma propriedade calculada com 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, ela pode expandir a representação da string da 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

O Bazel é muito afetado por esses dois cenários. Por isso, introduzimos um conjunto de classes de coleção 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, então nós 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 foi mudanças no uso de depsets em vez do usado anteriormente.

Infelizmente, o uso de depsets não resolve automaticamente todos os problemas. Em especial, mesmo a iteração em um depset em cada regra reintroduz o consumo de tempo quadrático. Internamente, os NestedSets também têm alguns métodos auxiliares para facilitar a interoperabilidade com classes de coleções normais. Infelizmente, a transmissão acidental de um NestedSet para um desses métodos leva ao comportamento de cópia e reintroduz o consumo de memória quadrática.