DeepRoute Lab | 【C++性能】CPU Cache Serial 1

性能

C++,作为最晦涩、最难掌握的主流编程语言,一向是容易上手,却很难写好。而这难写好的部分中,除了代码风格等略微抽象的部分,最难也最容易疏忽的部分则是性能了。根据经典的二八原则,通常20%左右的代码消耗了80%左右的性能。然而,用户日常接触到的功能、或者在日常使用的场景下,性能是可以满足的,因此这往往造成程序员的忽视并埋下了隐患。

通常情况下,一旦遇到性能问题,那将是比功能问题更棘手、更难解决的。

C++,作为经常和硬件直接打交道的高级语言,性能问题可谓是重灾区。本系列文章,将结合理论和实践,深度剖析典型的性能问题和陷阱。

在上一篇性能专题的文章(点击可跳转上期文章)中,我们详细地介绍了C++代码中的性能杀手【拷贝】,并提供了几类具有针对性的应对措施。然而作为需要时刻考虑硬件条件的编程语言,如果你以为避免了不必要的构造和拷贝,就可以拥有极致的性能的话,那就大错特错了。

让我们来看一个与拷贝无关,但是又存在明显性能问题的例子。

举例

DeepRoute Lab | 【C++性能】CPU Cache Serial 1_第1张图片

DeepRoute Lab | 【C++性能】CPU Cache Serial 1_第2张图片

DeepRoute Lab | 【C++性能】CPU Cache Serial 1_第3张图片

CPU Cache

首先我们需要介绍一下什么是CPU Cache。

在我们写代码的时候,有3个计算机硬件和我们关系密切,分别是CPU、内存和硬盘。为什么这样说呢?因为我们敲的代码和编译出来的执行文件是存储在硬盘上的,每次程序启动,对应的数据和代码被加载进内存,然后各种各样的指令会在CPU中运行。

看似内存中的数据会被直接加载进CPU进行处理,然后这中间实际上还存在着一个至关重要的组件:缓存(CPU Cache)。缓存通常会存储着最近访问过的内存中的数据(这就是大家熟悉的LRU、LFU等缓存替换算法作用的地方),它拥有远快于内存(RAM)的访问速度,但是容量也显著地小于内存,它可以被视作CPU和内存数据的桥梁。

同时,CPU Cache也有不同的种类,主要有D-Cache,I-Cache和TLB。
image.png
其实只要知道全名,就很好理解了。D-Cache全名data cache,用来缓存数据。I-Cache全名instruction cache,用来缓存CPU指令。TLB全名translation lookaside buffer,用来缓存虚拟内存到物理内存的映射结果。

TLB看似让人摸不着头脑,但是有一个好消息是:如果D-Cache和I-Cache能够被很好地利用,那么TLB的性能通常也能从中受益。

I-Cache呢,在小片代码中区别不会太大,而且编译器会帮忙做一定的优化,因此我们最首要考虑的就是D-Cache,它和我们写代码的方式具备最紧密的联系。

以一个6核12线程CPU为例, Cache的结构大致如下:

DeepRoute Lab | 【C++性能】CPU Cache Serial 1_第4张图片

可以看到,每个物理核心拥有2个硬件线程,每个线程拥有自己的L1 Cache,每个核心拥有自己的L2 Cache,所有核心共享L3 Cache和内存。(请记住这个结构,后面的分析都会基于此)

那么,这些CPU Cache具体是怎么影响我们代码的性能的呢?

前文中我们提到了,CPU Cache是CPU和内存之间的桥梁并且拥有多个层次,可能此时我们已经发现,如果每一级缓存拥有不同的访问速度,是不是就会导致访问同样的数据(这些相同的数据可能因为之前的访问方式坐落于不同级的缓存和内存中)消耗不同的时间?答案是肯定的,下图中可以看到一个典型的现代CPU各个硬件的访问速度。可以看到,L1 Cache的访问速度基本是内存的100倍以上,L2 Cache则是10倍以上。

DeepRoute Lab | 【C++性能】CPU Cache Serial 1_第5张图片

因此,整体上来看,越高的缓存命中率意味着程序具有越高的性能。所谓缓存命中率,直白地说,就是指本来要去内存中读取的数据,正好存在于缓存中的比例。

那么,是什么样的原因会导同样的数据在不同的访问模式下会坐落于不同层级的缓存中呢?这就不得不提到缓存的存储方式和基本单元了。

CPU Cache的基本单元叫做cache line,这些cache line中存储的就是内存中的hottest data。内存和Cache交换数据都是通过它。对于典型的现代处理器,cache line的大小通常都是64 bytes,意味着,每一条cache line可以存储8个64位的整型。到目前为止,一切看上去都那么正常。

