← 返回文章列表
June 30, 2026
5 分鐘閱讀

IPC 稅:把回測引擎放到 socket 背後會損失 13%——而這幾乎與 socket 無關

IPC 稅:把回測引擎放到 socket 背後會損失 13%——而這幾乎與 socket 無關
#演算法交易
#回測
#效能
#ipc
#rust
#架構
Part 10 of 10 · Collection
High-Performance Backtest Engines

"回測無幻覺"系列文章。

📄 本文成長為了一篇研究論文。 一個路徑依賴的回測核心被逐行從 numba 移植到 Rust,並以四種方式跨程序/語言邊界呼叫,配有等價性校驗確認逐組合 PnL 完全一致——此外還包含對純 IPC 延遲曲線、序列化稅和 spawn 成本的獨立測量。可線上閱讀論文(互動版 + PDF):ipc-tax.marketmaker.cc,程式碼與資料見 github.com/suenot/ipc-tax

每一個變得夠快的回測引擎,最終都會引發同一場爭論。我們的引擎也如期而至。速度階梯剛剛把一個 80 組合的參數掃描從 69.9 秒的 pandas 壓到了大約 2 秒的單執行緒 numba,接下來自然而然的衝動就是:為什麼止步於 Python JIT?把核心用 Rust 重寫。把它做成一個真正的引擎服務——一個編譯好的二進位制檔案放在 socket 後面,可以被每一個研究指令碼、每一種語言、以及實盤交易者呼叫。一個核心,一份真相,沒有重複的邏輯。

然後反駁意見也如期而至:一旦你離開程序,IPC 就會把你吃掉。 資料必須被序列化、跨邊界傳輸、再反序列化;每一次呼叫都要付出系統呼叫和上下文切換的代價;你那漂亮的 Rust 核心將把一生都花在等待一根管道上。留在程序內。這是人盡皆知的道理。

本文測量的正是這個人盡皆知的東西,而測量結果比爭論的任何一方都更有意思。這條民間信念——"更快的跨語言引擎會輸給程序內的 numba,因為 IPC 會要了你的命"——結果證明總體上是錯的,只在特定條件下才是對的。以原始位元組跨越一次邊界,在一個兩秒的任務上成本約為 2 毫秒:一個舍入誤差。稅不在邊界本身。它在於你如何跨越它——而引擎服務在實踐中通常被部署的三種方式(JSON API、每單位工作呼叫一次、每次呼叫都 spawn 一個程序)分別都可測量地印證了民間傳說所預言的那場災難的一部分。

以下是這場實驗的全貌。後文的一切都是對每一行的解剖。

架構 每次掃描跨越邊界的內容 牆鍾時間 相對程序內
程序內 numba 無——直接呼叫 2.010 s 1.00x
Rust 伺服器,批次(Unix socket) 一次往返:整條序列 + 全部 80 組參數 2.276 s 1.13x
Rust 伺服器,批次,get_unchecked 核心 同樣的單次往返——一個無邊界檢查的核心變體(見結論一節) 2.337 s 1.16x
Rust 伺服器,健談(Unix socket) 80 次往返:每個組合都重新傳輸一次序列 2.383 s 1.19x
Rust spawn(stdin/stdout) 程序 spawn + 一次管道請求 2.300 s 1.14x

Apple M2 Max,Python 3.14.6,numpy 2.4.3,numba 0.64.0,rustc 1.94.0(release 構建,零外部 crate)。150,000 根 K 線 × 80 個組合,0.09% 往返手續費,seed 42;收盤價序列在線上是 1,200,000 位元組(1.2 MB)。每種架構取 10 次執行的中位數;最小–最大差幅保持在約 2% 以內。全部五種架構運行同一個 HMA/HMA3 止損反手掃描,且一個等價性校驗確認兩個 Rust 核心變體的逐組合(PnL、交易筆數)結果與 numba 完全一致——指紋 PnL 為 −5165.58,跨越 57,029 筆交易,與速度階梯研究中同一 seed 下的 numba 核心逐位元組相同。我們比較的是邊界,不是實現。

