Clause
← Toute la documentation

Traduit de l'original anglais · 06042fa · 2026-10-09 · Lire en anglais

Modèle d'exécution : cadres et continuations

Décision du plan 11 step 17 (2026-10-05). Elle fixe la manière dont les fonctions Erlang générées appellent, retournent, se suspendent et échouent une fois arrivés la récursion, les appels terminaux et les processus. Le step 19 a implémenté les appels, les retours, les appels terminaux et les cadres (Implémentation liste ce qui reste ouvert) ; les steps 23, 24, 26 et 43 s'appuient dessus.

Décision

Chaque processus Erlang s'exécute sur sa propre pile plate de cadres explicites (frames), et le code généré ne passe d'une fonction à l'autre que par des transferts terminaux garantis (LLVM musttail). Un appel non terminal stocke la continuation de l'appelant dans le cadre de l'appelant et saute vers l'appelé ; un retour saute de nouveau vers cette continuation. La pile native reste donc à un seul niveau d'appel au-dessus de l'ordonnanceur, quelle que soit la profondeur de récursion Erlang, et un processus peut s'arrêter à n'importe quel transfert et reprendre plus tard sur n'importe quel thread.

État du processus

ChampSignification
stack, capacityUn tableau de mots extensible ; se déplace lorsqu'il grandit
frame, topDécalages en mots de l'en-tête du cadre courant et du premier mot libre
x[], liveRegistres d'arguments/de résultat ; les live premiers mots sont des racines lors d'un transfert
reductionsAppels restants dans la tranche de temps
resume_atEntrée à laquelle un processus suspendu reprend
canal d'échecLe canal de révision 2 actuel : raison, charge utile, trace de pile, halted

Cadres

Un cadre est un en-tête fixe suivi des emplacements de la fonction (registres Y de BEAM). Les emplacements sont mis à zéro à l'empilement, comme le sont aujourd'hui les cadres de racines.

Mot d'en-têteSignification
previousDécalage de l'en-tête de l'appelant (les cadres sont chaînés par décalages, jamais par pointeurs)
functionLe descripteur de la fonction
resumeIndice de continuation sur lequel le corps aiguille lorsque le contrôle revient ici
handlerIndice de continuation du gestionnaire actif le plus interne, 0 s'il n'y en a pas

Le descripteur étend l'actuel abi::v1::FrameDescriptor (descripteur de module, emplacements des atomes de module et de fonction, arité) avec les pointeurs de code de l'entrée et du corps et le nombre d'emplacements. Les mots d'en-tête ne sont pas des termes ; un parcours suit previous et lit le nombre d'emplacements dans les descripteurs. Un cadre de fond (bottom frame) appartenant au runtime se trouve sous le premier appel de chaque processus : la continuation 1 est une sortie normale (résultat dans x[0]), la continuation 2 le gestionnaire d'une exception non capturée.

Opérations

Une fonction qui ne fait aucun appel non terminal et ne conserve aucun emplacement à travers un point sûr peut se passer de cadre et retourner directement au corps de son appelant. C'est une optimisation que le compilateur pourra ajouter plus tard, pas une partie du contrat.

Visibilité des racines

À chaque transfert et à chaque point sûr, les racines d'un processus sont : tous les emplacements de tous les cadres de sa pile, x[0..live), et la charge utile, la liste d'arguments et le terme de pile du canal d'échec (déjà des racines du processus). Les mots de transmission du résultat de l'actuelle pile de racines disparaissent ; x[0] prend leur rôle.

Les valeurs ne survivent jamais à un transfert dans des registres natifs ou des valeurs SSA. Chaque corps recharge l'adresse de son cadre depuis stack + frame après y être entré, et lit les valeurs vivantes dans les emplacements. Au sein d'une continuation, un service qui peut empiler un cadre ou déplacer le tas invalide tout pointeur d'emplacement et tout pointeur de tas conservés dans des valeurs SSA ; la règle de rechargement pour les collectes est fixée dans collecte dans le code généré.

Successeur de la pile de racines 8F

La pile segmentée n'existe que parce que le code généré conserve des pointeurs de cadre absolus à travers les appels. Dans ce modèle, aucun pointeur de cadre ne survit à un transfert, donc la pile devient un bloc plat unique par processus :

Cibles

musttail avec la signature uniforme void (Process *) est accepté par chaque cible requise en O0 et O2 (prototype ci-dessous, clang 23.1.2) :

CibleMotRésultat
x86_64-pc-windows-msvc, x86_64-unknown-linux-gnu64sauts terminaux
i686-pc-windows-msvc, i686-unknown-linux-gnu32sauts terminaux
aarch64-unknown-linux-gnu, arm64-apple-macosx14.064sauts terminaux
armv7-unknown-linux-gnueabihf32sauts terminaux

