3.3.2 Cache的性能测试

用于测试程序的数据可以模拟一个任意大小的工作集:包括读、写访问,随机、连续访问。在图3.4中我们可以看到,程序为工作集创建了一个与其大小和元素类型相同的数组:

struct l {struct l *n;long int pad[NPAD];
};

n字段将所有节点随机得或者顺序的加入到环形链表中,用指针从当前节点进入到下一个节点。pad字段用来存储数据,其可以是任意大小。在一些测试程序中,pad字段是可以修改的, 在其他程序中,pad字段只可以进行读操作。

在性能测试中,我们谈到工作集大小的问题,工作集使用结构体l定义的元素表示的。2N 字节的工作集包含

个元素. 显然sizeof(struct l) 的值取决于NPAD的大小。在32位系统上,NPAD=7意味着数组的每个元素的大小为32字节,在64位系统上,NPAD=7意味着数组的每个元素的大小为64字节。

单线程顺序访问

最简单的情况就是遍历链表中顺序存储的节点。无论是从前向后处理,还是从后向前,对于处理器来说没有什么区别。下面的测试中,我们需要得到处理链表中一个元素所需要的时间,以CPU时钟周期最为计时单元。图3.10显示了测试结构。除非有特殊说明, 所有的测试都是在Pentium 4 64-bit 平台上进行的,因此结构体l中NPAD=0,大小为8字节。

一开始的两个测试数据收到了噪音的污染。由于它们的工作负荷太小,无法过滤掉系统内其它进程对它们的影响。我们可以认为它们都是4个周期以内的。这样一来,整个图可以划分为比较明显的三个部分:

  • 工作集小于214字节的。
  • 工作集从215字节到220字节的。
  • 工作集大于221字节的。

这样的结果很容易解释——是因为处理器有16KB的L1d和1MB的L2。而在这三个部分之间,并没有非常锐利的边缘,这是因为系统的其它部分也在使用缓存,我们的测试程序并不能独占缓存的使用。尤其是L2,它是统一式的缓存,处理器的指令也会使用它(注: Intel使用的是包容式缓存)。

测试的实际耗时可能会出乎大家的意料。L1d的部分跟我们预想的差不多,在一台P4上耗时为4个周期左右。但L2的结果则出乎意料。大家可能觉得需要14个周期以上,但实际只用了9个周期。这要归功于处理器先进的处理逻辑,当它使用连续的内存区时,会 预先读取下一条缓存线的数据。这样一来,当真正使用下一条线的时候,其实已经早已读完一半了,于是真正的等待耗时会比L2的访问时间少很多。

在工作集超过L2的大小之后,预取的效果更明显了。前面我们说过,主存的访问需要耗时200个周期以上。但在预取的帮助下,实际耗时保持在9个周期左右。200 vs 9,效果非常不错。

我们可以观察到预取的行为,至少可以间接地观察到。图3.11中有4条线,它们表示处理不同大小结构时的耗时情况。随着结构的变大,元素间的距离变大了。图中4条线对应的元素距离分别是0、56、120和248字节。

图中最下面的这一条线来自前一个图,但在这里更像是一条直线。其它三条线的耗时情况比较差。图中这些线也有比较明显的三个阶段,同时,在小工作集的情况下也有比较大的错误(请再次忽略这些错误)。在只使用L1d的阶段,这些线条基本重合。因为这时候还不需要预取,只需要访问L1d就行。

在L2阶段,三条新加的线基本重合,而且耗时比老的那条线高很多,大约在28个周期左右,差不多就是L2的访问时间。这表明,从L2到L1d的预取并没有生效。这是因为,对于最下面的线(NPAD=0),由于结构小,8次循环后才需要访问一条新缓存线,而上面三条线对应的结构比较大,拿相对最小的NPAD=7来说,光是一次循环就需要访问一条新线,更不用说更大的NPAD=15和31了。而预取逻辑是无法在每个周期装载新线的,因此每次循环都需要从L2读取,我们看到的就是从L2读取的时延。

