你以为 __rdtsc() 在数 CPU 周期,其实它可能只是在看表
前段时间,我分别用 std::chrono 和 __rdtsc() 测量同一类计算,得到了两组看起来完全不在一个世界里的结果:
chrono delta = 29224041600
rdtsc delta = 70798114701第一反应很容易是:CPU 执行了七百多亿个周期,而 chrono 测出了两百多亿纳秒。把两者一除,甚至还能得到一个看起来很像 CPU 主频的数。
但这一步其实埋着一个常见误区:
RDTSC返回的是 Time Stamp Counter 的 tick,不一定是 CPU 核心真正执行过的 cycle。
在现代 x86 处理器上,TSC 更像一只位于 CPU 内部、读取成本很低的参考时钟。它非常适合测量时间间隔,却不能直接回答“这段代码消耗了多少个核心周期”。更麻烦的是,即使时钟本身准确,乱序执行和编译器优化仍然可能把待测代码挪出我们以为的计时区间。
所以,__rdtsc() 并不是不能用。恰恰相反,它是研究微小延迟时很有价值的工具。只是使用它之前,必须先弄清楚它到底测量了什么。
三个经常被混为一谈的量
讨论 CPU 计时时,至少要区分下面三个概念。
1. 经过了多长时间
std::chrono::steady_clock 主要回答这个问题:从开始到结束,单调时钟经过了多久。
const auto begin = std::chrono::steady_clock::now();
work();
const auto end = std::chrono::steady_clock::now();如果线程被操作系统抢占了 2 毫秒,这 2 毫秒仍会包含在结果中。因为对墙上时间来说,程序确实等待了这么久。
2. TSC 增加了多少 tick
__rdtsc() 生成 x86 的 RDTSC 指令,读取一个 64 位时间戳计数器。微软文档也将其返回值描述为 tick count,并提醒不同硬件代际对 TSC 的解释存在差异。
const std::uint64_t begin = __rdtsc();
work();
const std::uint64_t end = __rdtsc();这个差值表示测量期间 TSC 增加了多少,并不天然带有“纳秒”单位。
3. 核心实际经历了多少周期
这通常需要硬件性能监控单元,也就是 PMU。Linux 的 perf 可以统计 cycles、ref-cycles、instructions 等事件:
perf stat -e cycles,ref-cycles,instructions ./benchmark这里的 cycles 更接近核心处于非暂停状态时经历的实际周期,可能受到动态频率和睿频影响;ref-cycles 则使用参考频率。具体事件语义仍需根据处理器型号确认。
三个数字都可能是正确的,只是它们回答的问题不同。
为什么 TSC 不跟着睿频一起加速?
早期 x86 处理器上的 TSC 与核心时钟关系更直接。当处理器调整频率或进入某些休眠状态时,计数行为可能随之变化。这让跨时间、跨核心测量变得很麻烦。
现代处理器通常支持 Invariant TSC。它以固定速率递增,不会因为 P-state、C-state 或 T-state 的变化而跟着改变。处理器是否声明这项能力,可以通过 CPUID.80000007H:EDX[8] 检查。
假设一颗 CPU 的 TSC 参考频率约为 2.5 GHz:
核心降频到 1.2 GHz,TSC 仍按固定速率走;
核心睿频到 4.8 GHz,TSC 也不会突然加速一倍;
线程被暂停,TSC 仍继续前进。
因此,在支持 invariant TSC 的机器上:
TSC delta / elapsed time更接近 TSC 的固定参考频率,而不是测量期间核心的实时主频。
这也解释了为什么有时用 rdtsc_delta / nanoseconds 算出的“主频”非常稳定,却和任务管理器显示的动态频率对不上。你算出的往往不是核心当时跑了多快,而是这只参考时钟每秒走多少格。
需要注意的是,TSC 保持恒定速率不等于所有插槽、所有虚拟 CPU 的 TSC 必然完美同步。现代操作系统和虚拟机通常会尽力提供稳定时间源,但严谨的微基准仍应关注 CPU 迁移与虚拟化环境。
第一层陷阱:CPU 不一定按源码顺序执行
下面这段代码看起来有一道清晰的计时边界:
const auto begin = __rdtsc();
work();
const auto end = __rdtsc();但 RDTSC 不是完整的序列化指令。现代 CPU 会乱序执行,只要最终的架构状态与顺序执行一致,就可以让后面的指令提前开始,也可以让前面的慢指令稍后完成。
这会带来两个方向的误差:
work()的部分指令可能在第一次读 TSC 之前就已开始;第二次读 TSC 可能在
work()的部分操作完全结束前执行。
最终测出的区间可能偏短,也可能包含本不希望计入的工作。
解决思路是在计时边界加入有明确排序语义的指令。Intel 文档对常见指令的关键差异可以概括为:
对于现代 Intel x86 上的普通用户态微基准,一种较完整的结构是:
开始:LFENCE -> RDTSC -> LFENCE
待测代码
结束:RDTSCP -> LFENCE这不是对所有 x86 厂商和所有处理器代际都无条件成立的万能模板。真正严谨的基准测试,应依据目标 CPU 的官方手册确认 LFENCE、RDTSC 与 RDTSCP 的排序语义。
第二层陷阱:编译器也会移动代码
即使 CPU 完全按照我们期待的顺序执行,编译器仍可能在生成机器码之前改变程序。
来看一个极端例子:
const auto begin = __rdtsc();
int sum = 0;
for (int i = 0; i < 1000; ++i) {
sum += i;
}
const auto end = __rdtsc();
std::cout << end - begin << '\n';变量 sum 没有被使用。开启优化后,编译器可能发现整个循环对程序可观察行为没有影响,于是直接将其删除。此时我们测到的主要是两次读 TSC 和周围代码的开销。
即使最后打印 sum,编译器也可能在编译期算出答案,或者把循环改写成完全不同的实现。
因此,计时代码必须同时防住两类重排:
编译器重排:发生在程序运行之前
CPU 乱序执行:发生在程序运行期间硬件栅栏主要约束处理器,不能自动替你设计一个不会被优化掉的基准测试。反过来,编译器屏障也不能让 CPU 停止推测执行。
实际测试中,可以让结果逃逸到编译器无法轻易消除的位置,使用成熟基准测试框架提供的 DoNotOptimize、ClobberMemory 一类设施,并查看最终汇编确认待测代码确实存在。
一个可运行的 x86 C++ 示例
下面的示例使用常见的 LFENCE + RDTSC 作为开始边界,使用 RDTSCP + LFENCE 作为结束边界,并返回 TSC_AUX 以便检查两次读取是否发生在不同的逻辑 CPU 上。
#include <atomic>
#include <cstdint>
#include <iostream>
#include <numeric>
#include <vector>
#if defined(_MSC_VER)
#include <intrin.h>
#else
#include <x86intrin.h>
#endif
struct TscSample {
std::uint64_t ticks;
unsigned int aux;
};
[[nodiscard]] std::uint64_t tsc_begin() {
std::atomic_signal_fence(std::memory_order_seq_cst);
_mm_lfence();
const std::uint64_t value = __rdtsc();
_mm_lfence();
std::atomic_signal_fence(std::memory_order_seq_cst);
return value;
}
[[nodiscard]] TscSample tsc_end() {
std::atomic_signal_fence(std::memory_order_seq_cst);
unsigned int aux = 0;
const std::uint64_t value = __rdtscp(&aux);
_mm_lfence();
std::atomic_signal_fence(std::memory_order_seq_cst);
return {value, aux};
}
int main() {
std::vector<std::uint64_t> values(1'000'000);
std::iota(values.begin(), values.end(), 1);
// Warm up the code and data before the measured run.
volatile std::uint64_t warmup =
std::accumulate(values.begin(), values.end(), std::uint64_t{0});
(void)warmup;
const std::uint64_t begin = tsc_begin();
const std::uint64_t sum =
std::accumulate(values.begin(), values.end(), std::uint64_t{0});
const TscSample end = tsc_end();
std::cout << "sum = " << sum << '\n';
std::cout << "TSC ticks = " << end.ticks - begin << '\n';
std::cout << "end AUX = " << end.aux << '\n';
}这个示例仍然只是教学起点,并不是一个完整的 benchmark harness。
首先,它只记录了结束时的 TSC_AUX。如果要检测测量期间是否迁移,开始和结束都可以使用能读取 AUX 的设计,或者直接把线程固定到一个逻辑 CPU。
其次,std::atomic_signal_fence 和 intrinsic 的具体编译器约束属于实现细节。针对特定编译器做严谨测量时,应检查生成的汇编,而不是仅凭源码推断边界一定正确。
最后,单次测量的噪声仍然很大。中断、缺页、缓存状态、SMT 竞争和操作系统调度都会改变结果。
为什么不能只测一次?
假设同一段代码运行十次,得到:
1032 1018 1021 5407 1019 1024 1017 1020 8891 1019其中两个高值不一定说明代码偶尔变慢了五倍。它们可能来自线程被抢占、中断、缓存干扰或缺页。
微基准更适合观察一个分布,而不是迷信某个数字:
先预热代码和数据;
执行足够多轮;
同时观察最小值、中位数和高分位数;
把待测操作重复多次,摊薄计时器自身开销;
记录上下文切换和 CPU 迁移;
避免在每轮计时区间中打印日志或分配内存。
最小值有时更接近“没有被系统打扰时”的执行成本,中位数更能描述典型表现。究竟选择哪个指标,取决于你想回答的问题。
chrono、RDTSC 和 perf 应该怎么选?
如果问题是“用户需要等待多久”,优先使用 std::chrono::steady_clock。它可移植、语义清楚,也更接近真实延迟。
如果问题是“一个非常短的 x86 代码片段间隔了多少 TSC tick”,并且你愿意处理排序、迁移与测量开销,可以使用 RDTSC/RDTSCP。
如果问题是“为什么慢”,仅靠计时通常不够。此时更需要 PMU:
perf stat \
-e cycles,ref-cycles,instructions,branches,branch-misses,cache-misses \
./benchmark这些指标可以进一步构造 IPC:
但 IPC 也不是一个脱离上下文的性能分数。向量指令可能一条完成大量工作,不同指令的代价也不相同。性能计数器的意义,是帮助我们提出和验证具体假设,而不是用一个指标代替全部分析。
一套更可靠的测量流程
真正做 C++ 性能实验时,可以采用下面的顺序:
明确要测的是墙上时间、TSC tick,还是核心周期;
使用 Release 构建并记录完整编译选项;
确保结果被使用,检查待测代码没有被删除;
查看汇编,确认计时边界和实际指令;
预热后多轮采样,不用单次结果下结论;
尽量固定 CPU 核心,记录迁移、调度与系统负载;
用
perf或其他 profiler 验证瓶颈原因;在不同机器和不同数据规模上复测。
如果待测代码短到与读取 TSC 本身处于同一个数量级,最好的办法通常不是精确扣除某个固定“计时开销”,而是让操作在循环中执行很多次,再计算平均成本。因为计时指令的开销也会波动,简单相减可能制造新的误差。
Apple Silicon 上怎么办?
RDTSC 是 x86 指令,不能直接用于 ARM,也不能用于 Apple Silicon 原生程序。ARM64 有自己的通用计时器和 PMU,macOS 还提供 mach_continuous_time、Instruments 等工具。
因此,包含 __rdtsc() 的代码应该明确限制在 x86/x64 构建中。跨平台项目如果只是测量时间,应优先从 std::chrono::steady_clock 开始;只有在确实研究特定微架构行为时,才进入平台相关计数器。
结语
__rdtsc() 最容易制造的一种错觉,是它返回了一个巨大的整数,于是这个数字看起来比“微秒”或“纳秒”更加接近硬件真相。
但越靠近硬件,数字越需要解释。
TSC tick 不是天然的核心 cycle,稳定计数不代表测量边界正确,加入栅栏也不代表编译器不会改变代码。真正可靠的性能测量,需要同时理解时钟来源、指令排序、编译器优化和操作系统噪声。
所以下一次看到:
delta = __rdtsc() - begin;不要立刻把结果写成“CPU 执行了多少周期”。先问三个问题:
这只钟按什么频率走?待测代码真的位于两次读数之间吗?我想测的究竟是时间,还是核心工作量?
当这三个问题都有答案时,RDTSC 才从一个会打印巨大数字的 intrinsic,变成真正有用的性能分析工具。