Intel64及IA-32架构优化指南第8章多核与超线程技术——8.2 编程模型与多线程

8.2 编程模型与多线程


并行在设计一个多线程应用中以及认识对多处理器的最优性能提升来说是最重要的概念。一个优化的多线程应用特征为在以下领域的大规模的并行性或最小的依赖性:

●  工作负载

●  线程交互

●  硬件利用

最大化工作负载并行度的关键是能在一个应用内标识出具有最小的相互依赖性的多个任务,并且为这些任务创建独立的线程来并行执行。

对独立线程的并发执行是在一个多处理器系统中部署一个多线程应用的本质。管理线程之间的交互来最小化线程同步的成本对于实现具有多个处理器的最优性能增幅也是很关键的。

并发线程之间的硬件资源的高效使用需要在特定领域的优化技术,来防止硬件资源的竞争。优化线程同步以及管理其它硬件资源的编程技术在后续小节讨论。

接下来讨论并行编程模型。


8.2.1 并行编程模型


将独立任务的需求转换为应用程序线程有两个公共的编程模型:

●  域分解

●  功能上的分解


8.2.1.1 域分解


通常,大规模密集计算任务使用可以被划分为一些小的子集的数据集,每个子集具有较大的计算独立性。例子有:

●  在一个二维数据上计算一个离散余弦变换(DCT),通过将此二维数据划分为若干个子集,并对每个子集创建相应的线程来计算这个变换。

●  矩阵乘法;这里,可以创建线程用乘数矩阵来做一半矩阵的乘法。

域分解是基于创建相同的或类似的线程来独立地处理更小的数据片段的一种编程模型。[译者注:这里所谓的相同(identical)是指几个线程的执行例程都是同一个函数。]这种模型可以利用现有的在一个传统的多处理器系统中的复制出来的执行资源。它也可以利用HT技术中两个逻辑处理器之间的共享的执行资源。这是因为一个数据域线程一般只消费片上可用的执行资源的某些部分。


8.2.2 功能上的分解


应用程序通常用各种不同的函数以及许多不相关的数据集来处理许多不同的任务。比如,一个视频编解码需要若干不同的处理函数。这些包括DCT、动作估计以及颜色转换。使用一个功能性的的线程模型,应用可以对独立的线程进行编程来做动作估计、颜色转换以及其它功能上的任务。

功能分解将实现更灵活的线程级并行,如果它更少依赖硬件资源的副本的话。比如,一个执行一个排序算法的线程与一个执行一个矩阵乘法例程的线程不太可能在同一时刻需要相同的执行单元。认识到这点的一个设计可以利用传统的多处理器系统,也可以利用使用支持HT技术的处理器的多处理器系统。


8.2.3 专门的编程模型


Intel Core Duo处理器以及基于Intel Core微架构的处理器在同一个物理包中提供了被两个处理器核心所共享的一个L2 Cache。这为两个应用线程提供了机会来访问一些应用程序的数据,同时最小化了总线交通负荷。

多线程应用可能需要使用专门的编程模型来利用这种类型的硬件特性。一种情景是生产者-消费者。在这种场景下,一个线程将数据写到某个目的(希望是在L2 Cache中),而另一个在同一个物理包的其它核心上执行的线程随后读取由第一个线程所生产出来的数据。

用于实现一个生产者-消费者模型的基本方法是创建两个线程;一个线程是生产者,另一个是消费者。一般情况下,生产者与消费者轮流操作一个缓存,并且当它们准备好交换缓存时通知对方。在一个生产者-消费者模型中,当缓存在生产者与消费者之间被交换时会有些线程同步负荷。为了达成最优的给定核心个数的性能增幅,必须将同步负荷保持到最低。这可以通过确保生产者与消费者线程具有相当多的时间常量,在交换缓存之前来完成每个增量任务。

例8-1描述了一系列任务单元的单线程执行的代码结构,这里每个任务单元(要么是生产者,要么是消费者)串行执行(如图8-2所示)。在多线程执行下的等价的场景中,每个生产者-消费者对被封装为一个线程函数,并且两个线程可以在可用的处理器资源上被同时调度。

