不要虐待你的cache
原文地址如下:http://msinilo.pl/blog/?p=614 。。。我翻译着玩的
有一些很短的技巧和指导方针,每一个游戏开发人员都应该记住。没有复杂的事,都是常识。但是仍然很少有人去考虑底层的东西,尤其是游戏开发人员。今天的硬件体系使得cache可以成为我们最大的朋友,或者敌人。CPU在某些地方的额外的计算所带来的低效远小于一个cache miss(因此LUT并不总是最好的选择)。笔者并不打算写太多cache机制相关的东西,这些自有专门的学科,如果你感兴趣你可以看看 Gustavo’s article 或 this paper by Ulrich Drepper。简单的说,cache是一个非常快速的存储器组织,由很多32-128字节的块组成。当内存被引用,cache首先被检查,如果其中有数据,访问时很快的。如果没有的话,整块都会被装载。在后面这种情况里,意味着cache miss会消耗上百个周期。要全面的考虑事情, 参考著名的Tony 的论文 Pitfalls of OOP, 通过改变数据的布局,他把包围球的计算速度提高了35%(代码部分没改过)。
造成cache miss有3个主要的原因:
- 数据是第一次读
- 数据已经被驱逐出cache
- cache超负荷了(当今的cache都是组相连的,所以存储单元映射限制了可能的cache块的数量)
怎么样避免呢?
不要使你的结构过大。考虑填补(Cruncher# to the rescue!),用bit域来替代多个bool变量,考虑使用更小的数据类型。当然这永远都不会无意识的做到,考虑所有的情况。就像上面说到的——也许值得牺牲CPU的存取速度来压缩数据。
考虑你的访问模式。让成员紧密排列。笔者经常看到代码的第一个访问成员在偏移量为2的地方,然后跳到了130。这里有很多的碎片,会导致对同一个对象的3次访问带来了3次cache miss。通过移动周围的变量,笔者已经为每个框架解决了上百个cache miss。
分离hot/cold数据。这与1和2都相关,他会让你的结构更小,访问数据更加的适合cache。考虑:
struct Lol { int a, b; Cat cat; // 140 bytes };
现在,试想我们对Lol有一个循环迭代,计算a&b,即使跳到下一个对象都会带来miss。然后,我们有了另一个循环,主要是对Cats进行操作。现在,试想结构体里变成了指向Cat的指针,突然,结构降到了12字节,第一个循环变得适合cache多了。Cat可以从池中申请,或者是存在另一个线性表中。也许他们可以变得更小一些(但140字节的对象还是能让cache挂掉)。
另一个经常看到的典型的模式是:
for every object { if (object->isActive) object->Update(); }
如果对象不是活动的,我们仍然需要加载整个cache块,仅仅为了查看1个位。这是我们能做的最坏的事情。就好像把cache请回家,然后在一秒种后把它踢出出。根据微软的this presentation,典型的控制台标题有30-40%的cache使用率。这就意味着,它每3次加载,只用了1字节。更好的解决方案是:
for every object { if (isObjectActive[i]) object->Update(); }
这好点了,如果对象不是活动的,它不用接触。活动对象标识的数组是一个很好的内存连续块,我们甚至可以用bit来压缩它。但这还不是完美的,cache和CPU都喜欢的是线性访问。理想的情况下,你想要加载存储于线性块上相邻的数据,对代码页一样,我们不想跳来跳去。当然,没有一个方法适合所有的情况,但也许有一个活动物体的列表并遍历他们是更好的:
for every active object object->Update();
当然,保留这样的一个list是要付出代价的,所以这依赖于这个列表是否经常改变,所以还是值得考虑的。这里要回答一些问题:这样的标识改变是否频繁,程序忽略对象的比率是多少,笔者见到的最变态的解决方案如下:
while (itObject != 0) { if (itObject->isDisposed()) disposedObjects.push_back(itObject); itObject = itObject->next;
每个框架都会调用这个。在99%的情况下,没有东西需要处理。他仍然要迭代上百个对象(这可是链表!)来发觉根本什么都不用做。笔者首先引用flag标志来指示是否有东西需要处理(3分钟的工作,平均情况下少了1k cache misses/frame)。
分离hot/cold数据在我们需要经常迭代访问对象时能够获得最高的回报。真实的情节——对每个NPC我们有一个知识基础结构,包含其他代理商的信息。我们经常需要通过ID查询特殊代理商的信息。首先,我们使用排序数组。这是一个很好的适合cache的结构,但是代理商信息很大,即使二分查找跳过了元素都意味着cache miss。把数组改成仅仅包括ID和指针对。真实的数据存储在别的地方。现在,当查找的时候我们仅仅涉及到局部数据,当ID找到时,才按指针查询代理人信息。
考虑使用基于组件的模型。这被证实具有数据局部性,并且让对象更小。现在,我们可以对部件进行批处理,仅仅在需要时才加载数据。在传统的方法中,基本的数据结构可以变得很大并且——我们加载400字节,仅仅这里访问1,4,然后那边64.对于基于组件的方法,所有这些数据可以被紧密存储,而没有其他的干扰。
我们可以探讨的更深入一些,将同样类型的组件存储于数组中,紧密排列,这样对批处理是最适合的。
考虑你的数据结构。cache喜欢线性访问。这就意味着,当你用树和链表的时候你要注意。使用池申请的时候他们会变得更高效,但也许切换成另一种数据结构也是值得考虑的。你也许需要忘记你的CS课程,不要太关注算法复杂度。如果有很多的元素,简单的数组还是会打败链表的,即使在插入和移动物体。排序的数组、哈希表是map、红黑树的很好的替换品。
不要复制。如果你看一下大部分的游戏引擎,他们花费了大量的时间在不同的链表间复制、移动和转换对象。每次你复制附近的数据——你是在浪费时间。问问你自己,你真的需要这样做吗,也许存储引用就够了。
就像Brian在评论里指出的——这听起来有点太激进了,把它改成“如非必要,不要拷贝”。和其他的技巧一样——有时你需要选择不那么邪恶。在一个理想的世界里,我们让每件事情都完美,适合cache。在真实情况中,我们有时需要重新安排数据。
剖析。即使你确定你理解这一状况——确定你的假设。首先关注于固定空间的可怜的字节、cache线性使用率。不管怎么样,时候分析来确保变化的准确性总是有帮助的。
预取。把它留在最后是因为这是最蛮力的解决方法,有时是不可避免的。即使数据组织在线性框架中,你还是需要为cache miss付出代价。要避免这个,你可以试着让CPU知道你很快需要它。这就是预取主要要做的事情,他在后台把数据加载到cache。这里是窍门——它仍然需要上百个时钟周期来完成。所以当你这样做的时候你不要奢望奇迹:
prefetch(0, tab[i]); int a = tab[i].a;
流行的样式是在处理当前数据的时候预取下一数据。依赖于对象大小、处理时间(也许短于预取时间),考虑首先预取2、3、4……个元素。预取是很有用,但它不应该是你的第一选择,试着蚕蛹其他的解决方法,不到万不得已不要使用预取。
每个程序员都应该读的书:
- Christer Ericson – Memory Optimization (这是Christer论文的折扣版),
- Bruce Dawson – Multi-Core Memory Coherence – The Hidden Perils of Sharing Data,
- Igor Ostrovsky – Gallery of Processor Cache Effects,
-
本翻译原创,转载请注明出处 http://www.cnblogs.com/luluathena/