Source code for qc_executor.qulacs.qulacs_circuit

"""Qulacs circuit wrapper built on the framework-independent circuit IR."""

from __future__ import annotations

from typing import Any, Callable, Iterable, List, Optional

import numpy as np
import sympy as sp
from qulacs import ParametricQuantumCircuit  # pylint: disable=no-name-in-module
from qulacs import QuantumCircuit as QulacsQuantumCircuit  # pylint: disable=no-name-in-module

from ..base.circuit_base import QuantumCircuitBase
from ..base.circuit_ir import CircuitIR
from ..base.expressions import compile_expression
from ..base.gate_set import GATE_DEFS, OpCode
from ..parameters import Parameter, sort_parameters
from .qulacs_gates import qiskit_qulacs_gate_dict, qiskit_qulacs_param_gate_dict

#: Opcodes Qulacs executes directly, taken from the gate tables below.  Anything
#: else is lowered by the shared decomposition pass, which replaced the
#: ``qiskit.transpile(target=qulacs_target)`` call this class used to make.
_SUPPORTED = frozenset(
    {
        OpCode.I,
        OpCode.H,
        OpCode.X,
        OpCode.Y,
        OpCode.Z,
        OpCode.S,
        OpCode.SDG,
        OpCode.T,
        OpCode.TDG,
        OpCode.SWAP,
        OpCode.CX,
        OpCode.CZ,
        OpCode.CCX,
        OpCode.RX,
        OpCode.RY,
        OpCode.RZ,
        OpCode.BARRIER,
    }
)


