30 秒看懂
Sebastian Funk 介绍 Jane Street 使用 OCaml 构建单核行情数据包处理器的工作。对交易所流量的排队分析确立了低于一微秒的处理目标,而不同交易所的协议又要求系统保持灵活和易维护。 他解释了内存分配、垃圾回收和内联,并通过两个例子展示如何保留有用的抽象,同时避免在关键路径上做多余的工作。最后,他介绍测量实践,并回答了同步处理、共享内存、绕过操作系统和 FPGA 方面的问题。
- 1基于真实流量制定延迟预算对 Nasdaq 收盘时段的顺序排队分析显示,每条消息处理时间超过一微秒后,积压会迅速增长。
- 2内存分配的两种代价垃圾回收损害尾延迟,而缓存局部性不佳也会影响中位延迟。
- 3内联需要权衡内联可以消除闭包并创造优化机会,但到处内联可能使代码变慢。
- 4关键路径无需分配的安全 option预处理器利用普通 option 的类型检查,同时为运行时生成即时值表示。
- 5生成高效解析器消息布局 DSL 生成缓冲区访问代码,避免急切解析和分配,也无须手工维护偏移量。
- 6从一开始就考虑性能Funk 建议先构建快速的最小系统,再逐步添加功能并测量性能回退。
要点建议
- 选择优化方法前,先根据实际工作负载和排队行为确定性能目标。
- 行情数据系统既要快,也要能适应不断变化的交易所协议。
- 内存分配既会影响垃圾回收停顿,也会影响缓存局部性,包括中位延迟。
- 在关键路径上避免分配,同时在其他地方保留更高层的功能。
- 内联能够带来优化机会,但需要启发式规则,因为不加区分地内联可能使程序变慢。
- 类型检查和代码生成既能保留安全接口,也能生成高效的数据表示。
- 持续测量并检查性能回退,因为编译器和运行时的变化可能改变性能。
按章节阅读整理后的全文
有约束的性能问题
Funk 介绍一个使用 OCaml 的单核数据包处理案例,目标是在保留有用软件特性的同时接近机器性能极限。
我想分享 Jane Street 用来构建接近机器能力极限的高性能、低延迟应用的一些技术。这是对多人工作的综述,并不是说我精通每个细节。速度并非公司的唯一价值,但数据量不断增长、时间窗口不断缩短,使性能变得必不可少。
我不会重点讨论算法选择、硬件选择、并行性或语言之争,尽管它们对真实系统都很重要。本案例的约束是一个用 OCaml 编写的单核数据包处理器。目标是说明 OCaml 能胜任这项工作,而不是说所有性能敏感代码都必须选择它。
数据包处理指接收网络数据包,执行有一定工作量但不一定非常复杂的处理,再将数据发往下游。这是一个优先考虑速度的特殊场景;对包括 Jane Street 内许多其他系统在内的情况,把性能最大化置于一切之上会是错误的权衡。
行情数据与延迟目标
交易所更新构成高流量数据流,供交易和其他系统使用。排队分析为所研究的工作负载确定了每条消息约 750 纳秒以下的目标。
这个案例源自行情数据分发。交易所维护订单簿,其中包含买卖某种证券的报价。参与者提交订单,交易所广播更新和成交;行情数据流报告活动,但不透露相关参与者的身份。

交易系统使用这些更新做出自动决策,风险、监控和历史数据系统也依赖准确的数据流。分发系统过慢可能让交易系统依据过时信息行动。问题是如何处理海量输入,并高效分发给内部客户端。
Nasdaq 每天产生大约十亿条行情更新,下午 4 点收盘前后就有数百万条。其他交易所和资产类别还有很多。为了理解需求,我考察严格顺序处理的系统在十秒窗口内,以不同固定处理时间处理消息时的排队情况。
每条消息处理 750 纳秒时,一个样本窗口的最大队列达到 22 条消息。整个收盘时段中,随着处理速度下降,队列增长呈非线性。每条消息需要十微秒时,队列可能达到约一百万条,此时系统还在处理突发流量的开头,后续更新却不断积压。
突发流量结束后,落后造成的影响仍可能持续:即使新流量很少,积压也会延迟后续消息。考察最大消息延迟得到了相同结论:在这项分析中,每条约 750 纳秒以下可行,超过一微秒则无法接受。这让我们有了具体目标,而不只是模糊地希望更快。

