Worker permanenti

Questa pagina illustra come utilizzare i worker permanenti, i vantaggi, i requisiti e l'impatto dei worker sulla sandbox.

Un worker permanente è un processo a lunga esecuzione avviato dal server Bazel, che funziona come un wrapper intorno allo strumento effettivo (in genere un compilatore) o è lo strumento stesso. Per trarre vantaggio dai worker permanenti, lo strumento deve supportare l'esecuzione di una sequenza di compilation e il wrapper deve tradurre tra l'API dello strumento e il formato di richiesta/risposta descritto di seguito. Lo stesso lavoratore potrebbe essere chiamato con e senza il flag --persistent_worker nella stessa build ed è responsabile dell'avvio e della comunicazione appropriata con lo strumento e di arresto dei worker all'uscita. A ogni istanza worker viene assegnata una directory di lavoro separata (<outputBase>/bazel-workers, ma non rooted).

L'uso di worker permanenti è una strategia di esecuzione che riduce l'overhead di avvio, consente una compilazione JIT più ampia e consente di memorizzare nella cache ad esempio la struttura ad albero della sintassi astratta nell'esecuzione dell'azione. Questa strategia consente di ottenere questi miglioramenti inviando più richieste a un processo a lunga esecuzione.

I worker permanenti sono implementati per più linguaggi, tra cui Java, Scala, Kotlin e altri.

I programmi che utilizzano un runtime NodeJS possono utilizzare la libreria di supporto @bazel/worker per implementare il protocollo worker.

Utilizzo di worker permanenti

Bazel 0.27 e versioni successive utilizza worker permanenti per impostazione predefinita durante l'esecuzione delle build, anche se l'esecuzione remota ha la precedenza. Per le azioni che non supportano i worker permanenti, Bazel torna all'avvio di un'istanza di strumento per ogni azione. Puoi impostare esplicitamente la build in modo da utilizzare i worker permanenti impostando la worker strategia per la risorsa strumento pertinente. Come best practice, questo esempio include l'utilizzo di local come riserva per la strategia worker:

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

L'utilizzo della strategia dei worker invece della strategia locale può incrementare notevolmente la velocità di compilazione, a seconda dell'implementazione. Per Java, le build possono essere da 2 a 4 volte più veloci, talvolta più volte per la compilazione incrementale. La compilazione di Bazel è circa 2,5 volte più veloce con i lavoratori. Per ulteriori dettagli, consulta la sezione "Scelta del numero di worker".

Se hai anche un ambiente di build remoto che corrisponde al tuo ambiente di build locale, puoi utilizzare la strategia dinamica sperimentale, che esegue un'esecuzione remota e una esecuzione worker. Per abilitare la strategia dinamica, passa il flag --experimental_spawn_scheduler. Questa strategia attiva automaticamente i worker, quindi non è necessario specificare la strategia worker, ma puoi comunque utilizzare local o sandboxed come riserva.

Scelta del numero di worker

Il numero predefinito di istanze worker per mnemonico è 4, ma può essere regolato con il flag worker_max_instances. Esiste un compromesso tra l'utilizzo efficace delle CPU disponibili e la quantità di hit JIT e cache memorizzati. Con più worker, un numero maggiore di target pagherà i costi di avvio dell'esecuzione di codice non JIT e di memorizzazione nella cache fredda. Se devi creare un numero limitato di target, un singolo worker potrebbe offrire il miglior compromesso possibile tra velocità di compilazione e utilizzo delle risorse (ad esempio, consulta il problema n. 8586). Il flag worker_max_instances imposta il numero massimo di istanze worker per insieme mnemonico e flag (vedi di seguito), quindi in un sistema misto potresti utilizzare con molta memoria se mantieni il valore predefinito. Per le build incrementali, il vantaggio di più istanze worker è ancora inferiore.

Questo grafico mostra i tempi di compilazione da zero per Bazel (target) //src:bazel su una workstation Linux Xeon 3,5 GHz con 3-core iper-thread con 64 GB di RAM. Per ogni configurazione worker, vengono eseguite 5 build pulite e viene utilizzata la media degli ultimi 4.

Grafico dei miglioramenti delle prestazioni delle build pulite

Figura 1. Grafico dei miglioramenti delle prestazioni delle build pulite.

Per questa configurazione, due worker forniscono la compilazione più rapida, sebbene solo il 14% di miglioramento rispetto a un worker. Un worker è una buona scelta se vuoi utilizzare meno memoria.

La compilazione incrementale in genere genera ancora di più. Le build pulite sono relativamente rare, ma cambiare un solo file tra le operazioni di compilazione è comune, in particolare nello sviluppo basato su test. L'esempio precedente contiene anche alcune azioni di pacchettizzazione non Java che possono oscurare il tempo di compilazione incrementale.

