CPU缓存如何影响C程序的性能

15

我正试图更好地理解CPU缓存如何影响性能。作为一个简单的测试,我正在对一个带有不同总列数的矩阵的第一列的值进行求和。

// compiled with: gcc -Wall -Wextra -Ofast -march=native cache.c
// tested with: for n in {1..100}; do ./a.out $n; done | tee out.csv
#include <assert.h>
#include <stdint.h>
#include <stdio.h>
#include <stdlib.h>
#include <time.h>

double sum_column(uint64_t ni, uint64_t nj, double const data[ni][nj])
{
    double sum = 0.0;
    for (uint64_t i = 0; i < ni; ++i) {
        sum += data[i][0];
    }
    return sum;
}

int compare(void const* _a, void const* _b)
{
    double const a = *((double*)_a);
    double const b = *((double*)_b);
    return (a > b) - (a < b);
}

int main(int argc, char** argv)
{
    // set sizes
    assert(argc == 2);
    uint64_t const iter_max = 101;
    uint64_t const ni       = 1000000;
    uint64_t const nj       = strtol(argv[1], 0, 10);

    // initialize data
    double(*data)[nj] = calloc(ni, sizeof(*data));
    for (uint64_t i = 0; i < ni; ++i) {
        for (uint64_t j = 0; j < nj; ++j) {
            data[i][j] = rand() / (double)RAND_MAX;
        }
    }

    // test performance
    double* dt        = calloc(iter_max, sizeof(*dt));
    double const sum0 = sum_column(ni, nj, data);
    for (uint64_t iter = 0; iter < iter_max; ++iter) {
        clock_t const t_start = clock();
        double const sum      = sum_column(ni, nj, data);
        clock_t const t_stop  = clock();
        assert(sum == sum0);
        dt[iter] = (t_stop - t_start) / (double)CLOCKS_PER_SEC;
    }

    // sort dt
    qsort(dt, iter_max, sizeof(*dt), compare);

    // compute mean dt
    double dt_mean = 0.0;
    for (uint64_t iter = 0; iter < iter_max; ++iter) {
        dt_mean += dt[iter];
    }
    dt_mean /= iter_max;

    // print results
    printf("%2lu %.8e %.8e %.8e %.8e\n", nj, dt[iter_max / 2], dt_mean, dt[0],
        dt[iter_max - 1]);

    // free memory
    free(data);
}
然而,结果并不是我所预期的:nj = 1..100据我所知,当CPU从"data"中加载一个值时,它还会将"data"的一些后续值放入缓存中。确切的数量取决于缓存行大小(在我的机器上为64字节)。这就可以解释为什么随着nj的增长,解决方案的时间首先线性增加,然后在某个值上趋于稳定。如果nj == 1,则一次加载将把下一个7个值放入缓存中,因此我们只需要每8个值从RAM中加载一次。如果nj == 2,根据相同的逻辑,我们需要每4个值访问一次RAM。在某个尺寸之后,我们将不得不为每个值访问RAM,这应该导致性能趋于平稳。我猜测,为什么图表的线性部分超过了4,是因为实际上有多个级别的缓存在这里起作用,值如何进入这些缓存的方式比我在这里解释的要复杂一些。我无法解释的是为什么有这些16的倍数性能峰值。在思考了一会儿这个问题之后,我决定检查是否对于更高的nj值也会出现这种情况: nj = 1..500实际上,确实如此。并且,还有更多:为什么在大约250之后性能又会提高?有人能向我解释一下,或者指点我一些适当的参考资料,为什么会出现这些峰值以及为什么在较高的nj值时性能会提高吗?如果您想自己尝试代码,我也会附上我的绘图脚本,以方便您使用:
import numpy as np
import matplotlib.pyplot as plt

data = np.genfromtxt("out.csv")
data[:,1:] /= data[0,1]

