2.1.3 期望为何
默认情况下,循环的并行度(即硬件能同时运行多少次迭代)取决于处理器中可用的内核数量。可用的内核越多循环便运行得越快,这种趋势可以一直持续到我们遇上阿姆达定律所预言的那个收益递减点。从那之后,循环究竟能运行得多快就取决于该循环本身的工作内容了(请参阅第1章关于阿姆达定律的讨论)。
内核的增加能使循环运行得更快,但这也总会有个上限。
为循环体选择正确的粒度是很有必要的,因为有太多过小的并行循环因其工作量过度分解而导致其多核优势被循环本身的开销抵消殆尽。
在parallel_for或parallel_for_each的执行过程中,如果某个迭代操作抛出了异常(exception),该异常将会被上抛至该函数所调用的线程处进行处理。在2.3节中,我们将会更详细地讲解并行循环中的异常处理问题。
强健的异常处理机制是并行循环处理的重要组成部分之一。
在用并行循环替换了串行循环之后,如果你发现程序不能按照自己所预期的方式执行,那么,最大的可能就是该循环步骤之间并非是彼此独立的。下面,我们将会介绍一些常见的依赖性循环体(dependent loop body)注4,以你供参考:
请仔细检查循环迭代之间是否存在某种依赖关系。因为到目前为止,对这种依赖性的疏忽是我们在使用并行循环时最常见错误。
对共享变量执行写操作:如果循环体内对共享变量执行了写操作,那么该循环体就是具有依赖性的。这在我们需要执行某些聚合求值操作时很常见。下面这个示例中的total就是被所有迭代体共同操作的变量。
- for(int i = 1; i < n; i++)
- total += data[i];
如果真的遇上了类似的情况,建议你参考第4章中的并行聚合模式。
当然,共享变量的形式是多样化的。任何在循环体外部声明的变量都有可能是共享变量。例如,如果我们对一些类似于类或数组(array)这样的类型对象使用某种共享性引用的话,这些对象中的所有字段、元素都有可能会被隐式地转换为共享变量;另外,通过引用或指针方式传递的函数参数也会导致变量共享;同样,在lambda表达式中使用引用方式捕捉的变量也一样会如此。
使用对象模型的数据访问器(data accessor):如果循环体是通过数据访问器来处理一个对象的话,我们就有必要了解一下这些访问器,看看其中是否涉及了某种共享状态抑或只是对象自身的状态。例如,下面有个名为GetParent的访问器方法很可能就涉及了某种全局状态(global state):
在用访问器读取数据的时候,你必须非常谨慎。因为这些大型对象往往会以令人费解的方式共享它们的可变状态。
- for(int i = 0; i < n; i++)
- SomeObject[i].GetParent().Update();
这个例子中的循环迭代体就很有可能不具备独立性。因为对于该循环中的每一个i来说,SomeObject[i]. GetParent()引用的都有可能是同一个对象实体。
引用了非线程安全的数据类型和函数:如果在一个并行循环中,循环体所使用的数据类型或函数不是线程安全的话,就表示这些线程中已经存在着依赖关系了,因此我们也就可以断定该循环体中的操作是不具有独立性的。
循环执行性依赖(Loop-carried dependence):如果在parallell_for循环中,循环体中所执行的数值运算是基于循环索引变量的,那么出现循环执行性依赖的可能性就非常大了。例如在下面的代码中,循环体中同时引用了data[i]和data[i-1],一旦这样的代码出现在parallel_for中,我们是无法确保data[i-1]能在处理data[i]之前就被更新完成的。
基于循环索引变量的数值运算,特别是加减法操作,通常都会导致循环执行性依赖。
- for(int i = 1; i < N; i++)
- data[i] = data[i] + data[i - 1];
然而在某些时候,这种包含循环执行性依赖的并行算法也是可行的,但这已经超出了本书的讨论范围。对此,我们最好的建议是:要么考虑一下程序中别的并行化机会,要么分析一下你的算法能否匹配某些用于科学计算的高级并行模式,例如并行扫描和并行动态规划之类的。
当我们在寻找并行化机会的时候,对应用程序的性能进行详细分析,将会有助于我们了解程序的运行时间主要消耗在哪些地方。但是,这种分析终究不能代替你理解应用程序的算法和结构,例如,它不能替你判断循环体中是否存在依赖关系。
不要对代码的性能分析寄予过高的期望—它不能替代你分析算法,所以我们只能靠自己。