← Torna agli articoli
July 2, 2026
5 min di lettura

La Tassa IPC: Metti il Motore di Backtest Dietro un Socket e Perdi il 13% — Ma Quasi Nulla è Colpa del Socket

La Tassa IPC: Metti il Motore di Backtest Dietro un Socket e Perdi il 13% — Ma Quasi Nulla è Colpa del Socket
#algotrading
#backtest
#performance
#ipc
#rust
#architettura
Part 4 of 4 · Collection
High-Performance Backtest Engines

Fa parte della serie "Backtest Senza Illusioni".

📄 Questo articolo è cresciuto fino a diventare un paper di ricerca. Un kernel di backtest path-dependent viene implementato riga per riga da numba a Rust e chiamato attraverso un confine di processo/linguaggio in quattro modi, con un gate di equivalenza che conferma un PnL identico per-combo — più misurazioni isolate della curva di latenza IPC pura, della tassa di serializzazione e del costo di spawn. Leggi il paper online (versione interattiva + PDF) su ipc-tax.marketmaker.cc, codice e dati su github.com/suenot/ipc-tax.

Ogni motore di backtest che diventa veloce prima o poi provoca la stessa conversazione. Il nostro è arrivato puntuale. La scala di velocità aveva appena portato uno sweep di parametri a 80 combo da 69.9 secondi di pandas a circa 2 secondi di numba single-thread, e la naturale tentazione successiva era: perché fermarsi a un JIT Python? Riscrivi il kernel in Rust. Fanne un vero e proprio engine service — un binario compilato dietro un socket, chiamabile da ogni script di ricerca, ogni linguaggio, e anche dal trader live. Un solo kernel, un'unica verità, nessuna logica duplicata.

E poi arriva la controargomentazione, anch'essa puntuale: nel momento in cui esci dal processo, l'IPC ti divora. I dati devono essere serializzati, spediti attraverso un confine, deserializzati; ogni chiamata paga syscall e context switch; il tuo bellissimo kernel Rust passerà la vita ad aspettare su una pipe. Resta in-process. Lo sanno tutti.

Questo articolo misura la cosa che tutti sanno, e la misurazione è più interessante di entrambi i lati dell'argomentazione. La credenza popolare — "un motore cross-language più veloce perde contro numba in-process perché l'IPC ti uccide" — si rivela sbagliata in generale e giusta solo a condizioni specifiche. Attraversare il confine una volta, in byte raw, costa circa 2 millisecondi su un job da due secondi: un errore di arrotondamento. La tassa non sta nel confine. Sta in come lo attraversi — e i tre modi in cui gli engine service vengono di solito distribuiti nella realtà (un'API JSON, una chiamata per unità di lavoro, uno spawn di processo per chiamata) sono ciascuno, misurabilmente, un pezzo del disastro che il folklore predice.

Ecco l'intero esperimento in apertura. Tutto quello che segue è l'anatomia di ogni riga.

Architettura Cosa attraversa il confine per sweep Wall time vs in-process
numba in-process niente — una chiamata diretta 2.010 s 1.00x
Server Rust, batched (Unix socket) un round-trip: l'intera serie + tutti gli 80 set di parametri 2.276 s 1.13x
Server Rust, batched, kernel get_unchecked stesso singolo round-trip — una variante del kernel senza bounds-check (vedi il verdetto) 2.337 s 1.16x
Server Rust, chatty (Unix socket) 80 round-trip: la serie rispedita per ogni combo 2.383 s 1.19x
Rust spawn (stdin/stdout) spawn di processo + una richiesta pipeata 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 release, zero crate esterne). 150,000 barre × 80 combo, fee di round-trip 0.09%, seed 42; la serie close è di 1,200,000 byte (1.2 MB) sul wire. Mediana di 10 run per architettura; gli spread min-max restano entro ~2%. Tutte e cinque eseguono lo stesso sweep stop-and-reverse HMA/HMA3, e un gate di equivalenza conferma che i risultati per-combo (PnL, numero di trade) di entrambe le varianti del kernel Rust corrispondono esattamente a numba — fingerprint PnL −5165.58 su 57,029 trade, byte-identico al kernel numba dello studio sulla scala di velocità sullo stesso seed. Stiamo confrontando confini, non implementazioni.

