如何在C和Java中实现CPU缓存效果?

8
在 Ulrich Drepper 的 《每个程序员都应该了解的内存知识》 这篇论文中,第三部分:CPU 缓存,他展示了一张图表,显示了“工作集”大小与每个操作(本例中为顺序读取)消耗的 CPU 循环次数之间的关系。图表中有两个跳跃点,表示 L1 缓存和 L2 缓存的大小。我编写了自己的程序以在 C 语言中重现这种效果。它只是简单地从头到尾顺序读取一个 int[] 数组,并尝试了不同大小的数组(从 1KB 到 1MB)。我将数据绘制成一张图表,但没有跳跃,图表是一条直线。
我的问题是:
  1. 我的方法有问题吗?生产CPU缓存效果的正确方法是什么(以查看跳转)。
  2. 我在想,如果是顺序读取,那么它应该像这样操作:当读取第一个元素时,会发生缓存未命中,并且在缓存行大小(64K)内会出现命中。借助预取技术,将隐藏读取下一个缓存行的延迟。它将连续地读取数据到L1缓存中,即使工作集大小超过L1缓存大小,它也会驱逐最近最少使用的数据,并继续预取。因此,大多数缓存未命中将被隐藏,从L2获取数据所需的时间将被隐藏在读取活动背后,这意味着它们同时运行。关联性(在我的情况下为8路)将隐藏从L2读取数据的延迟。因此,我的程序现象应该是正确的,我错过了什么吗?
  3. 在Java中是否可能获得相同的效果?

顺便说一下,我是在Linux上进行这个操作的。


编辑1

感谢Stephen C的建议,以下是一些额外的信息: 这是我的代码:

int *arrayInt;

void initInt(long len) {
    int i;
    arrayInt = (int *)malloc(len * sizeof(int));
    memset(arrayInt, 0, len * sizeof(int));
}

long sreadInt(long len) {   
    int sum = 0;
    struct timespec tsStart, tsEnd;

    initInt(len);

    clock_gettime(CLOCK_REALTIME, &tsStart);
    for(i = 0; i < len; i++) {
        sum += arrayInt[i];
    }
    clock_gettime(CLOCK_REALTIME, &tsEnd);
    free(arrayInt);
    return (tsEnd.tv_nsec - tsStart.tv_nsec) / len;
}

在main()函数中,我尝试了从1KB到100MB的数组大小,仍然相同,每个元素的平均耗时为2纳秒。我认为这个时间是L1d的访问时间。
我的缓存大小:
L1d == 32k
L2 == 256k
L3 == 6144k

编辑 2

我已经更改了我的代码,使用了链表。

// element type
struct l {
    struct l *n;
    long int pad[NPAD]; // the NPAD could be changed, in my case I set it to 1
};

struct l *array;
long globalSum;

// for init the array
void init(long len) {
    long i, j;

    struct l *ptr;

    array = (struct l*)malloc(sizeof(struct l));
    ptr = array;
    for(j = 0; j < NPAD; j++) {
        ptr->pad[j] = j;
    }
    ptr->n = NULL;

    for(i = 1; i < len; i++) {
        ptr->n = (struct l*)malloc(sizeof(struct l));
        ptr = ptr->n;
        for(j = 0; j < NPAD; j++) {
            ptr->pad[j] = i + j;
        }
        ptr->n = NULL;
    }

}

// for free the array when operation is done
void release() {
    struct l *ptr = array;
    struct l *tmp = NULL;
    while(ptr) {
        tmp = ptr;
        ptr = ptr->n;
        free(tmp);
    }
}

double sread(long len) {
    int i;
    long sum = 0;

    struct l *ptr;
    struct timespec tsStart, tsEnd;


    init(len);

    ptr = array;

    clock_gettime(CLOCK_REALTIME, &tsStart);
    while(ptr) {
        for(i = 0; i < NPAD; i++) {
            sum += ptr->pad[i];
        }
        ptr = ptr->n;
    }
    clock_gettime(CLOCK_REALTIME, &tsEnd);

    release();

    globalSum += sum;

    return (double)(tsEnd.tv_nsec - tsStart.tv_nsec) / (double)len;
}

