Skip to main contentIBM Quantum Documentation Preview

Pulse schedules


Overview

Most quantum algorithms can be described with circuit operations alone. When you need more control over the low-level program implementation, you can use pulse gates. Pulse gates remove the constraint of executing circuits with basis gates only and let you override the default implementation of any basis gate.

Pulse gates let you map a logical circuit gate (for example, X) to a Qiskit Pulse program, called a ScheduleBlock. This mapping is referred to as a calibration. A high-fidelity calibration is one that faithfully implements the logical operation it is mapped from (for example, whether the X gate calibration drives 0|0\rangle to 1|1\rangle).

A schedule specifies the exact time dynamics of the input signals across all input channels to the device. There are usually multiple channels per qubit, such as drive and measure. This interface is more powerful, and requires a deeper understanding of the underlying device physics.

It's important to note that pulse programs operate on physical qubits. A drive pulse on qubit aa does not enact the same logical operation on the state of qubit bb. In other words, gate calibrations are not interchangeable across qubits. This is in contrast to the circuit level, where an X gate is defined independently of its qubit operand.

This page shows you how to add a calibration to your circuit.

Note: Not all providers support pulse gates.

Build your circuit

Let's start with a very simple example, a Bell state circuit.

[1] :
from qiskit import QuantumCircuit
 
circ = QuantumCircuit(2, 2)
circ.h(0)
circ.cx(0, 1)
circ.measure(0, 0)
circ.measure(1, 1)
 
circ.draw('mpl')

Output:

Build your calibrations

Define a calibration for the Hadamard gate on qubit 0.

In practice, the pulse shape and its parameters would be optimized through a series of calibration experiments. For this demonstration, the Hadamard will be a Gaussian pulse. You play the pulse on the drive channel of qubit 0.

For more information on calibrations, see the Qiskit Experiments tutorial.(opens in a new tab)

[2] :
from qiskit import pulse
from qiskit.pulse.library import Gaussian
from qiskit_ibm_runtime.fake_provider import FakeValenciaV2
 
backend = FakeValenciaV2()
 
with pulse.build(backend, name='hadamard') as h_q0:
    pulse.play(Gaussian(duration=128, amp=0.1, sigma=16), pulse.drive_channel(0))

Let's draw the new schedule to see what we've built.

[3] :
h_q0.draw()

Output:

All that remains is to complete the registration. The circuit method add_calibration needs information about the gate and a reference to the schedule to complete the mapping:

QuantumCircuit.add_calibration(gate, qubits, schedule, parameters)

The gate can be either a circuit.Gate object or the name of the gate. Usually, you'll need a different schedule for each unique set of qubits and parameters. Since the Hadamard gate doesn't have any parameters, there is no need to supply any.

[4] :
circ.add_calibration('h', [0], h_q0)

Lastly, note that the transpiler will respect your calibrations. Use it as you normally would (our example is too simple for the transpiler to optimize, so the output is the same).

[5] :
from qiskit_ibm_runtime.fake_provider import FakeHanoiV2
from qiskit.transpiler.preset_passmanagers import generate_preset_pass_manager
 
backend = FakeHanoiV2()
passmanager = generate_preset_pass_manager(optimization_level=1, backend=backend)
circ = passmanager.run(circ)
 
# Print instructions that only affect qubits 0 and 1
for instruction, qubits in FakeHanoiV2().instructions:
    if qubits and set(qubits).issubset({0, 1}):
        print(instruction, qubits)
circ.draw('mpl', idle_wires=False)

Output:

