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.
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/httpde 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) etrocket_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
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églage | Applications | Pourquoi |
|---|---|---|
Le pool envoie un ping à Postgres à chaque emprunt (sqlx test_before_acquire) | Rocket, Loco, Cot | Aucun réglage pour le désactiver. Il est désactivé dans axum + sqlx, actix + sqlx et Rustango. |
| 10 connexions de pool au lieu de 16 | Cot | Cot n'a pas de réglages de pool. |
| Une tranche de pool par worker (4 des 16 connexions) et une connexion Redis par worker | actix + sqlx, Rustango · actix | Un pool partagé est plus lent sur actix-web (voir constat 6). |
| sqlx 0.7.4 et hyper 0.14 | Rocket | Imposé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 4 | Cot | Son API de requêtes ne peut pas exprimer les requêtes de Go (voir constat 9). |
| askama au lieu de Tera | Cot | Le 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
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
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
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 :
| Application | Index | Détail | Tag | Index, en cache | Détail, en cache | Calcul |
|---|---|---|---|---|---|---|
| Go | 5.50 / 9.96 | 1.91 / 5.02 | 7.61 / 11.72 | 0.35 / 2.73 | 0.24 / 2.05 | 2.37 / 10.75 |
| axum + sqlx | 3.49 / 4.71 | 2.14 / 3.60 | 7.42 / 13.89 | 0.24 / 0.53 | 0.22 / 0.51 | 3.35 / 6.07 |
| actix + sqlx | 3.32 / 6.38 | 2.21 / 4.30 | 7.23 / 13.23 | 0.20 / 0.50 | 0.19 / 0.45 | 3.41 / 4.97 |
| Rocket | 3.66 / 5.83 | 1.97 / 3.58 | 7.14 / 11.55 | 0.34 / 0.70 | 0.30 / 0.67 | 3.40 / 6.14 |
| Rustango · axum | 3.28 / 5.65 | 1.83 / 3.55 | 6.90 / 11.18 | 0.27 / 0.59 | 0.25 / 0.52 | 3.38 / 6.09 |
| Rustango · actix | 2.80 / 4.51 | 1.77 / 3.37 | 6.91 / 11.53 | 0.20 / 0.48 | 0.19 / 0.45 | 3.41 / 3.96 |
| Loco | 4.62 / 10.84 | 2.99 / 6.34 | 8.67 / 14.52 | 0.30 / 0.79 | 0.28 / 0.62 | 3.48 / 6.90 |
| Cot | 23.35 / 50.36 | 5.28 / 8.02 | 15.57 / 27.06 | 0.25 / 0.50 | 0.23 / 0.52 | 3.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
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
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.
Où va le CPU restant, par requête sur l'index en cache :
| Go | axum + sqlx | |
|---|---|---|
| CPU de l'application | 27.2 µs | 14.6 µs |
| Chemin d'écriture du noyau | 10.6 µs | 6.9 µs |
| Syscalls | 10.7 (6.6 read, 2.9 write) | 4.8 (2.0 recvfrom, 1.4 writev) |
| CPU de Redis | 6.8 µs | 2.5 µs |
| Trafic Redis | go-redis : un GET par requête | une 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
La page d'index montre les mêmes chiffres.
axum + sqlx : ping à l'acquisition désactivé
Exécutions de profilage
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.
Rendu des templates, page d'index
CPU par requête, d'après le profil CPU
microsecondes · lower is better- Go
- Rust
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
mallocde glibc : environ 56 µssendtodu 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 avant | Part du CPU | Correctif |
|---|---|---|
| Un emprunt au pool pour chaque requête SQL | 19–35% | Une connexion du pool par requête HTTP : détail +26% |
| Ping vers Postgres à chaque emprunt | 8–16% | Ping désactivé par défaut |
CachePageLayer stocke les pages en JSON + base64 | ~12–20% par accès au cache | Pages 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
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
| Changements de contexte par requête | Pool partagé | Pool par worker |
|---|---|---|
| actix + sqlx, index | 4.04 | 1.45 |
| actix + sqlx, détail | 3.57 | 1.64 |
| Rustango · actix, index | 2.70 | 0.81 |
| Rustango · actix, détail | 2.34 | 1.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
mallocde 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 betterDeux 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.