Control flow#

Complex schedules can be constructed from pulses, gates and schedules using control flow.

Sub-schedules#

A schedule can be added to a 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
from qblox_scheduler.operations import 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 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 from outside as reference operation.

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 qblox-scheduler loop

from qblox_scheduler.operations.loop_domains 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(amp=my_amplitude, duration=1e-6, port=<port>))

A few important syntactic rules can be understood from above example:

  • The context manager is open with the Python keyword instruction with. The operations inside the indented block underneath will be repeated.

  • The looping operation is described by the Schedule.loop method. Its principal argument is a LinearDomain object that defines a set of values – linearly distributed – that is explored by a variable usable in the scope 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 the Schedule – as it is the case for example with SquarePulse(amp=my_amplitude, duration=1e-6, port=<port>), but also in acquisition for referencing a loop as an axis of parameters of the measurement: more infos 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 a qblox-scheduler loop, which is an independent concept. This variable object will be compiled by qblox-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.

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.

  • ROLLED compilation strategy

    Q1ASM also feature natively the loop instruction, that enables a real time interpreter to perform iterations, inside to the Q1ASM script itself. qblox-scheduler can compile Schedule-level scripts into a Q1ASM program that is making use of the loop instruction. 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.

    Warning

    The current version of qblox-scheduler supports only AMPLITUDE and NCO FREQUENCY for 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(amp=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
from qblox_scheduler.operations import IdlePulse, SquarePulse
from qblox_scheduler.operations.control_flow_library import LoopStrategy
from qblox_scheduler.operations.expressions import DType
from qblox_scheduler.operations.loop_domains import linspace

# define paths for hardware and device configurations
config_dir = Path(".") / "config_files"

hw_config_path = config_dir / "hw_config_min.json"

# initialize hardware agent
hw_agent = HardwareAgent(hw_config_path, debug=True)
hw_agent.connect_clusters()

Considering a Cluster with a QCM module in slot number 2:

{
    "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 7011 lines, whereas the rolled one only use 29 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.operations.loop_domains 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.operations.expressions.

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.

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(amp=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(amp=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")
    )
    schedule.add(IdlePulse(100e-9))would

As many swept domains as one likes can be stacked this way. The innermost loops are executed first.

“Sparse” zipped loops#

It also sometimes required to sweep several parameters “at the same time”, meaning that the values of several loop variables are updated at each new iteration of the loop. That means that the 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(amp=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 shall pay attention to the following points:

  • Even though similar, the syntax is different from the dense case: in particular Schedule.loop is called only once, and a tuple of variable is returned by the context manager.

  • For having a zipped loop that make sense, one must pay attention to zipping LinearDomains that share the exact same number of points (in above case: num=20)

  • 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:

alt

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 a 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 a hardware configuration file, and are part of the HardwareAgent attributes.

  • 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-scheduler to 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 coords of 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. I 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