F# 中多核并行中缓存局部性的最佳实践
Posted
技术标签:
【中文标题】F# 中多核并行中缓存局部性的最佳实践【英文标题】:Best Practices for cache locality in Multicore Parallelism in F# 【发布时间】:2011-09-05 08:52:43 【问题描述】:我正在研究 F# 中的多核并行性。我不得不承认,不变性确实有助于编写正确的并行实现。然而,当核心数量增加时,很难实现良好的加速和良好的可扩展性。例如,我对快速排序算法的经验是,许多尝试以纯函数方式实现并行快速排序并使用List
或Array
作为表示都失败了。分析这些实现表明,与顺序版本相比,缓存未命中的数量显着增加。但是,如果使用数组内部的变异来实现并行快速排序,则可以获得良好的加速。因此,我认为变异可能是优化多核并行性的好习惯。
我相信cache locality 是函数式语言中多核并行的一大障碍。函数式编程涉及创建许多短期对象;破坏这些对象可能会破坏 CPU 缓存的一致性属性。我已经看到很多关于如何在命令式语言中提高缓存局部性的建议,例如here 和here。但我不清楚它们将如何在函数式编程中完成,尤其是对于经常出现的递归数据结构,如树等。
是否有任何技术可以在不纯的函数式语言(特别是 F#)中提高缓存局部性?任何建议或代码示例都非常受欢迎。
【问题讨论】:
如果您的问题确实针对不纯的函数式语言,那么答案将与任何过程语言(例如 C#)的答案相同。 不一样。您可以从一个纯粹的实现开始,然后使用突变对其进行优化;我认为这个想法与命令式方法有根本的不同。 【参考方案1】:据我所知,缓存局部性(多线程或其他)的关键是
将工作单元保存在适合缓存的连续 RAM 块中为此;
尽可能避免使用物体 对象是在堆上分配的,并且可能会散布到各处,具体取决于堆碎片等。 您对对象的内存位置的控制基本上为零,以至于 GC 可能随时移动它们。 使用数组。大多数编译器将数组解释为连续的内存块。 其他集合数据类型可能会将事物分布在各处 - 例如,链表由指针组成。 使用原始类型的数组。对象类型是在堆上分配的,因此对象数组只是一个指向对象的指针数组,这些对象可能分布在整个堆中。 如果不能使用原语,请使用结构数组。结构的字段在内存中按顺序排列,并被 .NET 编译器视为基元。 计算出要执行缓存的机器上的缓存大小 CPU 具有不同大小的二级缓存 谨慎的做法是设计您的代码以适应不同的缓存大小 或者更简单地说,编写适合运行代码的最小公共缓存大小的代码 找出靠近每个基准的位置 实际上,您不会将整个工作集放入 L2 缓存中 检查(或重新设计)您的算法,以便您使用的数据结构将“下一个”需要的数据与之前需要的数据保持接近。在实践中,这意味着您最终可能会使用在理论上不是计算机科学完美示例的数据结构 - 但没关系,计算机在理论上也不是计算机科学的完美示例。
关于该主题的一篇好的学术论文是Cache-Efficient String Sorting Using Copying
【讨论】:
我很惊讶没有人支持这篇文章。您的回答很好,因为它提到了许多关于托管环境中缓存位置的优点。我喜欢你关于理论上不完美的数据结构的想法,我的经验表明,有时它们在实践中是最好的。【参考方案2】:在 F# 中允许函数内的可变性是一件好事,但它只应在优化代码时使用。纯函数式风格通常会产生更直观的实现,因此是首选。
快速搜索返回的结果如下:Parallel Quicksort in Haskell。让我们把关于性能的讨论集中在性能上。选择一个处理器,然后使用特定算法对其进行基准测试。
为了回答您的问题,我想说Clojure 的实现STM 的方法在一般情况下可能是关于如何解耦多核处理器上的执行路径和改善缓存局部性的一个教训。但它只有在读取次数超过写入次数时才有效。
【讨论】:
Haskell 链接实际上与缓存局部性没有任何关系,也是一篇颇有偏见的文章。 Haskell 中的惯用数组排序可以通过例如更好地实现。 vector-algorithms 包。 @Don Stewart:您的链接与使用并行计算进行排序的努力有什么关系?你能否指出是什么让你觉得链接的博客条目有偏见? 我同意,但我觉得那里有一个很好的 OP 起点。顺便说一句,John Harrop 的书非常出色,所以我每天都会使用他的博客作为可靠的参考。 amazon.com/F-Scientists-Jon-Harrop/dp/0470242116 @pad:Haskell 正在尝试构建自己的运行时版本,而 F# 则依赖于现有的 IL 技术。由于 JITter 中有一些深奥的技巧,这里有但在那里没有,最终游戏完全有可能在另一个方面更快。我的偏好仍然是仍然可读的优化。我认为在 F# 中更容易完成。 “纯功能风格通常会产生更直观的实现,因此是首选”。有时但并非总是如此。在许多情况下没有显着差异,例如线性代数,状态机。在某些情况下,没有已知的直观纯解决方案,例如图算法,类型推断。【参考方案3】:我不是并行性专家,但无论如何这里是我的建议。
-
我认为,为每个内核分配一个可读写的内存区域的本地可变方法总是优于纯方法。
尝试制定算法,使其在连续的内存区域上按顺序运行。这意味着,如果您正在使用图形,则可能值得将节点“展平”为数组并在处理之前用索引替换引用。无论缓存位置问题如何,这始终是 .NET 中的一种很好的优化技术,因为它有助于避免垃圾收集。
【讨论】:
“可能值得将节点“展平”到数组中,并在处理之前用索引替换引用”。又名避免拳击。【参考方案4】:一个很好的方法是将工作分成更小的部分,并在每个核心上迭代每个部分。
我开始的一个选择是在并行之前在单个内核上寻找缓存位置改进,这应该只是为每个内核再次细分工作的问题。例如,如果您正在使用大型矩阵进行矩阵计算,那么您可以将计算分成更小的部分。
这是一个很好的例子:Cache Locality For Performance
Tomas Petricek 的书真正的函数式编程中有一些很棒的部分,请查看第 14 章编写并行函数式程序,您可能会发现二叉树的并行处理特别感兴趣。
【讨论】:
我读过这一章,在二叉树中并行处理的想法很有吸引力,但在我看来它仍然对缓存局部性很敏感。【参考方案5】:编写可扩展的应用程序缓存位置对于您的应用程序速度至关重要。 Scott Meyerstalk 很好地解释了这些原则。由于您在内存中创建新对象,这会迫使 CPU 再次从新对象重新加载数据,因此不可变性在缓存局部性方面表现不佳。 正如谈话中提到的那样,即使在现代 CPU 上,L1 缓存也只有 32 KB 大小,用于在所有内核之间共享代码和数据。如果您使用多线程,您应该尝试消耗尽可能少的内存(再见不变性)以保持在最快的缓存中。 L2 缓存大约为 4-8 MB,与您尝试排序的数据相比,这要大得多,但仍然很小。
如果您设法编写一个消耗尽可能少内存的应用程序(数据缓存局部性),您可以获得 20 或更多的加速。但是,如果您为 1 个核心进行管理,那么扩展到更多核心可能会损害性能,因为所有核心都在竞争相同的 L2 缓存。
为了充分利用它,C++ 人员使用 PGA(Profile Guided Optimizations),它允许他们分析他们的应用程序,该应用程序用作编译器的输入数据,以便针对特定用例发出更好的优化代码。
在托管代码中您可以在一定程度上变得更好,但由于有很多因素会影响您的缓存位置,因此由于总缓存位置,您不太可能在现实世界中看到 20 的加速。这仍然是 C++ 和使用分析数据的编译器的制度。
【讨论】:
感谢您的链接。虽然您的帖子没有直接回答我的问题,但它对一般的缓存感知编程很有帮助。【参考方案6】:您可能会从中得到一些想法:
缓存遗忘http://supertech.csail.mit.edu/cacheObliviousBTree.html Cache-Oblivious Search Trees Project
DSapce@MIT Cache coherence strategies in a many-core processor http://dspace.mit.edu/handle/1721.1/61276
describes the revolutionary idea of cache oblivious algorithms via the elegant and efficient implementation of a matrix multiply in F#.
【讨论】:
感谢您的链接。其实我读过其中的一些。它们与 F# 的习语相去甚远。关于 F# 中缓存遗忘矩阵乘法的帖子使用了数组变异,我正在寻找其他的想法。 @pad “他们离 F# 的习语还很远”。我不同意。 @JonHarrop:我知道缓存遗忘算法是最先进的。我只是想看看在 F# 中完成的一些事情。 @pad “缓存遗忘算法是最先进的”。它们最初在 Harold Prokop 1999 年的开创性硕士论文中有所描述。以上是关于F# 中多核并行中缓存局部性的最佳实践的主要内容,如果未能解决你的问题,请参考以下文章
ehcache 或 Spring MVC 的 Spring 缓存中的最佳缓存实践是啥?