La compilazione delle origini Java solo (//src/main/java/com/google/devtools/build/lib/bazel:BazelServer_deploy.jar) dopo aver modificato una costante stringa interna in AbstractContainerizingSandboxedSpawn.java offre un triplo di velocità (in media di 20 build incrementali con una build di riscaldamento scartata):

Grafico dei miglioramenti delle prestazioni delle build incrementali

Figura 2. Grafico dei miglioramenti delle prestazioni delle build incrementali.

La velocità dipende dalla modifica apportata. Quando la costante utilizzata di solito viene modificata, viene misurata la velocità di un fattore 6.

Modifica dei worker permanenti

Puoi trasmettere il flag --worker_extra_flag per specificare i flag di avvio ai worker, con chiave mnemonica. Ad esempio, il passaggio di --worker_extra_flag=javac=--debug attiva il debug solo per Javac. È possibile impostare un solo flag worker per ogni utilizzo di questo flag, e soltanto per un flag mnemonico. I worker non vengono creati solo separatamente per ogni mnemonica, ma anche per le variazioni nei flag di avvio. Ogni combinazione di flag mnemonici e di avvio viene combinata in un WorkerKey e per ogni WorkerKey può essere creato un massimo di worker_max_instances worker. Consulta la sezione seguente per informazioni su come la configurazione delle azioni può anche specificare i flag di configurazione.

Puoi utilizzare il flag --high_priority_workers per specificare un mnemonico che deve essere eseguito a preferenza di uno di tipo Normale. Questo può dare la priorità alle azioni che si trovano sempre nel percorso critico. Se due o più worker con priorità elevata eseguono le richieste, viene impedito l'esecuzione di tutti gli altri worker. Questo flag può essere utilizzato più volte.

Se superi il flag --worker_sandboxing, ogni richiesta di worker utilizza una directory sandbox separata per tutti i suoi input. La configurazione di sandbox richiede tempo, in particolare su macOS, ma garantisce una migliore garanzia di correttezza.

Il flag --worker_quit_after_build è principalmente utile per il debug e la profilazione. Questo flag impone a tutti i worker di uscire dopo il completamento di una build. Puoi anche trasmettere --worker_verbose per ottenere maggiori informazioni su ciò che stanno facendo i lavoratori. Il flag si riflette nel campo verbosity in WorkRequest, consentendo di implementare anche le implementazioni dei worker.

I worker archiviano i propri log nella directory <outputBase>/bazel-workers, ad esempio /tmp/_bazel_larsrc/191013354bebe14fdddae77f2679c3ef/bazel-workers/worker-1-Javac.log. Il nome del file include l'ID worker e la mnemonica. Poiché può esserci più di un WorkerKey per ogni file mnemonico, potrebbero essere visualizzati più di worker_max_instances file di log per una determinata risorsa.

Per le build Android, visualizza i dettagli nella pagina delle prestazioni di Android Build.

Implementazione dei worker permanenti

Consulta la pagina relativa alla creazione di worker permanenti per informazioni su come creare un worker.

Questo esempio mostra una configurazione Starlark per un worker che utilizza 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" }
)

Con questa definizione, il primo utilizzo di questa azione inizierà con l'esecuzione della riga di comando /bin/some_compiler -max_mem=4G --persistent_worker. Una richiesta per compilare Foo.java avrebbe il seguente aspetto:

arguments: [ "-g", "-source", "1.5", "Foo.java" ]
inputs: [
  {path: "symlinkfarm/input1" digest: "d49a..." },
  {path: "symlinkfarm/input2", digest: "093d..."},
]

Il worker lo riceve su stdin in formato JSON delimitato da nuova riga (perché requires-worker-protocol è impostato su JSON). Successivamente, il worker esegue l'azione e invia un WorkResponse in formato JSON a Bazel sulla sua stdout. Bazel analizza quindi questa risposta e la converte manualmente in un protocollo WorkResponse. Per comunicare con il worker associato utilizzando un protobuf con codifica binaria anziché JSON, requires-worker-protocol viene impostato su proto, in questo modo:

  execution_requirements = {
    "supports-workers" : "1" ,
    "requires-worker-protocol" : "proto"
  }

Se non includi requires-worker-protocol nei requisiti di esecuzione, Bazel avrà per impostazione predefinita la comunicazione con i worker per utilizzare il protobuf.

Bazel ricava il WorkerKey dal mnemonico e dai flag condivisi, quindi se questa configurazione ha consentito di modificare il parametro max_mem, verrà generato un worker separato per ogni valore utilizzato. Ciò può comportare un consumo eccessivo di memoria se vengono utilizzate troppe varianti.

Attualmente ogni worker può elaborare una sola richiesta alla volta. La funzionalità sperimentale Worker multiplex consente di utilizzare più thread, se lo strumento sottostante è multi-thread e il wrapper è configurato per comprendorlo.

In questo repository GitHub puoi vedere wrapper worker di esempio scritti in Java e in Python. Se lavori in JavaScript o TypeScript, il pacchetto@bazel/worker e l'esempio di worker nodejs potrebbero essere utili.

In che modo i lavoratori influiscono sulla sandbox?

L'utilizzo della strategia worker per impostazione predefinita non esegue l'azione in una sandbox, in modo simile alla strategia local. Puoi impostare il flag --worker_sandboxing per l'esecuzione di tutti i worker all'interno di sandbox, assicurandoti che ogni esecuzione dello strumento mostri solo i file di input di cui dovrebbe disporre. Lo strumento potrebbe comunque divulgare informazioni tra le richieste internamente, ad esempio tramite una cache. L'utilizzo della strategia dynamic richiede l'utilizzo della sandbox per i worker.

Per consentire un corretto utilizzo delle cache del compilatore con i worker, viene passato un digest con ogni file di input. Pertanto il compilatore o il wrapper può verificare se l'input è ancora valido senza dover leggere il file.

Anche quando si utilizza la sintesi interna per proteggersi da memorizzazione nella cache indesiderata, i worker con sandbox offrono sandbox più rigorose rispetto a una sandbox pura, perché lo strumento può mantenere un altro stato interno che è stato interessato dalle richieste precedenti.

I sandbox di multiplex possono essere sottoposti a sandbox solo se l'implementazione dei worker supporta questa operazione; inoltre, questa sandbox deve essere abilitata separatamente con il flag --experimental_worker_multiplex_sandboxing. Scopri di più nel documento di progettazione.

Per approfondire

Per ulteriori informazioni sui worker permanenti, vedi: