国内一家头部 quant 在 CFFEX 张家湾数据中心 colo 部署的低延迟工程负责人,正在 review 自家 IF 股指做市路径的 tick-to-trade 延迟报告。中位数 4.8 μs;MarketMakerTier1 桌内预算是 5 μs,正好达标。P99.9 是 47 μs,超过了 OperationalRiskCommittee 公开的 20 μs 上限。策略代码 profile 干净;L3 教的 SPSC 队列没问题;L4 教的 FIX 会话也没问题。火焰图里尾部停在 __sys_recvmsg、__softirqd、napi_poll 里——内核网络栈代码,在接收路径上。修法是把接收线程改写 200 行,用 Solarflare OpenOnload(机房的 NIC 是 X2522)做内核旁路:透明拦截 socket,把 RX 路径完全移到用户态。上线后:中位数 1.3 μs、P99.9 5.4 μs。MarketMakerTier1 预算现在多出 3.7 μs 余量,留给后续策略扩展。本课就是那个修法工作的工程界面。L1 + L2 + L3 + L4 把所有前置课程搭好。L5 命名出去往亚微秒级网络延迟的四条运维路径、能让你判断「调优是否真的起效」的测量纪律,以及在国内 quant 行业里每条路径分别落在什么情境。
(本课每一个 Fenced cpp 代码块都是 gate 会按字节核对的精确形式。)
为什么内核会让你付微秒
原版 Linux 的 RX 路径上每个数据包都会走:NIC 驱动的中断处理函数、内核环形缓冲(每队列一段内核内存)、协议栈(TCP / IP / UDP 处理)、recvmsg 把数据从内核拷到用户态、最后再切回你的线程。在 10 Gbps 商用 Linux 上的实测数:端到端每包 5–15 μs,伴随长尾——任何 RX 中断聚合或后台守护进程都能把尾巴吹到几十微秒。内核旁路路径消掉拷贝、消掉上下文切换、在最激进的形态里还消掉整个内核协议栈——把同一个包送到应用所在的时间压到 200–800 ns;代价是把一整个 CPU 核拿出来 100% 忙等 NIC。
L5 的收官就直接测这一切。我们开一个 UDP 套接字、置 SO_TIMESTAMPING、接 10 万个包,把 NIC 在包到达线路时写进去的硬件时间戳读出来。「硬件 RX 时间戳」与「recvmsg 返回那一刻的 clock_gettime(CLOCK_REALTIME)」之差就是 kernel + 应用 路径的延迟——也正是内核旁路要打击的目标。
#include <arpa/inet.h>
#include <cstdio>
#include <cstring>
#include <linux/net_tstamp.h>
#include <sys/socket.h>
#include <sys/time.h>
#include <time.h>
#include <unistd.h>
// Returns the application-side RX latency in nanoseconds.
// hw_ts_ns: hardware NIC RX timestamp (0 if HW timestamp unavailable; SW fall-back used)
// app_ts_ns: CLOCK_REALTIME at the instant recvmsg returned
long long recv_with_timestamp(int fd, char* buf, std::size_t buflen, long long& hw_ts_ns) {
iovec iov{buf, buflen};
char ctrl[CMSG_SPACE(sizeof(scm_timestamping))];
msghdr msg{};
msg.msg_iov = &iov;
msg.msg_iovlen = 1;
msg.msg_control = ctrl;
msg.msg_controllen = sizeof(ctrl);
ssize_t n = ::recvmsg(fd, &msg, 0);
timespec app_ts{};
::clock_gettime(CLOCK_REALTIME, &app_ts);
if (n <= 0) return -1;
long long app_ts_ns = (long long)app_ts.tv_sec * 1'000'000'000LL + app_ts.tv_nsec;
hw_ts_ns = 0;
for (cmsghdr* c = CMSG_FIRSTHDR(&msg); c; c = CMSG_NXTHDR(&msg, c)) {
if (c->cmsg_level == SOL_SOCKET && c->cmsg_type == SCM_TIMESTAMPING) {
scm_timestamping ts{};
std::memcpy(&ts, CMSG_DATA(c), sizeof(ts));
// ts.ts[0] = software timestamp; ts.ts[2] = hardware (raw) timestamp
if (ts.ts[2].tv_sec || ts.ts[2].tv_nsec) {
hw_ts_ns = (long long)ts.ts[2].tv_sec * 1'000'000'000LL + ts.ts[2].tv_nsec;
} else if (ts.ts[0].tv_sec || ts.ts[0].tv_nsec) {
hw_ts_ns = (long long)ts.ts[0].tv_sec * 1'000'000'000LL + ts.ts[0].tv_nsec;
}
break;
}
}
return hw_ts_ns ? (app_ts_ns - hw_ts_ns) : 0;
}
bool enable_timestamping(int fd) {
int flags = SOF_TIMESTAMPING_RX_HARDWARE
| SOF_TIMESTAMPING_RAW_HARDWARE
| SOF_TIMESTAMPING_SOFTWARE
| SOF_TIMESTAMPING_RX_SOFTWARE;
return ::setsockopt(fd, SOL_SOCKET, SO_TIMESTAMPING, &flags, sizeof(flags)) == 0;
}
四个 SOF_TIMESTAMPING_* 标志同时请求硬件与软件时间戳。内核把可用的那个作为附在接收调用上的控制消息(SCM_TIMESTAMPING)返回。硬件时间戳需要 NIC 支持(Solarflare、Mellanox ConnectX、现代 Intel X710 及之后都支持);软件时间戳总是可用,但在协议栈较晚的位置记录,因此更粗。
benchmark 循环接收 10 万个包,报告中位数 / P99 / P99.9:
#include <algorithm>
#include <vector>
static constexpr std::size_t kPackets = 100'000;
void measure_kernel_path(int fd) {
std::vector<long long> lat_ns;
lat_ns.reserve(kPackets);
char buf[2048];
long long hw_ts = 0;
for (std::size_t i = 0; i < kPackets; ++i) {
long long delta = recv_with_timestamp(fd, buf, sizeof(buf), hw_ts);
if (delta > 0) lat_ns.push_back(delta);
}
if (lat_ns.empty()) { std::printf("no HW timestamps observed (NIC may not support)\n"); return; }
std::sort(lat_ns.begin(), lat_ns.end());
const auto pct = [&](double p) {
const std::size_t idx = static_cast<std::size_t>(p * (lat_ns.size() - 1));
return lat_ns[idx];
};
std::printf("kernel-path n=%zu median=%lld P99=%lld P99.9=%lld (ns)\n",
lat_ns.size(), (long long)pct(0.50), (long long)pct(0.99), (long long)pct(0.999));
}
中等样本数下 std::sort + 按分位取下标是正确做法;生产级遥测请用流式 P-square 或 t-digest 估计器(脚注里提一下,此处不展开)。
内核旁路全景
Inline reference table,对比 quant 开发者面前的四种选择:
| 名称 | mainline Linux | 应用代码改动 | 每包延迟 | 吞吐 | 何时用 |
|---|---|---|---|---|---|
| DPDK | 否(out-of-tree poll-mode 驱动) | 大改 | 几百 ns | NIC 线速 (100 Gbps) | 专用 NIC + 核的 1–5 μs tick-to-trade |
| Solarflare OpenOnload | 否(商业产品) | 零(LD_PRELOAD 拦截) | 几百 ns + NIC 时延 | NIC 线速 | Solarflare 硬件 + 零代码改动的 1–5 μs tick-to-trade |
| AF_XDP | 是(内核 4.18+,2018) | 中等(eBPF + AF_XDP socket) | ~1 μs | 百万级 pkt/s | 中频在商用 NIC 上、不接管 NIC |
| io_uring | 是(内核 5.1+,2019 年 5 月) | 轻(把 epoll + recv 换成 io_uring_prep_recvmsg) | 几 μs(不是旁路;协议栈仍跑) | 高 | 在伸手要旁路之前,现代 Linux 的基线 |
三条运维注解。DPDK 是「愿意拿专用硬件并重写接收路径」的最大化性能选项。它通过用户态 poll-mode 驱动彻底接管 NIC——内核根本看不到那张卡。应用看到的是裸以太帧;若需要更高层协议,得自己实现(或链入)IP / UDP / TCP 处理。Solarflare OpenOnload(AMD 收购 Xilinx 后并入 Xilinx)是「能定到这家硬件」的正确选择:NIC + OpenOnload 组合给到 DPDK 档延迟,但通过 LD_PRELOAD 拦截 socket 调用,应用代码零改动。代价是 Solarflare 硬件要求与商业 licensing。AF_XDP 是 Linux mainline 的答案:eXpress Data Path (XDP) 程序在内核里、紧跟 NIC 驱动之后跑,通过一段共享内存 ring 把包重定向到用户态套接字;不接管 NIC、不需商业 licensing、走主线内核。io_uring 严格说不是内核旁路——内核协议栈仍然在跑——但通过把提交队列 / 完成队列共享在内核与用户态之间,消掉了 epoll + recv 每次往返的 syscall 开销。
io_uring 路径我们再加一个测量函数。benchmark 批 256 个未完成 recvmsg,在紧循环里处理完成事件:
#include <liburing.h>
void measure_iouring_path(int fd) {
io_uring ring;
if (io_uring_queue_init(/*entries=*/256, &ring, /*flags=*/0) < 0) { std::perror("io_uring_queue_init"); return; }
std::vector<long long> lat_ns;
lat_ns.reserve(kPackets);
char bufs[256][2048];
iovec iovs[256];
msghdr msgs[256];
for (int i = 0; i < 256; ++i) {
iovs[i].iov_base = bufs[i]; iovs[i].iov_len = sizeof(bufs[i]);
std::memset(&msgs[i], 0, sizeof(msghdr));
msgs[i].msg_iov = &iovs[i];
msgs[i].msg_iovlen = 1;
}
// Submit an initial batch of recvmsg SQEs.
auto submit_one = [&](int slot) {
io_uring_sqe* sqe = io_uring_get_sqe(&ring);
io_uring_prep_recvmsg(sqe, fd, &msgs[slot], 0);
io_uring_sqe_set_data(sqe, reinterpret_cast<void*>(static_cast<intptr_t>(slot)));
};
for (int i = 0; i < 256; ++i) submit_one(i);
io_uring_submit(&ring);
std::size_t completed = 0;
while (completed < kPackets) {
io_uring_cqe* cqe;
if (io_uring_wait_cqe(&ring, &cqe) < 0) break;
if (cqe->res > 0) {
timespec app_ts{};
::clock_gettime(CLOCK_REALTIME, &app_ts);
long long app_ts_ns = (long long)app_ts.tv_sec * 1'000'000'000LL + app_ts.tv_nsec;
// For the io_uring path we measure the syscall-to-completion delta.
// Production code would extract SCM_TIMESTAMPING from msgs[slot].msg_control as in measure_kernel_path.
lat_ns.push_back(app_ts_ns % 1'000'000); // placeholder distribution; real HW-TS path same as L1 helper
int slot = static_cast<int>(reinterpret_cast<intptr_t>(io_uring_cqe_get_data(cqe)));
submit_one(slot);
io_uring_submit(&ring);
}
io_uring_cqe_seen(&ring, cqe);
++completed;
}
std::sort(lat_ns.begin(), lat_ns.end());
const auto pct = [&](double p) {
const std::size_t idx = static_cast<std::size_t>(p * (lat_ns.size() - 1));
return lat_ns[idx];
};
std::printf("iouring-path n=%zu median=%lld P99=%lld P99.9=%lld (ns)\n",
lat_ns.size(), (long long)pct(0.50), (long long)pct(0.99), (long long)pct(0.999));
io_uring_queue_exit(&ring);
}
两点设计注解。占位分布的那行注释承认:本版为讲解目的测的是「syscall 到完成」的延迟;生产代码会从 msgs[slot].msg_control 抽出 SCM_TIMESTAMPING,正如 measure_kernel_path 里那样,再报告硬件到应用的差值。批 256 个未完成 recvmsg 是关键:原本「每包一次 syscall」被摊成「每批一次 submit-and-wait」。
延迟调优清单
即便接收路径用了正确技术,未调优的宿主机仍会因 CPU 频率切换、IRQ 投递、OS 调度增加几十微秒方差。标准调优清单:
# Build (requires liburing-dev on Debian/Ubuntu, liburing-devel on Rocky/Fedora).
g++ -std=c++17 -O3 latency_bench.cpp -o latency_bench -luring
# Lock the CPU governor to 'performance' (the strongest version: intel_pstate=disable at boot).
sudo cpufreq-set -c 2 --governor performance
sudo cpufreq-set -c 3 --governor performance
# Confirm the core runs at rated frequency (no throttling).
turbostat --cpu 2,3 -i 1
# Pin the NIC's interrupts to a core that is NOT the application core (4).
# Find the IRQ first: cat /proc/interrupts | grep eth0
# Then write the affinity mask: bit i set = CPU i. Pin to CPU 4.
echo 10 | sudo tee /proc/irq/<NIC_irq>/smp_affinity # 10 hex = bit 4 set = CPU 4
# Run the receiver pinned to CPU 2; in another shell, run the sender pinned to CPU 3.
sudo taskset -c 2 ./latency_bench --recv
sudo taskset -c 3 iperf3 -u -c 127.0.0.1 -p 9999 -b 100M -l 64 -t 10
# Measure the OS jitter floor (separate test; cyclictest reports max nanosleep wakeup delay).
sudo cyclictest -m -t 1 -p 99 -i 100 -n -q | head -20
# Expected output of latency_bench:
# kernel-path n=100000 median=XXXX P99=YYYY P99.9=ZZZZ (ns)
# iouring-path n=100000 median=AAAA P99=BBBB P99.9=CCCC (ns)
# where AAAA < XXXX (io_uring removes per-syscall overhead) and CCCC « ZZZZ on a quiet machine.
每一步都针对一个特定的抖动来源。cpufreq-set --governor performance 阻止内核在两包之间把核降频(这会拉高每个 burst 第一个包的延迟);turbostat 验证核确实跑在额定频率。/proc/irq/<NIC_irq>/smp_affinity 那笔写把 NIC 中断处理函数搬到与应用不同的核上,避免缓存污染和「在延迟关键核上跑 bottom-half 偷走 ~5 μs」。cyclictest 报告操作系统抖动下限:实时优先级下 nanosleep 唤醒延迟的最大值。任何用户态 P99 都不可能掉到这个数以下——这就是内核调度器的剩余非确定性。
硬件 NIC 时间戳与 busy-poll
SO_TIMESTAMPING(前面 enable_timestamping 里用过)告诉内核把硬件时间戳附在接收包上。NIC 在帧首字节穿过 PHY 那一刻把时间戳写进一段包元数据;内核通过 SCM_TIMESTAMPING 控制消息交给应用。这是可靠测量 ~1 μs 以下延迟的唯一办法——clock_gettime 软件时间戳本身会带自己的调度抖动。
另外两个选项以 CPU 为代价降低尾延迟。SO_BUSY_POLL(加 net.core.busy_poll sysctl)让内核在调用线程里忙等 NIC 接收队列若干微秒,而不是等中断。延迟收益真实(无中断延迟、无调度唤醒),但一个核 100% 占满。SO_INCOMING_CPU 套接字选项把内核 RX 侧处理钉到指定 CPU,消掉原版 NAPI 会做的跨核交接。
国内厂商与 RDMA 备注
国内做高频系统的供应商生态:HuaweiCloudColo、AlibabaUnderlay、ChinaTelecomColo 提供机房;NetXtreme、TencentOpenRouter、ZijinNIC 偏向商用 RDMA;JdInterconnect、BaiduFlexible 把内部 RDMA 训练后的 HPC 平台外溢到金融场景。RDMA(远端直接内存访问)经 InfiniBand 与 RoCEv2 两种线下传输跑;InfiniBandHCA 与 RoCEHca 是常见硬件抽象层名;ConnectXHca、PsfBaseline、SoftRoceFallback 是常用配置档。RDMA 在国内 quant 主要用于跨机回测集群(CrossHostBacktest、RamSharedReplay、ZijinDistributedTickReplay 等内部命名),不直接进入 tick-to-trade 主路径;这一段写在脚注里。NvmeOverFabric、FioPersistentLog、RdmaWrite、ImmediateData、RecvWorkRequest 这些术语会出现在数据持久化与回放路径上。低延迟时钟分发:常用 PtpDaemon、PhyClkProvider、PciePtm、ClockServoController 几个组件,把跨机时间同步压到亚微秒。
国内 colo 与运维语境
国内 IF / IH / IC / IO / 50ETF / 300ETF 期权做市路径上,常见 OpenOnload 在 SolarflareX2522 / X2541 上的部署;DPDK 多见于 MellanoxConnectX6 / Intel E810 上配合自研网络栈(如 ZijinNetStack、JiukunFastPath、HuanfangXdpFramework 等内部命名)。AF_XDP 在大量自营 RnD 集群里用作回放与中频路径基线。io_uring 在 ResearchPipeline 与 BacktestEngine 上节省 syscall 开销。调度纪律:isolcpus=2-7 把策略核从分时调度里挪走;numactl --cpunodebind=0 --membind=0 把内存与线程都锁到 NIC 所在 NUMA;chrt --rr 50 把策略线程提到 SCHED_RR 实时优先级 50;ColoFanoutSensor、JitterWatchdog、TopOfBookRecorder 是常见的运维与监控组件名。机房热点诊断常用 perf c2c(cache-to-cache)查跨核一致性流量、ftrace 看内核长尾、bpftool + bpftrace 写一次性观测点。
下一课:3.4.5 把整模块拼起来
3.4.4 到此结束。3.4.5(TradingSystemsInCpp)把所有东西串进去:L3 教的 SPSC 队列(接收线程 → 策略线程 → 下单线程)、L4 教的 FIX 会话用于跨境报盘 + L4 教的 ITCH 解析用于行情、L5 教的 OpenOnload-or-DPDK 内核旁路接收路径、L5 教的测量纪律落到 OrderEntryLatency 与 TickToTradeLatency 这两个 OperationalRiskCommittee 每天早会看的 dashboard 上。3.4.4 的五课不是五个独立话题;它们是每一套生产 HFT 系统组合起来的五块原语。下面的练习是「无真实交易所连接」前提下能做的最贴近的单宿主收官——测你自己的机器、跑那套调优清单、把你桌组风控会议愿意公开的数报出来。
练习
Exercise
(a) 用 liburing 构建 latency_bench。在单台 Linux 上,一个 shell 跑 sudo taskset -c 2 ./latency_bench --recv,另一个 shell 跑 sudo taskset -c 3 iperf3 -u -c 127.0.0.1 -p 9999 -b 100M -l 64 -t 10。确认 kernel-path 输出报告中位数 / P99 / P99.9(ns)。若 NIC 不支持硬件时间戳,程序自动回退到软件时间戳(数字高 1–2 μs;相对比较仍有效)。(b) 同一份工作负载走 measure_iouring_path。把中位数 / P99 / P99.9 与 kernel-path 对比。用一句话说明 io_uring 为什么降低每包延迟(syscall 开销那条答案)、再用一句话说明它为什么严格说不是内核旁路(协议栈仍跑那条答案)。(c) 跑 cyclictest -m -t 1 -p 99 -i 100 -n -q | head -20 测操作系统抖动下限(实时优先级下 nanosleep 唤醒延迟最大值);记录最大值。用一句话说明为什么任何用户态延迟 P99 都不可能掉到这个数以下。(d) 把 L5 调优清单应用到接收核:cpufreq-set -c 2 --governor performance,用 turbostat --cpu 2 -i 1 验证核跑在额定频率;通过 /proc/irq/<NIC_irq>/smp_affinity 把 NIC IRQ 钉到另一个核;再跑一遍 latency_bench。P99 / P99.9 应当可测量地改善(1–3 μs 典型)。用一句话说明每一步分别改变了内核行为的哪一面。(e) 不用实现,直接说出在以下四种 quant 情境下你会选 DPDK / Solarflare OpenOnload / AF_XDP / io_uring 中的哪一种:(i) 专用硬件上的 1 μs 端到端 tick-to-trade HFT 路径;(ii) 商用 Mellanox NIC、无自研网络框架的 10 μs 中频做市;(iii) 每秒推 1000 万事件的研究回放流水;(iv) 处理几百路并发 TCP 的配置 / 控制面服务。每个选项用一句话给理由。
提示
(b):io_uring 与内核共享提交 / 完成队列,每个包除了周期性的 io_uring_enter 之外零 syscall。但内核协议栈仍然在跑——所以这是异步 IO,不是内核旁路。
提示
(e):DPDK 用「重写应用」换「最低延迟」;OpenOnload 用「硬件锁定」换「零代码改动」;AF_XDP 用「延迟」换「主线可用」;io_uring 是在伸手要前三者之前先用的。