Thuế IPC: Đặt Engine Backtest Sau Một Socket Và Mất 13% — Nhưng Gần Như Không Gì Trong Đó Là Do Socket
Bài viết thuộc series "Backtest Không Ảo Tưởng".
📄 Bài viết này đã phát triển thành một bài báo nghiên cứu. Một kernel backtest phụ thuộc đường đi (path-dependent) được port theo từng dòng từ numba sang Rust và được gọi qua ranh giới tiến trình/ngôn ngữ theo bốn cách khác nhau, với một cổng tương đương xác nhận PnL giống hệt nhau theo từng combo — cùng với các phép đo cô lập của đường cong độ trễ IPC thuần túy, thuế tuần tự hóa, và chi phí spawn. Đọc bài báo trực tuyến (phiên bản tương tác + PDF) tại ipc-tax.marketmaker.cc, mã nguồn và dữ liệu tại github.com/suenot/ipc-tax.
Mọi engine backtest khi trở nên đủ nhanh cuối cùng đều khơi lên cùng một cuộc tranh luận. Của chúng tôi cũng đến đúng hẹn. Thang tốc độ vừa mới đưa một sweep tham số 80 combo từ 69.9 giây của pandas xuống còn khoảng 2 giây của numba đơn luồng, và cơn ngứa ngáy tự nhiên tiếp theo là: tại sao lại dừng ở một JIT của Python? Viết lại kernel bằng Rust. Biến nó thành một dịch vụ engine đúng nghĩa — một binary đã biên dịch đứng sau một socket, có thể gọi được từ mọi script nghiên cứu, mọi ngôn ngữ, và cả trader sống (live trader) nữa. Một kernel, một sự thật duy nhất, không có logic trùng lặp.
Và rồi lập luận phản bác cũng xuất hiện, cũng đúng hẹn: khoảnh khắc bạn rời khỏi tiến trình, IPC sẽ ăn thịt bạn. Dữ liệu phải được tuần tự hóa, truyền qua một ranh giới, giải tuần tự hóa; mỗi lệnh gọi phải trả giá bằng syscall và context switch; chiếc kernel Rust xinh đẹp của bạn sẽ dành cả đời chờ đợi trên một pipe. Cứ ở trong tiến trình (in-process). Ai cũng biết điều này.
Bài viết này đo lường chính cái điều mà ai cũng biết, và phép đo hóa ra thú vị hơn cả hai phía của cuộc tranh luận. Quan niệm dân gian — "một engine đa ngôn ngữ nhanh hơn vẫn thua in-process numba vì IPC sẽ giết chết bạn" — hóa ra sai nói chung và chỉ đúng trong những điều kiện cụ thể. Băng qua ranh giới một lần, bằng byte thô, tốn khoảng 2 mili giây trên một công việc dài hai giây: một sai số làm tròn. Cái thuế không nằm ở ranh giới. Nó nằm ở cách bạn băng qua nó — và ba cách mà các dịch vụ engine thường được triển khai trong thực tế (một JSON API, một lệnh gọi cho mỗi đơn vị công việc, một lần spawn tiến trình cho mỗi lệnh gọi) mỗi cách, có thể đo lường được, đều là một mảnh của thảm họa mà truyền miệng đã dự đoán.
Đây là toàn bộ thí nghiệm ngay từ đầu. Mọi thứ bên dưới là giải phẫu của từng dòng.
| Kiến trúc | Điều gì băng qua ranh giới mỗi sweep | Wall time | so với in-process |
|---|---|---|---|
| in-process numba | không gì cả — một lệnh gọi trực tiếp | 2.010 s | 1.00x |
| Rust server, batched (Unix socket) | một round-trip: toàn bộ series + cả 80 bộ tham số | 2.276 s | 1.13x |
Rust server, batched, kernel get_unchecked |
cùng một round-trip duy nhất — một biến thể kernel không kiểm tra biên (xem phần phán quyết) | 2.337 s | 1.16x |
| Rust server, chatty (Unix socket) | 80 round-trip: series được truyền lại theo từng combo | 2.383 s | 1.19x |
| Rust spawn (stdin/stdout) | spawn tiến trình + một yêu cầu qua pipe | 2.300 s | 1.14x |
Apple M2 Max, Python 3.14.6, numpy 2.4.3, numba 0.64.0, rustc 1.94.0 (bản dựng release, zero external crate). 150,000 nến × 80 combo, phí round-trip 0.09%, seed 42; chuỗi giá đóng là 1,200,000 byte (1.2 MB) trên đường truyền. Trung vị của 10 lần chạy cho mỗi kiến trúc; độ trải min–max nằm trong khoảng ~2%. Cả năm kiến trúc đều chạy cùng một sweep stop-and-reverse HMA/HMA3 như nhau, và một cổng tương đương xác nhận rằng kết quả (PnL, số giao dịch) theo từng combo của cả hai biến thể kernel Rust khớp chính xác với numba — fingerprint PnL −5165.58 trên 57,029 giao dịch, giống hệt từng byte với kernel numba của nghiên cứu thang tốc độ trên cùng một seed. Chúng tôi đang so sánh các ranh giới, không phải các cách triển khai.
Hãy đọc kỹ dòng batched, vì nó mang cả luận điểm chính của bài viết. Kiến trúc Rust-qua-socket chậm hơn 1.13x so với in-process numba — chậm hơn 266 ms trên toàn bộ sweep (tính ra: 2.276 − 2.010). Câu chuyện dân gian nói rằng những mili giây đó là IPC. Không phải vậy. Khoảng 2 ms trong khoảng cách đó là ranh giới — toàn bộ chuỗi giá đóng 1.2 MB được truyền vào, kết quả được truyền về, đo trực tiếp. ~264 ms còn lại là vì kernel Rust ngây thơ của chúng tôi đơn giản là tính sweep chậm hơn khoảng 13% so với kernel numba (tính ra: 2.276 s trừ đi ~2 ms ranh giới ≈ 2.274 s tính toán của Rust, so với 2.010 s của numba). Rust-ngôn-ngữ không thua Python-ngôn-ngữ; một vòng lặp scalar biên dịch bằng LLVM đã thua một cuộc đua codegen trước một vòng lặp scalar khác — và chúng tôi thậm chí còn không thể quy kết tổn thất đó cho nghi phạm hiển nhiên: một bản dựng get_unchecked không kiểm tra biên của cùng kernel đó lại ra không nhanh hơn (2.337 s; phần phán quyết sẽ mổ xẻ điều này). Chiếc socket gần như không liên quan gì đến bất kỳ điều nào trong số đó.
Hãy giữ cả hai nửa của câu đó trong đầu. Ranh giới gần như miễn phí khi được băng qua đúng cách — và "viết lại bằng Rust" mua cho bạn một ranh giới triển khai, chứ không phải một chiến thắng tính toán tự động. Cả hai sự thật đều đi ngược lại bản năng phổ biến, và cả hai đều nằm trong bảng.
Một kernel, hai ngôn ngữ, bốn ranh giới
Khối lượng công việc cố ý giống hệt khối lượng mà thang tốc độ đã cố định, để hai nghiên cứu neo vào nhau. Kernel là một giao cắt HMA/HMA3 — một hệ thống stop-and-reverse trên hai moving average kiểu Hull, bảy lượt weighted-moving-average cho mỗi tổ hợp tham số cộng với một vòng lặp sự kiện stateful theo từng nến mang theo một vị thế, ghi nhận PnL trừ đi phí round-trip 0.09% ở mỗi lần giao cắt, và đảo chiều. Dữ liệu là 150,000 nến chuyển động Brown hình học tổng hợp có seed (seed=42); grid là 80 độ dài HMA trải trên . Tham chiếu in-process là bậc numba đơn luồng của thang tốc độ, được đo lại cho nghiên cứu này: 1.98 s ở đó, 2.010 s ở đây — cùng kernel, cùng máy, buồn tẻ một cách đáng yên tâm.
Engine đa ngôn ngữ là một bản port theo từng dòng của kernel numba đó sang Rust — cùng vòng lặp, cùng cách xử lý NaN, cùng cách tính phí — được biên dịch ở chế độ release không có external crate nào, để toàn bộ thí nghiệm không phụ thuộc và có thể tái tạo. Nó nói một giao thức nhị phân cố ý tối giản: một frame có tiền tố độ dài cho mỗi chiều, mọi thứ đều 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
Opcode echo là con dao mổ của nghiên cứu này: một round-trip có kích thước kiểm soát được mà không tính toán gì cả, để chi phí ranh giới thuần túy có thể được đo cô lập — tuần tự hóa, syscall, truyền qua socket, giải tuần tự hóa, và không gì khác.
Năm kiến trúc được đo — bốn mẫu hình ranh giới cộng với một biến thể kernel:
- in_process — gọi kernel numba trực tiếp. Không có ranh giới. Tham chiếu.
- rust_batch_unix — một Rust server thường trực trên một Unix domain socket. Một round-trip duy nhất truyền toàn bộ chuỗi giá đóng cộng với cả 80 bộ tham số; Rust tính mọi combo; một phản hồi duy nhất quay về. Lệnh gọi chunky.
- rust_batch_unchecked — cùng ranh giới batched đó, nhưng kernel đánh chỉ mục bằng
get_unchecked(không kiểm tra biên trong hot path). Nó tồn tại để kiểm tra một giả thuyết cụ thể về khoảng cách tính toán; phần phán quyết sẽ giải quyết nó. - rust_chatty_unix — cùng server đó, nhưng một round-trip cho mỗi combo, series 1.2 MB được truyền lại mỗi lần. Kiến trúc RPC-trên-mỗi-đơn-vị-công-việc ngây thơ.
- rust_spawn_stdin — spawn binary cho mỗi sweep và truyền yêu cầu qua stdin bằng pipe. Mẫu hình "shell ra một engine CLI"; phải trả giá cho việc tạo tiến trình.
Và cổng tương đương, nếu thiếu nó thì không điều gì ở đây có ý nghĩa cả: sau khi đo thời gian, vector (PnL, số giao dịch) theo từng combo của mỗi biến thể Rust được so sánh với vector của numba — số giao dịch phải khớp chính xác, PnL trong phạm vi sai số tuyệt đối . Lần chạy đã commit báo cáo all_ok: true cho cả bản dựng đánh chỉ mục an toàn lẫn bản dựng get_unchecked. Fingerprint của combo đầu tiên — PnL −5165.58 điểm phần trăm trên 57,029 giao dịch — khớp từng chữ số với kernel numba của nghiên cứu thang tốc độ, điều này ghim cả hai bài báo vào cùng một kernel trên cùng một seed. Các bản port đa ngôn ngữ chính là nơi mà sự sai lệch âm thầm thích ẩn náu nhất (một khoản phí được áp dụng trước thay vì sau phép chuyển đổi phần trăm, một phép so sánh NaN rẽ nhánh khác đi, một lỗi off-by-one trong một cửa sổ — cùng loài lỗi mà phân loại look-ahead của chúng tôi cho thấy có thể tạo ra một Sharpe 15 từ nhiễu thuần túy). Một benchmark của hai engine tính ra những thứ khác nhau không phải là một benchmark; đó là hai chương trình không liên quan đang chạy đua.
Với tính tương đương đã được thiết lập, mọi khác biệt trong bảng trên đều là ranh giới và tính toán — không gì khác.
Việc băng qua thực sự tốn bao nhiêu: đường cong echo

