El impuesto IPC: pon el motor de backtesting detrás de un socket y pierde un 13% — casi nada de eso es del socket
Parte de la serie "Backtests sin ilusiones".
📄 Este artículo se convirtió en un paper de investigación. Un kernel de backtesting dependiente de la trayectoria se porta línea por línea de numba a Rust y se llama a través de un límite de proceso/lenguaje de cuatro formas, con una puerta de equivalencia que confirma un PnL idéntico por combinación — más mediciones aisladas de la curva de latencia IPC pura, el impuesto de serialización y el costo de spawn. Lee el paper en línea (versión interactiva + PDF) en ipc-tax.marketmaker.cc, código y datos en github.com/suenot/ipc-tax.
Todo motor de backtesting que se vuelve rápido eventualmente provoca la misma conversación. El nuestro llegó puntual. La escalera de velocidad acababa de llevar un barrido de parámetros de 80 combinaciones de 69.9 segundos de pandas a unos 2 segundos de numba de un solo hilo, y la siguiente tentación natural fue: ¿por qué detenerse en un JIT de Python? Reescribir el kernel en Rust. Convertirlo en un verdadero servicio de motor — un binario compilado detrás de un socket, invocable desde cada script de investigación, cada lenguaje, y también el trader en vivo. Un kernel, una verdad, sin lógica duplicada.
Y luego llega el contraargumento, también puntual: en el momento en que sales del proceso, el IPC te devora. Los datos deben serializarse, enviarse a través de un límite, deserializarse; cada llamada paga syscalls y cambios de contexto; tu hermoso kernel de Rust se pasará la vida esperando en un pipe. Quédate en el proceso. Todo el mundo lo sabe.
Este artículo mide lo que todo el mundo cree saber, y la medición es más interesante que cualquiera de los dos lados del argumento. La creencia popular — "un motor multilenguaje más rápido pierde contra numba en proceso porque el IPC te mata" — resulta ser falsa en general y correcta solo bajo condiciones específicas. Cruzar el límite una vez, en bytes crudos, cuesta unos 2 milisegundos en un trabajo de dos segundos: un error de redondeo. El impuesto no está en el límite. Está en cómo lo cruzas — y las tres formas en que los servicios de motor suelen desplegarse en la práctica (una API JSON, una llamada por unidad de trabajo, un spawn de proceso por llamada) son, cada una, medible y comprobablemente, una pieza del desastre que predice el folclore.
Aquí está todo el experimento por adelantado. Todo lo de abajo es la anatomía de cada línea.
| Arquitectura | Qué cruza el límite por barrido | Tiempo real | vs en proceso |
|---|---|---|---|
| numba en proceso | nada — una llamada directa | 2.010 s | 1.00x |
| Servidor Rust, batched (socket Unix) | un round-trip: toda la serie + los 80 conjuntos de parámetros | 2.276 s | 1.13x |
Servidor Rust, batched, kernel get_unchecked |
el mismo round-trip único — una variante de kernel sin comprobación de límites (ver el veredicto) | 2.337 s | 1.16x |
| Servidor Rust, charlatán (socket Unix) | 80 round-trips: la serie se reenvía por cada combinación | 2.383 s | 1.19x |
| Rust spawn (stdin/stdout) | spawn de proceso + una petición canalizada | 2.300 s | 1.14x |
Apple M2 Max, Python 3.14.6, numpy 2.4.3, numba 0.64.0, rustc 1.94.0 (build de release, cero crates externos). 150,000 barras × 80 combinaciones, comisión de round-trip de 0.09%, semilla 42; la serie de cierre son 1,200,000 bytes (1.2 MB) en el cable. Mediana de 10 ejecuciones por arquitectura; los rangos min-máx se mantienen dentro de ~2%. Las cinco ejecutan el mismo barrido stop-and-reverse HMA/HMA3, y una puerta de equivalencia confirma que los resultados por combinación (PnL, número de trades) de ambas variantes del kernel de Rust coinciden exactamente con numba — huella de PnL −5165.58 a través de 57,029 trades, idéntica byte a byte al kernel numba del estudio de la escalera de velocidad con la misma semilla. Estamos comparando límites, no implementaciones.
Lee la fila batched con cuidado, porque carga toda la tesis. La arquitectura Rust-sobre-socket es 1.13x más lenta que numba en proceso — 266 ms por detrás en el barrido completo (derivado: 2.276 − 2.010). La historia popular dice que esos milisegundos son IPC. No lo son. Unos 2 ms de esa brecha son el límite — toda la serie de cierre de 1.2 MB enviada, resultados devueltos, medido directamente. Los otros ~264 ms restantes se deben a que nuestro kernel de Rust ingenuo simplemente calcula el barrido alrededor de un 13% más lento que el kernel numba (derivado: 2.276 s menos ~2 ms de límite ≈ 2.274 s de cómputo en Rust, frente a 2.010 s de numba). Rust el lenguaje no perdió contra Python el lenguaje; un bucle escalar compilado por LLVM perdió una carrera de generación de código contra otro — y ni siquiera pudimos atribuir la pérdida al sospechoso obvio: un build get_unchecked sin comprobación de límites del mismo kernel resultó no ser más rápido (2.337 s; la sección del veredicto lo diseca). El socket tuvo casi nada que ver con todo esto.
Sostén ambas mitades de esa frase. El límite es casi gratis cuando se cruza correctamente — y "reescríbelo en Rust" te compra un límite de despliegue, no una victoria automática de cómputo. Ambos hechos van en contra del instinto popular, y ambos están en la tabla.
Un kernel, dos lenguajes, cuatro límites
La carga de trabajo es deliberadamente la misma que fijó la escalera de velocidad, de modo que los dos estudios se anclan entre sí. El kernel es un cruce HMA/HMA3 — un sistema stop-and-reverse sobre dos medias móviles estilo Hull, siete pasadas de media móvil ponderada por combinación de parámetros más un bucle de eventos con estado barra por barra que lleva una posición, registra el PnL menos una comisión de round-trip del 0.09% en cada cruce, y se revierte. Los datos son 150,000 barras de movimiento browniano geométrico sintético con semilla (seed=42); la cuadrícula son 80 longitudes HMA repartidas en . La referencia en proceso es el peldaño numba de un solo hilo de la escalera, re-medido para este estudio: 1.98 s allí, 2.010 s aquí — mismo kernel, misma máquina, tranquilizadoramente aburrido.
El motor multilenguaje es un port línea por línea de ese kernel numba a Rust — mismos bucles, mismo manejo de NaN, misma aritmética de comisiones — compilado en modo release sin crates externos, de modo que todo el experimento se mantiene libre de dependencias y reproducible. Habla un protocolo binario deliberadamente mínimo: un frame prefijado por longitud en cada dirección, todo little-endian.
request: [u32 body_len][body]
body: [u8 opcode][u32 n_bars][u32 n_combos]
[n_bars × f64 close][n_combos × 6 × i64 params]
opcode 0 = sweep : reply = [n_combos × f64 pnl][n_combos × i64 trades]
opcode 1 = echo : reply = the close array, verbatim
El opcode echo es el bisturí del estudio: un round-trip de tamaño controlable que no computa nada, de modo que el costo puro del límite pueda medirse aislado — serialización, syscalls, tránsito por socket, deserialización, y nada más.
Cinco arquitecturas medidas — cuatro patrones de límite más una variante de kernel:
- in_process — llamar al kernel numba directamente. Sin límite. La referencia.
- rust_batch_unix — un servidor Rust persistente en un socket de dominio Unix. Un round-trip envía toda la serie de cierre más los 80 conjuntos de parámetros; Rust calcula cada combinación; llega una sola respuesta. La llamada voluminosa.
- rust_batch_unchecked — el mismo límite batched, pero el kernel indexa con
get_unchecked(sin comprobaciones de límites en la ruta caliente). Existe para probar una hipótesis específica sobre la brecha de cómputo; la sección del veredicto la gasta. - rust_chatty_unix — el mismo servidor, pero un round-trip por combinación, la serie de 1.2 MB reenviada cada vez. La arquitectura RPC-por-unidad-de-trabajo ingenua.
- rust_spawn_stdin — spawnear el binario por barrido y canalizar la petición por stdin. El patrón "invocar un motor CLI desde fuera"; paga la creación del proceso.
Y la puerta de equivalencia, sin la cual nada de esto tendría sentido: tras la medición de tiempo, el vector por combinación (PnL, número de trades) de cada variante de Rust se compara con el de numba — número de trades exacto, PnL a un absoluto. La ejecución comiteada reporta all_ok: true tanto para el build de indexación segura como para el get_unchecked. La huella de la primera combinación — PnL −5165.58 puntos porcentuales a través de 57,029 trades — coincide dígito por dígito con el kernel numba del estudio de la escalera de velocidad, lo que ancla ambos papers al mismo kernel con la misma semilla. Los ports multilenguaje son precisamente donde ama vivir la divergencia silenciosa (una comisión aplicada antes en lugar de después de la conversión a porcentaje, una comparación de NaN que se ramifica de forma distinta, un off-by-one en una ventana — la misma especie de bug que nuestra taxonomía de look-ahead bias mostró que puede fabricar un Sharpe de 15 a partir de ruido). Un benchmark de dos motores que computan cosas diferentes no es un benchmark; son dos programas no relacionados compitiendo.
Con la equivalencia establecida, cada diferencia en la tabla anterior es límite y cómputo — nada más.
Lo que realmente cuesta cruzar: la curva de eco

