Chaque message lit votre config : Hermes vient de retirer le verrou de ce chemin chaud


Vous demandez à Hermes de relever un seuil de compression. Il le fait, et réécrit la modification dans config.yaml. Pendant ces quelques centaines de millisecondes, vous envoyez un message à votre propre bot sur Telegram — et rien ne revient. Ce n’est ni le modèle ni le réseau. Ce message attend un verrou.

Cela ressemble à un cas limite. Ça ne l’est pas ; ça arrive tous les jours. La raison est banale : avant de pouvoir répondre à « quel modèle j’utilise », « ce plugin est-il activé » ou « combien de temps dure ce timeout », la plupart de ses chemins critiques doivent lire la config. Et lire la config a toujours été une opération coûteuse, qui doit en plus faire la queue. Ce lot de travaux (#118961, qui absorbe #115069, #115070 et #117440) supprime le parsing redondant et le verrou global de ce chemin.

D’où venait réellement le coût

La config est du YAML, donc la lire signifie : localiser le fichier, parser le YAML, le fusionner en profondeur par-dessus les défauts, puis remettre une copie à l’appelant. Pour éviter de lire un fichier à moitié écrit, tout cela s’exécutait sous un unique _CONFIG_LOCK global.

Deux problèmes en découlent :

  1. Chaque lecture reparsait. Même quand pas un octet n’avait changé. Certains chemins étaient pires : la sortie en streaming relisait la config par reasoning token pour décider d’émettre ou non des deltas, et la restauration de heartbeat ré-entrait dans chaque portée de profile par ligne.
  2. Les lectures faisaient la queue derrière les écritures. Si un writer (disons save_config()) tenait le verrou, toutes les lectures attendaient. Sur un gateway, cette lecture se trouve sur le chemin des hooks de chaque message entrant, donc une seule sauvegarde pouvait bloquer le traitement des messages de tout le monde.

Correctif un : lire une fois par flux, pas par token

Trois changements absorbés, chacun supprimant une répétition :

  • Livraison en streaming (agent/stream_delivery.py) : plugins.stream_reasoning_deltas est désormais résolu une fois par flux plutôt qu’une fois par reasoning token. La mesure de la PR : environ 340 µs à environ 23 µs par lecture.
  • Restauration de heartbeat (gateway/run_heartbeat_restore.py) : chaque portée de profile est entrée une fois par scan de restauration au lieu d’une fois par ligne.
  • Parsing de la config de profile (utils.load_yaml_file_readonly) : les parsings du config.yaml d’un profile sont mis en cache sur la signature du fichier (mtime_ns, taille, inode, ctime_ns) — résultats positifs uniquement, donc un fichier modifié est reparsé plutôt que servi périmé.

Correctif deux : un hit de cache ne prend plus le verrou

C’est le plus important. load_config() dans hermes_cli/config.py a désormais un chemin rapide sans verrou, et le commentaire source énonce clairement l’enjeu :

# Chemin rapide sans verrou pour les hits de cache — même contrat de publication que
# `_read_raw_config_impl` ci-dessus (remplacement du tuple entier, `_CONFIG_LOCK` ne
# sérialise que les reconstructions et les writers). Un hit coûte ~0,024ms ; derrière un verrou
# tenu par `save_config()` la même lecture mesurait 10010ms, et sur un gateway cela bloque
# le chemin des hooks de chaque message entrant. Une course perdue retombe sur le verrou.

Pourquoi est-ce sûr ? À cause d’un contrat de publication explicite : l’entrée de cache est remplacée comme un tuple entier, et le verrou ne sérialise que les reconstructions et les writers. Un lecteur voit donc soit l’ancien tuple complet, soit le nouveau complet — jamais un état intermédiaire déchiré. Après avoir pris le snapshot, il revérifie la signature du fichier ; si la signature ne correspond pas, la lecture est traitée comme un miss et part sur le chemin verrouillé.

Autrement dit, les lectures n’ont plus besoin de faire la queue parce qu’elles ne lisent pas un état sous protection — elles lisent un snapshot qui n’est jamais muté en place. Et perdre la course n’est pas un pari sur la correction : le repli est l’implémentation d’origine, pleinement défensive.

Le même chemin rapide a été étendu à read_raw_config() et read_raw_config_readonly().

Les chiffres de la sonde dans la PR (sur la machine de l’auteur, avec un writer tenant _CONFIG_LOCK pendant 2 secondes) :

Lecture Avant Après
Hit de load_config() en cache ~2008 ms 0,3–1,9 ms
read_raw_config() ~2001 ms 0,1–0,2 ms
Pire cas consigné dans le commentaire source 10010 ms ~0,024 ms

Il a également été vérifié que basculer HERMES_HOME de A → B → A ne montre aucune fuite de cache entre homes, ce qui est exactement ce qui compte dans les déploiements multi-profile et multi-gateway.

Correctif trois : supprimer une mémoïsation qui ne se rentabilisait plus

Il y a un joli détail enfoui dans ce lot : l’auteur a supprimé une mémoïsation au lieu de la garder.

hermes_cli/plugins.py avait une mémo pour hook_callback_timeout. Une fois les hits de load_config devenus sans verrou, la mémo ne gagnait presque plus rien — la PR donne 8,4 µs pour un hit de mémo contre 9,0 µs pour un appel direct à load_config_readonly(), soit une différence de 0,6 µs. Mais elle avait un effet de bord bien réel : elle contournait la vérification de snapshot d’environnement de load_config, si bien que si vous aviez exprimé ce timeout comme une référence d’environnement — la forme ${VAR} — modifier la variable ne l’aurait pas rafraîchi.

Économiser 0,6 µs en échange d’un piège « j’ai changé la variable d’env et rien ne s’est passé » est un mauvais marché. Elle a donc été supprimée plutôt qu’optimisée.

Ce que cela signifie pour vous

  • Une latence de messages plus stable sur les gateways. « Sauvegarder la config » et « recevoir des messages » n’interfèrent plus.
  • Rien à configurer. Ce n’est pas un interrupteur à activer ; c’est le comportement par défaut.
  • Les setups multi-profile et multi-gateway en profitent le plus, parce que la restauration de heartbeat et l’entrée dans les portées par profile étaient des coûts qui grandissaient avec le nombre de profiles.

Si ce que vous rencontriez à la place était « la machine semble lente, et le CPU tourne alors qu’elle est au repos », c’est un autre balayage de perf — voyez l’article sur le disque lent / CPU au repos. Si votre douleur est celle de longues tâches qui se bloquent en cours de route, le réglage pour les longues tâches est la lecture la plus proche. Pour voir comment la config est lue et écrite depuis la CLI, la page de référence est hermes config ; et pour comprendre pourquoi ramener plusieurs profiles sur un seul hôte est payant, voyez un gateway par hôte.

Pour conclure

Aucune nouvelle fonctionnalité et aucune nouvelle clé de config ici — juste une idée : lire la config ne devrait ni faire la queue ni reparser. Un hit de cache est passé de « prendre le verrou et reparser » à « lire un snapshot qui n’est jamais muté en place, puis vérifier la signature », avec l’implémentation verrouillée d’origine comme repli. Les chiffres de performance sont impressionnants, mais la partie la plus précieuse est le compromis : une mémoïsation qui vaut 0,6 µs, mais qui pouvait ignorer silencieusement un changement de variable d’environnement, ne mérite pas d’être gardée.