Leggi con attenzione la riga batched, perché porta con sé l'intera tesi. L'architettura Rust-su-socket è 1.13x più lenta di numba in-process — 266 ms in ritardo sull'intero sweep (derivato: 2.276 − 2.010). La storia popolare dice che quei millisecondi sono IPC. Non lo sono. Circa 2 ms di quel divario sono il confine — l'intera serie close da 1.2 MB spedita dentro, i risultati spediti fuori, misurati direttamente. Gli altri ~264 ms sono dovuti al fatto che il nostro kernel Rust naive semplicemente calcola lo sweep circa il 13% più lentamente del kernel numba (derivato: 2.276 s meno ~2 ms di confine ≈ 2.274 s di calcolo Rust, contro 2.010 s per numba). Rust-il-linguaggio non ha perso contro Python-il-linguaggio; un loop scalare compilato con LLVM ha perso una gara di codegen contro un altro — e non siamo nemmeno riusciti a incollare la perdita al sospettato ovvio: una build dello stesso kernel senza bounds-check con get_unchecked è risultata non più veloce (2.337 s; la sezione verdetto lo analizza). Il socket non c'entrava quasi nulla con tutto questo.

Tieni a mente entrambe le metà di questa frase. Il confine è quasi gratuito quando viene attraversato correttamente — e "riscrivilo in Rust" ti compra un confine di deployment, non una vittoria automatica sul calcolo. Entrambi i fatti vanno contro l'istinto popolare, ed entrambi sono nella tabella.

Un kernel, due linguaggi, quattro confini

Il workload è deliberatamente lo stesso che la scala di velocità ha fissato, così i due studi si ancorano l'uno all'altro. Il kernel è un incrocio HMA/HMA3 — un sistema stop-and-reverse su due medie mobili in stile Hull, sette passate di weighted-moving-average per combinazione di parametri più un event loop stateful bar-by-bar che porta una posizione, contabilizza il PnL meno una fee di round-trip dello 0.09% a ogni incrocio, e inverte. I dati sono 150,000 barre di moto browniano geometrico sintetico seedato (seed=42); la griglia è di 80 lunghezze HMA distribuite su [6,200][6, 200]. Il riferimento in-process è il gradino numba single-thread della scala, rimisurato per questo studio: 1.98 s là, 2.010 s qui — stesso kernel, stessa macchina, rassicurantemente noioso.

Il motore cross-language è un porting riga per riga di quel kernel numba a Rust — stessi loop, stessa gestione dei NaN, stessa aritmetica delle fee — compilato in modalità release senza crate esterne, così l'intero esperimento resta privo di dipendenze e riproducibile. Parla un protocollo binario deliberatamente minimale: un frame con prefisso di lunghezza in ogni direzione, tutto 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

L'opcode echo è il bisturi dello studio: un round-trip di dimensione controllabile che non calcola nulla, così il costo puro del confine può essere misurato in isolamento — serializzazione, syscall, transito sul socket, deserializzazione, e nient'altro.

Cinque architetture misurate — quattro pattern di confine più una variante del kernel:

  • in_process — chiama direttamente il kernel numba. Nessun confine. Il riferimento.
  • rust_batch_unix — un server Rust persistente su un Unix domain socket. Un round-trip spedisce l'intera serie close più tutti gli 80 set di parametri; Rust calcola ogni combo; torna indietro una sola risposta. La chiamata chunky.
  • rust_batch_unchecked — lo stesso confine batched, ma il kernel indicizza con get_unchecked (nessun bounds check nel percorso caldo). Esiste per testare un'ipotesi specifica sul divario di calcolo; la sezione verdetto la spende.
  • rust_chatty_unix — lo stesso server, ma un round-trip per combo, con la serie da 1.2 MB rispedita ogni volta. L'architettura naive RPC-per-unità-di-lavoro.
  • rust_spawn_stdin — spawna il binario per ogni sweep e pipea la richiesta via stdin. Il pattern "shell out to a CLI engine"; paga la creazione del processo.

E il gate di equivalenza, senza il quale nulla di tutto questo avrebbe significato: dopo il timing, il vettore per-combo (PnL, numero di trade) di ogni variante Rust viene confrontato con quello di numba — numero di trade esatto, PnL entro un assoluto 10610^{-6}. Il run committato riporta all_ok: true sia per la build a indicizzazione sicura sia per quella get_unchecked. Il fingerprint del primo combo — PnL −5165.58 punti percentuali su 57,029 trade — corrisponde cifra per cifra al kernel numba dello studio sulla scala di velocità, il che ancora entrambi i paper allo stesso kernel sullo stesso seed. I porting cross-language sono esattamente il posto dove ama nascondersi la divergenza silenziosa (una fee applicata prima invece che dopo la conversione in percentuale, un confronto NaN che si dirama diversamente, un off-by-one in una finestra — la stessa specie di bug che la nostra tassonomia del look-ahead ha mostrato poter fabbricare uno Sharpe di 15 dal rumore). Un benchmark di due motori che calcolano cose diverse non è un benchmark; sono due programmi non correlati che gareggiano.

