Перейти к содержанию

Производительность

Все цифры ниже измерены на одной и той же машине (ноутбук на Apple Silicon, Python 3.14) во время подготовки 2.0, причём t.me был заменён записанными страницами, чтобы измерять только сам сервис. Воспринимайте их как относительные, а не абсолютные.

1.x → 2.0 внутри процесса

Медианная задержка одного запроса, источник отдаётся из фикстур:

Запрос 1.x 2.0
Разбор пяти страниц каналов (сумма) 33.7 мс 25.0 мс −26 %
/v1/body/durov, без кэша 14.5 мс 7.1 мс в 2 раза быстрее
/v1/body/tginfo, без кэша 24.5 мс 15.5 мс в 1.6 раза быстрее
/v1/body/durov, из кэша — (нет кэша страниц) 0.9 мс в 16 раз быстрее
/v1/body/tginfo, из кэша — 1.1 мс в 22 раза быстрее
/v1/feed, 5 каналов, страницы в кэше 81.9 мс 5.4 мс в 15 раз быстрее

Двухуровневый кэш

Когда был задан REDIS_URL, повторный запрос стоил одного обращения к Redis: Upstash в продакшене находится в ~90 мс по TLS, так что почти всё время ответа из кэша уходило на GET. Теперь каждый воркер сначала отвечает из своего кэша внутри процесса (Rust, moka) и идёт в Redis только при промахе (см. Кэширование).

Медиана / p95 для 200 повторных запросов к @durov (один воркер, ASGI вызывается напрямую, источник отдаётся из фикстур, Apple M-серии, Python 3.13). Redis в 90 мс — это локальный Valkey 9.1 за прокси, который задерживает каждое направление на 45 мс; «кэш» — время, проведённое в cache.get за один запрос:

Запрос Redis 90 мс: 2.0.0 Redis 90 мс: сейчас Локальный Redis: 2.0.0 Локальный Redis: сейчас Без Redis: 2.0.0 Без Redis: сейчас
/v1/body/durov 95.0 / 97.4 мс (кэш 94.3) 0.10 / 0.12 мс (кэш 0.04) 0.35 / 0.69 мс 0.10 / 0.13 мс 0.15 / 0.16 мс 0.10 / 0.11 мс
/v1/post/durov/<id> 96.7 / 98.5 мс (кэш 94.7) 0.25 / 0.28 мс (кэш 0.04) 0.88 / 1.08 мс 0.25 / 0.36 мс 0.29 / 0.33 мс 0.25 / 0.26 мс
/v1/more/durov/before/<id> 95.0 / 96.1 мс (кэш 94.4) 0.09 / 0.10 мс (кэш 0.03) 0.34 / 0.54 мс 0.09 / 0.12 мс 0.11 / 0.12 мс 0.09 / 0.10 мс
/v1/preview/durov 94.4 / 96.0 мс (кэш 93.9) 0.08 / 0.08 мс (кэш 0.003) 0.30 / 0.53 мс 0.08 / 0.11 мс 0.08 / 0.09 мс 0.08 / 0.08 мс
Смешанная нагрузка, 3 000 запросов, 8 клиентов 51.7 с 33.3 с 2.31 с 2.33 с 2.27 с 2.04 с
Пиковый RSS за этот прогон 101 МиБ 120 МиБ 135 МиБ 166 МиБ 153 МиБ 152 МиБ
  • Первое чтение ключа на воркере по-прежнему стоит одного обращения к Redis (GET и PTTL в одном конвейере), как и раньше; запись — тоже. Смешанная нагрузка состоит в основном из первых чтений (каждый запрос называет новый псевдоним канала), поэтому выигрыш там меньше.
  • Без Redis нативный кэш заменяет LRU на Python: попадание больше не распаковывается в Python (/v1/body 0.15 → 0.10 мс), а страница в 95 КБ сохраняется за 83 мкс вместо 461 мкс (zlib на 3.13; zstd в Python 3.14 ближе).
  • С Redis каждый воркер теперь также держит то, что прочитал или записал за последние MEMORY_CACHE_MAX_TTL (10) секунд, в пределах MEMORY_CACHE_MAX_MB: +19–31 МиБ пикового RSS в этом прогоне, где всё читалось в пределах 10 с.
  • Отпускание GIL на время чтения и записи было измерено и отвергнуто: рядом с двумя нагружающими CPU потоками Python (pydantic в потоках разбора) чтение 95 КБ заняло 6.3 мс (p50) с отпущенным GIL и 38 мкс с удерживаемым.

