rdtsc 计时:为什么 TSC 恒速、为什么 rdtscp、为什么标定而非假设
HFT 系统里几乎所有 µs 级延迟数字都出自同一个源头:
rdtscp读数 × 一个启动时标定的换算系数。这套做法为什么合理?本文不罗列"最佳实践",而是把三个设计决策(依赖 TSC 不变性、选 rdtscp 而非 rdtsc、实测标定而非假设频率)各自的备选方案摆出来逐一淘汰——每个选择都有它的道理,换一个前提,结论就会不同。
0. 问题定义:测 µs 级延迟,需要一个什么样的钟? #
从需求出发,一个用于延迟测量的时钟必须同时满足四条:
- 读取开销远小于被测量程——测 µs 级区间,读钟本身必须是 ns 级;
- 恒速——两次读数之差必须正比于真实流逝时间,比例系数恒定;
- 单调——不回退;
- 跨核可比——打点发生在不同线程/进程/核上,读数必须在同一把尺上。
候选其实有两个。clock_gettime(CLOCK_MONOTONIC) 走 vDSO,不进内核,~20ns,四条都满足;TSC(Time Stamp Counter)一条指令直读,~10ns。但这不是二选一——vDSO 的 clock_gettime 底层就是"读 TSC + 用内核维护的系数换算",它是 TSC 的包装,不是替代品。选择裸读 rdtscp,买到的是三样东西:开销再减一半(打点在最热的路径上,10ns 也值得省);可以只存原始 tick、离线再换算——热路径连乘除都免了;换算系数自己掌控(§3 会讲为什么这很重要)。代价是换算的正确性要自己负责——这正是本文其余部分的主题。而这一切成立的前提是 TSC 满足第 2、4 条,这并非天经地义——第一节先把它论证清楚。
1. 为什么 TSC 恒速(invariant)——一个计数器不能同时忠于"周期"和"时间" #
TSC 诞生时(Pentium 时代)真的是周期计数器:每个核心时钟周期 +1。那时 CPU 频率固定,周期数 × 周期长度 = 时间,二者等价,没有矛盾。
矛盾由两个硬件演化引入:
- 变频(SpeedStep/Turbo):核心频率随负载在 1.2GHz~4GHz 之间滑动 → 同样的周期数对应不同的时间;
- 休眠(C-states):核心时钟直接停掉 → 周期计数停止,时间却在流逝。
于是出现一个不可回避的分岔:当频率可变时,“数周期"和"数时间"成为两个互斥的语义,一个计数器只能忠于其中一个。 Intel 的选择是时间:
constant_tsc:TSC 的驱动时钟改为从基准晶振(BCLK × 固定倍率,约等于标称基频)派生,与核心 PLL 解耦——核心怎么变频,TSC 恒速;nonstop_tsc:C-state 休眠时 TSC 继续走。
两者合称 invariant TSC(Nehalem 起,~2008 后所有 x86)。而"数周期"的职责移交给了 PMU(CPU_CLK_UNHALTED 等性能计数器)——rdtsc 测时间,PMU 测周期,各司其职。想用 rdtsc 差值衡量"这段代码消耗了多少个周期”,在变频机器上是范畴错误。
跨核可比性也由此推出。两个钟能直接比较,当且仅当它们同源(同一个振荡源驱动)或已被显式对齐:
- 同 socket 的所有核:TSC 由 package 级的同一时钟驱动,RESET 时同时清零 → 天然同步;
- 跨 socket:平台通过
TSC_ADJUST机制对齐,现代服务器平台可靠,但属于"已对齐"而非"同源"——值得进验证清单; - 跨机器:同源前提彻底破灭——两台机器的晶振各走各的,rdtsc 读数没有任何可比性。跨机比较必须换体系(NTP/PTP 墙钟同步),误差量级直接跳三个数量级(ns → µs~ms)。同一张延迟报表里混用同机差值和跨机差值,是测量体系里最常见的错误。
验证这些前提是否成立(设计依赖它,就要显式检查它):
grep -o "constant_tsc\|nonstop_tsc" /proc/cpuinfo | sort -u # 两个都在 = invariant
cat /sys/devices/system/clocksource/clocksource0/current_clocksource # 应为 tsc
2. 为什么 rdtscp 而非 rdtsc——打点的语义与最弱够用的序列化 #
现代 CPU 乱序执行:指令的程序顺序与执行顺序是两回事。rdtsc 是一条普通指令,调度器完全可以让它在程序序中位于它之前的指令还没执行完时先行执行。
后果:你写下
do_work();
uint64_t t = rdtsc(); // 想测"work 完成后的时刻"
实际可能读到的是 work 只执行了一半时的时间——打点位置在程序里,读数时刻却在流水线里。测量误差不是噪声,而是系统性提前。
“打点"这个动作的语义要求是:“此刻” = 程序序中之前的所有工作已完成的时刻。要在乱序机器上兑现这个语义,需要某种序列化。候选有三:
| 方案 | 语义 | 代价 |
|---|---|---|
裸 rdtsc | 无保证,可被提前执行 | 零 |
cpuid; rdtsc | 全序列化(前后都挡) | 流水线完全排空,几百周期,测量严重扰动被测对象 |
rdtscp | 等之前指令全部 retire 后才读数;不阻挡之后的指令 | 恰好等于打点语义,几十周期 |
选择原则:观测者效应——序列化每多一分,流水线气泡多一分,测量本身对被测系统的扰动就多一分。所以取"满足语义的最弱序列化”。rdtscp 挡前不挡后:前面的工作必须完成(语义要求),后面的指令允许提前(语义不关心)——不多不少。(精确说,rdtscp 等的是之前指令执行完毕,并不保证之前的 store 已全局可见——它不是 cpuid 那种完全序列化;给本核打点足够,做跨核可见性实验则要知道这层。等价写法是 lfence; rdtsc;rdtscp 还免费附赠 IA32_TSC_AUX 里的 CPU 编号,可用于检测测量期间被迁核——多数实现忽略这个返回值,其实是个可以白捡的自检。)
3. 为什么标定——频率是环境属性,不写死在代码里 #
拿到 tick 差值后,换算成 µs 需要一个系数:TSC 频率。获取它有四条路:
- 假设:硬编码标称频率(如 3.0GHz);
- 询问内核:内核开机标定的
tsc_khz; - 硬件自报:较新的 CPU 通过
CPUID 0x15/0x16报告晶振频率与比率,可精确得出 TSC 频率; - 实测:对一个已知正确的参考钟,量一段窗口内的 tick 数,算比值。
核心判断:频率是部署环境的属性,不该被写死在代码里。逐条淘汰:
- 假设最差:晶振有 ppm 级制造偏差、不同机型基频不同、虚拟化环境有 TSC scaling,而二进制不会永远跑在你以为的机器上——写死的数字随环境更换静默失效;
- 询问内核:值本身没问题(内核也是自报+标定得来的),但没有稳定的用户态 ABI(要抓 dmesg 或走 perf 接口),不适合作为程序的运行时依赖;
- 硬件自报最准(零测量误差),但不普遍可用——不少机型 CPUID 0x15 的晶振频率字段为空,虚拟化下还可能被 hypervisor 改写;
- 实测普适且稳健:不管面对什么钟、什么缩放,量出来的就是真实生效的比值。
所以稳妥的结构是:实测为基,自报可用时互相校验(两者差超过若干 ppm 即报警,给标定加一道自检)。实测还白得一个可移植性:ARM 的系统计数器(CNTVCT_EL0)频率与 x86 完全不同(几十 MHz 级),同一段标定代码零修改透明兼容——因为它从不假设自己面对的是什么钟。
实践中的标定实现(生产系统常见形态):
uint64_t r1 = rdtscp();
clock_gettime(CLOCK_MONOTONIC_RAW, &ts1);
sleep 200ms;
uint64_t r2 = rdtscp();
clock_gettime(CLOCK_MONOTONIC_RAW, &ts2);
ticks_per_us = (r2 - r1) / elapsed_us(ts1, ts2); // 启动时标定一次,终身使用
三个设计点,每个都有原理依据:
① 参考钟为什么必须是 CLOCK_MONOTONIC_RAW?
标定要的是"纯硬件速率"。CLOCK_REALTIME 会被 NTP 跳变;CLOCK_MONOTONIC 不跳变但会被 NTP 调速(slew,为了追上真时间悄悄加减速)——用它做参考,等于把 NTP 的修正混进了你的换算系数。CLOCK_MONOTONIC_RAW 是唯一不被 NTP 触碰的时钟,才是干净的参考。
② 误差结构:为什么 200ms 窗口就够?
比值 = Δticks / Δt。误差来源是窗口两端 rdtscp 与 clock_gettime 之间的非原子间隙(各几十 ns,加性、固定量级);窗口长度是分母。相对误差 ≈ 边沿抖动 / 窗口 ≈ 50ns / 200ms ≈ 0.3ppm——换算 1 秒的区间才偏差 0.3µs,对 µs 级测量绰绰有余。窗口再拉长收益递减,却拖慢启动。加性误差配长分母,是所有比值测量的通用降误差结构。
但这个分析里藏着一个假设:窗口两端的 rdtscp 与 clock_gettime 之间没有被调度走。万一线程恰好在这两条指令之间被抢占,间隙就不是 50ns 而是毫秒级——200ms 窗口混进 1ms 边沿误差 = 0.5% 的系统性乘法误差,并且终身携带(此后所有延迟数字整体偏 0.5%,且无从察觉)。概率极小、后果很大,典型的尾部风险。标准缓解:标定跑多轮,取窗口最短(或比值中位数)的一轮;或标定期间临时绑核提优先级。单次标定的实现应该补上这几行。
③ 为什么一次标定终身使用? 合法性完全建立在第 1 节的不变性上:因为 TSC 恒速,所以系数不随时间漂移,标定一次即可。注意这个依赖关系——如果跑在没有 invariant TSC 的老机器/某些虚拟化环境上,“标定一次"就是错误设计。设计的正确性依赖某个硬件性质时,这个性质要显式验证(第 1 节的两条命令),而不是默认成立。
4. 边界清单:这套体系在哪里失效 #
| 场景 | 失效原因 | 正确工具 |
|---|---|---|
| 测代码消耗的周期数(IPC/优化分析) | rdtsc 测时间不测周期 | PMU(perf、CPU_CLK_UNHALTED) |
| 跨 socket 打点比较 | “同源"降级为"已对齐” | 验证 TSC_ADJUST / 平台文档 |
| 跨机器打点比较 | 同源前提破灭 | NTP/PTP,且误差量级重新评估 |
| 虚拟机 | TSC offset/scaling 由 hypervisor 决定 | 确认 invtsc 暴露,或退回 vDSO clock_gettime |
| 测量期间被迁核(极端情况) | 读数来自不同核(通常已同步,但自检无害) | 用 rdtscp 附赠的 CPU id 校验 |
5. 收束:三个决策,一条原则 #
- 依赖不变性:硬件已经把"时间"从"周期"里分离出来,顺着这个分离用,别逆着用;
- 选 rdtscp:打点语义要求前序完成,取满足语义的最弱序列化,测量对被测系统的扰动最小化;
- 标定为基:频率是环境属性,不写死在代码里;参考钟选不被 NTP 触碰的;加性误差配长分母,并防住抢占尾部;自报可用时与实测互校;一次标定的合法性来自不变性,而不变性要显式验证。
一句话:测量体系的每个环节,要么建立在已验证的硬件性质上,要么自己实测——不留任何未验证的假设。 时钟是所有延迟数字的地基,地基里的每个假设,都会成为将来某张延迟报表上无法解释的毛刺。
相关文章 #
- 全链路时间戳与延迟归因——这套时钟在系统里的应用层
- CPU cycles 与性能测量——PMU 侧的"数周期"世界
- 虚拟内存与 Page Table——同系列:从硬件机制出发理解系统设计