dy = np.diff(data[:,2]) / np.diff(data[:,0])
for i in range(len(dy) - 1):
    if dy[i] - dy[i + 1] > (dy.max() - dy.min()) / 2:
        plt.axvline(data[i + 1,0], color='gray', linestyle='--')
        plt.text(data[i + 1,0], 1.5 * data[0,3], f"{int(data[i + 1,0])}",
                 rotation=0, ha="center", va="center",
                 bbox=dict(boxstyle="round", ec='gray', fc='w'))

plt.fill_between(data[:,0], data[:,3], data[:,4], color='gray')
plt.plot(data[:,0], data[:,1], label="median")
plt.plot(data[:,0], data[:,2], label="mean")
plt.legend(loc="upper left")
plt.xlabel("nj")
plt.ylabel("dt / dt$_0$")
plt.savefig("out.pdf")

1
我无法解释的是为什么在16的倍数处会出现这些性能峰值。我的猜测是你遇到了与此问题相同的问题:当循环遍历恰好8192个元素时,为什么我的程序变慢? - Andreas Wenzel
2
关于“据我所知,当CPU从data中加载一个值时,它还会将data的一些后续值放入缓存。”这是不正确的。当启用缓存时进行加载时,CPU会加载数据所在的缓存块(或者如果跨越边界,则为两个缓存块)。如果数据位于块的开头,则确实会加载其他数据。如果数据位于块的中间或末尾,则可能会加载之前的数据或替代它。 - Eric Postpischil
1
关于“我无法解释的是为什么在16的倍数处会出现这些性能峰值。”:缓存具有“几何”特征。其中之一是缓存行只能放置在某些缓存集中。例如,您的处理器可能有64个集合,每个集合可以容纳64字节的4个缓存行。但对于给定的缓存行,其内存地址的某些位确定了它必须进入哪个集合。当nj是16的倍数时,您的加载方式会跳过一些缓存集,从而跳过了一些缓存集。因此,您只能使用大约一半的缓存。 - Eric Postpischil
1
nj的值不是16的倍数时,负载将通过不同的高速缓存集合进行处理。我还没有分析您的代码以确定这种特定效果是否是原因。 - Eric Postpischil
2
据我所知,当CPU从数据中加载一个值时,它还会将一些后续的数据值放置在缓存中。您可能指的是两件不同的事情:1. 访问double时,它不仅会将8个字节加载到缓存中,而且会将通常为64个字节的整个缓存行加载到缓存中,其中可能包括double之前和之后的数据。2. 如果CPU检测到顺序访问模式,它将根据预测预取数据。 - Andreas Wenzel
1个回答

9
这些图表展示了几个复杂的低级效应的组合(主要是缓存崩溃和预取问题)。我假设目标平台是一个具有64字节缓存行的主流现代处理器(通常是x86处理器)。我可以在我的i5-9600KF处理器上重现这个问题。以下是结果图表:

performance plot



首先,当nj很小时,获取地址(即步幅)之间的间隔很小,缓存行相对高效地使用。例如,当nj = 1时,访问是连续的。在这种情况下,处理器可以有效地从DRAM预取缓存行,以隐藏其高延迟。还存在良好的空间缓存局部性,因为许多连续的项目共享同一缓存行。当nj=2时,只使用了缓存行值的一半。这意味着对于相同数量的操作,请求的缓存行数量增加了两倍。话虽如此,由于浮点数相加的相对高延迟导致代码成为计算受限。您可以将循环展开4次,并使用4个不同的和变量,以便(主流现代)处理器可以并行添加多个值。请注意,大多数处理器也可以每个周期从缓存中加载多个值。当nj = 4时,每2个周期请求一个新的缓存行(因为double占用8个字节)。因此,内存吞吐量可能变得非常大,导致计算成为内存受限。人们可能期望nj >= 8时时间稳定,因为请求的缓存行数应该相同,但实际上处理器预取多个连续的缓存行,以免支付DRAM延迟的开销,在这种情况下开销巨大。预取的缓存行数量通常在2到4之间(据我所知,当步幅大于512时,在英特尔处理器上禁用此类预取策略,因此当nj >= 64时)。这就解释了为什么nj < 32时时间急剧增加,并且它们在32 <= nj <= 256范围内变得相对稳定,但有例外峰值。
nj 是 16 的倍数时发生的定期峰值是由复杂的缓存效应引起的,称为缓存抖动。现代缓存通常是 N路关联,N 通常在 4 到 16 之间。例如,这里是我 i5-9600KF 处理器的统计数据:
Cache 0: L1 data cache,        line size 64,  8-ways,    64 sets, size 32k 
Cache 1: L1 instruction cache, line size 64,  8-ways,    64 sets, size 32k 
Cache 2: L2 unified cache,     line size 64,  4-ways,  1024 sets, size 256k 
Cache 3: L3 unified cache,     line size 64, 12-ways, 12288 sets, size 9216k 

