Skip to content

Circuit Execution

execute() runs a mimiqcircuits circuit on the MPS engine and returns a QCSResults.

from mimiqcircuits import Circuit, GateH, GateCX
from tensorweaver import execute

c = Circuit()
c.push(GateH(), 0)
c.push(GateCX(), 0, 1)

results = execute(c, nsamples=1000, bonddim=64)
print(results.histogram())
print(results.fidelities[0])
print(results.timings)

execute() builds a fresh simulator per call. To reuse one configuration across many circuits, construct a TwSimulator instead and call its execute() method. The configuration keywords below map onto the constructor; nsamples, seed, and num_qubits stay per call.

Parameter sweeps

A circuit with free symbols runs once per point of a parameter table. Build a SweepExperiment from the circuit and a ParameterTable, and pass it where a circuit goes. The result is a list of QCSResults, one per point, each run with its own seed derived from the run's seed.

from mimiqcircuits import Circuit, GateRX, Measure, SweepExperiment, gridtable
from symengine import symbols
from tensorweaver import TwSimulator

theta = symbols("theta")
c = Circuit()
c.push(GateRX(theta), 0)
c.push(Measure(), 0, 0)

# One point per angle; `gridtable` takes the Cartesian product when given
# several symbols.
ex = SweepExperiment(c, gridtable({theta: [0.0, 1.0, 3.14159]}))
results = TwSimulator(bonddim=32).execute(ex, nsamples=1000, seed=7)
print([r.histogram() for r in results])

The table must bind every free symbol of the circuit, and nothing else; the SweepExperiment constructor checks it. TwSimulator.execute(c, param_grid=[...]) with a list of dictionaries runs the same loop without the check.

Execution modes

The mode is chosen from the circuit's content, and it decides what nsamples means. Concepts covers why.

Sampling mode Trajectory mode
Chosen when Circuit is unitary apart from measurements that nothing depends on afterwards Circuit contains a measurement whose qubit is reused, a Reset, an IfStatement, or a noise channel
Evolutions One nsamples
nsamples means Bitstrings drawn from the final state Independent runs of the full circuit
results.fidelities One entry nsamples entries

A measurement does not by itself force trajectory mode. Measurements that nothing later depends on are stripped before evolution and reapplied when samples are mapped onto classical bits, so the run stays in the cheap mode.

from mimiqcircuits import Circuit, GateH, GateCX, Measure
from tensorweaver import execute

c = Circuit()
c.push(GateH(), 0)
c.push(GateCX(), 0, 1)
c.push(Measure(), 0, 0)
c.push(Measure(), 1, 1)

results = execute(c, nsamples=1000, bonddim=64)
print(len(results.fidelities))   # 1: sampling mode, evolved once
print(results.histogram())       # e.g. {bs"00": 498, bs"11": 502}

Reusing a measured qubit makes each shot follow its own history, so each shot needs its own evolution:

c = Circuit()
c.push(GateH(), 0)
c.push(Measure(), 0, 0)   # qubit 0 is used again below
c.push(GateH(), 0)
c.push(Measure(), 0, 1)

results = execute(c, nsamples=100, bonddim=64)
print(len(results.fidelities))   # 100: one per trajectory

Trajectory mode needs explicit measurements

In sampling mode every qubit is projected when the final state is sampled, so results.histogram() is populated even with no Measure in the circuit. In trajectory mode cstates come from the classical register, so a circuit with no Measure yields no classical states and an empty histogram. Push the measurements you want to read.

Amplitudes in trajectory mode

bitstrings=, and an in-circuit Amplitude(...), report the last trajectory's state, not an ensemble average. An amplitude is a pure-state quantity and a trajectory ensemble is a mixed state. To get ensemble statistics, aggregate over runs yourself.

Parameters

circuit and nsamples are positional; everything else is keyword-only.

Accuracy and cost

