Workers persistentes

Informar um problema Ver fonte Nightly · 8.3 · 8.2 · 8.1 · 8.0 · 7.6

Esta página explica como usar workers persistentes, os benefícios, os requisitos e como eles 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 ferramenta em si. Para aproveitar os benefícios dos workers persistentes, a ferramenta precisa oferecer suporte a uma sequência de compilações, e o wrapper precisa traduzir 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 no mesmo build e é responsável por iniciar e se comunicar adequadamente com a ferramenta, além de encerrar os workers ao sair. Cada instância de worker é atribuída (mas não chrooted) a um diretório de trabalho separado em <outputBase>/bazel-workers.

O uso de workers persistentes é uma estratégia de execução que diminui a sobrecarga de inicialização, permite mais compilação JIT e ativa o armazenamento em cache, por exemplo, das árvores de sintaxe abstrata na execução da ação. Essa estratégia envia várias solicitações a um processo de longa duração para alcançar essas melhorias.

Os workers persistentes são implementados para várias linguagens, incluindo Java, Scala, Kotlin e outras.

Programas que usam um ambiente de execução do NodeJS podem usar a biblioteca auxiliar @bazel/worker para implementar o protocolo de worker.

Como usar workers persistentes

O Bazel 0.27 e versões mais recentes usam workers persistentes por padrão ao executar builds, embora a execução remota tenha precedência. Para ações que não oferecem suporte a workers permanentes, o Bazel volta a iniciar uma instância de ferramenta para cada ação. É possível definir explicitamente o build para usar workers persistentes definindo a worker estratégia para os mnemônicos de ferramentas aplicáveis. Como prática recomendada, este exemplo inclui a especificação de local como um fallback para a estratégia worker:

bazel build //my:target --strategy=Javac=worker,local

Usar a estratégia de workers em vez da local pode aumentar significativamente a velocidade de compilação, dependendo da implementação. Para Java, os builds podem ser de 2 a 4 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 Escolher o número de workers.

