diff --git a/qiskit_experiments/framework/base_analysis.py b/qiskit_experiments/framework/base_analysis.py index adfe2cf831..7d6123351a 100644 --- a/qiskit_experiments/framework/base_analysis.py +++ b/qiskit_experiments/framework/base_analysis.py @@ -101,6 +101,10 @@ def run( if figures: experiment_data.add_figures(figures) + # Run post analysis + if experiment_data.experiment is not None: + experiment_data.experiment._post_analysis_action(experiment_data) + return experiment_data def _format_analysis_result(self, data, experiment_id, experiment_components=None): diff --git a/qiskit_experiments/framework/base_experiment.py b/qiskit_experiments/framework/base_experiment.py index e6161ba1f3..9db9170d8a 100644 --- a/qiskit_experiments/framework/base_experiment.py +++ b/qiskit_experiments/framework/base_experiment.py @@ -22,7 +22,6 @@ from qiskit.providers import BaseJob from qiskit.providers.backend import Backend from qiskit.providers.basebackend import BaseBackend as LegacyBackend -from qiskit.test.mock import FakeBackend from qiskit.exceptions import QiskitError from qiskit.qobj.utils import MeasLevel from qiskit_experiments.framework import Options @@ -96,10 +95,6 @@ def run( Returns: The experiment data object. - - Raises: - QiskitError: if experiment is run with an incompatible existing - ExperimentData container. """ # Create experiment data container experiment_data = self._initialize_experiment_data(backend, experiment_data) @@ -109,43 +104,24 @@ def run( run_opts.update_options(**run_options) run_opts = run_opts.__dict__ - # Scheduling parameters - if backend.configuration().simulator is False and isinstance(backend, FakeBackend) is False: - timing_constraints = getattr(self.transpile_options.__dict__, "timing_constraints", {}) - timing_constraints["acquire_alignment"] = getattr( - timing_constraints, "acquire_alignment", 16 - ) - scheduling_method = getattr( - self.transpile_options.__dict__, "scheduling_method", "alap" - ) - self.set_transpile_options( - timing_constraints=timing_constraints, scheduling_method=scheduling_method - ) - # Generate and transpile circuits - transpile_opts = copy.copy(self.transpile_options.__dict__) - transpile_opts["initial_layout"] = list(self._physical_qubits) - circuits = transpile(self.circuits(backend), backend, **transpile_opts) - self._postprocess_transpiled_circuits(circuits, backend, **run_options) + circuits = self.run_transpile(backend) + # Execute experiment if isinstance(backend, LegacyBackend): qobj = assemble(circuits, backend=backend, **run_opts) job = backend.run(qobj) else: job = backend.run(circuits, **run_opts) - # Add Job to ExperimentData and add analysis for post processing. - run_analysis = None - # Add experiment option metadata self._add_job_metadata(experiment_data, job, **run_opts) if analysis and self.__analysis_class__ is not None: - run_analysis = self.run_analysis - - experiment_data.add_data(job, post_processing_callback=run_analysis) + experiment_data.add_data(job, post_processing_callback=self.run_analysis) + else: + experiment_data.add_data(job) - # Return the ExperimentData future return experiment_data def _initialize_experiment_data( @@ -167,11 +143,106 @@ def _initialize_experiment_data( return experiment_data._copy_metadata() - def run_analysis(self, experiment_data, **options) -> ExperimentData: + def _pre_transpile_action(self, backend: Backend): + """An extra subroutine executed before transpilation. + + Note: + This method may be implemented by a subclass that requires to update the + transpiler configuration based on the given backend instance, + otherwise the transpiler configuration should be updated with the + :py:meth:`_default_transpile_options` method. + + For example, some specific transpiler options might change depending on the real + hardware execution or circuit simulator execution. + By default, this method does nothing. + + Args: + backend: Target backend. + """ + pass + + # pylint: disable = unused-argument + def _post_transpile_action( + self, circuits: List[QuantumCircuit], backend: Backend + ) -> List[QuantumCircuit]: + """An extra subroutine executed after transpilation. + + Note: + This method may be implemented by a subclass that requires to update the + circuit or its metadata after transpilation. + Without this method, the transpiled circuit will be immediately executed on the backend. + This method enables the experiment to modify the circuit with pulse gates, + or some extra metadata regarding the transpiled sequence of instructions. + + By default, this method just passes transpiled circuits to the execution chain. + + Args: + circuits: List of transpiled circuits. + backend: Target backend. + + Returns: + List of circuits to execute. + """ + return circuits + + def run_transpile(self, backend: Backend, **options) -> List[QuantumCircuit]: + """Run transpile and return transpiled circuits. + + Args: + backend: Target backend. + options: User provided runtime options. + + Returns: + Transpiled circuit to execute. + """ + # Run pre transpile if implemented by subclasses. + self._pre_transpile_action(backend) + + # Get transpile options + transpile_options = copy.copy(self.transpile_options) + transpile_options.update_options( + initial_layout=list(self._physical_qubits), + **options, + ) + transpile_options = transpile_options.__dict__ + + circuits = transpile(circuits=self.circuits(backend), backend=backend, **transpile_options) + + # Run post transpile. This is implemented by each experiment subclass. + circuits = self._post_transpile_action(circuits, backend) + + return circuits + + def _post_analysis_action(self, experiment_data: ExperimentData): + """An extra subroutine executed after analysis. + + Note: + This method may be implemented by a subclass that requires to perform + extra data processing based on the analyzed experimental result. + + Note that the analysis routine will not complete until the backend job + is executed, and this method will be called after the analysis routine + is completed though a handler of the experiment result will be immediately + returned to users (a future object). This method is automatically triggered + when the analysis is finished, and will be processed in background. + + If this method updates some other (mutable) objects, you may need manage + synchronization of update of the object data. Otherwise you may want to + call :meth:`block_for_results` method of the ``experiment_data`` here + to freeze processing chain until the job result is returned. + + By default, this method does nothing. + + Args: + experiment_data: A future object of the experimental result. + """ + pass + + def run_analysis(self, experiment_data: ExperimentData, **options) -> ExperimentData: """Run analysis and update ExperimentData with analysis result. Args: - experiment_data (ExperimentData): the experiment data to analyze. + experiment_data: The experiment data to analyze. options: additional analysis options. Any values set here will override the value from :meth:`analysis_options` for the current run. @@ -180,7 +251,7 @@ def run_analysis(self, experiment_data, **options) -> ExperimentData: An experiment data object containing the analysis results and figures. Raises: - QiskitError: if experiment_data container is not valid for analysis. + QiskitError: Method is called with an empty experiment result. """ # Get analysis options analysis_options = copy.copy(self.analysis_options) @@ -335,10 +406,6 @@ def set_analysis_options(self, **fields): """ self._analysis_options.update_options(**fields) - def _postprocess_transpiled_circuits(self, circuits, backend, **run_options): - """Additional post-processing of transpiled circuits before running on backend""" - pass - def _metadata(self) -> Dict[str, any]: """Return experiment metadata for ExperimentData. diff --git a/qiskit_experiments/framework/common_operations.py b/qiskit_experiments/framework/common_operations.py new file mode 100644 index 0000000000..dbf7fe5407 --- /dev/null +++ b/qiskit_experiments/framework/common_operations.py @@ -0,0 +1,47 @@ +# This code is part of Qiskit. +# +# (C) Copyright IBM 2021. +# +# This code is licensed under the Apache License, Version 2.0. You may +# obtain a copy of this license in the LICENSE.txt file in the root directory +# of this source tree or at http://www.apache.org/licenses/LICENSE-2.0. +# +# Any modifications or derivative works of this code must retain this +# copyright notice, and modified files need to carry a notice indicating +# that they have been altered from the originals. + +"""A collection of common operation callback in execution chain.""" + + +from qiskit.providers import Backend +from qiskit.test.mock import FakeBackend + +from .base_experiment import BaseExperiment + + +def apply_delay_validation(experiment: BaseExperiment, backend: Backend): + """Enable delay duration validation to conform to backend alignment constraints. + + Args: + experiment: Experiment instance to run. + backend: Target backend. + """ + is_simulator = getattr(backend.configuration(), "simulator", False) + + if not is_simulator and not isinstance(backend, FakeBackend): + timing_constraints = getattr( + experiment.transpile_options.__dict__, "timing_constraints", {} + ) + + # alignment=16 is IBM standard. Will be soon provided by IBM providers. + # Then, this configuration can be removed. + timing_constraints["acquire_alignment"] = getattr( + timing_constraints, "acquire_alignment", 16 + ) + + scheduling_method = getattr( + experiment.transpile_options.__dict__, "scheduling_method", "alap" + ) + experiment.set_transpile_options( + timing_constraints=timing_constraints, scheduling_method=scheduling_method + ) diff --git a/qiskit_experiments/framework/composite/batch_experiment.py b/qiskit_experiments/framework/composite/batch_experiment.py index c6d0a71ada..2bc6b7da83 100644 --- a/qiskit_experiments/framework/composite/batch_experiment.py +++ b/qiskit_experiments/framework/composite/batch_experiment.py @@ -13,7 +13,7 @@ Batch Experiment class. """ -from collections import OrderedDict +from typing import List from qiskit import QuantumCircuit @@ -21,7 +21,43 @@ class BatchExperiment(CompositeExperiment): - """Batch experiment class""" + """Batch experiment class. + + This experiment takes multiple experiment instances and generates a list of circuits for + each nested experiment. This nested circuit list is flattened to a single long list of + all circuits to execute, and the circuits are executed ony be one + on the target backend as a single job. + + If an experiment analysis needs results of different types of experiments, + ``BatchExperiment`` may be convenient to describe the flow of the entire experiment. + + The experimental result of ``i``-th experiment can be accessed by + + .. code-block:: python3 + + batch_exp = BatchExperiment([exp1, exp2, exp3]) + batch_result = batch_exp.run(backend) + + exp1_res = batch_result.component_experiment_data(0) # data of exp1 + exp2_res = batch_result.component_experiment_data(1) # data of exp2 + exp3_res = batch_result.component_experiment_data(2) # data of exp3 + + One can also create a custom analysis class that estimates some parameters by + combining above analysis results. Here the ``exp*_res`` is a single + :py:class:`~qiskit_experiments.framework.experiment_data.ExperimentData` class of + a standard experiment, and the associated analysis will be performed once the batch job + is completed. Thus analyzed parameter value of each experiment can be obtained as usual. + + .. code-block:: python3 + + param_x = exp1_res.analysis_results("target_parameter_x") + param_y = exp2_res.analysis_results("target_parameter_y") + param_z = exp3_res.analysis_results("target_parameter_z") + + param_xyz = param_x + param_y + param_z # do some computation + + The final parameter ``param_xyz`` can be returned as an outcome of this batch experiment. + """ def __init__(self, experiments): """Initialize a batch experiment. @@ -29,46 +65,35 @@ def __init__(self, experiments): Args: experiments (List[BaseExperiment]): a list of experiments. """ - - # Generate qubit map - self._qubit_map = OrderedDict() - logical_qubit = 0 + qubits = list() for expr in experiments: - for physical_qubit in expr.physical_qubits: - if physical_qubit not in self._qubit_map: - self._qubit_map[physical_qubit] = logical_qubit - logical_qubit += 1 - qubits = tuple(self._qubit_map.keys()) + for qubit in expr.physical_qubits: + if qubit not in qubits: + qubits.append(qubit) + super().__init__(experiments, qubits) - def circuits(self, backend=None): + # pylint: disable=unused-argument + def _flatten_circuits( + self, + circuits: List[List[QuantumCircuit]], + num_qubits: int, + ) -> List[QuantumCircuit]: + """Flatten circuits. + Note: + This experiment just flattens a list of list of circuit to a single long list of + circuits. The structure of experiment is kept in a metadata. + """ batch_circuits = [] - # Generate data for combination - for index, expr in enumerate(self._experiments): - if self.physical_qubits == expr.physical_qubits: - qubit_mapping = None - else: - qubit_mapping = [self._qubit_map[qubit] for qubit in expr.physical_qubits] - for circuit in expr.circuits(backend): - # Update metadata - circuit.metadata = { + for expr_idx, sub_circs in enumerate(circuits): + for sub_circ in sub_circs: + sub_circ.metadata = { "experiment_type": self._type, - "composite_metadata": [circuit.metadata], - "composite_index": [index], + "composite_index": [expr_idx], + "composite_metadata": [sub_circ.metadata], } - # Remap qubits if required - if qubit_mapping: - circuit = self._remap_qubits(circuit, qubit_mapping) - batch_circuits.append(circuit) - return batch_circuits + batch_circuits.append(sub_circ) - def _remap_qubits(self, circuit, qubit_mapping): - """Remap qubits if physical qubit layout is different to batch layout""" - num_qubits = self.num_qubits - num_clbits = circuit.num_clbits - new_circuit = QuantumCircuit(num_qubits, num_clbits, name="batch_" + circuit.name) - new_circuit.metadata = circuit.metadata - new_circuit.append(circuit, qubit_mapping, list(range(num_clbits))) - return new_circuit + return batch_circuits diff --git a/qiskit_experiments/framework/composite/composite_analysis.py b/qiskit_experiments/framework/composite/composite_analysis.py index 0749fe707a..f514ea6517 100644 --- a/qiskit_experiments/framework/composite/composite_analysis.py +++ b/qiskit_experiments/framework/composite/composite_analysis.py @@ -12,6 +12,7 @@ """ Composite Experiment Analysis class. """ +import warnings from qiskit.exceptions import QiskitError from qiskit_experiments.framework import BaseAnalysis, AnalysisResultData @@ -40,8 +41,18 @@ def _run_analysis(self, experiment_data: CompositeExperimentData, **options): QiskitError: if analysis is attempted on non-composite experiment data. """ - if not isinstance(experiment_data, CompositeExperimentData): - raise QiskitError("CompositeAnalysis must be run on CompositeExperimentData.") + if not isinstance(experiment_data, self.__experiment_data__): + raise QiskitError( + f"CompositeAnalysis must be run on {self.__experiment_data__.__class__.__name__}." + ) + + if len(options) > 0: + warnings.warn( + f"Analysis options for the composite experiment are provided: {options}. " + "Note that the provided options will override every analysis of an experiment" + "associated with this composite experiment.", + UserWarning, + ) # Add sub-experiment metadata as result of batch experiment # Note: if Analysis results had ID's these should be included here diff --git a/qiskit_experiments/framework/composite/composite_experiment.py b/qiskit_experiments/framework/composite/composite_experiment.py index b58a642fe4..aa9f783e01 100644 --- a/qiskit_experiments/framework/composite/composite_experiment.py +++ b/qiskit_experiments/framework/composite/composite_experiment.py @@ -13,16 +13,44 @@ Composite Experiment abstract base class. """ -from abc import abstractmethod import warnings +from abc import abstractmethod +from typing import List, Optional + +from qiskit import QuantumCircuit +from qiskit.exceptions import QiskitError +from qiskit.providers import Backend from qiskit_experiments.framework import BaseExperiment -from .composite_experiment_data import CompositeExperimentData from .composite_analysis import CompositeAnalysis +from .composite_experiment_data import CompositeExperimentData class CompositeExperiment(BaseExperiment): - """Composite Experiment base class""" + """Composite Experiment base class. + + Composite experiment defines different option handling policies for different + kind of options. + + transpile options + The transpile options set to nested experiments are retained. + Thus, the experiment can transpile experimental circuits individually and combine. + Note that no transpile option can be set to the composite experiment itself. + + experiment options + Same with the transpile options. + + analysis options + Same with transpile options. However, one can set analysis options + to the composite experiment. The set value will override all analysis configurations + of experiments associated with the composite experiment. + + run options + The run options set to nested experiments are discarded. + This is because Qiskit doesn't assume a backend that can execute each circuit + with different run options in a single job. If you want to keep run options + set to the individual experiment, you need to individually run these experiments. + """ __analysis_class__ = CompositeAnalysis __experiment_data__ = CompositeExperimentData @@ -40,10 +68,64 @@ def __init__(self, experiments, qubits, experiment_type=None): self._num_experiments = len(experiments) super().__init__(qubits, experiment_type=experiment_type) + def run_transpile(self, backend: Backend, **options) -> List[QuantumCircuit]: + """Run transpile and returns transpiled circuits. + + Args: + backend: Target backend. + options: User provided runtime options. + + Returns: + Transpiled circuit to execute. + """ + # Generate a set of transpiled circuits for each nested experiment and have them as a list. + # In each list element, a list of quantum circuit for nested experiment is stored. + + # : List[List[QuantumCircuit]] + experiment_circuits_list = list( + map(lambda expr: expr.run_transpile(backend), self._experiments) + ) + + # This is not identical to the `num_qubits` when the backend is AerSimulator. + # In this case, usually a circuit qubit number is determined by the maximum qubit index. + n_qubits = 0 + for circuits in experiment_circuits_list: + circuit_qubits = [circuit.num_qubits for circuit in circuits] + n_qubits = max(n_qubits, *circuit_qubits) + + # merge circuits + return self._flatten_circuits(experiment_circuits_list, n_qubits) + @abstractmethod - def circuits(self, backend=None): + def _flatten_circuits( + self, + circuits: List[List[QuantumCircuit]], + num_qubits: int, + ) -> List[QuantumCircuit]: + """An abstract method to control flattening logic of sub experiments. + + This method takes a nested list of circuits, which corresponds to a + list of circuits generated by each experiment, and generates a single list of circuits + that is executed on the backend. This flattening logic may depend on the + type of composite experiment. + """ pass + def circuits(self, backend: Optional[Backend] = None): + """Composite experiment does not provide this method. + + Args: + backend: The targe backend. + + Raises: + QiskitError: When this method is called. + """ + raise QiskitError( + f"{self.__class__.__name__} does not generate experimental circuits by itself. " + "Call the corresponding method of individual experiment class to find circuits, " + "or call `run_transpile` method to get circuits run on the target backend." + ) + @property def num_experiments(self): """Return the number of sub experiments""" @@ -85,7 +167,12 @@ def _add_job_metadata(self, experiment_data, job, **run_options): sub_data = experiment_data.component_experiment_data(i) sub_exp._add_job_metadata(sub_data, job, **run_options) - def _postprocess_transpiled_circuits(self, circuits, backend, **run_options): - for expr in self._experiments: - if not isinstance(expr, CompositeExperiment): - expr._postprocess_transpiled_circuits(circuits, backend, **run_options) + def set_transpile_options(self, **fields): + """Composite experiment itself doesn't provide transpile options.""" + warnings.warn( + "A composite experiment class doesn't provide transpile options. " + "Note that transpile options are provided by each nested experiment, " + f"and thus provided options here {fields} are just discarded.", + UserWarning, + ) + super().set_transpile_options(**fields) diff --git a/qiskit_experiments/framework/composite/parallel_experiment.py b/qiskit_experiments/framework/composite/parallel_experiment.py index 286b6c8efc..b9c0ccd4a5 100644 --- a/qiskit_experiments/framework/composite/parallel_experiment.py +++ b/qiskit_experiments/framework/composite/parallel_experiment.py @@ -13,13 +13,100 @@ Parallel Experiment class. """ +import itertools +from collections import defaultdict +from typing import List + from qiskit import QuantumCircuit, ClassicalRegister from .composite_experiment import CompositeExperiment class ParallelExperiment(CompositeExperiment): - """Parallel Experiment class""" + """Parallel Experiment class. + + This experiment takes multiple experiment instances and generates + a list of merged circuit to execute. + The experimental circuits are executed in parallel on the target backend. + + This experiment is often used to simultaneously execute the same experiment on + different qubit sets. For example, given we write a simple experiment + ``BitFlipExperiment`` that flips the input quantum state and measure it. + Then we feed multiple experiments of this kind into ``ParallelExperiment``. + + .. code-block:: python3 + + exp1 = BitFlipExperiment(qubits=[0]) + exp2 = BitFlipExperiment(qubits=[1]) + + parallel_exp = BatchExperiment([exp1, exp2]) + + The direct transpiled output of ``exp1`` and ``exp2`` may look like + + .. parsed-literal:: + + # exp1.run_transpile(backend) + + ┌───┐┌─┐ + q_0: ┤ X ├┤M├ + └───┘└╥┘ + q_1: ──────╫─ + ║ + c: 1/══════╩═ + 0 + + # exp2.run_transpile(backend) + + q_0: ──────── + ┌───┐┌─┐ + q_1: ┤ X ├┤M├ + └───┘└╥┘ + c: 1/══════╩═ + 0 + + The ``ParallelExperiment`` merges these circuits into a single circuit like below. + + .. parsed-literal:: + + # parallel_exp.run_transpile(backend) + + ┌───┐┌─┐ + q_0: ┤ X ├┤M├─── + ├───┤└╥┘┌─┐ + q_1: ┤ X ├─╫─┤M├ + └───┘ ║ └╥┘ + c0: 1/══════╩══╬═ + 0 ║ + ║ + c1: 1/═════════╩═ + 0 + + This parallel execution may save us from waiting a job queueing time for each experiment, + at the expense of some extra crosstalk error possibility. + + The experimental result of ``i``-th experiment can be accessed by + + .. code-block:: python3 + + parallel_result = parallel_exp.run(backend) + + exp1_res = parallel_result.component_experiment_data(0) # data of exp1 + exp2_res = parallel_result.component_experiment_data(1) # data of exp2 + + Note that we can also combine different types of experiments, even if the length of + generated circuits are different. If two experiment instances provide different + length of circuits, for example, ``exp1`` and ``exp2`` create 10 and 20 circuits, + respectively, the merged circuit will have the length of 20, and circuits with index + from 10 to 19 will contain only experimental circuits of ``exp2``. + + Note: + The transpile and analysis configurations of each experiment will be retained, + however, the run configurations of nested experiments will be discarded. + For example, if a backend provides a run option ``meas_level`` controlling a + qubit state discrimination, we cannot set ``meas_level=1`` for ``exp1`` and + ``meas_level=2`` for ``exp2``. Parallelized experiment will always be + executed under the consistent run configurations. + """ def __init__(self, experiments): """Initialize the analysis object. @@ -32,61 +119,46 @@ def __init__(self, experiments): qubits += exp.physical_qubits super().__init__(experiments, qubits) - def circuits(self, backend=None): - - sub_circuits = [] - sub_qubits = [] - sub_size = [] - num_qubits = 0 - - # Generate data for combination - for expr in self._experiments: - # Add subcircuits - circs = expr.circuits(backend) - sub_circuits.append(circs) - sub_size.append(len(circs)) - - # Add sub qubits - qubits = list(range(num_qubits, num_qubits + expr.num_qubits)) - sub_qubits.append(qubits) - num_qubits += expr.num_qubits - - # Generate empty joint circuits - num_circuits = max(sub_size) - joint_circuits = [] - for circ_idx in range(num_circuits): - # Create joint circuit - circuit = QuantumCircuit(self.num_qubits, name=f"parallel_exp_{circ_idx}") - circuit.metadata = { - "experiment_type": self._type, - "composite_index": [], - "composite_metadata": [], - "composite_qubits": [], - "composite_clbits": [], - } - for exp_idx in range(self._num_experiments): - if circ_idx < sub_size[exp_idx]: - # Add subcircuits to joint circuit - sub_circ = sub_circuits[exp_idx][circ_idx] - num_clbits = circuit.num_clbits - qubits = sub_qubits[exp_idx] - clbits = list(range(num_clbits, num_clbits + sub_circ.num_clbits)) - circuit.add_register(ClassicalRegister(sub_circ.num_clbits)) - circuit.append(sub_circ, qubits, clbits) - # Add subcircuit metadata - circuit.metadata["composite_index"].append(exp_idx) - circuit.metadata["composite_metadata"].append(sub_circ.metadata) - circuit.metadata["composite_qubits"].append(qubits) - circuit.metadata["composite_clbits"].append(clbits) - - # Add the calibrations - for gate, cals in sub_circ.calibrations.items(): - for key, sched in cals.items(): - circuit.add_calibration( - gate, qubits=key[0], schedule=sched, params=key[1] - ) - - # Add joint circuit to returned list - joint_circuits.append(circuit.decompose()) - - return joint_circuits + def _flatten_circuits( + self, + circuits: List[List[QuantumCircuit]], + num_qubits: int, + ) -> List[QuantumCircuit]: + """Flatten circuits. + + Note: + This experiment merges sub experiment circuits into a single circuit + by the circuit ``append`` method. + Quantum and classical register indices are retained. + """ + joint_circs = [] + for circ_idx, sub_circs in enumerate(itertools.zip_longest(*circuits)): + joint_circ = QuantumCircuit(num_qubits, name=f"parallel_exp_{circ_idx}") + joint_metadata = defaultdict(list) + for expr_idx, sub_circ in enumerate(sub_circs): + if not sub_circ: + # This experiment provides small number of circuits than others. + # No circuit available for combining with others. + # Skip merging process. + continue + # Add sub circuits to joint circuit + clbits = ClassicalRegister(sub_circ.num_clbits) + joint_circ.add_register(clbits) + joint_circ.compose( + sub_circ, + qubits=range(sub_circ.num_qubits), + clbits=list(clbits), + inplace=True, + ) + joint_metadata["composite_index"].append(expr_idx) + joint_metadata["composite_metadata"].append(sub_circ.metadata) + joint_metadata["composite_qubits"].append( + self.component_experiment(expr_idx).physical_qubits + ) + joint_metadata["composite_clbits"].append( + list(joint_circ.clbits.index(cb) for cb in clbits) + ) + joint_circ.metadata = {"experiment_type": self._type, **joint_metadata} + joint_circs.append(joint_circ) + + return joint_circs diff --git a/qiskit_experiments/library/characterization/t1.py b/qiskit_experiments/library/characterization/t1.py index d03b9761c1..51075d6771 100644 --- a/qiskit_experiments/library/characterization/t1.py +++ b/qiskit_experiments/library/characterization/t1.py @@ -19,7 +19,7 @@ from qiskit.providers import Backend from qiskit.circuit import QuantumCircuit -from qiskit_experiments.framework import BaseExperiment, Options +from qiskit_experiments.framework import BaseExperiment, Options, common_operations from qiskit_experiments.library.characterization.t1_analysis import T1Analysis @@ -131,3 +131,8 @@ def circuits(self, backend: Optional[Backend] = None) -> List[QuantumCircuit]: circuits.append(circ) return circuits + + def _pre_transpile_action(self, backend: Backend): + """Set timing constraints if backend is real hardware.""" + + common_operations.apply_delay_validation(self, backend) diff --git a/qiskit_experiments/library/characterization/t2ramsey.py b/qiskit_experiments/library/characterization/t2ramsey.py index abb93d486f..062b4805bc 100644 --- a/qiskit_experiments/library/characterization/t2ramsey.py +++ b/qiskit_experiments/library/characterization/t2ramsey.py @@ -21,7 +21,7 @@ from qiskit.utils import apply_prefix from qiskit.providers import Backend from qiskit.circuit import QuantumCircuit -from qiskit_experiments.framework import BaseExperiment, Options +from qiskit_experiments.framework import BaseExperiment, Options, common_operations from .t2ramsey_analysis import T2RamseyAnalysis @@ -154,3 +154,8 @@ def circuits(self, backend: Optional[Backend] = None) -> List[QuantumCircuit]: circuits.append(circ) return circuits + + def _pre_transpile_action(self, backend: Backend): + """Set timing constraints if backend is real hardware.""" + + common_operations.apply_delay_validation(self, backend) diff --git a/qiskit_experiments/library/randomized_benchmarking/rb_experiment.py b/qiskit_experiments/library/randomized_benchmarking/rb_experiment.py index bf9f3b29e1..05137236bf 100644 --- a/qiskit_experiments/library/randomized_benchmarking/rb_experiment.py +++ b/qiskit_experiments/library/randomized_benchmarking/rb_experiment.py @@ -220,12 +220,16 @@ def _get_circuit_metadata(self, circuit): return meta return None - def _postprocess_transpiled_circuits(self, circuits, backend, **run_options): - """Additional post-processing of transpiled circuits before running on backend""" - for c in circuits: - meta = self._get_circuit_metadata(c) + def _post_transpile_action( + self, circuits: List[QuantumCircuit], backend: Backend + ) -> List[QuantumCircuit]: + """Count gate operations in each circuit and update metadata.""" + for circuit in circuits: + meta = self._get_circuit_metadata(circuit) if meta is not None: - c_count_ops = RBUtils.count_ops(c, self.physical_qubits) + c_count_ops = RBUtils.count_ops(circuit, self.physical_qubits) circuit_length = meta["xval"] count_ops = [(key, (value, circuit_length)) for key, value in c_count_ops.items()] meta.update({"count_ops": count_ops}) + + return circuits diff --git a/qiskit_experiments/test/t1_backend.py b/qiskit_experiments/test/t1_backend.py index b87c763475..63684ef54e 100644 --- a/qiskit_experiments/test/t1_backend.py +++ b/qiskit_experiments/test/t1_backend.py @@ -36,7 +36,7 @@ def __init__(self, t1, initial_prob1=None, readout0to1=None, readout1to0=None, d configuration = QasmBackendConfiguration( backend_name="t1_simulator", backend_version="0", - n_qubits=int(1e6), + n_qubits=100, basis_gates=["barrier", "x", "delay", "measure"], gates=[], local=True, diff --git a/qiskit_experiments/test/t2ramsey_backend.py b/qiskit_experiments/test/t2ramsey_backend.py index ca8999eaa3..8eb8d4d00b 100644 --- a/qiskit_experiments/test/t2ramsey_backend.py +++ b/qiskit_experiments/test/t2ramsey_backend.py @@ -46,7 +46,7 @@ def __init__( configuration = QasmBackendConfiguration( backend_name="T2Ramsey_simulator", backend_version="0", - n_qubits=int(1e6), + n_qubits=100, basis_gates=["barrier", "h", "p", "delay", "measure"], gates=[], local=True, diff --git a/releasenotes/notes/update-base-experiment-execution-chain-74fb10adb88bf09d.yaml b/releasenotes/notes/update-base-experiment-execution-chain-74fb10adb88bf09d.yaml new file mode 100644 index 0000000000..5476dcacae --- /dev/null +++ b/releasenotes/notes/update-base-experiment-execution-chain-74fb10adb88bf09d.yaml @@ -0,0 +1,32 @@ +--- +features: + - | + The execution chain of :py:class:`~qiskit_experiments.framework.base_experiment.BaseExperiment` + is updated with some flexibility. This new feature will benefit experiment developers + who need to modify the standard job execution and analysis workflow. + + With this change, following three methods are newly introduced. + + - :py:meth:`~qiskit_experiments.framework.base_experiment.BaseExperiment#pre_transpile_action` + - :py:meth:`~qiskit_experiments.framework.base_experiment.BaseExperiment#post_transpile_action` + - :py:meth:`~qiskit_experiments.framework.base_experiment.BaseExperiment#post_analysis_action` + + These methods allow a developer to insert extra data processing routine (somewhat of hooks) + in between circuit generation and result data generation. + This feature increases the flexibility of job execution. + + In addition, :py:meth:`run_transpile` method is added to all experiment classes. + This returns a list of quantum circuits to execute on the given backend. + +upgrade: + - | + Behavior of the composite experiment is upgraded. + Now transpile options set to the nested experiment instances are retained during transpilation. + This means we can practically combine different set of experiments in a + single parallel or batch job. + Previously, all experimental circuits set to the composite experiment used to be transpiled + under the consistent transpile configurations, which may discard required + transpile configurations for a specific experiment. + + Note that run options set to each experiment instance is still discarded because + Qiskit assumes a backend doesn't change run configurations per circuit. diff --git a/test/calibration/experiments/test_rabi.py b/test/calibration/experiments/test_rabi.py index 638b96738e..147a0d4996 100644 --- a/test/calibration/experiments/test_rabi.py +++ b/test/calibration/experiments/test_rabi.py @@ -20,6 +20,7 @@ from qiskit.circuit import Parameter from qiskit.providers.basicaer import QasmSimulatorPy from qiskit.test import QiskitTestCase +from qiskit.test.mock.utils import ConfigurableFakeBackend from qiskit.qobj.utils import MeasLevel import qiskit.pulse as pulse @@ -274,7 +275,7 @@ def test_calibrations(self): experiments.append(rabi) par_exp = ParallelExperiment(experiments) - par_circ = par_exp.circuits()[0] + par_circ = par_exp.run_transpile(backend=ConfigurableFakeBackend("test", 3))[0] # If the calibrations are not there we will not be able to transpile try: diff --git a/test/test_composite.py b/test/test_composite.py index c579eb79ab..5e1d86adc4 100644 --- a/test/test_composite.py +++ b/test/test_composite.py @@ -10,45 +10,340 @@ # copyright notice, and modified files need to carry a notice indicating # that they have been altered from the originals. -"""Class to test composite experiments.""" +"""Test suite for composite experiments. -from test.fake_backend import FakeBackend -from test.fake_experiment import FakeExperiment +This test platform defines simple virtual experiment classes, rather than using the +actual experiments from the experiment library to isolate test of composite framework +from other experiments. +""" +from qiskit.circuit import QuantumCircuit +from qiskit.pulse import Schedule from qiskit.test import QiskitTestCase +from qiskit.test.mock import FakeBogota +from qiskit.test.mock.utils import ConfigurableFakeBackend -from qiskit_experiments.framework import ParallelExperiment, Options +from qiskit_experiments.framework import ( + BaseExperiment, + BaseAnalysis, + AnalysisResultData, + ParallelExperiment, + BatchExperiment, + Options, +) -class TestComposite(QiskitTestCase): +class FakeAnalysisProbability(BaseAnalysis): + """A fake analysis that calculate probability from counts.""" + + @classmethod + def _default_options(cls) -> Options: + return Options(dummyoption="default_value") + + def _run_analysis(self, experiment_data, **options): + """Calculate probability.""" + + expdata = experiment_data.data(0) + counts = expdata["counts"] + probability = counts.get("1", 0) / sum(counts.values()) + + fake_data_entry = AnalysisResultData( + name="probability", + value=probability, + extra={ + "run_options": options, + "shots": sum(counts.values()), + }, + ) + + return [fake_data_entry], [] + + +class FakeExperimentCommon(BaseExperiment): + """A fake experiment that just flip qubit state and measure.""" + + __analysis_class__ = FakeAnalysisProbability + + @classmethod + def _default_experiment_options(cls): + return Options(dummyoption="default_value") + + @classmethod + def _default_transpile_options(cls): + return Options(basis_gates=["x"]) + + @classmethod + def _default_run_options(cls): + return Options(shots=1024) + + def circuits(self, backend=None): + """Fake circuits.""" + test_circ = QuantumCircuit(1, 1) + test_circ.x(0) + test_circ.measure(0, 0) + test_circ.metadata = {"dummy": "test_value"} + + return [test_circ] + + +class TestParallelExperiment(QiskitTestCase): + """Test parallel experiment. + + This experiment uses a fake experiment that just flips qubit state and measure it, + while attached analysis class calculate the excited state population + and create analysis result data. + + This experiment can cover the situation that deals with + several pre and post processing methods and experiment-wise configurations. + This simplicity may benefit the debugging when some unexpected error is induced. """ - Test composite experiment behavior. + + def test_standard_circuit_construction(self): + """Test standard parallel experiment construction.""" + backend = ConfigurableFakeBackend("test", 2) + exp0 = FakeExperimentCommon(qubits=[0]) + exp1 = FakeExperimentCommon(qubits=[1]) + + par_exp = ParallelExperiment([exp0, exp1]) + test_circ = par_exp.run_transpile(backend=backend)[0] + + ref_circ = QuantumCircuit(*test_circ.qregs, *test_circ.cregs) + ref_circ.x(0) + ref_circ.x(1) + ref_circ.measure(0, 0) + ref_circ.measure(1, 1) + + self.assertEqual(test_circ, ref_circ) + + def test_pulse_gate_experiment_with_post_transpile_hook(self): + """Test pulse gate parallel experiment with different transpile hook.""" + + class FakeExperimentPulseGate(FakeExperimentCommon): + """Add transpiler hook to insert calibration.""" + + def _post_transpile_action(self, circuits, backend): + for circ in circuits: + circ.add_calibration( + "x", self.physical_qubits, Schedule(name="test_calibration") + ) + + return circuits + + backend = ConfigurableFakeBackend("test", 2) + exp0 = FakeExperimentCommon(qubits=[0]) + exp1 = FakeExperimentPulseGate(qubits=[1]) + + par_exp = ParallelExperiment([exp0, exp1]) + test_circ = par_exp.run_transpile(backend=backend)[0] + + ref_circ = QuantumCircuit(*test_circ.qregs, *test_circ.cregs) + ref_circ.x(0) + ref_circ.x(1) + ref_circ.measure(0, 0) + ref_circ.measure(1, 1) + + # only q1 has pulse gate + ref_circ.add_calibration("x", (1,), Schedule(name="test_calibration")) + + self.assertEqual(test_circ, ref_circ) + + def test_update_circuit_metadata_with_post_transpile_hook(self): + """Test new metadata with different transpile hook.""" + + class FakeExperimentUpdateCircuitMetadata(FakeExperimentCommon): + """Add transpiler hook to update metadata.""" + + def _post_transpile_action(self, circuits, backend): + for circ in circuits: + circ.metadata["new_data"] = "test_value" + + return circuits + + backend = ConfigurableFakeBackend("test", 2) + exp0 = FakeExperimentCommon(qubits=[0]) + exp1 = FakeExperimentUpdateCircuitMetadata(qubits=[1]) + + par_exp = ParallelExperiment([exp0, exp1]) + test_circ = par_exp.run_transpile(backend=backend)[0] + + ref_metadata = [ + {"dummy": "test_value"}, + {"dummy": "test_value", "new_data": "test_value"}, + ] + + self.assertListEqual(test_circ.metadata["composite_metadata"], ref_metadata) + + def test_retain_transpile_configuration(self): + """Test retain transpile configurations of nested experiments.""" + + backend = ConfigurableFakeBackend("test", 2) + exp0 = FakeExperimentCommon(qubits=[0]) + exp1 = FakeExperimentCommon(qubits=[1]) + + # update exp1 basis gate + exp1.set_transpile_options(basis_gates=["sx", "rz"]) + + par_exp = ParallelExperiment([exp0, exp1]) + test_circ = par_exp.run_transpile(backend=backend)[0] + + ref_circ = QuantumCircuit(*test_circ.qregs, *test_circ.cregs) + ref_circ.x(0) + + # q1 x is decomposed into two sx + ref_circ.sx(1) + ref_circ.sx(1) + + ref_circ.measure(0, 0) + ref_circ.measure(1, 1) + + self.assertEqual(test_circ, ref_circ) + + def test_analyze_standard(self): + """Test analyze standard parallel experiment result.""" + + backend = FakeBogota() + exp0 = FakeExperimentCommon(qubits=[0]) + exp1 = FakeExperimentCommon(qubits=[1]) + + par_exp = ParallelExperiment([exp0, exp1]) + par_exp_data = par_exp.run(backend) + par_exp_data.block_for_results() + + exp_data0 = par_exp_data.component_experiment_data(0) + exp_data1 = par_exp_data.component_experiment_data(1) + + prob_entry_exp0 = exp_data0.analysis_results("probability") + self.assertGreater(prob_entry_exp0.value, 0.8) + + prob_entry_exp1 = exp_data1.analysis_results("probability") + self.assertGreater(prob_entry_exp1.value, 0.8) + + def test_retain_analysis_options(self): + """Test retain analysis configurations of nested experiments.""" + + backend = FakeBogota() + exp0 = FakeExperimentCommon(qubits=[0]) + exp1 = FakeExperimentCommon(qubits=[1]) + + # update exp1 analysis option + exp1.set_analysis_options(new_config="test_value") + + par_exp = ParallelExperiment([exp0, exp1]) + par_exp_data = par_exp.run(backend) + par_exp_data.block_for_results() + + exp_data0 = par_exp_data.component_experiment_data(0) + prob_entry_exp0 = exp_data0.analysis_results("probability") + ref_config0 = {"dummyoption": "default_value"} + self.assertDictEqual(prob_entry_exp0.extra["run_options"], ref_config0) + + # this keeps updated analysis configuration + exp_data1 = par_exp_data.component_experiment_data(1) + prob_entry_exp1 = exp_data1.analysis_results("probability") + ref_config1 = {"dummyoption": "default_value", "new_config": "test_value"} + self.assertDictEqual(prob_entry_exp1.extra["run_options"], ref_config1) + + def test_run_option_overriden(self): + """Test if run option is overridden.""" + + backend = FakeBogota() + exp0 = FakeExperimentCommon(qubits=[0]) + exp1 = FakeExperimentCommon(qubits=[1]) + + # update exp1 run option + exp1.set_run_options(shots=2048) + + par_exp = ParallelExperiment([exp0, exp1]) + par_exp_data = par_exp.run(backend, shots=1024) + par_exp_data.block_for_results() + + exp_data1 = par_exp_data.component_experiment_data(1) + prob_entry_exp1 = exp_data1.analysis_results("probability") + self.assertEqual(prob_entry_exp1.extra["shots"], 1024) + + def test_transpile_option_overriden(self): + """Test if transpile option is retained.""" + backend = ConfigurableFakeBackend("test", 2) + exp0 = FakeExperimentCommon(qubits=[0]) + exp1 = FakeExperimentCommon(qubits=[1]) + + par_exp = ParallelExperiment([exp0, exp1]) + + # transpile option for composite experiment doesn't affect transpilation + with self.assertWarns(UserWarning): + par_exp.set_transpile_options(basis_gates=["sx", "rz"]) + + test_circ = par_exp.run_transpile(backend=backend)[0] + + ref_circ = QuantumCircuit(*test_circ.qregs, *test_circ.cregs) + ref_circ.x(0) + ref_circ.x(1) + ref_circ.measure(0, 0) + ref_circ.measure(1, 1) + + self.assertEqual(test_circ, ref_circ) + + def test_analysis_hook(self): + """Test update class variable with analysis result.""" + + class FakeExperimentAnalysisHook(FakeExperimentCommon): + """Extract probability and update instance variable.""" + + def __init__(self, qubits): + super().__init__(qubits) + self.probability = None + + def _post_analysis_action(self, experiment_data): + prob_val = experiment_data.analysis_results("probability").value + self.probability = prob_val + + backend = FakeBogota() + exp0 = FakeExperimentCommon(qubits=[0]) + exp1 = FakeExperimentAnalysisHook(qubits=[1]) + + par_exp = ParallelExperiment([exp0, exp1]) + par_exp_data = par_exp.run(backend, shots=1024) + par_exp_data.block_for_results() + + exp_data1 = par_exp_data.component_experiment_data(1) + prob_entry_exp1 = exp_data1.analysis_results("probability") + + self.assertEqual(exp1.probability, prob_entry_exp1.value) + + +class TestBatchExperiment(QiskitTestCase): + """Test batch experiment. + + Note: + The only difference of this from ``ParallelExperiment`` is the circuit construction. + Thus the run and analysis tests can be omitted while keeping the good coverage. """ - def test_parallel_options(self): - """ - Test parallel experiments overriding sub-experiment run and transpile options. - """ - - # These options will all be overridden - exp0 = FakeExperiment(0) - exp0.set_transpile_options(optimization_level=1) - exp2 = FakeExperiment(2) - exp2.set_experiment_options(dummyoption="test") - exp2.set_run_options(shots=2000) - exp2.set_transpile_options(optimization_level=1) - exp2.set_analysis_options(dummyoption="test") - - par_exp = ParallelExperiment([exp0, exp2]) - - with self.assertWarnsRegex( - Warning, - "Sub-experiment run and transpile options" - " are overridden by composite experiment options.", - ): - self.assertEqual(par_exp.experiment_options, Options()) - self.assertEqual(par_exp.run_options, Options(meas_level=2)) - self.assertEqual(par_exp.transpile_options, Options(optimization_level=0)) - self.assertEqual(par_exp.analysis_options, Options()) - - par_exp.run(FakeBackend()) + def test_standard_circuit_construction(self): + """Test standard batch experiment construction.""" + backend = ConfigurableFakeBackend("test", 2) + exp0 = FakeExperimentCommon(qubits=[0]) + exp1 = FakeExperimentCommon(qubits=[1]) + + par_exp = BatchExperiment([exp0, exp1]) + test_circs = par_exp.run_transpile(backend=backend) + + ref_circ0 = QuantumCircuit(2, 1) + ref_circ0.x(0) + ref_circ0.measure(0, 0) + + ref_circ1 = QuantumCircuit(2, 1) + ref_circ1.x(1) + ref_circ1.measure(1, 0) + + self.assertListEqual(test_circs, [ref_circ0, ref_circ1]) + + def test_keep_qubit_ordering(self): + """Test if qubit ordering is preserved.""" + + exp0 = FakeExperimentCommon(qubits=[1, 0]) + exp1 = FakeExperimentCommon(qubits=[1, 0]) + + par_exp = BatchExperiment([exp0, exp1]) + + self.assertTupleEqual(par_exp.physical_qubits, (1, 0))