Rust 2026-10-15·Ievgenii Svyryd

Benchmark, round 2 : Rust, Go et les frameworks Rust, profilés

Go et sept applications Rust (axum, actix-web, Rocket, Loco, Cot et Rustango sur axum et actix) soumis aux mêmes règles, avec seule l'application testée en marche, plus les profils CPU, syscalls et réseau qui montrent où chacune passe son temps.

Benchmark, round 2 : Rust, Go et les frameworks Rust, profilés

Voici le round 2 du benchmark équitable. Le round 2 ne couvre que les applications compilées : Go et sept applications Rust. Il ajoute trois frameworks Rust (Rocket, Loco, Cot) ainsi que des profils CPU, syscalls et réseau qui montrent où chaque application passe son temps.

Mesuré le 14 octobre 2026 sur un Apple M3 Pro (VM Docker avec 12 CPU et 16 Go).

  • 441/s collectes de mémoire de Go sur l'index en cache
  • 3 vs 9 allers-retours Postgres par page de détail : Go vs sqlx avec le ping à l'acquisition
  • +45% Rustango sur actix, page d'index, avec une tranche de pool par worker
  • 6.3–22.8 MiB mémoire sous charge, les huit applications
  • 0 erreur ou réponse non-200 en 144 exécutions

La configuration

Le même blog qu'au round 1 : 10 auteurs, 30 tags, 1 000 articles, 2 600 liens article-tag et 10 000 commentaires. Six routes : l'index (les 20 derniers articles), une page de détail d'article avec tous ses commentaires, une page de tag, l'index et la page de détail servis depuis un cache de pages Redis, et /compute (somme des nombres premiers inférieurs à 20 000, sans base de données).

Huit applications :

  • Go : net/http de la stdlib, pgx v5, go-redis
  • axum + sqlx et actix + sqlx : sqlx brut avec le texte SQL de Go, templates Tera
  • Rocket : rocket_db_pools (sqlx) et rocket_dyn_templates (Tera)
  • Rustango sur axum et sur actix-web : l'ORM Rustango et son cache de pages
  • Loco : SeaORM, Tera
  • Cot : l'ORM de Cot, templates askama

Les règles :

  • L'application dispose de 4 CPU (épinglés sur les cœurs 0–3) et de 2 Go de RAM, avec 4 workers. PostgreSQL 16 et Redis 7 tournent sur les cœurs 4–7, le générateur de charge oha sur les cœurs 8–11.
  • Seule l'application testée tourne ; les autres sont arrêtées. Entre deux applications, le nombre de connexions Postgres revient à sa valeur de base.
  • Pools : 16 connexions max, 4 min.
  • Avant toute charge, chaque page de chaque application est comparée au HTML de Go. Une différence arrête l'exécution.
  • Pas de compression. 5 s de préchauffage, puis trois exécutions de 20 s par endpoint à 50 connexions ; la médiane fait foi.

Le banc de test

DOCKER NETWORK · NO COMPRESSION oha load generator 8 9 10 11 CORES 8–11 App under test 4 CPUs · 2 GB · 4 workers 0 1 2 3 CORES 0–3 · PINNED Databases Postgres · MySQL · Redis 4 5 6 7 CORES 4–7 HTTP 50 conns SQL · cache 16 conns ✓ HTML parity vs Go, before any load PER ENDPOINT 5 s run 1 · 20 s run 2 · 20 s run 3 · 20 s WARM-UP → MEDIAN OF 3 · HTTP 200 ONLY DOCKER NETWORK oha load generator CORES 8–11 8 9 10 11 HTTP 50 conns ✓ HTML parity vs Go App under test 4 CPUs · 2 GB · 4 workers CORES 0–3 0 1 2 3 SQL · cache · 16 conns Databases PostgreSQL · MySQL · Redis CORES 4–7 4 5 6 7 PER ENDPOINT 20 s 20 s 20 s 5 S WARM-UP → MEDIAN OF 3

Même banc de test qu'au round 1. Le round 2 utilise uniquement PostgreSQL et Redis (pas de MySQL).

