Nœuds de calcul persistants

Cette page explique comment utiliser les nœuds de calcul persistants, leurs avantages, leurs exigences et leur impact sur le bac à sable.

Un nœud de calcul persistant est un processus de longue durée lancé par le serveur Bazel qui fonctionne comme un wrapper autour de l'outil réel (généralement un compilateur) ou de l'outil lui-même. Pour bénéficier des nœuds de calcul persistants, l'outil doit permettre une séquence de compilations, et le wrapper doit être traduit entre l'API de l'outil et le format requête/réponse décrit ci-dessous. Le même nœud de calcul peut être appelé avec et sans l'option --persistent_worker dans le même build. Il est responsable du démarrage et de la communication appropriés vers l'outil, et de l'arrêt des nœuds de calcul en sortie. Chaque instance de nœud de calcul est attribuée à un répertoire de travail distinct (mais pas modifié) sous <outputBase>/bazel-workers.

L'utilisation de nœuds de calcul persistants est une stratégie d'exécution qui réduit les coûts de démarrage, permet une compilation plus JIT et permet la mise en cache, par exemple, des arbres de syntaxe abstraite lors de l'exécution de l'action. Cette stratégie obtient ces améliorations en envoyant plusieurs requêtes à un processus de longue durée.

Les nœuds de calcul persistants sont mis en œuvre pour plusieurs langages, y compris Java, Scala, Kotlin, etc.

Les programmes utilisant un environnement d'exécution NodeJS peuvent utiliser la bibliothèque d'aide @bazel/worker pour mettre en œuvre le protocole de nœud de calcul.

Utiliser des nœuds de calcul persistants

Bazel 0.27 et versions ultérieures utilisent des nœuds de calcul persistants par défaut lors de l'exécution des builds, bien que l'exécution à distance soit prioritaire. Pour les actions non compatibles avec les nœuds de calcul persistants, Bazel recommence à démarrer une instance d'outil pour chaque action. Vous pouvez explicitement configurer votre build de sorte qu'il utilise des nœuds de calcul persistants en définissant la stratégie worker pour les outils mnémotechniques applicables. De bonne pratique, cet exemple consiste à spécifier local comme solution de remplacement à la stratégie worker:

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

L'utilisation de la stratégie des nœuds de calcul plutôt que de la stratégie locale peut accélérer considérablement la compilation, en fonction de la mise en œuvre. Pour Java, les compilations peuvent être deux à quatre fois plus rapides, voire plus souvent dans le cas d'une compilation incrémentielle. Compilation Bazel est environ 2,5 fois plus rapide avec les nœuds de calcul. Pour plus d'informations, consultez la section Choisir le nombre de nœuds de calcul.

Si vous disposez également d'un environnement de compilation distant correspondant à votre environnement de compilation local, vous pouvez utiliser la stratégie dynamique expérimentale, qui va entraîner une exécution à distance et une exécution du nœud de calcul. Pour activer la stratégie dynamique, transmettez l'option --experimental_spawn_scheduler. Cette stratégie active automatiquement les nœuds de calcul. Il n'est donc pas nécessaire de spécifier la stratégie worker, mais vous pouvez toujours utiliser local ou sandboxed en tant que créations de remplacement.

Sélectionner le nombre de nœuds de calcul

Le nombre d'instances de nœuds de calcul par mnémonique est par défaut de 4, mais peut être ajusté à l'aide de l'option worker_max_instances. Il y a un compromis entre la bonne utilisation des processeurs disponibles, et la quantité de compilation JIT et de succès de cache que vous obtenez. Plus vous avez de nœuds de calcul, plus vous atteignez les coûts de démarrage pour l'exécution du code non JITT et les mises en cache à froid. Si vous avez un petit nombre de cibles à créer, un seul nœud de calcul peut offrir le meilleur compromis entre vitesse de compilation et utilisation des ressources (par exemple, le problème 8586). L'option worker_max_instances définit le nombre maximal d'instances de nœuds de calcul par ensemble mnémonique et d'indicateurs (voir ci-dessous). Dans un système mixte, vous pourriez donc utiliser beaucoup de mémoire si vous conservez la valeur par défaut. Pour les builds incrémentiels, l'avantage de plusieurs instances de nœuds de calcul est encore plus faible.