Нативная сборка ленты

Раньше лента декодировала каждую закэшированную страницу в объекты Python, строила вход ранжировщика для каждого поста и сериализовала результат через pydantic — в event loop, с удерживаемым GIL: ~41 мс из ленты на 100 каналов. Теперь это один вызов telegram_me_native.build_feed с отпущенным GIL (на входе байты закэшированных страниц, на выходе байты ответа). scripts/audit.py, внутри процесса, та же машина, ленты на 100 каналов и 2 000 постов:

До После
POST /v1/feed, 100 каналов 83.7 мс 42.7 мс
CPU этой ленты на одном ядре (RAYON_NUM_THREADS=1) 131 мс 106 мс
Аллокации Python для этой ленты 20.8 МиБ 9.8 МиБ
Кэшированный /v1/body рядом с циклом лент, p50 / p99 15.2 / 61.6 мс 2.3 / 10.5 мс
Пиковый RSS воркера, отдающего 200 таких лент (Linux) 220 МиБ 218 МиБ (шум)

Подробности, стоимость по эндпоинтам и что переносится дальше — план перехода на Rust.

Объединение запросов

scripts/coalescing_load.py запускает 6 процессов-воркеров (как два сервера по три воркера granian) на одном Valkey, у каждого 32 клиента, которые вызывают его приложение напрямую. Каждый клиент случайно выбирает запросы из одних и тех же 37: каждый записанный канал через /v1/body (последняя страница и вокруг поста), /v1/post, /v1/more в обе стороны, три превью, лента из 6 каналов и GraphQL-запрос с body и лентой. t.me заменён записанными страницами, которые отдаются через 150 мс; CACHE_MODE=normal (10 с), так что 27 различных запросов к источнику истекают дважды за 25-секундный прогон. По два прогона, Apple M-серии с 8 ядрами:

main (с нативной лентой) это изменение, SHARED_COALESCING=false это изменение
Запросов к источнику, локальный Valkey 417 / 412 421 / 428 75 / 75
Запросов к источнику, Valkey в 90 мс 434 / 422 414 / 413 75 / 75
На один различный запрос к источнику 15.3–16.1 15.3–15.9 2.8 (раз за период TTL)
Запросов обслужено за 25 с, локально 117 k / 117 k 530 k / 537 k 548 k / 533 k
CPU на запрос, локально 1.41 / 1.43 мс 0.30 / 0.29 мс 0.29 / 0.29 мс
CPU на запрос, 90 мс 1.43 / 1.41 мс 0.27 / 0.27 мс 0.27 / 0.27 мс
Задержка p99, локально 917 / 902 мс 206 / 203 мс 199 / 206 мс
Самая долгая остановка event loop воркера, локально 845 / 905 мс 246 / 200 мс 224 / 228 мс
  • Дубликаты между процессами убирает аренда: 75 запросов к источнику — это минимум для такого прогона (27 различных, обновлённых не больше двух раз), и он держится при Redis в 90 мс. Без неё каждый процесс загружает каждую страницу сам, как раньше.
  • CPU сокращает общий рендеринг: одинаковые ленты собираются один раз (а для GraphQL и декодируются один раз), а не на каждый запрос, /v1/post кэшируется отрендеренным, а одновременные промахи /v1/body рендерятся один раз. Освободившийся CPU ушёл на обслуживание в 4–5 раз большего числа запросов за то же время, с более короткими остановками event loop.
  • Команда Redis, прерванная по тайм-ауту, размыкает circuit breaker воркера; тогда этот воркер загружает сам (одна загрузка на ключ, как до аренды), пока breaker не замкнётся. Такое наблюдалось при разработке, когда event loop воркера останавливался больше чем на 2 с (до того, как ленты GraphQL стали декодироваться один раз), но не в прогонах выше.

Кэшированный горячий путь не изменился (один воркер, медиана 2 500 попаданий, ASGI вызывается напрямую):

Попадание main это изменение
/v1/body/durov 0.101 мс 0.101 мс
/v1/more/durov/before/<id> 0.091 мс 0.091 мс
/v1/post/durov/<id> 0.242 мс 0.067 мс (кэшируется отрендеренным)

Допуск — это несколько нативных вызовов только при промахе; попадание добавляет один нативный вызов, который сравнивает возраст записи с её точкой обновления.

Разбор страниц: Python + lexbor → Rust

Медианное время разбора одной записанной страницы канала (scripts/benchmark.py):

