diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index 636bd04..5db0bb2 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -10,6 +10,7 @@ jobs: python-version: ["3.10"] steps: - uses: actions/checkout@v3 + - uses: mpi4py/setup-mpi@v1 - name: Setup PDM uses: pdm-project/setup-pdm@v3 with: diff --git a/data/benchmark/__init__.py b/data/benchmark/__init__.py index 13e4734..326d9af 100644 --- a/data/benchmark/__init__.py +++ b/data/benchmark/__init__.py @@ -1,3 +1,5 @@ """Benchmarking tools for the MILP model.""" + from .benchmark import run_experiments +from .heuristic_benchmark import run_heuristic_experiments from .processing import analyze_benchmarks diff --git a/data/benchmark/heuristic_benchmark.py b/data/benchmark/heuristic_benchmark.py new file mode 100644 index 0000000..c7b0475 --- /dev/null +++ b/data/benchmark/heuristic_benchmark.py @@ -0,0 +1,211 @@ +"""Generates the benchmark data.""" + +import logging + +from mqt.bench import get_benchmark +from qiskit import QuantumCircuit +import numpy as np + +from src.common import jobs_from_experiment +from src.provider import Accelerator +from src.scheduling import ( + Benchmark, + InfoProblem, + PTimes, + Result, + SchedulerType, + STimes, + generate_schedule, +) +from src.scheduling.heuristics import ( + generate_heuristic_info_schedule as heuristic_schedule, +) + +from src.tools import cut_circuit +from utils.helpers import Timer + + +def _generate_batch(max_qubits: int, circuits_per_batch: int) -> list[QuantumCircuit]: + # Generate a random circuit + batch = [] + for _ in range(circuits_per_batch): + size = np.random.randint(2, max_qubits + 1) + circuit = get_benchmark(benchmark_name="ghz", level=1, circuit_size=size) + circuit.remove_final_measurements(inplace=True) + batch.append(circuit) + + return batch + + +def run_heuristic_experiments( + circuits_per_batch: int, + settings: list[list[Accelerator]], + t_max: int, + num_batches: int, +) -> Benchmark: + """Generates the benchmarks and executes scheduling.""" + results: Benchmark = [] + for setting in settings: + logging.info("New Setting started...") + max_size = sum(s.qubits for s in setting) + benchmarks = [ + _generate_batch(max_size, circuits_per_batch) for _ in range(num_batches) + ] + benchmark_results: list[Result] = [] + for benchmark in benchmarks: + problme_circuits = _cut_circuits(benchmark, setting) + logging.info("Setting up times...") + + p_times = _get_benchmark_processing_times(problme_circuits, setting) + s_times = _get_benchmark_setup_times( + problme_circuits, + setting, + default_value=2**5, + ) + logging.info("Setting up problems...") + problem = InfoProblem( + base_jobs=problme_circuits, + accelerators={str(acc.uuid): acc.qubits for acc in setting}, + big_m=1000, + timesteps=t_max, + process_times=p_times, + setup_times=s_times, + ) + result: dict[str, Result] = {} + logging.info("Running benchmark for setting.") + # Run the baseline model + with Timer() as t0: + makespan, jobs, _ = generate_schedule(problem, SchedulerType.BASELINE) + result["baseline"] = Result(makespan, jobs, t0.elapsed) + logging.info("Baseline model done: Makespan: %d.", makespan) + # Run the simple model + # if makespan > t_max: + # continue + + # with Timer() as t1: + # makespan, jobs, _ = generate_schedule(problem, SchedulerType.SIMPLE) + # result["simple"] = Result(makespan, jobs, t1.elapsed) + # logging.info("Simple model done: Makespan: %d.", makespan) + # Run the heurstic model + with Timer() as t2: + # TODO convert ScheduledJob to JobResultInfo + makespan, jobs = heuristic_schedule( + benchmark, setting, num_iterations=128, partition_size=4, num_cores=1 + ) + result["heuristic"] = Result(makespan, jobs, t2.elapsed) + logging.info("Heuristic model done: Makespan: %d.", makespan) + # Store results + benchmark_results.append(results) + if len(benchmark_results) > 0: + results.append({"setting": setting, "benchmarks": benchmark_results}) + return results + + +def _cut_circuits( + circuits: list[QuantumCircuit], accelerators: list[Accelerator] +) -> list[QuantumCircuit]: + """Cuts the circuits into smaller circuits.""" + partitions = _generate_partitions( + [circuit.num_qubits for circuit in circuits], accelerators + ) + logging.info( + "Partitions: generated: %s", + " ".join(str(partition) for partition in partitions), + ) + jobs = [] + logging.info("Cutting circuits...") + for idx, circuit in enumerate(circuits): + logging.info("Cutting circuit %d", idx) + if len(partitions[idx]) > 1: + experiments, _ = cut_circuit(circuit, partitions[idx]) + jobs += [ + job.circuit + for experiment in experiments + for job in jobs_from_experiment(experiment) + ] + else: + # assumption for now dont cut to any to smaller + jobs.append(circuit) + return jobs + + +def _generate_partitions( + circuit_sizes: list[int], accelerators: list[Accelerator] +) -> list[list[int]]: + partitions = [] + qpu_sizes = [acc.qubits for acc in accelerators] + num_qubits: int = sum(qpu_sizes) + for circuit_size in circuit_sizes: + if circuit_size > num_qubits: + partition = qpu_sizes + remaining_size = circuit_size - num_qubits + while remaining_size > num_qubits: + partition += qpu_sizes + remaining_size -= num_qubits + if remaining_size == 1: + partition[-1] = partition[-1] - 1 + partition.append(2) + else: + partition += _partition_big_to_small(remaining_size, accelerators) + partitions.append(partition) + elif circuit_size > max(qpu_sizes): + partition = _partition_big_to_small(circuit_size, accelerators) + partitions.append(partition) + else: + partitions.append([circuit_size]) + return partitions + + +def _partition_big_to_small(size: int, accelerators: list[Accelerator]) -> list[int]: + partition = [] + for qpu in sorted(accelerators, key=lambda a: a.qubits, reverse=True): + take_qubits = min(size, qpu.qubits) + if size - take_qubits == 1: + # We can't have a partition of size 1 + # So in this case we take one qubit less to leave a partition of two + take_qubits -= 1 + partition.append(take_qubits) + size -= take_qubits + if size == 0: + break + else: + raise ValueError( + "Circuit is too big to fit onto the devices," + + f" {size} qubits left after partitioning." + ) + return partition + + +def _get_benchmark_processing_times( + base_jobs: list[QuantumCircuit], + accelerators: list[Accelerator], +) -> PTimes: + return [ + [accelerator.compute_processing_time(job) for accelerator in accelerators] + for job in base_jobs + ] + + +def _get_benchmark_setup_times( + base_jobs: list[QuantumCircuit], + accelerators: list[Accelerator], + default_value: float, +) -> STimes: + return [ + [ + [ + ( + default_value + if id_i in [id_j, 0] + else ( + 0 + if job_j is None + else accelerator.compute_setup_time(job_i, job_j) + ) + ) + for accelerator in accelerators + ] + for id_i, job_i in enumerate([None] + base_jobs) + ] + for id_j, job_j in enumerate([None] + base_jobs) + ] diff --git a/pdm.lock b/pdm.lock index bb1d75d..2e13894 100644 --- a/pdm.lock +++ b/pdm.lock @@ -4,8 +4,29 @@ [metadata] groups = ["default", "dev"] strategy = ["cross_platform"] -lock_version = "4.4" -content_hash = "sha256:9ea14b993f28a05c3677d61c7fadcf661d79f33fc6b18437b4735ea08f7da97e" +lock_version = "4.4.1" +content_hash = "sha256:b63ed5595a93b888b5ac4e6752913c8052dc386f5bc8e86acc436de8398b45bc" + +[[package]] +name = "ale-py" +version = "0.8.1" +requires_python = ">=3.7" +summary = "The Arcade Learning Environment (ALE) - a platform for AI research." +dependencies = [ + "importlib-resources", + "numpy", + "typing-extensions; python_version < \"3.11\"", +] +files = [ + {file = "ale_py-0.8.1-cp310-cp310-macosx_10_15_x86_64.whl", hash = "sha256:b2aa2f69a4169742800615970efe6914fa856e33eaf7fa9133c0e06a617a80e2"}, + {file = "ale_py-0.8.1-cp310-cp310-macosx_11_0_arm64.whl", hash = "sha256:6f2f6b92c8fd6189654979bbf0b305dbe0ecf82176c47f244d8c1cbc36286b89"}, + {file = "ale_py-0.8.1-cp310-cp310-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:c9b168eb88c87d0f3e2a778e6c5cdde4ad951d1ca8a6dc3d3679fd45398df7d1"}, + {file = "ale_py-0.8.1-cp310-cp310-win_amd64.whl", hash = "sha256:5fcc31f495de79ee1d6bfc0f4b7c4619948851e679bbf010035e25f23146a687"}, + {file = "ale_py-0.8.1-cp311-cp311-macosx_10_15_x86_64.whl", hash = "sha256:0856ca777473ec4ae8a59f3af9580259adb0fd4a47d586a125a440c62e82fc10"}, + {file = "ale_py-0.8.1-cp311-cp311-macosx_11_0_arm64.whl", hash = "sha256:f10b1df8774bbe3b00365748b5e0e07cf35f6a703bbaff991bc7b3b2247dccc9"}, + {file = "ale_py-0.8.1-cp311-cp311-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:0006d80dfe7745eb5a93444492337203c8bc7eb594a2c24c6a651c5c5b0eaf09"}, + {file = "ale_py-0.8.1-cp311-cp311-win_amd64.whl", hash = "sha256:9773eea7505484e024beb2fff0f3bfd363db151bdb9799d70995448e196b1ded"}, +] [[package]] name = "appdirs" @@ -220,6 +241,16 @@ files = [ {file = "click-8.1.7.tar.gz", hash = "sha256:ca9853ad459e787e2192211578cc907e7594e294c7ccc834310722b41b9ca6de"}, ] +[[package]] +name = "cloudpickle" +version = "3.0.0" +requires_python = ">=3.8" +summary = "Pickler class to extend the standard pickle.Pickler functionality" +files = [ + {file = "cloudpickle-3.0.0-py3-none-any.whl", hash = "sha256:246ee7d0c295602a036e86369c77fecda4ab17b506496730f2f576d9016fd9c7"}, + {file = "cloudpickle-3.0.0.tar.gz", hash = "sha256:996d9a482c6fb4f33c1a35335cf8afd065d2a56e973270364840712d9131a882"}, +] + [[package]] name = "colorama" version = "0.4.6" @@ -350,6 +381,15 @@ files = [ {file = "exceptiongroup-1.1.3.tar.gz", hash = "sha256:097acd85d473d75af5bb98e41b61ff7fe35efe6675e4f9370ec6ec5126d160e9"}, ] +[[package]] +name = "farama-notifications" +version = "0.0.4" +summary = "Notifications for all Farama Foundation maintained libraries." +files = [ + {file = "Farama-Notifications-0.0.4.tar.gz", hash = "sha256:13fceff2d14314cf80703c8266462ebf3733c7d165336eee998fc58e545efd18"}, + {file = "Farama_Notifications-0.0.4-py3-none-any.whl", hash = "sha256:14de931035a41961f7c056361dc7f980762a143d05791ef5794a751a2caf05ae"}, +] + [[package]] name = "fastdtw" version = "0.3.4" @@ -440,6 +480,60 @@ files = [ {file = "graphviz-0.20.1.zip", hash = "sha256:8c58f14adaa3b947daf26c19bc1e98c4e0702cdc31cf99153e6f06904d492bf8"}, ] +[[package]] +name = "gym" +version = "0.26.2" +requires_python = ">=3.6" +summary = "Gym: A universal API for reinforcement learning environments" +dependencies = [ + "cloudpickle>=1.2.0", + "gym-notices>=0.0.4", + "numpy>=1.18.0", +] +files = [ + {file = "gym-0.26.2.tar.gz", hash = "sha256:e0d882f4b54f0c65f203104c24ab8a38b039f1289986803c7d02cdbe214fbcc4"}, +] + +[[package]] +name = "gym-notices" +version = "0.0.8" +summary = "Notices for gym" +files = [ + {file = "gym-notices-0.0.8.tar.gz", hash = "sha256:ad25e200487cafa369728625fe064e88ada1346618526102659b4640f2b4b911"}, + {file = "gym_notices-0.0.8-py3-none-any.whl", hash = "sha256:e5f82e00823a166747b4c2a07de63b6560b1acb880638547e0cabf825a01e463"}, +] + +[[package]] +name = "gym" +version = "0.26.2" +extras = ["atari", "classic_control"] +requires_python = ">=3.6" +summary = "Gym: A universal API for reinforcement learning environments" +dependencies = [ + "ale-py~=0.8.0", + "gym==0.26.2", + "pygame==2.1.0", +] +files = [ + {file = "gym-0.26.2.tar.gz", hash = "sha256:e0d882f4b54f0c65f203104c24ab8a38b039f1289986803c7d02cdbe214fbcc4"}, +] + +[[package]] +name = "gymnasium" +version = "0.29.1" +requires_python = ">=3.8" +summary = "A standard API for reinforcement learning and a diverse set of reference environments (formerly Gym)." +dependencies = [ + "cloudpickle>=1.2.0", + "farama-notifications>=0.0.1", + "numpy>=1.21.0", + "typing-extensions>=4.3.0", +] +files = [ + {file = "gymnasium-0.29.1-py3-none-any.whl", hash = "sha256:61c3384b5575985bb7f85e43213bcb40f36fcdff388cae6bc229304c71f2843e"}, + {file = "gymnasium-0.29.1.tar.gz", hash = "sha256:1a532752efcb7590478b1cc7aa04f608eb7a2fdad5570cd217b66b6a35274bb1"}, +] + [[package]] name = "h5py" version = "3.10.0" @@ -515,6 +609,16 @@ files = [ {file = "idna-3.4.tar.gz", hash = "sha256:814f528e8dead7d329833b91c5faa87d60bf71824cd12a7530b5526063d02cb4"}, ] +[[package]] +name = "importlib-resources" +version = "6.1.1" +requires_python = ">=3.8" +summary = "Read resources from Python packages" +files = [ + {file = "importlib_resources-6.1.1-py3-none-any.whl", hash = "sha256:e8bf90d8213b486f428c9c39714b920041cb02c184686a3dee24905aaa8105d6"}, + {file = "importlib_resources-6.1.1.tar.gz", hash = "sha256:3893a00122eafde6894c59914446a512f728a0c1a45f9bb9b63721b6bacf0b4a"}, +] + [[package]] name = "inflection" version = "0.5.1" @@ -781,6 +885,19 @@ files = [ {file = "more_itertools-10.1.0-py3-none-any.whl", hash = "sha256:64e0735fcfdc6f3464ea133afe8ea4483b1c5fe3a3d69852e6503b43a0b222e6"}, ] +[[package]] +name = "mpi4py" +version = "3.1.5" +requires_python = ">=2.7, !=3.0.*, !=3.1.*, !=3.2.*, !=3.3.*, !=3.4.*" +summary = "Python bindings for MPI" +files = [ + {file = "mpi4py-3.1.5-cp310-cp310-win32.whl", hash = "sha256:f39df0d985cb6fb342ee6c6902cadf21b2d828d7df00b182573da0242646b715"}, + {file = "mpi4py-3.1.5-cp310-cp310-win_amd64.whl", hash = "sha256:aec0e6238ed76c930c07df7dcea19f3be5ca958fb76353e668b19511ed4c86d7"}, + {file = "mpi4py-3.1.5-cp311-cp311-win32.whl", hash = "sha256:f73686e3ff8f76bacb9ecacba0515f84392ad4c561b76603f9680f0fe64ef0ed"}, + {file = "mpi4py-3.1.5-cp311-cp311-win_amd64.whl", hash = "sha256:d854dae2e62042a0355fa24ef7bea50b5380414806319240a57e654be1e59d9c"}, + {file = "mpi4py-3.1.5.tar.gz", hash = "sha256:a706e76db9255135c2fb5d1ef54cb4f7b0e4ad9e33cbada7de27626205f2a153"}, +] + [[package]] name = "mpmath" version = "1.3.0" @@ -928,6 +1045,30 @@ files = [ {file = "numpy-1.26.1.tar.gz", hash = "sha256:c8c6c72d4a9f831f328efb1312642a1cafafaa88981d9ab76368d50d07d93cbe"}, ] +[[package]] +name = "opencv-python" +version = "4.9.0.80" +requires_python = ">=3.6" +summary = "Wrapper package for OpenCV python bindings." +dependencies = [ + "numpy>=1.17.0; python_version >= \"3.7\"", + "numpy>=1.17.3; python_version >= \"3.8\"", + "numpy>=1.19.3; python_version >= \"3.6\" and platform_system == \"Linux\" and platform_machine == \"aarch64\"", + "numpy>=1.19.3; python_version >= \"3.9\"", + "numpy>=1.21.2; python_version >= \"3.10\"", + "numpy>=1.21.4; python_version >= \"3.10\" and platform_system == \"Darwin\"", + "numpy>=1.23.5; python_version >= \"3.11\"", +] +files = [ + {file = "opencv-python-4.9.0.80.tar.gz", hash = "sha256:1a9f0e6267de3a1a1db0c54213d022c7c8b5b9ca4b580e80bdc58516c922c9e1"}, + {file = "opencv_python-4.9.0.80-cp37-abi3-macosx_10_16_x86_64.whl", hash = "sha256:7e5f7aa4486651a6ebfa8ed4b594b65bd2d2f41beeb4241a3e4b1b85acbbbadb"}, + {file = "opencv_python-4.9.0.80-cp37-abi3-macosx_11_0_arm64.whl", hash = "sha256:71dfb9555ccccdd77305fc3dcca5897fbf0cf28b297c51ee55e079c065d812a3"}, + {file = "opencv_python-4.9.0.80-cp37-abi3-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:7b34a52e9da36dda8c151c6394aed602e4b17fa041df0b9f5b93ae10b0fcca2a"}, + {file = "opencv_python-4.9.0.80-cp37-abi3-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:e4088cab82b66a3b37ffc452976b14a3c599269c247895ae9ceb4066d8188a57"}, + {file = "opencv_python-4.9.0.80-cp37-abi3-win32.whl", hash = "sha256:dcf000c36dd1651118a2462257e3a9e76db789a78432e1f303c7bac54f63ef6c"}, + {file = "opencv_python-4.9.0.80-cp37-abi3-win_amd64.whl", hash = "sha256:3f16f08e02b2a2da44259c7cc712e779eff1dd8b55fdb0323e8cab09548086c0"}, +] + [[package]] name = "packaging" version = "23.2" @@ -1107,6 +1248,30 @@ files = [ {file = "pycparser-2.21.tar.gz", hash = "sha256:e644fdec12f7872f86c58ff790da456218b10f863970249516d60a5eaca77206"}, ] +[[package]] +name = "pygame" +version = "2.1.0" +requires_python = ">=3.6" +summary = "Python Game Development" +files = [ + {file = "pygame-2.1.0-cp310-cp310-macosx_10_9_x86_64.whl", hash = "sha256:c84a93e6d33dafce9e25080ac557342333e15ef7e378ba84cb6181c52a8fd663"}, + {file = "pygame-2.1.0-cp310-cp310-macosx_11_0_arm64.whl", hash = "sha256:a0842458b49257ab539b7b6622a242cabcddcb61178b8ae074aaceb890be75b6"}, + {file = "pygame-2.1.0-cp310-cp310-manylinux_2_12_i686.manylinux2010_i686.whl", hash = "sha256:6efa3fa472acb97c784224b59a89e80da6231f0dbf54df8442ffa3352c0534d6"}, + {file = "pygame-2.1.0-cp310-cp310-manylinux_2_12_x86_64.manylinux2010_x86_64.whl", hash = "sha256:02a26b3be6cc478f18f4efa506ee5a585f68350857ac5e68e187301e943e3d6d"}, + {file = "pygame-2.1.0-cp310-cp310-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:d5c62fbdb30082f7e1dcfa253da48e7b4be7342d275b34b2efa51f6cffc5942b"}, + {file = "pygame-2.1.0-cp310-cp310-manylinux_2_17_i686.manylinux2014_i686.whl", hash = "sha256:7a305dcf44f03a8dd7baefb97dc24949d7e719fd686cd3211121639aec4ce464"}, + {file = "pygame-2.1.0-cp310-cp310-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:847b4bc22edb1d77c992b5d56b19e1ab52e14687adb8bc3ed12a8a98fbd7e1ff"}, + {file = "pygame-2.1.0-cp310-cp310-win32.whl", hash = "sha256:e9368c105a8bccc8adfe7fd7fa5220d2b6c03979a3a57a8178c42f6fa9914ebc"}, + {file = "pygame-2.1.0-cp310-cp310-win_amd64.whl", hash = "sha256:9a81d057a7dea95850e44118f141a892fde93c938ccb08fbc5dd7f1a26c2f1fe"}, + {file = "pygame-2.1.0-pp36-pypy36_pp73-win32.whl", hash = "sha256:b0e405fdde643f14d60c2dd140f110a5a38f588396a8b61a1a86374f25cba589"}, + {file = "pygame-2.1.0-pp37-pypy37_pp73-macosx_10_9_x86_64.whl", hash = "sha256:646e871ff5ab7f933cde5ea2bff7b6cd74d7369f43e84a291baebe00bb9a8f6f"}, + {file = "pygame-2.1.0-pp37-pypy37_pp73-manylinux_2_12_i686.manylinux2010_i686.whl", hash = "sha256:88a2dabe617e6173003b65762c636947719da3e2d881a4ea47298e8d70886386"}, + {file = "pygame-2.1.0-pp37-pypy37_pp73-manylinux_2_12_x86_64.manylinux2010_x86_64.whl", hash = "sha256:7281366b4ebd7f16eac8ec6a6e2adb4c729beda178ea82637d9981e93dd40c9b"}, + {file = "pygame-2.1.0-pp37-pypy37_pp73-manylinux_2_17_i686.manylinux2014_i686.whl", hash = "sha256:0227728f2ef751fac43b89f4bcc5c65ce39c855b2a3391ddf2e6024dd667e6bd"}, + {file = "pygame-2.1.0-pp37-pypy37_pp73-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:4ab5aba8677d135b94c4714e8256efdfffefc164f354a4d05b846588caf43b99"}, + {file = "pygame-2.1.0.tar.gz", hash = "sha256:232e51104db0e573221660d172af8e6fc2c0fda183c5dbf2aa52170f29aa9ec9"}, +] + [[package]] name = "pyjwt" version = "2.8.0" @@ -1686,6 +1851,39 @@ files = [ {file = "sspilib-0.1.0.tar.gz", hash = "sha256:58b5291553cf6220549c0f855e0e6973f4977375d8236ce47bb581efb3e9b1cf"}, ] +[[package]] +name = "stable-baselines" +version = "2.10.2" +summary = "A fork of OpenAI Baselines, implementations of reinforcement learning algorithms." +dependencies = [ + "cloudpickle>=0.5.5", + "gym[atari,classic_control]>=0.11", + "joblib", + "matplotlib", + "numpy", + "opencv-python", + "pandas", + "scipy", +] +files = [ + {file = "stable_baselines-2.10.2-py3-none-any.whl", hash = "sha256:59d7657723e73be15f4f3eacf957ffb4954cec03d1a9ee6c3a452bd1b1273191"}, + {file = "stable_baselines-2.10.2.tar.gz", hash = "sha256:82491c8028edd5ae17186b8c8b3963368b45a37c9a0a00841ce26990b381f556"}, +] + +[[package]] +name = "stable-baselines" +version = "2.10.2" +extras = ["mpi"] +summary = "A fork of OpenAI Baselines, implementations of reinforcement learning algorithms." +dependencies = [ + "mpi4py", + "stable-baselines==2.10.2", +] +files = [ + {file = "stable_baselines-2.10.2-py3-none-any.whl", hash = "sha256:59d7657723e73be15f4f3eacf957ffb4954cec03d1a9ee6c3a452bd1b1273191"}, + {file = "stable_baselines-2.10.2.tar.gz", hash = "sha256:82491c8028edd5ae17186b8c8b3963368b45a37c9a0a00841ce26990b381f556"}, +] + [[package]] name = "stevedore" version = "5.1.0" @@ -1775,15 +1973,15 @@ files = [ [[package]] name = "tqdm" -version = "4.66.1" +version = "4.66.2" requires_python = ">=3.7" summary = "Fast, Extensible Progress Meter" dependencies = [ "colorama; platform_system == \"Windows\"", ] files = [ - {file = "tqdm-4.66.1-py3-none-any.whl", hash = "sha256:d302b3c5b53d47bce91fea46679d9c3c6508cf6332229aa1e7d8653723793386"}, - {file = "tqdm-4.66.1.tar.gz", hash = "sha256:d88e651f9db8d8551a62556d3cff9e3034274ca5d66e93197cf2490e2dcb69c7"}, + {file = "tqdm-4.66.2-py3-none-any.whl", hash = "sha256:1ee4f8a893eb9bef51c6e35730cebf234d5d0b6bd112b0271e10ed7c24a02bd9"}, + {file = "tqdm-4.66.2.tar.gz", hash = "sha256:6cd52cdf0fef0e0f543299cfc96fec90d7b8a7e88745f411ec33eb44d5ed3531"}, ] [[package]] diff --git a/pyproject.toml b/pyproject.toml index a573498..75b67af 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -17,6 +17,9 @@ dependencies = [ "pandas>=2.1.1", "mqt-bench>=1.0.5", "matplotlib>=3.8.2", + "tqdm>=4.66.2", + "stable-baselines[mpi]>=2.10.2", + "gymnasium>=0.29.1", ] requires-python = ">=3.10, <3.12" readme = "README.md" diff --git a/run_experiments.py b/run_experiments.py index 903ec5d..c1e7a37 100644 --- a/run_experiments.py +++ b/run_experiments.py @@ -1,11 +1,20 @@ """Generates the benchmark data.""" + +import logging + from dataclasses import is_dataclass, asdict from typing import Any import json # import numpy as np -from data.benchmark import run_experiments, analyze_benchmarks +from src.provider import Accelerator, IBMQBackend + +from data.benchmark import ( + run_experiments, + analyze_benchmarks, + run_heuristic_experiments, +) class DataclassJSONEncoder(json.JSONEncoder): @@ -27,8 +36,21 @@ def default(self, o) -> dict[str, Any] | Any: {"A": 5, "B": 5}, {"A": 5, "B": 6, "C": 20}, ] -T_MAX = 2**6 -if __name__ == "__main__": +T_MAX = 200 +ACC_SETTINGS = [ + [ + Accelerator(IBMQBackend.BELEM, shot_time=5, reconfiguration_time=12), + Accelerator(IBMQBackend.NAIROBI, shot_time=7, reconfiguration_time=12), + ], + # [ + # Accelerator(IBMQBackend.BELEM, shot_time=5, reconfiguration_time=12), + # Accelerator(IBMQBackend.NAIROBI, shot_time=7, reconfiguration_time=12), + # Accelerator(IBMQBackend.QUITO, shot_time=2, reconfiguration_time=16), + # ], +] + + +def run_default() -> None: experiment_results = run_experiments( CIRCUITS_PER_BATCH, SETTINGS, T_MAX, NUM_BATCHES ) @@ -54,3 +76,22 @@ def default(self, o) -> dict[str, Any] | Any: for setting, result in numbers.items(): print(f"Setting: {setting}") print(result) + + +def run_heuristic() -> None: + experiment_results = run_heuristic_experiments( + CIRCUITS_PER_BATCH, ACC_SETTINGS, T_MAX, NUM_BATCHES + ) + if len(experiment_results) < 1: + return + with open( + "./data/results/benchmark_results_heuristic.json", "w+", encoding="utf-8" + ) as f: + json.dump(experiment_results, f, cls=DataclassJSONEncoder) + + +if __name__ == "__main__": + logging.basicConfig(level=logging.INFO) + logging.getLogger("qiskit").setLevel(logging.WARNING) + logging.getLogger("circuit_knitting").setLevel(logging.WARNING) + run_heuristic() diff --git a/src/provider/accelerator.py b/src/provider/accelerator.py index 0dada9f..5d019a1 100644 --- a/src/provider/accelerator.py +++ b/src/provider/accelerator.py @@ -1,7 +1,10 @@ """Wrapper for IBMs backend simulator.""" + from uuid import UUID, uuid4 +import logging from qiskit import QuantumCircuit, transpile +from qiskit.providers.fake_provider import FakeSherbrooke from qiskit_aer import AerSimulator from src.common import IBMQBackend @@ -65,10 +68,16 @@ def compute_processing_time(self, circuit: QuantumCircuit) -> float: Returns: float: The processing time in µs. """ + logging.debug("Computing processing time for circuit...") # TODO: doing a full hardware-aware compilation just to get the processing # time is not efficient. An approximation would be better. be = self._backend.value() - transpiled_circuit = transpile(circuit, be, scheduling_method="alap") + if circuit.num_qubits > self._qubits: + # Workaround to get time estiamte for circuits bigger then the backend + transpiled_circuit = transpile(circuit, FakeSherbrooke(), scheduling_method="alap") + else: + transpiled_circuit = transpile(circuit, be, scheduling_method="alap") + logging.debug("Done.") return Accelerator._time_conversion( transpiled_circuit.duration, transpiled_circuit.unit, dt=be.dt ) @@ -86,6 +95,7 @@ def compute_setup_time( Returns: float: Set up time from circuit_from to circuit_to in µs. """ + logging.debug("Computing setup time for circuit...") if circuit_from is None: return self._reconfiguration_time if circuit_to is None: diff --git a/src/scheduling/heuristics/__init__.py b/src/scheduling/heuristics/__init__.py new file mode 100644 index 0000000..1de5de0 --- /dev/null +++ b/src/scheduling/heuristics/__init__.py @@ -0,0 +1,6 @@ +"""Heuristics for scheduling.""" + +from .schedule import ( + generate_heuristic_info_schedule, +) # , generate_heuristic_exec_schedule +from .types import * diff --git a/src/scheduling/heuristics/diversify.py b/src/scheduling/heuristics/diversify.py new file mode 100644 index 0000000..131d882 --- /dev/null +++ b/src/scheduling/heuristics/diversify.py @@ -0,0 +1,100 @@ +"""Diversify population by generation new local and global solutions.""" + +import numpy as np + +from .types import Schedule, Bucket + + +def generate_new_solutions(population: list[Schedule], **kwargs) -> list[Schedule]: + """Generates new solutions by local search and diversification. + + Local search swaps jobs or buckets within the same machine. + Diversification swaps jobs between different machines. + + Args: + population (list[Schedule]): List of schedules to generate new solutions from. + num_solutions (int, optional): Controls the number of solutions to add. Defaults to 1. + Not used yet. + + Returns: + list[Schedule]: local and global candidates for the next generation. + """ + + local_candidates = _local_search(population) + global_candidates = _diversify(population) + return local_candidates + global_candidates + + +def _local_search(population: list[Schedule]) -> list[Schedule]: + """Swaps jobs within the same machine.""" + # TODO: maybe add tabu list here? + local_population = population.copy() + for schedule in local_population: + for machine in schedule.machines: + if len(machine.buckets) == 0: + continue + swap_buckets = np.random.randint(1, dtype=bool) + number_of_swaps = ( + np.random.randint(1, len(machine.buckets)) + if len(machine.buckets) > 1 + else 1 + ) + for _ in range(number_of_swaps): + idx1, idx2 = np.random.choice(len(machine.buckets), 2) + if swap_buckets: + machine.buckets[idx1], machine.buckets[idx2] = ( + machine.buckets[idx2], + machine.buckets[idx1], + ) + else: + _swap_jobs( + machine.buckets[idx1], machine.buckets[idx2], machine.capacity + ) + + return local_population + + +def _swap_jobs( + bucket1: Bucket, + bucket2: Bucket, + machine1_capacity: int, + machine2_capacity: int | None = None, +): + candidates: list[tuple[int, int]] = [] + if machine2_capacity is None: + machine2_capacity = machine1_capacity + bucket1_capacity = sum(job.circuit.num_qubits for job in bucket1.jobs) + bucket2_capacity = sum(job.circuit.num_qubits for job in bucket2.jobs) + for idx1, job1 in enumerate(bucket1.jobs): + for idx2, job2 in enumerate(bucket2.jobs): + if ( + bucket1_capacity - job1.circuit.num_qubits + job2.circuit.num_qubits + <= machine1_capacity + and ( + bucket2_capacity - job2.circuit.num_qubits + job1.circuit.num_qubits + <= machine2_capacity + ) + ): + candidates.append((idx1, idx2)) + if len(candidates) > 0: + idx1, idx2 = candidates[np.random.choice(len(candidates))] + bucket1.jobs[idx1], bucket2.jobs[idx2] = bucket2.jobs[idx2], bucket1.jobs[idx1] + + +def _diversify(population: list[Schedule]) -> list[Schedule]: + """Swaps jobs between different machines.""" + local_population = population.copy() + for schedule in population: + number_of_swaps = np.random.randint(1, len(schedule.machines)) + for _ in range(number_of_swaps): + idx1, idx2 = np.random.choice(len(schedule.machines), 2) + machine1, machine2 = schedule.machines[idx1], schedule.machines[idx2] + if len(machine1.buckets) == 0 or len(machine2.buckets) == 0: + continue + _swap_jobs( + np.random.choice(machine1.buckets), + np.random.choice(machine2.buckets), + machine1.capacity, + machine2.capacity, + ) + return local_population diff --git a/src/scheduling/heuristics/improve.py b/src/scheduling/heuristics/improve.py new file mode 100644 index 0000000..2a59af5 --- /dev/null +++ b/src/scheduling/heuristics/improve.py @@ -0,0 +1,44 @@ +"""Active solution improvement heuristics.""" + +import numpy as np + +from src.provider import Accelerator + +from .select import evaluate_solution +from .types import Schedule + + +def improve_solutions( + population: list[Schedule], accelerators: list[Accelerator] +) -> list[Schedule]: + """Improve solutions by also allowing invalid solutions. + + Improves the solution by taking the worst machine and removing a bucket from it. + + Args: + schedules (list[Schedule]): The list of schedules to improve. + accelerators (list[Accelerator]): The list of accelerators to schedule on. + + Returns: + list[Schedule]: An improved list of schedules, protentially with invalid solutions. + """ + # Find the machine with the longest makespan + for schedule in population: + schedule = evaluate_solution(schedule, accelerators) + worst_machine = max(schedule.machines, key=lambda m: m.makespan) + bucket = worst_machine.buckets.pop( + np.random.randint(len(worst_machine.buckets)) + ) + # Remove bucket from worst machine + for job in bucket.jobs: + for machine in sorted(schedule.machines, key=lambda m: m.makespan): + if machine == worst_machine: + continue + # Find the bucket with the biggest remaining capacity + smallest_bucket = min( + machine.buckets, + key=lambda b: sum(job.circuit.num_qubits for job in b.jobs), + ) + smallest_bucket.jobs.append(job) + + return population diff --git a/src/scheduling/heuristics/initialize.py b/src/scheduling/heuristics/initialize.py new file mode 100644 index 0000000..9bbc48c --- /dev/null +++ b/src/scheduling/heuristics/initialize.py @@ -0,0 +1,339 @@ +"""_summary_""" + +from collections import Counter +from functools import partial +from multiprocessing import Pool, cpu_count +from typing import Protocol +import logging + +from qiskit import QuantumCircuit +import numpy as np + +from src.common import CircuitJob, jobs_from_experiment, job_from_circuit +from src.provider import Accelerator + +from src.tools import cut_circuit + +from .types import Schedule, Machine, Bucket +from ..bin_schedule import _do_bin_pack + + +class Option(Protocol): + """Helper to typehint init options""" + + def __call__( + self, circuits: QuantumCircuit, accelerators: list[Accelerator], **kwargs + ) -> list[list[int]]: ... + + +def initialize_population( + circuits: list[QuantumCircuit], accelerators: list[Accelerator], **kwargs +) -> list[Schedule]: + """Initializes a population of schedules for the given circuits and accelerators. + + At the moment supports following partitioning methods: + - greedy_partitioning: Partitions the circuits in a greedy way, by trying to fit the biggest + circuits first. + - even_partitioning: Partitions the circuits in similar sized chunks. + - informed_partitioning: Finds cuts by recursively cutting the line with the least cnots. + - choice_partitioning: Randomly chooses a qpu and cuts the current circuit according + to qpu size. + - random_partitioning: Randomly chooses the cutsize between 1 - max(qpu_sizes). + - fixed_partitioning: Partitions the circuits in chunks of a fixed size. + + Args: + circuits (list[QuantumCircuit]): The initial batch of circuits to schedule. + accelerators (list[Accelerator]): The available accelerators to schedule the circuits on. + + Returns: + list[Schedule]: Initial scheduel candidates. + """ + + schedules = [] + num_cores = max(len(OPTIONS), cpu_count()) + with Pool(processes=num_cores) as pool: + work = partial(_task, circuits=circuits, accelerators=accelerators, **kwargs) + schedules = pool.map(work, OPTIONS) + return schedules + + +def _task( + option: Option, + circuits: QuantumCircuit, + accelerators: list[Accelerator], + **kwargs, +) -> Schedule: + logging.debug("Starting init on... %s", option.__name__) + partitions = option(circuits, accelerators, **kwargs) + jobs: list[CircuitJob] = _convert_to_jobs(circuits, partitions) + logging.debug("%s init done.", option.__name__) + return Schedule(_bin_schedule(jobs, accelerators), 0.0) + + +def _greedy_partitioning( + circuits: list[QuantumCircuit], + accelerators: list[Accelerator], + **kwargs, +) -> list[list[int]]: + """taken from scheduler.py""" + partitions = [] + qpu_sizes = [acc.qubits for acc in accelerators] + total_qubits = sum(qpu_sizes) + circuit_sizes = [circ.num_qubits for circ in circuits] + for circuit_size in sorted(circuit_sizes, reverse=True): + if circuit_size > total_qubits: + partition = qpu_sizes.copy() + remaining_size = circuit_size - total_qubits + while remaining_size > total_qubits: + partition += qpu_sizes + remaining_size -= total_qubits + if remaining_size == 1: + if partition[-1] <= 2: + partition[-1] += 1 + else: + partition[-1] = partition[-1] - 1 + partition.append(2) + else: + partition += _partition_big_to_small(remaining_size, qpu_sizes) + partitions.append(partition) + elif circuit_size > max(qpu_sizes): + partition = _partition_big_to_small(circuit_size, qpu_sizes) + partitions.append(partition) + else: + partitions.append([circuit_size]) + + return partitions + + +def _partition_big_to_small(size: int, qpu_sizes: list[int]) -> list[int]: + partition = [] + for qpu_size in qpu_sizes: + take_qubits = min(size, qpu_size) + if size - take_qubits == 1: + # We can't have a partition of size 1 + # So in this case we take one qubit less to leave a partition of two + take_qubits -= 1 + partition.append(take_qubits) + size -= take_qubits + if size == 0: + break + else: + raise ValueError( + "Circuit is too big to fit onto the devices," + + f" {size} qubits left after partitioning." + ) + return partition + + +def _even_partitioning( + circuits: list[QuantumCircuit], + accelerators: list[Accelerator], + **kwargs, +) -> list[list[int]]: + """Partition circuit in similar sized chunks""" + partitions = [] + partition_size = sum(acc.qubits for acc in accelerators) // len(accelerators) + circuit_sizes = [circ.num_qubits for circ in circuits] + for circuit_size in sorted(circuit_sizes, reverse=True): + if circuit_size > partition_size: + partition = [partition_size] * (circuit_size // partition_size) + if circuit_size % partition_size != 0: + partition.append(circuit_size % partition_size) + if partition[-1] == 1: + partition[-1] = 2 + partition[-2] -= 1 + partitions.append(partition) + else: + partitions.append([circuit_size]) + return partitions + + +def _informed_partitioning( + circuits: list[QuantumCircuit], accelerators: list[Accelerator], **kwargs +) -> list[list[int]]: + """Finds cuts by recursively cutting the line with the least cnots""" + partitions = [] + max_qpu_size = max(acc.qubits for acc in accelerators) + + for circuit in sorted( + circuits, + key=lambda circ: circ.num_qubits, + reverse=True, + ): + counts = _count_cnots(circuit) + cuts = sorted(_find_cuts(counts, 0, circuit.num_qubits, max_qpu_size)) + if len(cuts) == 0: + partitions.append([circuit.num_qubits]) + else: + partition = [] + current = -1 + for cut in cuts: + partition.append(cut - current) + current = cut + partition.append(circuit.num_qubits - current - 1) + partitions.append(partition) + return partitions + + +def _count_cnots(circuit: QuantumCircuit) -> Counter[tuple[int, int]]: + counter: Counter[tuple[int, int]] = Counter() + for instruction in circuit.data: + if instruction.operation.name == "cx": + first_qubit = circuit.find_bit(instruction.qubits[0]).index + second_qubit = circuit.find_bit(instruction.qubits[1]).index + if abs(first_qubit - second_qubit) <= 1: + counter[(first_qubit, second_qubit)] += 1 + return counter + + +def _find_cuts( + counts: Counter[tuple[int, int]], start: int, end: int, max_qpu_size: int +) -> list[int]: + if end - start <= max_qpu_size: + return [] + possible_cuts = [_calulate_cut(counts, cut) for cut in range(start + 1, end - 2)] + best_cut = min(possible_cuts, key=lambda cut: cut[1])[0] + partitions = ( + [best_cut] + + _find_cuts(counts, start, best_cut, max_qpu_size) + + _find_cuts(counts, best_cut + 1, end, max_qpu_size) + ) + + return partitions + + +def _calulate_cut(counts: Counter[tuple[int, int]], cut: int) -> tuple[int, int]: + left = sum( + count for (first, second), count in counts.items() if first <= cut < second + ) + right = sum( + count for (first, second), count in counts.items() if second <= cut < first + ) + return cut, left + right + + +def _choice_partitioning( + circuits: list[QuantumCircuit], + accelerators: list[Accelerator], + **kwargs, +) -> list[list[int]]: + partitions = [] + qpu_sizes = [acc.qubits for acc in accelerators] + circuit_sizes = [circ.num_qubits for circ in circuits] + for circuit_size in sorted(circuit_sizes, reverse=True): + partition = [] + remaining_size = circuit_size + while remaining_size > 0: + qpu = np.random.choice(qpu_sizes) + take_qubits = min(remaining_size, qpu) + if remaining_size - take_qubits == 1: + # We can't have a partition of size 1 + # So in this case we take one qubit less to leave a partition of two + take_qubits -= 1 + partition.append(take_qubits) + remaining_size -= take_qubits + partitions.append(partition) + return partitions + + +def _random_partitioning( + circuits: list[QuantumCircuit], + accelerators: list[Accelerator], + **kwargs, +) -> list[list[int]]: + partitions = [] + max_qpu_size = max(acc.qubits for acc in accelerators) + 1 + circuit_sizes = [circ.num_qubits for circ in circuits] + for circuit_size in sorted(circuit_sizes, reverse=True): + partition = [] + remaining_size = circuit_size + if circuit_size <= 3: + partitions.append([circuit_size]) + continue + while remaining_size > 0: + qpu = np.random.randint(2, max_qpu_size) + take_qubits = min(remaining_size, qpu) + if remaining_size - take_qubits == 1: + # We can't have a partition of size 1 + # So in this case we take one qubit less to leave a partition of two + take_qubits -= 1 + partition.append(take_qubits) + remaining_size -= take_qubits + partitions.append(partition) + return partitions + + +def _fixed_partitioning( + circuits: list[QuantumCircuit], + accelerators: list[Accelerator], + **kwargs, +) -> list[list[int]]: + if "partition_size" not in kwargs: + partition_size = 10 + else: + partition_size = kwargs["partition_size"] + partitions = [] + circuit_sizes = [circ.num_qubits for circ in circuits] + for circuit_size in sorted(circuit_sizes, reverse=True): + if circuit_size > partition_size: + partition = [partition_size] * (circuit_size // partition_size) + if circuit_size % partition_size != 0: + partition.append(circuit_size % partition_size) + if partition[-1] == 1: + partition[-1] = 2 + partition[-2] -= 1 + partitions.append(partition) + else: + partitions.append([circuit_size]) + return partitions + + +def _convert_to_jobs( + circuits: list[QuantumCircuit], partitions: list[list[int]] +) -> list[CircuitJob]: + jobs = [] + for idx, circuit in enumerate( + sorted(circuits, key=lambda circ: circ.num_qubits, reverse=True) + ): + if len(partitions[idx]) > 1: + experiments, _ = cut_circuit(circuit, partitions[idx]) + jobs += [ + job + for experiment in experiments + for job in jobs_from_experiment(experiment) + ] + else: + # assumption for now dont cut to any to smaller + circuit = job_from_circuit(circuit) + jobs.append(circuit) + return jobs + + +def _bin_schedule( + jobs: list[CircuitJob], accelerators: list[Accelerator] +) -> list[Machine]: + closed_bins = _do_bin_pack(jobs, [qpu.qubits for qpu in accelerators]) + # Build combined jobs from bins + machines = [] + for acc in accelerators: + machines.append( + Machine( + capacity=acc.qubits, + id=str(acc.uuid), + buckets=[], + ) + ) + + for _bin in sorted(closed_bins, key=lambda x: x.index): + machines[_bin.qpu].buckets.append(Bucket(jobs=_bin.jobs)) + return machines + + +OPTIONS = [ + _greedy_partitioning, + _even_partitioning, + _informed_partitioning, + _random_partitioning, + _choice_partitioning, + _fixed_partitioning, +] diff --git a/src/scheduling/heuristics/schedule.py b/src/scheduling/heuristics/schedule.py new file mode 100644 index 0000000..6e26144 --- /dev/null +++ b/src/scheduling/heuristics/schedule.py @@ -0,0 +1,71 @@ +"""Schedule wrapper for population-based heuristics.""" + +from qiskit import QuantumCircuit + +from src.common import ScheduledJob +from src.provider import Accelerator +from src.tools import assemble_job + +from .search import scatter_search +from ..types import JobResultInfo + + +def generate_heuristic_exec_schedule( + circuits: list[QuantumCircuit], + accelerators: list[Accelerator], + **kwargs, +) -> tuple[list[ScheduledJob], float]: + """Generates a schedule for the given jobs and accelerators using a scatter search heuristic. + + TODO: + - Adapt to the existing interface of info/exec schedule + - (Parallelize scatter search?) + - Find meta-parameters for scatter search + - Improve the heuristic (temperature, tabu list) + - Find a good way to implement init options + + Args: + circuits (list[QuantumCircuit]): List of circuits (jobs) to schedule. + accelerators (list[Accelerator]): List of accelerators to schedule on. + + + Returns: + tuple[list[ScheduledJob], float]: The list of jobs with their assigned machine and + the makespan of the schedule. + """ + schedule = scatter_search(circuits, accelerators, **kwargs) + combined_jobs = [] + for machine in schedule.machines: + + machin_idx = next( + idx for idx, acc in enumerate(accelerators) if str(acc.uuid) == machine.id + ) + for bucket in machine.buckets: + combined_jobs.append(ScheduledJob(assemble_job(bucket.jobs), machin_idx)) + return combined_jobs, schedule.makespan + + +def generate_heuristic_info_schedule( + circuits: list[QuantumCircuit], + accelerators: list[Accelerator], + **kwargs, +) -> tuple[float, list[JobResultInfo]]: + """tmp workaround""" + schedule = scatter_search(circuits, accelerators, **kwargs) + combined_jobs = [] + for machine in schedule.machines: + for idx, bucket in enumerate(machine.buckets): + for job in bucket.jobs: + if job is None or job.circuit is None: + continue + combined_jobs.append( + JobResultInfo( + name=str(job.uuid), + machine=machine.id, + start_time=idx, + completion_time=-1.0, + capacity=job.circuit.num_qubits, + ) + ) + + return schedule.makespan, combined_jobs diff --git a/src/scheduling/heuristics/search.py b/src/scheduling/heuristics/search.py new file mode 100644 index 0000000..513ead0 --- /dev/null +++ b/src/scheduling/heuristics/search.py @@ -0,0 +1,108 @@ +"""Scatter search heuristic for scheduling problems.""" + +from functools import partial +from multiprocessing import Pool, cpu_count, current_process +import logging + +from qiskit import QuantumCircuit +import tqdm + +from src.provider import Accelerator + +from .diversify import generate_new_solutions +from .improve import improve_solutions +from .initialize import initialize_population +from .select import ( + select_best_solution, + select_elite_solutions, + select_diverse_solutions, +) +from .types import Schedule + + +def scatter_search( + circuits: list[QuantumCircuit], + accelerators: list[Accelerator], + num_iterations: int = 100, + num_elite_solutions: int = 10, + **kwargs, +) -> Schedule: + """Scatter search heuristic for scheduling problems. + + Args: + circuits (list[QuantumCircuit]): Batch of circuits to schedule. + accelerators (list[Accelerator]): List of accelerators to schedule on. + num_iterations (int, optional): Number of search iterations. Defaults to 100. + num_elite_solutions (int, optional): Max number of solutions to keep each round. + Defaults to 10. + + Returns: + Schedule: The approximate best schedule found by the heuristic. + """ + # TODO maybe decrease num_elite_solutions/diversificaiton over time? (similar to SA) + num_cores = kwargs.get("num_cores", cpu_count()) + population = initialize_population(circuits, accelerators, **kwargs) + kwargs["num_iterations"] = num_iterations // num_cores + kwargs["num_elite_solutions"] = num_elite_solutions + logging.info("Starting scatter search with %d cores", num_cores) + if num_cores == 1: + return _task(population, accelerators, **kwargs) + with Pool(processes=num_cores) as pool: + work = partial( + _task, + accelerators=accelerators, + **kwargs, + ) + solutions = pool.map(work, [population for _ in range(num_cores)]) + + return select_best_solution(solutions, accelerators) + + +def _task( + population: list[Schedule], + accelerators: list[Accelerator], + num_iterations: int, + num_elite_solutions: int, + **kwargs, +) -> Schedule: + logging.info("Starting new task on process %s", current_process().name) + best_solution = select_best_solution(population, accelerators) + for idx in range(num_iterations): + logging.info("Starting iteration %d on process %s", idx, current_process().name) + # Diversification + new_solutions = generate_new_solutions(population) + improved_population = improve_solutions(population, accelerators) + + # ensure we don't add duplicates + population = _combine_solutions(population, new_solutions, improved_population) + + # Intensification + elite_solutions = select_elite_solutions( + population, num_elite_solutions, accelerators + ) + diverse_solutions = select_diverse_solutions(population, num_elite_solutions) + population = _combine_solutions(elite_solutions, diverse_solutions) + + # Update best solution + current_best_solution = select_best_solution(population, accelerators) + if current_best_solution.makespan < best_solution.makespan: + best_solution = current_best_solution + logging.info( + "Update best solution on process %s: New value %d ", + current_process().name, + current_best_solution.makespan, + ) + return best_solution + + +def _combine_solutions( + population: list[Schedule], + *args: list[Schedule], +) -> list[Schedule]: + """Combines solutions and removes duplicates.""" + combined_solution = [] + for solution in population + [schedule for other in args for schedule in other]: + if solution not in combined_solution: + combined_solution.append(solution) + + return combined_solution diff --git a/src/scheduling/heuristics/select.py b/src/scheduling/heuristics/select.py new file mode 100644 index 0000000..41e0b76 --- /dev/null +++ b/src/scheduling/heuristics/select.py @@ -0,0 +1,149 @@ +"""Evaluation and selection of solutions.""" + +from uuid import UUID +import logging + +from src.provider import Accelerator +from .types import Schedule, Machine, Bucket, MakespanInfo, is_feasible + + +def select_elite_solutions( + population: list[Schedule], num_solutions: int, accelerators: list[Accelerator] +) -> list[Schedule]: + """Selects the #num_solutions best solutions from the population by makespan. + + Args: + population (list[Schedule]): List of schedules to select from. + num_solutions (int): Number of solutions to select. + accelerators (list[Accelerator]): Reference to the accelerators for makespan calculation. + + Raises: + ValueError: If the population is empty. + + Returns: + list[Schedule]: The #num_solutions best schedules with lowest makespan. + """ + logging.info("Selecting elite solutions...") + if len(population) == 0: + raise ValueError("Population must not be empty.") + + population = [evaluate_solution(schedule, accelerators) for schedule in population] + logging.debug("Evaluation done.") + return sorted(population, key=lambda x: x.makespan)[:num_solutions] + + +def select_best_solution( + population: list[Schedule], accelerators: list[Accelerator] +) -> Schedule: + """Selects the best solution from the population by makespan. + + Args: + population (list[Schedule]): List of schedules to select from. + accelerators (list[Accelerator]): Reference to the accelerators for makespan calculation. + + Returns: + Schedule: The schedule with the lowest makespan. + """ + logging.debug("Selecting best solution.") + for solution in select_elite_solutions(population, len(population), accelerators): + if is_feasible(solution): + return solution + return population[-1] + + +def select_diverse_solutions( + population: list[Schedule], num_solutions: int +) -> list[Schedule]: + """Selects the #num_solutions most diverse solutions from the population. + + Args: + population (list[Schedule]): List of schedules to select from. + num_solutions (int): Number of solutions to select. + + Returns: + list[Schedule]: The #num_solutions most diverse schedules. + """ + return sorted( + population, + key=lambda x: sum( + _hamming_proxy(x, other) for other in population if other != x + ), + )[-num_solutions:] + + +def evaluate_solution(schedule: Schedule, accelerators: list[Accelerator]) -> Schedule: + """Calculates and updates the makespan of a schedule. + + Args: + schedule (Schedule): A schedule to evaluate. + accelerators (list[Accelerator]): The list of accelerators to schedule on. + + Returns: + Schedule: The schedule with updated makespan and machine makespans. + """ + logging.debug("Evaluating makespan...") + makespans = [] + for machine in schedule.machines: + accelerator = next(acc for acc in accelerators if str(acc.uuid) == machine.id) + makespans.append(_calc_machine_makespan(machine.buckets, accelerator)) + machine.makespan = makespans[-1] + schedule.makespan = max(makespans) + return schedule + + +def _calc_machine_makespan(buckets: list[Bucket], accelerator: Accelerator) -> float: + jobs: list[MakespanInfo] = [] + for idx, bucket in enumerate(buckets): + # assumption: jobs take the longer of both circuits to execute and to set up + jobs += [ + MakespanInfo( + job=job.circuit, + start_time=idx, + completion_time=-1.0, + capacity=job.circuit.num_qubits, + ) + for job in bucket.jobs + ] + + assigned_jobs = jobs.copy() + for job in jobs: + last_completed = max( + (job for job in assigned_jobs), key=lambda x: x.completion_time + ) + if job.start_time == 0.0: + last_completed = MakespanInfo(None, 0.0, 0.0, 0) + job.start_time = last_completed.completion_time + job.completion_time = ( + last_completed.completion_time + + accelerator.compute_processing_time(job.job) + + accelerator.compute_setup_time(last_completed.job, job.job) + ) + if len(jobs) == 0: + return 0.0 + return max(jobs, key=lambda j: j.completion_time).completion_time + + +def _hamming_proxy(schedule: Schedule, other: Schedule) -> int: + """Hamming distance proxy function for schedules.""" + num_buckets = max(len(schedule.machines), len(other.machines)) + distance = 0 + # should be same order + for machine1, machine2 in zip(schedule.machines, other.machines): + num_buckets = max(len(machine1.buckets), len(machine2.buckets)) + jobs1 = _helper(machine1) + jobs2 = _helper(machine2) + for job in jobs1: + if job in jobs2: + distance += abs(jobs1[job] - jobs2[job]) + else: + distance += num_buckets + + return distance + + +def _helper(machine: Machine) -> dict[UUID, int]: + return { + job.uuid: idx + for idx, bucket in enumerate(machine.buckets) + for job in bucket.jobs + } diff --git a/src/scheduling/heuristics/types.py b/src/scheduling/heuristics/types.py new file mode 100644 index 0000000..ca4f717 --- /dev/null +++ b/src/scheduling/heuristics/types.py @@ -0,0 +1,83 @@ +"""Data structures for population-based heuristics.""" + +from dataclasses import dataclass, field + +from qiskit import QuantumCircuit + +from src.common import CircuitJob + + +@dataclass +class Bucket: + """A bucket is a list of jobs that are performed on the same machine at one timestep.""" + + # All + jobs: list[CircuitJob] = field(default_factory=list) + + # max_duration: int + # start_time: int + # end_time: int + def __eq__(self, __value: object) -> bool: + if not isinstance(__value, Bucket): + return False + return sorted([job.uuid for job in self.jobs]) == sorted( + [job.uuid for job in __value.jobs] + ) + + +@dataclass +class Machine: + """A machine has a list of jobs, which are performed in buckets over several timesteps. + One bucket represents one timestep. + """ + + capacity: int + id: str + buckets: list[Bucket] # Has to be ordered + makespan: float = 0.0 + + def __eq__(self, __value: object) -> bool: + if not isinstance(__value, Machine) or self.id != __value.id: + return False + for bucket_self, bucket_other in zip(self.buckets, __value.buckets): + if bucket_self != bucket_other: + return False + return True + + +@dataclass +class Schedule: + """A schedule is a list of machines, and their jobs.""" + + machines: list[Machine] + makespan: float + + def __eq__(self, __value: object) -> bool: + if not isinstance(__value, Schedule): + return False + other_machines = {machine.id: machine for machine in __value.machines} + for machine in self.machines: + if machine.id not in other_machines: + return False + if machine != other_machines[machine.id]: + return False + return True + + +@dataclass +class MakespanInfo: + """Dataclass to track job completion times for makespan calc""" + + job: QuantumCircuit | None + start_time: float + completion_time: float + capacity: int + + +def is_feasible(schedule: Schedule) -> bool: + """Checks if a schedule is feasible.""" + return all( + sum(job.circuit.num_qubits for job in bucket.jobs) <= machine.capacity + for machine in schedule.machines + for bucket in machine.buckets + ) diff --git a/src/scheduling/learning/__init__.py b/src/scheduling/learning/__init__.py new file mode 100644 index 0000000..e69de29 diff --git a/src/scheduling/types.py b/src/scheduling/types.py index e5c7c69..20f6667 100644 --- a/src/scheduling/types.py +++ b/src/scheduling/types.py @@ -24,7 +24,7 @@ class Bin: capacity: int index: int qpu: int - jobs: list[QuantumCircuit] = field(default_factory=list) + jobs: list[CircuitJob] = field(default_factory=list) full: bool = False diff --git a/src/tools/cutting.py b/src/tools/cutting.py index 08358a6..e3d52bb 100644 --- a/src/tools/cutting.py +++ b/src/tools/cutting.py @@ -1,5 +1,7 @@ """Circuit cutting using the CTK library.""" + from uuid import UUID, uuid4 +import logging from circuit_knitting.cutting import ( partition_problem, @@ -15,7 +17,7 @@ def cut_circuit( circuit: QuantumCircuit, partitions: list[int], - observables: (PauliList | None) = None, + observables: PauliList | None = None, ) -> tuple[list[Experiment], UUID]: """Cut a circuit into multiple subcircuits. @@ -31,12 +33,17 @@ def cut_circuit( if observables is None: observables = PauliList("Z" * circuit.num_qubits) gen_partitions = _generate_partition_labels(partitions) + logging.debug("Partitioning circuit into... %s", gen_partitions) partitioned_problem = partition_problem(circuit, gen_partitions, observables) + logging.debug("Cutting done, generating experiments...") + logging.debug("Number of subcircuits: %d", len(partitioned_problem.subcircuits)) + logging.debug("Number of bases: %d", len(partitioned_problem.bases)) experiments, coefficients = generate_cutting_experiments( partitioned_problem.subcircuits, partitioned_problem.subobservables, num_samples=np.inf, ) + logging.debug("Experiments generated.") uuid = uuid4() return [ Experiment( diff --git a/tests/scheduling/heuristics/__init__.py b/tests/scheduling/heuristics/__init__.py new file mode 100644 index 0000000..e69de29 diff --git a/tests/scheduling/heuristics/test_schedule.py b/tests/scheduling/heuristics/test_schedule.py new file mode 100644 index 0000000..02e871a --- /dev/null +++ b/tests/scheduling/heuristics/test_schedule.py @@ -0,0 +1,24 @@ +"""_summary_""" + +import pytest + +from src.scheduling.heuristics import generate_heuristic_info_schedule + +from src.circuits import create_quantum_only_ghz +from src.common import IBMQBackend +from src.provider import Accelerator + + +@pytest.mark.skip(reason="Disabling during WIP.") +def test_generate_heuristic_info_schedule(): + """Test for generate_heuristic_info_schedule.""" + circuits = [create_quantum_only_ghz(qubits) for qubits in range(8, 9)] + accelerators = [ + Accelerator(IBMQBackend.BELEM, shot_time=1, reconfiguration_time=1), + Accelerator(IBMQBackend.NAIROBI, shot_time=1, reconfiguration_time=1), + ] + schedule, makespan = generate_heuristic_info_schedule( + circuits, accelerators, num_iterations=32, partition_size=3 + ) + assert 5 < len(schedule) < 13 + assert 45 < makespan < 100 diff --git a/tests/scheduling/heuristics/test_search.py b/tests/scheduling/heuristics/test_search.py new file mode 100644 index 0000000..475a593 --- /dev/null +++ b/tests/scheduling/heuristics/test_search.py @@ -0,0 +1,36 @@ +"""_summary_""" + +from src.scheduling.heuristics.search import _combine_solutions +from src.scheduling.heuristics.types import Bucket, Machine, Schedule + + +from src.circuits import create_quantum_only_ghz +from src.common import job_from_circuit + + +def test_combine_solutions() -> None: + """Test set generation in combine solutions""" + circuits = [job_from_circuit(create_quantum_only_ghz(q)) for q in range(2, 7)] + + buckets = [ + Bucket(circuits[:2]), + Bucket(circuits[2:4]), + Bucket([circuits[1], circuits[0]]), + Bucket(circuits[2:5]), + ] + machines = [ + Machine(5, "1", buckets[:2], 0.0), + Machine(5, "2", buckets[2:4], 0.0), + Machine(5, "3", buckets[:2], 0.0), + Machine(5, "3", buckets[:2], 1.0), + Machine(5, "3", [buckets[2], buckets[1]], 0.0), + ] + + population = [Schedule(machines[:2], 0.0), Schedule(machines[1:3], 0.0)] + new_population = [ + Schedule(machines[:2], 1.0), + Schedule(machines[1::2], 0.0), + Schedule([machines[1], machines[4]], 0.0), + ] + combined = _combine_solutions(population, new_population) + assert len(combined) == 2