Circuit Execution and Toolchain Workflow
Overview
This page brings together the core usage of circuit execution, result inspection, circuit analysis and drawing, and OpenQASM import/export, serving as a quick reference for the complete workflow.
Executing Circuits and Inspecting Results
Regular users call Circuit.execute() directly, which returns an ExecutionResult object:
from unitarylab import Circuit
qc = Circuit(2)
qc.h(0)
qc.cx(0, 1)
result = qc.execute()Core Fields and Methods of ExecutionResult
result.num_qubits: The number of qubits represented by the result.result.shots: The number of circuit executions represented by this result.result.state: The final statevector, a read-only one-dimensional NumPy array (converted from the backend state on every access, with no caching); for multiple shots, it is the state from the last shot.result.backend_state: The raw underlying backend state object (e.g., a torch tensor on GPU), with no device copy performed; can be used directly in performance-sensitive scenarios.result.probabilities: The complete computational-basis probability dictionary, including zero-probability outcomes, with little-endian binary strings as keys.result.probability(bitstring, qubits=None): Queries the probability of a single computational basis state outcome.result.marginal_probabilities(qubits=None, threshold=0.0): The marginal probability distribution over a specified subset of qubits; a positive threshold omits smaller values.result.sample(shots=1, qubits=None, seed=None): Samples a number of computational basis state outcomes according to the probability distribution without collapsing the state, returning a list of strings.result.expectation(observable, qubits=None): Computes the expectation value of an observable , supporting multiple input forms such as"Z","XX", a full-length Pauli string, or a list of(coefficient, operator, qubit)terms.result.measure(target_indices, seed=None): Performs a projective measurement and collapses the internal state for the specified qubits, returning the measurement result string; this seed is independent ofCircuit.execute(seed=...).result.counts: Classical bit-string counts aggregated across all shots.result.classical_results_map: A dictionary mapping classical bit indices to the last shot’s measured values (0or1), populated only aftermeasureoperations have been added to the circuit.result.classical_registers: The last shot’s measurement results grouped by register name.
print(result.state)
# [0.70710678+0.j 0.+0.j 0.+0.j 0.70710678+0.j]
print(result.probabilities)
# {'00': 0.4999..., '10': 0.0, '01': 0.0, '11': 0.4999...}
print(result.expectation("ZZ", qubits=(0, 1)))
# Under the Bell state, the ZZ expectation value should be close to 1 (the two qubits are fully correlated)Characters in a Pauli string correspond to qubits in the order supplied through qubits. When qubits is omitted, the first character acts on qubit 0, the second on qubit 1, and so on. This differs from probability-string display order, where qubit 0 is the rightmost bit.
Backend, Device, and Execution Parameters
execute() supports the backend, device, dtype, shots, seed, and backend_options parameters:
result = qc.execute(backend='torch', device='cpu')| Parameter combination | Use case |
|---|---|
backend='torch', device='cpu' (default) | Suitable for most situations |
backend='torch', device='gpu' | Uses a compatible CUDA or Apple MPS PyTorch environment |
backend='numpy', device='cpu' | Pure NumPy environment |
backend='cpp', device='cpu' | Uses compiled C++ gate kernels to accelerate CPU execution |
backend='cpp', device='gpu' | Optional CUDA C++ backend; install unitarylab[cuda] |
backend='tensornet', device='cpu' | Large qubit counts with low-entanglement circuits, using an MPS representation |
Usually qc.execute() needs no explicit parameters. Torch can use compatible CUDA or Apple MPS GPUs. After installing unitarylab[cuda], C++ can also use CUDA with device='gpu'. The extra supports Linux x86_64, Linux aarch64 server environments, and Windows x86-64, and installs the matching unitarylab-cu12 wheel. A compatible NVIDIA driver is required, but a local CUDA Toolkit installation is not. If that optional extension is unavailable, use Torch GPU, C++ CPU, Torch CPU, or NumPy. For large, weakly entangled circuits, try tensornet with max_bond/cutoff/routing. seed defaults to 42; pass None for nondeterministic sampling.
Density Matrices and Noise Models
Use Circuit.execute_density() for mixed states and noise. This interface is separate from the statevector backends selected through execute(): device="cpu" uses the native cppdensity backend, while device="gpu", "cuda", or "cuda:N" uses the CUDA density-matrix backend when the CUDA extra is installed.
import numpy as np
from unitarylab import Circuit
qc = Circuit(2)
qc.h(0)
qc.cx(0, 1)
result = qc.execute_density(initial_state=None, device="cpu", dtype=np.complex128)
print(result.state)
print(result.trace)
print(result.purity)
print(result.probabilities)
print(result.expectation("ZZ"))initial_state may be None, a statevector, a density matrix, or DensityMatrixState. DensityMatrixResult exposes state, backend_state, trace, purity, probabilities, probability(), marginal_probabilities(), and expectation(). The density-matrix backends support unitary evolution, but not circuit measurement, state collapse, or shots/counts.
Built-in single-qubit channels are BitFlip, PhaseFlip, DepolarizingNoise, AmplitudeDamping, and PhaseDamping:
from unitarylab.backend import AmplitudeDamping, BitFlip, NoiseModel, NoiseOperator
noise_model = NoiseModel([
NoiseOperator(BitFlip(0.02), gate_ids=("x", "rx")),
NoiseOperator(AmplitudeDamping(0.05), qubits=(1,)),
])
noisy_result = qc.execute_density(noise_model=noise_model)Rules run after matching primitive gates. A NoiseOperator qubit filter matches gate targets only, not control qubits. For a multi-target gate, a matching single-qubit channel is applied independently to each matching target. gate_ids=None matches every physical primitive gate, while qubits=None applies the channel to every target of a matching gate. Identity (i) and global-phase gates (gp, gphase, global_phase) do not trigger noise. Public custom Kraus channels, pre-gate noise, correlated multi-qubit noise, and readout error are not currently supported.
Optimization, Compilation, and Hardware Submission
from unitarylab import Circuit
TOKEN = "your-provider-token"
qc = Circuit(2)
qc.h(0)
qc.cx(0, 1)
optimized = qc.optimize(optimization_level=1)
transpiled = qc.transpile(basis="default")
compiled = qc.compile(provider="guodun", token=TOKEN) # no submission
hardware_result = qc.submit(
provider="guodun", machine="gd_qc1", token=TOKEN, shots=1024
)optimize() and transpile() return a Circuit. compile() returns a CompilationResult containing the mapped circuit and QCIS, but does not submit a job or consume credits. submit() sends a hardware job and returns a provider-neutral HardwareResult. Guodun and Tianyan query the machine list and select a machine automatically when machine is omitted; other adapters generally require an explicit machine.
compile() supports only local, guodun, and tianyan; other provider adapters compile as part of submit(). Provider credentials may be passed explicitly through token. When omitted, Guodun and Tianyan fall back to GUODUN_LOGIN_KEY and TIANYAN_LOGIN_KEY, respectively; advanced QCompiler configuration may also use hardware.login_key.
For OpenQASM inputs or detailed stage configuration, use the lazily exported QCompiler:
from unitarylab import QCompiler
# Construct without advanced configuration; CompileConfig, a configuration
# mapping, or a configuration-file path may also be supplied.
compiler = QCompiler()
optimized = compiler.optimize(circuit_or_qasm)
transpiled = compiler.transpile(qc, basis="default")
compiled = compiler.compile(circuit_or_qasm, provider="guodun", token=TOKEN)
hardware_result = compiler.submit(
circuit_or_qasm, provider="guodun", token=TOKEN, shots=1024
)The artifact flow is raw_qasm -> optimized_qasm -> mapped_qcis -> hardware_result. LQCloud requires lqcloud, QPanda3 requires pyqpanda3, and Quafu requires QuarkStudio (quarkstudio); missing SDKs are reported during submission.
Circuit Analysis and Drawing
draw()
qc.draw() # opens a plot window (default output='mpl')
qc.draw(filename='circuit.png') # save as a file
qc.draw(filename='circuit.png', title='Bell State') # add a title
qc.draw(output='text') # plain-text circuit diagram
qc.draw(output='latex') # LaTeX (quantikz) circuit diagram
qc.draw(compact=False) # disable merging of adjacent single-qubit gatesIn Jupyter Notebooks, the diagram is displayed inline; in regular scripts, it opens in a separate window. output supports three modes: 'mpl' (default, Matplotlib), 'text', and 'latex', corresponding internally to MatplotlibCircuit, TextCircuitDrawer, and LatexCircuitDrawer respectively.
CircuitInfo
CircuitInfo provides static circuit analysis, and can be called via the qc.analyze() shortcut:
from unitarylab.circuit_analysis import CircuitInfo
info = CircuitInfo(qc) # equivalent to info = qc.analyze(show=False)
info.show() # prints the overview, instruction list, and layer structureCommonly used methods:
| Method | Description |
|---|---|
size() | Total gate count |
depth() | Number of conflict-free layers produced by greedy build_layers(); a static parallel-grouping metric, not a strict order-preserving data-dependency critical path |
count_ops() | Number of occurrences of each gate type, returned as a dict |
count_single_qubit_gates() / count_two_qubit_gates() / count_multi_qubit_gates() | Gate counts classified by qubit count |
count_parameterized_gates() | Number of gates with parameters (rotation angles, etc.) |
get_qubit_usage() | Number of operations on each qubit |
get_coupling_map() | Qubit coupling relationships (two-qubit gate connection pairs), e.g. [(0, 1), (1, 2)] |
get_qubit_history(qubit) | Complete operation history for a specified qubit |
get_instructions() / get_layers() / get_parameters() | Respectively return the instruction list, gates grouped by the greedy build_layers() rule, and a summary of parameterized gate parameters |
is_parameterized() | Whether the circuit contains parameterized gates |
get_summary() / to_dict() | Structured overview / exports the analysis results as a dictionary |
show(sections=None, qubit=None) | Prints analysis results by section |
The sections values supported by show(): 'overview' (or its alias 'summary'), 'instructions', 'layers', 'qubit_usage', 'coupling_map', 'parameters', 'qubit_history' (requires also specifying the qubit= parameter). When sections is not passed, it defaults to printing ['overview', 'instructions', 'layers'].
info.show(sections=['overview', 'coupling_map'])
info.show(sections='qubit_history', qubit=0)OpenQASM Import and Export
Both OpenQASM 2.0 and 3.0 are supported, suitable for saving and exchanging circuits, or interoperating with other tools. For a quick reference of the public methods and parameters, see Core Circuit Interface; this section focuses on the underlying interfaces and compatibility limitations.
# Using the qc built earlier; exports OpenQASM 3.0 by default
qasm3_str = qc.to_qasm()
qasm2_str = qc.to_qasm2()
# Import (automatically detects 2.0 / 3.0)
qc2 = Circuit.from_qasm(qasm3_str)Underlying Functions (Advanced Usage)
Circuit.to_qasm() / from_qasm() internally delegate to a set of functions in the unitarylab.backend.qasm module, which operate directly on GateSequence rather than Circuit. Direct calls are only needed when you need to work with GateSequence independently of Circuit, or need to explicitly specify the register structure:
from unitarylab.backend.qasm import (
gate_sequence_to_qasm, # unified entry point, exports QASM 3.0 by default
gate_sequence_to_qasm2,
gate_sequence_to_qasm3,
gate_sequence_from_qasm, # unified entry point, automatically detects the version
qasm2_to_gate_sequence,
qasm3_to_gate_sequence,
circuit_from_gate_sequence, # rebuilds a Circuit from a GateSequence
)
qasm_str = gate_sequence_to_qasm3(qc.gate_sequence, qreg_name='q')
gs = qasm3_to_gate_sequence(qasm_str)
qc3 = circuit_from_gate_sequence(Circuit, gs)Supported Scope and Limitations
The converters cover common single-qubit, parameterized, controlled, SWAP, and measurement operations, as well as custom gate definitions. QASM 3.0 uses ctrl @ / negctrl @ modifiers for positive and negative controls; QASM 2.0 rewrites controlled gates within the forms that version can represent. The exact round-trip syntax and gate set are defined independently by unitarylab.backend.qasm.qasm2 and unitarylab.backend.qasm.qasm3; the two versions do not have identical representational capabilities.
Key limitations:
- Both OpenQASM 2.0 and 3.0 are supported; the default export/auto-detection is based on 3.0. When 2.0 is needed, explicitly call
to_qasm2()/gate_sequence_to_qasm2(). - Gate parameters must be numeric values; symbolic expressions are not supported (e.g.,
pi/2will be numerically evaluated). - Classical control flow (
if,while, etc.) is not supported. - Not all custom gates can be fully round-tripped on export; non-contiguous block gates are inlined and expanded.
unitarygates carrying matrix information (i.e., gates added viaqc.unitary(matrix, target)whoseg.matrixattribute is non-empty) are exported as UnitaryLab extension statements (with matrix elements encoded as alternating real/imaginary parameter values), and can be fully restored viafrom_qasm()— this is a UnitaryLab-specific extension, not a standard OpenQASM 3.0 interoperability format; if the goal is to import into third-party tools, it is still recommended to calltranspile()first. Only when the matrix information for aunitarygate is missing (g.matrix is None, which generally occurs on certain intermediate processing paths) willto_qasm()raise aNotImplementedError, with retry suggestions given in the error message (transpile=Trueordecompose=True, transpile=True).
Comprehensive Example
Below is a minimal complete example that includes execution, result inspection, analysis, drawing, and OpenQASM export:
from unitarylab import Circuit
# 1. Create a Bell circuit
qc = Circuit(2)
qc.h(0)
qc.cx(0, 1)
# 2. Execute the circuit
result = qc.execute()
# 3. Inspect the probabilities
print(result.probabilities)
# {'00': 0.4999..., '11': 0.4999...}
# 4. Circuit analysis
qc.analyze(show=True, sections=["summary"])
# 5. Draw the circuit
qc.draw(title="Bell State")
# 6. Export OpenQASM 3.0 (see above for details)
qasm_str = qc.to_qasm()Notes
- Qubit ordering: The keys of
probabilitiesare little-endian binary strings, with the least significant bit corresponding to qubit 0. - Projective measurement collapses the state:
result.measure()modifies the internal state (projective collapse). If you need to repeatedly query the distributions of different subsystems on the same uncollapsed state, useresult.marginal_probabilities()orresult.sample(), neither of which modifiesresult.state; onlyresult.measure()causes collapse. - GPU and backend: The default settings are suitable for most scenarios; adjust
backend,device,dtype, andbackend_optionsonly when tuning performance. - Compilation is not submission:
compile()produces hardware artifacts;submit()sends the real job. - MDX special characters: In MDX files,
|within table cell content should be written as|; special characters such as{}and\should be placed inside code blocks or inline code.
Recommended Reading
- Full API of the core circuit interface → Core Circuit Interface
- Algorithm library (QFT, QPE, HHL, etc.) → Algorithms and Utility Library
- Molecular electronic structure, DMRG, and measurement → Chemistry Extension