metis 多线程图划分论文笔记

多线程图划分

目的

利用多线程来完成图划分,从而达到提高效率的作用。

定义

balance:
balance 是用来衡量 k 划分有多么的平等的。
balance 定义为 k * max_i{η(V_i)/η(V)},其中 η(A)是 A 集合中的点的权重之和。balance 接近一表明划分非常的平均,当值大于 1 的时候,说明有更好的划分方式。

给定用户提供的一个 ε,我们定义一个 k 划分问题为划分图为最小的 edgecut 且 balance 不超过 1 + ε


多层次图划分

依旧分为:
coarsening,initial partitioning,uncoarsening。

有算法 KMetis,ParMetis

KMetis

Kmetis 将图存储在类似于用于稀疏矩阵的压缩的稀疏的行格式的结构里面,以及用了额外的向量,包括一个存储顶点权重的向量,一个 partition 向量 P,一个 vertex mapping(点映射)向量 C。

coarsening

在 Kmetis 的粗化阶段中包含两个部分:匹配(matching)和收缩(contraction)。匹配就是 C i C_i Ci 产生于 G i G_i Gi 图的 vertex mapping 向量,而收缩就是产生了图 G i + 1 G_{i+1} Gi+1(来源于 C i C_i Ci G i G_i Gi)。

matching 阶段:
我们用一个 matching 向量 M 开始,来生成 C。如果两个点 v 和 u,在 M 中有相应的项,则它们匹配,比如 M v = u M^v=u Mv=u 以及 M u = v M^u=v Mu=v(就是 M[u] = v 以及 M[v] = u,则 u 和 v 匹配了)。他们也同样映射于相同的一个粗化节点 c = C i v = C i u c=C^v_i=C^u_i c=Civ=Ciu (i 表示第几个图的 vertex mapping 向量 C,即 C i C_i Ci 这一向量中的 C i C_i Ci[v]= C i C_i Ci[u],表示 v 和 u 将在下一个图中合并为一个粗化节点)
根据点的权重的升序来遍历他们,相同权重的则以随机顺序遍历(来鼓励探索在多次运行中找到解决的空间?)。如果一个点 v 已经被 match 过了,那么他被跳过。如果他没被 match 那么找该点(v)的邻居中与 v 有权重最高的边的且没被 match 的邻接点 u,然后将 u 和 v 记录在 matching vector M 中,即 M v = u M^v=u Mv=u 以及 M u = v M^u=v Mu=v。如果 v 没有 unmatch 的邻居,则记录 M v = v M^v=v Mv=v。升序访问是为了减少不匹配节点的出现(通过让低权重的节点先选择邻居来匹配)。

contraction 阶段:两个 match 的点 v,u 映射到同一个点 c。c 的权重等于 v 和 u 权重之和。连接 c 的边就是连接 v 和 u 的边减去 v,u 之间相连的边。当多个边连接两个粗化节点时,这些多个边被简化成一个边,其权重为这些边权重之和。

coarsening 阶段会终止,在最终的那个图包含的点的数目小于一个阈值 t,在 Kmetis 算法中这个阈值 t 是基于 G 0 G_0 G0 图的规模的。因此不论 G 0 G_0 G0 的大小如何,initial partitioning 所花费额时间和粗化与精化的时间之比是相同的。

initial partitioning

对图 Gm 的 partitioning 的划分是由 Kmetis 的姐妹程序 PMetis 完成的,PMetis 递归的平分来生成 k 个区域的划分。PMetis 生成一系列的划分,并最终选择一个最好的划分效果,给定为 P m P_m Pm

P i P_i Pi[v] 即对于图 G i G_i Gi 来说,v 点放在哪个划分中

uncoarsening

uncoarsening 也由两部分组成:映射(projection)和精化(refinement)。

在 uncoarsening 的 projection 阶段中,
Pi+1 中的 partition labels(即每个点属于哪个划分) 通过 C i C_i Ci 赋给 G i G_i Gi 中的精细化的节点。即对于一个精细化的节点 v 可以被表示为 P i v = P i + 1 C i v P^v_i = P^{C^v_i}_{i+1} Piv=Pi+1Civ C i [ v ] C_i[v] Ci[v] G i + 1 G_{i+1} Gi+1 对应的粗化的节点 c,即 P i P_i Pi[v]= P i + 1 P_{i+1} Pi+1[c],即精细化的节点放在与粗化的节点相同的划分中)。在这一时刻的 cut edges 的权重和 partition 的权重对于 P i + 1 P_{i+1} Pi+1 P i P_i Pi 是相同的。

