LOBPCG法の実装

# Objective and Motivation
構造力学の数値解析として，モード解析またはモーダル解析と呼ばれる手法があります．これは，半離散化された運動方程式

$$ M \frac{\partial^2 u_I}{\partial t^2} + K u_I =0$$

を位相と振幅に分けて共通項を消去すると，

$$ K U_I = \omega^2 M U_I $$

の形の一般化固有値問題を解くことで，自由度Nに対して最大N次の固有振動数と固有振動モードを求めることができます．

このとき，固有値と固有ベクトルを求める必要があります．固有値をデジタルコンピュータで求めるには，

-　QR法
-　Arnoldi法
-　Locally Optimal Block Preconditioned Conjugate Gradient(LOBPCG)法

等の方法が知られています．有名なLAPACKはQR法を用いており，実行時に自由度$N$のとき$O(N^2)$のメモリを確保せねばならず，条件分岐を伴う複雑なアルゴリズムを実行する必要があり，そのため大規模問題(例えば$N>100,000$)への適用には工夫が必要です．

一方で，計算コストを下げられる手法として，Krylov部分空間法を用いたアルゴリズムが知られます．上記のArnoldi法(Lanczos法)やLOBPCG法はKrylov部分空間法ベースの手法であり，特にLOBPCG法は求める固有値の数$k$(最大固有値または最小固有値から数えて$k$番目まで)の数が全固有値の20-30%を超えない範囲であれば適用できるマトリックスフリーな方法であることが知られています(https://github.com/lobpcg/blopex)．


モード解析においては，最小固有値から高々5個ないし10個程度の固有振動数と固有振動モードが計算できれば十分であることも多く，自由度Nに対してkは数%程度となることが通常ですので，LOBCPG法の弱点であるkの値の上限は全く問題になりません．一方で，マトリクス$M$や$K$は一般に大規模な疎行列であることが多いので，LOBPCG法の強みである，主にSpMVだけで計算を進められるメリットが生きます．

そこで，本記事では，一般化固有値問題を対象としたLOBPCGの実装方法をレビューするとともに，ソルバーを運用していて体験した本手法の強み/弱みを付記します．

# その前に，そもそもLOBPCGをユーザである私が実装する必要があるか?

あなたにとっては，その必要はありません．各種言語で[著者らの手による実装](https://github.com/lobpcg/blopex)が公開されており，それを直接的ないし間接的に呼び出して使えばよいです．
まずはドキュメントを読み，それでわからなければコードを読めば何をやっているかわかるので，実装する必要はありません．もしそれでもわからなければ以下の文献をご一読ください．

A. V. Knyazev, I. Lashuk, M. E. Argentati, and E. Ovchinnikov, Block Locally Optimal Preconditioned Eigenvalue Xolvers (BLOPEX) in hypre and PETSc, SIAM Journal on Scientific Computing 25(5): 2224-2239 (2007) DOI

A. V. Knyazev, Toward the Optimal Preconditioned Eigensolver: Locally Optimal Block Preconditioned Conjugate Gradient Method, SIAM Journal on Scientific Computing 23(2): 517-541 (2001) DOI


ここまで読んで頂きありがとうございました．記事は以上で終わります．


# LOBPCG法の実装
## Single-Vector LOBPCG法の実装

では，部外者が帰ったようですので，実装に移ります．
上記Githubリンクでは2報の論文が紹介されていますが，そのうち

Knyazev, A. V., Argentati, M. E., Lashuk, I. & Ovtchinnikov, E. E. Block Locally Optimal Preconditioned Eigenvalue Xolvers (BLOPEX) in hypre and PETSc. SIAM J. Sci. Comput. 29, 2224–2239 (2007).

の論文がわかりやすくてよいです．
論文にしたがって，まずは最小固有値を求める場合のみ考えてみます．

LOBPCG法は，ざっくりいえばReyleigh商を最小化ないし最大化する固有値・固有ベクトルを求める最適化問題を，適当な初期推定値を改善することで求める方法です．

本文p. 3のSection 2.1.2に，最小固有値のみを求めるアルゴリズムが僅か2行で紹介されています．

実装としては，
(0) 初期推定値として$x^{(0)}$を与えます．これは一様乱数列を与えてもよいですが，すでにおおよその予測値があればそれを用いることが推奨されます．モード解析であれば，たとえばよりシンプルな串団子のモデルで事前解析を回しておいて，その結果をもとに最小固有値に対応する固有モードを初期推定値として与えるなどすると，局所解に寄り道しにくく，収束が非常に速くなります．
(1) 3項間漸化式(式2.1の1行目)により固有ベクトル$x$を更新します．
(2) 前処理行列$T$，なければ$T=I$を使って$w$ベクトルを更新します．以降，収束するまで(1)-(2)を繰り返します．

実装例はこのようになります．

```Fortran


subroutine LOBPCG_single_CRS(A,B,lambda,X,alpha,tol,debug)
    type(CRS_),intent(in) :: A, B
    real(real64),allocatable,intent(inout) :: X(:)
    real(real64),allocatable,intent(inout) :: lambda
    real(real64),intent(in) :: alpha
    logical,optional,intent(in) :: debug
    logical :: debug_mode
    
    type(Random_) :: random

    real(real64),allocatable :: r(:),rho
    integer(int32) :: n,i
    integer(int32) :: MAX_ITR = 1000000
    real(real64),intent(in) :: tol
    
    debug_mode = input(default=.false.,option=debug)
    ! BLOPEX IN HYPRE AND PETSC, SIAM Eq. (2.2) 
    ! number of eigen value :: m
    ! initialize X and lambda
    n = A%size()
    X = 2.0d0*random%randn(n)-1.0d0
    
    lambda = 0.0d0
    
    !Single-vector version
    do i=1,MAX_ITR
        rho = dot_product(x, A%matmul(x) )/dot_product(x,B%matmul(x))
        r = A%matmul(x) - rho*B%matmul(x) 
        x = x - alpha*r
        if(debug_mode)then
            print *, i, norm(r)
        endif
        if(norm(r) < tol )then
            if(debug_mode)then
                print *, "[OK] converged."
            endif
            exit
        else
            cycle
        endif
    enddo
    if(i==MAX_ITR) print *, "[ERROR] LOBPCG NOT CONVERGED."
    lambda = rho
end subroutine

```

なお，```type :: CRS_``` は適当なCRS形式のデータ型です．

前処理行列が単位行列でも，悪条件でない行列であれば素直に収束します(個人の感想です)．





## 複数の固有ベクトル/固有値の組を求める場合

この場合，Section 2.2のアルゴリズムによることになります．
反復すべきことはそう難しくはなく，

(1) m本のn次元固有ベクトル候補からなる行列X, n×m残差行列R，それらと直交するベクトルからなるn×m行列Pの3つを作成．
(2) V = [X, R, P] によりVを求める．ただし[,]は列方向にスタックするオペレータとする． また，Vの全ての縦ベクトルを直交化しておく．
(3) $\bar{A} = V^T A V$,$\bar{B} = V^T B V$により$\bar{A}$と$\bar{B}$を求め，一般化固有値問題$\bar{B}x = \lambda \bar{A}x$をLAPACKなどにより解く
(3) 求めた3m個の固有値$\lambda$を用いて，残差行列ベクトルRを更新する．
(4) $P=V \bar{B}$などにより行列Pを更新する．
(5) 残差が十分小さくなるまで(1)-(4)を繰り返す．

となります．
ただし，ここでも初期Xおよび初期Pの決定の仕方が問題となります．そこは永井さんにより公開されている以下の記事が非常に丁寧です．

https://webpark1378.sakura.ne.jp/nagai/note_141009_LOBPCG.pdf

要は，一旦はV = [X,R]で(1)-(4)を行い，RとPを求めた上で上記のV=[X,R,P]の反復を実行することになります．

以下がその実装例です．

```Fortran

subroutine LOBPCG_CRS(A,B,lambda,X,m,MAX_ITR,TOL,debug)
    type(CRS_),intent(in) :: A, B
    real(real64),allocatable,intent(out) :: X(:,:)
    real(real64),allocatable,intent(out) :: lambda(:)
    real(real64),intent(in) :: TOL
    integer(int32),intent(in) :: m,MAX_ITR
    logical,optional,intent(in) :: debug    
    logical :: debug_mode

    type(Random_) :: random

    real(real64),allocatable :: r(:,:), p(:,:),V(:,:),A_small(:,:),AV(:,:),&
        b_small(:,:),lambda_small(:), XAX(:,:),AX(:,:),OR(:,:),BX(:,:),XBX(:,:),&
        Bm_small(:,:),BV(:,:)
    real(real64)   :: initial_R
    integer(int32) :: n,i,j,k
    !integer(int32) :: MAX_ITR = 1000000
    
    ! References:
    ! Knyazev, A. V., Argentati, M. E., Lashuk, I. & Ovtchinnikov, E. E. Block Locally Optimal Preconditioned Eigenvalue Xolvers (BLOPEX) in hypre and PETSc. SIAM J. Sci. Comput. 29, 2224–2239 (2007).
    ! https://en.wikipedia.org/wiki/LOBPCG
    ! https://webpark1378.sakura.ne.jp/nagai/note_141009_LOBPCG.pdf
    
    debug_mode = input(default=.false.,option=debug)
    ! BLOPEX IN HYPRE AND PETSC, SIAM Eq. (2.2) 
    ! number of eigen value :: m


    ! debug
    
    ! initialize X and lambda
    n = A%size()
    
    !X = random%randn(n,m)
    X = random%randn(n,m)! + random%randn(n,m) !+ random%randn(n,m)
    do i=1,m
        X(:,i) = X(:,i) / norm(X(:,i) )
    enddo



    call GramSchmidt(X,size(X,1),size(X,2),X )

    lambda = zeros(m)



    R = zeros(n,m)
    P = zeros(n,m)
    AV = zeros(n,3*m)
    AX = zeros(n,m)
    A_small = zeros(3*m,3*m)
    V = zeros(n,3*m)
    

    ! m x m 固有値問題

    do i=1,m
        AX(:,i) = A%matmul(X(:,i) )
    enddo

    BX = zeros(size(AX,1),size(AX,2) ) 
    do i=1,m
        BX(:,i) = B%matmul(X(:,i) )
    enddo
    
    XAX = matmul(transpose(X(:,1:m) ),AX)
    XBX = matmul(transpose(X(:,1:m) ),BX)
    call LAPACK_EIG(XAX, XBX, b_small,lambda_small)
    X = matmul(X,b_small)
    
    do i=1,m
        AX(:,i) = A%matmul(X(:,i) )
    enddo
    BX = zeros(size(AX,1),size(AX,2) ) 
    do i=1,m
        BX(:,i) = B%matmul(X(:,i) )
    enddo
    
    do i=1,m
        R(:,i) = AX(:,i) - BX(:,i)*lambda_small(i)
    enddo
    
    

    V = zeros(n,2*m)

    V(:,1     : m ) = X(:,:)
    V(:,m+1   : 2*m ) = R(:,:)

    call GramSchmidt(V,size(V,1),size(V,2),V )
    do k=1,size(X,2)
        V(:,k) = V(:,k) / norm(V(:,k) )
    enddo

    X(:,:) = V(:,1     : m )
    R(:,:) = V(:,m+1   : 2*m )

    AV = zeros(n,2*m)
    do j=1,2*m  ! n x n, n x 2m
        AV(:,j)= A%matmul(V(:,j) )
    enddo

    BV = zeros(n,2*m)
    do j=1,2*m  ! n x n, n x 2m
        BV(:,j)= B%matmul(V(:,j) )
    enddo
    
    
    A_small = matmul(transpose(V),AV)
    Bm_small = matmul(transpose(V),BV)
    

    call LAPACK_EIG(A_small,Bm_small, b_small,lambda_small)
    
    do j=1,m
        X(:,j) = matmul(V,b_small(:,j) )
    enddo
    AX = zeros(n,m)
    do i=1,m
        AX(:,i) = A%matmul(X(:,i) )
    enddo

    BX = zeros(size(AX,1),size(AX,2) ) 
    do j=1,m
        BX(:,j) = B%matmul(X(:,j) )
    enddo

    R = zeros(n,m)
    do j=1,m
        R(:,j) = AX(:,j) - BX(:,j)*lambda_small(j) 
    enddo

    OR = zeros(n,2*m)
    OR(:,1:m) = 0.0d0
    OR(:,m+1:2*m) = R(:,:)

    P = zeros(n,m)
    do i=1,m
        P(:,i) = matmul(OR,b_small(:,i) )
    enddo
    
    V = zeros(n,3*m)
    AV = zeros(n,3*m)
    BV = zeros(n,3*m)

    AX = zeros(n,m)
    BX = zeros(n,m)

    do i=1,MAX_ITR
        V(:,1     : m ) = X(:,:)
        V(:,m+1   : 2*m ) = R(:,:)
        V(:,2*m+1 : 3*m ) = P(:,:)

        ! Gram-Scmidtを計算する．
        call GramSchmidt(V,size(V,1),size(V,2),V )
        do k=1,size(X,2)
            V(:,k) = V(:,k) / norm(V(:,k) )
        enddo
        do k=size(X,2)+size(R,2)+1,size(V,2)
            V(:,k) = V(:,k) / norm(V(:,k) )
        enddo
        
        X(:,:) = V(:,1     : m )   
        R(:,:) = V(:,m+1   : 2*m ) 
        P(:,:) = V(:,2*m+1 : 3*m ) 
        
        !$OMP parallel do
        do j=1,3*m
            AV(:,j)=A%matmul(V(:,j) )
        enddo
        !$OMP end parallel do

        !$OMP parallel do
        do j=1,3*m
            BV(:,j)=B%matmul(V(:,j) )
        enddo
        !$OMP end parallel do

        A_small = matmul(transpose(V),AV)
        Bm_small = matmul(transpose(V),BV)
        
        ! LAPACKの固有値求めるやつを呼ぶ
        call LAPACK_EIG(A_small,Bm_small,b_small,lambda_small)
        
        X = matmul(V,b_small(:,1:m) )
        
        !$OMP parallel do
        do j=1,m
            AX(:,j) = A%matmul(X(:,j) )
        enddo
        !$OMP end parallel do

        !$OMP parallel do
        do j=1,m
            BX(:,j) = B%matmul(X(:,j) )
        enddo
        !$OMP end parallel do

        !$OMP parallel do
        do j=1,m
            R(:,j) = AX(:,j) - BX(:,j)*lambda_small(j)
        enddo
        !$OMP end parallel do

        V(:,1 : m ) = 0.0d0 
        
        ! matmul:: (n,3*m) x (n,3*m)
        P = matmul(V,b_small(:,1:m) )
        
        ! Detect convergence
        if(i==1)then
            initial_R = maxval(abs(R) )
        endif

        if(debug_mode)then
            print *, i, maxval(abs(R) )/initial_R
        endif
        if(maxval(abs(R) )/initial_R < tol )then
            if(debug_mode)then
                print *, "[OK] converged."
            endif
            lambda = lambda_small(1:m)
            
            exit
        else
            cycle
        endif
    enddo

    if(i==MAX_ITR) print *, "[ERROR] LOBPCG NOT CONVERGED."


end subroutine
! #################################################


```


# 上記コードを運用してみての感想

実際に計算を行うと，自由度$N>1,000,000$程度の悪条件でない疎行列であれば，ごかてい（なぜか変換できない)のパソコンであっという間に計算できます．また，ここでは詳細には記載しませんでしたが，SpMVを並列化すればアクセラレータやクラスタを用いて高速化することも容易で，役に立っています．
加えて，許容誤差を緩めた計算を回してからよりシビアな許容誤差を与えることで，固有モードが分離されていく様子が確認でき，解釈性にも優れると感じています．


ただし，悪条件行列であれば，当然のこと著しく収束性が悪化します．また，このコードのように前処理行列を用いない(これを用いないLOBPCGというのもなんか矛盾しますが)場合には，先述の本家実装と比べて収束性が悪い感じもします（再び，個人の感想です）．

また，解の近傍になるほど収束速度が著しく遅くなるので，その点も注意が必要です．自由度$N<10,000$程度の小規模な問題の場合，LAPACKで計算したほうが速い場合も稀によくあります（誤記ではありません）．


ということで，みなさまよき固有値ライフを！