Недавно мне на глаза попалась статья, в которой утверждалось, что производительность tokio/hyper от Rust выше, чем у go http. Но неожиданным был комментарий со сравнением против fasthttp, где Rust ведет себя немного хуже.
Решил провести тестирование производительности самостоятельно. Все тесты выполняются на экземпляре AWS r5n.8xlarge с помощью следующей команды:
wrk -t16 -c1000 -d120s http://127.0.0.1:8080/
Код go/fasthttp:
package main
import (
“github.com/valyala/fasthttp”
)
func fastHTTPHandler(ctx *fasthttp.RequestCtx) {
ctx.Success(“application/json”, []byte(“Welcome!”))
}
func main() {
// передаем в fasthttp обычную функцию
fasthttp.ListenAndServe(“:8080”, fastHTTPHandler)
}
Код rust/warp:
use warp::Filter;
use http::header::{HeaderValue, HeaderName};
#[tokio::main]
async fn main() {
let json = HeaderValue::from_static("application/json");
let server_key = HeaderName::from_static("server");
let server = HeaderValue::from_static("rusthttp");
let routes = warp::any()
.map(|| "Welcome!")
.with(warp::reply::with::header(http::header::CONTENT_TYPE, json))
.with(warp::reply::with::header(server_key, server)); warp::serve(routes)
.run(([127, 0, 0, 1], 8080))
.await;
}
Обратите внимание: чтобы соответствовать реализации fasthttp, здесь потребовались дополнительные заголовки.
Первоначальные результаты тестов
go/fasthttp:
Запросы (в сек.): 1153173,95
Передача (в сек.): 146,27 Мб
rust/warp:
Запросы (в сек.): 1044974,17
Передача (в сек.): 132,54 Мб
fasthttp опережает версию Rust примерно на 10 %!
Оптимизация версии Rust
Есть несколько хитрых приемов, способных улучшить работу Rust. Применим их один за другим и посмотрим, что получится.
Активируем LTO
LTO — это оптимизация времени компоновки, уменьшающая финальный размер двоичного кода и улучшающая производительность. Больше об LTO.
Запросы (в сек.): 1099343,12
Передача (в сек.): 139,44 Мб
Здесь мы получили +5 % по сравнению с первоначальной версией.
Задействуем «Jemalloc»
В коде на Rust по умолчанию используется распределитель памяти libc, обычно нерекомендуемый для критически важных приложений с высокими нагрузками.
Но есть много других программ для выделения памяти. По моему мнению, jemalloc — лучшая по производительности и использованию памяти. Больше о jemalloc.
Запросы (в сек.): 1119851,69
Передача (в сек.): 142,04 Мб
Здесь мы получили +1,8 % по сравнению с LTO-версией.
Подключаем «unstable_pipeline»
Бенчмарк с wrk — это очень простой, маленький текстовый тест HTTP/1. Но в таких тестах есть некоторые особенности. Подключим unstable_pipeline
в экземпляр warp:
Запросы (в сек.): 1163136,54
Передача (в сек.): 147,53 Мб
Здесь мы получили еще +3,8 % по сравнению с версией LTO+jemalloc. И превзошли fasthttp!
Оптимизируем tokio
Посмотрим flamegraph и узнаем, есть ли явная неэффективность в запускаемом коде. Не буду выкладывать здесь все, чтобы не перегружать статью. Обращу лишь внимание на функцию wake0
(см. тут):
const NUM_WAKERS: usize = 32;
let mut wakers: [Option<Waker>; NUM_WAKERS] = Default::default();
Массив wakers
создается при каждом вызове функции, чтобы собирать все wakers
, которые необходимо уведомлять (и он фактически уведомляет их).
Поиграв с этим кодом, я понял: уменьшение NUM_WAKERS
ведет к увеличению производительности! Но это массив, а не вектор, и выделяется он в стеке, поэтому выделение памяти под него не происходит.
В чем причина недостаточной эффективности? При инициализации массива в Rust значение None
копируется в каждую ячейку массива. И при увеличении NUM_WAKERS
это приводит к замедлению.
Попробуем повысить эффективность, использовав относительно новую структуру MaybeUnint
. С помощью MaybeUnint::unint().assume_init()
создаем массив со всеми элементами, обернутыми в MaybeUnint
(больше об этом здесь). Обратите внимание: Option
больше не нужен.
Посмотрите в запрос на включение изменений в репозиторий с этой реализацией:
Запросы (в сек.): 1196535,29
Передача (в сек.): 151,77 Мб
Здесь мы получили еще +2,8 % и превзошли go/fasthttp на 3,7 %!
Что не совсем получилось
Было испробовано еще несколько хитрых приемов, но большого воздействия они не оказали:
- aHash вместо FNV в http-заголовках, вопреки ожиданиям, дал небольшое снижение производительности.
- При попытке скомпилировать с помощью
RUSTFLAGS=’-C target-cpu=native’
произошло серьезное снижение >10 %. - Уменьшение числа вызовов
SystemTime::now()
в hyper. Hyper нужно генерировать заголовокDate
.
В третьем пункте используется оптимизация для отображения даты в формате RFC 7231 один раз в секунду. Но для получения времени при каждом HTTP-вызове этот вызов все равно выполняется. А это около 0,6 % времени, затрачиваемого в clock_gettime
на flamegraph.
Возможная оптимизация здесь: кешировать время и сбрасывать его один раз за каждую приостановку потока на tokio. По этой теме есть открытый вопрос.
Мне показалось, что реализовывать сложновато. Вместо этого я закомментировал повторное создание отображаемой даты на hyper. Никакого стабильного улучшения не выявлено.
Читайте также:
- Сравнение Go и Rust через написание CLI-инструмента
- Кто на свете всех сильнее - Java, Go и Rust в сравнении
- Go скучный. И это здорово!
Читайте нас в Telegram, VK и Яндекс.Дзен
Перевод статьи Gleb Pomykalov: Let’s overtake go/fasthttp with rust/warp