refinement 阶段,用贪婪算法,且只对边界节点进行操作。refinement 阶段开始时,所有的边界节点加入到优先级队列中,其中以他们的 gain 值作为其优先级判定, 对于点 v 的 gain 由其与其他的定义为:对于 v 所有的邻接节点 u 来说,若 v 和 u 同处于一个划分,则加上 {v,u} 边权重的负值,若 v 和 u 不同处于一个划分,加上 {v,u} 边权重的正值。从优先级队列中取出一个节点,考虑是否移动,如果移动可以减少总的 edgecut 并且仍然是平均的权重划分(balance 定义),则移动节点,并更新每一个划分的权重。为了加快过程,每一个节点都存储着一些信息,即其连接不同划分的边的权重。如果至少一个节点,并且没有超过最大的可以移动的次数,则尝试使用新的边界节点进行另一次移动,否则这个划分就投影到更精细的图了。

uncoarsening 到 G 0 G_0 G0 时停止

ParMetis

ParMetis 是基于 MPI 的并行化 KMetis,因此也通过 p 个进程来达到并行化。
每个进程都被分配了一个连续的块,包含 ∣ V ∣ p \frac{|V|}{p} pV 个顶点,然后存储相关的边和权重。每个进程也存储着一个向量 D,长度为 p + 1(p 为进程个数),用于描述整个图的顶点分布的情况。一个属于进程 i 的顶点 v 为 D i ≤ v < D i + 1 D^i \leq v < D^{i+1} Div<Di+1

coarsening

ParMetis 的 Matching 过程被分为不同的 pass。在每一个 pass 中进程选择最大边(多轮match,因为不是每一个节点都能被match)。在本地的节点的 match 和 KMetis 相同(即被分到这个进程里的节点),match 非本地的节点就在该轮 pass 的最后产生了一个请求,该请求其他进程可以接受或拒绝。经过一定轮数的 pass 就到了 contraction 阶段,因为对于每一个关联节点的每条边都只存储一次,所以当非本地的节点的 match 信息被传输了之后,每个进程都可以独立的进行 contraction 了

initial partition

通过递归的二分而成,进程对其他进程进行广播然后保证每个进程都有 G m G_m Gm 的信息,然后都用一个相同的随机种子,让进程各自创建 k 分区所需的二分树的一个分支。即所有进程对图生成一个初始的二分,然后一半的进程生成右半部分的二分,另一半生成左半部分的二分,然后继续下去。这些划分被组合起来,这样每个进程就可以将 k 个分区应用于 G m G_m Gm 的本地部分

uncoarsening

每个进程将划分的信息从 V i + 1 V_{i+1} Vi+1 投影回 V i V_i Vi(自己拥有的部分),它还会传输它拥有的粗节点的信息,然后包括其他进程的细节点
ParMetis 对 KMetis 的 refinement 做了一些改进。
并行化的选择移动会导致一个权重高的边连接的两个边界顶点都被选择移动到彼此的分区中,这样会增加 edgecut。因此 ParMetis 将每个 refinement 迭代分为两个 pass。第一个 pass 顶点只能移动到比他们所在的分区的 label 低的,第二个 pass 只能移动到比其所在的高的。
而且,每个这些 pass 都包含3个部分。
第一个部分,每个进程生成一个向量包含其所有想要的移动。第二个部分,一旦进程沟通了这将如何影响分区平衡,移动请求将被拒绝直到剩余的请求可以导致一个平衡的分割。第三个部分,对于那些允许移动的节点,点和划分的信息被更新。与 KMetis 一样,在没有移动或者达到最大次数的时候停止


SHARED MEMORY MULTILEVEL GRAPH PARTITIONING

coarsening

通过 match 然后通过 contraction 构建下一级的图,即 match 的点结合然后其邻接表也结合。
如 KMetis 以权重递增的方式遍历然后找每一个点的最大边相连的邻接的 unmatch 的点,如果没有这样的点那么就保持 unmatch。并行化该算法时,将点分给不同的线程,每个线程都将分给他的点来 match。

matching

第一类方法:
一个直接的实现方法时用一个共享的 matching 向量 M,并且每一个线程都会看这个向量来决定点的 matching 状态,然后更新这个向量每次一个点的 matching 状态改变(确定)。为了确保没有 race condition,每一个读写操作都有锁保护。这也会导致额外的开销然后导致差的并行性能。

另一个方法是不锁读但保证写是有效的。当一个线程确定要 match u 和 v,该线程锁住 M v M^v Mv M u M^u Mu,然后最后再检查一下保证两个节点都是 unmatch 的,如果是,则设置 M v = u M^v=u Mv=u 以及 M u = v M^u=v Mu=v 在放锁之前。因为点的数目可以达上百万个,对每一个点都设置一个锁是不可行的,我们应当用一个比线程数目大得多的固定数目的锁,然后用一个哈希函数将点映射到锁上,也就是说获得一个锁会锁多个不相关的顶点,锁以升序顺序获取来防止死锁。
以上被称为 fine-grain matching。

第二类方法:
上述方法依然有可能有大量的数据访问的同步开销,又有新的一个方法是类似于 ParMetis 算法的 two-pass 的方式的方法。每个线程在 match v 的过程中,通过优先选择那些也被分给这个线程的 v 的邻接点。一个线程对于其他的每一个线程也都有一个请求缓冲区。那些涉及到分配给其他线程的邻接节点的 matchings 会被放到缓冲区中。在第二个 pass 中,每个线程都要去处理与别人的缓冲区,接受或者拒绝这些请求,并且相应的修改 M。经过几次 pass 后,不匹配的节点与其自己匹配。
上方法被称为 multi-pass matching。

multi-pass matching 解决了同步开销的问题,但是要维护和服务请求缓冲区。

第三类方法:
利用了启发式和前两类的一些特点,和 fine-grain 一样对于非本地和本地的节点都填写 M(即每个线程匹配分给自己的结点的时候可以匹配分给其他线程的节点),但是不一样的是这次写不受保护,所以 v 相信自己已经匹配上了 u,而 u 相信自己匹配上了 w 的情况是可能发生的。修正这种情况:线程遍历分给他们的节点,然后对于 M v = u M^v=u Mv=u 以及 M u ≠ v M^u \neq v Mu=v 的 v 节点,v 节点都该跟自己匹配。只要点的数目远大于线程个数,那么这种情况不会发生太多次来影响性能。
这被称为 unprotected matching。

matching 阶段结束后,在 matching 上进行并行的前缀和,这样子的话在第二次 pass 中粗化节点个数就可以并行的分给 match 来产生 C(记录每一个点对应的是哪个缩小以后的点)

contraction

contraction 阶段很简单(在共享内存的时候):下一级的粗化的图的节点被分配到各个不同的线程之中,线程根据匹配的节点来合并邻接表(应当是根据 C 向量)。

initial partition

两个方法:
parallelizing each bisection, parallel bisectioning, 和 parallelizing all of the k-way partitionings, parallel k-sectioning.

在 parallel bisectioning 中,每个线程将图 G m G_m Gm 二分成 G a G_a Ga G b G_b Gb,然后从中选出一个更好的划分成 G a G_a Ga G b G_b Gb 的结果,然后线程分成两个组,一个组递归的二分 G a G_a Ga,另一个组递归的二分 G b G_b Gb,直到最后有 k 区域划分为止。这需要线程至少同步 l o g 2 k log_2k log2k 次然后选出最好的划分。每一次二分时都有 16 种二分的结果可以选择。

在 parallel k-sectioning 中,每个线程递归二分地产生 G m G_m Gm 的 k 区域划分,然后选择最好的划分结果。生成 16 个 k 区域划分的结果。尽管 parallel k-sectioning 比 parallel bisectioning 的并行任务数少,但是由于他只在最后才同步(选择最好的划分结果),所以开销也少。

uncoarsening

包含两步:第一步,下一级粗化图的 P i + 1 P_{i+1} Pi+1 被投影到这一级的粗化图来计算 P i P_i Pi,第二步,基于贪心移动的 refinement 算法来减少 k 分区的 edgecut(KMetis 部分)。

因所有的数据都可以被线程访问,包括 P i + 1 P_{i+1} Pi+1,那么 projection(投影)就可以通过把点分到多个线程,然后让线程决定分给他的点放到哪个划分中,来轻松的并行化,这是一个由内存限制的操作,当用大量的 cores 完成这件事的时候,内存带宽将会成为限制。

而并行化第二步比较麻烦,既要放松贪心策略,又要保证 balance,举个例子 t a t_a ta 线程想要把点 v 移动到划分 V i V_i Vi t b t_b tb 线程想要把点 u 移动到划分 V i V_i Vi,他们都做了检查移动后划分 V i V_i Vi 的权重为 V i V_i Vi+η(v) 或 V i V_i Vi+η(u),但实际上他们一起移动之后权重变为了 V i V_i Vi+η(v)+η(u),有可能超过了最大限制。还有个问题是并行的移动顶点可能会导致 edgecut 减少(即使基于贪心),比如有两个点 v 和 u,分别属于划分一和二,边 {v,u} 的权重大于 v 和 u 有关的其他边的权重之和,在这种情况下,v 移动到 u 或者 u 移动到 v 将可以减少 edgecut,但如果这两个点 v 和 u 被分配给不同的线程,然后每个线程都决定要移动点,那就不会减少 edgecut,然后基于 v 和 u 的其他边,edgecut 可能潜在的增加。再者,KMetis 中的串行算法只考虑边界上的点,并且预先计算了边界节点对于其他分区的 edgecut,这些信息会随着定点移动而更新,uncoarsening 会非常紧凑而迅速,导致很难减少并行化的开销(本身 uncoarsening 就比 coarsening 时间要短得多)。

