← Voltar aos artigos
June 30, 2026
5 min read

O imposto IPC: coloque o motor de backtest atrás de um socket e perca 13% — quase nada disso é do socket

#algotrading
#backtest
#performance
#ipc
#rust
#architecture
Part 10 of 10 · Collection
High-Performance Backtest Engines

Parte da série "Backtests sem ilusões".

📄 Este artigo se transformou em um paper de pesquisa. Um kernel de backtest dependente de trajetória é portado linha por linha de numba para Rust e chamado através de uma fronteira de processo/linguagem de quatro formas, com um portão de equivalência confirmando PnL idêntico por combinação — além de medições isoladas da curva de latência IPC pura, do imposto de serialização e do custo de spawn. Leia o paper online (versão interativa + PDF) em ipc-tax.marketmaker.cc, código e dados em github.com/suenot/ipc-tax.

Todo motor de backtest que se torna rápido eventualmente provoca a mesma conversa. O nosso chegou pontualmente. A escada de velocidade tinha acabado de levar uma varredura de parâmetros de 80 combinações de 69,9 segundos de pandas para cerca de 2 segundos de numba de thread única, e a próxima vontade natural era: por que parar em um JIT Python? Reescrever o kernel em Rust. Transformá-lo em um verdadeiro serviço de motor — um binário compilado atrás de um socket, invocável a partir de todo script de pesquisa, toda linguagem, e também o trader ao vivo. Um kernel, uma verdade, sem lógica duplicada.

E então chega o contra-argumento, também pontualmente: no momento em que você sai do processo, o IPC te devora. Os dados precisam ser serializados, enviados através de uma fronteira, desserializados; toda chamada paga syscalls e trocas de contexto; seu belo kernel Rust vai passar a vida esperando em um pipe. Fique no processo. Todo mundo sabe disso.

Este artigo mede o que todo mundo acha que sabe, e a medição é mais interessante que qualquer um dos lados do argumento. A crença popular — "um motor multilinguagem mais rápido perde para numba em processo porque o IPC te mata" — revela-se falsa em geral e correta apenas sob condições específicas. Cruzar a fronteira uma vez, em bytes brutos, custa cerca de 2 milissegundos em um trabalho de dois segundos: um erro de arredondamento. O imposto não está na fronteira. Está em como você a atravessa — e as três formas como os serviços de motor costumam ser implantados na prática (uma API JSON, uma chamada por unidade de trabalho, um spawn de processo por chamada) são cada uma, mensuravelmente, um pedaço do desastre que o folclore prevê.

Aqui está todo o experimento antecipadamente. Tudo abaixo é a anatomia de cada linha.

Arquitetura O que cruza a fronteira por varredura Tempo real vs em processo
numba em processo nada — uma chamada direta 2,010 s 1,00x
Servidor Rust, batched (socket Unix) uma viagem de ida e volta: toda a série + todos os 80 conjuntos de parâmetros 2,276 s 1,13x
Servidor Rust, batched, kernel get_unchecked a mesma viagem única de ida e volta — uma variante de kernel sem verificação de limites (veja o veredito) 2,337 s 1,16x
Servidor Rust, tagarela (socket Unix) 80 viagens de ida e volta: a série reenviada por combinação 2,383 s 1,19x
Rust spawn (stdin/stdout) spawn de processo + uma requisição encanada 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, zero crates externos). 150.000 barras × 80 combinações, taxa de ida e volta de 0,09%, seed 42; a série de fechamento tem 1.200.000 bytes (1,2 MB) no fio. Mediana de 10 execuções por arquitetura; os intervalos min-máx ficam dentro de ~2%. Todas as cinco executam a mesma varredura stop-and-reverse HMA/HMA3, e um portão de equivalência confirma que os resultados por combinação (PnL, número de operações) de ambas as variantes do kernel Rust correspondem exatamente ao numba — impressão digital de PnL −5165,58 ao longo de 57.029 operações, idêntica byte a byte ao kernel numba do estudo da escada de velocidade com a mesma seed. Estamos comparando fronteiras, não implementações.

Leia a linha batched com cuidado, porque ela carrega toda a tese. A arquitetura Rust-sobre-socket é 1,13x mais lenta que numba em processo — 266 ms atrás na varredura completa (derivado: 2,276 − 2,010). A história popular diz que esses milissegundos são IPC. Não são. Cerca de 2 ms dessa diferença são a fronteira — toda a série de fechamento de 1,2 MB enviada, resultados devolvidos, medido diretamente. Os outros ~264 ms restantes se devem ao fato de que nosso kernel Rust ingênuo simplesmente calcula a varredura cerca de 13% mais devagar que o kernel numba (derivado: 2,276 s menos ~2 ms de fronteira ≈ 2,274 s de computação Rust, contra 2,010 s para numba). Rust a linguagem não perdeu para Python a linguagem; um loop escalar compilado por LLVM perdeu uma corrida de geração de código para outro — e nem conseguimos atribuir a perda ao suspeito óbvio: um build get_unchecked sem verificação de limites do mesmo kernel não se mostrou mais rápido (2,337 s; a seção do veredito dissseca isso). O socket teve quase nada a ver com nada disso.

