圖片來源:http://fourier.eng.hmc.edu/e176/lectures/NM/node29.html
閱讀難度 ✦✦✧✧✧
特別感謝 冠大大、王大大。
《夢魘のCUDA》是 CUDA Programming 系列,此系列不會介紹任何 CUDA 的基礎知識,而會介紹一些 CUDA 相關的應用。本篇作為《夢魘のCUDA》系列的首篇文,將會介紹既實用又不實用的兩套 CUDA 內建 Library — cuBLAS / cuSPARSE;除此之外,本篇也會講解如何使用這兩套 Library 實作出經典應用 — Preconditioned Conjugate Gradient。之所以說是經典應用的原因是因為,cuSPARSE 幾乎是為了這個應用而誕生的。
1. Introduction
Conjugate gradient method 是一種數值分析 (Numerical analysis) 方法,用來解決大型稀疏線性方程組 (Large-scale sparse linear systems)。其中,線性方程組的矩陣必須是對稱正定矩陣 (Symmetric positive definite matrix)。
1-1. Inner Product Space
首先必須知道內積 (inner product) 的定義,對一個 map function $\langle\cdot,\cdot\rangle: V\times V \to F$,其中 vector space $V$, field $F$,若滿足以下三個條件則稱這個 map function 是 inner product,其中 $x,y,z\in V$:
- Conjugate symmetry:
這通常是定義給複數的 Vector space,對於實數的 Vector space 只需滿足 $\langle x, y \rangle=\langle y, x \rangle$
- Linearity:
這應該不需多做解釋
- Positive-definiteness:
注意 $\mathbf{0}$ 代表 Vector space $V$ 的零向量。
此外,這個性質也 imply 若 $\langle x,x\rangle=0$ 則 $x=\mathbf{0}$。
1-2. Symmetric Positive Definite Matrix
若一個 $N\times N$ 的 Real matrix $A$ 滿足以下條件,則稱 $A$ 為 Symmetric positive definite matrix:
- Symmetric:$A=A^T$
- Positive definite:對於任意非 $0$ 向量 $x\in\mathbb{R}^N$,滿足 $x^TAx>0$
Symmetric matrix (複數則是 Hermitian matrix) 具有以下特性:
- Eigenvalues 一定為實數
- 一定存在一組 Orthonormal eigenvectors
Prove Eigenvalues 一定為實數:
假設 Symmetric (Hermitian) matrix $A$,假設 $\lambda$ 為 $A$ 的 Eigenvalue,以及對應的 Eigenvector $x$,則 $Ax=\lambda x$。取 Conjugate transpose $\bar{x}^TA^\dagger=\bar{x}^T\bar{\lambda}$。由於 $A$ 為 Symmetric (Hermitian) matrix,$A^\dagger=A$,因此 $\bar{x}^TA=\bar{x}^T\bar{\lambda}$。兩邊同乘 $x$ 得到:
對 $Ax=\lambda x$ 兩邊同乘 $\bar{x}$:
得到 $\bar{x}^T\bar{\lambda}x=\bar{x}^T\lambda x$,若 $x\ne \mathbf{0}$,則 $\bar{\lambda}=\lambda$,因此 Eigenvalue 一定為 Real number。
Prove 一定存在一組 Orthonormal eigenvectors:
假設兩個 Symmetric (Hermitian) matrix $A$ 的 Eigenvalue $\lambda_a$ 與 $\lambda_b$,且 $\lambda_a\ne \lambda_b$,以及其分別對應的 Eigenvectors $x_a$, $x_b$,
得到 $\lambda_a \langle x_a, x_b \rangle=\lambda_b \langle x_a, x_b \rangle $,移項整理後得到:
由於 $\lambda_a \ne \lambda_b \Leftrightarrow \lambda_a-\lambda_b\ne 0$,因此得到 $\langle x_a, x_b\rangle=0$,表示 $x_a$ 與 $x_b$ 為 Orthogonal。
Symmetric positive definite matrix 有更棒的特性:Eigenvalues 一定都為正數
Prove Symmetric (Hermitian) matrix $A$ 的 Eigenvalue 都是正數 $\Leftrightarrow$ Matrix $A$ 為 Symmetric positive definite matrix:
$(\Rightarrow)$:假設 $\Lambda$ 為由 $A$ 所有 Eigenvalues 組成的 Diagonal matrix $\Lambda_{ii}=\lambda_i$,且 $\lambda_i \ne \lambda_j, i\ne j$,$Q$ 為各個 Column 由各個 Eigenvalue $\lambda_i$ 所對應的 Eigenvector $x_i$ 組成的 Matrix $\left[\begin{matrix} x_1, \dots, x_N \end{matrix}\right]$。則根據 $Ax=\lambda x$,得到:
由於 $A$ 為 Symmetric matrix,因此由 Eigenvectors 組成的 $Q$ 為 Orthonomal matrix,Orthonomal matrix 有特性 $Q^{-1}=Q^T$,因此得到:
接著假設任意 Vector $x$ 屬於 Vector space,且不為 $\mathbf{0}$,則:
由於 $A$ 為 Symmetric matrix,$\lambda_i>0$ 且 $y_i^2>0$ 因此得到 $x^TAx>0$,得證。
$(\Leftarrow)$ 假設 Symmetric positive definite matrix $A$ 的任意 Eigenvalue $\lambda$,以及對應的 Eigenvector $x\ne \mathbf{0}$,有 $Ax=\lambda x$。兩邊同乘 $x^T$:
由於 $A$ 為 Symmetric positive definite,因此 $x^TAx>0$。且 $x\ne\mathbf{0}$,因此 ${x^Tx} > 0$。得證。
基於這些特性,Symmetric positive definite matrix $A$ 可以定義內積。
利用 Symmetric positive definite matrix $A$ 定義一個基於 Vector space $\mathbb{R}^N$ 的內積空間 (Inner product space),內積定義為:
由於 Matrix $A$ 為 Symmetric,我們可以知道這個內積滿足 Inner product 的第一個條件 (Conjugate symmetry),也就是 $x^TAy=y^TAx$。由於 $x^TAy$ 是線性的,滿足 Inner product 的第二個條件 (Linearity),由於 $A$ 為 Positive definite,滿足 Inner product 的第三個條件 (Positive-definiteness)。因此,$ \langle \cdot,\cdot\rangle_A $ 是一個合法的內積。
產生 Symmetric positive definite matrix 非常簡單,設一個 $m\times n, m < n$ Matrix $A$,為 Full row rank,$AA^T$ 就會是 Symmetric positive definite matrix。
Prove $AA^T$ 是 Symmetric positive definite matrix:
假設 Matrix $M=AA^T$,$AA^T$ 為 Symmetric matrix。假設任意 Vector $x\in \mathbb{R}^m$ 且 $x\ne\mathbf{0}$,則:
故得證。
1-3. Steepest Descent
設 Linear system
其中 $A\in\mathbb{R}^{N\times N}$ 為 Symmetric positive definite matrix, $b\in \mathbb{R}^N$ 為已知的 Vector。若要求解 vector $x$,根據傳統的解法 (高中、大學教的方法)是,首先先移項 $A$,使用高斯法求出 $A$ 的 Inverse matrix $A^{-1}$ 後,求出 $x$:
然而,對於 Large-scale sparse matrix 而言 ($N$ 非常巨大),求 Inverse matrix 是非常困難的,因此另一種解法就是使用 Numerical method,將問題變成能夠使用 Iterative 趨近的方式來求出近似解。首先將 [式 1.3.1] 移項 $Ax-b=\mathbf{0}$,接著假設 Function $f(x)$,並令其一階微分:
根據一階微分,我們能夠得出 Quadratic form:
則目標變成:找到 $x$ 使得 $f’(x)=\mathbf{0}$。也就是說,找到 $x$,使得 $f(x)$ 為極值,且若 $A$ 為 Symmetric positive definite matrix,則該極值一定會是最小值。
證明方法非常簡單,假設 $x^* $ 滿足 $Ax^* = b$,我們只需證明添加上任意 Error $e\in \mathbb{R}^{N}\setminus {\mathbf{0}}$,一定滿足 $f(x^* + e) > f(x^*)$:
由於 $A$ 是 Positive definite,且 $e\ne \mathbf{0}$,因此最後一項 $\frac{1}{2}e^TAe > 0$,則可以得證當 $Ax^* =b$ 時,$f(x^*)$ 一定為最小值。因此我們將一個 Linear system 的問題變成了 Minimization problem:找到 $x$ 使得 minimize $f(x)$。
接著是要如何解這個 Minimization problem,一種方法就是使用 Steepest descent method。
這邊列出一些常用的 term:
- Error: $e_i=x_i-x^*$,寫成 Iterative form,$e_{i+1}=e_i+\alpha_i r_i$
- Residual: $r_i=b-Ax_i=-f’(x_i)$
Residual 與 Error 的關係式:
首先從任意一點 $x_0$ 開始,選擇負梯度方向 $-f’(x_0)=r_0$ 前進適當步長 $\alpha_0$,這個步長必須能夠在負梯度方向上最小化下一個 Step 的值 $f(x_1)$,也就是 $\alpha_0=\arg\min f(x_1)=\arg\min f(x_{0}+\alpha_0 r_0)$,通常會使用 Line search 的方式求得。求得步長後,移動到下一個位置 $x_1=x_0 + \alpha_0 r_0$,求出下一個步長 $\alpha_1$,再移動到下一個位置 $x_2=x_1+\alpha_1 r_1$ …,重複動作直到找到最佳解為止。
至於求解步長 $\alpha_i$ 可以使用求極值 (導數為 $0$) 的方式求得:
因為 $x_{i+1}=x_i+\alpha_i r_i$。由於 $f’(x_{i+1})=-r_{i+1}$,求得 $\alpha_i$:
最後就可以得到 Steepest descent method:
Gradient descent:往 Negative gradient 方向前進任意步長 $\alpha$,此步長可以使用固定值,也可以使用估計的方式,例如:SGD 等等。
Steepest descent:往 Negative gradient 方向前進適當步長 $\alpha$,此步長必須使得下一個值在這個方向上是最小值。
2. Methodology
2-1. Conjugate Gradient
Steepest descent 有個缺點在於,經常發生重複搜索同一方向 (如 Figure 8) 的情況,導致收斂速度變慢,為了解決這項問題,有人提出了 Conjugate gradient。首先假設一個 Vector set $\{p_0,p_1,\dots,p_{N-1}\}$ 代表 Search directions,且這些 Vector 兩兩互相垂直 (Orthoginal)。因此可以將每個 Step 的 $x$ 列為:
且
這樣就能夠確保每個搜索方向只需要搜尋一次就能夠找到最佳。由於每個 Step $i$ 的 Search direction $p_i$ 一定會與 Error $e_{i+1}$ 垂直,因此:
Prove 每個 Step $i$ 的 Search direction $p_i$ 一定會與 Error $e_{i+1}$ 垂直:
因為 $e_{i+1}=x_{i+1}-x^*$ 且 $x^*=x_{i+1}+\sum_{j=i+1}^{N-1}\alpha_jp_j$,因此 $e_{i+1}=-\sum_{j=i+1}^{N-1}\alpha_jp_j$,也就是說,$e_{i+1}$ 是 $\{p_{i+1}, \dots, p_{N-1}\}$ 的 Linear combination,而 $p_{i}$ 與所有$\{p_{i+1}, \dots, p_{N-1}\}$ 互為 Orthogonal,因此可以得證 $p_{i}$ 與 $e_i$ 一定是 Orthogonal。
如上根據 Error 的 Iterative form 展開後,可以算出:
然而,尷尬的是我們沒有辦法求出 $e_i$,因為 $e_i=x_i-x^*$,如果可以求得 $e_i$ 那麼 $x^*$ 就已經算出來了。避免掉這個窘境的方式是改變假設 Search directions 兩兩互為 $A$-orthogonal ($A$-conjugate),而非 Orthogonal:
這樣假設的原因是因為,如果 $e_i$ 算不出來,只需要對 $e_i$ 乘上 $A$,就會得到 $Ae_i=Ax_i-Ax^*=Ax_i-b$,如此就可以避免掉 $x^*$ 的問題。又因 $Ae_i=-r_i$,因此同 Steepest descent 求 $\alpha$ 可以得到:
因 $e_{i+1}=e_i+\alpha_ip_i$,因此:
移項整理後求得 $\alpha_i$:
(能夠想到這種方法的人肯定腦力發揮 100%)
接下來的問題在於,要如何產生 Mutually $A$-orthogonal vectors $\{p_0,\dots,p_{N-1}\}$,可以使用 Gram-Schmidt process,來 Iteratively 產生互相 Orthogonal 的向量。
假設一組 Linearly independent set $\{u_0, \dots, u_{N-1}\}$,Gram-Schmidt process 利用 Projection 的方式來製造出兩兩互相 Orthogonal 的 Vectors,原理非常簡單:
首先兩個 Linearly independent vectors $u_0, u_1$,先將 $u_1$ Project 到 $u_0$ 上算出投影向量 $\text{proj}_{u_0}(u_1)$,接著再將 $u_1$ 扣掉 $\text{proj}_{u_0}(u_1)$ 得到的就會是與 $u_0$ Orthogonal 的 Vector $u_1-\text{proj}_{u_0}(u_1)$。此時我們只需要令 $p_0=u_0$、$p_1=u_1-\text{proj}_{d_0}(u_1)$ 就可以得到兩個 Mutually orthogonal vectors $p_0, p_1$ 以同樣方式推廣至 $i$:
以此類推就可以得到一組 Mutually orthogonal vectors $\{p_0,\dots,p_{N-1}\}$,然而我們需要的是 $A$-orthogonal 的 vectors,因此可以定義 Projection 為 $A$ 內積空間的 Projection:
至於要如何找到 Linearly independent set $\{u_0, u_1, \dots, u_{N-1}\}$ 一個很直接的方式就是使用現成的 Residual $r$。
我們知道 Residual 與 Error 的關係式 $r_j=-Ae_j$,而 $e_j=x_j-x^*$,$x_j=x_0+\sum_{k=0}^{j-1}\alpha_kp_k$ 且 $x^*=x_0+\sum_{k=0}^{N-1}\alpha_kp_k$,全部展開來後得到:
由此可以看出,Residual $r_i$ 是由 $\{Ap_{i+1}, \dots, Ap_{N-1}\}$ 組成的 Linear combination,而 $\{Ap_{i+1}, \dots, Ap_{N-1}\}$ 是 Linearly independent set,因此可以知道 $\{r_0, \dots, r_{N-1}\}$ 也一定會是 Linearly independent set。
此外,這邊如果對兩邊同時乘上 $p_i$ 就會發生一件很有趣的事情,其中 $i<j$ ,由於 $p_i$ 與其他 $p_j, \dots, p_{N-1}$ 為 $A$-orthogonal,因此得到:
使用 Residual $r$ 代替 $u$ 後,令 $\beta$ 為 Projection 的 Coefficient 項:
因此 [式 2.1.4] 變成:
將 [式 2.1.7] 兩邊同乘 $r_j$,得到:
由 [式 2.1.6] 可以知道當 $i<j$ 時 $p^T_ir_j=0$ 且 $p_k^Tr_j=0$ 因為 $k\le i-1<i<j$,因此:
可以得出一個結論:Residual $r$ 兩兩互為 Orthogonal。
除此之外,若 $i=j$ 時,$p^T_ir_i \ne 0$ 但 $p_k^Tr_i=0$ 因為 $k\le i-1<i$,因此得到:
代入到 [式 2.1.3] 的 $\alpha_i$,得到新的 $\alpha_i$:
已知 $r_{j+1}=r_j-\alpha_jAp_j$,對等號兩邊乘上 $r_i$,整理後同除 $\alpha_j$:
根據 [式 2.1.8],可以得到,只有兩個狀況下 $r^T_iAp_j$ 會有值:
將兩邊同除以 $-p_j^TAp_j$,得到 $\beta_{ij}$,由於在 Gram-Schmidt process 中 $\beta_{ij}$ 只存在 $i<j$ 這個 Case,不存在 $i=j$ 的 Case,因此只剩下一種 Case $i=j+1$,剩下其他 Case 都為 $0$:
將 [式 2.1.9] 的 $\alpha_{i-1}$ 代入,最後得到新的 $\beta$:
結果 $p_i$ 一整個不見了,全部變成 Residual $r$。完全出乎意料。
寫到這邊為止的我:
最後將全部放在一起得到完整的 Conjugate Gradient:
第一行 $p_0=r_0=b-Ax_0$ 是 Initial condition,之後就是持續 Iterate 直到 Residual $|r_{i+1}|\to 0$ 就結束 Iteration。通常可以另外設定一個 Tolerance 參數 $\epsilon \to 0$,當 $|r_{i+1}| < \epsilon$ 時結束 Conjugate gradient。
接下來就是 Convergence Analysis 與 Complexity 分析的部分,雖然這部分才是真正 Numerical Analysis 要做的事情,但礙於篇幅且不是很重要,在這邊就直接忽略,有興趣的可以自行研究 Ch. 9 Convergence Analysis / Ch. 10 Complexity。這本書真的寫得很棒,有機會一定要看完!(我在說我)
2-2. Preconditioned Conjugate Gradient
如果有看 Ch. 9 Convergence Analysis / Ch. 10 Complexity 的話應該就會知道,Conjugate gradient 的收歛性取決於 Matrix $A$ 的 Condition number $\kappa(A)$ (Iteration 與 $\sqrt{\kappa(A)}$ 成正比),$\kappa(A)$ 小則容易收斂,$\kappa(A)$ 大則不容易收斂。而 Condition number 定義為 $\kappa(A)=\frac{\lambda_\text{max}}{\lambda_\text{min}}\ge 1$,其中 $\lambda$ 為 $A$ 的 Eigenvalue。(等我學會了 Numerical Analysis 再來補充)
在某些情況下,我們會希望 Conjugate Gradient 能夠再收斂的更快,例如:對於 $N$ 為 Million 等級的 Matrix $A$,我們不希望 Conjugate Gradient 執行 100 萬次才收斂到解答,而是希望在 1000 次內就可以得到近似解。這種情況下我們就會需要對原本的 Linear system 進行 Preconditioning,使得新的 Linear system 能夠更快收斂。
假設 Linear system 不容易收斂 (也有人稱做 ill-conditioned system) $Ax=b$,我們希望可以找到新的 Linear system $\hat{A}\hat{x}=\hat{b}$,且新的 Linear system 能夠收斂的更快。換句話說,希望可以找到一個 $\hat{A}$,滿足 $\kappa(\hat{A})\ll \kappa(A)$。一種方法就是選擇一個 Non-singular matrix $M$,並套用到 Linear system:
使得 $\kappa(M^{-1}A)\ll \kappa(A)$,同時 $M^{-1}A$ 必須維持 Symmetric positive definite matrix,$M$ 就稱為 Preconditioner。若要維持 Symmetric positive definite matrix,最簡單的方式就是 $M$ 同為 Symmetric positive definite matrix,為了確保 $M$ 為 Symmetric positive definite matrix,我們也可以利用另一個 Non-singular matrix $E$,且令 $M=EE^T$ 來表示 $M$,如此就可以確保 $M$ 一定是 Symmetric positive definite matrix (證明請看 [Sec 1-2])
Non-singular matrix $M$ 表示 $M$ 是 Invertible,且具有 Inverse matrix $M^{-1}$。
至於要去哪裡找到如此好的 $M$,聰明如你,最直覺的方式就是選擇 $M=A$,如此一來,$M^{-1}A=A^{-1}A=I$,我們就可以確保 $\kappa(M^{-1}A)=\kappa(I)=1\ll\kappa(A)$,同時 $I$ 也是 Symmetric positive definite matrix。然而,事實卻沒有這麼簡單,原因是因為,如果取 $M=A$ 的話,勢必要先計算出 $A^{-1}$ 才能套用進新的 Linear system $A^{-1}Ax=A^{-1}b$,但問題就在於,就是因為 $A$ 的 Inverse matrix $A^{-1}$ 很難求才會需要使用 Conjugate gradient 的方式來求解,要不然 Linear system $Ax=b$ 早就解完了。因此,只能限定 $M\approx A$,且 $M$ 必須更容易計算 Inverse。
最終歸納出 $M$ 必須具有以下條件,只要 $M$ 具有以下條件,就可以大幅縮減 Condition number:
- $\kappa(M^{-1}A)\ll\kappa(A)$,有一說是 $M^{-1}A$ 的 Eigenvalues 必須要更 Clustered,因為 $\kappa=\frac{\lambda_\text{max}}{\lambda_\text{min}}$
- $M^{-1}A$ 是 Symmetric positive definite matrix
- $M\approx A \Longrightarrow M^{-1}A\approx A^{-1}A= I$
- $M$ Invertible 且比 $A$ 更容易計算 Inverse matrix
接著就是如何求解新的 Linear system,一種方式是直接計算出 $M^{-1}$ 後,將新的 $\hat{A}=M^{-1}A$,$\hat{b}=M^{-1}b$ 直接帶入 Conjugate gradient。另一種方式是將 $M$ 分解 $M=EE^T$ 且 $E$ 可能是比 $M$ 還要更容易計算 Inverse 的 Matrix (例如: Triangular matrix)。如果使用第二種 Case 的話,可以將 Linear system 拆解並轉換成新的 Linear system $\hat{A}\hat{x} = \hat{b}$:
其中 $\hat{A}=E^{-1}AE^{-T}$,$\hat{x}=E^Tx$,$\hat{b}=E^{-1}b$。帶入 Conjugate gradient 後得到新的 Conjugate gradient (其實就是全部加上 Hat):
接下來就是將 Hat 都展開來。計算出新的 Residual $\hat{r}_i$ 與舊 Residual $r_i$ 的關係:
再由 [式 2.1.2] 可以得到:
因此定義 $\hat{p}_i=E^Tp_i$,則 $\hat{\alpha}_i$ 可以展開:
接下來將 $\hat{x}_{i+1} = \hat{x}_i+\hat{\alpha}_i\hat{p}_i$ 展開得到:
接下來 $\hat{r}_{i+1}=\hat{r}_i-\hat{\alpha}_i\hat{A}\hat{p}_i$ 也展開:
接下來展開 $\hat{\beta}_{i+1}$:
接下來展開 $\hat{p}_{i+1}=\hat{r}_{i+1}+\hat{\beta}_{i+1}\hat{p}_i$:
最後一件最重要的事情就是展開 $\hat{p}_0=\hat{r}_0$:
全部整理起來得到:
接著令 $z_i=M^{-1}r_i$ 得到 Preconditioned conjugate gradient:
結果發現其實根本不需要 $E$ 的我:
2-3. Incomplete-Cholesky Preconditioned Conjugate Gradient (ICCG)
Preconditioner $M$ 可以有很多種,在上一節中我們討論出 $M$ 必須具有以下特性:
- $\kappa(M^{-1}A)\ll\kappa(A)$
- $M^{-1}A$ 是 Symmetric positive definite matrix
- $M\approx A \Longrightarrow M^{-1}A\approx A^{-1}A=I$
- $M$ Invertible 且比 $A$ 更容易計算 Inverse matrix
但根據 Preconditioned conjugate gradient 計算 $z_{i+1}=M^{-1}r_{i+1}$,其實可以發現不一定要計算出 $M^{-1}$。假若 $M$ 可以被分解為 $M=EE^T$,帶入公式後得到
令 $y=E^{-T}r_{i+1}$,原本需要計算出 Inverse matrix $M^{-1}$ 變成只須計算 $E^{-1}$ 並分別求解 $y=E^{-T}r_{i+1}$ 與 $z_{i+1} = E^{-1}y$,就能能夠計算出 $z_{i+1}$。因此若有辦法將 $M$ 分解成更容易計算 Inverse 的 Matrix $E$ 那將會一大福音。正好,Cholesky 認為任何 Symmetric (Hermitian) positive definite matrix $M$ 都能夠分解為 Lower triangular matrix $L$ 及其 Transpose $L^T$ (若非 Real matrix 則為 Conjugate transpose),也就是 $M=LL^T$,這個方法稱作 Cholesky factorization。而剛好 Triangular matrix 非常好求 Inverse。
Prove: 若 $M$ 為 Symmetric positive definite matrix,則存在唯一的 Cholesky factorization $LL^T$,其中 $L$ 是 Lower triangular matrix,且 Diagonal element 皆為正。
(等我想到再補充)
然而,為了滿足 $M\approx A$ 而直接取 $M=A$ 計算 Cholesky factorization 還是會遇到同樣 $A^{-1}$ 的問題,除此之外,對於 Sparse matrix ($n\ll N\times N$,其中 $n$ 為非零項的數量) 而言, Cholesky factorization 是一大問題:經過分解後會爆出一堆非零項,使得 Sparse matrix 變得不是 Sparse。因此,解決方法就是使用 Incomplete-Cholesky factorization,來維持 Matrix 的 Sparsity。Incomplete-Cholesky factorization 在對 $A$ 計算 Cholesky factorization 的時候會將原本 $A$ 為 $0$ 的項維持為 $0$,只針對非零項計算,因此最後的結果會與原先的 Matrix 擁有相同的 Sparsity,同時可以得到一個很好的 Approximation $M=LL^T\approx A$。
2-4. ICCG Algorithm
Recall 前幾節的結果,Incomplete-Cholesky preconditioned conjugate gradient:
假設 Initial guess $x_0=\mathbf{0}$,可以得到:
轉換成演算法:
基本上這個演算法是一步一步根據推出來的公式計算,最標準的流程。維基百科也是使用這個順序。但為了後續方便使用 cuBLAS/cuSPARSE 實做,我比較習慣稍微調動一些順序變成下圖:
但基本上這兩個演算法做的事情是完全相同的。
3. cuBLAS & cuSPARSE
cuBLAS 與 cuSPARSE 都是 CUDA 內建的 Library,但是不同版本 CUDA 的 cuBLAS 與 cuSPARSE API 或多或少會有一些差異,這篇將以 CUDA 11.0 版本為主,此外也會加入一些 CUDA 8.0 的資料作為補充。
3-1. cuBLAS Introduction
cuBLAS 是以 CUDA 實作的 BLAS (Basic Linear Algebra Subprograms) library。提供了一些常用計算 Matrix/Vector 相關的 High-level API。例如:Matrix/vector copy、sort、dot product、multiplication 等等,另外也有提供對於特殊類型矩陣 (symmetric、triangular、hemitian) 做過優化的 API。
cuBLAS 使用 column-major,所以在儲存 Array 時需要注意順序。此外,在 Fortran 中是使用 1-based indexing,但在 C/C++ 中是使用 0-based indexing。
使用 cuBLAS 時首先需要在程式碼中 Include cublas_v2.h
,後面的 v2
代表是新的 API,而舊的 API cublas.h
則是 CUDA 4.0 以前的 API。1
編譯時需要加上 Library 的 Link
- 使用
nvcc
Compile 需要加上-lcublas
:1
nvcc hello_cublas.cu -o hello_cublas -lcublas
- 使用 Visual Studio 則是從
Property
加:- 首先到
CUDA C/C++
的地方,在Additional Include Directories
地方加上$(CudaToolkitIncludeDir)
。這是為了讓 Visual Studio 找的到 cuBLAS 的 header file。 - 接著到
CUDA Linker
的地方,在Additional Librbary Directories
地方加上$(CudaToolkitLibDir)
,並在 Additional Dependencies 地方加上cublas.lib
- 首先到
這樣就可以正常編譯 cuBLAS。
需要注意的是 Visual Studio 的 CUDA C/C++
與 CUDA Linker
頁籤是對應 .cu
File,如果想要在一般 .c
或 .cpp
File 使用 cuBLAS 則必須在 VC++ Directories
與 Linker
下的 Input
頁籤做相同的處理,否則會出現 Link error。
3-2. cuBLAS APIs
在使用 cuBLAS 的 API 前,需要先呼叫 cublasCreate
建立 cuBLAS 的 Handle,有 Handle 才能正常使用 cuBLAS API,且在呼叫 cuBLAS API 時,Handle 必須傳入 Function。最後程式結束時必須呼叫 cublasDestroy
來刪除 cuBLAS Handle。如下:
1 | int main(int argc, char **argv) |
cubStat
用來呼叫的 Function call 是否有正常執行,如果 API 有發生錯誤的話,Error code 會儲存在內,詳細可以參考官方的 Document,每個 Function call 底下都會寫有可能回傳的 Error code。
接下來是建立 Vector,使用 cudaMalloc
在 GPU 建立空間後,將資料複製到 GPU 有兩種方式:
- 第一種是使用 CUDA 原生的
cudaMemcpy
複製到 GPU 上 - 第二種是使用 cuBLAS API
cublasSetVector
/cublasGetVector
來存取 GPU 上的空間
不管使用哪個 API,最後都必須 Free memory。
1 | double vec[] = {/*...*/}; // Vector in Host memory |
使用 cuBLAS API cublasSetVector
/cublasGetVector
的好處在於,他有提供使用者設定 increment 的功能,也就是複製每個資料間要間隔多少資料,這在從一個 Matrix 中複製出 Column vector 或 Row vector 時非常實用。
接下來是 Matrix type,同樣使用 cudaMalloc
方式建立 GPU 空間,同 Vector,可以使用 cudaMemcpy
或是使用 cuBLAS API cublasSetMatrix
/cublasGetMatrix
存取 Matrix。最重要的是,他是 Column-major!因此 Host memory 與 Device memory 都必須儲存成 Column-major 的形式。
1 | cudaMalloc(&d_A, N*M*sizeof(float)); |
cuBLAS API cublasSetMatrix
/cublasGetMatrix
有兩個參數 lda
與 ldb
,分別代表 Source 與 Destination Matrix 的 Leading dimension。
接下來是介紹 cuBLAS 的 Operation API,cuBLAS 將 API 分成 3 個 Level:
- Level-1:與 Vector 相關,或是 Vector-vector operations。例如,
min
、max
、sum
、copy
、dot product
、euclidean norm
等等。 - Level-2:Matrix-vector operations。例如,Matrix-vector multiplication 等等。
- Level-3:Matrix-matrix operations。例如,Matrix-matrix multiplication 等等。
根據不同的資料型態,API 的名稱也會不一樣:
Type | Notation <t> |
Meaning |
---|---|---|
float |
‘s’ or ‘S’ | Real single-precision |
double |
‘d’ or ‘D’ | Real double-precision |
cuComplex |
‘c’ or ‘C’ | Complex single-precision |
cuDoubleComplex |
‘z’ or ‘Z’ | Complex double-precision |
舉例來說, Level-1 的 Function cublas<t>axpy
,計算 Vector $x$ 與 Vector $y$ 的加法,$y\gets\alpha x+y$
Level-2 的 Function cublas<t>gemv
,計算 Matrix $A$ 與 Vector $x$ 的乘法,$y\gets\alpha \text{OP}(A)x+\beta y$
其中 $\text{OP}$ 是在計算前,cuBLAS 會對 Matrix $A$ 套用的 Operation:
OP | Meaning |
---|---|
CUBLAS_OP_N |
The non-transpose operation is selected |
CUBLAS_OP_T |
The transpose oeration is selected |
CUBLAS_OP_C |
The conjugate transpose operation is selected |
Level-3 的 Function cublas<t>gemm
計算 Matrix $A$ 與 Matrix $B$ 的矩陣乘法,$C\gets\alpha\text{OP}(A)\text{OP}(B)+\beta C$
Level-3 除了一般的 Matrix-matrix multiplication 之外,cuBLAS 還有提供額外的 API 給特殊的 Matrix 型態使用:cublasFillMode_t
用來指定是 Upper triangle 還是 Lower triangle。
cublasFillMode_t |
Meaning |
---|---|
CUBLAS_FILL_MODE_LOWER |
The lower part of the matrix is filled |
CUBLAS_FILE_MODE_UPPER |
The upper part of the matrix is filled |
例如 cublas<t>symm
,計算 Symmetric matrix-matrix multiplication,如果你的 Matrix 是 Symmetric 就可以使用這個 API 來進行更有效率的計算,則 cuBLAS 在運算時其實只需要取一半的值 (Upper triangle 或是 Lower triangle) 出來就可以了,另外一半是對稱的,這樣在 GPU memory 的存取上會比較有效率。
另外還有像是 cublas<t>trmm
計算 Triangle matrix-matrix multiplication。
cublasDiagType_t
代表 Matrix 的對角線是不是 Unit (Unit matrix 的那個 unit),如果是的話,cuBLAS 在運算時就會忽略對角線。
cublasDiagType_t |
Meaning |
---|---|
CUBLAS_DIAG_NON_UNIT |
The matrix diagonal has non-unit elements |
CUBLAS_DIAG_UNIT |
The matrix diagonal has unit elements |
除此之外,cuBLAS 還有提供 Stream
API 可以進行 Asynchronous 的計算,詳細請看 document。
3-3. Hello cuBLAS
這節將會使用 cuBLAS 示範 Matrix-matrix multiplication。首先寫好基本的 Framework:
1 |
|
這段 Code 在一開始建立了 cubHandle
,接著向 cuBLAS 打招呼,順便印出 cuBLAS 的版本資訊,最後將 cubHandle
摧毀,結束程序。
接下來是撰寫 Matrix-matrix multiplication,$AB=C$,其中 $A\in\mathbb{R}^{N\times M}$,$B\in\mathbb{R}^{M\times P}$,$C\in\mathbb{R}^{N\times P}$。1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19// TODO: Matrix-matrix multiplication
const int N = 3;
const int M = 4;
const int P = 2;
float a[N*M];
float b[M*P];
float c[N*P];
// Create matrix A, B in row-major order
for(int i=0;i<N*M;++i)
{
a[i] = (float)(i+1); //1, 2, 3, 4, ~
}
for(int i=0;i<M*P;++i)
{
b[i] = (float)(M*P-i); //8, 7, 6, 5, ~
}
宣告 Dimension,宣告 Host memory 上的 matrix,需要注意的是在這邊我是使用 row-major 的方式初始化 Matrix,後面有點小 Trick。印出來看:
1 | void print_matrix(float *mat, const int N, const int M) |
1 | //print matrix |
接著是宣告、分配 Device memory 空間,使用 cuBLAS API 複製 Matrix data 到 device:1
2
3
4
5
6
7
8
9
10
11float *device_a;
float *device_b;
float *device_c;
error_check(cudaMalloc(&device_a, N*M*sizeof(float)));
error_check(cudaMalloc(&device_b, M*P*sizeof(float)));
error_check(cudaMalloc(&device_c, N*P*sizeof(float)));
// copy host matrix 'a' to device matrix 'device_a'
error_check(cublasSetMatrix(M, N, sizeof(float), a, M, device_a, M));
error_check(cublasSetMatrix(P, M, sizeof(float), b, P, device_b, P));
這邊因為之後打算使用 cublasSgemm
這個 API 計算矩陣乘法:
cublasSgemm
會要求兩個常數 $\alpha$ 與 $\beta$:1
2const float alpha = 1.0f;
const float beta = 0.0f;
直接分別設為 $1$ 與 $0$,計算就會 reduce 成 $C\gets\text{OP}(A)\text{OP}(B)$。
接下來就是 Tricky 的地方。做 Matrix-matrix multiplication:1
2
3error_check(cublasSgemm(cubHandle, CUBLAS_OP_N, CUBLAS_OP_N, P, N, M,
&alpha, device_b, P, device_a, M,
&beta, device_c, P));
這邊要注意 Tricky 的地方。因為 Row-major 與 Column-major 只差在一次 Transpose,因此根據 $(AB)^T=B^TA^T$ 的特性,Row-major 的 matrix b
與 matrix a
在 Column-major 下必須倒過來相乘。在 row-major 下,我們的 Matrix a
與 b
會長這樣:
但是 cuBLAS 是 column-major,因此在 cuBLAS 看來矩陣其實長這樣:
所以在計算 $AB$ 時,必須將 $A$ 與 $B$ 對調,變成 $BA$,如此一來就會計算出 Transpose 過的 $C$
因為 cuBLAS 是 column-major,因此直接將 device_c
copy 回 c
,在 row-major 下就會 Transpose 回來,得到正確的值。
1 | // device to host |
在 row-major 下,c
為:
最後不要忘記 Free memory:1
2
3
4// Free memory
cudaFree(device_a);
cudaFree(device_b);
cudaFree(device_c);
如果能夠做到這種程度,代表你對 row-major/column-major 關係很熟了
3-4. cuSPARSE Introduction
cuSPARSE 是 cuBLAS 的 Sparse version。在 cuBLAS 中使用的都是 Dense vector/matrix,而 cuSPARSE 則提供了一系列 API 用來從事 Sparse vector/matrix 相關的計算。且除了 Matrix/Vector operation 之外,cuSPARSE 也有提供許多求解 Sparse linear system 相關的 API。
Sparse matrix 有一個特點就是矩陣非常大,其中包含了許多 $0$ 項,如果在 GPU 上儲存這些 $0$ 項其實是非常浪費空間的,因此 cuSPARSE 提供了幾種 Data format 來儲存 Sparse matrix,節約 Memory 使用量:
這邊我懶得重寫,直接貼我的 Slide
- COO (Coordinate Format)
這是最為直接的表示方法,儲存矩陣中每個非 $0$ 項的位置: - CSR (Compressed Sparse Row Format)
這個表示法對 Row 方向的資料進行了壓縮,他紀錄的是 Row 開始的 Entry 的 index。 - CSC (Compressed Sparse Column Format)
這個表示法則是對 Col 進行壓縮 - 另外還有 BSR 與 BSRX 但由於不常使用,就不多介紹
Sparse vector 使用兩個 Array 來表示,第一個 Array 儲存 Non-zero term,第二個 Array 則儲存 Non-zero term 的 Index。
使用 cuSPARSE 的方式與 cuBLAS 類似,先 Include cusparse.h
(這邊不用 v2
),Compile 時同樣要加上 Link:
- 使用
nvcc
:1
nvcc hello_cusparse.cu -o hello_cusparse -lcusparse
- 使用 Visual Studio,在
Additional Dependencies
加上cusparse.lib
就可以正常 Compile。
3-5. cuSPARSE API
在使用 cuSPARSE 時,需要呼叫 cusparseCreate
建立 cuSPARSE 的 Handle cusparseHandle_t
,在最後程式結束前則需呼叫 cusparseDestroy
Destroy:1
2
3
4
5
6
7
8
9
10
11
12
13int main(int argc, char **argv)
{
cusparseHandle_t cusHandle;
// Initialize cuSPARSE
cusparseCreate( &cusHandle );
...
// Destroy cuSPARSE handle
cusparseDestroy(cusHandle);
return 0;
}
建立 Sparse matrix 時,則需要根據 Data format (COO、CSR、CSC) 建立對應的 Data,如:COO format 需要建立三個 Array:
cooValA
儲存 Non-zero termcooRowIndA
儲存 Non-zero term 的 row indexcooColIndA
儲存 Non-zero term 的 column index。
如果想要建立 CSR
或 CSC
format 但是不會 (或是不方便) 建立 Row/Column index 話,可以先建立 COO
format,使用 cuSPARSE 提供的 Format Conversion API cusparse<t>coo2csr
做 Data format 的轉換。也可以直接從 Dense matrix,使用 cusparse<t>dense2csc
/ cusparse<t>dense2csr
轉換。
與 cuBLAS 不同的地方在於,cuSPARSE 在宣告 Matrix 時,必須建立該 Matrix 的 Descriptor cusparseMatDescr_t
。cusparseMatDescr_t
包含了大略以下資料:1
2
3
4
5
6typeof struct {
cusparseMatrixType_t MatrixType;
cusparseFillMode_t FillMode;
cusparseDiagType_t DiagType;
cusparseIndexBase_t IndexBase;
} cusparseMatDescr_t;
cusparseDiagType_t |
Meaning |
---|---|
CUSPARSE_DIAG_TYPE_NON_UNIT |
The matrix diagonal has non-unit elements. |
CUSPARSE_DIAG_TYPE_UNIT |
The matrix diagonal has unit elements. |
cusparseFillMode_t |
Meaning |
---|---|
CUSPARSE_FILL_MODE_LOWER |
The lower triangular part is stored. |
CUSPARSE_FILL_MODE_UPPER |
The upper triangular part is stored. |
cusparseIndexBase_t |
Meaning |
---|---|
CUSPARSE_INDEX_BASE_ZERO |
The base index is zero. |
CUSPARSE_INDEX_BASE_ONE |
The base index is one. |
在過去的版本中 (CUDA 8.0) 為了節約 Memory,針對特殊的 Matrix (Symmetric、Triangular、Hermitian) 可以在 cusparseMatrixType_t
指定。如果有指定特殊的 Matrix 的話,cuSPARSE 會只使用 Lower triangle 或 Upper triangle 半邊的 Data 做計算,端看 cusparseFillMode_t
是指定 Lower 還是 Upper。如此一來,使用者可以只需要儲存半邊的 Matrix。
cusparseMatrixType_t |
Meaning |
---|---|
CUSPARSE_MATRIX_TYPE_GENERAL |
The matrix is general. |
CUSPARSE_MATRIX_TYPE_SYMMETRIC |
The matrix is symmetric. (Deprecated) |
CUSPARSE_MATRIX_TYPE_HERMITIAN |
The matrix is Hermitian. (Deprecated) |
CUSPARSE_MATRIX_TYPE_TRIANGULAR |
The matrix is triangular. (Deprecated) |
然而在 CUDA 11.0 中,官方不再鼓勵使用者使用 General 以外的 Matrix Type,原因是在許多計算中 (Matrix-vector multiplication、Preconditioners、Lienar system solvers 等等),使用非 General type 的計算耗費時間會是 General type 的 10 倍慢**,因此在 CUDA 11.0 中,大部分的 API 不再支援非 General type 的 Matrix。因此使用者在指定 Matrix type 時直接填寫 CUSPARSE_MATRIX_TYPE_GENERAL
即可。
以下示範如何宣告一個 cusparseMatDescr_t
:1
2
3
4
5
6
7
8
9
10// Create descriptor for the matrix A
cusparseMatDescr_t descr_A = 0;
cusparseCreateMatDescr(&descr_A);
// Set properties of the matrix A
cusparseSetMatType(descr_A, CUSPARSE_MATRIX_TYPE_GENERAL);
cusparseSetMatIndexBase(descr_A, CUSPARSE_INDEX_BASE_ZERO);
// Free descriptor
cusparseDestroyMatDescr(descr_A);
cuSPARSE 同樣有提供 3 個 Level 的 API:
- Level-1:Sparse vector operation (在 CUDA 11.0 中已列為 Deprecated)
- Level-2:Sparse matrix-dense/sparse vector operation 以及求解 Matrix-vector sparse linear system 相關 API
- Level-3:Sparse matrix-dense matrix operation 以及求解 Matrix-matrix sparse linear system
需要注意的是 Level-2 與 Level-3 求解 Sparse linear system 的 API 有限定 Matrix 必須是 Triangular,如果矩陣非 Triangular 則只會使用 Lower triangle 的 Data,Upper triangle 忽略。
注意到,Level-3 是 Sparse matrix 對 dense matrix operation,Sparse matrix 對 sparse matrix 的 operation 則是放在 Extra API,但其中也有一些 API 已經列為 Deprecated。而多數 API 被列為 Deprecated 的原因是因為,CUDA 11.0 決定要將一些常用的 Multiplication operation 做整合,因此提供了另一種型態的 API —- Generic API。
使用 Generic API 的方法與 cuBLAS 大不相同。首先需要建立對應 Data format 的 Descriptor (不同於 cusparseMatDescr_t
)。例如,如果要建立 CSR
format 的 Sparse matrix 則必須呼叫對應的 API 來建立 Descriptor:1
2
3
4
5
6cusparseSpMatDescr_t mat_A;
cusparseCreateCsr(&mat_A, N, N, nnz, d_rowPtr, d_colIdx, d_A,
CUSPARSE_INDEX_32I, // row index data type (int)
CUSPARSE_INDEX_32I, // col index data type (int)
CUSPARSE_INDEX_BASE_ZERO, // 0-based index
CUDA_R_64F); // Data type (real double-floating point)
建立 Dense vector:1
2cusparseDnVecDescr_t vec_x;
cusparseCreateDnVec(&vec_x, N, d_x, CUDA_R_64F);
建立完後,呼叫 Generic API 時都是使用 Descriptor 來代表 Vector/Matrix。例如 cusparseSpMV
計算 Sparse-matrix 對 dense-vector 的 Multiplication:
計算分成兩個階段,首先使用者必須自行呼叫 cusparseSpMV_bufferSize
估算 cusparseSPMV
所需的額外 Buffer 空間大小,之後自行呼叫 cudaMalloc
建立後,再呼叫 cusparseSpMV
換成計算。1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26double alpha = 1;
double beta = 0;
size_t buf_size = 0;
double *d_buf;
// Compute buffer size in computing matrix-vector multiplocation
cusparseSpMV_bufferSize(cusHandle, CUSPARSE_OPERATION_NON_TRANSPOSE,
&alpha, mat_A, vec_x,
&beta, vec_y,
CUDA_R_64F,
CUSPARSE_CSRMV_ALG1,
&buf_size);
// Allocate buffer memory
cudaMalloc(&d_buf, buf_size);
// Perform matrix-vector multiplocation
cusparseSpMV(cusHandle, CUSPARSE_OPERATION_NON_TRANSPOSE,
&alpha, mat_A, vec_x,
&beta, vec_y,
CUDA_R_64F,
CUSPARSE_CSRMV_ALG1,
d_buf));
// Free buffer
cudaFree(d_buf);
最後需要呼叫對應的 API 來銷毀 Generic API 的 descriptor1
2
3
4// Destroy descriptor
cusparseDestroySpMat(mat_A);
cusparseDestroyDnVec(vec_x);
cusparseDestroyDnVec(vec_y);
求解 Sparse linear system 在 CUDA 11.0 中也有大幅度的變更,原先在 CUDA 8.0 中,求解 Sparse linear system 時 cuSPARSE 會自己建立刪除額外的 Buffer,但從 CUDA 11.0 開始也需要使用者自行處理 Buffer。除此之外,Sparse linear system 只支援 Triangular matrix,若 Matrix 不是 Triangular,cuSPARSE 會忽略除了 Lower triangle 以外的 Data。
求解 Sparse linear system 前需要先建立對應 Data format 的 Infomation object,接著分成兩個階段 (Phase),首先先執行 Analysis phase 分析 Matrix 型態,之後再執行 Solve phase 解出 Linear system。以下示範使用 cusparse<t>csrsv2
求解 Sparse linear system $\text{OP}(A)y=\alpha x$:1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33// Create infomation object
csrsv2Info_t info_A;
cusparseCreateCsrsv2Info(&info_A);
int buf_size;
double *d_buf;
// Compute buffer size for solving linear system
cusparseDcsrsv2_bufferSize(cusHandle, CUSPARSE_OPERATION_NON_TRANSPOSE,
N, nnz, descr_A, //descr_A: cusparseMatDescr_t
d_A, d_rowIdx, d_colIdx,
info_A, &buf_size);
// Allocate buffer memory
cudaMalloc(&d_buf, buf_size);
// Analysis phase
cusparseDcsrsv2_analysis(cusHandle, CUSPARSE_OPERATION_NON_TRANSPOSE,
N, nnz, descr_A, //descr_A: cusparseMatDescr_t
d_A, d_rowIdx, d_colIdx,
info_A, CUSPARSE_SOLVE_POLICY_USE_LEVEL, d_buf);
double alpha = 1;
// Solve phase
cusparseDcsrsv2_solve(cusHandle, CUSPARSE_OPERATION_NON_TRANSPOSE,
N, nnz, &alpha, descr_A, //descr_A: cusparseMatDescr_t
d_A, d_rowIdx, d_colIdx,
info_A, d_x, d_y,
CUSPARSE_SOLVE_POLICY_USE_LEVEL, d_buf);
// Free buffer
cudaFree(d_buf);
// Destroy information object
cusparseDestroyCsrsv2Info(info_A);
求解 Linear system 這類 API 在 CUDA 11.0 時有新增一個新的參數cusparseSolvePolicy_t
:
cusparseSolvePolicy_t |
Meaning |
---|---|
CUSPARSE_SOLVE_POLICY_NO_LEVEL |
No level information is generated and used. |
CUSPARSE_SOLVE_POLICY_USE_LEVEL |
Generate and use level information. |
在 Document 裡,每個 API 底下的解說都有寫這個參數的用途,但並沒有說的很詳細,我也不清楚這個參數實際的用途。簡而言之,這個參數能夠提升某些運算的效率,本身並不影響計算的結果。
除了這些 API 之外,cuSPARSE 還有提供其他額外的 API,例如 Preconditioner、Format conversion 等等,詳細請看 Document。
3-6. Hello cuSPARSE
這邊範例將會介紹如何使用 cuSPARSE 計算 Sparse matrix dense vector multiplication。首先建立基礎 Framework。
1 |
|
接下來在 Host 建立一個 Sparse matrix 與一個 Dense vector,這邊隨便寫一些 Hash 函數來產生亂數:1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65// random hash (2D to 1D)
double hash21(double x, double y)
{
x = (x*24.36854 + y*15.75427 + 43.454614);
y = (x*57.5654 + y*21.1435 + 37.159636);
x = sin(x+y)*516.918738574;
return ((int)((-1. + 2. * fabs(x - (long)x))*100.))*0.1;
}
// create tridiagonal matrix in column-major order
void createDenseMatrix(double **o_mat, int N)
{
double *mat = new double[N*N]{};
for(int x=0;x<N;++x)
{
for(int y=0;y<N;++y)
{
if(x == y)
mat[y+x*N] = hash21(x, y);
else if( abs(x-y) == 1 )
mat[y+x*N] = hash21(x, y);
}
}
*o_mat = mat;
}
// create random vector
void createDenseVector(double **o_vec, int N)
{
double *vec = new double[N]{};
for(int x=0;x<N;++x)
{
vec[x] = hash21(x, -x+10);
}
*o_vec = vec;
}
// print matrix
void print_matrix(double *mat, const int N)
{
for(int i=0;i<N;++i)
{
for(int j=0;j<N;++j)
{
if(j != 0) std::cout << ", ";
std::cout << std::fixed << std::setprecision(2) << mat[i + j*N];
}
std::cout << std::endl;
}
}
// print vector
void print_vector(double *vec, const int N)
{
for(int i=0;i<N;++i)
{
if(i != 0) std::cout << ", ";
std::cout << std::fixed << std::setprecision(2) << vec[i];
}
std::cout << std::endl;
}
這邊不需要太在意寫了甚麼,基本上就是用盡各種辦法建立了一個 Sparse matrix 跟 Dense vector。
接著回到 TODO
的地方繼續完成 Matrix vector multiplication。建立 Matrix 跟 Vector,copy 到 GPU 上:1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34// TODO: Sparse matrix dense vector multiplication
// Generate random dense tridiagonal matrix A (column-major)
int N = 5;
double *A = 0;
createDenseMatrix(&A, N);
// Print matrix
std::cout << "My matrix 'A': " << std::endl;
print_matrix(A, N);
std::cout << std::endl;
// Generate random dense vector b
double *b = 0;
createDenseVector(&b, N);
// Print matrix
std::cout << "My vector 'b': " << std::endl;
print_vector(b, N);
std::cout << std::endl;
// Device pointer (Dense)
double *d_A = 0;
double *d_b = 0;
double *d_c = 0;
// Allocate GPU memory
error_check(cudaMalloc(&d_A, sizeof(double) * N * N));
error_check(cudaMalloc(&d_b, sizeof(double) * N));
error_check(cudaMalloc(&d_c, sizeof(double) * N));
// you can also use cudaMemcpy/cudaMemcpy2D as well.
error_check(cublasSetMatrix(N, N, sizeof(double), A, N, d_A, N));
error_check(cublasSetVector(N, sizeof(double), b, 1, d_b, 1));
建立 Matrix A 的 Description:1
2
3
4
5
6
7// Create descriptor of the matrix A
cusparseMatDescr_t descr_A = 0;
error_check(cusparseCreateMatDescr(&descr_A));
// Set properties of the matrix A
error_check(cusparseSetMatType(descr_A, CUSPARSE_MATRIX_TYPE_GENERAL));
error_check(cusparseSetMatIndexBase(descr_A, CUSPARSE_INDEX_BASE_ZERO));
接下來要將 Dense matrix A 轉換成 Sparse 的 Format,因此要先計算 Matrix A 的 Non-zero term 數量,這個地方可以使用 cuSPARSE API cusparseDnnz
計算,之後再使用 cusparseDdense2csr
將 Dense format 的 Matrix A 轉換成 CSR format。1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21// Count non-zero terms
int nnz = 0;
int *d_nnz_perRow = 0;
error_check(cudaMalloc(&d_nnz_perRow, N * sizeof(int)));
error_check(cusparseDnnz(cusHandle, CUSPARSE_DIRECTION_ROW, N,
N, descr_A, d_A,
N, d_nnz_perRow, &nnz));
// Print message
std::cout << "Total number of non-zero terms in dense matrix A = " << nnz << std::endl;
// Convert dense format to csr format
double *d_csrValA = 0;
int *d_rowPtrA = 0;
int *d_colIdxA = 0;
error_check(cudaMalloc(&d_csrValA, nnz * sizeof(double)));
error_check(cudaMalloc(&d_rowPtrA, (N+1) * sizeof(int)));
error_check(cudaMalloc(&d_colIdxA, nnz * sizeof(int)));
error_check(cusparseDdense2csr(cusHandle, N, N,
descr_A, d_A,
N, d_nnz_perRow,
d_csrValA, d_rowPtrA, d_colIdxA));
接著因為要使用 Generic API cusparseSpMV
計算 Matrix vector multiplication,因此要先建立 Descriptors:1
2
3
4
5
6
7
8
9
10
11
12// Create Generic API descriptor
cusparseSpMatDescr_t mat_A;
error_check(cusparseCreateCsr(&mat_A, N, N, nnz,
d_rowPtrA, d_colIdxA, d_csrValA,
CUSPARSE_INDEX_32I, CUSPARSE_INDEX_32I,
CUSPARSE_INDEX_BASE_ZERO, CUDA_R_64F));
cusparseDnVecDescr_t vec_b;
error_check(cusparseCreateDnVec(&vec_b, N, d_b, CUDA_R_64F));
cusparseDnVecDescr_t vec_c;
error_check(cusparseCreateDnVec(&vec_c, N, d_c, CUDA_R_64F));
接下來就是計算 Matrix vector multiplication,先呼叫 cusparseSpMV_bufferSize
計算計算所需的 Buffer 空間,接著呼叫 cusparseSpMV
完成計算:1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18size_t buf_size;
double *d_buf;
double alpha = 1.0;
double beta = 0.0;
error_check(cusparseSpMV_bufferSize(cusHandle,
CUSPARSE_OPERATION_NON_TRANSPOSE,
&alpha, mat_A, vec_b,
&beta, vec_c, CUDA_R_64F,
CUSPARSE_CSRMV_ALG1, &buf_size));
// Allocate buffer
cudaMalloc(&d_buf, buf_size);
error_check(cusparseSpMV(cusHandle,
CUSPARSE_OPERATION_NON_TRANSPOSE,
&alpha, mat_A, vec_b,
&beta, vec_c, CUDA_R_64F,
CUSPARSE_CSRMV_ALG1, d_buf));
最後將解答從 GPU memory 複製到 Host,並印出答案:1
2
3
4
5
6
7
8// device to host
double *c = new double[N]{};
error_check(cublasGetVector(N, sizeof(double), d_c, 1, c, 1));
// print answer
std::cout << "Answer: " << std::endl;
print_vector(c, N);
std::cout << std::endl;
程式最後將所有資源 Free 掉:1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17delete[] A;
delete[] b;
delete[] c;
cudaFree(d_A);
cudaFree(d_b);
cudaFree(d_c);
cudaFree(d_nnz_perRow);
cudaFree(d_csrValA);
cudaFree(d_rowPtrA);
cudaFree(d_colIdxA);
cudaFree(d_buf);
// Free handles, descriptors,
cusparseDestroyMatDescr(descr_A);
cusparseDestroySpMat(mat_A);
cusparseDestroyDnVec(vec_b);
cusparseDestroyDnVec(vec_c);
成功的的話就會算出答案:-33.48, 52.97, -47.62, 82.27, 3.97
4. Implementation
這章節將解說如何使用 cuBLAS/cuSPARSE 實做 Incomplete-Cholesky preconditioned conjugate gradient。
4-1. Frameworks
1 |
|
首先要處理的就是 Matrix 的讀寫。
- Input:使用 COO 格式
- $N$ (32-bit int):表示 Matrix $A$ 大小為 $N\times N$,Vector $b$ 維度為 $N$
- $\text{nz}$ (32-bit int):表示矩陣 $A$ 具有的 Non-zero term 數量
- 3-tuple 有 $\text{nz}$ 個:
- $i$ (32-bit int):表示 Row index (0-based)
- $j$ (32-bit int):表示 Column index (0-based)
- $A_{ij}$ (64-bit float):表示 Element $A_{ij}$ 的值
- $N$ 個 64-bit float 代表 Vector $b$
- Output:
- $N$ (32-bit int):表示 Vector $x$ 的維度
- $N$ 個 64-bit float 代表 Vector $x$
因此讀檔的部份:1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42// Read testcase
void read(std::string filePath,
int *pN, int *pnz,
double **cooVal,
int **cooRowIdx, int **cooColIdx,
double **b)
{
std::ifstream in(filePath, std::ios::binary);
in.read((char*)pN, sizeof(int)); // read N
in.read((char*)pnz, sizeof(int)); // read nz
// Create array
*cooVal = new double[*pnz]{};
*cooRowIdx = new int[*pnz]{};
*cooColIdx = new int[*pnz]{};
*b = new double[*pN]{};
// read each element Aij
for (int i = 0; i < *pnz; ++i)
{
in.read((char*)&(*cooRowIdx)[i], sizeof(int));
in.read((char*)&(*cooColIdx)[i], sizeof(int));
in.read((char*)&(*cooVal)[i], sizeof(double));
}
// read b
in.read((char*)(*b), sizeof(double)*(*pN));
}
// Read answer
void readAnswer(std::string filePath,
int *pN, double **x)
{
std::ifstream in(filePath, std::ios::binary);
in.read((char*)pN, sizeof(int));
*x = new double[*pN]{};
in.read((char*)(*x), sizeof(double)*(*pN));
}
讀檔這邊因為會需要更改傳入的參數,因此使用 Call by reference。接下來回到 main
:1
2
3
4
5
6
7
8
9
10
11
12
13
14
15// in `main` function
int N;
int nz;
double *A;
int *rowIdxA;
int *colIdxA;
double *b;
read(inputPath, &N, &nz, &A, &rowIdxA, &colIdxA, &b);
double *ans_x;
readAnswer(answerPath, &N, &ans_x);
// Print message
std::cout << "N = " << N << std::endl;
std::cout << "nz = " << nz << std::endl;
讀檔到這邊就完成了,由於讀進來的 Matrix $A$ 是 COO,需要轉換成 CSR,因此需要先初始化 cuBLAS/cuSPARSE:1
2
3
4
5
6// Create handles
cublasHandle_t cubHandle;
cusparseHandle_t cusHandle;
error_check(cublasCreate(&cubHandle));
error_check(cusparseCreate(&cusHandle));
接著分配 Matrix $A$ 在 GPU 上的 Memory,並複製一份過去:1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17// Allocate GPU memory & copy matrix/vector to device
double *d_A;
int *d_rowIdxA; // COO
int *d_rowPtrA; // CSR
int *d_colIdxA;
double *d_b;
error_check(cudaMalloc(&d_A, nz * sizeof(double)));
error_check(cudaMalloc(&d_rowIdxA, nz * sizeof(int)));
error_check(cudaMalloc(&d_rowPtrA, (N + 1) * sizeof(int))); // (N+1) !!!!
error_check(cudaMalloc(&d_colIdxA, nz * sizeof(int)));
error_check(cudaMalloc(&d_b, N * sizeof(double)));
error_check(cudaMemcpy(d_A, A, nz * sizeof(double), cudaMemcpyHostToDevice));
error_check(cudaMemcpy(d_rowIdxA, rowIdxA, nz * sizeof(int), cudaMemcpyHostToDevice));
error_check(cudaMemcpy(d_colIdxA, colIdxA, nz * sizeof(int), cudaMemcpyHostToDevice));
error_check(cudaMemcpy(d_b, b, N * sizeof(double), cudaMemcpyHostToDevice));
這邊需要注意 CSR 格式是針對 RowIdx 做壓縮,壓縮後的大小為 $N+1$,在 Document 上有寫。接著就是 Call cusparseXcoo2csr
做轉換:1
2
3// Convert matrix A from COO format to CSR format
error_check(cusparseXcoo2csr(cusHandle, d_rowIdxA, nz, N,
d_rowPtrA, CUSPARSE_INDEX_BASE_ZERO));
接著就是建立 ICCGsolver 跟 Call solve,內容的部份會在 4-2 節實作:1
2
3
4
5
6
7
8
9
10
11// Create conjugate gradient solver
int max_iter = 1000;
double tolerance = 1e-12;
ICCGsolver solver(max_iter, tolerance, cubHandle, cusHandle);
// Print message
std::cout << "Solving linear system..." << std::endl;
bool res = solver.solve(N, nz, d_A, d_rowPtrA, d_colIdxA, d_b);
std::cout << (res ? "Converged!": "Failed to converge") << std::endl;
這邊我讓 solver.solve
回傳 true
如果 solver
成功在 max_iter
Iteration 內收斂 (小於 tolerance
) 的話,否則回傳 false
。接下來就是將解答從 GPU 摳回 Host memory,並驗證結果:1
2
3
4
5
6
7
8
9
10
11
12
13
14double *x = new double[N] {};
error_check(cudaMemcpy(x, solver.x_ptr(), N * sizeof(double), cudaMemcpyDeviceToHost));
double tol = 0;
for (int i = 0; i < N; ++i)
{
tol += x[i] - ans_x[i];
}
// print message
std::cout << "Solved in " << solver.iter_count() << " iterations, final norm(r) = "
<< std::scientific << solver.err() << std::endl;
std::cout << "Total error (compared with ans_x): " << tol << std::endl;
最後要記得把 Memory 都 Free 掉:1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18// Free Host memory
delete[] A;
delete[] rowIdxA;
delete[] colIdxA;
delete[] b;
delete[] ans_x;
delete[] x;
// Free Device memory
cudaFree(d_A);
cudaFree(d_rowIdxA);
cudaFree(d_rowPtrA);
cudaFree(d_colIdxA);
cudaFree(d_b);
// Free handles
cublasDestroy(cubHandle);
cusparseDestroy(cusHandle);
到這邊 main
就完成了,下一節就是實作 ICCGsolver
。
4-2. Main Algorithm
先定義 class ICCGsolver
:1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61class ICCGsolver
{
private:
cublasHandle_t cubHandle;
cusparseHandle_t cusHandle;
cusparseMatDescr_t descr_A;
cusparseMatDescr_t descr_L;
// host data
int N = 0;
int nz = 0;
int max_iter;
int k; // k iteration
double tolerance;
double alpha;
double beta;
double rTr;
double pTq;
double rho; //rho{k}
double rho_t; //rho{k-1}
const double one = 1.0; // constant
const double zero = 0.0; // constant
// device data
double *d_ic = nullptr; // Factorized L
double *d_x = nullptr;
double *d_y = nullptr;
double *d_z = nullptr;
double *d_r = nullptr;
double *d_rt = nullptr;
double *d_xt = nullptr;
double *d_q = nullptr;
double *d_p = nullptr;
bool release_cusHandle = false;
bool release_cubHandle = false;
public:
ICCGsolver(int max_iter = 1000, double tol = 1e-12,
cublasHandle_t cub_handle = NULL,
cusparseHandle_t cus_handle = NULL);
~ICCGsolver();
bool solve(int N, int nz,
double *d_A, int *d_rowIdx, int *d_colIdx,
double *d_b);
double *x_ptr();
double err();
int iter_count();
private:
void allocate_nz_memory();
void allocate_N_memory();
void free_nz_memory();
void free_N_memory();
void check_and_resize(int N, int nz);
}
只要有前綴 d_*
表示他是指向 GPU device 的 Pointer。
- 首先是 Line
4
、5
宣告 Handle,接著是因為我們會用到兩個 Matrix 分別為 $A$ 與 Factorized matrix $L$,因此宣告兩個 Descriptor。 11
~23
是計算時會用到的變數。25
~34
則是 GPU 計算實用到的變數。36
~37
用來紀錄 Handler 需要需要自動釋放,如果 Handler 是使用者宣告ICCGsolver
時傳入的則使用者要自行釋放,如果不是則自動釋放。40
~42
是 Constructor,第一個參數max_iter
表示 Iteration 的上限,tol
表示 Tolerance,cub_handle
與cus_handle
可傳入可不傳入。44
是 Destructor,要 Free 掉所有 GPU memory。46
~48
是解方程的 API,N
、nz
分別表示 Matrix 大小以及 Non-zero term 數量,d_A
、d_rowIdx
、d_colIdx
為 CSR format 的 Matrix $A$,d_b
是 Vector $b$。50
用來取得d_x
Pointer,可以利用這個 Pointer 將答案複製出來。51
用來取得誤差值 $|r_k|$。52
用來取得 Iteration count $k$。55
~60
用來分配、釋放 GPU memory。
首先是完成 Constructor 與 Destructor:1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38ICCGsolver::ICCGsolver(int max_iter, double tol,
cublasHandle_t cub_handle, cusparseHandle_t cus_handle)
: max_iter(max_iter), tolerance(tol),
cubHandle(cub_handle), cusHandle(cus_handle)
{
// create cuBLAS handle
if (cubHandle == NULL)
{
error_check(cublasCreate(&cubHandle));
release_cubHandle = true;
}
// create cuSPARSE handle
if (cusHandle == NULL)
{
error_check(cusparseCreate(&cusHandle));
release_cusHandle = true;
}
// create descriptor for matrix A
error_check(cusparseCreateMatDescr(&descr_A));
// initialize properties of matrix A
error_check(cusparseSetMatType(descr_A, CUSPARSE_MATRIX_TYPE_GENERAL));
error_check(cusparseSetMatFillMode(descr_A, CUSPARSE_FILL_MODE_LOWER));
error_check(cusparseSetMatDiagType(descr_A, CUSPARSE_DIAG_TYPE_NON_UNIT));
error_check(cusparseSetMatIndexBase(descr_A, CUSPARSE_INDEX_BASE_ZERO));
// create descriptor for matrix L
error_check(cusparseCreateMatDescr(&descr_L));
// initialize properties of matrix L
error_check(cusparseSetMatType(descr_L, CUSPARSE_MATRIX_TYPE_GENERAL));
error_check(cusparseSetMatFillMode(descr_L, CUSPARSE_FILL_MODE_LOWER));
error_check(cusparseSetMatIndexBase(descr_L, CUSPARSE_INDEX_BASE_ZERO));
error_check(cusparseSetMatDiagType(descr_L, CUSPARSE_DIAG_TYPE_NON_UNIT));
}
Constructor 做的事情很簡單,初始化傳入得參數後,建立 Matrix $A$ 與 $L$ 的 Descriptor,這邊需要注意的是在 CUDA 11.0 cusparseMatDescr_t
已經有快要 Deprecate 的傾向,因此這邊設定 Properties 也沒什麼選項可選,首先是 cusparseSetMatType
,目前除了 CUSPARSE_MATRIX_TYPE_GENERAL
選項以外其他都 Deprecate 因此直接勇敢的設成 CUSPARSE_MATRIX_TYPE_GENERAL
就可以了,剩下就是除了 cusparseSetMatIndexBase
需要照實填寫外,其他都會被忽略,並不是很重要。
接下來是 Destructor:1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21ICCGsolver::~ICCGsolver()
{
// free data
free_nz_memory();
free_N_memory();
// release descriptor
cusparseDestroyMatDescr(descr_A);
cusparseDestroyMatDescr(descr_L);
// release handles
if (release_cubHandle)
{
cublasDestroy(cubHandle);
}
if (release_cusHandle)
{
cusparseDestroy(cusHandle);
}
}
Destructor 做的事情更簡單,就是將 GPU memory 都 Free 掉,Descriptor 也 Free 掉,Handle 看是不是使用者傳入的,如果不是,就一起毀掉,如果是就不用毀掉。
接下來是除了 solve
以外的 Function:1
2
3
4
5
6
7
8
9
10
11
12
13
14double *ICCGsolver::x_ptr()
{
return d_x;
}
double ICCGsolver::err()
{
return rTr;
}
int ICCGsolver::iter_count()
{
return k;
}
x_ptr()
用來取得答案的 GPU Pointer。err()
用來取得誤差值 $|r_k|=r^Tr$。iter_count()
用來取得最後一次 solve
的 Iteration count。接下來 Memory 相關:1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17void ICCGsolver::check_and_resize(int N, int nz)
{
// allocate N
if (this->N < N)
{
this->N = N;
free_N_memory();
allocate_N_memory();
}
if (this->nz < nz)
{
this->nz = nz;
free_nz_memory();
allocate_nz_memory();
}
}
check_and_resize
用來檢查,呼叫 solve
時新傳進來的 $N$ 與 $\text{nz}$ 有沒有比目前分配的還大,如果比較大,就要 Free 掉並重新分配一個更大的空間來做計算。
因此釋放與分配空間的 Function:1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33void ICCGsolver::allocate_N_memory()
{
error_check(cudaMalloc(&d_x, N * sizeof(double)));
error_check(cudaMalloc(&d_y, N * sizeof(double)));
error_check(cudaMalloc(&d_z, N * sizeof(double)));
error_check(cudaMalloc(&d_r, N * sizeof(double)));
error_check(cudaMalloc(&d_rt, N * sizeof(double)));
error_check(cudaMalloc(&d_xt, N * sizeof(double)));
error_check(cudaMalloc(&d_q, N * sizeof(double)));
error_check(cudaMalloc(&d_p, N * sizeof(double)));
}
void ICCGsolver::allocate_nz_memory()
{
error_check(cudaMalloc(&d_ic, nz * sizeof(double)));
}
void ICCGsolver::free_N_memory()
{
cudaFree(d_x);
cudaFree(d_y);
cudaFree(d_z);
cudaFree(d_r);
cudaFree(d_rt);
cudaFree(d_xt);
cudaFree(d_q);
cudaFree(d_p);
}
void ICCGsolver::free_nz_memory()
{
cudaFree(d_ic);
}
到這邊,週邊的 Function 全部完成了,總算可以進入正題:Preconditioned conjugate gradient。建議這邊實作的時候可以搭配 Algorithm 1。
Recall Algorithm 1:
1 | bool ICCGsolver::solve(int N, int nz, |
首先一開始需要先檢查、分配 GPU 空間,因此 Call check_and_resize
,接著我將分成 5 個部份分別實作。
先從第二部份開始,這邊會對應到 Algorithm 1 的 Line 4
:1
2
3
4
5
6
7// --- Perform incomplete cholesky factorization ---
csric02Info_t icinfo_A;
size_t buf_size = 0;
size_t u_temp_buf_size = 0;
size_t u_temp_buf_size2 = 0;
int i_temp_buf_size = 0;
void *d_buf = NULL;
cuSPARSE 進行 Incomplete cholesky factorization 分成幾個步驟:
- Call
cusparseCreateCsric02Info
建立csric02Info_t
object - Call
cusparseDcsric02_bufferSize
讓 cuSPARSE 估計計算 Factorization 所需的 Buffer 大小,然後分配 Bufferd_buf
空間 - Call
cusparseDcsric02_analysis
(analysis phase) 讓 cuSPARSE 分析 Matrix $A$ 的 Sparsity。 - Call
cusparseDcsric02
(solve phase) 進行 Factorization
1 | // Create info object for incomplete-cholesky factorization |
這邊我將 d_A
複製到 d_ic
是因為,cuSPARSE 在進行 Factorization 的時候會直接將結果覆寫到 Input array 上,因此這邊將 d_A
複製一份到 d_ic
,將 Factorized 的 Lower triangular matrix 存到 d_ic
裡面。
另外,根據 Document 的說法,CUSPARSE_SOLVE_POLICY_USE_LEVEL
有時會對計算進行稍微的優化,有時不會,因此這邊要設定 NO_LEVEL
或 USE_LEVEL
都可以,但是 Analysis phase 與 Solve phase 的設定一定要一致。
接下來第三部份是估算 Conjugate gradient 所需的 Buffer 空間:1
2
3
4
5// --- Prepare for performing conjugate gradient ---
// Create info object for factorized matrix L, LT
csrsv2Info_t info_L, info_U;
error_check(cusparseCreateCsrsv2Info(&info_L));
error_check(cusparseCreateCsrsv2Info(&info_U));
在 Algorithm 1 Line 9
跟 10
有計算到 $L$ 與 $L^T$ 的 Inverse,但實際上 cuSPARSE 並沒有求 Inverse matrix 的 API,因此可以將這兩行看做求解兩個 Triangular sparse linear system:$Ly=r_k$、求 $y$ 與 $L^Tz_k=y$ 求 $z_k$。而求解 Triancular sparse linear system 可以使用 cusparseDcsrsv2
這個 API。因此首先我們需要建立兩個 csrsv2Info_t
:
- 利用
info_L
代表 $L$ (Lower triangular matrix) - 利用
info_U
代表 $L^T$ (Upper triangular matrix)
接下來就是分別估算解 Triangular sparse linear system 所需的 Buffer 大小:1
2
3
4
5
6
7
8
9
10
11
12
13
14// Compute buffer size in solving linear system
error_check(cusparseDcsrsv2_bufferSize(cusHandle, CUSPARSE_OPERATION_NON_TRANSPOSE,
N, nz, descr_L, d_ic, d_rowIdx, d_colIdx, info_L, &i_temp_buf_size));
u_temp_buf_size = i_temp_buf_size;
error_check(cusparseDcsrsv2_bufferSize(cusHandle, CUSPARSE_OPERATION_TRANSPOSE,
N, nz, descr_L, d_ic, d_rowIdx, d_colIdx, info_U, &i_temp_buf_size));
// check whether need more buffer
if (i_temp_buf_size > u_temp_buf_size)
{
u_temp_buf_size = i_temp_buf_size;
}
除此之外,Conjugate gradient 有發生一次 Sparse Matrix-dense vector multiplication 在 Algorithm 1 Line 17
$q\gets Ap_k$,這個需要使用 cuSPARSE 的 Generic API cusparseSpMV
,因此先回到第一部份宣告 Generic API 所需的 Objects:1
2
3
4
5
6
7
8
9
10// --- 1. Create cuSPARSE generic API objects ---
cusparseSpMatDescr_t smat_A;
error_check(cusparseCreateCsr(&smat_A, N, N, nz, d_rowIdx, d_colIdx, d_A, CUSPARSE_INDEX_32I,
CUSPARSE_INDEX_32I, CUSPARSE_INDEX_BASE_ZERO, CUDA_R_64F));
cusparseDnVecDescr_t dvec_p;
error_check(cusparseCreateDnVec(&dvec_p, N, d_p, CUDA_R_64F));
cusparseDnVecDescr_t dvec_q;
error_check(cusparseCreateDnVec(&dvec_q, N, d_q, CUDA_R_64F));
建立一個 CSR Matrix $A$ 的 Descriptor,建立 Dense vector $p_k$ 與 $q$ 的 Descriptor。再回到第三部份完成剩下的部份,估算 Sparse Matrix-dense vector multiplication 所需的 Buffer 數量:1
2
3
4
5
6
7
8// Compute buffer size for matrix-vector multiplication
error_check(cusparseSpMV_bufferSize(cusHandle, CUSPARSE_OPERATION_NON_TRANSPOSE, &one, smat_A,
dvec_p, &zero, dvec_q, CUDA_R_64F, CUSPARSE_CSRMV_ALG1, &u_temp_buf_size2));
if (u_temp_buf_size2 > u_temp_buf_size)
{
u_temp_buf_size = u_temp_buf_size2;
}
然後分配空間:1
2
3
4
5
6
7// re-allocate buffer
if (u_temp_buf_size > buf_size)
{
buf_size = u_temp_buf_size;
cudaFree(d_buf);
error_check(cudaMalloc(&d_buf, buf_size));
}
這邊需要注意是,通常 Natrix-vector multiplication 會需要比 Incomplete cholesky factorization 與 Solving lienar system 還要更多的 Buffer。如果 Buffer 空間不夠時,cuSPARSE 會回傳 CUSPARSE_STATUS_INTERNAL_ERROR
。
分配完 Buffer 後,就可以先對 Triangular sparse linear system 進行 Analysis phase:1
2
3
4
5// analysis phase
error_check(cusparseDcsrsv2_analysis(cusHandle, CUSPARSE_OPERATION_NON_TRANSPOSE,
N, nz, descr_L, d_ic, d_rowIdx, d_colIdx, info_L, CUSPARSE_SOLVE_POLICY_USE_LEVEL, d_buf));
error_check(cusparseDcsrsv2_analysis(cusHandle, CUSPARSE_OPERATION_TRANSPOSE,
N, nz, descr_L, d_ic, d_rowIdx, d_colIdx, info_U, CUSPARSE_SOLVE_POLICY_USE_LEVEL, d_buf));
這邊需要注意的是 d_ic
是 Lower triangular matrix,因此在分析 $L$ (Lower triangular matrix) 時要設定 Operation 為 CUSPARSE_OPERATION_NON_TRANSPOSE
,但是在分析 $L^T$ (Upper triangular matrix) 時則要設為 CUSPARSE_OPERATION_TRANSPOSE
,這個非常容易被忽略。
所以到目前為止,計算 Conjugate gradient 的事前準備都已經完成了:1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23bool ICCGsolver::solve(int N, int nz,
double *d_A, int *d_rowIdx, int *d_colIdx,
double *d_b, double *d_guess)
{
check_and_resize(N, nz);
// --- 1. Create cuSPARSE generic API objects ---
// DONE
// --- 2. Perform incomplete cholesky factorization ---
// DONE
// --- 3. Prepare for performing conjugate gradient ---
// DONE
// --- 4. Perform conjugate gradient ---
// TODO
// --- 5. Finalize ---
// TODO
return rTr < tolrance; // return true if converged
}
接下來第四部份就是按照 Algorithm 1 一行一行刻:1
2
3
4// x = 0
error_check(cudaMemset(d_x, 0, N * sizeof(double)));
// r0 = b (since x == 0, b - A*x = b)
error_check(cudaMemcpy(d_r, d_b, N * sizeof(double), cudaMemcpyDeviceToDevice));
Line 2
、3
初始化 $x_0$ 與 $r_0$。接下來就是 For 迴圈的內容:1
2
3
4for(k = 0; k < max_iter; ++k)
{
//TODO
}//EndFor
Algorithm 1 Line 6
~8
,計算 $|r_k|$,由於 r_k$ 是 Dense vector,因此可以使用 cuBLAS 的 API cublasDnrm2
計算:1
2
3
4
5
6
7// if ||rk|| < tolerance
error_check(cublasDnrm2(cubHandle, N, d_r, 1, &rTr));
if (rTr < tolerance)
{
break;
}
如果誤差 rTr
已經比設定的 tolerance
還小就結束 Conjugate gradient。
Algorithm 1 Line 9
、10
分別解出兩個 Triangular sparse linear system:1
2
3
4
5
6
7// Solve L*y = rk, find y
error_check(cusparseDcsrsv2_solve(cusHandle, CUSPARSE_OPERATION_NON_TRANSPOSE, N, nz, &one,
descr_L, d_ic, d_rowIdx, d_colIdx, info_L, d_r, d_y, CUSPARSE_SOLVE_POLICY_USE_LEVEL, d_buf));
// Solve L^T*zk = y, find zk
error_check(cusparseDcsrsv2_solve(cusHandle, CUSPARSE_OPERATION_TRANSPOSE, N, nz, &one,
descr_L, d_ic, d_rowIdx, d_colIdx, info_U, d_y, d_z, CUSPARSE_SOLVE_POLICY_USE_LEVEL, d_buf));
這邊同樣 Solve phase 與 Analysis phase 的參數 CUSPARSE_SOLVE_POLICY_USE_LEVEL
必須一致。
Algorithm 1 Line 11
計算 $\rho_k=r^T_kz_k$,這邊是兩個 Dense vector 的內積,因此可以使用 cuBLAS 的 cublasDdot
:1
2
3
4// rho_t = r{k-1} * z{k-1}
rho_t = rho;
// rho = rk * zk
error_check(cublasDdot(cubHandle, N, d_r, 1, d_z, 1, &rho));
這邊還有一個重點就是,在計算 $\beta$ 的時候會需要 $\rho_{k-1}=r_{k-1}^Tz_{k-1}$,因此可以用一個變數 rho_t
把他暫存起來。
Algorithm 1 Line 12
~17
,當 $k=0$ 時直接 Assign $p_k\gets z_k$,可以使用 cublasDcopy
;否則,先計算 $\beta$,再更新 $p_k$:1
2
3
4
5
6
7
8
9
10
11
12
13if (k == 0)
{
// pk = zk
error_check(cublasDcopy(cubHandle, N, d_z, 1, d_p, 1));
}
else
{
// beta = (rk*zk) / (r{k-1}*z{k-1})
beta = rho / rho_t;
// pk = zk + beta*p{k-1}
error_check(cublasDscal(cubHandle, N, &beta, d_p, 1));
error_check(cublasDaxpy(cubHandle, N, &one, d_z, 1, d_p, 1));
}
Line 8
計算 $\beta$ 後,先 Call cublasDscal
對 $p_{k-1}$ 進行 Scale $p_{k-1}\gets \beta p_{k-1}$, 在 Call cublasDaxpy
計算 $p_k\gets 1\cdot z_k+p_{k-1}$。
Algorithm 1 Line 18
計算 Sparse matrix-dense vector multiplication,這邊需要使用 cuSPARSE 的 Generic API cusparseSpMV
:1
2
3// q = A*pk
error_check(cusparseSpMV(cusHandle, CUSPARSE_OPERATION_NON_TRANSPOSE, &one, smat_A,
dvec_p, &zero, dvec_q, CUDA_R_64F, CUSPARSE_MV_ALG_DEFAULT, d_buf));
Algorithm 1 Line 19
計算 $\alpha$,但在計算 $\alpha$ 前要先算出 $p_k^Tq$:1
2
3// alpha = (rk*zk) / (pk*q)
error_check(cublasDdot(cubHandle, N, d_p, 1, d_q, 1, &pTq));
alpha = rho / pTq;
因此首先用 cublasDdot
計算 $p_k^Tq$,再計算出 $\alpha$。
Algorithm 1 Line 20
更新 $x_{k+1}$ 可以使用 cublasDaxpy
計算 $x_{k+1}\gets \alpha p_k + x_k$1
2// x{k+1} = xk + alpha*pk
error_check(cublasDaxpy(cubHandle, N, &alpha, d_p, 1, d_x, 1));
最後更新 Algorithm 1 Line 21
更新 Residual $r_{k+1}$。這邊減法可以看作 $r_{k+1}\gets (-\alpha)q + r_k$,因此同樣使用 cublasDaxpy
計算:1
2
3// r{k+1} = rk - alpha*q
double n_alpha = -alpha;
error_check(cublasDaxpy(cubHandle, N, &n_alpha, d_q, 1, d_r, 1));
到這邊第四部份就完成了。最後第五部份就是將 Buffer 之類的全部 Free 掉:1
2
3
4
5
6
7
8
9
10
11// free buffer
cudaFree(d_buf);
// free objects
error_check(cusparseDestroySpMat(smat_A));
error_check(cusparseDestroyDnVec(dvec_p));
error_check(cusparseDestroyDnVec(dvec_q));
error_check(cusparseDestroyDnVec(dvec_x));
error_check(cusparseDestroyCsric02Info(icinfo_A));
error_check(cusparseDestroyCsrsv2Info(info_L));
error_check(cusparseDestroyCsrsv2Info(info_U));
就完成了 Preconditioned conjugate gradient。
試著執行了一下,結果 $N=1,000,000$ 的 Sparse linear system 只花了 $368$ 個 Iteration 誤差就收斂到小於 $10^{-12}$。
完整的 Code 可以在 Github 找到:Ending2015a/ICCG0_CUDA - github