Stabilita l'equivalenza, ogni differenza nella tabella qui sopra è confine e calcolo — nient'altro.

Cosa costa davvero attraversare: la curva echo

Il costo misurato di un attraversamento del confine: una curva di latenza piatta a quattordici microsecondi per payload minuscoli, che sale solo oltre i diecimila float, raggiungendo due millisecondi per l'intera serie da 1.2 megabyte

Si parte dal bisturi. L'operazione echo fa un round-trip di un payload di nn float attraverso il server Rust — Python costruisce il frame, il server fa il parsing di tutti gli nn float, li ri-codifica e li rispedisce indietro. Entrambe le direzioni pagano serializzazione, syscall e transito sul socket. Ecco la curva misurata (mediane su 10 run):

Payload (float) Byte per direzione 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

Due fatti strutturali vivono in questa tabella.

Primo, il floor. Un round-trip che porta essenzialmente nulla — 8 byte — costa 14 µs. Questo è il prezzo irriducibile del fare una chiamata attraverso questo transport: due syscall write, due syscall read, la macchina del socket del kernel, i risvegli dello scheduler. Nota quanto è piatta la curva a sinistra: da 1 float a 1,000 float il costo si muove appena (14.1 → 18.1 µs). Sotto circa 8 KB stai pagando per la chiamata, non per i byte. Questo numero — il floor di latenza — è la singola costante più importante di tutto lo studio, e ci costruiremo sopra l'aritmetica del break-even più avanti.

Secondo, la pendenza. Oltre i ~10,000 float la curva diventa bandwidth-bound e grossomodo lineare. L'intera serie da 1.2 MB — 2.4 MB spostati in totale, andata e ritorno, incluso un parsing completo e la ri-codifica di 150,000 float sul lato Rust — costa 2,043.4 µs. Questo si traduce in un throughput effettivo di ~1.2 GB/s attraverso l'intero stack naive (derivato: 2.4 MB / 2.04 ms) — un Unix domain socket con frame a prefisso di lunghezza e un parser di float byte-per-byte, nessun trucco zero-copy, nessuna memoria condivisa, niente di furbo.

Un modello ragionevole di un singolo attraversamento, con entrambe le costanti misurate:

Tcall(b)    14 μsfloor  +  2b1.2 GB/spayload, both waysT_{\text{call}}(b) \;\approx\; \underbrace{14\ \mu\text{s}}_{\text{floor}} \;+\; \underbrace{\frac{2b}{1.2\ \text{GB/s}}}_{\text{payload, both ways}}

Ora mettiamo il numero principale in contesto. L'intero sweep richiede 2.010 s in-process. Spedire l'intero dataset attraverso il confine e ritorno costa ~2.0 ms — circa lo 0.1% del lavoro (derivato: 2.0434 ms / 2.010 s). Se attraversi una volta sola, in byte raw, il confine è un errore di arrotondamento. Questa è la metà della credenza popolare che muore per prima: la paura non ha mai riguardato qualcosa di così economico.

Il lato Rust di quell'attraversamento è tanto poco affascinante quanto può esserlo il codice di sistema — adattato da 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 onesta sull'ambito prima di andare avanti: tutti i numeri di confine in questo studio sono un Unix domain socket su un solo host. Il motore parla anche TCP (con TCP_NODELAY), ma non lo abbiamo misurato; il TCP loopback si posiziona un po' sopra questi floor, e un vero hop di rete è un regime completamente diverso — millisecondi di floor, non microsecondi. Tutto ciò che c'è qui è quindi il caso quasi migliore per attraversare un confine in questo modo. Il che rende le tasse misurate di seguito ancora più dannose: sono ciò che paghi in aggiunta a questo, per scelta.

La tassa di serializzazione: 1348x per aver scelto JSON

Due codifiche dello stesso array di 150,000 float affiancate: un memcpy raw-bytes misurato in microsecondi contro una codifica di testo JSON che svetta tre ordini di grandezza più in alto

Ecco dove la credenza popolare sull'"overhead IPC" si rivela un'etichetta sbagliata. Abbiamo misurato il costo di codificare la stessa serie close di 150,000 float in tre modi — esattamente il payload che ogni architettura qui sopra spedisce:

Codifica Tempo per codificare 1.2 MB di float vs raw
byte raw (.tobytes()) 49.1 µs 1.0x
pickle 29.8 µs 0.6x
JSON (json.dumps(close.tolist())) 66,243 µs 1348x

Il percorso raw è un memcpy travestito da chiamata di funzione:

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 risulta persino leggermente più economico del nostro percorso raw perché astype paga una copia di conversione del dtype anche quando il dtype corrisponde già; entrambi sono memcpy-class ed entrambi sono errori di arrotondamento. La famiglia binaria nel suo complesso vive tre ordini di grandezza sotto la famiglia testuale.)