[docs] class QulacsCircuit(QuantumCircuitBase): """A quantum circuit that compiles to Qulacs. Built like any other circuit -- ``QulacsCircuit(2)`` then ``circuit.h(0)`` -- or converted from an existing one with :meth:`from_quantum_circuit`. Compilation into Qulacs operations is lazy and re-runs whenever the instruction store changes. Args: num_qubits: Number of qubits in the circuit. num_clbits: Number of classical bits. Qulacs cannot execute mid-circuit measurement, so these exist only for interface parity. _ir: Adopt this instruction store instead of starting empty. """
[docs] @classmethod def supported_opcodes(cls) -> frozenset: """Return the opcodes Qulacs executes directly.""" return _SUPPORTED
def __init__( self, num_qubits: int = 0, num_clbits: int = 0, *, _ir: "CircuitIR | None" = None, ) -> None: super().__init__(num_qubits, num_clbits, _ir=_ir) self._operation_list = [] self._qubit_list = [] self._func_list = [] self._func_grad_list = [] self._used_parameters = [] self._compiled_revision = -1 self._circuit_func_cache = {} self._outer_jacobi_circuit_cache = {} self._qulacs_circuit = None # ------------------------------------------------------------------ # Compilation # ------------------------------------------------------------------ def _ensure_compiled(self) -> None: """Compile the instruction store into Qulacs operations, once per revision.""" if self._compiled_revision != self._ir.revision: self._build_circuit_instructions(self._lowered_ir()) self._compiled_revision = self._ir.revision self._circuit_func_cache = {} self._outer_jacobi_circuit_cache = {} self._qulacs_circuit = None def _build_native(self) -> Callable: """Compile the instruction store into the callable Qulacs circuit.""" return self.get_circuit_func() @property def qulacs_circuit(self) -> Optional[Callable]: """Qulacs circuit that can be called with parameters""" return self._qulacs_circuit @property def parameter_names(self) -> list: """List of circuit parameter names""" return list(self.parameter_dimensions) @property def parameter_dimensions(self) -> dict: """Dictionary with the dimension of each circuit parameter. Derived from the instruction store rather than from the compiled operations, so it answers without forcing compilation. Lowering does not change which parameters appear, only which gates carry them. """ dimensions: dict = {} for parameter in self.parameters: dimensions[parameter.vector_name] = dimensions.get(parameter.vector_name, 0) + 1 return dimensions @property def _symbol_tuple_circuit(self) -> tuple: """Symbols the lambdified angle callables accept, in argument order. Derived rather than stored so an angle can be lambdified before the circuit has been compiled, and so this order is by construction the same one :attr:`parameter_dimensions` reports. """ return tuple(self.parameters) @property def circuit_arguments(self) -> dict: """Dictionary of all circuit and observable parameters names""" return self.parameter_dimensions @property def free_parameters(self) -> set: """Return the set of free (non-bound) parameters in the circuit.""" return set(self._ir.free_parameters)
[docs] def get_qulacs_circuit(self) -> Callable: """Builds and returns the Qulacs circuit as callable function""" self._qulacs_circuit = self.get_circuit_func() return self._qulacs_circuit
def __call__(self, *args, **kwargs): if self._qulacs_circuit is None: self._qulacs_circuit = self.get_circuit_func() return self._qulacs_circuit(*args, **kwargs) def _add_parameter_expression(self, angle: Any) -> Any: """ Adds a parameter expression to the circuit and do the pre-processing. Angles arrive as plain numbers or SymPy expressions. Symbolic angles are compiled once (see :func:`compile_expression`), and their derivatives come from ``sympy.diff``; this used to go through Qiskit's ``ParameterExpression.gradient``. Args: angle: Angle of rotation, numeric or symbolic. Returns: tuple: the angle callable (or constant), the per-parameter gradient callables, the parameters used, and whether it is symbolic. Raises: TypeError: If the angle is neither numeric nor a SymPy expression. """ func_list_element = None func_grad_list_element = None parameterized = False used_parameters = None # Change sign because of the way Qulacs defines the rotation gates angle = -angle if isinstance(angle, sp.Basic) and angle.free_symbols: parameterized = True func_list_element = compile_expression(angle, self._symbol_tuple_circuit) func_grad_list_element = [] used_parameters = [] for param_element in sort_parameters( s for s in angle.free_symbols if isinstance(s, Parameter) ): used_parameters.append(param_element) derivative = sp.diff(angle, param_element) if derivative.free_symbols: func_grad_list_element.append( compile_expression(derivative, self._symbol_tuple_circuit) ) else: # Call-by-value so the closure keeps this gate's constant. value = float(derivative) func_grad_list_element.append(lambda *_args, value=value: value) elif isinstance(angle, (float, int, sp.Basic)): func_list_element = float(angle) func_grad_list_element = None else: raise TypeError( f"Unsupported type for angle: {type(angle)}. " "Expected a number or a SymPy expression." ) return func_list_element, func_grad_list_element, used_parameters, parameterized def _add_single_qubit_gate(self, gate_name: str, qubits: int | Iterable[int]): """ Adds a single qubit gate to the circuit. Args: gate_name (str): Name of the gate qubits (int or Iterable[int]): qubit indices """ qubits = [qubits] if isinstance(qubits, int) else qubits for q in qubits: self._operation_list.append(gate_name) if q >= self.num_qubits: raise ValueError(f"Qubit index {q} is out of range") self._qubit_list.append([q]) self._func_list.append(None) self._func_grad_list.append(None) self._used_parameters.append([]) self._rebuild_circuit_func = True def _add_two_qubit_gate( self, gate_name: str, qubit1: int | Iterable[int], qubit2: int | Iterable[int] ) -> None: """ Adds a two qubit gate to the circuit. Args: gate_name (str): Name of the gate qubit1 (int or Iterable[int]): qubit indices of the first qubit (e.g. control) qubit2 (int or Iterable[int]): qubit indices of the second qubit (e.g. target) """ qubit1 = [qubit1] if isinstance(qubit1, int) else qubit1 qubit2 = [qubit2] if isinstance(qubit2, int) else qubit2 for control, target in zip(qubit1, qubit2): if control >= self.num_qubits or target >= self.num_qubits: raise ValueError("Qubit index is out of range") self._operation_list.append(gate_name) self._qubit_list.append([control, target]) self._func_list.append(None) self._func_grad_list.append(None) self._used_parameters.append([]) self._rebuild_circuit_func = True def _add_three_qubit_gate(self, gate_name: str, qubit1: int, qubit2: int, qubit3: int) -> None: """ Adds a three qubit gate to the circuit. Args: gate_name (str): Name of the gate qubit1 (int): qubit index of the first qubit (e.g. first control) qubit2 (int): qubit index of the second qubit (e.g. second control) qubit3 (int): qubit index of the third qubit (e.g. target) """ for q in (qubit1, qubit2, qubit3): if q >= self.num_qubits: raise ValueError(f"Qubit index {q} is out of range") self._operation_list.append(gate_name) self._qubit_list.append([qubit1, qubit2, qubit3]) self._func_list.append(None) self._func_grad_list.append(None) self._used_parameters.append([]) self._rebuild_circuit_func = True def _add_parameterized_single_qubit_gate( self, gate_name: str, qubits: int | Iterable[int], angle: Any, ): """ Adds a single qubit parameterized gate to the circuit. Args: gate_name (str): Name of the gate qubits (int or Iterable[int]): qubit indices angle: Angle of rotation; a number or a SymPy expression """ func_list_element, func_grad_list_element, used_parameters, _ = ( self._add_parameter_expression(angle) ) qubits = [qubits] if isinstance(qubits, int) else qubits for q in qubits: if q >= self.num_qubits: raise ValueError(f"Qubit index {q} is out of range") self._operation_list.append(gate_name) self._qubit_list.append([q]) self._func_list.append(func_list_element) self._func_grad_list.append(func_grad_list_element) if used_parameters is None: self._used_parameters.append([]) else: self._used_parameters.append(used_parameters) self._rebuild_circuit_func = True def _add_parameterized_two_qubit_gate( self, gate_name: str, qubit1: int | Iterable[int], qubit2: int | Iterable[int], angle: Any, ): """ Adds a single qubit parameterized gate to the circuit. Args: gate_name (str): Name of the gate qubits (int or Iterable[int]): qubit indices angle: Angle of rotation; a number or a SymPy expression """ func_list_element, func_grad_list_element, used_parameters, _ = ( self._add_parameter_expression(angle) ) qubit1 = [qubit1] if isinstance(qubit1, int) else qubit1 qubit2 = [qubit2] if isinstance(qubit2, int) else qubit2 for control, target in zip(qubit1, qubit2): if control >= self.num_qubits or target >= self.num_qubits: raise ValueError("Qubit index is out of range") self._operation_list.append(gate_name) self._qubit_list.append([control, target]) self._func_list.append(func_list_element) self._func_grad_list.append(func_grad_list_element) if used_parameters is None: self._used_parameters.append([]) else: self._used_parameters.append(used_parameters) self._rebuild_circuit_func = True def _build_circuit_instructions(self, ir: CircuitIR) -> None: """Build the Qulacs instruction lists from the circuit IR. Walks the lowered instruction store directly; this used to iterate a transpiled Qiskit circuit and look gates up by their Qiskit name. Args: ir: The lowered instruction store. Raises: NotImplementedError: For gates or features Qulacs cannot express. """ self._operation_list = [] self._param_list = [] self._qubit_list = [] self._func_list = [] self._func_grad_list = [] for instruction in ir: # Barriers are compiler directives with no effect on the statevector. if instruction.opcode is OpCode.BARRIER: continue if instruction.condition is not None or instruction.opcode in { OpCode.MEASURE, OpCode.RESET, }: raise NotImplementedError( "Mid-circuit measurement, reset and classical conditions are not " "supported by the Qulacs backend." ) name = GATE_DEFS[instruction.opcode].name if name not in qiskit_qulacs_gate_dict and name not in qiskit_qulacs_param_gate_dict: raise NotImplementedError( f"Gate {name} is unfortunatly not supported in sQUlearn's Qulacs backend." ) wires = list(instruction.qubits) parameterized_gate = bool(instruction.params) if len(wires) == 1: if not parameterized_gate: self._add_single_qubit_gate(name, wires) else: self._add_parameterized_single_qubit_gate(name, wires, instruction.params[0]) elif len(wires) == 2: if not parameterized_gate: self._add_two_qubit_gate(name, wires[0], wires[1]) else: self._add_parameterized_two_qubit_gate( name, wires[0], wires[1], instruction.params[0] ) elif len(wires) == 3 and not parameterized_gate: self._add_three_qubit_gate(name, wires[0], wires[1], wires[2]) else: raise NotImplementedError( "Only up to three qubit (non-parameterized) gates are supported " "in sQUlearn's Qulacs backend." )
[docs] def get_circuit_func(self, gradient_param=None): """Returns the Qulacs circuit function for the circuit.""" self._ensure_compiled() if isinstance(gradient_param, Parameter): gradient_param = [gradient_param] gradient_param = list(gradient_param) if gradient_param is not None else [] is_parameterized = len(gradient_param) parameterized_operations = [ any(param in gradient_param for param in self._used_parameters[i]) for i, _ in enumerate(self._operation_list) ] cache_value = "no_gradient" if is_parameterized: cache_value = tuple(gradient_param) if cache_value in self._circuit_func_cache: return self._circuit_func_cache[cache_value] def qulacs_circuit(*args): # Collects the args values connected to the circuit parameters circ_param_list = sum([list(args[i]) for i in range(len(self.parameter_names))], []) if is_parameterized: circuit = ParametricQuantumCircuit(self.num_qubits) else: circuit = QulacsQuantumCircuit(self.num_qubits) # Build the Qulacs circuit and evaluate the parametric terms for i, qulacs_operation in enumerate(self._operation_list): if self._func_list[i] is None: qiskit_qulacs_gate_dict[qulacs_operation](circuit, *self._qubit_list[i]) elif isinstance(self._func_list[i], (float, int)): qiskit_qulacs_gate_dict[qulacs_operation]( circuit, self._func_list[i], *self._qubit_list[i] ) else: value = self._func_list[i](*circ_param_list) if parameterized_operations[i]: assert isinstance(circuit, ParametricQuantumCircuit) qiskit_qulacs_param_gate_dict[qulacs_operation]( circuit, value, *self._qubit_list[i] ) else: qiskit_qulacs_gate_dict[qulacs_operation]( circuit, value, *self._qubit_list[i] ) return circuit self._circuit_func_cache[cache_value] = qulacs_circuit return qulacs_circuit
[docs] def get_gradient_outer_jacobian( self, gradient_parameters: Parameter | List[Parameter] | None = None, ): """Returns the outer jacobian needed for the chain rule in circuit derivatives. Qulacs does not support multiple parameters and parameter expressions, so we need to calculate a transformation which also includes the gradient of the parameter expression. Args: gradient_parameters (Parameter | List[Parameter] | None): Parameters to calculate the gradient for """ self._ensure_compiled() if isinstance(gradient_parameters, Parameter): gradient_parameters = [gradient_parameters] gradient_parameters = list(gradient_parameters) if gradient_parameters is not None else [] gradient_param_dict = {p: i for i, p in enumerate(gradient_parameters)} cache_value = "no_gradient" if len(gradient_parameters) > 0: cache_value = tuple(gradient_parameters) if cache_value in self._outer_jacobi_circuit_cache: return self._outer_jacobi_circuit_cache[cache_value] def outer_jacobian(*args): # Collects the args values connected to the circuit parameters circ_param_list = sum([list(args[i]) for i in range(len(self.parameter_names))], []) relevant_operations = [ i for i in range(len(self._operation_list)) if any(param in gradient_parameters for param in self._used_parameters[i]) ] outer_jacobian = np.zeros((len(relevant_operations), len(gradient_parameters))) for i, operation in enumerate(relevant_operations): for j, param in enumerate(self._used_parameters[operation]): if param in gradient_parameters: outer_jacobian[i, gradient_param_dict[param]] = self._func_grad_list[ operation ][j](*circ_param_list) return outer_jacobian self._outer_jacobi_circuit_cache[cache_value] = outer_jacobian return outer_jacobian