J’ai donc cette API coroutine, étendue par moi, basée sur le code que j’ai trouvé ici: https://the8bitpimp.wordpress.com/2014/10/21/coroutines-x64-and-visual-studio/
struct mcontext { U64 regs[8]; U64 stack_pointer; U64 return_address; U64 coroutine_return_address; }; struct costate { struct mcontext callee; struct mcontext caller; U32 state; }; void coprepare(struct costate **token, void *stack, U64 stack_size, cofunc_t func); /* C code */ void coenter(struct costate *token, void *arg); /* ASM code */ void coyield(struct costate *token); /* ASM code */ int coresume(struct costate *token); /* ASM code, new */
Je suis bloqué sur la mise en œuvre de coyield (). coyield () peut être écrit en C, mais c’est l’assemblage avec lequel j’ai des problèmes. Voici ce que j’ai eu jusqu’à présent (syntaxe MASM / VC ++).
;;; function: void _yield(struct mcontext *callee, struct mcontext *caller) ;;; arg0(RCX): callee token ;;; arg2(RDX): caller token _yield proc lea RBP, [RCX + 64 * 8] mov [RCX + 0], R15 mov [RCX + 8], R14 mov [RCX + 16], R13 mov [RCX + 24], R12 mov [RCX + 32], RSI mov [RCX + 40], RDI mov [RCX + 48], RBP mov [RCX + 56], RBX mov R11, RSP mov RSP, [RDX + 64] mov [RDX + 64], R11 mov R15, [RDX + 0] mov R14, [RDX + 8] mov R13, [RDX + 16] mov R12, [RDX + 24] mov RSI, [RDX + 32] mov RDI, [RDX + 40] mov RBP, [RDX + 48] mov RBX, [RDX + 56] ret _yield endp
Ceci est une adaptation directe du code de 8bitpimp. Si je comprends bien ce code, ce qu’il ne fait pas est mis mcontext-> return_address et mcontext-> coroutine_return_address sur la stack à faire apparaître par le ret. Aussi, est-ce rapide? IIRC, il provoque une discordance sur le prédicteur de twig de retour trouvé dans les éléments x64 modernes.
Cette réponse ne concerne que la partie “est-ce rapide” de la question.
Tout d’abord, une brève description du comportement d’un prédicteur d’adresse de retour typique .
call
est effectué, l’adresse de retour qui est insérée dans la stack est également stockée dans une structure de la CPU appelée mémoire tampon d’adresse de retour ou quelque chose du genre. L’effet est de prédire parfaitement les paires call
/ ret
, à condition qu’elles se produisent dans leur motif habituel correctement nested et que ret
supprime réellement l’adresse de retour non modifiée poussée par call
dans chaque cas. Pour plus de détails, vous pouvez commencer ici .
Les appels de fonction normaux en C ou C ++ (ou à peu près dans n’importe quel autre langage) suivront généralement toujours ce modèle correctement nested 2 . Vous n’avez donc pas besoin de faire quelque chose de spécial pour tirer parti de la prévision de retour.
Dans les cas où les call
/ ret
ne sont pas appariés normalement, les prédictions peuvent échouer (au moins) de deux manières différentes:
ret
ne retourne pas l’endroit où l’ call
correspondant a été poussé, vous obtiendrez un échec de prédiction de cible de twig pour ce ret
, mais les instructions ret
normalement nestedes continueront. pour prédire correctement tant qu’ils sont correctement nesteds. Par exemple, si at function ajoute quelques octets à la valeur [rsp]
pour ignorer l’instruction qui suit l’ call
de la fonction appelante, le prochain [rsp]
de manière [rsp]
, mais le ret
suivant dans la fonction doit être bien. call
et ret
ne sont pas correctement nestedes, le tampon de prédiction de retour entier peut devenir mal aligné, ce qui entraîne d’éventuelles instructions ret
, qui utilisent les valeurs existantes pour prédire de manière erronée la version 2.5 . Par exemple, si vous call
une fonction mais que vous utilisez ensuite jmp
pour revenir à l’appelant, il y a un call
incompatible sans ret
. Le ret
à l’intérieur de l’appelant sera mal interprété, de même que le ret
dans l’appelant de l’appelant, et ainsi de suite, jusqu’à ce que toutes les valeurs mal alignées soient utilisées ou écrasées 3 . Un cas similaire se produirait si un ret
ne correspondait pas à un appel correspondant (et ce cas est important pour l’parsing ultérieure). Plutôt que les deux règles ci-dessus, vous pouvez également déterminer simplement le comportement du prédicteur de retour en effectuant un suivi dans le code et en indiquant à quoi ressemble la stack de résultats à chaque point. Chaque fois que vous recevez une instruction ret
, voyez si elle retourne au sumt actuel de la stack de retours. Sinon, vous obtiendrez une mauvaise prédiction.
Le coût réel d’une mauvaise prédiction dépend du code environnant. Un chiffre de ~ 20 cycles est couramment donné et est souvent observé dans la pratique, mais le coût réel peut être inférieur: par exemple, aussi bas que zéro si le CPU est capable de résoudre la mauvaise prédiction tôt et de commencer à chercher le long chemin sans interrompre chemin critique ou supérieur: par exemple, si les échecs de prédiction de twig prennent beaucoup de temps à résoudre et réduisent le parallélisme effectif des opérations à longue latence. Quoi qu’il en soit, on peut dire que la sanction est généralement importante lorsqu’elle survient dans le cadre d’une opération qui ne prend que quelques instructions.
La fonction existante _yield
(context switch) permute le pointeur de stack rsp
et utilise ensuite ret
pour retourner à un emplacement différent de celui que l’appelant a réellement poussé (en particulier, il retourne à l’emplacement qui a été placé sur la stack d’appels lorsque l’appelant a appelé auparavant ). Cela provoquera généralement une mauvaise prédiction au niveau de la ret
dans _yield
.
Par exemple, considérons le cas où une fonction A0
effectue un appel de fonction normal à A1
, qu’elle appelle ensuite coresume
4 pour reprendre une coroutine B1
, qui appelle ensuite coyield
pour revenir à A1
. Dans l’appel à coresume
, la stack de retour ressemble à A0, A1
, mais coresume
permute ensuite rsp
de pointer sur la stack pour B1
et la valeur supérieure de cette stack est une adresse dans B1
immédiatement après coyield
dans le code de B1
. Le ret
inside inside coresume
saute donc à un sharepoint B1
et non à un sharepoint A1
comme le prévoit la stack de retour. Par conséquent, vous obtenez une mauvaise prédiction sur ce ret
et la stack de retour ressemble à A0
.
Examinons maintenant ce qui se passe lorsque B1
appelle coyield
, qui est implémenté de la même manière que coresume
: l’appel de coyield
pousse B1
sur la stack de retour qui ressemble maintenant à A0, B1
, puis remplace la stack pour pointer vers la stack A1
, puis ret
qui reviendra à A1
. Ainsi, la mauvaise prédisposition se produira de la même manière, et la stack est laissée en tant que A0
.
La mauvaise nouvelle est donc qu’une série serrée d’appels à coresume
et à coyield
(comme cela est typique avec un iterator basé sur le rendement, par exemple), sera à chaque fois erronée. La bonne nouvelle est que maintenant dans A1
au moins la stack de retour est correcte (et non désalignée) – si A1
retourne à l’appelant A0
, le retour est correctement prédit (et ainsi de suite lorsque A0
renvoie à l’ appelant, etc.). Vous subissez donc une pénalité imprévisible à chaque fois, mais au moins vous ne désalignez pas la stack de retour dans ce scénario. L’importance relative de cela dépend de la fréquence à laquelle vous appelez coresume
/ coyield
par rapport aux fonctions coyield
normalement dans la partie inférieure de la fonction qui appelle coresume
.
Alors, pouvons-nous réparer l’erreur de prédiction? Malheureusement, il est délicat de combiner les appels C externes et ASM, car appeler coresume
ou coyield
implique un appel inséré par le compilateur et il est difficile de le décompresser dans l’asm.
Essayons quand même.
Une approche consiste à utiliser la ret
du tout et à n’utiliser que des sauts indirects.
C’est-à-dire qu’il suffit de remplacer le ret
à la fin de vos coresume
et coyield
par:
pop r11 jmp r11
Cela équivaut fonctionnellement à ret
, mais affecte le tampon de la stack de retour différemment (en particulier, cela ne l’affecte pas).
Si nous analysons la séquence répétée d’ coresume
et coyield
comme ci-dessus, nous obtenons le résultat suivant: la mémoire tampon de la stack de retour commence à croître indéfiniment comme A0, A1, B1, A1, B1, ...
Cela se produit car en fait, nous n’utilisons pas du tout la ret
dans cette implémentation. Donc, nous ne souffrons pas de mauvaises prédictions de retour, car nous n’utilisons pas de ret
! Au lieu de cela, nous nous fions à la précision du prédicteur de twig indirecte pour prédire jmp11
.
Le fonctionnement de ce prédicteur dépend de la manière dont coresume
et coyeild
sont implémentés. S’ils appellent tous deux une fonction _yield
partagée qui n’est pas en ligne, il n’y a qu’un seul emplacement jmp r11
et ce jmp
ira alternativement vers un emplacement situé en A1
et B1
. La plupart des prédicteurs indirects modernes reprochent cette simple répétition, bien que ceux plus anciens qui ne suivent qu’un seul lieu ne le feront pas. Si _yield
est _yield
dans coresume
et coyield
ou si vous copiez simplement le code dans chaque fonction, il existe deux sites d’appel jmp r11
distincts, chacun ne jmp r11
qu’un seul emplacement et devant être bien prédits par tout processeur doté d’un paramètre indirect. prédicteur de twig 6 .
Donc, cela devrait généralement prédire une série d’appels coyield
pour coyield
et coresume
bien 7 , mais au prix de l’effacement du tampon de retour, donc, lorsque A1
décide de retourner à A0
il sera prédit de manière erronée, ainsi que les retours ultérieurs de A0
et ainsi de suite. La taille de cette pénalité est limitée ci-dessus par la taille de la mémoire tampon de la stack de retour. Par conséquent, si vous effectuez de nombreux appels de coresume/yield
restreints, cela peut constituer un bon compromis.
C’est ce qui me convient le mieux compte tenu de la contrainte des appels externes aux fonctions écrites en ASM, car vous avez déjà un call
implicite pour vos co
routines, et vous devez faire le saut vers l’autre couroutine depuis l’intérieur et je ne peux pas. voyez comment maintenir les stacks en équilibre et retourner au bon endroit avec ces contraintes.
Si vous pouvez utiliser le code en ligne sur le site d’appel de vos méthodes de cours (par exemple, avec le support du compilateur ou inline asm), vous pourrez peut-être faire mieux.
L’appel à coresume
pourrait être en ligne comme ceci (j’ai omis d’enregistrer et de restaurer le code dans le registre parce que c’est simple):
; rcx - current context ; rdc - context for coroutine we are about to resume ; save current non-volatile regs (not shown) ; load non-volatile regs for dest (not shown) lea r11, [rsp - 8] mov [rcx + 64], r11 ; save current stack pointer mov r11, [rdx + 64] ; load dest stack pointer call [r11]
Notez que coresume
ne fait pas réellement l’échange de stack – il charge simplement la stack de destination dans r11
puis effectue un call
contre [r11]
pour passer à la coroutine. Cela est nécessaire pour que l’ call
pousse correctement l’emplacement vers lequel nous devrions retourner sur la stack de l’appelant.
Ensuite, coyield
ressemblerait à quelque chose comme (intégré dans la fonction appelante):
; save current non-volatile regs (not shown) ; load non-volatile regs for dest (not shown) lea r11, [after_ret] push r11 ; save the return point on the stack mov rsp, [rdx + 64] ; load the destination stack ret after_ret: mov rsp, r11
Lorsqu’un appel de coresume
saute dans la coroutine, il se termine avec after_ret
. Avant d’exécuter le code utilisateur mov rsp, r11
instruction mov rsp, r11
par la stack appropriée pour la coroutine r11
dans r11
par coresume
.
Donc, essentiellement coyield
a deux parties: la moitié supérieure exécutée avant le rendement (ce qui se produit lors du rappel) et la moitié inférieure qui complète le travail commencé par coresume
. Cela vous permet d’utiliser call
comme mécanisme pour effectuer le saut de coresume
et ret
de faire le saut de coyield
. Les call
/ ret
sont équilibrés dans ce cas.
J’ai passé en revue certains détails de cette approche: par exemple, comme il n’y a pas d’appel de fonction, les registres non volatils spécifiés par ABI ne sont pas vraiment spéciaux: dans le cas d’un assemblage en ligne, vous devez l’indiquer au Le compilateur détermine les variables que vous allez graver et économiser le rest, mais vous pouvez choisir le jeu qui vous convient le mieux. Le choix d’un plus grand ensemble de variables coresume
coyield
séquences de codes coresume
/ coyield
, mais peut potentiellement exercer une pression supplémentaire sur le code environnant et peut forcer le compilateur à en renverser davantage. L’idéal serait peut-être de déclarer tout ce qui est enrayé et que le compilateur répande ce dont il a besoin.
1 Bien sûr, il existe des limitations dans la pratique: la taille de la mémoire tampon de la stack de retour est probablement limitée à un petit nombre (16 ou 24, par exemple); ainsi, une fois la profondeur de la stack des appels dépassée, certaines adresses de retour sont perdues et gagnées ‘ t être correctement prédit. En outre, divers événements tels qu’un changement de contexte ou une interruption sont susceptibles de perturber le prédicteur de retour de stack.
2 Une exception intéressante était un motif commun pour la lecture du pointeur d’instruction en cours dans un code x86 (32 bits): il n’y a pas d’instruction pour le faire directement, donc à la place un call next; next: pop rax
call next; next: pop rax
séquence call next; next: pop rax
peut être utilisée: un call
à l’instruction suivante qui sert uniquement à transmettre l’adresse de la stack qui a été éjectée. Il n’y a pas de correspondant correspondant. Les processeurs actuels reconnaissent toutefois ce modèle et ne déséquilibrent pas le prédicteur d’adresse de retour dans ce cas particulier.
2.5 Le nombre de prédictions erronées que cela implique dépend de la manière dont net peut renvoyer la fonction appelante: si elle commence immédiatement à appeler une autre chaîne d’appels approfondie, les entrées de stack de retour mal alignées pourraient ne jamais être utilisées du tout, par exemple.
3 Ou, peut-être, jusqu’à ce que la stack d’adresses de retour soit réalignée par un ret
sans appel correspondant, un cas de “deux torts font droit”.
4 Vous n’avez pas réellement montré comment coyield
et coresume
appellent réellement _yield
. Par conséquent, pour le rest de la question, je suppose qu’ils sont mis en œuvre essentiellement comme _yield
, directement dans coyield
ou coresume
sans appeler _yield
: c’est-à-dire copier-coller le code _yield
dans chaque fonction, possible avec quelques petites modifications pour tenir compte de la différence. Vous pouvez également effectuer ce travail en appelant _yield
, mais vous disposez alors d’une couche supplémentaire d’appels et de rets qui complique l’parsing.
5 Dans la mesure où ces termes ont même un sens dans une implémentation symésortingque d’une couroutine, puisqu’il n’ya en fait aucune notion absolue d’appelant et d’appelé dans cette affaire.
6 Bien entendu, cette parsing s’applique uniquement au cas simple où vous avez un seul appel coresume
appelant dans une coroutine avec un seul appel coyield
. Des scénarios plus complexes sont possibles, tels que plusieurs appels coresume
dans l’appelé ou plusieurs appels internes dans l’appelant (éventuellement vers des lignes de cours différentes). Toutefois, le même schéma s’applique: le cas des sites divisés jmp r11
présentera une vapeur plus simple que le cas combiné (éventuellement au prix de ressources supplémentaires de l’iBTB).
7 Une exception serait le premier ou les deux premiers appels: le prédicteur ret
ne nécessite aucun “échauffement”, mais le prédicteur de twig indirect peut le faire, en particulier lorsqu’un autre coroutine a été appelé entre-temps.