Skip to main content

Current Release Coverage Status Python License

QCut

QCut is a quantum circuit knitting package built on top of qiskit for performing gate cuts and resetless wire cuts allowing simulation of larger quantum circuits on smaller quantum devices or simulators at the cost of a circuit overhead. QCut has been designed and tested to work with IQM's qpus, and the Finnish Quantum Computing Infrastructure (FiQCI).

QCut has been built at CSC - IT Center for Science (Finnish IT Center for Science).

Check out docs for instructions and more examples.

Installation

For installation a UNIX-like system is currently needed due to pymetis being used for automatic cut finding. On Windows use WSL

Pip:
Installation should be done via uv

uv pip install QCut
#or
uv add QCut

If using other than the default Qiskit version (newest) it is recommended to install Qiskit first before installing QCut.

IQM hardware and fake backends:

uv pip install "QCut[iqm]"
#or
uv add "QCut[iqm]"

This pulls in IQM's Qiskit adapter, which supports Qiskit 1.0 up to but not including 2.2, so installing it will hold Qiskit below 2.2. Install it into an environment whose Qiskit is already in that range if you would rather the resolver did not move it.

After installing you can simply import what you need:

from iqm.qiskit_iqm import IQMFakeAdonis
backend = IQMFakeAdonis()

Uv can be installed with

#Linux / mac
curl -LsSf https://astral.sh/uv/install.sh | sh

Note: for drawing circuits you might have to install pylatexenc. This can also be done with uv.

uv pip install pylatexenc
#or
uv add pylatexenc

Install from source
It is also possible to use QCut by cloning this repository and including it in your project folder.

cd QCut
uv pip install .
#or
uv sync --no-dev

#or with dev deps
uv sync

Usage

Creating cut circuits and experiments

1: Import needed packages

import numpy as np
import QCut as ck
from QCut import cut, cutGate, CutOptions, find_cuts
from qiskit import QuantumCircuit, transpile
from qiskit.circuit.library import CXGate
from qiskit.quantum_info import SparsePauliOp
from qiskit_aer import AerSimulator
from qiskit.primitives import StatevectorEstimator as Estimator, BackendEstimatorV2 as BackendEstimator
from iqm.qiskit_iqm import IQMFakeAdonis

2: Start by defining a QuantumCircuit just like in Qiskit

circuit = QuantumCircuit(5)

for qubit in range(5):
    circuit.ry(0.4 + 0.3*qubit, qubit)
circuit.cx(0,1)
circuit.cx(1,2)
circuit.cx(2,3)
circuit.cx(3,4)

circuit.draw("mpl")

3: Insert cut operations to the circuit to denote where we want to cut the circuit

Note that here we don't insert any measurements. Measurements will be automatically handled by QCut.

marked_circuit = QuantumCircuit(5)

for qubit in range(5):
    marked_circuit.ry(0.4 + 0.3*qubit, qubit)
marked_circuit.cx(0,1)
marked_circuit.append(**cutGate(CXGate(), 1, 2))
marked_circuit.cx(2,3)
marked_circuit.append(cut(), [3])
marked_circuit.cx(3,4)

marked_circuit.decompose(gates_to_decompose=["CutGate"]).draw("mpl")

cutGate() marks a gate cut and cut() a wire cut. Any two-qubit gate can be cut, with the decomposition derived from the gate's KAK coordinates, so the CX above is a single cut. See gate cuts and wire cuts for more on placing them, and Theory for where the decompositions come from.

4: Extract cut locations from the marked circuit and split it into independent subcircuits.

cut_circuit = ck.get_locations_and_subcircuits(marked_circuit)

Now we can draw our subcircuits.

cut_circuit.subcircuits[0].draw("mpl")

cut_circuit.subcircuits[1].draw("mpl")

cut_circuit.subcircuits[2].draw("mpl")

5: Generate experiment circuits

