Elixir comme système de fanout
- Chaque fois qu’un événement se produit sur Discord, comme l’envoi d’un message ou l’arrivée de quelqu’un dans un canal vocal, l’interface doit être mise à jour sur le client de tous les utilisateurs en ligne présents sur le même serveur (aussi appelé « guilde »)
- Discord utilise un processus Elixir par guilde comme point de routage central pour tout ce qui se passe sur ce serveur, et un autre processus (« session ») pour le client de chaque utilisateur connecté
- Le processus de guilde suit les sessions des utilisateurs membres de cette guilde et se charge de diffuser les opérations vers ces sessions
- Quand une session reçoit une mise à jour, elle la transmet au client via la connexion WebSocket
- Certaines opérations s’appliquent à tout le monde sur le serveur, tandis que d’autres nécessitent une vérification des permissions ; il faut donc connaître les rôles de l’utilisateur ainsi que les rôles et canaux de ce serveur
- Le niveau d’activité d’une guilde est proportionnel au nombre de personnes sur ce serveur, et la quantité de travail nécessaire pour faire le fanout d’un message est elle aussi proportionnelle au nombre d’utilisateurs en ligne sur ce serveur
- En d’autres termes, la charge nécessaire pour traiter un serveur Discord augmente à la puissance 4 en fonction de la taille du serveur
- Si 1 000 personnes sont en ligne sur un serveur et qu’elles disent toutes une fois « j’aime la gelée », cela représente 1 million de notifications à traiter
- Avec 10 000 personnes, cela fait 100 millions de notifications, et avec 100 000 personnes, il faut en livrer 10 milliards
- Au-delà du problème global de débit, certaines opérations deviennent plus lentes à mesure que le serveur grossit
- Si l’on veut qu’un serveur paraisse réactif — qu’un message envoyé soit visible immédiatement, ou qu’une personne puisse commencer à participer dès qu’elle rejoint un canal vocal — il faut traiter presque toutes les opérations rapidement
- Si des opérations coûteuses prennent plusieurs secondes, l’expérience utilisateur se dégrade
- Malgré ces difficultés, comment ont-ils pu prendre en charge le serveur Midjourney, qui compte plus de 10 millions de membres, dont plus d’1 million toujours en ligne ?
- Il fallait d’abord comprendre les performances du système
- Une fois les données collectées, ils ont cherché des opportunités d’améliorer à la fois le débit et la réactivité
Comprendre les performances du système
- Wall time analysis :
- Traçage de pile avec
Process.info(pid, :current_stacktrace)
- Mesure de la boucle de traitement d’événements pour enregistrer, pour chaque type de message, le nombre reçu ainsi que les temps maximal, minimal, moyen et total de traitement
- Toutes les opérations représentant moins de 1 % du temps total sont ignorées, sauf en cas d’explosion extrême
- Cela permet d’écarter les opérations peu coûteuses et de mettre en évidence les plus onéreuses
- Process Heap Memory Analysis
- Comprendre l’usage mémoire est aussi important
- Plutôt que d’examiner chaque élément un par un, ils ont écrit une bibliothèque helper qui échantillonne les grands maps et listes (hors structs) pour estimer l’utilisation mémoire
- Cette bibliothèque a aidé non seulement à comprendre les performances du GC, mais aussi à identifier les champs qui valaient la peine d’être optimisés et ceux qui étaient finalement sans importance
- Une fois qu’ils ont compris où le processus de guilde passait son temps, ils ont pu définir une stratégie pour éviter qu’il ne soit occupé à 100 % en permanence
- Dans certains cas, il suffisait de réécrire une implémentation inefficace de façon plus efficace
- Mais cette approche avait ses limites. Un changement plus fondamental était nécessaire
Sessions passives — éviter le travail inutile
- L’un des meilleurs moyens de supprimer un goulot d’étranglement de débit est de réduire la quantité de travail
- Une manière d’y parvenir consiste à réfléchir aux besoins de l’application cliente
- Dans la topologie d’origine, tous les utilisateurs recevaient toutes les actions visibles dans toutes les guildes auxquelles ils appartenaient
- Or, certains utilisateurs appartiennent à plusieurs guildes et peuvent ne même pas cliquer sur certaines d’entre elles pour voir ce qui s’y passe
- Et si l’on n’envoyait pas tout tant que l’utilisateur n’avait pas cliqué ? Il ne serait plus nécessaire de vérifier individuellement les permissions sur tous les messages, et la quantité de données envoyées au client diminuerait fortement
- Ils ont appelé cela une connexion « Passive », conservée dans une liste distincte des connexions « Active », qui doivent recevoir toutes les données
- Résultat : sur les grands serveurs, environ 90 % des connexions utilisateur-guilde étaient passives, ce qui a réduit de 90 % le coût du travail de fanout
- Cela leur a donné un peu d’air, mais à mesure que la communauté continuait de grandir, cela ne suffisait naturellement plus
(Un volume de travail divisé par 10 permet un gain d’environ 3x à l’échelle des plus grandes communautés)
Relays — répartir le fanout sur plusieurs machines
- L’une des techniques classiques pour dépasser la limite de débit d’un cœur unique consiste à répartir le travail sur plusieurs threads (ou, en termes Elixir, plusieurs processus)
- À partir de cette idée, ils ont construit un système de « relay » entre les guildes et les sessions utilisateur
- Au lieu de traiter tout le travail lié aux sessions dans un seul processus, ils l’ont réparti entre plusieurs relays, afin qu’une même guilde puisse mobiliser davantage de ressources pour servir de très grandes communautés
- Certaines opérations devaient toujours être exécutées dans le processus principal de la guilde, mais cela a permis de gérer des communautés de plusieurs centaines de milliers de membres
- Pour y parvenir, il a fallu identifier les opérations critiques à exécuter côté relay, celles à laisser côté guilde, et celles pouvant être réalisées dans les deux systèmes
- Une fois les besoins clarifiés, ils ont lancé un travail de refactoring pour extraire la logique partageable entre systèmes
- Par exemple, l’essentiel de la logique sur la manière d’effectuer le fanout a été refactoré dans une bibliothèque utilisée à la fois par les guildes et les relays
- Une partie de la logique ne pouvait pas être partagée et nécessitait d’autres solutions ; la gestion de l’état vocal, par exemple, a été implémentée de manière à ce que les relays servent essentiellement de proxy en relayant tous les messages vers la guilde avec un minimum de changements
- L’une des décisions de conception intéressantes prises lors du premier lancement des relays a été d’inclure la liste complète des membres dans l’état de chaque relay
- C’était un bon choix du point de vue de la simplicité, car toutes les informations membres nécessaires étaient disponibles
- Mais à l’échelle de Midjourney, avec des millions de membres, cette conception a commencé à perdre son sens
- Non seulement des dizaines de copies des informations de dizaines de millions de membres étaient conservées en RAM, mais créer un nouveau relay imposait aussi de sérialiser toutes ces données pour les envoyer au nouveau relay, ce qui entraînait des latences de plusieurs dizaines de secondes sur la guilde
- Pour résoudre ce problème, ils ont ajouté une logique permettant d’identifier les membres réellement nécessaires au fonctionnement du relay, ce qui ne représentait qu’une toute petite fraction de l’ensemble des membres
Maintenir la réactivité du serveur
- En plus de rester sous les limites de débit, il fallait préserver la réactivité du serveur
- Là encore, l’analyse des données de timing s’est révélée utile
- Il était plus efficace de se concentrer sur les opérations dont la durée par appel était élevée plutôt que sur la durée totale
- Processus workers + ETS
- L’une des principales sources de manque de réactivité venait des opérations exécutées dans la guilde et qui nécessitaient d’itérer sur tous les membres
- Ces cas restent très rares, mais ils se produisent. Par exemple, lorsqu’une personne fait un ping everyone, il faut déterminer toutes les personnes du serveur autorisées à voir ce message
- Or ces vérifications peuvent prendre plusieurs secondes. Comment les gérer ?
- L’idéal serait d’exécuter cette logique pendant que la guilde continue de traiter les autres opérations, mais les processus Elixir partagent mal la mémoire. Il fallait donc une autre solution
- L’un des outils d’Erlang/Elixir permettant de stocker des données dans une mémoire partageable entre processus est ETS
- Il s’agit d’une base de données en mémoire prenant en charge un accès sûr par plusieurs processus Elixir
- C’est moins efficace qu’un accès à des données stockées sur le heap d’un processus, mais cela reste très rapide. Cela présente aussi l’avantage de réduire la taille du heap du processus et donc de diminuer les latences de garbage collection
- Ils ont décidé de créer une structure hybride pour stocker la liste des membres :
- stocker la liste des membres dans ETS pour qu’elle soit lisible par d’autres processus, tout en conservant aussi les changements récents (insertions, mises à jour, suppressions) sur le heap du processus
- comme la plupart des membres ne sont pas mis à jour en permanence, l’ensemble des changements récents ne représente qu’une très petite partie de l’ensemble total des membres
- Il devient alors possible de créer des processus workers utilisant les membres dans ETS et de leur transmettre l’identifiant de la table ETS sur laquelle travailler lorsqu’une opération coûteuse survient
- Les workers peuvent traiter la partie coûteuse pendant que la guilde continue à avancer sur d’autres tâches. L’article montre aussi une manière simple de procéder (avec un snippet de code dans l’original)
- Un exemple d’usage de cette méthode survient lorsqu’il faut transférer un processus de guilde d’une machine à une autre (généralement pour de la maintenance ou un déploiement)
- Dans ce processus, il faut créer sur la nouvelle machine un nouveau processus chargé de la guilde, copier l’état de l’ancien processus vers le nouveau, reconnecter toutes les sessions connectées au nouveau processus de guilde, puis traiter le backlog accumulé pendant l’opération
- En utilisant des workers, ils peuvent transférer la majeure partie des membres (ce qui peut représenter plusieurs Go de données) pendant que l’ancien processus de guilde continue à travailler, réduisant ainsi des délais qui pouvaient auparavant atteindre plusieurs minutes à chaque handoff
- Manifold offload
- Une autre idée pour améliorer la réactivité et dépasser les limites de débit consistait à étendre le manifold afin que le fanout soit réalisé vers les nœuds destinataires par des processus « sender » dédiés, au lieu d’être effectué par le processus de guilde
- Cela réduit non seulement la charge de travail du processus de guilde, mais protège aussi contre la backpressure du BEAM si l’une des connexions réseau entre la guilde et les relays se retrouve temporairement saturée (BEAM étant la machine virtuelle qui exécute le code Elixir)
- En théorie, cela semblait facile à résoudre, mais malheureusement, lorsqu’ils ont essayé cette fonctionnalité (appelée manifold offload), ils ont constaté que les performances se dégradaient fortement en pratique
- Comment cela pouvait-il arriver ? En théorie, la charge de travail diminuait, alors pourquoi le processus devenait-il plus occupé ?
- En regardant de plus près, ils ont découvert que l’essentiel du travail supplémentaire était lié au garbage collection
- C’est alors que la fonction
erlang.trace a joué un rôle salvateur
- Grâce à elle, ils ont pu collecter des données à chaque garbage collection du processus de guilde, obtenant ainsi des informations non seulement sur la fréquence des GC, mais aussi sur ce qui les déclenchait
- En s’appuyant sur ces traces et sur une lecture du code de garbage collection du BEAM, ils ont découvert qu’avec manifold offload activé, la condition principale déclenchant les garbage collections majeures (complètes) était le virtual binary heap
- Le virtual binary heap est un mécanisme conçu pour permettre à un processus de libérer la mémoire occupée par des chaînes non stockées dans le heap du processus, même lorsqu’un garbage collection ne serait pas autrement nécessaire
- Malheureusement, leur modèle d’utilisation provoquait des GC répétés afin de récupérer quelques centaines de kilo-octets, au prix de la copie de heaps de plusieurs gigaoctets — un compromis manifestement désavantageux
- Heureusement, dans le BEAM, ce comportement peut être ajusté via le process flag
min_bin_vheap_size
- En augmentant cette valeur à quelques mégaoctets, ce comportement pathologique de garbage collection a disparu, et l’activation de manifold offload a alors apporté une nette amélioration des performances
9 commentaires
Vive Elixir
Les sessions passives ne sont techniquement pas grand-chose, mais ça me semble être une bonne idée.
On dirait que cela permettrait clairement de réduire la charge.
Je suppose que Discord n’est pas le seul à avoir implémenté ce type de fonctionnalité, et je me demande quelles pourraient être les différences selon les services.
C’est vraiment impressionnant.
Ces derniers temps, on dirait bien que l’aboutissement du streaming SSR de Next.js, si populaire, c’est aussi le framework Phoenix d’Elixir. À bien des égards, Elixir semble être à l’avant-garde des langages de programmation modernes.
Vive Elixir
Il y a quelques années, en m’appuyant sur le blog technique de Discord, j’ai adopté Elixir pour un service temps réel. Nous avons lancé le service avec un niveau de satisfaction très élevé, tant sur la vitesse de développement que sur la sécurité, de ma part comme de celle des dirigeants en charge, et j’en garde un très bon souvenir.
J’aimerais qu’Elixir devienne plus populaire.
Ces temps-ci, je n’ai pas l’impression que ce soit vraiment le cas des grands noms du type Naver-Kakao-Line-Coupang ; au contraire, on dirait plutôt que les petites et moyennes startups sont quasiment monopolisées par Spring. C’est difficile à éviter, vu que, dans la plupart des cas, leurs managers sont des spécialistes de Spring.
Toutes les inefficacités peuvent être compensées avec de l’argent et de la taille. De toute façon, l’entreprise n’y comprend pas grand-chose.