我相信您看到的是locality of reference 在计算机内存层次结构中的影响。
通常,计算机内存分为具有不同性能特征的不同类型(这通常称为 memory hierarchy)。最快的内存位于处理器的寄存器中,(通常)可以在单个时钟周期内访问和读取。但是,这些寄存器通常只有少数几个(通常不超过 1KB)。另一方面,计算机的主内存很大(比如 8GB),但访问速度要慢得多。为了提高性能,计算机的物理构造通常在处理器和主内存之间有several levels of caches。这些缓存比寄存器慢,但比主内存快得多,所以如果你做一个在缓存中查找某些东西的内存访问,它往往比你必须去主内存快得多(通常在 5-25 倍之间)快点)。访问内存时,处理器首先检查内存缓存中的该值,然后再返回主内存以读取该值。如果您始终如一地访问缓存中的值,那么您最终将获得比跳过时更好的性能内存,随机访问值。
大多数程序的编写方式是,如果将内存中的单个字节读入内存,程序随后也会从该内存区域周围读取多个不同的值。因此,这些缓存通常被设计成当您从内存中读取单个值时,该单个值周围的一块内存(通常在 1KB 到 1MB 之间)也会被拉入缓存中。这样,如果您的程序读取附近的值,它们已经在缓存中,您不必转到主内存。
现在,最后一个细节 - 在 C/C++ 中,数组以行优先顺序存储,这意味着矩阵的单行中的所有值彼此相邻存储。因此,在内存中,数组看起来像第一行,然后是第二行,然后是第三行,等等。
鉴于此,让我们看看您的代码。第一个版本如下所示:
for (int i = 0; i < n; i++)
for (int j = 0; j < n; j++)
for (int k = 0; k < n; k++)
c[i][j] = c[i][j] + a[i][k]*b[k][j];
现在,让我们看看最里面的代码行。在每次迭代中,k 的值都在变化增加。这意味着在运行最内层循环时,循环的每次迭代在加载b[k][j] 的值时很可能出现缓存未命中。这样做的原因是因为矩阵以行优先顺序存储,每次增加 k 时,您都会跳过矩阵的整行并跳到内存中更远,可能远远超过您缓存的值.但是,您在查找 c[i][j] 时不会错过(因为 i 和 j 相同),您也不会错过 a[i][k],因为这些值是按行优先顺序排列的,如果a[i][k] 的值是从上一次迭代中缓存的,本次迭代中读取的 a[i][k] 的值来自相邻的内存位置。因此,在最内层循环的每次迭代中,您都可能发生一次缓存未命中。
但请考虑第二个版本:
for (int i = 0; i < n; i++)
for (int k = 0; k < n; k++)
for (int j = 0; j < n; j++)
c[i][j] = c[i][j] + a[i][k]*b[k][j];
现在,由于您在每次迭代中都增加了j,让我们考虑一下您在最里面的语句中可能有多少缓存未命中。因为这些值是按行优先顺序排列的,c[i][j] 的值很可能在缓存中,因为前一次迭代中的c[i][j] 的值也可能被缓存并准备好被读取。同样,b[k][j] 可能已被缓存,并且由于 i 和 k 没有改变,a[i][k] 也可能被缓存。这意味着在内部循环的每次迭代中,您都可能没有缓存未命中。
总的来说,这意味着代码的第二个版本不太可能在循环的每次迭代中出现缓存未命中,而第一个版本几乎肯定会出现。因此,如您所见,第二个循环可能比第一个更快。
有趣的是,许多编译器开始支持原型,以检测代码的第二个版本比第一个版本快。有些人会尝试自动重写代码以最大化并行性。如果您有 Purple Dragon Book 的副本,第 11 章将讨论这些编译器的工作原理。
此外,您可以使用更复杂的循环进一步优化此循环的性能。例如,一种称为blocking 的技术可用于显着提高性能,方法是将数组拆分为可以在缓存中保存更长时间的子区域,然后对这些块使用多个操作来计算整体结果。
希望这会有所帮助!