Trabalhadores permanentes

Informar um problema Ver código-fonte

Nesta página, explicamos como usar workers permanentes, os benefícios, os requisitos e como eles afetam o sandbox.

Um worker permanente é 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 como a própria ferramenta. Para aproveitar os workers permanentes, a ferramenta precisa oferecer suporte a 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 sinalização --persistent_worker no mesmo build e é responsável por iniciar e falar de maneira adequada com a ferramenta, além de desligar os workers na saída. Cada instância de worker é atribuída (mas não está vinculada a) um diretório de trabalho separado em <outputBase>/bazel-workers.

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

Os workers permanentes são implementados em várias linguagens, incluindo Java, Scala, Kotlin e muito mais.

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

Como usar workers permanentes

O Bazel versão 0.27 e superior usa workers permanentes por padrão ao executar versões, embora a execução remota tenha precedência. Para ações não compatíveis com workers permanentes, o Bazel volta a iniciar uma instância de ferramenta para cada ação. Você pode configurar explicitamente seu build para usar workers permanentes definindo a estratégia worker para os métodos de ferramentas aplicáveis. 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 muito a velocidade de compilação, dependendo da implementação. Para Java, as versões podem ser de duas a quatro vezes mais rápidas, às vezes mais para compilação incremental. A compilação do Bazel é cerca de 2,5 vezes mais rápida com os workers. Para mais detalhes, consulte a seção "Como escolher o número de workers".

Se você também tiver um ambiente de build remoto que corresponda ao seu ambiente de build local, será possível usar a estratégia dinâmica experimental, que executa uma execução remota e uma de worker. Para ativar a estratégia dinâmica, transmita a sinalização --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.

Como escolher o número de workers

O número padrão de instâncias de worker por mnemônica é 4, mas pode ser ajustado com a sinalização worker_max_instances. Há uma desvantagem de fazer bom uso das CPUs disponíveis e da quantidade de compilações de JIT e ocorrências em cache que você recebe. Com mais workers, mais destinos pagarão os custos de inicialização para executar o código não JITADO e atingir os caches frios. Se você tiver um pequeno número de destinos a serem criados, um único worker poderá oferecer a melhor compensação entre a velocidade de compilação e o uso de recursos. Por exemplo, consulte o problema 8586 (link em inglês). A sinalização worker_max_instances define o número máximo de instâncias de worker por conjunto de sinalizações e sinalizações (veja abaixo). Portanto, em um sistema misto, é possível usar 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 do Bazel (//src:bazel de destino) em uma estação de trabalho Intel Xeon de 3,5 GHz com seis núcleos com 64 GB de RAM. Para cada configuração de worker, são executados cinco builds limpos, e a média dos últimos quatro é aceita.

Gráfico de melhorias de desempenho de builds limpos

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

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

A compilação incremental geralmente se beneficia ainda mais. Builds limpos são relativamente relativamente raros, mas alterar um único arquivo entre compilações é comum, principalmente no desenvolvimento orientado por 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 somente de origens Java (//src/main/java/com/google/devtools/build/lib/bazel:BazelServer_deploy.jar) depois de alterar uma constante de string interna em AbstractContainerizingSandboxedSpawn.java aumenta em três vezes a velocidade (média de 20 builds incrementais com um build de aquecimento descartado):

Gráfico de melhorias de desempenho de builds incrementais

Figura 2. Gráfico de melhorias de desempenho de builds incrementais.

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

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 apenas para Javac. Apenas uma sinalização de worker pode ser definida por uso e só para um mnemônico. Os workers não são apenas criados separadamente para cada mnemônico, mas também para variações nas sinalizações de inicialização. Cada combinação de sinalizações mnemônicas e de inicialização é combinada em uma WorkerKey. É possível criar até worker_max_instances workers para cada WorkerKey. Consulte a próxima seção para saber como a configuração da ação também pode especificar flags de configuração.

É possível usar a sinalização --high_priority_workers para especificar um expediente a ser executado, em vez de 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 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 mais tempo, principalmente no macOS, mas oferece uma garantia de correção melhor.

A sinalização --worker_quit_after_build é útil principalmente para depurar e criar perfis. Essa sinalização força todos os workers a sair quando uma versão é concluída. Também é possível transmitir --worker_verbose para ver mais informações 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 de desempenho do build do Android.

Como implementar workers permanentes

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 semelhante a:

OBSERVAÇÃO: embora a especificação do buffer de protocolo use "caso de snake" (request_id), o protocolo JSON usa "concatenação" (requestId). Neste documento, usaremos "CamaleCase" nos exemplos do JSON, mas "snake case" 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 esse valor no 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 na stdout. Depois, o Bazel analisa essa resposta e a converte manualmente em um protótipo WorkResponse. Para se comunicar com o worker associado usando protobuf codificado em vez de JSON, requires-worker-protocol seria 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 usará como padrão 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 permitir a alteração do parâmetro max_mem, um worker separado será 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 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 (em inglês), é possível ver exemplos de wrappers de worker escritos em Java e Python. Se você estiver trabalhando em JavaScript ou TypeScript, o pacote@bazel/worker e o exemplo de worker do nodejs (em inglês) podem ser úteis.

Como os workers afetam o sandbox?

Por padrão, o uso da estratégia worker não executa a ação em um sandbox, semelhante à estratégia local. É possível definir a sinalização --worker_sandboxing para executar todos os workers dentro dos sandboxes, certificando-se de que cada execução da ferramenta veja apenas os arquivos de entrada que ela 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 sejam colocados no sandbox.

Para permitir o uso correto de caches do compilador com os workers, um resumo é transmitido com cada arquivo de entrada. Assim, o compilador ou o wrapper pode verificar se a entrada ainda é válida sem ter que ler o arquivo.

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

Os workers multiplex só poderão ser colocados no sandbox se a implementação do worker for compatível. Esse sandbox precisa ser ativado separadamente com a sinalização --experimental_worker_multiplex_sandboxing. Veja mais detalhes no documento de design.

Leia mais

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