它到底有多快?关于算法交易系统中的延迟、测量与优化

本文信息来源:architect

“光速太慢了。”——John Carmack


在低延迟自动化交易领域(俗称“高频交易”或 HFT)中,软件工程师对速度近乎痴迷。从购买微波塔之间的专用带宽,到分析不同编译器版本下的 x86 指令,有过该行业经验的人都见证过为优化代码和网络路径以实现最小执行时间所投入的巨大时间和金钱。

但实际上,如何衡量一个程序的速度呢?对外行人来说,这听起来像是一项简单的任务。然而,衡量一个交易系统的真实延迟,甚至仅仅是定义应该测量什么,本身就包含了许多复杂的层次。在算法交易系统中理解延迟,可能会遇到类似海森堡测不准原理的困境——你写的测量延迟的代码越多,给程序增加的开销也就越大,从而导致测量结果越不准确。

在 Architect,我们一直在结合多种技术手段,来测量构成我们机构级交易技术套件的各种代码路径和流程的延迟。让我们一起来探讨这些问题的解决方案。



假设你已经编写了一个算法交易策略。你的策略会对某个合约的市场成交做出反应,可能会通过计算一个专有模型估值,并在满足特定条件时对该合约发出订单。你希望以可复现的方式测量这种反应所需的时间,以便尽可能缩短这个时间。我们用 Python 风格的伪代码来描述这个程序(尽管在实际中,通常会使用 C、C++ 和 Rust 这类对程序延迟要求极高的语言):

def on_market_trade(self, instrument, market_trade):
  model_value = self.compute_model_value(instrument, market_trade)
  order = self.compute_order_decision(instrument, model_value)
  if order is not None:
    self.send_order(order)

在了解关键代码路径延迟的过程中,一个合理的起点是为执行主要工作的函数加上计时器:

def on_market_trade(self, instrument, market_trade):
  start_time = datetime.now()
  model_value = self.compute_model_value(instrument, market_trade)
  order = self.compute_order_decision(instrument, model_value)
  end_time = datetime.now()
  self.add_time_sample(end_time - start_time)
  if order is not None:
    self.send_order(order)

函数 self.add_time_sample 会将经过的时间添加到一个直方图中,你可以在程序生命周期结束时,或者根据时间或观测到的样本数量定期打印统计信息。

上述方法存在许多问题:

  1. 它测量的是计算下单决策所需的时间,但并不包括实际发送订单所需的时间。
  2. 它会在每一笔市场交易时观察计算时间,而不仅仅是在那些导致下单的交易时观察——这会导致结果出现偏差,因为在市场事件量大或其他因素影响下,你的程序运行最慢的时候,往往正是最需要下单的时候。
  3. datetime.now() 本身是一个缓慢且开销较大的函数,会影响上述代码的运行速度和内存表现,如果你的程序已经在微秒级别运行,这种影响会不断累积。通常解决这个问题的方法是使用大多数编程语言都能访问的原生性能计数器。

下面是一个尝试修复上述问题的新代码示例:

def on_market_trade(self, instrument, market_trade):
  start_time = time.perf_counter_ns()
  model_value = self.compute_model_value(instrument, market_trade)
  order = self.compute_order_decision(instrument, model_value)
  if order is not None:
    self.send_order(order)
    end_time = time.perf_counter_ns()
    self.add_time_sample(end_time - start_time)

这确实有所改进,但我们真的测量到了交易系统的全部延迟吗?上述代码并未包含关键路径中的重要环节,比如解析市场交易更新所需的时间,或任何涉及网络 I/O 的部分。让我们退一步,追踪一下市场数据更新在 自动化交易系统(ATS) 的完整关键路径:

  1. 包含市场成交信息的网络数据包到达运行 ATS 的服务器的网卡(由交易所发送)
  2. 数据包被传递到 ATS 的运行时环境
  3. ATS 解析数据包的字节,从中提取所需字段(如成交价格或成交数量)
  4. ATS 计算模型数值并决定是否发送订单
  5. 订单的内部内存表示被转换为订单将要发送到的交易所的协议格式
  6. ATS 通过函数调用将订单字节传递给本机的网卡以进行发送
  7. 本机的网卡将订单字节发送到交易所

(上述内容省略了许多细节,比如从第1步到第2步以及第6步到第7步的多种实现方式,但为了简化说明,我们暂时省略这些内容。)

上面的代码示例只测量了第4、5和6步。我在许多实际案例中看到,完整延迟曲线中有90%甚至更多的延迟出现在第1、2、6和7步。如果第3步操作不当,或者在第3和第4步中需要构建订单簿,也可能会产生大量延迟。

如果我们只想测量系统的延迟,可以为每个外发消息打上市场数据事件序列号,然后,对于每个订单,你可以获取标记在入站市场数据事件上的 NIC 硬件时间戳,并用外发订单的 NIC 硬件时间戳相减。

为了真正以可复现的方式捕捉全部七个步骤(这样我们才能对你的代码改进做 A/B 测试),你可以采用以下这种替代方法来测量延迟:

  • 编写一个程序,模拟交易所的市场数据,通过定时器发送随机的市场交易事件
  • 让同一个程序通过接收以交易所原生协议发送的订单来模拟交易所本身
  • 让模拟器在发送市场成交前,用当前时间对成交进行时间戳标记
  • 配置 ATS 从模拟器接收数据并向模拟器发送订单。让 ATS 在其发送给交易所的订单上附加交易所成交的时间戳,或者如果协议不允许,则记录订单 ID 与交易所成交时间戳的映射关系
  • 让模拟器在收到 ATS 订单时记录时间戳。然后根据订单本身的数据或 ATS 记录的订单 ID 与交易所成交时间戳的映射,计算市场成交发送时间与订单接收时间之间的差值

虽然上述方法确实完整捕捉了整个关键路径,但它对延迟的估算过于保守:它还捕捉到了模拟器本身的类似代码路径!为了更接近正确答案,你可以编写另一个模拟交易所,以及一个模拟 ATS,只需在两者之间来回传递单个时间戳,而无需进行任何协议转换、模型构建、订单发送等操作。这为程序间延迟提供了一个基线,可以从上述实验结果中减去。

几乎完美的解决方案涉及一个更为高级的设置,即使用现代交换硬件复制进出主机的数据包流量,并对原始网络数据包进行解析和关联以进行时间戳标记。但这些细节我会留到以后的文章中再讲。

了解 RecodeX 的更多信息

立即订阅以继续阅读并访问完整档案。

继续阅读