rdtscp和rdtsc的区别:内存和cpuid / rdtsc?

69

假设我们正在尝试使用tsc进行性能监控,我们希望防止指令重排序。

这是我们的选择:

1:rdtscp 是一个序列化调用。它可以防止在调用 rdtscp 时发生重排序。

__asm__ __volatile__("rdtscp; "         // serializing read of tsc
                     "shl $32,%%rdx; "  // shift higher 32 bits stored in rdx up
                     "or %%rdx,%%rax"   // and or onto rax
                     : "=a"(tsc)        // output to tsc variable
                     :
                     : "%rcx", "%rdx"); // rcx and rdx are clobbered

然而,rdtscp只在较新的CPU上可用。所以在这种情况下,我们必须使用rdtsc。但是,rdtsc是非序列化的,所以仅使用它不会防止CPU对其进行重新排序。

因此,我们可以使用以下两个选项之一来防止重新排序:

2:这是对cpuidrdtsc的调用。cpuid是一个串行化调用。

volatile int dont_remove __attribute__((unused)); // volatile to stop optimizing
unsigned tmp;
__cpuid(0, tmp, tmp, tmp, tmp);                   // cpuid is a serialising call
dont_remove = tmp;                                // prevent optimizing out cpuid

__asm__ __volatile__("rdtsc; "          // read of tsc
                     "shl $32,%%rdx; "  // shift higher 32 bits stored in rdx up
                     "or %%rdx,%%rax"   // and or onto rax
                     : "=a"(tsc)        // output to tsc
                     :
                     : "%rcx", "%rdx"); // rcx and rdx are clobbered

3:这是对rdtsc的调用,其中在clobber列表中使用了memory,这可以防止重新排序。

__asm__ __volatile__("rdtsc; "          // read of tsc
                     "shl $32,%%rdx; "  // shift higher 32 bits stored in rdx up
                     "or %%rdx,%%rax"   // and or onto rax
                     : "=a"(tsc)        // output to tsc
                     :
                     : "%rcx", "%rdx", "memory"); // rcx and rdx are clobbered
                                                  // memory to prevent reordering

我对第三个选项的理解如下:

将调用声明为__volatile__可以防止优化器删除或将其移动到可能需要asm结果(或更改输入)的任何指令之前。但是,它仍然可以相对于不相关的操作移动。因此,__volatile__还不够。

告诉编译器内存被破坏了:: "memory")"memory"表示GCC不能对asm中的内存内容做出任何假设,因此不会重新排序它。

所以我的问题是:

  • 1:我对__volatile__"memory"的理解正确吗?
  • 2:后两个调用是否执行相同的操作?
  • 3:使用"memory"比使用另一个序列化指令简单得多。为什么有人会选择第三种选项而不是第二种选项?

15
你似乎混淆了编译器生成指令的重新排序和处理器执行指令的重新排序(也称为乱序执行)。你可以通过使用“ volatile”和“ memory”来避免前者,而通过使用“ cpuid”来避免后者。 - Gunther Piez
@hirschhornsalz但是在clobber列表中有memory不会防止处理器重新排序指令吗? memory不像内存栅栏一样起作用吗? - Steve Lorimer
或许在清单中的memory只是针对gcc发出的,而生成的机器码并不会将其暴露给处理器? - Steve Lorimer
1
不,内存栅栏是另一回事,如果使用“memory” clobber,编译器将不会插入这些内容。这些内容涉及处理器重新排序加载/存储,并与具有弱内存排序的指令结合使用,例如movntdq,以便在多线程环境中使用。在Intel / AMD处理器上,大多数情况下您不需要内存栅栏,因为这些处理器默认具有强内存排序。是的,“memory”只影响编译器发出指令的顺序,它不会使编译器发出额外的指令。 - Gunther Piez
12
rdtscp指令不能防止重排序,它仅确保所有先前的指令已经执行完毕后才会读取计数器的值:RDTSCP指令等待所有先前的指令都执行完毕才会读取计数器。但在执行读操作之前,后续指令可能已经开始执行。如果你考虑将其用于基准测试等,请参考英特尔的这篇白皮书: http://download.intel.com/embedded/software/IA/324264.pdf(实际上显示你需要使用`rdtsc`+ cpuidrdtscp+cpuid来进行正确的测量)。 - Necrolis
1
@Necrolis 非常有趣的论文 - Gunther Piez
2个回答

52
如评论中所提到的,编译器屏障处理器屏障之间存在差异。在asm语句中,volatilememory作为编译器屏障,但处理器仍然可以重新排序指令。
处理器屏障是必须明确给出的特殊指令,例如 `rdtscp, cpuid`,内存屏障指令 (`mfence, lfence,`...) 等等。`lfence` 也是一个执行屏障(在 Intel 上,以及最近的 AMD 上),因此与 `rdtsc` 结合使用时很有趣(`rdtsc` 不是一个内存操作,并且只有在手册中有相关说明时才会被 `*fence` 指令排序)。有趣的事实是:x86 的强序内存模型使得 `lfence` 对于内存排序基本上是无用的,它的主要用途是执行排序。
顺便说一下,虽然在使用`cpuid`作为`rdtsc`之前的屏障是常见的,但从性能的角度来看,这样做可能非常糟糕,因为虚拟机平台通常会捕获和模拟`cpuid`指令,以便在集群中的多台机器上强制实施一组公共的CPU特性(以确保实时迁移正常工作)。因此,最好使用更便宜的执行屏障指令,如`lfence`,或者在非常新的CPU上使用`serialize`(它也是一个内存屏障,并且像`cpuid`一样完全序列化流水线,但没有虚拟机退出,因此在`rdtsc`之前使用它将等待存储提交,而不像`lfence`只等待指令执行完成)。
Linux内核在AMD平台上曾经使用`mfence;rdtsc`,而在Intel平台上使用`lfence;rdtsc`。从Linux内核5.4开始,无论是在Intel还是AMD上,都使用lfence来序列化rdtsc指令。请参阅此提交:"x86: Remove X86_FEATURE_MFENCE_RDTSC":https://git.kernel.org/pub/scm/linux/kernel/git/torvalds/linux.git/commit/?id=be261ffce6f13229dad50f59c5e491f933d3167f

6
cpuid; rdtsc不是关于内存栅栏的问题,它是关于序列化指令流的。通常用于基准测试,以确保重排序缓冲区/保留站中没有“旧”的指令。然后需要减去cpuid的执行时间(它相当长,我记得> 200个周期)。如果通过这种方式得到的结果更“精确”,对我来说不是很清楚,我进行了有和没有的实验,差异似乎小于测量的自然误差,即使在单用户模式下也是如此,没有其他运行的任何程序。 - Gunther Piez
6
根据git提交日志,AMD和Intel确认现有CPU上的m/lfence将使rdtsc序列化。如果您感兴趣并询问Andi Kleen,他可能能提供更多详细信息。 - janneb
1
@hirschhornsalz:如果我没记错,这个论点基本上是说,虽然栅栏指令只在读/写内存的指令方面进行序列化,但实际上在相对于 rdtsc 的非内存指令重排序方面是没有意义的,因此不会这样做。尽管根据架构手册,在原则上是允许的。 - janneb
2
在Intel上使用lfence,在AMD上使用mfence可能非常重要;任何关于“更强的屏障”的争论都是完全不适用的,因为我们谈论的是指令流和额外的微架构效应,而不是众所周知的内存排序效应。例如,LFENCE在AMD上并没有完全序列化:它在Bulldozer系列/Ryzen上有每个时钟4个吞吐量!也许它会序列化rdtsc,但不会序列化自己或其他一些指令?或者更有可能是在AMD上非常便宜,因为他们的内存排序实现方式不同。 - Peter Cordes
1
是的,进步了。但是你的回答中仍然提到了内存屏障与RDTSC有关。实际上并没有关系。只有执行屏障的效果才会对计时有影响。我做了一些修改,你可能需要再次审查一下,看看我是否过于啰嗦,比如可以删除对新的serialize指令的提及。(我不知道为什么Linux在只是想获取当前时间的情况下还要使用lfence。除非lfence;rdtscrdtscp版本主要用于Spectre缓解。无论如何,这个问题是考虑了基准测试的使用场景。) - undefined
显示剩余21条评论

4
您可以如下所示使用它:
asm volatile (
"CPUID\n\t"/*serialize*/
"RDTSC\n\t"/*read the clock*/
"mov %%edx, %0\n\t"
"mov %%eax, %1\n\t": "=r" (cycles_high), "=r"
(cycles_low):: "%rax", "%rbx", "%rcx", "%rdx");
/*
Call the function to benchmark
*/
asm volatile (
"RDTSCP\n\t"/*read the clock*/
"mov %%edx, %0\n\t"
"mov %%eax, %1\n\t"
"CPUID\n\t": "=r" (cycles_high1), "=r"
(cycles_low1):: "%rax", "%rbx", "%rcx", "%rdx");

在上面的代码中,第一个CPUID调用实现了一个屏障,以避免RDTSC指令上下的指令乱序执行。通过这种方法,我们避免在实时寄存器读取之间调用CPUID指令。
第一个RDTSC然后读取时间戳寄存器,并将其值存储在内存中。然后执行我们要测量的代码。 RDTSCP指令第二次读取时间戳寄存器,并保证我们想要测量的所有代码已经执行完毕。之后的两个“mov”指令将edx和eax寄存器的值存储到内存中。最后,CPUID调用再次确保实现了一个屏障,因此在CPUID本身之前不可能执行任何指令。

20
你好,看起来你抄袭了Gabriele Paolini的白皮书《如何在Intel® IA-32和IA-64指令集架构上基准测试代码执行时间》中的答案(不过你错过了一行换行符)。在未给作者署名的情况下使用他人作品是不合适的。为什么不添加作者署名呢? - Jonatan Lindén
是的,确实可以应对。我也在想读取开始时间的两个mov是否必要:https://dev59.com/Spnga4cB1Zd3GeqPfexw - Edd Barrett
有没有特定的原因需要有两个变量 high 和 low? - ExOfDe
1
是的,@ExOfDe,这是有原因的。RDTSC[P]指令返回一个64位值,但它将其分为两个32位的部分:高半部分在EDX寄存器中,低半部分在EAX寄存器中(这是在32位x86系统上返回64位值的常见约定)。当然,如果您想要,可以将这两个32位部分组合成单个64位值,但这需要(A)64位处理器(而RDTSC[P]指令是在64位整数被本地支持之前引入到ISA中的),或者(B)编译器/库支持64位整数。 - Cody Gray
5
如果你打算使用自己的内联汇编而不是内置函数/内联函数,至少要编写有效的内联汇编,并使用约束告诉编译器应该查看哪些寄存器,而不是使用 mov 指令。 - Peter Cordes

网页内容由stack overflow 提供, 点击上面的
可以查看英文原文,
原文链接