数组大小对 Cache 访问性能影响

在知乎看到一个回答很有意思

“用C++写一个有栈协程,协程的栈设置为4096时,运行效率比4096-8或者4096+8慢了40%左右。。。。简直快要把脑袋钻到显示屏里了。。

结果努力的读了又读N-way associative cache相关的内容, 最终确定仅仅是CPU cache的问题。。还好2天之内就定位到问题了。

-----------------------------------------------------------------------------------------------------------------------

这么多人赞就分享一个相关的图(和我的协程无关,但情况完全一致,测试用例设法避免了别的因素的影响,让4K的慢显得特别明显。)”

写这篇的时候还没有意识到这个本来是计组的内容其实是计算机学生的常识,等到之后的一年学了更多东西的时候才发现,这个确实要成为常识,重复在数据库课程、操作系统课程、网络编程里面出现。

   

    所以问题就是在 page aligned 的情况下以特定步长访问连续数组的时候会出现不同的访问速度,

本文就来分析一下这个问题. 结合 CSAPP Cache 内容 以及 Intel 的实际 Cache 的一些参数来分析.

结合有一篇文章值得一看: Data alignment and caches (danluu.com)

组相联 Cache 原理两种视角

    先给出 Intel 某代处理器的 L1 Cache spec:  64 sets of 8-way associative scheme, 64 bytes per line.

    我们可以计算L1 Cache 的大小: 64*64*8 = 32,768 bytes = 32KB, 也可以换算成 8PGSIZE  (默认的 PGSIZE 应该是 4KB).

    简要复习 Cache 直接映射的思路,给 DRAM 根据块大小(一般是 4KB)分成一块一块的,假如有 4 块 Cache 块(16KB)编号 0 1 2 3,内存就分成 RAM/16KB 个组,每组(一组16KB)编号 0 1 2 3,规定只有相同编号的内存块和 Cache 块能匹配。这样做的好处是比较简单,不好是对于对齐的数据访问容易出现抖动(假如对同样的编号的数据进行访问,就会频繁地逐出和载入某个编号的 Cache 块)。而全相联的相联比较器成本较高,所以引入了组相联,思路只是简单地搞多几个并列的 Cache 编号,比如常见的 8 路组相联即搞 8 个上面说的这个 0 1 2 3 编号的 Cache 组。下面画图分析组相联。为了说明的方便,下列使用 Group 作为内存分组(直接映射的一个 Cache 块,内含多个 set)标识,对应内存地址中的 Tag;使用 Index 标识 Cache 内分块的编号。

    之后我们可以来分析 Cache 组相联的第一种视角, 即从直接映射改进法.

    考虑原来的直接映射方案, 认为是 1-way set associative, 这样每个拥有相同的 Index_i 就只能出现其中的一个, 比如这里 Group0 和 Group1 的 index 编号为 0 的两个块只能 Cache 一个, 如果我需要在 group0 和 Group1 之间来回访问, 就必定会出现频繁的逐出和加载.

    改进就是我提供多路的支持, 让每个 index 相同的都能出现 k way, 就是组相联. 我们这里是 8-ways 就说明同时能加载 8个 Group 的 Index 为 0 的块.

    第二种视角就是有8路去读取同一个编号的不同 Tag(group), 每个 Group都有64个编号.

