本文主要是一个实验和思维扩展,是一个初步的尝试,旨在研究一些时间测量实现和在 C/C++ 中内联汇编和汇编函数的使用方法。除非你有特殊用途,不然不要使用汇编指令来实现这个功能。在“扩展阅读”部分会列出了一些不需要内联汇编实现的方法。
写本文是因为为了《Windows上的类似clock_gettime(CLOCK_MONOTONIC)的高精度测量时间函数》这篇文章找资料的时候,发现了一些关于测量时间如何实现的内容,所以出于好奇心进行了一些研究和实践。
平台和系统方面,本文只尝试了 X86 的 macOS。但因为没有使用系统内核,而是使用的内联汇编,所以理论上 Linux 也可以使用 macOS 的方法,只不过我没进行尝试(手上没有 X86 的 Linux 设备)。
如果你想在 Windows 中实现,那么请导入intrin.h
,使用__rdtsc()
和__cpuid()
(这两个 intrinsic 函数作用和本文写的大部分内联汇编一样或者可以等价替换),而不是使用内联汇编。因为 Visual Studio 内联汇编比 GCC 和 Clang 的复杂许多,考虑到多个兼容性,还是这样的好。本文虽然没有列出 Windows 的方法,但是在“扩展阅读”中尽量列出所需的资料,便于读者自己尝试。
本打算写一下 Windows 部分的,但是出于篇幅,还是不写了,博客太长对于读者和作者都不太好(个人喜好罢了)。
思路
本文这种测量时间的思路是通过计时器晶体的时钟计数做差,然后除以计时器的频率来获取时间,这种方法的计时误差主要取决于晶体质量,类似clock()
,不过要复杂的多,因为这里作为分母的频率并不是clock()
中简单的 1 MHz。
在 x86 上,最常用的计时器是 CPU 上的 TSC。不过由于 BIOS 设置、CPU 是否支持 TSC 等诸多因素,常用的一些计时函数往往是使用了多个计时器来消除误差。本文并不会做到这种程度,只使用 TSC 计数和频率来实现计时。
此外我看了国内外很多文章,可以明显感觉到 x86 的 TSC 这个技术在 2014 年前后(个人感觉)有了巨大变化,因为早期的博客、文章、代码等提及的一系列方法和问题与今天的情况几乎可以说是完全不同。并且 2014 年前后这个时间段也与 Intel 官方文档中一些处理器的更新和内容的时间段对应上了。
早期有很多文章写 TSC 的,包括在 CSDN 上。这是因为当时汇编还是一个比较流行的编程手段,有很多情况下会用到,很多人都会研究和写博客。而今天各种高级语言可以满足几乎所有开发场景,汇编已经慢慢淡出视野了,所以今天没有多少人会去研究汇编和基础设施的实现了,这样的文章也就少了很多。
内联汇编实现
由于日常开发并不会出现纯汇编,所以使用内联汇编来降低复杂度和增加可读性。而且内联汇编也比较能展现汇编的结构,如果后续需要实现纯汇编,也比较方便进行实现。
确定设备有TSC,并且是不变的
第一步是要确定设备有 TSC,并且是不变的。
如果是可变的,也就是会随着睿频、过热、核心等情况变化频率,那么这个方法是无法可靠地测量时间的。如果没有 TSC,那也就没必要下面的步骤了。
查看是否有 TSC 的方法是CPUID.1:EDX.TSC[bit 4] = 1
,也就是使用0x1
初始化,检查 CPUID 返回的EDX
寄存器中第 4 位的值,如果为1
则有 TSC。
查看是否是稳定不变的 TSC 的方法是使用0x80000007
初始化EAX
,然后检查 CPUID 返回的EDX
寄存器中第 8 位的值,如果为1
则表示可以使用不变 TSC。
内联汇编代码如下:
#define BIT(nr) (1UL << (nr)) //方便确定哪一位的宏:获取第nr位的内容,右端为最小端
static inline void isTSC(void)
{
uint32_t a=0x1, b, c, d;
asm volatile ("cpuid"
: "=a" (a), "=b" (b), "=c" (c), "=d" (d)
: "a" (a), "b" (b), "c" (c), "d" (d)
);
if ((d & BIT(4))) {
printf("TSC exist!\n");
} else
a=0x80000007;
asm volatile ("cpuid"
: "=a" (a), "=b" (b), "=c" (c), "=d" (d)
: "a" (a), "b" (b), "c" (c), "d" (d)
);
if ((d & BIT(8))) {
printf("Invariant TSC available!\n");
}
}
static inline
这个是静态内联是为了提高程序性能,不过鉴于编译转换流程,有时候写了反而降低性能,所以写不写看情况。asm volatile ("cpuid\n\t" : "=a" (a), "=b" (b), "=c" (c), "=d" (d) : "a" (a), "b" (b), "c" (c), "d" (d));
这行就是内联汇编了。这部分的内容使用冒号:
分隔,最多有三个部分,第一个部分是汇编指令,第二个部分是输出,第三个部分是输入(需要注意先是输出,再是输入)。比如这里的"a" (a), "b" (b), "c" (c), "d" (d)
就是汇编的输出,输出eax
、ebx
、ecx
和edx
4 个寄存器(由于不同位的机器上寄存器名称不一样,但是是兼容的,所以直接使用不同处a
、b
、c
和d
表示寄存器)的值给了a
、b
、c
和d
4个变量。- 虽然这里只使用了
eax
和edx
,但是最好还是都声明和输入一下,不然程序可能会闪退,因为有的指令需要使用其他的寄存器。比如说如果在 macOS 上确定是否不变 TSC 的时候,没有输入和输出ebx
和ecx
寄存器,那么会出现访问内存超出的错误。
输出为:
TSC exist!
Invariant TSC available!
需要注意的是,这里是使用打印信息来说明是否有二者,但是实际程序中,需要返回值来判断是否可以使用 TSC 来计时,最后的完整代码中会加入返回值和判断部分。
这时候就可以开始准备使用 TSC 来计时了。
使用内联汇编通过rdtsc
获取 TSC 计数
处理器时间戳计数器的值是使用rdtsc
指令从 64 位的 MSR(Model-specific register,特定模块寄存器)中读取到EDX:EAX
两个寄存器中的。
如果你好奇 MSR,可以看看我的这篇博客:MSR是个什么寄存器 - ZhongUncle CSDN
包装内联汇编的 C/C++ 函数如下:
static inline unsigned long long rdtsc(void)
{
unsigned long long low, high;
asm volatile ("rdtsc" : "=a" (low), "=d" (high));
return low | (high << 32);
}
low | (high << 32)
是因为edx
寄存器加载了 MSR 的高阶32位,eax
寄存器加载了低阶32位。所以为了得到 64 位的 TSC 计数,要将高位的部分左移 32 位,然后再将二者进行或|
运算得到 64 位的无符号数。
输出一个值看看效果:
printf("%llu\n", rdtsc());
printf("%llu\n", rdtsc());
输出结果为:
13207359930699
13207359964314
使用内联汇编获取cpuid
中 TSC 频率
知道了 TSC 计数的值,如何获取计算时间所需的频率呢?
这是整篇文章中最困难的一段:因为出于理解、安全等诸多原因,直接暴露 TSC 值是不好的。这就导致需要一定的操作才能获得 TSC 频率,并不像计数一样可以轻松获得。
简单例子
先列出代码,然后再进行说明,因为这里情况实在是有点复杂。代码实现如下(下面的24000000
需要根据不同的处理器设置不同的值,看完下面的内容你就知道是怎么得到的了,并且最后会弄出一个比较通用的办法):
static inline uint32_t tsc_freq(void)
{
uint32_t a=0x15, b, c=0, d;
asm volatile ("cpuid"
: "=a" (a), "=b" (b), "=c" (c), "=d" (d)
: "0" (a), "1" (b), "2" (c), "3" (d)
);
return b/a*24000000;
}
代码解释比较长,没法写成一个列表,所以请仔细看下面的内容。
TSC 频率是如何获取的呢?
为什么说情况有些复杂呢?因为早期处理器和现在的处理器的 TSC 频率获取不是一回事。
很多文章、博客、书籍获取 TSC 频率由于获取 TSC 的复杂性,都是通过内核的
/sys/devices/system/cpu/cpu0/tsc_freq_khz
实现的,本文不使用这个。如果你对此感兴趣,在“参考资料/扩展阅读”部分我会列出一些使用这种方法等博客和帖子。
(这里我删了一大段,因为我需要确定一件事去问了几位业内人士,如果得到确定会在这里进行补充)
首先在官方文档 Intel® 64 and IA-32 Architectures Software Developer Manuals(2021-04)中可以看到CPUID.15H
的返回信息部分有以下内容:
不同版本的 Intel® 64 and IA-32 Architectures Software Developer Manuals 表格数可能会不一样。
CPUID 在 EAX、EBX、ECX 和 EDX 寄存器中返回处理器识别和功能信息。指令的输出内容在执行时取决于 EAX 寄存器的值(在某些情况下还会附带上 ECX 的值)。
我们代码中使用的EAX
值为0x25
,而ECX
为0x0
。你也可以使用mov
指令设置寄存器的值,也可以像上面一样通过输入来设置几个寄存器的值。
从上图中可以看到EAX
是TSC/core crystal clock
的分母,而EBX
是TSC/core crystal clock
的分子,二者组成一个比例,这个比例有什么用呢?
这个值乘以core crystal clock
就是 TSC 频率了,但是该如何获得core crystal clock
呢?
如何获取核心晶体时钟频率(core crystal clock)
在 18.7.3 中有以下内容:
这个图包含了一些信息:
- 名义核心晶体时钟频率在
ecx
中列出。核心晶体时钟运行在固定频率上,与整个系统的总时钟相协调。现在的绝大部分处理器(大约是从 Skylake 开始)或者使用缩放频率的处理器都不会在ecx
列出核心晶体时钟频率了。如果ecx
中没有说明核心晶体时钟频率,那么需要按照表 18-85 中的值计算 TSC 频率。 CPUID.15H
的返回值是核心晶体时钟比例的编码。- 中间的这个公式计算的是名义上 TSC 频率。
简而言之就是,对于CPUID.15H.EBX[31:0] ÷ CPUID.0x15.EAX[31:0]
值存在,但是CPUID.15H.ECX
没有值的情况,CPUID.15H.ECX
的值应该如下:
如何使用晶体时钟来计算TSC频率
那么该如何使用上面获取的那些值呢?
我用 Intel i5 8500B 测的CPUID.15H
返回各寄存器的值如下:
eax=2
ebx=250
ecx=0
edx=0
按照这个计算来说,ebx/eax=125
,ecx=0
,也就是需要从表格中获取值,这里使用24 Mhz
(我怀疑是文档没更新,应该包含 8 代等的处理器的,因为其他两行都有说明 CPUID 签名,中间没说明应该就是其余所有这种处理器)。
这时候 TSC 频率 = 24 Mhz * ebx/eax=24 Mhz * 125 = 3000 Mhz,刚好就是 i5 8500B 的基础频率,这时候计算得到的时间精度挺高的。
如下是两次测试测到的结果,time
是使用这种方法计算出的时间,下面的duration
是使用clock_gettime()
计算出的时间:
多次测试发现误差最多不到 500 ns(也不保证一定就是 500 ns 以内,因机器和环境而异)
要判断 TSC 频率是多少,从上面的信息知道需要考虑 2 点:
ECX
寄存器是否已经有频率信息了,如果有的话,直接使用;ECX
寄存器没有频率信息的情况下,需要判断 CPUID 签名,从而得知频率是哪个。
第一个很好解决,一个判断语句。第二个 CPUID 签名是什么呢?
什么是 CPUID 签名
CPUID 签名就是当EAX=1
时,cpuid
在EAX
中的返回值。内容如下:
实际上并不会用到整个EAX
的内容,只要取得处理器的Model
部分即可。
为什么只要取得处理器的Model
部分
只要取得处理器的Model
部分即可有两个原因。
第一,因为前面那个表格中,两个列出的签名都是06H
开头的,这是 Family ID,这部分包含以下系列的处理器:
可以看到现在使用的整个 Intel 酷睿(Core)系列和绝大部分志强(Xeon)系列都是6H
开头的。这时候你会说,上面不是06H
嘛?还有一位十六进制呢?这就是Extended Model ID
的作用,和在一起就表示06H
。可以说在过去三十年里,绝大部分 Intel 处理器的 Family ID都是6H
(或者06H
),只有早期处理器、Itanium 系列和极个别例外,具体情况自己搜一下“Processor Family IDs”即可。
下表是用到Extended Family ID
部分的处理器:
比如 Zen 3 和 Zen 4 的 Family ID
就是19H
,那么就是Extended Family ID [1] + Family ID [9]
。
第二,其他处理器都在ECX
中有核心晶体频率,只有这部分列出的没有,而这些都是06H
为 Family ID 的处理器,所以只要考虑 Model 部分即可。但是处理器的 Model 依旧有扩展部分,所以需要获取两个部分再组合。
获取处理器 Model 部分的方法
获取处理器 Model 部分的方法为:
static inline unsigned int cpu_model(void)
{
uint32_t a=0x1, b, c, d;
asm volatile ("cpuid"
: "=a" (a), "=b" (b), "=c" (c), "=d" (d)
: "a" (a), "b" (b), "c" (c), "d" (d)
);
uint32_t model = (a>>4)&0b1111;
uint32_t extend_model = (a>>16)&0b1111;
return (extend_model<<4) | model;
}
这里提示一下,不要使用a&0b11110000
,因为这样获取的会带上最后四位,而我们并不需要这 4 位。
我的处理器输出结果为:
9e
接下来我们只需要判断是否可以是表格中的那两个就行。这里就不列出判断的代码了,我将其结合到完整代码中。
注意事项
在某些文章中提到 TSC 无法测量多核并行任务的时间,我猜测这是某些系统内核实现的问题或者早期一些处理器的特性。所以要根据自己的处理器来判断是否可以使用 TSC 实现计时。
因为我依旧使用 ISPC 编写多核并行任务,得到的时间和clock_gettime()
非常相近。如下:
所以在现如今的处理器上,应该是可以测量并行程序的。
至于有些人提到执行时,可能会将rdtsc
挤滞后的,在现在的文档中,提到这么一段话:
不过也这段话提到:RDTSC 并非串行指令,不一定要等到所有前置指令执行完才能读取计数器。RDTSC 的后续指令也可能会在 RDTSC 读取完就开始执行。关于执行顺序分了三种情况,所以具体情况还是要看软件程序的要求。
完整代码
使用 C++ 的标准库iostream
,这样可以使用uint32_t
、bool
等类型的数据,稍微方便一些。如果你需要使用纯 C,那么自己替换成合适的格式即可,以及引入<timer.h>
。
我将 C 和 C++ 两个版本的整理到TSC_Timer - Github ZhongUncle了,你可以克隆下来直接试试看。
下面是完整的代码:
#include <iostream>
#define BIT(nr) (1UL << (nr)) //获取第nr位的内容,右端为最小端
static inline unsigned long long rdtsc(void)
{
unsigned long long low, high;
asm volatile ("rdtsc" : "=a" (low), "=d" (high));
return low | (high << 32);
}
static inline bool isTSC(void)
{
uint32_t a=0x1, b, c, d;
asm volatile ("cpuid"
: "=a" (a), "=b" (b), "=c" (c), "=d" (d)
: "a" (a), "b" (b), "c" (c), "d" (d)
);
if ((d & BIT(4))) {
// TSC exist!
a=0x80000007;
asm volatile ("cpuid"
: "=a" (a), "=b" (b), "=c" (c), "=d" (d)
: "a" (a), "b" (b), "c" (c), "d" (d)
);
if ((d & BIT(8))) {
// Invariant TSC available!
return true;
}
} else {
// TSC not exist
return false;
}
return false;
}
static inline unsigned int cpu_model(void)
{
uint32_t a=0x1, b, c, d;
asm volatile ("cpuid"
: "=a" (a), "=b" (b), "=c" (c), "=d" (d)
: "a" (a), "b" (b), "c" (c), "d" (d)
);
uint32_t model = (a>>4)&0b1111;
uint32_t extend_model = (a>>16)&0b1111;
return (extend_model<<4)|model;
}
static inline unsigned long tsc_freq(void)
{
uint32_t model = cpu_model();
uint32_t a=0x15, b, c, d;
asm volatile ("cpuid"
: "=a" (a), "=b" (b), "=c" (c), "=d" (d)
: "0" (a), "1" (b), "2" (c), "3" (d)
);
if (c != 0) {
return b / a * c;
}
if (model == 0x55) {
return b / a * 25000000;
}
if (model == 0x5c) {
return b / a * 19200000;
}
return b/a*24000000;
}
int main(int argc, const char * argv[]) {
//判断是否有可靠的TSC
if (!isTSC()) {
printf("TSC is not exist or variant!");
}
//获取TSC频率
unsigned long freq = tsc_freq();
// 使用clock_gettime辅助验证
struct timespec start;
clock_gettime(CLOCK_MONOTONIC, &start);
//获取TSC计数作为开始
uint64_t rdtsc1 = rdtsc();
//用于计时的测试代码
for (int i=0; i<100; i++) {
std::cout << "Hello, World!\n";
}
//获取TSC计数作为结束
uint64_t rdtsc2 = rdtsc();
struct timespec end;
clock_gettime(CLOCK_MONOTONIC, &end);
//获取TSC的时间
double time = (double)(rdtsc2-rdtsc1)/(double)freq*1e9;
//打印时钟、频率和TSC时间
printf("clock\t = %llu cycles\n", rdtsc2-rdtsc1);
printf("freq\t = %lu Hz\n", freq);
printf("TSC time = %.0f ns\n", time);
//计算clock_gettime辅助验证的时间
double duration = (double)(end.tv_nsec-start.tv_nsec) + (double)(end.tv_sec-start.tv_sec)*((double) 1e9);
//打印clock_gettime辅助验证的时间
printf("duration = %.0f ns\n", duration);
return 0;
}
希望能帮到有需要的人~
扩展阅读
How to get the CPU cycle count in x86_64 from C++? - Stack Overflow:这篇帖子的最高赞回答解释了 Linux 和 Windows 使用 C++ 内建的__rdtsc
获取 x86_64 计数的方法。
__rdtsc - Microsoft Learn:__rdtsc
的微软官方文档。
TSC Frequency For All: Better Profiling and Benchmarking - Trail of Bits:这篇博客就是使用/sys/devices/system/cpu/cpu0/tsc_freq_khz
来获取 TSC 频率的。
Getting TSC rate from x86 kernel - Stack Overflow:这个帖子的这个回答,介绍了很多“高层级”获取 TSC 频率的方法。
Using and Preserving Registers in Inline Assembly - Microsoft Learn:微软官方介绍 Windows 使用内联汇编的方法。
How to do cpuid and rdtsc on 32 bit and 64 bit Windows - By Stephen Kellett:这篇博客介绍了如何在 Windows 的内联汇编中使用cpuid
和rdtsc
的方法。
参考资料
研究的时候被很多资料误导了,所以列出使用到的和一些我觉得还不错的资料。
Intel® 64 and IA-32 Architectures Software Developer Manuals:Intel 处理器的官方文档,介绍了很多指令和架构信息。唯一缺陷就是太太太长了,所以如果你想自己看看,那么建议下一本看看大概,看的差不多了之后,后续只用看更新部分,这样大大降低时间。如果更新一本换一本,那么看不完的。
6.48.2 Extended Asm - Assembler Instructions with C Expression Operands - GNU GCC: GNU GCC 内联汇编的官方文档。
Inline Assembly/Examples - OS Dev:这篇 wiki 提供了许多内联汇编的例子。
建议下面 4 篇结合着看!
CPUID — CPU Identification - felixcloutier:介绍和解释了 CPUID 的指令使用方法和返回值含义等内容。这个可以当做 Intel SDM 的在线版,他把指令和内容解释做成了网页的。
CPUID - OS Dev:这篇 wiki 非常详细的介绍了如何通过汇编和内联汇编来使用 CPUID 的案例,但是对内容没有进行什么解释。
CPUID - Wikipedia:这里比较方便查看 CPUID 家族等一些寄存器的信息,但不是全部。
Query CPUID with Inline Assembly - Jack Henschel’s Blog:这篇博客是作者写学士论文时,研究内联汇编请求 CPUID 内容的总结。个人感觉例子比上面的 OS Dev 的例子好一些(个人喜好)。
RDTSC — Read Time-Stamp Counter - felixcloutier:介绍和解释了 CPUID 的指令使用方法和返回值含义等内容。
Function asm volatile(“rdtsc”); - Stack Overflow:这篇帖子讨论了如何使用内联汇编使用rdtsc
命令获取 TSC 计数。
TSC frequency computation - Intel Community:这个帖子讨论了早期 CPU 计算 TSC 频率出现的精度问题。