メインコンテンツへスキップ

プログラミングモデル

プログラミングモデルは、ソフトウェアの構造と実行方法を定義する基本的な仕様です。開発者がアルゴリズムを表現し、コードを整理するためのフレームワークを提供し、多くの場合、基盤となるハードウェアや実行環境の低レベルな詳細を抽象化します。それぞれのモデルは、異なる種類の問題やハードウェアアーキテクチャに適しており、抽象化と制御のレベルもさまざまです。

このレッスンでは、量子および古典的なプログラミングモデルを確認し、それらを組み合わせてヘテロジニアス環境でアルゴリズムを動作させる方法を見ていきます。Iskandar Sitdikov が以下の動画で概要を説明します。

QPU 向けプログラミングモデル

まず、量子コンピューター向けのプログラミングモデルから始めます。ほぼすべての量子開発者にとって馴染み深い基本的なプログラミングモデルは、量子回路です。量子回路モデルの詳細についてはここでは触れません。John Watrous による詳細な講義がすでに用意されており、そちらで詳しく説明されているからです。回路は、量子ビットを表す線(ワイヤと呼ばれます)、量子状態への演算を表すゲート、そして一連の測定で構成されているとだけ述べておきましょう。

量子ビットを水平線として、量子ゲートをボックスまたは量子ビット間の接続として示す量子回路図。

量子コンピューティングにおけるもう一つの重要なプログラミングモデルの概念が、「計算プリミティブ」と呼ばれるものです。これらのプリミティブは、ユーザーが量子コンピューターで達成しようとする最も一般的なタスクの一部を表しています。現在、Executor を含むいくつかのプリミティブが利用可能です。このコースでは、主に Sampler と Estimator のプリミティブに焦点を当てます。Sampler は、量子回路で準備された状態をサンプリングする機能を提供します。量子回路で準備された量子状態を構成する計算基底状態を示します。Estimator は、量子回路で準備された状態にあるシステムに対して、オブザーバブルの期待値を推定することができます。よくある使用例としては、特定の状態にあるシステムのエネルギーを推定することが挙げられます。

サンプラーの結果のモデルヒストグラム。測定される確率が非常に高い状態もあれば、非常に低い状態もあります。

このセクションで最後に取り上げるのはトランスパイルです。トランスパイルとは、与えられた入力回路を、特定の量子デバイスの物理的制約と命令セットアーキテクチャ(ISA)に合わせて書き換えるプロセスです。古典的なコンパイラと同様に、抽象的なユニタリ演算をターゲットデバイスが実行できるネイティブゲートセットに変換することを意味します。また、ノイズの多い量子コンピューターで効率的に実行できるよう回路命令を最適化し、複数の最適化ステージを適用することで回路の構造を段階的に変更します。

抽象的な回路が命令セットアーキテクチャ回路にマッピングされる様子を示すトランスパイルの図。つまり、ターゲットハードウェアのネイティブゲートと接続性を使用して回路が書き換えられます。

理解度チェック

以下の回路には量子ビットがいくつありますか? 4本の水平線と多くのゲートを持つ回路図。

答え:

4つです。

理解度チェック

分子内の電子をモデル化しているとします。(a) 分子の基底状態エネルギーと、(b) 分子の基底状態で最も支配的な計算基底状態を近似したいとします。それぞれの場合、Estimator と Sampler のどちらのプリミティブを使用しますか?

答え:

(a) Estimator (b) Sampler

古典的なプログラミングモデル

古典コンピューター向けにはさまざまなプログラミングモデルがありますが、このセクションでは最もよく使われる2つ、並列プログラミングとタスクワークフローに焦点を当てます。この2つのモデルを量子プログラミングモデルと組み合わせることで、あらゆる複雑さのハイブリッド量子古典ワークフローをほぼすべて表現できます。

並列プログラミング

並列プログラミングは、プログラムを同時に実行できるサブ問題に分割するモデルです。並列プログラミングには主に2つのパラダイムがあります:

  • 共有メモリ並列処理(Open Multiprocessing、または OpenMP):単一の計算ノード内で複数のコアを活用するために使用されます。実行スレッドは1つのメモリ空間を共有します。

  • 分散メモリ並列処理(Message Passing Interface、または MPI):複数の独立した計算ノードにまたがってスケールするために使用されます。各プロセスは独自の独立したメモリ空間を持ちます。

