« Fonctionnement d'un ordinateur/Architectures multiprocesseurs et multicœurs » : différence entre les versions

Aller à la navigation Aller à la recherche
m
aucun résumé des modifications
mAucun résumé des modifications
Rien ne vaut l'utilisation de plusieurs processeurs pour réellement tirer partieparti du parallélisme de taches. Les premières tentatives consistaient à relier plusieurs ordinateurs via un réseau local, ou à les placer sur la même carte mère. De nos jours, il est possible de placer plusieurs processeurs sur la même puce : on obtient un '''processeur multicœur''' (chaque processeur s’appelle un cœur). Suivant le nombre de cœurs présents dans notre processeur, celui-ci sera appelé un processeur dualdouble-corecoeur (deux cœurs), quadquadruple-corecoeur (4 cœurs), octooctuple-corecoeur (8 cœurs), etc.
 
==Types de multicœurs==
===Multicœurs asymétrique===
 
Le processeur CELL est un des exemples les plus récentrécents de processeur multicœurs asymétrique. Vous connaissez surement ce fameux processeur, et vous en possédez peut-être un chez vous. Évidemment, il ne faut pas chercher dans l'unité centrale de votre PC de bureau, non. Il faut chercher dans votre console de jeux : et oui, votre PS3 contient un processeur CELL. Pour simplifier, notre processeur CELL peut être vu comme intégrant un cœur principal POWER PC version 5, qu'on retrouvait autrefois dans les Mac, et environ 8 processeurs auxiliaires. Le processeur principal est appelé le PPE et les processeurs auxiliaires sont les SPE. Les SPE sont reliés à une mémoire locale de 256 kibioctets, le Local Store, qui communique avec le processeur principal via un bus spécial. Les SPE communiquent avec la RAM principale via des contrôleurs DMA. Les SPE possèdent des instructions permettant de commander leur contrôleur DMA et que c'est le seul moyen qu'ils ont pour récupérer des informations depuis la mémoire. Et c'est au programmeur de gérer tout ça ! C'est le processeur principal qui va envoyer aux SPE les programmes qu'ils doivent exécuter. Il va déléguer des calculs à effectuer aux SPE. Pour cela, notre processeur principal va simplement écrire dans le local store du SPE, et va lui envoyer une demande lui ordonnant de commencer l’exécution du programme qu'il vient d'écrire.
 
[[File:Cell Arch.png|centre|650px|Architecture du processeur CELL.]]
===Cluster multithreading===
 
Sur certains processeurs multicoeursmulticœurs, certains circuits sont partagés entre plusieurs cœurs. Cette technique consistant à ne pas dupliquer certains circuits et à en partager certains s'appelle le '''cluster multithreading'''. Cette technique est notamment utilisée sur les processeurs FX-8150 et FX-8120 d'AMD, et les autres processeurs basés sur l'architecture Bulldozer. Avec ces processeurs, tous les cœurs se partagent l'unité de calcul sur les nombres flottants (les nombres à virgule). Le partage de circuits permet d'éviter de dupliquer trop de circuits. Il est en effet évident qu'un seul circuit partagé entre tous les cœurs prendra moins de place et utilisera moins de composants électroniques que plusieurs circuits. Le problème est que ce partage est source de dépendances structurelles, ce qui peut entrainer des pertes de performances.
 
==Interruptions inter-processeurs==
 
QuelqueQuel que soit la technique de multicœur utilisée, les différents cœurs sont gérés par le système d'exploitation de l'ordinateur, avec l'aide d'interruptions inter-processeurs, des interruptions déclenchées sur un cœur et exécutées sur un autre. Pour générer des interruptions inter-processeur, le contrôleur d'interruption doit pouvoir rediriger des interruptions déclenchées par un processeur vers un autre.
 
===L'exemple du X86===
 
Les anciens PC incorporaient sur leur carté mère un contrôleur d'interruption crée par Intel, le 8259A, qui ne gérait pas les interruptions inter-processeurs. Pour gérer cette situation, les carte mères multiprocesseurs devaient incorporer un contrôleur spécial en complément. De nos jours, chaque cœur x86 possède son propre contrôleur d’interruption, le local APIC, qui s'occupe de gérer les interruptions en provenance ou arrivant vers ce processeur. On trouve aussi un IO-APIC, qui s'occupe de gérer les interruptions en provenance des périphériques et de les redistribuer vers les localAPIC APIClocaux. Ce IO-APIC s'occupe aussi de gérer les interruptions inter-processeurs en faisant passer les interruptions d'un local APIC vers un autre. Tous les localAPIC APIClocaux et l'IO-APIC sont reliés ensembles par un bus APIC spécialisé, par lequel ils vont pouvoir communiquer et s'échanger des demandes d'interruptions.
 
[[File:Contrôleurs d'interrptions sur systèmes x86 multicoeurs.png|centre|Contrôleurs d’interruptions sur systèmes x86 multicœurs.]]
==Partage des caches==
 
Quand on conçoit un processeur multicœursmulticœur, il ne faut pas oublier ce qui arrive à la pièce maitresse de tout processeur actuel : le cache ! Il est possible d'utiliser des '''caches dédiés''', à savoir que chaque cœur possède son propre cache, ou bien de partager un cache entre plusieurs coeurscœurs. Ces deux méthodes ont des inconvénients et des avantages.
 
{|
===Caches dédiés et partagés===
 
Avant toute chose, il faut savoir que la quantité de mémoire cache que l'on peut placer sur une surface donnée est limitée : on ne peut pas mettre autant de cache que l'on veut dans un processeur. Et le cache prend une très grande place dans notre processeur : environ la moitié, voire 60% des circuits de notre processeur servent à intégrer la mémoire cache ! Autant vous dire que le cache est une ressource précieuse. Et cela pose un problème pour les architectures qui utilisent des caches séparés pour chaque cœur : ceux-ci seront individuellement assez petits. Le principal défaut des architectures à cache dédiés vient de là. Si on exécute un programme prévu pour n'utiliser qu'un seul cœur, celui-ci n'aura accès qu'à un seul cache : celui dédié au cœur sur lequel il va s’exécuter. Alors qu'est-ce qui est le mieux : pleinsplein de caches plus petits, ou un unique cache aussi gros que la somme de tous les caches séparés ? Vu que la quantité de cache est limitée, on peut se demander s'il vaut mieux un petit cache pour chaque processeur ou un gros cache partagé. Un '''cache partagé''' répartit le cache de manière optimale : un programme gourmand peut utiliser autant de cache qu'il veut, laissant juste ce qu'il faut à l'autre programme. Par contre, plusieurs programmes peuvent entrer en compétition pour le cache, que ce soit pour y placer des données ou pour les accès mémoire. De plus, les caches partagés ont un temps d'accès nettement plus élevé.
 
[[File:Cache partagé contre cache dédié.png|centre|Cache partagé contre cache dédié]]
===Architecture hybride===
 
Dans la réalité, il faut nuancer un tout petit peu les choses : un processeur multicœursmulticœur ne contient pas qu'un seul cache, et on se retrouve avec une organisation assez hybride, dans laquelle certains caches sont partagés et pas d'autres. Généralement, on trouve deux à trois caches dans un processeur (multicœurs ou non) : le L1, le L2, et le L3. Le L2 et le L3 sont souvent partagés, tandis que le L1 n'est jamais partagé ! La raison à cela tient dans le fait que ce cache doit avoir une latence très faible, et que partager un cache n'est jamais vraiment innocent en termetermes de temps d'accès. Partager le cache L1 serait parfaitement possible, mais rendrait celui-ci tellement lent qu'on aurait l'impression que nos programmes tourneraient au ralenti. Par contre, rien n’empêche de partager les autres caches, comme le L2 ou le L3 sans trop pourrir leur temps d'accès.
 
On peut décider de partager un cache entre tous les cœurs, voire limiter ce partage à quelques cœurs particuliers pour des raisons de performances. Ainsi, rien n’empêche pour un processeur quad-core d'avoir deux caches L2, chacun partagés avec deux cœurs, et le cache L3 partagé entre tous les cœurs.
{|
|[[File:Dual Core Generic.svg|centre|vignette|upright=1.25|Partage des caches sur un double cœurs.]]
|[[File:Partage des caches sur un processeur multicoeurs.png|centre|vignette|upright=2.0|Partage des caches sur un processeur multicœursmulticœur.]]
|}
 
[[File:Cohérence des caches.png|centre|Cohérence des caches]]
 
Les caches partagés ne posent aucun problème de cohérence. Avec eux, une donnée n'est pas dupliquée en plusieurs exemplaires, mais n'est présente qu'une seule fois dans tout le cache. Ainsi, si on écrit celle-ci, on peut être sursûr que les autres processeurs liront la donnée mise à jour et non une ancienne version. Vous remarquerez que sur le schéma, la mémoire RAM contient encore une autre version de la donnée (du moins, si on utilise un cache Write Back). Mais cela ne pose pas de problème : les processeurs ne pourront pas accéder à la donnée en RAM, et iront toujours chercher la donnée dans le cache. Le cache est conçu pour. Au final, notre processeur aura donc toujours accès à la dernière valeur écrite par les autres processeurs.
 
[[File:Shared cache coherency.png|centre|Cohérence et caches partagés]]
Tout protocole de cohérence des caches doit répondre ce problème : comment les autres caches sont-ils au courant qu'ils ont une donnée périmée ? Pour cela, il existe deux solutions : l'espionnage du bus et l'usage de répertoires. Certains protocoles utilisent un '''répertoire''', une table de correspondance qui mémorise, pour chaque ligne de cache, toutes les informations nécessaires pour maintenir sa cohérence. Ces protocoles sont surtout utilisés sur les architectures distribuées : ils sont en effet difficiles à implémenter, tandis que leurs concurrents sont plus simples à implanter sur les machines à mémoire partagée.
 
Avec l’'''espionnage du bus''', les caches interceptent les écritures sur le bus (qu'elles proviennent ou non des autres processeurs), afin de lettre à jour la ligne de cache ou de la périmer. Avec ces protocoles, chaque ligne de cache contient des bits qui indique si la donnée contenue est à jour ou périmée. Quand on veut lire une donnée, les circuits chargés de lire dans le cache vont vérifier ces bits. Si ces bits indiquent que la ligne de cache est périmée, le processeur va lire les données depuis la RAM ou les autres caches et mettre à jour la ligne de cache. La mise à jour de ces bits de validité a lieu à chaque écriture (y compris les caches des autres coeurscœurs). Plus précisément, nos caches sont interconnectés entre eux, afin de maintenir la cohérence. Si un cache contient une donnée partagée, ce cache devra prévenir tous les autres caches qu'une donnée a été mise à jour. La mise à jour des données périmées peut être automatique ou basée sur une invalidation. Avec la '''mise à jour sur écriture''', les caches sont mis à jour automatiquement le plus tôt possible, avant même toute tentative de lecture. Avec l''''invalidation sur écriture''', toute écriture invalide les versions de la donnée dans les autres caches. Ces versions seront mises à jour quand le processeur les lira : il détecta que la ligne de cache a été invalidée et la mettra à jour si besoin.
 
