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.
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. |
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 |
False |
Off by default: the circuit evolves in the layout you wrote. True (same as "greedy") is worth trying when the interaction graph has locality the numbering hides and the bond dimension is the limit. Use "sa" (simulated annealing) or "multilevel" when greedy leaves a high bond dimension and the layout search is worth more time. Leave it off when you need the MPS chain to keep your qubit numbering, when the interaction graph is genuinely all-to-all so nothing can be gained, or when the layout it picks turns out worse than yours. |
reorderqubits_alpha |
3.0 |
Exponent on the distance penalty in the layout cost, C(π) = Σ \|π(i) − π(j)\|^α · w(i,j). Raise it to punish long-range pairs harder, lower it to spread interactions more evenly. |
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.
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. In trajectory mode, trajectory i uses seed + i. |
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 |
False |
Fuses runs of gates into GateCustom blocks before evolution. Distinct from, and usually redundant with, the operator batching applied during evolution. |
fuse_threshold |
0 |
Minimum qubit count for fuse to act. Smaller circuits pass through unfused. |
canonicaldecompose |
False |
Decompose to the canonical basis before evolution. |
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 |
|---|---|---|
usemkl |
None |
Selects the BLAS engine. See BLAS engine selection. |
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()returnsdict[BitString, int], counting each classical state.results.histzvars()returnsdict[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)
BLAS engine selection (usemkl)¶
The dense linear algebra underneath the MPS runs on a BLAS/LAPACK library. Engines are separate native modules, selected per simulator:
usemkl |
Engine | Availability |
|---|---|---|
None (default) |
Whichever this build ships, OpenBLAS for preference | Always works: the default is resolved from the engines actually installed. |
False |
OpenBLAS | Vendored into the wheel; works from a plain pip install with no system BLAS. |
True |
Intel MKL | Comes from the mkl package, a dependency of the Linux x86_64 and Windows wheels. |
from mimiqcircuits import Circuit, GateH, GateCX
from tensorweaver import TwSimulator, execute
circuit = Circuit()
circuit.push(GateH(), 0)
circuit.push(GateCX(), 0, 1)
sim = TwSimulator(usemkl=True) # whole simulator
results = sim.execute(circuit, nsamples=1000)
results = execute(circuit, nsamples=1000, usemkl=True) # one call
Which engines a wheel contains depends on the platform: Linux ships both,
macOS arm64 ships OpenBLAS only (MKL is x86-only), and Windows ships MKL only.
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.usemkl.
An explicit usemkl is never quietly switched. usemkl=False 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.
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 usemkl=True 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.
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"oralgorithm="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.usemkl=Truecannot be combined with it. The CUDA engine is a single module whose host provider is OpenBLAS.- Circuits needing trajectory mode are refused: a mid-circuit
MeasureorReset, 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. Useusegpu=Falsefor 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
get_core().gpu_available() # False on a machine with no device
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(usemkl=False)
sim_mkl = TwSimulator(usemkl=True)
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, ParityCheck, 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, matching op |
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)