ここでは、マルチノードのスーパーコンピューティングと大規模なヘテロジニアス量子古典ジョブの調整に不可欠な分散メモリモデルに焦点を当てます。

分散メモリ並列プログラミングモデルで操作するために理解しておく必要があるいくつかの概念があります:

  • プロセス - 独自のメモリ空間を持つプログラムの独立したインスタンス。
  • ランク - 各プロセスに割り当てられた一意の整数識別子で、通信中の送信者と受信者を識別するために使用されます(必ずしも優先順位の意味での「ランク」ではありません)。
  • 同期 - 異なるランクやプロセス間の調整のためのメカニズム。
  • Single Program, Multiple Data(SPMD)- 1つのソースコードインスタンスが複数のプロセスで同時に実行され、各プロセスが全データの異なるサブセットを処理する抽象的な計算モデル。
  • メッセージパッシング - 独立したプロセスがデータや中間結果を交換できるようにする分散メモリアーキテクチャで使用される通信パラダイム。異なる計算ノード間の実行を調整するために、明示的な「送信」と「受信」の操作に依存します。

並列アーキテクチャ向けにこのメッセージパッシングパラダイムを実装するMPIという標準があります。MPIは上記のすべての概念の具体的な実装として機能し、SPMDモデルのもとでプロセスの管理、ランクの割り当て、同期の実現、メッセージパッシングの有効化に必要な特定のライブラリ呼び出しを提供します。これらすべての概念をまとめると、並列プログラムの実行は次のように行われます:

  • 1つのコンパイル済みプログラム(同じバイナリファイル)がジョブランチャーによってコピーされ実行され、複数のノードにまたがる複数の並列プロセスが作成されます。
  • プログラムのメイン制御フローはプロセスのランクによって決まります。これがSPMDの原則の実践です:プログラムは条件付きロジック(例:if (rank == 0))を使用して、コードの特定の並列化されたセクションをワーカープロセスのみが実行し、マスタープロセス(多くの場合 Rank 0)が初期化と最終集約を処理するようにします。
  • プロセス間の通信は、プロセスが別のランクとデータや中間結果を交換する必要があるときに呼び出されるメッセージパッシング(MPIを使用)を通じて行われます。

視覚的には、次のようなイメージになります:

タスクがノード間で分割される様子を示す図。

先ほど学んだ概念のいくつかをコードに適用してみましょう。

まず、MPIプロトコル(並列プログラミングにおけるメッセージパッシングの標準)の実装である OpenMPI を使用して、シンプルな「hello world」並列プログラムを実行してみます。ここでは、Message Passing Interface(MPI)標準のPythonバインディングである mpi4py Pythonパッケージを使用します。

$ vim mpi-hello-world.py
from mpi4py import MPI
import sys

comm = MPI.COMM_WORLD
rank = comm.Get_rank()
size = comm.Get_size()

sys.stdout.write(f"[Rank {rank}] Hello from process {rank} of {size}!\n")

if rank == 0:
data = {'answer': 42, 'pi': 3.14}
sys.stdout.write(f"[Rank {rank}] Sending: {data}\n")
comm.send(data, dest=1, tag=42)
elif rank == 1:
data = comm.recv(source=0, tag=42)
sys.stdout.write(f"[Rank {rank}] Received: {data}\n")

~
~

このプログラムを実行するために2つのノードを使用します。これはサブミッションスクリプトで指定します。

$ vim mpi-hello-world.sh

#!/bin/bash
#
#SBATCH --job-name=mpi-hello-world
#SBATCH --output=mpi-hello-world.out
#SBATCH --nodes=2
#SBATCH --ntasks-per-node=1
#SBATCH --cpus-per-task=1
#SBATCH --partition=normal

/usr/lib64/openmpi/bin/mpirun python /data/ch3/parallel/mpi-hello-world.py

次にシェルスクリプトを実行します。

$ sbatch mpi-hello-world.sh

ジョブの結果ログを確認できます。

$ cat mpi-hello-world.out | grep Rank

[Rank 1] Hello from process 1 of 2!
[Rank 0] Hello from process 0 of 2!
[Rank 0] Sending: {'answer': 42, 'pi': 3.14}
[Rank 1] Received: {'answer': 42, 'pi': 3.14}

ここでは2つのノードを使用し、各ノード上のプロセスがランク(Rank 0 と Rank 1)によって識別されるようになっています。このランクがプログラムの制御フローを決定します。

タスクワークフロー

