프로그래밍 모델
프로그래밍 모델은 소프트웨어가 구조화되고 실행되는 방식을 정의하는 기본 사양입니다. 이들은 개발자가 알고리즘을 표현하고 코드를 구성할 수 있는 프레임워크를 제공하며, 종종 기본 하드웨어 또는 실행 환경의 낮은 수준의 세부 사항을 추상화합니다. 서로 다른 모델은 서로 다른 유형의 문제와 하드웨어 아키텍처에 적합하며, 다양한 수준의 추상화와 제어를 제공합니다.
이 강의에서는 양자 및 고전 프로그래밍 모델을 검토하고, 이들을 결합하여 이질적 환경에서 알고리즘을 작동시키는 방법을 살펴봅시다. Iskandar Sitdikov이 다음 비디오에서 개요를 제시합니다.
QPU용 프로그래밍 모델
양자 컴퓨터의 프로그래밍 모델부터 시작하겠습니다. 거의 모든 양자 개발자가 익숙한 기본 프로그래밍 모델은 양자 회로입니다. 양자 회로 모델의 세부 사항에 대해서는 여기서 다루지 않겠습니다. 이미 이를 자세히 설명하는 훌륭한 John Watrous의 강의가 있기 때문입니다. 우리가 언급할 것은 회로가 큐빗을 나타내는 선(와이어라고 불림), 양자 상태에 대한 연산을 나타내는 게이트, 그리고 측정의 집합으로 구성된다는 것입니다.
양자 컴퓨팅을 위한 또 다른 중요한 프로그래밍 모델 개념은 계산 기본요소(computational primitives)입니다. 이러한 기본요소들은 사용자가 양자 컴퓨터로 달성하고자 하는 가장 일반적인 작업들을 나타냅니다. 현재 Executor를 포함한 여러 기본요소가 있습니다. 이 과정에서는 주로 Sampler와 Estimator 기본요소에 중점을 두겠습니다. Sampler는 양자 회로에 의해 준비된 상태를 샘플링할 수 있게 해줍니다. 양자 회로에서 준비된 양자 상태를 구성하는 계산 기저 상태를 알려줍니다. Estimator는 양자 회로에 의해 준비된 상태의 시스템에 대해 관측가능량의 기댓값을 추정할 수 있게 해줍니다. 일반적인 경우는 특정 상태의 시스템 에너지를 추정하는 것입니다.
이 섹션에서 이야기할 마지막 것은 트랜스파일레이션(transpilation)입니다. 트랜스파일레이션은 주어진 입력 회로를 특정 양자 장치의 물리적 제약과 명령어 집합 아키텍처(ISA)와 일치하도록 다시 쓰는 과정입니다. 고전 컴파일러와 유사하게, 이는 추상적인 유니터리 연산을 대상 장치가 실행할 수 있는 기본 게이트 집합으로 변환하는 것을 의미합니다. 또한 회로 명령어를 노이즈가 있는 양자 컴퓨터에서 효율적으로 실행할 수 있도록 최적화하며, 여러 최적화 단계를 적용하여 점진적으로 회로의 구조를 변경합니다.
이해도 확인하기
아래 회로에는 몇 개의 큐빗이 있습니까?