Parameter Default When to change it
bonddim 256 The main accuracy/cost dial. Raise until your observable stops moving; lower for speed on lightly entangled circuits. Cost per two-qubit gate grows like bonddim³.
sv_cutoff -1.0 (means 1e-7) Rarely. Lower it towards 1e-10 when you need tighter accuracy at a bonddim you are not saturating; raising it trades accuracy for speed. Any negative value selects the library default.
algorithm "vmpoa" How each fused operator is applied. See Choosing an algorithm.
strict_bonddim None Whether bonds may exceed bonddim while an operator is applied. None is strict for "dmpo", "vmpoa", "vmpob" and relaxed for "cmpo". Strict keeps memory bounded by bonddim and can cost accuracy, most for "dmpo"; the reported fidelity shows the loss. Strict needs bonddim >= entdim and raises ValueError otherwise. False is exact whenever the result fits, at more memory and time. "cmpo" with True raises ValueError. See Strict and relaxed bond dimension.
entdim 16 Caps the bond dimension of the operator that gates accumulate into. Raise for deep, highly entangling segments so more gates batch per application. A single gate whose operator rank exceeds entdim raises RuntimeError.
post_compress True Leave on. Off defers the sweep that restores canonical form, which is faster on circuits well below bonddim but lets later truncations compare the wrong singular values. Turn it off only after measuring that your circuits are unaffected.
max_bond_dim None Deprecated alias for bonddim. Overrides bonddim when set. Use bonddim.

Layout

Parameter Default When to change it
reorderqubits None Off by default: the circuit evolves in the layout you wrote. True runs the whole portfolio of layout searches and keeps whichever result scores best against bonddim, or your own layout when none of them clearly beats it. Worth trying when the interaction graph has locality the numbering hides and the bond dimension is the limit. Naming one search ("greedy", "sa" / "simulated_annealing", "multilevel") pins it, which is mostly useful for comparing them. Leave it off when you need the MPS chain to keep your qubit numbering.
num_qubits None Set it when the circuit does not touch every qubit of the register you want simulated. Inferred from the circuit otherwise.

Reordering, when you turn it on, is transparent: samples, amplitudes, and z-variables come back in your original qubit labelling. To tune the search's margin or alpha, pass a TwReorderQubitsPass to TwSimulator.execute through passes=; see Qubit ordering.

Sampling and observation

Parameter Default When to change it
nsamples 1000 Shots in sampling mode, full evolutions in trajectory mode. Cost is flat in sampling mode and linear in trajectory mode.
seed 42 Set it for reproducibility. Separate streams for sampling, noise, trajectories and randomised passes are derived from it, so trajectories differ from one another while the whole run repeats exactly.
bitstrings None Pass a list of BitString to read specific amplitudes out of results.amplitudes.

Circuit preparation

Parameter Default When to change it
traversal "sequential" Order gates are walked while they batch into operators. See Circuit traversal order.
fuse None Fuses runs of gates into GateCustom blocks before evolution. None does not fuse. Distinct from, and usually redundant with, the operator batching applied during evolution.
fuse_threshold None Minimum qubit count for fuse to act. Setting it alone also turns fusion on; with fuse=False it raises ValueError.
canonicaldecompose None Decompose to the canonical basis before evolution. None does not decompose.
simplify None Simplify the circuit before evolution: cancel inverse pairs, merge rotations, resynthesize one-qubit blocks. Two-qubit resynthesis is left out: it reorders the gates and slows the evolution. The unitary is kept exactly. None does not simplify. It runs before fuse and reorderqubits, so both see the shorter circuit. Worth trying on transpiled circuits, which often carry gates that cancel.
buffer 0 Prepares up to buffer operators on a background thread while evolution applies the current one. Results are identical to 0. A depth of 2 to 4 is enough, since only one or two operators are in flight.

Engine

Parameter Default When to change it
linalg None Selects the linear-algebra library: "openblas", "mkl" or "faer". See Linear-algebra engine.
usemkl None Deprecated. True is linalg="mkl", False is linalg="openblas".
usegpu False Runs the state on an NVIDIA GPU. Requires algorithm="dmpo" or "cmpo". See GPU engine.

Bitstring / qubit ordering

TensorWeaver uses little-endian by qubit index: bitstring position i is qubit i. This is the opposite of Qiskit's convention, so bitstrings from results.cstates and results.histogram(), and those passed via bitstrings=, appear reversed compared to equivalent Qiskit output.