仔細讀一讀"批次"這一行,因為它承載了整篇文章的論點。Rust-over-socket 架構比程序內 numba 慢 1.13 倍——在整個掃描上落後 266 毫秒(推算:2.276 − 2.010)。民間說法認為這些毫秒都是 IPC。它們不是。這個差距中約 2 毫秒是邊界——整條 1.2 MB 的收盤價序列傳進去,結果傳回來,直接測量得到。其餘約 264 毫秒,是因為我們那個樸素的 Rust 核心計算這次掃描的速度就是比 numba 核心慢約 13%(推算:2.276 秒減去約 2 毫秒的邊界開銷 ≈ 2.274 秒的 Rust 計算時間,對比 numba 的 2.010 秒)。這不是 Rust 語言輸給了 Python 語言;是一個標量的、LLVM 編譯的迴圈在程式碼生成競賽中輸給了另一個——而我們甚至沒能把這次失利歸咎於那個顯而易見的嫌疑物件:同一核心的無邊界檢查 get_unchecked 構建版本跑出來並沒有更快(2.337 秒;結論一節會拆解這一點)。socket 與這一切幾乎毫無關係。

把這句話的兩半都記住。邊界在正確跨越的情況下幾乎是免費的——而"用 Rust 重寫"買到的是一個部署邊界,而不是自動的計算勝利。這兩個事實都與流行的直覺相悖,而它們都寫在上面那張表裡。

一個核心,兩種語言,四種邊界

工作負載刻意選用了速度階梯一文中已經釘死的那一個,這樣兩項研究就能相互錨定。核心是一個 HMA/HMA3 交叉——兩條 Hull 式移動平均線上的止損反手系統,每個參數組合要做七次加權移動平均計算,外加一個帶狀態的逐 bar 事件迴圈,持有倉位、每次交叉都記錄 PnL 並扣除 0.09% 的往返手續費,然後反手。資料是 150,000 根帶種子的合成幾何布朗運動 K 線(seed=42);網格是分佈在 [6,200][6, 200] 區間內的 80 個 HMA 長度。程序內參照是速度階梯中單執行緒 numba 那一級,為本研究重新測量:那邊是 1.98 秒,這邊是 2.010 秒——同一個核心,同一臺機器,令人安心地平淡無奇。

這個跨語言引擎是那個 numba 核心逐行移植到 Rust 的版本——相同的迴圈、相同的 NaN 處理、相同的手續費運算——以 release 模式編譯,不依賴任何外部 crate,因此整個實驗保持無依賴且可復現。它使用一個刻意做到最簡的二進位制協議:每個方向一幀、帶長度字首,全部小端序。

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

echo 操作碼是這項研究的手術刀:一次大小可控、什麼都不計算的往返,因此純粹的邊界成本可以被單獨測量出來——序列化、系統呼叫、socket 傳輸、反序列化,僅此而已。

五種被測量的架構——四種邊界模式外加一個核心變體:

  • in_process ——直接呼叫 numba 核心。沒有邊界。參照組。
  • rust_batch_unix ——一個跑在 Unix domain socket 上的常駐 Rust 伺服器。一次往返傳輸整條收盤價序列加全部 80 組參數;Rust 計算每一個組合;一條回覆傳回來。這是"大塊"呼叫。
  • rust_batch_unchecked ——同樣的批次邊界,但核心用 get_unchecked 做索引(熱路徑上沒有邊界檢查)。它的存在是為了檢驗一個關於計算差距的具體假設;結論一節會把它用掉。
  • rust_chatty_unix ——同一個伺服器,但每個組合一次往返,1.2 MB 的序列每次都重新傳輸。這是樸素的"每單位工作一次 RPC"架構。
  • rust_spawn_stdin ——每次掃描都 spawn 這個二進位制檔案,通過 stdin 管道傳輸請求。這是"shell 呼叫 CLI 引擎"的模式;要支付程序建立的代價。

還有等價性校驗,沒有它這一切都毫無意義:計時結束後,每個 Rust 變體的逐組合(PnL、交易筆數)向量都會與 numba 的結果比對——交易筆數精確相等,PnL 精確到絕對值 10610^{-6}。提交的執行記錄對安全索引版本和 get_unchecked 版本都報告 all_ok: true。第一個組合的指紋——PnL 為 −5165.58 個百分點,跨越 57,029 筆交易——與速度階梯研究中的 numba 核心逐位數字相符,這就把兩篇論文釘在了同一個 seed 下的同一個核心上。跨語言移植正是無聲偏差最喜歡潛伏的地方(手續費在百分比轉換之前而不是之後應用、NaN 比較走了不同的分支、窗口裡的一個差一錯誤——正是我們前視偏差分類一文展示過的那種能從純噪聲裡憑空製造出夏普比率 15 的 bug)。兩個計算不同東西的引擎做基準測試,那不是基準測試;那是兩個互不相關的程式在賽跑。

在等價性確立之後,上表中的每一處差異都只是邊界和計算——沒有別的了。

