【问题标题】:C function to flush all cache lines that hold an arrayC函数刷新所有保存数组的缓存行
【发布时间】:2021-09-09 07:22:25
【问题描述】:

我正在尝试强制用户应用程序从所有级别的缓存中刷新所有保存数组(由它自己创建)的缓存行。

在阅读了这篇文章 (Cflush to invalidate cache line via C function) 并得到了 @PeterCordes 的大力指导后,我尝试用 C 语言编写一个函数来完成此任务。

#include <x86intrin.h>
#include <stdint.h>

inline void flush_cache_range(uint64_t *ptr, size_t len){
    size_t i;
    // prevent any load or store to be scheduled across 
    // this point due to CPU Out of Order execution.
    _mm_mfence();
    for(i=0; i<len; i++)
        // flush the cache line that contains ptr+i from 
        // all cache levels
        _mm_clflushopt(ptr+i); 
    _mm_mfence();
}

int main(){
    size_t plen = 131072; // or much bigger
    uint64_t *p = calloc(plen,sizeof(uint64_t));
    for(size_t i=0; i<plen; i++){
        p[i] = i;
    }
    flush_cache_range(p,plen);
    // at this point, accessing any element of p should
    // cause a cache miss. As I access them, adjacent
    // elements and perhaps pre-fetched ones will come
    // along.
    (...)
    return 0;
}

我在运行内核 5.11.14 (Fedora 33) 的 AMD Zen2 处理器中使用 gcc -O3 -march=native source.c -o exec.bin 进行编译。

我不完全理解mfence/sfence/lfence 之间的区别,或者当一个或另一个足够时,所以我只使用了mfence,因为我相信它施加了最强的限制(我对吧?)。

我的问题是:我是否缺少此实现的某些内容?它会像我想象的那样做吗? (我的想象是在调用flush_cache_range函数后的评论中)

谢谢。


编辑 1:每行刷新一次,并移除栅栏。

在@PeterCordes 的回答之后,我正在做一些调整:

  • 首先,该函数接收一个指向 char 的指针及其以 char 为单位的大小,因为它们是 1 个字节长,所以我可以控制从一个刷新到下一个刷新的跳转量。

  • 然后,我需要确认缓存行的大小。我可以使用程序 cpuid:
    cpuid -1 | grep -A12 -e "--- cache [0-9] ---"
    获取该信息 对于 L1i、L1d、L2 和 L3,我得到 line size in bytes = 0x40 (64) 所以这是每次刷新后我必须跳过的字节数。

  • 然后我通过添加ptr + len - 1 来确定指向最后一个字符的指针。

  • 然后遍历所有地址,每个缓存行一个,包括最后一个 (ptr_end)。

这是代码的更新版本:

#include <stdio.h>
#include <x86intrin.h>
#include <stdint.h>

inline void flush_cache_range(char *ptr, size_t len);

void flush_cache_range(char *ptr, size_t len){
    const unsigned char cacheline = 64;
    char *ptr_end = ptr + len - 1;
    while(ptr <= ptr_end){
        _mm_clflushopt(ptr);
        ptr += cacheline;
    }
}

int main(){
    size_t i, sum=0, plen = 131072000; // or much bigger
    uint64_t *p = calloc(plen,sizeof(uint64_t));
    for(i=0; i<plen; i++){
        p[i] = i;
    }
    flush_cache_range((char*)p, sizeof(p[0])*plen);
    // there should be many cache misses now
    for(i=0; i<plen; i++){
        sum += p[i];
    }
    printf("sum is:%lu\n", sum);
    return 0;
}

现在当我编译并运行perf时: gcc -O3 -march=native src/source.c -o exec.bin &amp;&amp; perf stat -e cache-misses,cache-references ./exec.bin

我明白了:

sum is:8589934526464000
 Performance counter stats for './exec.bin':

         1,202,714      cache-misses:u # 1.570 % of all cache refs    
        76,612,476      cache-references:u                                          
       0.377100534 seconds time elapsed
       0.170473000 seconds user
       0.205574000 seconds sys


如果我评论调用flush_cache_range 的行,我得到的几乎相同:

