自作量子回路シミュレータJisaq.jl

目次

概要

Jisaq.jl(github):量子回路シミュレータ
目的:
  1. 状態ベクトルシミュレータの高速な実装を理解する
  2. MPSの実装を理解する
  3. CUDA.jlを用いたGPUのカーネルプログラミングを理解する
  4. Juliaのパッケージの作り方を理解する
  5. 願わくば実用に耐える量子回路シミュレータにしたい
現在、1~4については概ね目標を達成できた。Juliaの代表的な量子回路シミュレータのパッケージ Yao.jlと比較したときの性能はこんな感じ。
benchmark_jisaq_vs_yao.png
回路は1次元横磁場ハイゼンベルグモデル $$H = \sum_i X_i X_{i+1} + X_i X_{i+1} + Y_i Y_{i+1} + Z_i Z_{i+1} + X_i$$ のTrotterizationの1ステップ分の回路。状態ベクトルシミュレータでCPU、GPUでの検証。 JisaqはYaoよりも速い。スペックなどは以下の通り。 以下、このページでは量子回路及び状態ベクトルシミュレーションについての基本的な理解を前提としています。

効率的な状態ベクトルシミュレータの実装

まず、パウリ\(X\)ゲートについて考える。具体的には3量子ビット中、2量子ビット目に\(X\)ゲートを作用させることについて 考えてみる。なお、ここでは量子ビットは上から1番目、2番め、3番目と数えることにする。





この\(X_2\)ゲートを作用させる一番安直な方法は、\[ I \otimes X \otimes I \]を計算し、それと\(|\psi_{in}\rangle\)をかけて\(X_2 |\psi_{in}\rangle = |\psi_{out}\rangle\) を得ることである。しかし、これは非常に効率が悪い。なぜなら\(X_2\)は\(8\times8\)行列、全体が\(n\)量子ビットであれば \(2^n \times 2^n\)行列であり、\(2^{2n}\)にスケールしてしまう。なので一般に、可能な限りゲートの行列そのものを 計算すべきでない。ここで、\(X_2\)ゲートがどのような表列要素を持つかを見てみる。

これは疎行列であり、しかも各行に一つだけ非ゼロ要素\(1\)をもつ。このような行列は 置換行列とよばれ、 ベクトルの要素の入れ替えを行う。つまり、\(X_2\)そのものは計算せずに、ベクトルの要素をうまいこと交換してやれば \(X_2\)と同じ作用を再現できる。

ここで何番目を交換すればよいかというのは、\(X\)ゲートが古典NOTゲートに対応することを考えればよい。 ベクトルの要素を上から順に000~111と二進数で番号付けしたとき、000と交換されるのは2番めのビットを反転させた 010であり、110と交換されるのは100である。

パウリ\(Y\)ゲートについてもほぼ同様にできる。\(X\)ゲートと違うのは行列要素が\(1\)ではなく\(-i\)または\(i\) であることである。つまり、要素の入れ替えと\(-i\)または\(\i\)倍である。パウリ\(Z\)に関しては対角行列であるため、 要素の入れ替えは必要なく、単に\(1\)または\(-1\)倍すればよい。ここでJisaq.jlにおける\(Y\)ゲートの実装を示そう。 これを見れば\(X\)、\(Z\)ゲート実装も自動的にわかると思う。

function apply!(sv::AbstractStatevector, x::Y)
    nq,loc,vec = sv.nq, x.locs[1], sv.vec
    _2_pow_locm1 = 2^(loc - 1)
    _2_pow_loc_m1 = 2^loc-1
    for i in 1 : 2^loc : 2^nq
        swap_rows!(vec, i : i+_2_pow_locm1-1, i+_2_pow_locm1:i+_2_pow_loc_m1)
        for j in i : i+_2_pow_locm1-1
            @inbounds vec[j] *= -im
        end
        for j in i+_2_pow_locm1 : i+_2_pow_loc_m1
            @inbounds vec[j] *= im
        end
    end
    return sv