observables = SparsePauliOp(["IIIIZ", "IIIZI", "IIZII", "IIIZZ"])

cut_experiment = ck.get_experiment_circuits(cut_circuit, observables)

print(cut_experiment.num_groups)

48

get_experiment_circuits() does not modify the CutCircuit it is given, so the same split can be reused for several observable sets. Both CutCircuit and CutExperiment implement the assign_parameters() function of Qiskit.QuantumCircuit.

What a cut costs

print(cut_circuit.gamma, cut_circuit.optimal_gamma)

12.0 9.0

gamma is the sampling overhead of this split and optimal_gamma the least those same cuts could cost with every decomposition available. Shot cost goes as gamma squared. Both are closed form, so a plan can be costed before any experiment circuits are built, and CutExperiment carries both forward.

The gap here is the lone wire cut: on its own it does not communicate under the default wire_cut_communication="auto", so it costs 4 rather than 3. Passing "always" closes it and this split reaches 9.

Cheaper decompositions

Cuts are not decomposed one at a time where a cheaper joint decomposition exists. All three of the below are on by default and can be turned off through CutOptions.

default turned off
A run of gates on one qubit pair costs a single cut 1 cut, γ 1.59, 6 subexperiments 2 cuts, γ 9, 36
Parallel single-axis rotations share one decomposition (derivation) γ 5.50, 30 subexperiments γ 6.77, 36
A block of parallel wire cuts exchanges its measured outcome (derivation) γ 7, 28 subexperiments γ 16, 64

A separate circuit, to show the last of those on its own. Two parallel wire cuts cost 2**(n+1) - 1 rather than 4**n:

pair = QuantumCircuit(4)
for qubit in range(4):
    pair.ry(0.4 + 0.2 * qubit, qubit)
pair.cx(0, 1)
pair.cx(0, 2)
pair.append(cut(), [1])
pair.append(cut(), [2])
pair.cx(1, 2)
pair.cx(2, 3)

block = ck.get_locations_and_subcircuits(pair)
local = ck.get_locations_and_subcircuits(
    pair, options=CutOptions(wire_cut_communication="never")
)

print(block.gamma, local.gamma, local.optimal_gamma)

7.0 16.0 7.0

Those cuts run in waves, since one side has to be measured before the other can prepare what it measured. run_experiments() handles that itself.

Options

CutOptions is collected once and carried through the run. It covers the three decompositions above, the expansion strategy, sampling and the cut finder. Above 1000 groups the experiment is sampled from the quasiprobability distribution rather than enumerated. Every option and its default is listed under Options.

Automatic cuts

QCut comes with functionality for automatically finding good cut locations that can place both wire and gate cuts.

options = CutOptions(
    finder_num_partitions=3,
    finder_cut_mode="both",
)

found = find_cuts(circuit, options=options)

print(len(found.cut_locations), found.gamma)

2 9.0

Here the finder reaches the optimal_gamma the hand-placed cuts above did not. See automatic cuts for the finder's own options and how it chooses.

Transpilation

Two helpers, transpile_subcircuits() and transpile_experiments() , differing in when they run:

fake = IQMFakeAdonis() #noisy
sim = AerSimulator() #ideal

transpile_circuits() does whichever of the two the thing it is given calls for. Each subcircuit once, before the experiment circuits are built:

transpiled = ck.transpile_circuits(cut_circuit, fake, optimization_level=3)
cut_experiment = ck.get_experiment_circuits(transpiled, observables)

Or every experiment circuit, afterwards:

cut_experiment = ck.transpile_circuits(
    ck.get_experiment_circuits(cut_circuit, observables), fake, optimization_level=3
)

Given a cut circuit it is much the faster of the two, but the subcircuits still carry the cut and observable placeholders, so the transpiler is working on a circuit it cannot see all of. On IQM backends It therefore disallows remove_final_rzs and optimize_single_qubits and raises. Given an experiment there are no placeholders left , so it optimises further.

