EVM vs SVM : pourquoi Solana exécute en parallèle

1. Aperçu général

L’Ethereum Virtual Machine (EVM) traite les transactions de manière séquentielle, chaque bloc étant une file d’attente d’opérations qui se suivent. La Solana Virtual Machine (SVM) fait l’inverse : elle exécute des dizaines de milliers de transactions par seconde en parallèle sur plusieurs cœurs CPU. Cet article explique pourquoi il existe cette différence — non pas parce que SVM est « plus rapide » de façon magique, mais parce que son modèle de compte permet au runtime de savoir à l’avance qui va accéder à quoi. Après la lecture, vous comprendrez le mécanisme Sealevel, pourrez simuler le planificateur parallèle en Java par vous-même, et saurez quand ce modèle échoue.

2. Le modèle d’exécution séquentiel de l’EVM

L’EVM stocke l’intégralité de l’état dans un Merkle Patricia Trie partagé par tous les smart contracts. Une transaction qui appelle la fonction increment() d’un contrat A ne déclare pas à l’avance quel storage slot elle va accéder — cela ne se sait que pendant l’exécution, car l’EVM est une machine d’état arbitraire (Turing-complète), les branches (if/else) peuvent décider d’écrire dans quel slot en fonction des données d’entrée.

contract Counter {
    uint256 public count;

    function increment() public {
        count += 1; // slot de stockage unique, personne ne peut le savoir à l'avance
    }
}

count est un storage slot fixe, mais le runtime EVM n’a aucun moyen de le savoir à l’avance — avant d’exécuter le bytecode — que cette fonction va lire/écrire ce slot sans l’essayer d’abord. Puisque « savoir à l’avance » est impossible avec un bytecode arbitraire, les clients Ethereum (geth, Nethermind…) sont forcés d’exécuter les transactions séquentiellement dans un bloc, en s’assurant que la transaction suivante voit toujours l’état mis à jour par la transaction précédente.

2.1. Pourquoi ne pas pouvoir prédire les conflits à l’avance

Il n’existe pas de mécanisme d’« analyse statique » assez rentable pour analyser toutes les branches du bytecode EVM et en déduire l’ensemble des storage slots affectés avant l’exécution. Certains projets comme Monad ou Block-STM (Aptos) résolvent cela avec la concurrence optimiste : exécuter en parallèle, et si un conflit est détecté, revenir en arrière et réexécuter. C’est une approche « exécuter d’abord, vérifier après » — très différente de celle de SVM, qui est « déclarer d’abord, planifier après ».

Bloc EVM (défaut mainnet) :
tx1 -> tx2 -> tx3 -> tx4   (séquentiel, un par un)

3. Le modèle de compte explicite de SVM (Sealevel)

Solana oblige chaque transaction à déclarer explicitement la liste des comptes qu’elle va lire ou écrire, directement dans le message de la transaction, avant de l’envoyer au réseau. Le runtime n’a pas besoin de l’essayer pour le savoir — le client l’a déjà indiqué.

pub fn process_instruction(
    _program_id: &Pubkey,
    accounts: &[AccountInfo],
    _instruction_data: &[u8],
) -> ProgramResult {
    let accounts_iter = &mut accounts.iter();
    let counter_account = next_account_info(accounts_iter)?;

    let mut data = counter_account.try_borrow_mut_data()?;
    let mut count = u64::from_le_bytes(data[0..8].try_into().unwrap());
    count += 1;
    data[0..8].copy_from_slice(&count.to_le_bytes());
    Ok(())
}

accounts est une slice passée de l’extérieur — le client doit spécifier clairement quel compte est counter_account, s’il est modifiable ou en lecture seule, au moment de construire la transaction. Le programme n’est pas autorisé à « découvrir » d’autres comptes au cours de l’exécution. C’est précisément cette contrainte — qui peut sembler restrictive — qui permet à Sealevel de planifier l’exécution en parallèle.

3.1. Déclarer writable/readonly directement dans la transaction