最后,我将使用printf输出globalSum,以避免编译器优化。正如您所看到的,这仍然是一个顺序读取,我甚至尝试了高达500MB的数组大小,每个元素的平均时间约为4纳秒(可能是因为它必须访问数据“pad”和指针“n”,两个访问),与1KB的数组大小相同。因此,我认为这是因为缓存优化(如预取)很好地隐藏了延迟,我是正确的吗?我将尝试随机访问,并稍后发布结果。

编辑3

我尝试了对链表进行随机访问,这是结果: 随机访问链表

第一条红线是我的L1缓存大小,第二条是L2。因此我们可以看到有一点跳跃。有时延迟仍然被很好地隐藏。


你从 main() 函数中调用了多少次 sreadInt() 函数?这很关键——只有当你多次读取内存位置时,缓存才能为你带来好处! - j_random_hacker
每个数组大小只有一次,那么,你认为我该如何产生跳跃现象? - dawnstar
我认为你需要至少运行两次,这样第二次和后续访问将从缓存中获取(仅当它们适合缓存时)。另一方面,你已经使用memset()写入了每个位置...不确定是否具有预热缓存的相同效果,但我怀疑它会。在任何情况下,多次调用sreadInt()是值得尝试的。 - j_random_hacker
我不知道这个小贴士是否有帮助,我注意到你说你将NPAD设置为1,在Drepper的论文中(我的副本第20页),他说对于32位系统,NPAD=7,因此每个数组元素将是32字节,而对于64位系统,它们是64字节。 - Robert Houghton
Drepper 表示,他的测试结果(除非另有说明)来自于以 64 位模式运行且 NPAD=0 的 Pentium 4,因此数组元素为 8 字节。 - Robert Houghton
5个回答

5

这个答案不是一个具体的答案,而更像是一组笔记。

首先,CPU通常会操作缓存行,而不是单个字节/字/双字。这意味着如果你顺序读取/写入整数数组,那么第一次访问缓存行可能会导致缓存未命中,但访问同一缓存行中不同整数的后续访问则不会。对于64字节的缓存行和4字节的整数,这意味着每16次访问只会出现一次缓存未命中; 这会使结果被稀释。

其次,CPU有一个“硬件预取器”。如果它检测到连续读取缓存行,则硬件预取器将自动预取它预测下一个需要的缓存行(试图在需要之前将它们获取到缓存中)。

第三,CPU执行其他操作(例如“乱序执行”)来隐藏提取成本。您可以测量的时间差(缓存命中和缓存未命中之间的时间差)是CPU无法隐藏的时间,而不是提取的总成本。

这三件事结合起来意味着:对于连续读取整数数组,CPU很可能在您从上一个缓存行进行16次读取时预取下一个缓存行;而任何缓存未命中成本都不会显着,并且可能完全隐藏。为了防止这种情况,您需要“随机”访问每个缓存行一次,以最大化在“工作集适合于缓存/s”和“工作集不适合于缓存/s”之间测量到的性能差异。

最后,还有其他因素可能会影响测量结果。例如,对于使用分页(例如Linux和几乎所有其他现代操作系统)的操作系统,以上所有内容之上会有整个缓存层(TLB /翻译后置缓存),并且当工作集超出一定大小时会发生TLB未命中;这应该在图表中显示为第四个“步骤”。还有来自内核的干扰(IRQ、页面错误、任务切换、多个CPU等),这可能会在图表中显示为随机静态/错误(除非经常重复测试并且离群值被丢弃)。缓存设计的工件(缓存关联度)也可以通过依赖内核分配的物理地址来减少缓存的有效性;这可能会导致图表中的“步骤”移动到不同的位置。


确实,这是一个复杂的问题,感谢您提供全面的观点,我认为它对任何想让他们的程序运行快速的程序员都是一个很好的指南。 - dawnstar

3

我的方法有问题吗?

可能有问题,但如果没有看到您的实际代码,就无法回答。

  • 您对代码正在执行的描述没有说明您是一次读取数组还是多次读取。

  • 数组可能不够大...这取决于您的硬件。(现代芯片不是有几兆字节的第三级缓存吗?)

  • 特别是在Java的情况下,您必须正确地执行许多操作才能实现有意义的微基准测试。