Страница Python + selectolax/lexbor Rust (html5ever)
@durov (20 постов, много кастомных эмодзи) 4.9 мс 3.6 мс
@tginfo (20 длинных постов, много ссылок) 12.4 мс 6.3 мс

Главная разница проявляется под нагрузкой: парсер на Rust отпускает GIL, поэтому конкурентные запросы разбирают страницы параллельно, а не стоят в очереди к одному ядру (24 страницы в 24 потоках: 111 мс → 67 мс на 8 ядрах, до описанных выше оптимизаций).

Event loop во время такта рефрешера

Задержка event loop одного воркера с Redis (Valkey) и встроенным рефрешером, внутрипроцессная нагрузка из 8 клиентов × 3 000 запросов дважды, а между прогонами — такт рефрешера, который обновляет ~200 каналов, ставших популярными за первый прогон:

Худшая задержка Медианная задержка Время прогона Срабатывания breaker
Разбор в event loop, такт без ограничений 0.87–1.31 с 9 мс 32–36 с 1 за прогон
2 потока разбора, ≤ 16 обновлений одновременно 25–122 мс 1.1 мс 13 с нет

Раньше каждая завершённая загрузка разбирала свою страницу (~3 мс нативно), а затем проверяла и рендерила её (~0.5–2 мс в pydantic) прямо в event loop. Когда загрузки одного такта завершались вместе, всё это выполнялось подряд около секунды — дольше REDIS_TIMEOUT, так что команда Redis в пути прерывалась по тайм-ауту и размыкала circuit breaker на 30 с. Теперь страницы разбираются в двух выделенных потоках: executor по умолчанию (до cpu + 4 потоков) не был быстрее и, соревнуясь с event loop за GIL, утраивал его задержку p99 (~21 мс вместо ~7 мс). Рефрешер обновляет не больше 16 каналов одновременно, что заодно оставляет слоты к источнику для запросов пользователей.

Исходящий HTTP-клиент

2 000 HTTPS-запросов страницы размером 148 КБ, 32 одновременно, к локальному TLS-серверу:

Клиент Запросов/с CPU на запрос Пиковая память
httpx, HTTP/2 (1.x) 584 1.31 мс 219 МБ
httpx, HTTP/1.1 378 1.85 мс 151 МБ
aiohttp 5 842 0.17 мс 71 МБ
curl_cffi 4 444 0.23 мс 58 МБ
httpr (Rust) 9 308 0.23 мс 53 МБ
pyreqwest (Rust) — 2.0 10 563 0.15 мс 51–64 МБ

Кроме того, 1.x создавал новый клиент — и новое TLS-рукопожатие — на каждый запрос к t.me.

HTTP-сервер

Один воркер, 64 соединения, 10 с (wrk), то же приложение:

Эндпоинт uvicorn + httptools + uvloop granian — 2.0
/healthz 15 774 запр./с, p99 7.97 мс 21 120 запр./с, p99 3.23 мс
/v1/body/durov (из кэша, 91 КБ) 4 088 запр./с, p99 23.9 мс 4 365 запр./с, p99 31.4 мс

Заранее отрендеренные ответы в кэше подняли пропускную способность /v1/body из кэша с ~850 до ~4 350 запросов в секунду на один воркер.

Остановка и память

1.x 2.0
docker stop 10 с, затем SIGKILL (uvicorn не был PID 1) 0.3 с, код выхода 0
Redis недоступен, /v1/preview 10 с на запрос один тайм-аут 0.5 с, дальше без кэша
Память контейнера, 1 воркер, в простое — ~110 МБ (см. Память)

Память

Измерено на arm64 с Docker-образом (docker stats, один воркер, если не указано иное) до и после работы над памятью, вошедшей в 2.0. «Лёгкий» трафик — несколько сотен реальных запросов по шести каналам; «высокий» — 2 × 20 000 запросов от 8 одновременных клиентов по 1 200 разным каналам, при этом t.me заменён записанными страницами.

