§稀疏矩阵的表示
以下面的矩阵为例:
COO(Coordinate)格式将它存储在三个数组中:
row: 0 0 1 1 1 2 2 3 3
col: 0 1 1 2 3 1 2 2 3
val: 1 2 3 4 5 6 7 8 9
将行号进行压缩可以得到CSR(Compressed Sparse Row)格式:
row: 0 2 5 7 9
col: 0 1 1 2 3 1 2 2 3
val: 1 2 3 4 5 6 7 8 9
相邻的两个元素标识了矩阵一行的非零元素在col和val中的下标范围[row[i], row[i+1])。为保证最后一行也不越界,一般在row末尾再添加一个哨兵元素,它的值是矩阵非零元素个数,如上面的9。
§矩阵转置
§稠密矩阵
稠密矩阵的转置比较简单,将上三角的元素与下三角逐一交换即可:
|
|
§COO格式
稀疏矩阵的转置就不太容易了。对于COO格式的矩阵,如果算法对元素存储顺序没有要求,那么可以简单地交换每个元素的行号和列号,如矩阵
的转置为:
A:
row: 0 0 1 1 1 2 2 3 3
col: 0 1 1 2 3 1 2 2 3
val: 1 2 3 4 5 6 7 8 9
At:
row: 0 1 1 2 3 1 2 2 3
col: 0 0 1 1 1 2 2 3 3
val: 1 2 3 4 5 6 7 8 9
用代码表示即为:
|
|
§CSR格式
对于CSR格式就不能简单地交换行号和列号了,可以使用按行填充的办法来进行转置。算法的基本想法是:按行遍历矩阵,对每个非零元素,可以得知其行号和列号,列号为它在转置矩阵中的行号,行号为它在转置矩阵中的列号,把非零元素逐个填充到转置矩阵中对应行的位置。此处有两个问题:1、转置矩阵中某行的起始下标是多少;2、如何保证填充过去的元素有序。
第一,转置矩阵中各行的起始下标在数值上等于它前面行的非零元素个数之和,因此可以这样计算:
|
|
第二,转置矩阵的元素顺序需要先按行号排序,行内按列号排序,遍历原矩阵时,从第0行逐行遍历到最后一行,则填充到转置矩阵时列号就是逐渐增大的,因此只需要按行主序遍历原矩阵,得到的转置矩阵就已经满足顺序要求了。完整的算法如下:
|
|
仍然以矩阵
为例,其CSR格式存储为:
row: 0 2 5 7 9
col: 0 1 1 2 3 1 2 2 3
val: 1 2 3 4 5 6 7 8 9
根据列号计算得到转置的每行元素个数为:
rowT: 1 3 3 2
对它累和得到转置矩阵的row:
rowT: 0 1 4 7 9
将原矩阵的元素逐个拷贝到转置矩阵中,过程为:
|
|
其中下划线是各行第一个非零元素所在位置,其它未填充的位置留白。
这种算法是串行算法中工作得最好的,因为它只需要遍历矩阵一次,时间复杂度为O(M+NNZ),其中M为矩阵行数,NNZ为非零元素个数。
另一个简单的思路是将CSR格式中压缩的行还原成COO格式,交换行号和列号,再按行号排序、行内按列号排序,再将它压缩成稀疏矩阵,这个算法的时间复杂度为O(NNZ log NNZ),明显高于前一个算法,它的好处在于可以更方便地实现为并行算法。此处给出使用CUDA设计的算法:
|
|
首先第一步是将CSR格式中压缩的行再展开成COO格式中的行号,这个非常简单,按行遍历矩阵,将[row[i], row[i+1])范围内的行号都置为i即可,如
row: 0 1 4 7 9
展开成
row: 0 1 1 1 2 2 2 3 3
这一步由核函数Csr2CooKernel来做。
其次是交换行号和列号,即将上一步展开的行号作为转置矩阵的列号,将原矩阵的列号作为行号。
然后是对转置矩阵的非零元素排序,先按行号排序,同行号按列号排序。由于排序之前,行号相同的元素其列号已经是从小到大排列的,只是不相邻而已,因此使用稳定的排序算法排序可以直接得到行内列号有序的结果。也可以使用自定义的比较函数进行排序,这个比较函数的比较方法为先比较行号,如果行号相等再比较列号,大致为:
|
|
此处选用第一种方案,原因很简单:不想多引入一个结构体,在GPU上对结构体的操作不好封装,而且对结构体数组的访问性能一般不如数组结构体。
串行的排序算法我们已经很熟悉了,比如:快速排序、归并排序、堆排序等,时间复杂度都是O(n log n)。当然快速排序最坏情况下是O(n^2),可以通过一些策略来改善其性能,比如GNU stl(SGI stl)的采用的方案为内省式排序(Introspective Sort),由Musser在1997年[1]提出,GNU stl的实现细节可以参考[2]。
然后并行的排序算法基本都不能直接由快速排序等经典排序算法并行化而来,必须专门设计。比较常见的并行排序方法有:奇偶排序[3]、双调排序[4]、基数排序[5]等。实现这些算法太过复杂,此处调用thrust库完成排序。thrust库是一个类似于stl的模板库,已经集成在CUDA安装包中,默认会安装。其使用方法与stl类似,可以参考其文档[6]。thrust库的排序方法使用基数排序,拥有非常好的性能,此处直接调用。
排完序后,最后一步是将COO格式的行号压缩成CSR格式。一个简单地办法是统计各行的非零元素个数,然后进行扫描生成各行第一个非零元素的偏移:
|
|
或者利用这样的性质:行号变化的位置就是对应行的第一个非零元素所在的位置。如
ind: 0 1 2 3 4 5 6 7 8
row: 0 1 1 1 2 2 2 3 3
行号在下标为1、4、7的位置变化,它们就是对应行的第一个非零元素,因此不难得到row_offsets为:0, 1, 4, 7, 9。这个过程由核函数Coo2CsrKernel完成。需要注意:这个算法虽然简单,但边界条件非常多,要写正确非常困难,比如一个稍微极端的例子:
ind: 0 1 2 3 4 5 6 7 8
row: 0 0 0 0 2 2 2 4 4
压缩后应为:
row: 0 4 4 7 7 9
而不是:
row: 0 4 7 9
这个矩阵有5行,压缩后的行号应当有6个元素,第1、3两行没有非零元素。此外还要考虑第1个0以及最后一个哨兵元素的生成,还有注意下标不要越界!
§双共轭梯度法
双共轭梯度法可以求解非对称、非正定的方程组,其思想在于同时求解$Ax=b$和$A^Tx=b$,详细过程如下:
|
|
由于计算能力6.0以下的设备无双精度浮点类型的原子操作,所以需要自己实现一个,可以参考CUDA文档Programming Guide的B.14节Atomic Functions。
这种方案的缺点是需要使用原子操作,在数万甚至数十万个线程并发时非常影响性能,而且这个操作在循环中,需要反复调用,很容易成为性能瓶颈。因此此处使用第二种方案:先将矩阵转置,直接使用转置矩阵乘向量:
|
|
A和转置通过参数传入,因此调用方法为:
|
|
需要注意:计算出来的浮点数在判断等于时不能用==,只有判断它们的差是否足够接近于0。这是因为浮点数的计算有舍入误差,除非是直接赋值,计算出来的浮点数结果和真实结果一般都有误差,所以不能使用==来逐二进制位比较。具体细节可以参考IEEE 754浮点数标准[7,8]。
§参考文献
[1] Musser, David. (1997). Introspective Sorting and Selection Algorithms. Software Practice and Experience.
[2] http://feihu.me/blog/2014/sgi-std-sort/.
[3] https://zh.wikipedia.org/wiki/奇偶排序.
[4] https://en.wikipedia.org/wiki/Bitonic_sorter.
[5] https://en.wikipedia.org/wiki/Radix_sort.
[6] https://thrust.github.io/doc/modules.html.
[7] IEEE Standard for Binary Floating-Point Arithmetic.
[8] https://zh.wikipedia.org/wiki/IEEE_754.
§思考问题
尝试使用CUDA在CSR存储格式下实现稳定双共轭梯度法(BiCGStab)。