Bắt đầu với con dao mổ. Opcode echo thực hiện round-trip một payload gồm số thực dấu phẩy động qua Rust server — Python xây dựng frame, server phân tích cả số thực đó, mã hóa lại chúng, và truyền chúng về. Cả hai chiều đều phải trả giá cho tuần tự hóa, syscall, và truyền qua socket. Đây là đường cong đo được (trung vị trên 10 lần chạy):
| Payload (số thực) | Byte mỗi chiều | 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 |
Hai sự thật mang tính cấu trúc nằm trong bảng này.
Thứ nhất, sàn (floor). Một round-trip mang theo gần như không gì cả — 8 byte — tốn 14 µs. Đó là cái giá không thể giảm thêm của việc thực hiện một lệnh gọi qua transport này: hai syscall write, hai syscall read, bộ máy socket của kernel hệ điều hành, các lần đánh thức scheduler. Hãy để ý đường cong phẳng như thế nào ở phía bên trái: từ 1 số thực đến 1,000 số thực, chi phí gần như không nhúc nhích (14.1 → 18.1 µs). Dưới khoảng 8 KB, bạn đang trả tiền cho lệnh gọi, chứ không phải cho byte. Con số này — sàn độ trễ — là hằng số quan trọng nhất trong toàn bộ nghiên cứu, và chúng tôi sẽ xây dựng phép tính điểm hòa vốn dựa trên nó ở bên dưới.
Thứ hai, độ dốc. Sau khoảng ~10,000 số thực, đường cong trở nên bị giới hạn bởi băng thông và gần như tuyến tính. Toàn bộ series 1.2 MB — tổng cộng 2.4 MB được di chuyển, đi và về, bao gồm cả việc phân tích và mã hóa lại đầy đủ 150,000 số thực ở phía Rust — tốn 2,043.4 µs. Điều đó tính ra khoảng ~1.2 GB/s hiệu dụng qua toàn bộ stack ngây thơ (tính ra: 2.4 MB / 2.04 ms) — một Unix domain socket với các frame có tiền tố độ dài và một bộ phân tích số thực từng byte một, không có mẹo zero-copy nào, không có shared memory, không có gì tinh vi cả.
Một mô hình hợp lý cho một lần băng qua, với cả hai hằng số đã được đo:
Giờ hãy đặt con số tiêu đề vào bối cảnh. Toàn bộ sweep tốn 2.010 s in-process. Truyền toàn bộ tập dữ liệu của nó qua ranh giới và về tốn ~2.0 ms — khoảng 0.1% công việc (tính ra: 2.0434 ms / 2.010 s). Nếu bạn băng qua một lần, bằng byte thô, ranh giới là một sai số làm tròn. Đó là nửa đầu tiên của quan niệm dân gian chết trước: nỗi sợ chưa bao giờ nói về thứ gì rẻ đến thế.
Phía Rust của lần băng qua đó thì tẻ nhạt hết mức có thể như code hệ thống thường là — được chuyển thể từ 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());
}
Một ghi chú trung thực về phạm vi trước khi tiếp tục: mọi con số ranh giới trong nghiên cứu này đều là một Unix domain socket trên một host. Engine cũng nói TCP (với TCP_NODELAY), nhưng chúng tôi không đo nó; TCP loopback nằm cao hơn phần nào so với các sàn này, và một bước nhảy mạng thực sự là một chế độ hoàn toàn khác — sàn tính bằng mili giây, không phải micro giây. Vì vậy mọi thứ ở đây là trường hợp gần-tốt-nhất để băng qua một ranh giới theo cách này. Điều đó khiến các khoản thuế được đo tiếp theo càng đáng lên án hơn: đó là những gì bạn phải trả thêm vào trên mức đó, do lựa chọn của chính bạn.
Thuế tuần tự hóa: 1348x cho việc chọn JSON