Return value: QCSResults

Field Type Contents
cstates list[BitString] Classical states, one per sample or trajectory. In trajectory mode these come from the circuit's Measure instructions.
fidelities list[float] Truncation fidelity per evolution. One entry in sampling mode, nsamples in trajectory mode. A lower bound: see reading the fidelity.
avggateerrors list[float] The same information expressed as an average error per two-qubit gate.
amplitudes dict[BitString, complex] Amplitudes for each bitstring passed to bitstrings=.
zstates list[list[complex]] Z-register values per evolution, written by z-variable operations.
timings dict[str, float] Wall-clock seconds: "total", "compile" (decomposition and layout), "apply" (evolution), "sample".
simulator str "TensorWeaver".
version str Version of the installed mimiq-tensorweaver package.

Two methods are worth knowing:

  • results.histogram() returns dict[BitString, int], counting each classical state.
  • results.histzvars() returns dict[tuple, int], counting each z-state.

Computing amplitudes

from mimiqcircuits import Circuit, GateH, GateCX, BitString
from tensorweaver import execute

c = Circuit()
c.push(GateH(), 0)
c.push(GateCX(), 0, 1)

bs00, bs11 = BitString([0, 0]), BitString([1, 1])
results = execute(c, nsamples=1, bonddim=64, bitstrings=[bs00, bs11])

print(results.amplitudes[bs00])   # ~0.7071+0j
print(results.amplitudes[bs11])   # ~0.7071+0j

Circuit traversal order (traversal)

A circuit is more than a flat list. Two instructions are ordered relative to each other only when they share a wire (a qubit, a classical bit, or a z-variable). traversal chooses how that freedom is used while gates batch into operators.

Value Order
"sequential" (default) The circuit's insertion order.
"bfs" Breadth-first topological order: independent gates group into layers.
"dfs" Depth-first topological order: one dependency chain is followed to its end before backtracking.

Reordering is confined to the gates between instructions that read or change the state. Measurement, reset, noise channels, observables (Amplitude, BondDim, SchmidtRank, VonNeumannEntropy, ExpectationValue), barriers, and conditionals stay where they are and act as synchronisation points.

All three produce the same simulated state. They differ in how gates batch, which changes the intermediate bond dimension and therefore runtime and memory. "bfs" tends to pack each operator with more mutually commuting gates, which helps on circuits with wide, shallow layers. "sequential" matches a literal reading of the circuit.

from mimiqcircuits import Circuit, GateH, GateCX
from tensorweaver import TwSimulator

circuit = Circuit()
for q in range(8):
    circuit.push(GateH(), q)
for q in range(0, 8, 2):
    circuit.push(GateCX(), q, q + 1)

sim = TwSimulator(bonddim=128, traversal="bfs")
results = sim.execute(circuit, nsamples=1000, seed=42)

Linear-algebra engine (linalg)

The dense linear algebra underneath the MPS runs on one of three libraries. Each is compiled into a separate native module, selected per simulator:

linalg Engine Availability
None (default) Whichever this build ships, OpenBLAS for preference Always works: the default is resolved from the engines actually installed.
"openblas" OpenBLAS Linux and macOS. Vendored into the wheel; works from a plain pip install with no system BLAS.
"mkl" Intel MKL Linux x86_64 and Windows. Comes from the mkl package, a dependency of those wheels.
"faer" faer Every platform. Pure Rust, links no system library. Usually slower than the other two, so it is never the default when another engine is installed.
from mimiqcircuits import Circuit, GateH, GateCX
from tensorweaver import TwSimulator, execute

circuit = Circuit()
circuit.push(GateH(), 0)
circuit.push(GateCX(), 0, 1)

sim = TwSimulator(linalg="mkl")                           # whole simulator
results = sim.execute(circuit, nsamples=1000)

results = execute(circuit, nsamples=1000, linalg="mkl")   # one call

Which engines a wheel contains depends on the platform: Linux ships all three, macOS arm64 ships OpenBLAS and faer (MKL is x86-only), and Windows ships MKL and faer. This is why the default is None rather than an engine name: a fixed OpenBLAS default would ask the Windows wheel for a module it does not have. The resolved choice is readable afterwards as sim.linalg.