更有趣的是工作集超过L2容量后的阶段。快看,4条线远远地拉开了。元素的大小变成了主角,左右了性能。处理器应能识别每一步(stride)的大小,不去为NPAD=15和31获取那些实际并不需要的缓存线(参见6.3.1)。元素大小对预取的约束是根源于硬件预取的限制——它无法跨越页边界。如果允许预取器跨越页边界,而下一页不存在或无效,那么OS还得去寻找它。这意味着,程序需要遭遇一次并非由它自己产生的页错误,这是完全不能接受的。在NPAD=7或者更大的时候,由于每个元素都至少需要一条缓存线,预取器已经帮不上忙了,它没有足够的时间去从内存装载数据。 另一个导致慢下来的原因是TLB缓存的未命中。TLB是存储虚实地址映射的缓存,参见第4节。为了保持快速,TLB只有很小的容量。如果有大量页被反复访问,超出了TLB缓存容量,就会导致反复地进行地址翻译,这会耗费大量时间。TLB查找的代价分摊到所有元素上,如果元素越大,那么元素的数量越少,每个元素承担的那一份就越多。

为了观察TLB的性能,我们可以进行另两项测试。第一项:我们还是顺序存储列表中的元素,使NPAD=7,让每个元素占满整个cache line,第二项:我们将列表的每个元素存储在一个单独的页上,忽略每个页没有使用的部分以用来计算工作集的大小。(这样做可能不太一致,因为在前面的测试中,我计算了结构体中每个元素没有使用的部分,从而用来定义NPAD的大小,因此每个元素占满了整个页,这样以来工作集的大小将会有所不同。但是这不是这项测试的重点,预取的低效率多少使其有点不同)。结果表明,第一项测试中,每次列表的迭代都需要一个新的cache line,而且每64个元素就需要一个新的页。第二项测试中,每次迭代都会在一个新的页中加载一个新的cache line。

结果见图3.12。该测试与图3.11是在同一台机器上进行的。基于可用RAM空间的有限性,测试设置容量空间大小为2的24次方字节,这就需要1GB的容量将对象放置在分页上。图3.12中下方的红色曲线正好对应了图3.11中NPAD等于7的曲线。我们看到不同的步长显示了高速缓存L1d和L2的大小。第二条曲线看上去完全不同,其最重要的特点是当工作容量到达2的13次方字节时开始大幅度增长。这就是TLB缓存溢出的时候。我们能计算出一个64字节大小的元素的TLB缓存有64个输入。成本不会受页面错误影响,因为程序锁定了存储器以防止内存被换出。

可以看出,计算物理地址并把它存储在TLB中所花费的周期数量级是非常高的。图3.12的表格显示了一个极端的例子,但从中可以清楚的得到:TLB缓存效率降低的一个重要因素是大型NPAD值的减缓。由于物理地址必须在缓存行能被L2或主存读取之前计算出来,地址转换这个不利因素就增加了内存访问时间。这一点部分解释了为什么NPAD等于31时每个列表元素的总花费比理论上的RAM访问时间要高。

通过查看链表元素被修改时测试数据的运行情况,我们可以窥见一些更详细的预取实现细节。图3.13显示了三条曲线。所有情况下元素宽度都为16个字节。第一条曲线“Follow”是熟悉的链表走线在这里作为基线。第二条曲线,标记为“Inc”,仅仅在当前元素进入下一个前给其增加thepad[0]成员。第三条曲线,标记为”Addnext0″, 取出下一个元素的thepad[0]链表元素并把它添加为当前链表元素的thepad[0]成员。

在没运行时,大家可能会以为”Addnext0″更慢,因为它要做的事情更多——在没进到下个元素之前就需要装载它的值。但实际的运行结果令人惊讶——在某些小工作集下,”Addnext0″比”Inc”更快。这是为什么呢?原因在于,系统一般会对下一个元素进行强制性预取。当程序前进到下个元素时,这个元素其实早已被预取在L1d里。因此,只要工作集比L2小,”Addnext0″的性能基本就能与”Follow”测试媲美。

但是,”Addnext0″比”Inc”更快离开L2,这是因为它需要从主存装载更多的数据。而在工作集达到2 21字节时,”Addnext0″的耗时达到了28个周期,是同期”Follow”14周期的两倍。这个两倍也很好解释。”Addnext0″和”Inc”涉及对内存的修改,因此L2的逐出操作不能简单地把数据一扔了事,而必须将它们写入内存。因此FSB的可用带宽变成了一半,传输等量数据的耗时也就变成了原来的两倍。

决定顺序式缓存处理性能的另一个重要因素是缓存容量。虽然这一点比较明显,但还是值得一说。图3.14展示了128字节长元素的测试结果(64位机,NPAD=15)。这次我们比较三台不同计算机的曲线,两台P4,一台Core 2。两台P4的区别是缓存容量不同,一台是32k的L1d和1M的L2,一台是16K的L1d、512k的L2和2M的L3。Core 2那台则是32k的L1d和4M的L2。