Instruction(name='cx', num_qubits=2, num_clbits=0, params=[]) (0, 1)
Instruction(name='cx', num_qubits=2, num_clbits=0, params=[]) (1, 0)
Instruction(name='id', num_qubits=1, num_clbits=0, params=[]) (0,)
Instruction(name='id', num_qubits=1, num_clbits=0, params=[]) (1,)
Instruction(name='sx', num_qubits=1, num_clbits=0, params=[]) (0,)
Instruction(name='sx', num_qubits=1, num_clbits=0, params=[]) (1,)
Instruction(name='reset', num_qubits=1, num_clbits=0, params=[]) (0,)
Instruction(name='reset', num_qubits=1, num_clbits=0, params=[]) (1,)
Delay(duration=t[unit=dt]) (0,)
Delay(duration=t[unit=dt]) (1,)
Instruction(name='rz', num_qubits=1, num_clbits=0, params=[Parameter(λ)]) (0,)
Instruction(name='rz', num_qubits=1, num_clbits=0, params=[Parameter(λ)]) (1,)
Instruction(name='x', num_qubits=1, num_clbits=0, params=[]) (0,)
Instruction(name='x', num_qubits=1, num_clbits=0, params=[]) (1,)
Instruction(name='measure', num_qubits=1, num_clbits=1, params=[]) (0,)
Instruction(name='measure', num_qubits=1, num_clbits=1, params=[]) (1,)

Notice that h is not a basis gate for the mock backend FakeHanoiV2. Since you added a calibration for it, the transpiler will treat the gate as a basis gate, but only on the qubits for which it was defined. A Hadamard applied to a different qubit would be unrolled to the basis gates.

Custom gates

This demonstrates the same process for nonstandard, completely custom gates, including a gate with parameters.

[6] :
from qiskit import QuantumCircuit
from qiskit.circuit import Gate
 
circ = QuantumCircuit(1, 1)
custom_gate = Gate('my_custom_gate', 1, [3.14, 1])
# 3.14 is an arbitrary parameter for demonstration
circ.append(custom_gate, [0])
circ.measure(0, 0)
 
circ.draw('mpl')

Output:

[7] :
with pulse.build(backend, name='custom') as my_schedule:
    pulse.play(Gaussian(duration=64, amp=0.2, sigma=8), pulse.drive_channel(0))
 
circ.add_calibration('my_custom_gate', [0], my_schedule, [3.14, 1])
# Alternatively: circ.add_calibration(custom_gate, [0], my_schedule)

If you use the Gate instance variable custom_gate to add the calibration, the parameters are derived from that instance. Remember that the order of parameters is significant.

[8] :
circ = passmanager.run(circ)
circ.draw('mpl', idle_wires=False)

Output:

Normally, if you tried to transpile circ, you would get an error. There was no functional definition provided for "my_custom_gate", so the transpiler can't unroll it to the basis gate set of the target device. You can show this by trying to add "my_custom_gate" to another qubit that hasn't been calibrated.

[9] :
circ = QuantumCircuit(2, 2)
circ.append(custom_gate, [1])
 
 
from qiskit import QiskitError
try:
    circ = passmanager.run(circ)
except QiskitError as e:
    print(e)

Output:

"HighLevelSynthesis was unable to synthesize Instruction(name='my_custom_gate', num_qubits=1, num_clbits=0, params=[3.14, 1])."

To link a custom gate to your circuits, you can also add to Target and transpile. A pass manager pass implicitly extracts calibration data from the target and calls add_calibration. This is convenient if you need to attach a calibration to multiple circuits or manage multiple calibrations.

[10] :
from qiskit_ibm_runtime.fake_provider import FakeKyoto
from qiskit.circuit import QuantumCircuit, Gate
from qiskit.pulse import builder, DriveChannel
from qiskit.transpiler import InstructionProperties
 
backend = FakeKyoto()
 
custom_gate = Gate("my_gate", 1, [])
qc = QuantumCircuit(1, 1)
qc.append(custom_gate, [0])
qc.measure(0, 0)
 
with builder.build() as custom_sched_q0:
    builder.play([0.1] * 160, DriveChannel(0))
 
backend.target.add_instruction(
    custom_gate,
    {(0,): InstructionProperties(calibration=custom_sched_q0)},
)
 
# Re-generate the passmanager with the new backend target
passmanager = generate_preset_pass_manager(optimization_level=1, backend=backend)
qc = passmanager.run(qc)

Build pulse schedules

Pulse gates define a low-level, exact representation for a circuit gate. A single operation can be implemented with a pulse program, which is comprised of multiple low-level instructions. Regardless of how the program is used, the syntax for building the program is the same.

Important: For IBM® devices, pulse programs are used as subroutines to describe gates. IBM devices do not accept full programs in this format.

A pulse program, which is called a ScheduleBlock, describes instruction sequences for the control electronics. Use the Pulse Builder to build a ScheduleBlock, then initialize a schedule:

[11] :
from qiskit import pulse
 
with pulse.build(name='my_example') as my_program:
    # Add instructions here
    pass
 
my_program

Output:

ScheduleBlock(, name="my_example", transform=AlignLeft())

You can see that there are no instructions yet. The next section explains each of the instructions you might add to a schedule, and the last section will describe various alignment contexts, which determine how instructions are placed in time relative to one another.

ScheduleBlock Instructions

Each instruction type has its own set of operands. As you can see above, they each include at least one Channel to specify where the instruction will be applied.

Channels are labels for signal lines from the control hardware to the quantum chip.

  • A DriveChannel is typically used for driving single-qubit rotations.
  • A ControlChannel is typically used for multi-qubit gates or additional drive lines for tunable qubits.
  • A MeasureChannel is specific to transmitting pulses that stimulate readout.
  • An AcquireChannel is used to trigger digitizers which collect readout signals.

DriveChannels, ControlChannels, and MeasureChannels are all PulseChannels; this means that they support transmitting pulses, whereas the AcquireChannel is a receive channel only and cannot play waveforms.

In the following examples, you can create one DriveChannel instance for each Instruction that accepts a PulseChannel. Channels take one integer index argument. Except for ControlChannels, the index maps trivially to the qubit label.

[12] :
from qiskit.pulse import DriveChannel
 
channel = DriveChannel(0)

The pulse ScheduleBlock is independent of the backend it runs on. However, you can build your program in a context that is aware of the target backend by supplying it to pulse.build. When possible you should supply a backend. By using the channel accessors pulse.<type>_channel(<idx>) you ensure you are only using available device resources.

[13] :
from qiskit_ibm_runtime.fake_provider import FakeValenciaV2
 
backend = FakeValenciaV2()
 
with pulse.build(backend=backend, name='backend_aware') as backend_aware_program:
    channel = pulse.drive_channel(0)
    print(pulse.num_qubits())
    # Raises an error as backend only has 5 qubits
    #pulse.drive_channel(100)

Output:

5

delay

One of the simplest instructions is delay. This is a blocking instruction that tells the control electronics to output no signal on the given channel for the duration specified. It is useful for controlling the timing of other instructions.

The duration here and elsewhere is in terms of the backend's cycle time (1 / sample rate), dt. It must take an integer value.

To add a delay instruction, pass a duration and a channel, where channel can be any kind of channel, including AcquireChannel. Use pulse.build to begin a Pulse Builder context. This automatically schedules the delay into the schedule delay_5dt.

[14] :
with pulse.build(backend) as delay_5dt:
    pulse.delay(5, channel)

Any instruction added after this delay on the same channel will execute five timesteps later than it would have without this delay.

play

The play instruction is responsible for executing pulses. It's straightforward to add a play instruction:

with pulse.build() as sched:
    pulse.play(pulse, channel)

Let's clarify what the pulse argument is and explore a few different ways to build one.

Pulses

A Pulse specifies an arbitrary pulse envelope. The modulation frequency and phase of the output waveform are controlled by the set_frequency and shift_phase instructions.

There are many methods available for building pulses, such as those available in the Qiskit Pulse library. Take for example a simple Gaussian pulse -- a pulse with its envelope described by a sampled Gaussian function. We arbitrarily choose an amplitude of 1, standard deviation σ\sigma of 10, and 128 sample points.

Note: The amplitude norm is arbitrarily limited to 1.0. Each backend may also impose further constraints. For instance, a minimum pulse size of 64. Any additional constraints are provided through Target.

[15] :
from qiskit.pulse import library
 
amp = 1
sigma = 10
num_samples = 128
Parametric pulses

You can build a Gaussian pulse by using the Gaussian parametric pulse. A parametric pulse sends the name of the function and its parameters to the backend, rather than every individual sample. Using parametric pulses makes the jobs much smaller to send. IBM Quantum backends limit the maximum job size that they accept, so parametric pulses might allow you to run larger programs.

Other parametric pulses in the library include GaussianSquare, Drag, and Constant. See the full list in the API reference.

Note: The backend is responsible for deciding how to sample the parametric pulses. It is possible to draw parametric pulses, but the samples displayed are not guaranteed to be the same as those executed on the backend.

[16] :
gaussian = pulse.library.Gaussian(num_samples, amp, sigma,
                              name="Parametric Gaussian")
gaussian.draw()

Output:

Pulse waveforms described by samples

A Waveform is a pulse signal specified as an array of time-ordered complex amplitudes, or samples. Each sample is played for one cycle, a timestep dt, determined by the backend. You must know the value of dt to determine a program's real-time dynamics. The (zero-indexed) ithi^{th} sample plays from time i*dt up to (i + 1)*dt, modulated by the qubit frequency.

[17] :
import numpy as np
 
times = np.arange(num_samples)
gaussian_samples = np.exp(-1/2 *((times - num_samples / 2) ** 2 / sigma**2))
 
gaussian = library.Waveform(gaussian_samples, name="WF Gaussian")
gaussian.draw()

Output:

Regardless of which method you use to specify your pulse, play is added to your schedule the same way:

[18] :
with pulse.build() as schedule:
    pulse.play(gaussian, channel)
schedule.draw()

Output:

You may also supply a complex list or array directly to play.

[19] :
with pulse.build() as schedule:
    pulse.play([0.001*i for i in range(160)], channel)
schedule.draw()

Output:

The play instruction gets its duration from its Pulse: the duration of a parametrized pulse is an explicit argument, and the duration of a Waveform is the number of input samples.

set_frequency

As explained previously, the output pulse waveform envelope is also modulated by a frequency and phase. Each channel has a default frequency listed in the backend.defaults.

A channel's frequency can be updated at any time within a ScheduleBlock by the set_frequency instruction. It takes a float frequency and a PulseChannel channel as input. All pulses on a channel following a set_frequency instruction are modulated by the given frequency until another set_frequency instruction is encountered or until the program ends.

The instruction has an implicit duration of 0.

Note: The frequencies that can be requested are limited by the total bandwidth and the instantaneous bandwidth of each hardware channel. In the future, these will be reported by the backend.

[20] :
with pulse.build(backend) as schedule:
    pulse.set_frequency(4.5e9, channel)

shift_frequency

The shift_frequency instruction shifts the frequency of a pulse channel.

[21] :
d0 = pulse.DriveChannel(0)
 
with pulse.build() as pulse_prog:
    pulse.shift_frequency(1e9, d0)
Note

The shift_frequency and set_frequency instructions change the frequency of following pulses and also change the channel's frame of reference. Because a qubit oscillates at its transition frequency, the controller needs to sync with its oscillation; otherwise, an unwanted Z drive is continuously applied. Usually, because the frame is matched with the drive's frequency, and drive matches with the transition's frequency, the Z drive is eliminated when the qubit frequency is calibrated properly. When you apply the shift_frequency instruction, it changes the drive frequency and impacts the frame. In other words, it accumulates the phase (Z) as a function of shifted frequency and duration of the program. Specifically, when you shift the frequency by df and spend dt on that frame, the qubit may experience a phase rotation of df * dt. The programmer needs to take this into account to control their qubits precisely.

Note also that these instructions are localized in the pulse gate in IBM devices. This means that accumulated phase and frequency shifts are not carried over. Each pulse gate always starts from the hardware default setting. This behavior is backend-dependent.

set_phase

The set_phase instruction sets the phase of a pulse channel.

[22] :
d0 = pulse.DriveChannel(0)
 
with pulse.build() as pulse_prog:
    pulse.set_phase(np.pi, d0)

shift_phase

The shift_phase instruction will increase the phase of the frequency modulation by phase. Like set_frequency, this phase shift will affect all following instructions on the same channel until the program ends. To undo the affect of a shift_phase, the negative phase can be passed to a new instruction.

Like set_frequency, the instruction has an implicit duration of 0.

[23] :
with pulse.build(backend) as schedule:
    pulse.shift_phase(np.pi, channel)

acquire

The acquire instruction triggers data acquisition for readout. It takes a duration, an AcquireChannel, which maps to the qubit being measured, and a MemorySlot or a RegisterSlot. The MemorySlot is classical memory where the readout result will be stored. The RegisterSlot maps to a register in the control electronics that stores the readout result for fast feedback.