Đây chính là nơi mà quan niệm dân gian về "chi phí IPC" hóa ra là một sự gán nhãn sai. Chúng tôi đã đo chi phí mã hóa cùng một series giá đóng 150,000 số thực theo ba cách — chính xác là payload mà mọi kiến trúc ở trên truyền đi:
| Mã hóa | Thời gian mã hóa 1.2 MB số thực | so với raw |
|---|---|---|
raw bytes (.tobytes()) |
49.1 µs | 1.0x |
| pickle | 29.8 µs | 0.6x |
JSON (json.dumps(close.tolist())) |
66,243 µs | 1348x |
Đường raw thực chất là một memcpy khoác lên mình vỏ bọc một lệnh gọi hàm:
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 thậm chí còn rẻ hơn một chút so với đường raw của chúng tôi vì astype phải trả giá cho một bản sao chuyển đổi dtype ngay cả khi dtype đã khớp sẵn; cả hai đều thuộc lớp memcpy và cả hai đều là sai số làm tròn. Cả họ nhị phân nói chung sống thấp hơn ba bậc độ lớn so với họ văn bản.)
Và đường văn bản chính là thứ mà gần như mọi triển khai kiểu "hãy biến engine thành một microservice" thực sự sử dụng:
body = json.dumps({"op": "sweep", "close": close.tolist(), "params": params})
Sáu mươi sáu mili giây. Chỉ để mã hóa. json.dumps(close.tolist()) box mỗi số thực vào một object Python, rồi render từng cái thành văn bản thập phân — 150,000 lần cấp phát heap và 150,000 lần chuyển đổi số thực sang chuỗi, trong khi đường raw chỉ làm một lần sao chép khối. Và payload trên đường truyền cũng phình to ra (một float64 tốn 8 byte ở dạng nhị phân và tốn gấp khoảng hai đến ba lần như vậy khi ở dạng văn bản thập phân — chúng tôi thậm chí còn chưa tính thêm chi phí truyền tải đó).
Giờ hãy scale nó theo cách một triển khai thực tế vẫn làm. 66 ms đó là một lần mã hóa, một phía, một lệnh gọi. Một dịch vụ JSON phải trả giá cho cả mã hóa lẫn giải mã, trên cả hai phía của ranh giới, ở mọi lệnh gọi. Chỉ riêng một lệnh gọi batched duy nhất qua JSON đã đốt ~3.3% toàn bộ ngân sách tính toán của sweep chỉ cho việc mã hóa phía client (tính ra: 66 ms / 2.010 s). Đặt JSON vào kiến trúc chatty — một lệnh gọi cho mỗi combo, mẫu hình bên dưới — và chỉ riêng việc mã hóa phía client đã tốn 80 × 66 ms = 5.3 s: hơn gấp hai lần rưỡi toàn bộ công việc hữu ích (tính ra), trước khi một byte nào di chuyển và trước khi server phân tích bất cứ thứ gì.
Đây chính là cái "thuế IPC" thực sự mà hầu hết các đội đã đo được trong production mà không hề hay biết. Nó chưa bao giờ là inter-process communication cả. Nó là tuần tự hóa văn bản của các mảng số — một khoản 1348x tự chuốc lấy trên thành phần rẻ nhất của ranh giới. Thế giới columnar đã học bài học này từ nhiều năm trước, và đó cũng chính là bài học mà nghiên cứu Polars vs pandas của chúng tôi liên tục gặp phải từ phía data pipeline: các định dạng như Arrow tồn tại chính là để dữ liệu mảng có thể băng qua ranh giới tiến trình và ngôn ngữ dưới dạng byte columnar thô, chứ không phải văn bản. Nếu dịch vụ engine của bạn nói JSON cho các mảng giá, không có việc tinh chỉnh socket nào cứu được bạn — giao thức chính là nút thắt cổ chai.
Chatty vs chunky: định luật của Fowler, được đo lường

Định Luật Thứ Nhất của Thiết Kế Đối Tượng Phân Tán của Martin Fowler — "đừng phân tán các đối tượng của bạn" — đi kèm với một hệ quả mà ông nói rõ trong cùng một hơi thở: nếu bạn buộc phải băng qua một ranh giới, giao diện phải hạt thô (coarse-grained), bởi vì một lệnh gọi từ xa tốn kém hơn nhiều bậc độ lớn so với một lệnh gọi cục bộ. Mọi cựu binh hệ thống phân tán đều gật đầu đồng ý. Gần như không ai có một con số cho khối lượng công việc của chính họ. Đây là con số của chúng tôi.
Các kiến trúc chunky và chatty chạy trên cùng một server, cùng một giao thức, cùng một dữ liệu — chỉ có độ hạt của lệnh gọi là khác nhau:
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) — chậm hơn 107 ms (tính ra: 2.383 − 2.276). Để chính xác về việc chênh lệch đó là gì và không phải là gì: đường cong echo đưa ra một dự đoán ngây thơ cho nó — 79 lần truyền thêm của toàn bộ series ở khoảng một nửa mức round-trip full-payload 2,043 µs mỗi lần, khoảng 81 ms — con số này thấp hơn khoảng 25% so với mức 107 ms đo được; phần còn lại là chi phí xây dựng yêu cầu và đóng khung (framing) cho mỗi lệnh gọi ở phía Python, thứ mà dự đoán từ đường cong echo không tính đến. Dù thế nào thì nó cũng tính ra ~1.4 ms cho mỗi lần băng qua thêm (tính ra: 107 / 79); các phản hồi thì không đáng kể — 16 byte cho mỗi combo.
Có hai cách đọc con số 107 ms đó, và cả hai đều quan trọng.
Cách đọc khoan dung: nó chỉ chiếm ~4.5% wall time, không phải một thảm họa. Đúng vậy — và đáng để hiểu tại sao thảm họa mà truyền miệng dự đoán lại không xảy ra ở đây. Mỗi lệnh gọi chatty vẫn mang theo 25,130 µs tính toán thực sự (giá trị của một combo — chi phí in-process đo được cho mỗi combo), vậy nên chi phí ranh giới cho mỗi lệnh gọi ~1.4 ms vẫn thấp hơn công việc mỗi lệnh gọi tới một bậc độ lớn. Các kiến trúc chatty không gây chết người khi mỗi lệnh gọi thực sự nặng. Chúng trở nên chết người khi độ hạt thu nhỏ lại — đó chính là toàn bộ chủ đề của phần điểm hòa vốn.
Cách đọc đáng lên án: khoản thuế này hoàn toàn tự nguyện, và nó scale theo số lệnh gọi × payload. Mẫu hình chatty truyền lại tập dữ liệu ở mỗi lệnh gọi chỉ vì một lý do duy nhất: dịch vụ là stateless, nên mỗi yêu cầu phải mang theo toàn bộ ngữ cảnh. Đó là hình dạng mặc định của một "sweep endpoint" ngây thơ — và về cơ bản là của mọi REST microservice từng được phác thảo trên bảng trắng. Một server stateful — nạp series một lần, rồi gửi các frame tham số 48 byte — sẽ đưa mỗi lệnh gọi theo từng combo về gần đầu payload-nhỏ của đường cong echo: khoảng 16 µs cho mỗi lệnh gọi, khoảng 1.3 ms cho cả 80 lệnh gọi (suy ra từ sàn của đường cong echo; mang tính giải tích, không được đo riêng). Khoản phạt chatty sẽ không thu nhỏ lại; nó sẽ biến mất. Bài học rất chính xác: vấn đề không phải là thực hiện nhiều lệnh gọi — mà là truyền lại trạng thái vì giao thức giả vờ như mỗi lệnh gọi đều là lệnh gọi đầu tiên.
Nạp trước dữ liệu. Truyền tham số. Băng qua ranh giới có chủ đích, chứ không phải mang theo cả thế giới trong vali của bạn mỗi lần.
Chi phí spawn: thuê engine theo từng lệnh gọi

