Skip to Content

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 of Circuit.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 (0 or 1), populated only after measure operations 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 combinationUse 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 gates

In 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 structure

Commonly used methods:

MethodDescription
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/2 will 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.
  • unitary gates carrying matrix information (i.e., gates added via qc.unitary(matrix, target) whose g.matrix attribute is non-empty) are exported as UnitaryLab extension statements (with matrix elements encoded as alternating real/imaginary parameter values), and can be fully restored via from_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 call transpile() first. Only when the matrix information for a unitary gate is missing (g.matrix is None, which generally occurs on certain intermediate processing paths) will to_qasm() raise a NotImplementedError, with retry suggestions given in the error message (transpile=True or decompose=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 probabilities are 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, use result.marginal_probabilities() or result.sample(), neither of which modifies result.state; only result.measure() causes collapse.
  • GPU and backend: The default settings are suitable for most scenarios; adjust backend, device, dtype, and backend_options only 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.

Last updated on