sum is:8589934526464000

 Performance counter stats for './exec.bin':
         1,211,462      cache-misses:u # 1.590 % of all cache refs    
        76,202,685      cache-references:u                                          
       0.356544645 seconds time elapsed
       0.160227000 seconds user
       0.195305000 seconds sys

我错过了什么?


编辑 2:添加sfence,并修复循环限制

  • 我按照@prl 的建议添加了 sfence
  • 将 ptr_end 更改为指向其缓存行的最后一个字节。
void flush_cache_range(char *ptr, size_t len){
    const unsigned char cacheline = 64;
    char *ptr_end = (char*)(((size_t)ptr + len - 1) | (cacheline - 1));

    while(ptr <= ptr_end){
        _mm_clflushopt(ptr);
        ptr += cacheline;
    }
    _mm_sfence();
}

我在性能方面仍然得到同样的意外结果。

【问题讨论】:

  • 您的编辑看起来应该是答案,而不是问题的一部分。
  • 嗨@PeterCordes。这些版本并没有解决问题,而是提供了更多的上下文以及我认为存在问题的原因。这就是为什么我不认为它们是答案。
  • 您的编译命令中的-o3 是不是拼写错误?应该是-O3,大写O。
  • // there should be many cache misses now - 你没有做任何事情来破坏循环内的硬件预取,或者来自同一行的所有 4 个向量加载都一起去。 IDK 如果 Zen2 有足够的带宽用于硬件预取以跟上每个时钟周期 16 个字节(或者如果 GCC 的循环不是 5 微秒或更少,则每多于 1 个字节),但如果是这样,那可以解释它。使用 -march=native 可能会有所帮助(对于 AVX2)和 -funroll-all-loops。或者使用clang-O3 -march=native;它可能使用多个向量累加器来隐藏 1/clock vpaddq 延迟。

标签: c x86 intrinsics cpu-cache memory-barriers


【解决方案1】:

是的,看起来正确但效率很低。

您对之后缓存未命中的期望(通过硬件预取减轻)是合理的。你可以使用perf stat来检查,如果你以后写了一些实际使用数组的测试代码。


您在每个单独的uint64_t 上运行 clflushopt,但在每个支持 clflushopt 的当前 CPU 上,x86 高速缓存行是 64 字节。因此,您正在执行 8 倍的刷新,并且在某些 CPU 上重复刷新到同一行可能会非常慢。 (比刷新缓存中热的更多行更糟糕。)

请参阅我在 The right way to use function _mm_clflush to flush a large struct 上的回答,了解数组以相对于缓存行的未知对齐方式开始的一般情况,并且数组大小不是行大小的倍数。在包含任何数组/结构的每个缓存行上运行一次 clflush 或 clflushopt。

除性能外,刷新是幂等的,因此您可以只循环使用 64 字节增量并刷新数组的最后一个字节,但在那个链接的答案中,我想出了一个便宜的方法来实现循环逻辑以准确地触摸每条线一次。对于数组指针 + 长度,显然使用 sizeof(ptr[0]) * len 而不是 sizeof(struct) 就像使用的链接答案一样。


代码审查:API

冲洗适用于整行。要么采用char*,要么采用void*,然后将其转换为char*,以按行大小递增。因为从逻辑上讲,你给 asm 指令一个指针,它只刷新包含该字节的一行。


之前不需要内存屏障

