作者:[email protected]
博客:blog.focus-linux.net   linuxfocus.blog.chinaunix.net
 
 
本文的copyleft归[email protected]所有,使用GPL发布,可以自由拷贝,转载。但转载请保持文档的完整性,注明原作者及原链接,严禁用于任何商业用途。
======================================================================================================
在现代的程序设计中,多核编程已经是很普遍的应用了。多核编程究竟有什么不同?我们如何提高多核编程的性能?针对这个问题,我们需要了解多核与单核在体系架构上有什么不同。

由于本文不是用于介绍多核架构的文章,所以不准备对其架构进行展开。感兴趣的朋友可以自行搜索google。今天就说其中的一点。大家都知道现代的CPU都具有cache,用于提高CPU访问指令或者数据的速度——一般来说,指令cache和数据cache是分开的,因为这样性能更好。在cache的匹配和访问过程中,cache的最小单元是line,即cache line,有的也称其为cache的data block。之所以称为block,因为在cache中存的不是内存传递的最小单元(字),而是多个字——32位机,一个字为4个bytes。当cache miss的时候,CPU从内存中预取一个data block大小的数据,放到cache中。(这里只是一个极其简单的描述,准确具体请google)。

回归正题。在多核编程下,cache line又是如何影响多核的性能的呢。比如有两个CPU,CPU1要修改一个变量var的值。这时var是在CPU1的cache中的,var的值被更新。那么万一CPU2的cache中也有var怎么办?为了保证数据的一致性,CPU1需要使CPU2中var变量对应的cache line失效或者将其同样更新为最新值。一般来说,使其失效更为普遍。如果使失效,那么当CPU2要访问var时,会产生一次cache miss。如果使其更新,同样要涉及更新CPU2的cache line操作,都是要损失一定性能的。

在多核编程的时候,为了保证并发性,往往使用空间来换取时间,让每个CPU访问独立的变量或者per cpu的变量,来避免加锁。这是一种很常见的多核编程技巧。一般的简单实现,都是使用数组来实现,其中数组的个数为CPU的个数。那么,在这个时候,该变量就需要选用一个适当的size,来避免多核cache失效带来的性能下降。

下面看实例。(我的硬件平台:双核Intel(R) Pentium(R) 4 CPU,这个CPU的cache line为64 bytes)

  1. #define _GNU_SOURCE
  2. #include <pthread.h>
  3. #include <sched.h>
  4. #include <stdio.h>
  5. #include <stdlib.h>
  6. #include <errno.h>
  7. #include <sys/types.h>
  8. #include <unistd.h>

  9. // 设置线程的CPU亲和性,使不同线程同时运行在不同的CPU上
  10. static int set_thread_affinity(int cpu_id)
  11. {
  12.     cpu_set_t cpuset;
  13.     int ret;

  14.     CPU_ZERO(&cpuset);
  15.     CPU_SET(cpu_id, &cpuset);

  16.     ret = pthread_setaffinity_np(pthread_self(), sizeof(cpu_set_t), &cpuset);
  17.     if (ret != 0) {
  18.         printf("set affinity error\n");
  19.         return -1;
  20.     }

  21.     return 0;
  22. }

 //检查线程的CPU亲和性
  1. static void check_cpu_affinity(void)
  2. {
  3.     cpu_set_t cpu_set;
  4.     int ret;
  5.     int i;

  6.     ret = pthread_getaffinity_np(pthread_self(), sizeof(cpu_set_t), &cpu_set);
  7.     if (ret != 0) {
  8.         printf("check err!\n");
  9.         return;
  10.     }

  11.     for (i = 0; i < CPU_SETSIZE; ++i) {
  12.         if (CPU_ISSET(i, &cpu_set)) {
  13.             printf("cpu %d\n", i);
  14.         }
  15.     }

  16. }


  17. #define CPU_NR          2
  18. #define CACHE_LINE_SIZE 64
  19. #define VAR_NR ((CACHE_LINE_SIZE/sizeof(int))-1)
  20. //这个结构为多核编程中最频繁使用的结构
  21. //其size大小为本文重点
  22. struct key {
  23.     int a[VAR_NR];
  24.     //int pad;
  25. } __attribute__((packed));
  26. //使用空间换时间,每个CPU拥有不同的数据
  27. static struct key g_key[CPU_NR];

  //丑陋的硬编码——这里仅仅为了说明问题,我就不改了。
  1. static void real_job(int index)
  2. {
  3. #define LOOP_NR 100000000
  4.     struct key *k = g_key+index;

  5.     int i;
  6.     for (i = 0; i < VAR_NR; ++i) {
  7.         k->a[i] = i;
  8.     }

  9.     for (i = 0; i < LOOP_NR; ++i) {
  10.         k->a[14] = k->a[14]+k->a[3];
  11.         k->a[3] = k->a[14]+k->a[5];
  12.         k->a[1] = k->a[1]+k->a[7];
  13.         k->a[7] = k->a[1]+k->a[9];
  14.     }
  15. }

  16. static volatile int thread_ready = 0;

  //这里使用丑陋的硬编码。最好是通过参数来设置亲和的CPU
  //这个线程运行在CPU 1上
  1. static void *thread_task(void *data)
  2. {
  3.     set_thread_affinity(1);
  4.     check_cpu_affinity();

  5.     thread_ready = 1;

  6.     real_job(1);

  7.     return NULL;
  8. }

  9. int main(int argc, char *argv[])
  10. {
  11.     pthread_t tid;
  12.     int ret;
 
     //设置主线程运行在CPU 0上
  1.     ret = set_thread_affinity(0);
  2.     if (ret != 0) {
  3.         printf("err1\n");
  4.         return -1;
  5.     }
  6.     check_cpu_affinity();

     //提高优先级,避免进程被换出。因为换出后,cache会失效,会影响测试效果
  1.     ret = nice(-20);
  2.     if (-1 == ret) {
  3.         printf("err2\n");
  4.         return -1;
  5.     }

  6.     ret = pthread_create(&tid, NULL, thread_task, NULL);
  7.     if (ret != 0) {
  8.         printf("err2\n");
  9.         return -1;
  10.     }

     //忙等待,使两个real_job同时进行
  1.     while (!thread_ready)
  2.         ;

  3.     real_job(0);

  4.     pthread_join(tid, NULL);

  5.     printf("Completed!\n");

  6.     return 0;
  7. }