end
AbstractStatevector型はフィールドvec、nqを持つ状態ベクトルである。Yも型で、作用する量子ビットを示すフィールドであるloc を持つ。swap_rows!は第一引数に与えられたベクトルの要素を交換する関数で、例えば
swap_rows!([10,20,30,40], [1,2], [3,4])
とすれば[30,40,10,20]が得られる。

次はCNOTゲートだが、概念的には簡単で制御ビットが0なら何もせず、標的ビットが1ならXゲートをかける。Jisaq.jl での実装は次のようになっている。

function apply!(sv::AbstractStatevector, x::CX)
    nq,v = sv.nq, sv.vec
    i,j = locs(x)
    offset = 1 + 2^(i-1)
    step = 2^(j-1)
    _i, _j = minmax(i,j)
    _2_pow_i = 1 << (_i-1)
    _2_pow_j = 1 << (_j-1)
    for k in 0 : 2^(nq-2)-1
        first = bit_insert(bit_insert(k, _2_pow_i), _2_pow_j) + offset
        swap_rows!(v, first, first+step)
    end
    sv
end

@inline function bit_insert(a, _2_pow_idx)
    rem = a % _2_pow_idx
    (a ⊻ rem) << 1 + rem
end
bit_insertは整数を2進表記したとき、指定したビットに0を挿入する関数である。挿入する位置はその位置のビットが立った 整数で与える。例えば、
bit_insert(0b11, 0b10)
は0b101を与える。上のようなコードで、CNOTゲートになっている ことは一目にはわかりにくいかもしれないが、「制御ビットが0なら何もせず~」を安直に実装すると条件分岐が発生し パフォーマンスが悪化する。

次は一般の1量子ビットゲートに進もう。まず、単純化した例としてアダマールゲートについて見てみる。次に \(I \otimes I \otimes H otimes I\)の行列要素を示す。

これは各要素に2つの非ゼロ要素が存在している(各要素が独立していない)ため、パウリゲートの時のような単純な入れ替えと 定数倍では対応できない。そこで、「独立しているペア」に注目する。上図の例でいうと、(1,3), (2, 4), (5, 7), (6, 8),... , (15, 16)番めの要素がそれぞれ独立しているペアである。つまり、独立なペアは一度に処理してしまう。Python風の疑似コードで書くと 次のようになる。

for (i, j) in (独立しているペア):
  x = c[i]
  y = c[j]
  c[i] = (x + y) / sqrt(2)
  c[j] = (x - y) / sqrt(2)
このようにすれば\(16 \times 16\)行列は計算せずに済む。Jisaq.jlで一般の1量子ビットゲート(U2)作用は以下のように 実装されている。
function apply!(sv::AbstractStatevector, x::U2)
    nq,loc,vec = sv.nq, x.locs[1], sv.vec
    a,c,b,d = x.mat
    step1 = 1 << (loc - 1)
    step2 = 1 << loc
    for j in 1 : step2 : size(vec, 1)-step1+1
        @inbounds for i in j : j+step1-1
            w = vec[i]
            v = vec[i+step1]
            vec[i] = a * w + b * v
            vec[i+step1] = c * w + d * v
        end
    end
    return sv
end
U2は\[ \begin{pmatrix} a && b\\ c && d \end{pmatrix} \]という行列である。

さて、ここまでに述べたゲートの実装はYao.jlとほぼ同一である。Jisaq.jlのオリジナリティとして(Pythonとかの 他のパッケージで同様のことをしているものはあるだろうが、)2量子ビット回転ゲートの効率的な実装を紹介したい。 RyyゲートとRxxゲートの積は次のような行列要素を持つ。

これは一般の1量子ビットゲートの時のように、各行にちょうど2つの非ゼロ要素があり、独立なペアが存在するパターンである。 なので、(もちろん独立なペアの求め方は異なるが)U2ゲートと同じように実装できる。