跨越到底要付出什麼代價:echo 曲線

跨越邊界的實測成本:一條在極小負載下平坦於十四微秒的延遲曲線,只有在超過一萬個浮點數之後才開始向上彎曲,在整條 1.2 兆位元組的序列上達到兩毫秒

從這把手術刀開始。echo 操作讓一份包含 nn 個浮點數的負載在 Rust 伺服器上走一趟往返——Python 構建幀,伺服器解析全部 nn 個浮點數,重新編碼它們,再傳回來。兩個方向都要支付序列化、系統呼叫和 socket 傳輸的代價。以下是實測曲線(10 次執行的中位數):

負載(浮點數個數) 每個方向的位元組數 往返耗時
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

這張表裡藏著兩個結構性事實。

首先是地板值。 一次幾乎不攜帶任何東西——8 位元組——的往返成本是 14 µs。這是在這條傳輸通道上發起一次呼叫本身不可再壓縮的代價:兩次 write 系統呼叫、兩次 read 系統呼叫、核心 socket 機制、排程器喚醒。注意曲線左端有多平:從 1 個浮點數到 1,000 個浮點數,成本幾乎不動(14.1 → 18.1 µs)。在大約 8 KB 以下,你付的錢是為了呼叫本身,而不是為了位元組數。這個數字——延遲地板——是整項研究中最重要的一個常數,下文我們會在它之上建立盈虧平衡的算術。

其次是斜率。 超過約 10,000 個浮點數之後,曲線進入頻寬受限區間,大致呈線性。整條 1.2 MB 的序列——去和回總共傳輸 2.4 MB,包括在 Rust 一側對 150,000 個浮點數的完整解析和重新編碼——成本是 2,043.4 µs。折算下來,整套樸素技術棧的有效吞吐約為 1.2 GB/s(推算:2.4 MB / 2.04 ms)——這是一個帶長度字首幀、逐位元組解析浮點數的 Unix domain socket,沒有零複製技巧,沒有共享記憶體,沒有任何取巧的手段。

一次跨越的合理模型,兩個常數都已實測:

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}}

現在把這個頭條數字放進語境裡看。整個掃描在程序內耗時 2.010 秒。把它的全部資料集跨邊界傳出去再傳回來的成本約 2.0 毫秒——約佔任務耗時的 0.1%(推算:2.0434 ms / 2.010 s)。如果你以原始位元組只跨越一次,邊界就是一個舍入誤差。這正是民間信念中最先站不住腳的那一半:那種恐懼從來就不是針對這麼便宜的東西。

這次跨越在 Rust 一側的實現,樸實無華到了系統程式碼該有的極致——改編自 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());
}

在繼續之前誠實地說明一下適用範圍:本研究中所有邊界數字都基於單主機上的 Unix domain socket。這個引擎也支援 TCP(帶 TCP_NODELAY),但我們沒有測量它;迴環 TCP 會比這些地板值略高一些,而真正的網路跳轉則完全是另一個量級——地板是毫秒而不是微秒。因此這裡的一切都是以這種方式跨越邊界的近乎最佳情形。這也讓接下來測量的那些稅更加令人震驚:它們是你在此基礎之上、出於選擇而額外支付的代價。

序列化稅:選擇 JSON 要付出 1348 倍的代價

同一個 150,000 個浮點數的陣列用兩種編碼方式並排對比:以微秒計的原始位元組 memcpy,對上足足高出三個數量級的 JSON 文本編碼

這就是關於"IPC 開銷"的民間信念被證明是貼錯標籤的地方。我們用三種方式測量了對同一條 150,000 個浮點數的收盤價序列進行編碼的成本——正是上面每種架構所傳輸的那份負載:

編碼方式 編碼 1.2 MB 浮點數所需時間 相對原始位元組
raw bytes(.tobytes() 49.1 µs 1.0x
pickle 29.8 µs 0.6x
JSON(json.dumps(close.tolist()) 66,243 µs 1348x

原始路徑本質上是一次套著函數呼叫外殼的 memcpy:

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 甚至比我們的原始路徑還略微便宜一點,因為 astype 即便在 dtype 已經匹配的情況下也要付出一次型別轉換複製的代價;兩者都屬於 memcpy 量級,都是舍入誤差。整個二進位制家族整體上比文本家族低三個數量級。)

而文本路徑正是幾乎每一個"把引擎做成微服務"的部署方案實際傳輸的東西:

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

六十六毫秒。只是為了編碼json.dumps(close.tolist()) 把每一個浮點數都裝箱成一個 Python 物件,然後把每一個都渲染成十進位制文本——150,000 次堆分配和 150,000 次浮點數轉字串,而原始路徑只做了一次整塊複製。而且線上負載本身也膨脹了(一個 float64 在二進位制裡是 8 位元組,渲染成十進位制文本大約要多花兩到三倍——我們甚至都還沒有把多出來的傳輸開銷算進去)。

現在按真實部署的方式把它放大。那 66 毫秒只是一次編碼、一側、一次呼叫。一個 JSON 服務要在邊界的兩側、在每一次呼叫上都付出編碼解碼的代價。一次批次呼叫如果用 JSON,光是客戶端編碼就要燒掉整個掃描計算預算的約 3.3%(推算:66 ms / 2.010 s)。把 JSON 放到下面這種健談架構裡——每個組合一次呼叫——光是客戶端編碼就要花 80 × 66 ms = 5.3 秒:超過整個有用任務耗時的兩倍半(推算),而這還是在一個位元組都沒傳輸、伺服器還沒開始解析任何東西之前。

這才是大多數團隊在生產環境裡實際測量到、卻不自知的那個"IPC 稅"。它從來都不是程序間通訊本身。它是數值陣列的文本序列化——在邊界最便宜的那個環節上自己給自己加了 1348 倍的代價。列式儲存的世界多年前就學到了這一課,我們的Polars 對比 pandas 研究也從資料管道那一側反覆撞上同一堵牆:Arrow 這類格式存在的意義正是讓陣列資料能夠以原始列式位元組而不是文本的形式跨越程序和語言邊界。如果你的引擎服務用 JSON 傳輸價格陣列,再怎麼調 socket 也救不了你——協議本身就是瓶頸。

健談 vs 大塊:Fowler 定律,實測版

一個大塊架構一次性跨邊界傳輸一份帶幀的大負載,旁邊是一個健談架構,做八十次小往返,每一次都拖著整個資料集

Martin Fowler 的分散式物件設計第一定律——"不要分佈你的物件"——附帶一條他在同一口氣裡講完的推論:如果你必須跨越一個邊界,介面就必須是粗粒度的,因為一次遠端呼叫的成本比本地呼叫高出幾個數量級。每一位分散式系統的老兵聽到這裡都會點頭。但幾乎沒有人能為自己的工作負載給出一個具體數字。這是我們的數字。

大塊架構和健談架構執行的是同一個伺服器、同一套協議、同一份資料——唯一的區別是呼叫粒度:

srv.call(0, close, params)

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

大塊:2.276 秒(1.13 倍)。健談:2.383 秒(1.19 倍)——慢 107 毫秒(推算:2.383 − 2.276)。精確說清這個差值是什麼、不是什麼:echo 曲線給出了一個樸素的預測值——79 次額外傳輸整條序列,每次大約是 2,043 µs 全負載往返的一半,約 81 毫秒——這比實測的 107 毫秒低了約 25%;差的部分是 Python 一側每次呼叫的請求構建和成幀開銷,echo 曲線的預測沒有把它算進去。不管怎樣,折算下來每次額外跨越約 1.4 毫秒(推算:107 / 79);回覆的開銷可以忽略不計——每個組合只有 16 位元組。

對這 107 毫秒有兩種解讀,兩種都很重要。

寬容的解讀:這隻佔牆鍾時間的約 4.5%,算不上災難。這是真的——而且值得弄清楚民間傳說預言的災難為什麼沒有在這裡發生。每一次健談呼叫仍然攜帶著 25,130 µs 的真實計算(相當於一個組合的量——即實測的程序內單組合成本),所以每次呼叫約 1.4 毫秒的邊界開銷,仍然比每次呼叫的工作量低一個數量級。當每次呼叫確實很重時,健談架構不會致命。它會隨著粒度變細而變得致命——這正是盈虧平衡一節要討論的全部主題。

定罪性的解讀:這份稅完全是自願承受的,而且它隨呼叫次數 × 負載大小而擴大。健談模式在每次呼叫時都重新傳輸資料集,原因只有一個:這個服務是無狀態的,所以每個請求都必須攜帶全部上下文。這正是樸素的"sweep 端點"的預設形態——也是幾乎每一個曾在白板上畫出來的 REST 微服務的預設形態。一個有狀態的伺服器——只加載一次序列,然後傳送 48 位元組的參數幀——會讓每一次逐組合呼叫都落在 echo 曲線極小負載的那一端:每次呼叫約 16 µs,全部 80 次加起來約 1.3 毫秒(從 echo 地板值推算得出;這是解析結果,未單獨實測)。健談的代價不會縮小;它會消失。這條教訓非常精確:問題不在於發起了很多次呼叫——而在於因為協議假裝每次呼叫都是第一次,所以每次都要重新傳輸狀態。

預先載入資料。只傳輸參數。帶著明確意圖跨越邊界,而不是每次都把整個世界裝進行李箱裡。

spawn 成本:按次租用引擎

一個引擎二進位制檔案為單次請求從零 spawn:程序建立、載入器、管道搭建堆疊成一道固定的收費站,擋在一小段有用的工作前面

第三種部署模式是最古老的:根本沒有伺服器。spawn 這個引擎二進位制檔案,通過 stdin 管道傳輸一個請求,從 stdout 讀取回復,然後讓它死掉。這是每一個 shell 指令碼作者的本能,是每一個"直接從 Python 呼叫 CLI"的整合方案,是每一個被配置成每次試驗都啟動一個二進位制檔案的超參數框架。

實測:2.300 秒(1.14 倍)——比常駐伺服器的批次方案多花約 24 毫秒(推算:2.300 − 2.276)。這 24 毫秒買到的是一次 fork/exec、動態載入器、管道搭建和程序回收。要注意的是,這個測量結果已經接近這種模式的地板值:一個體積小、無依賴的原生二進位制檔案,且已經預熱在頁快取裡。如果 spawn 的是帶執行時的東西——一個 JVM、一個帶有大量 import 的 Python 直譯器——成本會高得多;我們沒有在這裡測量那些情形,但方向毫無疑問。

重要的是這份稅的結構:它是每次呼叫固定的,與這次呼叫攜帶多少工作量無關。攤到整個 80 組合的掃描上,24 毫秒約佔 1%——噪聲而已。如果改成每個組合都重新 spawn 一次,同一個常數就變成 80 × ~24 ms ≈ 1.9 秒——基本上把整個有用的任務都燒在了程序建立上(推算;解析結果)。如果改成每根 K 線都重新 spawn 一次,那筆賬就不值得寫出來了。

固定成本,還是細粒度:二選一。要支付 spawn 代價的模式,只有在 spawn 很稀少、而它背後攜帶的負載又足夠龐大時才是理智的——正如我們"每次掃描一次 spawn"的測量情形,也正好和"每個交易對一個子程序"的架構在交易對數量增長之後最終被使用的方式相反。

盈虧平衡算術:地板值就是門檻利率

天平上的盈虧平衡算術:一側是十四微秒的邊界地板值,另一側是每次呼叫攜帶的計算量——逐組合呼叫遠遠浮在水面之上,逐 K 線呼叫則被淹沒

到目前為止測量到的一切都可以壓縮成一條設計準則,而這條準則是算術,不是意見。

每一次跨越邊界至少要付出延遲地板值的代價——這裡是 14 µs,即極小負載的 echo 往返,也接近這條傳輸通道所能提供的最佳值。這個地板值就是一個門檻利率:只有當一次跨邊界呼叫所攜帶的計算量以一個足夠舒適的倍數越過這道門檻時,這次呼叫才值得發起。定義粒度比:

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

邊界佔你牆鍾時間的比例大致是 1/(1+G)1/(1+G)——如果這次呼叫還攜帶資料,那就再加上負載傳輸的開銷。

現在把這次掃描的數字代進去。實測的程序內單組合成本是 25,130 µs。在逐組合粒度下:

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

逐組合呼叫位於地板值之上約 1,795 倍——邊界每次呼叫所佔的比例遠低於千分之一。這就是為什麼即便是健談架構也只損失了 107 毫秒:在這個工作負載的粒度下,任何不重新傳輸資料、不使用文本協議的跨越模式都能被安全地攤薄。組合級、fold 級、掃描級的呼叫都穩穩地處在便宜區間裡。

現在翻轉到另一個極端。這是一個用於說明的跨工作負載外推——不是我們這次掃描的變體,而是現實世界中確實存在的一種工作負載形態:引擎按每根 K 線被諮詢一次。一個類實盤的逐 tick 引擎服務;一個 gRPC-per-bar 的訊號流;一個針對 150,000 根 K 線中的每一根都輪詢一次的"策略伺服器"。這個核心中每根 K 線的有用計算量是 25,130 µs / 150,000 ≈ 0.17 µs(推算)——每次呼叫攜帶的有用工作量,大約只有它自身邊界成本的 1/84(推算:14.05 µs 的地板值除以 0.168 µs 的計算量)。總賬比這個比例聽起來還要糟糕:

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}

