交换了两个变量的位置,程序运行耗时翻倍了!

本文较长,请耐心读完,你一定会有收获!

两个几乎一模一样的程序

程序很简单,声明了一个结构体,并定义一个全局变量data,然后创建两个线程,thread_1给data.a字段赋值,thread_2给data.b字段赋值,每个线程各循环10亿次。

和test1.c唯一的区别是交换了结构体中字段b和字段data的位置。

不可思议的性能差异

现在分别编译运行test1.c和test2.c,并用time命令测量一下这两个程序的运行时间:

test1的消耗的时间居然是test2的两倍!

两个几乎一模一样的程序,就只是简单的交换了一下结构体中的两个字段,却有着不可思议的性能差异!为什么?

建议先不要继续往下读,请先花一分钟时间,仔细思考一下,可能是什么原因呢?

如果你能不假思索地给出答案,并且和后文中的解释是不谋而合的,说明你对计算机系统底层原理已经有了相当程度的了解!如果思考之后,暂时还不能给出答案,也不必着急,后文会详细讲解!

重要说明

由于涉及到计算机系统比较底层的知识,为了尽可能的讲解清楚,有必要先补充一些关于现代计算机存储系统相关的背景知识,这也是理解这个问题的关键所在。不管你是否已经对此非常熟悉,建议都仔细看下。

为方便大家理解,我会尽量以白话的形式进行讲解,尽可能避免枯燥无味的纯理论描述。

存储金字塔

相信不少人都听过“存储金字塔”这个词,或者至少见过类似下面这张图:

这张图很直观的描述了现代计算机系统的分级存储模型。

可以认为CPU就位于金字塔的顶点上,越靠近塔顶,离CPU越近,访问速度越快,但生产成本越高,相应的容量也就越小。在所有存储器中,寄存器直接内嵌在CPU中,访问速度最快,容量也最小,一般CPU上也就最多几十个通用寄存器供应用程序使用。

反之,越往下靠近塔底,访问速度越慢,生产成本越低,相应的容量也就越大比如图中最底部的网络存储设备,相对其他存储设备而言是访问速度最慢的,但其容量却几乎可以认为是无限制的。

那么,这种金字塔式结构中,不同层级的存储设备之间究竟是如何协调工作的呢?

用一句话概括:高一级的存储设备,往往是作为低一级存储设备的缓存来使用的。

简单来说,系统运行时,为了提升数据访问效率,把程序中最近最经常访问的数据,尽可能放到访问速度更快的高一级存储器中。这样一来,每次访问数据时,从金字塔的顶端开始,都先尝试在高一级存储器中查找,如果被访问的数据存在且有效,则直接访问,否则,就逐级到更低级的存储器中去查找。

这种金字塔式的分级存储模型之所以能够以近乎完美的方式运行,实际上都是建立在现代计算机科学中的一个非常重要的理论基础之上:程序的局部性原理。

局部性原理

一个程序的局部性,包含两个维度:时间局部性和空间局部性。

高速缓存 - Cache

根据常识,我们知道,程序要想被CPU正常执行,必须要先被加载到内存中。(当然也有例外,有童鞋知道哪些程序不是在内存中运行的吗?欢迎留言讨论!)

但是,内存的访问速度与CPU运行速度相比,要慢好几个数量级,可以想象一下,如果CPU每次都直接从内存中存取数据,会造成大量的计算资源浪费,程序性能严重受损,假如真是这样的话,你还能像现在这样愉快的吃鸡吗?

为了解决CPU和内存之间速度严重不匹配的问题,在CPU和内存之间设计了高速缓存,即Cache。

目前,主流CPU一般都有三级(或更多级)的Cache,依次是L1 Cache、L2 Cache、L3 Cache。其中L1 Cache速度最快,几乎可以和内嵌在CPU中的寄存器媲美,容量也最小,而L3 Cache速度最慢(但仍然比内存快很多),但容量最大。

CPU读取数据时,会在L1、L2、L3Cache中逐级查找,如果找到,就从Cache直接读取,找不到再从内存读取,并且把数据存放到Cache中,以便提高下次访问的效率。

在这个过程中,如果在Cache中找到所需数据,称为Cache命中(Cache Hit), 找不到称为Cache未命中(Cache Miss)。

不难看出,L1 Cache命中的时候,读取数据最快,性能最好,而当L1、L2、L3 Cache全部未命中时,就必须要直接从内存中读取数据,性能最差!

Cache Line

Cache Line 是 Cache和内存之间进行数据传输的最小单位。