===Protocole sans nouveaux états===
===Protocoles à état Exclusif===
 
Le protocole MSI n'est pas parfait : si un seul cache possède une donnée, on aura prévenu les autres caches pour rien en cas d'écriture. Ces communications sur le bus ne sont pas gratuites et en faire trop peutpeu ralentir fortement notre ordinateur. Pour régler ce problème, on a scindé l'état Shared en deux états : Exclusive si les autres processeurs ne possèdent pas de copie de la donnée, Shared sinon. Avec cette distinction, on peut éviter l'envoi de messages aux autres caches (ou aux circuits chargés de gérer la cohérence des caches) si on écrit dans une donnée marquée Exclusive : on sait que les autres caches ne possèdent pas de copie de la donnée, alors il ne sert à rien de prévenir inutilement. Le '''protocole MESI''' ainsi créé est identique au protocole MSI, avec quelques ajouts. Par exemple, si une donnée est chargée depuis la mémoire pour la première fois dans un cache, elle passe soit en Exclusive (les autres caches ne contenaient pas la donnée), soit en Shared (les autres caches en possèdent une copie). Une donnée marquée Exclusive peut devenir Shared si la donnée est chargée dans le cache d'un autre processeur.
 
{|
|[[File:Diagrama MESI.GIF|Diagramme du protocole MESI. Les abréviations PrRd et PrWr correspondent à des accès mémoire initiés par le processeur associé au cache, respectivement aux lectures et écritures. Les abréviations BusRd et BusRdx et Flush correspondent aux lectures, lectures exclusives ou écritures initiées par d'autres processeurs sur la ligne de cache.]]
|[[File:MESI State Transaction Diagram.svg|Autre description du protocleprotocole MESI.]]
|}
 
[[File:MOESI State Transaction Diagram.svg|centre|MOESI State Transaction Diagram]]
 
Cependant, celui-ci n'est pas le seul. On peut notamment citer le '''protocole MOSI''', une variante du MESI où l'état exclusiveexclusif est remplacé par l'état O.
 
{|
|}
 
Lors d'une lecture, le cache va vérifier si la lecture envoyée sur le bus correspond à une de ses donnéedonnées. Mais cette vérification va prendre du temps, et le processeur va devoir attendre un certain temps. Si au bout d'un certain temps, aucun cache n'a répondu, le processeur postule qu'aucun cache n'a la donnée demandée et va lire la donnée en mémoire. Ce temps est parfois fixé une fois pour toute lors de la création des processeurs, mais il peut aussi être variable, qui est géré comme suit :
 
* pour savoir si un cache contient une copie de la donnée demandée, chaque cache devra répondre en fournissant un bit ;
Pour avoir le bon résultat il y a une seule et unique solution : le processeur qui accède à la donnée doit avoir un accès exclusif à la donnée partagée. Sans cela, l'autre processeur ira lire une version de la donnée qui n'aura pas encore été modifiée par l'autre processeur. Dans notre exemple, un seul thread doit pouvoir manipuler notre compteur à la fois. Et bien sûr, cette réponse peut, et doit se généraliser à presque toutes les autres situations impliquant une donnée partagée. Chaque thread doit donc avoir un accès exclusif à notre donnée partagée, sans qu'aucun autre thread ne puisse manipuler notre donnée. On doit donc définir ce qu'on appelle une '''section critique''' : un morceau de temps durant lequel un thread aura un accès exclusif à une donnée partagée : notre thread est certain qu'aucun autre thread n'ira modifier la donnée qu'il manipule durant ce temps. Autant prévenir tout de suite : créer de telles sections critiques se base sur des mécanismes mêlant le matériel et le logiciel. Il existe deux grandes solutions, qui peuvent être soit codées sous la forme de programmes, soit implantées directement dans le silicium de nos processeurs.
 
Voyons la première de ces solutions : l''''exclusion mutuelle'''. Avec celle-ci, on fait en sorte qu'un thread puisse réserver la donnée partagée. Un thread qui veut manipuler cette donnée va donc attendre qu'elle soit libre pour la réserver afin de l'utiliser, et la libérera une fois qu'il en a fini avec elle. Si la donnée est occupée par un thread, tous les autres threads devront attendre leur tour. Pour mettre en œuvre cette réservation/dé-réservation, on va devoir ajouter un compteur à la donnée partagée, qui indique si la donnée partagée est libre ou déjà réservée. Dans le cas le plus simple, ce compteur vaudra 0 si la donnée est réservée, et 1 si elle est libre. Ainsi, un thread qui voudra réserver la donnée va d'abord devoir vérifier si ce nombre est à 1 avant de pouvoir réserver sa donnée. Si c'est le cas, il réservera la donnée en passant ce nombre à 0. Si la donnée est réservée par un autre thread, il devra tout simplement attendre son tour. On a alors créecréé ce qu'on appelle un verrou d'exclusion mutuelle, aussi appelé mutex.
 
[[File:Mutex.png|centre|Mutex]]
|}
 
Comme je l'ai dit plus haut, ces instructions empêchent tout autre processeur d'aller lire ou modifier la donnée qu'elles sont en train de modifier. Ainsi, lors de l’exécution de l'instruction atomique, aucun processeur ne peut aller manipuler la mémoire : notre instruction atomique va alors bloquer la mémoire et en réserver l'accès au bus mémoire rien que pour elle. Cela peut se faire en envoyant un signal sur le bus mémoire, ou pas d'autres mécanismes de synchronisation entre processeurs. Quoiqu'ilQuoi qu’il en soit, le cout de ce blocage de la mémoire est assez lourd : cela peut prendre un sacré bout de temps, ce qui fait que nos instructions atomiques sont lentes. Du moins, c'est le cas si la donnée est en mémoire et que le processeur est un peu stupide. En effet, sur certains processeurs, on peut optimiser le tout dans le cas où la donnée est dans le cache. Dans ce cas, pas besoin de bloquer la mémoire : le processeur a juste à écrire dans la mémoire cache, et les mécanismes de cohérence des caches se contenteront de mettre à jour la donnée de façon atomique automatiquement. Le cout des instructions atomiques est alors fortement amorti.
 
===Instructions LL/SC===
===Speculative Lock Elision===
 
Les instructions atomiques sont lentes, sans compter qu'elles sont utilisées de façon pessimistes : l'atomicité est garantie même si aucun autre thread n'accède à la donnée lue/écrite. Aussi, pour accélérer l'exécution des instructions atomiques, des chercheurs se sont penchés sur ce problème de réservations inutiles et ont trouvé une solution assez sympathique, basée sur la mémoire transactionnelle. Au lieu de devoir mettre un verrousverrou et de réserver notre donnée juste au cas où, on peut agir d'une façon un peu plus optimiste. Rien n’empêche de transformer nos instructions atomiques servant pour les réservations en instructions permettant de démarrer des transactions. Bien évidemment, les instructions atomiques servant à libérer la donnée partagée vont marquer la fin de notre transaction. Ce mécanisme tente donc de se passer des instructions atomiques en les transformant en transaction une première fois, puis revient à la normale en cas d'échec. Il s'appelle le '''Speculative Lock Elision'''.
 
Ainsi, on n’exécute pas l'instruction atomique permettant d'effectuer une réservation, et on la transforme en une simple instruction de lecture de la donnée partagée. Une fois cela fait, on commence à exécuter la suite du programme en faisant en sorte que les autres processeurs ne voient pas les modifications effectuées sur nos données partagées. Puis, vient le moment de libérer la donnée partagée via une autre instruction atomique. A ce moment, si aucun autre thread n'a écrit dans notre donnée partagée, tout ira pour le mieux : on pourra rendre permanents les changements effectués. Par contre, si jamais un autre thread se permet d'aller écrire dans notre donnée partagée, on doit annuler les changements faits. A la suite de l'échec de cette exécution optimiste, cette transaction cachée, le processeur reprendre son exécution au début de notre fausse transaction, et va exécuter son programme normalement : le processeur effectuera alors de vraies instructions atomiques, au lieu de les interpréter comme des débuts e transactions.
===L'exemple avec le x86===
 
Autre exemple, on peut citer les processeurs Intel récents. Je pense notamment aux processeurs basés sur l’architecture Haswell. Au alentoursalentour de mars 2013, de nouveaux processeurs Intel sortiront sur le marché : ce seront les premiers processeurs grand public qui supporteront la mémoire transactionnelle matérielle. Attardons-nous un peu sur ces processeurs, et sur l'implémentation de la mémoire transactionnelle matérielle de ces processeurs. Sur ces processeurs, deux modes sont disponibles pour la mémoire transactionnelle matérielle : le mode TSX, et le mode HLE.
 
Le mode TSX correspond simplement à quelques instructions supplémentaires permettant de gérer la mémoire transactionnelle matérielle. On trouve ainsi trois nouvelles instructions : XBEGIN, XEND et XABORT. XBEGIN sert en quelque sorte de top départ : elle sert à démarrer une transaction. Toutes les instructions placées après elles dans l'ordre du programme seront ainsi dans une transaction. Au cas où la transaction échoue, il est intéressant de laisser le programmeur quoi faire. Pour cela, l'instruction XBEGIN permet au programmeur de spécifier une adresse. Cette adresse permet de pointer sur un morceau de code permettant de gérer l'échec de la transaction. En cas d'échec de la transaction, notre processeur va reprendre automatiquement son exécution à cette adresse. Évidemment, si on a de quoi marquer le début d'une transaction, il faut aussi indiquer sa fin. Pour cela, on utilise l'instruction XEND. XABORT quandquant à elle, va servir à stopper l’exécution d'une transaction : elle sert à faire planter notre transaction si jamais on s’aperçoit d'un problème lors de l’exécution de notre transaction. Lors de la fin d'une transaction, le processeur va automatiquement reprendre à l'adresse indiquée par XBEGIN, et va remmettre le processeur dans l'état dans lequel il était avant le début de la transaction : les registres modifiés par la transaction sont remis dans leur état initial, à une exception prêt : EAX. Celui-ci sert à stocker un code d'erreur qui indique les raisons de l'échec d'une transaction. Cela permet de donner des informations au code de gestion d'échec de transaction, qui peut alors gérer la situation plus finement.
HLE
 
Les processeurs Haswall supportent aussi le Lock Elision. Les instructions atomiques peuvent ainsi supporter l’exécution en tant que transaction à une condition : qu'on leur rajoute un préfixe. Le préfixe, pour les instructions x86, correspond simplement à un octet optionnel, placé au début de notre instruction dans la mémoire. Cet octet servira à donner des indications au processeur, qui permettront de modifier le comportement de notre instruction. Par exemple, les processeurs x86 supportent pas mal d'octets de préfixe : LOCK, qui permet de rendre certaines instructions atomiques, ou REPNZE, qui permet de répéter certaines instructioninstructions tant qu'une condition est requise. Le fait est que certains préfixes n'ont pas de signification pour certaines instructions : les placer devant ces instructions n'a alors pas de sens. Autrefois, ils étaient totalement ignorés, et le processeur ne tenait pas compte de ces préfixes sans signification. Pour supporter le Lock Elision, ces préfixes sans significations sont réutilisés histoire de dire au processeur : cetcette instruction atomique doit subir la Lock Elision et doit être tentée en tant que transaction. Deux "nouveaux" préfixes font leur apparition : XAQUIRE qui sert à indiquer que notre instruction atomique doit être tentée en tant que transaction ; et XRELEASE qui dit que la transaction spéculative est terminée. Ainsi, un programme peut être conçu pour utiliser la Lock Elision, tout en fonctionnant sur des processeurs plus anciens, qui ne la supportent pas ! Belle tentative de garder la rétrocompatibilité.
 
==Consistance mémoire==
|-
!No Store Ordering
|Toutes les réorganisations possibles entre lectures et écritures sont autorisées, tant qu'elleelles se font sur des données différentes. On peut parfaitement démarrer une lecture pendant que d'autres sont en attente ou en cours.
|}
 
===L'exemple du modèle mémoire x86===
 
Après avoir vu la théorie, passons maintenant à la pratique. Dans cette partie, on va voir les modèles de consistances utilisés sur les processeurs x86, ceux qu'on retrouve dans nos PC actuels. Le modèle de consistance des processeurs x86 a varié au cours de l'existence de l'architecture : un vulgaire 486DX n'a pas le même modèle de consistance qu'un Core 2 duo, par exemple. Quoiqu'ilQuoi qu’il en soit, les modèles de consistance des processeurs x86 ont toujours étés assez forts, avec pas mal de restrictions. Si on compare aux autres processeurs, le x86 est assez strict. Bref, le premier modèle de consistance utilisé sur les processeurs x86 est apparu sur les premiers processeurs x86 et est resté en place sur tous les processeurs de marque Pentium. Ce modèle est assez simple : hormis une exception, tout doit se passer comme si le processeur accédait à la mémoire dans l'ordre des opérations de notre programme. Cette exception concerne les lectures : dans certains cas, on peut les exécuter avant certaines écritures, sous réserve que les conditions suivantes soient respectées :
 
* ces écritures doivent se faire dans la mémoire cache ;
* des lectures peuvent être déplacées avant des écritures, si ces écritures et la lecture ne se font pas au même endroit, à la même adresse.
 
Dans cette liste, j'ai mentionné le fait que les écritures en mémoire peuvent changer dans certains cas exceptionnels. Ces cas exceptionnels sont les écritures effectuées par les instructions de gestion de tableaux et de chaines de caractères, ainsi que certaines instructions SSE. Ces instructions SSE sont les instructions qui permettent d'écrire des données sans passer par le cache, mentionnées il y a quelques chapitres. Ce sont donc les instructions MOVNTI, MOVNTQ, MOVNTDQ, MOVNTPS, MOVNTPD. Mais ce ne sont pas les seules : le x86 possède quelques instructions permettant de travailler directement sur des chaines de caractères ou des tableaux : ce sont les instructioninstructions REPMOVSD, REPSCACB, et bien d'autres encore. EtEh bien sachez que les écritures effectuées dans ces instructions peuvent se faire dans un désordre complet (ou presque).
 
<noinclude>
40 599

modifications

Menu de navigation