답:
4개입니다.
이해도 확인하기
분자의 전자를 모델링하고 있다고 가정합시다. (a) 분자의 기저 상태 에너지와 (b) 분자의 기저 상태에서 가장 지배적인 계산 기저 상태가 어떤 것인지를 근사하고 싶습니다. 각각의 경우, Estimator 또는 Sampler 기본요소 중 어느 것을 사용하겠습니까?
답:
(a) Estimator (b) Sampler
고전 프로그래밍 모델
고전 컴퓨터를 위한 많은 프로그래밍 모델이 있지만, 이 섹션에서는 가장 인기 있는 두 가지인 병렬 프로그래밍과 작업 워크플로에 중점을 두겠습니다. 이 두 모델을 양자 프로그래밍 모델과 함께 사용하면, 거의 모든 복잡도의 하이브리드 양자-고전 워크플로를 표현할 수 있습니다.
병렬 프로그래밍
병렬 프로그래밍은 프로그램을 동시에 실행할 수 있는 하위 문제로 나누는 모델입니다. 병렬 프로그래밍에는 두 가지 주요 패러다임이 있습니다:
-
공유 메모리 병렬 처리(Open Multiprocessing 또는 OpenMP): 단일 계산 노드 내의 여러 코어를 활용하는 데 사용됩니다. 실행 스레드는 단일 메모리 공간을 공유합니다.
-
분산 메모리 병렬 처리(Message Passing Interface 또는 MPI): 여러 개별 계산 노드에 걸쳐 확장하는 데 사용됩니다. 각 프로세스는 자체 격리된 메모리 공간을 가집니다.
여기서는 분산 메모리 모델에 중점을 두겠습니다. 이 모델은 다중 노드 슈퍼컴퓨팅 및 대규모 이질적 양자-고전 작업 조율에 필수적이기 때문입니다.
분산 메모리 병렬 프로그래밍 모델에서 작동하기 위해 이해해야 할 몇 가지 개념이 있습니다:
- 프로세스(Process) - 자체 메모리 공간을 가진 프로그램의 독립적 인스턴스입니다.
- 순위(Rank) - 각 프로세스에 할당된 고유 정수 식별자로, 통신 중 발신자와 수신자를 식별하는 데 사용됩니다(반드시 우선순위의 의미에서 "순위"는 아닙니다).
- 동기화(Synchronization) - 서로 다른 순위와 프로세스 간 조율을 위한 메커니즘입니다.
- 단일 프로그램, 다중 데이터(SPMD) - 단일 소스 코드 인스턴스가 여러 프로세스에서 동시에 실행되고, 각각 전체 데이터의 서로 다른 부분 집합에서 작동하는 추상 계산 모델입니다.
- 메시지 전달(Message passing) - 분산 메모리 아키텍처에서 사용되는 통신 패러다임으로, 독립적 프로세스가 데이터 및 중간 결과를 교환할 수 있게 해줍니다. 이는 명시적인 '송신' 및 '수신' 연산에 의존하여 서로 다른 계산 노드 간 실행을 조율합니다.
MPI라는 표준이 병렬 아키텍처를 위한 이 메시지 전달 패러다임을 구현합니다. MPI는 위에 나열된 모든 개념의 기능적 구현으로, 프로세스를 관리하고 순위를 할당하고 동기화를 촉진하고 SPMD 모델에서 메시지 전달을 가능하게 하는 데 필요한 특정 라이브러리 호출을 제공합니다. 이 모든 개념을 함께 모으면, 병렬 프로그램의 실행은 다음과 같이 일어난다고 말할 수 있습니다:
- 컴파일된 단일 프로그램(동일한 바이너리 파일)이 작업 시작 프로그램에 복사되어 여러 노드에 걸쳐 여러 병렬 프로세스를 생성하기 위해 실행됩니다.
- 프로그램의 주 제어 흐름은 프로세스의 순위에 의해 결정됩니다. 이것이 SPMD 원칙의 작동입니다. 프로그램은 조건부 로직(예를 들어, if (rank == 0))을 사용하여 코드의 병렬화된 섹션만 워커 프로세스에 의해 실행되도록 하고, 마스터 프로세스(종종 Rank 0)가 초기화 및 최종 집계를 처리하도록 합니다.
- 프로세스 간 통신은 메시지 전달(MPI 사용)을 통해 발생하며, 이는 프로세스가 다른 순위와 데이터 또는 중간 결과를 교환해야 할 때마다 호출됩니다.
시각적으로, 다음과 같이 보일 것입니다:
우리가 방금 배운 개념들을 코드에 적용해봅시다.
먼저 OpenMPI를 사용하는 간단한 "hello world" 병렬 프로그램을 실행해보겠습니다. OpenMPI는 병렬 프로그래밍의 메시지 전달 표준인 MPI 프로토콜의 구현입니다. 여기서 우리는 메시지 전달 인터페이스(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")
~
~
이 프로그램을 실행하기 위해 우리의 제출 스크립트에서 지정할 두 개의 노드를 사용하겠습니다.
$ 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}
여기서 우리는 두 개의 노드를 사용했으며, 각 노드의 프로세스는 이제 순위(Rank 0과 Rank 1)로 식별되며, 이는 프로그램 제어 흐름을 결정하는 데 사용됩니다.
작업 워크플로
이제 Task workflow 프로그래밍 모델에 대해 이야기해봅시다. 작업 워크플로는 계산을 방향성 비순환 그래프(DAG)로 추상화합니다. 이 그래프에서 각 노드는 특정 작업 또는 작업을 나타내고, 엣지(노드를 연결하는 화살표)는 이들 간의 의존성(데이터 및 순서)을 나타냅니다. 스케줄러는 작업을 리소스에 매핑하고 실행을 조율하는 구성 요소입니다.
양자 컴퓨팅에 적용된 작업 워크플로 모델의 구체적인 예는 Qiskit 패턴 프레임워크입니다. Qiskit 패턴은 영역별 문제를 일련의 단계로 분해하기 위해 설계된 일반 프레임워크이며, 특히 양자 작업을 위한 것입니다. 이를 통해 IBM Quantum® 연구원(및 기타)이 개발한 새로운 기능의 원활한 조합성을 가능하게 하며, 양자 컴퓨팅 작업이 강력한 이질적(CPU/GPU/QPU) 컴퓨팅 인프라에 의해 수행되는 미래를 가능하게 합니다. Qiskit 패턴의 네 단계는 매핑, 최적화, 실행 및 후처리이며, 모든 작업은 파이프라인에서 차례대로 실행됩니다. 하지만 작업 워크플로 사용 시, 우리는 선형 실행 순서에 국한되지 않으며 작업을 병렬로 실행할 수 있습니다. 워크플로의 각 작업은 자체 병렬 작업 전체일 수 있습니다. 따라서 이 모델들을 혼합하고 매칭하여 임의로 복잡한 알고리즘을 설명할 수 있으며, Slurm과 같은 워크로드 관리자가 이를 처리합니다.
위의 이미지는 Qiskit 패턴이 작동 중인 것을 보여줍니다. 워크플로는 네 단계를 가진 그래프 구조를 가지고 있습니다. 이 가지 같은 구조는 스케줄러에 의해 조율되고 실행됩니다. 문제는 초기 단계에서 양자 실행 가능한 형태(양자 회로)로 매핑됩니다. 다음 단계에서, 이 양자 회로는 특정 양자 하드웨어에 대해 최적화됩니다. 이미지는 이를 병렬 프로세스로 보여주며, 이는 여러 최적화 전략이 동시에 적용될 수 있는 방식을 시연합니다. 최적화된 양자 회로는 그런 다음 실제 양자 하드웨어에서 실행됩니다. 이것은 스케줄러가 하나의 보라색 양자 처리 장치와 함께 작동하는 이미지의 세 번째 단계입니다. 마지막으로, 결과는 고전 리소스에 의해 후처리됩니다.
왜 둘 다?
그러면 왜 병렬 프로그래밍과 작업 워크플로가 모두 필요한가요? 양자 병렬성에 대한 모든 논의에도 불구하고, 양자 컴퓨팅에서 모든 것이 병렬적인 것은 아니라는 것을 명확히 할 가치가 있습니다.
이전의 SQD 워크플로에 대한 강의는 병렬화할 수 없는 일부 프로세스를 언급했습니다. 예를 들어, 우리는 행렬을 다루기 쉬운 차원의 부분 공간으로 투영하기 위해 많은 양자 측정의 결과가 필요합니다. 순차적으로, 우리는 대각화된 행렬과 관련된 상태 벡터가 필요합니다(예를 들어, 전하 보존을 사용하여) 양자 측정의 자기 일관성을 확인합니다. 그 후에, 우리는 기저 상태 에너지가 우리의 목적을 위해 충분히 수렴했는지 결정해야 합니다. 이러한 단계는 반드시 순차적이며 계속하기 전에 수렴과 자기 일관성 조건의 테스트가 필요합니다.
이 워크플로는 다음 섹션에서 더 자세히 검토되고 구현될 것입니다. 이 섹션에서 당신이 가져가야 할 유일한 것은 작업 워크플로가 필요하다는 것입니다.
프로그래밍 실습
프로그래밍 모델의 아름다움은 이들을 모두 함께 혼합하고 매칭할 수 있다는 것입니다. 양자 및 고전 프로그래밍 모델을 알면, 임의의 복잡도를 가진 이질적 계산을 설명하고 하드웨어에서 실행할 수 있습니다. 마지막 장에서 배운 Qiskit 패턴(맵, 최적화, 실행 및 후처리)을 Slurm 내에서 구현하는 결합된 워크플로의 작은 예제로 이를 연습해봅시다. 네 작업 각각은 자체 리소스를 가진 별도의 Slurm 작업이 될 것입니다. 최적화 작업은 MPI를 사용하여 회로를 병렬로 최적화합니다(예제를 위해서만, 위의 이미지처럼). 실행 작업은 양자 리소스 및 양자 프로그래밍 모델(회로 및 Sampler)을 사용합니다. 마지막 작업인 후처리는 다시 고전 리소스와 함께 MPI를 병렬로 사용합니다.
매핑
mapping.py 프로그램은 PauliTwoDesign 회로를 구축하기 위해 설계되었으며, 이는 양자 머신러닝 문헌과 양자 벤치마크 문헌에서 자주 사용되며, n-큐빗 시스템의 Z 방향에서 (n-1)번째 큐빗을 측정하는 간단한 관측가능량을 가집니다. 무작위 초기 매개변수가 포함됩니다. 이들 각각(양자 회로를 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에서 실행되는 핵심 양자 단계입니다. 이를 수행하기 위해, 먼저 세 파일(트랜스파일된 양자 회로, 관측가능량, 초기 매개변수)을 가져온 다음 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 명령의 의존성 인수를 사용하여 이 모든 작업을 워크플로로 연결할 수 있습니다:
$ 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단계 워크플로를 구축, 관리 및 실행하기 위해 다중 고전 및 양자 프로그래밍 모델을 결합하는 방법을 시연했습니다. 우리는 양자 회로와 기본요소의 기본 개념부터 시작했고, 그 다음 병렬 프로그래밍 및 작업 워크플로와 같은 고전 모델을 살펴봤습니다. 모든 개념을 결합함으로써, 우리는 Qiskit 패턴(매핑, 최적화, 실행 및 후처리)을 구축했으며, 이는 간단한 양자 회로 및 관측가능량으로 Slurm 워크로드 관리자에 의해 조율됩니다.
다음 강의에서, 우리는 이 프레임워크를 사용하여 표본 기반 양자 알고리즘을 실행하여, 이 워크플로가 의미 있는 문제를 해결하는 데 어떻게 적용될 수 있는지 보여줄 것입니다.
이 장에서 사용된 모든 코드 및 스크립트는 이 Github 저장소 내에서 여러분이 사용할 수 있습니다.