——比整個 2.010 秒的程序內任務還要多,而且這些開銷是在遠端引擎算出第一個數字之前就已經花掉的,即便對面的引擎速度無限快,也依然會是 2.1 秒(推算:150,000 × 14 µs)。沒有任何計算優勢能在這麼細的粒度下倖存。而且別忘了,這個地板值是單主機上的 Unix socket;如果把這種逐 K 線呼叫發給網路另一端的服務,在 150,000 次呼叫上,地板值會膨脹兩到三個數量級。

同機邊界地板值作為一種實現選擇:一次十四微秒的 Python-over-Unix-socket 往返,遠遠高出一次三十九納秒的共享記憶體環形跨越,兩者相差三個數量級

再做最後一次誠實的校準,因為 14 µs 也不是物理定律——它是我們這套傳輸方案的代價:一個 Python 客戶端、一個核心 socket、兩個方向上的系統呼叫。一個為同機場景專門打造的傳輸方案能低得多。ZigBolt——我們為 HFT 工作負載開發的開源 Zig 訊息匯流排,在同一臺機器上做了原生基準測試——一次共享記憶體環形往返平均約 39 ns(在 64/256/1024 位元組訊息下,單向 p50 分別為 10/20/30 ns)。這大約比我們的 socket 地板值低 360 倍(推算:14.05 µs / 39 ns)。這個對比刻意是"蘋果對橘子"的,我們也如實標註:我們的 14 µs 是一次 Python 客戶端的 socket 往返,ZigBolt 的 39 ns 是原生 Zig 在共享記憶體上的表現,所以這個差距把傳輸方式執行時混在了一起。不要把它讀成兩者之間的一場競賽,而應該讀成同機地板值所能佔據的區間:大約三個數量級,由實現方式決定。這正是古老的輕量級 RPC 教訓(Bershad 等人,1990 年)穿上了現代的外衣——同機跨越的成本主要由協議機制主導,而當傳輸方案是專門為同機場景打造時,這個成本就會大幅坍縮。上面的盈虧平衡算術形式不會變;門檻只是移動了。在 39 ns 的地板值下,即便是逐 K 線粒度也能越過門檻(150,000 × 39 ns ≈ 5.9 毫秒,推算)——這正是 HFT 系統能夠負擔得起 REST 服務無法負擔的邊界的原因。

