09 custom gate registration
Custom gate registration¶
propaq automatically decomposes any gate outside its native basis (xx_plus_yy, p, rz, rx, ry, cp, x, swap for Qiskit; the *PowGate family for Cirq) via transpilation. This can produce far more rotations than necessary,
which can become expensive for large circuits.
If you already know the exact generator decomposition for a gate, propaq.circuits.register_qiskit_gate/register_cirq_gate let you register it directly, skipping the transpiler entirely. propaq automatically checks a registered gate's output against its own trusted decomposition path the first time it's actually used, and raises an error if they disagree. This notebook demonstrates both the speedup and that safety feature.
Setup¶
We'll use CNOT here, since it's not in propaq's native basis, but it has a short, exact closed form. We'll build a circuit that's mostly CNOTs - a linear entangling ladder repeated over several layers - and track how a single-qubit Z observable propagates through it.
import math
import time
from qiskit import QuantumCircuit
from qiskit.quantum_info import SparsePauliOp
from propaq.circuits import (
GateValidationError,
PauliCircuit,
pauli_rotation_generator,
register_qiskit_gate,
)
from propaq.datatypes import PauliTermSum
from propaq.propagators import PauliPropagator
N_QUBITS = 6
DEPTH = 8
qc = QuantumCircuit(N_QUBITS)
for _ in range(DEPTH):
for i in range(N_QUBITS - 1):
qc.cx(i, i + 1)
print(f"{len(qc.data)} CNOTs")
obs = PauliTermSum.from_sparse_pauli_op(SparsePauliOp("Z" + "I" * (N_QUBITS - 1)))
40 CNOTs
The default path¶
Without registering anything, propaq decomposes every CNOT via Qiskit's transpiler into the native rotation basis. Let's see how many primitive rotations that produces, and how long it takes to build the circuit.
t0 = time.perf_counter()
circuit_default = PauliCircuit.from_qiskit(qc)
t1 = time.perf_counter()
want = PauliPropagator().expectation_value(obs, circuit_default, initial_state=0).expectation_value
print(f"{len(circuit_default.rotations)} rotations, built in {t1 - t0:.4f}s")
print("expectation value:", want)
280 rotations, built in 0.0261s expectation value: 1.0
The exact decomposition¶
CNOT has a well-known closed form:
$$\text{CNOT}_{c,t} = e^{i\pi/4}\, e^{-i\frac{\pi}{4}Z_c}\, e^{-i\frac{\pi}{4}X_t}\, e^{i\frac{\pi}{4}Z_c X_t}$$
In propaq's rotation convention, that's coefficients $(\pi/2,\ \pi/2,\ -\pi/2)$ on generators $Z{\otimes}I$, $I{\otimes}X$, $Z{\otimes}X$. pauli_rotation_generator builds a generator from a Pauli label directly and it works for both the Pauli and Majorana representations, and for both the Qiskit and Cirq frontends.
def cnot_terms(instr_or_op, q_indices, width, rep):
i, j = q_indices # control, target
n_qubits = rep.qubits_in_width(width)
def label(axis_i, axis_j):
chars = ["I"] * n_qubits
if axis_i:
chars[n_qubits - 1 - i] = axis_i
if axis_j:
chars[n_qubits - 1 - j] = axis_j
return "".join(chars)
terms = []
for axis_i, axis_j, coeff in (
("Z", None, math.pi / 2),
(None, "X", math.pi / 2),
("Z", "X", -math.pi / 2),
):
gen, unit = pauli_rotation_generator(rep, label(axis_i, axis_j))
terms.append((gen, coeff * unit))
return [terms]
register_qiskit_gate("cx", cnot_terms)
Building the circuit again now uses the registered fast path. The very first build still validates it against propaq's own decomposition every build after that is served directly, with no transpilation and no re-validation.
t0 = time.perf_counter()
circuit_first = PauliCircuit.from_qiskit(qc) # validates once here
t1 = time.perf_counter()
print(f"first registered build (validates): {len(circuit_first.rotations)} rotations, {t1 - t0:.4f}s")
t0 = time.perf_counter()
circuit_fast = PauliCircuit.from_qiskit(qc) # served from cache, no validation
t1 = time.perf_counter()
print(f"cached registered build: {len(circuit_fast.rotations)} rotations, {t1 - t0:.4f}s")
got = PauliPropagator().expectation_value(obs, circuit_fast, initial_state=0).expectation_value
print("\nexpectation value:", got)
assert got == want
first registered build (validates): 120 rotations, 0.0426s cached registered build: 120 rotations, 0.0006s expectation value: 1.0
The validation safety feature¶
To see the check in action, here's what happens if we register a decomposition that's subtly wrong.
def broken_cnot_terms(instr_or_op, q_indices, width, rep):
i, j = q_indices
n_qubits = rep.qubits_in_width(width)
chars = ["I"] * n_qubits
chars[n_qubits - 1 - i] = "Z"
chars[n_qubits - 1 - j] = "X"
gen, unit = pauli_rotation_generator(rep, "".join(chars))
return [[(gen, math.pi * unit)]] # missing the Z_c and X_t terms
register_qiskit_gate("cx", broken_cnot_terms)
try:
PauliCircuit.from_qiskit(qc)
except GateValidationError as exc:
print(f"{type(exc).__name__}:")
print(str(exc)[:400], "...")
GateValidationError: propaq: custom terms_fn registered for Qiskit gate 'cx' (pauli representation) disagrees with propaq's own decomposition: params=actual observable='IY' generator=PauliString(x=0b10, z=0b11, n_qubits=6): expected 1.0, got 0j params=actual observable='IY' generator=PauliString(x=0b10, z=0b10, n_qubits=6): expected 0j, got -1.0 params=actual observable='IZ' generator=PauliString(x=0b0, z=0b11, n_qubi ...
Cirq¶
The same mechanism works for Cirq circuits via register_cirq_gate, keyed by exact gate type.
import cirq
from propaq.circuits import register_cirq_gate
q = cirq.LineQubit.range(N_QUBITS)
cirq_circuit = cirq.Circuit(
cirq.CNOT(q[i], q[i + 1]) for _ in range(DEPTH) for i in range(N_QUBITS - 1)
)
default_cirq_circuit = PauliCircuit.from_cirq(cirq_circuit)
print(f"default: {len(default_cirq_circuit.rotations)} rotations")
register_cirq_gate(type(cirq.CNOT), cnot_terms)
PauliCircuit.from_cirq(cirq_circuit) # first call validates
fast_cirq_circuit = PauliCircuit.from_cirq(cirq_circuit) # cached
print(f"registered: {len(fast_cirq_circuit.rotations)} rotations")
got_cirq = PauliPropagator().expectation_value(obs, fast_cirq_circuit, initial_state=0).expectation_value
assert got_cirq == want
default: 200 rotations registered: 120 rotations