协程栈问题分析

    接下来我们来分析协程库进行 stack 切换的问题.

    为了方便表达4K等, 这里还是用PAGE 和GROUP/TAG, INDEX 等词对应图片来说明.

    我们一开始请求很多个 PAGE 作为多个 stack 使用. 这样得到的应该是对齐的n个page(kalloc). 考虑我们运行大于8个协程, 这些协程在不断的 context switching. 根据计算我们可以得知一个 TAG(Group) 刚好就是一个 PAGE, 这样又因为 page aligned 的关系, 我们最多会保持8个(粗略估计)携程的栈在 Cache 中不会 miss (实际 Cache 不会完全用来放协程栈), 实际运行中, 我们一秒进行一亿次 context switch 的话, 由于Cache 每次都以一个 block 或者Cache line 容量64bytes进行逐出和重载, 考虑我们执行和数据,  绝对估计的情况是每 8 次切换就要 Cache 重载.

    Cache 重载又涉及3级的层次结构, 最终引发瓶颈.

    考虑如果把栈的大小设置为 Cache 一整个Group(64sets) 的大小的一半的话, 这样能同时进入Cache的协程上限会加一倍, 减少一半的装载.

    现在分析不对齐的情况, 我们图片画 2 sets 的情况, 但是仍然考虑一个 Page 占完一个 Set, 问题是等价的.

    这里看实际上的程序运行时根据局部性原理并不会一整个64sets都装的同一个协程的内容, 实际上还是交错的也就是一个Cache组64个sets会加载大于1小于64个协程的栈的一些小块.

    因为这里确实是涉及局部性的内容具体说的话会很乱, 我们理想化考虑, 先考虑Cache中加载了8个协程的栈, 而且恰好是完全载入的, 也就是载入了8个4kb栈.

    为了更好看出两种方案的区别, 我们假定协程都是在频繁访问他们的栈的同一个地方. 如果是一开始的页面对齐方案, 很容易想到每切换8个协程就要重载Cache, 我们分析其中一种可能的情况:

    前8协程内切换->载入新的8个协程->回到前8个协程, 这里对齐必须8路全部逐出再读回来. 如果是不对齐的情况, 很多都不需要逐出, 因为我们的不同协程频繁访问的那个点代表的 Index(sets内的编号)已经不是同一个了, 错开得好的时候, 这种情况, 甚至可能会出现编号分别从0到63, 这样64个协程同时出现, 减少了切换次数.

矩阵求转置问题分析

    上面例子是比较复杂的, 我们不知道他短时间内切换回Cache曾经用过的频率是多少. 很难精确描述这个性能差. 接下来再看一个矩阵转置的例子.

    为了编得更准确, 重新复习一下Intel这款处理器的L1 Cache 的参数: 64 sets of 8-way associative scheme, 64 bytes per line.

    可以计算得出一个Group的大小是 64*64=4,096 bytes, 我们假定sizeof(int) = 4, 那么就使用 1024为矩阵的边长. 这样一个以1024步长来访问的时候肯定会导致 Cache miss.

    转置的时候, 主要是二层循环, 内部 [i][j] <-> [j][i], 然后第一次有一个数组叫做B吧, 步长是内层按1024访问的, 另一个按1访问, 但是内层结束后A将回到一开始的地方按步长1增加之后再进入内层循环. 我们画图说明.

    图中红色的就是数组的访问, 荧光色是Cache的块. 复习前面对那个协程库的分析(虽然有点模糊), 不难得出, B数组的访问基本是没有利用到Cache的, 这是因为他不满足数据的局部性, 同一个块两次访问的间隔太久了(1024轮后才回访), 但是我们可以想到保留这个块用于将来访问不行吗? 实际本来是有的, 我们在访问到B第九行之前的前八行按理想情况都能保留在Cache中, 但是到第九行之前都没有回访, 第九行访问到之后, 由于这些块是对齐的拥有相同的Index, 他们会立即要求发生evict然后填充新的块.

    为了利用好 Cache 使数据保留得更久, 我们必须错开这些步长访问的块的 Index 的编号. 一种方案是之前说的位移错开, 这也就是为什么+- 都能比幂更快. 第二种方法是分块之后再转置, 等价于减少步长提高数据的局部性, 证明分块转置的正确性可以通过列转移方程来得到.

    实际可以编程序统计运行时间感受一下 4KB 相关的数据访问会出现时间上性能的差异。

  • 1
    点赞
  • 2
    收藏
    觉得还不错? 一键收藏
  • 0
    评论