An explicit linalg is never quietly switched. linalg="openblas" on Windows asks for an engine that is not installed and raises ImportError saying so, rather than running on MKL behind your back. Leave the keyword out to get whatever this platform provides. An unknown name raises ValueError.

usemkl (deprecated)

usemkl is the older spelling and still works, with a DeprecationWarning: usemkl=True is linalg="mkl" and usemkl=False is linalg="openblas". Passing both with different engines raises ValueError. sim.usemkl still reads back whether the simulator runs on MKL, also with a warning.

Why MKL is a dependency and not part of the wheel

OpenBLAS is an ordinary shared library, so it is bundled into the wheel. MKL is not: libmkl_rt is a dispatcher that loads its backing libraries (libmkl_core, libmkl_intel_thread, the ISA kernels) by name at call time, so they never appear in the wheel's import table and no wheel-repair tool can find them. Bundling the dispatcher alone would be worse than bundling nothing, since the engine would import and then die on the first BLAS call.

So the wheels that carry an MKL engine, Linux x86_64 and Windows, carry no MKL library at all and depend on the PyPI mkl package instead. pip install mimiq-tensorweaver brings it in, and tensorweaver opens it before importing the engine, so nothing has to be set in the environment. That package installs into a directory no library search path knows about, which is why opening it explicitly is necessary: LD_LIBRARY_PATH is read once when the process starts and cannot be set from inside it.

Any other complete MKL works too, as long as its libmkl_rt major version matches the one the wheel was built against: a conda mkl, a distribution package, or Intel oneAPI's setvars.sh.

Requesting linalg="mkl" without a usable runtime raises an ImportError explaining how to install it, or, on a platform with no MKL build at all, saying so instead. The check runs up front in a short subprocess, so you get a Python exception rather than MKL aborting the process mid-operation.

GPU engine (usegpu)

usegpu=True runs the state on an NVIDIA GPU. Linux x86_64 only.

pip install "mimiq-tensorweaver[cuda]"

The engine is in the published Linux x86_64 wheels; the extra adds the CUDA maths libraries it loads. The macOS and Windows wheels have no GPU engine.

Driver requirement

The wheels are built with CUDA 12.9, and the device kernel ships as PTX that the driver compiles on the fly. So the driver has to be at least as new as that release: 575.51.03 or newer. An older driver loads the engine and fails when the kernel is first used. nvidia-smi reports the version it is running.

The CUDA libraries themselves come from the extra, not from the system, so no CUDA toolkit installation is needed on the machine that runs this.

from mimiqcircuits import Circuit, GateH, GateCX
from tensorweaver import TwSimulator

circuit = Circuit()
circuit.push(GateH(), 0)
circuit.push(GateCX(), 0, 1)

sim = TwSimulator(usegpu=True, algorithm="cmpo", bonddim=512)
results = sim.execute(circuit, nsamples=1000)

The state is moved into device memory once, evolved there, and moved back once, so a circuit pays one transfer at each end rather than one per gate. A transfer over PCIe costs more than the truncated SVD it would feed, which is why the residency is what makes the GPU worth using at all.

It is not always faster

The device wins where the state is large and loses where it is small: each site's truncated SVD carries a fixed launch cost the host does not pay. On an NVIDIA A30 against one Xeon core, a 24-qubit brick-layer circuit of depth 24:

bonddim CPU (s) GPU (s) Speed-up
64 0.35 0.44 0.8x
128 1.41 0.51 2.8x
256 6.35 0.78 8.2x
512 27.8 1.16 24x

So below roughly bonddim=64 the GPU is slower, and the case for it grows with the problem: over those four circuits the CPU's time grows 80x while the GPU's grows under 3x. Both engines truncate identically and the reported fidelities agree to every digit printed, so the speed-up is not bought with accuracy.

The CPU column is one core

Single-threaded OpenBLAS, which is that machine's fastest configuration for this workload rather than a handicapped one, and MKL is not in the comparison at all. Treat these as orders of magnitude, not as a benchmark.

