Desafios de escrever regras

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

Resumo dos requisitos

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

Suposições

Estas são algumas suposições feitas sobre o sistema de build, como a necessidade de correção, facilidade de uso, capacidade de processamento 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.

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

Supomos que o sistema de build precisa ser correto em relação aos 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 todas as entradas que entram em uma determinada etapa de build, para que possa executar essa etapa 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 alterações nos atributos do arquivo. 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 deles relacionados ao Fileset ou às regras de C++, que são problemas difíceis. Temos esforços de longo prazo para corrigir esses problemas.

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

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

A latência denota o tempo necessário para iniciar um build e receber o resultado pretendido , seja um registro de teste de um teste aprovado ou com falha ou uma mensagem de erro informando que um BUILD arquivo tem um erro de digitação.

Esses objetivos 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 a facilidade de uso.

Repositórios em grande escala

O sistema de build precisa operar na escala de grandes repositórios, 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 precisará ler e analisar dezenas de milhares de BUILD arquivos 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 razoável de tempo e memória. Portanto, é 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 seja 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 examinar 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 analisando regras usando uma configuração (flags de linha de comando, essencialmente) e só então executando ações. Essa distinção ainda faz parte da API de regras hoje, mesmo que o núcleo do Bazel não a exija mais (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 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 pelas dependências dela.

Intrínseco

Há 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 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 em que ele precisa ser executado é surpreendente: 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 trabalhos desnecessários 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 de build calcula uma impressão digital de ação exclusiva e pede ao programador uma ocorrência de cache. Se uma ocorrência de cache for encontrada, o programador vai responder com os resumos dos arquivos de saída. Os arquivos em si são tratados 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 codificaçã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 ainda está atualizada. O mesmo vale para o carregamento de pacotes e a análise de regras, e nós 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 'build //foo com estas opções') e o divide em partes constituintes, que são avaliadas e combinadas para gerar este 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ó executa um processo de descoberta de dependências. Cada nó pode declarar dependências e usar o conteúdo dessas dependências para declarar ainda mais 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 (e, por motivos históricos, estamos atualmente vinculados ao uso do Java, portanto, sem linhas de execução leves e sem 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 abortar 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. A um nó que declara N dependências serialmente pode ser reiniciado N vezes, custando O(N^2) de tempo. Em vez disso, nosso objetivo é a declaração em massa de dependências, 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.

Essa tecnologia não está disponível 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 do idioma em que o sistema de build é implementado ou em que as regras são escritas (elas não precisam ser as mesmas), os autores de regras não podem usar bibliotecas ou padrões padrão que ignorem 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 fortemente que os autores de regras não sejam expostos a um ambiente de execução de linguagem completo em primeiro lugar. O perigo 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 domínio.

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

Para piorar a situação, além dos requisitos impostos pelo Skyframe, das restrições históricas do uso do Java e da desatualizaçã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árias. Há dois padrões muito comuns que introduzem o consumo de memória quadrático (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, queremos calcular alguma propriedade sobre o fechamento transitivo de essas regras, como o classpath de execução Java ou o comando do vinculador C++ para cada biblioteca. De forma ingênua, 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 que dependem das mesmas regras de biblioteca , como se você tiver 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 regras de biblioteca. Agora, considere que cada binário faz uma cópia de alguma propriedade calculada sobre o fechamento transitivo de regras de biblioteca, como o classpath 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 link C++. N/2 cópias de N/2 elementos são 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 coleção 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 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 foram mudanças 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 apenas iterar em um depset 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 um NestedSet para um desses métodos leva ao comportamento de cópia e reintroduz o consumo de memória quadrático.