如何并行化小纯函数?

时间:2009-02-24 00:28:09

标签: performance multithreading optimization parallel-processing d

我有D2程序,在其当前形式中,是单线程的,并且对于该程序的外部循环的每次迭代,在内部循环中调用相同的纯函数大约10到100次。呼叫之间没有数据依赖性,即没有呼叫使用来自任何其他呼叫的结果。总的来说,这个功能被称为数百万次,是我程序中的主要瓶颈。这些参数几乎每次都是唯一的,因此缓存无济于事。

乍一看,这似乎是并行化的完美候选者。唯一的问题是该函数每次调用只需要大约3微秒,远低于创建新线程的延迟,并且远远高于将任务添加到任务池的开销(意味着,获取互斥锁,分配内存到保存有关任务的信息,处理可能的任务池队列争用等)。有没有什么好方法可以利用这种细粒度的并行性?

8 个答案:

答案 0 :(得分:3)

如何创建具有自己的队列的多个线程呢?因为队列没有重叠,所以你不应该创建锁。

答案 1 :(得分:3)

不要启动每个线程以运行单个任务,然后将其关闭。

在程序开始时,为每个核心创建一个线程,等待队列中的数据(管道或您自己创建的某种机制)。如果你能想出一个所有线程在同一队列上等待的机制,那就更好了,但是队列的get方法必须同步......

每当你要计算几百或几千个进程的块时,将整个块放入下一个空队列。

实际上,您最终会有一个或多个线程为队列提供数据,一组线程处理来自队列的数据,以及一个或多个读取和处理结果的线程。

您可能需要在正在处理的“项目”中放入足够的数据,以便在完成后能够告诉您如何处理这些数据。它们几乎肯定是一个对象,你可能希望它们包含状态信息。

你可能不希望有更多的线程在进行处理而不是核心。

编辑:还要查看一些并发库,例如ThreadPoolExecutor。很容易忘记并发库(就像我刚才那样),这可能正是你所寻找的(因此强调)

答案 2 :(得分:2)

如上所述,每次进入此功能时都不要启动线程,而且“作业”粒度大于内部函数的一个操作,这样就可以很好地分摊作业创建开销。将您的原始例程描述为:

void OuterFunction( Thingy inputData[N] )
{
  for ( int i = 0 ; i < N ; ++i )
    InnerFunction( inputData[i] );
}

我们通过(假设存在作业队列系统)来解决您的问题:

void JobFunc( Thingy inputData[], int start, int stop )
{
  for ( int i = start ; i < stop ; ++i )
    InnerFunction( inputData[i] );  
}
void OuterFunction( Thingy inputData[N], int numCores )
{
   int perCore = N / numCores; // assuming N%numCores=0 
                               // (omitting edge case for clarity)
   for ( int c = 0 ; c < numCores ; ++c )
     QueueJob( JobFunc, inputData, c * perCore, (c + 1) * perCore );
}

只要您的输入数据完全独立,正如您在原始问题中所说的那样,您无需锁定它;只有当线程之间存在依赖关系时才需要同步,这里没有。

此外,在这种性能水平下,微优化开始变得相关:最重要的是,缓存局部性。预取可以让你有一个令人惊讶的漫长道路。

然后考虑SIMD的可能性,您可以将其矢量化以同时通过单个寄存器运行四个输入点。有了4个内核和4个宽的SIMD,你理论上可以 获得16倍的加速,但这假设InnerFunction正在做的工作主要是一个固定的数学函数,因为分支往往会消除SSE / VMX的性能提升。

答案 3 :(得分:2)

这是一个多么有趣的问题......正如您所指出的那样,您将无法承担与传统锁定相关的工作队列的开销。如果可以的话,我建议您尝试使用现有的基于细粒度任务的编程环境之一...我在三个工作桶中考虑这个问题:

问题的第一部分是确保安全性,正确性和可并行性,听起来你已经覆盖了因为你的功能是纯粹的。

我认为下一个最具挑战性的部分是描述并发性,特别是你提到这个函数被多次调用。你可以管道这个并将函数从其工作中分开安排吗?如果你不能管道这个,它看起来像一个并行循环,树遍历还是比这更无结构化。具体来说,obeying Amdahl如果您不能重叠工作并确保有多个实例或其他同时运行的东西,即使您是纯粹的,您仍然是有效的。您可以做任何将工作重构为管道,递归树遍历(或并行循环)的任何事情,或者如果您必须在任务之间使用明确的依赖关系进行更多非结构化工作,那么无论使用哪个库,这都将有所帮助。

我想到的最后一个方面是确保在您的平台上有效执行,这包括减少代码和调度代码中的开销和争用,并确保任何串行代码尽可能高效。如果您不能使用现有的库之一并且必须自己构建库,我建议您查看work-stealing queue和自引导调度算法,因为您已经注意到您将无法看到使用传统锁定会获得收益,因为它们的成本超过了您的功能成本,您很可能需要查看无锁技术,以降低调度成本并将任务移除到您使用的任何队列上。您还需要在调度算法和函数内共同注意共享和争用,因为除了通常的分支错误预测和指令吞吐量问题之外,在这种粒度级别,您还需要查看在shared state and contention even on reads because they can be sources of contention too

如果这不是特定的,我很抱歉,但我希望它很有用。

答案 4 :(得分:1)

根据程序的结构,您始终可以将一组调用合并为一个任务。如果每个任务执行50次函数调用,则任务管理的开销不再是一个很大的因素。

答案 5 :(得分:1)

这听起来像是SIMD指令可以提供帮助的地方。如果你有一个自动矢量化编译器,你应该能够重写该函数同时对4个值进行操作,编译器可以将其压缩到适当的SSE指令中。这可以帮助减少函数调用开销。如果您的编译器不擅长自动向量化代码,那么您可以使用SSE内在函数来几乎达到汇编级别来编写函数体。

答案 6 :(得分:0)

你可以使用Compare-and-Swap将循环内部转出来获得一个无原子锁的增量:

void OuterFunction()
{
  for(int i = 0; i < N; i++)
    InnerFunction(i);
}

转到:

void OuterFunction()
{
   int i = 0, j = 0;

   void Go()
   {
      int k;
      while((k = atomicInc(*i)) < N)
      {
         InnerFunction(k);

         atomicInc(*j);
      }
   }

   for(int t = 0; t < ThreadCount - 1; t++) Thread.Start(&Go);

   Go(); // join in

   while(j < N) Wait(); // let everyone else catch up.
}

编辑:我的线程生锈,因此无法编译,因为名称都错了

答案 7 :(得分:0)

  

呼叫之间没有数据依赖性,即没有呼叫使用来自任何其他呼叫的结果。

这将有助于并行化,但绝对肯定该功能根本没有副作用。如果函数正在更新数据结构,它是否是线程安全的?如果它正在进行IO,如果你设法并行执行该功能,那么IO会不会成为瓶颈?

如果对这些问题的回答是肯定的,那么以前的建议很好,只是尝试通过将每个线程尽可能多的函数执行分配来最大化应用程序的粒度。

尽管如此,你可能不会从大规模的并行性中获得任何好处,但可能会有一些更适度的加速......