整個盈虧平衡的故事可以濃縮成一句話:邊界不在乎你的引擎有多快;它按次跨越收費,所以你能控制的變數是每次跨越攜帶多少工作量——以及這次跨越是用什麼做的。 按每次掃描批次處理,GG 超過十萬。按每個組合批次處理,G1795G \approx 1795——依然沒問題。用 socket 逐 K 線呼叫,G<1G < 1——這個架構在第一次最佳化之前就已經死了,不管用 Rust 還是別的什麼語言重寫引擎,都救不活它。

那 1.13 倍到底藏在哪裡——以及結論

266 毫秒的差距被拆解開:一小條標著"邊界"的兩毫秒,緊挨著一大塊兩個標量編譯核心之間實測出的程式碼生成差異,民間信念被劃上了刪除線

是時候誠實地解剖這個頭條差距了,因為它承載著本研究中最反直覺的發現。

批次 Rust 架構落後程序內 numba 266 毫秒(推算:2.276 − 2.010)。實測的邊界構成:一次全負載往返約 2.0 毫秒,原始序列化 49 µs,幀頭幾個位元組——整筆邊界賬單約為 2 毫秒。因此,這個差距中超過 99% 的部分根本不是邊界造成的。它是計算:剝離掉 IPC 之後,Rust 伺服器做同一次掃描要花約 2.274 秒,而 numba 只要 2.010 秒——樸素的 Rust 核心在純計算上大約慢了 13%(推算)。