这意味着,如果(A1 % 32768) / 64 == (A2 % 32768) / 64,则来自DRAM的两个取回值与各自的地址A1和A2可能在我的L1缓存中产生冲突。在这种情况下,处理器需要从一组N=8个缓存行中选择要替换的缓存行。有许多缓存替换策略,但没有一个是完美的。因此,一些有用的缓存行有时会被过早地驱逐,导致稍后需要额外的缓存未命中。在病态情况下,许多DRAM位置可以竞争相同的缓存行,导致过多的缓存未命中。更多信息也可以在这篇文章中找到。

关于nj步幅,L1缓存中可以有效使用的缓存行数是有限的。例如,如果所有获取的值都具有相同的地址模量缓存大小,则实际上只能使用N个缓存行(即我的处理器为8)来存储所有值。可用的缓存行较少是一个大问题,因为预取器需要在缓存中占用相当大的空间,以便存储稍后需要的许多缓存行。 并发获取的数量越小,内存吞吐量就越低。这在这里尤其正确,因为从DRAM中获取1个缓存行的延迟约为几十纳秒(例如,~70 ns),而其带宽约为数十GiB/s(例如,~40 GiB/s):应同时获取数十个缓存行(例如,~ 40)以隐藏延迟和饱和DRAM。

这是关于nj值时我L1缓存中实际使用的缓存行数的模拟:

 nj  #cache-lines
  1      512
  2      512
  3      512
  4      512
  5      512
  6      512
  7      512
  8      512
  9      512
 10      512
 11      512
 12      512
 13      512
 14      512
 15      512
 16      256    <----
 17      512
 18      512
 19      512
 20      512
 21      512
 22      512
 23      512
 24      512
 25      512
 26      512
 27      512
 28      512
 29      512
 30      512
 31      512
 32      128    <----
 33      512
 34      512
 35      512
 36      512
 37      512
 38      512
 39      512
 40      512
 41      512
 42      512
 43      512
 44      512
 45      512
 46      512
 47      512
 48      256    <----
 49      512
 50      512
 51      512
 52      512
 53      512
 54      512
 55      512
 56      512
 57      512
 58      512
 59      512
 60      512
 61      512
 62      512
 63      512
 64       64    <----
==============
 80      256
 96      128
112      256
128       32
144      256
160      128
176      256
192       64
208      256
224      128
240      256
256       16
384       32
512        8
1024       4

