CPU为了更快速度读取数据,都会用到Cache,因为直接访问RAM速度会比较慢。现代的CPU架构都会支持多级Cache,有单核独享的cache,也有多核共享的cache。

Cache Ping-Pong

当多个core要并行操作内存中的同一份数据,就会出现Cache ping-pong的问题. 举一个多线程修改变量的例子:

#include <iostream>
#include <thread>
#include <atomic>

std::atomic<std::uint64_t> counter;
std::uint64_t max_iter = 10000000000;

void single_thread()
{
    counter = 0;
    for(std::uint64_t pos = 0; pos < max_iter; pos ++){
        counter ++;
    }
    std::cout << "counter:" << counter << std::endl;
}

void multi_thread()
{
    counter = 0;
    max_iter /= 2;
    std::thread t0 = std::thread([&](){
        for(std::uint64_t pos = 0; pos < max_iter; pos ++){
            counter ++;
        }
    });
    std::thread t1 = std::thread([&](){
        for(std::uint64_t pos = 0; pos < max_iter; pos ++){
            counter ++;
        }
    });
    if(t0.joinable()){t0.join();}
    if(t1.joinable()){t1.join();}
    std::cout << "counter:" << counter << std::endl;
}

int main(int argc, const char **argv)
{
    //single_thread();
    multi_thread();
    return 0;
}

编译运行:

g++ -o3 -pthread example.cpp -o example && time ./example

运行结果: 单线程版本:63.19s user 0.00s system 99% cpu 1:03.19 total 多线程版本:276.84s user 0.01s system 197% cpu 2:19.87 total

可以看到单线程版本和多线程版本cpu都运行满的情况下,单线程版本居然比多线程更快。

使用perf统计性能:sudo apt-get install -y linux-tools-$(uname -r) linux-tools-generic && sudo perf stat ./example 结果为:

Performance counter stats for './example':

         63,188.09 msec task-clock                #    1.000 CPUs utilized          
                41      context-switches          #    0.649 /sec                   
                 3      cpu-migrations            #    0.047 /sec                   
               129      page-faults               #    2.042 /sec                   
   270,748,024,665      cycles                    #    4.285 GHz                    
   240,110,277,766      instructions              #    0.89  insn per cycle         
    40,019,880,972      branches                  #  633.345 M/sec                  
           275,858      branch-misses             #    0.00% of all branches        

      63.189142058 seconds time elapsed

      63.188912000 seconds user
       0.000000000 seconds sys

Performance counter stats for './example':

        316,290.36 msec task-clock                #    1.998 CPUs utilized          
               570      context-switches          #    1.802 /sec                   
                76      cpu-migrations            #    0.240 /sec                   
               140      page-faults               #    0.443 /sec                   
 1,353,827,714,501      cycles                    #    4.280 GHz                    
   240,501,155,187      instructions              #    0.18  insn per cycle         
    40,091,473,600      branches                  #  126.755 M/sec                  
         1,145,854      branch-misses             #    0.00% of all branches        

     158.316800457 seconds time elapsed

     316.291403000 seconds user
       0.000000000 seconds sys

可以看到insn per cycle(表示每时钟周期运行多少个指令)单线程为0.89,比多线程0.18大得多。

分析: 为了保证cache的一致性,如果两个核心的cache中都是用了同一个变量,那么该变量会分别出现在C1 cache和C2 cache中, 而在多核计算机上,操作系统显然有极大概率将两个线程分配给了两个CPU核心,这里假设为C1和C2。
两个线程都需要对该变量执行加1操作,假设此时线程1开始对变量执行加法操作,为保证cache一致性必须将C2 cache中的变量置为无效:乒。
此后,C2将不得不从内存中读取变量的值,然而C2也需要将全局变量加1,为保证cache一致性必须将C1 cache中的变量置为无效:乓。
至此两个线程频繁的维持cache一致性导致缓存不但没有起到应有的作用反而拖累了程序性能。在这种情况下,维护cache的开销以及从内存中读取数据的开销占据了主导地位,这样的多线程程序其性能反而不如单线程程序。

False Sharing(伪共享)

缓存系统中是以缓存行(cache line)为单位存储的。缓存行是2的整数幂个连续字节,一般为32-256个字节。最常见的缓存行大小是64个字节。当多线程修改互相独立的变量时,如果这些变量共享同一个缓存行,就会无意中影响彼此的性能,这就是伪共享。缓存行上的写竞争是运行在SMP系统中并行线程实现可伸缩性最重要的限制因素。有人将伪共享描述成无声的性能杀手,因为从代码中很难看清楚是否会出现伪共享。

#include <iostream>
#include <thread>
#include <atomic>

struct CounterType{
    std::uint64_t a;
    //int arr[16];
    std::uint64_t b;
} counter;

std::uint64_t max_iter = 10000000000;

void single_thread()
{
    counter.a = 0;
    counter.b = 0;
    for(std::uint64_t pos = 0; pos < max_iter; pos ++){
        counter.a ++;
    }
    for(std::uint64_t pos = 0; pos < max_iter; pos ++){
        counter.b ++;
    }
    std::cout << "counter:" << counter.a << " " << counter.b << std::endl;
}

void multi_thread()
{
    counter.a = 0;
    counter.b = 0;
    std::thread t0 = std::thread([&](){
        for(std::uint64_t pos = 0; pos < max_iter; pos ++){
            counter.a ++;
        }
    });
    std::thread t1 = std::thread([&](){
        for(std::uint64_t pos = 0; pos < max_iter; pos ++){
            counter.b ++;
        }
    });
    if(t0.joinable()){t0.join();}
    if(t1.joinable()){t1.join();}
    std::cout << "counter:" << counter.a << " " << counter.b << std::endl;
}

int main(int argc, const char **argv)
{
    single_thread();
    //multi_thread();
    return 0;
}

编译运行:

g++ -o3 -pthread example.cpp -o example && time ./example

运行结果: 单线程版本:36.19s user 0.00s system 99% cpu 36.200 total 多线程版本:64.40s user 0.00s system 195% cpu 32.883 total

可以看到多线程操作并没有共享变量,此时单线程版本依然比多线程版本更快。

分析: 尽管这两个线程没有共享任何变量,但这两个变量极有可能位于同一个cache line上,也就是说这两个变量可能会共享同一个cache line。cache和内存之间是按照cache line为单位来交互的,当访问变量a未能命中cache时,会把a所在的cache line一并加载到缓存中,而变量b极有可能也会被加载进来。
也就是说,尽管看上去这两个线程没有共享任何数据,但cache的工作方式导致其可能会共享cache line,这就是有趣的False Sharing问题,直译过来就是伪共享,这同样会导致cache ping-ponging问题。
通过查看cache line 大小:cat cat /sys/devices/system/cpu/cpu0/cache/index0/coherency_line_size 结果为:64字节。 在变量a与b之间填充超过64字节大小的数据即可避免False Sharing问题。


Reference