What it requires, and what it refuses

  • algorithm="dmpo" or algorithm="cmpo" is required. Only the two single-pass algorithms have a device implementation; the variational sweeps ("vmpoa", the default, and "vmpob") run on the host. The simulator raises rather than switching for you: the algorithms do not return the same state once truncation binds, so changing it silently would make the same arguments answer differently depending on the hardware. Prefer "cmpo", the accurate one of the two.
  • linalg must be unset or "openblas". The CUDA engine is a single module whose host provider is OpenBLAS, so linalg="mkl" or "faer" with usegpu=True raises ValueError.
  • Circuits needing trajectory mode are refused: a mid-circuit Measure or Reset, a noise channel, or qubit loss. None of these has a device path, so running one would transfer the whole state back and forth per operation, which is slower than staying on the host. Use usegpu=False for those.

Sampling and amplitudes are host operations, and the state is brought back for them automatically, so nothing extra is needed to read results.

When there is no GPU

The engine module links no CUDA library: the driver and the maths libraries are loaded by name at call time. So it imports on any machine, and asking for the device is what fails, with an ImportError naming the fix. To check first:

from tensorweaver._engine import get_core

try:
    get_core(usegpu=True)    # loads the CUDA engine and probes for a device
    has_gpu = True
except ImportError as err:   # no CUDA engine in this build, or no usable GPU
    has_gpu = False
    print(err)

The [cuda] extra supplies cuBLAS and cuSOLVER. The NVIDIA driver itself is not redistributable and must come from the system; nvidia-smi working is the usual sign that it is there.

Those two libraries do not have to be on any library search path. The extra installs them under site-packages/nvidia/, which the loader knows nothing about, so selecting the CUDA engine opens them from there by absolute path first. Setting LD_LIBRARY_PATH is neither needed nor harmful, and a CUDA runtime already installed system-wide is used exactly as before.

Engines do not share objects

Objects are engine-specific

The engines are independent native modules. They can run side by side in one process, but their objects do not interoperate: an MPS, MPO, Rng, or TwState from one engine cannot be passed to a simulator or method backed by the other, and attempting it raises TypeError. Move a state across by serialising it:

from tensorweaver import TwSimulator

sim_ob  = TwSimulator(linalg="openblas")
sim_mkl = TwSimulator(linalg="mkl")

state = sim_ob.build_state(3)
blob = state.mps.serialize()                      # protobuf bytes, a copy
mps_mkl = sim_mkl._core.PyMps.deserialize(blob)   # usable by the MKL engine

Both engines compute the same results, so a workflow normally stays on one engine end to end.

TensorWeaverBasis

TensorWeaverBasis defines which operations run natively, without decomposition.

Terminal operations

Operation Handling
GateRX(θ), GateRY(θ), GateRZ(λ) Applied directly.
GateU(θ, φ, λ, γ) Applied directly.
GateID Skipped.
GateCX, GateCZ, GateISWAP Applied directly.
Any two-qubit unitary Applied from its matrix.
Control(n, 1Q-gate) Applied as a multi-controlled gate, for any number of controls.
Measure Collapses the state, stores the outcome in the classical register.
Reset, MeasureReset Measure, then a conditional X back to |0⟩.
IfStatement(op, bs) Applies op when the classical bits match bs. Terminal when op is.
Amplitude, BondDim, SchmidtRank, VonNeumannEntropy, ExpectationValue Read the state into the z-register.
Any krauschannel Mixed-unitary or general Kraus channel, on one or two qubits.
Classical logic (Not, And, Or, Xor, SetBit, ...) Applied to the classical register.
Z-register arithmetic (Add, Multiply, Pow) Combines z-variables.
Barrier A fusion boundary for the qubits it names; no effect on the state.
Annotations (Detector, loss markers, ...) No simulation semantics.

Decomposed operations

Everything else goes through the canonical rewrite, calling each gate's _decompose(). For example GateH becomes GateU(π/2, 0, π) and GateSWAP becomes three GateCX.

Multi-controlled gates

Control(n, 1Q-gate) is terminal for any number of controls: the operator is built directly rather than decomposed into a CX ladder. Toffoli (CCX), C3X, and higher-order controlled gates all take this path.

Z-variable operations

