diff --git a/CHANGELOG.md b/CHANGELOG.md index ba6a13df..d8eb4ba8 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -4,6 +4,12 @@ ### Improvements 🛠 +* The `rigetti.qvm` and `rigetti.qpu` device can now be initialized + with a `parallel` and `max_threads` parameter. When `parallel` is + set to True, jobs will be executed in parallel using a `ThreadPool`. + This can be used in conjunction with `max_threads` to set the + maximum number of worker threads to use. + ### Breaking changes 💔 ### Deprecations 👋 @@ -12,10 +18,18 @@ ### Bug fixes 🐛 +* Wire mapping is now based on the device specification instead of being + assumed to be a zero-indexed sequence. + +* QPU devices now configure the compiler to generate programs that will + be compatible to run on a real QPU. + ### Contributors ✍️ This release contains contributions from (in alphabetical order): +Marquess Valdez + --- # Release 0.32.0 diff --git a/pennylane_rigetti/qc.py b/pennylane_rigetti/qc.py index 12318d65..aa309ef7 100644 --- a/pennylane_rigetti/qc.py +++ b/pennylane_rigetti/qc.py @@ -23,10 +23,11 @@ from typing import Dict from collections import OrderedDict +from multiprocessing.pool import ThreadPool + from pyquil import Program from pyquil.api import QAMExecutionResult, QuantumComputer, QuantumExecutable from pyquil.gates import RESET, MEASURE -from pyquil.quil import Pragma from pennylane import DeviceError, numpy as np from pennylane.wires import Wires @@ -45,15 +46,17 @@ class QuantumComputerDevice(RigettiDevice, ABC): device (str): the name of the device to initialise. shots (int): number of circuit evaluations/random samples used to estimate expectation values of observables. - wires (Iterable[Number, str]): Iterable that contains unique labels for the + wires (Iterable[Number, str]): Iterable that contains unique labels for the qubits as numbers or strings (i.e., ``['q1', ..., 'qN']``). The number of labels must match the number of qubits accessible on the backend. - If not provided, qubits are addressed as consecutive integers ``[0, 1, ...]``, and their number - is inferred from the backend. + If not provided, qubits are addressed by the backend. active_reset (bool): whether to actively reset qubits instead of waiting for for qubits to decay to the ground state naturally. Setting this to ``True`` results in a significantly faster expectation value evaluation when the number of shots is larger than ~1000. + parallel (bool): If set to ``True`` batched circuits are executed in parallel. + max_threads (int): If ``parallel`` is set to True, this controls the maximum number of threads + that can be used during parallel execution of jobs. Has no effect if ``parallel`` is False. Keyword args: compiler_timeout (int): number of seconds to wait for a response from quilc (default 10). @@ -64,12 +67,27 @@ class QuantumComputerDevice(RigettiDevice, ABC): version = __version__ author = "Rigetti Computing Inc." - def __init__(self, device, *, shots=1000, wires=None, active_reset=False, **kwargs): + def __init__( + self, + device, + *, + shots=1000, + wires=None, + active_reset=False, + parallel=False, + max_threads=4, + **kwargs, + ): if shots is not None and shots <= 0: raise ValueError("Number of shots must be a positive integer or None.") - self._compiled_program = None + if parallel and max_threads <= 0: + raise ValueError("max_threads must be set to a positive integer greater than 0") + + self._parallel = parallel + self._max_threads = max_threads + self._compiled_program = None self.parametric_compilation = kwargs.get("parametric_compilation", True) if self.parametric_compilation: @@ -86,6 +104,12 @@ def __init__(self, device, *, shots=1000, wires=None, active_reset=False, **kwar their numeric values. This map will be used to bind parameters in a parametric program using PyQuil.""" + self._batched_parameter_map = {} + """dict[str, list[float]]: a map of broadcasted parameter names to each of their values""" + + self._batch_size = 0 + """the batch size of the currently executing circuit""" + self._parameter_reference_map = {} """dict[str, pyquil.quilatom.MemoryReference]: stores the string of symbolic parameters associated with their PyQuil memory references.""" @@ -97,9 +121,8 @@ def __init__(self, device, *, shots=1000, wires=None, active_reset=False, **kwar self.num_wires = len(self.qc.qubits()) if wires is None: - # infer the number of modes from the device specs - # and use consecutive integer wire labels - wires = range(self.num_wires) + # infer the wires from the device specs + wires = self.qc.qubits() if isinstance(wires, int): raise ValueError( @@ -113,7 +136,7 @@ def __init__(self, device, *, shots=1000, wires=None, active_reset=False, **kwar f"cannot be created with {len(wires)} wires." ) - self.wiring = dict(enumerate(self.qc.qubits())) + self.wiring = {q: q for q in sorted(wires)} self.active_reset = active_reset super().__init__(wires, shots) @@ -180,10 +203,8 @@ def define_wire_map(self, wires): def apply(self, operations, **kwargs): """Applies the given quantum operations.""" - prag = Program(Pragma("INITIAL_REWIRING", ['"PARTIAL"'])) if self.active_reset: - prag += RESET() - self.prog = prag + self.prog + self.prog = Program(RESET()) + self.prog if self.parametric_compilation: self.prog += self.apply_parametric_operations(operations) @@ -192,13 +213,16 @@ def apply(self, operations, **kwargs): rotations = kwargs.get("rotations", []) self.prog += self.apply_rotations(rotations) - + self.prog.wrap_in_numshots_loop(self.shots) + # Measure every qubit used by the program into a readout register. + # Devices don't always have sequentially adressed qubits, so + # we use a normalized value to index them into the readout register. qubits = sorted(self.wiring.values()) + used_qubits = self.wiring.keys() ro = self.prog.declare("ro", "BIT", len(qubits)) - for i, q in enumerate(qubits): - self.prog.inst(MEASURE(q, ro[i])) - - self.prog.wrap_in_numshots_loop(self.shots) + normalized_qubit_indices = {wire: i for i, wire in enumerate(list(self.wiring.values()))} + for qubit in used_qubits: + self.prog += MEASURE(qubit, ro[normalized_qubit_indices[qubit]]) def apply_parametric_operations(self, operations): """Applies a parametric program by applying parametric operation with symbolic parameters. @@ -210,7 +234,6 @@ def apply_parametric_operations(self, operations): pyquil.Prgram(): a pyQuil Program with the given operations """ prog = Program() - # Apply the circuit operations for i, operation in enumerate(operations): # map the operation wires to the physical device qubits device_wires = self.map_wires(operation.wires) @@ -223,56 +246,175 @@ def apply_parametric_operations(self, operations): # Prepare for parametric compilation par = [] - for param in operation.data: - if getattr(param, "requires_grad", False) and operation.name != "BasisState": - # Using the idx for trainable parameter objects to specify the - # corresponding symbolic parameter - parameter_string = "theta" + str(id(param)) - - if parameter_string not in self._parameter_reference_map: - # Create a new PyQuil memory reference and store it in the - # parameter reference map if it was not done so already - current_ref = self.prog.declare(parameter_string, "REAL") - self._parameter_reference_map[parameter_string] = current_ref - - # Store the numeric value bound to the symbolic parameter - self._parameter_map[parameter_string] = [param.unwrap()] - - # Appending the parameter reference to the parameters - # of the corresponding operation - par.append(self._parameter_reference_map[parameter_string]) - else: - par.append(param) + if operation.batch_size is not None: + parameter_string = f"theta{i}" + if parameter_string not in self._parameter_reference_map: + # Create a new PyQuil memory reference and store it in the + # parameter reference map if it was not done so already + current_ref = self.prog.declare(parameter_string, "REAL") + self._parameter_reference_map[parameter_string] = current_ref + + # Store the values bound to the symbolic parameter + self._batched_parameter_map[parameter_string] = operation.data[0] + + # Appending the parameter reference to the parameters + # of the corresponding operation + par.append(self._parameter_reference_map[parameter_string]) + else: + for param in operation.data: + if getattr(param, "requires_grad", False) and operation.name != "BasisState": + # Using the idx for trainable parameter objects to specify the + # corresponding symbolic parameter + parameter_string = "theta" + str(id(param)) + + if parameter_string not in self._parameter_reference_map: + # Create a new PyQuil memory reference and store it in the + # parameter reference map if it was not done so already + current_ref = self.prog.declare(parameter_string, "REAL") + self._parameter_reference_map[parameter_string] = current_ref + + # Store the numeric value bound to the symbolic parameter + self._parameter_map[parameter_string] = [param.unwrap()] + + # Appending the parameter reference to the parameters + # of the corresponding operation + par.append(self._parameter_reference_map[parameter_string]) + else: + par.append(param) prog += self._operation_map[operation.name](*par, *device_wires.labels) return prog + @classmethod + def capabilities(cls): + """Get the capabilities of this device class. + + Returns: + dict[str->*]: results + """ + capabilities = super().capabilities().copy() + capabilities.update(supports_broadcasting=True) + return capabilities + def compile(self) -> QuantumExecutable: """Compiles the program for the target device""" return self.qc.compile(self.prog) + def _compile_with_cache(self, circuit_hash=None) -> QuantumExecutable: + """When parametric compilation is enabled, fetches the compiled program from the cache if it exists. + If not, the program is compiled and stored in the cache. + + If parametric compilation is disabled, this just compiles the program + + Args: + circuit_hash: The circuit hash to use to fetch or store the compiled program. If ``None``, uses + the circuit_hash from the currently running circuit. + """ + if not self.parametric_compilation: + return self.compile() + + if circuit_hash is None: + circuit_hash = self.circuit_hash + + compiled_program = self._compiled_program_dict.get(circuit_hash, None) + if compiled_program is None: + compiled_program = self.compile() + self._compiled_program_dict[circuit_hash] = compiled_program + return compiled_program + def execute(self, circuit, **kwargs): """Executes the given circuit""" if self.parametric_compilation: + self._batch_size = circuit.batch_size self._circuit_hash = circuit.graph.hash return super().execute(circuit, **kwargs) + def batch_execute(self, circuits): + """Execute a batch of quantum circuits on the device. + + The circuits are represented by tapes, and they are executed one-by-one using the + device's ``execute`` method. The results are collected in a list. + + Args: + circuits (list[~.tape.QuantumTape]): circuits to execute on the device + + Returns: + list[array[float]]: list of measured value(s) + """ + if not self._parallel or len(circuits) <= 1: + return super().batch_execute(circuits) + + tasks = [] + + for circuit in circuits: + self.reset() + + self.apply(circuit.operations, rotations=circuit.diagonalizing_gates) + + program = self.prog.copy() + + parameters = None + if self.parametric_compilation: + parameters = self._parameter_map + if circuit.batch_size is not None: + for i in range(circuit.batch_size): + for region, values in self._batched_parameter_map.items(): + parameters[region] = values[i] + tasks.append((program, parameters, circuit.graph.hash)) + else: + tasks.append((program, parameters, circuit.graph.hash)) + + else: + tasks.append((program, parameters, circuit.graph_hash)) + + pool_size = len(tasks) + if self._max_threads is not None: + pool_size = min(self._max_threads, pool_size) + + with ThreadPool(pool_size) as pool: + results = pool.starmap(self._run_task, tasks) + + # Batched circuits get broken down into individual tasks to maximize parallelism. + # This step joins those tasks into a single result array so we return the expected + # result shape for those circuits. + normalized_results = [] + i = 0 + for circuit in circuits: + if circuit.batch_size is None: + normalized_results.append(results[i]) + i += 1 + else: + normalized_results.append(np.array(results[i : i + circuit.batch_size])) + i += circuit.batch_size + + return normalized_results + + def _run_task(self, program, parameters, circuit_hash): + program = self._compile_with_cache(circuit_hash=circuit_hash) + if self.parametric_compilation: + for region, value in parameters.items(): + program.write_memory(region_name=region, value=value) + results = self.qc.run(program) + return self.extract_samples(results) + def generate_samples(self): """Executes the program on the QuantumComputer and uses the results to return the computational basis samples of all wires.""" + self._compiled_program = self._compile_with_cache() if self.parametric_compilation: + results = [] # Set the parameter values in executable memory for region, value in self._parameter_map.items(): - self.prog.write_memory(region_name=region, value=value) - # Fetch the compiled program, or compile and store it if it doesn't exist - self._compiled_program = self._compiled_program_dict.get(self.circuit_hash, None) - if self._compiled_program is None: - self._compiled_program = self.compile() - self._compiled_program_dict[self.circuit_hash] = self._compiled_program - else: - # Parametric compilation is disabled, just compile the program - self._compiled_program = self.compile() + self._compiled_program.write_memory(region_name=region, value=value) + if self._batch_size: + for i in range(self._batch_size): + for region, values in self._batched_parameter_map.items(): + self._compiled_program.write_memory(region_name=region, value=values[i]) + samples = self.qc.run(self._compiled_program) + samples = self.extract_samples(samples) + results.append(samples) + return np.array(results) results = self.qc.run(self._compiled_program) return self.extract_samples(results) @@ -299,3 +441,5 @@ def reset(self): self._circuit_hash = None self._parameter_map = {} self._parameter_reference_map = {} + self._batched_parameter_map = {} + self._batch_size = None diff --git a/pennylane_rigetti/qpu.py b/pennylane_rigetti/qpu.py index 8191a25c..2d167841 100644 --- a/pennylane_rigetti/qpu.py +++ b/pennylane_rigetti/qpu.py @@ -26,7 +26,7 @@ from pennylane.operation import Tensor from pennylane.tape import QuantumTape from pyquil import get_qc -from pyquil.api import QuantumComputer +from pyquil.api import QuantumComputer, QuantumExecutable from pyquil.experiment import SymmetrizationLevel from pyquil.operator_estimation import ( Experiment, @@ -67,6 +67,9 @@ class QPUDevice(QuantumComputerDevice): symmetrization by default calibrate_readout (str): method to perform calibration for readout error mitigation, normalizing by the expectation value in the +1-eigenstate of the observable by default + parallel (bool): If set to ``True`` batched circuits are executed in parallel. + max_threads (int): If ``parallel`` is set to ``True``, this controls the maximum number of threads + that can be used during parallel execution of jobs. Has no effect if ``parallel`` is ``False`` Keyword args: compiler_timeout (int): number of seconds to wait for a response from quilc (default 10). @@ -114,6 +117,9 @@ def __init__( super().__init__(device, wires=wires, shots=shots, active_reset=active_reset, **kwargs) + def compile(self) -> QuantumExecutable: + return self.qc.compile(self.prog, protoquil=True) + def get_qc(self, device, **kwargs) -> QuantumComputer: return get_qc(device, as_qvm=self.as_qvm, **kwargs) diff --git a/tests/test_qvm.py b/tests/test_qvm.py index bb979ace..57f8db08 100644 --- a/tests/test_qvm.py +++ b/tests/test_qvm.py @@ -28,11 +28,10 @@ compiled_program = ( "DECLARE ro BIT[2]\n" - 'PRAGMA INITIAL_REWIRING "PARTIAL"\n' - "RZ(0.432) 1\n" - "CZ 1 0\n" - "MEASURE 1 ro[0]\n" - "MEASURE 0 ro[1]\n" + "RZ(0.432) 0\n" + "CZ 0 1\n" + "MEASURE 0 ro[0]\n" + "MEASURE 1 ro[1]\n" "HALT\n" )