Skip to content

Commit c688134

Browse files
jan-analexanderivriiCryoris
authored
SynthesizeRZRotations pass (#15641)
* SynthesizeRZRotations pass (WIP) * formatting fixes * suggested changes to synthesize_rz_rotations.rs * changes to synthesize_rz_rotations.py * changes to synthesize_rz_rotations.py * changes to release notes * change tau to two_pi * added more functions for canonicalization and included code for reusing synthesis of same angles * canonical representation corrections * removes square brackets * added a param test * angle approximation logic implemented and epsilon reduced * documentation added and epsilon modified to approximation degree * added more tests * change to t_count test * fix lint and doc issues * empty-commit-deleted-empty-lines-to-rerun-checks * lint fixes * header changes * typo correct * reorder pass import in init file * add to autosummary * documentation corrections * epsilon-related modifications * edited comments * release note correction * make clippy happy * minor test edits * documentation corrections * improve angle canonicalization test * lint fix * doc changes/ remove debug assert * Add description to doc * fix pylint expression not assigned issue for list comprehension * fix comments * use Result instead of PyResult & anyhow context to covert/wrap error for Result. Also convert panic! to unreachable! since ross_sellinger handles return of nonstandard gates * convert back to PyErr at call * formatting change * undo changes * separate rust and python calls for gridsynth_rz * pyres to res changes * pauli rotns fixed * fix panic and unwrap with expect * formatting changes * Adding a path to specify error budget for synthesis and for caching separately * Shortening the description of the pass based on review comments * Updating (and renaming) release note * minor tweak of pass docstring * clippy * typos mentioned in the review * adding tests for cache_error and synthesis_error * fixing merge conflict with main * Small fixes on metrics & test - our derivations use operator norm, not frobenius norm - use exact angle computation with arcsin - reduce some of the tests and add a real-life QFT one - use approximation_degree None instead of arithmetic in the signature - use PyResult over anyhow since that was the only result type * Review comments --------- Co-authored-by: Alexander Ivrii <alexi@il.ibm.com> Co-authored-by: Julien Gacon <jules.gacon@googlemail.com>
1 parent fe1b981 commit c688134

10 files changed

Lines changed: 506 additions & 4 deletions

File tree

crates/pyext/src/lib.rs

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -91,6 +91,8 @@ fn _accelerate(m: &Bound<PyModule>) -> PyResult<()> {
9191
add_submodule(m, ::qiskit_transpiler::passes::wrap_angles_mod, "wrap_angles")?;
9292
add_submodule(m, ::qiskit_transpiler::passes::optimize_clifford_t_mod, "optimize_clifford_t")?;
9393
add_submodule(m, ::qiskit_transpiler::passes::substitute_pi4_rotations_mod, "substitute_pi4_rotations")?;
94+
add_submodule(m, ::qiskit_transpiler::passes::synthesize_rz_rotations_mod, "synthesize_rz_rotations")?;
95+
9496
add_submodule(m, ::qiskit_transpiler::passes::convert_to_pauli_rotations_mod, "convert_to_pauli_rotations")?;
9597
Ok(())
9698
}

crates/synthesis/src/ross_selinger.rs

Lines changed: 8 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -66,16 +66,20 @@ where
6666
Ok(circuit)
6767
}
6868

