Retour au blog
StreamingFast

Substreams 0.8.0 : un nouveau codec protobuf accélère les backfills jusqu'à 2,9x

Chaque module Substreams paie la même taxe de calcul avant d'exécuter sa propre logique. Le runtime lui remet un bloc protobuf, le module le décode, exécute sa logique, puis réencode le résultat. Ce décodage est payé une fois par bloc et par module : avec dix modules actifs, chaque bloc est décodé dix fois. Rien de tout cela n'est votre logique. C'est de la mise en place.

Les chaînes à haute fréquence produisent des blocs plusieurs fois plus vite que les chaînes qui existaient au lancement de Substreams, et les blocs eux-mêmes ont grossi : un bloc médian sur Base Network coûte un ordre de grandeur de plus à décoder qu'un bloc médian sur Ethereum. C'est lors des backfills que cela se voit, car un job de retraitement rejoue des années d'historique à travers ce même coût par bloc.

Le décodage protobuf peut représenter jusqu'à 90 % du calcul total nécessaire au traitement d'un bloc. Anthropic a récemment annoncé une nouvelle implémentation protobuf Rust performante, nous sommes donc allés regarder le décodeur tout de suite.

Substreams 0.8.0 génère désormais ses bindings protobuf Rust avec buffa au lieu de prost. Les modules décodent les blocs 2,9 à 4,9x plus vite selon la chaîne, et les vrais backfills que nous avons mesurés se sont terminés jusqu'à 2,9x plus vite de bout en bout. La nouvelle implémentation apporte des vues zero-copy, un décodage lazy et quelques autres leviers de haute performance. Le format wire protobuf est identique avec les deux, donc rien ne change en aval, et la mise à niveau est optionnelle.

Pourquoi le décodeur était le goulot d'étranglement

prost décode de façon eager. Il parcourt tout le bloc et construit des structs Rust possédées pour l'ensemble, chaque transaction, chaque log et chaque message imbriqué, en allouant au fur et à mesure. Votre handler lit ensuite les deux ou trois champs qui l'intéressent réellement.

Les vues lazy de buffa ne fonctionnent pas ainsi. Elles empruntent les champs string et bytes directement dans le buffer d'entrée au lieu de les copier, et laissent les messages imbriqués encodés tant que rien ne les lit : un handler ne paie donc que le décodage des champs qu'il touche. Un module qui extrait des événements de swap d'un bloc Solana cesse de payer pour la centaine d'autres éléments de ce bloc.

Voilà toute l'idée. Le gain est le plus important là où les blocs sont les plus gros et où les modules sont les plus sélectifs, ce qui décrit justement là où la douleur existait déjà.

Diagramme comparant le décodage eager et le décodage lazy. À gauche, prost (décodage eager) analyse entièrement un bloc protobuf encodé en structures Rust allouées pour les transactions, reçus, logs et en-tête, même si le handler n'utilise que quelques champs. À droite, buffa (vues lazy) laisse le buffer du bloc encodé intact, FooView empruntant des slices de string et de bytes directement dans le buffer sans allocation, et les messages imbriqués restent non analysés jusqu'à leur accès.

Ce que nous avons mesuré

D'abord le codec seul : de vrais blocs mainnet, Rust natif, sans WASM. Petit, médian et grand correspondent aux blocs p10, p50 et p95 par taille encodée, issus d'un scan de 2 000 blocs par chaîne. Chaque valeur est la médiane de neuf exécutions, et nous avons vérifié que chaque bibliothèque effectue un aller-retour identique sur chaque bloc avant toute mesure.

Résumé des benchmarks : buffa décode 2,9 à 4,9 fois plus vite et encode 1,4 à 1,8 fois plus vite que prost. Latence de décodage et d'encodage par bloc pour Ethereum, Base et Solana avec des blocs petits, médians et grands, en comparant prost, prost avec la feature bytes et les vues lazy de buffa.

Un tableau complet des résultats de benchmark se trouve dans l'annexe à la fin de cet article.

Nous avons comparé les vues lazy de buffa à prost standard et à prost avec sa feature bytes activée, qui fait des champs bytes générés des bytes::Bytes plutôt que des Vec<u8>. C'est un vrai gain de 1,4x à 1,9x au décodage pour une ligne de configuration, utile à connaître si vous restez sur prost pour l'instant. Réutiliser les buffers d'un appel à l'autre n'a produit aucune différence mesurable, ni pour prost ni pour buffa.

