优化C++软件(13)

10. 多线程

CPU的时钟频率受到物理因素的限制。在时钟频率受限时,增加CPU密集程序吞吐率的方式是同时做多件事情。有3种方式并行做事情:

  • 使用多个CPU或多核CPU,如本章所述。
  • 使用现代CPU的乱序能力,如第11章所述。
  • 使用现代CPU的向量操作,如第12章所述。

大多数现代CPU有两个或更多的核,可以预期在将来核数还将增长。要使用多个CPU或CPU核,我们需要将工作分为多个线程。这里有两个主要原则:功能分解与数据分解。功能分解表示不同的线程进行不同类型的工作。例如,一个线程负责用户接口,另一个线程负责与远程线程的通讯,第三个线程进行数学计算。用户接口与非常耗时任务不在同一个线程里是重要的,因为这将给出了人恼怒的长且不规则的响应时间。通常将耗时任务放入低优先级的独立线程里。

不过,在许多情形里,有一个消耗大多数资源的线程。在这个情形里,我们需要将数据分为多个块,以利用多个处理器核。每个线程应该处理自己的块。这是数据分解。

在决定并行进行是否有好处时,区分粗粒度并行与细粒度并行是重要的。粗粒度并行指一个长序列操作必须与其他并行运行的任务无关地进行。细粒度并行是任务被分为许多小子任务的情形,但在特定的子任务上,在需要其他子任务协作前,不可能工作很长时间。

多线程工作使用粗粒度并行比使用细粒度并行效率更高,因为不同核间的通讯与同步速度慢。如果粒度太细,将任务分为多个线程没有优势。乱序执行(第11章)与向量操作(第12章)是利用细粒度并行更有用的方法。

使用多个CPU核的方式是将工作分解为多个线程。线程的使用在第47页讨论。在数据分解的情形里,我们更倾向于不使具有相同优先级的线程数超过系统可用的核数或逻辑处理器数。通过系统调用(如Windows中的GetProsessAffinityMask)可用确定可用逻辑处理器数。

有几个方式在多个CPU核间分开工作负荷:

  • 定义多个线程,每个线程上放置相同的工作量。这个方法对所有的编译器有效。
  • 使用自动并行。Gnu、Intel与PathScale编译器可以自动检测代码中的并行机会,并把它分解为多个线程,但编译器可能不能找出数据的最优分解。
  • 使用OpenMP指示。OpenMP是在C++与Fortran中说明并行处理的一个标准。Microsoft、Intel、PathScale与Gnu编译器都支持这些指示。细节参考www.openmp.org与编译器手册。
  • 使用本质多线程的函数库,如Intel Math Kernel Library。

多CPU核或逻辑处理器通常共享相同的缓存,至少在最后一级缓存,在某些情形里,甚至相同的1级缓存。共享缓存的好处是,线程间通讯变得更快且线程可以共享代码与只读数据。坏处是,如果线程使用不同的内存区,缓存将被填满,而如果线程写到相同内存区,则有缓存竞争。

只读数据可以在多个线程间共享,而修改的数据应该对每个线程独立。让两个或更多线程写相同的缓存行不是一个好主意,因为线程将使其他线程的缓存无效,导致大的时延。制作线程安全数据最容易的方法是,在线程函数中局部地声明它,使之保持在栈上。每个线程有自己的栈。另外,你可以定义一个包含线程特定数据的结构体或类,对每个线程制作一个实例。这个结构体或类应该至少对齐到缓存行大小,以避免多个线程写相同的缓存行。在现今处理器上,缓存行大小通常是64字节。在将来的处理器上,缓存行大小可能会更大(128或256字节)。

有各种方法在线程间通讯与同步,比如信号量、互斥量与消息系统。所有这些方法都是耗时的。因此,应该组织数据与资源,尽量减少线程间必须的通讯。例如,多个线程可以共享相同的队列、链表、数据库或其他数据结构,你可以考虑向每个线程给出它自己的数据结构,然后在所有线程完成耗时的数据处理时,最后合并多个数据结构,是否可能的。

在仅有一个逻辑处理器的系统上运行多个线程没有优势,如果这些线程竞争相同的资源。但将耗时的计算放入具有较低优先级的独立线程是一个好主意。将文件访问与网络访问放入独立的线程,在一个线程等待硬盘或网络响应时,另一个线程可以进行计算,也是有用的。

从Intel可获得支持多线程软件的各种开发工具。参考Intel Technology Journal Vol. 11, Iss. 4, 2007 (www.intel.com/technology/itj/)。

10.1. 并发多线程

许多微处理器能够在每个核上运行两个线程。例如,具有4个核的处理器可以同时8个线程。这个处理器有4个物理处理器,8个逻辑处理器。

“超线程”是Intel用于并发多线程的术语。运行在同一个核上的两个线程将总是竞争相同的资源,比如缓存与执行单元。如果任何共享资源是性能的受限因素,使用并发多线程没有优势。相反,因为缓存逐出与其他资源冲突,每个线程可能运行小于一半速度。但如果很大部分时间花在缓存不命中、分支误预测或长依赖链上,每个线程将以超过一半速度运行。在这个情形里,使用并发多线程是有优势的,但性能不会加倍。与另一个线程共享核资源的线程将总是比单独运行在这个核上的线程要慢。

进行试验以确定在一个特定的应用中使用并发多线程是否有好处,通常是必要的。

如果并发多线程没有优势,那么查询某些操作系统函数(比如Windows里的GetLogicalProcessorInformation)来确定处理器是否有并发多线程是必要的。如果这样,通过仅使用偶数逻辑处理器(0,2,4等),可以避免并发多线程。较旧的操作系统缺乏区分物理处理器数与逻辑处理器数的必须函数。

没有办法告诉处理器给予一个线程比另一个更高的优先级。因此,经常出现一个低优先级线程从运行在同一个核的更高优先级的线程偷窃资源。在相同的处理器核里避免运行两个优先级不同的线程是操作系统的责任。不幸的是,现代操作系统不能很好地处理这个问题。

Intel编译器能够制作两个线程,其中一个线程为另一个线程预取数据。不过,在大多数情形里,自动硬件预取比软件预取高效。

11. 乱序执行

所有现代x86 CPU可以乱序执行指令或者同时做多件事,除了某些小的低功耗CPU(Intel Atom)。下面的例子显示了如何利用这个能力:

// Example 11.1a

float a, b, c, d, y;

y = a + b + c + d;

这个表达式被计算为((a+b)+c)+d。这是一个依赖链,其中每个加法必须等待之前一个的结果。你可以通过这样写证明这:

// Example 11.1b

float a, b, c, d, y;

y = (a + b) + (c + d);

现在,两个括号可以独立计算。CPU将在完成(a+b)之前,开始计算(c+d)。这可以节省几时钟周期。你不能假设优化编译器自动将把例子11.1a中的代码改变为11.1b,虽然这看起来显而易见。为什么编译器不能在浮点表达式上进行这种优化的原因是,它可能导致精度损失,如第64页所述。你必须手动设置括号。

长依赖链影响更大。这通常是循环里的情形。考虑下面的例子,它计算100个数的和:

// Example 11.2a

const int size = 100;

float list[size], sum = 0; int i;

for (i = 0; i < size; i++) sum += list[i];

这有一个长依赖链。如果浮点加法需要5时钟周期,这个循环将需要大约500时钟周期。你可以通过展开循环、将依赖链一分为二,提高性能:

// Example 11.2b

const int size = 100;

float list[size], sum1 = 0, sum2 = 0; int i;

for (i = 0; i < size; i += 2) {

     sum1 += list[i];

     sum2 += list[i+1];}

sum1 += sum2;

如果微处理器从时刻T到T+5进行对sum1的加法,它可以从时刻T+1到T+6进行另一个对sum2的加法,整个循环将仅需256时钟周期。

在循环中的计算,其中每次迭代需要前面的结果,被称为循环携带依赖链。这样的依赖链可以非常长且耗时。如果可以打破这样的依赖链,增益很大。两个总和变量sum1与sum2被称为累加器。当前CPU仅有一个浮点加法单元,但这个单元是流水线化的,如上所述,因此它可以在前面的加法完成周期,开始一个新的加法。

浮点加法与乘法的最优累加器数是3或4,依赖于CPU。

如果迭代数不能被展开因子整除,展开循环变得有点复杂。例如,如果例子11.2b中list元素数是奇数,我们必须在循环外增加最后的元素,或者向list增加一个额外伪元素并将它置零。

如果没有循环携带依赖链,展开循环与使用多个累加器是不必要的。具有乱序能力的微处理器可以重叠迭代,在前面迭代完成之前,开始一个迭代的计算。例子:

// Example 11.3

const int size = 100; int i;

float a[size], b[size], c[size];

float register temp;

for (i = 0; i < size; i++) {

     temp = a[i] + b[i];

     c[i] = temp * temp;

}

具有乱序能力的微处理器非常聪明。它们可以检测出例子11.3中循环一次迭代中寄存器temp的值与之前迭代的值无关。这允许在完成之前值使用之前,它可以开始计算temp的一个新值。通过对temp分配一个新物理寄存器来实现,即使出现机器代码中的逻辑寄存器是相同的。这称为寄存器重命名。CPU可以保存相同逻辑寄存器的许多重命名实例。

这个优势自动得到。没有理由展开该循环,并拥有temp1与temp2。如果满足特定条件,现代CPU能够进行寄存器重命名,并行执行多个计算。使CPU重叠循环迭代计算成为可能的条件有:

  • 没有循环携带依赖链。一次迭代的计算中不依赖之前迭代的结果(除了循环计数器,如果它是整数,它计算得很快)。
  • 所有中间结果应该保存在寄存器中,而不是内存。重命名机制仅工作在寄存器,但不能在内存或缓存里的变量上。大多数编译器将使例子11.3中的temp成为寄存器变量,即使没有register关键字。CodeGear编译器不能制作浮点寄存器变量,而是将temp保存在内存。这阻止了CPU重叠计算。
  • 循环分支应该被预测。如果重复计数很大或是常量,这不是问题。如果循环计数小且改变,那么CPU偶尔预测循环退出,而实际上没有,因此不能开始下一个计数。不过,乱序机制允许CPU预先递增循环计数器,使得它能不至太迟检测到误预测。因此,你不必太担心这个情形。

一般来说,乱序执行机制自动工作。不过,程序员可以做一些事情来最大程度地利用乱序执行。最重要的是避免长依赖链。另一件你可以做的事情是混合不同类型的操作,以在CPU中不同执行单元间平均分配工作。混合整形与浮点计算是有好处的,只要你不需要在整形与浮点值之间转换。混合浮点加法与浮点乘法,混合简单整形与向量整形操作,以及混合数学计算与内存访问也是有好处的。

非常长的依赖链对CPU的乱序资源施加压力,即使它们不带入循环的下一个迭代。现代CPU通常可以处理超过100个挂起的操作(参考手册3《Intel,AMD与VIA CPU的微架构》)。将一个循环分解并保存中间结果,对打破极长的依赖链是有帮助的。

  • 0
    点赞
  • 0
    收藏
    觉得还不错? 一键收藏
  • 0
    评论

“相关推荐”对你有帮助么?

  • 非常没帮助
  • 没帮助
  • 一般
  • 有帮助
  • 非常有帮助
提交
评论
添加红包

请填写红包祝福语或标题

红包个数最小为10个

红包金额最低5元

当前余额3.43前往充值 >
需支付:10.00
成就一亿技术人!
领取后你会自动成为博主和红包主的粉丝 规则
hope_wisdom
发出的红包
实付
使用余额支付
点击重新获取
扫码支付
钱包余额 0

抵扣说明:

1.余额是钱包充值的虚拟货币,按照1:1的比例进行支付金额的抵扣。
2.余额无法直接购买下载,可以购买VIP、付费专栏及课程。

余额充值