Benchmark, runda 2: Rust, Go i frameworki Rusta pod profilerem
Go i siedem aplikacji w Rust (axum, actix-web, Rocket, Loco, Cot oraz Rustango na axum i actix) na tych samych zasadach, z uruchomioną tylko testowaną aplikacją, oraz profile CPU, wywołań systemowych i ruchu sieciowego, które pokazują, na co każda z nich zużywa czas.
To runda 2 uczciwego benchmarku. Runda 2 obejmuje tylko aplikacje kompilowane: Go i siedem aplikacji w Rust. Dodaje trzy frameworki Rusta (Rocket, Loco, Cot) oraz profile CPU, wywołań systemowych i ruchu sieciowego, które pokazują, na co każda aplikacja zużywa czas.
Pomiary z 14 października 2026 na Apple M3 Pro (maszyna wirtualna Dockera z 12 CPU i 16 GB).
- 441/s odśmiecania Go na indeksie z cache
- 3 vs 9 rund do Postgresa na stronę szczegółów: Go vs sqlx z pingiem przy pobraniu
- +45% Rustango na actix, strona indeksu, z jednym wycinkiem puli na workera
- 6.3–22.8 MiB pamięć pod obciążeniem, wszystkie osiem aplikacji
- 0 błędów lub odpowiedzi innych niż 200 w 144 przebiegach
Konfiguracja
Ten sam blog co w rundzie 1: 10 autorów, 30 tagów, 1 000 wpisów, 2 600 powiązań wpis-tag i 10 000 komentarzy. Sześć tras: indeks (20 najnowszych wpisów), strona szczegółów wpisu ze wszystkimi komentarzami, strona tagu, indeks i strona szczegółów z cache stron w Redis oraz /compute (suma liczb pierwszych poniżej 20 000, bez bazy danych).
Osiem aplikacji:
- Go:
net/httpz biblioteki standardowej, pgx v5, go-redis - axum + sqlx i actix + sqlx: surowy sqlx z tekstem SQL z Go, szablony Tera
- Rocket:
rocket_db_pools(sqlx) irocket_dyn_templates(Tera) - Rustango na axum i na actix-web: ORM Rustango i jego cache stron
- Loco: SeaORM, Tera
- Cot: ORM Cota, szablony askama
Zasady:
- Aplikacja dostaje 4 CPU (przypięte do rdzeni 0–3) i 2 GB RAM, z 4 workerami. PostgreSQL 16 i Redis 7 działają na rdzeniach 4–7, generator obciążenia oha na rdzeniach 8–11.
- Działa tylko testowana aplikacja; pozostałe są zatrzymane. Między aplikacjami liczba połączeń z Postgresem wraca do wartości bazowej.
- Pule: maks. 16 połączeń, min. 4.
- Przed jakimkolwiek obciążeniem każda strona każdej aplikacji jest porównywana z HTML-em z Go. Niezgodność przerywa przebieg.
- Bez kompresji. 5 s rozgrzewki, potem trzy przebiegi po 20 s na endpoint przy 50 połączeniach; liczy się mediana.
Stanowisko testowe
To samo stanowisko co w rundzie 1. Runda 2 używa tylko PostgreSQL i Redis (bez MySQL).
Wersje
- 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 bez pingu przy pobraniu, z cache stron przechowującym surowe bajty i jednym połączeniem z puli na żądanie (wniosek 5)
Ustawienia, których nie dało się wyrównać
| Ustawienie | Aplikacje | Dlaczego |
|---|---|---|
Pula pinguje Postgresa przy każdym pobraniu połączenia (sqlx test_before_acquire) | Rocket, Loco, Cot | Brak ustawienia, które to wyłącza. W axum + sqlx, actix + sqlx i Rustango jest wyłączone. |
| 10 połączeń w puli zamiast 16 | Cot | Cot nie ma ustawień puli. |
| Jeden wycinek puli na workera (4 z 16 połączeń) i jedno połączenie Redis na workera | actix + sqlx, Rustango · actix | Wspólna pula jest wolniejsza na actix-web (zob. wniosek 6). |
| sqlx 0.7.4 i hyper 0.14 | Rocket | Wymuszone przez Rocket 0.5.1. |
| 5, 5 i 7 zapytań na stronę indeksu, szczegółów i tagu zamiast 3, 3 i 4 | Cot | Jego API zapytań nie potrafi wyrazić zapytań z Go (zob. wniosek 9). |
| askama zamiast Tera | Cot | Silnik szablonów Cota. |
Wyniki
Żądania na sekundę, im więcej, tym lepiej.
Strony bez cache
Ten sam HTML, 50 połączeń, PostgreSQL przy każdym żądaniu
req/s · higher is better- Go
- Rust
- Rustango
- Loco
- Cot
Mediana z trzech przebiegów po 20 s przy 50 połączeniach. Działała tylko testowana aplikacja. 0 odpowiedzi innych niż 200, 0 błędów.
Strony z cache
Serwowane z cache stron w Redis, bez SQL
req/s · higher is better- Go
- Rust
- Rustango
- Loco
- Cot
Mediana z trzech przebiegów po 20 s przy 50 połączeniach. Działała tylko testowana aplikacja. 0 odpowiedzi innych niż 200, 0 błędów.
Obliczenia
Liczby pierwsze poniżej 20 000, bez bazy danych
req/s · higher is better- Go
- Rust
- Rustango
- Loco
- Cot
Mediana z trzech przebiegów po 20 s przy 50 połączeniach. Działała tylko testowana aplikacja. 0 odpowiedzi innych niż 200, 0 błędów.
Opóźnienie, p50 / p99 w ms:
| Aplikacja | Indeks | Szczegóły | Tag | Indeks, z cache | Szczegóły, z cache | Obliczenia |
|---|---|---|---|---|---|---|
| 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 |
Pamięć
Pamięć pod obciążeniem
Pamięć rezydentna kontenera aplikacji na indeksie bez cache
MiB · lower is better- Go
- Rust
- Rustango
- Loco
- Cot
docker stats, próbka z najwyższym CPU spośród sześciu pobranych podczas pierwszego przebiegu indeksu.
Najwięcej zużywają Go i Cot. Indeks Cota przy każdym żądaniu ładuje wszystkie 1 000 wpisów wraz z treścią (wniosek 9).
Wnioski
Każdy wniosek mówi, co zmierzono, dlaczego tak się dzieje i jak to wykazano. Profile to pojedyncze przebiegi po 25 s na tym samym stanowisku, jedna aplikacja naraz, więc ich req/s nieco różnią się od uczciwego przebiegu powyżej. Jeśli przyczyna nie jest udowodniona, jest to zaznaczone.
1. Obliczenia: Go jest szybsze o 8–17%
Co. Go obsługuje 16 580 req/s na /compute; aplikacje w Rust 14 184–15 330.
Dlaczego. Obie skompilowane pętle mają 8 instrukcji, 64-bitowy sdiv i jedno sprawdzenie dzielenia przez zero. Różnica leży w kolejności instrukcji, którą wybiera LLVM. To, który szczegół mikroarchitektury CPU spowalnia tę kolejność, jest nieudowodnione: maszyna wirtualna Dockera nie ma liczników sprzętowych.
Jak wykazano. Sama pętla, na jednym rdzeniu, poza HTTP:
Pętla liczb pierwszych, jedno wywołanie
Jeden rdzeń, bez HTTP
mikrosekundy · lower is better- Go
- Rust
Rust z dokładnie taką kolejnością instrukcji jak w Go działa w czasie Go. Reszta każdego żądania jest tańsza w Rust: 8 µs wobec 14 µs w Go.
Opóźnienie. Go ma niższe p50, ale wyższe p99. Średnie opóźnienie to 50 / req/s dla każdej aplikacji, więc średnie idą za przepustowością; różnica tkwi w rozrzucie. Go obsługuje żądania poza kolejnością: p10 0.56 ms, p99 10.75 ms. Aplikacje w Rust obsługują niemal w kolejności przybycia: axum + sqlx p10 1.85 ms, p99 6.07 ms. Dlaczego p99 w Go jest blisko 10 ms, jest nieudowodnione.
2. Strony z cache: garbage collector Go
Co. Na indeksie z cache Go obsługuje 91 839 req/s; axum + sqlx 198 669.
Dlaczego. Go wykonuje na tej trasie 441 odśmiecań na sekundę, a pauzy stop-the-world sumują się do około 10% czasu. Reszta to CPU na żądanie: 27.2 µs w Go wobec 14.6 µs w axum + sqlx.
Jak wykazano. Podniesienie GOGC usuwa większość różnicy:
Go, indeks z cache, dostrojony GC
Przed: domyślne GOGC=100. Przebiegi profilujące.
Na co idzie pozostałe CPU, na żądanie na indeksie z cache:
| Go | axum + sqlx | |
|---|---|---|
| CPU aplikacji | 27.2 µs | 14.6 µs |
| Ścieżka zapisu w jądrze | 10.6 µs | 6.9 µs |
| Wywołania systemowe | 10.7 (6.6 read, 2.9 write) | 4.8 (2.0 recvfrom, 1.4 writev) |
| CPU Redisa | 6.8 µs | 2.5 µs |
| Ruch do Redisa | go-redis: jeden GET na żądanie | jedno połączenie, GET-y z wielu żądań zebrane w jednym pipeline |
Go: CPU na żądanie z cache
łącznie 27.2 µs
- 39%Ścieżka zapisu w jądrze, 10.6 µs
- 61%Cała reszta, 16.6 µs
axum + sqlx: CPU na żądanie z cache
łącznie 14.6 µs
- 47%Ścieżka zapisu w jądrze, 6.9 µs
- 53%Cała reszta, 7.7 µs
3. Strona szczegółów: rundy do Postgresa
Co. Strona szczegółów: Go 22 607, axum + sqlx 22 842, Rocket 24 561, Rustango · axum 25 788 req/s. Wszystkie wysyłają te same trzy zapytania.
Dlaczego. Różni się liczba rund do Postgresa. pgx wysyła jedną na zapytanie. sqlx dodaje jedną, gdy połączenie jest pobierane z puli (ping przy pobraniu), i jedną, gdy do niej wraca. axum + sqlx pobiera połączenie na każde zapytanie, więc płaci za każde zapytanie; Rocket i Rustango pobierają jedno połączenie na stronę, więc płacą raz. Każda dodatkowa runda kosztuje około 10 µs CPU Postgresa.
Jak wykazano. Przechwycenie protokołu Postgresa pod obciążeniem i zliczenie komunikatów ReadyForQuery na stronę:
Rundy do Postgresa na stronę szczegółów
Przechwycenie ruchu, te same trzy zapytania; ping = ping sqlx przy pobraniu
rundy · lower is better- Go
- Rustango
- Rust
Strona indeksu pokazuje te same liczby.
axum + sqlx: wyłączony ping przy pobraniu
Przebiegi profilujące
Trzy rundy mniej oszczędzają 29–32 µs CPU Postgresa na stronę: około 10 µs każda.
Go ma najmniej rund, ale tu nie jest najszybsze: jego garbage collector zatrzymuje je na tej trasie przez około 9% czasu.
4. Strona indeksu: GC i renderowanie szablonów
Co. Strona indeksu: Go 8 741 req/s, axum + sqlx 14 140.
Dlaczego. Dwie zmierzone przyczyny. Znowu garbage collector Go (pauzy sumują się do około 8% czasu na tej trasie) oraz renderowanie szablonów: html/template w Go używa refleksji i kosztuje 167 µs na żądanie; serde + Tera w Rust kosztują 72 µs.
Jak wykazano.
Go, indeks bez cache, dostrojony GC
Przed: domyślne GOGC=100. Przebiegi profilujące.
Renderowanie szablonów, strona indeksu
CPU na żądanie, z profilu CPU
mikrosekundy · lower is better- Go
- Rust
Na co axum + sqlx zużywa CPU na indeksie, według jego profilu (części się pokrywają: alokacje zachodzą wewnątrz sqlx i Tera):
- sqlx: około 112 µs na żądanie, z czego około 25 µs to ping, gdy połączenie wraca do puli
mallocz glibc: około 56 µssendtow jądrze: 38 µs
5. Rustango: co zmieniły poprawki
Co. Profile Rustango sprzed tych poprawek pokazały cztery koszty. Trzy są naprawione:
| Koszt przed | Udział w CPU | Poprawka |
|---|---|---|
| Pobranie połączenia z puli przy każdym zapytaniu | 19–35% | Jedno połączenie z puli na żądanie: szczegóły +26% |
| Ping do Postgresa przy każdym pobraniu | 8–16% | Ping domyślnie wyłączony |
CachePageLayer przechowuje strony jako JSON + base64 | ~12–20% na trafienie w cache | Strony przechowywane jako surowe bajty |
| Querysety budowane i kompilowane od nowa przy każdym żądaniu | ~2% | Jeszcze nienaprawione |
Rustango · axum, przed → po poprawkach
Przebiegi profilujące, jedna aplikacja naraz
W uczciwym przebiegu Rustango na actix-web i na axum to dwie najszybsze aplikacje na wszystkich trzech stronach bez cache.
Otwarte. Na indeksie z cache Rustango · axum obsługuje 174 501 req/s wobec 198 669 w axum + sqlx. Tej różnicy jeszcze nie sprofilowano.
6. actix-web: wspólna pula jest wolna
Co. W rundzie 1 aplikacje actix-web zostawały w tyle za axum na stronach bez cache i używały mniej niż 300% ze swojego budżetu 400% CPU.
Dlaczego. Przy jednej wspólnej puli każde zapytanie budzi wątek innego runtime'u. Danie każdemu workerowi actix własnego wycinka puli (4 z 16 połączeń) i własnego połączenia Redis rozwiązuje problem. Mechanizm, czyli to, że gniazdo jest obsługiwane przez runtime, który je otworzył, to wnioskowanie; efekt jest zmierzony.
Jak wykazano. Te same aplikacje, wspólna pula wobec wycinków na workera:
Wspólna pula → jeden wycinek puli na workera
Przebiegi profilujące
| Przełączenia kontekstu na żądanie | Wspólna pula | Pula na workera |
|---|---|---|
| actix + sqlx, indeks | 4.04 | 1.45 |
| actix + sqlx, szczegóły | 3.57 | 1.64 |
| Rustango · actix, indeks | 2.70 | 0.81 |
| Rustango · actix, szczegóły | 2.34 | 1.03 |
Około 2.5× mniej przełączeń kontekstu na żądanie. Więcej workerów nie pomaga: 8 workerów ze wspólną pulą osiąga 11 904 req/s na indeksie actix + sqlx (4 workery, wspólna: 11 094).
7. Rocket: mocny na stronach z bazą, najwolniejszy z cache
Co. Rocket: szczegóły 24 561 req/s, przed Go i aplikacjami z surowym sqlx; indeks z cache 138 224, najwolniejszy wynik w Rust.
Dlaczego. Na stronach z bazą Rocket pobiera jedno połączenie na stronę i wykonuje 5 rund (wniosek 3). Na stronach z cache zużywa 24.1 µs CPU na żądanie wobec 14.6 µs w axum + sqlx, przy 8.1 wywołaniach systemowych na żądanie wobec 4.8. Profil pokazuje dodatkowy writev (2.24 na żądanie wobec 1.36) i rywalizację w malloc; wywołań futex jest 2.88 na żądanie wobec 0.91.
Jak wykazano. Rozliczenie CPU, liczniki wywołań systemowych i profile CPU na indeksie z cache.
8. Loco
Co. Loco: indeks 10 290, szczegóły 15 635, indeks z cache 153 243 req/s.
Dlaczego. SeaORM wysyła tyle samo zapytań co Go. Na stronach z cache Loco zużywa 21.0 µs na żądanie: o 2.4 µs więcej w malloc i o 1.2 µs więcej w middleware niż axum + sqlx. Na stronach z bazą pingu przy pobraniu nie da się wyłączyć (wniosek 3 pokazuje jego koszt w sqlx).
Jak wykazano. Rozliczenie CPU i profile CPU na indeksie z cache. Stron Loco z bazą nie profilowano, więc to, jaką część tej różnicy wyjaśnia ping, jest nieudowodnione.
9. Cot: granicę wyznacza API zapytań
Co. Cot: indeks 2 018, szczegóły 9 282, tag 3 065 req/s, ale indeks z cache 190 245.
Dlaczego. API zapytań Cota ma tylko filter, limit i offset: brak ORDER BY, JOIN, IN i COUNT. Aby pokazać 20 najnowszych wpisów, indeks ładuje wszystkie 1 000 wpisów wraz z treścią. Strony potrzebują 5, 5 i 7 zapytań zamiast 3, 3 i 4. Pamięć pod obciążeniem to 22.6 MiB. Strony z cache nie dotykają ORM i są szybkie: 16.4 µs CPU na żądanie.
Jak wykazano. Liczba zapytań z kodu, pamięć z uczciwego przebiegu, rozliczenie CPU na indeksie z cache.
10. Pamięć
Zob. wykres pamięci powyżej. Sześć aplikacji w Rust używa 6.3–12.0 MiB pod obciążeniem. Go używa 22.8 MiB. Cot używa 22.6 MiB; jego indeks ładuje każdy wpis przy każdym żądaniu (wniosek 9).
11. Wspólne koszty
Dwa koszty pojawiają się w każdej aplikacji w Rust i nie należą do żadnego frameworka:
- Renderowanie Tera: około 18–20% CPU na stronach bez cache
mallocz glibc: około 12%
Szybszy alokator, taki jak mimalloc lub jemalloc, mógłby zmniejszyć ten drugi. To nieprzetestowane.
Ograniczenia
- Jedna maszyna: Apple M3 Pro. Inne procesory mogą ułożyć wyniki inaczej, zwłaszcza w obliczeniach (wniosek 1).
- Obciążenie zdominowane przez odczyty: bez zapisów, bez transakcji.
- Aplikacje działają w maszynie wirtualnej Dockera na macOS, nie na Linuksie bare-metal. Maszyna wirtualna nie ma sprzętowych liczników wydajności.
Podsumowanie: dlaczego Rustango na actix-web stało się dużo szybsze
Rustango · actix, strona indeksu
Trzy przebiegi: runda 1, przebieg profilujący i uczciwy przebieg tej rundy
req/s · higher is betterDwie zmiany, w dwóch miejscach.
1. Poprawki w Rustango. Pomagają zarówno na axum, jak i na actix-web:
- Każda kolumna jest dekodowana raz, według swojego typu. Wcześniej dekodowanie zajmowało 46% CPU.
- Brak pingu przed każdym pobraniem z puli: jedna runda do Postgresa mniej na zapytanie.
- Jedno połączenie z puli na żądanie: zapytania jednej strony dzielą jedno połączenie, zamiast każde osobno pobierać połączenie i pingować przy zwrocie.
2. Konfiguracja actix-web. axum uruchamia jeden wspólny, wielowątkowy runtime Tokio. actix-web uruchamia jednowątkowy runtime na każdego workera. Przy jednej puli wspólnej dla wszystkich workerów gniazdo połączenia pozostaje obsługiwane przez runtime, który je otworzył, więc gdy inny worker używa tego połączenia, każdy odczyt i zapis budzi tamten wątek. Aplikacja wykonuje wtedy 2.5× więcej przełączeń kontekstu i używa około 280% ze swoich 4 CPU. Jeden wycinek puli (4 z 16 połączeń) i jedno połączenie Redis na workera usuwają te przeskoki między wątkami:
- Rustango · actix: indeks 12 052 → 17 454, szczegóły 23 032 → 28 693, indeks z cache 190 889 → 237 962 req/s
- actix + sqlx: indeks 11 094 → 15 108 req/s
- 8 workerów ze wspólną pulą nie pomogło: 11 904 req/s na indeksie actix + sqlx
Przyspieszenie i spadek liczby przełączeń kontekstu są zmierzone. Własność gniazd to najlepsze wyjaśnienie, ale nie jest udowodnione bezpośrednio. axum nie ma tego problemu, bo wszystkie jego zadania działają w jednym wspólnym runtime.
W praktyce: na actix-web twórz pulę połączeń z bazą w każdym workerze, a nie raz dla wszystkich workerów.