🐈

cuQuantumの速度計測

2024/12/21に公開

この記事は量子コンピューター Advent Calendar 2024の21日目の記事です。

量子コンピュータ初心者です。
量子機械学習をやったり量子回路を眺めてたりすると、もっと量子ビット数の大きな回路をシミュレーションしたいなと思うことがあります(データを圧縮せずに量子回路に入力したい、とか)。
ただ、家庭用の16GB~32GB程度のPCだと、だいたい25~30量子ビット前後でメモリに載りきらなくなります。1量子ビット増えるごとにメモリ使用量が倍になるので、単にRAMを大きくするのは現実的ではないです。

そこで、テンソルネットワークを使って計算量を圧縮すれば何とかならんかなーと考えます。
(テンソルネットワークのことは詳しくないので、ここでは詳細は逃げ避けます)

テンソルネットワークが使えるライブラリとしてcuQuantumがあります。
https://developer.nvidia.com/cuquantum-sdk
テンソルネットワークとGPUによって高速化するだけでなく、計算量の圧縮のおかげで、100量子ビットを超える回路のシミュレーションも可能になったりします。とても便利そうなかんじがします。

cuQuantumの恩恵は量子ビット数が増えるにつれて大きくなる(CPUと比べたときの速度差が大きくなる)よというグラフは見かけるのですが、回路の深さ等を変えて計測したものをあまり見たことがないので、そのあたりのポテンシャルを調べてみました。

実験環境

今回は以下の環境で計測しました。

HW

CPU: Ryzen 7950X
RAM: 32GB
GPU: GeForce RTX 3080 10GB

SW

CUDA: v11.7
Qiskit: v1.1.1
cuQuantum: v24.03.0

実験準備

適当に考えた以下の回路を使います。
適当なので何でも良いのですが、Qiskitのrandom_circuitではあまりにもランダムなので多少それっぽい回路にしました。

def make_circuit(n_qubits, n_depth):
    qr = QuantumRegister(n_qubits, 'q')
    qc = QuantumCircuit(qr)
    for i in range(n_qubits):
        qc.h(i)
    for i in range(n_depth):
        for j in range(n_qubits):
            qc.rz(0.1, j)
            if j > 0:
                qc.cx(0, j)
            qc.ry(0.1, j)
    return qc

この回路のn_qubits、n_depthを色々変えて試していきます。
なお計測結果はすべて3回ずつの平均値(最小値にしてもよかったかも)ですが、ものによっては結構バラつきが大きかったので、傾向を見る程度にご覧ください。分散もとればよかった。

通常計算

用意した回路を、cuQuantumを使って以下のように実行します。

def exec(qc):
    converter = CircuitToEinsum(qc, dtype='complex128', backend='cupy')
    bitstring = '1' + '0' * (qc.num_qubits-1)
    expr, operands = converter.amplitude(bitstring=bitstring)
    amplitude = contract(expr, *operands)

def measure_duration(n_qubits, n_depth):
    qc = make_circuit(n_qubits, n_depth)
    print("start")
    start = time.time()
    exec(qc)
    end = time.time()
 
    duration = end-start
    print(duration)
    return duration

n_qubits = 100
n_depth = 10

durations = []
for i in range(3):
  durations.append(measure_duration(n_qubits, n_depth))

print(f"average: {np.mean(durations)}")

上記コードのように、cuQuantumで巨大な回路をシミュレーションする場合、「測定したい計算基底の値」を指定する必要があります。あくまでbitstringで指定したビット列の確率振幅のみが得られます。状態ベクトルの全てを求めるようなものではありません。
「全量子ビットが0になる確率」だけが分かればOKというような設計の量子回路に向いているのかなと思います。

回路深さを変化させて計測

n_qubitsとn_depthを色々変えて計測してみた結果が下図です。

20量子ビットだと回路を深くしてもそれほど処理時間が延びませんが、30量子ビット以上のものは、n_depthが12~13になった辺りで急激に処理時間が増えました(それ以上はどんどん時間がかかるので諦めました)。
そもそも100量子ビットが動かせるだけで大変ありがたいのですが、やはり回路が深くなると相応に時間がかかるようです。

バッチ計算

先述の通りcuQuantumでは「測定したい値」を指定する必要がありますが、batched_amplitudes関数を用いて一部の量子ビットのみ指定することも可能です。

def exec(qc, n_fixed):
    converter = CircuitToEinsum(qc, dtype='complex128', backend='cupy')
    fixed = dict(zip(converter.qubits[:n_fixed], "0"*n_fixed))
    expr, operands = converter.batched_amplitudes(fixed=fixed)
    amplitude = contract(expr, *operands)

ただし、例えば3量子ビット中の下1桁のみを指定した場合は残りの2量子ビットを全パターン計算することに(おそらく)なるので、それだけ時間がかかります。
batched_amplitudesの処理時間も計測してみました。

n_fixedの桁数を固定して計測

fixedを1桁のみに固定し、量子ビット数と深さを変えて計測した結果が以下です。

量子ビット数とn_depthが増えると、急激に処理時間が増加しています。
ただ、こちらも20量子ビットのときは回路が深くなっても処理時間があまり変わりません。

n_fixedの桁数を変化させて計測

量子ビット数と深さを固定し、fixedの桁数を変えて計測した結果が下図です。

やはり20量子ビットの場合、fixedの桁数が少なくても処理時間はそんなに変わらないです。
逆に言えば、量子ビット数が少ないと、固定桁数を増やしてもあまり恩恵がないとも言えそうです。
23量子ビットだと、fixedの桁数が減るにつれて処理時間が急激に増加しました。ただ、だいたい4〜5桁より大きければそう差はありません。このあたりは量子ビット数や回路の深さによって変わってきそうです。

最後に、n_qubits = 30、 n_depth = 20に固定し、n_fixedを4から20まで変化させたときは下図のようになりました。

n_fixed = 4だとかなり時間がかかりますが、n_fixed = 11あたりで処理時間は落ち着きます。
なおn_fixedが1~3桁のときは、GPUのメモリが足りず実行不可でした。

まとめ

cuQuantumでテンソルネットワークを使った時のポテンシャル(処理時間)を色々測ってみました。
回路の深さやbatched_amplitudesで指定する桁数次第で、飛躍的に処理時間が増大しました。
cuQuantumをうまく使うには、「(恩恵が受けられる程度の)適度に大きな量子ビット数」 「一定以下の深さ」 「測定したい値が決まっている(状態ベクトルを全て知る必要がない)」という条件が揃っていると良さそうです。

Discussion