中央处理器内核的“虚共享”是怎么一回事?
译者:陈敬凤(nunu) 审校:王磊(未来的未来)
这个文章是一个系列教程的一部分 - 去这里看整个教程的索引。
在上上篇文章中,我解释了合并写入,并使用了一个现实的例子来显示如果不小心的话,可能会出现什么问题。上一篇文章是关于一些字符串和内存管理方面的一些不合理的行为,会严重损害一个程序的加载时间。该程序是英特尔的软件遮挡剔除示例,我在过去两个周末一直在研究这个示例。
现在事实证明,这个示例程序还有一些更常见的性能问题。请不要觉得我现在这么做是故意在挑英特尔或是这个示例程序作者的刺,不要因为有这种印象而不看这篇文章。我真正想在这里做的事情是谈论在典型的游戏代码库中可能发现的常见性能问题。我曾经在几个这样的项目中工作过,但是这些项目的代码都需要保密。但这一次,问题恰好出现在一个开放源代码的例子中,具有许可证,由第三方编写。这意味着我可以自由地发布代码以及我的修改,而且如果我要去博客讨论它的话,还有一个很大的好处 - 真正的代码比稻草人例子更有趣。说实话,我在公开谈论“我在互联网上发现的抽象代码”中的性能问题的时候要比谈论我认识的一个朋友为了赶上里程碑在两天内赶出来的代码感觉上要舒服的多。
我想在这里说的是,不要因为这篇文章而打消你看实际的遮挡剔除代码的念头,毕竟我举得只是整个示例代码的一点。而对英特尔的家伙来说,他们首先克服了编写和发布的麻烦,对于我这些文章的内容,请别见怪。
我们今天要说的问题
也就是说,我们今天仍然不会看到任何实际的遮挡剔除性能问题或优化。 因为在我们到达那里之前,我们还有一些更之前的问题来解决。像往常一样,这是一个分析文件 - 这次的渲染时间。
所有在名字中有SSE并与实际深度缓冲区光栅化器有关的函数都是这个演示的核心(先说明一下,我们最终会看到)。XdxInitXopAdapterServices实际上是用户模式的图形驱动程序,tbb_graphics_samples是TBB调度程序等待工作线程完成(此示例程序使用英特尔的TBB将作业分配到多个工作线程),dxgmms1.sys是视频内存管理器/ 图形管理器调度程序, 而atikmdag.sys是内核模式的图形驱动程序。 简而言之,前十名列表中充满了你将期望的一些例子,它们渲染了大量具有软件遮挡的小型模型。
除了第二位的热点。 这个函数 - CPUTFrustum :: IsVisible- 简单地检查一个轴对齐的边界框是否与视锥体相交,它用于粗截锥体剔除,甚至在考虑遮挡之前。这是一个主要的时间消耗。
现在,这个分析文件是使用硬件事件计数器来生成的,与合并写入那片文章的生成方法相同,而不是我上次用来查看加载的分层调用堆栈分析。我已经冒昧地破坏了初步的调查,并直接进入了重要的部分:看到“机器清除(MachineClears)”列中的蓝色条了么? 那个蓝色条告诉我们,IsVisible函数花费了23.6%的总运行时间来执行机器清除! 很好,但是这是什么意思?
理解机器清除
英特尔在目前的架构上称之为“机器清除”的东西基本上是一种让人感到恐慌的模式:中央处理器的内核需要找到所有当前未决的操作(即尚未完成的任何操作),取消它们,然后重新开始。只要有一些隐含的假设,证明这些待处理的指令是错误的,它就需要这样做。
在Sandy Bridge i7上,我运行这个例子,有三种机器清除的事件计数器。其中两个我们可以在这篇文章中放心地忽略 - 其中之一是处理自我修改代码(我们没有),另外一种是在执行AVX屏蔽加载操作(我们不使用它们)时发生。 然而,第三个会做更仔细的检查:它的官方名称是MACHINE_CLEAR.MEMORY_ORDERING,这是那个在IsVisible期间最终消耗所有中央处理器周期23.6%的事件。
只要中央处理器内核检测到“内存排序冲突”,就会触发一个内存排序机器清除。 基本上,这意味着一些目前挂起的指令试图访问我们刚才发现的一些其他中央处理器内核会写入的内存。由于这些指令仍然被标记为待处理,而“这个内存刚被写入”这个事件意味着其他一些内存成功完成了写入操作,挂起的指令以及取决于其结果的所有内容都具有追溯性:当我们开始执行这些指令的时候,我们那时候正在使用的内存内容已经过时了。所以我们需要抛出所有的工作,并干掉它们。这就是机器清除。
现在,我不会详细介绍一个内核如何知道其他内核开始写入内存,或是内核如何确保当有多个内核尝试写入一个内存位置的时候,总是有一个(而且只有一个)赢家。我也不会解释内核如何确保它们及时了解这些内容,以取消所有可能依赖这些操作的操作。所有这些都是深刻和引人入胜的问题,但细节令人难以置信地粗暴(一旦你深入内核是如何工作内的底层的话你就会发现这一点),并且它们远远超出了这篇文章的范围。我会在这里说的是,内核会在缓存线粒度上跟踪内存的“所有权”。所以当发生内存排序冲突的时候,这意味着我们刚刚访问的缓存行中的某些东西在同一时间内被更改。可能是我们实际看过的一些数据,可能是别的 – 内核并不知道。所有权在缓存行这一量级进行跟踪,而不是字节级。
因此,当我们刚刚查看过的缓存行中的某些内容发生变化的时候,内核会执行一个机器清除。这可能是由于实际的共享数据,或者它可能是两个不相关的数据项碰巧加载到存储器中的同一个高速缓存行中- 后一种情况通常被称为“虚假共享”。这里很多人容易搞错,为了澄清这一点,“虚假分享”纯粹是一个软件概念。 中央处理器真的只跟踪缓存行级别的所有权,并且不管高速缓存行是共享的还是不是共享的,它永远不会“被错误地共享”。所以“虚假共享”纯粹是你的数据在内存布局中的属性, 这不是中央处理器能知道(或可以做任何事情)的东西。
嗯,我有点离题了。显然,我们正在分享一些东西,无论是有意或是无意的,有些事情会导致很多指令被取消和重新执行。问题是:这是什么?
寻找罪魁祸首
这就是它变得讨厌的地方。有很多事情,如高速缓存未命中或是执行的比较缓慢的指令,分析器可以准确地告诉我们是哪个指令导致了问题。存储器排序问题难以追溯,原因有两个:首先,它们必然涉及多个内核(这往往使得更难找到相应的事件链),其次,由于缓存行粒度,即使它们在一个线程中显示为事件,它们是在实际共享数据附近去访问内存的任意指令中执行的。可能是在其他地方实际修改的数据,或者可能是别的。没有简单的方法来找出。在源代码级分析文件中查看这些事件几乎是完全没用的 - 在优化的代码中,逻辑上属于另一个源代码行的完全无关的指令可能会导致执行的尖峰。在程序集级别的分析文件中,你至少可以看到触发事件的实际指令,但是由于上述原因并不一定非常有用。
所以它可以归结到这一点:一个分析器会告诉你去看哪里,它通常会指向某段代码附近的代码,这些代码实际上导致了这个问题,以及一些正在被共享的数据附近的数据。这是一个很好的起点,但是从那里开始就完全是手工检测工作 - 盯着代码,盯着数据结构,并试图找出导致问题的原因。这是令人讨厌的工作,但随着时间的推移,你会变得更好,有一些常见的错误 -其中一个我们会在一分钟内看到。
但首先是一些上下文。 IsVisible在多个线程(通过TBB)并行调用,通过一个全局的初始圆锥体裁剪渲染通道。这是我们看到出现减速的地方。显然,这些线程在某处写入共享数据:它必须是写入 - 只要内存不变,你就不会得到任何内存排序冲突。
以下是CPUTFrustum类的声明(为简洁起见省略了几种方法):
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 | class CPUTFrustum { public : float3 mpPosition[8]; float3 mpNormal[6]; UINT mNumFrustumVisibleModels; UINT mNumFrustumCulledModels; void InitializeFrustum( CPUTCamera *pCamera ); bool IsVisible( const float3 ¢er, const float3 &half ); }; |
这是IsVisible的完整代码,有一些小的格式需要更改,使其适合布局(摘录它会破坏整个的展现):
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 21 22 23 24 25 26 27 28 29 30 31 32 33 34 35 36 37 38 39 40 41 42 43 44 45 46 47 48 49 50 51 52 53 54 | bool CPUTFrustum::IsVisible( const float3 ¢er, const float3 &half ){ // TODO: There are MUCH more efficient ways to do this. float3 pBBoxPosition[8]; pBBoxPosition[0] = center float3( half.x, half.y, half.z ); pBBoxPosition[1] = center float3( half.x, half.y, -half.z ); pBBoxPosition[2] = center float3( half.x, -half.y, half.z ); pBBoxPosition[3] = center float3( half.x, -half.y, -half.z ); pBBoxPosition[4] = center float3( -half.x, half.y, half.z ); pBBoxPosition[5] = center float3( -half.x, half.y, -half.z ); pBBoxPosition[6] = center float3( -half.x, -half.y, half.z ); pBBoxPosition[7] = center float3( -half.x, -half.y, -half.z ); // Test each bounding box point against each of the six frustum // planes. // Note: we need a point on the plane to compute the distance // to the plane. We only need two of our frustum's points to do // this. A corner vertex is on three of the six planes. We // need two of these corners to have a point on all six planes. UINT pPointIndex[6] = {0,0,0,6,6,6}; UINT ii; for ( ii=0; ii<6; ii ) { bool allEightPointsOutsidePlane = true ; float3 *pNormal = &mpNormal[ii]; float3 *pPlanePoint = &mpPosition[pPointIndex[ii]]; float3 planeToPoint; float distanceToPlane; UINT jj; for ( jj=0; jj<8; jj ) { planeToPoint = pBBoxPosition[jj] - *pPlanePoint; distanceToPlane = dot3( *pNormal, planeToPoint ); if ( distanceToPlane < 0.0f ) { allEightPointsOutsidePlane = false ; break ; // from for. No point testing any // more points against this plane. } } if ( allEightPointsOutsidePlane ) { mNumFrustumCulledModels ; return false ; } } // Tested all eight points against all six planes and // none of the planes had all eight points outside. mNumFrustumVisibleModels ; return true ; } |
你能看出有什么问题吗? 尝试自己弄清楚。 如果你自己发现它会印象更加的深刻。如果你认为你有了答案(或者你放弃的话),请向下滚动找到正确答案。
正确答案揭示
正如我所说,只有写入才会发生内存排序冲突。函数参数是const类型,mpPosition和mpNormal也不会被修改。局部变量放在寄存器或堆栈中,无论是放在哪里,他们在不同线程之间的距离足够远不会发生冲突。那么只留下了两个变量:mNumFrustumCulledModels和mNumFrustumVisibleModels。 实际上,这两个全局(调试)计数器都是按每个实例来存储的。 所有线程都使用与CPUTFrustum相同的实例,因此写入位置是共享的,我们找到了我们的罪魁祸首。现在,在多线程的情况下,这些计数器不会产生正确的值,因为正常的C 增量不是原子操作。正如我之前提到的,这些计数器只是在调试(或至少看起来没有其他作用),所以我们也可以完全删除这两个增量。
那么这在多大程度上有助于摆脱这两个增量呢?
再次,这两个运行的长度有一些不同(因为我在加载结束之后手动启动/停止它们后),所以我们不能直接比较循环计数,但我们可以比较比率。 CPUTFrustum :: IsVisible过去与我们的第一位的函数相比要花费大约60%的时间,并且排在第二位。现在位于前十名中的第5位,占我们主要耗时函数的时间的32%。 换句话说,删除这两个增量只是使我们的表现提高了一倍 -这在一个做了大量的其他工作的函数。在一些功能更少的函数中,它可以发挥更大的作用。
就像我们在合并写入中看到的一样,这种错误很容易犯,也难以追踪,可能导致严重的性能和可扩展性问题。我所知道的那些认真使用线程的人至少已经陷入过这个陷阱 - 把它当成你通过使用线程考试的仪式。
无论如何,这个函数现在运行顺利,没有导致任何主要的延迟,实际上完全受到后端执行时间的约束 - 也就是说,这个函数的昂贵部分现在确实是实际的计算工作。正如TODO的意见所提到的那样,有更好的方法来解决这个问题。我不会在这里做这个优化,因为我在两年多前已经写了一篇关于使用SIMD指令来解决这个问题的有效方法的文章 - 使用Cell SPE内在函数,而不是SSE内在函数,但是想法还是一样的。
我不会打扰这里的代码 -一切都在GitHub上了,如果你想仔细查阅的话。但是这里可以说的是,随着共享瓶颈的消失,IsVisible的确可以做得更快。在最后一个分析文件中(使用SSE),它出现在前20名的第十九位。
向前两步,向后一步
然而并没有搞好一切,因为方法AABBoxRasterizerSSEMT ::IsInsideViewFrustum,你可以(几乎没有)看到在一些早期的分析文件中,突然变得很慢:
再次,我不会在这里深入挖掘这个问题,但事实证明这是去调用IsVisible的函数。不,这不是你现在可能会想的那样 - IsVisible没有内联或任何类似的东西。 实际上,它的代码看起来和之前完全一样。更重要的是,问题实际上并不在AABBoxRasterizerSSEMT :: IsInsideViewFrustum中,它位于TransformedAABBoxSSE :: IsInsideViewFrustum函数的内部,供它调用,并且它被内联到AABBoxRasterizerSSEMT :: IsInsideViewFrustum函数中:
1 2 3 4 5 6 7 8 | void TransformedAABBoxSSE::IsInsideViewFrustum(CPUTCamera *pCamera) { float3 mBBCenterWS; float3 mBBHalfWS; mpCPUTModel->GetBoundsWorldSpace(&mBBCenterWS, &mBBHalfWS); mInsideViewFrustum = pCamera->mFrustum.IsVisible(mBBCenterWS, mBBHalfWS); } |
这里也不用冥思苦想-一个getter调用会去检索包围盒的中心和半区,然后调用IsVisible函数。不,没有涉及到代码实质性变化,并且在GetBoundsWorldSpace函数中没有发生什么奇怪的事情。它不是一个虚拟的调用,并且得到正确的内联。它所做的只是将6个浮点值从mpCPUTModel函数复制到堆栈。
然而,我们在这种方法中所做的工作会在复制期间导致很多L3高速缓存未命中(或最后一级缓存未命中/ LLC未命中,这是英特尔喜欢称呼它们的方式)。 现在,代码的缓存未命中,不会比我添加一些SSE代码到IsVisible之前更多。 但是它比以前更快地生成它们。之前,一些长时间的内存提取与速度较慢较早的盒体可见性测试执行相互重叠。现在,我们正在快速完成指令,让代码等待包围盒到达那里。
这就是我们处理无序内核的方式:他们真的很善于利用不好的情况尽量做到最好。这也意味着,通常,修复性能问题只是立即将瓶颈移到其他地方,而没有任何实质的加速。 通常需要几次尝试逐一解决各种瓶颈,直到最后你才能有效的提高效率。 为了让这个执行更快,我们必须提高缓存使用率。而提高缓存使用率是另一个帖子的主题。 让我们在下一篇文章中再见!
【版权声明】
原文作者未做权利声明,视为共享知识产权进入公共领域,自动获得授权。