On an IQM backend both use IQM's own transpiler. Pass use_iqm_transpiler=False for the ordinary Qiskit path. On a resonator device such as IQMFakeDeneb the MOVE gates are routed in for you. See Basic usage for running against IQM fake backends and real hardware.

Execution

results = ck.run_experiments(cut_experiment, backend=fake)
expectation_values = ck.estimate_expectation_values(results)

The cost of an experiment run can be checked before submission:

print(ck.estimate_run(cut_experiment, shots=4096, max_batch_size=40))

Experiment will run 144 circuits in 4 jobs with a total of 589824 shots. See the returned object for the breakdown.

{'job1': JobEstimate(circuits=40, shots=4096), 'job2': JobEstimate(circuits=40, shots=4096), ...}

Exact for non-cummunicating runs. Runs whose wire cuts communicate get executed in waves so that each wave after the first spends its shots on what the one before it measured. For these jobs the circuit count is exact and job and shot counts are estimates.

backend takes a Qiskit backend, a V2 sampler, or anything else shaped like a backend, which is what lets e.g. fiqci-ems run the experiment:

from fiqci.ems import FiQCISampler

results = ck.run_experiments(
    cut_experiment, backend=FiQCISampler(backend, mitigation_level=1)
)

Every batch is submitted before any of it is collected, so a run queues all its jobs at once. max_batch_size bounds how many circuits go in one, and a target that batches on its own account is given that same size so it does not split a batch again. run_options is passed on to every run call for anything else the target takes.

Note that qiskit.primitives.StatevectorSampler cannot be used since circruits from QCut contain mid-circuit measurements and that sampler refuses those. qiskit_aer.primitives.SamplerV2 and BackendSamplerV2 are both fine.

Comparing against the exact and noisy expectation values of the original circuit:

obs = [ob.to_label() for ob in observables.paulis]

estimator = Estimator()
exact_expvals = [e.data.evs for e in
    estimator.run([(x) for x in zip([circuit] * len(obs), obs)]).result()
]

tr = transpile(circuit, backend=fake)

tr_obs = observables.apply_layout(tr.layout)

tr_obs_separate = [
    SparsePauliOp(pauli.to_label()) for pauli in tr_obs.paulis
]

fake_estimator = BackendEstimator(backend=fake)
exps = [e.data.evs for e in
    fake_estimator.run([(x) for x in zip([tr] * len(tr_obs_separate), tr_obs_separate)]).result()
]
np.set_printoptions(formatter={"float": lambda x: f"{x:0.6f}"})

print(f"QCut expectation values:{np.array(expectation_values)}")
print(f"Noisy expectation values with fake backend:{np.array(exps)}")
print(f"Exact expectation values with ideal simulator :{np.array(exact_expvals)}")

QCut expectation values:[0.763209 0.602396 0.328544 0.575471]

Noisy expectation values with fake backend:[0.846680 0.564941 0.323242 0.593262]

Exact expectation values with ideal simulator :[0.921061 0.704466 0.380625 0.764842]

As we can see QCut is able to accurately reconstruct the expectation values and be more accurate that just using the fake backend as is. (Note that since this is a probabilistic method the results vary a bit each run)

Additionally we can execute QCut using the ideal Aer simulator and see that we get (practically) exact results:

QCut expectation values:[0.920158 0.706660 0.379018 0.778012]

Shorthand

It is not necessary to go through each of the aforementioned steps individually. run() takes a circuit with cuts marked in it and executes the whole sequence, and run_cut_circuit() does the same for one that has already been split.

print(ck.run(marked_circuit, observables, sim, shots=2**12))

print(ck.run_cut_circuit(found, observables, sim))

[0.926887 0.699149 0.372935 0.765253]

[0.932540 0.717683 0.386038 0.775063]

Probability distributions

A cut experiment estimates expectation values, so there are no counts to tally, but the distribution over a chosen set of qubits can still be recovered from them. Pass qubits instead of observables:

cut_experiment = ck.get_experiment_circuits(cut_circuit, qubits=[0, 1])
results = ck.run_experiments(cut_experiment, shots=4096, backend=sim)

probs = ck.estimate_probabilities(results)

{'00': 0.8658, '01': -0.0024, '10': 0.1041, '11': 0.0324}

run() and run_cut_circuit() take qubits in the same way, and hand back the distribution rather than expectation values.

This costs 2**k values for k qubits but no extra circuits, since the Z observables it needs all commute. See the derivation.

The result is a dict, so it indexes and plots like one, and carries three views:

probs['00']                     # 0.8658
probs.quasi_probabilities()     # the same values, as a plain dict
probs.nearest_probabilities()   # closest true distribution, negatives projected away
probs.counts()                  # scaled by the shots the experiment ran at
probs.counts(shots=1000)        # or by any other shots

Against the same circuit run whole:

from qiskit.result import marginal_counts
from qiskit.visualization import plot_histogram

measured = circuit.measure_all(inplace=False)
counts = sim.run(transpile(measured, sim), shots=4096).result().get_counts()

plot_histogram(
    [probs.counts(), marginal_counts(counts, [0, 1])], legend=["QCut", "uncut circuit"]
)

Running on FiQCI

For running on real hardware using the Lumi supercomputer follow the instructions here. If you are used to using Qiskit on jupyter notebooks it is recommended to use the Lumi web interface.

Running on other hardware

Running on other providers such as IBM is untested at the moment but as long as the hardware can be accessed with Qiskit QCut should be compatible.

Logging

QCut reports what it decided at INFO, so nothing prints until logging is configured:

import logging

logging.basicConfig(
    level=logging.INFO,
    format="%(asctime)s - %(name)s - %(levelname)s - %(message)s",
)

logging.getLogger("QCut").setLevel(logging.INFO)
logging.getLogger("qiskit").setLevel(logging.WARNING)

That covers the whole run: which partitions the finder costed and which it kept, whether consolidation was worth it, which cuts were bundled and the gamma that bought, any bundle the device could not take and what it fell back to, how many circuits went into each job and that job's id, and how a communicating run split its shots between waves.

The job ids are the most important part. A job that fails or stalls on the device can be looked up by its id from the log.

Benchmarks

benchmarks/QCutVsAddon.ipynb briefly compares QCut against IBM's qiskit-addon-cutting given the same cuts: the gamma each achieves, how many subexperiments that comes to, how long they take to generate, and what the two cut finders settle on under the same qubit budget. The benchmarks show QCut consistently matching or beating the Qiskit Cutting Addon.

uv sync --group benchmark

Where one of QCut's joint decompositions applies, the same cuts cost less:

QCut addon
three wires cut at the same point γ 15, 240 subexperiments γ 64, 1024
three rotations crossing one split γ 7.16, 264 subexperiments γ 10.5, 432
an rzz and an rxx on each of three crossing pairs γ 115 γ 203

Where none applies, the QAOA layer at the sizes measured, the two agree exactly. Turning consolidation, joint rotation cutting and communicating wire cuts off reproduces the addon's gamma in every case the notebook measures, which is what says the difference is those three and not something else. The notebook also lists what each library has that the other does not.

Documentation

Check out jooniv.github.io/QCut/ for documentation and more examples.

The docs are built with sphinx using the sphinx book theme. To build the docs:

uv sync --group docs
cd docs
uv run sphinx-build -v -b html . build/sphinx/html -W

HTML files can then be found under build/sphinx/html/

Acknowledgements

This project is built on top of Qiskit which is licensed under the Apache 2.0 license.

License

Apache 2.0 license

Download files

Download the file for your platform. If you're not sure which to choose, learn more about installing packages.

Source Distribution

qcut-2.1.4.tar.gz (1.7 MB view details)