Empecemos con el bisturí. La operación echo hace un round-trip de un payload de floats a través del servidor Rust — Python construye el frame, el servidor parsea los floats, los recodifica y los envía de vuelta. Ambas direcciones pagan serialización, syscalls y tránsito por socket. Aquí está la curva medida (medianas sobre 10 ejecuciones):
| Payload (floats) | Bytes por dirección | Round-trip |
|---|---|---|
| 1 | 8 | 14.1 µs |
| 100 | 800 | 16.4 µs |
| 1,000 | 8,000 | 18.1 µs |
| 10,000 | 80,000 | 192.5 µs |
| 100,000 | 800,000 | 1,367.3 µs |
| 150,000 | 1,200,000 | 2,043.4 µs |
En esta tabla viven dos hechos estructurales.
Primero, el piso. Un round-trip que no lleva esencialmente nada — 8 bytes — cuesta 14 µs. Ese es el precio irreducible de hacer una llamada en absoluto sobre este transporte: dos syscalls write, dos syscalls read, la maquinaria del socket del kernel, despertares del planificador. Nota lo plana que es la curva a la izquierda: de 1 float a 1,000 floats el costo apenas se mueve (14.1 → 18.1 µs). Por debajo de unos 8 KB estás pagando por la llamada, no por los bytes. Este número — el piso de latencia — es la constante más importante de todo el estudio, y construiremos la aritmética de punto de equilibrio sobre él más abajo.
Segundo, la pendiente. Pasados los ~10,000 floats la curva se vuelve limitada por ancho de banda y aproximadamente lineal. La serie completa de 1.2 MB — 2.4 MB movidos en total, ida y vuelta, incluyendo un parseo y recodificación completos de 150,000 floats en el lado de Rust — cuesta 2,043.4 µs. Eso da un rendimiento efectivo de ~1.2 GB/s a través de toda la pila ingenua (derivado: 2.4 MB / 2.04 ms) — un socket de dominio Unix con frames prefijados por longitud y un parser de floats byte a byte, sin trucos de zero-copy, sin memoria compartida, nada sofisticado.
Un modelo razonable de un cruce individual, con ambas constantes medidas:
Ahora pongamos el número titular en contexto. El barrido completo tarda 2.010 s en proceso. Enviar todo su conjunto de datos a través del límite y de vuelta cuesta ~2.0 ms — alrededor del 0.1% del trabajo (derivado: 2.0434 ms / 2.010 s). Si cruzas una vez, en bytes crudos, el límite es un error de redondeo. Esa es la mitad de la creencia popular que muere primero: el miedo nunca fue sobre algo tan barato.
El lado Rust de ese cruce es tan poco glamoroso como puede ser el código de sistemas — adaptado de engine/src/main.rs:
fn read_frame<R: Read>(r: &mut R) -> Option<Vec<u8>> {
let mut len_buf = [0u8; 4];
r.read_exact(&mut len_buf).ok()?;
let len = u32::from_le_bytes(len_buf) as usize;
let mut body = vec![0u8; len];
r.read_exact(&mut body).ok()?;
Some(body)
}
fn write_frame<W: Write>(w: &mut W, body: &[u8]) {
w.write_all(&(body.len() as u32).to_le_bytes()).unwrap();
w.write_all(body).unwrap();
w.flush().unwrap();
}
// the server is a loop: read frame -> compute -> write frame
for stream in listener.incoming() {
serve_stream(stream.unwrap());
}
Una nota honesta sobre el alcance antes de continuar: todos los números de límite en este estudio son de un socket de dominio Unix en un solo host. El motor también habla TCP (con TCP_NODELAY), pero no lo medimos; el TCP en loopback se sitúa algo por encima de estos pisos, y un salto de red real es un régimen completamente distinto — milisegundos de piso, no microsegundos. Todo aquí es, por tanto, el mejor caso posible para cruzar un límite de esta manera. Lo que hace que los impuestos medidos a continuación sean aún más condenatorios: son lo que pagas encima de eso, por elección.
El impuesto de serialización: 1348x por elegir JSON