Segure as duas metades dessa frase. A fronteira é quase gratuita quando cruzada corretamente — e "reescreva em Rust" compra uma fronteira de implantação, não uma vitória computacional automática. Ambos os fatos vão contra a intuição popular, e ambos estão na tabela.

Um kernel, duas linguagens, quatro fronteiras

A carga de trabalho é deliberadamente a mesma que a escada de velocidade fixou, de modo que os dois estudos se ancoram um ao outro. O kernel é um cruzamento HMA/HMA3 — um sistema stop-and-reverse sobre duas médias móveis estilo Hull, sete passagens de média móvel ponderada por combinação de parâmetros mais um loop de eventos com estado barra por barra que carrega uma posição, registra PnL menos uma taxa de ida e volta de 0,09% a cada cruzamento, e reverte. Os dados são 150.000 barras de movimento browniano geométrico sintético com semente (seed=42); a grade são 80 comprimentos HMA espalhados por [6,200][6, 200]. A referência em processo é o degrau numba de thread única da escada, remedido para este estudo: 1,98 s lá, 2,010 s aqui — mesmo kernel, mesma máquina, tranquilizadoramente entediante.

O motor multilinguagem é um port linha por linha daquele kernel numba para Rust — mesmos loops, mesmo tratamento de NaN, mesma aritmética de taxas — compilado em modo release sem crates externos, de modo que todo o experimento permanece livre de dependências e reproduzível. Ele fala um protocolo binário deliberadamente mínimo: um frame prefixado por comprimento em cada direção, tudo 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

O opcode echo é o bisturi do estudo: uma viagem de ida e volta de tamanho controlável que não computa nada, de modo que o custo puro da fronteira possa ser medido isoladamente — serialização, syscalls, trânsito de socket, desserialização, e nada mais.

Cinco arquiteturas medidas — quatro padrões de fronteira mais uma variante de kernel:

  • in_process — chamar o kernel numba diretamente. Sem fronteira. A referência.
  • rust_batch_unix — um servidor Rust persistente em um socket de domínio Unix. Uma viagem de ida e volta envia toda a série de fechamento mais todos os 80 conjuntos de parâmetros; Rust computa cada combinação; uma resposta volta. A chamada volumosa.
  • rust_batch_unchecked — a mesma fronteira batched, mas o kernel indexa com get_unchecked (sem verificações de limites no caminho crítico). Existe para testar uma hipótese específica sobre a diferença de computação; a seção do veredito a gasta.
  • rust_chatty_unix — o mesmo servidor, mas uma viagem de ida e volta por combinação, a série de 1,2 MB reenviada toda vez. A arquitetura RPC-por-unidade-de-trabalho ingênua.
  • rust_spawn_stdin — spawnar o binário por varredura e canalizar a requisição via stdin. O padrão "invocar um motor CLI externamente"; paga a criação do processo.

E o portão de equivalência, sem o qual nada disso teria significado: após a medição de tempo, o vetor por combinação (PnL, número de operações) de cada variante Rust é comparado com o numba — número de operações exato, PnL a um 10610^{-6} absoluto. A execução commitada relata all_ok: true para ambos os builds — indexação segura e get_unchecked. A impressão digital da primeira combinação — PnL −5165,58 pontos percentuais ao longo de 57.029 operações — corresponde dígito por dígito ao kernel numba do estudo da escada de velocidade, o que fixa ambos os papers ao mesmo kernel com a mesma seed. Ports multilinguagem são precisamente onde a divergência silenciosa adora viver (uma taxa aplicada antes em vez de depois da conversão percentual, uma comparação NaN que ramifica de forma diferente, um off-by-one em uma janela — o mesmo tipo de bug que nossa taxonomia de viés look-ahead mostrou poder fabricar um Sharpe de 15 a partir de ruído). Um benchmark de dois motores que computam coisas diferentes não é um benchmark; são dois programas não relacionados competindo.

Com a equivalência estabelecida, toda diferença na tabela acima é fronteira e computação — nada mais.

O que cruzar realmente custa: a curva de eco