Uploaded Source

Built Distribution

If you're not sure about the file name format, learn more about wheel file names.

qcut-2.1.4-py3-none-any.whl (1.5 MB view details)

Uploaded Python 3

File details

Details for the file qcut-2.1.4.tar.gz.

File metadata

  • Download URL: qcut-2.1.4.tar.gz
  • Upload date:
  • Size: 1.7 MB
  • Tags: Source
  • Uploaded using Trusted Publishing? Yes
  • Uploaded via: uv/0.12.9 {"installer":{"name":"uv","version":"0.12.9","subcommand":["publish"]},"python":null,"implementation":{"name":null,"version":null},"distro":{"name":"Ubuntu","version":"24.04","id":"noble","libc":null},"system":{"name":null,"release":null},"cpu":null,"openssl_version":null,"setuptools_version":null,"rustc_version":null,"ci":true}

File hashes

Hashes for qcut-2.1.4.tar.gz
Algorithm Hash digest
SHA256 a842a0baade99538fac92c34f3bf04fd1567c092875d74cefd7d061c1bd3783f
MD5 a8cf8989735fe3eb9e99ad9ee720b2cd
BLAKE2b-256 f832e0aa4dcd8a1c6a067dd1fa15547ff17f2433ebec8d3ac072097d91ad0f45

See more details on using hashes here.

File details

Details for the file qcut-2.1.4-py3-none-any.whl.

File metadata

  • Download URL: qcut-2.1.4-py3-none-any.whl
  • Upload date:
  • Size: 1.5 MB
  • Tags: Python 3
  • Uploaded using Trusted Publishing? Yes
  • Uploaded via: uv/0.12.9 {"installer":{"name":"uv","version":"0.12.9","subcommand":["publish"]},"python":null,"implementation":{"name":null,"version":null},"distro":{"name":"Ubuntu","version":"24.04","id":"noble","libc":null},"system":{"name":null,"release":null},"cpu":null,"openssl_version":null,"setuptools_version":null,"rustc_version":null,"ci":true}

File hashes

Hashes for qcut-2.1.4-py3-none-any.whl
Algorithm Hash digest
SHA256 59974218fd04d989c5229792064bcc722193e5718e58c96197c2ad01f0a3021f
MD5 7ec4415cd44081eaaac7c57334bccdf2
BLAKE2b-256 97b3027742d52cbb507af35c37c088625816b5c9b53b2709d0bfcd32074bd624

See more details on using hashes here.

Release history Release notifications | RSS feed

This release

2.1.4 This release

2 files

2.1.3

2 files

2.1.2

2 files

2.1.1

2 files

2.1.0

2 files

2.0.0

2 files

1.3.2

2 files

1.3.1

2 files

1.3.0

2 files

1.2.0

2 files

1.1.1

2 files

1.1.0

2 files

1.0.2

2 files

1.0.1

2 files

1.0.0

2 files

0.9.2

2 files

0.9.1

2 files

0.9.0

2 files

0.8.0

2 files

0.7.0

2 files

0.6.0

2 files

0.3.0

2 files

0.2.4

2 files

0.2.3

2 files

0.2.2

2 files

0.2.1

2 files

0.2.0

2 files

0.1.3

2 files

0.1.0

1 file

0.0.10

2 files

0.0.9

2 files

0.0.8

2 files

0.0.7

2 files

0.0.6

2 files

0.0.5

2 files

0.0.4

2 files

0.0.3

2 files

0.0.2

2 files

0.0.1

2 files

Anthropic, PBC Visionary sponsor Bloomberg Visionary sponsor Hudson River Trading Visionary sponsor Meta Visionary sponsor NVIDIA Visionary sponsor Microsoft Sustainability sponsor Depot Continuous Integration AWS Cloud computing and Security Sponsor Datadog Monitoring Fastly CDN Google Download Analytics Sentry Error logging StatusPage Status page