Aquí es donde la creencia popular sobre el "overhead de IPC" resulta ser un error de etiquetado. Medimos el costo de codificar la misma serie de cierre de 150,000 floats de tres formas — exactamente el payload que envía cada arquitectura anterior:
| Codificación | Tiempo para codificar 1.2 MB de floats | vs crudo |
|---|---|---|
bytes crudos (.tobytes()) |
49.1 µs | 1.0x |
| pickle | 29.8 µs | 0.6x |
JSON (json.dumps(close.tolist())) |
66,243 µs | 1348x |
La ruta cruda es un memcpy disfrazado de llamada a función:
def build_request(opcode, close, params):
body = bytes([opcode]) + struct.pack("<II", len(close), len(params))
body += close.astype("<f8").tobytes() # 150,000 floats -> 1.2 MB in 49 µs
body += np.asarray(params, dtype="<i8").reshape(-1).tobytes()
return struct.pack("<I", len(body)) + body # length-prefixed frame
(Pickle sale incluso ligeramente más barato que nuestra ruta cruda porque astype paga una copia de conversión de dtype incluso cuando el dtype ya coincide; ambas son de clase memcpy y ambas son errores de redondeo. La familia binaria en su conjunto vive tres órdenes de magnitud por debajo de la familia de texto.)
Y la ruta de texto es lo que casi todo despliegue de "hagamos del motor un microservicio" realmente envía:
body = json.dumps({"op": "sweep", "close": close.tolist(), "params": params})
Sesenta y seis milisegundos. Solo para codificar. json.dumps(close.tolist()) empaqueta cada float en un objeto Python, y luego renderiza cada uno como texto decimal — 150,000 asignaciones de heap y 150,000 conversiones de float a string donde la ruta cruda hizo una sola copia de bloque. Y el payload en el cable también se infla (un float64 cuesta 8 bytes en binario y aproximadamente de dos a tres veces eso como texto decimal — ni siquiera cobramos por el tránsito extra).
Ahora escalemos esto como lo hace un despliegue real. Esos 66 ms son una codificación, un lado, una llamada. Un servicio JSON paga codificación y decodificación, en ambos lados del límite, en cada llamada. Una única llamada batched sobre JSON quemaría ~3.3% de todo el presupuesto de cómputo del barrido solo en codificación del lado cliente (derivado: 66 ms / 2.010 s). Pon JSON bajo la arquitectura charlatana — una llamada por combinación, el patrón de abajo — y la codificación del lado cliente sola cuesta 80 × 66 ms = 5.3 s: más de dos veces y media el trabajo útil completo (derivado), antes de que se mueva un solo byte y antes de que el servidor parsee nada.
Este es el "impuesto IPC" real que la mayoría de los equipos han medido en producción sin saberlo. Nunca fue comunicación entre procesos. Fue serialización de texto de arreglos numéricos — un 1348x autoinfligido sobre el componente más barato del límite. El mundo columnar aprendió esta lección hace años, y es la misma en la que nuestro estudio de Polars vs pandas seguía tropezando desde el lado del pipeline de datos. Formatos como Arrow existen precisamente para que los datos de arreglo puedan cruzar límites de proceso y de lenguaje como bytes columnares crudos, no como texto. Si tu servicio de motor habla JSON para arreglos de precios, ningún ajuste de socket te salvará — el protocolo es el cuello de botella.
Charlatán vs voluminoso: la ley de Fowler, medida