Versions

  • Go 1.25.14, pgx v5.7.2, go-redis v9.7.0
  • rustc 1.99.0 ; axum 0.8.9, actix-web 4.16.0, tokio 1.53, sqlx 0.8.6, Tera 1.20.1
  • Rocket 0.5.1 (sqlx 0.7.4, hyper 0.14)
  • Loco 1.2.0 (SeaORM 2.0.4, sqlx 0.9.0)
  • Cot 0.7.0 (sqlx 0.9.0, askama 0.16.1)
  • Rustango sans ping à l'acquisition, avec un cache de pages qui stocke des octets bruts et une connexion du pool par requête (constat 5)

Réglages impossibles à égaliser

RéglageApplicationsPourquoi
Le pool envoie un ping à Postgres à chaque emprunt (sqlx test_before_acquire)Rocket, Loco, CotAucun réglage pour le désactiver. Il est désactivé dans axum + sqlx, actix + sqlx et Rustango.
10 connexions de pool au lieu de 16CotCot n'a pas de réglages de pool.
Une tranche de pool par worker (4 des 16 connexions) et une connexion Redis par workeractix + sqlx, Rustango · actixUn pool partagé est plus lent sur actix-web (voir constat 6).
sqlx 0.7.4 et hyper 0.14RocketImposés par Rocket 0.5.1.
5, 5 et 7 requêtes par page d'index, de détail et de tag au lieu de 3, 3 et 4CotSon API de requêtes ne peut pas exprimer les requêtes de Go (voir constat 9).
askama au lieu de TeraCotLe moteur de templates de Cot.

Résultats

Requêtes par seconde, plus c'est haut, mieux c'est.

Pages sans cache

Même HTML, 50 connexions, PostgreSQL à chaque requête

req/s · higher is better
  • Go
  • Rust
  • Rustango
  • Loco
  • Cot
Index
Go 8 741 req/s
axum + sqlx 14 140 req/s
actix + sqlx 14 408 req/s
Rocket 13 316 req/s
Rustango · axum 14 686 req/s
Rustango · actix 17 401 req/sfastest
Loco 10 290 req/s
Cot 2 018 req/s
Détail
Go 22 607 req/s
axum + sqlx 22 842 req/s
actix + sqlx 21 630 req/s
Rocket 24 561 req/s
Rustango · axum 25 788 req/s
Rustango · actix 27 292 req/sfastest
Loco 15 635 req/s
Cot 9 282 req/s
Tag
Go 6 386 req/s
axum + sqlx 6 467 req/s
actix + sqlx 6 628 req/s
Rocket 6 836 req/s
Rustango · axum 7 090 req/sfastest
Rustango · actix 7 056 req/s
Loco 5 604 req/s
Cot 3 065 req/s

Médiane de trois exécutions de 20 s à 50 connexions. Seule l'application testée tournait. 0 réponse non-200, 0 erreur.

Pages en cache

Servies depuis un cache de pages Redis, sans SQL

req/s · higher is better
  • Go
  • Rust
  • Rustango
  • Loco
  • Cot
Index, en cache
Go 91 839 req/s
axum + sqlx 198 669 req/s
actix + sqlx 226 887 req/s
Rocket 138 224 req/s
Rustango · axum 174 501 req/s
Rustango · actix 231 975 req/sfastest
Loco 153 243 req/s
Cot 190 245 req/s
Détail, en cache
Go 141 574 req/s
axum + sqlx 214 871 req/s
actix + sqlx 240 161 req/s
Rocket 155 691 req/s
Rustango · axum 192 897 req/s
Rustango · actix 242 609 req/sfastest
Loco 170 158 req/s
Cot 203 119 req/s

Médiane de trois exécutions de 20 s à 50 connexions. Seule l'application testée tournait. 0 réponse non-200, 0 erreur.

Calcul

Nombres premiers inférieurs à 20 000, sans base de données

req/s · higher is better
  • Go
  • Rust
  • Rustango
  • Loco
  • Cot
Go 16 580 req/sfastest
axum + sqlx 14 963 req/s
actix + sqlx 14 578 req/s
Rocket 14 696 req/s
Rustango · axum 14 799 req/s
Rustango · actix 14 710 req/s
Loco 14 184 req/s
Cot 15 330 req/s

Médiane de trois exécutions de 20 s à 50 connexions. Seule l'application testée tournait. 0 réponse non-200, 0 erreur.

Latence, p50 / p99 en ms :

