如何使用rdtsc和C/C++来测量运行时间(如何使用内联汇编和获取CPU的TSC时钟频率)

本文主要是介绍如何使用rdtsc和C/C++来测量运行时间(如何使用内联汇编和获取CPU的TSC时钟频率),希望对大家解决编程问题提供一定的参考价值,需要的开发者们随着小编来一起学习吧!

本文主要是一个实验和思维扩展,是一个初步的尝试,旨在研究一些时间测量实现和在 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)就是汇编的输出,输出eaxebxecxedx4 个寄存器(由于不同位的机器上寄存器名称不一样,但是是兼容的,所以直接使用不同处abcd表示寄存器)的值给了abcd4个变量。
  • 虽然这里只使用了eaxedx,但是最好还是都声明和输入一下,不然程序可能会闪退,因为有的指令需要使用其他的寄存器。比如说如果在 macOS 上确定是否不变 TSC 的时候,没有输入和输出ebxecx寄存器,那么会出现访问内存超出的错误。

输出为:

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,而ECX0x0。你也可以使用mov指令设置寄存器的值,也可以像上面一样通过输入来设置几个寄存器的值。

从上图中可以看到EAXTSC/core crystal clock的分母,而EBXTSC/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=125ecx=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 点:

  1. ECX寄存器是否已经有频率信息了,如果有的话,直接使用;
  2. ECX寄存器没有频率信息的情况下,需要判断 CPUID 签名,从而得知频率是哪个。

第一个很好解决,一个判断语句。第二个 CPUID 签名是什么呢?

什么是 CPUID 签名

CPUID 签名就是当EAX=1时,cpuidEAX中的返回值。内容如下:

请添加图片描述

实际上并不会用到整个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_tbool等类型的数据,稍微方便一些。如果你需要使用纯 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 existreturn 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[]) {//判断是否有可靠的TSCif (!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 的内联汇编中使用cpuidrdtsc的方法。

参考资料

研究的时候被很多资料误导了,所以列出使用到的和一些我觉得还不错的资料。

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 频率出现的精度问题。

这篇关于如何使用rdtsc和C/C++来测量运行时间(如何使用内联汇编和获取CPU的TSC时钟频率)的文章就介绍到这儿,希望我们推荐的文章对编程师们有所帮助!



http://www.chinasem.cn/article/936108

相关文章

使用Python实现IP地址和端口状态检测与监控

《使用Python实现IP地址和端口状态检测与监控》在网络运维和服务器管理中,IP地址和端口的可用性监控是保障业务连续性的基础需求,本文将带你用Python从零打造一个高可用IP监控系统,感兴趣的小伙... 目录概述:为什么需要IP监控系统使用步骤说明1. 环境准备2. 系统部署3. 核心功能配置系统效果展

Java controller接口出入参时间序列化转换操作方法(两种)

《Javacontroller接口出入参时间序列化转换操作方法(两种)》:本文主要介绍Javacontroller接口出入参时间序列化转换操作方法,本文给大家列举两种简单方法,感兴趣的朋友一起看... 目录方式一、使用注解方式二、统一配置场景:在controller编写的接口,在前后端交互过程中一般都会涉及

使用Java将各种数据写入Excel表格的操作示例

《使用Java将各种数据写入Excel表格的操作示例》在数据处理与管理领域,Excel凭借其强大的功能和广泛的应用,成为了数据存储与展示的重要工具,在Java开发过程中,常常需要将不同类型的数据,本文... 目录前言安装免费Java库1. 写入文本、或数值到 Excel单元格2. 写入数组到 Excel表格

redis中使用lua脚本的原理与基本使用详解

《redis中使用lua脚本的原理与基本使用详解》在Redis中使用Lua脚本可以实现原子性操作、减少网络开销以及提高执行效率,下面小编就来和大家详细介绍一下在redis中使用lua脚本的原理... 目录Redis 执行 Lua 脚本的原理基本使用方法使用EVAL命令执行 Lua 脚本使用EVALSHA命令

C#如何调用C++库

《C#如何调用C++库》:本文主要介绍C#如何调用C++库方式,具有很好的参考价值,希望对大家有所帮助,如有错误或未考虑完全的地方,望不吝赐教... 目录方法一:使用P/Invoke1. 导出C++函数2. 定义P/Invoke签名3. 调用C++函数方法二:使用C++/CLI作为桥接1. 创建C++/CL

Java 中的 @SneakyThrows 注解使用方法(简化异常处理的利与弊)

《Java中的@SneakyThrows注解使用方法(简化异常处理的利与弊)》为了简化异常处理,Lombok提供了一个强大的注解@SneakyThrows,本文将详细介绍@SneakyThro... 目录1. @SneakyThrows 简介 1.1 什么是 Lombok?2. @SneakyThrows

判断PyTorch是GPU版还是CPU版的方法小结

《判断PyTorch是GPU版还是CPU版的方法小结》PyTorch作为当前最流行的深度学习框架之一,支持在CPU和GPU(NVIDIACUDA)上运行,所以对于深度学习开发者来说,正确识别PyTor... 目录前言为什么需要区分GPU和CPU版本?性能差异硬件要求如何检查PyTorch版本?方法1:使用命

python处理带有时区的日期和时间数据

《python处理带有时区的日期和时间数据》这篇文章主要为大家详细介绍了如何在Python中使用pytz库处理时区信息,包括获取当前UTC时间,转换为特定时区等,有需要的小伙伴可以参考一下... 目录时区基本信息python datetime使用timezonepandas处理时区数据知识延展时区基本信息

使用Python和Pyecharts创建交互式地图

《使用Python和Pyecharts创建交互式地图》在数据可视化领域,创建交互式地图是一种强大的方式,可以使受众能够以引人入胜且信息丰富的方式探索地理数据,下面我们看看如何使用Python和Pyec... 目录简介Pyecharts 简介创建上海地图代码说明运行结果总结简介在数据可视化领域,创建交互式地

Java Stream流使用案例深入详解

《JavaStream流使用案例深入详解》:本文主要介绍JavaStream流使用案例详解,本文通过实例代码给大家介绍的非常详细,对大家的学习或工作具有一定的参考借鉴价值,需要的朋友参考下吧... 目录前言1. Lambda1.1 语法1.2 没参数只有一条语句或者多条语句1.3 一个参数只有一条语句或者多