這值得用一整段毫不含糊地講清楚,因為"用 Rust 重寫就會更快"和"IPC 會要了你的命"一樣,都是民間信念。兩個核心最終都落在 LLVM 上——numba 把 Python 位元組碼降到 LLVM,rustc 把 MIR 降到 LLVM——而且兩者很可能都是以標量迴圈執行的:WMA 內層的求和是一次浮點歸約,如果沒有 fast-math 重結合許可,LLVM 不會自動向量化它,而 numba 的 @njit 預設不會授予這個許可,我們的移植版本也沒有請求它。所以這約 13% 是兩個標量、LLVM 編譯的迴圈之間實測出的程式碼生成差距——我們沒有直接斷言一個原因,而是檢驗了那個最顯而易見的嫌疑物件。天然的嫌疑物件是 Rust 的安全索引:熱路徑上的 WMA 迴圈對每一次陣列訪問都做邊界檢查,而 numba 的 @njit 編譯時關閉了邊界檢查。於是我們基於 get_unchecked 構建了同一核心的一個經過等價性校驗的變體——熱路徑上完全沒有邊界檢查——並把它作為第五種架構計時。它沒有縮小差距:2.337 秒(1.16 倍),比帶邊界檢查的構建版本的 2.276 秒還要略微一點。假設經過檢驗,假設被推翻。誠實的認知現狀是:這約 13% 是真實且可復現的(10 次執行的中位數,差幅在約 2% 以內),而目前尚無法歸因——可能是分配行為、迴圈結構或指令排程上的某種差異,只有彙編級別的效能分析才能定論。這條教訓完好無損地保留了下來:樸素的 Rust 並不會自動比優秀的 numba 更快,而一個建立在"計算收益是白得的"這一假設之上的語言邊界,完全可能連帶著一份計算損失一起到貨。一個經過調優的 Rust 核心——預分配緩衝區、顯式 SIMD、跨組合多執行緒——仍然有可能扭轉這個符號。但那是一個要靠效能分析和核心工作來解決的計算問題,而本研究關心的問題是邊界。邊界給出的答案是:只跨越一次、以位元組形式跨越,成本約為 0.1%。

那麼,把完整的結論拼起來,每一個分句都是上文實測出來的。

當以下條件全部成立時,跨語言引擎服務才會勝出:

  • 計算優勢是真實的——在你自己的核心上實測出來的,而不是根據語言的名聲臆測出來的。(在被證明相反之前,我們的數字是 −13%——而對這個虧損最初那個"顯而易見"的解釋,在檢驗中已經站不住腳了。)
  • 粗粒度地跨越——每次掃描或每個 fold 才呼叫一次,比 14 µs 的地板值高出成千上萬倍,正如批次架構總體 1.13 倍(邊界約佔 0.1%)所展示的那樣。
  • 使用二進位制協議——帶長度字首的原始陣列、Arrow,或任何 memcpy 量級、每 1.2 MB 只需 49 µs 的方案;絕不使用需要 66,243 µs 的文本協議。
  • 資料被預先載入——一個有狀態的伺服器只接收純參數呼叫,落在 echo 曲線約 16 µs 的那一端,而不是每次都重新傳輸幾兆位元組。

而當它按引擎服務通常被部署的方式部署時,就會輸:

  • 一個 JSON/REST 微服務——每次呼叫、兩個方向都要支付 1348 倍的序列化稅;在健談粒度下,一個 2 秒的任務光編碼就要花 5.3 秒。
  • 每單位工作一次 RPC——逐組合的代價在這裡是 107 毫秒,能挺過去僅僅是因為每次呼叫都攜帶 25,130 µs 的計算量;逐 K 線的話,在任何工作發生之前就要先付出約 2.1 秒的純 IPC,而整個任務只有 2.0 秒。
  • 每次呼叫都 spawn 一次——每次約 24 毫秒的固定成本,每次掃描只付一次時無傷大雅,但按每個組合支付時就接近兩秒。

也就是說:會失敗的那些架構並不古怪。JSON REST 引擎、每個交易對一個子程序、逐 tick 一次 gRPC——這如實反映了"把回測引擎拆出來"這件事在實踐中通常是怎麼被搭建出來的。這條民間信念作為對常見實踐的描述,在經驗上是站得住腳的;但作為一條自然規律,在經驗上是錯的。邊界從來都不是問題。跨越它的那些預設方式才是。

有一個支援邊界的論點值得單獨用一句話講清楚,因為它正是我們做這項研究的初衷。一個編譯好的核心放在一個設計良好的邊界後面,可以同時服務於研究掃描和實盤交易迴圈——同一個二進位制檔案,同一套算術,逐位相同。我們的回測-實盤一致性研究記錄過當研究引擎和生產引擎是兩套程式碼庫時,兩者是如何逐漸漂移分離的;一個引擎服務是這種漂移最有力的結構性解藥,而本研究誠實地給這劑解藥標了價:做對了,代價是約 0.1% 的牆鍾時間,外加一道等價性校驗來證明翻譯過程中沒有任何東西被改變。那筆交易——用一個專用的程序邊界換取單核心一致性——按這些數字來看,是一筆劃算的買賣。做錯了,同樣的想法會把 1348 倍的序列化稅連同你的 PnL 一起送進生產環境。