La Primera Ley del Diseño de Objetos Distribuidos de Martin Fowler — "no distribuyas tus objetos" — viene con un corolario que él mismo formuló en el mismo aliento: si debes cruzar un límite, la interfaz tiene que ser de grano grueso, porque una llamada remota cuesta órdenes de magnitud más que una local. Todo veterano de sistemas distribuidos asiente. Casi nadie tiene un número para su propia carga de trabajo. Aquí está el nuestro.
Las arquitecturas voluminosa y charlatana ejecutan el mismo servidor, mismo protocolo, mismos datos — solo difiere la granularidad de la llamada:
srv.call(0, close, params)
[srv.call(0, close, [params[k]]) for k in range(n)]
Voluminosa: 2.276 s (1.13x). Charlatana: 2.383 s (1.19x) — 107 ms más lenta (derivado: 2.383 − 2.276). Para ser precisos sobre qué es y qué no es este delta: la curva de eco da una predicción ingenua para él — 79 envíos extra de la serie completa a aproximadamente la mitad del round-trip de payload completo de 2,043 µs cada uno, unos 81 ms — que cae aproximadamente un 25% por debajo de los 107 ms medidos; el resto es construcción de la petición y framing del lado Python por llamada, que la predicción del eco no incluye. De cualquier manera, sale a ~1.4 ms por cruce extra (derivado: 107 / 79); las respuestas son insignificantes — 16 bytes por combinación.
Dos lecturas de esos 107 ms, y ambas importan.
La lectura indulgente: es solo ~4.5% del tiempo real, no una catástrofe. Cierto — y vale la pena entender por qué el desastre del folclore no se materializó aquí. Cada llamada charlatana sigue cargando 25,130 µs de cómputo real (el equivalente a una combinación — el costo medido en proceso por combinación), de modo que el overhead de límite por llamada de ~1.4 ms se mantiene un orden de magnitud por debajo del trabajo por llamada. Las arquitecturas charlatanas no son fatales cuando cada llamada es genuinamente pesada. Se vuelven fatales a medida que la granularidad se reduce — que es todo el tema de la sección de punto de equilibrio.
La lectura condenatoria: este impuesto fue totalmente voluntario, y escala con número de llamadas × payload. El patrón charlatán reenvía el conjunto de datos en cada llamada por una sola razón: el servicio es sin estado, así que cada petición debe cargar todo el contexto. Esa es la forma predeterminada de un "endpoint de barrido" ingenuo — y de prácticamente todo microservicio REST jamás bosquejado en una pizarra. Un servidor con estado — cargar la serie una vez, luego enviar frames de parámetros de 48 bytes — pondría cada llamada por combinación cerca del extremo de payload diminuto de la curva de eco: unos 16 µs por llamada, aproximadamente 1.3 ms para las 80 (derivado del piso de eco; analítico, no medido por separado). La penalización charlatana no se reduciría; desaparecería. La lección es precisa: el problema no es hacer muchas llamadas — es reenviar estado porque el protocolo finge que cada llamada es la primera.
Precarga los datos. Envía parámetros. Cruza el límite con intención, no con todo el mundo en tu maleta cada vez.
El costo del spawn: alquilar el motor por llamada