The measured cost of a boundary crossing: a latency curve flat at fourteen microseconds for tiny payloads, bending upward only past ten thousand floats, reaching two milliseconds for the full 1.2-megabyte series

Comece com o bisturi. A operação echo faz uma viagem de ida e volta de um payload de nn floats através do servidor Rust — Python constrói o frame, o servidor analisa todos os nn floats, os recodifica e os envia de volta. Ambas as direções pagam serialização, syscalls e trânsito de socket. Aqui está a curva medida (medianas ao longo de 10 execuções):

Payload (floats) Bytes por direção Ida e volta
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

Dois fatos estruturais vivem nesta tabela.

Primeiro, o piso. Uma viagem de ida e volta carregando essencialmente nada — 8 bytes — custa 14 µs. Esse é o preço irredutível de fazer uma chamada de qualquer forma sobre este transporte: dois syscalls write, dois syscalls read, maquinário de socket do kernel, despertares do escalonador. Note como a curva é plana à esquerda: de 1 float a 1.000 floats o custo mal se move (14,1 → 18,1 µs). Abaixo de cerca de 8 KB você está pagando pela chamada, não pelos bytes. Este número — o piso de latência — é a constante mais importante de todo o estudo, e construiremos a aritmética de ponto de equilíbrio sobre ele abaixo.

Segundo, a inclinação. Passados ~10.000 floats a curva se torna limitada por largura de banda e aproximadamente linear. A série completa de 1,2 MB — 2,4 MB movidos no total, ida e volta, incluindo uma análise e recodificação completas de 150.000 floats do lado Rust — custa 2.043,4 µs. Isso resulta em um throughput efetivo de ~1,2 GB/s através de toda a pilha ingênua (derivado: 2,4 MB / 2,04 ms) — um socket de domínio Unix com frames prefixados por comprimento e um analisador de floats byte a byte, sem truques de zero-copy, sem memória compartilhada, nada sofisticado.

Um modelo razoável de um cruzamento único, com ambas as constantes medidas:

Tcall(b)    14 μspiso  +  2b1,2 GB/spayload, ambas as direc¸o˜esT_{\text{call}}(b) \;\approx\; \underbrace{14\ \mu\text{s}}_{\text{piso}} \;+\; \underbrace{\frac{2b}{1,2\ \text{GB/s}}}_{\text{payload, ambas as direções}}

Agora coloque o número principal em contexto. A varredura completa leva 2,010 s em processo. Enviar todo o seu conjunto de dados através da fronteira e de volta custa ~2,0 ms — cerca de 0,1% do trabalho (derivado: 2,0434 ms / 2,010 s). Se você cruza uma vez, em bytes brutos, a fronteira é um erro de arredondamento. Essa é a metade da crença popular que morre primeiro: o medo nunca foi sobre algo tão barato.

O lado Rust desse cruzamento é tão pouco glamouroso quanto código de sistema pode ser — 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());
}

Uma nota honesta sobre o escopo antes de continuar: todos os números de fronteira neste estudo são de um socket de domínio Unix em um único host. O motor também fala TCP (com TCP_NODELAY), mas não medimos isso; TCP loopback fica um pouco acima desses pisos, e um salto de rede real é um regime completamente diferente — milissegundos de piso, não microssegundos. Tudo aqui é, portanto, o melhor caso possível para cruzar uma fronteira desta forma. O que torna os impostos medidos a seguir ainda mais condenáveis: são o que você paga além disso, por escolha.

O imposto de serialização: 1348x por escolher JSON

Two encodings of the same 150,000-float array side by side: a raw-bytes memcpy measured in microseconds against a JSON text encoding towering three orders of magnitude taller

Aqui é onde a crença popular sobre "overhead de IPC" se revela um erro de rotulagem. Medimos o custo de codificar a mesma série de fechamento de 150.000 floats de três formas — exatamente o payload que cada arquitetura acima envia:

Codificação Tempo para codificar 1,2 MB de floats vs bruto
bytes brutos (.tobytes()) 49,1 µs 1,0x
pickle 29,8 µs 0,6x
JSON (json.dumps(close.tolist())) 66.243 µs 1348x

O caminho bruto é um memcpy disfarçado de chamada de função:

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 sai até um pouco mais barato que nosso caminho bruto porque astype paga uma cópia de conversão de dtype mesmo quando o dtype já corresponde; ambos são de classe memcpy e ambos são erros de arredondamento. A família binária como um todo vive três ordens de magnitude abaixo da família de texto.)

E o caminho de texto é o que quase toda implantação "vamos transformar o motor em um microsserviço" realmente envia:

body = json.dumps({"op": "sweep", "close": close.tolist(), "params": params})

Sessenta e seis milissegundos. Só para codificar. json.dumps(close.tolist()) empacota cada float em um objeto Python, depois renderiza cada um como texto decimal — 150.000 alocações de heap e 150.000 conversões float-para-string onde o caminho bruto fez uma única cópia de bloco. E o payload no fio também infla (um float64 custa 8 bytes em binário e aproximadamente duas a três vezes isso como texto decimal — nem sequer cobramos pelo trânsito extra).

Agora vamos escalar isso da forma como uma implantação real faz. Aqueles 66 ms são uma codificação, um lado, uma chamada. Um serviço JSON paga codificação e decodificação, em ambos os lados da fronteira, em cada chamada. Uma única chamada batched sobre JSON queimaria ~3,3% de todo o orçamento computacional da varredura só na codificação do lado do cliente (derivado: 66 ms / 2,010 s). Coloque JSON sob a arquitetura tagarela — uma chamada por combinação, o padrão abaixo — e a codificação do lado do cliente sozinha custa 80 × 66 ms = 5,3 s: mais de duas vezes e meia o trabalho útil inteiro (derivado), antes de um único byte se mover e antes de o servidor analisar qualquer coisa.

Este é o "imposto IPC" real que a maioria das equipes mediu em produção sem saber. Nunca foi comunicação entre processos. Foi serialização de texto de arrays numéricos — um 1348x autoinfligido sobre o componente mais barato da fronteira. O mundo colunar aprendeu essa lição anos atrás, e é a mesma que nosso estudo Polars vs pandas continuava encontrando pelo lado do pipeline de dados. Formatos como Arrow existem precisamente para que dados de array possam cruzar fronteiras de processo e linguagem como bytes colunares brutos, não como texto. Se seu serviço de motor fala JSON para arrays de preços, nenhum ajuste de socket vai te salvar — o protocolo é o gargalo.

Tagarela vs volumoso: a lei de Fowler, medida

A chunky architecture shipping one large framed payload across the boundary once, beside a chatty architecture making eighty small round-trips that each drag the full dataset along

A Primeira Lei do Design de Objetos Distribuídos de Martin Fowler — "não distribua seus objetos" — vem com um corolário que ele formulou no mesmo fôlego: se você precisa cruzar uma fronteira, a interface tem que ser de grão grosso, porque uma chamada remota custa ordens de magnitude mais que uma local. Todo veterano de sistemas distribuídos concorda balançando a cabeça. Quase ninguém tem um número para sua própria carga de trabalho. Aqui está o nosso.

As arquiteturas volumosa e tagarela executam o mesmo servidor, mesmo protocolo, mesmos dados — apenas a granularidade da chamada difere:

srv.call(0, close, params)

[srv.call(0, close, [params[k]]) for k in range(n)]

Volumosa: 2,276 s (1,13x). Tagarela: 2,383 s (1,19x) — 107 ms mais lenta (derivado: 2,383 − 2,276). Para ser preciso sobre o que essa diferença é e não é: a curva de eco dá uma previsão ingênua para ela — 79 envios extras da série completa a aproximadamente metade da viagem de ida e volta de payload completo de 2.043 µs cada, cerca de 81 ms — o que fica cerca de 25% abaixo dos 107 ms medidos; o resto é construção de requisição e framing do lado Python por chamada, que a previsão de eco não inclui. De qualquer forma, isso dá ~1,4 ms por cruzamento extra (derivado: 107 / 79); as respostas são negligenciáveis — 16 bytes por combinação.

Duas leituras desses 107 ms, e ambas importam.

A leitura leniente: é apenas ~4,5% do tempo real, não uma catástrofe. Verdadeiro — e vale a pena entender por que o desastre do folclore não se materializou aqui. Cada chamada tagarela ainda carrega 25.130 µs de computação real (o equivalente a uma combinação — o custo medido em processo por combinação), então o overhead de fronteira por chamada de ~1,4 ms permanece uma ordem de magnitude abaixo do trabalho por chamada. Arquiteturas tagarelas não são fatais quando cada chamada é genuinamente pesada. Elas se tornam fatais à medida que a granularidade diminui — que é todo o assunto da seção de ponto de equilíbrio.

