并行与分布式系统中的并行矩阵转置算法:基于块划分的通信模式优化
字数 9042 2025-12-20 01:07:30

好的,我们来看一个尚未在列表中出现的、在并行与分布式计算中非常基础和重要的算法。

并行与分布式系统中的并行矩阵转置算法:基于块划分的通信模式优化

题目描述

矩阵转置是一个简单的操作:给定一个 M x N 的矩阵 A,其转置矩阵 AT 是一个 N x M 的矩阵,满足 AT[j][i] = A[i][j]

在单机内存中,这通常是一个简单的内存拷贝或索引重映射问题。然而,在分布式内存并行系统(例如,由多个计算节点通过高速网络连接构成的集群,每个节点拥有自己的私有内存)中,矩阵通常被分块存储在不同的处理器(或进程)上。当我们需要对这样一个分布式存储的矩阵进行转置时,数据必须通过网络在处理器之间进行交换。这个操作是许多科学计算应用(如快速傅里叶变换FFT、某些矩阵乘法变体等)的核心通信原语之一。

问题:假设我们有一个 P x Q 的处理器网格(总共有 P*Q 个处理器),一个大型的 M x N 矩阵 A 以二维块循环(或称棋盘划分)的方式分布在这个网格上。具体来说,矩阵 A 在行方向被划分为 P 块,在列方向被划分为 Q 块。处理器 (p, q)(其中 0 ≤ p < P, 0 ≤ q < Q)本地存储着矩阵 A 的一个大小为 (M/P) x (N/Q) 的子块,我们记作 A_local[p][q]

现在,我们需要计算其转置矩阵 AT,并要求 AT 同样以二维块循环的方式分布在相同的 P x Q 处理器网格上。也就是说,计算完成后,处理器 (p, q) 应该本地存储着 AT 的一个大小为 (N/Q) x (M/P) 的子块 AT_local[p][q],并且 AT_local[p][q][j][i] = A[i][j] 对于其本地数据成立。

核心挑战:处理器 (p, q) 本地持有的 A_local[p][q] 在转置后,其数据需要分散到处理器网格中新的位置。设计一个高效的并行算法,最小化处理器之间的通信开销(包括通信量和通信轮次),来完成这个分布式矩阵转置操作。


解题过程

我们循序渐进地分析并解决这个问题。

第一步:理解数据映射关系

这是最关键的一步。我们首先用数学和图示明确转置前后每个数据元素的归属变化。

  1. 原始分布 (A)

    • 全局矩阵 A 的大小为 M x N
    • 处理器 (p, q) 存储子块 A_local[p][q]。这个子块包含全局矩阵中以下行和列的数据:
      • 行范围:[p * (M/P), (p+1) * (M/P)) (为简化,假设 M 能被 P 整除,N 能被 Q 整除)。
      • 列范围:[q * (N/Q), (q+1) * (N/Q))
    • 我们用 (i, j) 表示全局矩阵 A 中一个元素的位置。那么,它位于处理器 (floor(i / (M/P)), floor(j / (N/Q))) 上。
  2. 目标分布 (AT)

    • 转置矩阵 AT 的大小为 N x M
    • 在目标分布中,处理器网格的划分逻辑不变(仍然是 P x Q 网格)。但现在,AT 的“行”对应原矩阵 A 的“列”,AT 的“列”对应原矩阵 A 的“行”。
    • 处理器 (p, q) 需要存储子块 AT_local[p][q]。这个子块包含全局矩阵 AT(也就是原矩阵 A 转置后)中以下行和列的数据:
      • AT 的行范围:[p * (N/Q), (p+1) * (N/Q)) (因为 AT 的行数 = N)。
      • AT 的列范围:[q * (M/P), (q+1) * (M/P)) (因为 AT 的列数 = M)。
    • 对应于原矩阵 A,AT[r][c] = A[c][r]。所以,处理器 (p, q) 最终需要持有的数据,是原矩阵 A 中所有满足以下条件的元素 A[i][j]
      • j(原列号)在 [p * (N/Q), (p+1) * (N/Q)) 区间内(因为 j 成为 AT 的行索引 r)。
      • i(原行号)在 [q * (M/P), (q+1) * (M/P)) 区间内(因为 i 成为 AT 的列索引 c)。

结论:处理器 (p, q) 最终需要的数据,最初是存储在处理器 (q, p) 上的!
因为根据原始分布,行属于块 q(对应目标分布的列块),列属于块 p(对应目标分布的行块)。

这是一个非常清晰的对称映射:转置操作对应于在处理器网格上沿着主对角线交换数据块。

第二步:朴素算法与通信分析

