说一个交易系统“延迟 3 µs”,这句话本身没有信息量。3 µs 是从哪到哪?是平均值还是 99 分位?是在空闲时测的,还是在开盘那一波行情里测的?这几个问题答不上,两个系统的数字就没法比,优化了半天也不知道省下的时间落在哪一段。所以第一件事,是说清楚量的是哪一段。
1. 衡量什么、怎么报、在哪打点
1.1 延迟指标:TTT 和 RTT 各量哪一段
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。
RTT(round-trip time):订单出去到回报回来
- 量的是什么:订单离开网卡,经网络到交易所,撮合,再把回报发回来,中间的时间。
- 大部分不归自己管:网络距离、交易所的处理都在里面。它用来盯网络和交易所有没有变慢,不是用来优化自己的代码的。
TTFB(time to first byte)
- 是什么:发出请求到收到第一个字节,网页和网络服务常用。
- 交易系统里很少用:行情是推过来的,不是请求来的,TTT 和 RTT 已经把要量的两段都分开了。
1.2 统计指标:看分布,不看平均
平均值会把尖刺藏起来
- 为什么:延迟的分布很偏,大部分样本挤在一个很窄的范围里,少数被打断的样本拖出一条很长的尾巴。平均值只告诉你总量,看不出尾巴有多长。
- 一个构造的例子:100 万个样本,99.8% 在 280–320 ns,0.2% 是 20 µs 的尖刺:
平均 p50 p99 p999 max 339 ns 310 ns 330 ns 20 µs 20 µs - 怎么读:平均值只比 p50 高 10%,p99 也很漂亮,每 500 条行情里有一条要等 20 µs,只有 p999 和最大值看得出来。
要报哪几个数
- p50(中位数):一半的样本比它快,代表平时的样子。
- p99、p999:100 条、1000 条里最慢的那一条大概多慢,也就是尾延迟。
- max:最坏的一次,常常就是线程被切走、缺页这类事件(速查 2.1 的“系统”那一组)。
- 抖动:只用方差不够,分布太偏。常用 p99 − p50 或者 p999 − p50 来说抖动有多大。
- 再加两句条件:一共多少个样本,在什么负载下测的。没有这两句,分位数没法和别人比。
尾巴为什么要紧
- 尖刺不是均匀撒的:行情最密的时候(开盘、大单、新闻),队列最容易堆、缓存最容易被冲掉,延迟最容易冒尖。
- 机会也在这时候:价格动得最快、最值钱的那几百条行情,恰好落在尾巴上。所以交易系统要压的是尾延迟,不是平均延迟。
样本要够多
- p999 要看到“最慢的千分之一”:1 万个样本里只有 10 个落在 p999 之上,再少就是在猜。
- 经验:要估第 $1 - 1/k$ 分位,样本数至少是 $k$ 的几十倍;p999 至少几万个,p9999 至少几十万个。
在线上怎么算分位数:直方图
- 不能每次都排序:线上一天几百万个样本,排序太贵,也不该放在热路径上。
- 固定桶宽的直方图:按延迟落在哪个桶给那个桶加一,算分位数时从小往大累加,到第 $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 到秒都能装下,误差按比例控制。
起点要从“该开始处理”算起
- 坑:只从“开始处理这条消息”打点,会把排队藏掉。引擎卡了 1 ms,后面进来的几百条行情都在队列里等着,可每一条从被读出来才开始计时,看起来都很快,只有卡住的那一条慢。
- 这叫 coordinated omission(协同遗漏):测量跟着被测系统一起停了,卡住期间本该出现的那些慢样本,一个都没记下来。
- 改法:起点用消息到达的时间(T7,或者网卡时间戳),不用开始处理的时间(T9)。压测时按计划的发送时间算,不按实际发出去的时间算。
p99 是怎么测出来的:从打点到出数
- 打点:消息进来时(T7)读一次 TSC,记在这条消息上;订单写出去时(T12)再读一次。热路径上只读 tick,不换算、不格式化、不加锁、不分配内存(#20 1.5)。
- 送出热路径:两次 tick 的差放进 SPSC 队列,热线程马上回去处理下一条。
- 聚合:日志线程把 tick 换算成 ns,加进固定桶宽的直方图(上面第 5 条)。每个样本只做一次除法、一次加一,不排序。
- 出数:定期从直方图读出 p50、p99、p999、max,和样本数、当时的负载一起报(第 2 条)。精度是一个桶宽,超出直方图范围的样本只知道最大值。
- 两处容易错:起点要用到达时间,不然排队被藏掉(第 6 条);样本要够多,p999 至少几万个(第 4 条)。
1.3 在哪打点:组件内看函数,组件间看排队
组件内:给关键函数各打一对点
- 做法:用
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()。
- 收行情:
- 能回答的问题:哪个函数最贵,改了一个函数以后它自己快了多少。
- 回答不了的:函数之间、线程之间的时间去哪了。
- 做法:用
组件间:在每个线程边界打绝对时间戳
- 做法:消息每过一个线程边界,记一次时间:从网卡读进来(T7)、写进队列(T8)、从队列读出来(T9)……一直到写进 TCP(T12)。图里就是这六个点。
- 相减就是一段:
- T8 − T7:行情线程处理这条消息的时间。
- T9 − T8:在队列里等的时间。
- T10 − T9:交易引擎处理的时间。
- T11 − T10:又一次排队。
- T12 − T11:订单网关打包、发出去的时间。
- 跨线程相减靠 TSC 同步:T8 和 T9 是不同的核读的,同一台机器上可以直接减(#20 1.4)。
- 要按消息拼起来:每条记录带上消息的编号(行情序号、订单号),事后按编号把一条消息的 T7–T12 拼成一行。
排队那两段为什么要单独看
- 一跳的底价:两个绑了核、忙等的线程之间,消息过一次 SPSC 队列,至少要把数据和下标两条缓存行从一个核搬到另一个核,每条约 20–100 ns(速查 2.1)。所以每跳约 50–200 ns。
- 尾巴常常长在这里:
- 只看 T12 − T7 分不出来:总数变慢了,可能是策略算慢了,也可能只是排队。拆成段以后,处理那几段稳、排队那段冒尖,问题就在线程怎么等、核怎么分,不在算法。
交易所那一边也有一套
- T1–T6:交易所内部从 TCP 读到订单、进撮合、发行情和回报,也是一样在线程边界打点。
- 我们量不到:那是交易所的机器。我们能看到的只有它在行情和回报里自己打的时间戳,要和自己的时间比,得靠 PTP 对过时的时钟(#20 1.5)。
1.4 工具和量级:先找热点,再做预算
先用工具找热点,再打点量链路
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)。
量级表:拿来做延迟预算
- 表在速查里:从读寄存器到读 SSD,每类操作大约多久,按类别列在 速查 2.1。
- 怎么用:假设软件 TTT 的预算是 2 µs,拿表里的数一项项往里放:
- 一次缓存缺失到内存:约 100 ns,占 5%。一条路径上碰上几次,预算就去了一小半。
- 一次系统调用:0.1–0.5 µs,一次就是 5%–25%。
- 一次线程切换:1–5 µs,一次就把预算用完了。
- 一次抛异常:1–2 µs,同上。
- 所以热路径上不进内核、不拿可能睡的锁、不分配内存、不抛异常:不是这些操作平均有多慢,而是任何一次都放不进预算。
1.5 测量手段汇总:哪一段用什么量
在哪读时间,决定了用哪个时钟;用哪个时钟,又决定了哪两个点能直接相减。前面讲过的五种拿时间的办法,放在同一条路径上是这样:
| 手段 | 用的时钟 | 在哪读时间 | 量到哪一段 | 量不到、要注意 |
|---|---|---|---|---|
| ① 函数打点 | TSC | 函数进、出 | 一个函数本身 | 函数之间、线程之间的时间 |
| ② 线程边界打点 | TSC | T7–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 条)。