想知道从收到一条行情到发出订单花了多久,最直接的办法是在两头各读一次时间,再相减。clock_gettime 读一次约 20–30 ns,可交易系统热路径上一段代码常常只有几百 ns,计时本身就占了一成。所以低延迟系统通常直接读 CPU 里的 TSC:一条指令,约 10 ns。直接读硬件计数器,就要自己回答三个问题:读到的数代表什么,它是不是在你写的那个位置读的,以及这个数怎么换算成纳秒。第 1 部分讲前两个和读数时在哪个核上,换算留到第 2 部分。
1. 读 TSC:读到的是什么,什么时候读到
1.1 TSC(time stamp counter):每个核里一个按固定频率走的计数器
TSC 是每个逻辑核里的一个 64 位计数器,开机时清零,之后一直往上加。它在硬件地图上没有单独的框,就是核里的一个寄存器。
早期的 CPU 上,TSC 跟着核心时钟走:降频时它变慢,进深度睡眠时它停下,这样的计数器没法拿来计时。现在的 CPU 都是 invariant TSC(不变的 TSC):按一个固定频率走,不管核心在降频、睡眠还是 Turbo。这个频率通常接近 CPU 的标称频率,例如 3.593 GHz。Linux 下 /proc/cpuinfo 的 flags 里有 constant_tsc 和 nonstop_tsc,就说明是这种。
所以 TSC 数的是时间,不是核心实际跑了多少个周期。核心开着 Turbo 跑在 4.5 GHz、TSC 按 3.0 GHz 走时,100 个 TSC tick 是 33 ns,这段时间里核心实际跑了 150 个周期。想数核心周期,要用 perf 的 cycles 事件,它来自性能计数器,不是 TSC。
频率变了,tick 怎么变,要看这段时间由谁决定:
- 纯计算:要跑多少个周期由指令和依赖决定,不随频率变。核心变快,花的时间就短,TSC 读到的 tick 跟着变少。例如 480 个周期:核心 2.4 GHz 时花 200 ns,按 3.6 GHz 走的 TSC 读到 720 tick;核心升到 3.6 GHz 时约 133 ns,读到 480 tick。
- 等内存:时间由内存延迟决定,不随核心频率变。tick 基本不变,核心在这段时间里空转的周期反而变多。
- 所以 tick 变少不一定是代码变快了,也可能只是核心频率变高了。比较两次测量,先把频率固定住(关掉省电和 Turbo,或者绑在不降频的核上)。
64 位按 3.6 GHz 加,要约 160 年才会回绕,不用担心溢出。同一颗 CPU 上的各个核从同一次复位开始计数,读数是同步的,Linux 开机时还会检查一遍,不同步就打出 tsc: Marking TSC unstable,改用别的时钟源。1.4 会用到这一点。
1.2 rdtsc:读一次 6–12 ns,AMD 上读数约 10 ns 才跳一次
- 测一段代码多快,最直接的办法是两头各读一次 TSC,再相减。
- 这个差可不可信,取决于两件事:读一次要多久,读数能分辨多短的时间。
怎么读
rdtsc指令把 64 位读数分两半放:低 32 位在 EAX,高 32 位在 EDX。这是 32 位年代留下来的,那时一个寄存器装不下 64 位。
inline uint64_t rdtsc() noexcept { unsigned int lo, hi; __asm__ __volatile__("rdtsc" : "=a"(lo), "=d"(hi)); return (uint64_t(hi) << 32) | lo; }- 自己写汇编时,
volatile不能漏。漏了,编译器会把它当成结果固定的计算,把两次读合成一次:实测 Clang 18-O2把“读两次相减”编成了返回 0。 - 优先用编译器自带的
__rdtsc()(头文件<x86intrin.h>):编出来是同一条指令,不会被合并。
为什么不用
clock_gettimeclock_gettime在用户态(vDSO)里读的其实也是 TSC。- 但它每次多一次函数调用,还要用乘法和移位换算成纳秒,一次 20–30 ns。
rdtsc只有读这一步,约 20–40 个周期,6–12 ns。- 所以热路径上只记 tick,换算留到事后。
读数是一级一级跳的
- 按频率算,一个 tick 是 1 / 3.593 GHz ≈ 0.28 ns。
- 但在 AMD Zen 系列(Ryzen、EPYC)上实测,读数约每 10 ns 才跳一次,一次加 36(36 × 0.28 ≈ 10 ns)。Intel 上一般看不到这种固定台阶,连读几次的差各不相同。手上的机器是哪种,连读几次就知道。
- 同一个 10 ns 里再读,只比上一次多 1。这个 1 只是为了让读数不重复,不代表真过了 0.28 ns。
- 台阶是计数器自己的更新方式,和
rdtsc读得快慢无关:就算读一次不花时间,读到的也是台阶。3.593 GHz ÷ 36 ≈ 100 MHz,看起来读数是跟着一个约 100 MHz 的参考时钟、每拍补 36(从数字推出来的,没有查到文档)。 - 为什么不每个 tick 跳一次:
- 要求:TSC 在所有核上要一样快、读数对得上,核降频、睡眠时也不能停(1.1)。
- 核自己的时钟靠不住:它会变频,睡眠时会停。
- 另起一个 3.6 GHz 的时钟也不划算:每个核都要一直开着它,费电,各核之间还要对齐到 0.28 ns。
- 实际做法:TSC 跟着一个慢、但一直开着的参考时钟走,每来一拍加一个固定的数(这里是 36),一次补够这一拍的 tick。
- Intel 也是这个思路:手册在 CPUID 的
0x15叶里写明“TSC 频率 = 晶振频率 × 一个比值”。Intel 读出来看不到台阶,说明它在两拍之间做了细分,怎么细分没有公开。
- 能不能更细:单次读 TSC,分辨率就是一跳,这是硬件定的,软件改不了。要更细只有两条路:
- 不看单次:重复跑再除,或者多次取平均(下面第 4 块),分辨的是平均值。
- 不用 TSC,改数核心周期:用性能计数器(PMU)的“核没停的周期”事件,
rdpmc读,每个周期加 1。代价是要内核先开好计数器、允许用户态读,虚拟机里常常拿不到;而且它数的是周期,不是时间,核一变频就换算不成纳秒(1.1)。测几个周期的指令延迟,用的就是这条路。
对测量的影响
- 量不准
rdtsc自己:连读两次的差几乎总是 36,正好一跳,只能看出读一次不超过约 10 ns。 - 任何一次测量,读数差都只有两种可能:区间里碰上了 n 次跳变,或者 n + 1 次(见上图的情况 A、B)。所以单次测量最多差一跳,约 10 ns,和被测代码多长无关。碰上几次跳变取决于起点落在台阶的哪个位置,这是随机的,多测几次取平均,多算和少算会互相抵掉,平均值没有偏差。
- 要看的是这 10 ns 占被测时间多少:测 1 µs 的代码,单次最多差 1%;测 100 ns,单次最多差 10%,比如分不出 100 ns 和 105 ns,要比这种差别只能靠多次平均或重复跑(见下面两种办法);测 5 ns,误差比代码本身还大。所以 100 ns 上下的代码不看单次读数,比 10–20 ns 还短的单次测量,读出来的主要是量化误差。
- 换到没有台阶的 Intel 上,单次也好不了多少:不加栅栏时乱序会让读数提前或推后十几个周期,加了栅栏读一次又更重(1.3),边界本来就有约 10 ns 的不确定。所以约 10 ns 是单次打点的分辨率,不只是 AMD 的问题;线上打点测的是几百 ns 到几 µs 的整段,这点误差只占几个百分点。
- 读数里还多了读指令自己:两次读之间什么都不放,差也有约 36。这部分大致固定,测之前先空测一次,把这个底数减掉。
- 测短代码,有两种办法:
- 让代码在两次读之间重复跑 1000 次,再除以 1000。误差还是一跳,但摊到每次只剩约 0.01 ns。
- 测很多次,取平均值,不取中位数。起点落在一级台阶的哪个位置是随机的,5 ns 的代码有一半的次数碰上跳变,平均下来 (1 + 36) / 2 = 18.5 tick ≈ 5.2 ns,接近真实值。中位数会被其中一种结果带偏:3 ns 的代码只有 30% 的次数碰上跳变,中位数是 1 tick,读成 0.28 ns。
- 基准测试报出的 52.6 ns、52.5 ns 这种小数点后的差别,就是靠第一种办法来的:工具(例如 Google Benchmark)把代码循环跑几十万到上百万次,总时间除以次数,一跳的误差摊到每次只剩十万分之一 ns 左右。这时结果准不准,取决于每轮之间的波动(频率、缓存、代码对齐),不再取决于计时分辨率;0.1 ns 的差别是不是真的,要多跑几轮,看区间是否重叠。
- 中位数、p99 这类分位数,留给比一跳长得多的区间,比如一次完整的处理要几百 ns 到几 µs,这时量化误差只占几个百分点。
- 乱序执行带来的误差是另一回事,见 1.3。
- 量不准
1.3 乱序执行:rdtsc 不等前面的指令做完
边界为什么模糊
- 乱序执行:CPU 不按代码顺序一条条做完(硬件地图上“重命名 + ROB”那一格)。
rdtsc不等它前面的指令做完就读,它后面的指令也可能比它先开始。 - 只读两次相减,看这段:
uint64_t t0 = __rdtsc(); int r = parse(msg); // 被测的代码 uint64_t t1 = __rdtsc();- 开头:
parse的前几条指令可能在读t0之前就开始了。 - 结尾:读
t1时,parse的最后几条可能还没做完。 - 结果:两头都往里缩,被测代码只有几十 ns 时,量出来的数比真实的短。
- 乱序执行:CPU 不按代码顺序一条条做完(硬件地图上“重命名 + ROB”那一格)。
修法:用栅栏把边界钉住
lfence:等它前面的指令都做完,它后面的指令才能开始。rdtscp:rdtsc的变体,等前面的指令都执行完、前面的读都完成才读,但不拦后面的指令。- 开始和结束各写成这样:
inline uint64_t tsc_begin() { // 开始打点:等前面的指令做完再读 _mm_lfence(); uint64_t t = __rdtsc(); _mm_lfence(); // 后面的指令不许提前开始 return t; } inline uint64_t tsc_end() { // 结束打点:rdtscp 等前面的指令做完才读 unsigned aux; uint64_t t = __rdtscp(&aux); _mm_lfence(); // 后面的指令不许提前开始 return t; }- 开始打点:第一道
lfence让前面的代码先做完,第二道不让被测代码在读数之前开始。 - 结束打点:
rdtscp等被测代码做完才读,后面的lfence不让后面的代码提前开始。 rdtscp只管前面一头:它等前面的指令做完才读,但不拦后面的指令,后面的代码可能在它读数之前就开始执行。所以它后面还要一道lfence。- 后面这道什么时候要紧:连续打点时,一次读数既是上一段的终点,又是下一段的起点(
t0、t1、t2依次读),两头都得钉住。只测一段、后面不再测时,它影响很小,但加上不吃亏,所以统一这样写。 - 开始打点也能用
rdtscp:把开头的lfence+rdtsc换成一条rdtscp,效果一样,都是等前面的指令做完再读,后面那道lfence照样要。好处是顺便拿到aux,可以查迁移(1.4 的代码就是这么写的)。
Intel 和 AMD 的差别
- Intel:
lfence的这个效果是手册规定的。 - AMD:要操作系统打开一个开关,
lfence才会这样等。较新的 Linux 和 Windows 默认都打开了,老系统上 AMD 建议用mfence。 - 还要前面的写对别的核可见:Intel 手册给的写法是
mfence; lfence; rdtsc。
- Intel:
栅栏的代价与取舍
- 额外开销:实测空测时,带栅栏的两次读数之差从 1 跳变成 2 跳,约 20 ns,比不带栅栏多一跳。
- 挡住重叠:栅栏让被测代码不能和前后的代码重叠执行,测到的已经不完全是平时跑的样子。
- 栅栏让边界准了,代价是测量本身更重,被测代码也不再和前后重叠。
- 怎么选:
- 微基准:测几十 ns 的短代码,边界误差和代码本身一样大,加栅栏。
- 线上热路径:测的是几 µs 的整段,十几 ns 的边界误差可以接受,通常用不带栅栏的
rdtsc。
编译器也会挪代码
- 整段删掉:被测代码的结果没人用,编译器可能把它删了。
- 挪到外面:不依赖内存的计算,可能被挪到两次读数之外。
- 修法:让结果被用到,比如写进一个
volatile变量。
1.4 rdtscp 和跨核:读数的同时知道自己在哪个核
aux:顺便读出在哪个核- 读了什么:
rdtscp除了读 TSC,还把IA32_TSC_AUX寄存器读进 ECX,就是上面代码里的aux。 - 里面是什么:Linux 把每个核的这个寄存器设成“NUMA 节点号 × 4096 + 核号”,vDSO 里的
getcpu也是靠它。拆开就是:
unsigned core = aux & 0xfff; // 低 12 位:核号 unsigned node = aux >> 12; // 其余位:NUMA 节点号- 读了什么:
用它查迁移:两头的
aux不一样就扔掉- 怎么查:开始和结束都用
rdtscp,比两次读到的aux。1.3 的tsc_begin用的是rdtsc,读不到aux,要查迁移时开始那次也换成rdtscp:
unsigned a0, a1; uint64_t t0 = __rdtscp(&a0); // 开始:rdtscp 自己等前面做完 _mm_lfence(); // 被测代码不许提前开始 int r = parse(msg); uint64_t t1 = __rdtscp(&a1); // 结束 _mm_lfence(); bool migrated = (a0 != a1); // 中途换过核- 不一样说明什么:线程中途被挪到了别的核,这段时间里含一次迁移(#1 1.1),两个核的读数也未必完全对得上。
- 怎么处理:这个样本扔掉,或者单独标出来。
- 热线程:按 #1 绑死在一个核上,就不会出现这种情况。
- 怎么查:开始和结束都用
跨核相减:同一台机器上可以
- 场景:生产者在 2 号核往队列里写消息时打一个时间戳,消费者在 4 号核取出时再打一个,相减得到消息在队列里待了多久。
- 为什么能减:靠 1.1 说的同一台机器上各核的 TSC 是同步的。
- 要留意:同步也不是分毫不差。排队时间很短时,差值可能是很小的负数。这说明排队时间短到在同步误差以内,不是程序出错。
不能相减的情况:同步这个前提不成立
- 前提:跨核相减成立,全靠各核的 TSC 同步(1.1)。下面几种情况,这个前提不成立。
- 这台机器上 TSC 本身没同步:固件(BIOS)写过 TSC,或者多路服务器的几个插槽没对齐。Linux 开机检查出来会打出
tsc: Marking TSC unstable(1.1),dmesg里能看到,这时跨核相减就不能信。 - 虚拟机:虚拟机监控程序可能给 TSC 加偏移,或者在虚拟 CPU 挪到别的物理核时出偏差,跨核相减就不可靠了。
- 两台机器:根本不是同一套计数器,谈不上同步,没法相减。要跨机器对时间,用 PTP 对过时的系统时钟(1.5 第 4 块)。
确认机器支持
- CPUID:
0x80000001的 EDX 第 27 位是rdtscp,0x80000007的 EDX 第 8 位是 invariant TSC。 - Linux 上更方便:直接看
/proc/cpuinfo里有没有这三个标志。
grep -o 'rdtscp\|constant_tsc\|nonstop_tsc' /proc/cpuinfo | sort -u- CPUID:
1.5 热路径打点:先记 tick,换算和输出交给别的线程
打点本身也花时间
- 花在哪:读一次 TSC 约 10 ns,记下来又要一次写内存或者入队。
- 热路径上只做这两步:把差值以 tick 为单位存进预先分配好的数组,或者交给 #4 的异步日志。
- 其余的挪走:换算成纳秒、格式化、写文件,都在别的线程上做。
常见写法:一对宏
#define START_MEASURE(TAG) const auto TAG = Common::rdtsc() #define END_MEASURE(TAG, LOGGER) \ do { \ const auto end = Common::rdtsc(); \ LOGGER.log("RDTSC " #TAG " %\n", (end - TAG)); \ } while (false)START_MEASURE:读一次 TSC,存进一个以TAG命名的局部变量。END_MEASURE:再读一次,把差值交给日志。LOGGER.log:只把参数放进一个无锁队列,日志线程再去格式化。- 单位:日志里记的
end - TAG是 tick 数,不是纳秒。 - 别在这里拼时间字符串:在
END_MEASURE里顺手调一个“取当前时间字符串”的函数,等于把格式化又放回了热路径。要记时间,就记原始的数,交给日志线程去转。 - 代价:每加一个打点,热路径就多几十 ns。所以线上只在关键的几处打,或者按比例抽样。
跨线程、跨机器:记绝对时间戳
- TSC 区间管不到的:它适合同一个线程里的一段代码。一笔交易从收到行情到发出订单,要经过行情线程、策略线程、下单线程,常常还跨进程。
- 做法:每个环节记一个绝对时间戳,事后按订单把各环节的记录拼起来,就知道每一段各花了多久。这种记录叫 tick-to-trade(TTT):
#define TTT_MEASURE(TAG, LOGGER) \ do { \ const auto TAG = Common::getCurrentNanos(); \ LOGGER.log("TTT " #TAG " %\n", TAG); \ } while (false)getCurrentNanos():返回从 1970 年起的纳秒数,也就是系统时钟。
TSC 还是系统时钟
- 同一台机器的几个进程:其实也能共用 TSC,各核的 TSC 是同步的(1.1)。
- 要和交易所回报、别的机器对上:TSC 对不上,得用各机器都对过时的系统时钟(用 PTP 同步)。
- 同一个线程里的区间用 TSC,跨组件、跨机器拼时间线用统一的绝对时钟。
看分布,不看单次
- 单次意义不大:同一段代码,这一次 300 ns,下一次碰上缓存未命中就是 2 µs。
- 要看的:很多次测量的分布,也就是中位数、99 分位、最大值。这是下一节交易系统性能衡量的内容。
2. 换算和校准:tick 怎么变成 ns
- 第 1 部分记下的都是 tick。要变成 ns,得知道 TSC 一秒走多少个 tick,也就是 TSC 频率。
- 这个数错了,每个时间都按同一个比例错,而且看不出来。所以先讲换算本身,再讲频率从哪来、怎么自己量、量完放在哪。
2.1 换算:tick 除以 TSC 频率
式子
$$t_{\text{ns}} = \frac{\Delta \text{tick}}{f_{\text{TSC}}} \times 10^9$$- 只缺一个数:读数差 $\Delta \text{tick}$ 已经有了,缺的是 TSC 频率 $f_{\text{TSC}}$。
- 频率错多少,时间就错多少:按 3.0 GHz 算、实际是 3.593 GHz,每个时间都多算约 20%(3.593 ÷ 3.0 ≈ 1.198)。这种错不会报错,数看起来照样合理。
- 在哪算:热路径只记 tick(1.5),换算在日志线程里做,或者事后做。
整数先乘后除会溢出
- 常见写法:
ticks * 1000000000ULL / freq,先乘 $10^9$ 再除,保住精度。 - 溢出点:乘出来的数要放进 64 位,所以 $\Delta \text{tick}$ 最多约 $1.8 \times 10^{10}$($2^{64} \div 10^9$)。按 3.6 GHz 算,区间超过约 5 秒就溢出,结果变成一个小得离谱的数,也不报错。
- 改法:用 double 算,double 有 15–16 位有效数字,ns 级够用;或者先算好
ns_per_tick = 1e9 / freq,换算时乘一次。
- 常见写法:
2.2 频率从哪来:先查,查不到再量
内核开机时量过一遍
- 怎么看:
dmesg | grep -i tsc,会有一行tsc: Refined TSC clocksource calibration: … MHz,或者tsc: Detected … MHz processor。 - 怎么来的:开机时内核拿别的时钟(HPET、PIT 这类)对着 TSC 量了一段。
- 怎么用:部署时读一次,写进配置。
- 怎么看:
Intel:CPUID 0x15
- 给的是什么:晶振频率(ECX)和一个比值(EBX ÷ EAX)。TSC 频率 = 晶振频率 × 比值,就是 1.2 第 3 块说的“慢晶振加每拍补固定数”。
- 可能查不到:有的型号 ECX 是 0,不报晶振频率;虚拟机里整个叶子可能都是 0(实测云上一台 Intel 虚拟机,0x15、0x16 都返回 0)。
uint64_t freq_from_cpuid_15() { unsigned eax, ebx, ecx, edx; if (!__get_cpuid(0x15, &eax, &ebx, &ecx, &edx)) return 0; // 没有这个叶子(多数 AMD) if (eax == 0 || ebx == 0 || ecx == 0) return 0; // 没报比值或晶振频率(虚拟机里常见) return uint64_t(ecx) * ebx / eax; }__get_cpuid:先查这个叶子存不存在,不存在返回 0,不会读到垃圾。- 查不到就返回 0:让调用方知道要换别的办法,不要退回一个写死的默认值。
别用 CPUID 0x16
- 它是什么:处理器的标称基础频率(MHz),是给人看的,Intel 没保证它等于 TSC 频率。
- AMD 上没有:AMD 没有定义这个叶子,查不到。
- 后果:拿它当 TSC 频率、查不到再退回 3.0 GHz 的默认值,在 3.593 GHz 的机器上,每个时间都多算约 20%。
都拿不到就自己量:对着
steady_clock校准,见 2.3。
2.3 自己校准:对着 steady_clock 量一段
做法
- 两头各记一对:开始时记下 TSC 读数和
steady_clock时间,等一段时间 $T$,结束时再记一对。 - 算频率:
- 两头各记一对:开始时记下 TSC 读数和
误差从哪来:两头的配对误差
- 配对误差:TSC 和
steady_clock不是同一瞬间读的,中间隔着一次now()(20–30 ns,1.2),偶尔还会被打断。记作 $\delta$。 - 摊到基线上:两头各有一个 $\delta$,频率的相对误差大约是
- 例子:两头合计 60 ns,基线 100 ms,相对误差 $60 \div 10^8 = 6 \times 10^{-7}$,也就是 0.6 ppm。拿这个频率换算一段 1 ms 的区间,只差 0.6 ns。
- 所以基线越长越准。100 ms 级的基线,误差已经远小于 1.2 的一跳(约 10 ns)。
- 配对误差:TSC 和
把配对误差压小:两次 TSC 夹一次 now()
tsc_arr[0] = rdtsc(); for (int i = 0; i < try_times; ++i) { // try_times = 5 ns_arr[i] = std::chrono::steady_clock::now(); tsc_arr[i + 1] = rdtsc(); } auto min_tsc_diff = tsc_arr[1] - tsc_arr[0]; auto ave_tsc = average(tsc_arr[0], tsc_arr[1]); auto ns = ns_arr[0]; for (int i = 1; i < try_times; ++i) { auto tsc_diff = tsc_arr[i + 1] - tsc_arr[i]; if (tsc_diff < min_tsc_diff) { // 找夹得最紧的一组 min_tsc_diff = tsc_diff; ave_tsc = average(tsc_arr[i], tsc_arr[i + 1]); ns = ns_arr[i]; } } return {ave_tsc, ns};- 夹住:
now()读到的那一刻,一定落在它前后两次rdtsc之间。取两次读数的中点当作配对的 TSC,误差不超过夹缝的一半。 - 连夹 5 次、取最紧的:夹缝最小的那次,说明中间没被中断或调度打断。
- 夹缝多宽:一次
now()加一次rdtsc,最紧的约 30–40 ns,所以中点误差不到 20 ns。
- 夹住:
对着哪个时钟量
steady_clock:单调,不会往回跳(Linux 上是CLOCK_MONOTONIC)。system_clock会跳:NTP 校时、有人改系统时间,它会整段往前或往后跳。校准的那 100 ms 里跳一下,频率就错了。high_resolution_clock别用:在 libstdc++(GCC 默认的标准库)里它就是system_clock(实测std::is_same为真),在 libc++ 里又是steady_clock。同一行代码,换个标准库意思就变了。- 还要更干净:NTP 也会微调
steady_clock走的快慢,只调快慢、不跳。要连这个也避开,用clock_gettime(CLOCK_MONOTONIC_RAW, …)。
2.4 两种计时器:校准放在什么时候
TSCTimer:第一次取结果时才校准
- 启动时记一对:
base_point是静态变量,程序启动时就用 2.3 的夹法记下一对(TSC,steady_clock)。 - 第一次
get()再记一对:两对一减,算出cycle_to_ns_scale(每个 tick 多少 ns),存进一个原子变量,之后不再算。
static void calibrate() { auto [end_tsc, end_time] = TimerHelper::get_tsc_ns_pair(); auto& [begin_tsc, begin_time] = TimerHelper::base_point; std::chrono::nanoseconds duration = end_time - begin_time; auto scale = static_cast<double>(duration.count()) / static_cast<double>(end_tsc - begin_tsc); double expected = 0.; cycle_to_ns_scale.compare_exchange_strong(expected, scale, std::memory_order_relaxed); }compare_exchange_strong:几个线程同时第一次get(),只有第一个写进去的算数,其余的用它的结果。- 好处:
start/end只读 TSC,不碰别的时钟;基线是“程序启动到第一次get()”,通常很长,误差很小。 - 代价:第一次
get()要做 5 次now(),约 100–150 ns,落在热路径上就是一次尖刺。程序刚启动就调get(),基线又太短。 - 怎么用:启动阶段等一会儿(比如 100 ms 以上),主动调一次
get(),把校准做在热路径之外。
- 启动时记一对:
TSCTimer:一次读数同时结束一段、开始下一段
timer.start(1); some_work(20); timer.start(0, timer.stop, 1); // 一次读数:开始区间 0,同时结束区间 1 some_work(30); timer.end(0);- 累计:每个区间一个槽,
end时做cycles_arr_[i] += tsc - last_tsc_arr_[i],同一个区间测多次会加在一起。 - 一次读数切换:就是 1.3 说的连续打点,一个读数既是区间 1 的终点,又是区间 0 的起点。少读一次,两段之间也没有缝。
- 没加栅栏:
start/end里只有rdtsc或rdtscp,适合 ms 级的长区间;测几十 ns 的代码,要按 1.3 加lfence。
- 累计:每个区间一个槽,
HardwareTimer:构造时就校准好
- 构造时做完:先用 CPUID 查有没有 TSC、
rdtscp、invariant TSC,再校准 5 次、每次 100 ms,取中位数。构造一次要 0.5 秒以上,所以只能放在启动阶段。 - 每次校准查迁移:两头都用
rdtscp,aux不一样就重来(1.4)。 - 取中位数:5 次里有一两次被打断,中位数不受影响。
- 单次校准可以这样写:
// 对着 steady_clock 量 baseline,返回 TSC 频率(Hz);连续 max_retry 次都换过核就返回 0 double calibrate(std::chrono::milliseconds baseline, int max_retry = 5) { using clock = std::chrono::steady_clock; for (int attempt = 0; attempt < max_retry; ++attempt) { unsigned a0, a1; auto c0 = clock::now(); uint64_t t0 = __rdtscp(&a0); std::this_thread::sleep_for(baseline); uint64_t t1 = __rdtscp(&a1); auto c1 = clock::now(); if (a0 != a1) continue; // 中途换过核:这次不算,重来 double ns = std::chrono::duration<double, std::nano>(c1 - c0).count(); return double(t1 - t0) * 1e9 / ns; // double 算,不会溢出 } return 0; // 一直在换核:先绑核再校准 }- 对着
steady_clock:理由见 2.3 第 4 块。 - 用 double 算:避开 2.1 的整数溢出。
- 重来要有上限:线程没绑核、机器又忙时,可能一直被挪,没有上限的重来就会一直转。超过几次就返回 0,让调用方先绑核(#1)再校准。
- 查不到频率就报错:按 2.2,不要退回写死的默认值。
- 构造时做完:先用 CPUID 查有没有 TSC、
两种怎么选
- TSCTimer:校准晚、开销小,适合嵌在程序里随时测几段。
- HardwareTimer:启动时付 0.5 秒,换来能检查的校准结果(查迁移、取中位数),适合先确认机器和频率再开工。
- 两种都把校准放在热路径之外,热路径上只读 TSC。
2.5 虚拟机里的 TSC
读数被改过
- 偏移:虚拟机监控程序给每台虚拟机的 TSC 加一个偏移,让它看起来从虚拟机开机时算起。
- 缩放:虚拟机迁到 TSC 频率不同的物理机上,读数要按比例缩放。CPU 支持 TSC scaling 时由硬件做;不支持时只能把
rdtsc拦下来模拟,一次读要进出虚拟机,慢到 µs 级。
频率不好拿
- CPUID 可能全是 0:0x15、0x16 都查不到(2.2)。
- 内核知道:虚拟机里的内核从虚拟机监控程序拿到频率,
dmesg里照样有tsc: Detected … MHz。 - 所以在虚拟机里:先看内核的值,再按 2.3 自己量一遍核对。
时钟源可能被换掉
- 怎么看:
cat /sys/devices/system/clocksource/clocksource0/current_clocksource,虚拟机里常见tsc或kvm-clock。 - 被判不可靠时:内核退回 HPET 这类时钟源,
clock_gettime每次都要访问芯片组,从 20–30 ns 涨到约 0.5–1.5 µs(#19 4.1)。 tsc=reliable:让内核跳过对 TSC 的核对,不会因为一次抖动就把它判掉(#19 4.2)。确定 TSC 没问题时才加。- 对校准的影响:
rdtsc照样能读,变的是steady_clock:对照的时钟每次读要 µs 级,配对误差跟着变大,基线要相应拉长。
- 怎么看: