Rotation Synthesis (Clifford+T)

It is typical in fault-tolerant quantum computing to consider only the discrete Cliffords augmented with the T gate for universality, like H, S, T, X and CNOT, the Clifford+T set. A continuous rotation such as rz(0.6) is not a member of this set, so before a kernel can run on such hardware every rotation must be synthesized into a sequence of digital operations which approximates it.

CUDA-Q does this with Gridsynth (Ross and Selinger, arXiv:1403.2975). Given an angle and a tolerance epsilon, it finds a short Clifford+T sequence \(U\) satisfying \(\|R_z(\theta) - U\| \le \epsilon\) in the operator norm.

There are two ways to use it. cudaq.synth is a Python API for approximating individual rotations, and is shown first. Synthesizing a whole kernel is done by a compiler pass, which is opt-in. It is not part of any default target pipeline, and the targets that run it are benchmarking targets for estimating how many T gates an algorithm would cost.

Synthesizing a rotation

To approximate a rotation, use cudaq.synth.gridsynth. Note that the synth submodule is imported explicitly:

from cudaq import synth

# Approximate a rotation of `theta` radians about the Z axis to within an
# operator-norm error of `epsilon`.
theta, epsilon = 0.6, 1e-10
seq = synth.gridsynth(theta, epsilon, seed=1234)

print(f"T count: {seq.t_count}")
print(f"gates:   {str(seq)[:32]}...")

# The achieved error is computed exactly and never exceeds the epsilon that was
# asked for.
print(f"error:   {synth.rz_error(theta, seq):.3e}")
assert synth.rz_error(theta, seq) <= epsilon
T count: 102
gates:   HTHTHTSHTHTSHTSHTHTHTSHTSHTHTSHT...
error:   9.665e-11

gridsynth returns a CliffordTSequence. Its t_count is the number of T gates, and str() gives the gate string over {H, S, T, X, W}, where W is the global phase \(\omega = e^{i\pi/4}\). The gates are listed in matrix-multiplication order, so as a circuit they apply right to left.

cudaq.synth.rz_error reports the error the sequence actually achieves. It is computed exactly in arbitrary precision, and it never exceeds the epsilon you asked for.

A CliffordTSequence can also be built directly from a gate string, which is useful for inspecting Clifford+T circuits that came from somewhere else. normalized rewrites a sequence into Matsumoto-Amano normal form. An exactly equal sequence with the smallest possible T count.

# A sequence can also be built directly from a gate string, which is useful for
# inspecting Clifford+T circuits that came from somewhere else.
imported = synth.CliffordTSequence("TST")
print(f"imported:   {imported} (T count {imported.t_count})")

# `normalized` rewrites a sequence into Matsumoto-Amano normal form. The result
# is exactly equal and has the smallest possible T count.
reduced = imported.normalized()
print(f"normalized: {reduced} (T count {reduced.t_count})")

# `gridsynth` already returns normal form, so normalizing its output changes
# nothing.
print(f"already normal form: {seq.normalized() == seq}")
imported:   TST (T count 2)
normalized: SS (T count 0)
already normal form: True

gridsynth already returns sequences in normal form, so normalizing its output changes nothing.

To use a synthesized sequence in a circuit, to_kernel builds a kernel that takes a single qubit, ready for apply_call:

# `to_kernel` builds a kernel taking a single qubit, for use with `apply_call`.
# Sandwiching the rotation between two Hadamards turns the phase it applies
# into a measurable population, so the counts below depend on theta.
kernel = cudaq.make_kernel()
qubit = kernel.qalloc()
kernel.h(qubit)
kernel.apply_call(seq.to_kernel(), qubit)
kernel.h(qubit)
kernel.mz(qubit)

# The same circuit built with an exact `rz`, for comparison.
exact = cudaq.make_kernel()
exact_qubit = exact.qalloc()
exact.h(exact_qubit)
exact.rz(theta, exact_qubit)
exact.h(exact_qubit)
exact.mz(exact_qubit)

cudaq.set_random_seed(13)
print(f"synthesized: {cudaq.sample(kernel, shots_count=1000)}")
cudaq.set_random_seed(13)
print(f"exact rz:    {cudaq.sample(exact, shots_count=1000)}")
synthesized: { 0:908 1:92 }
exact rz:    { 0:908 1:92 }

The synthesized sequence reproduces the exact rotation. Note that to_kernel drops the W phase factors, so the kernel it returns equals \(R_z(\theta)\) only up to a global phase. That phase has no effect when the kernel is used on its own, but it becomes an observable relative phase if the kernel is made the target of a controlled operation. The compiler-bench-ftqc-clifford-t target does not have this limitation. It emits the phase explicitly, so the circuit it produces is exactly equal to the original.

Estimating the T count of a kernel

The T gate is the expensive resource on fault-tolerant hardware, so the T count of a circuit is the number usually worth measuring. The compiler-bench-ftqc-clifford-t target runs synthesis over a whole kernel so that count can be read off directly. It reduces rx, ry and r1 rotations to rz, synthesizes each one, and optimizes the result. Its epsilon argument sets the per-rotation tolerance and defaults to 1e-10.

import cudaq

# Rewrite every rotation in the kernel into Clifford+T. `epsilon` is the
# per-rotation approximation tolerance.
cudaq.set_target("compiler-bench-ftqc-clifford-t", epsilon="1e-10")


@cudaq.kernel
def circuit():
    q = cudaq.qvector(2)
    h(q[0])
    rz(0.6, q[0])
    ry(0.25, q[1])
    x.ctrl(q[0], q[1])
    mz(q)


operations = cudaq.estimate_resources(circuit).to_dict()

# Only Clifford+T operations are left. The rotations are gone.
CLIFFORD_T = {
    "h", "s", "sdg", "t", "tdg", "x", "y", "z", "cx", "cy", "cz", "swap", "mx",
    "my", "mz"
}

print(f"only Clifford+T: {set(operations) <= CLIFFORD_T}")
print(f"T count:         {operations.get('t', 0) + operations.get('tdg', 0)}")

cudaq.reset_target()
only Clifford+T: True
T count:         208

This is a benchmarking target for resource counting, not a path for compiling kernels to run on hardware. It is also Python only. An nvq++ build accepts the target but does not run the synthesis.

Rotation angles must be compile-time constants when synthesis runs. A kernel whose angle comes from a runtime argument has to be specialized first.

Choosing epsilon

epsilon trades accuracy against cost. A tighter tolerance means more T gates and more compile time; the T count grows roughly with \(\log(1/\epsilon)\), while compile time grows much faster and becomes significant below about 1e-30.

Synthesis results are cached per distinct angle, so a kernel that applies the same rotation a hundred times pays the cost once.

Synthesis is randomized. Repeated calls with the same angle and tolerance return sequences with the same T count, but not necessarily the same gates. Pass seed to make a run reproducible, as the example above does. The remaining arguments to gridsynth are work budgets that trade compile time against T count; see the Python API reference for what each one bounds.

Dependencies

Rotation synthesis uses exact arbitrary-precision arithmetic, provided by the GMP and MPFR libraries. These ship with every CUDA-Q binary distribution, so no action is needed to use the feature. See Dynamic linking to GMP and MPFR for details on how they are packaged and how to substitute your own builds.