GNNAdvisor
目前,支持GNN训练和推理的工作可以分为两类:
但都是初步的、不完善的,有三个方面的缺陷:
提出GNNAdvisor
GNNAdvisor使用Pytorch作为前端,在底层是用CUDA写的,并用pytorch wrapper集成到pytorch里面。它可以被看作是具有kernel优化、运行时支持的operator
使用GNNAdvisor实现的两层GCN如下:
import GNNAdvisor as GNNA
import torch
# create a GCN class
class GCN(torch.nn.Module):
def __init__(self, inDim, hiDim, outDim, nLayers):
self.layers = torch.nn.ModuleList()
self.layers.append(GNNA.GCNConv(inDim, hiDim))
for i in range(nLayers - 2):
layer = GNNA.GCNConv(hiDim, hiDim)
self.layers.append(layer)
self.layers.append(GNNA.GCNConv(hiDim, outDim))
self.softmax = torch.nn.Softmax()
def forward(self, X, graph, param):
for i in range(len(self.layers)):
X = self.layers[i](X, graph, param)
X = self.ReLU(X)
X = self.softmax(X)
return X
# define a 2-layer GCN model
model = GCN(inDim=100, hiDim=16, outDim=10, nLayers=2)
# loading graph and extracting input properties
graphObj, inputInfo = GNNA.LoaderExtractor(graphFile, model)
# set runtime parameters automatically
X, graph, param = GNNA.Decider(graphObj, inputInfo)
# run model
predict_y = model(X, graph, param)
# compute loss and accuracy
# gradient backpropagation for training
贡献:
GNN计算节点v在第k+1层的embedding的公式如下:
a v k + 1 = A g g r e g a t e ( k + 1 ) ( h u k ∣ u ∈ N ( v ) ∪ h v k ) h v k + 1 = U p d a t e ( k + 1 ) ( a v k + 1 ) a_v^{k+1}=Aggregate^{(k+1)}(h_u^k|u\in{N(v)\cup{h_v^k}}) \\ h_v^{k+1}=Update^{(k+1)}(a_v^{k+1}) avk+1=Aggregate(k+1)(huk∣u∈N(v)∪hvk)hvk+1=Update(k+1)(avk+1)
其中, a v k + 1 a_v^{k+1} avk+1表示聚合了邻居信息(比如邻居的embedding)之后的结果, h u k h_u^k huk表示节点u在第k层的embedding, N ( v ) N(v) N(v)表示节点v的邻居节点
Aggregate: 图操作,聚合邻居的信息(比如邻居节点的embedding)
Update: NN操作,比如全连接层、MLP,将aggregate得到的结果:乘w、加b(w和b都是可学习的参数)
经过多次Aggregate和Update迭代之后(也就是多个GNN层之后),得到输出embedding,可以用来执行下游任务(比如节点分类、链接预测等)
注意:初始的embedding,可以是从图数据中得到的、也可以是通过embedding生成算法得到的,并不包含在GNN的执行过程中
有三点原因,不以传统的图处理系统为基础,拓展GNNAdvisor的工作:
Pytorch、TensorFlow,内置了许多NN operator,针对欧几里得数据做了很多优化,但是对于图这样的非欧几里得数据,面临一些挑战:
GNN的update较为固定,但是aggregation变化较多,主流的aggregation方法有两种:
举例来说,输入embedding维度是128,隐藏维度是16。GCN先更新,embedding维度从128降到16,然后以16的embedding维度进行聚合;GIN先以128的embedding维度进行聚合,再更新。因此对于GCN来说,更倾向于内存优化(把embedding放到cache里面);对于GIN来说,更倾向于并行计算上的优化(将较大的embedding维度进行划分)
现实世界的图的节点度数大多遵循幂律分布,这会带来负载不均衡的问题(如果划分节点的话),在GNN上,负载不均衡的问题更严重(相对传统的图处理系统来说,他们的节点带的是标量),因为每个节点都带有一个更高维度的embedding。
节点embedding使得某些传统图处理系统上的cache优化失效,因为embedding维度更高,而cache的内存相对较小,存不下太多embedding。比如,对于节点带有标量属性的传统图处理系统,64KB的L1 cache可以存放 16 × 1 0 3 16\times{10^3} 16×103个node,而对于embedding维度为64的GNN来说,只能存放 256 256 256个node。
根据节点度数和embedding维度,可以估计节点的负载。如果负载取决于节点度数,那么从节点的邻居上并行,同时执行更多的邻居;如果负载取决于embedding维度,那么从embedding的维度上并行。
图社区是现实世界图的一个关键特征,一小小组节点,组内保持强连接(即很多边连接),组间保持弱连接(即较少的边连接)。
图处理系统以节点为中心的聚合方式中,每个节点独立地load自己的邻居节点(每个节点带有标量属性)(如Fig. 3(b)),会重复加载,但是由于是并行load,因此并行带来的优势与重复加载引入的冗余相抵消。但对于GNN来说,每个节点都带有高维的embedding,此时,重复加载带来的额外成本显著提高。若采用图社区的特点,则可减少冗余加载(如Fig. 3©)。
GNNAdvisor包含了由输入驱动的带参数的2D Workload Management,包含三种技术:coarse-grained neighbor partitioning, fine-grained dimension partitioning, warp-based thread alignment
将邻居节点划分为大小相等的neighbor group(NG),并将NG作为调度的基本单元。
如Fig. 4所示,NG的大小取为2(这是一个可调的参数),节点0有四个邻居2 3 6 10,划分到NG-0和NG-1中,注意:一个NG中只放同一节点的邻居(便于调度、同步),当邻居节点的数量不能被NG的大小整除时,会带来不平衡,可以通过改变NG的大小来缓解。
为了支持neighbor group,引入neighbor-partitioning module和neighbor-partitioning graph store,neighbor-partitioning建立在graph loader上,将图上的邻居节点划分为一个个NG;neighbor-partitioning graph store存储关于分组的元信息,包括分组id、源节点、邻居节点的起始位置。
划分邻居进行聚合有三个好处:
划分邻居,可以缓解低维embedding的负载不均衡问题,对于高维embedding,还需要进一步的操作:Fine-grained Dimension Partitioning
沿embedding划分一个NG的负载,如Fig. 5所示,原始的NG的负载被分配给了11个连续的线程,每个线程负责embedding的一个维度的聚合(如果ngs=2,那么一个线程管理两个embedding的一个维度上的聚合),如果维数大于线程数,就迭代。
使用维度划分有两个原因:
4.1、4.2在逻辑上描述了如何平衡负载,但是没有回答如何将负载映射到GPU硬件的层面上。
一种直观的想法是:将连续的线程分配给不同的ng(一个warp对应多个ng),同时处理多个ng(如Fig. 6(a)),但是不同的线程可能具有不同的行为(计算、访存),而warp内部是SIMT,因此,会有部分线程处于等待状态,导致线程发散。(如Fig. 6(a))
因此,让一个warp管理一个ng。可以很好的并行化,减少线程发散。(如Fig. 6(b))
有三个好处:
为了进一步利用2D负载,引入了针对GNN的内存优化,Community-aware Node Renumbering和Warp-aware Memory Customization
为了利用图社区的性质,引入lightweight node renumbering,重排节点id,以提高GNN聚合时的时空局部性。由代码可知:重排是在load之后进行的。
idea:将节点id的邻近性映射到GPU计算单元上。在GNNAdvisor中,2D workload management将节点id连续的节点的邻居分组分配给连续的warp(如果两个节点的id是连续的,那么它们的邻居分组会被分配给连续的warp)。因此,它们更有可能被分配到同一个SM上,来提高加载的共同邻居的数据局部性。
首先要回答一个问题,什么样的图更能从node renumbering中获利,作者的答案是,如Fig. 7(a)这样的、邻接矩阵近似对角模式的图,很难获利,因为这样的图的社区内部的节点id已经是连续的了;而像Fig. 7(b)这样的、形状很不规则的图,更容易从node renumbering中获利。因此提出一个metric—***Averaged Edge Span(AES)***来决定执行重排是否是有利的。
其中,E是图的边集,#E是边数, s r c i d src_{id} srcid和 t r g i d trg_{id} trgid是每条边源节点、目标节点的id。在加载图的时候,计算AES,作者在大型图上的分析表明:当 A E S > ⌊ N 100 ⌋ \sqrt{AES}>\lfloor{\frac{\sqrt{N}}{100}}\rfloor AES>⌊100N⌋时,更有可能提升性能(注意,N前面有个#,typora公式内部不让打#,所以没打上去)
利用了Rabbit Reordering,这是一种完全并行且成本低廉的图重排序技术。具体而言,它首先通过分层合并边和聚类节点来最大化图的模块化程度。然后通过深度优先搜索(DFS)遍历为每个聚类生成节点顺序。
更重要的是,Rabbit Reordering可以层次化地捕获图社区(即,一组较小的子社区包含在一个更大的社区中,如Fig. 7(a)所示)。不同粒度的这些社区可以很好地匹配GPU缓存层次结构,较小的子社区(占用一个 SM)可以从L1缓存中获得数据局部性的好处,而较大的社区(占用多个 SM)则可以从更大的L2缓存中获得数据局部性。
PyG、Gunrock在读写embedding和聚合时,使用了大量的全局内存访问和原子操作,带来了巨大的开销,并未充分利用shared memory。
作者提出了一种以warp为中心的共享内存优化技术。
为每个邻居组(warp)的目标节点保留一个共享内存空间(每个目标节点对应一块空间),这样来自warp的线程可以将规约的中间结果缓存到共享内存中。随后,在线程块内部,我们仅为每个目标节点指定一个warp(称为leader),以将中间结果从共享内存复制到全局内存。具体而言,每个warp(维护在warpPtr中)有三个属性:nodeSharedAddr(邻居分组聚合结果的共享内存地址)、nodeID(目标节点的ID)和leader(一个布尔标志,指示当前warp是否是用于将结果从共享内存更新到全局内存的leader warp)。
GNNAdvisor的Decider中的分析模型和自动参数选择功能。
GNNAdvisor的性能分析模型有两个变量:WPT是workload per thread,SMEM是shared memory usage per block
其中,ngs是一个邻居分组中的节点数量,Dim是embedding的维度,dw是4.2中负责聚合的线程数,tpb是thread per block,tpw是thread per warp,FloatS在GPU上是4字节。tpb是用户自己选择的,tpw在GPU上是32。
为了确定ngs和dw的值,作者设计了以下两个步骤:
注意:DGL是最优秀的baseline,因此,选择在所有数据集上与DGL相比。而只在PyG、NeuGraph、Gunrock擅长的数据集上对比,其中Gunrock没法做GNN,因此使用SpMM来比较。
这篇论文主要关注点在于如何利用model dataset的特点,来针对性地做优化,发挥在给定model dataset上的最大性能。
第三章,讲述了作者设计第四章、第五章优化策略所根据的性质。
第四章,考虑的主要是节点度数和embedding维度,对于节点度数大的而言,节点上的并行(邻居划分 4.1)更有帮助,而对于embedding维度大的而言,embedding上的并行(维度划分 4.2)更有帮助。4.3讲了如何在硬件层面上实现4.1 4.2提到的策略。
第五章,考虑的主要是图社区这一性质,将图数据经过节点重排(5.1)组织成图社区性质较强的形式,可以减少聚合阶段的重复load。5.2设计了一个算法来利用shared memory缓存归约的中间结果。
第六章,是如何自动选择第四章第五章的策略中涉及到的调优参数(ngs dw)。