例8-1 串行执行生产者与消费者工作项

for(i = 0; i < number_of_iterations; i++){
    producer(i, buff);    // 传递缓存索引与缓存地址
    consumer(i, buff);
}


8.2.3.1 生产者-消费者线程模型


图8-3描述了在一对生产者和消费者线程之间交互的基本模式。水平方向表示时间。每个块表示一个任务单元,处理指派给一个线程的缓存。

每个任务之间的间隙表示同步负荷。在圆括号里的十进制数表示一个缓存索引。在一个Intel Core Duo处理器上,生产者线程可以在L2 Cache内存储数据来允许消费者线程继续作业,最小化了对总线交通的需求。

实现生产者与消费者线程函数,伴随与缓存索引通信进行同步的基本结构在例8-2中展示。

例8-2:实现生产者-消费者线程的基本结构

// (a)一个生产者线程函数的基本结构
void producer_thread()
{
    int iter_num = workamount - 1;    // 做本地拷贝
    int mode1 = 1;    // 通过0和1来追踪两个缓存的使用
    produce(buffs[0], count);    // 占位符函数
    while(iter_num--){
        Signal(&signal1, 1);    // 告诉另一个线程可以开始作业了
        produce(buffs[mode1], count);    // 占位符函数
        WaitForSignal(&end, 1);
        mode1 = 1 - mode1;    // 切换到另一个缓存
    }
}

// (b)消费者线程的基本结构
void consume_thread()
{
    int mode2 = 0;    // 从缓存0开始的第一次迭代,然后交换
    int iter_num = workamount - 1;
    while(iter_num--){
        WaitForSignal(&signal1);
        consume(buffs[mode2, count);    // 占位符函数
        Signal(&end, 1);
        mode2 = 1 - mode2;
    }
    consume(buffs[mode2], count);
}

以一种交错的形式来构建生产者-消费者模型也是可能的,这样,它可以最小化总线交通,并且对不共享L2 Cache的多核处理器也有效。

在这种交错版本的生产者-消费者模型中,一个应用线程的每次调度量由一个生产者任务与一个消费者任务组成。两个相同的线程并行地被创建来执行。在一个线程的调度量期间,生产者任务先启动,然后消费者任务紧跟在生产者任务完成之后;两个任务都对同一个缓存操作。当每个任务完成之后,一个线程对另一个线程发出信号,通知其相应的任务使用对它所指派的缓存。从而,生产者和消费者任务在两个线程中并行执行。只要由生产者所产生的数据驻留在同一核心的L1或L2 Cache中,那么消费者可以直接访问它们而不会遭受总线交通。交错的生产者-消费者模型的调度在图8-4中展示。

例8-3展示了可以以这种交错的生产者-消费者模型来使用的一个线程函数的基本结构。

例8-3:一个交错的生产者-消费者模型的线程函数

// 主控线程启动第一个迭代,另一个线程必须等待一次迭代
void producer_consumer_thread(int master)
{
    int mode = 1 - master;    // 追踪哪个线程及其指派的缓存索引
    unsigned int iter_num = workamount >> 1;
    unsigned int i = 0;

    iter_num += master & workamount & 1;
    
    if(master)    // 主控线程启动第一次迭代
    {
        produce(buffs[mode], count);
        Signal(sigp[1-mode], 1);     // 通知跟从线程中的消费者任务,它可以进行处理了
        consume(buffs[mode], count);
        Signal(sigc[1-mode], 1);
        i = 1;
    }
    
    for(; i < iter_num; i++)
    {
        WaitForSignal(sigp[mode]);
        produce(buffs[mode], count);    // 通知另一线程的消费者任务
        Signal(sigp[1-mode], 1);
        WaitForSignal(sigc[mode]);
        consume(buffs[mode], count);
        Signal(sigc[1-mode], 1);
    }
}


8.2.4 创建多线程应用的工具


——译者注:这段基本上就是Intel的软文,略之~

posted @ 2013-05-06 14:05  zenny_chen  Views(585)  Comments(0Edit  收藏  举报