ApplicationIndexDétailTagIndex, en cacheDétail, en cacheCalcul
Go5.50 / 9.961.91 / 5.027.61 / 11.720.35 / 2.730.24 / 2.052.37 / 10.75
axum + sqlx3.49 / 4.712.14 / 3.607.42 / 13.890.24 / 0.530.22 / 0.513.35 / 6.07
actix + sqlx3.32 / 6.382.21 / 4.307.23 / 13.230.20 / 0.500.19 / 0.453.41 / 4.97
Rocket3.66 / 5.831.97 / 3.587.14 / 11.550.34 / 0.700.30 / 0.673.40 / 6.14
Rustango · axum3.28 / 5.651.83 / 3.556.90 / 11.180.27 / 0.590.25 / 0.523.38 / 6.09
Rustango · actix2.80 / 4.511.77 / 3.376.91 / 11.530.20 / 0.480.19 / 0.453.41 / 3.96
Loco4.62 / 10.842.99 / 6.348.67 / 14.520.30 / 0.790.28 / 0.623.48 / 6.90
Cot23.35 / 50.365.28 / 8.0215.57 / 27.060.25 / 0.500.23 / 0.523.27 / 5.85

Mémoire

Mémoire sous charge

Mémoire résidente du conteneur de l'application sur l'index sans cache

MiB · lower is better
  • Go
  • Rust
  • Rustango
  • Loco
  • Cot
Go 22.8 MiB
axum + sqlx 7.6 MiB
actix + sqlx 7.2 MiB
Rocket 8.7 MiB
Rustango · axum 6.9 MiB
Rustango · actix 6.3 MiBlowest
Loco 12.0 MiB
Cot 22.6 MiB

docker stats, l'échantillon avec le CPU le plus élevé parmi six pris pendant la première exécution sur l'index.

Go et Cot en consomment le plus. L'index de Cot charge les 1 000 articles avec leur contenu à chaque requête (constat 9).

Constats

Chaque constat indique ce qui a été mesuré, pourquoi cela se produit et comment cela a été démontré. Les profils sont des exécutions uniques de 25 s sur le même banc, une application à la fois, donc leurs req/s diffèrent un peu de l'exécution équitable ci-dessus. Lorsqu'une cause n'est pas prouvée, c'est indiqué.

1. Calcul : Go est 8–17% plus rapide

Quoi. Go sert 16 580 req/s sur /compute ; les applications Rust en servent 14 184–15 330.

Pourquoi. Les deux boucles compilées comptent 8 instructions, un sdiv 64 bits et une vérification de division par zéro. La différence tient à l'ordre des instructions choisi par LLVM. Quel détail micro-architectural du CPU rend cet ordre plus lent reste non prouvé : la VM Docker n'a pas de compteurs matériels.

Comment c'est prouvé. La boucle seule, sur un cœur, hors HTTP :

Boucle des nombres premiers, un appel

Un seul cœur, sans HTTP

microsecondes · lower is better
  • Go
  • Rust
Go 194 microsecondeslowest
Rust 224 microsecondes
Rust, ordre d'instructions de Go 194 microsecondeslowest

Rust avec exactement l'ordre d'instructions de Go s'exécute dans le temps de Go. Le reste de chaque requête coûte moins cher en Rust : 8 µs contre 14 µs pour Go.

Latence. Go a le p50 le plus bas mais le p99 le plus haut. La latence moyenne vaut 50 / req/s pour chaque application, donc les moyennes suivent le débit ; la différence tient à la dispersion. Go sert les requêtes dans le désordre : p10 0.56 ms, p99 10.75 ms. Les applications Rust servent presque dans l'ordre d'arrivée : axum + sqlx p10 1.85 ms, p99 6.07 ms. Pourquoi le p99 de Go se situe près de 10 ms reste non prouvé.

2. Pages en cache : le ramasse-miettes de Go

Quoi. Sur l'index en cache, Go sert 91 839 req/s ; axum + sqlx en sert 198 669.

Pourquoi. Go effectue 441 collectes de mémoire par seconde sur cette route, et les pauses stop-the-world représentent environ 10% du temps. Le reste est le CPU par requête : 27.2 µs pour Go contre 14.6 µs pour axum + sqlx.

Comment c'est prouvé. Augmenter GOGC supprime l'essentiel de l'écart :