超级有影响力的Java面试题大全文档 1.抽象: 抽象就是忽略一个主题中与当前目标无关的那些方面,以便更充分地注意与当前目标有关的方面。抽象并不打算了解全部问题,而只是选择其中的一部分,暂时不用部分细节。抽象包括两个方面,一是过程抽象,二是数据抽象。 2.继承:  继承是一种联结类的层次模型,并且允许和鼓励类的重用,它提供了一种明确表述共性的方法。对象的一个新类可以从现有的类中派生,这个过程称为类继承。新类继承了原始类的特性,新类称为原始类的派生类(子类),而原始类称为新类的基类(父类)。派生类可以从它的基类那里继承方法和实例变量,并且类可以修改或增加新的方法使之更适合特殊的需要。 3.封装:  封装是把过程和数据包围起来,对数据的访问只能通过已定义的界面。面向对象计算始于这个基本概念,即现实世界可以被描绘成一系列完全自治、封装的对象,这些对象通过一个受保护的接口访问其他对象。 4. 多态性:  多态性是指允许不同类的对象对同一消息作出响应。多态性包括参数化多态性和包含多态性。多态性语言具有灵活、抽象、行为共享、代码共享的优势,很好的解决了应用程序函数同名问题。 5、String是最基本的数据类型吗?  基本数据类型包括byte、int、char、long、float、double、boolean和short。  java.lang.String类是final类型的,因此不可以继承这个类、不能修改这个类。为了提高效率节省空间,我们应该用StringBuffer类 6、int 和 Integer 有什么区别  Java 提供两种不同的类型:引用类型和原始类型(或内置类型)。Int是java的原始数据类型,Integer是java为int提供的封装类。Java为每个原始类型提供了封装类。 原始类型 封装类 boolean Boolean char Character byte Byte short Short int Integer long Long float Float double Double  引用类型和原始类型的行为完全不同,并且它们具有不同的语义。引用类型和原始类型具有不同的特征和用法,它们包括:大小和速度问题,这种类型以哪种类型的数据结构存储,当引用类型和原始类型用作某个类的实例数据时所指定的缺省值。对象引用实例变量的缺省值为 null,而原始类型实例变量的缺省值与它们的类型有关。 7、String 和StringBuffer的区别  JAVA平台提供了两个类:String和StringBuffer,它们可以储存和操作字符串,即包含多个字符的字符数据。这个String类提供了数值不可改变的字符串。而这个StringBuffer类提供的字符串进行修改。当你知道字符数据要改变的时候你就可以使用StringBuffer。典型地,你可以使用 StringBuffers来动态构造字符数据。 8、运行时异常与一般异常有何异同?  异常表示程序运行过程中可能出现的非正常状态,运行时异常表示虚拟机的通常操作中可能遇到的异常,是一种常见运行错误。java编译器要求方法必须声明抛出可能发生的非运行时异常,但是并不要求必须声明抛出未被捕获的运行时异常。 9、说出Servlet的生命周期,并说出Servlet和CGI的区别。  Servlet被服务器实例化后,容器运行其init方法,请求到达时运行其service方法,service方法自动派遣运行与请求对应的doXXX方法(doGet,doPost)等,当服务器决定将实例销毁的时候调用其destroy方法。 与cgi的区别在于servlet处于服务器进程中,它通过多线程方式运行其service方法,一个实例可以服务于多个请求,并且其实例一般不会销毁,而CGI对每个请求都产生新的进程,服务完成后就销毁,所以效率上低于servlet。 10、说出ArrayList,Vector, LinkedList的存储性能和特性  ArrayList 和Vector都是使用数组方式存储数据,此数组元素数大于实际存储的数据以便增加和插入元素,它们都允许直接按序号索引元素,但是插入元素要涉及数组元素移动等内存操作,所以索引数据快而插入数据慢,Vector由于使用了synchronized方法(线程安全),通常性能上较ArrayList差,而LinkedList使用双向链表实现存储,按序号索引数据需要进行前向或后向遍历,但是插入数据时只需要记录本项的前后项即可,所以插入速度较快。 11、EJB是基于哪些技术实现的?并说出SessionBean和EntityBean的区别,StatefulBean和StatelessBean的区别。 EJB包括Ses

“相关推荐”对你有帮助么?

  • 非常没帮助
  • 没帮助
  • 一般
  • 有帮助
  • 非常有帮助
提交
评论
添加红包

请填写红包祝福语或标题

红包个数最小为10个

红包金额最低5元

当前余额3.43前往充值 >
需支付:10.00
成就一亿技术人!
领取后你会自动成为博主和红包主的粉丝 规则
hope_wisdom
发出的红包
实付
使用余额支付
点击重新获取
扫码支付
钱包余额 0

抵扣说明:

1.余额是钱包充值的虚拟货币,按照1:1的比例进行支付金额的抵扣。
2.余额无法直接购买下载,可以购买VIP、付费专栏及课程。

余额充值