次に、タスクワークフロープログラミングモデルについて説明します。タスクワークフローは、計算を有向非巡回グラフ(DAG)として抽象化します。このグラフでは、各ノードが特定のタスクやジョブを表し、エッジ(ノードをつなぐ矢印)がそれらの間の依存関係(データと順序)を表します。スケジューラーは、タスクをリソースにマッピングし、実行を調整するコンポーネントです。

量子コンピューティングに適用されたタスクワークフローモデルの具体的な例が、Qiskit パターンフレームワークです。Qiskit パターンは、ドメイン固有の問題を一連のステージに分解するために設計された汎用フレームワークであり、特に量子タスクに適しています。これにより、IBM Quantum® の研究者(およびその他の人々)が開発した新しい機能をシームレスに組み合わせることができ、強力なヘテロジニアス(CPU/GPU/QPU)コンピューティングインフラストラクチャによって量子コンピューティングタスクが実行される未来を可能にします。Qiskit パターンの4つのステップは、マッピング、最適化、実行、後処理であり、すべてのタスクはパイプラインで順番に実行されます。しかしタスクワークフローでは、線形の実行順序に縛られることなく、タスクを並列で実行することもできます。ワークフローの各タスクは、それ自体が独立した並列ジョブであることもできます。つまり、これらのモデルを組み合わせて任意に複雑なアルゴリズムを記述でき、Slurm のようなワークロードマネージャーがそれを処理します。

一部のプロセスが並列で実行され、他のプロセスが順次実行されるワークフローに編成された計算タスクの図。

上の画像は Qiskit パターンの動作を示しています。ワークフローは4つのステージを持つグラフ構造をしています。この分岐のような構造は、スケジューラーによって調整・実行されます。問題は最初のステージで量子実行可能な形式(量子回路)にマッピングされます。次のステージでは、この量子回路が特定の量子ハードウェア向けに最適化されます。画像ではこれを並列プロセスとして示しており、複数の最適化戦略を同時に適用できることを示しています。最適化された量子回路は、実際の量子ハードウェアで実行されます。これが画像の3番目のステージで、スケジューラーが1つの紫色の量子処理ユニットと連携しています。最後に、結果が古典リソースによって後処理されます。

なぜ両方必要なのか?

では、なぜ並列プログラミングとタスクワークフローの両方が必要なのでしょうか?量子並列性についての様々な議論がありますが、量子コンピューティングにおいてすべてが並列であるわけではないことを明確にしておく価値があります。

SQD ワークフローに関する前のレッスンでは、並列化できないプロセスのいくつかに触れました。たとえば、行列を扱いやすい次元の部分空間に射影するためには、多くの量子測定の結果が必要です。次に、量子測定の自己整合性を確認するために(たとえば、電荷保存を使用して)、対角化された行列と関連する状態ベクトルが必要です。そのすべての後に、基底状態エネルギーが私たちの目的に対して十分に収束しているかどうかを判断する必要があります。これらのステップは必然的に順次的であり、進む前に収束と自己整合性の条件をテストする必要があります。

サンプルベースの量子対角化に特有のワークフローの模式図。変分量子回路、測定を使用してハミルトニアンを部分空間に射影するステップ、次に古典オプティマイザーを使用して回路の変分パラメーターを更新して繰り返すステップが含まれます。

このワークフローは次のセクションでより詳細に再検討され、実装されます。このセクションから持ち帰るべき唯一のことは、タスクワークフローが必要だということです。

プログラミング実践

プログラミングモデルの素晴らしさは、それらすべてを組み合わせることができる点です。量子および古典的なプログラミングモデルを知ることで、任意の複雑さのヘテロジニアス計算を記述し、ハードウェアで実行することができます。前章で学んだ Slurm 内で Qiskit パターン(マップ、最適化、実行、後処理)を実装する、組み合わせたワークフローの小さな例で練習しましょう。4つのタスクのそれぞれが別個の Slurm ジョブとなり、それぞれが独自のリソースを持ちます。最適化タスクは MPI を使用して回路を並列で最適化します(上の画像のように、あくまで例示のためです)。実行タスクは量子リソースと量子プログラミングモデル(回路と Sampler)を使用します。最後のタスク(後処理)は、古典リソースと MPI を再び並列で使用します。

マッピング

