See also

A Jupyter notebook version of this tutorial can be downloaded here.

Double Buffering (Preloading)#

In this tutorial, we will demonstrate the Double Buffering (or preloading) feature. This feature allows you to upload and assemble a new sequence while a sequencer is still running an existing one.

By overlapping the preparation of the next experiment with the execution of the current one, you can significantly reduce the “dead time” between runs.

To enable this feature, the immediate_asm_install parameter must be set to False during the sequence upload. This tells the instrument to prepare the program in the background without stopping the sequencer.

The general workflow for Double Buffering is:

  1. Start Sequence A.

  2. Preload Sequence B (with immediate_asm_install=False) while Sequence A is still running.

  3. Wait for Sequence A to finish.

  4. Arm and Start Sequence B. Because it was already prepared in the background, it is ready to run immediately.

When does Double Buffering help?#

The speedup from double buffering depends on the payload size relative to the sequence runtime. Each call to update_sequences() incurs an overhead cost that scales with the size of the payload — the number of waveforms, the length of the Q1ASM program, and the number of sequencers targeted. Double buffering hides this cost by performing the preparation during the previous sequence’s playback.

As a rule of thumb:

  • Small payloads (no waveforms, short programs, few sequencers): the overhead is small, and the speedup is modest.

  • Large payloads (multiple waveforms, many instructions, many sequencers): the overhead grows significantly, and double buffering can hide all of it.

  • The benefit is maximized when the sequence runtime is longer than the preparation overhead.

  • For sequences with a long runtime, the overhead is a small fraction of total iteration time, so the percentage speedup diminishes even though the absolute time saved remains constant.

Setup#

First, we import the required packages.

[1]:
from __future__ import annotations

import time

import scipy
from qcodes.instrument import find_or_create_instrument

from qblox_instruments import Cluster, ClusterType

Scan For Clusters#

We scan for the available devices connected via ethernet using the Plug & Play functionality of the Qblox Instruments package (see Plug & Play for more info).

!qblox-pnp list

[2]:
cluster_ip = "10.10.200.42"
cluster_name = "cluster0"

Connect to Cluster#

We now make a connection with the Cluster.

[3]:
cluster: Cluster = find_or_create_instrument(
    Cluster,
    recreate=True,
    name=cluster_name,
    identifier=cluster_ip,
    dummy_cfg=(
        {
            2: ClusterType.CLUSTER_QCM,
            4: ClusterType.CLUSTER_QRM,
            6: ClusterType.CLUSTER_QCM_RF,
            8: ClusterType.CLUSTER_QRM_RF,
            10: ClusterType.CLUSTER_QTM,
            12: ClusterType.CLUSTER_QRC,
            16: ClusterType.CLUSTER_QSM,
        }
        if cluster_ip is None
        else None
    ),
)

cluster.reset()
print(cluster.get_system_status())
Status: OKAY, Flags: NONE, Slot flags: NONE

Get connected modules#

[4]:
# QCM baseband modules
modules = cluster.get_connected_modules(lambda mod: mod.is_qcm_type and not mod.is_rf_type)
# This uses the module of the correct type with the lowest slot index
module = list(modules.values())[0]

Preparation#

We prepare a payload with multiple waveforms and a long program to demonstrate a realistic use case where double buffering provides a clear speedup. This simulates a parameter sweep where each iteration uploads new waveform data and a non-trivial Q1ASM program to multiple sequencers.

[5]:
# Define sequencers to target: (slot_index, sequencer_index)
seq_targets = [
    (module.slot_idx, 0),
    (module.slot_idx, 1),
    (module.slot_idx, 2),
    (module.slot_idx, 3),
    (module.slot_idx, 4),
    (module.slot_idx, 5),
]
[6]:
# Waveform parameters
waveform_length = 1600  # samples

# Create waveforms
waveforms = {
    "gaussian": {
        "data": scipy.signal.windows.gaussian(waveform_length, std=0.12 * waveform_length).tolist(),
        "index": 0,
    },
}

# Integration weights
weights = {
    "weight_gaussian": {
        "data": scipy.signal.windows.gaussian(waveform_length, std=0.12 * waveform_length).tolist(),
        "index": 0,
    },
}
[7]:
module.disconnect_outputs()

module.sequencer0.mod_en_awg(True)

# module.sequencer0.connect_sequencer("out0_1")
module.sequencer0.connect_out0("I")
module.sequencer0.connect_out1("Q")
[8]:
# Enable synchronization for all sequencers
module.sequencer0.sync_en(True)
module.sequencer1.sync_en(True)
module.sequencer2.sync_en(True)
module.sequencer3.sync_en(True)
module.sequencer4.sync_en(True)
module.sequencer5.sync_en(True)
[9]:
# Benchmark parameters
iterations = 50
sequence_length_ms = 50
[10]:
# We can use many nop commands to simulate a large Q1ASM program
nop_block = "\n    ".join(["nop"] * 2000)
sequence = {
    "waveforms": waveforms,
    "weights": weights,
    "acquisitions": {},
    "program": f"""
    move      {sequence_length_ms * 100}, R0
    {nop_block}
wait_loop:
    wait      10000
    loop      R0, @wait_loop
    stop
""",
}

