Rust roadmap¶
Where a worker still spends its time in Python, which way the remaining hot path moves to Rust, and in which order. The rule this follows: heavy work runs natively; native code we write ourselves is Rust (ready-made components may be C, C++ or Rust); Python is glue: business rules, wiring, orchestration and scripts, nothing on the hot path.
Two things do not change on the way: the REST /v1 responses and the
GraphQL schema (both pinned by tests/snapshots/, REST byte for byte), and
the deployment (Docker, compose, deploy/remote-deploy.sh, 1 GB VMs).
Audit¶
Measured with scripts/audit.py
on Apple Silicon (8 cores, Python 3.13), in process, with t.me replaced by the
recorded pages and the in-process cache (the L1 of the
two-level cache) as the cache; memory on Linux (arm64,
Python 3.14). The baseline is main after the two-level cache (PR #201).
"Allocated" is the peak of Python allocations while serving one request
(tracemalloc). Treat absolute numbers as relative.
uv run python scripts/audit.py --stages # per endpoint and per stage
RAYON_NUM_THREADS=1 uv run python scripts/audit.py # CPU cost on one core
uv run python scripts/audit.py --memory 200 # RSS of a feed loop (Linux)
Cost per request¶
| Request | Wall | CPU (all threads) | Allocated | Response |
|---|---|---|---|---|
GET /healthz |
0.03 ms | 0.03 ms | 15 KiB | 15 B |
GET /v1/body/durov, cached |
0.11 ms | 0.11 ms | 105 KiB | 91 KB |
GET /v1/body/tginfo, cached |
0.17 ms | 0.17 ms | 248 KiB | 238 KB |
GET /v1/post/durov/531, cached |
0.33 ms | 0.33 ms | 278 KiB | 3 KB |
GET /v1/preview/durov, cached |
0.07 ms | 0.07 ms | 18 KiB | 0.5 KB |
POST /v1/previews, 100 channels, cached |
1.14 ms | 1.10 ms | 327 KiB | 65 KB |
POST /graphql, one body, cached |
0.95 ms | 0.95 ms | 122 KiB | 175 B |
POST /v1/feed, 6 channels, cached |
8.97 ms | 15.8 ms | 2.0 MiB | 0.6 MB |
POST /v1/feed, 30 channels, cached |
27.0 ms | 54.0 ms | 6.2 MiB | 0.9 MB |
POST /v1/feed, 100 channels, cached |
83.7 ms | 170 ms | 20.8 MiB | 1.7 MB |
GET /v1/body/durov, uncached |
4.90 ms | 4.86 ms | 742 KiB | 91 KB |
GET /v1/body/tginfo, uncached |
9.40 ms | 9.35 ms | 1.5 MiB | 238 KB |
The 100-channel feeds use aliases of the six recorded channels, each with its own username: 2 000 posts with many duplicate stories, a heavy case for the duplicate search.
Cold page pipeline, per recorded channel page (ms, single thread):
| Page | parse (Rust) | from_json |
validate | 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 |
Parsing releases the GIL; everything after it (from_json to Python
objects, pydantic validation and serialization, the cache encoding) holds it:
0.5–2.6 ms per page. A /v1/body miss also validates and renders the page a
second time on the event loop (body_json → body()), although the parsing
thread already rendered it.
Feed build, 100 cached pages, 2 000 posts (2.0 code, ms):
| Stage | Where | Time |
|---|---|---|
| decode every page to Python objects | event loop, GIL held | 33 |
build a Candidate per post |
event loop, GIL held | 5.5 |
| rank | worker thread, Rust, GIL released | 35 (8 cores) / 82 (1 core) |
| build entries, validate, dump | event loop, GIL held | 2.3 |
Event-loop stall. Cached /v1/body/durov from two clients at 50 req/s
each, latency counted from when each request was due, next to one client
building 100-channel feeds in a loop:
| p50 | p99 | Feeds/s | |
|---|---|---|---|
| 8 cores | 15.2 ms | 61.6 ms | 11.2 |
1 core (RAYON_NUM_THREADS=1) |
2.2 ms | 61.0 ms | 7.4 |
(Alone, that request takes 0.11 ms.)
By component¶
| Component | Python work per request | Bound by | Notes |
|---|---|---|---|
| granian + FastAPI routing, validation, 3 middlewares | ~30 µs in process; ~50 µs per request on a real socket (21 k req/s per worker on /healthz) |
CPU (Python) | the per-request floor of every endpoint |
| Response building | cached /v1/body, /v1/more: none (bytes from the cache); /v1/post: decode the page, validate one post, dump (0.25 ms); /v1/preview(s): decode, validate, dump (~10 µs per channel) |
CPU (Python, pydantic-core) | |
| Page pipeline on a miss | 0.5–2.6 ms per page after the native parse, plus the second render of /v1/body |
CPU (Python, pydantic-core) | runs on 2 parsing threads, but holds the GIL |
| Cache | L1: native (moka + zstd), tens of µs with the GIL held; L2: the asyncio redis-py client (hiredis parser) per command plus the round trip | L1 CPU, L2 I/O | the two-level cache (PR #201) |
Single-flight (coalesce.py) |
one task, a shield and a dict entry per call: ~40 µs, mostly an extra event-loop turn | CPU (Python) | cross-worker coalescing is in progress elsewhere |
Rate limit, proof of work (protection.py) |
checked in Rust (ratelimit.rs, pow.rs) in under a microsecond, inside one pure ASGI middleware; the Python sliding window it replaced cost +4 µs per request |
CPU (Rust) | off by default; PR #202 |
| Feed assembly | see above: 41 ms of GIL-held Python per 100 channels | CPU (Python) | stage 1 |
| Popularity, leader lock | one Redis pipeline per feed (ZINCRBY × channels), one EVAL per tick |
I/O | redis-py builds the pipeline in Python |
| Refresher loop | scheduling only; the pages go through the page pipeline | I/O + page pipeline | ≤ 16 refreshes at a time |
HTTP client (client.py) |
a semaphore and an await; /v1/more also json.loads of the reply (~0.2 ms) |
I/O (Rust, wreq) | |
| GraphQL (strawberry) | ~0.85 ms per query on top of the services | CPU (Python) | query parsing, validation, resolver per field |
| Worker model | one process per worker, each with its own interpreter, L1 cache and HTTP pool | memory | see below |
Memory per worker¶
Linux, arm64, Python 3.14 (scripts/memory_profile.py, scripts/audit.py --memory):
| RSS | Anonymous | |
|---|---|---|
| Interpreter alone | 9.0 MiB | 3.4 MiB |
| granian main process (supervisor) | 43.7 MiB | 26.2 MiB |
| Worker with the application built, idle | 85.3 MiB | 63.2 MiB |
| Worker after 20 000 mixed requests over 1 200 channels (in-process cache 41 MiB accounted) | 187–212 MiB, peak 220 MiB | |
| Worker serving 200 100-channel feeds | peak 220 MiB |
Python's own live objects stay at ~13 MiB through the mixed workload (tracemalloc); the rest is the interpreter and its imports, the in-process cache, native buffers and what malloc keeps between bursts.
A worker's baseline is the interpreter and the imported application (FastAPI,
pydantic, strawberry, redis-py, the native extension), plus up to
MEMORY_CACHE_MAX_MB of L1. WEB_CONCURRENCY workers duplicate all of it,
the L1 included.
What it shows¶
- Nearly all CPU-bound work outside the parser and the ranker was in feed assembly: 41 ms of Python per 100-channel feed with the GIL held, ~20 MiB of short-lived Python objects, and a stall of every other request on the worker for that long. That is the biggest single item.
- Next is the page pipeline after the parser (0.5–2.6 ms of GIL-held
pydantic per page, twice for a
/v1/bodymiss). It matters for refresher ticks and cold traffic, not for cache hits. - Cache hits are already cheap (0.07–0.33 ms). What remains there is the
per-request floor of granian's ASGI bridge and FastAPI, and pydantic for
/v1/postand previews. - GraphQL costs ~0.85 ms of Python per query regardless of the data.
- The I/O-bound parts (HTTP client, Redis) are already native or C; what Python adds there is per-command overhead, not throughput.
- The ranker itself (Rust) is now the largest CPU item of a feed: ~80 ms per 2 000 posts on one core. Optimising it is independent of where the glue runs.
Options¶
(a) Keep the granian/FastAPI shell, move whole pipelines native¶
Each endpoint's work becomes one native call: fetch, parse, validate and render bytes, with the GIL released; native cache, single-flight and rate limiter. Python keeps routing, settings, feature flags, the error mapping, the feed's freshness rules and the refresher's scheduling.
- For: incremental, every step ships alone and is checked against the byte snapshots; OpenAPI (from the pydantic models), GraphQL and the CLI stay as they are; business rules stay in Python; Docker and the deploy scripts do not change. It is the path the parser, the ranker and the L1 cache already took.
- Against: the per-request floor stays (granian's ASGI bridge, FastAPI, ~50 µs per request, ~20 k req/s per worker), and so does a full interpreter per worker. GraphQL stays in Python.
(b) A Rust HTTP server (axum/hyper on tokio) serving /v1¶
The server is Rust; Python is embedded (PyO3) or called only for business rules.
- For: the lowest floor (tens of µs and several times the throughput per core), one multi-threaded process instead of one process per worker, so one L1 cache and one HTTP pool per server instead of one per worker: the biggest memory saving.
- Against: a big-bang switch of the server, router, middleware, error mapping and OpenAPI generation; an embedded interpreter brings the GIL back for every rule it runs, so rules must become data or Rust to get the benefit; GraphQL would need a Rust port (async-graphql) with an identical schema and errors, or stay a second, Python service. The rules stop being "easy to change in Python".
(c) A Rust core service and a thin Python control plane¶
A Rust binary serves /v1 (and later GraphQL) and runs the refresher; Python
keeps configuration, the CLI, fixtures and scripts, and possibly GraphQL as a
separate process calling the core.
- For: the same runtime gains as (b) without embedding; a clean boundary.
- Against: two processes per VM to deploy, configure and monitor; the control plane needs an interface (config file, admin API); the same big-bang server switch as (b), and everything that today is Python glue (feature flags, freshness rules) has to be re-expressed in Rust or in config.
Recommendation: (a), staged¶
Option (a) gets the CPU and GIL wins where they are measured (feed assembly,
the page pipeline, GraphQL resolution) without a risky switch. Each stage moves
a whole pipeline into telegram-me-native, ships on its own, is verified by
the byte snapshots and has its own measured win. When the pipelines are
native, the process is already a Rust core behind a Python shell: what is left
in Python is the ~50 µs ASGI/FastAPI floor and one interpreter per worker.
Whether that is worth replacing with an axum front (moving towards (c)) is
decided then, on numbers (stage 6), not now.
Stages¶
Each stage: snapshots (make snapshots, raw digests) unchanged, its own
before/after numbers, its own PR.
- Native feed assembly — done in this release.
FeedServicehands the cached page bytes totelegram_me_native.build_feedand gets the response bytes back (one call, GIL released): reading pages, ranking signals, ranking, rendering. Python keeps the stale-while-revalidate rules and popularity. See the results. - Native page pipeline. Parse, validate and render in one call:
parse_channel_pagereturns the cache encoding and the rendered/v1/body(and/v1/more) bytes directly, with the schema checks pydantic does today (URL normalisation — pydantic-core uses the sameurlcrate —, literal values, defaults, the field validators, dropping posts that do not validate). The pydantic models stay as the documented schema (OpenAPI, GraphQL types) and become the oracle of a differential test over the fixtures and generated posts. Removes 0.5–2.6 ms of GIL-held Python per page and the second render of a/v1/bodymiss; makes refresher ticks GIL-free. - Native cached reads.
/v1/post,/v1/previewand/v1/previewsrendered natively from cached bytes (extract one post, render previews), so no cache hit builds Python objects. Builds on stage 2's renderer. - Native Redis level and popularity. A Rust Redis client (redis-rs or
fred, on the tokio runtime) behind the existing
Cacheprotocol and the two-level cache, plus popularity and the leader lock; one round trip per feed for all its pages (MGET). Coordinated with the cross-worker request coalescing work, which also lives on Redis. - GraphQL resolution. Resolvers read native-rendered bytes and the
heaviest selections (
body.posts) are projected natively; strawberry keeps the schema, so the SDL snapshot does not change. Removes most of the ~0.85 ms per query. - The shell, decided on numbers. With 1–5 done, measure what the ASGI
bridge, FastAPI and the middlewares cost and what one interpreter per
worker costs in RAM. If it is worth it, serve
/v1from an axum front in the same image (Python keeping GraphQL, docs and the CLI) or run one worker with native threads instead of several processes. The rate limiter and proof of work are already native (ratelimit.rs,pow.rs, PR #202).
Not planned: the ranker's algorithm. Its speed is separate work: at ~80 ms per 2 000 posts on one core it is now the largest CPU item of a feed.
Stage 1 results¶
Same machine, same script, main (with the two-level cache) against this
release. Responses are byte-identical (tests/snapshots/raw_digests.json).
| Before | After | ||
|---|---|---|---|
POST /v1/feed, 6 channels, wall |
8.97 ms | 6.17 ms | −31 % |
POST /v1/feed, 30 channels, wall |
27.0 ms | 14.7 ms | −45 % |
POST /v1/feed, 100 channels, wall |
83.7 ms | 42.7 ms | −49 % |
POST /v1/feed, 100 channels, CPU on one core |
131 ms | 106 ms | −19 % |
POST /v1/feed, 6 / 30 channels, CPU on one core |
11.3 / 40.6 ms | 9.1 / 32.5 ms | −20 % |
POST /v1/feed, 100 channels, allocated by Python |
20.8 MiB | 9.8 MiB | −53 % |
| Python allocations, 6-channel feed | 2.0 MiB | 1.1 MiB | −44 % |
Cached /v1/body next to a feed loop, p50 / p99 (8 cores) |
15.2 / 61.6 ms | 2.3 / 10.5 ms | 6.6× / 5.9× |
| Same, one core, p99 | 61.0 ms | 4.7 ms | 13× |
| 100-channel feeds per second, one worker (8 cores / 1 core) | 11.2 / 7.4 | 23.3 / 9.3 | 2.1× / 1.3× |
Resident memory does not change measurably: on Linux a worker serving 200 100-channel feeds peaked at 220 MiB before and 218 MiB after, and the mixed 20 000-request workload at 220 and 223 MiB (run-to-run noise is about ±10 MiB). The Python objects a feed no longer builds were short-lived, and a worker's RSS is dominated by the interpreter with its imports (85 MiB) and the in-process cache. The memory win of this roadmap comes from stage 6 (fewer interpreters, one cache per server), not from moving individual pipelines.
What is left of a feed in Python is resolving the pages (the cache lookups, whose L1 reads still hold the GIL for tens of µs each) and the popularity update. The rest of the CPU is the ranker.