0

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

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

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

在代码的最后,会打印开销和未命中延迟。但是,结果无效!

#include <stdio.h>
#include <stdint.h>
#include <x86intrin.h>
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。那可靠吗?

更新1:

感谢彼得和哈迪,总结到目前为止的反应,我发现

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

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

“硬件预取器带来其他块”的说法大约有 80% 正确。那是怎么回事?还有更准确的说法吗?

4

1 回答 1

3

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

查看编译器生成的 asm,例如在 Godbolt compiler explorer 上。当您尝试对此类非常低级的东西进行微基准测试时,您应该始终这样做,尤其是当您的计时结果出乎意料时。

    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定时区域之外的变量。(或使用 inlineasm 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 上下文中评估表达式时发出警告,例如 write x;

这种看起来像严格混叠 UB,但我的理解是 gcc 定义了这种行为。Linux 内核投射一个指针以volatile在其宏中添加一个限定符ACCESS_ONCE,因此它用于 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 或持续传播。实际上_mm_mfence(),Hadi 的原始答案中的或其他内容包括足够的内存屏障,以使 gcc 实际上重做缓存未命中 + 缓存命中情况的负载,但它很容易优化掉其中一个重新加载。


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

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

我自己在 Skylake 上对此进行了测试,xor eax,eax方法是在编译器的 asm 输出中添加 , 以终止mov eax, DWORD PTR [rsp]缓存未命中加载的结果。这并不影响时间。

尽管如此,这仍然是丢弃volatile负载结果的潜在问题。未来的 CPU 可能会有不同的行为。将加载结果(在定时区域之外)相加并在最后将它们分配给 a 可能会更好volatile int sink,以防将来的 CPU 开始丢弃产生未读结果的微指令。但仍volatile用于负载以确保它们发生在您想要的位置。


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


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

作为 Meltdown 或 Spectre 攻击的一部分的缓存读取侧通道通常使用足够大的步幅,以至于硬件预取无法检测到访问模式。例如,在单独的页面上,而不是在连续的行上。第一个谷歌点击meltdown cache read prefetch stridehttps://medium.com/@mattklein123/meltdown-spectre-explained-6bc8634cc0c2,它使用 4096 的步幅。对于 Spectre 来说可能更难,因为你的步幅是由“gadgets”你可以在目标进程中找到。

于 2018-08-23T01:22:29.307 回答