Go, index en cache, GC ajusté

Avant : GOGC=100 par défaut. Exécutions de profilage.

beforeafter
GOGC=400, req/s 97 289 req/s 144 083 req/s +48%
GC presque désactivé, req/s 97 289 req/s 159 734 req/s +64%
GC presque désactivé, p99 2.48 ms 0.90 ms −64%

Où va le CPU restant, par requête sur l'index en cache :

Goaxum + sqlx
CPU de l'application27.2 µs14.6 µs
Chemin d'écriture du noyau10.6 µs6.9 µs
Syscalls10.7 (6.6 read, 2.9 write)4.8 (2.0 recvfrom, 1.4 writev)
CPU de Redis6.8 µs2.5 µs
Trafic Redisgo-redis : un GET par requêteune connexion, les GET de nombreuses requêtes regroupés dans un même pipeline

Go : CPU par requête en cache

27.2 µs au total

  • 39%Chemin d'écriture du noyau, 10.6 µs
  • 61%Tout le reste, 16.6 µs

axum + sqlx : CPU par requête en cache

14.6 µs au total

  • 47%Chemin d'écriture du noyau, 6.9 µs
  • 53%Tout le reste, 7.7 µs

3. Page de détail : allers-retours Postgres

Quoi. Page de détail : Go 22 607, axum + sqlx 22 842, Rocket 24 561, Rustango · axum 25 788 req/s. Toutes envoient les trois mêmes requêtes.

Pourquoi. Le nombre d'allers-retours vers Postgres diffère. pgx en envoie un par requête. sqlx en ajoute un quand une connexion est prise dans le pool (le ping à l'acquisition) et un quand elle y retourne. axum + sqlx prend une connexion par requête SQL, donc paie à chaque requête ; Rocket et Rustango prennent une connexion par page, donc paient une seule fois. Chaque aller-retour supplémentaire coûte environ 10 µs de CPU Postgres.

Comment c'est prouvé. Capture du protocole Postgres pendant la charge, en comptant les messages ReadyForQuery par page :

Allers-retours Postgres par page de détail

Capture réseau, les trois mêmes requêtes ; ping = ping à l'acquisition de sqlx

allers-retours · lower is better
  • Go
  • Rustango
  • Rust
Go · pgx 3 allers-retourslowest
Rustango 4 allers-retours
Rocket 5 allers-retours
axum + sqlx, ping désactivé 6 allers-retours
axum + sqlx, ping activé 9 allers-retours

La page d'index montre les mêmes chiffres.

axum + sqlx : ping à l'acquisition désactivé

Exécutions de profilage

beforeafter
Détail, req/s 18 222 req/s 23 472 req/s +29%
Détail, CPU Postgres 166.6 µs/req 134.3 µs/req −19%
Index, req/s 12 140 req/s 14 419 req/s +19%
Index, CPU Postgres 237.9 µs/req 208.5 µs/req −12%

Trois allers-retours de moins économisent 29–32 µs de CPU Postgres par page : environ 10 µs chacun.

Go fait le moins d'allers-retours mais n'est pas le plus rapide ici : son ramasse-miettes l'arrête environ 9% du temps sur cette route.

4. Page d'index : GC et rendu des templates

Quoi. Page d'index : Go 8 741 req/s, axum + sqlx 14 140.

Pourquoi. Deux causes mesurées. Encore le ramasse-miettes de Go (les pauses représentent environ 8% du temps sur cette route), et le rendu des templates : html/template de Go utilise la réflexion et coûte 167 µs par requête ; serde + Tera en Rust coûtent 72 µs.

Comment c'est prouvé.

Go, index sans cache, GC ajusté

Avant : GOGC=100 par défaut. Exécutions de profilage.

beforeafter
GOGC=400, req/s 9 359 req/s 12 848 req/s +37%
GC presque désactivé, req/s 9 359 req/s 13 967 req/s +49%

Rendu des templates, page d'index

CPU par requête, d'après le profil CPU

microsecondes · lower is better
  • Go
  • Rust
Go · html/template 167 microsecondes
Rust · serde + Tera 72 microsecondeslowest

