Running on several backends#

A cut experiment is a collection of many independent circuits, so it does not have to run on one machine. ParallelBackend takes several backends and hands them the batches in turn.

from qiskit import QuantumCircuit
from qiskit.circuit.library import CXGate
import QCut as ck
from QCut import ParallelBackend, cutGate

circuit = QuantumCircuit(6)
for qubit in range(6):
    circuit.ry(0.4 + 0.2 * qubit, qubit)
circuit.cx(0, 1)
circuit.cx(3, 4)
circuit.append(**cutGate(CXGate(), 2, 3))
circuit.cx(4, 5)

cut_circuit = ck.get_locations_and_subcircuits(circuit)
experiment = ck.get_experiment_circuits(cut_circuit, ["IIIIIZ"])

backend = ParallelBackend([first_device, second_device])
results = ck.run_experiments(experiment, shots=4096, backend=backend)

expectation_values = ck.estimate_expectation_values(results)

backend.submitted  # circuits given to each, e.g. [1728, 1728]

Why it can be shared#

QCut submits every batch of a wave before collecting any of it, so the machines queue at the same time rather than one after another. Communicating wire cuts still run in waves, because a preparing side needs the measuring side’s outcome, but the circuits within a wave are independent and are shared like any others.

The experiment is not transpiled beforehand. Each batch is transpiled for the backend about to run it, so only one batch is ever held in its transpiled form and the experiment itself stays as it was built.

Machines of different sizes#

A batch only goes to a backend with room for it, so the pieces that fit anywhere are shared while the widest go to the backends that can fit them. Cutting a circuit into a wide piece and a narrow one and giving it a large device and a small one uses both:

backend = ParallelBackend([five_qubit_device, twenty_qubit_device])
ck.run_experiments(experiment, shots=4096, backend=backend)

If nothing is wide enough for a subcircuit, QCut says so rather than letting the device refuse the job. Cut into smaller pieces with max_qubits, or pass a larger backend.

Writing your own#

ParallelBackend is not special: QCut asks a backend only for run(circuits, shots=..., **options) returning a job whose result() gives counts by position. Anything answering that can decide for itself where circuits go.

import QCut as ck

class MyParallelBackend:
    def __init__(self, backends):
        self.backends = list(backends)
        self._turn = 0

    def run(self, circuits, shots=1024, **options):
        backend = self.backends[self._turn % len(self.backends)]
        self._turn += 1
        ready = ck.transpile_circuits(list(circuits), backend)
        return backend.run(ready, shots=shots, **options)

transpile_circuits() is what QCut transpiles with itself, so an IQM device goes through IQM’s own transpiler and a resonator machine gets its MOVE routing. It takes a CutCircuit, a CutExperiment or plain circuits.

Four things keep it correct:

  • Return the backend’s own job. Its result() already answers get_counts(index) for that batch, which is what QCut reads. Splitting one batch across machines instead means merging the results back into submission order yourself.

  • Pass ``shots`` through untouched. It is what the estimate divides by, and the waves allocate it themselves.

  • Submit one batch per call and do not merge them. The batches are already sized, and waves are grouped so that circuits wanting very different shot counts do not share a job.

  • Do not wait for results inside run. Returning immediately is what leaves the machines running at the same time.

A backend that declares max_shots has it respected; ParallelBackend reports the smallest of its own, since a batch may land on any of them. Add max_batch_size to run’s signature to use the value QCut used.