Z-variable operations observe the state without changing it, writing into the circuit's z-register. They never push a circuit into trajectory mode.

Operation Arguments Stores
Amplitude(bs) A BitString Complex amplitude ⟨bs\|ψ⟩
BondDim() A bond index Bond dimension, in the .real part
SchmidtRank() A bond index Schmidt rank above a 1e-12 cutoff, in the .real part
VonNeumannEntropy() A bond index Entanglement entropy in bits, in the .real part
ExpectationValue(op) 1 or 2 qubits for a gate or matrix operator, any number for a PauliString Complex ⟨ψ\|op\|ψ⟩

BondDim, SchmidtRank, and VonNeumannEntropy take a bond index, naming the bipartition {0..k-1} | {k..n-1}, not a qubit. Qubit reordering keeps those cuts intact and leaves the index alone.

from mimiqcircuits import (
    Circuit, GateH, GateCX, GateZ,
    Amplitude, BondDim, SchmidtRank, VonNeumannEntropy,
    ExpectationValue, BitString,
)
from tensorweaver import execute

c = Circuit()
c.push(GateH(), 0)
c.push(GateCX(), 0, 1)

c.push(Amplitude(BitString([0, 0])), 0)      # -> z0
c.push(BondDim(), 1, 1)                      # bond 1 -> z1
c.push(SchmidtRank(), 1, 2)                  # bond 1 -> z2
c.push(VonNeumannEntropy(), 1, 3)            # bond 1 -> z3
c.push(ExpectationValue(GateZ()), 0, 4)      # -> z4

results = execute(c, nsamples=1, num_qubits=2, bonddim=64)
zs = results.zstates[0]
print(f"amplitude |00>     {zs[0]}")
print(f"bond dim at cut 1  {zs[1].real:.0f}")
print(f"Schmidt rank       {zs[2].real:.0f}")
print(f"entropy (bits)     {zs[3].real:.4f}")
print(f"<Z> on qubit 0     {zs[4].real:.4f}")

Noise channels

Any krauschannel from mimiqcircuits on one or two qubits is applied directly. Mixed-unitary channels such as PauliX, Depolarizing, and PauliNoise are detected through ismixedunitary() and sample one unitary per shot. General channels such as AmplitudeDamping use the full operator-sum form.

Noise selects trajectory mode, so nsamples becomes the number of independent evolutions, and the ensemble over them reproduces the mixed state. Push the measurements you want to read: in trajectory mode the histogram comes from the classical register.

from mimiqcircuits import Circuit, GateH, GateCX, PauliX, Measure
from tensorweaver import execute

c = Circuit()
c.push(GateH(), 0)
c.push(GateCX(), 0, 1)
c.push(PauliX(0.05), 0)     # 5% bit-flip on qubit 0
c.push(PauliX(0.05), 1)     # 5% bit-flip on qubit 1
c.push(Measure(), 0, 0)
c.push(Measure(), 1, 1)

results = execute(c, nsamples=500, bonddim=64)
print(results.histogram())   # weight leaks onto 01 and 10

Conditional operations

IfStatement(op, bitstring) applies op only when the classical register matches. It selects trajectory mode.

from mimiqcircuits import Circuit, GateH, GateX, Measure, IfStatement, BitString
from tensorweaver import execute

c = Circuit()
c.push(GateH(), 0)
c.push(Measure(), 0, 0)
c.push(IfStatement(GateX(), BitString("1")), 1, 0)   # X on qubit 1 if bit 0 is set
c.push(Measure(), 1, 1)

results = execute(c, nsamples=200, num_qubits=2, bonddim=64)
print(results.histogram())   # only 00 and 11 appear

Using the basis directly

TensorWeaverBasis plugs into the mimiqcircuits decomposition API, which is useful for inspecting what a circuit lowers to before running it.

from mimiqcircuits import Circuit, GateH
from mimiqcircuits.decomposition import decompose, eachdecomposed
from tensorweaver.basis import TensorWeaverBasis

c = Circuit()
c.push(GateH(), 0)

decomposed = decompose(c, TensorWeaverBasis())

for inst in eachdecomposed(c, TensorWeaverBasis()):   # streaming variant
    print(inst)