Nesta página, explicamos como usar workers persistentes, os benefícios, os requisitos e como os workers afetam o sandbox.
Um worker persistente é um processo de longa duração iniciado pelo servidor do Bazel, que
funciona como um wrapper em torno da ferramenta real (geralmente um compilador) ou é
a própria ferramenta. Para se beneficiar de workers persistentes, a ferramenta precisa
ser compatível com a realização de uma sequência de compilações, e o wrapper precisa fazer a conversão
entre a API da ferramenta e o formato de solicitação/resposta descrito abaixo. O mesmo
worker pode ser chamado com e sem a flag --persistent_worker
na
mesma versão e é responsável por iniciar e se comunicar adequadamente com a
ferramenta, além de desligar os workers na saída. Cada instância de worker recebe um diretório de trabalho separado em <outputBase>/bazel-workers
, mas não recebe o chroot.
O uso de workers permanentes é uma estratégia de execução que diminui a sobrecarga de inicialização, permite mais compilação JIT e possibilita o armazenamento em cache, por exemplo, das árvores de sintaxe abstrata na execução da ação. Essa estratégia consegue essas melhorias enviando várias solicitações para um processo de longa duração.
Os workers permanentes são implementados para várias linguagens, incluindo Java, Scala, Kotlin e muito mais.
Os programas que utilizam um ambiente de execução do NodeJS podem utilizar a biblioteca auxiliar @bazel/worker para implementar o protocolo do worker.
Como usar workers permanentes
O Bazel 0.27 e versões posteriores usam workers persistentes por padrão ao executar builds, embora a execução remota tenha precedência. Para ações que não são compatíveis com workers persistentes,
o Bazel volta a iniciar uma instância de ferramenta para cada ação. É possível definir explicitamente sua versão para usar workers persistentes, definindo a estratégia worker
para as mneumônicas da ferramenta aplicável. Como prática recomendada, este exemplo inclui especificar local
como um substituto da estratégia worker
:
bazel build //my:target --strategy=Javac=worker,local
O uso da estratégia de workers em vez da estratégia local pode aumentar significativamente a velocidade de compilação, dependendo da implementação. Para Java, os builds podem ser de duas a quatro vezes mais rápidos, às vezes mais para compilação incremental. A compilação do Bazel é cerca de 2,5 vezes mais rápida com workers. Para mais detalhes, consulte a seção Como escolher o número de workers.
Se você também tem um ambiente de build remoto que corresponda ao ambiente de build local, use a estratégia dinâmica experimental, que disputa a execução remota e a de worker. Para ativar a estratégia dinâmica, transmita a sinalização --experimental_spawn_scheduler. Essa estratégia ativa os workers automaticamente. Portanto, não é necessário
especificar a estratégia worker
, mas você ainda pode usar local
ou sandboxed
como
substitutas.
Como escolher o número de workers
O número padrão de instâncias de worker por mnemônico é 4, mas pode ser ajustado
com a
flag
worker_max_instances
. Há uma compensação entre fazer bom uso das CPUs disponíveis e a
quantidade de ocorrências de compilação e cache JIT que você recebe. Com mais workers, mais destinos pagarão os custos iniciais de execução de código não JITted e acesso a caches frios. Se você tem um pequeno número de destinos para criar, um único worker pode oferecer
a melhor compensação entre a velocidade de compilação e o uso de recursos. Por exemplo,
consulte o problema 8586.
A flag worker_max_instances
define o número máximo de instâncias de worker por
mnemônico e conjunto de sinalizações (veja abaixo). Portanto, em um sistema misto, você pode acabar usando
muita memória se mantiver o valor padrão. Para builds incrementais, o
benefício de várias instâncias de worker é ainda menor.
Este gráfico mostra os tempos de compilação do início do Bazel (destino
//src:bazel
) em uma estação de trabalho Linux Xeon Xeon 3,5 GHz com 64 GB de RAM e com hiperprocessamento de 6 núcleos. Para cada configuração de worker, cinco builds limpos são executados e
a média dos últimos quatro é usada.
Figura 1. Gráfico de melhorias de desempenho de builds limpos.
Para essa configuração, dois workers oferecem a compilação mais rápida, com uma melhoria de apenas 14% em comparação com um worker. Um worker é uma boa opção se você quer usar menos memória.
A compilação incremental geralmente beneficia ainda mais. Builds limpos são relativamente raros, mas a mudança de um único arquivo entre compilações é comum, em particular no desenvolvimento orientado a testes. O exemplo acima também tem algumas ações de empacotamento não Java que podem ofuscar o tempo de compilação incremental.
A recompilação das origens Java somente (//src/main/java/com/google/devtools/build/lib/bazel:BazelServer_deploy.jar
) depois de alterar uma constante de string interna em AbstractContainerizingSandboxedSpawn.java oferece uma aceleração de três vezes maior (média de 20 builds incrementais com um build de aquecimento descartado):
Figura 2. Gráfico de melhorias de desempenho de builds incrementais.
A aceleração depende da alteração que está sendo feita. Uma aceleração de um fator 6 é medida na situação acima quando uma constante usada com frequência muda.
Como modificar workers permanentes
É possível transmitir a sinalização
--worker_extra_flag
para especificar sinalizações de inicialização para workers, codificadas por mnemônico. Por exemplo,
transmitir --worker_extra_flag=javac=--debug
ativa a depuração somente para Javac.
Somente uma flag de worker pode ser definida por uso dessa flag e apenas para um mnemônico.
Os workers não são criados separadamente para cada mnemônico, mas também para
variações nas flags de inicialização. Cada combinação de sinalizações mnemônicas e de inicialização
é combinada em um WorkerKey
, e até
worker_max_instances
workers podem ser criados para cada WorkerKey
. Consulte a próxima seção para saber como a
configuração da ação também pode especificar sinalizações de configuração.
É possível usar a flag
--high_priority_workers
para especificar um mnemônico que precisa ser executado em vez das mnemônicas de
prioridade normal. Isso pode ajudar a priorizar ações que estão sempre no caminho
crítico. Se houver dois ou mais workers de alta prioridade executando solicitações, todos
os outros workers serão impedidos de executar. Essa sinalização pode ser usada várias vezes.
Transmitir a sinalização
--worker_sandboxing
faz com que cada solicitação de worker use um diretório de sandbox separado para todas as
entradas. A configuração do sandbox leva algum tempo a mais,
especialmente no macOS, mas oferece uma garantia melhor de correção.
A sinalização
--worker_quit_after_build
é útil principalmente para depuração e criação de perfil. Essa sinalização força todos os workers a sair
quando a criação é concluída. Você também pode transmitir
--worker_verbose
para
ter mais saídas sobre o que os workers estão fazendo. Essa sinalização é refletida no
campo verbosity
em WorkRequest
, permitindo que as implementações do worker também sejam
mais detalhadas.
Os workers armazenam os registros no diretório <outputBase>/bazel-workers
, por
exemplo,
/tmp/_bazel_larsrc/191013354bebe14fdddae77f2679c3ef/bazel-workers/worker-1-Javac.log
.
O nome do arquivo inclui o ID do worker e o mnemônico. Como pode haver mais
de um WorkerKey
por mnemônico, você pode ver mais de worker_max_instances
arquivos de registro para um determinado mnemônico.
Para builds do Android, consulte os detalhes na página Desempenho da versão do Android.
Como implementar workers persistentes
Consulte a página Como criar workers permanentes para mais informações sobre como criar um worker.
Este exemplo mostra uma configuração do Starlark para um worker que usa JSON:
args_file = ctx.actions.declare_file(ctx.label.name + "_args_file")
ctx.actions.write(
output = args_file,
content = "\n".join(["-g", "-source", "1.5"] + ctx.files.srcs),
)
ctx.actions.run(
mnemonic = "SomeCompiler",
executable = "bin/some_compiler_wrapper",
inputs = inputs,
outputs = outputs,
arguments = [ "-max_mem=4G", "@%s" % args_file.path],
execution_requirements = {
"supports-workers" : "1", "requires-worker-protocol" : "json" }
)
Com essa definição, o primeiro uso dessa ação começaria com a execução da linha de comando /bin/some_compiler -max_mem=4G --persistent_worker
. Uma solicitação
para compilar Foo.java
seria assim:
OBSERVAÇÃO: embora a especificação do buffer de protocolo use "snake case" (request_id
),
o protocolo JSON usa "camelCase" (requestId
). Neste documento, usaremos
camelCase nos exemplos JSON, mas o snake ao falar sobre o campo,
independentemente do protocolo.
{
"arguments": [ "-g", "-source", "1.5", "Foo.java" ]
"inputs": [
{ "path": "symlinkfarm/input1", "digest": "d49a..." },
{ "path": "symlinkfarm/input2", "digest": "093d..." },
],
}
O worker recebe isso em stdin
no formato JSON delimitado por nova linha, porque
requires-worker-protocol
está definido como JSON. Em seguida, o worker executa a ação
e envia um WorkResponse
formatado em JSON para o Bazel em stdout. Em seguida, o Bazel analisa essa resposta e a converte manualmente em um proto WorkResponse
. Para
se comunicar com o worker associado usando um protobuf codificado binário em vez de
JSON, requires-worker-protocol
precisaria ser definido como proto
, desta forma:
execution_requirements = {
"supports-workers" : "1" ,
"requires-worker-protocol" : "proto"
}
Se você não incluir requires-worker-protocol
nos requisitos de execução,
o Bazel vai padronizar a comunicação do worker para usar protobuf.
O Bazel deriva o WorkerKey
das sinalizações mnemônicas e compartilhadas. Portanto, se essa
configuração permitiu a alteração do parâmetro max_mem
, um worker separado seria
gerado para cada valor usado. Isso pode levar ao consumo excessivo de memória se
muitas variações forem usadas.
No momento, cada worker só pode processar uma solicitação por vez. O recurso experimental Workers multiplex permite o uso de várias linhas de execução se a ferramenta subjacente tiver várias linhas de execução e o wrapper estiver configurado para entender isso.
Neste repositório do GitHub, você pode ver exemplos de wrappers de worker escritos em Java e em Python. Se você estiver trabalhando em JavaScript ou TypeScript, o pacote@bazel/worker e o exemplo de worker do nodejs podem ser úteis.
Como os workers afetam o sandbox?
O uso da estratégia worker
por padrão não executa a ação em um
sandbox, semelhante à estratégia local
. Você pode definir a
flag --worker_sandboxing
para executar todos os workers dentro de sandboxes, garantindo que cada
execução da ferramenta veja apenas os arquivos de entrada que precisa ter. A ferramenta ainda
pode vazar informações entre solicitações internamente, por exemplo, por um
cache. O uso da estratégia dynamic
exige que os workers fiquem no sandbox.
Para permitir o uso correto de caches do compilador com workers, um resumo é transmitido com cada arquivo de entrada. Assim, o compilador ou o wrapper pode verificar se a entrada ainda é válida sem precisar ler o arquivo.
Mesmo ao usar os resumos de entrada para proteger contra armazenamento em cache indesejado, os workers em sandbox oferecem sandbox menos rigorosa do que o sandbox puro, porque a ferramenta pode manter outro estado interno afetado por solicitações anteriores.
Os workers multiplex só podem ser colocados no sandbox se a implementação do worker for compatível
e esse sandbox precisa ser ativado separadamente com a
flag --experimental_worker_multiplex_sandboxing
. Confira mais detalhes no
documento de design.
Leia mais
Para mais informações sobre workers persistentes, consulte:
- Postagem original do blog sobre trabalhadores permanentes
- Descrição da implementação do Haskell {: .external}
- Postagem do blog de Mike Morearty {: .external}
- Desenvolvimento de front-end com o Bazel: Angular/TypeScript e Persistent Workers com Asana {: .external} (link em inglês)
- Estratégias do Bazel explicadas {: .external}
- Discussão informativa sobre a estratégia do worker na lista de e-mails bazel-discuss {: .external}