Modelo de avaliação paralela e incrementalidade do Bazel.
Modelo de dados
O modelo de dados consiste nos seguintes itens:
SkyValue
. Também chamados de nós.SkyValues
são objetos imutáveis que contêm todos os dados criados ao longo do build e as entradas do build. Exemplos: arquivos de entrada, arquivos de saída, destinos e destinos configurados.SkyKey
: um nome curto imutável para fazer referência a umaSkyValue
, por exemplo,FILECONTENTS:/tmp/foo
ouPACKAGE://foo
.SkyFunction
: cria nós com base nas chaves e nos nós dependentes.- Gráfico de nó. Uma estrutura de dados que contém a relação de dependência entre nós.
Skyframe
: nome de código do framework de avaliação incremental em que o Bazel se baseia.
Avaliação
Um build consiste em avaliar o nó que representa a solicitação de build. Esse é o estado que queremos, mas há muito código legado no caminho. Primeiro, o SkyFunction
é encontrado e chamado com a chave do SkyKey
de nível superior. Em seguida, a função solicita a avaliação dos nós necessários para avaliar o nó de nível superior, o que resulta em outras invocações de função e assim por diante, até que os nós de folha sejam alcançados, que geralmente representam arquivos de entrada no sistema de arquivos. Por fim, chegamos ao valor da SkyValue
de nível superior, alguns efeitos colaterais (como arquivos de saída no sistema de arquivos) e um gráfico acíclico dirigido das dependências entre os nós envolvidos no build.
Um SkyFunction
pode solicitar SkyKeys
em várias passagens se não puder informar com antecedência todos os nós necessários para fazer o trabalho. Um exemplo simples é avaliar um nó de arquivo de entrada que acaba sendo um link simbólico: a função tenta ler o arquivo, percebe que ele é um link simbólico e, portanto, busca o nó do sistema de arquivos que representa o destino do link simbólico. Mas ele pode ser um link simbólico. Nesse caso, a função original também precisa buscar o destino.
As funções são representadas no código pela interface SkyFunction
e pelos serviços fornecidos a ela por uma interface chamada SkyFunction.Environment
. As funções podem fazer o seguinte:
- Solicite a avaliação de outro nó chamando
env.getValue
. Se o nó estiver disponível, o valor dele será retornado. Caso contrário,null
será retornado e a própria função deverá retornarnull
. No último caso, o nó dependente é avaliado, e o builder de nó original é invocado novamente, mas desta vez a mesma chamadaenv.getValue
vai retornar um valor diferente denull
. - Solicite a avaliação de vários outros nós chamando
env.getValues()
. Isso faz basicamente o mesmo, exceto que os nós dependentes são avaliados em paralelo. - Fazer a computação durante a invocação
- Ter efeitos colaterais, por exemplo, gravar arquivos no sistema de arquivos. É preciso ter cuidado para que duas funções diferentes não se sobreponham. De modo geral, os efeitos colaterais de gravação (em que os dados fluem para fora do Bazel) são aceitáveis, mas os efeitos colaterais de leitura (em que os dados fluem para dentro do Bazel sem uma dependência registrada) não são, porque são uma dependência não registrada e, como tal, podem causar builds incrementais incorretos.
As implementações de SkyFunction
não devem acessar dados de nenhuma outra maneira, exceto solicitando dependências (por exemplo, lendo diretamente o sistema de arquivos), porque isso faz com que o Bazel não registre a dependência de dados no arquivo que foi lido, resultando em builds incrementais incorretos.
Quando uma função tiver dados suficientes para fazer o trabalho, ela precisará retornar um valor que não seja null
indicando a conclusão.
Essa estratégia de avaliação tem várias vantagens:
- Hermética. Se as funções solicitarem dados de entrada apenas dependendo de outros nós, o Bazel poderá garantir que, se o estado de entrada for o mesmo, os mesmos dados serão retornados. Se todas as funções do Sky forem determinísticas, isso significa que todo o build também será determinístico.
- Incrementabilidade correta e perfeita. Se todos os dados de entrada de todas as funções forem registrados, o Bazel poderá invalidar apenas o conjunto exato de nós que precisam ser invalidados quando os dados de entrada mudarem.
- Paralelismo. Como as funções só podem interagir entre si solicitando dependências, as funções que não dependem umas das outras podem ser executadas em paralelo, e o Bazel pode garantir que o resultado seja o mesmo que se elas fossem executadas sequencialmente.
Incrementality
Como as funções só podem acessar dados de entrada dependendo de outros nós, o Bazel pode criar um gráfico de fluxo de dados completo dos arquivos de entrada para os arquivos de saída e usar essas informações para recriar apenas os nós que realmente precisam ser recriados: o fechamento transitivo reverso do conjunto de arquivos de entrada alterados.
Existem duas estratégias de incrementalidade possíveis: a bottom-up e a top-down. A escolha depende da aparência do gráfico de dependência.
Durante a invalidação bottom-up, depois que um gráfico é criado e o conjunto de entradas alteradas é conhecido, todos os nós que dependem transitivamente de arquivos alterados são invalidados. Isso é ideal se soubermos que o mesmo nó de nível superior será criado novamente. A invalidação de baixo para cima exige a execução de
stat()
em todos os arquivos de entrada do build anterior para determinar se eles foram alterados. Isso pode ser melhorado usandoinotify
ou um mecanismo semelhante para saber mais sobre os arquivos alterados.Durante a invalidação de cima para baixo, o fechamento transitivo do nó de nível superior é verificado, e apenas os nós com fechamento transitivo limpo são mantidos. Isso é melhor se soubermos que o gráfico de nó atual é grande, mas só precisamos de um pequeno subconjunto dele no próximo build: a invalidação bottom-up invalidaria o gráfico maior do primeiro build, ao contrário da invalidação top-down, que apenas percorre o gráfico pequeno do segundo build.
No momento, só fazemos involuções bottom-up.
Para aumentar a incrementalidade, usamos a redução de mudanças: se um nó for invalidado, mas, após a reconstrução, descobrirmos que o novo valor é o mesmo que o antigo, os nós que foram invalidados devido a uma mudança nesse nó serão "ressuscitados".
Isso é útil, por exemplo, se alguém mudar um comentário em um arquivo C++: o arquivo .o
gerado a partir dele será o mesmo, então não precisamos chamar o linker novamente.
Vinculação / compilação incremental
A principal limitação desse modelo é que a invalidação de um nó é tudo ou nada: quando uma dependência muda, o nó dependente é sempre reconstruído do zero, mesmo que um algoritmo melhor possa mudar o valor antigo do nó com base nas mudanças. Confira alguns exemplos em que isso seria útil:
- Vinculação incremental
- Quando um único arquivo
.class
muda em um.jar
, teoricamente podemos modificar o arquivo.jar
em vez de criá-lo do zero novamente.
O motivo pelo qual o Bazel não oferece suporte a essas coisas de forma consistente (temos um pouco de suporte para vinculação incremental, mas não implementado no Skyframe) é duplo: só tivemos ganhos de desempenho limitados e foi difícil garantir que o resultado da mutação fosse o mesmo de uma recriação limpa, e o Google valoriza builds que são repetíveis bit a bit.
Até agora, sempre conseguíamos alcançar um bom desempenho simplesmente decompondo uma etapa de build cara e realizando uma reavaliação parcial dessa forma: ela divide todas as classes em um app em vários grupos e faz a desserialização separadamente. Dessa forma, se as classes em um grupo não mudarem, a decodificação não precisará ser refeita.
Como mapear para conceitos do Bazel
Esta é uma visão geral aproximada de algumas das implementações de SkyFunction
que o Bazel usa para executar um build:
- FileStateValue. O resultado de uma
lstat()
. Para arquivos existentes, também calculamos outras informações para detectar mudanças no arquivo. Esse é o nó de nível mais baixo no gráfico do Skyframe e não tem dependências. - FileValue. É usado por qualquer coisa que se preocupe com o conteúdo real e/ou o caminho resolvido de um arquivo. Depende do
FileStateValue
correspondente e de todos os links simbólicos que precisam ser resolvidos (como oFileValue
paraa/b
, que precisa do caminho resolvido dea
e do caminho resolvido dea/b
). A distinção entreFileStateValue
é importante porque, em alguns casos (por exemplo, ao avaliar globs do sistema de arquivos, comosrcs=glob(["*/*.java"])
), o conteúdo do arquivo não é necessário. - DirectoryListingValue. Basicamente, o resultado de
readdir()
. Depende doFileValue
associado ao diretório. - PackageValue. Representa a versão analisada de um arquivo BUILD. Depende do
FileValue
do arquivoBUILD
associado e também transitivamente de qualquerDirectoryListingValue
usado para resolver os globs no pacote (a estrutura de dados que representa o conteúdo de um arquivoBUILD
internamente) - ConfiguredTargetValue. Representa um destino configurado, que é um tupla do conjunto de ações geradas durante a análise de um destino e as informações fornecidas a destinos configurados que dependem dele. Depende do
PackageValue
em que o destino correspondente está, doConfiguredTargetValues
das dependências diretas e de um nó especial que representa a configuração do build. - ArtifactValue. Representa um arquivo no build, seja um artefato de origem ou de saída (os artefatos são quase equivalentes a arquivos e são usados para se referir a arquivos durante a execução real das etapas de build). Para arquivos de origem, depende da
FileValue
do nó associado. Para artefatos de saída, depende daActionExecutionValue
de qualquer ação que gera o artefato. - ActionExecutionValue. Representa a execução de uma ação. Depende do
ArtifactValues
dos arquivos de entrada. A ação que ele executa está atualmente contida na chave Sky, o que é contrário ao conceito de que as chaves Sky precisam ser pequenas. Estamos trabalhando para resolver essa discrepância. Observe queActionExecutionValue
eArtifactValue
não são usados se não executarmos a fase de execução no Skyframe.