(行优先,Row-major)
(列优先,Column-major)
事实上,在编程时经常会使用双重循环,但我们很少会去思考为什么要先遍历行(row,横向)再遍历列(column,纵向)。
然而,在纯科学或数据科学领域中,
有些语言使用的是列优先(Column-major)存储方式。
那么为什么主流语言大多采用行优先(Row-major)呢?
答案可以在计算机系统领域的经典书籍《Computer Systems: A Programmer's Perspective》中找到。
首先,我们需要了解计算机的缓存(Cache)。
什么是缓存?
缓存是以块为单位存储内存数据的一种机制。
每个块通常大小在32字节到128字节之间。
这些块由标签(Tag) 、**索引(Index)和 偏移量(Offset)**组成:
- 标签(Tag) :内存地址的高位部分,用于标识缓存块中存储的是哪个内存地址的数据。
- 索引(Index) :指定缓存中块的位置。
- 偏移量(Offset) :选择块内的特定字节。
当有内存访问请求时,缓存会检查该地址的数据是否存在于缓存中。如果存在,则称为“命中(Hit)”;如果不存在,则称为“未命中(Miss)”。
通常情况下,当发生缓存未命中时,会将包含所需数据的整个缓存块从内存加载到缓存中。
缓存块越大,越能利用空间局部性(Spatial Locality) ,但同时也会增加缓存容量和缓存未命中的惩罚。
首先,正如《CS:APP》第3章所述,C语言中的数组是连续存储的。
数组元素存储在连续的内存块中,
在C语言中,数组的所有元素在内存中是连续分配的,并且每个元素的地址与基本地址之间有一个固定的间隔(L,即数据类型的大小)。
这正是C语言数组以行(Row)连续存储的原因。
那么,结合这两个事实,我们可以得出以下结论:
由于数组是以行优先顺序(Row-major order)存储的,因此一行的数据在内存中是连续的,
而缓存块会加载连续的内存地址,
因此一个缓存块通常会包含某一行的部分或全部连续数据。
因此,编写缓存友好的代码时,应该遵循以下原则。
接下来,在解释如何编写缓存友好的代码时,我们提到行优先(Row-major)存储方式。
这里所说的行优先顺序(Row-major order)是指多维数组在内存中按行连续存储的方式。
这种方式最大化了空间局部性(Spatial Locality) ,即访问连续的内存地址时,缓存效率更高。
例如,a[i][j]
的缓存行为是按行访问的,因此一次缓存未命中可以将多个连续元素加载到缓存中。
举例来说:
当访问a[0][0]
时,缓存会加载从a[0][0]
到a[0][3]
的块,随后对a[0][1]
、a[0][2]
、a[0][3]
的访问都会命中缓存。
因此,缓存未命中率降低了。
相反,如果按列访问,例如a[j][i]
,
这种列优先访问需要不同的缓存块,访问a[0][0]
后,a[1][0]
位于另一个缓存块中,结果导致缓存未命中率显著增加。
也就是说,按行顺序扫描数组时,相邻的数据会被加载到同一个缓存块中,从而实现高效处理。
那么,现在我们知道C语言系列的数组存储方式是行优先(Row-major),所以在编写双重循环时,通常是按照a[i][j]
的顺序编写的。
但在Fortran、MATLAB等科学计算语言中,为什么是按照a[j][i]
的顺序编写的呢?
这是因为Fortran和MATLAB使用的是列优先(Column-major)存储方式。
缓存本身只是简单地以连续的内存块(缓存行)为单位读取数据,因此内存上的连续性,也就是数组是如何存储的,才是关键。
所以,根据数组的存储方式,
我们需要考虑在双重循环中是以外层循环为行还是以外层循环为列来实现更高效的访问。
顺便提一下,C语言选择行优先(Row-major)的原因是,指针算术在这种存储方式下更容易表示。
而像Fortran和MATLAB这样的科学计算语言选择列优先(Column-major)的原因是,在线性代数中,向量通常以列向量的形式表示。
也就是说,接近工程领域的语言倾向于使用行优先(Row-major),而接近科学领域的语言则倾向于使用列优先(Column-major)。这样理解起来会更方便。
因此,在编写双重循环之前,考虑到数组的存储方式,这样写代码可能会更有意义。