要點總結

  1. 邊界幾乎是免費的;民間信念經不起測量的檢驗。 把整條 1.2 MB 的收盤價序列通過 Unix socket 走一趟往返——包括完整的解析和重新編碼——成本是 2,043.4 µs,約佔 2.010 秒任務的 0.1%(推算)。批次 Rust-over-socket 架構總體落在 1.13 倍,而即便是這個差距,也有約 99% 與 IPC 無關。
  2. "用 Rust 重寫"是一個關於計算的主張——在為邊界買單之前先驗證它。 我們逐行移植的 Rust 版本計算速度比 numba 核心慢約 13%(推算:2.274 秒對比 2.010 秒)——這是兩個標量、LLVM 編譯的迴圈之間一個可復現的程式碼生成差距,目前尚無法歸因:我們檢驗了那個顯而易見的嫌疑物件並否定了它,因為一個經等價性校驗、無邊界檢查的 get_unchecked 構建版本並沒有更快(2.337 秒對比 2.276 秒)。樸素的 Rust 並不會自動更快;一個經過調優的核心則很可能會——先測量,再決定。
  3. 真正的稅是文本。 把 150,000 個浮點數編碼成 JSON 要花 66,243 µs,而原始位元組只要 49.1 µs——1348 倍,兩側、每個方向、每次呼叫都要付。一個健談式的 JSON 部署會在一個 2 秒的任務上燒掉 5.3 秒的編碼時間(推算)。跨邊界時使用二進位制協議:原始幀、Arrow——絕不要對價格陣列用 json.dumps
  4. 健談還是大塊,是可以測量的,而無狀態才是罪魁禍首。 每次都重新傳輸資料的逐組合呼叫:1.19 倍,對比批次的 1.13 倍(多 107 毫秒,推算;echo 曲線單向預測的約 81 毫秒比它低約 25%,差額是每次呼叫的成幀開銷)。一個預先載入好資料的有狀態伺服器,同樣是 80 次呼叫,每次約 16 µs——總共約 1.3 毫秒(從 echo 地板值推算)。傳輸參數,而不是傳輸資料集。
  5. 敬畏地板值——並且要知道地板值本身也是一種選擇。 我們的 Python-over-Unix-socket 跨越地板值是 14 µs;逐組合粒度以約 1,795 倍越過它(每次呼叫 25,130 µs 的計算量)——安全。一種逐 K 線模式(一個用於說明的跨工作負載極端情形:一個實盤逐 tick 引擎,而不是本次掃描)在一個 2.0 秒的任務上要付出 150,000 × 14 µs ≈ 2.1 秒的純 IPC(推算)——即便引擎速度無限快也是胎死腹中。每次呼叫都 spawn 會額外增加約 24 毫秒的固定成本(推算)。而像 ZigBolt 這樣專門打造的共享記憶體傳輸方案,在這臺機器上原生往返約 39 ns——比我們的 socket 地板值低約 360 倍(推算;原生 Zig 對比 Python 客戶端,所以應該把它讀作地板值所能佔據的區間,而不是一場競賽)。
  6. 只跨越一次,以位元組形式,資料已經就位——邊界只花約 0.1% 的代價就為你買到了一致性。 一個核心同時服務研究和實盤,由一道等價性校驗把關(PnL −5165.58,57,029 筆交易,跨語言、跨兩個 Rust 構建版本完全一致),這是支援引擎服務最誠實的理由。而那些不誠實的情形——JSON、健談、逐次 spawn——才是讓 IPC 落得這個名聲的元兇。

完整的實驗——Rust 引擎、線協議、echo 和序列化測試工具、等價性校驗,以及本文中每一個數字都可以從一個確定性指令碼重新生成——都在配套論文中:ipc-tax.marketmaker.cc,程式碼與資料見 github.com/suenot/ipc-tax

socket 從來都不是問題所在。整個資料集往返只要兩毫秒——民間傳說的估計偏差了三個數量級,而且是兩個方向同時偏差:對位元組太悲觀,對文本太寬容。把邊界當成有代價的東西去跨越,它就不會真的有代價。

免責宣告:本文提供的資訊僅用於教育和參考目的,不構成財務、投資或交易建議。加密貨幣交易涉及重大損失風險。

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

緊跟市場步伐

訂閱我們的時事通訊,獲取獨家 AI 交易見解、市場分析和平台更新。

我們尊重您的隱私。您可以隨時退訂。