function apply!(sv::AbstractStatevector, x::I_plus_A)
    a1,a2,b,c = x.d1, x.d2, x.b, x.c
    i,j,nq,v = x.locs[1], x.locs[2], sv.nq, sv.vec
    mask = 2^(i-1) + 2^(j-1)
    mini,maxi = minmax(i,j)
    _2_pow_maxim1 = 2^(maxi-1)
    step1 = 2^mini
    step2 = 2^(mini-1)
    for l in 0 : step1 : 2^nq-1
        selector = l & _2_pow_maxim1 == 0
        bc = selector ? b : c
        a12 = selector ? a1 : a2
        @inbounds for k in 0:step2-1
            idx1 = l+k+1
            idx2 = (l+k)⊻mask+1
            x = v[idx1]
            y = v[idx2]
            v[idx1] = x * a12 + y * bc
            v[idx2] = x * bc + y * a12
        end
    end
    sv
end
I_plus_Aというのは\[ \begin{pmatrix} d1 && 0 && 0 && b\\ 0 && d2 && c && 0\\ 0 && c && d2 && 0\\ b && 0 && 0 && d1 \end{pmatrix} \]という行列である。2量子ビット回転ゲートおよびそれらの積は全てこの形で書ける。Yao.jlの多量子ビット回転ゲートは 一般の回転軸に対応したものになっているため、これらについてはJisaq.jlの方が結構速い。

密度演算子シミュレータの実装

ここまで状態ベクトルシミュレータの実装について述べてきた。次は密度演算子シミュレータの実装を行いたいのだが、 上で行ったような最適化をもう一度密度演算子に対しても考えるのは正直しんどい。そこで、状態ベクトルシミュレータを利用して 密度演算子シミュレータを実装できないかを考えてみる。

量子ゲート\(U\)の状態ベクトル\(|\psi\rangle\)への作用をテンソルネットっぽく書くと次のようになる。

一方で密度演算子\(\rho\)への\(U\)の作用はこう。

\(U^*\)は\(U\)の要素ごとの複素共役である。またはこう書いても同じである。

3つ目の図は「\(\rho\)をreshapeしてベクトルとしてみなし、\(U \otimes U^*\)を作用させる」と読める。これを利用した Jisaq.jlにおける密度演算子シミュレータの実装を次に示す。

function apply!(dm::DensityMatrix, x::Operator)
    nq = nqubits(dm)
    vdm = Statevector(reshape(dm.mat, 4^nq) )
    apply!(vdm, x)
    xx = deepcopy(x)
    xx.locs = xx.locs .+ nq
    apply!(vdm, conj(xx) )
    dm
end
Yao.jlも同様にやっているのでこの方法が極端に効率が悪いということはないと思うが、最速級であるqulacsやqiskitがどのような 実装になっているかは調べていない。

MPSシミュレータの実装

MPSに関しては効率は求めず、計算の原理を理解することを主目的にしている。

まず、MPSについて浅く説明しておく。MPS(Matrix Product State)とは量子ビット間のエンタングルメントを簡略化することで、 多量子ビット状態を効率的に表現する方法である。MPSは次のようなダイアグラムで書ける。

この図はボンド次元2、一次元開放端のMPSである。ボンド次元とはMPSを構成するテンソル同士をつなぐ足の次元であり、 これが系のエンタングルメントどこまで正確に表現するかを決定する。ボンド次元が一定だとすると、量子ビット数が増えたとき 系の情報量は線形にしか増えないので、この近似が有効な時はMPSは非常に有効な情報圧縮手段となる。 MPSを使って量子回路をシミュレートするなら、

を考える必要がある。

まずゲートの作用についてだが、1量子ビットゲートについては簡単である。所望の量子ビットに対応するテンソルに 所望のゲートをかければよい。以下の図は2番めの量子ビットにアダマールゲートをかける例である。

2量子ビットゲートについては少し頭を使う。まず、所望の箇所にゲートをつなぎ、縮約を取る。つぎにこうしてできたテンソルを 特異値分解し、適当なボンド次元のみを残すことで近似する。こうすることで元のボンド次元を保ったまま、2量子ビットゲートを 作用させた状態(の近似)を得ることができる。次の図は2,3番目にCNOTゲートをかける例である。