但是,cache line有一个非常特殊的性质,就是它的读写必须操作一整条!比如,你只想读前8个bytes,对不起,不行,整条cache line都会被读取;你只想写最后8个bytes,对不起,不行,整个cache line都需要更新。
image.png

缓存局部性(Cache locality)

有了上面关于CPU Cache的介绍,我们可以分析为什么例1中访问二维数组的方式会对性能有如此大的影响了。以下分析我们考虑冷启动的模式,意即缓存最开始是空的,里面没有任何数据。

注意到,我们的二维数组中存储的数据类型为整型,每个元素的大小为8 bytes,因此一条大小为64 bytes的cache line可以存储8个数据。

如果我们一行一行地访问二维数组数组,第一次读取缓存的某条cache line的时候,数据不在缓存上,需要从内存中读取,因此这次是一次cache miss,CPU读取这条数据的耗时是 t_memory + t_cache。但是请注意,由于cache line的特性,它的操作必须是整条的,因此在t_memory 的这次消耗中,在内存上相邻的另外7个int64型的数据也被加载到了这条cache line中。由于我们是按行读取,后面访问的7个数据正好就是被加载进了cache line的数据,他们的读取时间都只需要t_cache,省去了7次t_memory 的高额开销。

在这种情况下,每读取8个元素的总时间开销就是 t_memory + 8 t_cache。以上图中的硬件为例,仅考虑L2 Cache,即100 + 8 7 = 156ns。

DeepRoute Lab | 【C++性能】CPU Cache Serial 1_第6张图片

反观一列一列地访问二维数组,读取每一列第一个元素的时候,与上述情况相同,内存上相邻的7个数据也被加载进了cache line中。然而不幸的是,这里是按列访问的,同一列下一行的数据并不是被提前加载进cache line的,这就需要继续把内存中的数据加载进下一条cache line中,使得所有的操作都会发生cache miss,从而耗时都是t_memory + t_cache。

更糟糕的是,当访问到第n(n>1)列的时候,如果此时缓存已耗尽,则需要将旧的数据从cache中踢出并加载进新的数据(同样地,新加载的数据会由于按列访问的模式继续无用武之地)。

即使我们忽略缓存替换的时间开销,着这种模式下,每读取8个元素的总时间开销就是8 (t_memory + t_cache),以上图中的硬件为例,仅考虑L2 Cache,即 8 (100 + 7) = 856ns。这就是导致二维数组按列访问性能差的根本原因。

DeepRoute Lab | 【C++性能】CPU Cache Serial 1_第7张图片

幸运的是,根据大多数人的习惯,如果没有特殊的需求,我们很自然地就会按照行优先(一行一行地)的模式来访问二维数组,因此这个问题在绝大多数情况下被自然而言地避免掉了。

然而,我们这里不可一概而论地认为,二维数组按列访问的性能就一定比按行访问差。

image.png

不错,一个更加准确的描述应该为,对于按行存储的二维数组,应该使用按行访问的方式;对于按列存储的二位数组,应该使用按列访问的方式。而Eigen中的数据,正是按列存储的。

DeepRoute Lab | 【C++性能】CPU Cache Serial 1_第8张图片

因此,我们可以看到在Eigen中遍历二位数组的代码通常和遍历std::vector的行列先后顺序互换。

DeepRoute Lab | 【C++性能】CPU Cache Serial 1_第9张图片

DeepRoute Lab | 【C++性能】CPU Cache Serial 1_第10张图片

image.png

DeepRoute Lab | 【C++性能】CPU Cache Serial 1_第11张图片

DeepRoute Lab | 【C++性能】CPU Cache Serial 1_第12张图片

DeepRoute Lab | 【C++性能】CPU Cache Serial 1_第13张图片

DeepRoute Lab | 【C++性能】CPU Cache Serial 1_第14张图片

结语

C++作为一个追求效率又和硬件紧密关联的高级语言,想要熟练掌控它的性能,必须对计算机体系架构拥有足够的认知。
本文旨在抛砖引玉,与大家探讨处理器缓存带来的巨大的性能差异。鉴于笔者水平有限,此文必定存在诸多值得商榷之处,欢迎批评指正,共同进步。

关于DeepRoute Lab

深圳元戎启行科技有限公司(DEEPROUTE.AI)是一家专注于研发 L4级自动驾驶技术的科技公司,聚焦出行和同城货运两大场景,拥有“元启行”(Robotaxi自动驾驶乘用车)和“元启运”(Robotruck自动驾驶轻卡)两大产品线。

【Deeproute Lab】是我们创办的自动驾驶学术产业前沿知识共享平台。我们将会把公司内部的paper reading分享在这里,让你轻松读懂paper;我们也会在这里分享我们对行业的理解,期待越来越多的同学认识自动驾驶,加入这个行业!

你可能感兴趣的:(DeepRoute Lab | 【C++性能】CPU Cache Serial 1)