如何解决C 函数刷新所有包含数组的缓存行 代码审查:API
我试图强制用户应用程序从所有级别的缓存中刷新保存数组(由其自身创建)的所有缓存行。
在阅读这篇文章 (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 的指针及其大小(以字符为单位),因为它们有 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 && 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,685 cache-references:u
0.356544645 seconds time elapsed
0.160227000 seconds user
0.195305000 seconds sys
我错过了什么?
编辑 2:添加 sfence
,并修复循环限制
- 我按照@prl 的建议添加了围栏
- 将 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();
}
我仍然在性能方面得到了同样的意外结果。
解决方法
是的,看起来正确但效率很低。
您对事后缓存未命中的期望(通过硬件预取减轻)是合理的。您可以使用 perf stat
来检查,如果您编写了一些稍后使用该数组的实际测试代码。
您在每个单独的 uint64_t
上运行 clflushopt,但 x86 缓存行在每个当前支持 clflushopt 的 CPU 上都是 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 是订购wrt。存储到相同的缓存行,因此将 clflushopt 存储到同一行(在 asm 中的顺序)将按该顺序发生,刷新新存储的数据。手册记录了这一点(https://www.felixcloutier.com/x86/clflushopt 来自英特尔,我假设 AMD 的手册在其 CPU 上记录了相同的语义。)
我认为/希望 C 编译器将 _mm_clflushopt(p)
视为至少对包含 p
的整行的可变访问,因此不会在编译时将存储重新排序到任何 C 对象*p
可以别名。 (并且可能也不会加载。)如果没有,您最多需要 asm("":::"memory")
,一个仅编译时屏障。 (像 atomic_signal_fence(memory_order_seq_cst)
,而不是 atomic_thread_fence)。
我认为如果您的循环不是微小的,并且您只关心该线程是否会缓存未命中,那么在此之后进行围栏也是不必要的。使用 sfence
肯定没用,它根本不排序加载,不像 mfence
或 lfence
在 sfence
之后使用 clflushopt
的正常原因是为了保证较早的存储在任何后续存储之前已进入持久存储,例如使崩溃后恢复一致性成为可能。 (在具有傲腾 DC PM 或其他类型的真正内存映射非易失性 RAM 的系统中)。参见 this Q&A 示例,了解 clflushopt 的排序以及有时需要围栏的原因。
这不会迫使以后的负载丢失,它们不是按顺序订购的。 sfence
,因此可以提前执行,在 sfence
之前和 clflushopt
之前。 mfence
会阻止这种情况发生。
lfence
(或大约 ROB 大小的 uops 数量,例如 Skylake 上的 224)可能,但要等待 clflushopt
从无序返回中退出 -end 并不意味着它已经完成驱逐线。它可能更像一个存储,并且必须通过存储缓冲区。
我在我的 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
不会改变太多时间,但更多 uops: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,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,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
重新排序负载的方式方面可能有所不同。
版权声明:本文内容由互联网用户自发贡献,该文观点与技术仅代表作者本人。本站仅提供信息存储空间服务,不拥有所有权,不承担相关法律责任。如发现本站有涉嫌侵权/违法违规的内容, 请发送邮件至 dio@foxmail.com 举报,一经查实,本站将立刻删除。