なので、離れたビット(例えば2,4番目とか)へのゲートは直接は実行できず、SWAPなどをかませる必要がある。

最後に期待値計算についてだが、こちらは縮約を取る順番が重要である。以下の図を見てほしい。

この図は\(\langle Z_2 \rangle\)を計算する場合である。このような順番で縮約を取れば、現れるテンソルの次元が 指数的に増えることはない。\(A_1 -- A_6\)の縮約を先に取ってしまうと\(n\)本の足を持つテンソルが登場してしまう。

いかにJisaq.jlのMPSシミュレータの計算結果を示す。

20サイトの結果では状態ベクトルシミュレータによるトロッター分解の結果は厳密計算のものにほぼ一致している。 MPSシミュレータについてはボンド次元が大きくなるほど結果が厳密なものに近づいているのが分かる。 26サイトでは厳密計算が難しくなったので、状態ベクトルシミュレータとMPSによるトロッター分解の結果のみを示している。 こちらもボンド次元が大きくなるとMPSの結果は状態ベクトルシミュレータのものに近づいている。 最後に100サイトのものについては状態ベクトルシミュレータはもはや適用できないので、(必要なメモリ領域は 2^100 * 16Byte)MPSの結果のみを載せている。この結果がどこまで正しいのかを知るのは簡単ではない。しかし、何らかの 方法で近似的に計算できるというところに価値がある。

CUDA.jlによるカーネルプログラミング

カーネルプログラミングとは、GPU上で動くコードを書くことである。GPUは並列処理が得意であり、特に各ループが独立なfor文 などはCUDA.jlを用いて容易にGPU上で実行できる。基本的な使い方は、

@cuda blocks=<ブロック数> threads=<スレッド数> カーネルで実行される関数(引数...)
である。ブロックやスレッドについてはCUDA一般の解説を参照してほしい。かいつまんで言うと、ブロック数×スレッド数の回数だけ カーネル関数が実行される。Jisaq.jlでは次のようなパターンを採用している。
function my_gpu_func(cuvec::CuArray, args...)
    function k(a, kargs...)
        i = threadIdx().x - 1
        j = blockIdx().x - 1
        idx = 1024j + i
        #=
        処理
        =#
    end
    #=
    前処理
    =#
    if length(cuvec) ≤ 1024
        @cuda blocks=1 threads=length(arr) k(cuvec, kargs...)
    else
        @cuda blocks=length(arr)÷1024 threads=1024 k(cuvec, kargs...)
    end
    cuvec
end
量子ビット系の計算ではベクトルの長さは常に2の累乗なので、それを暗黙に仮定している。カーネル関数(上のコードでいうところの k(a, kargs...)には次のような制約がある。 あとこれはバグか仕様か分からないのだが、前処理のところで定義したローカル変数と同名の変数(仮引数を除く)をk内で 使用することはできない。

上記のパターンを使えば、CPU向けに書いた上記の状態ベクトルシミュレータのコードをほぼ機械的に移植できる。 以下にGPU向けのパウリ\(Y\)の実装を示す。(上に示したCPU向けの実装と見比べてみてほしい。)

function Jisaq.apply!(cusv::Statevector{<:CuArray}, x::Y)
    function k(a, loc)
        i = threadIdx().x - 1
        j = blockIdx().x - 1
        idx = 1024j + i
        lm1 = loc - 1
        idx1 = bit_insert(idx, 1 << lm1 )
        idx2 = idx1 ⊻ 1 << lm1
        idx1 += 1
        idx2 += 1
        a[idx1],a[idx2] = a[idx2]*(-im), a[idx1]*im
        return
    end
    loc = x.locs[1]
    arr = cusv.vec
    if length(arr) ≤ 1024
        @cuda blocks=1 threads=length(arr)÷2 k(arr, loc)
    else
        @cuda blocks=length(arr)÷(2048) threads=1024 k(arr, loc)
    end
    cusv
end
このパターンで書けるものについては、CUDA.jlを用いたカーネルプログラミングは拍子抜けするほど簡単である。

トップに戻る