在关于深入挖掘性能的演讲中,Eric Brumer解释了为什么内存往往是最关键的部件。尽管他探讨的是C++开发,但是很多建议都可以应用于托管代码。
在Intel的Sandy Bridge架构中,在4核心的CPU上,一个核心有6条流水线,也就是每个周期内可以处理多达6条指令。每个核心内还有64kB的L1-cache和256 kB的L2-cache,用作指令和/或数据的高速缓存。L3-cache为4个核心共享,根据模型的不同,大小从8MB到20MB不等。
从L1-cache中读取1个32位长的整数需要4个指令周期,从L2-cache中读取则需要12个指令周期,计算一个数的平方根也需要这么多时间。L3-cache则需要两倍多的时间——26个指令周期。从内存中读取更是相当相当长了。
在开始讨论数据访问模式之前,Eric先拿出了这个空间局部性(spatial locality)很差的矩阵乘法的经典例子,并给出了其解决方案:
在比较强大的系统上,交换循环变量j和k就能带来10倍的性能改进。在低功率的桌面计算机上,性能改进高达18倍。需要重申的是,实际上主体代码并未改变,我们只不过修改了一下循环变量的顺序。
另请注意,Visual Studio 2013的发布版本有望识别并修正这类例子。这需要改进数据依赖分析引擎,不过前几天发布的预览版本还没有做好准备。
假设有两个数组,每个数组中都保存着400万个float值,然后将它们按元素相加,保存到第3个数组中(即c[i] = a[i] + b[i]; i = 0..3 999 999)。Eric在搭载了40个核心的处理器上进行了测试。理论上,该计算很容易扩展,可以并行地将其放到5个核心上,速度就能提升到原来的5倍。但是Eric发现速度只提升到了原来的1.6倍(扩展率是32%)。当所用核心数增加到10个时,速度是原来的2.4倍,扩展率只有24%了。
回顾实验结果,他发现所有的时间都花在了内存装载上。三个数组总共有48MB,超过了10核心CPU所提供的30 MB的L3-cache。将负载放到两个CPU上,并将计算并行地放到20个核心上,L3-cache达到了60MB。这次速度提升到原来的18倍,扩展率为90%。
Eric继续说道,实际上核心的数目无关紧要,起作用的其实是高速缓存的大小。他解释说,这个例子有点极端。对大多数程序而言,当相关的线程运行在同一CPU上时速度会更快,这是因为它们更容易共享高速缓存。但是这个例子和我们的想象完全相反,标准并行库带来了不利的影响。
在下面的例子中,Eric演示了一个粒子物理问题,该问题中有4096个元素相互作用,粒子之间的引力可以按照万有引力公式计算。使用标准的循环程序来计算,每秒可以运行8帧。利用Visual C++的自动向量化和128位SSE2指令,循环迭代次数从4096减少到1024,帧率提升到18。256位的AVK指令则可将迭代次数减少到512,但帧率只提升到23。
后来发现,原来是数据组织的问题。对每个粒子而言,其位置数据在给定的一帧内不会变化,还与要计算的加速数据混到了一起。要装载保存了8个粒子的X坐标的 256位YMM寄存器,需要执行9条独立指令。装载Y坐标和Z坐标还需要18条指令。
重写这段代码使X、Y和Z三个坐标分别保存到一个单独的连续数组中,这超出了我们的研究范围。因此Eric进行了简化,他把它们复制到3个临时数组中,运行主循环的一个修改版本,然后将数据复制了回去。
仍然使用256位的AVK指令,这些额外的内存复制实际上改进了性能,帧率达到42。应该注意的是,这一改进——即帧率从8提升到42——是在一台搭载单核处理器的笔记本上实现的。
下一个问题是内存对齐。在L1-cache中,每一行长64字节。如果数据结构的长度都是缓存行长度的因子(比如长32、16、8或4字节),那就可以直接将其放入缓存行中,而不会浪费空间。不过这要求数据结构按照64字节对齐。
回到前面的例子,Eric修改了一下代码,跳过数组的第一个元素,强制进行非对齐访问。结果与j从0开始相比,性能损失了8%。
按照C语言标准,malloc返回的数据只需要按8字节对齐。可以使用_aligned_malloc获得按32字节或64字节对齐的内存。我们正在进行相关开发工作,希望数据用于循环体之内时,编译器自动调用_aligned_malloc,但是这也存在增加内存碎片的风险。我们希望在VS 2013 Update 1版本中提供些相关的新特性。
如果要更详细地了解这些内容,并要学习与内存有关的其他性能问题,可以观看Build 2013的视频:“Native Code Performance and Memory: The Elephant in the CPU”。
查看英文原文:Memory and Native Code Performance