一个最直接的想法是:每个处理器 (p, q) 直接向它的“目标”处理器 (q, p) 发送自己本地持有的整个 A_local[p][q] 子块。然后,目标处理器收到后,在本地对其进行内存转置,得到 AT_local[q][p]

  • 通信模式:这是 P*Q 个点对点通信。对于不在对角线上的处理器(p != q),这是一个双向交换。对角线上的处理器(p == q)只需要在本地进行内存转置。
  • 问题
    1. 数据不对应:处理器 (p, q) 发送的是 A_local[p][q](原矩阵的一块行连续、列连续的区域),但处理器 (q, p) 需要的是 A_local[q][p] 转置后的数据吗?不完全是! 仔细看第一步的分析:处理器 (q, p) 最终需要的是原矩阵中“行属于块 p,列属于块 q”的数据。而 A_local[q][p] 是“行属于块 q,列属于块 p”的数据。这完全是两个不同的数据块。因此,简单的块交换是错误的。
    2. 通信内容复杂:即使我们修正目的地,处理器 (p, q) 本地持有的连续数据块,在转置后需要被拆散并发送到多个不同的处理器。反之,它最终需要的数据也来自于多个不同的处理器。

所以,我们需要一个更精细的通信策略。

第三步:设计正确的通信模式——两步法(All-to-All)

核心洞见:转置操作 (i, j) -> (j, i) 可以分解为两个阶段:

  1. 索引重排阶段1:交换行和列的“归属”。我们可以先进行一次全局数据重排,使得每个处理器持有的数据,是其最终需要的数据在转置前的“列连续”形式
  2. 本地计算与最终重排:然后每个处理器在本地对数据进行内存转置,得到最终布局。

一种经典高效的方法是使用 All-to-All 集体通信

具体算法步骤(假设 P=Q,网格是方的,这很常见,且简化描述):

  1. 本地重排(准备发送数据)

    • 在每个处理器 (p, q) 内部,将本地子块 A_local[p][q](尺寸 (M/P) x (N/Q))看作由 P 个更小的“行块”组成(因为最终目标分布与行块数 P 有关)。更准确地说,处理器思考:“我最终需要的数据,其原行号属于哪个块?”。
    • 处理器 (p, q) 将其本地数据 A_local[p][q] 在列方向上划分为 Q 个“列片段”(Column Slice),每个片段大小为 (M/P) x (N/(Q*Q))?这里需要更精确。实际上,它应该按最终需要它的处理器所在的行号来划分。
    • 正确的划分是:对于处理器 (p, q),它需要为处理器网格中第 r 行的所有处理器准备数据。具体来说,它本地 A_local[p][q] 的第 k 列(全局列索引为 j = q*(N/Q) + k),在转置后将变为 AT 的第 j 行。这一行数据最终属于处理器 (r, *),其中 r = floor(j / (N/Q))。由于 j 的范围就在 q 这个列块内,所以 floor(j / (N/Q)) 恒等于 q?不对,j 是全局列索引,(N/Q) 是目标分布的行块大小。floor(j / (N/Q)) 计算的是这个数据在目标分布中属于哪个行块(即哪个处理器行号 p_target)。而 j 的取值范围是 [q*(N/Q), (q+1)*(N/Q)),所以 floor(j / (N/Q)) 的结果可能是 q,但更一般地,我们需要将本地数据的每一列(或每一组列)映射到其目标处理器行。
    • 简化理解(当 P=Q 时):每个处理器将其 (M/P) x (N/Q) 的本地块,在逻辑上划分为一个 P x Q 的小块网格(与处理器网格同构)。本地块中位于 (sub_i, sub_j) 位置的小子块(尺寸 (M/(P*P)) x (N/(Q*Q))),其对应的原全局元素 A[i_global][j_global] 将去往处理器 (floor(j_global/(N/Q)), floor(i_global/(M/P))),即处理器 (q_target, p_target)。其中 p_targetsub_i 决定,q_targetsub_j 决定。
    • 因此,为了准备通信,处理器 (p, q) 将其本地数据 按行分割成 Q 个“行片段”(Row Slice)可能更直接。每个行片段包含 (M/P) 行,但只有 (N/Q)/Q = N/(Q*Q) 列(假设整除)。这个行片段 k0 ≤ k < Q)将被发送到处理器 (k, ?)。实际上,决定因素是目标处理器的行号,而目标处理器的行号由原数据的列索引范围决定。
    • 让我们采用更标准的描述:处理器 (my_row, my_col) 将其 local_m x local_n 的块,在列方向上切分成 P 个列块(因为目标处理器网格有 P 行)。第 dest_row 个列块(0 ≤ dest_row < P)将被发送到处理器行号为 dest_row 的某个处理器。这个列块包含了原矩阵中列索引属于“最终将由目标处理器行 dest_row 负责”的那些列的数据。
  2. All-to-All 通信(按行)

    • 在处理器网格的每一行内,进行一次 All-to-All 通信
    • 同一行 p 上的所有 Q 个处理器 (p, 0), (p, 1), ..., (p, Q-1) 都参与。
    • 每个处理器 (p, q) 准备 P 个数据块(上一步划分好的),其中第 dest_row 个块(dest_row 从0到P-1)被发送给同一行内的处理器 (p, dest_row)?不,等等,目标处理器行号是 dest_row,但列号呢?
    • 关键:在这次行内All-to-All中,每个处理器 (p, q) 发送给同行处理器 (p, k) 的数据,是原矩阵中那些最终将属于目标处理器行 k 的列数据。同时,它也从同行处理器 (p, k) 那里接收数据,这些数据是原矩阵中那些最终将属于当前处理器行 p 的列数据(但从不同的原列块 k 来的)。
    • 经过这次通信后,处理器 (p, q) 现在拥有一个“拼凑”起来的中间块,其行数仍是 local_m = M/P,但列数现在变成了 N(因为从同行所有处理器那里收集了不同列块的数据)。更重要的是,这个中间块的列顺序现在是“按最终的目标处理器行号”排列好的。也就是说,这个中间块的数据,其原列索引 j 已经满足了“转置后属于处理器行 p”的条件(因为 floor(j/(N/Q)) = p 现在可能不成立,但数据已经按目标行聚合了)。实际上,经过这一步,处理器 (p, q) 拥有了所有原行号属于块 p,且原列号属于任意块,但这些数据转置后的行号(即原列号)其所属的目标处理器行号是 q 的数据?让我们换个方式。
    • 更清晰的表述(两步All-to-All转置)
      • 第一步All-to-All(按行):每个处理器 (my_row, my_col) 将其本地块按列切分成 Q 个列条带。它将第 k 个列条带发送给同行的处理器 (my_row, k)。同时,它从同行的每个处理器 (my_row, k) 那里接收一个列条带。通信结束后,每个处理器 (my_row, my_col) 现在拥有一个 (M/P) x N 的中间矩阵,但这个矩阵的列是“按发送方的原始列号 my_col”交织的。实际上,它现在拥有的是原矩阵中所有“行属于块 my_row”的数据(这正是它最终需要的行的来源),但列是全局的。然而,这些数据的列组织方式还不是最终处理器网格列号 my_col 所需要的。
  3. 本地转置

    • 每个处理器对第一步通信后得到的中间块(尺寸 (M/P) x N)在内存中进行本地转置,得到一个尺寸为 N x (M/P) 的块。
  4. 第二次All-to-All 通信(按列)

    • 在处理器网格的每一列内,进行一次 All-to-All 通信
    • 同一列 q 上的所有 P 个处理器 (0, q), (1, q), ..., (P-1, q) 都参与。
    • 每个处理器 (my_row, my_col) 将本地转置后的块(尺寸 N x (M/P))按行切分成 P 个行条带。它将第 k 个行条带发送给同列的处理器 (k, my_col)。同时,它从同列的每个处理器 (k, my_col) 那里接收一个行条带。
    • 通信结束后,处理器 (my_row, my_col) 将收到的 P 个行条带拼接起来,形成一个尺寸为 (N/Q) x (M/P) 的最终本地块 AT_local[my_row][my_col]。检查维度:第一次通信后块尺寸为 (M/P) x N,本地转置后为 N x (M/P)。按行切分成P份,每份大小为 (N/P) x (M/P)?不对,N行分成P份,每份是 (N/P) x (M/P)。但我们的目标是 (N/Q) x (M/P)。这里矛盾了,说明我们的划分粒度假设(P=Q)是重要的。
    • P=Q 时,N/P = N/Q。因此,第二次All-to-All按列通信后,处理器 (my_row, my_col) 正好得到 (N/Q) x (M/P) 的最终块,并且数据的布局正是转置后所需的布局。