Se você também tiver um ambiente de build remoto que corresponda ao ambiente de build local, use a estratégia dinâmica experimental, que executa uma execução remota e uma execução de worker. Para ativar a estratégia dinâmica, transmita a flag --experimental_spawn_scheduler. Essa estratégia ativa automaticamente os workers. Portanto, não é necessário especificar a estratégia worker, mas ainda é possível usar local ou sandboxed como substitutos.

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 o bom uso das CPUs disponíveis e a quantidade de compilação JIT e acertos de cache que você recebe. Com mais workers, mais destinos vão pagar os custos de inicialização da execução de código não JITted e atingir caches frios. Se você tiver um pequeno número de destinos para criar, um único worker poderá oferecer a melhor compensação entre velocidade de compilação e uso de recursos. Por exemplo, consulte problema nº 8586. A flag worker_max_instances define o número máximo de instâncias de worker por mnemônico e conjunto de flags (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 zero para o Bazel (destino //src:bazel) em uma estação de trabalho Linux Intel Xeon de 3,5 GHz com hiperesamento de 6 núcleos e 64 GB de RAM. Para cada configuração de worker, cinco builds limpos são executados, e a média dos últimos quatro é calculada.

Gráfico de melhorias de performance de builds limpos

Figura 1. Gráfico de melhorias de desempenho de builds limpas.

Para essa configuração, dois workers oferecem a compilação mais rápida, embora com apenas 14% de melhoria em comparação com um worker. Um worker é uma boa opção se você quiser usar menos memória.

A compilação incremental geralmente traz ainda mais benefícios. Builds limpos são relativamente raros, mas é comum mudar um único arquivo entre as compilações, principalmente no desenvolvimento orientado a testes. O exemplo acima também tem algumas ações de empacotamento que não são do Java e podem ofuscar o tempo de compilação incremental.

A recompilação das fontes Java apenas (//src/main/java/com/google/devtools/build/lib/bazel:BazelServer_deploy.jar) depois de mudar uma constante de string interna em AbstractContainerizingSandboxedSpawn.java resulta em uma aceleração de 3 vezes (média de 20 builds incrementais com um build de pré-aquecimento descartado):

Gráfico de melhorias de performance de builds incrementais

Figura 2. Gráfico das melhorias de performance das builds incrementais.

A aceleração depende da mudança que está sendo feita. Um aumento de velocidade de um fator 6 é medido na situação acima quando uma constante usada com frequência é alterada.

Como modificar workers permanentes

É possível transmitir a flag --worker_extra_flag para especificar flags de inicialização para workers, com chave por mnemônico. Por exemplo, transmitir --worker_extra_flag=javac=--debug ativa a depuração apenas para o Javac. Só é possível definir uma flag de worker por uso dessa flag e apenas para um mnemônico. Os workers não são criados separadamente apenas para cada mnemônico, mas também para variações nas flags de inicialização. Cada combinação de mnemônico e flags de inicialização é combinada em um WorkerKey, e para cada WorkerKey até worker_max_instances workers podem ser criados. Consulte a próxima seção para saber como a configuração de ação também pode especificar flags de configuração.

Use a flag --high_priority_workers para especificar um mnemônico que deve ser executado em vez dos mnemônicos de prioridade normal. Isso ajuda 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 serem executados. Essa flag pode ser usada várias vezes.

Ao transmitir a flag --worker_sandboxing, cada solicitação de worker usa um diretório de sandbox separado para todas as entradas. Configurar o sandbox leva um tempo extra, principalmente no macOS, mas oferece uma garantia de correção melhor.

A flag --worker_quit_after_build é útil principalmente para depuração e criação de perfil. Essa flag força todos os workers a sair assim que um build é concluído. Também é possível transmitir --worker_verbose para receber mais informações sobre o que os workers estão fazendo. Essa flag é refletida no campo verbosity em WorkRequest, permitindo que as implementações de 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, talvez você veja mais de worker_max_instances arquivos de registros para um determinado mnemônico.

Para builds do Android, consulte detalhes na página de desempenho do build do Android.

Implementar workers persistentes

Consulte a página Como criar workers persistentes 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 ficaria assim:

OBSERVAÇÃO: embora a especificação do buffer de protocolo use "snake case" (request_id), o protocolo JSON usa "camel case" (requestId). Neste documento, vamos usar camel case nos exemplos JSON, mas snake case ao falar sobre o campo independente 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 realiza a ação e envia um WorkResponse formatado em JSON para o Bazel no stdout. Em seguida, o Bazel analisa essa resposta e a converte manualmente em um proto WorkResponse. Para se comunicar com o worker associado usando protobuf codificado em binário em vez de JSON, requires-worker-protocol seria definido como proto, assim:

  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 usar protobuf por padrão para a comunicação do worker.

O Bazel deriva o WorkerKey do mnemônico e das flags compartilhadas. Portanto, se essa configuração permitisse mudar o parâmetro max_mem, um worker separado seria gerado para cada valor usado. Isso pode levar a um 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 multiplex workers permite usar várias linhas de execução se a ferramenta subjacente for multithread e o wrapper estiver configurado para entender isso.

Em este repositório do GitHub, você pode conferir 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?

Usar a 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 em sandbox, garantindo que cada execução da ferramenta veja apenas os arquivos de entrada que deve 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 estejam em 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 podem verificar se a entrada ainda é válida sem precisar ler o arquivo.

Mesmo ao usar os resumos de entrada para evitar o cache indesejado, os workers em sandbox oferecem um sandbox menos restrito do que um sandbox puro, porque a ferramenta pode manter outro estado interno afetado por solicitações anteriores.

Os workers multiplex só podem ser isolados em sandbox se a implementação do worker oferecer suporte a isso. Esse isolamento precisa ser ativado separadamente com a flag --experimental_worker_multiplex_sandboxing. Confira mais detalhes no documento de design).

Leitura adicional

Para mais informações sobre workers persistentes, consulte: