C ++中的显式代码并行性

时间:2020-03-06 14:48:42  来源:igfitidea点击:

CPU中的乱序执行意味着CPU可以对指令重新排序以获得更好的性能,这意味着CPU必须做一些非常漂亮的簿记工作。还有其他处理器方法,例如超线程。

一些奇特的编译器在有限的程度上理解了指令的(非)相互关联性,并将自动交织指令流(可能在比CPU看到的更长的窗口中)以更好地利用处理器。浮点和整数指令的故意编译时交织是这种情况的另一个示例。

现在我有高度并行的任务。而且我通常会使用不带超线程的老化单核x86处理器。

是否有一种简单的方法可以使我的" for"循环的主体与这个高度并行的任务交错以使两个(或者多个)迭代一起完成? (据我所知,这与"循环展开"略有不同。)

我的任务是运行一组指令的"虚拟机",为简化说明,我将其简化为:

void run(int num) {
  for(int n=0; n<num; n++) {
     vm_t data(n);
     for(int i=0; i<data.len(); i++) {
        data.insn(i).parse();
        data.insn(i).eval();
     }
  }  
}

因此,执行跟踪可能如下所示:

data(1) insn(0) parse
data(1) insn(0) eval
data(1) insn(1) parse
...
data(2) insn(1) eval
data(2) insn(2) parse
data(2) insn(2) eval

现在,我想要的是能够并行地显式执行两个(或者更多)迭代:

data(1) insn(0) parse
data(2) insn(0) parse  \ processor can do OOO as these two flow in
data(1) insn(0) eval   /
data(2) insn(0) eval   \ OOO opportunity here too
data(1) insn(1) parse  /
data(2) insn(1) parse

我通过剖析(例如,将Callgrind与--simulate-cache = yes一起使用)知道,解析是关于随机内存访问(缓存丢失),而eval是关于在寄存器中执行操作,然后写回结果。每一步的长度为数千条指令。因此,如果我可以一次将两个步骤混合进行两次迭代,那么在解析步骤发生缓存未命中的情况下,处理器有望有所作为...

是否有某种C ++模板疯狂来获取这种显式并行性?

当然,我可以在代码中进行交错甚至交错处理,但是这样会使代码的可读性大大降低。如果我真的很想读不懂,那么我可以做为汇编器!但是可以肯定的是,这种事情有某种模式吗?

解决方案

最好的计划可能是研究OpenMP。它基本上允许我们在代码中插入"编译指示",以告诉编译器如何在处理器之间进行拆分。

与指令重新排序相比,超线程是更高级别的系统。对于操作系统,它使处理器看起来像两个处理器,因此我们需要使用实际的线程库来利用它。同样的事情自然适用于多核处理器。

如果我们不想使用低级线程库,而是想使用基于任务的并行系统(听起来就是我们要这样做的话),建议我们使用OpenMP或者英特尔的线程构建模块。

TBB是一个库,因此可以与任何现代C ++编译器一起使用。 OpenMP是一组编译器扩展,因此我们需要一个支持它的编译器。 GCC / G ++将从版本4.2及更高版本开始。 Intel和Microsoft编译器的最新版本也支持它。我不知道其他任何人。

编辑:另一注。使用TBB或者OpenMP之类的系统将尽可能地扩展处理,即,如果我们要处理100个对象,则在两核系统25/25/25/25中它们将被拆分为大约50/50。在四核系统中,等等。

诸如Core 2之类的现代处理器具有巨大的指令重排序缓冲区,其数量接近100条指令。即使编译器很笨,CPU仍然可以弥补它。

主要问题是代码是否使用了很多寄存器,在这种情况下,即使理论上可以并行执行,寄存器压力也可能迫使代码按顺序执行。

当前的C ++标准不支持并行执行。该标准将在明年左右发布的下一个版本中更改。

但是,我看不到我们要完成的工作。我们是指一个单核处理器,还是多个处理器或者核?如果只有一个内核,则应该执行使高速缓存未命中最少的操作,这意味着无论哪种方法都使用最小的内存工作集。这可能是在进行所有解析之后进行所有评估,或者是交替进行解析和评估。

如果我们有两个核心,并且想要有效地使用它们,则将不得不使用特别聪明的编译器或者语言扩展。我们要开发一个特定的操作系统,还是应该用于多个系统?

听起来我们遇到了芯片设计人员面临的同样问题:执行一条指令需要花费很多精力,但是它涉及许多不同的步骤,这些步骤可以在执行流水线中串在一起。 (当我们可以从单独的硬件模块中构建事物时,并行执行事物会更容易。)

最明显的方法是将每个任务划分为不同的线程。我们可能想要创建一个线程来执行每条指令以完成操作,或者为两个执行步骤中的每个步骤创建一个线程并在它们之间传递数据。无论哪种情况,我们都必须非常谨慎地了解如何在线程之间共享数据,并确保处理一条指令影响下一条指令的结果的情况。即使我们只有一个核心并且在任何给定时间只能运行一个线程,操作系统也应该能够调度计算密集型线程,而其他线程正在等待其高速缓存未命中。

(我们可能需要花费几个小时的时间来购买一台非常快的计算机,但是如果我们尝试将其广泛部署在廉价的硬件上,则可以按照自己的看法来考虑问题。一个值得考虑的有趣问题。)

给定优化的编译器和流水线处理器,我建议我们只编写清晰易读的代码。

看一下cilk。它是对ANSI C的扩展,具有用于在C中编写并行化代码的一些不错的结构。但是,由于它是C的扩展,因此对编译器的支持非常有限,并且使用起来很棘手。

编写此答案是假设问题不包含"并且我通常具有老化的单核x86处理器而没有超线程"。我希望它对希望并行处理高度并行任务但针对双/多核CPU的其他人有所帮助。

正如已经在另一个答案中发布的那样,OpenMP是一种可移植的方法。但是我的经验是OpenMP的开销很高,并且很容易被击败
推出DIY(自己动手)实施。希望OpenMP会随着时间的推移而有所改进,但是就目前而言,除原型设计之外,我不建议将其用于其他任何用途。

鉴于任务的性质,我们想要做的很可能是基于数据的并行性,以我的经验,这很容易,编程风格可以与单核代码非常相似,因为我们知道其他线程在做什么,这使维护线程安全性变得容易得多,一种对我有用的方法:避免依赖关系,并从循环中仅调用线程安全函数。

要创建DYI OpenMP并行循环,我们需要:

  • 作为准备,请创建一个串行for循环模板,并更改代码以使用函子来实现循环主体。这可能很乏味,因为我们需要跨函子对象传递所有引用
  • 为函子创建虚拟JobItem接口,并从该接口继承函子
  • 创建一个能够处理单个JobItems对象的线程函数
  • 使用此线程函数创建线程的线程池
  • 试用各种同步原语,看看哪种最适合我们。尽管信号量非常易于使用,但其开销却非常可观,如果循环体非常短,则我们不希望为每次循环迭代付出此开销。对我而言最有效的是将手动重置事件+原子(互锁)计数器结合在一起,这是一种更快的选择。
  • 试验各种JobItem调度策略。如果循环时间足够长,则每个线程一次拾取多个连续的JobItem更好。这减少了同步开销,同时使线程对缓存更友好。我们可能还希望以某种动态的方式执行此操作,以减少执行任务时的调度序列长度,或者让单个线程从其他线程调度中窃取项目。