8

在 Ulrich Drepper 的论文What every developer should know about memory中,第 3 部分:CPU Caches,他展示了一个图表,显示了“工作集”大小和每个操作消耗的 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;
}

最后,我将打印出 globalSum 以避免编译器优化。如您所见,它仍然是顺序读取,我什至尝试了高达 500MB 的数组大小,每个元素的平均时间约为 4 纳秒(可能是因为它必须访问数据'pad'和指针' n', 两次访问), 与 1KB 的数组大小相同。所以,我认为这是因为像预取这样的缓存优化很好地隐藏了延迟,对吗?我会尝试随机访问,稍后再放上结果。


编辑 3

我尝试了对链表的随机访问,结果如下: 随机访问一个链表

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

4

5 回答 5

5

这个答案不是答案,而是更多的一组笔记。

首先,CPU 倾向于在高速缓存行上运行,而不是在单个字节/字/双字上运行。这意味着如果您按顺序读取/写入整数数组,那么对缓存行的第一次访问可能会导致缓存未命中,但随后对同一缓存行中不同整数的访问不会。对于 64 字节缓存行和 4 字节整数,这意味着每 16 次访问只会出现一次缓存未命中;这会稀释结果。

其次,CPU 有一个“硬件预取器”。如果它检测到正在按顺序读取高速缓存行,则硬件预取器将自动预取它预测接下来需要的高速缓存行(以尝试在需要之前将它们提取到高速缓存中)。

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

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

最后,还有其他可能影响测量的因素。例如,对于使用分页的操作系统(例如 Linux 和几乎所有其他现代操作系统),在所有这些之上都有一整层缓存(TLB/翻译后备缓冲区),一旦工作集超过一定大小,TLB 就会丢失; 这应该在图中作为第四个“步骤”可见。还有来自内核的干扰(IRQ、页面错误、任务切换、多个 CPU 等);这可能在图中显示为随机静态/错误(除非经常重复测试并丢弃异常值)。还有一些缓存设计(缓存关联性)的伪影,它们会以依赖于内核分配的物理地址的方式降低缓存的有效性;这可能被视为“步骤”

于 2012-09-23T13:52:43.947 回答
3

我的方法有问题吗?

可能,但没有看到无法回答的实际代码。

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

  • 阵列可能不够大……取决于您的硬件。(一些现代芯片不是有几兆字节的第三级缓存吗?)

  • 特别是在 Java 案例中,您必须以正确的方式做很多事情来实现有意义的微基准测试。


在 C 情况下:

  • 您可以尝试调整 C 编译器的优化开关。

  • 由于您的代码正在串行访问数组,因此编译器可能能够对指令进行排序,以便 CPU 可以跟上,或者 CPU 可能会乐观地预取或进行广泛的取指。您可以尝试以不太可预测的顺序读取数组元素。

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

(根据这个 Q&A - How much time it take to fetch one word from memory?,从 L2 高速缓存中提取约 7 纳秒,从主存储器中提取约 100 纳秒。但你得到约 2 纳秒。一些聪明的东西必须在此处进行以使其运行速度与您观察到的一样快。)

于 2012-09-23T00:57:52.943 回答
1

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

我必须改进你的源代码;#include缺少一些-s,并且i未在第二个函数中声明,因此您的示例甚至无法按原样编译。

创建一个全局变量,或以某种方式将sum其传递给调用者(可能使用全局变量int globalsum;并放在globalsum=sum;循环之后)。

而且我不确定您是否正确使用memset. 我可以想象一个足够聪明的编译器理解你正在对所有零求和。

最后,您的代码具有非常规则的行为和良好的局部性:偶尔会发生缓存未命中,加载整个缓存行并且数据足以进行多次迭代。一些巧妙的优化(例如-O3或更好)可能会产生好的prefetch指令。这对于高速缓存来说是最优的,因为对于 32 字 L1 高速缓存行,高速缓存未命中每 32 个循环发生一次,因此可以很好地摊销。

制作数据链表会使缓存行为变得更糟。相反,在一些实际程序中,在几个精心选择的地方仔细添加__builtin_prefetch可能会提高 10% 以上的性能(但添加太多会降低性能)。

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

于 2012-09-23T06:36:26.013 回答
0

从图 3.26 可以看出,英特尔酷睿 2 在读取时几乎没有任何跳跃(图顶部的红线)。它是在跳跃清晰可见的地方书写/复制。最好做一个写测试。

于 2012-09-23T06:30:45.917 回答
0

我不能肯定地说 1 和 2,但是在 Java 中成功运行这样的测试会更具挑战性。特别是,我可能担心像自动垃圾收集这样的托管语言功能可能会在您的测试过程中发生并影响您的结果。

于 2012-09-23T00:13:10.357 回答