E il percorso testuale è ciò che quasi ogni deployment del tipo "trasformiamo il motore in un microservizio" spedisce davvero:

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

Sessantasei millisecondi. Solo per codificare. json.dumps(close.tolist()) incapsula ogni float in un oggetto Python, poi rende ciascuno come testo decimale — 150,000 allocazioni heap e 150,000 conversioni float-to-string dove il percorso raw faceva una singola copia a blocchi. E anche il payload sul wire si gonfia (un float64 costa 8 byte in binario e circa due-tre volte tanto come testo decimale — non abbiamo nemmeno addebitato il transito extra).

Ora scaliamolo come fa un deployment reale. Quei 66 ms sono una codifica, un lato, una chiamata. Un servizio JSON paga encode e decode, su entrambi i lati del confine, su ogni chiamata. Una singola chiamata batched su JSON brucerebbe ~3.3% dell'intero budget di calcolo dello sweep solo per la codifica lato client (derivato: 66 ms / 2.010 s). Metti JSON sotto l'architettura chatty — una chiamata per combo, il pattern qui sotto — e la sola codifica lato client costa 80 × 66 ms = 5.3 s: più di due volte e mezzo l'intero job utile (derivato), prima che un solo byte si muova e prima che il server faccia il parsing di qualsiasi cosa.

Questa è la vera "tassa IPC" che la maggior parte dei team ha misurato in produzione senza saperlo. Non è mai stata comunicazione inter-processo. Era serializzazione testuale di array numerici — un autoinflitto 1348x sulla componente più economica del confine. Il mondo columnar ha imparato questa lezione anni fa, ed è la stessa in cui il nostro studio Polars vs pandas continuava a imbattersi dal lato della pipeline dati: formati come Arrow esistono proprio perché i dati degli array possano attraversare confini di processo e linguaggio come byte columnar raw, non come testo. Se il tuo engine service parla JSON per gli array di prezzo, nessun tuning del socket ti salverà — il protocollo è il collo di bottiglia.

Chatty vs chunky: la legge di Fowler, misurata

Un'architettura chunky che spedisce un unico grande payload framed attraverso il confine una sola volta, accanto a un'architettura chatty che fa ottanta piccoli round-trip ciascuno dei quali si trascina dietro l'intero dataset

La First Law of Distributed Object Design di Martin Fowler — "non distribuire i tuoi oggetti" — viene con un corollario che ha esplicitato nello stesso respiro: se devi attraversare un confine, l'interfaccia deve essere a grana grossa, perché una chiamata remota costa ordini di grandezza più di una locale. Ogni veterano dei sistemi distribuiti annuisce. Quasi nessuno ha un numero per il proprio workload. Ecco il nostro.

Le architetture chunky e chatty eseguono lo stesso server, lo stesso protocollo, gli stessi dati — solo la granularità della chiamata differisce:

srv.call(0, close, params)

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

Chunky: 2.276 s (1.13x). Chatty: 2.383 s (1.19x) — 107 ms più lento (derivato: 2.383 − 2.276). Per essere precisi su cosa sia e cosa non sia quel delta: la curva echo ne dà una previsione naive — 79 spedizioni extra dell'intera serie a circa metà del round-trip a payload completo di 2,043 µs ciascuna, circa 81 ms — che atterra circa il 25% sotto i 107 ms misurati; il resto è costruzione della richiesta e framing per-chiamata sul lato Python, che la previsione echo non include. In ogni caso arriva a ~1.4 ms per attraversamento extra (derivato: 107 / 79); le risposte sono trascurabili — 16 byte per combo.

Due letture di quei 107 ms, ed entrambe contano.