根据上文讲解的程序的局部性原理,如果一个数据被CPU访问了,那么这个数据相邻的其他数据也很快会被访问到。因此,为了提高内存数据的读取效率,并且最大化利用CPU资源,数据在Cache和内存之间传输时,不是一个字节一个字节进行传输的,而是以缓存行(Cache Line)为单位进行传输的。

不同CPU的Cache line大小可能不同,典型的CPU Cache line大小是64个字节。

我们通过下面一个简单的例子,加深一下理解。

Cache Line 实例讲解

在一个Cache Line大小为64字节的机器上,定义个数组:

int a[16] = {1, 2, 3, 4, 5, 6, 7, 8, 9, 10, 11, 12, 13, 14, 15, 16};

我们假设数组a的起始地址是Cache Line对齐的,可以简单理解为a的地址能被64整除。假设,数组a还从来没有被访问过,如果此时需要访问a中的一个元素a[5],如:

int x = a[

由于在此之前数组a没有被访问过,所以理论上讲,数组a应该只存在于内存中,并未被加载到Cache中。因此,此时CPU在Cache中找不到a[5],触发Cache Miss,然后需要从内存中读取数据,并更加载到Cache中。

前面提到,Cache和内存之间是以Cache Line为单位进行数据传输的,因此,这里会把一个Cache line大小(64字节)的数据从内存读取出来加载到Cache中。由于a的起始地址恰巧是Cache line对齐的,所以CPU会把整个数组(64个字节,刚好一个Cache Line)都加载到Cache中。

紧接着,如果再访问数组a的元素,如:

int y = a[10];

此时,整个数组都在Cache中,所以CPU在访问时,触发Cache Hit,直接从Cache读取数据即可,不需要再从内存中读取。

下面,用一个简单的例子,直观感受下Cache对程序性能究竟能有多大!

Cache 对程序性能影响有多大

定义一个同样大小的二维数组,然后循环遍历,对数组元素赋值。

编译运行,并用time命令统计一下运行时间:

array1用时0.528秒,array2用时10.310秒。array2耗时居然是array1的将近20倍!

有没有被这个结果震惊到!

为什么会有如此之大的性能差异呢?

先不要往下看,花一分钟时间,结合上面讲到的知识,先自己仔细思考一下,看能否想明白呢?

按行访问和按列访问的差异 - Cache

C语言的数组,所有元素是存放在地址连续的内存中的,此外,C语言的多维数组,是按行进行存储的。

array1.c按行对数组进行访问,也就是从数组起始地址开始,一直连续访问到数组的最后一个元素的地址处。第一次访问一个Cache Line的首个元素时,触发Cache Miss,与该元素临近的几个元素会组成一个Cache Line,被一起加载到Cache中。如此,在访问下一个元素的时候,就会Cache Hit,直接从Cache读取数据即可。

而array2.c按列对数组进行访问,因此并不是按照连续地址进行访问的,而是每次间隔10240 * 4个字节进行访问。第一次访问一个Cache Line的首个元素时,假设地址为x,尽管该元素临近的一个Cache Line大小的元素也会被一起加载进Cache中,但是程序接下来访问的并不是临近的元素,而是地址为x + 10240 * 4处的元素,因此会再次触发Cache Miss。而当程序回过头来访问x + 4地址处的元素时,这个Cache Line可能已经被其他数据冲刷掉了。因为,尽管Cache会尽量缓存最近访问过的数据,但毕竟大小有限,当Cache被占满时,一些旧的数据就会被冲刷替换掉。

可以看出,无论是时间局部性还是空间局部性,array1.c都要比array2.c好很多!相比array1.c,array2.c会触发大量的Cache Miss,这也是为什么array2的性能会如此之差!

前面讨论的都是单CPU系统,在多CPU系统上,情况会更加复杂。

多CPU系统上的Cache

在SMP(对称多处理器)系统中,每个CPU core都有自己的本地Cache。如下图所示:

在上图中,每个CPU Core都有自己的独立的L1 Cache,Core 0和Core 1共享L2 Cache,Core 2和Core 3共享L2 Cache,而L3 Cache则是在所有的CPU core之间共享。

Cache一致性

因此,在多CPU系统上,同一个地址的数据,比如同一个变量,有可能在多个CPU的本地Cache中存在多分拷贝。既然同一数据存在多分拷贝,那么就必然存在数据一致性问题。

简单来说,就是必须要保证,在任意时间点,所有CPU看到的同一个地址的数据,必须是一样的。换言之,就是必须要保证每个CPU的本地Cache中的数据,能够如实反映内存中数据的真实的值。

假设内存中一个变量A = 1, 在CPU 0 和CPU 1的本地Cache中都有一份拷贝A0 = 1 和 A1 = 1。 如果此时CPU 0需要修改变量的值,至少需要这些操作:

  1. 在本地Cache中修改:A0 = 100
  2. 把变量最新的值更新到内存中:A = 100
  3. 通过某种方式,通知CPU 1。比如,把CPU 1本地的Cache的数据标记为无效,这样下次CPU 1从Cache访问A1的时候,发现数据已经无效,便会触发Cache Miss,重新从内存中读取最新的值,并把最新的值更新到本地Cache。

CPU为了保证Cache的一致性,实现了非常复杂的Cache一致性协议,感兴趣的童鞋可以去了解下MESI协议,这里不再赘述。(文末推荐了一本书,强烈推荐看下!)

有了这些背景知识,现在不妨花一分钟时间,自己思考下,看文章开头的那个问题能否想明白呢:为什么交换了结构体的两个字段,性能就会相差这么大呢?

交换结构体的两个字段,为什么性能差异这么大?

再看一下两个程序代码:

这两个程序几乎一模一样:有两个线程,分别访问一个struct全局变量的两个不同字段。唯一的区别是:

test1.c中,两个线程访问的两个字段是相邻的:

test2.c中,这两个字段是分开的

看起来,test1.c中两个字段a和b紧挨着的,按道理说,空间局部性应该更好,对吧?那为什么它的性能会比test2.c差那么多呢?

其实,这是由于CPU为了保证Cache的一致性,而引起的Cache伪共享问题!

Cache伪共享(Cache False Sharing)问题

所谓Cache Line 伪共享,是由于运行在不同CPU上的不同线程,同时修改同一个Cache Line上的数据,导致Cache Line失效,从而引起频繁的Cache Miss。

数据在Cache和内存直接传输时以Cache Line为单位的,所以,即便每个CPU各自修改的是不同的变量,但只要这些变量是在同一个Cache Line上的,那么一个CPU修改了这个Cashe Line之后,为了Cache一致性,必然会导致另外一个CPU的本地Cache中的同一个Cache Line无效,进而引发Cache Miss。

运行于不同CPU的多个线程,如果频繁修改位于同一个Cache Line上的数据(即便不是同一个地址的数据),必然会导致大量Cache Miss,严重降低程序性能。

分析性能差异的真正原因

test1.c中,字段a和b在同一个Cache Line上,运行于不同CPU的两个线程同时修改这两个字段时,会相互导致对方CPU的本地Cache Line频繁失效,触发大量Cache Miss,从而性能非常差!

test2.c中,字段a和b中间隔了一个64字节的数组,从而确保了data.a和data.b分别处在不同的Cache Line上。如此一来,即便是两个线程同时修改这两个字段,但是他们是不同的Cache Line,也不会相互干扰,大大提高Cache命中率,甚至几乎不会出现Cache Miss,这就是为什么会有如此巨大的性能提升!

如何检测Cache伪共享

在Linux上,可以使用perf c2c工具来检测和分析Cache伪共享的问题,篇幅所限,不在展开介绍,感兴趣的童鞋可以查看相关手册,或留言讨论!

思考题

  1. Cache伪共享问题产生的条件是什么?
  2. test1.c和test2.c的两个线程都是对字段a和b进行写操作,由于Cache伪共享问题的存在,性能差异比较大。思考一下:

好书分享

Brendan Gregg这个名字,关注过系统性能优化的童鞋,应该不陌生。在性能优化这个领域,绝对是大神般的存在!应该没有之一!每每遇到搞不定的性能问题,首先想到的就是到他的博客上去寻找答案,每一次都会折服于他技术之精湛,涉猎之广泛!

大神的这本<<性能之巅>>强烈推荐!无需多言,去看下目录就知道了!

(此处已添加书籍卡片,请到今日头条客户端查看)

喜欢看视频讲解的童鞋,可以看下这个专栏,也非常不错!


原创不易,别忘了点赞、转发!欢迎右上角关注!

我更新文章的主题,大多是小伙伴们留言或私信给我感兴趣的内容,如果你有任何建议或感兴趣的东西,欢迎留言或私信!

展开阅读全文

页面更新:2024-02-25

标签:局部性   两个   程序   翻倍   数组   字段   变量   元素   内存   性能   位置   地址   数据

1 2 3 4 5

上滑加载更多 ↓
推荐阅读:
友情链接:
更多:

本站资料均由网友自行发布提供,仅用于学习交流。如有版权问题,请与我联系,QQ:4156828  

© CopyRight 2020-2024 All Rights Reserved. Powered By 71396.com 闽ICP备11008920号-4
闽公网安备35020302034903号

Top