Execution

QAT also provides utility for executing quantum programs and interpreting the results. In the pipelines, we saw that programs could be executed using QAT().execute(pkg). Here we break this down into the lower-level details. In particular, execution in QAT is composed of two elements: the Engine and the Runtime.

Below is a diagram that explains how execution is done in QAT, in particular, for the SimpleRuntime. The native code is fed to the runtime, which passes it to the engine. The engine communicates with the target to execute the program and fetch the results. These results then enter the post-processing pipeline - a series of passes that each mutate the results according the program. The mutated readout results are returned to the user.

../_images/runtime.png

Engines

The NativeEngine is the base class used to implement an engine. The engine is expected to uphold a contract with the Runtime:

  • Packages can be executed through the method NativeEngine.execute, which expects to receive an Executable (the native code) as an argument.

  • In return, the engine returns the results to the runtime in an expected format, which is a dictionary of acquisition results (one result per acquisition). The result is an array of readout acquisitions, whose shape will depend on the acquisition mode. The key for the acquisition in the dictionary is the output_variable stored in the AcquireData.

  • The number of shots to execute is stored in the attribute compiled_shots. Note that while the total number of shots in a program might be larger than the compiled_shots, sometimes the target cannot support the required amount of shots. When this is the case, shots will be batched.

Engines available in QAT

The following engines are available to use within QAT. Engines written for proprietary OQC hardware is not available here.

  • EchoEngine: an engine compatible only with the WaveformV1Backend that simply “echos” back the readout pulses, primarily used for testing purposes.

  • ZeroEngine: returns all readout responses as zeroes, again used for testing purposes.

  • QiskitEngine: a legacy engine that simulates quantum circuits using Qiskit’s AerSimulator. To be refactored to make full use of the pipelines API.

  • RealtimeChipSimEngine: OQC’s home-made simulator for accurate and realistic simulation of superconducting qubits. Also a legacy engine and needs to be refactored to make full use of the pipelines API.

Echo engine example

As an example, let us use the EchoEngine to execute a QASM2 program. For simplicity, we will make use of a pipeline to compile the program, but then use to engine independently to execute the program.

 1from qat import QAT
 2from qat.pipelines.echo import echo8
 3from qat.engines.waveform_v1 import EchoEngine
 4from compiler_config.config import CompilerConfig, Tket
 5
 6qasm_str = """
 7OPENQASM 2.0;
 8include "qelib1.inc";
 9qreg q[2];
10creg c[2];
11h q[0];
12cx q[0], q[1];
13measure q -> c;
14"""
15config = CompilerConfig(repeats=10, optimizations=Tket().disable())
16
17core = QAT()
18core.pipelines.add(echo8, default=True)
19pkg, _ = core.compile(qasm_str, config)
20results = EchoEngine().execute(pkg)

The results returned as a dictionary: the keys correspond to output variables assigned to the readouts at compilation, in this case, it has the format c[{clbit}]_{qubit}, where clbit corresponds to the bit specified in the QASM program, and the qubit denotes the qubit that is read out (note this may differ to what is specified in the QASM program if optimizations are used). Since the AcquireMode.INTEGRATOR is used by default for readout acquisitions, the values in the dictionary are arrays with one readout per shot. For this example, the results are:

results = {
    'c[0]_0': array([1.+0.j, 1.+0.j, 1.+0.j, 1.+0.j, 1.+0.j, 1.+0.j, 1.+0.j, 1.+0.j,
    1.+0.j, 1.+0.j]),
    'c[1]_1': array([1.+0.j, 1.+0.j, 1.+0.j, 1.+0.j, 1.+0.j, 1.+0.j, 1.+0.j, 1.+0.j,
    1.+0.j, 1.+0.j])
}

Connection handling with engines

Sometimes an engine requires a connection to be made with the target. Connection capabilities can be specified by mixing in a ConnectionMixin. To demonstrate how connection handling can be specified, see the following example, which adds a mock connection to the ZeroEngine.

 1from qat.engines import ConnectionMixin
 2from qat.engines.zero import ZeroEngine
 3
 4class NewEngine(ZeroEngine, ConnectionMixin):
 5    is_connected: bool = False
 6
 7    def connect(self):
 8        self.is_connected = True
 9        print("Engine has connected.")
10        return self.is_connected
11
12    def disconnect(self):
13        self.is_connected = False
14        print("Engine has disconnected.")
15        return self.is_connected

Runtimes

The Runtime is the object that is used to fully execute a program. When provided with a package, it makes calls to the engine to execute the “quantum parts” of the program, and then runs the results it receives through a post-processing pipeline to execute the “classical parts”. See qat.runtime.passes for a full list of post-processing passes available. The standard runtime to use is the SimpleRuntime, which simply calls the engine (possibly multiple times if the shots are batched) and then processes the results. In the future, there may be more complex runtimes such as hybrid runtimes that allow for a more comprehensive interplay of classical and quantum computation.

For engines where a connection is required, the Runtime can be provided a ConnectionMode flag that instructs the runtime on how the connection should be handled. For example, if a connection should always be maintained for the entire lifetime of a runtime, we can use the flag ConnectionMode.ALWAYS. Alternatively, if we want to delegate the responsibility of connection to the user, we can use the ConnectionMode.MANUAL flag.

Simple runtime

The following example shows how to use the SimpleRuntime with a ZeroEngine and a custom pipeline. For completeness, it also shows how to add a connection flag, although it will be of no use here as the ZeroEngine does not require a connection!

 1from qat import QAT
 2from qat.pipelines.echo import echo8
 3from qat.engines.zero import ZeroEngine
 4from qat.runtime import SimpleRuntime
 5from qat.runtime.connection import ConnectionMode
 6from qat.passes.pass_base import PassManager
 7from compiler_config.config import CompilerConfig, QuantumResultsFormat
 8from qat.runtime.transform_passes import (
 9    AssignResultsTransform,
10    InlineResultsProcessingTransform,
11    PostProcessingTransform,
12    ResultTransform
13)
14
15qasm_str = """
16OPENQASM 2.0;
17include "qelib1.inc";
18qreg q[2];
19creg c[2];
20h q[0];
21cx q[0], q[1];
22measure q -> c;
23"""
24config = CompilerConfig(repeats=10, results_format=QuantumResultsFormat().binary_count())
25
26core = QAT()
27core.pipelines.add(echo8, default=True)
28pkg, _ = core.compile(qasm_str, config)
29
30pipeline = (
31    PassManager()
32    | PostProcessingTransform()
33    | InlineResultsProcessingTransform()
34    | AssignResultsTransform()
35    | ResultTransform()
36)
37
38runtime = SimpleRuntime(ZeroEngine(), pipeline, ConnectionMode.ALWAYS)
39results = runtime.execute(pkg, compiler_config=config)

Since the Runtime takes care of post-processing responsibilities, the results returned look quite a bit different to what was returned from the engine:

results = {'c': {'11': 10}}

Legacy runtime

QAT pipelines also have support for legacy engines through the LegacyRuntime. For example, we can define a runtime for the RTCS:

1from qat.runtime import LegacyRuntime
2from qat.model.loaders.legacy import RTCSModelLoader
3from qat.purr.backends.realtime_chip_simulator import RealtimeChipSimEngine
4
5model = RTCSModelLoader().load()
6runtime = LegacyRuntime(RealtimeChipSimEngine(model))

Warning

Legacy engines can vary in the post-processing responsibilities that they carry out. An appropriate post-processing pipeline must be picked to match the legacy engine.