A leitura condenatória: este imposto foi inteiramente voluntário, e escala com número de chamadas × payload. O padrão tagarela reenvia o conjunto de dados a cada chamada por um único motivo: o serviço é sem estado, então cada requisição deve carregar todo o contexto. Essa é a forma padrão de um "endpoint de varredura" ingênuo — e de essencialmente todo microsserviço REST já esboçado em um quadro branco. Um servidor com estado — carregar a série uma vez, depois enviar frames de parâmetros de 48 bytes — colocaria cada chamada por combinação perto da extremidade de payload minúsculo da curva de eco: cerca de 16 µs por chamada, aproximadamente 1,3 ms para as 80 (derivado do piso de eco; analítico, não medido separadamente). A penalidade tagarela não diminuiria; ela desapareceria. A lição é precisa: o problema não é fazer muitas chamadas — é reenviar estado porque o protocolo finge que toda chamada é a primeira.

Pré-carregue os dados. Envie parâmetros. Cruze a fronteira com intenção, não com o mundo inteiro na sua mala toda vez.

O custo do spawn: alugando o motor por chamada

An engine binary being spawned from scratch for a single request: process creation, loader, and pipe setup stacked as a fixed toll booth in front of a short stretch of useful work

O terceiro padrão de implantação é o mais antigo: nenhum servidor. Spawnar o binário do motor, canalizar uma requisição via stdin, ler a resposta de stdout, deixá-lo morrer. O instinto de todo scripter de shell, toda integração "só chame a CLI a partir do Python", todo framework de hiperparâmetros configurado para lançar um binário por trial.

Medido: 2,300 s (1,14x) — cerca de 24 ms acima do batch do servidor persistente (derivado: 2,300 − 2,276). Esses 24 milissegundos compram um fork/exec, o carregador dinâmico, configuração de pipes e desmontagem do processo. E note que o que isso mede está próximo do piso para o padrão: um binário nativo pequeno e sem dependências, quente no cache de páginas. Spawnar qualquer coisa com um runtime — uma JVM, um interpretador Python com imports — custa muito mais; não medimos isso aqui, mas a direção não está em dúvida.

A estrutura deste imposto é o que importa: é fixo por chamada, indiferente a quanto trabalho a chamada carrega. Amortizado ao longo de uma varredura completa de 80 combinações, 24 ms são cerca de 1% — ruído. Respawne por combinação e a mesma constante se torna 80 × ~24 ms ≈ 1,9 s — essencialmente todo o trabalho útil queimado em criação de processos (derivado; analítico). Respawne por barra e a aritmética não vale a pena escrever.

Custo fixo, granularidade fina: escolha um. O padrão que paga um spawn só faz sentido quando o spawn é raro e o payload por trás dele é enorme — exatamente como nossa medição de um-spawn-por-varredura, e exatamente ao contrário de como arquiteturas de subprocesso por símbolo acabam sendo usadas quando o número de símbolos cresce.

A aritmética do ponto de equilíbrio: um piso é uma taxa mínima

Break-even arithmetic on a balance: fourteen microseconds of boundary floor on one side weighed against the compute each call carries, with per-combo calls far above water and per-bar calls drowned

Tudo medido até agora se comprime em uma regra de design, e a regra é aritmética, não opinião.

Todo cruzamento de fronteira custa pelo menos o piso de latência — 14 µs aqui, a viagem de ida e volta de eco de payload minúsculo, e próximo do melhor que este transporte oferece. Esse piso é uma taxa mínima: uma chamada através da fronteira só vale a pena se a computação que ela envia superar essa taxa por um múltiplo confortável. Defina a razão de granularidade

G  =  Tcomputac¸a˜o por chamadaTpisoG \;=\; \frac{T_{\text{computação por chamada}}}{T_{\text{piso}}}

e a parcela da fronteira no seu tempo real é aproximadamente 1/(1+G)1/(1+G) — com trânsito de payload por cima se a chamada também carregar dados.

Agora vamos rodar os números da varredura por meio disso. O custo medido em processo de uma combinação é 25.130 µs. Na granularidade por combinação:

G  =  25.130 μs14 μs    1795G \;=\; \frac{25{.}130\ \mu\text{s}}{14\ \mu\text{s}} \;\approx\; 1795

Chamadas por combinação ficam ~1.795x acima do piso — a fronteira reivindica bem menos de um décimo por cento por chamada. É por isso que mesmo a arquitetura tagarela perdeu apenas 107 ms: nesta granularidade de carga de trabalho, todo padrão de cruzamento que não reenvia dados ou fala texto é amortizado com segurança. Chamadas em nível de combinação, fold, varredura estão todas profundamente na zona barata.