Mẫu hình triển khai thứ ba là mẫu hình cũ nhất: không có server nào cả. Spawn binary engine, truyền một yêu cầu qua stdin bằng pipe, đọc phản hồi từ stdout, để nó chết đi. Đó là bản năng của mọi người viết shell script, mọi tích hợp kiểu "cứ gọi CLI từ Python", mọi framework hyperparameter được cấu hình để khởi chạy một binary cho mỗi trial.
Đo được: 2.300 s (1.14x) — cao hơn khoảng 24 ms so với batch của server thường trực (tính ra: 2.300 − 2.276). 24 mili giây đó mua cho bạn một fork/exec, dynamic loader, thiết lập pipe, và tháo dỡ tiến trình. Và hãy lưu ý rằng con số đo được ở đây gần với sàn của mẫu hình này: một binary native nhỏ không phụ thuộc gì, đã ấm sẵn trong page cache. Spawn bất cứ thứ gì có runtime — một JVM, một Python interpreter với các import — tốn kém hơn nhiều; chúng tôi không đo những trường hợp đó ở đây, nhưng hướng đi thì không có gì phải nghi ngờ.
Cấu trúc của khoản thuế này mới là điều quan trọng: nó cố định cho mỗi lệnh gọi, bất kể lệnh gọi mang theo bao nhiêu công việc. Khấu hao trên toàn bộ sweep 80 combo, 24 ms chỉ chiếm khoảng 1% — nhiễu. Spawn lại cho mỗi combo và cùng một hằng số đó trở thành 80 × ~24 ms ≈ 1.9 s — về cơ bản là đốt cháy toàn bộ công việc hữu ích vào việc tạo tiến trình (tính ra; mang tính giải tích). Spawn lại cho mỗi nến thì phép tính đó không đáng để viết ra.
Chi phí cố định, độ hạt mịn: chọn một trong hai. Mẫu hình phải trả giá cho một lần spawn chỉ hợp lý khi việc spawn hiếm khi xảy ra và payload đứng sau nó khổng lồ — chính xác như phép đo một-spawn-cho-mỗi-sweep của chúng tôi, và chính xác trái ngược với cách các kiến trúc subprocess-cho-mỗi-symbol cuối cùng bị sử dụng khi số lượng symbol tăng lên.
Phép tính điểm hòa vốn: sàn chính là một ngưỡng vượt qua

Mọi thứ được đo cho đến nay đều nén lại thành một quy tắc thiết kế duy nhất, và quy tắc đó là số học, không phải ý kiến.
Mỗi lần băng qua ranh giới tốn ít nhất bằng sàn độ trễ — 14 µs ở đây, round-trip echo payload nhỏ, và gần với mức tốt nhất mà transport này có thể mang lại. Sàn đó chính là một ngưỡng vượt qua (hurdle rate): một lệnh gọi qua ranh giới chỉ đáng thực hiện nếu lượng tính toán mà nó mang theo vượt qua ngưỡng đó với một bội số thoải mái. Hãy định nghĩa tỷ lệ độ hạt
và tỷ trọng của ranh giới trong wall time của bạn xấp xỉ — cộng thêm thời gian truyền payload nếu lệnh gọi cũng mang theo dữ liệu.
Giờ hãy chạy các con số của sweep qua công thức đó. Chi phí in-process đo được của một combo là 25,130 µs. Ở độ hạt mỗi combo:
Các lệnh gọi theo từng combo nằm ~1,795x trên sàn — ranh giới chiếm hẳn dưới một phần mười phần trăm cho mỗi lệnh gọi. Đây là lý do tại sao ngay cả kiến trúc chatty cũng chỉ mất 107 ms: ở độ hạt của khối lượng công việc này, mọi mẫu hình băng qua ranh giới mà không truyền lại dữ liệu hoặc không nói văn bản đều được khấu hao an toàn. Các lệnh gọi cấp combo, cấp fold, cấp sweep đều nằm sâu trong vùng rẻ.
Giờ hãy lật sang thái cực đối lập. Đây là một ngoại suy minh họa qua các dạng khối lượng công việc khác — không phải một biến thể của sweep của chúng tôi, mà là một hình dạng khối lượng công việc thực sự tồn tại ngoài đời: engine được tham vấn theo từng nến. Một dịch vụ engine per-tick kiểu live; một luồng tín hiệu gRPC-mỗi-nến; một "strategy server" được polling một lần cho mỗi nến trong số 150,000 nến. Lượng tính toán hữu ích cho mỗi nến trong kernel này là 25,130 µs / 150,000 ≈ 0.17 µs (tính ra) — mỗi lệnh gọi sẽ chỉ mang theo khoảng 1/84 chi phí ranh giới của chính nó dưới dạng công việc hữu ích (tính ra: sàn 14.05 µs so với 0.168 µs tính toán). Tổng số còn tệ hơn tỷ lệ đó nghe có vẻ:
— nhiều hơn toàn bộ công việc in-process 2.010 s, tiêu tốn trước khi engine từ xa tính ra được dù chỉ một con số, và nó vẫn sẽ là 2.1 s ngay cả khi engine ở phía bên kia nhanh vô hạn (tính ra: 150,000 × 14 µs). Không có lợi thế tính toán nào sống sót qua một độ hạt mịn đến thế. Và hãy nhớ rằng sàn này là một Unix socket trên một host; hãy thực hiện lệnh gọi theo từng nến đó tới một dịch vụ qua mạng và sàn đó sẽ tăng lên hai đến ba bậc độ lớn, trên 150,000 lệnh gọi.

