PMPP 6.3 Performance considerations - Thread coarsening

Thread coarsening即线程粗化

在我们迄今为止所见的所有 kernel 中,工作都是以最细粒度跨线程并行化的。也就是说,每个线程被分配了最小可能的工作单元。例如,在向量加法 kernel 中,每个线程被分配一个输出元素。在 RGB 到灰度转换和图像模糊 kernel 中,每个线程被分配输出图像中的一个像素。在矩阵乘法 kernel 中,每个线程被分配输出矩阵中的一个元素。

跨线程以最细粒度并行化工作的优势在于,它增强了透明可伸缩性,正如第 4 章计算架构和调度中所讨论的。如果硬件拥有足够的资源来并行执行所有工作,那么应用程序就暴露了足够的并行性来充分利用硬件。否则,如果硬件没有足够的资源来并行执行所有工作,硬件可以通过一个接一个地执行线程块来简单地序列化工作。

并行化工作在最细粒度上的缺点在于,当并行化这项工作需要付出“代价”时。并行化的代价可能有多种形式,如不同线程块的冗余数据加载、冗余工作、同步开销等。当线程由硬件并行执行时,这种并行化的代价往往是值得付出的。然而,如果由于资源不足,硬件最终将工作序列化,则这个代价就是不必要地付出了。在这种情况下,程序员最好部分序列化工作,减少为并行化付出的代价。这可以通过为每个线程分配多个工作单元来实现,通常称为线程粗化。

我们使用第 5 章内存架构和数据局部性中的瓦片矩阵乘法示例来演示线程粗化优化。图 6.12 描述了计算输出矩阵 P 的两个水平相邻输出 tile 的内存访问模式。对于这些输出 tile,我们观察到需要加载不同的输入 tile 来自矩阵 N。然而,对于两个输出 tile,加载的矩阵 M 的相同输入 tile 是一样的。

在第 5 章内存架构和数据局部性中的瓦片实现中,每个输出 tile 由一个不同的线程块处理。因为共享内存内容不能跨块共享,每个块必须加载其自己的矩阵 M 输入 tile 副本。尽管不同的线程块加载相同的输入 tile 是冗余的,但这是我们为了能够使用不同的块并行处理两个输出 tile 而付出的代价。如果这些线程块并行运行,这个代价可能是值得的。另一方面,如果这些线程块被硬件序列化,则这个代价是徒劳的。在后一种情况下,程序员最好让一个线程块处理两个输出 tile,块中的每个线程处理两个输出元素。这样,粗化的线程块将加载一次 M 的输入 tile,并为多个输出 tile 重用它们。

图表展示了如何将线程粗化应用于第 5 章内存架构和数据局部性中的瓦片矩阵乘法代码。在第 02 行,增加了一个常量 COARSE_FACTOR 来表示粗化因子,即每个粗化线程将负责的原始工作单元的数量。在第 13 行,列索引的初始化被替换为 colStart 的初始化,colStart 是线程负责的第一列的索引,因为现在线程负责具有不同列索引的多个元素。在计算 colStart 时,块索引 bx 乘以 TILE_WIDTH×COARSE_FACTOR 而不是仅 TILE_WIDTH,因为现在每个线程块负责 TILE_WIDTH×COARSE_FACTOR 列。在第 16-19 行,声明并初始化了多个 Pvalue 实例,每个实例对应线程负责的一个元素。第 17 行上循环遍历粗化线程负责的不同工作单元,有时被称为粗化循环。在第 22 行上循环遍历输入 tile 内,每次循环迭代只加载一个 M 的 tile,与原始代码一样。然而,对于加载的每个 M 的 tile,粗化循环第 27 行上会加载并使用多个 N 的 tile。这个循环首先确定粗化线程负责当前 tile 的哪一列(第 29 行),然后加载 N 的 tile(第 32 行)并使用该 tile 来计算和更新不同的 Pvalue(第 35-37 行)。最后,在第 44-47 行上,另一个粗化循环被用于每个粗化线程来更新它负责的输出元素。

线程粗化是一种强大的优化,可以为许多应用程序带来显著的性能提升。它是一种常用的优化。然而,在应用线程粗化时需要避免几个陷阱。首先,必须小心不要在不必要时应用优化。回想一下,当并行化的代价可以通过粗化减少时,线程粗化是有益的,如冗余数据加载、冗余工作、同步开销等。并非所有计算都有这样的代价。例如,在第 2 章异构数据并行计算中的向量加法 kernel,处理不同向量元素的并行化没有代价。因此,将线程粗化应用于向量加法 kernel 不太可能带来显著的性能差异。同样的情况也适用于第 3 章多维网格和数据中的 RGB 到灰度转换 kernel。

第二个要避免的陷阱是不要过度粗化以至于硬件资源被低效利用。回想一下,向硬件暴露尽可能多的并行性能够实现透明可伸缩性。它为硬件提供了根据其拥有的执行资源量并行化或序列化工作的灵活性。当程序员粗化线程时,他们减少了暴露给硬件的并行性。如果粗化因子过高,将不会有足够的并行性暴露给硬件,导致一些并行执行资源未被利用。实际上,不同的设备有不同的执行资源量,因此最佳的粗化因子通常是设备特定和数据集特定的,需要针对不同的设备和数据集进行重新调整。因此,当应用线程粗化时,可伸缩性变得不那么透明。

应用线程粗化的第三个陷阱是避免过度增加资源消耗,以至于影响占用率。根据 kernel 的不同,线程粗化可能需要每个线程使用更多的寄存器或每个线程块使用更多的共享内存。如果是这种情况,程序员必须小心不要使用太多寄存器或过多共享内存,以至于降低占用率。从降低占用率中获得的性能损失可能比线程粗化可能提供的性能益处更为严重。

线程粗化(Thread coarsening)是一种优化技术,用于改善并行计算性能。其核心思想是增加每个线程的工作量。通常情况下,一个线程只负责一小块工作,比如处理一个像素或一个矩阵元素。线程粗化就是让每个线程处理更多的工作单元,比如多个像素或多个矩阵元素。这样做的好处是减少了线程间的同步和数据传输的开销,特别是在有大量线程执行相似任务时。

然而,线程粗化并不是在所有情况下都有效。它的应用需要考虑以下几个要点:

  1. 避免不必要的应用:并不是所有计算任务都适合线程粗化。如果原本的任务就没有很大的并行化代价(如同步和数据传输开销),那么线程粗化可能不会带来显著的性能提升,甚至可能有负面影响。

  2. 保持硬件资源的充分利用:过度粗化会导致并行度降低,可能会造成部分硬件资源未被充分利用。因此,需要找到一个平衡点,既能减少并行化的开销,又能保持较高的硬件利用率。

  3. 避免过多消耗资源:线程粗化可能会增加每个线程的资源需求(如寄存器和共享内存的使用量)。如果资源消耗过多,可能会影响到程序的占用率,即同时活跃的线程数可能会减少,从而影响整体性能。

总的来说,线程粗化是一种平衡艺术。它可以提高性能,但需要谨慎使用,避免过度应用,并根据具体情况进行适当的调整。