我们可以看到,当 nj 是 16 的倍数时,可用缓存行的数量较少。在这种情况下,预取器将预先加载数据到可能被后续并发获取早期驱逐的缓存行中。在代码中执行的加载指令在可用缓存行较少时更有可能导致缓存未命中。当缓存未命中发生时,需要从 L2 或甚至 L3 再次获取值,导致执行速度变慢。请注意,虽然 L2 缓存更大,但它也受到同样的影响,只不过不太明显。现代 x86 处理器的 L3 缓存利用哈希来更好地分配事物,以减少固定步幅的冲突(至少在英特尔处理器上,肯定也是在 AMD 处理器上,尽管据我所知这个没有文档记录)。
以下是我的机器上一些峰值的时间:
  32 4.63600000e-03 4.62298020e-03 4.06400000e-03 4.97300000e-03
  48 4.95800000e-03 4.96994059e-03 4.60400000e-03 5.59800000e-03
  64 5.01600000e-03 5.00479208e-03 4.26900000e-03 5.33100000e-03
  96 4.99300000e-03 5.02284158e-03 4.94700000e-03 5.29700000e-03
 128 5.23300000e-03 5.26405941e-03 4.93200000e-03 5.85100000e-03
 192 4.76900000e-03 4.78833663e-03 4.60100000e-03 5.01600000e-03
 256 5.78500000e-03 5.81666337e-03 5.77600000e-03 6.35300000e-03
 384 5.25900000e-03 5.32504950e-03 5.22800000e-03 6.75800000e-03
 512 5.02700000e-03 5.05165347e-03 5.02100000e-03 5.34400000e-03
1024 5.29200000e-03 5.33059406e-03 5.28700000e-03 5.65700000e-03

如预期,当可用缓存行数较少时,实际计时总体上更长。然而,当nj >= 512时,结果令人惊讶,因为它们比其他情况快得多。这种情况是可用缓存行数等于关联性方式数(N)的情况。我猜测这是因为Intel处理器肯定会检测到这种病态情况并优化预取以减少缓存未命中次数(使用线填充缓冲区绕过L1缓存 - 见下文)。
最后,对于大的nj步幅,更大的nj应该导致更高的开销,主要是由于翻译后的查找缓冲器(TLB):有更多的页地址需要翻译,随着nj的增加,TLB条目的数量是有限的。事实上,这就是我在我的机器上观察到的:计时趋于缓慢稳定地增加,不像在目标平台上那样。
我还不能解释这种非常奇怪的行为。以下是一些猜测:
  • nj很大时(为了减少TLB的开销),操作系统可能会倾向于使用更多的巨大页面,因为它分配更广泛的块。这可能会导致预取器的并发性增加,因为据我所知,它无法跨越页面边界。您可以尝试检查已分配的(透明)巨大页面数量(在Linux中通过查看/proc/meminfo中的AnonHugePages来实现),或在这种情况下强制使用它们(使用显式的memmap),或者通过禁用它们来实现。我的系统似乎独立于nj值使用了2 MiB透明巨大页面。
  • 如果目标架构是NUMA架构(例如新的AMD处理器或拥有自己内存的多个处理器的服务器),则操作系统可能会分配物理存储在另一个NUMA节点上的页面,因为当前NUMA节点上的空间不足。这可能会导致更高的性能,因为吞吐量更大(虽然延迟更高)。您可以在Linux上使用numactl来控制此策略以强制进行本地分配。

想了解更多关于这个主题的信息,请阅读优秀文档程序员应该了解的有关内存的知识。此外,一篇非常好的关于x86缓存如何实际运作的文章可以在这里找到。


消除峰值

为了消除x86处理器上由缓存崩溃引起的峰值,您可以使用非暂态软件预取指令,以便将缓存行获取到非暂态缓存结构中,并获取到靠近不应在L1中引起缓存崩溃的处理器位置。这种缓存结构通常是Intel处理器上的行填充缓冲区(LFB)和AMD Zen处理器上的(等效的)缺失地址缓冲区(MAB)。有关非暂态指令和LFB的更多信息,请阅读this postthis one。以下是修改后的代码,还包括循环展开优化,以加速nj较小时的代码:

double sum_column(uint64_t ni, uint64_t nj, double* const data)
{
    double sum0 = 0.0;
    double sum1 = 0.0;
    double sum2 = 0.0;
    double sum3 = 0.0;

    if(nj % 16 == 0)
    {
        // Cache-bypassing prefetch to avoid cache trashing
        const size_t distance = 12;
        for (uint64_t i = 0; i < ni; ++i) {
            _mm_prefetch(&data[(i+distance)*nj+0], _MM_HINT_NTA);
            sum0 += data[i*nj+0];
        }
    }
    else
    {
        // Unrolling is much better for small strides
        for (uint64_t i = 0; i < ni; i+=4) {
            sum0 += data[(i+0)*nj+0];
            sum1 += data[(i+1)*nj+0];
            sum2 += data[(i+2)*nj+0];
            sum3 += data[(i+3)*nj+0];
        }
    }
    
    return sum0 + sum1 + sum2 + sum3;
}