图中最有趣的地方,并不是Core 2如何大胜两台P4,而是工作集开始增长到连末级缓存也放不下、需要主存热情参与之后的部分。

与我们预计的相似,最末级缓存越大,曲线停留在L2访问耗时区的时间越长。在220字节的工作集时,第二台P4(更老一些)比第一台P4要快上一倍,这要完全归功于更大的末级缓存。而Core 2拜它巨大的4M L2所赐,表现更为卓越。

对于随机的工作负荷而言,可能没有这么惊人的效果,但是,如果我们能将工作负荷进行一些裁剪,让它匹配末级缓存的容量,就完全可以得到非常大的性能提升。也是由于这个原因,有时候我们需要多花一些钱,买一个拥有更大缓存的处理器。

单线程随机访问模式的测量

前面我们已经看到,处理器能够利用L1d到L2之间的预取消除访问主存、甚至是访问L2的时延。

但是,如果换成随机访问或者不可预测的访问,情况就大不相同了。图3.15比较了顺序读取与随机读取的耗时情况。

换成随机之后,处理器无法再有效地预取数据,只有少数情况下靠运气刚好碰到先后访问的两个元素挨在一起的情形。

图3.15中有两个需要关注的地方。首先,在大的工作集下需要非常多的周期。这台机器访问主存的时间大约为200-300个周期,但图中的耗时甚至超过了450个周期。我们前面已经观察到过类似现象(对比图3.11)。这说明,处理器的自动预取在这里起到了反效果。

其次,代表随机访问的曲线在各个阶段不像顺序访问那样保持平坦,而是不断攀升。为了解释这个问题,我们测量了程序在不同工作集下对L2的访问情况。结果如图3.16和表3.2。

从图中可以看出,当工作集大小超过L2时,未命中率(L2未命中次数/L2访问次数)开始上升。整条曲线的走向与图3.15有些相似: 先急速爬升,随后缓缓下滑,最后再度爬升。它与耗时图有紧密的关联。L2未命中率会一直爬升到100%为止。只要工作集足够大(并且内存也足够大),就可以将缓存线位于L2内或处于装载过程中的可能性降到非常低。

缓存未命中率的攀升已经可以解释一部分的开销。除此以外,还有一个因素。观察表3.2的L2/#Iter列,可以看到每个循环对L2的使用次数在增长。由于工作集每次为上一次的两倍,如果没有缓存的话,内存的访问次数也将是上一次的两倍。在按顺序访问时,由于缓存的帮助及完美的预见性,对L2使用的增长比较平缓,完全取决于工作集的增长速度。

而换成随机访问后,单位耗时的增长超过了工作集的增长,根源是TLB未命中率的上升。图3.17描绘的是NPAD=7时随机访问的耗时情况。这一次,我们修改了随机访问的方式。正常情况下是把整个列表作为一个块进行随机(以∞表示),而其它11条线则是在小一些的块里进行随机。例如,标签为’60′的线表示以60页(245760字节)为单位进行随机。先遍历完这个块里的所有元素,再访问另一个块。这样一来,可以保证任意时刻使用的TLB条目数都是有限的。 NPAD=7对应于64字节,正好等于缓存线的长度。由于元素顺序随机,硬件预取不可能有任何效果,特别是在元素较多的情况下。这意味着,分块随机时的L2未命中率与整个列表随机时的未命中率没有本质的差别。随着块的增大,曲线逐渐逼近整个列表随机对应的曲线。这说明,在这个测试里,性能受到TLB命中率的影响很大,如果我们能提高TLB命中率,就能大幅度地提升性能(在后面的一个例子里,性能提升了38%之多)。

