Qdislib — 08 · Backends under the hood

Every execution target — a CPU simulator (Qibo / Qiskit / CUDA-Q), a GPU, or a real QPU — is a BaseBackend. The cutting algorithms never branch on the backend: they hand each subcircuit to a backend object that build_execution_backend chose from your software= / gpu= / qpu= arguments. This notebook opens that box — and shows how to plug in your own backend.

[ ]:
import Qdislib as qd

The registry

Backends are registered by name and imported only on first use, so a missing SDK never breaks import Qdislib.

[ ]:
print(qd.available_backends())

How software= picks a backend

build_execution_backend turns (software, gpu, qpu, qpu_dict, num_qubits) into one concrete backend — the single place that decides. software= maps to a CPU backend; qpu=True + a big-enough QPU in qpu_dict routes to that QPU; a large-enough gpu=True routes to GPU Aer.

[ ]:
for sw in ('qiskit', 'qibo', 'cudaq'):
    backend = qd.build_execution_backend(sw, num_qubits=4)
    print(f'{sw:7s} -> {backend.name}')

# qpu=True + a big-enough QPU in qpu_dict routes to the QPU backend instead:
qpu = qd.build_execution_backend('qibo', num_qubits=4, qpu=True, qpu_dict={'MN_Ona': 8})
print('qpu     ->', qpu.name)

The BaseBackend interface

A backend implements a few hooks: transpile, _execute_samples, _execute_estimator (the generic sample/estimator path), plus the cutting seams the algorithms call — evaluate_expectation (⟨O⟩ of a subcircuit), evaluate_samples (counts), and evaluate_joint_counts (joint final + cut-point counts, for sampling reconstruction). Any backend that implements these can run cut subcircuits.

Plug in your own backend

Subclass BaseBackend, implement the hooks (wrap an in-house simulator or a cloud QPU’s API), register it by name, then select it with software='...' — with no change to the cutting code:

[ ]:
from Qdislib import BaseBackend, register_backend

class MyBackend(BaseBackend):
    def __init__(self, config):
        super().__init__('MY_BACKEND', config)
    def transpile(self, circuit):
        ...
    def _execute_samples(self, circuit, transpile, shots, job):
        ...   # -> SampleJobResult(counts=...)
    def _execute_estimator(self, circuit, transpile, observable, shots, job):
        ...   # -> ExpectationJobResult(expectation_value=...)
    def evaluate_expectation(self, dag, num_qubits, prep_qubits=(), shots=1024,
                             method='automatic'):
        ...   # build the native circuit from the DAG, run it, return <O>

# Registered by import path + class name (imported lazily on first use):
register_backend('MY_BACKEND', 'my_pkg.my_backend', 'MyBackend')
value = qd.gate_cutting(circuit, cut, software='MY_BACKEND', shots=8192)

The unified front-end: QdBackEnd

QdBackEnd holds a pool of backends and routes each job for you. Given a circuit it (1) keeps the backends with enough qubits for it — the qubit fit;

  1. tries them shortest queue first (queue_length); and (3) falls back to the next candidate if one errors (or lacks an estimator). Handy when you have several devices — but optional; the cutting API calls build_execution_backend directly.

The pool is a dict of name -> {'num_qubits': ..., <extra config>}. We use two simulators here so it runs anywhere; a real deployment lists QPUs ('IBM_BACKEND', 'BSC_QPU_BACKEND', …) with their URLs and tokens.

[ ]:
import qibo
from qibo import Circuit, gates
qibo.set_backend('numpy')

backend = qd.QdBackEnd(qpu_dict={
    'AER_BACKEND': {'num_qubits': 30},
    'QIBO_BACKEND': {'num_qubits': 20},
})

circuit = Circuit(2)
circuit.add(gates.H(0)); circuit.add(gates.CZ(0, 1)); circuit.add(gates.M(0, 1))

res = backend.sample_circuit(circuit, shots=1024)          # shortest-queue backend that fits
exp = backend.get_expectation_value(circuit, 'ZZ', shots=1024)
print('counts:', res.counts)
print('<ZZ>  :', round(exp.expectation_value, 3))

Next: 09 · Distributed execution & real QPUs — run the pieces in parallel with PyCOMPSs, and on IBM / BSC hardware.