Thêm một phép hiệu chỉnh trung thực nữa, vì 14 µs cũng không phải là một định luật vật lý — nó là cái giá của transport của chúng tôi: một client Python, một socket của kernel hệ điều hành, các syscall theo cả hai chiều. Một transport cùng-máy được xây dựng chuyên biệt có thể thấp hơn nhiều. ZigBolt — bus nhắn tin Zig mã nguồn mở của chúng tôi cho các khối lượng công việc HFT, được benchmark native trên chính chiếc máy này — thực hiện một round-trip ring bộ nhớ chia sẻ trong khoảng 39 ns trung bình (p50 một chiều là 10/20/30 ns ở các message 64/256/1024 byte). Con số đó thấp hơn khoảng 360x so với sàn socket của chúng tôi (tính ra: 14.05 µs / 39 ns). Phép so sánh này cố ý mang tính khập khiễng (apples-to-oranges), và chúng tôi đánh dấu rõ điều đó: 14 µs của chúng tôi là một round-trip socket từ client Python, còn 39 ns của ZigBolt là Zig native qua bộ nhớ chia sẻ, vậy nên khoảng cách đó gộp chung cả transport lẫn runtime. Đừng đọc nó như một cuộc đua giữa hai bên, mà hãy đọc nó như phạm vi mà sàn cùng-máy có thể chiếm giữ: khoảng ba bậc độ lớn, tùy thuộc vào cách triển khai. Đây chính là bài học cũ của Lightweight RPC (Bershad và cộng sự, 1990) khoác lên bộ áo hiện đại — các lần băng qua cùng-máy bị chi phối bởi bộ máy giao thức, và chúng sụp đổ khi transport được xây dựng riêng cho trường hợp cùng-máy. Phép tính điểm hòa vốn ở trên không thay đổi hình dạng; ngưỡng chỉ đơn giản là di chuyển. Ở một sàn 39 ns, ngay cả độ hạt theo từng nến cũng sẽ vượt qua được ngưỡng đó (150,000 × 39 ns ≈ 5.9 ms, tính ra) — đó chính xác là cách mà các hệ thống HFT có thể đủ khả năng chi trả cho những ranh giới mà một dịch vụ REST thì không thể.
Đây là toàn bộ câu chuyện điểm hòa vốn gói gọn trong một câu: ranh giới không quan tâm engine của bạn nhanh đến đâu; nó tính phí theo từng lần băng qua, vậy nên các biến số bạn kiểm soát được là mỗi lần băng qua mang theo bao nhiêu công việc — và lần băng qua đó được cấu thành từ gì. Batch theo từng sweep và vượt quá một trăm nghìn. Batch theo từng combo, — vẫn ổn. Gọi theo từng nến qua một socket, — kiến trúc đã chết trước cả lần tối ưu hóa đầu tiên, và không có bản viết lại nào của engine, dù bằng Rust hay bất cứ thứ gì khác, có thể hồi sinh nó.
1.13x đó thực sự nằm ở đâu — và phán quyết

