Rendimiento¶
Todas las cifras de abajo se midieron en la misma máquina (un portátil Apple Silicon, Python 3.14) durante la preparación de 2.0, con t.me sustituido por páginas grabadas para medir solo el propio servicio. Tómalas como relativas, no como absolutas.
1.x → 2.0, dentro del proceso¶
Latencia mediana de una solicitud, con el origen servido desde fixtures:
| Solicitud | 1.x | 2.0 | |
|---|---|---|---|
| Analizar cinco páginas de canal (suma) | 33.7 ms | 25.0 ms | −26 % |
/v1/body/durov, sin caché |
14.5 ms | 7.1 ms | 2× más rápido |
/v1/body/tginfo, sin caché |
24.5 ms | 15.5 ms | 1.6× más rápido |
/v1/body/durov, en caché |
— (sin caché de páginas) | 0.9 ms | 16× más rápido |
/v1/body/tginfo, en caché |
— | 1.1 ms | 22× más rápido |
/v1/feed, 5 canales, páginas en caché |
81.9 ms | 5.4 ms | 15× más rápido |
Caché de dos niveles¶
Una solicitud repetida costaba un viaje de ida y vuelta a Redis cuando
REDIS_URL estaba definida: el Upstash de producción está a ~90 ms por TLS,
así que casi todo el tiempo de una respuesta en caché era el GET. Ahora cada
worker responde primero desde su caché dentro del proceso (Rust, moka) y solo
va a Redis en un fallo (consulta Caché).
Mediana / p95 de 200 solicitudes repetidas para @durov (un worker, ASGI
llamado directamente, origen servido desde fixtures, Apple serie M,
Python 3.13). El Redis a 90 ms es un Valkey 9.1 local detrás de un proxy que
retrasa cada dirección 45 ms; "caché" es el tiempo dedicado a cache.get por
solicitud:
| Solicitud | Redis 90 ms: 2.0.0 | Redis 90 ms: ahora | Redis local: 2.0.0 | Redis local: ahora | Sin Redis: 2.0.0 | Sin Redis: ahora |
|---|---|---|---|---|---|---|
/v1/body/durov |
95.0 / 97.4 ms (caché 94.3) | 0.10 / 0.12 ms (caché 0.04) | 0.35 / 0.69 ms | 0.10 / 0.13 ms | 0.15 / 0.16 ms | 0.10 / 0.11 ms |
/v1/post/durov/<id> |
96.7 / 98.5 ms (caché 94.7) | 0.25 / 0.28 ms (caché 0.04) | 0.88 / 1.08 ms | 0.25 / 0.36 ms | 0.29 / 0.33 ms | 0.25 / 0.26 ms |
/v1/more/durov/before/<id> |
95.0 / 96.1 ms (caché 94.4) | 0.09 / 0.10 ms (caché 0.03) | 0.34 / 0.54 ms | 0.09 / 0.12 ms | 0.11 / 0.12 ms | 0.09 / 0.10 ms |
/v1/preview/durov |
94.4 / 96.0 ms (caché 93.9) | 0.08 / 0.08 ms (caché 0.003) | 0.30 / 0.53 ms | 0.08 / 0.11 ms | 0.08 / 0.09 ms | 0.08 / 0.08 ms |
| Carga mixta, 3 000 solicitudes, 8 clientes | 51.7 s | 33.3 s | 2.31 s | 2.33 s | 2.27 s | 2.04 s |
| RSS máximo de esa ejecución | 101 MiB | 120 MiB | 135 MiB | 166 MiB | 153 MiB | 152 MiB |
- La primera lectura de una clave en un worker sigue costando un viaje de ida
y vuelta a Redis (
GETyPTTLen un solo pipeline), como antes; también una escritura. La carga mixta es sobre todo de primeras lecturas (cada solicitud nombra un alias de canal nuevo), por eso gana menos. - Sin Redis, la caché nativa sustituye al LRU de Python: un acierto ya no se
descomprime en Python (
/v1/body0.15 → 0.10 ms) y una página de 95 KB se guarda en 83 µs en lugar de 461 µs (zlib en 3.13; el zstd de Python 3.14 se acerca más). - Con Redis, cada worker ahora también guarda lo que leyó o escribió en los
últimos
MEMORY_CACHE_MAX_TTL(10) segundos, limitado porMEMORY_CACHE_MAX_MB: +19–31 MiB de RSS máximo en esta ejecución, en la que todo se leyó en menos de 10 s. - Se midió y descartó liberar el GIL en lecturas y escrituras: junto a dos hilos de Python que consumen CPU (pydantic en los hilos de análisis), una lectura de 95 KB tardó 6.3 ms (p50) con el GIL liberado y 38 µs con él retenido.
Montaje nativo del feed¶
Un feed decodificaba cada página en caché en objetos de Python, construía una
entrada del clasificador por publicación y serializaba el resultado con
pydantic, en el event loop y con el GIL retenido: ~41 ms de un feed de 100
canales. Ahora es una sola llamada a telegram_me_native.build_feed con el
GIL liberado (entran los bytes de las páginas en caché, salen los bytes de la
respuesta). scripts/audit.py, dentro del proceso, en la misma máquina, feeds
de 100 canales con 2 000 publicaciones:
| Antes | Después | |
|---|---|---|
POST /v1/feed, 100 canales |
83.7 ms | 42.7 ms |
CPU de ese feed en un núcleo (RAYON_NUM_THREADS=1) |
131 ms | 106 ms |
| Asignaciones de Python de ese feed | 20.8 MiB | 9.8 MiB |
/v1/body en caché junto a un bucle de feeds, p50 / p99 |
15.2 / 61.6 ms | 2.3 / 10.5 ms |
| RSS máximo de un worker que sirve 200 feeds así (Linux) | 220 MiB | 218 MiB (ruido) |
Detalles, costes por endpoint y lo que se mueve a continuación: Hoja de ruta hacia Rust.
Agrupación de solicitudes¶
scripts/coalescing_load.py arranca 6 procesos worker (piensa en dos
servidores de tres workers de granian) sobre un mismo Valkey, cada uno con 32
clientes que llaman directamente a su app. Cada cliente elige al azar entre
las mismas 37 solicitudes: cada canal grabado a través de /v1/body (la más
reciente y alrededor de una publicación), /v1/post, /v1/more en ambos
sentidos, tres vistas previas, un feed de 6 canales y una consulta GraphQL con
un body y el feed. t.me se sustituye por páginas grabadas que se responden tras
150 ms; CACHE_MODE=normal (10 s), así que las 27 solicitudes distintas al
origen caducan dos veces en una ejecución de 25 s. Dos ejecuciones de cada
caso, Apple serie M con 8 núcleos:
| main (con el feed nativo) | este cambio, SHARED_COALESCING=false |
este cambio | |
|---|---|---|---|
| Solicitudes al origen, Valkey local | 417 / 412 | 421 / 428 | 75 / 75 |
| Solicitudes al origen, Valkey a 90 ms | 434 / 422 | 414 / 413 | 75 / 75 |
| Por solicitud distinta al origen | 15.3–16.1 | 15.3–15.9 | 2.8 (una vez por periodo de TTL) |
| Solicitudes servidas en 25 s, local | 117 k / 117 k | 530 k / 537 k | 548 k / 533 k |
| CPU por solicitud, local | 1.41 / 1.43 ms | 0.30 / 0.29 ms | 0.29 / 0.29 ms |
| CPU por solicitud, 90 ms | 1.43 / 1.41 ms | 0.27 / 0.27 ms | 0.27 / 0.27 ms |
| Latencia p99, local | 917 / 902 ms | 206 / 203 ms | 199 / 206 ms |
| Mayor bloqueo del event loop de un worker, local | 845 / 905 ms | 246 / 200 ms | 224 / 228 ms |
- La concesión es lo que elimina los duplicados entre procesos: 75 solicitudes al origen es el mínimo para esta ejecución (27 distintas, renovadas como mucho dos veces), y se mantiene con Redis a 90 ms. Sin ella, cada proceso descarga cada página por su cuenta, como antes.
- Compartir el renderizado es lo que recorta la CPU: los feeds idénticos se
montan una vez (y, para GraphQL, se decodifican una vez) en lugar de una vez
por solicitud,
/v1/postse guarda en caché ya renderizado y los fallos concurrentes de/v1/bodyse renderizan una vez. La CPU liberada se dedicó a servir 4–5 veces más solicitudes en el mismo tiempo, con bloqueos del event loop más cortos. - Un comando de Redis que agota su tiempo de espera abre el circuit breaker del worker; ese worker descarga entonces por su cuenta (una descarga por clave, como antes de la concesión) hasta que se cierra. Se vio en desarrollo cuando el event loop de un worker se bloqueó más de 2 s (antes de que los feeds de GraphQL se decodificaran una sola vez), no en las ejecuciones de arriba.
La ruta crítica en caché no cambia (un worker, mediana de 2 500 aciertos, ASGI llamado directamente):
| Acierto | main | este cambio |
|---|---|---|
/v1/body/durov |
0.101 ms | 0.101 ms |
/v1/more/durov/before/<id> |
0.091 ms | 0.091 ms |
/v1/post/durov/<id> |
0.242 ms | 0.067 ms (en caché ya renderizado) |
La admisión son unas pocas llamadas nativas solo en un fallo de caché; un acierto añade una llamada nativa que compara la antigüedad de la entrada con su punto de renovación.
Análisis de páginas: Python + lexbor → Rust¶
Tiempo mediano para analizar una página de canal grabada
(scripts/benchmark.py):
| Página | Python + selectolax/lexbor | Rust (html5ever) |
|---|---|---|
| @durov (20 publicaciones, muchos emoji personalizados) | 4.9 ms | 3.6 ms |
| @tginfo (20 publicaciones largas, muchos enlaces) | 12.4 ms | 6.3 ms |
La diferencia mayor se nota bajo carga: el parser de Rust libera el GIL, así que las solicitudes concurrentes analizan páginas en paralelo en lugar de hacer cola en un solo núcleo (24 páginas en 24 hilos: 111 ms → 67 ms en 8 núcleos, antes de las optimizaciones de arriba).
El event loop durante un ciclo del actualizador¶
Retardo del event loop de un worker con Redis (Valkey) y el actualizador integrado, con una carga dentro del proceso de 8 clientes × 3 000 solicitudes dos veces y, entre ambas, un ciclo del actualizador que refresca los ~200 canales que la primera pasada hizo populares:
| Retardo máximo | Retardo mediano | Duración | Aperturas del breaker | |
|---|---|---|---|---|
| Análisis en el event loop, ciclo sin límite | 0.87–1.31 s | 9 ms | 32–36 s | 1 por ejecución |
| 2 hilos de análisis, ≤ 16 actualizaciones a la vez | 25–122 ms | 1.1 ms | 13 s | ninguna |
Cada descarga terminada analizaba su página (~3 ms en nativo) y la validaba y
renderizaba (~0.5–2 ms en pydantic) directamente en el event loop. Cuando las
descargas de un ciclo terminaban juntas, se ejecutaban una tras otra durante
alrededor de un segundo, más que REDIS_TIMEOUT, así que un comando de Redis
en curso agotaba su tiempo de espera y abría el circuit breaker durante 30 s.
Ahora las páginas se analizan en dos hilos dedicados: el executor por defecto
(hasta cpu + 4 hilos) no era más rápido y, al competir con el event loop por
el GIL, triplicaba su retardo p99 (~21 ms en lugar de ~7 ms). El actualizador
refresca como mucho 16 canales a la vez, lo que además deja huecos hacia el
origen para las solicitudes de los usuarios.
Cliente HTTP saliente¶
2 000 solicitudes HTTPS de una página de 148 KB, 32 concurrentes, contra un servidor TLS local:
| Cliente | Solicitudes/s | CPU por solicitud | Memoria máxima |
|---|---|---|---|
| httpx, HTTP/2 (1.x) | 584 | 1.31 ms | 219 MB |
| httpx, HTTP/1.1 | 378 | 1.85 ms | 151 MB |
| aiohttp | 5 842 | 0.17 ms | 71 MB |
| curl_cffi | 4 444 | 0.23 ms | 58 MB |
| httpr (Rust) | 9 308 | 0.23 ms | 53 MB |
| pyreqwest (Rust): 2.0 | 10 563 | 0.15 ms | 51–64 MB |
Además, 1.x creaba un cliente nuevo (y un nuevo handshake TLS) para cada solicitud a t.me.
Servidor HTTP¶
Un worker, 64 conexiones, 10 s (wrk), la misma aplicación:
| Endpoint | uvicorn + httptools + uvloop | granian: 2.0 |
|---|---|---|
/healthz |
15 774 sol./s, p99 7.97 ms | 21 120 sol./s, p99 3.23 ms |
/v1/body/durov (en caché, 91 KB) |
4 088 sol./s, p99 23.9 ms | 4 365 sol./s, p99 31.4 ms |
Prerrenderizar las respuestas en caché elevó el rendimiento de /v1/body en
caché de ~850 a ~4 350 solicitudes por segundo en un worker.
Apagado y memoria¶
| 1.x | 2.0 | |
|---|---|---|
docker stop |
10 s y después SIGKILL (uvicorn no era el PID 1) |
0.3 s, código de salida 0 |
Redis inaccesible, /v1/preview |
10 s por solicitud | un tiempo de espera de 0.5 s y después sin caché |
| Memoria del contenedor, 1 worker, en reposo | — | ~110 MB (consulta Memoria) |
Memoria¶
Medido en arm64 con la imagen de Docker (docker stats, un worker salvo que se
indique otra cosa), antes y después del trabajo sobre la memoria que se hizo
para 2.0. «Ligero» son unos cientos de solicitudes reales sobre seis canales;
«intenso» son 2 × 20 000 solicitudes desde 8 clientes concurrentes sobre 1 200
canales distintos, con t.me sustituido por las páginas grabadas.
| Escenario | Antes | Después |
|---|---|---|
| En reposo | 146–153 MiB | 110 MiB |
| Tráfico ligero | 185 MiB | 143 MiB |
WEB_CONCURRENCY=2, en reposo / tráfico ligero |
217 / 278 MiB | 180 / 224 MiB |
| Tráfico intenso, caché en memoria (límite de 64 MB) | 458 MiB | 229–253 MiB |
| Tráfico intenso, Redis, durante / 15 s después | 296–302 / 299 MiB | 190–263 / 155 MiB |
| Proceso principal de granian (memoria privada) | 65 MiB | 29 MiB |
Qué cambió y por qué:
-
La caché en memoria contaba caracteres, no bytes. Los valores eran
strde Python; un solo emoji hace que CPython guarde toda la cadena con cuatro bytes por carácter, así que una página renderizada de 89 KiB en UTF-8 ocupaba 353 KiB, y el límite de 64 MB permitía a un worker retener ~260 MiB de caché. Ahora los valores son bytes UTF-8, comprimidos con zstd nivel 1 (zlib en Python 3.13), y el límite cuenta el valor comprimido, la clave y los datos de control (~230 bytes por entrada, medido):Página Como strUTF-8 En la caché ahora @durov, renderizada 353 KiB 89 KiB 21 KiB @tginfo, renderizada 700 KiB 233 KiB 43 KiB @stranaua, renderizada 93 KiB 59 KiB 13 KiB Descomprimir una página (25–120 µs) es más rápido de lo que era codificar la misma
stra UTF-8 en cada acierto (27–306 µs), y las respuestas de Redis también se leen como bytes. -
glibc retenía la memoria liberada. Las respuestas se construyen a partir de objetos grandes de vida corta y, tras una ráfaga, el heap de glibc se quedaba en su máximo: con Redis, un worker retenía 97 MiB de heap, de los que solo 15 MiB estaban en uso. Ahora los workers llaman a
malloc_trim(0)cadaMEMORY_TRIM_INTERVALsegundos (alrededor de 1 ms por llamada), lo que en ese caso devolvió ~80 MiB. - El proceso principal de granian importaba la aplicación (FastAPI, GraphQL, Redis, el cliente HTTP, la extensión nativa) solo para supervisar a los workers. Ahora la importa en la fábrica de workers.
- FastAPI ejecutaba las dependencias en un pool de hilos (
defsimples), lo que mantenía un hilo adicional, con su propia arena de malloc, en cada worker. Ahora sonasync def.
Evaluado y no adoptado (tráfico intenso, medido cuando el parser todavía era Python; compara las filas entre sí):
| Opción | Tráfico intenso, caché en memoria | Veredicto |
|---|---|---|
| glibc (por defecto) | 205 MiB | se mantiene |
MALLOC_ARENA_MAX=2 |
202 MiB | dentro del ruido |
glibc con MALLOC_MMAP_THRESHOLD_ / MALLOC_TRIM_THRESHOLD_ fijos |
211 MiB | dentro del ruido; con Redis recuperaba solo la mitad de lo que recupera malloc_trim |
jemalloc (LD_PRELOAD), por defecto / decaimiento de 1 s / 2 arenas |
212 / 206 / 200 MiB, +5–17 MiB en reposo | sin mejora |
PYTHONMALLOC=malloc + jemalloc |
200–227 MiB, +15 MiB en reposo | sin mejora |
| mimalloc | 256 MiB | peor |
| tcmalloc | 252 MiB | peor |
Workers creados por fork desde un proceso principal precargado + gc.freeze() (2 workers) |
273 en lugar de 333 MiB | no adoptado: granian hace fork desde un supervisor multihilo («use of fork() may lead to deadlocks»), así que los workers se siguen creando con spawn |
El análisis no retiene memoria: las páginas se analizan de forma nativa y se
devuelven como una única cadena JSON, y las pruebas de regresión de memoria no
muestran crecimiento tras cientos de análisis sin caché. WEB_CONCURRENCY,
MEMORY_CACHE_MAX_MB y WORKERS_MAX_RSS_MB conservan sus valores por
defecto: con una contabilidad real, 64 MB de caché alojan ahora entre 4 y 16
veces más páginas que antes dentro de un presupuesto fijo.
Protección frente a regresiones¶
tests/test_memory.py ejecuta miles de solicitudes a través de la app y falla
cuando la memoria sigue creciendo tras un calentamiento o la caché supera su
límite; detecta tanto una página analizada que se pierde en cada solicitud
como memoria nativa que se pierde.
rust/telegram-me-native/tests/cache_memory.rs cuenta cada asignación de la
caché dentro del proceso y falla cuando su contabilidad ya no las cubre. Para
ver las cifras:
Reproducir¶
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
Para el rendimiento del servidor, arranca una instancia y apunta un generador de carga hacia ella: