var
X
=
0
if
:
set
X
=
42
# ceci est annulé en raison de
# la prochaine ligne
X
>
100
# ceci échoue et entraîne une annulation
X
=
0
# X revient à 0 grâce à l'annulation
set
dans Verse. Nous aurions pu par exemple appeler une fonction C++, qui modifiait une variable mutable avec C++ (en l'entreposant dans un pointeur), avant d'utiliser d'autres fonctions C++ pour lire cette variable mutable :SetSomething
(
0
)
# implémenté en C++, définit
# une variable C++
if
:
SetSomething
(
42
)
# ceci est annulé en raison de
# la prochaine ligne
GetSomething
()
>
100
# ceci échoue et entraîne une annulation
GetSomething
()
=
0
# revient à 0 grâce à l'annulation
if
) sont des transactions imbriquées qui sont validées si la condition est remplie, mais s'annulent dans le cas inverse. Les expressions conditionnelles peuvent avoir des effets. Si la condition échoue, c'est comme si les effets n'avaient jamais eu lieu.<no_rollback>
, qui deviendra obsolète dès que le problème sera résolu.<no_rollback>
) et entraîne des plantages difficiles à déboguer, comme quand un gestionnaire d'annulation est enregistré pour une action qui est supprimée.void
Foo
()
{
TArray
<int> Array
=
{
1
,
2
,
3
};
AutoRTFM::
Transact
(
[
&
] ()
{
Array
.
Push
(
42
);
//
Ici la matrice est {1, 2, 3, 42}.
AutoRTFM::
Abort
();
});
//
Ici la matrice est {1, 2, 3} en raison d'Abort.
}
TArray
. Cela fonctionne même si TArray
doit réallouer son stockage dans Push
. TArray
n'est en rien une exception. AutoRTFM peut faire la même chose pour std::vector
/std::map
et de nombreuses autres structures de données et fonctions de l'Unreal Engine et de la bibliothèque standard C++. Les seuls changements nécessaires pour que cela fonctionne sont l'intégration à AutoRTFM d'allocateurs de mémoire de bas niveau et le nettoyage de mémoire (GC), et comme nous le verrons dans cette partie, ce sont des changements mineurs (il s'agit de l'ajout d'une poignée de lignes que nous avons ajoutées aux nombreux malloc de l'UE, à son nettoyage de mémoire, et à la fonction malloc et à l'opérateur new du système). Qui plus est, AutoRTFM effectue l'opération sans aucune perte de performances pour les chemins de code qui s'exécutent hors transaction (donc en dehors de l'étendue dynamique d'un appel Transact
). En résumé, même si un chemin de code (comme TArray
entre autres) est utilisé à l'intérieur de transactions, ceux qui sont utilisés en dehors des transactions n'engendrent pas de coût. Quand du code s'exécute dans une transaction, le coût en performances est élevé (jusqu'à 4 fois plus élevé d'après nos tests de performances réalisés en interne), mais pas suffisamment pour impacter la vitesse de ticks du serveur de Fortnite.AutoRTFMPass
est d'instrumenter tout le code pour qu'il obéisse à la sémantique transactionnelle de Verse. Mais dans Fortnite, le même code peut être utilisé à des endroits où il n'y a pas de code Verse dans la pile et où la sémantique Verse est donc inutile, ou encore à des endroits où du code Verse est dans la pile et où une annulation est possible.bCurrentThreadIsInTransaction
est encodée dans le compteur ordinal. Le rôle du compilateur est simple : il clone l'intégralité de votre code, attribue des noms décorés aux fonctions clonées et n'instrumente ensuite que les clones. Le code d'origine n'est jamais instrumenté, consigné dans un journal ou soumis à une autre action spécifique, et son exécution n'engendre donc aucun coût. Le code cloné bénéficie de l'instrumentation et sait qu'il fait partie d'une transaction, ce qui nous permet même d'appliquer des astuces de machine virtuelle, comme jouer avec des conventions de nommage différentes.AutoRTFMPass
s'exécute au même moment dans le pipeline LLVM que les assainisseurs : après les optimisations de représentation intermédiaire et juste avant l'envoi du code au back-end. Ceci a des répercussions importantes, comme le fait que même dans les clones instrumentés, seuls les charges et les stockages qui ont survécu à l'optimisation bénéficient de l'instrumentation. Ainsi, si l'assignation à une variable locale de non-échappement en C++ peut être qualifiée de "stockage" au sens abstrait (et Clang le représenterait de cette façon lors de la création de la représentation intermédiaire LLVM non optimisée pour ce programme), le stockage aurait totalement disparu au moment où AutoRTFMPass
agirait. Il serait sans doute remplacé par un flux de données SSA auquel AutoRTFM n'a pas besoin de toucher. Nous avons également implémenté quelques optimisations s'appuyant sur des analyses statiques LLVM existantes pour éviter d'instrumenter des stockages quand cela n'est pas nécessaire.AutoRTFM::Transact
. En appelant cette fonction avec une lambda, l'exécution établit un objet de transaction qui contient les structures de données du journal ainsi que d'autres états de transaction. Transact
appelle ensuite le clone de la lambda. Il est parfaitement identique à la lambda, mais bénéficie de l'instrumentation transactionnelle et obéit à l'API AutoRTFM.Transact
quand Verse entre dans un contexte d'échec, comme une condition "if". À l'avenir, nous appellerons Transact
avant toute exécution de Verse.malloc/free
et les fonctions liées (comme les allocateurs personnalisés).WriteFile
dans une transaction est donc une erreur par défaut. C'est un comportement par défaut souhaitable, puisque WriteFile
ne peut pas être annulé de façon fiable. Nous aurions beau essayer d'annuler l'écriture ultérieurement, un autre thread (dans notre processus ou un autre) aurait pu assister à l'écriture et effectuer une action irrécupérable en conséquence. Il est donc pratique de prendre un programme C++ existant, de le compiler avec AutoRTFM et d'essayer d'effectuer un Transact
. Soit l'opération fonctionnera, soit vous obtiendrez une erreur indiquant que vous avez appelé une fonction non transaction-safe ainsi qu'un message expliquant comment trouver cette fonction et son site d'appel.malloc
et ses fonctions reliées. AutoRTFM prend le parti de ne pas rendre l'implémentation de malloc
transactionnelle, mais de forcer les appelants à englober les appels à malloc
avec l'API AutoRTFM. C'est d'autant plus simple dans des programmes comme l'Unreal Engine, où une API de façade s'occupe déjà d'englober l'implémentation de malloc
. Pour mieux comprendre ces actions, regardons le code. Avant AutoRTFM, FMemory::Malloc
ressemblait à ceci :void*
FMemory::
Malloc
(SIZE_T
Count
, uint32
Alignment
)
{
return
GMalloc
->
Malloc
(Count, Alignment);
}
void*
FMemory::
Malloc
(SIZE_T
Count
, uint32
Alignment
)
{
void*
Ptr;
UE_AUTORTFM_OPEN
(
{
Ptr =
GMalloc
->
Malloc
(Count, Alignment);
});
AutoRTFM::
OnAbort
([
Ptr
]
{
Free
(Ptr);
});
return
AutoRTFM::
DidAllocate
(Ptr, Count);
}
UE_AUTORTFM_OPEN
pour déclarer que le bloc délimité (qui est converti en lambda) doit s'exécuter sans instrumentation de transaction. C'est une fonction simple à implémenter pour l'exécution et le compilateur : elle signifie juste que nous lançons un appel vers la version d'origine (non clonée) de la lambda. GMalloc->Malloc
s'exécute donc dans la transaction, mais sans instrumentation transactionnelle. Nous appelons cela l'"exécution du code ouvert" (par opposition à l'"exécution du code fermé", qui désigne l'exécution du clone rendu transactionnel). Après le retour de UE_AUTORTFM_OPEN
, l'appel de malloc doit s'être exécuté immédiatement jusqu'au bout sans qu'aucune annulation n'ait été enregistrée. On conserve ainsi la sémantique transactionnelle, car malloc est déjà thread-safe, et le fait que ce thread ait effectué un malloc n'est pas observable par les autres threads avant que le pointeur vers l'objet visé par l'allocation de mémoire fuie de la transaction. Le pointeur ne fuira pas de la transaction tant que nous n'aurons pas validé, puisque les seuls endroits où Ptr
peut être stocké sont soit une autre mémoire nouvellement allouée, soit une mémoire qu'AutoRTFM rendra transactionnelle en consignant l'écriture.AutoRTFM::OnAbort
. Si la transaction est validée, ce gestionnaire ne s'exécute pas et l'objet visé par malloc survit. Mais si la transaction est interrompue, l'objet est libéré. Cela garantit que l'abandon n'engendre pas de fuite de mémoire.OnAbort
, puisque nous n'avons pas à craindre qu'AutoRTFM essaie d'écraser le contenu de l'objet pour revenir à un état précédent alors que nous l'avons déjà libéré. De plus, il s'agit d'une optimisation utile.UE_AUTORTFM_OPEN
exécute uniquement le bloc de code.AutoRTFM::OnAbort
est une no-op (une instruction nulle) ; la lambda transmise est ignorée.AutoRTFM::DidAllocate
est une no-op.void
FMemory::
Free
(
void*
Original
)
{
UE_AUTORTFM_ONCOMMIT
(
{
GMalloc
->
Free
(Original);
});
}
UE_AUTORTFM_ONCOMMIT
a le comportement suivant :
Open/OnCommit/OnAbort
, nous disposons de tout ce dont nous avons besoin pour exécuter du code C++ dans une transaction, tant que ce code n'utilise pas d'opérations atomiques, de verrous, d'appels système ou d'appels vers des parties de la base de code de l'UE que nous avons choisi de ne pas rendre transactionnelles (comme les moteurs de rendu et physique, à l'heure actuelle). Au total, cela a engendré 94 utilisations des API Open
dans tout Fortnite. Nous avons notamment englobé du code existant avec Open
et consorts au bon niveau de granularité plutôt que de modifier les algorithmes ou les structures de données.<no_rollback>
afin que plus de code puisse être appelé avec les fonctions
if
et for
. Cet effet (qui pollue de nombreuses signatures de fonction) existe uniquement, car une grande partie du code C++ ne peut pas être transactionnelle. Mais AutoRTFM résout ce problème.