好的,我们来看一个尚未在列表中出现的、在并行与分布式计算中非常基础和重要的算法。
并行与分布式系统中的并行矩阵转置算法:基于块划分的通信模式优化
题目描述
矩阵转置是一个简单的操作:给定一个 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)))上。
- 全局矩阵 A 的大小为
-
目标分布 (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)。
- AT 的行范围:
- 对应于原矩阵 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)。
- 转置矩阵 AT 的大小为
结论:处理器 (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所需要的。
- 第一步All-to-All(按行):每个处理器
-
本地转置:
- 每个处理器对第一步通信后得到的中间块(尺寸
(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)中的基础通信操作之一。