Le backend signale une erreur lorsqu'il ne peut pas honorer un appel musttail, donc une compilation réussie constitue la garantie. Solution de repli pour une future cible qui le rejetterait : un trampoline. Chaque code retourne le pointeur de code suivant à la boucle de l'ordonnanceur au lieu de sauter (null suspend) ; les cadres, les racines et les indices de continuation sont inchangés. Aucune cible requise n'en a besoin.

Implémentation

Le step 19 (2026-10-05) implémente le modèle avec ces choix et ces lacunes :

Alternatives comparées

Prototype dans tests/prototypes/execution_model : les mêmes fonctions Erlang (récursion de corps sum/1, récursion terminale loop/2, fail/1 levant boom à la profondeur N, catcher/1 la capturant) abaissées à la main de trois façons. Hôte : Windows x64, clang 23.1.2 ; les temps sont des exécutions uniques en O2. Lancer python tests/prototypes/execution_model/run.py.

Cadres explicites + musttail (choisi)Appels natifs + cadres de racines (actuel)Coroutines LLVM (C++20)
Récursion de corps à 1M de profondeurok, 17 ms ; 5 mots par cadre ; 17 déplacements de pile80 o de pile native par niveau : environ 13 000 niveaux dans un thread de 1 Miook, 60 ms ; une allocation sur le tas de 64 à 80 o par appel
10M appels terminauxok, 5 ms ; pile constanteEn O0, croît de 80 o par appel ; en O2, seulement par chance grâce aux sibling callspas d'appels terminaux : 1M d'itérations conservent 1M de cadres
Cession / repriseà chaque entrée ; deux processus s'entrelacentimpossible sans une pile native par processustransfert symétrique (lui-même musttail)
Pile native à la profondeur 1M136 à 144 ocroît à chaque niveau144 à 520 o
Racines visibles par le GCemplacements dans des cadres connusemplacements dans des cadres connusdisposition du cadre de coroutine choisie par LLVM ; les termes nécessiteraient une seconde copie enracinée
Exceptionsdéroulement jusqu'au cadre du gestionnairevérification du canal à chaque retourvérification du canal à chaque retour

La variante trampoline du modèle choisi réussit les mêmes exécutions (20 ms de récursion, 16 ms pour 10M d'appels terminaux en O2). Rejetées :

Coûts acceptés : un saut indirect par retour plus un aiguillage par switch ; les valeurs vivantes à travers les appels sont rechargées depuis les emplacements (elles y sont déjà stockées) ; les backtraces du débogueur natif ne montrent que la fonction courante, tandis que les traces de pile Erlang proviennent de la chaîne de cadres.

Preuves du prototype

run.py construit le modèle choisi (musttail et trampoline), la référence native et le modèle à coroutines pour l'hôte en O0 et O2, les exécute, et compile les fonctions abaissées à la main (generated.cpp, freestanding) pour chaque cible ci-dessus en O0 et O2, en comptant les appels musttail dans l'IR et les sauts terminaux dans l'assembleur. Résultat du 2026-10-05 : PASS. Pour le modèle choisi aux deux niveaux : retour (42), récursion de corps à 1 000 000 de profondeur, 10 000 000 d'appels terminaux, une erreur levée à la profondeur 100 000 capturée par un cadre de gestionnaire, la même erreur non capturée (cadre de fond, trace de 8 cadres fail), et deux processus s'entrelaçant par tranches de 4 000 réductions (1 002 tranches). Les 15 sites de transfert sont tous musttail en O0 sur chaque cible (14 en O2 après l'inlining).

Le corps de sum/1 pour i686-pc-windows-msvc (IR O2, noms raccourcis). L'IR x86_64-pc-windows-msvc est identique avec des mots i64 et des décalages doublés.

%2 = load ptr, ptr %0, align 4                      ; stack base
%3 = getelementptr inbounds nuw i8, ptr %0, i32 8
%4 = load i32, ptr %3, align 4                      ; current frame offset
%5 = getelementptr inbounds nuw [4 x i8], ptr %2, i32 %4
%6 = getelementptr inbounds nuw i8, ptr %5, i32 16  ; slot 0 (N)
%7 = getelementptr inbounds nuw i8, ptr %5, i32 8   ; header: resume index
%8 = load i32, ptr %7, align 4
%9 = icmp eq i32 %8, 0                              ; switch on resume
...
16:                                                 ; N > 0: call sum(N - 1)
  store i32 1, ptr %7, align 4                      ; resume = 1
  ...                                               ; x0 = N - 1
  %19 = tail call ptr @call(ptr %0, ptr @SUM)       ; push frame or park
  musttail call void %19(ptr nonnull %0)
  ret void
20:                                                 ; resume 1: x0 += N
  ...
  %24 = tail call ptr @leave(ptr %0)                ; pop, caller body
  musttail call void %24(ptr nonnull %0)
  ret void