C function to flush all cache lines that hold an array

Viewed 1245

I am trying to force a user application to flush all the cache lines that hold an array (created by itself) from all levels of cache.

After reading this post (Cflush to invalidate cache line via C function) and having great guidance from @PeterCordes, I tried to come up with a function in C that would allow me to accomplish this.

#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;
}

I am compiling with gcc -O3 -march=native source.c -o exec.bin in an AMD Zen2 processor running kernel 5.11.14 (Fedora 33).

I do not completely understand the difference between mfence/sfence/lfence, or when one or the other is enough, so I just used mfence because I believe it imposes the strongest restriction (Am I right?).

My question is: Am I missing something with this implementation? Will it do what I imagine it will do? (my imagination is in a comment after calling the flush_cache_range function)

Thanks.


Edit 1: flushing once per line, and removing fences.

After the answer of @PeterCordes I am making some adjustments:

  • First, the function receives a pointer to char and its size in chars, because they are 1 byte long, so I have control on how much to jump from one flush to the next.

  • Then, I need to confirm the size of my cache lines. I can get that info using the program cpuid:
    cpuid -1 | grep -A12 -e "--- cache [0-9] ---"
    For L1i, L1d, L2, and L3, I get line size in bytes = 0x40 (64) So this is the amount of bytes I have to skip after each flush.

  • Then I determine the pointer to the last char by adding ptr + len - 1.

  • And loop across all the addresses, one in each cache line, including the last one (ptr_end).

This is the updated version of the code:

#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;
}

Now when I compile and run perf: gcc -O3 -march=native src/source.c -o exec.bin && perf stat -e cache-misses,cache-references ./exec.bin

I get:

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


If I comment the line calling flush_cache_range, I get pretty much the same:

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

What am I missing?


Edit 2: adding sfence, and fixing loop limit

  • I added the sfence as suggested by @prl
  • Changed ptr_end to point to the last byte of its cache line.
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();
}

I still get the same unexpected results in perf.

1 Answers

Yes that looks correct but quite inefficient.

Your expectation of cache misses afterward (mitigated by HW prefetch) is justified. You can use perf stat to check, if you write some actual test code that uses the array later.


You run clflushopt on each separate uint64_t, but x86 cache lines are 64 bytes on every current CPU that supports clflushopt. So you're doing 8x as many flushes, and repeated flushes to the same line can be quite slow on some CPUs. (Worse than flushing more lines that were hot in cache.)

See my answer on The right way to use function _mm_clflush to flush a large struct for the general case where the array starts at unknown alignment relative to a cache line, and the array size isn't a multiple of the line size. Running clflush or clflushopt once on each cache line that contains any of an array / struct.

Flushing is idempotent except for performance, so you can just loop with 64-byte increments and also flush the last byte of the array, but in that linked answer I came up with a cheap way to implement the loop logic to touch each line exactly once. For an array pointer + length, obviously use sizeof(ptr[0]) * len instead of sizeof(struct) like that linked answer used.


Code review: API

Flushes work on whole lines. Either take a char*, or a void* which you then cast to char* to increment it by the line size. Because logically, you give the asm instruction a pointer and it flushes just the one line containing that byte.


Memory Barrier Not Needed Before

It's pointless to mfence before flushing; clflushopt is ordered wrt. stores to the same cache line, so a store then clflushopt to the same line (in that order in asm) will happen in that order, flushing the newly stored data. The manual documents this (https://www.felixcloutier.com/x86/clflushopt is from Intel's, I assume AMD's manual documents the same semantics for it on their CPUs.)

I think/hope that C compilers treat _mm_clflushopt(p) like at least a volatile access to the whole line containing p, and thus won't do compile-time reordering of stores to any C objects that *p could alias. (And probably not loads either.) If not, you'd want at most asm("":::"memory"), a compile-time-only barrier. (Like atomic_signal_fence(memory_order_seq_cst), not atomic_thread_fence).


I think it's also somewhat unnecessary to fence after, if your loop is non-tiny, if all you care about is this thread getting cache misses. It's certainly useless to use sfence, which doesn't order loads at all, unlike mfence or lfence

The normal reason to use sfence after clflushopt is to guarantee that earlier stores have made it to persistent storage, before any later stores, e.g. to make it possible to recover consistency after a crash. (In a system with Optane DC PM or other kind of truly memory-mapped non-volatile RAM). See for example this Q&A about clflushopt ordering and why sfence is sometimes needed.

That doesn't force later loads to miss, they're not ordered wrt. sfence and thus can execute early, before sfence and before clflushopt. mfence would prevent that.

lfence (or about ROB size amount of uops, e.g. 224 on Skylake) might, but waiting for clflushopt to just retire from the out-of-order back-end doesn't mean it's done evicting the line. It might work more like a store, and have to go through the store buffer.

I tested this on my CPU, and 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% )

That's with sfence, and is surprisingly the fastest. The average run time is pretty variable. Using clflush instead of clflushopt doesn't change timing much, but more uops: 150,185,359 uops_issued.any (fused domain) and 110,219,059 uops_executed.thread (unfused domain).

With mfence is the slowest, and every clflush costs us two cache misses (one this iteration right after the load, and another next iteration when we get back to it.)

## 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% )

With no fence, still slower then sfence. I don't know why. Perhaps sfence stops the clflush operations from executing so fast, giving loads in later iterations a chance to get ahead of them and both read the cache line before clflushopt evicts it?

     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% )

These experimental results are from Intel Skylake, not AMD

(And older or newer Intel may be different in how they allow loads to reorder with clflushopt.

Related