El tercer patrón de despliegue es el más antiguo: ningún servidor en absoluto. Spawnear el binario del motor, canalizar una petición por stdin, leer la respuesta de stdout, dejarlo morir. El instinto de todo scripter de shell, toda integración "solo llama al CLI desde Python", todo framework de hiperparámetros configurado para lanzar un binario por trial.
Medido: 2.300 s (1.14x) — unos 24 ms por encima del batch del servidor persistente (derivado: 2.300 − 2.276). Esos 24 milisegundos compran un fork/exec, el cargador dinámico, la configuración de pipes y el desmantelamiento del proceso. Y nótese que lo que esto mide está cerca del piso para el patrón: un binario nativo pequeño y sin dependencias, caliente en la caché de páginas. Spawnear cualquier cosa con un runtime — una JVM, un intérprete Python con imports — cuesta mucho más; no medimos eso aquí, pero la dirección no está en duda.
La estructura de este impuesto es lo que importa: es fijo por llamada, indiferente a cuánto trabajo lleve la llamada. Amortizado sobre un barrido completo de 80 combinaciones, 24 ms son aproximadamente 1% — ruido. Respawnea por combinación y la misma constante se convierte en 80 × ~24 ms ≈ 1.9 s — esencialmente todo el trabajo útil quemado en creación de procesos (derivado; analítico). Respawnea por barra y la aritmética no vale la pena escribirla.
Costo fijo, granularidad fina: elige uno. El patrón que paga un spawn solo es sensato cuando el spawn es raro y el payload detrás de él es enorme — exactamente como nuestra medición de un-spawn-por-barrido, y exactamente distinto de cómo terminan usándose las arquitecturas de subproceso por símbolo una vez que crece el número de símbolos.
La aritmética del punto de equilibrio: un piso es una tasa mínima