The acquire instruction can also take custom Discriminators and Kernels as keyword arguments. The Kernel subroutine integrates a time series of measurement responses and generates an IQ data point, which will be classified into a quantum state by the discriminator. This indicates that if you use a custom measurement stimulus, as in a measurement pulse, you might need to update the kernel setting to not deteriorate the measurement SNR.

[24] :
from qiskit.pulse import Acquire, AcquireChannel, MemorySlot
 
with pulse.build(backend) as schedule:
    pulse.acquire(1200, pulse.acquire_channel(0), MemorySlot(0))

After adding ScheduleBlock instructions, you need to understand how to control when they're played.


Pulse Builder

Below are the most important Pulse Builder features for learning how to build schedules. This is not an exhaustive list. For more details about using the Pulse Builder, refer to the Pulse API reference.

Alignment contexts

The builder has alignment contexts that influence how a schedule is built. Contexts can also be nested. Try them out, and use .draw() to see how the pulses are aligned.

Regardless of the alignment context, the duration of the resulting schedule is as short as it can be while including every instruction and following the alignment rules. This still allows some degrees of freedom for scheduling instructions off the "longest path". The examples below illustrate this.

align_left

The builder has alignment contexts that influence how a schedule is built. The default is align_left.

[25] :
with pulse.build(backend, name='Left align example') as program:
    with pulse.align_left():
        gaussian_pulse = library.Gaussian(100, 0.5, 20)
        pulse.play(gaussian_pulse, pulse.drive_channel(0))
        pulse.play(gaussian_pulse, pulse.drive_channel(1))
        pulse.play(gaussian_pulse, pulse.drive_channel(1))
 
program.draw()

Output:

Notice how there is no scheduling freedom for the pulses on D1. The second waveform begins immediately after the first. The pulse on D0 can start at any time between t=0 and t=100 without changing the duration of the overall schedule. The align_left context sets the start time of this pulse to t=0. You can think of this like left-justification of a text document.

align_right

align_right does the opposite of align_left. It chooses t=100 in the above example to begin the Gaussian pulse on D0. Left and right are also sometimes called "as soon as possible" and "as late as possible" scheduling, respectively.

[26] :
with pulse.build(backend, name='Right align example') as program:
    with pulse.align_right():
        gaussian_pulse = library.Gaussian(100, 0.5, 20)
        pulse.play(gaussian_pulse, pulse.drive_channel(0))
        pulse.play(gaussian_pulse, pulse.drive_channel(1))
        pulse.play(gaussian_pulse, pulse.drive_channel(1))
 
program.draw()

Output:

align_equispaced(duration)

If the duration of a particular block is known, you can also use align_equispaced to insert equal duration delays between each instruction.

[27] :
with pulse.build(backend, name='example') as program:
    gaussian_pulse = library.Gaussian(100, 0.5, 20)
    with pulse.align_equispaced(2*gaussian_pulse.duration):
        pulse.play(gaussian_pulse, pulse.drive_channel(0))
    pulse.play(gaussian_pulse, pulse.drive_channel(1))
    pulse.play(gaussian_pulse, pulse.drive_channel(1))
 
program.draw()

Output:

align_sequential

This alignment context does not schedule instructions in parallel. Each instruction will begin at the end of the previously added instruction.

[28] :
with pulse.build(backend, name='example') as program:
    with pulse.align_sequential():
        gaussian_pulse = library.Gaussian(100, 0.5, 20)
        pulse.play(gaussian_pulse, pulse.drive_channel(0))
        pulse.play(gaussian_pulse, pulse.drive_channel(1))
        pulse.play(gaussian_pulse, pulse.drive_channel(1))
 
program.draw()

Output:

Phase and frequency offsets

The builder can help temporarily offset the frequency or phase of pulses on a channel.

[29] :
with pulse.build(backend, name='Offset example') as program:
    with pulse.phase_offset(3.14, pulse.drive_channel(0)):
        pulse.play(gaussian_pulse, pulse.drive_channel(0))
        with pulse.frequency_offset(10e6, pulse.drive_channel(0)):
            pulse.play(gaussian_pulse, pulse.drive_channel(0))
 
program.draw()

Output:


Next steps

Recommendations