Où axum + sqlx dépense du CPU sur l'index, d'après son profil (les parties se recoupent : les allocations ont lieu dans sqlx et Tera) :

  • sqlx : environ 112 µs par requête, dont environ 25 µs pour le ping quand une connexion retourne au pool
  • malloc de glibc : environ 56 µs
  • sendto du noyau : 38 µs

5. Rustango : ce que les correctifs ont changé

Quoi. Les profils de Rustango avant ces correctifs montraient quatre coûts. Trois sont corrigés :

Coût avantPart du CPUCorrectif
Un emprunt au pool pour chaque requête SQL19–35%Une connexion du pool par requête HTTP : détail +26%
Ping vers Postgres à chaque emprunt8–16%Ping désactivé par défaut
CachePageLayer stocke les pages en JSON + base64~12–20% par accès au cachePages stockées en octets bruts
Querysets reconstruits et compilés à chaque requête~2%Pas encore corrigé

Rustango · axum, avant → après les correctifs

Exécutions de profilage, une application à la fois

beforeafter
Index, req/s 11 215 req/s 15 849 req/s +41%
Détail, req/s 17 267 req/s 28 097 req/s +63%
Index, CPU de l'application 286.6 µs/req 220.8 µs/req −23%
Détail, CPU de l'application 164.7 µs/req 110.4 µs/req −33%

Dans l'exécution équitable, Rustango sur actix-web et sur axum sont les deux applications les plus rapides sur les trois pages sans cache.

Ouvert. Sur l'index en cache, Rustango · axum sert 174 501 req/s contre 198 669 pour axum + sqlx. Cet écart n'a pas encore été profilé.

6. actix-web : un pool partagé est lent

Quoi. Au round 1, les applications actix-web étaient derrière axum sur les pages sans cache et utilisaient moins de 300% de leur budget CPU de 400%.

Pourquoi. Avec un seul pool partagé, chaque requête SQL réveille un thread d'un autre runtime. Donner à chaque worker actix sa propre tranche de pool (4 des 16 connexions) et sa propre connexion Redis règle le problème. Le mécanisme, à savoir qu'un socket est piloté par le runtime qui l'a ouvert, est une déduction ; l'effet est mesuré.

Comment c'est prouvé. Mêmes applications, pool partagé contre tranches par worker :

Pool partagé → une tranche de pool par worker

Exécutions de profilage

beforeafter
actix + sqlx
Index 11 094 req/s 15 108 req/s +36%
Détail 18 080 req/s 23 330 req/s +29%
Index, en cache 192 604 req/s 238 884 req/s +24%
Rustango · actix
Index 12 052 req/s 17 454 req/s +45%
Détail 23 032 req/s 28 693 req/s +25%
Index, en cache 190 889 req/s 237 962 req/s +25%
Changements de contexte par requêtePool partagéPool par worker
actix + sqlx, index4.041.45
actix + sqlx, détail3.571.64
Rustango · actix, index2.700.81
Rustango · actix, détail2.341.03

Environ 2.5× moins de changements de contexte par requête. Ajouter des workers n'aide pas : 8 workers avec un pool partagé atteignent 11 904 req/s sur l'index actix + sqlx (4 workers, partagé : 11 094).

7. Rocket : fort sur les pages avec base de données, le plus lent en cache

Quoi. Rocket : détail 24 561 req/s, devant Go et les applications sqlx brutes ; index en cache 138 224, le résultat Rust le plus lent.

Pourquoi. Sur les pages avec base de données, Rocket prend une connexion par page et fait 5 allers-retours (constat 3). Sur les pages en cache, il dépense 24.1 µs de CPU par requête contre 14.6 µs pour axum + sqlx, avec 8.1 syscalls par requête contre 4.8. Le profil montre un writev supplémentaire (2.24 par requête contre 1.36) et de la contention dans malloc ; les appels futex sont de 2.88 par requête contre 0.91.

Comment c'est prouvé. Comptabilité CPU, comptage des syscalls et profils CPU sur l'index en cache.

8. Loco

Quoi. Loco : index 10 290, détail 15 635, index en cache 153 243 req/s.

Pourquoi. SeaORM envoie le même nombre de requêtes que Go. Sur les pages en cache, Loco dépense 21.0 µs par requête : 2.4 µs de plus dans malloc et 1.2 µs de plus dans les middlewares qu'axum + sqlx. Sur les pages avec base de données, le ping à l'acquisition ne peut pas être désactivé (le constat 3 montre son coût dans sqlx).

