Недавно мне на глаза попалась статья, в которой утверждалось, что производительность 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 %!

Что не совсем получилось

Было испробовано еще несколько хитрых приемов, но большого воздействия они не оказали:

  1. aHash вместо FNV в http-заголовках, вопреки ожиданиям, дал небольшое снижение производительности.
  2. При попытке скомпилировать с помощью RUSTFLAGS=’-C target-cpu=native’ произошло серьезное снижение >10 %.
  3. Уменьшение числа вызовов SystemTime::now() в hyper. Hyper нужно генерировать заголовок Date.

В третьем пункте используется оптимизация для отображения даты в формате RFC 7231 один раз в секунду. Но для получения времени при каждом HTTP-вызове этот вызов все равно выполняется. А это около 0,6 % времени, затрачиваемого в clock_gettime на flamegraph.

Возможная оптимизация здесь: кешировать время и сбрасывать его один раз за каждую приостановку потока на tokio. По этой теме есть открытый вопрос.

Мне показалось, что реализовывать сложновато. Вместо этого я закомментировал повторное создание отображаемой даты на hyper. Никакого стабильного улучшения не выявлено.

Читайте также:

Читайте нас в TelegramVK и Яндекс.Дзен


Перевод статьи Gleb Pomykalov: Let’s overtake go/fasthttp with rust/warp

Предыдущая статьяВсе, что нужно знать о SASS
Следующая статьяMiddleware Django: пользовательское ПО промежуточного слоя