diff --git a/qiskit/assembler/assemble_circuits.py b/qiskit/assembler/assemble_circuits.py index 1f84bffa71b0..61ec1dce038a 100644 --- a/qiskit/assembler/assemble_circuits.py +++ b/qiskit/assembler/assemble_circuits.py @@ -11,14 +11,36 @@ # that they have been altered from the originals. """Assemble function for converting a list of circuits into a qobj.""" +from collections import defaultdict +from typing import Dict, List, Optional, Tuple + +from qiskit.assembler.run_config import RunConfig +from qiskit.assembler.assemble_schedules import _assemble_instructions as _assemble_schedule +from qiskit.circuit import QuantumCircuit from qiskit.qobj import (QasmQobj, QobjExperimentHeader, QasmQobjInstruction, QasmQobjExperimentConfig, QasmQobjExperiment, - QasmQobjConfig) + QasmQobjConfig, QasmExperimentCalibrations, GateCalibration, + PulseQobjInstruction, PulseLibraryItem, converters, QobjHeader) from qiskit.tools.parallel import parallel_map -def _assemble_circuit(circuit): - # header stuff +PulseLibrary = Dict[str, List[complex]] + + +def _assemble_circuit( + circuit: QuantumCircuit, + run_config: RunConfig +) -> Tuple[QasmQobjExperiment, Optional[PulseLibrary]]: + """Assemble one circuit. + + Args: + circuit: circuit to assemble + run_config: configuration of the runtime environment + + Returns: + One experiment for the QasmQobj, and pulse library for pulse gates (which could be None) + """ + # header data num_qubits = 0 memory_slots = 0 qubit_labels = [] @@ -47,9 +69,14 @@ def _assemble_circuit(circuit): creg_sizes=creg_sizes, name=circuit.name, global_phase=circuit.global_phase) + # TODO: why do we need n_qubits and memory_slots in both the header and the config config = QasmQobjExperimentConfig(n_qubits=num_qubits, memory_slots=memory_slots) + calibrations, pulse_library = _assemble_pulse_gates(circuit, run_config) + if calibrations: + config.calibrations = calibrations + # Convert conditionals from QASM-style (creg ?= int) to qobj-style # (register_bit ?= 1), by assuming device has unlimited register slots # (supported only for simulators). Map all measures to a register matching @@ -105,21 +132,114 @@ def _assemble_circuit(circuit): del instruction._condition instructions.append(instruction) - return QasmQobjExperiment(instructions=instructions, header=header, - config=config) + return (QasmQobjExperiment(instructions=instructions, header=header, config=config), + pulse_library) + + +def _assemble_pulse_gates( + circuit: QuantumCircuit, + run_config: RunConfig +) -> Tuple[Optional[QasmExperimentCalibrations], Optional[PulseLibrary]]: + """Assemble and return the circuit calibrations and associated pulse library, if there are any. + The calibrations themselves may reference the pulse library which is returned as a dict. + + Args: + circuit: circuit which may have pulse calibrations + run_config: configuration of the runtime environment + + Returns: + The calibrations and pulse library, if there are any + """ + if not circuit.calibrations: + return None, None + if not hasattr(run_config, 'parametric_pulses'): + run_config.parametric_pulses = [] + calibrations = [] + pulse_library = {} + for gate, cals in circuit.calibrations.items(): + for (qubits, params), schedule in cals.items(): + qobj_instructions, _ = _assemble_schedule( + schedule, + converters.InstructionToQobjConverter(PulseQobjInstruction), + run_config, + pulse_library) + calibrations.append( + GateCalibration(str(gate), list(qubits), list(params), qobj_instructions)) + return QasmExperimentCalibrations(gates=calibrations), pulse_library + + +def _extract_common_calibrations( + experiments: List[QasmQobjExperiment] +) -> Tuple[List[QasmQobjExperiment], Optional[QasmExperimentCalibrations]]: + """Given a list of ``QasmQobjExperiment``s, each of which may have calibrations in their + ``config``, collect common calibrations into a global ``QasmExperimentCalibrations`` + and delete them from their local experiments. + + Args: + experiments: The list of Qasm experiments that are being assembled into one qobj + + Returns: + The input experiments with modified calibrations, and common calibrations, if there + are any + """ + def index_calibrations() -> Dict[int, List[Tuple[int, GateCalibration]]]: + """Map each calibration to all experiments that contain it.""" + exp_indices = defaultdict(list) + for exp_idx, exp in enumerate(experiments): + for gate_cal in exp.config.calibrations.gates: + # They must be keyed on the hash or identical cals will be indexed separately + exp_indices[hash(gate_cal)].append((exp_idx, gate_cal)) + return exp_indices + + def collect_common_calibrations() -> List[GateCalibration]: + """If a gate calibration appears in all experiments, collect it.""" + common_calibrations = [] + for _, exps_w_cal in exp_indices.items(): + if len(exps_w_cal) == len(experiments): + _, gate_cal = exps_w_cal[0] + common_calibrations.append(gate_cal) + return common_calibrations + + def remove_common_gate_calibrations(exps: List[QasmQobjExperiment]) -> None: + """For calibrations that appear in all experiments, remove them from the individual + experiment's ``config.calibrations``.""" + for _, exps_w_cal in exp_indices.items(): + if len(exps_w_cal) == len(exps): + for exp_idx, gate_cal in exps_w_cal: + exps[exp_idx].config.calibrations.gates.remove(gate_cal) + + if not (experiments and all(hasattr(exp.config, 'calibrations') for exp in experiments)): + # No common calibrations + return experiments, None + + exp_indices = index_calibrations() + common_calibrations = collect_common_calibrations() + remove_common_gate_calibrations(experiments) + + # Remove the ``calibrations`` attribute if it's now empty + for exp in experiments: + if not exp.config.calibrations.gates: + del exp.config.calibrations + + return experiments, QasmExperimentCalibrations(gates=common_calibrations) -def assemble_circuits(circuits, run_config, qobj_id, qobj_header): +def assemble_circuits( + circuits: List[QuantumCircuit], + run_config: RunConfig, + qobj_id: int, + qobj_header: QobjHeader +) -> QasmQobj: """Assembles a list of circuits into a qobj that can be run on the backend. Args: - circuits (list[QuantumCircuit]): circuit(s) to assemble - qobj_id (int): identifier for the generated qobj - qobj_header (QobjHeader): header to pass to the results - run_config (RunConfig): configuration of the runtime environment + circuits: circuit(s) to assemble + run_config: configuration of the runtime environment + qobj_id: identifier for the generated qobj + qobj_header: header to pass to the results Returns: - QasmQobj: the qobj to be run on the backends + The qobj to be run on the backends """ qobj_config = QasmQobjConfig() if run_config: @@ -138,7 +258,19 @@ def assemble_circuits(circuits, run_config, qobj_id, qobj_header): qobj_config.memory_slots = max(memory_slot_sizes) qobj_config.n_qubits = max(qubit_sizes) - experiments = parallel_map(_assemble_circuit, circuits) + experiments_and_pulse_libs = parallel_map(_assemble_circuit, circuits, [run_config]) + experiments = [] + pulse_library = {} + for exp, lib in experiments_and_pulse_libs: + experiments.append(exp) + if lib: + pulse_library.update(lib) + if pulse_library: + qobj_config.pulse_library = [PulseLibraryItem(name=name, samples=samples) + for name, samples in pulse_library.items()] + experiments, calibrations = _extract_common_calibrations(experiments) + if calibrations and calibrations.gates: + qobj_config.calibrations = calibrations return QasmQobj(qobj_id=qobj_id, config=qobj_config, diff --git a/qiskit/compiler/assemble.py b/qiskit/compiler/assemble.py index d61249a6124b..f6e886bcdad3 100644 --- a/qiskit/compiler/assemble.py +++ b/qiskit/compiler/assemble.py @@ -131,7 +131,6 @@ def assemble(experiments: Union[QuantumCircuit, List[QuantumCircuit], Schedule, Raises: QiskitError: if the input cannot be interpreted as either circuits or schedules - NotImplementedError: if circuit.calibrations is not empty. """ start_time = time() experiments = experiments if isinstance(experiments, list) else [experiments] @@ -142,16 +141,8 @@ def assemble(experiments: Union[QuantumCircuit, List[QuantumCircuit], Schedule, # assemble either circuits or schedules if all(isinstance(exp, QuantumCircuit) for exp in experiments): - # calibrate circuits to schedules (if any) - for exp in experiments: - if len(exp.calibrations) != 0: - # TODO: Do something here to schedule the circuits - # Raise an error - NotImplementedError" or try to schedule by adding - # cals to inst_map and call schedule. - # if scheduled, raise a warning - "Let the user know whats happening" - raise NotImplementedError - - run_config = _parse_circuit_args(parameter_binds, **run_config_common_dict) + run_config = _parse_circuit_args(parameter_binds, backend, parametric_pulses, + **run_config_common_dict) # If circuits are parameterized, bind parameters and remove from run_config bound_experiments, run_config = _expand_parameters(circuits=experiments, @@ -352,7 +343,7 @@ def _parse_pulse_args(backend, qubit_lo_freq, meas_lo_freq, qubit_lo_range, return run_config -def _parse_circuit_args(parameter_binds, **run_config): +def _parse_circuit_args(parameter_binds, backend, parametric_pulses, **run_config): """Build a circuit RunConfig replacing unset arguments with defaults derived from the `backend`. See `assemble` for more information on the required arguments. @@ -361,9 +352,13 @@ def _parse_circuit_args(parameter_binds, **run_config): and determines the runtime environment. """ parameter_binds = parameter_binds or [] - # create run configuration and populate run_config_dict = dict(parameter_binds=parameter_binds, **run_config) + if backend: + run_config_dict['parametric_pulses'] = getattr(backend.configuration(), 'parametric_pulses', + []) + if parametric_pulses: + run_config_dict['parametric_pulses'] = parametric_pulses run_config = RunConfig(**{k: v for k, v in run_config_dict.items() if v is not None}) return run_config diff --git a/qiskit/qobj/qasm_qobj.py b/qiskit/qobj/qasm_qobj.py index 3429f99c8261..c6f08b01696b 100644 --- a/qiskit/qobj/qasm_qobj.py +++ b/qiskit/qobj/qasm_qobj.py @@ -424,6 +424,10 @@ def __init__(self, name, qubits, params, instructions): self.params = params self.instructions = instructions + def __hash__(self): + return hash((self.name, tuple(self.qubits), tuple(self.params), + tuple(str(inst) for inst in self.instructions))) + def to_dict(self): """Return a dictionary format representation of the Gate Calibration. diff --git a/releasenotes/notes/pulse-gate-calibrations-78fd3fa5a5328761.yaml b/releasenotes/notes/pulse-gate-calibrations-78fd3fa5a5328761.yaml index e501057a1eb0..bd482f87692a 100644 --- a/releasenotes/notes/pulse-gate-calibrations-78fd3fa5a5328761.yaml +++ b/releasenotes/notes/pulse-gate-calibrations-78fd3fa5a5328761.yaml @@ -34,3 +34,8 @@ features: # Register the schedule to the gate circ.add_calibration('h', [0], custom_h_schedule) # or gate.name string to register circ.add_calibration(RxGate(3.14), [0], q1_x180) # Can accept gate + + Previously, this functionality could only be capitalized on through complete Pulse + Schedules. With updates to the QASM Qobj and the assembler, jobs can now be sent as + circuit jobs, augmented with calibration libraries to communicate your custom definitions + to the backend. diff --git a/test/python/compiler/test_assembler.py b/test/python/compiler/test_assembler.py index 994ef8674a9b..783aca8930c2 100644 --- a/test/python/compiler/test_assembler.py +++ b/test/python/compiler/test_assembler.py @@ -19,7 +19,7 @@ import numpy as np import qiskit.pulse as pulse -from qiskit.circuit import Instruction, Parameter +from qiskit.circuit import Instruction, Gate, Parameter from qiskit.circuit import QuantumRegister, ClassicalRegister, QuantumCircuit from qiskit.compiler.assemble import assemble from qiskit.exceptions import QiskitError @@ -35,6 +35,17 @@ from qiskit.validation.jsonschema import SchemaValidationError +class RxGate(Gate): + """Used to test custom gate assembly. + + Useful for testing pulse gates with parameters, as well. + Note: Parallel maps (e.g., in assemble_circuits) pickle their input, + so circuit features have to be defined top level. + """ + def __init__(self, theta): + super().__init__('rxtheta', 1, [theta]) + + class TestCircuitAssembler(QiskitTestCase): """Tests for assembling circuits to qobj.""" @@ -353,6 +364,97 @@ def test_circuit_with_global_phase(self): self.assertEqual(getattr(qobj.experiments[0].header, 'global_phase'), .3 * np.pi) + def test_pulse_gates_single_circ(self): + """Test that we can add calibrations to circuits.""" + theta = Parameter('theta') + circ = QuantumCircuit(2) + circ.h(0) + circ.append(RxGate(3.14), [0]) + circ.append(RxGate(theta), [1]) + circ = circ.assign_parameters({theta: 3.14}) + + with pulse.build() as custom_h_schedule: + pulse.play(pulse.library.Drag(50, 0.15, 4, 2), pulse.DriveChannel(0)) + + with pulse.build() as x180: + pulse.play(pulse.library.Gaussian(50, 0.2, 5), pulse.DriveChannel(1)) + + circ.add_calibration('h', [0], custom_h_schedule) + circ.add_calibration(RxGate(3.14), [0], x180) + circ.add_calibration(RxGate(3.14), [1], x180) + + qobj = assemble(circ, FakeOpenPulse2Q()) + # Only one circuit, so everything is stored at the job level + cals = qobj.config.calibrations + lib = qobj.config.pulse_library + self.assertFalse(hasattr(qobj.experiments[0].config, 'calibrations')) + self.assertEqual([gate.name == 'rxtheta' for gate in cals.gates].count(True), 2) + self.assertEqual([gate.name == 'h' for gate in cals.gates].count(True), 1) + self.assertEqual(len(lib), 2) + self.assertTrue(all(len(item.samples) == 50 for item in lib)) + + def test_pulse_gates_with_parameteric_pulses(self): + """Test that pulse gates are assembled efficiently for backends that enable + parametric pulses. + """ + with pulse.build() as custom_h_schedule: + pulse.play(pulse.library.Drag(50, 0.15, 4, 2), pulse.DriveChannel(0)) + + circ = QuantumCircuit(2) + circ.h(0) + circ.add_calibration('h', [0], custom_h_schedule) + + backend = FakeOpenPulse2Q() + backend.configuration().parametric_pulses = ['drag'] + qobj = assemble(circ, backend) + self.assertFalse(hasattr(qobj.config, 'pulse_library')) + self.assertTrue(hasattr(qobj.config, 'calibrations')) + + def test_pulse_gates_multiple_circuits(self): + """Test one circuit with cals and another without.""" + with pulse.build() as dummy_sched: + pulse.play(pulse.library.Drag(50, 0.15, 4, 2), pulse.DriveChannel(0)) + + circ = QuantumCircuit(2) + circ.h(0) + circ.append(RxGate(3.14), [1]) + circ.add_calibration('h', [0], dummy_sched) + circ.add_calibration(RxGate(3.14), [1], dummy_sched) + + circ2 = QuantumCircuit(2) + circ2.h(0) + + qobj = assemble([circ, circ2], FakeOpenPulse2Q()) + self.assertEqual(len(qobj.config.pulse_library), 1) + self.assertEqual(len(qobj.experiments[0].config.calibrations.gates), 2) + self.assertFalse(hasattr(qobj.config, 'calibrations')) + self.assertFalse(hasattr(qobj.experiments[1].config, 'calibrations')) + + def test_pulse_gates_common_cals(self): + """Test that common calibrations are added at the top level.""" + with pulse.build() as dummy_sched: + pulse.play(pulse.library.Drag(50, 0.15, 4, 2), pulse.DriveChannel(0)) + + circ = QuantumCircuit(2) + circ.h(0) + circ.append(RxGate(3.14), [1]) + circ.add_calibration('h', [0], dummy_sched) + circ.add_calibration(RxGate(3.14), [1], dummy_sched) + + circ2 = QuantumCircuit(2) + circ2.h(0) + circ2.add_calibration(RxGate(3.14), [1], dummy_sched) + + qobj = assemble([circ, circ2], FakeOpenPulse2Q()) + # Identical pulses are only added once + self.assertEqual(len(qobj.config.pulse_library), 1) + # Identical calibrations are only added once + self.assertEqual(qobj.config.calibrations.gates[0].name, 'rxtheta') + self.assertEqual(qobj.config.calibrations.gates[0].params, [3.14]) + self.assertEqual(qobj.config.calibrations.gates[0].qubits, [1]) + self.assertEqual(len(qobj.experiments[0].config.calibrations.gates), 1) + self.assertFalse(hasattr(qobj.experiments[1].config, 'calibrations')) + class TestPulseAssembler(QiskitTestCase): """Tests for assembling schedules to qobj."""