为解决上述问题,提出两种方法:两种方法里都是每个线程拥有自己的优先级队列,而非全局的优先级队列,了,因此边界节点被分给各个线程,然后线程把分给他的点插入到优先级队列来处理。在 ParMetis 中的经验表明,这种方法对于划分的质量影响很小。

第一种方法:
试图保持所有分区信息都是最新的,在每次移动之后更新。一个线程从优先级队列中移出一个节点后,将其移动到最符合条件的一个分区中,一个分区 V i V_i Vi 符合的条件是:其权重加上移进的点的权重小于可用的最大的划分权重(小于最大值),而最符合条件的分区则是其连接到将要移进的点的边的权重是最大的(即看这个点可符合的分区中,哪个分区连接到这个点的边的权重之和最大,且符合定义)。如果 v 所在的分区就是其最佳的分区,或者找不到分区,那么 v 就不移动。否则的话 v 和 v 的邻接点都被锁定,且包含 v 的分区和 v 将要移动的分区都被锁定(来保证移动)(用那个比线程多的锁的方法来),然后最后做一个检查确保 balance 约束,并且移动确实会减少 edgecut,还要保证 refinement-related data-structures 是一致的,执行完操作就释放锁,这被称为 fine-grain refinement。

第一种方法有三种潜在的问题:
1.锁,导致同步开销。
2.他要求分区的数量大于线程的数量来不断更新分区的权重,以免成为瓶颈。
3.由于是多个线程一起更新数据结构,可能会导致错误的问题。

解决上述问题,第二个方法更接近 ParMetis
第二种方法:
为保证节点不会交换而增加 edgecut,在一个时刻节点只能往一个方向流动。当一个线程从优先级队列中取出一个结点的时候,它决定是否如 fine-grain refinement 一样移动节点,并且也增加了移动方向的限制。额外的,每个线程对其他每一个线程都有一个 update buffer,当他决定更新一个顶点 v 时,先更新本地节点,然后对非本地的更新都放到 buffer 中,在每个线程都从优先级队列中移除了固定数量节点后,所有线程在 commit 移动后互相 communicate 潜在的分区的权重。k 分区的 balance 是通过取消那些正处于 pending 状态的 moves 来保证的,直到剩下的 moves 可以保证 balance。commit 剩余的移动,线程从 update buffer 中阅读其他线程的移动来更新本地的节点,一直重复上述步骤直到优先级队列空。这被称为 coarse-grain refinement。

对于这两种方法,pass 在所有线程的优先级队列清空时结束。在最后一次 refinement pass 或者达到最大 pass 数时 refinement 终止。

Thread Lifetimes

两种线程生存时间的方法:fork-join 和 thread-persistence
fork-join:在 parallel block of work(metis细分成好几个阶段) 的开始处启动,在结束时停止。
在这里面即在 matching 的开始阶段创建,在结尾 join(c 里面应该是等待线程结束) 他们,然后对于 contraction,initial partition,projection 和 refinement 都是这么做的。(线程是从线程池拿出来还是创建和终止线程跟实现就有关系了)
thread-persistence:创建多个线程,在每个阶段结束时做同步。(中间不会 kill,会一直运行线程)

Data Ownership

数据的所有权和线程生命周期是紧密耦合的。即分给线程的顶点以及这些顶点相关的边。
三种方法:
1.动态分配
2.在每个并行工作块开始静态的分配工作
3.数据所有权在整个执行过程中都保持在多级范式中(metis 需要多轮迭代,每一轮迭代中 thread 负责的数据不变)
fork-join 将所有权限制在一个工作块内,所以第三个方法只在 thread-persistence 中用。

第一种方法:
没有数据所有权的概念,以增加分配工作的开销为代价,提供了动态负载平衡的好处,也不能保持数据局部性。从共享池中提取顶点,执行 match 之类的操作如 IV-A 和 IV-C 中提的一样。IV-B 中提的 initial partition 不受这个设计的影响。这一方法称为 dynamic work-distribution。

第二种方法:
在每个工作块开始分配工作,数据局部性仅限于工作块。对于每一个图 G 0 G_0 G0 G 1 G_1 G1等,在顶点匹配、收缩等工作开始时顶点被分配给线程。static work-distribution.

第三种方法:
线程持续拥有的数据(包括其派生的数据)的所有权,比如 G 0 G_0 G0 产生的粗顶点,线程也同样拥有,称为 persistent work-distribution。

三种实现:mt-metis-du 用 fork-join 以及 dynamic work-distribution,mt-metis-su 用 fork-join 及 static work-distribution,mt-metis 用 thread-persistent 和 persistent work-distribution


你可能感兴趣的:(算法,图计算)