Сценарий До После
В простое 146–153 МиБ 110 МиБ
Лёгкий трафик 185 МиБ 143 МиБ
WEB_CONCURRENCY=2, в простое / лёгкий трафик 217 / 278 МиБ 180 / 224 МиБ
Высокий трафик, кэш в памяти (лимит 64 МБ) 458 МиБ 229–253 МиБ
Высокий трафик, Redis, во время / через 15 с после 296–302 / 299 МиБ 190–263 / 155 МиБ
Главный процесс granian (приватная память) 65 МиБ 29 МиБ

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

  • Кэш в памяти считал символы, а не байты. Значения были Python str; одно эмодзи заставляет CPython хранить всю строку по четыре байта на символ, поэтому отрендеренная страница размером 89 КиБ в UTF-8 занимала 353 КиБ, а лимит в 64 МБ позволял воркеру держать ~260 МиБ кэша. Теперь значения — байты UTF-8, сжатые zstd уровня 1 (zlib на Python 3.13), а лимит учитывает сжатое значение, ключ и служебные данные (~230 байт на запись, измерено):

    Страница Как str UTF-8 В кэше сейчас
    @durov, отрендеренная 353 КиБ 89 КиБ 21 КиБ
    @tginfo, отрендеренная 700 КиБ 233 КиБ 43 КиБ
    @stranaua, отрендеренная 93 КиБ 59 КиБ 13 КиБ

    Распаковка страницы (25–120 мкс) быстрее, чем было кодирование той же str в UTF-8 при каждом попадании (27–306 мкс), а ответы Redis тоже читаются как байты.

  • glibc удерживал освобождённую память. Ответы строятся из больших короткоживущих объектов, и после всплеска куча glibc оставалась на пиковом размере: с Redis воркер держал 97 МиБ кучи, из которых использовалось 15 МиБ. Теперь воркеры вызывают malloc_trim(0) каждые MEMORY_TRIM_INTERVAL секунд (около 1 мс на вызов), что в этом случае вернуло ~80 МиБ.

  • Главный процесс granian импортировал приложение (FastAPI, GraphQL, Redis, HTTP-клиент, нативное расширение) только ради надзора за воркерами. Теперь импорт происходит в фабрике воркеров.
  • FastAPI выполнял зависимости в пуле потоков (обычные def), из-за чего в каждом воркере жил лишний поток — со своей ареной malloc. Теперь это async def.

Рассмотрено и не принято (высокий трафик, измерено, когда парсер ещё был на Python; сравнивайте строки между собой):

Вариант Высокий трафик, кэш в памяти Вердикт
glibc (по умолчанию) 205 МиБ оставлено
MALLOC_ARENA_MAX=2 202 МиБ в пределах шума
glibc с фиксированными MALLOC_MMAP_THRESHOLD_ / MALLOC_TRIM_THRESHOLD_ 211 МиБ в пределах шума; с Redis возвращал лишь половину того, что возвращает malloc_trim
jemalloc (LD_PRELOAD), по умолчанию / затухание 1 с / 2 арены 212 / 206 / 200 МиБ, +5–17 МиБ в простое без выигрыша
PYTHONMALLOC=malloc + jemalloc 200–227 МиБ, +15 МиБ в простое без выигрыша
mimalloc 256 МиБ хуже
tcmalloc 252 МиБ хуже
Fork воркеров из заранее загруженного главного процесса + gc.freeze() (2 воркера) 273 вместо 333 МиБ не принято: granian делает fork из многопоточного супервизора («use of fork() may lead to deadlocks»), поэтому воркеры по-прежнему запускаются через spawn

Разбор не удерживает память: страницы разбираются нативно и возвращаются одной JSON-строкой, а регрессионные тесты памяти не показывают роста за сотни разборов без кэша. WEB_CONCURRENCY, MEMORY_CACHE_MAX_MB и WORKERS_MAX_RSS_MB сохраняют значения по умолчанию: при честном учёте 64 МБ кэша теперь вмещают в 4–16 раз больше страниц, чем раньше, в рамках фиксированного бюджета.

Защита от регрессий

tests/test_memory.py прогоняет через приложение тысячи запросов и падает, если после прогрева память продолжает расти или кэш превышает лимит; он ловит как утечку разобранной страницы на каждый запрос, так и утечку нативной памяти. rust/telegram-me-native/tests/cache_memory.rs считает каждое выделение памяти кэша внутри процесса и падает, когда его учёт перестаёт их покрывать. Чтобы увидеть цифры:

uv run python scripts/memory_profile.py --requests 20000 --channels 200

Воспроизведение

uv run python scripts/benchmark.py   # in-process, recorded pages, no network
uv run python scripts/audit.py --stages   # per endpoint (wall, CPU, allocations) and per stage
docker run -d --name tme-valkey -p 6390:6379 valkey/valkey:8
uv run python scripts/coalescing_load.py --redis redis://127.0.0.1:6390/0

Для пропускной способности сервера запустите экземпляр и направьте на него генератор нагрузки:

make up                              # API + Valkey on :8000
wrk -t2 -c64 -d10s --latency http://127.0.0.1:8000/healthz