Ce graphique montre les temps de compilation initiaux pour Bazel (cible) //src:bazel sur un poste de travail Linux Intel Xeon 3,5 GHz 6 cœurs doté de 6 cœurs et de 64 Go de RAM. Pour chaque configuration de nœud de calcul, cinq builds propres sont exécutés et la moyenne des quatre derniers est effectuée.

Graphique des améliorations de performances des builds propres

Figure 1 : Graphique des améliorations de performances des builds propres.

Pour cette configuration, deux nœuds de calcul fournissent la compilation la plus rapide, mais avec une amélioration de seulement 14 % par rapport à un seul nœud de calcul. Un nœud de calcul est une bonne option si vous souhaitez utiliser moins de mémoire.

La compilation incrémentielle est généralement encore plus utile. Les builds propres sont relativement rares, mais il est courant de modifier un seul fichier entre les compilations, en particulier dans le développement basé sur des tests. L'exemple ci-dessus comporte également des actions de packaging non Java qui peuvent surcharger la durée de compilation incrémentielle.

La compilation des sources Java uniquement (//src/main/java/com/google/devtools/build/lib/bazel:BazelServer_deploy.jar) après la modification d'une constante de chaîne interne dans AbstractContainerizingSandboxedSpawn.java permet de multiplier par trois la vitesse (20 compilations incrémentielles avec un build de préchauffage supprimé):

Graphique sur l&#39;amélioration des performances des builds incrémentiels

Figure 2. Graphique des améliorations de performances des builds incrémentiels.

La vitesse dépend de la modification apportée. L'accélération d'un facteur 6 est mesurée dans ce cas lorsqu'une constante couramment utilisée est modifiée.

Modifier des nœuds de calcul persistants

Vous pouvez transmettre l'option --worker_extra_flag pour spécifier les options de démarrage aux nœuds de calcul, selon une procédure mnémotechnique. Par exemple, la transmission de --worker_extra_flag=javac=--debug active uniquement le débogage pour Javac. Vous ne pouvez définir qu'un seul indicateur de nœud de calcul par utilisation de cette option et un seul mnémonique. Les travailleurs ne sont pas simplement créés séparément pour chaque mnémonique, mais également pour les variations dans leurs indicateurs de démarrage. Chaque combinaison d'indicateurs mnémoniques et de démarrage est combinée en WorkerKey, et pour chaque WorkerKey, un maximum de worker_max_instances nœuds de calcul peuvent être créés. Consultez la section suivante pour savoir comment la configuration d'action peut également spécifier des options de configuration.

Vous pouvez utiliser l'option --high_priority_workers pour spécifier un mnémonique à exécuter en priorité sur les mnémotechniques standards. Vous pourrez ainsi donner la priorité aux actions qui se trouvent toujours dans le chemin critique. Si plusieurs nœuds de calcul à priorité élevée exécutent des requêtes, l'exécution de tous les autres nœuds de calcul est bloquée. Vous pouvez utiliser cet indicateur plusieurs fois.

Si vous transmettez l'option --worker_sandboxing, chaque requête de nœud de calcul utilise un répertoire sandbox distinct pour toutes ses entrées. La configuration du bac à sable prend plus de temps, en particulier sous macOS, mais garantit une plus grande précision.

L'option --worker_quit_after_build est principalement utile pour le débogage et le profilage. Cette option oblige tous les nœuds de calcul à quitter une fois la compilation terminée. Vous pouvez également transmettre --worker_verbose pour obtenir plus de résultats sur les tâches des nœuds de calcul. Cette option est reflétée dans le champ verbosity de WorkRequest, ce qui permet d'améliorer la précision des implémentations de nœuds de calcul.

Les nœuds de calcul stockent leurs journaux dans le répertoire <outputBase>/bazel-workers, par exemple /tmp/_bazel_larsrc/191013354bebe14fdddae77f2679c3ef/bazel-workers/worker-1-Javac.log. Le nom de fichier comprend l'ID du nœud de calcul et le mnémotechnique. Étant donné qu'il peut y avoir plusieurs WorkerKey par mnémonique, vous pouvez voir plus de worker_max_instances fichiers journaux pour un mnémotechnique donné.

Pour les versions d'Android, consultez les détails sur la page Android Build Performance.

Implémenter des nœuds de calcul persistants

Pour savoir comment créer un nœud de calcul, consultez la page Créer des nœuds de calcul persistants.

Cet exemple présente une configuration Starlark pour un nœud de calcul utilisant 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" }
)