sequences = {target: sequence for target in seq_targets}
print(f"Sequence estimated runtime: {sequence_length_ms} ms")
print(f"Iterations: {iterations}")
print(f"Sequencers: {len(seq_targets)}")
Sequence estimated runtime: 50 ms
Iterations: 50
Sequencers: 6

Blocking (Non-Buffered) Approach#

In a blocking workflow, you upload and assemble the sequence before starting it. The sequencer hardware sits idle during the upload and preparation phase. Over many iterations, this dead time accumulates.

[11]:
print(f"Blocking: Starting {iterations} iterations...")
t_start_blocking = time.perf_counter()

for i in range(iterations):
    # 1. Upload and install immediately (hardware is idle during this step)
    cluster.update_sequences(sequences=sequences, immediate_asm_install=True, erase_existing=True)

    # 2. Arm and Start
    cluster.arm_sequencers(sequencers=seq_targets)
    cluster.start_sequencers(sequencers=seq_targets)

    # 3. Wait for sequence to finish before looping
    cluster.wait_for_sequencers(sequencers=seq_targets, timeout=1)

t_blocking = time.perf_counter() - t_start_blocking
print(f"Blocking approach total time: {t_blocking:.2f} seconds")
Blocking: Starting 50 iterations...
Blocking approach total time: 11.65 seconds

Double Buffering (Preloading) Approach#

With Double Buffering, we preload the next sequence while the hardware is still executing the current one. By setting immediate_asm_install=False, the instrument uploads and prepares the program in the background. When the current sequence finishes, and we arm the next one, it is ready to run immediately.

[12]:
print(f"\nDouble Buffering: Starting {iterations} iterations...")
t_start_buffered = time.perf_counter()

# 1. Initial upload: install immediately so the first arm is ready without delay.
cluster.update_sequences(sequences=sequences, immediate_asm_install=True, erase_existing=True)

for i in range(iterations):
    # 2. Arm and Start the current sequence
    cluster.arm_sequencers(sequencers=seq_targets)
    cluster.start_sequencers(sequencers=seq_targets)

    # 3. Preload the next sequence while the hardware is running
    if i < iterations - 1:
        cluster.update_sequences(
            sequences=sequences, immediate_asm_install=False, erase_existing=True
        )

    # 4. Wait for the current sequence to finish
    cluster.wait_for_sequencers(sequencers=seq_targets, timeout=1)

t_buffered = time.perf_counter() - t_start_buffered
print(f"Double Buffering approach total time: {t_buffered:.2f} seconds")

# Stop sequencers
cluster.stop_sequencer()

Double Buffering: Starting 50 iterations...
Double Buffering approach total time: 8.97 seconds

Comparison#

[13]:
speedup_percentage = (1 - t_buffered / t_blocking) * 100
time_saved = t_blocking - t_buffered
print(f"Blocking total:         {t_blocking:.2f} s")
print(f"Double Buffered total:  {t_buffered:.2f} s")
print(f"Time saved:             {time_saved:.2f} s ({speedup_percentage:.1f}%)")
print(f"Time saved per iter:    {time_saved / iterations * 1000:.1f} ms")
Blocking total:         11.65 s
Double Buffered total:  8.97 s
Time saved:             2.67 s (23.0%)
Time saved per iter:    53.5 ms

The speedup comes from hiding the sequence preparation cost behind active playback. In the blocking approach, the hardware sits idle while each sequence is uploaded and prepared. With double buffering, this work happens concurrently with execution, so the next sequence is ready to arm and start as soon as the current one finishes.

Stop#

Finally, let’s stop the sequencers if they haven’t already and close the instrument connection. One can also display a detailed snapshot containing the instrument parameters before closing the connection by uncommenting the corresponding lines.

[14]:
# Stop all sequencers.
module.stop_sequencer()

# Print status of sequencers 0 and 1 (should now say it is stopped).
print(module.get_sequencer_status(0))
print(module.get_sequencer_status(1))
print()
Status: OKAY, State: STOPPED, Exit Code: 0, Info Flags: FORCED_STOP, Warning Flags: NONE, Error Flags: NONE, Log: []
Status: OKAY, State: STOPPED, Exit Code: 0, Info Flags: FORCED_STOP, Warning Flags: NONE, Error Flags: NONE, Log: []