Desafios de escrever regras

Informar um problema Mostrar fonte Por noite · 7,3 · 7,2 · 7,1 · 7,0 · 6,5

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

  • Suposição: busque 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 forte separação entre carregamento, análise e execução é Está desatualizado, 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

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

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

Presumimos que o sistema de build precisa estar, acima de tudo, correto com em relação a builds incrementais. Para uma determinada árvore de origem, a saída do Mesmo build deve ser sempre o mesmo, independentemente da aparência da árvore de saída Na primeira aproximação, isso significa que o Bazel precisa saber cada entrada que entra em determinada etapa de compilação, de modo que possa executar novamente essa etapa, se houver das entradas mudam. 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. Sandbox ajuda a garantir a correção impedindo leituras em arquivos de entrada não declarados. Além disso, limites intrínsecos do sistema, há alguns problemas de correção conhecidos, a maioria deles está relacionada ao conjunto de arquivos ou às regras C++, ambas difíceis problemas. Temos esforços de longo prazo para corrigir esses problemas.

A segunda meta do sistema de build é ter alta capacidade de processamento. estamos ultrapassando permanentemente os limites do que pode ser feito no contexto 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 a seguir. 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 que leva do início de um build até a entrega do 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 de grande escala

O sistema de build precisa operar na escala de grandes repositórios em que significa que ela não cabe em um único disco rígido, sendo impossível realizar uma compra completa em praticamente todas as máquinas de desenvolvimento. Um build de tamanho médio vai precisar ler e analisar dezenas de milhares de arquivos BUILD e avaliar centenas de milhares de globs. Embora, teoricamente, seja possível ler tudo BUILD em uma única máquina, ainda não conseguimos fazer isso em uma com uma quantidade razoável de tempo e memória. Por isso, é essencial que os arquivos BUILD podem ser carregados e analisados de modo independente.

Linguagem de descrição semelhante a 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 examinar os 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 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, é suficiente que uma regra conheça os arquivos de entrada e saída de uma ação antes de ser enviada para a execução remota. No entanto, a base de código original do Bazel tinha uma separação rigorosa entre o carregamento de pacotes, analisar regras usando uma configuração (basicamente, sinalizações de linha de comando) e e executar ações. Essa distinção ainda faz parte da API de regras 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 regra interface (quais atributos ela tem, tipos de atributos). Existem algumas exceções em que a API permite que o código personalizado seja executado durante a fase de carregamento para computar nomes implícitos de arquivos de saída e valores implícitos de atributos. Para exemplo, uma regra da biblioteca java chamada "foo" gera implicitamente uma saída chamada "libfoo.jar", que pode ser referenciado de 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 bipartito 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 as regras de escrita desafiadoras e alguns dos mais comuns são descritos 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 ao aproximadamente duas ordens de magnitude em comparação com a execução do build em um único máquina virtual. No entanto, a escala em que ele precisa ser executado é impressionante: o o serviço de execução remota foi projetado para lidar com um grande número de solicitações por segundo, e o protocolo evita idas e voltas desnecessárias e também 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 agir 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 uma ocorrência em cache for encontrada, o programador responde com os resumos dos arquivos de saída; os arquivos em si são resolvidos por resumo mais tarde. No entanto, isso impõe restrições ao 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

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, projetaram o Skyframe para lidar com isso. em 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 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 mudança em um arquivo de entrada (incluindo a criação ou exclusão de um arquivo de entrada), fazer 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 ainda mais dependências. Em princípio, isso é muito bom modelo de linhas de execução por nó. No entanto, builds de tamanho médio contêm centenas de milhares de nós Skyframe, o que não é facilmente possível com as soluções atuais do Java tecnologia (e, por motivos históricos, estamos atualmente vinculados ao uso de Java, por isso 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 precisemos cancelar esse e reiniciá-la (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, visamos a declaração em massa antecipada de dependências, que às vezes exigem a reorganização do código, ou até mesmo a divisão um nó em vários nós 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 outros nós precisam passar pelo framework para que ele possa rastrear dependências correspondentes. Independente da linguagem usada no sistema de build seja implementada ou em que as regras estejam 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 é grande demais. no passado, vários bugs do Bazel eram causados por regras que usavam APIs não seguras, até mesmo embora as regras tenham sido escritas pela equipe do Bazel ou por outros especialistas do domínio.

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

Para piorar, além dos requisitos impostos pelo Skyframe, os as restrições históricas do uso de 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. Existem duas padrões muito comuns que introduzem o consumo de memória quadrática consumo de tempo quadrático).

  1. 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. Imaginando, podemos pegar 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).

  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 são binárias e a outra metade das regras da biblioteca. Agora considere que cada binário faz uma cópia uma propriedade calculada no fechamento transitivo das regras da biblioteca, como o caminho de classe do tempo de execução do 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 de semântica, por isso a 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 todos os problemas automaticamente. em particular, mesmo que a simples iteração de um encerramento em cada regra reintroduz consumo de tempo quadrático. 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.