在冲洗之前冲洗之前围起来是没有意义的; clflushopt 是按顺序订购的。存储到相同的缓存行,因此存储然后 clflushopt 到同一行(按 asm 中的顺序)将按该顺序发生,刷新新存储的数据。手册记录了这一点(https://www.felixcloutier.com/x86/clflushopt 来自英特尔,我假设 AMD 的手册在其 CPU 上记录了相同的语义。)

我认为/希望 C 编译器将 _mm_clflushopt(p) 视为至少对包含 p 的整行的易失性访问,因此不会在编译时对 *p 可以存储的任何 C 对象进行重新排序别名。 (并且可能也不会加载。)如果没有,您最多需要asm("":::"memory"),这是一个仅编译时的屏障。 (像 atomic_signal_fence(memory_order_seq_cst),不是 atomic_thread_fence)。


我认为,如果你的循环非常小,如果你关心的只是这个线程获得缓存未命中,那么在之后进行栅栏也是不必要的。使用sfence 肯定没用,它根本不会订购负载,不像mfencelfence

clflushopt 之后使用sfence 的正常原因是为了保证较早的存储在任何以后的存储之前已经进入持久存储,例如以便在崩溃后恢复一致性。 (在具有 Optane DC PM 或其他类型的真正内存映射非易失性 RAM 的系统中)。例如,请参阅 this Q&A 关于 clflushopt 排序以及为什么有时需要 sfence。

这不会迫使以后的加载丢失,它们不是按顺序排列的。 sfence,因此可以在sfenceclflushopt 之前提前执行。 mfence 会阻止这种情况。

lfence(或大约 ROB 大小的 uops,例如 Skylake 上的 224 个)可能,但等待 clflushopt 从无序后端退出不会'不意味着它已经完成驱逐线路。它可能更像是一个存储,并且必须通过存储缓冲区。

我在我的 CPU 和 i7-6700k Intel Skylake 上对此进行了测试:

default rel
%ifdef __YASM_VER__
    CPU Conroe AMD
    CPU Skylake AMD
%else
%use smartalign
alignmode p6, 64
%endif

global _start
_start:

%if 1
    lea        rdi, [buf]
    lea        rsi, [bufsrc]
%endif

    mov     ebp, 10000000

  mov [rdi], eax
  mov [rdi+4096], edx   ; dirty a couple BSS pages
align 64
.loop:
    mov  eax, [rdi]
;    mov  eax, [rdi+8]
    clflushopt [rdi]          ; clflush vs. clushopt doesn't make a different here except in uops issued/executed
sfence            ; actually speeds things up
;mfence           ; the load after this definitely misses.
    mov  eax, [rdi+16]
;    mov  eax, [rdi+24]
    add rdi, 64        ; next cache line
    and rdi, -(1<<14)  ; wrap to 4 pages
    dec ebp
    jnz .loop
.end:

    xor edi,edi
    mov eax,231   ; __NR_exit_group  from /usr/include/asm/unistd_64.h
    syscall       ; sys_exit_group(0)


section .bss
align 4096
buf:    resb 4096*4096

bufsrc:  resb 4096
resb 100
t=testloop; asm-link -dn "$t".asm && taskset -c 3 perf stat --all-user -etask-clock,context-switches,cpu-migrations,page-faults,cycles,instructions,uops_issued.any,uops_executed.thread,mem_load_retired.l1_hit,mem_load_retired.l1_miss -r4 ./"$t"
+ nasm -felf64 -Worphan-labels testloop.asm
+ ld -o testloop testloop.o

...
0000000000401040 <_start.loop>:
  401040:       8b 07                   mov    eax,DWORD PTR [rdi]
  401042:       66 0f ae 3f             clflushopt BYTE PTR [rdi]
  401046:       0f ae f8                sfence 
  401049:       8b 47 10                mov    eax,DWORD PTR [rdi+0x10]
  40104c:       48 83 c7 40             add    rdi,0x40
  401050:       48 81 e7 00 c0 ff ff    and    rdi,0xffffffffffffc000
  401057:       ff cd                   dec    ebp
  401059:       75 e5                   jne    401040 <_start.loop>
...

 Performance counter stats for './testloop' (4 runs):

            334.27 msec task-clock                #    0.999 CPUs utilized            ( +-  7.62% )
                 0      context-switches          #    0.000 K/sec                  
                 0      cpu-migrations            #    0.000 K/sec                  
                 3      page-faults               #    0.009 K/sec                  
     1,385,639,019      cycles                    #    4.145 GHz                      ( +-  7.68% )
        80,000,116      instructions              #    0.06  insn per cycle           ( +-  0.00% )
       100,271,634      uops_issued.any           #  299.968 M/sec                    ( +-  0.04% )
       100,257,154      uops_executed.thread      #  299.924 M/sec                    ( +-  0.04% )
            16,894      mem_load_retired.l1_hit   #    0.051 M/sec                    ( +- 17.24% )
         2,347,561      mem_load_retired.l1_miss  #    7.023 M/sec                    ( +- 14.76% )

            0.3346 +- 0.0255 seconds time elapsed  ( +-  7.62% )

这是sfence,速度惊人的最快。平均运行时间变化很大。使用clflush 而不是clflushopt 不会改变太多时间,但更多的微指令:150,185,359 uops_issued.any(融合域)和110,219,059 uops_executed.thread(未融合域)。

mfence 是最慢的,每次 clflush 都会导致两次缓存未命中(加载后立即进行一次迭代,当我们返回时进行下一次迭代。)

## With MFENCE
     3,765,471,466      cycles                    #    4.129 GHz                      ( +-  1.26% )
        80,000,292      instructions              #    0.02  insn per cycle           ( +-  0.00% )
       160,386,634      uops_issued.any           #  175.881 M/sec                    ( +-  0.03% )
       100,533,848      uops_executed.thread      #  110.246 M/sec                    ( +-  0.06% )
             7,005      mem_load_retired.l1_hit   #    0.008 M/sec                    ( +- 21.58% )
         9,966,476      mem_load_retired.l1_miss  #   10.929 M/sec                    ( +-  0.05% )

没有围栏,仍然比sfence 慢。我不知道为什么。或许sfence 会阻止 clflush 操作如此快速地执行,从而让后续迭代中的负载有机会领先于它们并在 clflushopt 驱逐之前读取缓存行?

     2,047,314,028      cycles                    #    4.125 GHz                      ( +-  2.58% )
        70,000,166      instructions              #    0.03  insn per cycle           ( +-  0.00% )
        80,619,482      uops_issued.any           #  162.427 M/sec                    ( +-  0.05% )
        80,584,719      uops_executed.thread      #  162.357 M/sec                    ( +-  0.04% )
            66,198      mem_load_retired.l1_hit   #    0.133 M/sec                    ( +-  6.61% )
         4,814,405      mem_load_retired.l1_miss  #    9.700 M/sec                    ( +-  4.59% )

这些实验结果来自 Intel Skylake,而不是 AMD

(旧的或新的英特尔在允许负载重新排序的方式上可能会有所不同clflushopt

【讨论】:

  • (如果我之前还记得那个问答,我会在之前的问题讨论中链接The right way to use function _mm_clflush to flush a large struct。)
  • Clflushopt 对于以后对该行的访问没有排序,因此如果您想确保后续访问未命中缓存,则应在 clflushopt 后跟一个 sfence。 (当然,即使这样也不一定足够,因为预取器可以随时重新加载行。)
  • 您好,我尝试了建议的更改,但仍然得到意想不到的结果。我正在添加@prl 建议的sfence
  • @prl: 嗯,如果您想确保后续加载未命中,您不需要 mfence 来确保加载不会在 sfence 之前(以及 clflush 之前)发生吗?在 Skylake 上的实践中,交替 clflush / load,我得到每个 clflush 与 mfence (mem_load_retired.l1_miss) 的缓存未命中(如 9.987 M 用于 10M clflush)和少数(~15k)l1_hit。在没有栅栏的情况下,我获得了约 4M +- 10% 的点击率,而在使用 sfence 的情况下,我惊讶地获得了 1.5 到 1.8M 的 l1d_hit 计数,但性能更快。 (~1.1G 周期 vs. 1.8G 没有围栏 vs. 3.7 有 mfence。i7-6700k)godbolt.org/z/qeY4hv917
  • ...(只有 MFENCE 可以在 AMD 上命令刷新)以确定性地显示刷新的效果。看起来 OP 不明白硬件预取需要被击败,因为刷新之后的需求负载一直错过。有多种方法可以做到这一点。这个答案显示了一个。如果选项可用,另一种方法是禁用 BIOS 中的预取器。还有一种方法是访问每一页中的一行或以不可预知的顺序访问这些行。
猜你喜欢
  • 2010-09-28
  • 1970-01-01
  • 1970-01-01
  • 1970-01-01
  • 2013-09-24
  • 2017-05-03
  • 2012-01-29
  • 1970-01-01
  • 1970-01-01
相关资源
最近更新 更多