La section 2 du livre jaune décrit Ethereum comme une machine à états pilotée par les transactions. Les conventions de notation de cette formalisation sont les suivantes : σ désigne l'état global, T une transaction, Υ la fonction de transition d'état au niveau d'une transaction qui applique une transaction à l'état ; B désigne un bloc, Π la fonction de transition au niveau du bloc, et les transactions du bloc sont notées dans l'ordre T₀, T₁… Sur cette base, le livre jaune donne deux formules : la transition d'état d'une transaction isolée est σ_{t+1} ≡ Υ(σ_t, T), et la transition au niveau du bloc est Π(σ, B) ≡ Υ(Υ(σ, T_0), T_1)…. La seconde mérite qu'on s'y attarde : elle écrit l'exécution de tout le bloc comme un appel de fonctions imbriqués, l'entrée de la deuxième transaction étant l'état obtenu après exécution de la première.
L'ordre d'évaluation fait donc partie de la spécification, et le client n'a pas la latitude de le choisir lui-même. Restent trois questions : pourquoi la définition ne peut être que celle-ci, ce que l'on perdrait à autoriser l'évaluation simultanée de deux transactions, et comment ce choix enferme le débit de l'EVM dans un seul thread. Le présent article ne traite que cette couche, à savoir le point d'origine de conception.
L'ordre est écrit dans la spécification, il n'est pas une liberté d'ordonnancement laissée au client
La formulation du livre jaune circonscrit ce que doit faire la couche d'exécution : étant donné un état parent et une suite de transactions, appliquer de façon répétée la fonction de transition d'état pour obtenir un état. L'une des conditions de validité de l'en-tête de bloc veut que la racine obtenue en repliant cet état dans le trie soit égale au stateRoot de l'en-tête. Qu'une transaction soit entièrement exécutée et son état complètement réécrit avant que la suivante ne commence à lire l'état : cette relation d'antériorité relève de la définition elle-même, sans marge d'optimisation.
La couche d'exécution n'a pas non plus de pouvoir d'ordonnancement. L'ordre des transactions est donné par la liste produite par la couche de consensus, et la couche d'exécution se contente d'évaluer dans cet ordre. Cette répartition évite à la couche de consensus de s'accorder avec la couche d'exécution sur « le résultat d'une exécution entrelacée » : il lui suffit de s'accorder sur « quelles transactions sont incluses, et dans quel ordre ». Tout nœud qui rejoue le même bloc dans un autre ordre obtient une racine qui ne correspond pas, et son résultat est directement déclaré erroné.
# Transition d'état au niveau du bloc : l'état d'entrée de la deuxième transaction est l'état de sortie de la première
state = parent_state
for tx in block.transactions:
state = apply_transaction(state, tx) # Υ(σ, T)
state = apply_withdrawals(state, block.withdrawals) # Les retraits sont exécutés après toutes les transactions
assert trie_root(state) == block.header.state_root
Les contraintes dures de l'ordre : nonce, solde et gaz cumulé
Avant même que le code du contrat soit interprété, la couche protocolaire impose déjà un ordre. Parmi les vérifications de validité initiale d'une transaction listées à la section 6 du livre jaune figure l'obligation que le nonce de la transaction soit égal au nonce courant du compte expéditeur. Deux transactions émises par le même expéditeur sont donc soumises à un ordre total imposé par le protocole : devancer la seconde rend la transaction immédiatement invalide, et ne produit pas un résultat différent. Une autre vérification exige que le compte expéditeur n'ait pas de code déployé (EIP-3607), ce qui revient là aussi à lire l'état après exécution d'une transaction.
Le solde et le gaz verrouillent l'ordre au même niveau. Chaque transaction vérifie avant exécution que le solde de l'expéditeur suffit à couvrir le paiement d'avance, et ce solde est le résultat de l'exécution de la transaction précédente ; la limite de gaz du bloc est une contrainte de niveau bloc, et le gaz cumulé figurant dans le reçu s'accumule à partir des transactions antérieures (dans la partie consacrée aux reçus de bloc, le livre jaune écrit la valeur cumulée de la n-ième transaction comme la somme de la valeur cumulée précédente et de la consommation de la transaction courante). Le gaz dont dispose une transaction dépend donc de ce qui a déjà été consommé avant elle.
Autrement dit, même en faisant totalement abstraction du stockage des contrats, la prémisse d'un « état avant / état après » est déjà posée par les règles du protocole. L'exécution des contrats ne fait qu'ajouter des dépendances sur cette séquence déjà enfilée.
D'où viennent les conflits : des read/write sets qui se recoupent, et ne se révèlent qu'après coup
Pour déterminer si deux transactions peuvent s'exécuter simultanément, la méthode standard consiste à comparer leurs read/write sets : l'ensemble des emplacements d'état qu'une transaction lit ou écrit. Dès qu'un emplacement écrit par l'une est lu ou écrit par l'autre, l'ordre d'exécution influence le résultat : ces transactions sont dites en conflit.
Les conflits sont très fréquents dans les charges réelles. Un échange sur un pool d'automated market maker (AMM) réécrit les réserves et l'accumulateur de prix, et la liquidation de prêt qui suit doit lire le prix du même pool : l'ordre des deux transactions décide directement si la liquidation se déclenche et qui supporte la perte. Nul besoin de fabriquer ces dépendances exprès : dès que des contrats partagent de l'état, elles apparaissent.
Plus gênant encore : l'exécuteur ne voit pas les read/write sets. L'EVM autorise une transaction à appeler n'importe quelle adresse et à lire ou écrire n'importe quel slot de stockage, or la cible de l'appel comme le numéro de slot peuvent n'être calculés qu'à l'exécution :
// La cible de l'appel et les paramètres ne sont déterminés qu'à l'exécution ; l'analyse statique ne fournit pas le read/write set
function dispatch(bytes32 poolId, bytes calldata payload) external {
address pool = pools[poolId]; // La cible vient du stockage : n'importe quel contrat enregistré
(bool ok, ) = pool.call(payload); // Les slots touchés dépendent de pool et de payload
require(ok, "call failed");
}
Le numéro de slot d'un mapping s'obtient en concaténant la clé et la position du slot, puis en prenant le keccak256 ; la clé peut être une entrée d'exécution, et la position du slot n'est pas nécessairement fixée à la compilation. « Quels slots cette transaction va toucher » ne peut donc pas se lire dans le bytecode avant l'exécution, seulement s'observer en exécutant réellement. La section de motivation de l'EIP-7928 le dit sans détour : sans savoir à l'avance quelles adresses et quels slots de stockage seront accédés, l'exécution des transactions ne peut pas être parallèle.
Ce que le monothread achète : le déterminisme et une vérification qui n'est qu'un rejeu
Le bénéfice de cette conception est concret. Tous les nœuds évaluent dans le même ordre et obtiennent la même racine d'état ; le vérificateur n'a pas besoin de faire confiance à un ordonnanceur ni d'envisager les diverses possibilités d'exécution entrelacée : il lui suffit de rejouer le même bloc avec les mêmes règles, puis de comparer le stateRoot de l'en-tête. Si cette conception permet à des clients issus d'implémentations indépendantes, de langages et de matériels différents, de s'accorder sur une même valeur, c'est qu'elle comprime l'incertitude en « entrées plus ordre ».
La sémantique d'échec dépend elle aussi de l'ordre. REVERT et les rollbacks sur exception s'appuient sur des instantanés d'état enregistrés pendant l'exécution pour défaire les modifications couche par couche, avec une granularité de rollback au niveau de la pile d'appels et de la transaction ; le remboursement de gaz, cumulativeGasUsed et le bit de statut du reçu n'ont de sens univoque que si l'ordre des transactions est fixé. Si l'on autorisait plusieurs transactions à progresser en s'entrelaçant, « la partie exécutée en premier est annulée, celle exécutée en second est conservée » exigerait une sémantique entièrement nouvelle.
La sérialisation transaction par transaction est donc bien un compromis, mais ce qu'elle apporte — un déterminisme vérifiable par tout le réseau, une vérification qui n'exige aucune preuve de correction supplémentaire — est le fondement même d'une chaîne publique ; l'expliquer par une « paresse d'implémentation » ne tient pas.
Les clients exploitent bel et bien plusieurs cœurs, mais toujours en dehors de la sémantique
Sur le terrain, les clients dominants ne gaspillent pas la machine. Prenons Reth : la récupération des signatures de transactions est confiée à un pool de threads qui l'exécute en parallèle ; le hachage des mises à jour d'état et la construction de la racine d'état sont confiés à des tâches de trie creux parallèles, où plusieurs workers se partagent la génération des preuves et la lecture des nœuds de trie, avec repli sur un calcul sériel en cas de dépassement de délai ou d'échec ; sur le chemin de traitement des blocs, des transactions sont en outre préexécutées dans un pool de threads distinct afin de préchauffer le cache pour l'exécution officielle qui suit.
L'exécution officielle, elle, reste sérielle. Dans le flux de traitement des blocs de Reth, le chemin par défaut, sans liste d'accès au niveau du bloc, effectue une exécution EVM sérielle dans l'ordre du bloc et remet les mises à jour d'état à la tâche de racine d'état sous forme de flux de données ; un bloc portant une BAL dispose d'une autre branche, à exécution parallèle, mais elle dépend du fork Amsterdam et de la présence de la BAL (la voie de la liste d'accès au niveau du bloc discutée plus loin). La préexécution ne fait que remplir le cache, et ses résultats ne sont pas directement retenus. Cette frontière est un héritage du point d'origine de conception : plusieurs cœurs peuvent accélérer la vérification des signatures, le hachage, la génération de preuves et le remplissage du cache, mais ils ne peuvent pas raccourcir « la chaîne d'évaluation unique du point de vue sémantique ».
Une mise au point au passage : monothread ne signifie pas qu'un seul cœur travaille sur la machine, mais qu'il n'existe qu'un seul ordre d'évaluation du point de vue sémantique. C'est pourquoi la question clé de la scalabilité n'est pas le nombre de cœurs, mais « cette chaîne d'évaluation peut-elle s'élargir ».
Le prix : le parallélisme est cédé hors du protocole
La spécification fixe l'ordre : si la couche d'exécution veut faire progresser les transactions sur plusieurs cœurs, la seule voie légale consiste à trouver un entrelacement équivalent à l'ordre spécifié et à faire converger le résultat vers le même état. Cela suppose de connaître ou de découvrir les read/write sets à l'avance : soit l'émetteur de la transaction ou le constructeur de bloc les déclare, soit le système les suit dynamiquement à l'exécution et traite les conflits. La première option transfère la charge au protocole et à l'outillage, la seconde au runtime et aux rollbacks.
La prémisse de l'état global rend ce prix plus visible. L'état est un arbre partagé par tous les nœuds, chaque transaction réécrit des nœuds le long du chemin des feuilles à la racine, et l'accès à l'état devient lui-même un maillon du chemin critique. Les E/S disque et la bande passante réseau peuvent monter en charge horizontalement ; la chaîne « lire l'état, calculer le résultat, réécrire l'état, lire l'état de la transaction suivante » ne le peut pas.
Une réponse frontale : déclarer les read/write sets à l'avance
La réponse directe à ce point d'origine consiste à fournir l'information manquante. La liste d'accès au niveau du bloc (Block-Level Access List, BAL) proposée par l'EIP-7928 ajoute à l'en-tête de bloc un champ block_access_list_hash qui enregistre tous les comptes et emplacements de stockage accédés pendant l'exécution du bloc, ainsi que leur valeur après exécution. Avec cette déclaration, un client peut lire le disque en parallèle, vérifier les transactions en parallèle, calculer la racine d'état en parallèle, et même mettre à jour l'état sans exécuter.
Le prix figure lui aussi dans la proposition. La liste d'accès doit être produite, généralement par le constructeur de bloc après exécution, puis sa validité vérifiée par les autres nœuds ; l'ordre des transactions, l'unicité et le déterminisme doivent être redéfinis, car le parallélisme suppose que les emplacements dont dépend chaque transaction ont déjà été déclarés. Le statut indiqué dans le corps de l'EIP-7928 est « en cours de relecture par les pairs », non finalisé ; la mise à niveau Glamsterdam a inscrit la BAL dans le périmètre de test des réseaux de développement, sans date d'activation sur le mainnet, l'avancement étant documenté sur la page de feuille de route Glamsterdam d'ethereum.org et les détails des devnets suivis par une page tierce. La proposition n'annule pas le point d'origine : elle ajoute une déclaration préalable, à vérifier, au service de l'exécution parallèle.
Notons au passage que la liste d'accès au niveau de la transaction introduite par l'EIP-2930 est facultative : la spécification n'impose pas à une transaction de déclarer les comptes et les slots qu'elle va toucher, si bien qu'elle ne constitue pas une prémisse de parallélisme fiable. C'est précisément pourquoi l'EIP-7928 veut la porter au niveau du bloc et rendre l'enregistrement obligatoire.
Contre-exemples et limites : l'ordre existe, mais pas forcément les conflits
Exposer clairement le point d'origine exige aussi d'en marquer les limites. L'ordre est imposé, mais les conflits dépendent de la charge. Les transactions de paiement en masse, de règlement ou de distribution d'airdrops ne touchent pour la plupart que le solde de leur destinataire respectif ; leurs read/write sets ne se recoupent presque pas et elles sont théoriquement très parallélisables. À l'inverse, les pools AMM populaires, les liquidations sur les marchés de prêt ou les ruées sur les NFT présentent des read/write sets fortement recoupés : l'espace de parallélisme est mangé par les conflits eux-mêmes. Un même modèle d'exécution se comporte très différemment selon la charge : toute discussion sur le gain du parallélisme doit préciser le profil de charge et le taux de conflit.
Une autre limite tient au point d'ancrage du déterminisme. L'exécution parallèle ne supprime pas l'exigence de déterminisme : elle desserre l'objet à préserver, qui passe d'un ordre d'évaluation réel à une classe d'exécutions sérialisables équivalentes à cet ordre. Le coût de la détection des conflits, de la vérification et du rejeu sélectif revient sur le débit : le parallélisme réduit donc la section sérielle aux chemins en conflit plutôt qu'il ne l'élimine.
Une dernière prémisse est facile à négliger : quelle que soit la parallélisation de la phase d'exécution, il faut in fine converger vers le même arbre d'état global et la même racine. Les transactions situées sur un chemin de conflit doivent être rejouées dans l'ordre spécifié, et les accès à l'état entre shards demandent une coordination supplémentaire. C'est une contrainte incontournable des discussions ultérieures sur le sharding de l'état.
Répartition : le point d'origine ici, le plafond et la congestion historique ailleurs
Le présent article ne répond qu'à une question : pourquoi la sérialisation transaction par transaction est-elle obligatoire, et quel en est le prix. La hauteur du plafond de performance qu'impose cette frontière, la manière dont les épisodes de congestion successifs depuis 2017 l'ont exposé à répétition, et les raisons pour lesquelles le relèvement de la limite de gaz comme l'EIP-1559 ne modifient pas le modèle d'exécution relèvent de l'article déjà publié « Pourquoi un EVM monothread plafonne le TPS : histoire de la congestion et modèle d'exécution » ; leurs chiffres et le récit des réformes ne sont pas repris ici. Les deux articles forment ensemble une chaîne complète : d'abord le point d'origine, puis la mesure du plafond.
Sources
- Ethereum Yellow Paper : la fonction de transition d'état de la section 2 (formule 1) et la définition imbriquée au niveau du bloc (formule 2), la validité globale du chapitre 4, la validité initiale des transactions de la section 6 (nonce, solde et EIP-3607), la définition du gaz cumulé dans les reçus de bloc (formule 186) ; les précisions de l'annexe D.1 sur l'espace de preuve.
- EIP-7928: Block-Level Access Lists (Review, créée le 2025-03-31) : la motivation selon laquelle l'ignorance des read/write sets empêche le parallélisme, le champ
block_access_list_hash, les exigences d'ordre et de déterminisme, le problème de la liste d'accès de l'EIP-2930 qui n'est pas obligatoire. - EIP-3607 : le rejet des transactions provenant de comptes ayant du code déployé.
- ethereum.org: Glamsterdam : le point d'avancement selon lequel la BAL est inscrite au périmètre de test des devnets Glamsterdam, sans date sur le mainnet.
- documentation reth : stages : la répartition des responsabilités entre SenderRecoveryStage et ExecutionStage.
- reth sender_recovery.rs : la récupération des signatures exécutée en parallèle dans le pool de threads rayon.
- code source de reth_trie_parallel::state_root_task : la tâche de racine d'état reçoit un hook d'exécution (correspondant à l'exécution sérielle) ou un flux de mises à jour hachées.
- reth_engine_tree::tree::state_root_strategy : le repli sur un calcul sériel de la racine d'état en cas de dépassement de délai ou d'échec d'une tâche de trie creux.
- code source de reth payload_validator et code source de payload_processor::prewarm : l'exécution EVM sérielle associée à un préchauffage par préexécution parallèle et à des tâches parallèles de racine d'état, ainsi que la branche d'exécution parallèle lorsqu'une BAL est présente.