常见性能瓶颈与优化方向#21

交易系统性能衡量

交易系统的延迟要先说清楚量的是哪一段:TTT 是收到行情到发出订单,软件打点只量到线程边界,网卡和内核那两段要靠硬件时间戳;RTT 大部分是网络和交易所。报数要看分布:平均值和 p99 都可能看不出 0.2% 的尖刺。在每个线程边界打点,按消息把记录拼起来,才知道时间是花在处理上还是花在排队上。

约 10 分钟
本页目录

说一个交易系统“延迟 3 µs”,这句话本身没有信息量。3 µs 是从哪到哪?是平均值还是 99 分位?是在空闲时测的,还是在开盘那一波行情里测的?这几个问题答不上,两个系统的数字就没法比,优化了半天也不知道省下的时间落在哪一段。所以第一件事,是说清楚量的是哪一段。

1. 衡量什么、怎么报、在哪打点

1.1 延迟指标:TTT 和 RTT 各量哪一段

从左到右:交易所发出行情,经过网卡进入行情线程(T7 读 UDP、T8 写队列),经队列到交易引擎(T9 读队列、T10 写队列),再经队列到订单网关(T11 读队列、T12 写 TCP),经网卡发给交易所,交易所撮合后把回报发回网卡。上方两个括号:软件 TTT = T12 − T7,只含自己的代码和队列;线上 TTT 从行情进网卡到订单出网卡,多了网卡和内核两段。下方红色括号:RTT 从订单出网卡到回报回到网卡。两个队列下方标着 T9 − T8、T11 − T10:在队列里等。

  1. TTT(tick-to-trade):从收到行情到发出订单

    • 量的是什么:一条行情进来,到因为它而发出的订单离开系统,中间所有的时间:解析行情、更新盘口、算信号、风控、发单。
    • 为什么最要紧:这一段全是自己的代码和机器,是能优化的那部分。高频交易拼的就是这一段。
    • 软件 TTT:在线程里打点,T12 − T7(打点的写法见 #20 1.5)。它只量到线程边界:行情在网卡和内核里走的那段、订单从 send 出去到真正离开网卡的那段,都不在里面。
    • 线上 TTT(wire-to-wire):从行情进网卡到订单出网卡。要靠网卡自己打的硬件时间戳,或者在交换机上把进出的包镜像一份、用抓包设备按同一个时钟打时间。行业里对外说的 TTT 一般指这个。
    • 两种都只要两头两个时间:软件 TTT 只用 T7 和 T12,中间的 T8–T11 是用来拆段的(1.3)。线上 TTT 也是两个时间,只是在软件外面打。两种都要按消息配对:软件这边按消息编号把 T7 和 T12 找到一起;抓包设备只看得到包,要拿行情包里的序号、订单包里的订单号,再借程序日志里“这个序号触发了这个订单”的记录连起来。
    • 两者差在哪:差的就是网卡和内核那两段。用了内核旁路(行情不经过内核协议栈,直接从网卡读)以后,这两段会短很多,但不会是 0。
  2. RTT(round-trip time):订单出去到回报回来

    • 量的是什么:订单离开网卡,经网络到交易所,撮合,再把回报发回来,中间的时间。
    • 大部分不归自己管:网络距离、交易所的处理都在里面。它用来盯网络和交易所有没有变慢,不是用来优化自己的代码的。
  3. TTFB(time to first byte)

    • 是什么:发出请求到收到第一个字节,网页和网络服务常用。
    • 交易系统里很少用:行情是推过来的,不是请求来的,TTT 和 RTT 已经把要量的两段都分开了。

1.2 统计指标:看分布,不看平均

  1. 平均值会把尖刺藏起来

    • 为什么:延迟的分布很偏,大部分样本挤在一个很窄的范围里,少数被打断的样本拖出一条很长的尾巴。平均值只告诉你总量,看不出尾巴有多长。
    • 一个构造的例子:100 万个样本,99.8% 在 280–320 ns,0.2% 是 20 µs 的尖刺:
    平均p50p99p999max
    339 ns310 ns330 ns20 µs20 µs
    • 怎么读:平均值只比 p50 高 10%,p99 也很漂亮,每 500 条行情里有一条要等 20 µs,只有 p999 和最大值看得出来。
  2. 要报哪几个数

    • p50(中位数):一半的样本比它快,代表平时的样子。
    • p99、p999:100 条、1000 条里最慢的那一条大概多慢,也就是尾延迟。
    • max:最坏的一次,常常就是线程被切走、缺页这类事件(速查 2.1 的“系统”那一组)。
    • 抖动:只用方差不够,分布太偏。常用 p99 − p50 或者 p999 − p50 来说抖动有多大。
    • 再加两句条件:一共多少个样本,在什么负载下测的。没有这两句,分位数没法和别人比。
  3. 尾巴为什么要紧

    • 尖刺不是均匀撒的:行情最密的时候(开盘、大单、新闻),队列最容易堆、缓存最容易被冲掉,延迟最容易冒尖。
    • 机会也在这时候:价格动得最快、最值钱的那几百条行情,恰好落在尾巴上。所以交易系统要压的是尾延迟,不是平均延迟。
  4. 样本要够多

    • p999 要看到“最慢的千分之一”:1 万个样本里只有 10 个落在 p999 之上,再少就是在猜。
    • 经验:要估第 $1 - 1/k$ 分位,样本数至少是 $k$ 的几十倍;p999 至少几万个,p9999 至少几十万个。
  5. 在线上怎么算分位数:直方图

    • 不能每次都排序:线上一天几百万个样本,排序太贵,也不该放在热路径上。
    • 固定桶宽的直方图:按延迟落在哪个桶给那个桶加一,算分位数时从小往大累加,到第 $p \times \text{总数}$ 个样本所在的桶为止:
    // 固定桶宽的延迟直方图:每来一个样本,只做一次除法和一次加一
    struct LatencyHist {
        static constexpr uint64_t kBucketNs = 10;     // 桶宽 10 ns
        static constexpr size_t   kBuckets  = 10000;  // 0–100 µs,再往上都进最后一个桶
        std::array<uint64_t, kBuckets + 1> count{};
        uint64_t total = 0, max_ns = 0;
    
        void add(uint64_t ns) {
            size_t i = ns / kBucketNs;
            count[i < kBuckets ? i : kBuckets]++;
            total++;
            if (ns > max_ns) max_ns = ns;
        }
    
        // 第 p 分位(0 < p < 1):从小往大累加,到第 p × total 个样本所在的桶,返回桶的上沿(不超过最大值)
        uint64_t percentile(double p) const {
            uint64_t need = uint64_t(std::ceil(p * double(total))), seen = 0;
            for (size_t i = 0; i <= kBuckets; ++i) {
                seen += count[i];
                if (seen >= need) return i < kBuckets && (i + 1) * kBucketNs < max_ns ? (i + 1) * kBucketNs : max_ns;
            }
            return max_ns;
        }
    };
    
    • add:一次除法定桶,一次加一,超过 100 µs 的都进最后一个桶。
    • percentile:返回桶的上沿,误差不超过一个桶宽(这里是 10 ns),和 TSC 的一跳差不多。
    • 放在哪个线程:热路径只把 tick 差丢进队列(#20 1.5),直方图在日志线程里更新。
    • 桶宽怎么定:固定桶宽简单,但范围一大就要很多桶。HdrHistogram 这类库用“按数量级分段、段内等分”的桶,从 ns 到秒都能装下,误差按比例控制。
  6. 起点要从“该开始处理”算起

    • 坑:只从“开始处理这条消息”打点,会把排队藏掉。引擎卡了 1 ms,后面进来的几百条行情都在队列里等着,可每一条从被读出来才开始计时,看起来都很快,只有卡住的那一条慢。
    • 这叫 coordinated omission(协同遗漏):测量跟着被测系统一起停了,卡住期间本该出现的那些慢样本,一个都没记下来。
    • 改法:起点用消息到达的时间(T7,或者网卡时间戳),不用开始处理的时间(T9)。压测时按计划的发送时间算,不按实际发出去的时间算。

p99 是怎么测出来的:从打点到出数

  1. 打点:消息进来时(T7)读一次 TSC,记在这条消息上;订单写出去时(T12)再读一次。热路径上只读 tick,不换算、不格式化、不加锁、不分配内存(#20 1.5)。
  2. 送出热路径:两次 tick 的差放进 SPSC 队列,热线程马上回去处理下一条。
  3. 聚合:日志线程把 tick 换算成 ns,加进固定桶宽的直方图(上面第 5 条)。每个样本只做一次除法、一次加一,不排序。
  4. 出数:定期从直方图读出 p50、p99、p999、max,和样本数、当时的负载一起报(第 2 条)。精度是一个桶宽,超出直方图范围的样本只知道最大值。
  5. 两处容易错:起点要用到达时间,不然排队被藏掉(第 6 条);样本要够多,p999 至少几万个(第 4 条)。

1.3 在哪打点:组件内看函数,组件间看排队

  1. 组件内:给关键函数各打一对点

    • 做法:用 START_MEASURE / END_MEASURE(#20 1.5)包住一个函数,得到它单次的耗时。
    • TTT 路径上的函数,按先后:
      • 收行情:MarketDataConsumer::recvCallback(),从 UDP 收包、解析。
      • 改盘口:MarketOrderBook::addOrder() / removeOrder() / updateBBO()。
      • 算特征:FeatureEngine::onOrderBookUpdate(),从盘口算价差、深度这类特征。
      • 做决策:TradeEngine::algoOnOrderBookUpdate(),跑策略。
      • 生成订单:OrderManager::newOrder()(或 moveOrder() / cancelOrder())。
      • 风控:RiskManager::checkPreTradeRisk(),查资金、持仓、价格限制。
      • 发单:TCPSocket::send()。
    • 能回答的问题:哪个函数最贵,改了一个函数以后它自己快了多少。
    • 回答不了的:函数之间、线程之间的时间去哪了。
  2. 组件间:在每个线程边界打绝对时间戳

    • 做法:消息每过一个线程边界,记一次时间:从网卡读进来(T7)、写进队列(T8)、从队列读出来(T9)……一直到写进 TCP(T12)。图里就是这六个点。
    • 相减就是一段:
      • T8 − T7:行情线程处理这条消息的时间。
      • T9 − T8:在队列里等的时间。
      • T10 − T9:交易引擎处理的时间。
      • T11 − T10:又一次排队。
      • T12 − T11:订单网关打包、发出去的时间。
    • 跨线程相减靠 TSC 同步:T8 和 T9 是不同的核读的,同一台机器上可以直接减(#20 1.4)。
    • 要按消息拼起来:每条记录带上消息的编号(行情序号、订单号),事后按编号把一条消息的 T7–T12 拼成一行。
  3. 排队那两段为什么要单独看

    • 一跳的底价:两个绑了核、忙等的线程之间,消息过一次 SPSC 队列,至少要把数据和下标两条缓存行从一个核搬到另一个核,每条约 20–100 ns(速查 2.1)。所以每跳约 50–200 ns。
    • 尾巴常常长在这里:
      • 消费者还在忙上一条:行情一波一波来,前一条还没处理完,后面的只能排着。
      • 消费者被切走了:线程没绑核、核没隔离,被别的线程或中断占了(#1、#19)。
      • 消费者在睡:用的是会睡的等待,被叫醒要 2–10 µs(futex,速查 2.1)。
    • 只看 T12 − T7 分不出来:总数变慢了,可能是策略算慢了,也可能只是排队。拆成段以后,处理那几段稳、排队那段冒尖,问题就在线程怎么等、核怎么分,不在算法。
  4. 交易所那一边也有一套

    • T1–T6:交易所内部从 TCP 读到订单、进撮合、发行情和回报,也是一样在线程边界打点。
    • 我们量不到:那是交易所的机器。我们能看到的只有它在行情和回报里自己打的时间戳,要和自己的时间比,得靠 PTP 对过时的时钟(#20 1.5)。

1.4 工具和量级:先找热点,再做预算

  1. 先用工具找热点,再打点量链路

    • perf:先看时间花在哪个函数、CPU 在算还是在等(perf 专题)。它告诉你“哪里”,打点告诉你“每一段多久”。
    • numastat:看一个进程的内存分在哪几个 NUMA 节点上(numastat -p <pid>)。热线程用到的内存应该几乎全在它自己的节点上;不在,就是分配内存时没绑好节点(#1)。
    • valgrind --tool=cachegrind:在软件里模拟一套缓存,数出每行代码的缓存缺失。它慢 20–100 倍,量不了真实延迟,但每次的结果都一样,适合拿来比较两种数据布局。
    • VTune(Intel)、uProf(AMD):厂商的分析工具,能把流水线卡在哪一步分得更细。
    • 计时用的时钟:区间用 TSC 或 steady_clock,不用 high_resolution_clock:它在 GCC 的标准库里就是会被 NTP 拨动的 system_clock(#20 2.3)。
  2. 量级表:拿来做延迟预算

    • 表在速查里:从读寄存器到读 SSD,每类操作大约多久,按类别列在 速查 2.1。
    • 怎么用:假设软件 TTT 的预算是 2 µs,拿表里的数一项项往里放:
      • 一次缓存缺失到内存:约 100 ns,占 5%。一条路径上碰上几次,预算就去了一小半。
      • 一次系统调用:0.1–0.5 µs,一次就是 5%–25%。
      • 一次线程切换:1–5 µs,一次就把预算用完了。
      • 一次抛异常:1–2 µs,同上。
    • 所以热路径上不进内核、不拿可能睡的锁、不分配内存、不抛异常:不是这些操作平均有多慢,而是任何一次都放不进预算。

1.5 测量手段汇总:哪一段用什么量

在哪读时间,决定了用哪个时钟;用哪个时钟,又决定了哪两个点能直接相减。前面讲过的五种拿时间的办法,放在同一条路径上是这样:

上方是一条路径:交易所、交换机、网卡、内核或旁路、行情线程(T7、T8)、队列、交易引擎(T9、T10)、队列、订单网关(T11、T12)、内核、网卡、交换机、交易所。下面五行,点是读时间的地方,线是两点相减量到的那一段:① 函数耗时,用 TSC,在每个线程里包住一个个函数;② 线程边界,用 TSC,T7 到 T12 六个点,处理段和排队段颜色不同;③ 网卡硬件时间戳,从进来的网卡到出去的网卡,再从出去的网卡绕到交易所回来,是 RTT;④ 交换机镜像加抓包设备,从进来的交换机到出去的交换机,也带一个 RTT 回环;⑤ 交易所时间戳,从交易所到网卡,靠 PTP 对时。

手段用的时钟在哪读时间量到哪一段量不到、要注意
① 函数打点TSC函数进、出一个函数本身函数之间、线程之间的时间
② 线程边界打点TSCT7–T12软件 TTT,处理和排队各段网卡和内核那两段
③ 网卡硬件时间戳网卡上的时钟(PHC),一般用 PTP 对时网卡收包、发包线上 TTT、RTT要和 T7 比,得先把网卡时钟和系统时钟对上
④ 交换机镜像 + 抓包设备抓包设备自己的时钟,一般用 PTP 或 GPS 对时交换机进、出线上 TTT、RTT只看得到包,要按行情序号、订单号配对
⑤ 交易所时间戳两个:交易所的时钟打发出时间,自己的时钟打收到时间,两边都对到 UTC交易所发出、自己收到交易所到自己(单程)误差是两边对时误差之和,约 20 ns–1 µs;有的交易所时间戳只到 ms;交易所内部量不到
  • 怎么挑:调自己的代码看 ① 和 ②;对外报 TTT 用 ③ 或 ④;怀疑网络或交易所变慢,看 RTT 和 ⑤。
  • 能直接相减的条件:两个点在同一个时钟上。① ② 都在本机的 TSC 上,③ 在网卡上,④ 在抓包设备上;跨时钟比,先对时,误差就是对时的误差。
  • PTP 不是第 6 种测法:它是对时协议(precision time protocol)。主时钟一般接 GPS,定时发报文;各台机器的网卡给这些报文打硬件时间戳,按来回的时间算出自己的时钟差多少,再拨过来。它只让不同设备的时钟走成同一个时间,不量交易路径上的任何一段。③ ④ ⑤ 能拿来跨设备比,靠的就是它。
  • 两样不是拿时间的:perf 告诉你时间花在哪个函数(1.4),直方图把量到的差值变成分位数(1.2 第 5 条)。