Todo lo medido hasta ahora se comprime en una regla de diseño, y la regla es aritmética, no opinión.
Cada cruce de límite cuesta al menos el piso de latencia — 14 µs aquí, el round-trip de eco de payload diminuto, y cerca de lo mejor que ofrece este transporte. Ese piso es una tasa mínima: una llamada a través del límite solo vale la pena si el cómputo que envía supera esa tasa por un múltiplo cómodo. Define la razón de granularidad
y la proporción del límite en tu tiempo real es aproximadamente — con el tránsito del payload encima si la llamada también lleva datos.
Ahora corramos los números del barrido a través de esto. El costo medido en proceso de una combinación es 25,130 µs. A granularidad por combinación:
Las llamadas por combinación se sitúan ~1,795x por encima del piso — el límite reclama muy por debajo de una décima de porcentaje por llamada. Por eso incluso la arquitectura charlatana solo perdió 107 ms: a esta granularidad de carga de trabajo, todo patrón de cruce que no reenvíe datos ni hable texto se amortiza de forma segura. Las llamadas a nivel de combinación, fold o barrido están todas profundamente en la zona barata.
Ahora pasemos al extremo opuesto. Este es una extrapolación ilustrativa entre cargas de trabajo — no una variante de nuestro barrido, sino una forma de carga de trabajo que genuinamente existe en la práctica: se consulta al motor por barra. Un servicio de motor por-tick al estilo en vivo; un stream de señales gRPC-por-barra; un "servidor de estrategia" sondeado una vez por cada una de las 150,000 barras. El cómputo útil por barra en este kernel es 25,130 µs / 150,000 ≈ 0.17 µs (derivado) — cada llamada llevaría aproximadamente 1/84 de su propio costo de límite en trabajo útil (derivado: el piso de 14.05 µs sobre 0.168 µs de cómputo). El total es peor de lo que suena la razón:
— más que el trabajo completo en proceso de 2.010 s, gastado antes de que el motor remoto compute un solo número, y seguirían siendo 2.1 s incluso si el motor del otro lado fuera infinitamente rápido (derivado: 150,000 × 14 µs). Ninguna ventaja de cómputo sobrevive a una granularidad tan fina. Y recuerda que este piso es un socket Unix en un solo host; haz esa llamada por barra a un servicio a través de una red y el piso crece dos o tres órdenes de magnitud, sobre 150,000 llamadas.

Una calibración honesta más, porque 14 µs tampoco es una ley física — es el precio de nuestro transporte: un cliente Python, un socket de kernel, syscalls en ambas direcciones. Un transporte diseñado específicamente para la misma máquina va mucho más bajo. ZigBolt — nuestro bus de mensajería Zig de código abierto para cargas de trabajo HFT, benchmarkeado nativamente en esta misma máquina — hace un round-trip de anillo en memoria compartida en unos 39 ns de media (p50 de una sola dirección de 10/20/30 ns con mensajes de 64/256/1024 bytes). Eso es aproximadamente 360x por debajo de nuestro piso de socket (derivado: 14.05 µs / 39 ns). La comparación es deliberadamente de peras con manzanas, y lo señalamos como tal: nuestros 14 µs son un round-trip de socket con cliente Python, los 39 ns de ZigBolt son Zig nativo sobre memoria compartida, así que la brecha mezcla transporte y runtime. Léelo no como una carrera entre los dos, sino como el rango que puede ocupar el piso de la misma máquina: unos tres órdenes de magnitud, elegido por la implementación. Esta es la vieja lección del RPC ligero (Bershad et al., 1990) con ropas modernas — los cruces en la misma máquina están dominados por la maquinaria del protocolo, y colapsan cuando el transporte está construido para el caso de la misma máquina. La aritmética de punto de equilibrio anterior no cambia de forma; la tasa mínima simplemente se mueve. Con un piso de 39 ns, incluso la granularidad por barra la superaría (150,000 × 39 ns ≈ 5.9 ms, derivado) — que es precisamente cómo los sistemas HFT pueden permitirse límites que un servicio REST no puede.
Esta es toda la historia del punto de equilibrio en una frase: el límite no le importa lo rápido que sea tu motor; cobra por cruce, así que las variables que controlas son cuánto trabajo lleva cada cruce — y de qué está hecho el cruce. Batch por barrido y supera los cien mil. Batch por combinación, — sigue bien. Llama por barra sobre un socket, — la arquitectura está muerta antes de la primera optimización, y ninguna reescritura del motor, en Rust o en cualquier otra cosa, puede resucitarla.
Dónde vive realmente el 1.13x — y el veredicto