这是修改后的代码结果:

performance plot of the optimized code

我们可以看到,时间中不再出现峰值。由于循环展开,dt0 大约缩小了 4 倍,因此数值要大得多。
请注意,在实践中,L2 缓存中的缓存崩溃并没有通过这种方法避免(至少在英特尔处理器上是这样)。这意味着,在我的机器上,当 nj 的步长为 512(4 KiB)的倍数时,效果仍然存在(实际上比以前更慢,特别是当 nj >= 2048 时)。在 x86 处理器上,当 (nj%512) == 0 && nj >= 512 时,停止预取可能是一个好主意。据我所知,没有办法解决这个问题。话虽如此,对非常大的数据结构进行如此大的步幅访问是非常糟糕的想法。
请注意,应仔细选择distance,因为早期预取可能导致缓存行在实际使用之前被驱逐(因此需要重新获取),而晚期预取并不是很有用。我认为在LFB / MAB中的条目数附近使用接近的值是一个好主意(例如,在Skylake / KabyLake / CannonLake上为12,在Zen-2上为22)。

2
对于循环展开,这更加复杂:编译器可以展开循环,但默认情况下效率不高。原因是IEEE-754标准防止它们重新排序浮点加法。结果,仍然会有一长串依赖指令,处理器无法有效执行。为了解决这个问题,您需要至少启用-ffast-math(实际上,在理论上,-fassociative-math应该足够,但在许多情况下并非如此)。此外,GCC的展开通常不是非常聪明的(它经常保留循环的条件分支)。 - Jérôme Richard
2
为了提高性能,您的架构非常不同:处理器(老虎湖)具有比通常更大的缓存,并且也没有2的幂大小或2的幂方式。据我所知,老虎湖核心对内存访问进行了更先进的优化,而且这个处理器似乎使用LPDDR内存,这与通常的DDR RAM有些不同。我不确定这是否可能是如此奇怪的影响的原因。所提出的NUMA假设在您的情况下不适用。关于巨大页面的假设仍然适用。 - Jérôme Richard
2
@Matthieu 这取决于处理器架构。大多数(最近的)英特尔处理器具有共享的L3缓存。一些处理器只有一个减少的L3,仅用于原子操作和共享缓存数据。在AMD Zen处理器上,据我所知,核心可以访问其CCX / CCD的有限空间的L3切片(同一处理器上属于同一NUMA节点的一组核心)。在使用8字节时获取64字节的缓存行往往会饱和DRAM(或L3,当工作数组足够小以适合缓存时)。 4个核心以200 MHz获取64字节足以饱和PC DRAM带宽。 - Jérôme Richard
2
@Matthieu 当多个核心被使用时,在L1中出现缓存崩溃问题不应该是一个大问题(因此很可能受到DRAM吞吐量的限制)。在L2中,这更为棘手,因为它会给L3带来更大的压力,但L3的设计(大多数情况下)可以与核心数量相匹配,因此影响应该是有限的(特别是在Zen处理器上)。在英特尔处理器上,这显然不太正确,因为L3被缩小了。实际上,某些核心的获取可能会驱逐有用的缓存行,因此其他核心可能需要稍后重新加载它们(关于执行代码)。 - Jérôme Richard
2
简而言之,我预计在这种情况下DRAM饱和会导致可扩展性差(因为数据不适合缓存)。实际上,在大多数平台上,少量核心就足以使其饱和(特别是使用修改后的代码)。事实上,对于类似的代码(如果矢量化),对于连续访问,这种情况通常已经发生。 - Jérôme Richard
显示剩余4条评论

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