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

План перехода на 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.

  1. Нативная сборка ленты — сделано в этом выпуске. FeedService передаёт байты страниц из кэша в telegram_me_native.build_feed и получает обратно байты ответа (один вызов, GIL отпущен): чтение страниц, сигналы ранжирования, ранжирование, рендер. В Python остаются правила stale-while-revalidate и популярность. См. результаты.
  2. Нативный конвейер страницы. Разбор, валидация и рендер одним вызовом: 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.
  3. Нативное чтение из кэша. /v1/post, /v1/preview и /v1/previews рендерятся нативно из байтов кэша (извлечь один пост, отрендерить превью), так что ни одно попадание в кэш не создаёт объектов Python. Опирается на рендерер этапа 2.
  4. Нативный уровень Redis и популярность. Клиент Redis на Rust (redis-rs или fred на рантайме tokio) за существующим протоколом Cache и двухуровневым кэшем, плюс популярность и блокировка лидера; один сетевой круг на ленту для всех её страниц (MGET). Согласуется с работой по объединению запросов между воркерами, которая тоже живёт на Redis.
  5. Разрешение GraphQL. Резолверы читают нативно отрендеренные байты, а самые тяжёлые выборки (body.posts) проецируются нативно; схема остаётся за strawberry, так что снапшот SDL не меняется. Убирает большую часть ~0.85 мс на запрос.
  6. Оболочка — по цифрам. Когда этапы 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 уходит на ранжирование.