Agora vá para o extremo oposto. Este é uma extrapolação ilustrativa entre cargas de trabalho — não uma variante de nossa varredura, mas uma forma de carga de trabalho que genuinamente existe na prática: o motor é consultado por barra. Um serviço de motor por-tick ao estilo ao vivo; um stream de sinais gRPC-por-barra; um "servidor de estratégia" consultado uma vez para cada uma das 150.000 barras. A computação útil por barra neste kernel é 25.130 µs / 150.000 ≈ 0,17 µs (derivado) — cada chamada carregaria cerca de 1/84 de seu próprio custo de fronteira em trabalho útil (derivado: o piso de 14,05 µs sobre 0,168 µs de computação). O total é pior do que a razão soa:

150.000 chamadas×14 μs    2,1 s de IPC pura150.000 \ \text{chamadas} \times 14\ \mu\text{s} \;\approx\; \mathbf{2,1\ s\ de\ IPC\ pura}

— mais que o trabalho inteiro em processo de 2,010 s, gasto antes de o motor remoto computar um único número, e permaneceria 2,1 s mesmo que o motor do outro lado fosse infinitamente rápido (derivado: 150.000 × 14 µs). Nenhuma vantagem computacional sobrevive a uma granularidade tão fina. E lembre-se de que este piso é um socket Unix em um único host; faça essa chamada por barra para um serviço através de uma rede, e o piso cresce de duas a três ordens de magnitude, sobre 150.000 chamadas.

The same-machine boundary floor as an implementation choice: a Python-over-Unix-socket round-trip at fourteen microseconds towering over a shared-memory ring crossing at thirty-nine nanoseconds, three orders of magnitude apart

Mais uma calibração honesta, porque 14 µs também não é uma lei da física — é o preço do nosso transporte: um cliente Python, um socket de kernel, syscalls em ambas as direções. Um transporte construído especificamente para a mesma máquina vai muito mais baixo. ZigBolt — nosso barramento de mensagens Zig de código aberto para cargas de trabalho HFT, testado nativamente nesta mesma máquina — faz uma viagem de ida e volta em anel de memória compartilhada em cerca de 39 ns de média (p50 unidirecional de 10/20/30 ns em mensagens de 64/256/1024 bytes). Isso é cerca de 360x abaixo do nosso piso de socket (derivado: 14,05 µs / 39 ns). A comparação é deliberadamente maçã com laranja, e sinalizamos isso como tal: nossos 14 µs são uma viagem de ida e volta de socket com cliente Python, os 39 ns do ZigBolt são Zig nativo sobre memória compartilhada, então a diferença mistura transporte e runtime. Leia isso não como uma corrida entre os dois, mas como o intervalo que o piso da mesma máquina pode ocupar: cerca de três ordens de magnitude, escolhido pela implementação. Esta é a velha lição do RPC leve (Bershad et al., 1990) em roupagem moderna — cruzamentos na mesma máquina são dominados pelo maquinário do protocolo, e eles colapsam quando o transporte é construído para o caso de mesma máquina. A aritmética de ponto de equilíbrio acima não muda de forma; a taxa mínima apenas se move. Em um piso de 39 ns, mesmo a granularidade por barra a superaria (150.000 × 39 ns ≈ 5,9 ms, derivado) — que é precisamente como sistemas HFT podem se dar ao luxo de fronteiras que um serviço REST não pode.

Esta é toda a história do ponto de equilíbrio em uma frase: a fronteira não se importa com a rapidez do seu motor; ela cobra por cruzamento, então as variáveis que você controla são quanto trabalho cada cruzamento carrega — e do que o cruzamento é feito. Faça batch por varredura e GG está acima de cem mil. Faça batch por combinação, G1795G \approx 1795 — ainda ótimo. Chame por barra sobre um socket, G<1G < 1 — a arquitetura está morta antes da primeira otimização, e nenhuma reescrita do motor, em Rust ou qualquer outra coisa, pode ressuscitá-la.

Onde o 1,13x realmente vive — e o veredito

The 266-millisecond gap dissected: a sliver of two milliseconds labeled as the boundary next to a large slab of measured codegen difference between two scalar compiled kernels, with the folk belief crossed out

Hora de dissecar honestamente a diferença principal, porque ela carrega a descoberta mais contraintuitiva do estudo.

A arquitetura Rust batched fica 266 ms atrás de numba em processo (derivado: 2,276 − 2,010). Os componentes de fronteira medidos: uma viagem de ida e volta de payload completo a ~2,0 ms, serialização bruta a 49 µs, cabeçalhos de frame a um punhado de bytes — chame toda a conta da fronteira de ~2 ms. Mais de 99% da diferença, portanto, não é a fronteira de forma alguma. É computação: despida do IPC, o servidor Rust gasta ~2,274 s fazendo a varredura que numba faz em 2,010 s — o kernel Rust ingênuo é cerca de 13% mais lento em computação bruta (derivado).

