F#中多核并行中缓存局部性的最佳实践

时间:2011-05-31 10:03:51

标签: asynchronous f# functional-programming parallel-processing multicore

我正在研究F#中的多核并行性。我必须承认,不可变性确实有助于编写正确的并行实现。但是,当核心数量增长时,很难实现良好的加速和良好的可扩展性。例如,我使用快速排序算法的经验是,许多尝试以纯函数方式实现并行快速排序并使用ListArray作为表示失败。对这些实现进行分析表明,与顺序版本相比,缓存未命中数量显着增加。但是,如果使用阵列内部的突变实现并行快速排序,则可以获得良好的加速。因此,我认为突变可能是优化多核并行性的一种很好的实践。

我认为cache locality是功能语言中多核并行的一大障碍。函数式编程涉及创建许多短期对象;破坏这些对象可能会破坏CPU缓存的一致性。我已经看到很多建议如何改进命令式语言中的缓存局部性,例如herehere。但是我不清楚如何在函数式编程中完成它们,特别是对于常常出现的递归数据结构,例如树等等。

是否有任何技术可以改进不纯函数语言(特别是F#)中的缓存局部性?任何建议或代码示例都非常受欢迎。

6 个答案:

答案 0 :(得分:24)

据我所知,缓存局部性(多线程或其他)的关键是

  • 将工作单元保存在适合缓存的连续RAM块中

为此目的;

  • 尽可能避免使用对象
    • 对象在堆上分配,可能会在整个地方喷洒,具体取决于堆碎片等。
    • 您可以基本上无法控制对象的内存位置,以便GC可以随时移动它们。
  • 使用数组。大多数编译器将数组解释为连续的内存块。
    • 其他集合数据类型可能会在所有地方分发内容 - 例如,链接列表由指针组成。
  • 使用基元类型的数组。对象类型在堆上分配,因此对象数组只是指向可遍布堆的对象的指针数组。
  • 如果不能使用基元,请使用结构数组。结构的字段按顺序排列在内存中,并由.NET编译器视为基元。
  • 在您要执行的计算机上计算缓存的大小
    • CPU具有不同大小的L2缓存
    • 设计代码以使用不同的缓存大小进行扩展可能是明智的
    • 或者更简单地说,编写适合代码运行的最低公共缓存大小的代码
  • 找出需要靠近每个数据的内容
    • 在实践中,您不会将整个工作集放入L2缓存
    • 检查(或重新设计)您的算法,以便您使用的数据结构保存“下一步”所需的数据,接近以前需要的数据。

在实践中,这意味着你可能最终使用的数据结构在理论上并不是计算机科学的完美例子 - 但没关系,计算机在理论上也不是计算机科学的完美例子。

关于这个问题的一篇好的学术论文是Cache-Efficient String Sorting Using Copying

答案 1 :(得分:3)

在F#中允许函数内的可变性是一种祝福,但它只应在优化代码时使用。纯粹的功能风格通常会产生更直观的实现,因此是首选。

以下是快速搜索返回的内容:Parallel Quicksort in Haskell。让我们继续讨论关于性能的性能。选择一个处理器,然后使用特定的算法进行调整。

为了回答您的问题而没有具体细节,我会说Clojure实施STM的方法可能是关于如何在多核处理器上解耦执行路径和改进缓存的一般案例局部性。但它只有在读数超过写入次数时才有效。

答案 2 :(得分:3)

我不是并行专家,但无论如何,这是我的建议。

  1. 我希望本地可变的方法,每个核心被分配一个内存区域,既可以读取也可以写入。
  2. 尝试制定算法,使其在连续的内存区域上顺序工作。这意味着如果您正在处理图形,则可能值得将节点“展平”为数组并在处理之前用索引替换引用。无论缓存局部性问题如何,这在.NET中始终是一种很好的优化技术,因为它有助于防止垃圾收集。

答案 3 :(得分:3)

一种很好的方法是将工作分成更小的部分,并迭代每个核心上的每个部分。

我开始的一个选择是在并行之前在单个核心上查找缓存局部性改进,它应该只是为每个核心再次细分工作。例如,如果您使用大型矩阵进行矩阵计算,则可以将计算分成更小的部分。

这是一个很好的例子:Cache Locality For Performance

Tomas Petricek的书中有一些很棒的部分实际功能编程,查看第14章编写并行功能程序,您可能会发现特别感兴趣的二叉树的并行处理。

答案 4 :(得分:2)

编写可扩展的应用缓存位置对于您的应用程序速度至关重要。 Scott Meyers谈话很好地解释了这些原则。由于您在内存中创建新对象,迫使CPU再次从新对象重新加载数据,因此不变性在缓存局部性方面不能很好地发挥作用。 正如在现代CPU中所说的那样,L1缓存只有32 KB大小,所有内核之间的代码和数据共享。如果你使用多线程,你应该尝试尽可能少地使用内存(再见不可变性)来保持最快的缓存。 L2缓存大约是4-8 MB,与您尝试排序的数据相比,它要大得多,但仍然很小。

如果您设法编写一个消耗尽可能少的内存(数据缓存位置)的应用程序,您可以获得20或更多的加速。但是如果你为1核处理这个问题,那么扩展到更多内核可能会损害性能,因为所有内核都在竞争相同的L2缓存。

为了充分利用它,C ++人员使用PGA(配置文件引导优化),允许他们分析他们的应用程序,该应用程序用作编译器的输入数据,以便为特定用例发出更好的优化代码。

在托管代码中,您可以在某种程度上获得更好的效果,但由于影响您的缓存局部性的因素很多,因此总体缓存局部性不太可能使您在现实世界中看到20的加速。这仍然是使用分析数据的C ++和编译器的制度。

答案 5 :(得分:1)