产生线程安全的原因(2)(操作系统)相关推荐

  1. 产生线程安全的原因(4)(操作系统)

    3.4 指令缓存 其实,不光处理器使用的数据被缓存,它们执行的指令也是被缓存的.只不过,指令缓存的问题相对来说要少得多,因为: 执行的代码量取决于代码大小.而代码大小通常取决于问题复杂度.问题复杂度则 ...

  2. 多线程编程:线程死锁的原因以及解决方法

    多线程编程:线程死锁的原因以及解决方法 关于线程死锁这个问题相信程序员在编写多线程程序时会经常遇到的一个经典问题,这种情况往往出现在多个线程同时对临界资源访问时所产生的. 属于临界资源的硬件有打印机. ...

  3. 调试JDK源码-Hashtable实现原理以及线程安全的原因

    调试JDK源码-一步一步看HashMap怎么Hash和扩容 调试JDK源码-ConcurrentHashMap实现原理 调试JDK源码-HashSet实现原理 调试JDK源码-调试JDK源码-Hash ...

  4. java 线程安全的原因_Java并发编程——线程安全性深层原因

    线程安全性深层原因 这里我们将会从计算机硬件和编辑器等方面来详细了解线程安全产生的深层原因. 缓存一致性问题 CPU内存架构 随着CPU的发展,而因为CPU的速度和内存速度不匹配的问题(CPU寄存器的 ...

  5. 线程安全问题的原因和解决方案

    一.首先产生线程安全的原因: (一)站在开发者的角度看:①多个线程之间操作同一个数据②至少有一个线程修改这个数据(不是读操作而是写操作) (二)站在系统的角度看:一条语句对应多个指令,线程调度可以发生 ...

  6. 导致线程死锁的原因?怎么解除线程死锁

    欢迎大家关注我的公众号[老周聊架构],Java后端主流技术栈的原理.源码分析.架构以及各种互联网高并发.高性能.高可用的解决方案. 一.导致线程死锁的原因 多个线程同时被阻塞,它们中的一个或者全部都在 ...

  7. 引发线程安全的原因是什么?怎么解决?程序员一定要掌握的东西

    目录 前言 一.引发线程安全的原因 1.抢占式执行 2.多线程修改同一个变量 3.操作是原子的 4.指令重排序 5.内存的可见性问题 对于线程不安全问题,如何解决? Java的代码中如何进行加锁呢? ...

  8. 产生线程安全的原因(3)(操作系统)

    3.3.3 写入时的行为 在我们开始研究多个线程或进程同时使用相同内存之前,先来看一下缓存实现的一些细节.我们要求缓存是一致的,而且这种一致性必须对用户级代码完全透明.而内核代码则有所不同,它有时候需 ...

  9. 产生线程安全的原因(1)(操作系统)

    首先了解cpu 告诉缓存问题和架构 这是Ulrich Drepper写"程序员都该知道存储器"的第二部.那些没有读过第一部 的读者可能希望从这一部开始.这本书写的非常好,并且感谢U ...

最新文章

  1. OpenCV学习笔记1
  2. [Issue Fixed]-分卷压缩解压:gzip: stdin: not in gzip format
  3. 【NOI 2011】阿狸的打字机
  4. 做好新型冠状病毒防疫 |春节第一重任:说服爸妈带口罩
  5. python nonetype转换float_如何在Python中将NoneType值从聚合转换为float?
  6. 关于敏捷规划的微信对话
  7. spring注入普通java类_普通java类如何取得注入spring Ioc容器的对象
  8. ffmpeg实战教程(八)Android平台下AVfilter 实现水印,滤镜等特效功能
  9. OpneCV之图像的平移、翻转、旋转、缩放、裁剪(笔记04)
  10. Python XML操作处理
  11. [ NOI 2005 ] 聪聪与可可
  12. ubuntu18.04桌面美化
  13. 零基础学习人工智能如何入门?
  14. UGUI ContentSizeFitter 嵌套 适配
  15. Week11——C密文
  16. 【码农教程】手把手教你Mockito的使用
  17. 【C++】读取txt文件中指定行的内容
  18. SOHO办公遭IBM叫停,为什么说它是创业大忌?
  19. Android开发本地音乐获取专辑封面图片
  20. 超市选址问题(Python实现)

热门文章

  1. ubnutu18.10拔除硬盘后进行recovery mode
  2. ubuntu启动后反复进入initramfs
  3. CentOS下面ERROR 2002 (HY000): Can't connect to local MySQL server through socket '/tmp/mysql.sock' (2)
  4. unicode编码查询方法
  5. php从大到小排列数字,php输入几个数从大到小排序
  6. echart关系树状图_干货 | 25个常用Matplotlib图的Python代码
  7. springboot配置index
  8. php数组逗号连接,php – 如何使用逗号组合数组中的所有元素?
  9. 2.5 隐藏委托关系
  10. Node.js实现基于TCP与UDP的数据通信