Nesta página, você encontra uma visão geral detalhada de problemas e desafios específicos de escrever regras eficientes do Bazel.
Requisitos de resumo
- Hipótese: buscar a correção, o throughput, a facilidade de uso e a latência
- Hipótese: 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: execução remota e armazenamento em cache são difíceis
- Intrínseco: como usar informações de alteração para builds incrementais rápidos e corretos requer padrões de codificação incomuns
- Intrínseco: é difícil evitar o tempo quadrático e o consumo de memória
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. O as seções a seguir abordam essas suposições e oferecem diretrizes para garantir 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 deve ser sempre igual, 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 qualquer uma das entradas mudar. Há limites para a correção do Bazel porque ele vaza algumas informações, como data / hora da compilação, e ignora certos tipos de como alterações nos atributos dos arquivos. O sandbox ajuda a garantir a correção impedindo 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 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 um alto throughput. Estamos permanentemente ampliando os limites do que pode ser feito na alocação de máquina atual para um serviço de execução remota. Se a execução remota fica sobrecarregado, ninguém consegue fazer o trabalho.
A facilidade de uso vem em seguida. De várias abordagens corretas com o mesmo (ou semelhante) do serviço de execução remota, escolhemos aquela que mais fáceis de usar.
A latência indica o tempo entre o início de um build e a entrega
resultado, seja um registro de teste de um teste aprovado ou reprovado ou um registro de erro
mensagem de que um arquivo BUILD
tem um erro de digitação.
Essas metas costumam se sobrepor. a latência é uma função da capacidade de processamento do serviço de execução remota conforme a precisã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" 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 é
É praticamente semelhante aos arquivos BUILD
na declaração de regras binárias e de bibliotecas
e as interdependências deles. 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 são descritos 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 regra interface (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 os arquivos de origem nem inspecionar os a saída de uma ação, ele precisa gerar um bloco bipartite direcionado parcial gráfico das etapas de build e nomes dos arquivos de saída que só é determinado pela regra e das dependências dele.
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 compilação conheça todas as entradas de um realizar ações com antecedência; o sistema de build calcula uma ação única impressão digital e solicita uma ocorrência em cache ao programador. 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, discutimos que, para estar correto, o Bazel precisa saber todas as entradas que entram em uma etapa de versão para detectar se ela está sendo ainda esteja atualizado. 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 usa nó de meta (como 'build //foo with these options') e o divide em suas partes constituintes, que são entã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 usamos para calcular saída própria, desde o nó de meta até os arquivos de entrada (que também são nós 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 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ó declarar uma dependência que ainda não está disponível, talvez precisemos cancelar esse e a reiniciar (possivelmente em outra linha de execução), quando a dependência for disponíveis. Isso, por sua vez, significa que os nós não devem fazer isso excessivamente. por 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 atualmente na API de regras. Em vez disso, a API de regras ainda é definida usando os conceitos legados 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 usada no sistema de build é implementada ou em que as regras são escritas (elas não precisam ser os autores de regras não devem usar bibliotecas padrão ou padrões que ignorem Skyframe. Para Java, isso significa evitar java.io.File, bem como qualquer forma de reflexão e qualquer biblioteca que faça isso. Bibliotecas que oferecem suporte a dependências dessas interfaces de baixo nível ainda precisam ser configuradas corretamente para Skyframe.
Isso sugere evitar a exposição dos autores de regras a um ambiente de execução de linguagem completo. em primeiro lugar. 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, além dos requisitos impostos pelo Skyframe, os as restrições históricas do uso do Java e a desatualização da API de regras, introduzir acidentalmente o tempo quadrático ou o consumo de memória é uma questão problema em qualquer sistema de build baseado em regras binárias e de bibliotecas. Há dois padrões muito comuns que introduzem consumo de memória quadrático (e, portanto, consumo de tempo quadrático).
Cadeias de regras de bibliotecas: Considere o caso de uma cadeia de regras de biblioteca A depende de B, depende de C e assim por diante. Depois, vamos calcular alguma propriedade sobre o fechamento transitivo da essas regras, como o caminho de classe do tempo de execução do Java ou o comando do vinculador C++ para cada biblioteca. Nós 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 duas, a terceira três, e assim ativado, para um total de 1+2+3+...+N = O(N^2).
Regras binárias que dependem das mesmas regras da biblioteca - Considere o caso em que um conjunto de binários que dependem da mesma biblioteca regras de teste, por exemplo, se você tiver várias regras que testam o mesmo o código da 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, ela pode expandir a representação de string da linha de comando da ação de link C++. N/2 cópias de elementos N/2 é 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, apresentamos um conjunto de
classes de coleções personalizadas que compactam efetivamente as informações na memória ao
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
mudanças para reduzir o consumo de memória do Bazel ao longo dos últimos anos foram
mudanças para usar dependências em vez do que foi 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, os NestedSets também têm alguns métodos auxiliares facilitar a interoperabilidade com classes de coleções normais; infelizmente, transmitir acidentalmente um NestedSet para um desses métodos leva à cópia. e reintroduz o consumo de memória quadrática.