Производительность¶
Все цифры ниже измерены на одной и той же машине (ноутбук на 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/body0.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 байт на запись, измерено):Страница Как strUTF-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/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
Для пропускной способности сервера запустите экземпляр и направьте на него генератор нагрузки: