使用时间戳计数器和clock_gettime进行缓存未命中

作为本主题的后续内容 ,为了计算内存未命中延迟,我使用_mm_clflush__rdtsc_mm_lfence (基于此问题/答案的代码)编写了以下代码。

正如您在代码中看到的,我首先将数组加载到缓存中。 然后我刷新一个元素,因此缓存行从所有缓存级别逐出。 为了在-O3期间保留顺序,我放了_mm_lfence

接下来,我使用时间戳计数器来计算延迟或读取array[0] 。 正如您在两个时间戳之间看到的那样,有三个指令:两个lfence和一个read 。 所以,我必须减去lfence开销。 代码的最后一部分计算开销。

在代码结束时,打印开销和未命中延迟。 但是,结果无效!

 #include  #include  #include  int main() { int array[ 100 ]; for ( int i = 0; i < 100; i++ ) array[ i ] = i; uint64_t t1, t2, ov, diff; _mm_lfence(); _mm_clflush( &array[ 0 ] ); _mm_lfence(); _mm_lfence(); t1 = __rdtsc(); _mm_lfence(); int tmp = array[ 0 ]; _mm_lfence(); t2 = __rdtsc(); _mm_lfence(); diff = t2 - t1; printf( "diff is %lu\n", diff ); _mm_lfence(); t1 = __rdtsc(); _mm_lfence(); _mm_lfence(); t2 = __rdtsc(); _mm_lfence(); ov = t2 - t1; printf( "lfence overhead is %lu\n", ov ); printf( "miss cycles is %lu\n", diff-ov ); return 0; } 

但是,输出无效

 $ gcc -O3 -o flush1 flush1.c $ taskset -c 0 ./flush1 diff is 161 lfence overhead is 147 miss cycles is 14 $ taskset -c 0 ./flush1 diff is 161 lfence overhead is 154 miss cycles is 7 $ taskset -c 0 ./flush1 diff is 147 lfence overhead is 154 miss cycles is 18446744073709551609 

任何想法?

接下来,我尝试使用clock_gettime函数来计算未命中延迟,如下所示

  _mm_lfence(); _mm_clflush( &array[ 0 ] ); _mm_lfence(); struct timespec start, end; clock_gettime(CLOCK_MONOTONIC, &start); _mm_lfence(); int tmp = array[ 0 ]; _mm_lfence(); clock_gettime(CLOCK_MONOTONIC, &end); diff = 1000000000 * (end.tv_sec - start.tv_sec) + end.tv_nsec - start.tv_nsec; printf("miss elapsed time = %lu nanoseconds\n", diff); 

输出是未miss elapsed time = 578 nanoseconds 。 这可靠吗?

UPDATE1:

感谢Peter和Hadi,总结一下到目前为止的反应,我发现了

1-在优化阶段省略了未使用的变量,这就是我在输出中看到的奇怪值的原因。 感谢Peter的回复,有一些方法可以解决这个问题。

2- clock_gettime不适合这种分辨率,并且该函数用于较大的延迟。

作为一种解决方法,我尝试将数组放入缓存中,然后刷新所有元素以确保所有元素都从所有缓存级别中逐出。 然后我测量了array[0]array[20]的延迟。 由于每个元素是4个字节,因此距离是80个字节。 我希望得到两个缓存未命中。 但是, array[20]的延迟类似于缓存命中。 一个安全的猜测是缓存行不是80字节。 因此,也许array[20]由硬件预取。 不总是,但我也看到了一些奇怪的结果

  for ( int i = 0; i < 100; i++ ) { _mm_lfence(); _mm_clflush( &array[ i ] ); _mm_lfence(); } _mm_lfence(); t1 = __rdtsc(); _mm_lfence(); int tmp = array[ 0 ]; _mm_lfence(); t2 = __rdtsc(); _mm_lfence(); diff1 = t2 - t1; printf( "tmp is %d\ndiff1 is %lu\n", tmp, diff1 ); _mm_lfence(); t1 = __rdtsc(); tmp = array[ 20 ]; _mm_lfence(); t2 = __rdtsc(); _mm_lfence(); diff2 = t2 - t1; printf( "tmp is %d\ndiff2 is %lu\n", tmp, diff2 ); _mm_lfence(); t1 = __rdtsc(); _mm_lfence(); _mm_lfence(); t2 = __rdtsc(); _mm_lfence(); ov = t2 - t1; printf( "lfence overhead is %lu\n", ov ); printf( "TSC1 is %lu\n", diff1-ov ); printf( "TSC2 is %lu\n", diff2-ov ); 

输出是

 $ ./flush1 tmp is 0 diff1 is 371 tmp is 20 diff2 is 280 lfence overhead is 147 TSC1 is 224 TSC2 is 133 $ ./flush1 tmp is 0 diff1 is 399 tmp is 20 diff2 is 280 lfence overhead is 154 TSC1 is 245 TSC2 is 126 $ ./flush1 tmp is 0 diff1 is 392 tmp is 20 diff2 is 840 lfence overhead is 147 TSC1 is 245 TSC2 is 693 $ ./flush1 tmp is 0 diff1 is 364 tmp is 20 diff2 is 140 lfence overhead is 154 TSC1 is 210 TSC2 is 18446744073709551602 

“HW prefetcher带来其他块”的说法大约是80%正确。 那是怎么回事? 还有更准确的陈述吗?

你通过删除最后读取的tmp打破Hadi的代码,因此它被gcc优化掉了。 您的定时区域没有负载。 C语句不是asm指令。

查看编译器生成的asm,例如在Godbolt编译器资源管理器上 。 你应该总是这样做,当你试图微软标记这样的低级别的东西,特别是如果你的计时结果是意外的。

  lfence clflush [rcx] lfence lfence rdtsc # start of first timed region lfence # nothing because tmp=array[0] optimized away. lfence mov rcx, rax sal rdx, 32 or rcx, rdx rdtsc # end of first timed region mov edi, OFFSET FLAT:.LC2 lfence sal rdx, 32 or rax, rdx sub rax, rcx mov rsi, rax mov rbx, rax xor eax, eax call printf 

你从-Wall得到一个关于未使用的变量的编译器警告,但你可以用仍然优化的方式使其静音。 例如,你的tmp++没有使tmp可用于函数之外的任何东西,所以它仍然可以优化掉。 沉默警告是不够的:打印值,返回值,或将其分配给定时区域之外的volatile变量。 (或者使用内联asm volatile来要求编译器在某个时刻将它放在寄存器中.Chandler Carruth的CppCon2015谈论使用perf提到了一些技巧: https : //www.youtube.com/watch?v = nXaxk27zwlk)


在GNU C中(至少使用gcc和clang -O3 ),可以通过强制转换为(volatile int*)来强制读取 ,如下所示:

 // int tmp = array[0]; // replace this (void) *(volatile int*)array; // with this 

(void)是为了避免在void上下文中评估表达式的警告,比如写x;

这种看起来像严格别名的UB,但我的理解是gcc定义了这种行为。 Linux内核会在其ACCESS_ONCE宏中投射一个指针来添加一个volatile限定符,因此它被用在gcc非常关心支持的一个代码库中。 你总是可以让整个数组变得volatile ; 如果它的初始化不能自动矢量化则无关紧要。

无论如何,这编译成

  # gcc8.2 -O3 lfence rdtsc lfence mov rcx, rax sal rdx, 32 mov eax, DWORD PTR [rsp] # the load which wasn't there before. lfence or rcx, rdx rdtsc mov edi, OFFSET FLAT:.LC2 lfence 

然后你不必乱用确保使用tmp ,或者担心死区消除,CSE或恒定传播。 在实践中,Hadi的原始答案中的_mm_mfence()或其他内容包括足够的内存阻塞,以使gcc实际上重做了缓存未命中+缓存命中情况的负载,但它很容易优化掉其中一个重载。


请注意,这可能导致asm加载到寄存器中但从不读取它。 当前的CPU仍然等待结果(特别是如果有一个lfence ),但覆盖结果可能会让假想的CPU丢弃负载而不是等待它。 (这取决于编译器是否恰好在下一个lfence之前对寄存器做了其他事情,比如那里的rdtsc结果的mov部分。)

这对硬件来说很难/不太可能,因为CPU必须为exception做好准备,请参阅此处的评论中的讨论 。)据报道,RDRAND确实以这种方式工作( Ivy Bridge上RDRAND指令的延迟和吞吐量是多少? ),但这可能是一个特例。

我在Skylake上自己测试了这个,通过在mov eax, DWORD PTR [rsp]之后添加一个xor eax,eax到编译器的asm输出,来杀死缓存未命中加载的结果。 这并没有影响到时机。

不过,这是一个潜在的问题,可以放弃volatile负载; 未来的CPU可能表现不同。 最好将加载结果(在定时区域之外)求和并将它们最终分配给volatile int sink ,以防将来的CPU开始丢弃产生未读结果的uops。 但仍然使用volatile来加载,以确保它们发生在你想要的地方。


另外,不要忘记进行某种预热循环以使CPU达到最大速度 ,除非您想要以空闲时钟速度测量缓存未命中执行时间。 看起来你的空定时区域需要大量的参考周期,所以你的CPU可能很慢。


那么,缓存攻击(例如崩溃和幽灵)究竟是如何克服这样的问题的呢? 基本上他们必须禁用hw prefetcher,因为他们试图测量相邻的地址,以便查找它们是被击中还是未命中。

作为Meltdown或Spectre攻击的一部分的高速缓存读取侧通道通常使用足够大的步幅,以使HW预取无法检测到访问模式。 例如,在单独的页面上而不是连续的行。 meltdown cache read prefetch stride的第一个google点击之一是https://medium.com/@mattklein123/meltdown-spectre-explained-6bc8634cc0c2 ,它使用了4096的步幅。对于Spectre来说可能更难,因为你的步幅是您可以在目标流程中找到“小工具”的摆布。