最近一直在学习OS, 然后今天早上给我们上OS的老师突然问了一句大概是 “C语言里面的数组是行存取快还是列存取快?" 的问题。这个问题挺有意思的,而且老师也说了,低级程序员写的代码和高级程序员写的代码就差在这些隐晦的问题上。哎,上个学期学的计算机组成原理终于用上了:
Hurb Sutter和Scott Meyers等人对此有过专门的论述[1][2][3],我这里做一点整理吧:
首先是两个问题:
1.有一个 M x M 的二元数组,求里面的元素的和,应该怎么写这个程序?是一行一行的地去访问然后数出来还是一列一列地去访问?
2.同样的数组,求里面有多少个奇数,这个程序如何用多线程去实现会比较快?
第一个问题如下图所示:
可以看到横着来和纵着来所访问的东西都是一样的。但是性能却可能有所不同。
其简单实现的伪代码如下所示:
1 void sumMatrix(const Matrix<int>& m, long long& sum, TraversalOrder order) 2 { 3 sum=0; 4 if(order==RowMajor) 5 { 6 for(unsigned r=0;r<m.rows();++r) 7 { 8 for(unsigned c=0;c<m.columns();++c) 9 { 10 sum += m[r][c]; 11 } 12 } 13 } 14 else 15 { 16 for(unsigned c=0;c<m.columns();++c) 17 { 18 for(unsigned r=0;r<m.rows();++r) 19 { 20 sum += m[r][c]; 21 } 22 } 23 } 24 }
当 TraversalOrder 是RowMajor时快还是ColmnsMajor时快? RowMajor(一行一行地访问)快。
第二个问题的多线程伪代码实现如下图:
这样子的实现比较慢。而且其中的 "速度与线程数量的关系" 如下图,是不成比例的:
但是,如果改成这样,就会很快:
1 int result[P]; 2 for(int p=0;p<P;++p) 3 pool.run([&,p]{ 4 in count=0; //instead of result[p] 5 int chunksize=DIM / P + 1; 6 int myStart=p * chunkSize; 7 in myEnd = min(myStart + chunkSize, DIM); 8 for( int j=0;j<DIM;++j) 9 if( matrix[i * DIM + j] % 2 != 0) 10 ++count; //instead of result[p] 11 result[p]=count;});
这分代码的 "执行速度与线程数量" 的关系如下,是成比例增长的:
为什么?
第一个问题的解释:
因为CPU有cache, CPU要访问某些数据的时候并不是总是从memory中取的,他会看cache中有没有,如果有就直接从cache中取,那会快很多;如果没有才会访问内存,那样相对来说会比较慢。
在cache中储存memory中的元素时是一行一行地取,也就是一个 cache line,如果 cache中没有那一个你想要的字节,那么CPU就会从memory中取一个 cache line (不是一个字节,是一个cache line),然后下次要用到这个字节的时候就可以直接从cache里面取了。
但是问题是cache总是很小的,所以如果以 ColumnMajor 的方式是访问,那么CPU取一个 cache line却只访问一个字节,当cache满了,就会有 cache miss, 就把一些旧的cache line丢弃换上新的,就很浪费。
相对而言,以 RowMajor 形式来访问就比较 “节省", 没有那么多 cache miss,所以比较快。
(当然如果你的cache大到足以放下整个matrix那么无论是RowMajor还是ColumnMajor都是一样的,因为都不会有cache miss)
第二个问题的解释:
这个问题有个很好听却表意不明的名称 False Sharing. 与之相关的技术细节是 "Cache coherency".
假设一个双核的CPU,当两个核都尝试去访问同一个字节,并且把那个字节所对应的cache line放到了自己的cache中。
现在,假设其中一个核改变了那个字节,为了保持两个核的cache中那个字节的内容是一样的,CPU会如何做?
这时CPU就会更新另外那个核里的那条cache line,而在那条cache line被更新之前,那个核什么也不能干,因为要保持数据的一致性。这就是所谓的cache coherency.
当然这是很耗时间的。
这时就可以看出为什么刚刚第一份多线程的代码的 "执行速度与线程数量" 是不成比例的了: 因为当两条线程在不同的核上时,如果两个核都频繁地改变各自的 result[p] ,这时,因为是数组的关系,所以 result[1] , result[2] , ... , result[n] 一般都会在同一条cache line上面,所以那条cache line会被频繁地更新,而在更新的时候,另外的核什么也不能干,这时候,其实只有一条线程在执行(另外的几个核上的线程都在等待他们的cache line被更新完才能执行),所以多线程在此时就变成了单线程了,所以速度就会很慢,不成比例。
然而,如果弄一个临时变量 count 出来,这个count一般不会与那些 result[p] 处于同一条cache line, 那么就不会有频繁的cache line更新,于是乎几条线程就可以真正并行地执行了,于是乎速度就很快,"执行速度与线程数量"的关系也很正常。
Scott Meyers也给了 "False Sharing" 成立的条件:
(False-Shaing problem arise only when all below are true)
- Independent values/variables fall on one cache line
- Different core concurrently access that cache line
- Frequently
- At least on is a writer
真不是每个人都能考虑到这些。。。。
最后是Guideline:
- Where practical, employ linear array traversals
- Use as much of a cache line as possible(不要取了以整行cache line但是却只用了里面的一个bit)
- Don't do so much unnecessary branching and function call.
- Explaination:
- CPU有两个cache, 一个是Data-cache,用来存放数据,一个是Instruction-Cache,用来存放指令。CPU会根据你的access-pattern来prefetch(预取)指令,所以如果你的程序是像 "一行一行" 这样的 predictable 地执行,则CPU则可能在某一行
- 语句真正执行之前就把那行指令放在cache里面了。但是如果你的指令总是跳来跳去(if语句,函数调用),unpredictable,那么CPU就没法做合适的预取。
- Inline cautiously:
- Explanation:
- 将一些函数inline,那么指令就是顺序执行的了,就不会跳来跳去.......
- Take advantage of PGO and WPO:
- Explanation:
- PGO(profiler guided optimization).编译器编译一份特定的代码来运行,然后看看哪些片段有哪些特征然后做一些优化。比如,如果在某一个点上,这个程序总是会调用某个函数,那么编译器会尝试将那个函数inline在那个地方。
Scott Meyers还提到有关cache-associaty的奇怪现象。
因为现在的cache都是 x-路组相联,所以如果如果你的matrix的内存地址总是对应与特定某一块cache,其余的cache没有被用到,那么就会造成浪费。这个要写起来就麻烦了。详情见下文链接。
【1】https://www.youtube.com/watch?v=WDIkqP4JbkE
【2】http://www.aristeia.com/TalkNotes/codedive-CPUCachesHandouts.pdf(里面最后几页有很多资源,关于memory/cache的,还有PGO的)
【3】http://www.drdobbs.com/parallel/eliminate-false-sharing/217500206
【4】https://herbsutter.com/2009/05/15/effective-concurrency-eliminate-false-sharing/
:)