План перехода на Rust¶
Где воркер всё ещё тратит время в Python, каким путём оставшийся горячий путь переходит в Rust и в каком порядке. Правило, которому всё это следует: тяжёлая работа выполняется нативно; нативный код, который мы пишем сами, пишется на Rust (готовые компоненты могут быть на C, C++ или Rust); Python — это клей: бизнес-правила, связывание компонентов, оркестрация и скрипты, ничего на горячем пути.
По дороге не меняются две вещи: ответы REST /v1 и схема GraphQL (обе
зафиксированы в tests/snapshots/, REST — побайтово) и развёртывание (Docker,
compose, deploy/remote-deploy.sh, виртуальные машины с 1 ГБ памяти).
Аудит¶
Измерено с помощью scripts/audit.py
на Apple Silicon (8 ядер, Python 3.13), внутри процесса: t.me заменён
записанными страницами, кэшем служит кэш в процессе (L1
двухуровневого кэша); память — на Linux (arm64,
Python 3.14). База для сравнения — main после двухуровневого кэша (PR #201).
«Выделено» — пик выделений памяти Python за время обработки одного запроса
(tracemalloc). Абсолютные значения стоит воспринимать как относительные.
uv run python scripts/audit.py --stages # по эндпоинтам и по этапам
RAYON_NUM_THREADS=1 uv run python scripts/audit.py # стоимость CPU на одном ядре
uv run python scripts/audit.py --memory 200 # RSS цикла запросов ленты (Linux)
Стоимость запроса¶
| Запрос | Время | CPU (все потоки) | Выделено | Ответ |
|---|---|---|---|---|
GET /healthz |
0.03 мс | 0.03 мс | 15 КиБ | 15 Б |
GET /v1/body/durov, из кэша |
0.11 мс | 0.11 мс | 105 КиБ | 91 КБ |
GET /v1/body/tginfo, из кэша |
0.17 мс | 0.17 мс | 248 КиБ | 238 КБ |
GET /v1/post/durov/531, из кэша |
0.33 мс | 0.33 мс | 278 КиБ | 3 КБ |
GET /v1/preview/durov, из кэша |
0.07 мс | 0.07 мс | 18 КиБ | 0.5 КБ |
POST /v1/previews, 100 каналов, из кэша |
1.14 мс | 1.10 мс | 327 КиБ | 65 КБ |
POST /graphql, один body, из кэша |
0.95 мс | 0.95 мс | 122 КиБ | 175 Б |
POST /v1/feed, 6 каналов, из кэша |
8.97 мс | 15.8 мс | 2.0 МиБ | 0.6 МБ |
POST /v1/feed, 30 каналов, из кэша |
27.0 мс | 54.0 мс | 6.2 МиБ | 0.9 МБ |
POST /v1/feed, 100 каналов, из кэша |
83.7 мс | 170 мс | 20.8 МиБ | 1.7 МБ |
GET /v1/body/durov, без кэша |
4.90 мс | 4.86 мс | 742 КиБ | 91 КБ |
GET /v1/body/tginfo, без кэша |
9.40 мс | 9.35 мс | 1.5 МиБ | 238 КБ |
Ленты из 100 каналов собраны из псевдонимов шести записанных каналов, у каждого свой username: 2 000 постов с множеством повторяющихся новостей — тяжёлый случай для поиска дубликатов.
Холодный конвейер страницы для каждой записанной страницы канала (мс, один поток):
| Страница | разбор (Rust) | from_json |
валидация | dump | encode | zstd | decode |
|---|---|---|---|---|---|---|---|
| @durov | 3.55 | 0.13 | 0.30 | 0.28 | 0.10 | 0.42 | 0.13 |
| @telegram | 2.84 | 0.09 | 0.24 | 0.22 | 0.07 | 0.27 | 0.09 |
| @tginfo | 5.97 | 1.00 | 0.66 | 0.56 | 0.21 | 1.14 | 1.00 |
| @nebudetgg | 2.06 | 0.16 | 0.21 | 0.18 | 0.06 | 0.23 | 0.16 |
Разбор отпускает GIL; всё, что после него (from_json в объекты Python,
валидация и сериализация pydantic, кодирование для кэша), держит его:
0.5–2.6 мс на страницу. Промах /v1/body к тому же валидирует и рендерит
страницу второй раз в цикле событий (body_json → body()), хотя поток
разбора её уже отрендерил.
Сборка ленты, 100 страниц из кэша, 2 000 постов (код 2.0, мс):
| Этап | Где | Время |
|---|---|---|
| декодировать каждую страницу в объекты Python | цикл событий, GIL удерживается | 33 |
построить Candidate для каждого поста |
цикл событий, GIL удерживается | 5.5 |
| ранжирование | рабочий поток, Rust, GIL отпущен | 35 (8 ядер) / 82 (1 ядро) |
| собрать записи, валидировать, сериализовать | цикл событий, GIL удерживается | 2.3 |
Остановка цикла событий. Кэшированный /v1/body/durov от двух клиентов по
50 запросов/с каждый, задержка считается с момента, когда запрос должен был
уйти, рядом с одним клиентом, который в цикле собирает ленты из 100 каналов:
| p50 | p99 | Лент/с | |
|---|---|---|---|
| 8 ядер | 15.2 мс | 61.6 мс | 11.2 |
1 ядро (RAYON_NUM_THREADS=1) |
2.2 мс | 61.0 мс | 7.4 |
(Сам по себе этот запрос занимает 0.11 мс.)
По компонентам¶
| Компонент | Работа Python на запрос | Чем ограничен | Примечания |
|---|---|---|---|
| granian + маршрутизация FastAPI, валидация, 3 middleware | ~30 мкс внутри процесса; ~50 мкс на запрос через настоящий сокет (21 тыс. запросов/с на воркер для /healthz) |
CPU (Python) | нижняя граница стоимости любого эндпоинта |
| Построение ответа | кэшированные /v1/body, /v1/more: нет (байты из кэша); /v1/post: декодировать страницу, валидировать один пост, сериализовать (0.25 мс); /v1/preview(s): декодировать, валидировать, сериализовать (~10 мкс на канал) |
CPU (Python, pydantic-core) | |
| Конвейер страницы при промахе | 0.5–2.6 мс на страницу после нативного разбора плюс второй рендер /v1/body |
CPU (Python, pydantic-core) | выполняется в 2 потоках разбора, но держит GIL |
| Кэш | L1: нативный (moka + zstd), десятки мкс с удержанием GIL; L2: асинхронный клиент redis-py (парсер hiredis) на каждую команду плюс сетевой круг | L1 — CPU, L2 — I/O | двухуровневый кэш (PR #201) |
Single-flight (coalesce.py) |
задача, shield и запись в словаре на вызов: ~40 мкс, в основном лишний оборот цикла событий | CPU (Python) | объединение запросов между воркерами делается отдельно |
Ограничение частоты, proof-of-work (protection.py) |
проверяются в Rust (ratelimit.rs, pow.rs) меньше чем за микросекунду внутри одного чистого ASGI-middleware; заменённое им скользящее окно на Python стоило +4 мкс на запрос |
CPU (Rust) | по умолчанию выключено; PR #202 |
| Сборка ленты | см. выше: 41 мс Python с удержанием GIL на 100 каналов | CPU (Python) | этап 1 |
| Популярность, блокировка лидера | один конвейер Redis на ленту (ZINCRBY × каналы), один EVAL за такт |
I/O | redis-py собирает конвейер в Python |
| Цикл обновления | только планирование; страницы проходят через конвейер страницы | I/O + конвейер страницы | ≤ 16 обновлений одновременно |
HTTP-клиент (client.py) |
семафор и await; для /v1/more ещё json.loads ответа (~0.2 мс) |
I/O (Rust, wreq) | |
| GraphQL (strawberry) | ~0.85 мс на запрос сверх сервисов | CPU (Python) | разбор и валидация запроса, резолвер на каждое поле |
| Модель воркеров | один процесс на воркер, у каждого свой интерпретатор, кэш L1 и пул HTTP | память | см. ниже |
Память воркера¶
Linux, arm64, Python 3.14 (scripts/memory_profile.py, scripts/audit.py --memory):
| RSS | Анонимная | |
|---|---|---|
| Только интерпретатор | 9.0 МиБ | 3.4 МиБ |
| Главный процесс granian (супервизор) | 43.7 МиБ | 26.2 МиБ |
| Воркер с собранным приложением, без нагрузки | 85.3 МиБ | 63.2 МиБ |
| Воркер после 20 000 смешанных запросов к 1 200 каналам (учтено 41 МиБ в кэше процесса) | 187–212 МиБ, пик 220 МиБ | |
| Воркер, обслуживший 200 лент из 100 каналов | пик 220 МиБ |
Собственные живые объекты Python держатся на уровне ~13 МиБ на протяжении всей смешанной нагрузки (tracemalloc); остальное — интерпретатор с импортами, кэш в процессе, нативные буферы и то, что malloc удерживает между всплесками.
Базовый объём воркера — интерпретатор и импортированное приложение (FastAPI,
pydantic, strawberry, redis-py, нативное расширение) плюс до
MEMORY_CACHE_MAX_MB под L1. WEB_CONCURRENCY воркеров дублируют всё это,
включая L1.
Что из этого следует¶
- Почти вся CPU-ёмкая работа помимо парсера и ранжирования приходилась на сборку ленты: 41 мс Python на ленту из 100 каналов с удержанием GIL, ~20 МиБ короткоживущих объектов Python и остановка всех остальных запросов воркера на это время. Это самая крупная отдельная статья.
- Следующая — конвейер страницы после парсера (0.5–2.6 мс pydantic с
удержанием GIL на страницу, дважды при промахе
/v1/body). Она важна для тактов обновления и холодного трафика, а не для попаданий в кэш. - Попадания в кэш уже дешёвые (0.07–0.33 мс). Там остаётся нижняя граница
на запрос — мост ASGI в granian и FastAPI — и pydantic для
/v1/postи превью. - GraphQL стоит ~0.85 мс Python на запрос независимо от данных.
- Части, ограниченные вводом-выводом (HTTP-клиент, Redis), уже нативные или на C; Python добавляет там накладные расходы на команду, а не ограничивает пропускную способность.
- Ранжирование (Rust) теперь самая крупная статья CPU в ленте: ~80 мс на 2 000 постов на одном ядре. Его оптимизация не зависит от того, где работает клей.
Варианты¶
(a) Оставить оболочку granian/FastAPI, перенести целые конвейеры в нативный код¶
Работа каждого эндпоинта становится одним нативным вызовом: загрузить, разобрать, валидировать и отрендерить байты с отпущенным GIL; нативный кэш, single-flight и ограничитель частоты. В Python остаются маршрутизация, настройки, флаги функций, отображение ошибок, правила свежести ленты и планирование обновлений.
- За: постепенность, каждый шаг выкатывается отдельно и проверяется побайтовыми снапшотами; OpenAPI (из моделей pydantic), GraphQL и CLI остаются как есть; бизнес-правила остаются в Python; Docker и скрипты развёртывания не меняются. Этим путём уже прошли парсер, ранжирование и кэш L1.
- Против: нижняя граница на запрос остаётся (мост ASGI в granian, FastAPI, ~50 мкс на запрос, ~20 тыс. запросов/с на воркер), как и полный интерпретатор на каждый воркер. GraphQL остаётся в Python.
(b) HTTP-сервер на Rust (axum/hyper на tokio), обслуживающий /v1¶
Сервер на Rust; Python встроен (PyO3) или вызывается только для бизнес-правил.
- За: самая низкая граница (десятки мкс и в несколько раз больше пропускная способность на ядро), один многопоточный процесс вместо процесса на воркер, а значит один кэш L1 и один пул HTTP на сервер вместо одного на воркер — самая большая экономия памяти.
- Против: одномоментная замена сервера, маршрутизатора, middleware, отображения ошибок и генерации OpenAPI; встроенный интерпретатор возвращает GIL для каждого правила, которое он выполняет, поэтому правила должны стать данными или кодом на Rust, чтобы был выигрыш; GraphQL потребует порта на Rust (async-graphql) с идентичной схемой и ошибками либо останется вторым сервисом на Python. Правила перестают быть «легко изменяемыми в Python».
(c) Основной сервис на Rust и тонкая управляющая часть на Python¶
Бинарник на Rust обслуживает /v1 (а позже GraphQL) и запускает обновление
ленты; Python отвечает за конфигурацию, CLI, фикстуры и скрипты и, возможно,
за GraphQL отдельным процессом, который обращается к ядру.
- За: тот же выигрыш во время работы, что и (b), без встраивания; чёткая граница.
- Против: два процесса на виртуальную машину, которые нужно разворачивать, настраивать и отслеживать; управляющей части нужен интерфейс (файл конфигурации, административный API); та же одномоментная замена сервера, что и в (b), а всё, что сегодня является клеем на Python (флаги функций, правила свежести), придётся заново выразить на Rust или в конфигурации.
Рекомендация: (a), поэтапно¶
Вариант (a) даёт выигрыш по CPU и GIL там, где он измерен (сборка ленты,
конвейер страницы, разрешение GraphQL), без рискованной замены. Каждый этап
переносит целый конвейер в telegram-me-native, выкатывается отдельно,
проверяется побайтовыми снапшотами и даёт свой измеренный выигрыш. Когда
конвейеры станут нативными, процесс уже будет ядром на Rust за оболочкой на
Python: в Python останутся нижняя граница ASGI/FastAPI в ~50 мкс и по
интерпретатору на воркер. Стоит ли заменять это фронтом на axum (движение в
сторону (c)), решается тогда, по цифрам (этап 6), а не сейчас.
Этапы¶
Каждый этап: снапшоты (make snapshots, побайтовые хэши) без изменений, свои
цифры до и после, свой PR.
- Нативная сборка ленты — сделано в этом выпуске.
FeedServiceпередаёт байты страниц из кэша вtelegram_me_native.build_feedи получает обратно байты ответа (один вызов, GIL отпущен): чтение страниц, сигналы ранжирования, ранжирование, рендер. В Python остаются правила stale-while-revalidate и популярность. См. результаты. - Нативный конвейер страницы. Разбор, валидация и рендер одним вызовом:
parse_channel_pageсразу возвращает представление для кэша и отрендеренные байты/v1/body(и/v1/more) с теми проверками схемы, которые сегодня делает pydantic (нормализация URL — pydantic-core использует тот же крейтurl, — допустимые значения literal, значения по умолчанию, валидаторы полей, отбрасывание постов, не прошедших валидацию). Модели pydantic остаются документированной схемой (OpenAPI, типы GraphQL) и становятся эталоном в дифференциальном тесте на фикстурах и сгенерированных постах. Убирает 0.5–2.6 мс Python с удержанием GIL на страницу и второй рендер при промахе/v1/body; такты обновления перестают требовать GIL. - Нативное чтение из кэша.
/v1/post,/v1/previewи/v1/previewsрендерятся нативно из байтов кэша (извлечь один пост, отрендерить превью), так что ни одно попадание в кэш не создаёт объектов Python. Опирается на рендерер этапа 2. - Нативный уровень Redis и популярность. Клиент Redis на Rust (redis-rs
или fred на рантайме tokio) за существующим протоколом
Cacheи двухуровневым кэшем, плюс популярность и блокировка лидера; один сетевой круг на ленту для всех её страниц (MGET). Согласуется с работой по объединению запросов между воркерами, которая тоже живёт на Redis. - Разрешение GraphQL. Резолверы читают нативно отрендеренные байты, а
самые тяжёлые выборки (
body.posts) проецируются нативно; схема остаётся за strawberry, так что снапшот SDL не меняется. Убирает большую часть ~0.85 мс на запрос. - Оболочка — по цифрам. Когда этапы 1–5 сделаны, измерить, сколько
стоят мост ASGI, FastAPI и middleware и сколько памяти занимает
интерпретатор на воркер. Если это того стоит — обслуживать
/v1фронтом на axum в том же образе (Python остаётся для GraphQL, документации и CLI) или запускать один воркер с нативными потоками вместо нескольких процессов. Ограничитель частоты и proof-of-work уже нативные (ratelimit.rs,pow.rs, PR #202).
Не планируется: сам алгоритм ранжирования. Его скорость — отдельная работа: ~80 мс на 2 000 постов на одном ядре — теперь самая крупная статья CPU в ленте.
Результаты этапа 1¶
Та же машина, тот же скрипт, main (с двухуровневым кэшем) против этого
выпуска. Ответы побайтово идентичны (tests/snapshots/raw_digests.json).
| До | После | ||
|---|---|---|---|
POST /v1/feed, 6 каналов, время |
8.97 мс | 6.17 мс | −31 % |
POST /v1/feed, 30 каналов, время |
27.0 мс | 14.7 мс | −45 % |
POST /v1/feed, 100 каналов, время |
83.7 мс | 42.7 мс | −49 % |
POST /v1/feed, 100 каналов, CPU на одном ядре |
131 мс | 106 мс | −19 % |
POST /v1/feed, 6 / 30 каналов, CPU на одном ядре |
11.3 / 40.6 мс | 9.1 / 32.5 мс | −20 % |
POST /v1/feed, 100 каналов, выделено Python |
20.8 МиБ | 9.8 МиБ | −53 % |
| Выделения Python, лента из 6 каналов | 2.0 МиБ | 1.1 МиБ | −44 % |
Кэшированный /v1/body рядом с циклом лент, p50 / p99 (8 ядер) |
15.2 / 61.6 мс | 2.3 / 10.5 мс | 6.6× / 5.9× |
| То же, одно ядро, p99 | 61.0 мс | 4.7 мс | 13× |
| Лент из 100 каналов в секунду, один воркер (8 ядер / 1 ядро) | 11.2 / 7.4 | 23.3 / 9.3 | 2.1× / 1.3× |
Резидентная память заметно не меняется: на Linux воркер, обслуживший 200 лент из 100 каналов, достиг пика в 220 МиБ до и 218 МиБ после, а смешанная нагрузка из 20 000 запросов — 220 и 223 МиБ (разброс между запусками около ±10 МиБ). Объекты Python, которые лента больше не создаёт, жили недолго, а RSS воркера определяется интерпретатором с импортами (85 МиБ) и кэшем в процессе. Выигрыш по памяти в этом плане даёт этап 6 (меньше интерпретаторов, один кэш на сервер), а не перенос отдельных конвейеров.
В Python от ленты остаётся получение страниц (обращения к кэшу, чтения L1 из которого всё ещё держат GIL по десятку микросекунд) и обновление популярности. Остальное время CPU уходит на ранжирование.