Бенчмарк, раунд 2: Rust, Go і фреймворки Rust під профайлером
Go і сім застосунків на Rust (axum, actix-web, Rocket, Loco, Cot і Rustango на axum та actix) за однаковими правилами, коли працює лише застосунок під тестом, а також профілі CPU, системних викликів і мережевого обміну, які показують, на що кожен із них витрачає час.
Це раунд 2 чесного бенчмарку. Раунд 2 охоплює лише компільовані застосунки: Go і сім застосунків на Rust. Він додає три фреймворки Rust (Rocket, Loco, Cot) і профілі CPU, системних викликів і мережевого обміну, які показують, на що кожен застосунок витрачає час.
Виміряно 14 жовтня 2026 року на Apple M3 Pro (Docker VM з 12 CPU і 16 GB).
- 441/s збирань сміття Go на кешованому індексі
- 3 vs 9 обмінів із Postgres на сторінку деталей: Go vs sqlx із пінгом під час отримання
- +45% Rustango на actix, сторінка індексу, з окремою часткою пулу на кожен воркер
- 6.3–22.8 MiB пам'ять під навантаженням, усі вісім застосунків
- 0 помилок або відповідей не-200 за 144 прогони
Налаштування
Той самий блог, що й у раунді 1: 10 авторів, 30 тегів, 1 000 дописів, 2 600 зв'язків допис-тег і 10 000 коментарів. Шість маршрутів: індекс (20 найновіших дописів), сторінка допису з усіма коментарями, сторінка тегу, індекс і сторінка допису з кешу сторінок у Redis, а також /compute (сума простих чисел менших за 20 000, без бази даних).
Вісім застосунків:
- Go:
net/httpзі стандартної бібліотеки, pgx v5, go-redis - axum + sqlx і actix + sqlx: сирий sqlx із SQL-текстом Go, шаблони Tera
- Rocket:
rocket_db_pools(sqlx) іrocket_dyn_templates(Tera) - Rustango на axum і на actix-web: ORM Rustango та його кеш сторінок
- Loco: SeaORM, Tera
- Cot: ORM Cot, шаблони askama
Правила:
- Застосунок отримує 4 CPU (закріплені за ядрами 0–3) і 2 GB RAM, з 4 воркерами. PostgreSQL 16 і Redis 7 працюють на ядрах 4–7, генератор навантаження oha на ядрах 8–11.
- Працює лише застосунок під тестом; решта зупинені. Між застосунками кількість з'єднань із Postgres повертається до базового рівня.
- Пули: максимум 16 з'єднань, мінімум 4.
- Перед будь-яким навантаженням кожна сторінка кожного застосунку порівнюється з HTML від Go. Розбіжність зупиняє прогін.
- Без стиснення. 5 s розігріву, потім три прогони по 20 s на endpoint при 50 з'єднаннях; враховується медіана.
Тестовий стенд
Той самий стенд, що й у раунді 1. Раунд 2 використовує лише PostgreSQL і Redis (без MySQL).
Версії
- 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 без пінгу під час отримання з'єднання, з кешем сторінок, що зберігає сирі байти, і одним з'єднанням із пулу на запит (висновок 5)
Налаштування, які не вдалося вирівняти
| Налаштування | Застосунки | Чому |
|---|---|---|
Пул пінгує Postgres при кожному отриманні з'єднання (sqlx test_before_acquire) | Rocket, Loco, Cot | Немає налаштування, щоб його вимкнути. В axum + sqlx, actix + sqlx і Rustango його вимкнено. |
| 10 з'єднань у пулі замість 16 | Cot | Cot не має налаштувань пулу. |
| Окрема частка пулу на кожен воркер (4 з 16 з'єднань) і одне з'єднання Redis на воркер | actix + sqlx, Rustango · actix | Спільний пул повільніший на actix-web (див. висновок 6). |
| sqlx 0.7.4 і hyper 0.14 | Rocket | Зафіксовані Rocket 0.5.1. |
| 5, 5 і 7 запитів на сторінку індексу, деталей і тегу замість 3, 3 і 4 | Cot | Його API запитів не може виразити запити Go (див. висновок 9). |
| askama замість Tera | Cot | Шаблонізатор Cot. |
Результати
Запитів на секунду, більше — краще.
Сторінки без кешу
Той самий HTML, 50 з'єднань, PostgreSQL на кожен запит
req/s · higher is better- Go
- Rust
- Rustango
- Loco
- Cot
Медіана трьох прогонів по 20 s при 50 з'єднаннях. Працював лише застосунок під тестом. 0 відповідей не-200, 0 помилок.
Кешовані сторінки
Віддаються з кешу сторінок у Redis, без SQL
req/s · higher is better- Go
- Rust
- Rustango
- Loco
- Cot
Медіана трьох прогонів по 20 s при 50 з'єднаннях. Працював лише застосунок під тестом. 0 відповідей не-200, 0 помилок.
Обчислення
Прості числа менші за 20 000, без бази даних
req/s · higher is better- Go
- Rust
- Rustango
- Loco
- Cot
Медіана трьох прогонів по 20 s при 50 з'єднаннях. Працював лише застосунок під тестом. 0 відповідей не-200, 0 помилок.
Затримка, p50 / p99 у ms:
| Застосунок | Індекс | Деталі | Тег | Індекс, кешований | Деталі, кешовані | Обчислення |
|---|---|---|---|---|---|---|
| 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 |
Пам'ять
Пам'ять під навантаженням
Резидентна пам'ять контейнера застосунку на некешованому індексі
MiB · lower is better- Go
- Rust
- Rustango
- Loco
- Cot
docker stats, вибірка з найвищим CPU з шести, знятих під час першого прогону індексу.
Найбільше використовують Go і Cot. Індекс Cot на кожен запит завантажує всі 1 000 дописів разом із текстом (висновок 9).
Висновки
Кожен висновок каже, що виміряно, чому так відбувається і як це доведено. Профілі — це одиночні прогони по 25 s на тому самому стенді, по одному застосунку, тож їхні req/s трохи відрізняються від чесного прогону вище. Якщо причину не доведено, про це сказано.
1. Обчислення: Go швидший на 8–17%
Що. Go обслуговує 16 580 req/s на /compute; застосунки на Rust — 14 184–15 330.
Чому. Обидва скомпільовані цикли мають 8 інструкцій, 64-бітний sdiv і одну перевірку ділення на нуль. Різниця — у порядку інструкцій, який обирає LLVM. Яка мікроархітектурна деталь CPU робить цей порядок повільнішим, не доведено: у Docker VM немає апаратних лічильників.
Як доведено. Сам цикл, на одному ядрі, поза HTTP:
Цикл простих чисел, один виклик
Одне ядро, без HTTP
мікросекунди · lower is better- Go
- Rust
Rust із точно таким самим порядком інструкцій, як у Go, виконується за час Go. Решта кожного запиту в Rust дешевша: 8 µs проти 14 µs у Go.
Затримка. Go має нижчий p50, але вищий p99. Середня затримка дорівнює 50 / req/s для кожного застосунку, тож середні значення йдуть за пропускною здатністю; різниця — у розкиді. Go обслуговує запити не по черзі: p10 0.56 ms, p99 10.75 ms. Застосунки на Rust обслуговують майже в порядку надходження: axum + sqlx p10 1.85 ms, p99 6.07 ms. Чому p99 у Go тримається біля 10 ms, не доведено.
2. Кешовані сторінки: збирач сміття Go
Що. На кешованому індексі Go обслуговує 91 839 req/s; axum + sqlx — 198 669.
Чому. Go виконує на цьому маршруті 441 збирання сміття на секунду, і паузи stop-the-world у сумі займають близько 10% часу. Решта — CPU на запит: 27.2 µs у Go проти 14.6 µs в axum + sqlx.
Як доведено. Підвищення GOGC прибирає більшу частину розриву:
Go, кешований індекс, налаштований GC
До: типове GOGC=100. Профілювальні прогони.
Куди йде решта CPU, на запит на кешованому індексі:
| Go | axum + sqlx | |
|---|---|---|
| CPU застосунку | 27.2 µs | 14.6 µs |
| Шлях запису в ядрі | 10.6 µs | 6.9 µs |
| Системні виклики | 10.7 (6.6 read, 2.9 write) | 4.8 (2.0 recvfrom, 1.4 writev) |
| CPU Redis | 6.8 µs | 2.5 µs |
| Трафік Redis | go-redis: один GET на запит | одне з'єднання, GET-и багатьох запитів об'єднані в один pipeline |
Go: CPU на кешований запит
27.2 µs загалом
- 39%Шлях запису в ядрі, 10.6 µs
- 61%Усе інше, 16.6 µs
axum + sqlx: CPU на кешований запит
14.6 µs загалом
- 47%Шлях запису в ядрі, 6.9 µs
- 53%Усе інше, 7.7 µs
3. Сторінка деталей: обміни з Postgres
Що. Сторінка деталей: Go 22 607, axum + sqlx 22 842, Rocket 24 561, Rustango · axum 25 788 req/s. Усі надсилають ті самі три запити.
Чому. Відрізняється кількість обмінів (round trips) із Postgres. pgx надсилає один на запит. sqlx додає один, коли з'єднання береться з пулу (пінг під час отримання), і ще один, коли воно повертається. axum + sqlx бере з'єднання на кожен запит, тож платить за кожен запит; Rocket і Rustango беруть одне з'єднання на сторінку, тож платять один раз. Кожен зайвий обмін коштує близько 10 µs CPU Postgres.
Як доведено. Перехоплення протоколу Postgres під навантаженням із підрахунком повідомлень ReadyForQuery на сторінку:
Обміни з Postgres на сторінку деталей
Перехоплення трафіку, ті самі три запити; ping = пінг sqlx під час отримання
обміни · lower is better- Go
- Rustango
- Rust
Сторінка індексу показує ті самі значення.
axum + sqlx: пінг під час отримання вимкнено
Профілювальні прогони
Три обміни менше заощаджують 29–32 µs CPU Postgres на сторінку: близько 10 µs кожен.
Go має найменше обмінів, але тут не найшвидший: його збирач сміття зупиняє його приблизно на 9% часу на цьому маршруті.
4. Сторінка індексу: GC і рендеринг шаблонів
Що. Сторінка індексу: Go 8 741 req/s, axum + sqlx 14 140.
Чому. Дві виміряні причини. Знову збирач сміття Go (паузи займають близько 8% часу на цьому маршруті) і рендеринг шаблонів: html/template у Go використовує рефлексію і коштує 167 µs на запит; serde + Tera у Rust коштують 72 µs.
Як доведено.
Go, некешований індекс, налаштований GC
До: типове GOGC=100. Профілювальні прогони.
Рендеринг шаблонів, сторінка індексу
CPU на запит, із профілю CPU
мікросекунди · lower is better- Go
- Rust
На що axum + sqlx витрачає CPU на індексі, за його профілем (частини перетинаються: алокації відбуваються всередині sqlx і Tera):
- sqlx: близько 112 µs на запит, з яких близько 25 µs — пінг, коли з'єднання повертається в пул
mallocз glibc: близько 56 µssendtoу ядрі: 38 µs
5. Rustango: що змінили виправлення
Що. Профілі Rustango до цих виправлень показали чотири витрати. Три виправлено:
| Витрата до | Частка CPU | Виправлення |
|---|---|---|
| Отримання з'єднання з пулу на кожен запит до БД | 19–35% | Одне з'єднання з пулу на HTTP-запит: деталі +26% |
| Пінг до Postgres при кожному отриманні | 8–16% | Пінг типово вимкнено |
CachePageLayer зберігає сторінки як JSON + base64 | ~12–20% на влучання в кеш | Сторінки зберігаються як сирі байти |
| Querysets перебудовуються і компілюються на кожен запит | ~2% | Ще не виправлено |
Rustango · axum, до → після виправлень
Профілювальні прогони, по одному застосунку
У чесному прогоні Rustango на actix-web і на axum — два найшвидші застосунки на всіх трьох некешованих сторінках.
Відкрите питання. На кешованому індексі Rustango · axum обслуговує 174 501 req/s проти 198 669 в axum + sqlx. Цей розрив ще не профілювали.
6. actix-web: спільний пул повільний
Що. У раунді 1 застосунки на actix-web відставали від axum на некешованих сторінках і використовували менше 300% зі свого бюджету CPU в 400%.
Чому. З одним спільним пулом кожен запит до БД будить потік іншого рантайму. Якщо дати кожному воркеру actix власну частку пулу (4 з 16 з'єднань) і власне з'єднання Redis, проблема зникає. Механізм — що сокет обслуговує рантайм, який його відкрив, — це припущення; ефект виміряно.
Як доведено. Ті самі застосунки, спільний пул проти часток на воркер:
Спільний пул → окрема частка пулу на воркер
Профілювальні прогони
| Перемикань контексту на запит | Спільний пул | Пул на воркер |
|---|---|---|
| actix + sqlx, індекс | 4.04 | 1.45 |
| actix + sqlx, деталі | 3.57 | 1.64 |
| Rustango · actix, індекс | 2.70 | 0.81 |
| Rustango · actix, деталі | 2.34 | 1.03 |
Приблизно у 2.5× менше перемикань контексту на запит. Більше воркерів не допомагає: 8 воркерів зі спільним пулом досягають 11 904 req/s на індексі actix + sqlx (4 воркери, спільний: 11 094).
7. Rocket: сильний на сторінках із БД, найповільніший із кешем
Що. Rocket: деталі 24 561 req/s, попереду Go і застосунків на сирому sqlx; кешований індекс 138 224, найповільніший результат серед Rust.
Чому. На сторінках із БД Rocket бере одне з'єднання на сторінку і робить 5 обмінів (висновок 3). На кешованих сторінках він витрачає 24.1 µs CPU на запит проти 14.6 µs в axum + sqlx, з 8.1 системного виклику на запит проти 4.8. Профіль показує зайвий writev (2.24 на запит проти 1.36) і конкуренцію в malloc; викликів futex 2.88 на запит проти 0.91.
Як доведено. Облік CPU, підрахунок системних викликів і профілі CPU на кешованому індексі.
8. Loco
Що. Loco: індекс 10 290, деталі 15 635, кешований індекс 153 243 req/s.
Чому. SeaORM надсилає стільки ж запитів, скільки Go. На кешованих сторінках Loco витрачає 21.0 µs на запит: на 2.4 µs більше в malloc і на 1.2 µs більше в middleware, ніж axum + sqlx. На сторінках із БД пінг під час отримання вимкнути неможливо (висновок 3 показує його вартість у sqlx).
Як доведено. Облік CPU і профілі CPU на кешованому індексі. Сторінки Loco з БД не профілювали, тож яку частину цього розриву пояснює пінг, не доведено.
9. Cot: межу задає API запитів
Що. Cot: індекс 2 018, деталі 9 282, тег 3 065 req/s, але кешований індекс 190 245.
Чому. API запитів Cot має лише filter, limit і offset: немає ORDER BY, JOIN, IN чи COUNT. Щоб показати 20 найновіших дописів, індекс завантажує всі 1 000 дописів разом із текстом. Сторінкам потрібно 5, 5 і 7 запитів замість 3, 3 і 4. Пам'ять під навантаженням — 22.6 MiB. Кешовані сторінки не торкаються ORM і швидкі: 16.4 µs CPU на запит.
Як доведено. Кількість запитів із коду, пам'ять із чесного прогону, облік CPU на кешованому індексі.
10. Пам'ять
Див. графік пам'яті вище. Шість застосунків на Rust використовують 6.3–12.0 MiB під навантаженням. Go використовує 22.8 MiB. Cot використовує 22.6 MiB; його індекс завантажує кожен допис на кожен запит (висновок 9).
11. Спільні витрати
Дві витрати є в кожному застосунку на Rust і не належать жодному фреймворку:
- Рендеринг Tera: близько 18–20% CPU на некешованих сторінках
mallocз glibc: близько 12%
Швидший алокатор, як-от mimalloc чи jemalloc, міг би зменшити другу. Це не перевірено.
Обмеження
- Одна машина: Apple M3 Pro. Інші процесори можуть упорядкувати результати інакше, особливо в обчисленнях (висновок 1).
- Навантаження переважно на читання: без записів, без транзакцій.
- Застосунки працюють у Docker VM на macOS, а не на bare-metal Linux. У VM немає апаратних лічильників продуктивності.
Підсумок: чому Rustango на actix-web став набагато швидшим
Rustango · actix, сторінка індексу
Три прогони: раунд 1, профілювальний прогін і чесний прогін цього раунду
req/s · higher is betterДві зміни, у двох місцях.
1. Виправлення в Rustango. Вони допомагають і на axum, і на actix-web:
- Кожна колонка декодується один раз, за своїм типом. Раніше декодування забирало 46% CPU.
- Немає пінгу перед кожним отриманням із пулу: на один обмін із Postgres менше на кожен запит до БД.
- Одне з'єднання з пулу на HTTP-запит: запити однієї сторінки ділять одне з'єднання, а не отримують його й пінгують під час повернення кожен окремо.
2. Налаштування actix-web. axum працює на одному спільному багатопотоковому рантаймі Tokio. actix-web запускає однопотоковий рантайм на кожен воркер. Коли один пул спільний для всіх воркерів, сокет з'єднання лишається за рантаймом, який його відкрив, тож коли інший воркер використовує це з'єднання, кожне читання і запис будять інший потік. Тоді застосунок робить у 2.5× більше перемикань контексту і використовує близько 280% своїх 4 CPU. Окрема частка пулу (4 з 16 з'єднань) і одне з'єднання Redis на воркер прибирають ці переходи між потоками:
- Rustango · actix: індекс 12 052 → 17 454, деталі 23 032 → 28 693, кешований індекс 190 889 → 237 962 req/s
- actix + sqlx: індекс 11 094 → 15 108 req/s
- 8 воркерів зі спільним пулом не допомогли: 11 904 req/s на індексі actix + sqlx
Прискорення і падіння кількості перемикань контексту виміряно. Прив'язка сокетів до рантайму — найкраще пояснення, але прямо воно не доведене. В axum такої проблеми немає, бо всі його задачі працюють на одному спільному рантаймі.
На практиці: в actix-web створюйте пул з'єднань із БД усередині кожного воркера, а не один раз для всіх воркерів.