69-
#[pyfunction]
70-
#[pyo3(name = "gridsynth_rz")]
71-
pub fn py_gridsynth_rz(theta: f64, epsilon: f64) -> PyResult<PyCircuitData> {
69+
pub fn gridsynth_rz(theta: f64, epsilon: f64) -> PyResult<CircuitData> {
7270
let res = gridsynth_gates(&mut config_from_theta_epsilon(
7371
theta, epsilon, 0u64, false, true,
7472
));
7573
let gates_iter = res.gates.chars();
7674
let phase = if res.global_phase { FRAC_PI_8 } else { 0. };
7775
let instruction_capacity = res.gates.len();
78-
circuit_from_string(gates_iter, phase, instruction_capacity).map(Into::into)
76+
circuit_from_string(gates_iter, phase, instruction_capacity)
77+
}
78+
79+
#[pyfunction]
80+
#[pyo3(name = "gridsynth_rz")]
81+
pub fn py_gridsynth_rz(theta: f64, epsilon: f64) -> PyResult<PyCircuitData> {
82+
gridsynth_rz(theta, epsilon).map(Into::into)
7983
}
8084

8185
/// Approximates 1q unitary matrix using Ross-Selinger algorithm

crates/transpiler/src/passes/mod.rs

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -49,6 +49,7 @@ mod remove_identity_equiv;
4949
pub mod sabre;
5050
mod split_2q_unitaries;
5151
mod substitute_pi4_rotations;
52+
mod synthesize_rz_rotations;
5253
pub mod unitary_synthesis;
5354
mod unroll_3q_or_more;
5455
pub mod vf2;
@@ -96,6 +97,7 @@ pub use remove_diagonal_gates_before_measure::{
9697
pub use remove_identity_equiv::{remove_identity_equiv_mod, run_remove_identity_equiv};
9798
pub use split_2q_unitaries::{run_split_2q_unitaries, split_2q_unitaries_mod};
9899
pub use substitute_pi4_rotations::{py_run_substitute_pi4_rotations, substitute_pi4_rotations_mod};
100+
pub use synthesize_rz_rotations::{py_run_synthesize_rz_rotations, synthesize_rz_rotations_mod};
99101
pub use unitary_synthesis::{
100102
UnitarySynthesisConfig, UnitarySynthesisState, run_unitary_synthesis, unitary_synthesis_mod,
101103
};
Lines changed: 200 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,200 @@
1+
// This code is part of Qiskit.
2+
//
3+
// (C) Copyright IBM 2026
4+
//
5+
// This code is licensed under the Apache License, Version 2.0. You may
6+
// obtain a copy of this license in the LICENSE.txt file in the root directory
7+
// of this source tree or at https://www.apache.org/licenses/LICENSE-2.0.
8+
//
9+
// Any modifications or derivative works of this code must retain this
10+
// copyright notice, and modified files need to carry a notice indicating
11+
// that they have been altered from the originals.
12+
13+
use pyo3::prelude::*;
14+
use std::f64::consts::{FRAC_PI_2, FRAC_PI_4, PI};
15+
16+
use crate::QiskitError;
17+
use qiskit_circuit::dag_circuit::DAGCircuit;
18+
use qiskit_circuit::operations::{OperationRef, Param, StandardGate, add_param};
19+
use qiskit_synthesis::ross_selinger::gridsynth_rz;
20+
21+
const MINIMUM_EPSILON: f64 = 1e-12; // minimum epsilon for synthesis
22+
const DEFAULT_ACCURACY: f64 = 1e-10; // default synthesis accuracy
23+
24+
/// Finds a canonical representation of an angle.
25+
///
26+
/// Given `angle`, this returns `(interval, angle_normalized)` such that
27+
/// angle_normalized = angle (mod pi/2)
28+
/// (angle - angle_normalized - interval * pi/2) = 0 (mod 4pi)
29+
///
30+
/// The canonical representation is limited by the f64 representation.
31+
/// Any angle that differs in decimal places beyond f64 will be non-unique.
32+
fn canonicalize_angle(angle: f64) -> (u8, f64) {
33+
let angle_normalized = angle.rem_euclid(FRAC_PI_2);
34+
let interval = ((angle - angle_normalized) / FRAC_PI_2)
35+
.round()
36+
.rem_euclid(8.) as u8;
37+
(interval, angle_normalized)
38+
}
39+
40+
/// Lookup table for fixing the circuit based on interval computed during
41+
/// canonicalization.
42+
///
43+
/// The table is based on the properties of `Rz(theta)` such as:
44+
/// * `Rz(theta + pi/2) = Rz(theta).S`, up to a global phase of `-pi/4`,
45+
/// * `Rz(theta + pi) = Rz(theta).Z`, up to a global phase of `-pi/2`,
46+
/// * `Rz(theta + 2*pi) = -Rz(theta)`, up to a global phase of `-pi`.
47+
static PHASE_GATE_LUT: [(f64, Option<StandardGate>); 8] = [
48+
(0.0, None),
49+
(-FRAC_PI_4, Some(StandardGate::S)),
50+
(-FRAC_PI_2, Some(StandardGate::Z)),
51+
(-3. * FRAC_PI_4, Some(StandardGate::Sdg)),
52+
(PI, None),
53+
(-5. * FRAC_PI_4, Some(StandardGate::S)),
54+
(-6. * FRAC_PI_4, Some(StandardGate::Z)),
55+
(-7. * FRAC_PI_4, Some(StandardGate::Sdg)),
56+
];
57+
58+
/// Approximates RZ-rotation using gridsynth.
59+
///
60+
/// Returns the sequence of gates in the synthesized circuit and
61+
/// an update to the global phase.
62+
fn synthesize_rz_gate_via_gridsynth(
63+
angle: f64,
64+
epsilon: f64,
65+
) -> PyResult<(Vec<StandardGate>, Param)> {
66+
let circ_data = gridsynth_rz(angle, epsilon)?;
67+
68+
// obtain phase from circuit data
69+
let phase = circ_data.global_phase().clone();
70+
71+
// get sequence of standard gates
72+
let sequence: Vec<StandardGate> = circ_data
73+
.data()
74+
.iter()
75+
.map(|inst| {
76+
if let OperationRef::StandardGate(gate) = inst.op.view() {
77+
gate
78+
} else {
79+
unreachable!("gridsynth only produces standard gates");
80+
}
81+
})
82+
.collect();
83+
Ok((sequence, phase))
84+
}
85+
86+
/// Synthesize RZ gates in the circuit, modifying the circuit in-place.
87+
///
88+
/// # Arguments
89+
///
90+
/// - `dag`: The DAG circuit in which the RZ gates will be synthesized.
91+
/// - `approximation_degree`: Controls the overall degree of approximation.
92+
/// - `synthesis_error`: Maximum allowed error for the approximate synthesis of
93+
/// :math:`RZ(\theta)`.
94+
/// - `cache_error`: Maximum allowed error when reusing a cached synthesis
95+
/// result for angles close to :math:`\theta`.
96+
///
97+
/// If both `synthesis_error` and `cache_error` are provided, they specify the error budget
98+
/// due to approximate synthesis and due to caching respectively. If either value is not
99+
/// specified, the total allowed error is derived from `approximation_degree`, and
100+
/// suitable values for `synthesis_error` and `cache_error` are computed automatically.
101+
#[pyfunction]
102+
#[pyo3(name = "synthesize_rz_rotations")]
103+
#[pyo3(signature = (dag, approximation_degree=None, synthesis_error=None, cache_error=None))]
104+
pub fn py_run_synthesize_rz_rotations(
105+
dag: &mut DAGCircuit,
106+
approximation_degree: Option<f64>,
107+
synthesis_error: Option<f64>,
108+
cache_error: Option<f64>,
109+
) -> PyResult<()> {
110+
// Skip the pass if there are no RZ rotation gates.
111+
if dag.get_op_counts().keys().all(|k| k != "rz") {
112+
return Ok(());
113+
}
114+
115+
// Compute error budgets. When approximation degree is used, the total error is
116+
// computed as 1 - approximation_degree, and the error budget for synthesis and for
117+
// caching are distributed equally.
118+
let (synthesis_error, cache_error) = match (synthesis_error, cache_error) {
119+
(Some(synthesis_error), Some(cache_error)) => (synthesis_error, cache_error),
120+
_ => {
121+
let total_error = if let Some(approximation_degree) = approximation_degree {
122+
MINIMUM_EPSILON.max(1. - approximation_degree)
123+
} else {
124+
DEFAULT_ACCURACY
125+
};
126+
(total_error / 2., total_error / 2.)
127+
}
128+
};
129+
130+
// By an explicit computation one can show that if the current angle is within
131+
// 4.0 * arcsin(cache_error / 2) from the previous angle, the error due to reusing the synthesis
132+
// result for the previous angle is precisely cache_error. Contact a Qiskit synthesis developer
133+
// for more details!
134+
let bin_width = 4. * (cache_error / 2.).asin();
135+
136+
// Iterate over nodes in the DAG and collect nodes that have RZ gates.
137+
// Canonicalize angles already at this stage, so that we can use them for sorting.
138+
let mut candidates: Vec<_> = dag
139+
.op_nodes(false)
140+
.filter_map(|(node_index, inst)| {
141+
if let OperationRef::StandardGate(StandardGate::RZ) = inst.op.view() {
142+
if let Param::Float(angle) = inst.params_view()[0] {
143+
let (interval_index, canonical_angle) = canonicalize_angle(angle);
144+
Some((node_index, canonical_angle, interval_index))
145+
} else {
146+
None
147+
}
148+
} else {
149+
None
150+
}
151+
})
152+
.collect();
153+
154+
// Sort candidates based on the canonicalized angles
155+
candidates.sort_unstable_by(|a, b| {
156+
a.1.partial_cmp(&b.1)
157+
.expect("Angles are never NaN here, so we can compare f64.")
158+
});
159+
160+
let mut prev_result: Option<(f64, (Vec<StandardGate>, Param))> = None;
161+
162+
for (node_index, angle, interval_index) in candidates {
163+
// Get or compute the sequence and phase update.
164+
let should_recompute = prev_result
165+
.as_ref()
166+
.is_none_or(|(prev_angle, _)| *prev_angle + bin_width < angle);
167+
168+
if should_recompute {
169+
let (sequence, phase_update) = synthesize_rz_gate_via_gridsynth(angle, synthesis_error)
170+
.map_err(|e| QiskitError::new_err(e.to_string()))?;
171+
172+
prev_result = Some((angle, (sequence, phase_update)));
173+
}
174+
175+
let (sequence, phase_update) = &prev_result
176+
.as_ref()
177+
.expect("is_none_or ensures prev_result is never None")
178+
.1;
179+
180+
// Add the gates and phase update to DAG, remove old node
181+
for new_gate in sequence {
182+
dag.insert_1q_on_incoming_qubit((*new_gate, &[]), node_index);
183+
}
184+
if let Some(gate) = PHASE_GATE_LUT[interval_index as usize].1 {
185+
dag.insert_1q_on_incoming_qubit((gate, &[]), node_index);
186+
}
187+
dag.remove_1q_sequence(&[node_index]);
188+
189+
let phase_update_with_shift =
190+
add_param(phase_update, PHASE_GATE_LUT[interval_index as usize].0);
191+
dag.add_global_phase(&phase_update_with_shift)?;
192+
}
193+
194+
Ok(())
195+
}
196+
197+
pub fn synthesize_rz_rotations_mod(m: &Bound<PyModule>) -> PyResult<()> {
198+
m.add_wrapped(wrap_pyfunction!(py_run_synthesize_rz_rotations))?;
199+
Ok(())
200+
}

qiskit/__init__.py

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -136,6 +136,7 @@
136136
sys.modules["qiskit._accelerate.litinski_transformation"] = _accelerate.litinski_transformation
137137
sys.modules["qiskit._accelerate.unroll_3q_or_more"] = _accelerate.unroll_3q_or_more
138138
sys.modules["qiskit._accelerate.substitute_pi4_rotations"] = _accelerate.substitute_pi4_rotations
139+
sys.modules["qiskit._accelerate.synthesize_rz_rotations"] = _accelerate.synthesize_rz_rotations
139140
sys.modules["qiskit._accelerate.convert_to_pauli_rotations"] = (
140141
_accelerate.convert_to_pauli_rotations
141142
)

qiskit/transpiler/passes/__init__.py

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -144,6 +144,7 @@
144144
HighLevelSynthesis
145145
LinearFunctionsToPermutations
146146
SolovayKitaev
147+
SynthesizeRZRotations
147148
UnitarySynthesis
148149
149150
Post Layout
@@ -270,6 +271,7 @@
270271
from .synthesis import LinearFunctionsToPermutations
271272
from .synthesis import SolovayKitaev
272273
from .synthesis import SolovayKitaevSynthesis
274+
from .synthesis import SynthesizeRZRotations
273275
from .synthesis import RossSelingerSynthesis
274276
from .synthesis import UnitarySynthesis
275277
from .synthesis import unitary_synthesis_plugin_names

qiskit/transpiler/passes/synthesis/__init__.py

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -20,3 +20,4 @@
2020
from .ross_selinger_plugin import RossSelingerSynthesis
2121
from .clifford_unitary_synth_plugin import CliffordUnitarySynthesis
2222
from .aqc_plugin import AQCSynthesisPlugin
23+
from .synthesize_rz_rotations import SynthesizeRZRotations
Lines changed: 94 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,94 @@
1+
# This code is part of Qiskit.
2+
#
3+
# (C) Copyright IBM 2026
4+
#
5+
# This code is licensed under the Apache License, Version 2.0. You may
6+
# obtain a copy of this license in the LICENSE.txt file in the root directory
7+
# of this source tree or at https://www.apache.org/licenses/LICENSE-2.0.
8+
#
9+
# Any modifications or derivative works of this code must retain this
10+
# copyright notice, and modified files need to carry a notice indicating
11+
# that they have been altered from the originals.
12+
13+
"""Synthesize RZ gates to Clifford+T efficiently"""
14+
15+
from qiskit.transpiler.basepasses import TransformationPass
16+
from qiskit.dagcircuit import DAGCircuit
17+
from qiskit._accelerate.synthesize_rz_rotations import synthesize_rz_rotations
18+
19+
20+
class SynthesizeRZRotations(TransformationPass):
21+
r"""Replace RZ gates with Clifford+T decompositions.
22+
23+
This pass replaces all single-qubit RZ rotation gates with floating-point
24+
angles by equivalent Clifford+T sequences.
25+
26+
Internally, the pass synthesizes `RZ(\theta)` for a general `\theta` by
27+
reducing the angle modulo `\pi/2`: the circuit for `RZ(\theta)` can be
28+
constructed from a circuit for `RZ(\theta mod pi/2)` by appending appropriate
29+
Clifford gates. Importantly, the pass also caches synthesis results and reuses
30+
them for angles that are within a given tolerance of each other.
31+
32+
For example::
33+
34+
from qiskit.circuit import QuantumCircuit
35+
from qiskit.transpiler.passes import SynthesizeRZRotations
36+
from qiskit.quantum_info import Operator
37+
from numpy import pi
38+
39+
# The following quantum circuit consists of 5 Clifford gates
40+
# and three single-qubit RZ rotations gates
41+
42+
qc = QuantumCircuit(4)
43+
qc.cx(0, 1)
44+
qc.rz(9*pi/4, 0)
45+
qc.cz(0, 1)
46+
qc.rz(3*pi/8, 1)
47+
qc.h(1)
48+
qc.s(2)
49+
qc.rz(13*pi/2, 2)
50+
qc.cz(2, 0)
51+
qc.rz(5*pi/3, 3)
52+
53+
qct = SynthesizeRZRotations()(qc)
54+
55+
# The transformed circuit consists of Clifford, T and Tdg gates
56+
clifford_t_names = get_clifford_gate_names() + ["t"] + ["tdg"]
57+
assert(set(qct.count_ops().keys()).issubset(set(clifford_t_names)))
58+
59+
# The circuits before and after the transformation are equivalent
60+
# (with the default value of approximation_degree used by SynthesizeRZRotations)
61+
assert Operator(qc) == Operator(qct)
62+
"""
63+
64+
def __init__(
65+
self,
66+
approximation_degree: float | None = None,
67+
synthesis_error: float | None = None,
68+
cache_error: float | None = None,
69+
):
70+
r"""
71+
If both ``synthesis_error`` and ``cache_error`` are provided, they specify the error budget
72+
for approximate synthesis and for caching respectively. If either value is not
73+
specified, the total allowed error is derived from ``approximation_degree``, and
74+
suitable values for ``synthesis_error`` and ``cache_error`` are computed automatically.
75+
76+
Args:
77+
approximation_degree: Controls the overall degree of approximation. Defaults
78+
to ``1 - 1e-10``.
79+
synthesis_error: Maximum allowed error for the approximate synthesis of
80+
:math:`RZ(\theta)`.
81+
cache_error: Maximum allowed error when reusing a cached synthesis
82+
result for angles close to :math:`\theta`.
83+
"""
84+
super().__init__()
85+
self.approximation_degree = approximation_degree
86+
self.synthesis_error = synthesis_error
87+
self.cache_error = cache_error
88+
89+
def run(self, dag: DAGCircuit) -> DAGCircuit:
90+
"""Run the SynthesizeRZRotations pass on `dag`."""
91+
new_dag = synthesize_rz_rotations(
92+
dag, self.approximation_degree, self.synthesis_error, self.cache_error
93+
)
94+
return new_dag
Lines changed: 23 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,23 @@
1+
2+
---
3+
features_transpiler:
4+
- |
5+
Added a new transpiler pass, :class:`.SynthesizeRZRotations`,
6+
which replaces all single-qubit :class:`.RZGate` rotation gates
7+
with floating-point angles by equivalent Clifford+T sequences.
8+
9+
The synthesis accuracy can be controlled by either specifying an
10+
``approximation_degree``, or alternatively by explicitly setting
11+
both ``synthesis_error`` and ``cache_error``.
12+
13+
Example usage::
14+
15+
from qiskit import QuantumCircuit
16+
from qiskit.transpiler.passes import SynthesizeRZRotations
17+
18+
qc = QuantumCircuit(1)
19+
qc.rz(1.23, 0)
20+
21+
rz_synthesis_pass = SynthesizeRZRotations(approximation_degree=0.9999)
22+
synthesized_qc = rz_synthesis_pass(qc)
23+
synthesized_qc.draw()

0 commit comments

Comments
 (0)