Message de transaction (simplifié) :
  instruction: increment_counter
  accounts:
    - counter_pda      (writable)
    - fee_payer        (writable, signer)
    - system_program    (readonly)

Le runtime n’a besoin que de lire ces métadonnées — sans exécuter le bytecode — pour savoir que cette transaction va écrire dans counter_pda et fee_payer, et lire system_program. C’est là la différence fondamentale avec l’EVM.

4. Mécanisme de détection des conflits et planification batch parallèle

Une fois que le runtime a le read-set/write-set de chaque transaction, le problème de planification parallèle se réduit au problème classique de coloration de graphe : deux transactions peuvent s’exécuter simultanément si l’ensemble des comptes qu’elles accèdent ne s’intersectent pas en mode write-write ou write-read.

public record BlockTransaction(String id, Set<String> writes, Set<String> reads) {}

writes et reads correspondent respectivement aux comptes que la transaction va modifier et lire uniquement. Deux transactions sont en conflit quand :

public final class SealevelScheduler {

    public static boolean conflicts(BlockTransaction a, BlockTransaction b) {
        return !Collections.disjoint(a.writes(), b.writes())
            || !Collections.disjoint(a.writes(), b.reads())
            || !Collections.disjoint(a.reads(), b.writes());
    }
}

Explication de chaque condition :

  • writes ∩ writes non-vide → les deux veulent écrire dans le même compte → doivent être séquentiels.
  • writes ∩ reads non-vide → un côté lit, un côté écrit dans le même compte → doivent être séquentiels.
  • reads ∩ reads non-vide → c’est OK, lire en parallèle n’a pas besoin de verrous exclusifs.

4.1. Simulation en Java : grouper en batch puis exécuter en parallèle

public final class ParallelExecutor {

    private final ExecutorService pool = Executors.newVirtualThreadPerTaskExecutor();

    public List<BlockTransaction> nextNonConflictingBatch(
            Deque<BlockTransaction> queue, Set<String> lockedAccounts) {

        List<BlockTransaction> batch = new ArrayList<>();
        for (BlockTransaction tx : queue) {
            boolean overlaps = Stream.concat(tx.writes().stream(), tx.reads().stream())
                    .anyMatch(lockedAccounts::contains);
            if (!overlaps) {
                batch.add(tx);
                lockedAccounts.addAll(tx.writes());
            }
        }
        queue.removeAll(batch);
        return batch;
    }

    public void runBatch(List<BlockTransaction> batch, Consumer<BlockTransaction> execute) {
        List<CompletableFuture<Void>> futures = batch.stream()
                .map(tx -> CompletableFuture.runAsync(() -> execute.accept(tx), pool))
                .toList();
        futures.forEach(CompletableFuture::join);
    }
}

nextNonConflictingBatch parcourt la file de transactions, accepte toute transaction qui ne touche pas lockedAccounts actuels dans le batch, puis verrouille les comptes supplémentaires qu’elle écrit. runBatch utilise les virtual threads (Java 21) pour exécuter tout le batch en parallèle, join attend que tout le batch soit terminé avant de créer le batch suivant — exactement comme le planificateur Sealevel groupe les transactions par « vagues ».

Note : c’est une simulation simplifiée pour illustrer l’idée. Le vrai Sealevel utilise un algorithme plus complexe (graphe de dépendances par compte, pas seulement par batch séquentiel) pour maximiser le parallélisme.

4.2. Vérifier la logique de conflit avec JUnit 5

class SealevelSchedulerTest {

    @Test
    void khongDungAccountThiKhongConflict() {
        var tx1 = new BlockTransaction("tx1", Set.of("walletA"), Set.of());
        var tx2 = new BlockTransaction("tx2", Set.of("walletB"), Set.of());

        assertThat(SealevelScheduler.conflicts(tx1, tx2)).isFalse();
    }

    @Test
    void cungGhiMotAccountThiConflict() {
        var tx1 = new BlockTransaction("tx1", Set.of("poolAccount"), Set.of());
        var tx2 = new BlockTransaction("tx2", Set.of("poolAccount"), Set.of());

        assertThat(SealevelScheduler.conflicts(tx1, tx2)).isTrue();
    }