在C语言中:
  • 您可以尝试调整C编译器的优化开关。

  • 由于您的代码是按顺序访问数组的,编译器可能能够对指令进行排序,以便CPU可以跟上,或者CPU可能会进行乐观预取或宽取。您可以尝试以不那么可预测的顺序读取数组元素。

  • 甚至可能编译器已经完全将循环优化掉了,因为循环计算的结果没有用于任何事情。

(根据这个问题和答案 - 从内存中获取一个字需要多长时间?,从L2缓存中获取需要约7纳秒,从主内存中获取需要约100纳秒。但是您却得到了约2纳秒的速度。这里一定有一些巧妙的方法使其运行如此之快。)


我已经发布了我的代码,我认为即使我的数组大小不足以超过L3缓存,至少我应该看到L1和L2之间或L2和L3之间的跳跃。是我的方法有问题,还是现代CPU优化得太好了? - dawnstar
我认为,即使我随机地访问数组或列表,它的大小并不重要,因为缓存是按块(如64字节)获取的,所以当您以不太可预测的顺序访问内存时,即使您的数组大小小于L1缓存大小,也总会有未命中情况。除非L1缓存会在第一次访问时尝试获取整个工作集,如果L1缓存的大小小于工作集的大小,则会获取其中的一部分来填充。我认为这不是缓存的作用,对吗? - dawnstar
随机访问的目的是为了“抵消”预取的影响并导致更多的缓存未命中。当你这样做时,你将能够在变化数据数组大小时更清楚地看到缓存大小的影响。 - Stephen C

1

使用gcc-4.7并使用gcc -std=c99 -O2 -S -D_GNU_SOURCE -fverbose-asm tcache.c编译,您可以看到编译器进行了足够的优化以删除for循环(因为sum未被使用)。

我不得不改进您的源代码; 缺少一些#include,第二个函数中未声明i,因此您的示例甚至无法编译。

sum设置为全局变量,或以某种方式将其传递给调用者(可能是通过全局int globalsum;并在循环后放置globalsum=sum;)。

我不确定您是否正确地使用memset清除数组。我可以想象一个聪明的编译器会理解您正在对所有零求和。

最终,您的代码具有极其规律的行为和良好的局部性:偶尔会发生缓存未命中,整个缓存行被加载,数据足够好用于许多迭代。一些聪明的优化(例如-O3或更好)可能会生成良好的prefetch指令。这对于缓存来说是最优的,因为对于32个字的L1缓存行,缓存未命中每32次循环发生一次,因此得到了很好的分摊。

将数据制作成链表会使缓存行为变差。相反,在一些真实的程序中,在几个精心选择的位置上添加__builtin_prefetch可能会将性能提高超过10%(但添加太多会降低性能)。

在现实生活中,处理器花费大部分时间等待某些缓存(很难测量;这种等待是CPU时间,而不是空闲时间)。请记住,在L3缓存未命中期间,从RAM模块加载数据所需的时间是执行数百条机器指令所需的时间!


抱歉代码有点粗糙,我已经更新了我的新代码,globalSum 是个好主意,我已经尝试过了。我还尝试了一个具有可预测顺序的数据链表,结果仍然没有“跳跃”。那么在你看来,这是因为 CPU 缓存优化还是编译器优化的原因呢? - dawnstar

0

关于1和2,我不能确定,但在Java中成功运行这样的测试会更具挑战性。特别是,我可能会担心类似自动垃圾回收之类的托管语言特性可能会在测试过程中发生,并扰乱您的结果。


4
现代的垃圾收集器不会因为没有必要而做出无谓的操作。一个没有分配任何内存的紧密循环不会触发垃圾收集。 - user395760

0

从图3.26可以看出,Intel Core 2在读取时几乎没有跳跃(图表顶部的红线)。只有在写入/复制时才能清楚地看到跳跃。最好进行写入测试。


网页内容由stack overflow 提供, 点击上面的
可以查看英文原文,
原文链接