Using the in-sequencer PRNG with LabOne Q¶
The PRNG is a peripheral of the sequencer on HDAWG and SHFSG for generating pseudo-random numbers.
The PRNG enables to play a (deterministically) shuffled sequence of pulses or gates, while efficiently using sequencer memory. This may be particularly interesting for long randomized benchmarking sequences and similar experiments.
General notes on PRNG setup and sampling¶
In LabOne Q, usage of the PRNG can be invoked via the setup_prng()
, prng_loop()
commands. An experiment using the PRNG roughly has the following structure:
with prng_setup(seed=..., range=...) as prng: # 1.
with prng_loop(prng=prng, count=...) as prng_sample: # 2.
...
with match(prng_sample=prng_sample): # 3.
with case(0):
play(...)
with case(1):
play(...)
...
The important steps here:
We seed the PRNG and specify its range with
prng_setup()
. We can now draw random numbers from the PRNG, in the range of 0 torange - 1
.The seed and range are valid within the scope of the
prng_setup()
block. As there is only a single PRNG available per sequencer, PRNG setups cannot be nested, but we are free to reseed the PRNG again later.The actual sampling of the random numbers happens in
prng_loop()
. This block marks a section that will be executedcount
times, with a new random number drawn each time. The result of the context manager (i.e. the right-hand side ofas
, hereprng_sample
) provides us with a handle to those random numbers.It may be helpful to think of
prng_sample
as similar to a sweep parameter. Like a sweep parameter, it is representative of the values that the the variable will take during the iterations of the loop. The PRNG sample is also a convenient way to access a simulation of the PRNG values, see below.We use the PRNG sample to branch into one of multiple options. We do this with a
match
block, and providing onecase
for each value the PRNG might emit.
Note on PRNG loop iteration length¶
If the body of the PRNG loop between subsequent calls to get_sample()
is too short, the waveform play-back may contain gaps as the sequencer may become unable to issue new waveforms fast enough.
We recommend to always make the body of the PRNG loop at least 64 samples long.
0. Imports and setup¶
import numpy as np
# To use the PRNG in LabOne Q DSL, we currently require the experiment builtins
from laboneq.contrib.example_helpers.plotting.plot_helpers import plot_simulation
from laboneq.dsl.experiment import PlayPulse
from laboneq.dsl.experiment.builtins import *
from laboneq.simple import *
Create a device setup and connect to a session
device_setup = DeviceSetup(uid="my_QCCS")
device_setup.add_dataserver(host="localhost", port="8004")
device_setup.add_instruments(
SHFQC(uid="device_shfqc", address="dev12345", device_options="SHFQC/QC6CH")
)
device_setup.add_connections(
"device_shfqc",
create_connection(to_signal="q0/drive_line", ports="SGCHANNELS/0/OUTPUT"),
create_connection(to_signal="q0/measure_line", ports="QACHANNELS/0/OUTPUT"),
create_connection(to_signal="q0/acquire_line", ports="QACHANNELS/0/INPUT"),
)
# set a minimal calibration to device setup
drive_lo = Oscillator(frequency=1e9)
measure_lo = Oscillator(frequency=4e9)
cal = Calibration()
cal["/logical_signal_groups/q0/drive_line"] = SignalCalibration(
local_oscillator=drive_lo
)
cal["/logical_signal_groups/q0/measure_line"] = SignalCalibration(
local_oscillator=measure_lo
)
cal["/logical_signal_groups/q0/acquire_line"] = SignalCalibration(
local_oscillator=measure_lo
)
device_setup.set_calibration(cal)
emulate = True
session = Session(device_setup)
session.connect(do_emulation=emulate)
1. Simple example¶
Let us look at a simple but already complete example.
To keep it simple, we specify range=4
when setting up the PRNG. This means the PRNG will only produce the numbers 0, 1, 2, and 3. We then play twenty pulses where the amplitude of each pulse is determined by the random number. After these 20 pulses, we read out, and then start over by reseeding the PRNG.
const_pulse = pulse_library.const(length=100e-9)
# needed to access the generated prng samples after experiment creation
prng_sample = None
@experiment(signals=["drive", "measure", "acquire"])
def prng_example1():
global prng_sample
with acquire_loop_rt(8):
with prng_setup(range=4, seed=123, uid="prng_setup") as prng:
with prng_loop(prng, count=20, uid="prng_sample") as prng_sample:
with match(prng_sample=prng_sample):
with case(0):
play("drive", const_pulse, amplitude=0.2)
with case(1):
play("drive", const_pulse, amplitude=0.4)
with case(2):
play("drive", const_pulse, amplitude=0.6)
with case(3):
play("drive", const_pulse, amplitude=0.8)
with section(uid="readout", play_after="prng_setup"):
play("measure", const_pulse)
acquire(signal="acquire", kernel=const_pulse, handle="h1")
delay("measure", 100e-9)
exp1 = prng_example1()
q0_ls = device_setup.logical_signal_groups["q0"].logical_signals
exp1.map_signal("drive", q0_ls["drive_line"])
exp1.map_signal("measure", q0_ls["measure_line"])
exp1.map_signal("acquire", q0_ls["acquire_line"])
Compile the experiment. We can then inspect the generated seqc code for the HDAWG (drive
signal) to see the code that drives tge PRNG.
compiled_exp1 = session.compile(exp1)
# print the sequencer code generated by the example
print(compiled_exp1.src[1]["text"])
Indeed we can see that the inner loop of 20 pulses is a simple repeat(20) {...}
loop, with the random number used as an pointer into the command table.
1.1 Simulation¶
We can simulate this experiment with the OutputSimulator
.
plot_simulation(
compiled_exp1,
start_time=0,
length=1e-3,
signals=["drive"],
plot_height=5,
plot_width=15,
)
1.2 Tracing the values produced by the PRNG¶
While the output simulator produces an accurate preview of the waveform that the AWG will produce, it'd be tedious to attempt to reconstruct the actual sequence of random numbers from the waveform alone.
Instead, L1Q can directly give us the values that the PRNG will emit as part of the PRNGSample
produced by prng_loop
.
We can inspect the prng_sample.values
from the example above (note how we had to sneak in a global prng_sample
to exfiltrate the object out of the @experiment
definition):
print(prng_sample.values)
1.3 Syntactic sugar: play_indexed()
¶
If you want to distinguish many different options for the random variable (e.g. 28 Clifford gates for a simple RB experiment, or even more), the match...case
notation becomes overly verbose.
LabOne Q provides a helper function that allows you to more concisely specify a list of pulses.
The command playIndexed(pulses, index)
takes an iterable (e.g. a list) of pulses, and plays one of them based on index: PRNGSample
.
The argument of pulses
needs to contain instances of PlayPulse
, this may be extended to complete sections in the future.
It is also currently not possible to play more than a single pulse per branch when using play_indexed()
.
The earlier example looked like so:
with prng_loop(prng, count=20, uid="prng_sample") as prng_sample:
with match(prng_sample=prng_sample):
with case(0):
play("drive", pulse, amplitude=0.2)
with case(1):
play("drive", pulse, amplitude=0.4)
with case(2):
play("drive", pulse, amplitude=0.6)
with case(3):
play("drive", pulse, amplitude=0.8)
We can rewrite it using play_indexed()
:
pulses = [
PlayPulse(signal="drive", pulse=pulse, amplitude=a)
for a in [0.2, 0.4, 0.6, 0.8]
]
with prng_loop(prng, count=20, uid="prng_sample") as prng_sample:
play_indexed(pulses, prng_samples)
Note that play_indexed()
is purely for convenience, it calls match()
internally, and both snippets yield the same experiment object.
2. Measurements inside PRNG loop¶
Measuring a qubit inside prng_loop()
is of course allowed. In this case, the results object will contain an extra dimension, labelled with the PRNGSample
, just as it would have if the PRNG loop was instead a sweep over some parameter.
pulses = [
PlayPulse(signal="drive", pulse=const_pulse, amplitude=a)
for a in [0.2, 0.4, 0.6, 0.8]
]
@experiment(signals=["drive", "measure", "acquire"])
def prng_example():
with acquire_loop_rt(4):
# We add a 'dummy' sweep here, to illustrate how sweeps compose with the PRNG.
with sweep_range(0, 1, count=5, axis_name="sweep_param"):
# Seed the PRNG
with prng_setup(range=4, seed=0xABCD, uid="seed_prng") as prng:
# Draw values from the PRNG in a loop
with prng_loop(prng, 35, uid="prng_loop") as prng_sample:
# 'match' the PRNG sample to choose a pulse to play
play_indexed(pulses, prng_sample)
# Readout _inside_ the PRNG loop
with section():
reserve("drive")
play("measure", const_pulse)
acquire("acquire", kernel=const_pulse, handle="h1")
delay("measure", 100e-9)
exp = prng_example()
q0_ls = device_setup.logical_signal_groups["q0"].logical_signals
exp.map_signal("drive", q0_ls["drive_line"])
exp.map_signal("measure", q0_ls["measure_line"])
exp.map_signal("acquire", q0_ls["acquire_line"])
compiled = session.compile(exp)
results = session.run(compiled)
acquired_results = results.acquired_results["h1"]
print(f"Result shape: {acquired_results.data.shape}")
print(f"Result axes: {acquired_results.axis_name}")
print("Result coordinates:")
for name, coords in zip(acquired_results.axis_name, acquired_results.axis):
print(f" {name}: {coords}")
3. Advanced examples¶
3.1 Reseeding the PRNG¶
Reseeding the PRNG is allowed. For example, consider this DSL snippet.
# seed the PRNG with the value 0xCAFE, with a max. value of 9
with prng_setup(seed=0xCAFE, range=10) as prng:
with prng_loop(prng=prng, count=...) as prng_sample:
...
with match(prng_sample=prng_sample):
# play something coniditionally on `prng_sample`
...
# reseed the PRNG with a different value, e.g. 0xBEEF, and an upper value of 15
with prng_setup(seed=0xBEEF, range=16) as prng2:
with prng_loop(prng=prng2, count=...) as prng_sample2:
...
with match(prng_sample=prng_sample2):
# play something coniditionally on `prng_sample2`
...
Naturally, the count of iterations in both instances of prng_loop
need not be identical either, nor do the pulses played in the match block.
The compiler will enforce that we cannot match prng_sample2
inside the first prng_setup
block and vice versa.
pulses = [
PlayPulse(signal="drive", pulse=const_pulse, amplitude=a)
for a in np.linspace(0.1, 1, 10)
]
@experiment(signals=["drive", "measure", "acquire"])
def prng_reseeding_example():
with acquire_loop_rt(4):
# seed the PRNG with the value 0xCAFE, with a max. value of 9
with prng_setup(seed=0xCAFE, range=10) as prng:
with prng_loop(prng=prng, count=5) as prng_sample:
play_indexed(pulses, prng_sample)
# Readout _inside_ the first PRNG loop
with section():
reserve("drive")
play("measure", const_pulse)
acquire("acquire", kernel=const_pulse, handle="h1")
delay("measure", 100e-9)
# reseed the PRNG with a different value, e.g. 0xBEEF, and an upper value of 4
with prng_setup(seed=0xBEEF, range=5) as prng2:
with prng_loop(prng=prng2, count=10) as prng_sample2:
play_indexed(pulses[::2], prng_sample2)
# Readout _inside_ the second PRNG loop
with section():
reserve("drive")
play("measure", const_pulse)
acquire("acquire", kernel=const_pulse, handle="h2")
delay("measure", 100e-9)
exp = prng_reseeding_example()
q0_ls = device_setup.logical_signal_groups["q0"].logical_signals
exp.map_signal("drive", q0_ls["drive_line"])
exp.map_signal("measure", q0_ls["measure_line"])
exp.map_signal("acquire", q0_ls["acquire_line"])
compiled = session.compile(exp)
results = session.run(compiled)
for handle in ["h1", "h2"]:
print(f"=== Result handle {handle} ===")
acquired_results = results.acquired_results[handle]
print(f"Result shape: {acquired_results.data.shape}")
print(f"Result axes: {acquired_results.axis_name}")
print("Result coordinates:")
for name, coords in zip(acquired_results.axis_name, acquired_results.axis):
print(f" {name}: {coords}")
print()
3.2 Multiple PRNG loops without reseeding¶
Similarly, we can also opt not to reseed, and directly command another prng_loop()
.
with prng_setup(seed=0xCAFE, range=10) as prng:
with prng_loop(prng=prng, count=...) as prng_sample:
with match(prng_sample=prng_sample):
# play something conditionally on `prng_sample`
...
# do something that is not randomized
play(...)
# enter another PRNG loop without reseeding
with prng_loop(prng=prng, count=...) as prng_sample2:
with match(prng_sample=prng_sample2):
# play something conditionally on `prng_sample2`
...
⚠️ Note¶
When using multiple PRNG loops without reseeding, the values provided by PRNGSample.values
are not correct. Similarly, the values stored in the the results object (AcquiredResult.axis
) are also not accurate. Indeed, these values are computed under the assumption that the PRNG was in fact freshly seeded before entering the loop.
If you still need to elaborate the values the PRNG will emit, use laboneq.core.utilities.prng
to simulate the PRNG at a lower level.
from laboneq.core.utilities.prng import PRNG as PRNGSim
# `upper` is the maximum value produced, i.e. it corresponds to `range - 1` in the DSL
prng_sim = PRNGSim(seed=0xCAFE, upper=17)
# the first 10 values
print([next(prng_sim) for _ in range(10)])