Comment c'est prouvé. Comptabilité CPU et profils CPU sur l'index en cache. Les pages avec base de données de Loco n'ont pas été profilées, donc la part de cet écart qu'explique le ping reste non prouvée.

9. Cot : l'API de requêtes fixe la limite

Quoi. Cot : index 2 018, détail 9 282, tag 3 065 req/s, mais index en cache 190 245.

Pourquoi. L'API de requêtes de Cot n'a que filter, limit et offset : pas de ORDER BY, JOIN, IN ni COUNT. Pour afficher les 20 derniers articles, l'index charge les 1 000 articles avec leur contenu. Les pages nécessitent 5, 5 et 7 requêtes au lieu de 3, 3 et 4. La mémoire sous charge est de 22.6 MiB. Les pages en cache ne touchent pas l'ORM et sont rapides : 16.4 µs de CPU par requête.

Comment c'est prouvé. Nombre de requêtes d'après le code, mémoire d'après l'exécution équitable, comptabilité CPU sur l'index en cache.

10. Mémoire

Voir le graphique de mémoire ci-dessus. Six des applications Rust utilisent 6.3–12.0 MiB sous charge. Go utilise 22.8 MiB. Cot utilise 22.6 MiB ; son index charge tous les articles à chaque requête (constat 9).

11. Coûts communs

Deux coûts apparaissent dans chaque application Rust et n'appartiennent à aucun framework :

  • Rendu Tera : environ 18–20% du CPU sur les pages sans cache
  • malloc de glibc : environ 12%

Un allocateur plus rapide comme mimalloc ou jemalloc pourrait réduire le second. Ce n'est pas testé.

Limites

  • Une seule machine : un Apple M3 Pro. D'autres CPU peuvent classer les résultats autrement, surtout pour le calcul (constat 1).
  • Une charge orientée lecture : pas d'écritures, pas de transactions.
  • Les applications tournent dans la VM Docker sous macOS, pas sur Linux bare-metal. La VM n'a pas de compteurs de performance matériels.

Conclusion : pourquoi Rustango sur actix-web est devenu bien plus rapide

Rustango · actix, page d'index

Trois exécutions : le round 1, une exécution de profilage et l'exécution équitable de ce round

req/s · higher is better
Round 1 (0.60.6) 6 341 req/s
Correctifs, pool partagé 12 052 req/s
Correctifs, pool par worker 17 401 req/sfastest

Deux changements, à deux endroits.

1. Les correctifs de Rustango. Ils aident sur axum comme sur actix-web :

  • Chaque colonne est décodée une seule fois, selon son type. Avant, le décodage prenait 46% du CPU.
  • Plus de ping avant chaque emprunt au pool : un aller-retour Postgres de moins par requête SQL.
  • Une connexion du pool par requête HTTP : les requêtes SQL d'une page partagent une connexion, au lieu d'un emprunt et d'un ping de retour chacune.

2. La configuration d'actix-web. axum exécute un seul runtime Tokio partagé et multi-thread. actix-web exécute un runtime mono-thread par worker. Avec un seul pool partagé par tous les workers, le socket d'une connexion reste piloté par le runtime qui l'a ouvert ; quand un autre worker utilise cette connexion, chaque lecture et chaque écriture réveillent l'autre thread. L'application fait alors 2.5× plus de changements de contexte et utilise environ 280% de ses 4 CPU. Une tranche de pool (4 des 16 connexions) et une connexion Redis par worker suppriment ces sauts entre threads :

  • Rustango · actix : index 12 052 → 17 454, détail 23 032 → 28 693, index en cache 190 889 → 237 962 req/s
  • actix + sqlx : index 11 094 → 15 108 req/s
  • 8 workers avec un pool partagé n'ont pas aidé : 11 904 req/s sur l'index actix + sqlx

Le gain de vitesse et la baisse des changements de contexte sont mesurés. La propriété des sockets est la meilleure explication, mais elle n'est pas prouvée directement. axum n'a pas ce problème, car toutes ses tâches tournent sur un seul runtime partagé.

En pratique : sur actix-web, créez le pool de base de données dans chaque worker, pas une seule fois pour tous les workers.

Me contacter →