Isso merece um parágrafo sem rodeios, porque "reescreva em Rust e vai ficar mais rápido" é tanto crença popular quanto "IPC vai te matar". Ambos os kernels acabam em LLVM — numba rebaixa bytecode Python através dele, rustc rebaixa MIR através dele — e ambos provavelmente rodam como loops escalares: a soma interna do WMA é uma redução de ponto flutuante, que o LLVM não vetorizará automaticamente sem a licença de reassociação fast-math que os padrões @njit do numba não concedem e nosso port não solicita. Então o ~13% é uma diferença de geração de código medida entre dois loops escalares compilados por LLVM — e em vez de afirmar uma causa, testamos a óbvia. O suspeito natural é a indexação segura do Rust: o loop quente do WMA verifica limites a cada acesso a array, enquanto o @njit do numba compila com verificação de limites desativada. Então construímos uma variante verificada por equivalência do mesmo kernel usando get_unchecked — sem verificações de limites em nenhum lugar do caminho crítico — e a medimos como uma quinta arquitetura. Ela não fechou a lacuna: 2,337 s (1,16x), marginalmente mais lenta que o build com verificação de limites de 2,276 s. Hipótese testada, hipótese rejeitada. O estado honesto do conhecimento: o ~13% é real e reproduzível (medianas ao longo de 10 execuções, intervalos dentro de ~2%), e atualmente não atribuído — alguma diferença no comportamento de alocação, estrutura de loop, ou escalonamento de instruções que só profiling em nível de assembly resolveria. A lição permanece intacta: Rust ingênuo não é automaticamente mais rápido que bom numba, e uma fronteira de linguagem comprada sob a suposição de um ganho computacional gratuito pode chegar com uma perda computacional anexada. Um kernel Rust ajustado — buffers pré-alocados, SIMD explícito, threads através de combinações — ainda poderia virar o sinal. Mas isso é uma questão computacional, a ser resolvida por profiling e trabalho de kernel, e a questão deste estudo é a fronteira. A resposta da fronteira: cruzada uma vez, em bytes, custa ~0,1%.

Então vamos montar o veredito completo, cada cláusula dele medida acima.

Um serviço de motor multilinguagem vence quando tudo isto se mantém:

  • A vantagem computacional é real — medida no seu próprio kernel, não assumida pela reputação da linguagem. (O nosso era −13% até prova em contrário — e a primeira explicação "óbvia" para aquele déficit morreu no teste.)
  • Você cruza com grão grosso — uma chamada por varredura ou por fold, milhares de múltiplos acima do piso de 14 µs, da forma como o 1,13x total da arquitetura batch (~0,1% de fronteira) demonstra.
  • Você fala binário — arrays brutos prefixados por comprimento, Arrow, qualquer coisa de classe memcpy a 49 µs por 1,2 MB; nunca texto a 66.243 µs.
  • Os dados estão pré-carregados — um servidor com estado aceita chamadas apenas-parâmetros na extremidade de ~16 µs da curva de eco em vez de reenviar megabytes.

Ele perde quando implantado da forma como serviços de motor costumam ser:

  • Um microsserviço JSON/REST — paga o imposto de serialização de 1348x em cada chamada, em ambas as direções; sob granularidade tagarela isso são 5,3 s de codificação em um trabalho de 2 s.
  • RPC por unidade de trabalho — por combinação custa 107 ms aqui e sobrevive só porque cada chamada carrega 25.130 µs de computação; por barra são ~2,1 s de IPC pura antes de qualquer trabalho acontecer, em um trabalho de 2,0 s.
  • Um spawn por chamada — ~24 ms de custo fixo toda vez, inofensivo uma vez por varredura, quase dois segundos quando pago por combinação.

Ou seja: as arquiteturas que falham não são exóticas. Motor JSON REST, subprocesso por símbolo, gRPC-por-tick — isso é um censo justo de como "vamos fatorar o motor de backtest" realmente é construído. A crença popular está empiricamente bem fundamentada como descrição da prática comum e empiricamente errada como lei da natureza. A fronteira nunca foi o problema. As formas padrão de cruzá-la são.

Um argumento a favor da fronteira merece sua própria frase, porque é a razão pela qual fizemos este estudo. Um único kernel compilado atrás de uma fronteira bem projetada pode servir tanto a varredura de pesquisa quanto o loop de trading ao vivo — o mesmo binário, a mesma aritmética, bit a bit. Nosso estudo de paridade backtest-live catalogou como motores de pesquisa e produção divergem quando são duas bases de código; um serviço de motor é a cura estrutural mais forte para essa deriva, e este estudo precifica a cura honestamente: feito corretamente, cerca de 0,1% do tempo real e um portão de equivalência para provar que nada mudou na tradução. Essa troca — uma fronteira de processo dedicada em troca de paridade de kernel único — é, segundo esses números, uma pechincha. Feito errado, a mesma ideia envia um imposto de serialização de 1348x para produção com seu PnL montado em cima.