速度并非唯一要求。各交易所使用不同且经常变化的协议,新的交易所或法规也会引入更多变化。我们希望把交易所特有的复杂性放在系统边缘,中间使用共享代码,并让设计支持小幅、安全的修改,而无须重写整个系统。
一位听众问模型是否假设处理时间恒定;确实如此。另一位问能否过滤消息。客户端可以订阅子集,但靠近输入端的某个组件通常仍需要先处理完整数据流,再进行分发。
OCaml、分配与垃圾回收
Funk 回顾运行时,解释临时对象如何影响回收停顿和缓存局部性,并介绍关键路径上的零分配技术。
OCaml 结合了强静态类型系统、具备高阶函数等功能式工具,以及命令式特性。其运行时区分即时值(通常是整数)和堆上的装箱值。它使用分代、增量式垃圾回收,回收期间会暂停程序执行;它还能通过低开销的外部函数接口调用 C。
考虑一个对列表执行两次映射的函数:它会创建列表副本,捕获乘数的闭包也可能分配内存。浮点值也可能需要装箱表示。对普通应用来说,这样的代码没有问题,但在对延迟极其敏感的路径上,临时分配发生在哪里并不总是显而易见。

分配本身通常很便宜:在常见的 OCaml 场景下可能只需几条机器指令。垃圾回收会造成停顿、恶化尾延迟,但这还不是全部。分配和回收可能挤掉有用的缓存行,因此大量短生命周期对象也会降低中位性能。
零分配方法避免在关键路径上分配,但允许在启动、错误处理和其他地方分配。代码常采用扁平表示、可复用的 I/O 缓冲区和同步处理。需要对象却不希望关键路径触发垃圾回收时,显式对象池或预分配也是可选方案。
外部函数接口可以支持由 OCaml 垃圾回收器之外管理的内存,通过仔细安排布局,运行时可以处理这些值。Funk 认为这比较底层,可能不太方便,但强调只有程序的一小部分需要这样处理。其他地方仍可受益于语言的类型系统和内存安全性。
函数调用与内联
闭包分配和错失编译器优化可能使调用成本增加。内联有所帮助,但编译器的启发式规则必须平衡收益与代码体积增长。
函数调用有一些显而易见的成本,比如寄存器数据移动或指令缓存影响,不过处理器优化通常会让这些成本比预期小。在这个场景中,更重要的两个问题是闭包分配,以及编译器跨调用边界优化能力受限。
内联在调用位置代入函数体。在这个例子中,它消除了闭包,并暴露出编译器可以简化的常量值。但将所有东西都内联可能使程序变慢,因此难点在于判断何时有益;Jane Street 的编译器工作包括开发这类启发式规则。
无须分配的安全 option
即时值表示避免了分配,但最初失去了 option 类型的安全性。预处理技巧利用类型检查器,同时生成快速的运行时代码。
option 类型表示一个值可能不存在,并迫使调用方处理这种情况。例如,市场订单簿可能没有当前价格。通过匹配 `Some` 和 `None`,缺失情况得到明确处理,无须依赖开发者记得检查空值。
这个例子的缺点是,即使价格本身是即时整数,普通 option 表示仍会分配内存。Funk 的基准测试显示每次操作分配四字节。每秒处理数百万条消息时,如果年轻代堆较小,就可能频繁触发回收。
即时值 option 可以保留一个整数值表示不存在,直接使用其他值,从而避免分配。但如果手工提取时不检查,就会重新带来安全问题:审阅者必须确认每次使用前都检查了值是否存在。如果抽象变得不安全,仅仅消除装箱还不够。
展示的解决方案使用预处理器。它将源代码展开为一个供类型检查器使用的普通 option 匹配分支,以及另一个使用高效即时值表示的分支。类型检查发生在死代码消除之前,因此能报告遗漏的情况,而实际运行的代码避免了分配。

