CppCon2017上Carl Cook做了题为《When a Microsecond Is an Eternity: High Performance Trading Systems in C++》的演讲。本文对其要点进行记录,并拓展相关知识。
关键词:高性能 C++ 缓存 优化 汇编


演讲中,Carl Cook提出了多项旨在实现超低延迟的实用技术和优化策略。这些技术主要围绕优化热路径(Hot Path)的代码执行效率、提高缓存利用率以及避免不可预测的延迟(Jitter)。

按照演讲顺序,内容记录如下。

一、热路径代码优化

“热路径”指的是系统中最关键、对延迟要求最高的代码,它虽然只占总代码量的 1% 到 5%,但需要以极快的速度执行,且执行频率并不高。

最小化分支和条件判断

  • 通过将复杂的错误处理代码(例如,包含 handle_error 函数)替换为简单的整数标志检查(检查整数是否为零或非零),可以减少分支预测器的负担,并减轻指令缓存的压力。

Branch Prediction的原理:对于一个有分支代码,猜测并选择一个路径并加载指令到流水线。流水线执行是现代CPU高性能的重要支撑,不进行猜测将极大降低分支处的执行速度。分支预测器根据不同设计,可能使用专门的硬件记录分支处的上下文与路径历史,从而可以利用历史信息提高猜测的准确率。因此简化分支处的条件判断上下文是非常重要的。

  • 在热路径中,分支是昂贵的。如果可以在编译时确定所有可能的条件(例如,只有买入或卖出),应使用 模板特化 (Template Specialization) 来完全消除分支,生成完全确定性的非分支代码。这本质上是锁定编译器的优化行为。

简而言之,模板特化就是通过函数模板将有分支代码在编译期分离为独立的无分支代码。在运行时,进行决策的分支代码的独立的、具有干净的上下文,因此易于预测。同时独立的无分支代码可以充分利用流水线特性提高性能。

避免虚函数(Virtual Functions)

  • 如果可以在编译时知道所有可能被实例化的类的完整集合,应使用基于模板的配置来代替虚函数。
  • 通过模板,可以调用非虚的、具体的类型(例如使用工厂函数实例化正确的类型并传入),这能生成更紧凑的代码,为优化器提供更多机会,并减轻指令缓存的压力。

虚函数如何影响运行时性能?虚函数的调用依赖vtable和vptr,在调用时需要先查表再获取目标函数的地址,属于间接跳转。而且因为跳转目标不确定,无法使用指令预取(prefetch)优化性能
而静态多态在编译时已经确定函数地址,使用直接跳转,速度更快。同时编译器可以优化代码布局使函数连续存放。充分利用局部性(locality)进行预取。

使用 Lambda 提升表达力并保持速度

  • Lambda 函数本身可能不会带来直接的加速,但它们允许使用相对高级的语言特性,同时仍然可以实现极快的速度。
  • 例如,在发送消息时,Lambda 可以被完全内联,低级别的操作(如准备消息或发送)可以被优化到极简(例如,只需翻转网卡上的一个比特位来触发发送。

控制内联行为

  • C++ 的 inline 关键字主要是对链接器而言的,并不意味着强制内联。
  • 应使用 GCC 或 Clang 的属性(例如,always_inlinenoinline)来强制控制内联。
  • 不要将非热路径代码(如日志记录)内联到热路径中,因为这会污染指令缓存。使用 noinline 可以将这些代码转为一个函数调用,避免指令缓存污染。

函数调用时的栈帧创建、参数传递、指令跳转等操作会消耗 CPU 周期。内联将函数体直接嵌入调用处,避免了这些开销,尤其对高频调用的小函数效果显著。
过度内联会导致可执行文件体积增大(代码段膨胀)。这可能引发指令缓存失效频率增加,反而导致内存访问开销上升。

二、数据与缓存优化(Data and Cache Optimization)

训练硬件分支预测器和保持缓存热度(虚拟订单)

  • 由于快速路径极少执行,硬件(分支预测器、缓存)通常是“冷的”。
  • 解决方案: 通过系统连续模拟发送订单(虚拟订单),但必须在发送到交易所之前停止。
  • 在系统中以每秒 1,000 到 10,000 次的频率模拟发送订单,可以保持指令缓存和数据缓存的“热度”,并训练硬件分支预测器,使其预测正确的快速路径。这种方法能带来 5 微秒的加速

数据查找的缓存行优化(非规范化)

  • 当读取数据(例如,乐器价格)时,机器会读取一个完整的 64 字节缓存行
  • 因此,应该将热路径中需要且很少变化的关联数据(例如,市场数据的乘数)直接放入正在读取的对象(例如,工具对象)中。虽然这可能违反一些软件工程原则,但可以获得更快的代码,因为这些数据被“免费”预取了。

使用缓存友好的自定义哈希表

  • 标准的 std::unordered_map 中的链接列表可能不位于连续内存中,导致缓存未命中。
  • 演讲者推荐使用一种缓存友好的哈希表实现,其中哈希表本身存储元数据:预先计算的哈希值(8 字节)和指向对象的指针(8 字节),每对占用 16 字节。
  • 由于一个缓存行可以容纳四对这样的元数据,读取一个条目时通常会预取另外三个,这有助于解决冲突。这种自定义实现比 std::unordered_map 快两倍

CPU 数据预取(prefetch)的核心是:提前将 “大概率会用到的数据” 从内存加载到,避免 CPU 等待内存读取,提升数据访问效率。可以通过硬件检测数据访问模式
或主动预加载。适合连续、可预测的数据访问(如数组遍历),能大幅降低内存延迟;若预测失误(预取了无用数据),会占用缓存空间和带宽,反而可能降低性能。

三、内存和系统管理(Memory and System Management)

避免内存分配和释放

  • 内存分配是缓慢的,因此应使用对象池来重用和回收对象。
  • 对象的删除或析构也可能相对昂贵,因为这涉及到对 free 的调用。

谨慎使用多线程

  • 如果必须使用多线程,需要将热路径与其他线程共享的数据量保持在绝对最小值
  • 可以考虑不共享数据,而是通过锁定的单写入单读取队列等方式,在生产者和消费者之间传递数据的副本

利用专用核心和缓存

  • 为了最大化 L3 缓存的可用性并消除其他进程的干扰,可以关闭多核 CPU 上的除一个核心以外的所有核心,以便该核心独占缓存。
  • 或者,将产生“噪音”的进程转移到不同的 CPU 上。

避免系统调用

  • 任何涉及调用内核空间的系统调用(例如 select 或内核空间 TCP)都会严重影响延迟。应将所有中断从运行热路径的核心上移除,确保该核心上只运行 C++ 代码。

关于异常

  • 只要不抛出异常,异常的成本可以忽略不计(零成本)。
  • 但如果异常被抛出,根据测量,代价为一到两微秒,因此绝对不应用于控制程序流程。

总结: 高频交易系统的优化核心在于保证代码的执行路径尽可能简单、确定性高,并且最大化地利用 CPU 缓存,同时通过精确的测量来指导优化方向。

Logo

Agent 垂直技术社区,欢迎活跃、内容共建。

更多推荐