Es hora de diseccionar honestamente la brecha titular, porque carga el hallazgo más contraintuitivo del estudio.
La arquitectura Rust batched va 266 ms por detrás de numba en proceso (derivado: 2.276 − 2.010). Los componentes de límite medidos: un round-trip de payload completo a ~2.0 ms, serialización cruda a 49 µs, cabeceras de frame a un puñado de bytes — llamemos a toda la factura del límite ~2 ms. Más del 99% de la brecha, por tanto, no es el límite en absoluto. Es cómputo: despojado de IPC, el servidor Rust gasta ~2.274 s haciendo el barrido que numba hace en 2.010 s — el kernel Rust ingenuo es aproximadamente 13% más lento en cómputo puro (derivado).
Eso merece un párrafo sin rodeos, porque "reescríbelo en Rust y será más rápido" es tanto creencia popular como "el IPC te matará". Ambos kernels acaban en LLVM — numba baja el bytecode de Python a través de él, rustc baja MIR a través de él — y ambos probablemente corren como bucles escalares: la suma interna del WMA es una reducción de punto flotante, que LLVM no vectorizará automáticamente sin la licencia de reasociación de fast-math que los valores predeterminados de @njit de numba no otorgan y nuestro port no solicita. Así que el ~13% es una brecha de generación de código medida entre dos bucles escalares compilados por LLVM — y en lugar de afirmar una causa, probamos la obvia. El sospechoso natural es la indexación segura de Rust: el bucle caliente del WMA comprueba los límites en cada acceso a arreglo, mientras que el @njit de numba compila con la comprobación de límites desactivada. Así que construimos una variante verificada por equivalencia del mismo kernel usando get_unchecked — sin comprobaciones de límites en ninguna parte de la ruta caliente — y la medimos como una quinta arquitectura. No cerró la brecha: 2.337 s (1.16x), marginalmente más lenta que el build con comprobación de límites de 2.276 s. Hipótesis probada, hipótesis rechazada. El estado honesto del conocimiento: el ~13% es real y reproducible (medianas sobre 10 ejecuciones, rangos dentro de ~2%), y actualmente sin atribuir — alguna diferencia en el comportamiento de asignación, la estructura del bucle, o la planificación de instrucciones que solo el profiling a nivel de ensamblador resolvería. La lección sobrevive intacta: el Rust ingenuo no es automáticamente más rápido que un buen numba, y un límite de lenguaje comprado sobre la suposición de una ganancia de cómputo gratuita puede llegar con una pérdida de cómputo adjunta. Un kernel Rust afinado — buffers preasignados, SIMD explícito, hilos a través de combinaciones — todavía podría voltear el signo. Pero esa es una pregunta de cómputo, a resolver por profiling y trabajo de kernel, y la pregunta de este estudio es el límite. La respuesta del límite: cruzado una vez, en bytes, cuesta ~0.1%.
Así que ensamblemos el veredicto completo, cada cláusula de él medida arriba.
Un servicio de motor multilenguaje gana cuando se cumple todo esto:
- La ventaja de cómputo es real — medida en tu kernel, no asumida por la reputación del lenguaje. (La nuestra era −13% hasta que se demostró lo contrario — y la primera explicación "obvia" para ese déficit murió en las pruebas.)
- Cruzas con grano grueso — una llamada por barrido o por fold, miles de múltiplos por encima del piso de 14 µs, tal como demuestra el 1.13x total de la arquitectura batch (~0.1% de límite).
- Hablas binario — arreglos crudos prefijados por longitud, Arrow, cualquier cosa de clase memcpy a 49 µs por 1.2 MB; nunca texto a 66,243 µs.
- Los datos están precargados — un servidor con estado toma llamadas solo-de-parámetros en el extremo de ~16 µs de la curva de eco en lugar de reenviar megabytes.
Pierde cuando se despliega como suelen desplegarse los servicios de motor:
- Un microservicio JSON/REST — paga el impuesto de serialización de 1348x en cada llamada, en ambas direcciones; bajo granularidad charlatana eso son 5.3 s de codificación en un trabajo de 2 s.
- RPC por unidad de trabajo — por combinación cuesta 107 ms aquí y sobrevive solo porque cada llamada lleva 25,130 µs de cómputo; por barra son ~2.1 s de IPC pura antes de que ocurra cualquier trabajo, en un trabajo de 2.0 s.
- Un spawn por llamada — ~24 ms de costo fijo cada vez, inofensivo una vez por barrido, casi dos segundos cuando se paga por combinación.
Es decir: las arquitecturas que fallan no son exóticas. Motor JSON REST, subproceso por símbolo, gRPC por tick — eso es un censo justo de cómo realmente se construye "factoricemos el motor de backtesting". La creencia popular está empíricamente bien fundada como descripción de la práctica común y empíricamente equivocada como ley de la naturaleza. El límite nunca fue el problema. Las formas predeterminadas de cruzarlo sí lo son.
Un argumento a favor del límite merece su propia frase, porque es la razón por la que hicimos este estudio. Un único kernel compilado detrás de un límite bien diseñado puede servir tanto al barrido de investigación como al bucle de trading en vivo — el mismo binario, la misma aritmética, bit a bit. Nuestro estudio de paridad backtest-live catalogó cómo los motores de investigación y producción divergen cuando son dos bases de código; un servicio de motor es la cura estructural más fuerte para esa deriva, y este estudio le pone precio a la cura honestamente: hecho bien, alrededor del 0.1% del tiempo real y una puerta de equivalencia que demuestra que nada cambió en la traducción. Ese intercambio — un límite de proceso dedicado a cambio de paridad de un solo kernel — es, según estos números, una ganga. Hecho mal, la misma idea envía un impuesto de serialización de 1348x a producción con tu PnL montado encima.
Conclusiones
- El límite es casi gratis; la creencia popular falla ante la medición. Hacer el round-trip de toda la serie de cierre de 1.2 MB a través de un socket Unix — con parseo y recodificación completos incluidos — cuesta 2,043.4 µs, alrededor del 0.1% del trabajo de 2.010 s (derivado). La arquitectura Rust-sobre-socket batched termina en 1.13x total, y ~99% incluso de esa brecha no es IPC.
- "Reescríbelo en Rust" es una afirmación de cómputo — verifícala antes de comprar el límite. Nuestro port de Rust línea por línea computa ~13% más lento que el kernel numba (derivado: 2.274 s vs 2.010 s) — una brecha de generación de código reproducible entre dos bucles escalares compilados por LLVM que permanece sin atribuir: probamos al sospechoso obvio y lo rechazamos, ya que un build
get_uncheckedverificado por equivalencia sin comprobaciones de límites no resultó más rápido (2.337 s vs 2.276 s). El Rust ingenuo no es automáticamente más rápido; un kernel afinado bien podría serlo — mide, luego decide. - El impuesto real es el texto. Codificar 150,000 floats como JSON cuesta 66,243 µs frente a 49.1 µs crudo — 1348x, pagado por dirección, por llamada, en ambos lados. Un despliegue JSON charlatán quema 5.3 s de codificación en un trabajo de 2 s (derivado). Habla binario a través de los límites: frames crudos, Arrow — nunca
json.dumpssobre un arreglo de precios. - Charlatán vs voluminoso es medible, y la falta de estado es la culpable. Las llamadas por combinación que reenvían los datos: 1.19x frente al 1.13x del batch (+107 ms, derivado; la predicción de una sola vía de la curva de eco de ~81 ms cae ~25% por debajo, el resto es framing por llamada). Un servidor con estado precargado tomaría las mismas 80 llamadas a ~16 µs cada una — unos 1.3 ms en total (derivado del piso de eco). Envía parámetros, no el conjunto de datos.
- Respeta el piso — y ten claro que el piso es una elección. Nuestro cruce Python-sobre-socket-Unix toca piso a 14 µs; la granularidad por combinación lo supera ~1,795x (25,130 µs de cómputo por llamada) — seguro. Un patrón por barra (un extremo ilustrativo entre cargas de trabajo: un motor en vivo por-tick, no este barrido) pagaría 150,000 × 14 µs ≈ 2.1 s de IPC pura en un trabajo de 2.0 s (derivado) — muerto al llegar incluso con un motor infinitamente rápido. Spawnear por llamada añade ~24 ms fijos (derivado). Y un transporte de memoria compartida diseñado a medida como ZigBolt hace round-trips en ~39 ns nativamente en esta máquina — ~360x por debajo de nuestro piso de socket (derivado; Zig nativo vs un cliente Python, así que léelo como el rango que puede ocupar el piso, no como una carrera).
- Cruza una vez, en bytes, con los datos ya presentes — y el límite te compra paridad por ~0.1%. Un kernel que sirve investigación y producción en vivo, protegido por una comprobación de equivalencia (PnL −5165.58, 57,029 trades, idéntico entre lenguajes y entre ambos builds de Rust), es el caso honesto para un servicio de motor. Los casos deshonestos — JSON, charlatán, spawn-por-llamada — son los que le dieron al IPC su reputación.
El experimento completo — el motor Rust, el protocolo de cable, los harnesses de eco y serialización, la puerta de equivalencia, y cada número de este artículo regenerable desde un script determinista — está en el paper complementario en ipc-tax.marketmaker.cc, con código y datos en github.com/suenot/ipc-tax.
El socket nunca fue el problema. Dos milisegundos para todo el conjunto de datos, ida y vuelta — el folclore erró por tres órdenes de magnitud, y en ambas direcciones a la vez: demasiado pesimista con los bytes, demasiado indulgente con el texto. Cruza el límite como si costara algo, y no costará.
Authors
Trading-systems engineer
Trading-systems engineer building bots since 2017: cross-exchange arbitrage (connected up to 30 venues), cointegration-based pairs arbitrage across spot and futures, scalping, news and sentiment-driven strategies, trend algorithms, and portfolio management and balancing algorithms. Also builds sub-millisecond order execution, big-data warehouses, backtesting engines, AI agents, and trading interfaces (incl. open-source profitmaker.cc). Stack: JS/TS, Python, Rust/Zig/Go, DevOps, backend, frontend, architecture.