生成数据包访问器
急切地构造和解析完整消息会浪费工作。DSL 生成高效的缓冲区访问器,同时保留易维护的规范。
第二个例子读取带有标识符和订单簿条目列表的行情消息,然后汇总订单簿一侧的数量。直接的表示方式会分配完整记录,并急切解析时间戳等字段,即使当前计算根本用不到它们。
更快的方法是直接读取 I/O 缓冲区,只访问所需偏移量。但手写偏移量难以理解,协议变化时也难以维护。Jane Street 因此使用一个小型 DSL 和名为 protogen 的代码生成器,根据消息布局规范生成这些访问器。
在笔记本电脑的基准测试中,生成的高效版本处理该例约需 700 纳秒,急切实现则约需五微秒,而且前者避免了分配。重点不只是速度提高约八倍:先前的排队分析告诉我们,为什么五微秒对这个特定工作负载不可接受。

部署与测量
即使代码已经过充分优化,操作系统和冷缓存的开销仍可能占主导。Funk 建议设定明确目标、逐步设计并持续测量。
即使应用代码已经优化,在生产环境中仍可能很慢。操作系统网络栈可能占据主要开销,冷缓存也可能让负载很轻的系统慢得出人意料。网络拓扑和核心分配同样重要;这些部署问题很重要,但不属于本案例的重点。
优化前先明确什么叫足够快,并了解语言、运行时、机器和测量工具。测错对象很容易让人得出自信却错误的结论。先构建满足目标的最小系统,逐步添加功能,并在每次修改后测量。
这些优化可能因编译器或运行时变化而失效,因此持续基准测试和回退检查很重要。Funk 认为,这个案例表明,高层且相对安全的语言可以达到亚微秒目标,同时不必放弃所有有用的抽象。
问答与权衡
Funk 讨论同步缓冲区复用、扇出、共享内存、绕过操作系统和 FPGA 的权衡,还回答了 Jane Street 为什么使用 OCaml 以及如何测试内联决策。
在问答中,Funk 解释,同步处理让消息完全处理后可以安全地复用缓冲区。它也要求处理器跟得上线速:某一步变慢就会拖慢整个流程。异步处理则需要额外小心,因为其他操作可能仍在使用可变状态。
一位听众提出扇出。Funk 同意后续进行并行分发可能有用,但交易所可能只提供一条海量数据流,初始组件必须处理它。因此,即使系统其余部分采用扇出,能够在一个核心上处理全部速率仍是一种架构选择。
被问到跨进程使用生成的数据表示时,他说团队试验过共享内存和设备驱动技术,但不声称了解每种实现的所有细节。后来有人问到可变消息布局,他承认在他简化的积类型例子之外,需要更丰富的类型系统技术。
展示的例子使用普通操作系统路径,但团队也试验过绕过网络栈的部分环节。FPGA 可以为特定任务提供更低延迟;Funk 说公司在一些地方使用了 FPGA,而这次演讲重点是通用单核软件方法能达到什么水平。
排队研究可以通过订阅真实数据流并模拟不同服务时间的处理器来复现,但 Funk 没有提供适用于所有系统的通用工具。他也承认,利用预处理绕开运行时表示的选择令人沮丧,而出于兼容性和垃圾回收器方面的原因,这些选择很难改变。
为什么使用 OCaml,而不是没有垃圾回收器的其他语言?Funk 说,在整个工作流程中使用共同语言有很大价值:开发者熟悉它、每天使用它,也有围绕它构建的工具。团队起初考虑过必要时使用其他方案,但发现这个案例可以用 OCaml 达到目标。
内联决策很大程度上依靠经验:对备选方案做基准测试,再根据函数大小或关键路径标注制定启发式规则。Funk 说,还可以指示编译器在某个调用点内联,如果无法做到就报告错误。团队在一些关键路径上使用了这一保证。
同频道的其他文章
1:48:29EN → 简体中文
1:05:45EN → 简体中文
1:25:00EN → 简体中文
1:16:29EN → 简体中文
1:34:29EN → 简体中文
1:34:36EN → 简体中文