感兴趣的同学,可以修改这代码,使其运行更多的线程来测试。但是一定注意你的平台的cache line的大小。

第一次,关键结构struct key的size为60字节。这样主线程CPU 0 在访问g_key[0]的时候,其对应的cache line包含了g_key[1]的开头部分的数据。那么当主线程更新g_key[0]的值时,会使CPU 1的cache失效,导致CPU1 访问g_key[1]的部分数据时产生cache miss,从而影响性能。

下面编译运行:
  1. [root@Lnx99 cache]#gcc -g -Wall cache_line.c -lpthread -o no_padd
  2. [root@Lnx99 cache]#time ./no_padd
  3. cpu 0
  4. cpu 1
  5. Completed!
  6. real 0m9.830s
  7. user 0m19.427s
  8. sys 0m0.011s
  9. [root@Lnx99 cache]#time ./no_padd
  10. cpu 0
  11. cpu 1
  12. Completed!
  13. real 0m10.081s
  14. user 0m20.074s
  15. sys 0m0.010s
  16. [root@Lnx99 cache]#time ./no_padd
  17. cpu 0
  18. cpu 1
  19. Completed!
  20. real 0m9.989s
  21. user 0m19.877s
  22. sys 0m0.010s
下面我们把int pad前面的//去掉,使struct key的size变为64字节,即与cache line匹配。这时CPU 0修改g_key[0]时就不会影响CPU 1的cache。因为g_key[1]的数据不包含在g_key[0]所在的CPU 0的cache中。也就是说g_key[0]和g_key[1]的所在的cache line已经独立,不会互相影响了。

请看测试结果:
  1. [root@Lnx99 cache]#gcc -g -Wall cache_line.c -lpthread -o padd
  2. [root@Lnx99 cache]#time ./padd
  3. cpu 0
  4. cpu 1
  5. Completed!
  6. real 0m1.824s
  7. user 0m3.614s
  8. sys 0m0.012s
  9. [root@Lnx99 cache]#time ./padd
  10. cpu 0
  11. cpu 1
  12. Completed!
  13. real 0m1.817s
  14. user 0m3.625s
  15. sys 0m0.011s
  16. [root@Lnx99 cache]#time ./padd
  17. cpu 0
  18. cpu 1
  19. Completed!
  20. real 0m1.824s
  21. user 0m3.613s
  22. sys 0m0.011s

结果有些出人意料吧。同样的代码,仅仅是更改了关键结构体的大小,性能却相差了近10倍!

从这个例子中,我们应该学到
1. CPU的cache对于提高程序性能非常重要!一个良好的设计,可以保证更高的cache hit,从而得到更好的性能;
2. 多核编程中,对于cache line一定要格外关注。关键结构体size大小的控制和选择,可以大幅提高多核的性能;
3. 在多核编程中,写程序时,一定要思考,思考,再思考