Conclusões

  1. A fronteira é quase gratuita; a crença popular falha diante da medição. Fazer a viagem de ida e volta de toda a série de fechamento de 1,2 MB através de um socket Unix — com análise e recodificação completas incluídas — custa 2.043,4 µs, cerca de 0,1% do trabalho de 2,010 s (derivado). A arquitetura Rust-sobre-socket batched fica em 1,13x total, e ~99% mesmo dessa diferença não é IPC.
  2. "Reescreva em Rust" é uma afirmação computacional — verifique antes de comprar a fronteira. Nosso port Rust linha por linha computa ~13% mais devagar que o kernel numba (derivado: 2,274 s vs 2,010 s) — uma diferença de geração de código reproduzível entre dois loops escalares compilados por LLVM que permanece não atribuída: testamos o suspeito óbvio e o rejeitamos, já que um build get_unchecked verificado por equivalência sem verificações de limites não se mostrou mais rápido (2,337 s vs 2,276 s). Rust ingênuo não é automaticamente mais rápido; um kernel ajustado bem poderia ser — meça, depois decida.
  3. O imposto real é o texto. Codificar 150.000 floats como JSON custa 66.243 µs contra 49,1 µs bruto — 1348x, pago por direção, por chamada, em ambos os lados. Uma implantação JSON tagarela queima 5,3 s de codificação em um trabalho de 2 s (derivado). Fale binário através de fronteiras: frames brutos, Arrow — nunca json.dumps em um array de preços.
  4. Tagarela vs volumoso é mensurável, e a ausência de estado é a culpada. Chamadas por combinação que reenviam os dados: 1,19x contra o 1,13x do batch (+107 ms, derivado; a previsão unidirecional da curva de eco de ~81 ms fica ~25% abaixo, o resto é framing por chamada). Um servidor com estado pré-carregado faria as mesmas 80 chamadas a ~16 µs cada — cerca de 1,3 ms no total (derivado do piso de eco). Envie parâmetros, não o conjunto de dados.
  5. Respeite o piso — e saiba que o piso é uma escolha. Nosso cruzamento Python-sobre-socket-Unix toca o piso em 14 µs; a granularidade por combinação o supera ~1.795x (25.130 µs de computação por chamada) — seguro. Um padrão por barra (um extremo ilustrativo entre cargas de trabalho: um motor ao vivo por-tick, não esta varredura) pagaria 150.000 × 14 µs ≈ 2,1 s de IPC pura em um trabalho de 2,0 s (derivado) — morto na chegada mesmo com um motor infinitamente rápido. Spawnar por chamada adiciona ~24 ms fixos (derivado). E um transporte de memória compartilhada construído sob medida como ZigBolt faz viagens de ida e volta em ~39 ns nativamente nesta máquina — ~360x abaixo do nosso piso de socket (derivado; Zig nativo vs um cliente Python, então leia isso como o intervalo que o piso pode ocupar, não uma corrida).
  6. Cruze uma vez, em bytes, com os dados já presentes — e a fronteira compra paridade por ~0,1%. Um kernel servindo pesquisa e ao vivo, protegido por uma verificação de equivalência (PnL −5165,58, 57.029 operações, idêntico entre linguagens e entre ambos os builds Rust), é o caso honesto para um serviço de motor. Os casos desonestos — JSON, tagarela, spawn-por-chamada — são os que deram ao IPC sua reputação.

O experimento completo — o motor Rust, o protocolo de fio, os harnesses de eco e serialização, o portão de equivalência, e cada número neste artigo regenerável a partir de um script determinístico único — está no paper complementar em ipc-tax.marketmaker.cc, com código e dados em github.com/suenot/ipc-tax.

O socket nunca foi o problema. Dois milissegundos para todo o conjunto de dados, ida e volta — o folclore errou por três ordens de magnitude, e em ambas as direções ao mesmo tempo: pessimista demais sobre bytes, permissivo demais sobre texto. Cruze a fronteira como se custasse alguma coisa, e ela não vai custar.

blog.disclaimer

Authors

Eugen Soloviov
Eugen Soloviov

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.

Newsletter

Fique à frente do mercado

Assine nossa newsletter para insights exclusivos sobre trading com IA, análises de mercado e atualizações da plataforma.

Respeitamos sua privacidade. Cancele a inscrição a qualquer momento.