Đã đến lúc mổ xẻ khoảng cách tiêu đề một cách trung thực, vì nó mang theo phát hiện phản trực giác nhất của nghiên cứu này.
Kiến trúc Rust batched tụt sau in-process numba 266 ms (tính ra: 2.276 − 2.010). Các thành phần ranh giới đo được: một round-trip full-payload ở ~2.0 ms, tuần tự hóa thô ở 49 µs, header của frame ở một nhúm byte — gọi toàn bộ hóa đơn ranh giới là ~2 ms. Vậy nên hơn 99% khoảng cách đó hoàn toàn không phải ranh giới. Đó là tính toán: bóc tách IPC ra, Rust server tốn ~2.274 s để làm sweep mà numba làm trong 2.010 s — kernel Rust ngây thơ chậm hơn khoảng 13% ở tính toán thô (tính ra).
Đoạn này xứng đáng được viết thẳng thắn không né tránh, vì "viết lại bằng Rust thì nó sẽ nhanh hơn" cũng là một quan niệm dân gian chẳng kém gì "IPC sẽ giết chết bạn." Cả hai kernel đều đáy xuống LLVM — numba hạ bytecode Python qua nó, rustc hạ MIR qua nó — và cả hai nhiều khả năng đều chạy dưới dạng các vòng lặp scalar: tổng bên trong của WMA là một phép reduction dấu phẩy động, thứ mà LLVM sẽ không tự động vector hóa nếu thiếu giấy phép tái kết hợp fast-math mà @njit của numba mặc định không cấp và bản port của chúng tôi cũng không yêu cầu. Vậy nên ~13% đó là một khoảng cách codegen đo được giữa hai vòng lặp scalar được biên dịch bằng LLVM — và thay vì khẳng định một nguyên nhân, chúng tôi đã kiểm tra nghi phạm hiển nhiên nhất. Nghi phạm tự nhiên là cách đánh chỉ mục an toàn của Rust: vòng lặp WMA nóng kiểm tra biên ở mọi lần truy cập mảng, trong khi @njit của numba biên dịch với việc kiểm tra biên đã tắt. Vậy nên chúng tôi đã xây dựng một biến thể đã được xác minh tương đương của cùng kernel đó dựa trên get_unchecked — không kiểm tra biên ở bất kỳ đâu trong hot path — và đo thời gian nó như kiến trúc thứ năm. Nó đã không thu hẹp khoảng cách đó: 2.337 s (1.16x), chậm hơn một chút so với bản dựng có kiểm tra biên ở mức 2.276 s. Giả thuyết đã được kiểm tra, giả thuyết bị bác bỏ. Trạng thái hiểu biết trung thực hiện tại: ~13% đó là có thật và có thể tái tạo (trung vị trên 10 lần chạy, độ trải trong khoảng ~2%), và hiện tại chưa xác định được nguyên nhân — một khác biệt nào đó trong hành vi cấp phát bộ nhớ, cấu trúc vòng lặp, hoặc lập lịch chỉ thị mà chỉ có profiling ở cấp độ assembly mới giải quyết được. Bài học vẫn nguyên vẹn: Rust ngây thơ không tự động nhanh hơn numba tốt, và một ranh giới ngôn ngữ được mua dựa trên giả định về một chiến thắng tính toán miễn phí có thể đi kèm với một tổn thất tính toán. Một kernel Rust đã được tinh chỉnh — buffer cấp phát trước, SIMD tường minh, luồng trên các combo — vẫn có thể lật ngược dấu này. Nhưng đó là một câu hỏi về tính toán, cần được giải quyết bằng profiling và công việc trên kernel, còn câu hỏi của nghiên cứu này là ranh giới. Câu trả lời của ranh giới: băng qua một lần, bằng byte, nó tốn ~0.1%.
Vậy hãy ráp lại thành phán quyết đầy đủ, mỗi mệnh đề của nó đều đã được đo ở trên.
Một dịch vụ engine đa ngôn ngữ thắng khi tất cả những điều sau đều đúng:
- Lợi thế tính toán là có thật — được đo trên kernel của bạn, không phải được giả định từ danh tiếng của ngôn ngữ. (Của chúng tôi là −13% cho đến khi được chứng minh ngược lại — và lời giải thích "hiển nhiên" đầu tiên cho khoản thâm hụt đó đã chết trong quá trình kiểm tra.)
- Bạn băng qua theo hạt thô — một lệnh gọi cho mỗi sweep hoặc mỗi fold, hàng nghìn lần cao hơn sàn 14 µs, đúng như cách kiến trúc batch với tổng 1.13x (~0.1% ranh giới) đã chứng minh.
- Bạn nói nhị phân — mảng thô có tiền tố độ dài, Arrow, bất cứ thứ gì thuộc lớp memcpy ở 49 µs cho mỗi 1.2 MB; không bao giờ dùng văn bản ở 66,243 µs.
- Dữ liệu được nạp trước — một server stateful nhận các lệnh gọi chỉ-có-tham-số ở đầu ~16 µs của đường cong echo thay vì truyền lại hàng megabyte.
Nó thua khi được triển khai theo cách mà các dịch vụ engine thường vẫn vậy:
- Một microservice JSON/REST — phải trả khoản thuế tuần tự hóa 1348x ở mỗi lệnh gọi, cả hai chiều; ở độ hạt chatty đó là 5.3 s mã hóa trên một công việc 2 s.
- RPC cho mỗi đơn vị công việc — theo từng combo, nó tốn 107 ms ở đây và chỉ sống sót được vì mỗi lệnh gọi mang theo 25,130 µs tính toán; theo từng nến, đó là ~2.1 s IPC thuần túy trước khi bất kỳ công việc nào diễn ra, trên một công việc 2.0 s.
- Một lần spawn cho mỗi lệnh gọi — ~24 ms chi phí cố định mỗi lần, vô hại nếu chỉ một lần cho mỗi sweep, gần hai giây nếu phải trả cho mỗi combo.
Nói cách khác: các kiến trúc thất bại không hề kỳ lạ. Engine JSON REST, subprocess-cho-mỗi-symbol, gRPC-mỗi-tick — đó là một cuộc thống kê công bằng về cách mà "hãy tách engine backtest ra riêng" thực sự được xây dựng. Quan niệm dân gian có cơ sở thực nghiệm vững chắc như một mô tả về thực hành phổ biến và sai về mặt thực nghiệm như một định luật tự nhiên. Ranh giới chưa bao giờ là vấn đề cả. Những cách băng qua nó theo mặc định mới là vấn đề.
Một lập luận ủng hộ ranh giới xứng đáng có riêng một câu, vì đó chính là lý do chúng tôi thực hiện nghiên cứu này ngay từ đầu. Một kernel đã biên dịch duy nhất đứng sau một ranh giới được thiết kế tốt có thể phục vụ cả sweep nghiên cứu lẫn vòng lặp giao dịch sống — cùng một binary, cùng một phép tính, giống nhau từng bit. Nghiên cứu về tính tương đồng backtest-live của chúng tôi đã liệt kê cách các engine nghiên cứu và production trôi dạt xa nhau khi chúng là hai codebase riêng biệt; một dịch vụ engine là liều thuốc chữa mang tính cấu trúc mạnh nhất cho sự trôi dạt đó, và nghiên cứu này định giá liều thuốc đó một cách trung thực: làm đúng cách, tốn khoảng 0.1% wall time và một cổng tương đương để chứng minh không có gì thay đổi trong quá trình chuyển đổi. Sự đánh đổi đó — một ranh giới tiến trình chuyên biệt để đổi lấy tính tương đồng một-kernel — theo các con số này, là một món hời. Làm sai cách, cùng một ý tưởng đó sẽ đưa một khoản thuế tuần tự hóa 1348x vào production với PnL của bạn cưỡi lên trên nó.
Điểm rút ra
- Ranh giới gần như miễn phí; quan niệm dân gian không vượt qua được phép đo. Round-trip toàn bộ series giá đóng 1.2 MB qua một Unix socket — bao gồm cả việc phân tích và mã hóa lại đầy đủ — tốn 2,043.4 µs, khoảng 0.1% công việc 2.010 s (tính ra). Kiến trúc Rust-qua-socket batched dừng lại ở tổng 1.13x, và ~99% của khoảng cách đó thậm chí cũng không phải IPC.
- "Viết lại bằng Rust" là một tuyên bố về tính toán — hãy xác minh nó trước khi mua ranh giới. Bản port Rust theo từng dòng của chúng tôi tính toán chậm hơn ~13% so với kernel numba (tính ra: 2.274 s so với 2.010 s) — một khoảng cách codegen có thể tái tạo giữa hai vòng lặp scalar biên dịch bằng LLVM mà vẫn chưa xác định được nguyên nhân: chúng tôi đã kiểm tra nghi phạm hiển nhiên và bác bỏ nó, vì một bản dựng
get_uncheckedđã được xác minh tương đương không có kiểm tra biên lại không hề nhanh hơn (2.337 s so với 2.276 s). Rust ngây thơ không tự động nhanh hơn; một kernel đã được tinh chỉnh rất có thể sẽ nhanh hơn — hãy đo, rồi mới quyết định. - Khoản thuế thực sự là văn bản. Mã hóa 150,000 số thực thành JSON tốn 66,243 µs so với 49.1 µs cho raw — 1348x, phải trả cho mỗi chiều, mỗi lệnh gọi, ở cả hai phía. Một triển khai JSON chatty đốt 5.3 s mã hóa trên một công việc 2 s (tính ra). Hãy nói nhị phân qua các ranh giới: frame thô, Arrow — không bao giờ dùng
json.dumpstrên một mảng giá. - Chatty vs chunky là điều có thể đo lường được, và statelessness chính là thủ phạm. Các lệnh gọi theo từng combo truyền lại dữ liệu: 1.19x so với 1.13x của batch (+107 ms, tính ra; dự đoán một chiều ~81 ms từ đường cong echo thấp hơn khoảng 25% so với con số đó, phần còn lại là framing cho mỗi lệnh gọi). Một server stateful được nạp trước sẽ thực hiện cùng 80 lệnh gọi đó ở ~16 µs mỗi lần — tổng cộng khoảng 1.3 ms (suy ra từ sàn của đường cong echo). Hãy truyền tham số, không phải toàn bộ tập dữ liệu.
- Hãy tôn trọng sàn — và biết rằng sàn là một lựa chọn. Lần băng qua Python-qua-Unix-socket của chúng tôi có sàn ở 14 µs; độ hạt theo từng combo vượt qua nó ~1,795x (25,130 µs tính toán cho mỗi lệnh gọi) — an toàn. Một mẫu hình theo từng nến (một thái cực minh họa qua các khối lượng công việc khác nhau: một engine per-tick kiểu live, không phải sweep này) sẽ phải trả 150,000 × 14 µs ≈ 2.1 s IPC thuần túy trên một công việc 2.0 s (tính ra) — chết ngay từ khi ra đời ngay cả với một engine nhanh vô hạn. Spawn cho mỗi lệnh gọi cộng thêm một khoản cố định ~24 ms (tính ra). Và một transport bộ nhớ chia sẻ được xây dựng chuyên biệt như ZigBolt round-trip trong ~39 ns native trên chính chiếc máy này — thấp hơn ~360x so với sàn socket của chúng tôi (tính ra; Zig native so với một client Python, vậy nên hãy đọc nó như phạm vi mà sàn có thể chiếm giữ, không phải một cuộc đua).
- Băng qua một lần, bằng byte, với dữ liệu đã sẵn có ở đó — và ranh giới sẽ mua cho bạn tính tương đồng với giá ~0.1%. Một kernel duy nhất phục vụ cả nghiên cứu lẫn live, được gác cổng bởi một kiểm tra tương đương (PnL −5165.58, 57,029 giao dịch, giống hệt nhau qua các ngôn ngữ và qua cả hai bản dựng Rust), chính là lý lẽ trung thực cho một dịch vụ engine. Những trường hợp không trung thực — JSON, chatty, spawn-cho-mỗi-lệnh-gọi — mới chính là những thứ đã tạo nên tiếng xấu cho IPC.
Toàn bộ thí nghiệm — engine Rust, giao thức truyền dữ liệu, các harness echo và tuần tự hóa, cổng tương đương, và mọi con số trong bài viết này đều có thể tái tạo lại từ một script tất định duy nhất — nằm trong bài báo đồng hành tại ipc-tax.marketmaker.cc, cùng với mã nguồn và dữ liệu tại github.com/suenot/ipc-tax.
Chiếc socket chưa bao giờ là vấn đề cả. Hai mili giây cho toàn bộ tập dữ liệu, cả đi lẫn về — truyền miệng đã sai đến ba bậc độ lớn, và sai theo cả hai hướng cùng lúc: quá bi quan về byte, quá dễ dãi với văn bản. Hãy băng qua ranh giới như thể nó tốn kém gì đó, và nó sẽ không tốn kém gì cả.
Tác Giả
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.