Une chose ressort clairement des chiffres : il n'existe pas de chiffre unique. Solana gagne le plus au décodage, 4,9x, et le moins à l'encodage, 1,4x. Citer un seul chiffre pour les deux les déformerait tous les deux.

Ce que cela donne de bout en bout

Les benchmarks du codec sont la partie facile. Ce qui compte, c'est qu'un vrai module se termine plus tôt. Nous avons exécuté deux modules de production sur de vraies plages, avec des données de production, sur Apple Silicon (darwin arm64), médiane de trois exécutions.

Graphique en barres du temps de backfill, prost contre buffa. Ethereum (uniswap-v4 map_events, 1,5 M de blocs) : prost 342 secondes, buffa 118 secondes, 2,90x plus rapide. Solana (solana-idl-history map_idl_accounts, 1,5 M de blocs) : prost 846 secondes, buffa 384,5 secondes, 2,20x plus rapide.

ModuleChaîneBlocsprostbuffaGain
uniswap-v4 map_eventsEthereum
(22,0 M–23,5 M)
1,5 M342 s118 s2,90x
solana-idl-history map_idl_accountsSolana
(450,0 M–451,5 M)
1,5 M846 s384,5 s2,20x

Graphique en barres du gain sur Ethereum par plage de blocs, du plus ancien au plus récent. 16,0 M à 17,5 M (1,5 M de blocs) : 1,74x. 17,5 M à 22,0 M (4,5 M de blocs) : 2,04x. 22,0 M à 23,5 M (1,5 M de blocs) : 2,90x. Le gain augmente à mesure que les blocs deviennent plus gros et plus lourds.

Deux points méritent d'être soulignés.

Sur Ethereum, plus les blocs sont récents, plus le gain est important. Les 1,5 M de blocs les plus récents ont tourné 2,90x plus vite ; les 1,5 M de blocs les plus anciens que nous avons testés, 16,0 M–17,5 M, 1,74x. Des blocs plus gros et plus chargés signifient un gain plus important, donc cela devient plus utile à mesure que les chaînes s'alourdissent, et non moins.

Sur Solana, le chiffre de bout en bout de 2,20x est inférieur au 4,4x du décodage, et c'est normal. Le bout en bout inclut la logique propre de votre module et l'encodage en sortie, où l'avantage de buffa est plus étroit, 1,4x. Le décodage cesse d'être le goulot d'étranglement et ce qui se trouve derrière en devient un.

Le code de vos handlers devient plus court

L'ergonomie a évolué en même temps que la vitesse. Les messages imbriqués qui étaient tous typés Option<T> sont désormais des MessageField<T>, qui déréférencent vers une instance par défaut lorsqu'ils ne sont pas définis, si bien que la plupart des accès imbriqués n'ont plus besoin de garde. Les champs enum sont des EnumValue<E> au lieu d'un simple i32.

prostbuffa
blk.header.as_ref().unwrap().numberblk.header.number
msg.field.is_some()msg.field.is_set()
msg.field = Some(value)msg.field = value.into()
Status::try_from(msg.status)? == Status::Activemsg.status == Status::Active
MyMessage::decode(&*bytes)?MyMessage::decode_from_slice(&bytes)?

La chaîne .as_ref().unwrap() disparaît de la plupart des handlers.

Mise à niveau

La mise à niveau est optionnelle. Un module figé sur substreams 0.7 continue de compiler et de diffuser, cargo update ne le fera pas passer à 0.8.0, et le format wire est le même dans les deux cas : les packages existants continuent de diffuser, les sorties de modules en cache restent valides, les sinks lisent les mêmes octets, et les consommateurs Go ou JavaScript n'ont aucun changement à faire. Un nouveau projet créé avec substreams init utilise déjà buffa.

Si vous migrez un module existant, toutes les crates doivent provenir de la même ligne. Mélanger les lignes place deux copies incompatibles de substreams dans l'arbre de dépendances.

substreamssubstreams-ethereumsubstreams-solanasubstreams-database-changeProtobuf
0.8.00.12.00.16.05.0.0buffa
0.7.x0.11.x0.15.x3.x, 4.xprost
0.6.x0.10.x0.14.x2.xprost