La lettura indulgente: è solo ~4.5% del wall time, non una catastrofe. Vero — e vale la pena capire perché il disastro del folklore non si è materializzato qui. Ogni chiamata chatty porta comunque 25,130 µs di calcolo reale (l'equivalente di un combo — il costo per-combo in-process misurato), quindi l'overhead di confine per-chiamata di ~1.4 ms resta un ordine di grandezza sotto il lavoro per-chiamata. Le architetture chatty non sono fatali quando ogni chiamata è genuinamente pesante. Diventano fatali quando la granularità si riduce — che è l'intero argomento della sezione break-even.

La lettura dannosa: questa tassa era interamente volontaria, e scala con numero di chiamate × payload. Il pattern chatty rispedisce il dataset a ogni chiamata per una sola ragione: il servizio è stateless, quindi ogni richiesta deve portare tutto il contesto. Questa è la forma predefinita di un naive "sweep endpoint" — e di praticamente ogni microservizio REST mai schizzato su una lavagna. Un server stateful — carica la serie una volta, poi manda frame di parametri da 48 byte — porterebbe ogni chiamata per-combo vicino all'estremità a payload minuscolo della curva echo: circa 16 µs per chiamata, all'incirca 1.3 ms per tutti gli 80 (derivato dal floor echo; analitico, non misurato separatamente). La penalità chatty non si ridurrebbe; svanirebbe. La lezione è precisa: il problema non è fare molte chiamate — è rispedire lo stato perché il protocollo finge che ogni chiamata sia la prima.

Precarica i dati. Spedisci i parametri. Attraversa il confine con intenzione, non con il mondo intero nella valigia ogni volta.

Il costo dello spawn: noleggiare il motore a chiamata

Un binario del motore che viene spawnato da zero per una singola richiesta: creazione del processo, loader e setup della pipe impilati come un casello a costo fisso davanti a un breve tratto di lavoro utile

Il terzo pattern di deployment è il più antico: nessun server. Spawna il binario del motore, pipea una richiesta via stdin, leggi la risposta da stdout, lascialo morire. L'istinto di ogni shell scripter, ogni integrazione "chiama semplicemente la CLI da Python", ogni framework di hyperparameter configurato per lanciare un binario per trial.

Misurato: 2.300 s (1.14x) — circa 24 ms in più rispetto al batch con server persistente (derivato: 2.300 − 2.276). Quei 24 millisecondi comprano un fork/exec, il loader dinamico, il setup della pipe e lo smontaggio del processo. E nota che ciò che misuriamo qui è vicino al floor per questo pattern: un piccolo binario nativo privo di dipendenze, caldo nella page cache. Spawnare qualsiasi cosa con un runtime — una JVM, un interprete Python con import — costa molto di più; non li abbiamo misurati qui, ma la direzione non è in dubbio.

Ciò che conta è la struttura di questa tassa: è fissa per chiamata, indifferente a quanto lavoro porta la chiamata. Ammortizzata su un intero sweep a 80 combo, 24 ms sono circa l'1% — rumore. Respawna per combo e la stessa costante diventa 80 × ~24 ms ≈ 1.9 s — praticamente l'intero job utile bruciato nella creazione di processi (derivato; analitico). Respawna per barra e l'aritmetica non vale nemmeno la pena scriverla.

Costo fisso, granularità fine: scegline uno. Il pattern che paga uno spawn è sensato solo quando lo spawn è raro e il payload dietro di esso è enorme — esattamente come la nostra misurazione a uno-spawn-per-sweep, ed esattamente diverso dal modo in cui finiscono per essere usate le architetture per-symbol-subprocess quando cresce il numero di simboli.

L'aritmetica del break-even: un floor è un hurdle rate

Aritmetica del break-even su una bilancia: quattordici microsecondi di floor di confine su un piatto pesati contro il calcolo che ogni chiamata porta, con le chiamate per-combo ben sopra il livello dell'acqua e le chiamate per-barra annegate

Tutto ciò che è stato misurato finora si comprime in un'unica regola di design, e la regola è aritmetica, non opinione.

Ogni attraversamento del confine costa almeno il floor di latenza — 14 µs qui, il round-trip echo a payload minuscolo, e vicino al meglio che questo transport offre. Quel floor è un hurdle rate: una chiamata attraverso il confine vale la pena solo se il calcolo che spedisce supera l'ostacolo con un multiplo comodo. Definisci il rapporto di granularità

G  =  Tcompute per callTfloorG \;=\; \frac{T_{\text{compute per call}}}{T_{\text{floor}}}

e la quota del confine sul tuo wall time è approssimativamente 1/(1+G)1/(1+G) — con il transito del payload in aggiunta se la chiamata porta anche dati.

Ora facciamo passare i numeri dello sweep attraverso questa formula. Il costo in-process misurato di un combo è 25,130 µs. Alla granularità per-combo:

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

Le chiamate per-combo stanno ~1,795x sopra il floor — il confine reclama ben meno di un decimo di punto percentuale per chiamata. Questo è il motivo per cui persino l'architettura chatty ha perso solo 107 ms: a questa granularità del workload, ogni pattern di attraversamento che non rispedisce dati o non parla testo è ammortizzato in modo sicuro. Le chiamate a livello combo, fold, sweep sono tutte ben dentro la zona economica.

Ora ribaltiamo all'estremo opposto. Questo è un'estrapolazione cross-workload illustrativa — non una variante del nostro sweep, ma una forma di workload che esiste davvero nella realtà: il motore viene consultato per barra. Un engine service in stile live per-tick; uno stream di segnali gRPC-per-barra; uno "strategy server" interrogato una volta per ognuna delle 150,000 barre. Il calcolo utile per barra in questo kernel è 25,130 µs / 150,000 ≈ 0.17 µs (derivato) — ogni chiamata porterebbe circa 1/84 del proprio costo di confine in lavoro utile (derivato: il floor di 14.05 µs su 0.168 µs di calcolo). Il totale è peggiore di quanto suoni il rapporto:

150,000 calls×14 μs    2.1 s of pure IPC150{,}000 \ \text{calls} \times 14\ \mu\text{s} \;\approx\; \mathbf{2.1\ s\ of\ pure\ IPC}

— più dell'intero job in-process da 2.010 s, speso prima che il motore remoto calcoli un singolo numero, e resterebbe 2.1 s anche se il motore dall'altro lato fosse infinitamente veloce (derivato: 150,000 × 14 µs). Nessun vantaggio di calcolo sopravvive a una granularità così fine. E ricorda che questo floor è un Unix socket su un solo host; fai quella chiamata per-barra a un servizio attraverso una rete e il floor cresce di due-tre ordini di grandezza, su 150,000 chiamate.

Il floor di confine sulla stessa macchina come scelta implementativa: un round-trip Python-su-Unix-socket a quattordici microsecondi che svetta su un attraversamento a shared-memory ring a trentanove nanosecondi, tre ordini di grandezza di distanza

Un'ultima calibrazione onesta, perché nemmeno 14 µs è una legge di fisica — è il prezzo del nostro transport: un client Python, un socket kernel, syscall in entrambe le direzioni. Un transport costruito appositamente per la stessa macchina va molto più in basso. ZigBolt — il nostro bus di messaggistica Zig open-source per workload HFT, benchmarkato nativamente su questa stessa macchina — fa un round-trip su shared-memory ring in circa 39 ns medi (p50 one-way di 10/20/30 ns a messaggi di 64/256/1024 byte). Questo è circa 360x sotto il nostro floor sul socket (derivato: 14.05 µs / 39 ns). Il confronto è deliberatamente tra mele e pere, e lo segnaliamo come tale: i nostri 14 µs sono un round-trip su socket con client Python, i 39 ns di ZigBolt sono Zig nativo su memoria condivisa, quindi il divario mescola transport e runtime. Leggilo non come una gara tra i due ma come il range che il floor sulla stessa macchina può occupare: circa tre ordini di grandezza, scelto dall'implementazione. Questa è la vecchia lezione della Lightweight RPC (Bershad et al., 1990) in abiti moderni — gli attraversamenti sulla stessa macchina sono dominati dalla macchina del protocollo, e collassano quando il transport è costruito per il caso della stessa macchina. L'aritmetica del break-even qui sopra non cambia forma; l'ostacolo si sposta soltanto. A un floor di 39 ns, persino la granularità per-barra lo supererebbe (150,000 × 39 ns ≈ 5.9 ms, derivato) — che è esattamente come i sistemi HFT possono permettersi confini che un servizio REST non può.

Questa è l'intera storia del break-even in una frase: al confine non importa quanto è veloce il tuo motore; addebita per attraversamento, quindi le variabili che controlli sono quanto lavoro porta ogni attraversamento — e di cosa è fatto l'attraversamento. Batch per sweep e GG è oltre centomila. Batch per combo, G1795G \approx 1795 — ancora bene. Chiamata per barra su un socket, G<1G < 1 — l'architettura è morta prima della prima ottimizzazione, e nessuna riscrittura del motore, in Rust o altro, può resuscitarla.

Dove vive davvero l'1.13x — e il verdetto

Il divario di 266 millisecondi sezionato: una fettina di due millisecondi etichettata come il confine accanto a una grande lastra di differenza di codegen misurata tra due kernel scalari compilati, con la credenza popolare barrata

È il momento di sezionare onestamente il divario principale, perché porta con sé la scoperta più controintuitiva dello studio.

L'architettura Rust batched è indietro rispetto a numba in-process di 266 ms (derivato: 2.276 − 2.010). Le componenti di confine misurate: un round-trip a payload completo di ~2.0 ms, serializzazione raw a 49 µs, header dei frame per una manciata di byte — chiamiamo l'intero conto del confine ~2 ms. Oltre il 99% del divario quindi non è affatto il confine. È calcolo: spogliato dell'IPC, il server Rust spende ~2.274 s a fare lo sweep che numba fa in 2.010 s — il kernel Rust naive è circa il 13% più lento nel calcolo puro (derivato).

Questo merita un paragrafo senza sconti, perché "riscrivilo in Rust e sarà più veloce" è tanto credenza popolare quanto "l'IPC ti ucciderà". Entrambi i kernel finiscono in LLVM — numba vi abbassa il bytecode Python, rustc vi abbassa MIR — ed entrambi molto probabilmente girano come loop scalari: la somma interna della WMA è una riduzione in virgola mobile, che LLVM non auto-vettorizzerà senza la licenza di riassociazione fast-math che i default di @njit di numba non concedono e che il nostro porting non richiede. Quindi il ~13% è un divario di codegen misurato tra due loop scalari compilati con LLVM — e invece di affermare una causa, abbiamo testato quella ovvia. Il sospettato naturale è l'indicizzazione sicura di Rust: il loop caldo della WMA fa un bounds-check a ogni accesso all'array, mentre @njit di numba compila con i bounds check disattivati. Così abbiamo costruito una variante equivalence-verified dello stesso kernel su get_unchecked — nessun bounds check da nessuna parte nel percorso caldo — e l'abbiamo cronometrata come quinta architettura. Non ha chiuso il divario: 2.337 s (1.16x), marginalmente più lenta dei 2.276 s della build con bounds check. Ipotesi testata, ipotesi respinta. Lo stato onesto della conoscenza: il ~13% è reale e riproducibile (mediane su 10 run, spread entro ~2%), e al momento non attribuito — qualche differenza nel comportamento di allocazione, nella struttura del loop, o nello scheduling delle istruzioni che solo un profiling a livello di assembly potrebbe chiarire. La lezione sopravvive intatta: il Rust naive non è automaticamente più veloce di un buon numba, e un confine di linguaggio acquistato sull'assunzione di una vittoria gratuita sul calcolo può arrivare con una perdita di calcolo allegata. Un kernel Rust ottimizzato — buffer preallocati, SIMD esplicito, thread tra i combo — potrebbe ancora ribaltare il segno. Ma quella è una domanda di calcolo, da risolvere con profiling e lavoro sul kernel, e la domanda di questo studio è il confine. La risposta del confine: attraversato una volta, in byte, costa ~0.1%.

Quindi assembliamo il verdetto completo, ogni sua clausola misurata qui sopra.

Un engine service cross-language vince quando tutte queste condizioni valgono:

  • Il vantaggio di calcolo è reale — misurato sul tuo kernel, non assunto dalla reputazione del linguaggio. (Il nostro era −13% fino a prova contraria — e la prima spiegazione "ovvia" per quel deficit è morta in fase di test.)
  • Attraversi in modo grossolano — una chiamata per sweep o per fold, migliaia di multipli sopra il floor di 14 µs, come dimostra il totale di 1.13x dell'architettura batch (~0.1% confine).
  • Parli binario — array raw a prefisso di lunghezza, Arrow, qualsiasi cosa memcpy-class a 49 µs per 1.2 MB; mai testo a 66,243 µs.
  • I dati sono precaricati — un server stateful prende chiamate solo-parametri all'estremità ~16 µs della curva echo invece di rispedire megabyte.

Perde quando viene distribuito come sono di solito distribuiti gli engine service:

  • Un microservizio JSON/REST — paga la tassa di serializzazione 1348x a ogni chiamata, in entrambe le direzioni; a granularità chatty sono 5.3 s di codifica su un job da 2 s.
  • RPC per unità di lavoro — per combo costa 107 ms qui e sopravvive solo perché ogni chiamata porta 25,130 µs di calcolo; per barra sono ~2.1 s di puro IPC prima che accada qualsiasi lavoro, su un job da 2.0 s.
  • Uno spawn per chiamata — ~24 ms di costo fisso ogni volta, innocuo una volta per sweep, quasi due secondi se pagato per combo.

Il che equivale a dire: le architetture che falliscono non sono esotiche. Motore JSON REST, subprocess per-symbol, gRPC-per-tick — questo è un censimento onesto di come viene effettivamente costruito il "fattorizziamo il motore di backtest". La credenza popolare è empiricamente ben fondata come descrizione della pratica comune ed empiricamente sbagliata come legge di natura. Il confine non è mai stato il problema. Lo sono i modi predefiniti di attraversarlo.

Un argomento a favore del confine merita una frase tutta sua, perché è la ragione per cui abbiamo condotto questo studio. Un singolo kernel compilato dietro un confine ben progettato può servire sia lo sweep di ricerca sia il loop di trading live — lo stesso binario, la stessa aritmetica, bit per bit. Il nostro studio sulla parità backtest-live ha catalogato come i motori di ricerca e produzione divergano quando sono due codebase; un engine service è la cura strutturale più forte per quella deriva, e questo studio quota la cura onestamente: fatto bene, circa lo 0.1% del wall time e un gate di equivalenza per dimostrare che nulla è cambiato nella traduzione. Quello scambio — un confine di processo dedicato in cambio della parità a un solo kernel — è, su questi numeri, un affare. Fatto male, la stessa idea spedisce in produzione una tassa di serializzazione 1348x con il tuo PnL a cavalcioni sopra di essa.

Takeaway

  1. Il confine è quasi gratuito; la credenza popolare fallisce la misurazione. Fare un round-trip dell'intera serie close da 1.2 MB attraverso un Unix socket — parsing completo e ri-codifica inclusi — costa 2,043.4 µs, circa lo 0.1% del job da 2.010 s (derivato). L'architettura Rust-su-socket batched atterra a 1.13x totale, e persino ~99% di quel divario non è IPC.
  2. "Riscrivilo in Rust" è un'affermazione sul calcolo — verificala prima di comprare il confine. Il nostro porting Rust riga per riga calcola ~13% più lentamente del kernel numba (derivato: 2.274 s vs 2.010 s) — un divario di codegen riproducibile tra due loop scalari compilati con LLVM che rimane non attribuito: abbiamo testato il sospettato ovvio e lo abbiamo respinto, dato che una build get_unchecked equivalence-verified senza bounds check non è risultata più veloce (2.337 s vs 2.276 s). Il Rust naive non è automaticamente più veloce; un kernel ottimizzato potrebbe esserlo — misura, poi decidi.
  3. La vera tassa è il testo. Codificare 150,000 float come JSON costa 66,243 µs contro 49.1 µs raw — 1348x, pagato per direzione, per chiamata, su entrambi i lati. Un deployment JSON chatty brucia 5.3 s di codifica su un job da 2 s (derivato). Parla binario attraverso i confini: frame raw, Arrow — mai json.dumps su un array di prezzo.
  4. Chatty vs chunky è misurabile, e la statelessness è la colpevole. Chiamate per-combo che rispediscono i dati: 1.19x contro l'1.13x del batch (+107 ms, derivato; la previsione one-way della curva echo di ~81 ms atterra ~25% sotto di esso, il resto è framing per-chiamata). Un server stateful precaricato prenderebbe le stesse 80 chiamate a ~16 µs ciascuna — circa 1.3 ms totali (derivato dal floor echo). Spedisci parametri, non il dataset.
  5. Rispetta il floor — e sappi che il floor è una scelta. Il nostro attraversamento Python-su-Unix-socket ha un floor di 14 µs; la granularità per-combo lo supera di ~1,795x (25,130 µs di calcolo per chiamata) — sicuro. Un pattern per-barra (un estremo cross-workload illustrativo: un motore live per-tick, non questo sweep) pagherebbe 150,000 × 14 µs ≈ 2.1 s di puro IPC su un job da 2.0 s (derivato) — morto all'arrivo anche con un motore infinitamente veloce. Spawnare per chiamata aggiunge un fisso ~24 ms (derivato). E un transport a memoria condivisa costruito ad hoc come ZigBolt fa round-trip in ~39 ns nativamente su questa macchina — ~360x sotto il nostro floor sul socket (derivato; Zig nativo contro un client Python, quindi leggilo come il range che il floor può occupare, non una gara).
  6. Attraversa una volta, in byte, con i dati già presenti — e il confine ti compra la parità per ~0.1%. Un solo kernel che serve ricerca e live, gatekeepato da un controllo di equivalenza (PnL −5165.58, 57,029 trade, identico attraverso i linguaggi e attraverso entrambe le build Rust), è il caso onesto per un engine service. I casi disonesti — JSON, chatty, spawn-per-chiamata — sono quelli che hanno dato all'IPC la sua reputazione.

L'esperimento completo — il motore Rust, il wire protocol, gli harness di echo e serializzazione, il gate di equivalenza, e ogni numero di questo articolo rigenerabile da un unico script deterministico — è nel paper companion su ipc-tax.marketmaker.cc, con codice e dati su github.com/suenot/ipc-tax.

Il socket non è mai stato il problema. Due millisecondi per l'intero dataset, andata e ritorno — il folklore ha sbagliato di tre ordini di grandezza, e in entrambe le direzioni contemporaneamente: troppo pessimista sui byte, troppo indulgente sul testo. Attraversa il confine come se costasse qualcosa, e non costerà.

Disclaimer: le informazioni fornite in questo articolo hanno solo scopo didattico e informativo e non costituiscono consulenza finanziaria, di investimento o di trading. Il trading di criptovalute comporta un rischio significativo di perdita.

Autori

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

Resta un Passo Avanti al Mercato

Iscriviti alla nostra newsletter per approfondimenti esclusivi sul trading con IA, analisi di mercato e aggiornamenti sulla piattaforma.

Rispettiamo la tua privacy. Annulla l'iscrizione in qualsiasi momento.