第四步:算法总结与通信复杂度

算法流程(对于 P x Q 处理器网格,且 M 可被 P 整除,N 可被 Q 整除):

  1. 输入:处理器 (p, q) 持有本地块 A_local[p][q],尺寸 m x n,其中 m = M/P, n = N/Q

  2. 按行 All-to-All

    • 处理器 (p, q) 将其 m x n 的本地块,按列分割成 Q 个 n/Q 宽的列条带(实际上,就是按本地列维度均匀分成Q份,每份尺寸 m x (n/Q),但注意 n = N/Q,所以 n/Q = N/(Q*Q)。当 P=Q 时,N/(Q*Q) = N/(P*Q))。
    • 在处理器行 p 内,执行一次 All-to-All 集体通信。处理器 (p, q) 将第 k 个列条带发送给同行处理器 (p, k),并从每个同行处理器 (p, k) 接收一个列条带。
    • 通信后,处理器 (p, q) 拥有一个 m x N 的中间矩阵 B(因为它从Q个处理器各收到一个 m x (n/Q) 的条带,拼起来是 m x (n/Q * Q) = m x n?这里有误。它发送了Q个条带,也接收了Q个条带,每个条带大小是 m x (n/Q)。因此,接收到的数据总大小是 m x (n/Q) * Q = m x n。所以中间矩阵 B 的大小仍然是 m x n,但数据内容已经重新排列了。实际上,经过这一步,处理器 (p, q) 现在持有的数据,是原矩阵中所有“行属于块 p”且“列属于块 q”的数据吗?不,是“行属于块 p”且“这些列在转置后的目标处理器列号是 q”的数据。这步操作可以理解为矩阵的“局部转置”或数据重排,为下一步做准备。更标准的解释是,这步操作相当于对处理器网格的列下标进行了一次全交换。

    为了更通用,我们采用文献中常见的描述:这两步All-to-All合起来称为 “全交换转置”。每个处理器最初有 (M/P) x (N/Q) 的数据。目标是有 (N/Q) x (M/P) 的数据。算法如下:

    • 第一步(按行All-to-All):将本地块视为 P x Q 个小块的网格(每个小块大小 (M/(P*Q)) x (N/(P*Q))?这要求M和N能被P*Q整除)。但实际上,我们可以按通信缓冲区来理解。处理器 (p,q) 准备Q个发送缓冲区,每个大小为 (M/P) x (N/(Q*Q))。发送缓冲区 k 包含原块中所有列 j 满足 floor(j / (N/(Q*Q))) = k 的数据?这太复杂。

    一个更简洁正确的描述(对于块划分)是:

    每个处理器将其 (M/P) x (N/Q) 的块划分为 Q 个列条带,每个条带大小为 (M/P) x 1?不对。
    实际上,经典的解决方案是执行两次全体到全体(All-to-All)通信,中间进行一次本地转置。并且,为了最优化,通常假设处理器网格是方的(P=Q),这样通信量是对称的。

    我们给出一个最终简洁且正确的版本:

    假设 P = Q = sqrt(K),K为处理器总数。每个处理器本地块大小为 (M/sqrt(K)) x (N/sqrt(K))

    1. 第一次All-to-All(在行通信组内)

      • 在每个处理器行内部(共 sqrt(K) 个处理器),执行一次 All-to-All。
      • 每个处理器将其本地块按分割成 sqrt(K) 个列片段,每个片段大小为 (M/sqrt(K)) x (N/K)
      • 处理器 (p, q) 将第 k 个列片段发送给同行(第p行)的处理器 (p, k)
      • 同时,它从同行每个处理器 (p, k) 接收一个列片段。接收到的第 k 个片段来自原处理器 (p, k) 的第 q 个列片段。
      • 通信后,处理器 (p, q) 将收到的 sqrt(K) 个列片段按发送方索引 k 顺序拼接,形成一个中间矩阵 Intermediate,其大小为 (M/sqrt(K)) x (N/sqrt(K))(因为 (N/K) * sqrt(K) = N/sqrt(K))。注意:此时数据已经重新排列,但这个中间矩阵的物理意义是:它包含了最终结果中,那些“原行号属于块 p”且“转置后的列号(即原行号)所属的目标处理器列q”的所有数据。但这还没有进行实际的转置操作。
    2. 本地转置

      • 每个处理器在本地对其 Intermediate 矩阵(尺寸 (M/sqrt(K)) x (N/sqrt(K)))执行内存转置,得到 Transposed_Intermediate,尺寸为 (N/sqrt(K)) x (M/sqrt(K))
    3. 第二次All-to-All(在列通信组内)

      • 在每个处理器列内部(共 sqrt(K) 个处理器),执行一次 All-to-All。
      • 每个处理器将其 Transposed_Intermediate 块按分割成 sqrt(K) 个行片段,每个片段大小为 (N/K) x (M/sqrt(K))
      • 处理器 (p, q) 将第 k 个行片段发送给同列(第q列)的处理器 (k, q)
      • 同时,它从同列每个处理器 (k, q) 接收一个行片段。接收到的第 k 个片段来自处理器 (k, q) 的第 p 个行片段。
      • 通信后,处理器 (p, q) 将收到的 sqrt(K) 个行片段按发送方索引 k 顺序拼接,形成最终的结果矩阵 AT_local[p][q],其大小为 (N/sqrt(K)) x (M/sqrt(K)),这正是所期望的尺寸,并且其内容正是全局矩阵 AT 中属于该处理器的子块。

通信复杂度分析

  • 每次 All-to-All 通信,每个处理器需要发送和接收 sqrt(K) - 1 个消息(如果忽略对角线自通信)。
  • 每个消息的大小约为 (M*N) / (K * sqrt(K)) 个数据元素(第一次通信消息大小为 (M/sqrt(K)) * (N/K) = M*N/(K*sqrt(K)),第二次相同)。
  • 因此,总通信量(每个处理器)约为 2 * (sqrt(K) - 1) * (M*N)/(K*sqrt(K)) ≈ 2 * (M*N) / K。注意 (M*N)/K 是平均每个处理器持有的数据量。所以,总通信量大约是每个处理器本地数据量的两倍。这是一个非常高效的结果,因为数据至少需要被移动一次(转置的本质),而这个算法通过巧妙的两次集体通信,将数据移动量控制在了理论下限的常数倍以内。
  • 通信轮次为 2 次集体通信。在诸如 MPI 这样的并行编程模型中,可以使用 MPI_Alltoall 原语高效实现。

总结

并行矩阵转置算法巧妙地利用了处理器网格的二维结构,通过两次All-to-All集体通信(一次按行,一次按列)夹着一次本地内存转置,高效地完成了分布式数据布局的转置。其核心思想是将全局的、不规则的数据交换路径,分解为两个规整的、沿着处理器网格行和列方向的集体通信操作,从而可以利用优化的网络通信模式,并使得通信量接近理论下限。这个算法是许多并行数学库(如ScaLAPACK)中的基础通信操作之一。

好的,我们来看一个尚未在列表中出现的、在并行与分布式计算中非常基础和重要的算法。 并行与分布式系统中的并行矩阵转置算法:基于块划分的通信模式优化 题目描述 矩阵转置是一个简单的操作:给定一个 M x N 的矩阵 A,其转置矩阵 AT 是一个 N x M 的矩阵,满足 AT[j][i] = A[i][j] 。 在单机内存中,这通常是一个简单的内存拷贝或索引重映射问题。然而,在 分布式内存并行系统 (例如,由多个计算节点通过高速网络连接构成的集群,每个节点拥有自己的私有内存)中,矩阵通常被分块存储在不同的处理器(或进程)上。当我们需要对这样一个分布式存储的矩阵进行转置时, 数据必须通过网络在处理器之间进行交换 。这个操作是许多科学计算应用(如快速傅里叶变换FFT、某些矩阵乘法变体等)的核心通信原语之一。 问题 :假设我们有一个 P x Q 的处理器网格(总共有 P*Q 个处理器),一个大型的 M x N 矩阵 A 以 二维块循环 (或称棋盘划分)的方式分布在这个网格上。具体来说,矩阵 A 在行方向被划分为 P 块,在列方向被划分为 Q 块。处理器 (p, q) (其中 0 ≤ p < P , 0 ≤ q < Q )本地存储着矩阵 A 的一个大小为 (M/P) x (N/Q) 的子块,我们记作 A_local[p][q] 。 现在,我们需要计算其转置矩阵 AT,并要求 AT 同样以二维块循环的方式分布在相同的 P x Q 处理器网格上。也就是说,计算完成后,处理器 (p, q) 应该本地存储着 AT 的一个大小为 (N/Q) x (M/P) 的子块 AT_local[p][q] ,并且 AT_local[p][q][j][i] = A[i][j] 对于其本地数据成立。 核心挑战 :处理器 (p, q) 本地持有的 A_local[p][q] 在转置后,其数据需要分散到处理器网格中新的位置。设计一个高效的并行算法,最小化处理器之间的通信开销(包括通信量和通信轮次),来完成这个分布式矩阵转置操作。 解题过程 我们循序渐进地分析并解决这个问题。 第一步:理解数据映射关系 这是最关键的一步。我们首先用数学和图示明确转置前后每个数据元素的归属变化。 原始分布 (A) : 全局矩阵 A 的大小为 M x N 。 处理器 (p, q) 存储子块 A_local[p][q] 。这个子块包含全局矩阵中以下行和列的数据: 行范围: [p * (M/P), (p+1) * (M/P)) (为简化,假设 M 能被 P 整除,N 能被 Q 整除)。 列范围: [q * (N/Q), (q+1) * (N/Q)) 。 我们用 (i, j) 表示全局矩阵 A 中一个元素的位置。那么,它位于处理器 (floor(i / (M/P)), floor(j / (N/Q))) 上。 目标分布 (AT) : 转置矩阵 AT 的大小为 N x M 。 在目标分布中, 处理器网格的划分逻辑不变 (仍然是 P x Q 网格)。但现在,AT 的“行”对应原矩阵 A 的“列”,AT 的“列”对应原矩阵 A 的“行”。 处理器 (p, q) 需要存储子块 AT_local[p][q] 。这个子块包含全局矩阵 AT(也就是原矩阵 A 转置后)中以下行和列的数据: AT 的行范围: [p * (N/Q), (p+1) * (N/Q)) (因为 AT 的行数 = N)。 AT 的列范围: [q * (M/P), (q+1) * (M/P)) (因为 AT 的列数 = M)。 对应于原矩阵 A, AT[r][c] = A[c][r] 。所以,处理器 (p, q) 最终需要持有的数据,是原矩阵 A 中所有满足以下条件的元素 A[i][j] : j (原列号)在 [p * (N/Q), (p+1) * (N/Q)) 区间内(因为 j 成为 AT 的行索引 r )。 i (原行号)在 [q * (M/P), (q+1) * (M/P)) 区间内(因为 i 成为 AT 的列索引 c )。 结论 :处理器 (p, q) 最终需要 的数据, 最初是存储在 处理器 (q, p) 上的! 因为根据原始分布,行属于块 q (对应目标分布的列块),列属于块 p (对应目标分布的行块)。 这是一个非常清晰的 对称映射 :转置操作对应于在处理器网格上沿着主对角线交换数据块。 第二步:朴素算法与通信分析 一个最直接的想法是:每个处理器 (p, q) 直接向它的“目标”处理器 (q, p) 发送自己本地持有的整个 A_local[p][q] 子块。然后,目标处理器收到后,在本地对其进行内存转置,得到 AT_local[q][p] 。 通信模式 :这是 P*Q 个点对点通信。对于不在对角线上的处理器( p != q ),这是一个双向交换。对角线上的处理器( p == q )只需要在本地进行内存转置。 问题 : 数据不对应 :处理器 (p, q) 发送的是 A_local[p][q] (原矩阵的一块行连续、列连续的区域),但处理器 (q, p) 需要的是 A_local[q][p] 转置后的数据吗? 不完全是! 仔细看第一步的分析:处理器 (q, p) 最终需要的是原矩阵中“行属于块 p ,列属于块 q ”的数据。而 A_local[q][p] 是“行属于块 q ,列属于块 p ”的数据。这完全是两个不同的数据块。因此,简单的块交换是 错误 的。 通信内容复杂 :即使我们修正目的地,处理器 (p, q) 本地持有的连续数据块,在转置后需要被 拆散并发送到多个不同的处理器 。反之,它最终需要的数据也来自于多个不同的处理器。 所以,我们需要一个更精细的通信策略。 第三步:设计正确的通信模式——两步法(All-to-All) 核心洞见:转置操作 (i, j) -> (j, i) 可以分解为两个阶段: 索引重排阶段1 :交换行和列的“归属”。我们可以先进行一次全局数据重排,使得每个处理器持有的数据,是其 最终需要的数据在转置前的“列连续”形式 。 本地计算与最终重排 :然后每个处理器在本地对数据进行内存转置,得到最终布局。 一种经典高效的方法是使用 All-to-All 集体通信 。 具体算法步骤(假设 P=Q,网格是方的,这很常见,且简化描述): 本地重排(准备发送数据) : 在每个处理器 (p, q) 内部,将本地子块 A_local[p][q] (尺寸 (M/P) x (N/Q) )看作由 P 个更小的“行块”组成(因为最终目标分布与行块数 P 有关)。更准确地说,处理器思考:“我最终需要的数据,其原行号属于哪个块?”。 处理器 (p, q) 将其本地数据 A_local[p][q] 在列方向上划分为 Q 个“列片段” (Column Slice),每个片段大小为 (M/P) x (N/(Q*Q)) ?这里需要更精确。实际上,它应该按 最终需要它的处理器所在的行号 来划分。 正确的划分是:对于处理器 (p, q) ,它需要为 处理器网格中第 r 行的所有处理器 准备数据。具体来说,它本地 A_local[p][q] 的第 k 列(全局列索引为 j = q*(N/Q) + k ),在转置后将变为 AT 的第 j 行。这一行数据最终属于处理器 (r, *) ,其中 r = floor(j / (N/Q)) 。由于 j 的范围就在 q 这个列块内,所以 floor(j / (N/Q)) 恒等于 q ?不对, j 是全局列索引, (N/Q) 是目标分布的行块大小。 floor(j / (N/Q)) 计算的是这个数据在目标分布中属于哪个 行块 (即哪个处理器行号 p_target )。而 j 的取值范围是 [q*(N/Q), (q+1)*(N/Q)) ,所以 floor(j / (N/Q)) 的结果可能是 q ,但更一般地,我们需要将本地数据的每一列(或每一组列)映射到其目标处理器行。 简化理解(当 P=Q 时) :每个处理器将其 (M/P) x (N/Q) 的本地块,在逻辑上划分为一个 P x Q 的小块网格 (与处理器网格同构)。本地块中位于 (sub_i, sub_j) 位置的小子块(尺寸 (M/(P*P)) x (N/(Q*Q)) ),其对应的原全局元素 A[i_global][j_global] 将去往处理器 (floor(j_global/(N/Q)), floor(i_global/(M/P))) ,即处理器 (q_target, p_target) 。其中 p_target 由 sub_i 决定, q_target 由 sub_j 决定。 因此,为了准备通信,处理器 (p, q) 将其本地数据 按行分割成 Q 个“行片段” (Row Slice)可能更直接。每个行片段包含 (M/P) 行,但只有 (N/Q)/Q = N/(Q*Q) 列(假设整除)。这个行片段 k ( 0 ≤ k < Q )将被发送到处理器 (k, ?) 。实际上,决定因素是目标处理器的 行号 ,而目标处理器的行号由原数据的 列索引范围 决定。 让我们采用更标准的描述:处理器 (my_row, my_col) 将其 local_m x local_n 的块,在列方向上切分成 P 个列块(因为目标处理器网格有 P 行)。第 dest_row 个列块( 0 ≤ dest_row < P )将被发送到 处理器行号为 dest_row 的某个处理器。这个列块包含了原矩阵中列索引属于“最终将由目标处理器行 dest_row 负责”的那些列的数据。 All-to-All 通信(按行) : 在处理器网格的 每一行 内,进行一次 All-to-All 通信 。 同一行 p 上的所有 Q 个处理器 (p, 0), (p, 1), ..., (p, Q-1) 都参与。 每个处理器 (p, q) 准备 P 个数据块(上一步划分好的),其中第 dest_row 个块( dest_row 从0到P-1)被发送给 同一行内 的处理器 (p, dest_row) ?不,等等,目标处理器行号是 dest_row ,但列号呢? 关键:在这次行内All-to-All中,每个处理器 (p, q) 发送给同行处理器 (p, k) 的数据,是原矩阵中那些 最终将属于目标处理器行 k 的列数据。同时,它也从同行处理器 (p, k) 那里接收数据,这些数据是原矩阵中那些 最终将属于当前处理器行 p 的列数据(但从不同的原列块 k 来的)。 经过这次通信后,处理器 (p, q) 现在拥有一个“拼凑”起来的中间块,其行数仍是 local_m = M/P ,但列数现在变成了 N (因为从同行所有处理器那里收集了不同列块的数据)。更重要的是,这个中间块的 列顺序 现在是“按最终的目标处理器行号”排列好的。也就是说,这个中间块的数据,其原列索引 j 已经满足了“转置后属于处理器行 p ”的条件(因为 floor(j/(N/Q)) = p 现在可能不成立,但数据已经按目标行聚合了)。实际上,经过这一步,处理器 (p, q) 拥有了所有 原行号属于块 p ,且 原列号属于任意块 ,但 这些数据转置后的行号(即原列号)其所属的目标处理器行号是 q 的数据?让我们换个方式。 更清晰的表述(两步All-to-All转置) : 第一步All-to-All(按行) :每个处理器 (my_row, my_col) 将其本地块按列切分成 Q 个列条带。它将第 k 个列条带发送给同行的处理器 (my_row, k) 。同时,它从同行的每个处理器 (my_row, k) 那里接收一个列条带。通信结束后,每个处理器 (my_row, my_col) 现在拥有一个 (M/P) x N 的中间矩阵,但这个矩阵的列是“按发送方的原始列号 my_col ”交织的。实际上,它现在拥有的是原矩阵中所有“行属于块 my_row ”的数据(这正是它最终需要的行的来源),但列是全局的。然而,这些数据的列组织方式还不是最终处理器网格列号 my_col 所需要的。 本地转置 : 每个处理器对第一步通信后得到的中间块(尺寸 (M/P) x N )在内存中进行 本地转置 ,得到一个尺寸为 N x (M/P) 的块。 第二次All-to-All 通信(按列) : 在处理器网格的 每一列 内,进行一次 All-to-All 通信 。 同一列 q 上的所有 P 个处理器 (0, q), (1, q), ..., (P-1, q) 都参与。 每个处理器 (my_row, my_col) 将本地转置后的块(尺寸 N x (M/P) )按行切分成 P 个行条带。它将第 k 个行条带发送给同列的处理器 (k, my_col) 。同时,它从同列的每个处理器 (k, my_col) 那里接收一个行条带。 通信结束后,处理器 (my_row, my_col) 将收到的 P 个行条带拼接起来,形成一个尺寸为 (N/Q) x (M/P) 的最终本地块 AT_local[my_row][my_col] 。检查维度:第一次通信后块尺寸为 (M/P) x N ,本地转置后为 N x (M/P) 。按行切分成P份,每份大小为 (N/P) x (M/P) ?不对,N行分成P份,每份是 (N/P) x (M/P) 。但我们的目标是 (N/Q) x (M/P) 。这里矛盾了,说明我们的划分粒度假设(P=Q)是重要的。 当 P=Q 时, N/P = N/Q 。因此,第二次All-to-All按列通信后,处理器 (my_row, my_col) 正好得到 (N/Q) x (M/P) 的最终块,并且数据的布局正是转置后所需的布局。 第四步:算法总结与通信复杂度 算法流程(对于 P x Q 处理器网格,且 M 可被 P 整除,N 可被 Q 整除): 输入 :处理器 (p, q) 持有本地块 A_local[p][q] ,尺寸 m x n ,其中 m = M/P , n = N/Q 。 按行 All-to-All : 处理器 (p, q) 将其 m x n 的本地块, 按列分割成 Q 个 n/Q 宽的列条带 (实际上,就是按本地列维度均匀分成Q份,每份尺寸 m x (n/Q) ,但注意 n = N/Q ,所以 n/Q = N/(Q*Q) 。当 P=Q 时, N/(Q*Q) = N/(P*Q) )。 在处理器行 p 内,执行一次 All-to-All 集体通信。处理器 (p, q) 将第 k 个列条带发送给同行处理器 (p, k) ,并从每个同行处理器 (p, k) 接收一个列条带。 通信后,处理器 (p, q) 拥有一个 m x N 的中间矩阵 B (因为它从Q个处理器各收到一个 m x (n/Q) 的条带,拼起来是 m x (n/Q * Q) = m x n ?这里有误。它发送了Q个条带,也接收了Q个条带,每个条带大小是 m x (n/Q) 。因此,接收到的数据总大小是 m x (n/Q) * Q = m x n 。所以中间矩阵 B 的大小仍然是 m x n ,但数据内容已经重新排列了。实际上,经过这一步,处理器 (p, q) 现在持有的数据,是原矩阵中所有“行属于块 p ”且“列属于块 q ”的数据吗?不,是“行属于块 p ”且“这些列在转置后的目标处理器列号是 q ”的数据。这步操作可以理解为矩阵的“局部转置”或数据重排,为下一步做准备。更标准的解释是,这步操作相当于对处理器网格的列下标进行了一次全交换。 为了更通用,我们采用文献中常见的描述:这两步All-to-All合起来称为 “全交换转置” 。每个处理器最初有 (M/P) x (N/Q) 的数据。目标是有 (N/Q) x (M/P) 的数据。算法如下: 第一步(按行All-to-All) :将本地块视为 P x Q 个小块的网格(每个小块大小 (M/(P*Q)) x (N/(P*Q)) ?这要求M和N能被P* Q整除)。但实际上,我们可以按通信缓冲区来理解。处理器 (p,q) 准备Q个发送缓冲区,每个大小为 (M/P) x (N/(Q*Q)) 。发送缓冲区 k 包含原块中所有列 j 满足 floor(j / (N/(Q*Q))) = k 的数据?这太复杂。 一个更简洁正确的描述(对于块划分)是: 每个处理器将其 (M/P) x (N/Q) 的块划分为 Q 个列条带,每个条带大小为 (M/P) x 1 ?不对。 实际上,经典的解决方案是执行两次 全体到全体(All-to-All) 通信,中间进行一次本地转置。并且,为了最优化,通常假设处理器网格是方的(P=Q),这样通信量是对称的。 我们给出一个最终简洁且正确的版本: 假设 P = Q = sqrt(K) ,K为处理器总数。每个处理器本地块大小为 (M/sqrt(K)) x (N/sqrt(K)) 。 第一次All-to-All(在行通信组内) : 在每个处理器行内部(共 sqrt(K) 个处理器),执行一次 All-to-All。 每个处理器将其本地块按 列 分割成 sqrt(K) 个 列片段 ,每个片段大小为 (M/sqrt(K)) x (N/K) 。 处理器 (p, q) 将第 k 个列片段发送给同行(第p行)的处理器 (p, k) 。 同时,它从同行每个处理器 (p, k) 接收一个列片段。接收到的第 k 个片段来自原处理器 (p, k) 的第 q 个列片段。 通信后,处理器 (p, q) 将收到的 sqrt(K) 个列片段按发送方索引 k 顺序拼接,形成一个中间矩阵 Intermediate ,其大小为 (M/sqrt(K)) x (N/sqrt(K)) (因为 (N/K) * sqrt(K) = N/sqrt(K) )。 注意 :此时数据已经重新排列,但这个中间矩阵的物理意义是:它包含了最终结果中,那些“原行号属于块 p ”且“ 转置后 的列号(即原行号)所属的 目标处理器列 是 q ”的所有数据。但这还没有进行实际的转置操作。 本地转置 : 每个处理器在本地对其 Intermediate 矩阵(尺寸 (M/sqrt(K)) x (N/sqrt(K)) )执行内存转置,得到 Transposed_Intermediate ,尺寸为 (N/sqrt(K)) x (M/sqrt(K)) 。 第二次All-to-All(在列通信组内) : 在每个处理器列内部(共 sqrt(K) 个处理器),执行一次 All-to-All。 每个处理器将其 Transposed_Intermediate 块按 行 分割成 sqrt(K) 个 行片段 ,每个片段大小为 (N/K) x (M/sqrt(K)) 。 处理器 (p, q) 将第 k 个行片段发送给同列(第q列)的处理器 (k, q) 。 同时,它从同列每个处理器 (k, q) 接收一个行片段。接收到的第 k 个片段来自处理器 (k, q) 的第 p 个行片段。 通信后,处理器 (p, q) 将收到的 sqrt(K) 个行片段按发送方索引 k 顺序拼接,形成最终的结果矩阵 AT_local[p][q] ,其大小为 (N/sqrt(K)) x (M/sqrt(K)) ,这正是所期望的尺寸,并且其内容正是全局矩阵 AT 中属于该处理器的子块。 通信复杂度分析 : 每次 All-to-All 通信,每个处理器需要发送和接收 sqrt(K) - 1 个消息(如果忽略对角线自通信)。 每个消息的大小约为 (M*N) / (K * sqrt(K)) 个数据元素(第一次通信消息大小为 (M/sqrt(K)) * (N/K) = M*N/(K*sqrt(K)) ,第二次相同)。 因此,总通信量(每个处理器)约为 2 * (sqrt(K) - 1) * (M*N)/(K*sqrt(K)) ≈ 2 * (M*N) / K 。注意 (M*N)/K 是平均每个处理器持有的数据量。所以, 总通信量大约是每个处理器本地数据量的两倍 。这是一个非常高效的结果,因为数据至少需要被移动一次(转置的本质),而这个算法通过巧妙的两次集体通信,将数据移动量控制在了理论下限的常数倍以内。 通信轮次为 2 次集体通信。在诸如 MPI 这样的并行编程模型中,可以使用 MPI_Alltoall 原语高效实现。 总结 并行矩阵转置算法巧妙地利用了处理器网格的二维结构,通过 两次All-to-All集体通信 (一次按行,一次按列)夹着一次 本地内存转置 ,高效地完成了分布式数据布局的转置。其核心思想是将全局的、不规则的数据交换路径,分解为两个规整的、沿着处理器网格行和列方向的集体通信操作,从而可以利用优化的网络通信模式,并使得通信量接近理论下限。这个算法是许多并行数学库(如ScaLAPACK)中的基础通信操作之一。