Mettez à jour vos dépendances :

 [dependencies]
-prost = "0.13"
-prost-types = "0.13"
-substreams = "0.7"
-substreams-ethereum = "0.11"
+buffa = "0.9.2"
+buffa-types = "0.9.2"
+substreams = "0.8.0"
+substreams-ethereum = "0.12.0"

Supprimez ensuite buf.gen.yaml et src/pb, puis lancez substreams build pour régénérer. Cela nécessite aussi le CLI Substreams v1.25.0 ou ultérieur, la version à partir de laquelle substreams build et substreams protogen génèrent des bindings buffa pour tout projet dont le Cargo.toml dépend de buffa.

Le CLI lit votre Cargo.toml pour décider quel plugin protobuf utiliser : ajoutez donc buffa à [dependencies] avant de compiler, sinon vous obtiendrez des bindings prost qui ne compilent pas avec 0.8.0. Mettre à niveau le CLI seul ne change rien pour un projet prost.

substreams-skills a lui aussi été mis à jour pour buffa : les Skills de codage IA que nous avons publiés pour Claude Code, Cursor et VS Code génèrent donc du code selon les nouvelles API plutôt que celles de prost. Si vous développez avec Substreams à l'aide d'un assistant IA, mettez à jour les Skills en même temps que vos crates.

Le guide complet, avec le dépannage des deux erreurs les plus fréquentes rencontrées lors de notre propre migration, se trouve dans Migrating from prost to buffa.

Pour commencer

  • Nouveau projet : substreams init vous donne déjà buffa.
  • Migration d'un module existant : suivez Migrating from prost to buffa.
  • Une erreur : la section de dépannage couvre les symboles alloc dupliqués et les bornes du trait Message non satisfaites, qui représentent à elles deux l'essentiel de ce que nous avons rencontré.

Si vous rencontrez un problème que le guide ne couvre pas, rejoignez le Discord et dites-nous ce que vous avez trouvé.

Annexe : benchmarks

Temps médian par bloc, Rust natif, vrais blocs mainnet. Gain par rapport à prost standard entre parenthèses. Plus bas est mieux.

Benchmarks de décodage

Décodage Ethereum

Taille de blocprostprost + bytesbuffa
Petit0,8950 ms (1,0x)0,4651 ms (1,9x)0,2393 ms (3,7x)
Médian1,8205 ms (1,0x)0,9611 ms (1,9x)0,4961 ms (3,7x)
Grand2,4703 ms (1,0x)1,3448 ms (1,8x)0,6576 ms (3,8x)

Décodage Base

Taille de blocprostprost + bytesbuffa
Petit6,0469 ms (1,0x)3,7501 ms (1,6x)2,0682 ms (2,9x)
Médian7,9017 ms (1,0x)4,8873 ms (1,6x)2,6242 ms (3,0x)
Grand14,4194 ms (1,0x)9,1413 ms (1,6x)5,0012 ms (2,9x)

Décodage Solana

Taille de blocprostprost + bytesbuffa
Petit2,7674 ms (1,0x)1,7508 ms (1,6x)0,6961 ms (4,0x)
Médian4,1832 ms (1,0x)2,8293 ms (1,5x)0,9472 ms (4,4x)
Grand5,3339 ms (1,0x)3,7439 ms (1,4x)1,0791 ms (4,9x)

Benchmarks d'encodage

Encodage Ethereum

Taille de blocprostbuffa
Petit0,2191 ms (1,0x)0,1303 ms (1,7x)
Médian0,4578 ms (1,0x)0,2788 ms (1,6x)
Grand0,6527 ms (1,0x)0,3980 ms (1,6x)

Encodage Base

Taille de blocprostbuffa
Petit2,0857 ms (1,0x)1,1424 ms (1,8x)
Médian2,6309 ms (1,0x)1,4809 ms (1,8x)
Grand4,6473 ms (1,0x)2,5890 ms (1,8x)

Encodage Solana

Taille de blocprostbuffa
Petit0,5999 ms (1,0x)0,4221 ms (1,4x)
Médian0,9326 ms (1,0x)0,6847 ms (1,4x)
Grand1,1897 ms (1,0x)0,8725 ms (1,4x)