    @Test
    void docChungThiKhongConflict() {
        var tx1 = new BlockTransaction("tx1", Set.of(), Set.of("priceOracle"));
        var tx2 = new BlockTransaction("tx2", Set.of(), Set.of("priceOracle"));

        assertThat(SealevelScheduler.conflicts(tx1, tx2)).isFalse();
    }
}

Ces trois tests décrivent précisément le principe read-read sans verrou, write-write avec verrou, write-read avec verrou — la base de tout le problème de parallélisation de SVM.

5. Comparaison EVM vs SVM

Critère EVM SVM
Modèle d’état Un trie partagé, accès implicite via le bytecode Comptes discrets, déclaration explicite dans la transaction
Quand détecter les conflits Seulement après exécution (ou exécution d’essai) Avant l’exécution, à partir des métadonnées de la transaction
Exécution Séquentielle selon l’ordre dans le bloc Parallèle par batch sans intersection
Cross-contract call Appeler d’autres contrats à volonté à l’exécution Cross-Program Invocation (CPI) doit toujours déclarer les comptes à l’avance
Responsabilité dev Pas besoin de penser au set de comptes Doit déclarer les comptes correctement et complètement — incomplet causera une erreur, excessif perdra le parallélisme
Direction d’amélioration du parallélisme Concurrence optimiste (Block-STM, Monad) Optimiser la planification du graphe de dépendances (Sealevel, Firedancer)

6. Compromis et erreurs courantes en écrivant des programmes Solana

La déclaration explicite de comptes est à l’origine du parallélisme, mais aussi à l’origine de deux erreurs courantes :

Compte manquant. Si la logique du programme a besoin d’écrire dans un compte que le client n’a pas listé dans la transaction, le programme échoue immédiatement à l’exécution avec une erreur du type « account not found in accounts list » — car SVM n’autorise pas la « découverte » de comptes en cours de route comme l’EVM.

Surcharge ou création de goulot. Si trop de transactions écrivent dans le même compte (par exemple un pool de liquidité unique, un compteur global), elles sont forcées de se sérialiser — exactement comme dans la situation EVM, sauf que vous l’avez créé vous-même. C’est une erreur courante chez les développeurs habitués à l’EVM : apporter l’habitude de la « variable globale unique » sur Solana sans fragmenter les comptes par shard.

Erroné : chaque transaction utilisateur écrit dans `global_stats` -> perte de parallélisme
Correct : chaque utilisateur a son propre compte (PDA par utilisateur), agrégation uniquement périodique

7. Quand le parallélisme n’aide pas

Note : si votre application est fondamentalement une vraie file d’attente séquentielle (par exemple une enchère avec tarification par ordre de soumission), la parallélisation au niveau du runtime ne vous aide en rien — vous devez toujours sérialiser la logique métier vous-même, SVM ne peut économiser que les transactions qui ne dépendent pas l’une de l’autre.

De la même façon, si vous concevez un système centralisé (pas une blockchain) mais que vous voulez « apprendre » le modèle Sealevel pour augmenter le débit, cela n’en vaut vraiment la peine que si la plupart des tâches ont des read/write sets clairement séparés. Si chaque tâche touche une table/enregistrement partagé, le coût de gestion du graphe de dépendances dépasse même celui du simple verrouillage séquentiel.

8. Conclusion

L’EVM s’exécute séquentiellement car son bytecode ne dit pas au runtime à l’avance qui va accéder à quoi — toute prédiction de conflit ne peut se faire que par exécution d’essai ou rollback. SVM s’exécute en parallèle car il oblige chaque transaction à déclarer les comptes lire/écrire dès le départ, permettant à Sealevel de construire un graphe de dépendances et de planifier des batches sans intersection pour exécuter sur plusieurs cœurs. Le compromis est que les développeurs Solana doivent eux-mêmes concevoir des comptes séparés, éviter de créer des goulots — sinon, vous transformez accidentellement SVM en un EVM séquentiel lent.

svmevmsolanablockchainparallel-computing