Avec cette définition, la première utilisation de cette action consiste à exécuter la ligne de commande /bin/some_compiler -max_mem=4G --persistent_worker. Une requête permettant de compiler Foo.java se présente comme suit:

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

Le nœud de calcul la reçoit sur stdin au format JSON délimité par des retours à la ligne (car requires-worker-protocol est défini sur JSON). Le nœud de calcul exécute ensuite l'action et envoie un WorkResponse au format JSON à Bazel sur son stdout. Bazel analyse ensuite cette réponse et la convertit manuellement en fichier protocole WorkResponse. Pour communiquer avec le nœud de calcul associé à l'aide d'un tampon de protocole encodé au format binaire au lieu de JSON, requires-worker-protocol doit être défini sur proto, comme suit:

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

Si vous n'incluez pas requires-worker-protocol dans les exigences d'exécution, Bazel définira par défaut la communication du nœud de calcul pour l'utiliser.

Bazel extrait la valeur WorkerKey de l'indicateur mnémonique et des options partagées. Par conséquent, si cette configuration permettait de modifier le paramètre max_mem, un nœud de calcul distinct est généré pour chaque valeur utilisée. Cela peut entraîner une consommation excessive de la mémoire si vous utilisez trop de variantes.

Pour le moment, chaque nœud de calcul ne peut traiter qu'une requête à la fois. La fonctionnalité expérimentale de nœuds de calcul multiplex permet d'utiliser plusieurs threads, si l'outil sous-jacent est multithread et que le wrapper est configuré pour comprendre cette situation.

Dans ce dépôt GitHub, vous trouverez des exemples de wrappers de nœuds de calcul écrits en Java et en Python. Si vous travaillez en JavaScript ou TypeScript, les packages @bazel/worker et nodejs worker peuvent vous être utiles.

Quel est l'impact des nœuds de calcul sur le bac à sable ?

L'action par défaut de la stratégie worker n'est pas exécutée dans un bac à sable, comme dans la stratégie local. Vous pouvez définir l'option --worker_sandboxing pour qu'il exécute tous les nœuds de calcul dans des bacs à sable, en vous assurant que chaque exécution de l'outil ne voit que les fichiers d'entrée qu'il est censé avoir. L'outil peut toujours transmettre des informations entre les requêtes en interne, par exemple via un cache. Avec la stratégie dynamic, les nœuds de calcul doivent être en bac à sable.

Pour permettre une utilisation appropriée des caches de compilation avec les nœuds de calcul, un condensé est transmis avec chaque fichier d'entrée. Ainsi, le compilateur ou le wrapper peut vérifier si l'entrée est toujours valide sans avoir à lire le fichier.

Même si vous utilisez des condensés d'entrée pour vous protéger contre la mise en cache indésirable, les nœuds de calcul en bac à sable proposent un bac à sable moins strict qu'un bac à sable pur, car l'outil peut conserver un autre état interne affecté par des requêtes précédentes.

Les nœuds de calcul multiplex ne peuvent être accessibles en bac à sable que si l'implémentation du nœud de calcul est compatible. Ce processus doit être activé séparément avec l'option --experimental_worker_multiplex_sandboxing. Pour en savoir plus, consultez le document de conception.

Documentation complémentaire

Pour en savoir plus sur les nœuds de calcul persistants, consultez la section suivante: