Benchmarks¶
Estas cifras salen directamente de los scripts en bench/, ejecutados en la
máquina del mantenedor (Python 3.14, red loopback). No son una suite de
benchmarks formal y reproducible con intervalos de confianza — trátalas como
una comprobación de orden de magnitud, y vuelve a ejecutarlas en tu propio
hardware antes de tomar una decisión basada en ellas:
uv run python bench/bench_remote.py # throughput
uv run python bench/bench_latency.py # percentiles de RTT
uv run python bench/bench_codec.py # ruta de codec + conversión JSON, capa a capa
uv run python bench/bench_memory.py # RSS bajo carga sostenida, churn de conexiones y backpressure de mailbox
uv run python bench/bench_pool.py # spawn_pool(): speedup de CPU con executor=, acotado de queue_capacity, limpieza de stop()
Throughput (bench_remote.py)¶
| Métrica | Resultado |
|---|---|
asyncio.Queue put/get (línea base, sin lapinbeam) |
~1.6M msg/s |
| Envío local de actor en lapinbeam | ~440K msg/s |
| Envío remoto en lapinbeam (TCP loopback) | ~20K msg/s |
Metodología: bench_asyncio_queue mide un asyncio.Queue puro con una
corrutina productora y una consumidora como techo teórico para el paso de
mensajes en un solo proceso Python. bench_local_send envía al ActorRef
de un actor ya creado (2000 envíos de calentamiento, luego se cronometra).
bench_remote_send envía a un RemoteRef sobre una conexión TCP loopback
ya establecida (100 envíos de calentamiento + 200ms de asentamiento, luego
se cronometra) — esto es throughput de disparar-y-olvidar, no de
petición/respuesta.
La caída de aproximadamente 20x entre envío local y remoto es esperable: los
envíos locales son referencias a objetos Python sin copia dentro de un
asyncio.Queue; los envíos remotos pagan la codificación JSON, una
escritura con framing bincode a un socket TCP real (aunque loopback), y la
cola de salida por peer y la tarea de escritura del lado Rust. Ver "Codec"
más abajo para saber dónde se va el coste propio de la ruta remota.
Codec (bench_codec.py)¶
Desglosa la ruta de envío remoto capa a capa — donde full encode y
end-to-end incluyen todo lo que está por encima en la tabla:
| Paso | Resultado |
|---|---|
codec.encode_payload (Python: tagging de dataclass/Pydantic) |
~42K ops/s |
codec.decode_payload (Python) |
~52K ops/s |
_core.encode_payload (Rust: PyAny → JSON) |
~5K ops/s |
_core.decode_payload (Rust: JSON → PyAny) |
~11K ops/s |
Encode completo (codec + _core, lo que hace _send_remote) |
~4.5K ops/s |
remote.send() de extremo a extremo (disparar-y-olvidar, conexión loopback real) |
~2.3K ops/s |
Metodología: 20.000 operaciones cronometradas por fila (200 de
calentamiento), sobre un payload moderadamente anidado — unos escalares,
un dict anidado, y una lista de 20 dicts anidados (ver el script para la
forma exacta). Es más pesado que el mensaje trivial {"n": 1} de
bench_remote.py, por eso estas cifras por operación son menores que la
cifra de ~20K msg/s de throughput de arriba — los dos scripts no miden el
mismo payload, solo la misma ruta de código.
Latencia (bench_latency.py)¶
| Métrica | Resultado |
|---|---|
| RTT de despacho local (envío → recepción) | p50 0.007 ms |
| RTT TCP loopback remoto (envío + ack) | p50 0.44 ms / p99 0.93 ms |
Metodología: 2000 round trips cronometrados (100 de calentamiento) por
medición. "Despacho local" cronometra un envío desde fuera de cualquier
actor a la mailbox de un actor Client y de vuelta a un asyncio.Event, es
decir, puro overhead de scheduling de Python/asyncio sin red involucrada.
"Loopback remoto" cronometra un round trip completo a través del transporte
Rust: el nodo A envía a un actor Echo en el nodo B, que responde al nodo A
sobre la misma conexión TCP multiplexada — esta es la cifra a comparar
contra un round trip mediado por un broker (ver
lapinbeam frente a Celery + RabbitMQ).
Memoria (bench_memory.py)¶
A diferencia de los benchmarks de throughput/latencia/codec de arriba, este
no da una cifra única que comparar — muestrea la RSS de este proceso
(VmRSS de /proc/self/status, solo Linux) a lo largo de tres
escenarios más largos e imprime si se estabiliza (sano) o sigue creciendo
(una fuga):
- Tráfico sostenido local + remoto — la RSS debería estabilizarse en los primeros segundos y quedarse plana.
- Churn rápido de
connect_peer()/forget_peer(), ejecutado en varias rondas seguidas — una fuga real por ciclo sigue añadiendo aproximadamente lo mismo cada ronda; un comportamiento sano se aplana después de la primera. - Un actor en bucle de caída permanente, una vez con el buzón sin
límite por defecto y otra con
mailbox_capacityconfigurado — demuestra que la limitación de "buzones sin límite" (ver Limitaciones) es trivial de disparar de verdad (basta un consumidor lento o que se cae en bucle) y quemailbox_capacityde verdad la acota, disparandoon_event(kind="mailbox_full")en vez de crecer para siempre.
Los valores absolutos de RSS dependen mucho de la máquina y no tiene sentido compararlos entre ejecuciones o hardware — lo que importa es la forma de la curva de cada escenario (plana frente a creciente), por eso este script imprime una serie de muestras en vez de un único par antes/después.
Pool (bench_pool.py)¶
Centrado específicamente en Supervisor.spawn_pool(), responde a tres
preguntas que las comprobaciones generales de memoria de arriba no cubren:
- ¿
executor="process"da de verdad paralelismo real de CPU para trabajo CPU-bound, frente a un pool async normal oexecutor="thread"(ambos igual de limitados por el GIL)? En la máquina de 12 núcleos del mantenedor, con 4 workers: ~1x para el pool async normal yexecutor="thread", ~3.4x paraexecutor="process"— speedup real, aunque no perfectamente lineal, solo cuando de verdad corre en procesos separados. Ver el aviso "Este paralelismo es para trabajo I/O-bound, no CPU-bound" en Primeros pasos. - ¿
queue_capacityde verdad acota la memoria bajo sobrecarga sostenida? Sin ella, el RSS crece tan rápido como se envían mensajes (cientos de MiB en pocos segundos); con ella, el crecimiento se queda plano y el exceso se descarta y se cuenta víaon_event(kind="pool_queue_full")en vez de acumularse. - ¿
pool.stop()de verdad libera todo — mailboxes, tareas, contabilidad delSupervisory (para pools conexecutor=) hilos o procesos del sistema operativo — a lo largo de muchos ciclos de crear/destruir? El RSS debería estabilizarse tras los primeros ciclos, la misma comprobación de "¿se estabiliza?" que el escenario de churn de conexiones del script de Memoria.
También muestrea el CPU% de este proceso con grano fino (~0.15s, leyendo
/proc/self/stat directamente) durante una ejecución de pool particionado
(key=), para detectar el tipo de pico breve que solo se ve con ese
grano fino.
Cómo leer estas cifras correctamente¶
- Solo loopback. Nada de esto mide latencia de red real entre hosts separados — espera que las cifras remotas estén dominadas por el RTT real de red una vez los nodos estén en máquinas distintas, no por el overhead propio de lapinbeam. Loopback es justo lo que aísla el coste del framework del coste de la red. Ver Ejemplos para correr nodos entre hosts reales.
- Un solo peer, un solo actor. Los benchmarks de throughput/latencia/
codec no ejercitan múltiples peers concurrentes ni muchos actores
multiplexados sobre una conexión — aíslan el coste en el mejor caso de un
único envío.
bench_memory.pyes la excepción: ejercita específicamente el churn de conexiones y el backpressure de mailbox. - No hay una comparación ejecutada contra Celery+RabbitMQ en este repositorio. La comparación cualitativa de latencia en la página frente a Celery + RabbitMQ (salto por broker típicamente entre unos pocos y varias decenas de milisegundos) es una característica general de la mensajería mediada por broker, no un benchmark ejecutado contra lapinbeam lado a lado bajo condiciones idénticas. Tómala como orientativa, no como una cifra que citar.