mapping.py プログラムは、量子機械学習文献や量子ベンチマーク文献で頻繁に使用される PauliTwoDesign 回路を構築するように設計されています。これは、ランダムな初期パラメーターを持つ nn 量子ビットシステムの ZZ 方向で (n1)th(n-1)^\text{th} 量子ビットを測定するシンプルなオブザーバブルを使用します。これらのそれぞれ(qasm ファイルに変換された量子回路、オブザーバブル、パラメーター)は、データディレクトリの別々のファイルに保存され、最適化ステージの入力として使用されます。

このステージのシェルスクリプト(mapping.sh)は次のとおりです。

#!/bin/bash
#
#SBATCH --job-name=mapping
#SBATCH --output=mapping.out
#SBATCH --nodes=1
#SBATCH --ntasks-per-node=1
#SBATCH --cpus-per-task=1
#SBATCH --partition=normal

srun python /data/ch3/workflows/mapping.py

これはジョブ名、出力形式、ノード/タスク/CPU の数を定義します。

最適化

optimization.py プログラムは、マッピングステージからファイルを取得することから始まります。ここでは QRMI を使用して、このプログラムに量子リソースを組み込みます。

qrmi = QRMI()
resources = qrmi.resources()
quantum_resource = resources[0]
...

次に、optimization_level=1 を設定して量子回路をトランスパイルし、回路のレイアウトをオブザーバブルに適用する軽い最適化を行い、これらをデータフォルダーに保存します。

このステージのシェルスクリプト(optimization.sh)は次のとおりです。

#!/bin/bash
#SBATCH --job-name=optimization
#SBATCH --output=output/optimization.out
#SBATCH --ntasks=4
#SBATCH --partition=classical

srun python3 /tmp/optimization.py

ここで --ntasks=4 は、並列処理のために Slurm に4つの古典タスクを要求します。

実行

これは、前のステップで最適化された量子回路が Estimator によって QPU で実行されるコアの量子ステージです。これを行うには、まずトランスパイルされた量子回路、オブザーバブル、初期パラメーターの3つのファイルを取得し、Estimator に渡します。オブザーバブルの推定値が得られ、出力されます。

execution.sh スクリプトは、Slurm プラグインを活用して量子リソースを使用します。

#!/bin/bash
#
#SBATCH --job-name=execution
#SBATCH --output=execution.out
#SBATCH --nodes=1
#SBATCH --ntasks-per-node=1
#SBATCH --cpus-per-task=1
#SBATCH --partition=quantum
#SBATCH --gres=qpu:1

srun python /data/ch3/workflows/execution.py

後処理

後処理ステップには、多くの場合、古典的な対角化と自己整合性チェックが含まれます。また、反復的な場合もあります。後処理ステップは次のレッスンで検討するのが最も適切です。そこでは、物理的なコンテキストと反復ステップの目的が明確になります。

すべてを組み合わせる

sbatch コマンドの dependency 引数を使用して、これらすべてのタスクをワークフローにつなげることができます:

$ MAPPING_JOB=$(sbatch --parsable mapping.sh)
$ OPTIMIZE_JOB=$(sbatch --parsable --dependency=afterok:$MAPPING_JOB optimization.sh)
$ EXECUTE_JOB=$(sbatch --parsable --dependency=afterok:$OPTIMIZE_JOB execute.sh)

Slurm の実行キューを確認できます。

$ squeue
# JOBID PARTITION NAME USER ST TIME NODES NODELIST(REASON)
# 3 classical mapping admin PD 0:00 1 (None)
# 4 classical optimiza admin PD 0:00 1 (Dependency)
# 5 quantum execute admin PD 0:00 1 (Dependency)

これはプログラミングモデルの組み合わせを示すためのサンプル例でした。次の章では、実世界のアルゴリズムを見て、有用なワークフローにおけるプログラミングモデルとリソース管理を実演します。

まとめ

このレッスンでは、複数の古典的および量子プログラミングモデルを組み合わせて、完全な4ステージワークフローを構築、管理、実行する方法を示しました。量子回路とプリミティブの基本概念から始め、並列プログラミングやタスクワークフローなどの古典モデルを探求しました。すべての概念を組み合わせることで、Slurm ワークロードマネージャーによってオーケストレーションされた Qiskit パターン(マップ、最適化、実行、後処理)を、シンプルな量子回路とオブザーバブルを使って構築しました。

次のレッスンでは、このフレームワークを使用してサンプルベースの量子アルゴリズムを実行し、このワークフローが意味のある問題を解決するためにどのように適用できるかを示します。

この章で使用したすべてのコードとスクリプトは、このGithub リポジトリで入手できます。