Control flow#
Complex schedules can be constructed from pulses, gates and schedules using control flow.
Sub-schedules#
A schedule can be added to another schedule just like an operation. This is roughly equivalent to a function call in python.
Such subschedules can be used e.g. to define a custom composite gate:
from qblox_scheduler import Schedule, X, Y90
def hadamard(qubit: str) -> Schedule:
hadamard_sched = Schedule("hadamard")
hadamard_sched.add(X(qubit))
hadamard_sched.add(Y90(qubit))
return hadamard_sched
my_schedule = Schedule("nice_experiment")
my_schedule.add(X("q1"))
my_schedule.add(hadamard("q1"))
Tip
If the same sequence of operations is used many times in a schedule (e.g. a specific Clifford gate in randomized benchmarking), using subschedules will yield performance benefits.
Schedules can be nested arbitrarily. Timing constraints relative to an inner schedule interpret the inner schedule as one continuous operation. It is not possible to use an operation within a subschedule as a reference for outside operations.
Loops#
Overview#
Loops are a form of unconditional control flow, allowing to iterate over a sub-part of a Schedule with or without the
modification of some parameters at each iteration.
Qblox scheduler uses context managers for an intuitive syntax to write a loop. The following simple schedule creates a series of 10 square pulses with increasing amplitude:
Syntax template for a simple loop
from qblox_scheduler import linspace, DType
schedule = Schedule()
with (
schedule.loop(
linspace(start=0.1, stop=1, num=10, dtype=DType.AMPLITUDE)
) as my_amplitude,
):
schedule.add(SquarePulse(amplitude=my_amplitude, duration=1e-6, port=<port>))
A few important syntactic rules can be understood from above example:
The context manager is opened with the Python keyword instruction
with. The operations inside the indented block underneath will be repeated.The looping operation is described by the
Schedule.loopmethod. Its principal argument is aLinearDomainobject that defines a set of values – linearly distributed – that is explored by a variable usable in the scope of the context manager.The variable referencing to the loop is introduced by the
as <var_name>specifier. This variable can be used to parametrize operations in theSchedule– as it is the case for example withSquarePulse(amplitude=my_amplitude, duration=1e-6, port=<port>), but also in acquisition for referencing a loop as an axis of parameters of the measurement: more information on that topic can be found here. It is important to understand that this variable is not a Pythonic iterator (such as a list or a tuple), but only reference to aqblox-schedulerloop, which is an independent concept. This variable object will be compiled byqblox-scheduler, and interpreted by the instrument afterwards.
It is also worth noticing that the Schedule.loop works similarly as the Schedule.add method, meaning that it had the
optional timing arguments rel_time, ref_op, ref_pt and ref_pt_new for positioning the loop block section
accurately on the time grid.
Parameters of the loop instruction
domain : The object that describes the domain to be looped over.
domains : Optional extra domains that will be looped over in parallel, in a zip-like fashion.
rel_time : Relative time between the reference operation and the added operation. The time is the time between the “ref_pt” in the reference operation and “ref_pt_new” of the operation that is added.
ref_op : Reference schedulable. If set to
None, will default to the last added operation.ref_pt : Reference point in reference operation; must be one of
"start","center","end", orNone. IfNone,_determine_absolute_timing()assumes"end".ref_pt_new : Reference point in added operation; must be one of
"start","center","end", orNone. IfNone,_determine_absolute_timing()assumes"start".strategy : Strategy to use for implementing this loop. Defaults to
Noneindicating no preference. See below for what is meant by strategy.
Real-time loops, near-time loops and compilation strategy#
A loop operation like the one presented in the Schedule above is called a “real-time” (RT) loop. It means that the
process of iterating over the sub-schedule encapsulated in the context manager, as well as updating the swept variable
is entirely handled by the instrument. In other words, once compiled and run, the instrument will execute the complete
flow of operations in the schedules (included the ones repeated in a loop), and report a resulting
xarray.Dataset to the control computer only once, after the full execution is finished.
This approach, where all the qblox-scheduler operations described in the Schedule are ultimately translated in a
sequence of elementary Q1ASM instruction (the loop one for example), has the
benefit of offering a very fast execution. Indeed, the absence of network communication (between the Qblox Cluster and
the operator’s computer) in the middle of the execution flow reduces the runtime to its minimum.
However, there are some hardware parameters that cannot be modified in real-time, as they are not controlled by the
sequencers of Qblox modules: for example, the frequency of the local oscillator (LO) of a RF module, or
hardware options such as the attenuation level applied on a given port. In that case, if a user wants
to run an experiment where some of these parameters are swept, a qblox-instruments API call changing the parameter
value needs to be emitted by the control computer at each iteration. Since the execution flow is then interleaved with
network communication, we name this type of loop “near-time” (NT) loops. Example of NT are provided in a dedicated
section below. In the case of the real time loops, two approaches are possible for translating the schedule-level
instructions (i.e. adding pulses, gates, acquisitions…) into a Q1ASM script. The chosen approach is referred as the
compilation strategy:
UNROLLED compilation strategy
Here each iteration of the loop is converted into its own set of Q1ASM instructions, that are stacked on the top of each other. At the execution the real time core of the sequencer will simply run through the generated instructions, line by line. This approach is simple, and works for sweeping over any parameter controlled by the sequencers.
The drawback is that it is very consuming regarding of Q1ASM instruction that are generated (especially in situation where several loop operations are nested). Indeed, we recall that the maximum number of instruction per Q1ASM program is 16384 for QCM-like modules, and 12288 for QRM-like modules. To overcome the instruction memory limit, Qblox scheduler introduced “Segment Mode” which allows users to divide a long schedule into multiple segments at suitable breakpoints.
ROLLED compilation strategy
Q1ASM also feature natively the
jmpinstruction, that enables a real time interpreter to perform iterations, inside to the Q1ASM script itself.qblox-schedulercan compileSchedule-level scripts into a Q1ASM program that is making use of thejmpinstruction. The benefit is that the resulting program is much efficient regarding the number of generated lines, but this approach is not yet supported for any type of parameters.Important
qblox-schedulersupports sweeping over all parameter types except time in combination with the the rolled strategy.
Comparison of the resulting Q1AMS scripts#
Let’s consider a real-case example. We will compare the result of the compilation of a same Schedule, choosing either
one strategy or the other.
We define a builder function, that generated a Schedule featuring a loop of square
pulses, whose amplitude are swept over
def amplitude_sweep_schedule_builder(
number_of_pulses: int,
duration_of_pulses: float,
start_value: float = -1.0,
stop_value: float = 1.0,
compilation_strategy: LoopStrategy | None = None,
) -> Schedule:
schedule = Schedule()
with schedule.loop(
linspace(start=start_value, stop=stop_value, num=number_of_pulses, dtype=DType.AMPLITUDE),
strategy=compilation_strategy,
) as amp:
schedule.add(SquarePulse(amplitude=amp, duration=duration_of_pulses, port="q0:out"))
schedule.add(IdlePulse(100e-9))
schedule.add(IdlePulse(1e-6))
return schedule
schedule = amplitude_sweep_schedule_builder(
1000, 100e-9, compilation_strategy=LoopStrategy.UNROLLED
)
# run the schedule
compiled_sched = hw_agent.compile(schedule)
By default, if the loop compilation strategy is set to None, the compiler will
preferably select the rolled version if it is available, otherwise fall back onto
the unrolled one.
Assuming that the hardware configuration files is in ./config_files/hw_config.json:
from pathlib import Path
from qblox_scheduler import HardwareAgent, Schedule, IdlePulse, SquarePulse, LoopStrategy, DType, linspace
# initialize hardware agent
hw_agent = HardwareAgent(hardware_config, debug=True)
hw_agent.connect_clusters()
Considering a Cluster with a QCM module in slot number 2:
hardware_config = {
"version": "0.2",
"config_type": "QbloxHardwareCompilationConfig",
"hardware_description": {
"cluster0": {
"instrument_type": "Cluster",
"ip": "<IPv4>",
"modules": {
"2": {
"instrument_type": "QCM"
}
},
"ref": "internal"
}
},
"hardware_options": {},
"connectivity": {
"graph": [
[
"cluster0.module2.complex_output_0",
"q0:out"
]
]
}
}
After compilation, the resulting Q1ASM code can be accessed from the compiled_sched.compiled_instruction object (that
one can explore interactively in a ipython kernel), or by following this attribute path:
prog = compiled_sched.compiled_instructions["cluster0"]["cluster0_module2"]["sequencers"][
"seq0"
].sequence["program"]
one can then count the corresponding number of lines with
number_of_lines = len(prog.splitlines())
print(f"\nThe number of lines is: {number_of_lines}")
and find out that the unrolled version of the program involves 5010 lines, whereas the rolled one only use 28 lines.
Swept domains#
Concept and core ideas#
Swept domains are the core arguments in the description of a loop in qblox-scheduler. Currently, qblox-scheduler
supports loops performing linear sweeps, meaning that at each iteration of the loop, the variable is incremented or
decremented by a fixed amount.
Hence, swept domains are instances of the LinearDomain class, but for convenience the qblox_scheduler
implements the linspace and arange functions that are reminiscent of numpy ones (same name and
same logic) to simplify the generation of linear domains.
For enabling correct compilation, swept domains require a dtype argument which specifies the nature of the physical
parameter that will be swept within the scope of the loop. DTypes are enums that can be loaded from qblox_scheduler.
Existing DTypes
DType.AMPLITUDE: for controlling pulses amplitude.DType.FREQUENCY: for NCO frequency sweeps.DType.TIME: for sweeping over duration (waiting time, or pulse durations),DType.NUMBER: usually used for repeating a sub-schedule without updating parameter values.DType.PHASE: for NCO phase sweeps.DType.DTYPE.DRAG_BETA: for DRAG pulsebetaparameter sweep.
Note that when different sweeps are done for different Dtypes, the required minimum run time of the schedule body inside
the loop is different to avoid an underflow error. It relates to how many Q1ASM instructions are needed to calculate and
update for the next iteration. In the example below, we have an amplitude sweep and a phase sweep for the X gates. To
avoid an underflow error, the duration of the first X gate needs to be at least 48 ns and the second X gate needs to be
at least 116 ns. The main reason why the latter takes longer lies in the fact that we use set_ph_delta when updating
the phase and reset it to prepare for the next iteration, which takes more Q1ASM instructions.
with schedule.loop(linspace(0, 1, 100, DType.AMPLITUDE)) as amp:
schedule.add(
X("q0", amplitude=amp, duration=duration_1)
) # ---> X gate duration_1 needs to be at least 48ns
with schedule.loop(linspace(0, 1, 100, DType.PHASE)) as phase:
schedule.add(
X("q0", phi=phase, duration=duration_2)
) # ---> X gate duration_2 needs to be at least 116ns
Non-linear sweeps
If one wants to execute a Schedule involving a loop running through an arbitrary list
of values, it needs to be unrolled manually using a Python for loop.
Such a loop would iterate over the list, and add explicitly new operations to the
schedule, making use of the values picked up in the list.
For example, assuming that arbitrary_list is a list of amplitude values, one could
write:
schedule = Schedule()
for amplitude in arbitrary_list:
schedule.add(SquarePulse(amplitude=amp, duration=1e-6, port=<port>))
Tip
LoopOperation
For expert users (e.g. for translating from another pulse API) sometimes it is useful to program in an
imperative mood. To this end, you can also add a
LoopOperation to the Schedule directly,
instead of using the loop context manager.
Multi-dimensional space#
“Dense” nested loops#
Loop operations can be nested in each other like Russian dolls. The resulting structure will be exploring the entire Cartesian product of swept domains: we call it a dense domain of parameters. Python syntax for context managers enables a compact writing of that type of multi-dimensional loops. For example, the Schedule below would execute a sequence of two pulses, with both of their amplitude linearly swept:
schedule = Schedule()
with (
schedule.loop(linspace(start=0.1, stop=1.0, num=5, dtype=DType.AMPLITUDE)) as amp1,
schedule.loop(linspace(start=-0.1, stop=-1.0, num=5, dtype=DType.AMPLITUDE)) as amp2,
):
schedule.add(SquarePulse(amplitude=amp1, duration=100e-9, port="q0:out"))
schedule.add(IdlePulse(50e-9))
schedule.add(
DRAGPulse(
G_amp=amp2, D_amp=0, phase=0, duration=100e-9, port="q0:out", clock="cl0.baseband"
)
)
As many swept domains as one likes can be stacked this way. The innermost loops are executed first. In the example above, a domain with 5x5=25 datapoints will be explored.
Tip
Schedules will often make use of acquisition inside the scope of a loop structure.
By default, the execution of the schedules returns a xarray.Dataset, where the
swept parameter values can be saved as coordinates, and the acquired data are formatted
in a one dimensional xarray.DataArray. This representation is convenient in the case
of sparse datasets, but not necessarily in the case of a dense domain of parameters,
where a multi-dimensional xarray.DataArray is usually preferable.
In the case of a dense xarray.Dataset, it is possible to change the representation
from 1D to ND, by using the provided function:
from qblox_scheduler import acq_coords_to_dims
More information about those techniques can be found in page
“Sparse” zipped/parallel loops#
It is also sometimes required to sweep several parameters “at the same time”, meaning that the values of several loop variables are updated in parallel at each new iteration of the loop. That means that the loop describes a one dimensional parameter sweep in a N dimensional space of parameters.
Following the Python lingo, we call that zipped loops, and qblox-scheduler allows this type of operation, by using
the following syntax:
schedule = Schedule()
with schedule.loop(
linspace(start=0.2, stop=1.0, num=20, dtype=DType.AMPLITUDE),
linspace(start=-0.2, stop=-0.5, num=20, dtype=DType.AMPLITUDE),
) as (amp1, amp2):
schedule.add(SquarePulse(amplitude=amp1, duration=100e-9, port="q0:mw", clock="cl0.baseband"))
schedule.add(IdlePulse(50e-9))
schedule.add(
DRAGPulse(G_amp=amp2, D_amp=0, phase=0, duration=100e-9, port="q0:mw", clock="cl0.baseband")
)
Important
One should pay attention to the following points:
Even though similar, the syntax is different from the dense case: in particular
Schedule.loopis called only once, and atupleof variable is returned by the context manager.For having a zipped loop that makes sense, one must pay attention to zipping
LinearDomainsthat share the exact same number of points (in above case:num=20). In the example above, a domain with 20 datapoints will be explored.Dense and zipped loop can be used in combination, meaning that the following syntax:
with ( schedule.loop(linspace(start=0.1, stop=1.0, num=5, dtype=DType.AMPLITUDE)) as amp1, schedule.loop( linspace(start=0.2, stop=1.0, num=20, dtype=DType.AMPLITUDE), linspace(start=-0.2, stop=-0.5, num=20, dtype=DType.AMPLITUDE), ) as (amp2, amp3), ): ...
is valid, and will explore a 3-dimensional domain with 5x20=100 datapoints.
Near-time loops#
The key difference with near-time loops is the fact that the iteration is carried out by the remote computer (communicating with the instrument). The loop is not executed on the qblox Cluster side. The flow of execution can be represented like this:
The big colored arrows represent network accesses, either to upload a compiled sequence to the instrument, or to download the dataset returned by the sequence execution.
Main ideas
Near-time loops are useful for performing sweeps over parameters that are not controlled in real time by the sequencer (i.e. static parameters). This is usually useful for the calibration of an experimental setup, and finding an appropriate set of static hardware parameters: for example the frequency of a local oscillator, or the power attenuation level at a module outport port. These parameters are typically set in a hardware configuration file, and are part of the
HardwareAgentattributes.The syntax of those near-time loops is actually exactly the same as the one for real-time ones. It is simply the context in which the loop reference is used that is interpreted by
qblox-schedulerto apply the appropriate behavior: real-time if possible, near-time otherwise. Users should be aware that near-time loops are typically longer to execute, since they rely on a succession of network communication between the cluster and a computer, costing tens of milliseconds each time.Network communication and remote computer side operation are not time-deterministic, meaning that each iteration of a near-time loop leads to the generation and execution of an independent time grid for the instrument.
Since the syntax is the same, near-time loop references can be used as
coordsof an acquisition in the exact same manner as for real-time loops.
To update the value of a static hardware parameter, one can use the
SetHardwareOption class. It requires three arguments:
name: corresponding to a hardware option to modify.value: Value to set the option to.port: Port/clock combination to set the option for.
Examples of use of this class is provided below:
po_sched = Schedule()
with po_sched.loop(
arange(start=att_start, stop=att_stop, step=att_step, dtype=DType.NUMBER)
) as att:
# Set output attenuation: `name` is simply a string
po_sched.add(SetHardwareOption("output_att", att, "q0:out-q0.ro"))
po_sched.add(
Measure(
"q0",
coords={"attenuation": att},
)
)
Sometimes the hardware option to update is a nested attribute. In that case the name is provided as a tuple of strings
corresponding to this nesting structure:
schedule = Schedule()
with schedule.loop(linspace(36e6, 38e6, 300, DType.FREQUENCY)) as lo_freq:
schedule.add(
SetHardwareOption(("modulation_frequencies", "lo_freq"), lo_freq, port="q0:mw-q0.f_larmor")
)
# equivalent to doing:
# hardware_config = device.generate_hardware_compilation_config()
# hardware_options = hardware_config.hardware_options
# hardware_options.modulation_frequencies["q0:mw-q0.f_larmor"].lo_freq = lo_freq
Segment mode#
When a schedule is too large to fit in the instruction memory of a sequencer, from Qblox scheduler V1.0 on, it can be
divided into smaller segments at suitable breakpoints. Users can indicate where segmentation is allowed by adding
rel_time = None, such as before a reset operation. In the example below, we use a simplified example mimicking the
randomized benchmarking sequence. The schedule body starts with a reset gate, followed by certain number of X gates
ranging from 1 to max_number_gates.
sched = Schedule("segment_mode_test")
max_num_gates = 130
repetitions = 1
with sched.loop(arange(0, repetitions, 1, DType.NUMBER)):
for n in range(max_num_gates):
sched.add(Reset(qubit.name))
for _ in range(n):
sched.add(X(qubit.name))
compiled = hw_agent.compile(sched)
If the defined max_number_gates is too large, it will give an error similar to below:
ValueError: Number of instructions (17176) compiled for 'seq0' of QCMRFCompiler 'cluster0_module2' exceeds the maximum supported number of instructions in Q1ASM programs for QCMRFCompiler (16384). Use `rel_time=None` in `Schedule.add` or `Schedule.loop` instead if possible.
To avoid such an error and still be able to run our simplified benchmarking sequence, we add rel_time = None at
designated points:
+ with sched.loop(arange(0, repetitions, 1, DType.NUMBER), rel_time = None):
for n in range(max_num_gates):
+ sched.add(Reset(qubit.name), rel_time = None)
for _ in range(n):
sched.add(X(qubit.name))
compiled = hw_agent.compile(sched)
Adding rel_time = None indicates where it would be okay to break the long sequence into smaller segments. In the
example above, they are added at the reset operation indicating it is okay to break befefore resetting the qubit. As a
result, it changes the real-time loops into near-time loops. Note that we also add rel_time = None at the
sched.loop() in the example above: if the inner schedule body becomes near-time, the schedule.loop() also needs to
be near-time.
Note
If the schedule is not very long and will not exhaust the instruction memory, it will still be executed as a complete block of instructions without breaks despite adding rel_time = None.
As adding rel_time = None will in general change a real-time loop to a near-time loop, Qblox-scheduler offers ways
to speed up the execution even if rel_time = None is added in segment mode. To be more specific, if a long schedule
has been divided into segment A and segment B, instead of running A, B, A, B, A, B, … for 100 repetitions, one can use
segment_repetitions = # in HardwareAgent.run() to run segment A for 100 times in real time on the hardware (AAA…),
and then segment B for 100 times in real time on the hardware (BBB…). Because this approach largely eliminates the
communication overhead from near-time loops between the host PC and Qblox hardware, it significantly speeds up
execution.
HardwareAgent.run(schedule, segment_repetitions=100)
If segment_repetitions is not specified, the schedule will run in the A, B, A, B, A, B… manner by default.
Arithmetic expressions inside loops using Assign#
Assign can be combined with loops to update variables using addition and subtraction.
This enables parameters to be adjusted or synced during loop execution. In the example below, the frequency is adjusted
for each iteration. Note that the variable first need to be declared via schedule.declare for it to be used in
arithmetic expressions inside loops.
from qblox_scheduler import Assign, Schedule, ClockResource, DType, SetClockFrequency, SquarePulse
schedule = Schedule("test")
schedule.add_resource(ClockResource("clock", 0, 0))
freq = schedule.declare(dtype=DType.FREQUENCY)
schedule.add(operation=Assign(variable=freq, value=0.0))
with schedule.loop(arange(0, 10, 1, dtype=DType.NUMBER)) as rep:
schedule.add(Assign(freq, freq + 10e6))
schedule.add(SetClockFrequency(clock="clock", frequency=freq), rel_time=100e-9)
schedule.add(SquarePulse(amp=0.5, duration=500e-9, port="A:B", clock="clock"))
As a result, we get 10 square pulses from 10 iterations, for which the frequency is increased by 10MHz at every iteration.
Known issues#
Schedules containing
Assigncurrently cannot be plotted using.plot_pulse_diagram().After compilation, if the resulted Q1ASM instructions contain consecutive
set_ph_deltainstructions, the resulted phase might not be as intended:set_ph_deltawrites to the latched register. If it is called twice without executing, the expected behavior is that the value will be overwritten, instead of summing up.