← 返回文章列表
July 2, 2026
5 分钟阅读

IPC 税:把回测引擎放到 socket 背后会损失 13%——而这几乎与 socket 无关

IPC 税:把回测引擎放到 socket 背后会损失 13%——而这几乎与 socket 无关
#算法交易
#回测
#性能
#ipc
#rust
#架构
Part 4 of 4 · 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 交易见解、市场分析和平台更新。

我们尊重您的隐私。您可以随时退订。