diff --git a/.gitignore b/.gitignore index 09e6a753..8969ca37 100644 --- a/.gitignore +++ b/.gitignore @@ -134,6 +134,11 @@ venv/ ENV/ env.bak/ venv.bak/ +.asv/ + +# Downloaded benchmark files (cached by tests/benchmarks/qasm/benchmark_downloader.py) +tests/benchmarks/qasm/*.qasm +!tests/benchmarks/qasm/neutral_atom_gate.qasm # Spyder project settings .spyderproject diff --git a/CHANGELOG.md b/CHANGELOG.md index 736421bc..9a882e7b 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -15,6 +15,7 @@ Types of changes: ## Unreleased ### Added +- Added `asv` benchmarking support in `pyqasm`. ([#258](https://github.com/qBraid/pyqasm/pull/258)) ### Improved / Modified diff --git a/asv.conf.json b/asv.conf.json new file mode 100644 index 00000000..1ad3caaa --- /dev/null +++ b/asv.conf.json @@ -0,0 +1,33 @@ +{ + "version": 1, + "project": "pyqasm", + "project_url": "https://sdk.qbraid.com/pyqasm/", + "repo": ".", + "install_command": [ + "in-dir={env_dir} python -m pip install {wheel_file}[visualization,pulse]" + ], + "uninstall_command": [ + "return-code=any python -m pip uninstall -y pyqasm" + ], + "build_command": [ + "python -m pip install -U build", + "python -m build --outdir {build_cache_dir} --wheel {build_dir}" + ], + "branches": [ + "main" + ], + "dvcs": "git", + "environment_type": "virtualenv", + "show_commit_url": "https://github.com/qBraid/pyqasm/commit/", + "pythons": [ + "3.10", + "3.11", + "3.12", + "3.13", + "3.14" + ], + "benchmark_dir": "tests/benchmarks", + "env_dir": ".asv/env", + "results_dir": ".asv/results", + "html_dir": ".asv/html" +} \ No newline at end of file diff --git a/tests/benchmarks/__init__.py b/tests/benchmarks/__init__.py new file mode 100644 index 00000000..358894cd --- /dev/null +++ b/tests/benchmarks/__init__.py @@ -0,0 +1,13 @@ +# Copyright 2025 qBraid +# +# Licensed under the Apache License, Version 2.0 (the "License"); +# you may not use this file except in compliance with the License. +# You may obtain a copy of the License at +# +# http://www.apache.org/licenses/LICENSE-2.0 +# +# Unless required by applicable law or agreed to in writing, software +# distributed under the License is distributed on an "AS IS" BASIS, +# WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +# See the License for the specific language governing permissions and +# limitations under the License. diff --git a/tests/benchmarks/import.py b/tests/benchmarks/import.py new file mode 100644 index 00000000..ed4b4d1a --- /dev/null +++ b/tests/benchmarks/import.py @@ -0,0 +1,28 @@ +# Copyright 2025 qBraid +# +# Licensed under the Apache License, Version 2.0 (the "License"); +# you may not use this file except in compliance with the License. +# You may obtain a copy of the License at +# +# http://www.apache.org/licenses/LICENSE-2.0 +# +# Unless required by applicable law or agreed to in writing, software +# distributed under the License is distributed on an "AS IS" BASIS, +# WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +# See the License for the specific language governing permissions and +# limitations under the License. + +""" +This module is used to test the import time of pyqasm. +""" + +from subprocess import check_call +from sys import executable + + +class PyqasmImport: + """Test the import time of pyqasm.""" + + def time_pyqasm_import(self): + # check_call, not call: a failed import would otherwise be timed as a fast success + check_call((executable, "-c", "import pyqasm")) diff --git a/tests/benchmarks/openpulse.py b/tests/benchmarks/openpulse.py new file mode 100644 index 00000000..cfac2ecf --- /dev/null +++ b/tests/benchmarks/openpulse.py @@ -0,0 +1,33 @@ +# Copyright 2025 qBraid +# +# Licensed under the Apache License, Version 2.0 (the "License"); +# you may not use this file except in compliance with the License. +# You may obtain a copy of the License at +# +# http://www.apache.org/licenses/LICENSE-2.0 +# +# Unless required by applicable law or agreed to in writing, software +# distributed under the License is distributed on an "AS IS" BASIS, +# WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +# See the License for the specific language governing permissions and +# limitations under the License. + +""" +This module is used to test the openpulse of pyqasm. +""" + +from pyqasm import load + +from .qasm.benchmark_downloader import get_benchmark_file + + +class Openpulse: + """Test the pyqasm openpulse functionality.""" + + def setup(self): + # Get benchmark file, downloading if necessary + # pylint: disable-next=attribute-defined-outside-init + self.qasm_file = get_benchmark_file("neutral_atom_gate.qasm") + + def time_openpulse(self): + _ = load(self.qasm_file).unroll() diff --git a/tests/benchmarks/pyqasm_functions.py b/tests/benchmarks/pyqasm_functions.py new file mode 100644 index 00000000..364eebab --- /dev/null +++ b/tests/benchmarks/pyqasm_functions.py @@ -0,0 +1,90 @@ +# Copyright 2025 qBraid +# +# Licensed under the Apache License, Version 2.0 (the "License"); +# you may not use this file except in compliance with the License. +# You may obtain a copy of the License at +# +# http://www.apache.org/licenses/LICENSE-2.0 +# +# Unless required by applicable law or agreed to in writing, software +# distributed under the License is distributed on an "AS IS" BASIS, +# WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +# See the License for the specific language governing permissions and +# limitations under the License. + +# pylint: disable=attribute-defined-outside-init + +""" +This module is used to test the pyqasm functions. +""" + +import os +from pathlib import Path + +from pyqasm import dump, dumps, load, printer + +from .qasm.benchmark_downloader import get_benchmark_file + + +class PyqasmFunctions: + """Test the pyqasm functions.""" + + # Define parameters for asv + params = [["small (224 lines)", "mid (2335 lines)", "large (17460 lines)"]] + param_names = ["qasm_file"] + timeout = 600 + + def setup(self, file_size): + # Extract the original file size name from the parameter value + if "(224 lines)" in file_size: + file_size_key = "small" + elif "(2335 lines)" in file_size: + file_size_key = "mid" + elif "(17460 lines)" in file_size: + file_size_key = "large" + else: + file_size_key = file_size + + # Define files for each size category + self.files = { + "small": "vqe_uccsd_n4.qasm", # 224 lines + "mid": "dnn_n16.qasm", # 2335 lines + "large": "qv_N029_12345.qasm", # 17460 lines + } + + # Get benchmark file for the specified size + self.qasm_file = get_benchmark_file(self.files[file_size_key]) + self.pyqasm_obj = load(self.qasm_file) + + # mpl_draw unrolls in place, so give it its own module, unrolled up front: + # sharing pyqasm_obj would make only the first draw pay for the unroll + self.draw_obj = load(self.qasm_file) + self.draw_obj.unroll() + + # Create output file path for dump operations + input_path = Path(self.qasm_file) + self.output_file = str(input_path.parent / f"{file_size_key}_unrolled.qasm") + + def teardown(self, _): + # Clean up the output file if it was created + if hasattr(self, "output_file") and os.path.exists(self.output_file): + try: + os.remove(self.output_file) + except OSError: + pass + + def time_load(self, _): + """Load QASM file of specified size.""" + _ = load(self.qasm_file) + + def time_dumps(self, _): + """Serialize QASM object of specified size to string.""" + _ = dumps(self.pyqasm_obj) + + def time_dump(self, _): + """Dump QASM object of specified size to file.""" + dump(self.pyqasm_obj, self.output_file) + + def time_draw(self, _): + """Draw QASM object of specified size.""" + _ = printer.mpl_draw(self.draw_obj, idle_wires=True, external_draw=False) diff --git a/tests/benchmarks/qasm/__init__.py b/tests/benchmarks/qasm/__init__.py new file mode 100644 index 00000000..358894cd --- /dev/null +++ b/tests/benchmarks/qasm/__init__.py @@ -0,0 +1,13 @@ +# Copyright 2025 qBraid +# +# Licensed under the Apache License, Version 2.0 (the "License"); +# you may not use this file except in compliance with the License. +# You may obtain a copy of the License at +# +# http://www.apache.org/licenses/LICENSE-2.0 +# +# Unless required by applicable law or agreed to in writing, software +# distributed under the License is distributed on an "AS IS" BASIS, +# WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +# See the License for the specific language governing permissions and +# limitations under the License. diff --git a/tests/benchmarks/qasm/benchmark_downloader.py b/tests/benchmarks/qasm/benchmark_downloader.py new file mode 100644 index 00000000..accf517a --- /dev/null +++ b/tests/benchmarks/qasm/benchmark_downloader.py @@ -0,0 +1,80 @@ +# Copyright 2025 qBraid +# +# Licensed under the Apache License, Version 2.0 (the "License"); +# you may not use this file except in compliance with the License. +# You may obtain a copy of the License at +# +# http://www.apache.org/licenses/LICENSE-2.0 +# +# Unless required by applicable law or agreed to in writing, software +# distributed under the License is distributed on an "AS IS" BASIS, +# WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +# See the License for the specific language governing permissions and +# limitations under the License. + +""" +Benchmark file downloader utility. + +This module handles downloading benchmark QASM files from the Qiskit repository +and caching them locally to avoid storing large files in version control. +""" + +import json +import urllib.request +from pathlib import Path +from typing import Dict, Optional + +_DOWNLOAD_TIMEOUT = 30 # seconds + + +class BenchmarkDownloader: + """Handles downloading and caching of benchmark files.""" + + def __init__(self, cache_dir: Optional[str] = None): + """Initialize the downloader.""" + self.cache_dir = Path(cache_dir) if cache_dir else Path(__file__).parent + self.cache_dir.mkdir(exist_ok=True) + + # Load metadata + with open(self.cache_dir / "benchmark_metadata.json", "r", encoding="utf-8") as f: + self.metadata = json.load(f) + + def get_file_path(self, filename: str) -> Path: + """Get the path for a benchmark file, fetching from remote repository if needed.""" + # Check local_files first + if filename in self.metadata["local_files"]: + file_path = self.cache_dir / filename + if not file_path.exists(): + raise FileNotFoundError(f"Local file {filename} not found in {self.cache_dir}") + return file_path + + # Check benchmark_files + if filename in self.metadata["benchmark_files"]: + file_info = self.metadata["benchmark_files"][filename] + # Fetch remote files + return self._fetch_remote_file(filename, file_info) + + raise ValueError(f"Unknown benchmark file: {filename}") + + def _fetch_remote_file(self, filename: str, file_info: Dict) -> Path: + """Fetch a remote benchmark file into the cache and return its path.""" + cached_path = self.cache_dir / filename + if cached_path.exists(): + return cached_path + + url = file_info["url"] + try: + # without a timeout a stalled connection blocks the whole benchmark run + with urllib.request.urlopen(url, timeout=_DOWNLOAD_TIMEOUT) as response: + content = response.read() + except Exception as e: + raise RuntimeError(f"Failed to fetch {filename} from {url}: {e}") from e + + cached_path.write_bytes(content) + return cached_path + + +def get_benchmark_file(filename: str) -> str: + """Get the path to a benchmark file, downloading if necessary.""" + downloader = BenchmarkDownloader() + return str(downloader.get_file_path(filename)) diff --git a/tests/benchmarks/qasm/benchmark_metadata.json b/tests/benchmarks/qasm/benchmark_metadata.json new file mode 100644 index 00000000..130ead76 --- /dev/null +++ b/tests/benchmarks/qasm/benchmark_metadata.json @@ -0,0 +1,37 @@ +{ + "benchmark_files": { + "vqe_uccsd_n4.qasm": { + "source": "Qiskit-benchpress", + "url": "https://raw.githubusercontent.com/Qiskit/benchpress/573c9ce7a1ffebd4ca6d1b0971eba7aca2c9c9a4/benchpress/qasm/qasmbench-small/vqe_uccsd_n4/vqe_uccsd_n4.qasm", + "description": "VQE UCCSD benchmark with 4 qubits (small)", + "size_bytes": 15000 + }, + "dnn_n16.qasm": { + "source": "Qiskit-benchpress", + "url": "https://raw.githubusercontent.com/Qiskit/benchpress/573c9ce7a1ffebd4ca6d1b0971eba7aca2c9c9a4/benchpress/qasm/qasmbench-medium/dnn_n16/dnn_n16.qasm", + "description": "Deep Neural Network benchmark with 16 qubits (medium)", + "size_bytes": 120000 + }, + "qv_N029_12345.qasm": { + "source": "Qiskit-benchpress", + "url": "https://raw.githubusercontent.com/Qiskit/benchpress/573c9ce7a1ffebd4ca6d1b0971eba7aca2c9c9a4/benchpress/qasm/qv/qv_N029_12345.qasm", + "description": "Quantum Volume benchmark with 29 qubits (large, 17,460 lines)", + "size_bytes": 470000 + } + }, + "repository_info": { + "name": "Qiskit-benchpress", + "url": "https://github.com/Qiskit/benchpress", + "description": "QASM benchmark files from Qiskit benchpress repository", + "license": "Apache-2.0", + "commit_hash": "573c9ce7a1ffebd4ca6d1b0971eba7aca2c9c9a4" + }, + "local_files": { + "neutral_atom_gate.qasm": { + "source": "local", + "description": "Neutral atom gate benchmark (local file)", + "size_bytes": 3584, + "local_only": true + } + } +} \ No newline at end of file diff --git a/tests/benchmarks/qasm/neutral_atom_gate.qasm b/tests/benchmarks/qasm/neutral_atom_gate.qasm new file mode 100644 index 00000000..1cfe2dcc --- /dev/null +++ b/tests/benchmarks/qasm/neutral_atom_gate.qasm @@ -0,0 +1,96 @@ +OPENQASM 3.0; +defcalgrammar "openpulse"; + +// Raman transition detuning delta from the 5S1/2 to 5P1/2 transition +const float delta = 100e6; + +// Hyperfine qubit frequency +const float qubit_freq = 6.0e9; + +// Positional frequencies for the AODS to target the specific qubit +const float q1_pos_freq = 5.0e9; +const float q2_pos_freq = 5.0e9; +const float q3_pos_freq = 5.0e9; + +// Calibrated amplitudes and durations for the Raman pulses supplied via the AOD envelopes +const complex[float[32]] q1_π_half_amp = 1.0 + 2.0im; +const complex[float[32]] q2_π_half_amp = 1.0 + 2.0im; +const complex[float[32]] q3_π_half_amp = 1.0 + 2.0im; +const duration pi_half_time = 10.0ns; + +// Time-proportional phase increment +const float tppi_1 = 1.0; +const float tppi_2 = 1.0; +const float tppi_3 = 1.0; + +cal { + port eom_a_port; + port eom_b_port; + port aod_port; + + // Define the Raman frames, which are detuned by an amount delta from the 5S1/2 to 5P1/2 transition + // and offset from each other by the qubit_freq + frame raman_a_frame = newframe(eom_a_port, delta, 0.0); + frame raman_b_frame = newframe(eom_b_port, delta-qubit_freq, 0.0); + const complex[float[32]] raman_a_amp = 1.0 + 2.0im; + const complex[float[32]] raman_b_amp = 1.0 + 2.0im; + + // Three frames to phase track each qubit's rotating frame of reference at it's frequency + frame q1_frame = newframe(aod_port, qubit_freq, 0.0); + frame q2_frame = newframe(aod_port, qubit_freq, 0.0); + frame q3_frame = newframe(aod_port, qubit_freq, 0.0); + + // Generic gaussian envelope + waveform pi_half_sig = gaussian(1.0 + 2.0im, pi_half_time, 100ns); + + // Waveforms ultimately supplied to the AODs. We mix our general Gaussian pulse with a sine wave to + // put a sideband on the outgoing pulse. This helps us target the qubit position while maintainig the + // desired Rabi rate. + waveform q1_pi_half_sig = mix(pi_half_sig, sine(q1_π_half_amp, pi_half_time, q1_pos_freq-qubit_freq, 0.0)); + waveform q2_pi_half_sig = mix(pi_half_sig, sine(q2_π_half_amp, pi_half_time, q2_pos_freq-qubit_freq, 0.0)); + waveform q3_pi_half_sig = mix(pi_half_sig, sine(q3_π_half_amp, pi_half_time, q3_pos_freq-qubit_freq, 0.0)); +} +// π/2 pulses on all three qubits +defcal rx(angle theta) $1, $2, $3 { +// Simultaneous π/2 pulses +play(raman_a_frame, constant(raman_a_amp, pi_half_time)); +play(raman_b_frame, constant(raman_b_amp, pi_half_time)); +play(q1_frame, q1_pi_half_sig); +play(q2_frame, q2_pi_half_sig); +play(q3_frame, q3_pi_half_sig); +} +// π/2 pulse on only qubit $2 +defcal rx(angle theta) $2 { +play(raman_a_frame, constant(raman_a_amp, pi_half_time)); +play(raman_b_frame, constant(raman_b_amp, pi_half_time)); +play(q2_frame, q2_pi_half_sig); +} +// Ramsey sequence on qubit 1 and 3, Hahn echo on qubit 2 +for duration tau_val in [1us:1us:2us] { + +// First π/2 pulse +rx(pi/2) $1, $2, $3; +// First half of evolution time +cal { + delay[tau_val/2] raman_a_frame, raman_b_frame, q1_frame, q2_frame, q3_frame; +} +// Hahn echo π pulse composed of two π/2 pulses +for int ct in [0:1]{ + rx(π/2) $2; +} +cal { + // Align all frames + barrier raman_a_frame, raman_b_frame, q1_frame, q2_frame, q3_frame; + + // Second half of evolution time + delay[tau_val/2] raman_a_frame, raman_b_frame, q1_frame, q2_frame, q3_frame; + + // Time-proportional phase increment signals different amount + shift_phase(q1_frame, tppi_1 * tau_val); + shift_phase(q2_frame, tppi_2 * tau_val); + shift_phase(q3_frame, tppi_3 * tau_val); +} + +// Second π/2 pulse +rx(π/2) $1, $2, $3; +} \ No newline at end of file diff --git a/tests/benchmarks/unroll.py b/tests/benchmarks/unroll.py new file mode 100644 index 00000000..2fcb0281 --- /dev/null +++ b/tests/benchmarks/unroll.py @@ -0,0 +1,62 @@ +# Copyright 2025 qBraid +# +# Licensed under the Apache License, Version 2.0 (the "License"); +# you may not use this file except in compliance with the License. +# You may obtain a copy of the License at +# +# http://www.apache.org/licenses/LICENSE-2.0 +# +# Unless required by applicable law or agreed to in writing, software +# distributed under the License is distributed on an "AS IS" BASIS, +# WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +# See the License for the specific language governing permissions and +# limitations under the License. + +# pylint: disable=attribute-defined-outside-init + +""" +This module is used to test the unrolling of pyqasm. +""" + +from pyqasm import load + +from .qasm.benchmark_downloader import get_benchmark_file + + +class Unrolling: + """Test the unrolling of pyqasm.""" + + # Define parameters for asv + params = [["small (224 lines)", "mid (2335 lines)", "large (17460 lines)"]] + param_names = ["qasm_file"] + timeout = 300 + + def setup(self, file_size): + # Extract the original file size name from the parameter value + if "(224 lines)" in file_size: + file_size_key = "small" + elif "(2335 lines)" in file_size: + file_size_key = "mid" + elif "(17460 lines)" in file_size: + file_size_key = "large" + else: + file_size_key = file_size + + # Define files for each size category + self.files = { + "small": "vqe_uccsd_n4.qasm", # 224 lines + "mid": "dnn_n16.qasm", # 2335 lines + "large": "qv_N029_12345.qasm", # 17460 lines + } + + # Get benchmark file for the specified size + self.qasm_file = get_benchmark_file(self.files[file_size_key]) + self.pyqasm_obj = load(self.qasm_file) + + def time_unroll(self, _): + """Unroll QASM file of specified size.""" + _ = self.pyqasm_obj.unroll() + + def time_qubit_reg_consolidation(self, _): + """Unroll QASM file of specified size with qubit consolidation.""" + _ = self.pyqasm_obj.unroll(consolidate_qubits=True) diff --git a/tests/benchmarks/validate.py b/tests/benchmarks/validate.py new file mode 100644 index 00000000..6af6998a --- /dev/null +++ b/tests/benchmarks/validate.py @@ -0,0 +1,58 @@ +# Copyright 2025 qBraid +# +# Licensed under the Apache License, Version 2.0 (the "License"); +# you may not use this file except in compliance with the License. +# You may obtain a copy of the License at +# +# http://www.apache.org/licenses/LICENSE-2.0 +# +# Unless required by applicable law or agreed to in writing, software +# distributed under the License is distributed on an "AS IS" BASIS, +# WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +# See the License for the specific language governing permissions and +# limitations under the License. + +# pylint: disable=attribute-defined-outside-init + +""" +This module is used to test the validation of pyqasm. +""" + +from pyqasm import load + +from .qasm.benchmark_downloader import get_benchmark_file + + +class Validate: + """Test the validation of pyqasm.""" + + # Define parameters for asv + params = [["small (224 lines)", "mid (2335 lines)", "large (17460 lines)"]] + param_names = ["qasm_file"] + timeout = 300 + + def setup(self, file_size): + # Extract the original file size name from the parameter value + if "(224 lines)" in file_size: + file_size_key = "small" + elif "(2335 lines)" in file_size: + file_size_key = "mid" + elif "(17460 lines)" in file_size: + file_size_key = "large" + else: + file_size_key = file_size + + # Define files for each size category + self.files = { + "small": "vqe_uccsd_n4.qasm", # 224 lines + "mid": "dnn_n16.qasm", # 2335 lines + "large": "qv_N029_12345.qasm", # 17460 lines + } + + # Get benchmark file for the specified size + self.qasm_file = get_benchmark_file(self.files[file_size_key]) + self.pyqasm_obj = load(self.qasm_file) + + def time_validate(self, _): + """Validate QASM file of specified size.""" + _ = self.pyqasm_obj.validate()