PennyLane
Install
Install

Related materials

  • Related contentResource estimation for Hamiltonian simulation with GQSP
  • Related contentIntro to qubitization
  • Related contentQSVT in Practice

Contents

  1. The Hamiltonian and the Exact Target
  2. Step 1: The Qubitization Walk
  3. Step 2: The Jacobi-Anger Polynomial
  4. Step 3: Run GQSP() and Recover \(e^{-iHt}\)
  5. Convergence
  6. Understanding the Results
  7. References
  8. About the author

Downloads

  • Download Python script
  • Download Notebook
  • View on GitHub
  1. Demos/
  2. Algorithms/
  3. How to do Hamiltonian Simulation with Generalized Quantum Signal Processing

How to do Hamiltonian Simulation with Generalized Quantum Signal Processing

Mostafa Atallah

Mostafa Atallah

Published: June 15, 2026. Last updated: June 16, 2026.

Generalized Quantum Signal Processing (GQSP), introduced by Motlagh and Wiebe [1], applies an arbitrary complex polynomial \(P\) of a unitary \(U\) using a single extra control qubit. Its flagship application is Hamiltonian simulation, which is mainly concerned with implementing the time-evolution operator \(e^{-iHt}\).

Hamiltonian simulation is the original motivation for quantum signal processing, and GQSP is a modern variant of it. For GQSP, a single ancilla qubit and one complex polynomial of the qubitization walk \(W\) (the block encoding of \(H\)) are enough; the complex target is applied directly, with no need to build its real and imaginary parts separately as ordinary QSP requires. This keeps the angle-finding step simple while still reaching \(e^{-iHt}\) with a query cost that grows favourably in the evolution time and the target accuracy.

PennyLane provides GQSP() as a runnable circuit primitive and a resource-estimation demo (Resource estimation for Hamiltonian simulation with GQSP [3]), which counts gates for cost analysis. This demo is the executable counterpart. We will build and run the GQSP circuit and verify \(e^{-iHt}\) against scipy.linalg.expm, spelling out the recipe end to end.

By the end of this demo, you will be able to carry out a qubitization [4] walk for a Pauli Hamiltonian, derive the GQSP polynomial for \(e^{-iHt}\) from the Jacobi-Anger expansion, run GQSP() and recover the evolution, and confirm that the error converges with the polynomial degree. The three pieces are:

  1. Block-encode \(H\) as a qubitization walk operator \(W\) whose eigenvalues are

    \[e^{\pm i\arccos(E/\lambda)}\]

    for each eigenvalue \(E\) of \(H\), with \(\lambda\) the \(\ell_1\)-norm of the coefficients.

  2. Choose your target polynomial \(P\) so that \(P(W) = e^{-iHt}\). Writing a walk eigenvalue as \(z=e^{i\theta}\) with \(\cos\theta = E/\lambda\), we need

    \[P(e^{i\theta}) = e^{-i\lambda t\cos\theta} = e^{-iEt},\]

    which the Jacobi-Anger expansion gives as a Laurent series in \(z\).

  3. Run GQSP() with the angles from qp.poly_to_angles(..., "GQSP"), undo the Laurent shift, and read \(e^{-iHt}\) from the top-left block.

The Hamiltonian and the Exact Target

We will use a small two-qubit Heisenberg-type Hamiltonian. This is a convenient choice for this demo since it is already a sum of Pauli terms (the form Qubitization() block-encodes directly), its terms do not all commute (so \(e^{-iHt}\) is non-trivial), and it is small enough that we can form the full matrix \(e^{-iHt}\) and compare against it directly. In general, though, the methods in this demo work for any Pauli Hamiltonian. \(\lambda=\sum_k|c_k|\) is the normalization used by the qubitization walk.

import warnings

warnings.filterwarnings("ignore", message=".*JAX.*")  # JAX is unused here
import numpy as np
import pennylane as qp
from scipy.linalg import expm
from scipy.special import jv  # Bessel functions of the first kind
import matplotlib.pyplot as plt

coeffs = [0.5, 0.3, 0.4, 0.2]
obs = [qp.X(0) @ qp.X(1), qp.Y(0) @ qp.Y(1), qp.Z(0) @ qp.Z(1), qp.Z(0)]
H = qp.Hamiltonian(coeffs, obs)
lam = sum(abs(c) for c in coeffs)  # l1-norm of the coefficients
t = 0.7  # evolution time

H_matrix = qp.matrix(H, wire_order=[0, 1])
U_exact = expm(-1j * H_matrix * t)

# show the Hamiltonian explicitly
print("H =", H)
print(f"{len(coeffs)} terms on 2 qubits   lambda = sum|c_k| = {lam}   t = {t}")
print("H as a matrix:")
print(np.round(H_matrix, 3))
H = 0.5 * (X(0) @ X(1)) + 0.3 * (Y(0) @ Y(1)) + 0.4 * (Z(0) @ Z(1)) + 0.2 * Z(0)
4 terms on 2 qubits   lambda = sum|c_k| = 1.4000000000000001   t = 0.7
H as a matrix:
[[ 0.6+0.j  0. +0.j  0. +0.j  0.2+0.j]
 [ 0. +0.j -0.2+0.j  0.8+0.j  0. +0.j]
 [ 0. +0.j  0.8+0.j -0.6+0.j  0. +0.j]
 [ 0.2+0.j  0. +0.j  0. +0.j  0.2+0.j]]

Step 1: The Qubitization Walk

qp.Qubitization(H, control) builds the Low and Chuang [2] qubitization walk operator

\[W = \text{Prep}^\dagger\,\text{Sel}\,\text{Prep}\,(2|0\rangle\langle 0| - I),\]

which is a block encoding of \(H/\lambda\) followed by a reflection about the control \(|0\rangle\). Its eigenphases are

\[\pm\arccos(E/\lambda)\]

for each eigenvalue \(E\) of \(H\), so a walk eigenvalue \(z = e^{i\theta}\) satisfies \(\cos\theta = E/\lambda\). The control register needs \(\lceil \log_2 L \rceil\) qubits for \(L\) Hamiltonian terms.

The printout below confirms this, since every \(\arccos(E/\lambda)\) appears among the walk’s eigenphases. The walk also carries a few extra phases (here \(0\) and \(\pi\)) coming from the complementary subspace where the control ancillas are not \(|0\rangle\). These encode no information about \(H\) and are projected out when we read off the top-left block in Step 3.

n_ctrl = int(np.ceil(np.log2(len(coeffs))))
anc = [f"a{i}" for i in range(n_ctrl)]


@qp.qnode(qp.device("default.qubit"))
def walk():
    qp.Qubitization(H, control=anc)
    return qp.state()


W = qp.matrix(walk, wire_order=anc + [0, 1])()
walk_phases = np.sort(np.unique(np.round(np.abs(np.angle(np.linalg.eigvals(W))), 4)))
arccos_E = np.sort(
    np.unique(np.round(np.arccos(np.clip(np.linalg.eigvalsh(H_matrix) / lam, -1, 1)), 4))
)
print("walk eigenphases   :", walk_phases)
print("arccos(E / lambda) :", arccos_E)
walk eigenphases   : [0.     1.0613 1.2626 1.487  2.6357 3.1416]
arccos(E / lambda) : [1.0613 1.2626 1.487  2.6357]

Step 2: The Jacobi-Anger Polynomial

We need a polynomial \(P\) with \(P(e^{i\theta}) = e^{-i\lambda t\cos\theta}\). The Jacobi-Anger expansion provides exactly this, as the Laurent series in \(z=e^{i\theta}\):

\[e^{-i a\cos\theta} = \sum_{k=-\infty}^{\infty} (-i)^k J_k(a)\, e^{ik\theta}, \qquad a = \lambda t,\]

with \(J_k\) being the Bessel functions of the first kind. The series converges super-exponentially once \(K \gtrsim a\), so we truncate at \(|k|\le K\), where \(K\) is the truncation order (the highest power of \(z\) we keep).

Two practical points that the GQSP machinery requires:

  • poly_to_angles() needs a polynomial in non-negative powers, so we shift the Laurent series by \(z^{K}\) (we undo this shift in the circuit later).

  • poly_to_angles() also requires \(|P(e^{i\theta})|\le 1\), so we rescale the coefficients by a constant \(s<1\) (and divide it back out at the end).

def jacobi_anger_poly(a, K):
    "shifted, rescaled Jacobi-Anger coefficients for exp(-i a cos theta), plus the scale s."
    laurent = {k: (-1j) ** k * jv(k, a) for k in range(-K, K + 1)}
    p = [laurent[j - K] for j in range(2 * K + 1)]  # shift to powers 0..2K
    grid = np.exp(1j * np.linspace(0, 2 * np.pi, 400))
    s = 0.99 / max(abs(np.polyval(p[::-1], z)) for z in grid)  # enforce |P| <= 1
    return [c * s for c in p], s


K = 8
poly, s = jacobi_anger_poly(lam * t, K)
print(f"K = {K}: polynomial degree {len(poly) - 1}, scale s = {s:.4f}")
K = 8: polynomial degree 16, scale s = 0.9900

Step 3: Run GQSP() and Recover \(e^{-iHt}\)

qp.GQSP(U, angles, control) applies the single polynomial \(P(U)\) with angles from qp.poly_to_angles(poly, "GQSP"). Recall how we built that polynomial in Step 2. The truncated Jacobi-Anger series already approximates \(e^{-iHt}\) on the walk eigenvalues, and we made two changes to it: multiplying by \(z^{K}\) to remove the negative powers, and scaling by \(s\) to enforce \(|P|\le 1\). So, the coefficients in poly are those of \(P(z) = s\, z^{K}\, [\text{Jacobi-Anger series}]\), and GQSP applies this single polynomial of \(W\) in one step, giving

\[P(W) \approx s\, W^{K}\, e^{-iHt}.\]

The \(s\) and \(W^{K}\) terms are not separate circuits we multiply on afterwards – they are the two corrections already baked into the polynomial, which we now simply undo. We cancel the \(W^{K}\) factor by applying the adjoint walk \(W^\dagger\) a total of \(K\) times after the GQSP block, and we divide out \(s\) at read-out. The evolution then sits in the all-ancilla-zero block (the top-left \(\dim\times\dim\) corner), up to a global phase.

def gqsp_evolution(K):
    # recovered exp(-iH t) on the system, read from the GQSP top-left block.
    poly, s = jacobi_anger_poly(lam * t, K)
    angles = qp.poly_to_angles(poly, "GQSP")

    @qp.qnode(qp.device("default.qubit"))
    def circuit():
        qp.GQSP(qp.Qubitization(H, control=anc), angles, control="g")
        for _ in range(K):  # undo the z^K Laurent shift
            qp.adjoint(qp.Qubitization(H, control=anc))
        return qp.state()

    M = qp.matrix(circuit, wire_order=["g"] + anc + [0, 1])()
    block = M[:4, :4] / s  # all-ancilla-zero (top-left) block
    return block


block = gqsp_evolution(K)
ph = np.exp(-1j * np.angle(block[0, 0] / U_exact[0, 0]))  # match global phase
err = np.linalg.norm(block * ph - U_exact, 2)
print(f"||GQSP block - exp(-iHt)||_2 = {err:.2e}   (K = {K})")
print(
    "recovered block is unitary?  ||B^dag B - I|| =",
    f"{np.linalg.norm(block.conj().T @ block - np.eye(4), 2):.2e}",
)
||GQSP block - exp(-iHt)||_2 = 1.21e-08   (K = 8)
recovered block is unitary?  ||B^dag B - I|| = 7.72e-09

Convergence

The truncation error falls super-exponentially with the order \(K\) once \(K\gtrsim \lambda t\), which is the hallmark of the Jacobi-Anger approach. We plot the 2-norm distance to the exact evolution against \(K\).

def gqsp_error(K, t_val):
    "spectral-norm error of the GQSP evolution at order K and time t_val."
    poly, s = jacobi_anger_poly(lam * t_val, K)
    angles = qp.poly_to_angles(poly, "GQSP")

    @qp.qnode(qp.device("default.qubit"))
    def circuit():
        qp.GQSP(qp.Qubitization(H, control=anc), angles, control="g")
        for _ in range(K):  # undo the z^K Laurent shift
            qp.adjoint(qp.Qubitization(H, control=anc))
        return qp.state()

    M = qp.matrix(circuit, wire_order=["g"] + anc + [0, 1])()
    block = M[:4, :4] / s
    U = expm(-1j * H_matrix * t_val)
    ph = np.exp(-1j * np.angle(block[0, 0] / U[0, 0]))
    return np.linalg.norm(block * ph - U, 2)


Ks = list(range(2, 21))
lambda_t_targets = [1, 3, 5]  # show lambda*t = 1, 3, 5 exactly
t_values = [m / lam for m in lambda_t_targets]  # so lam * t = m

plt.style.use("pennylane.drawer.plot")
fig, ax = plt.subplots(figsize=(5.4, 3.4))
for t_val in t_values:
    errs = [gqsp_error(K, t_val) for K in Ks]
    a = lam * t_val
    ax.semilogy(Ks, errs, "o-", label=rf"$\lambda t = {round(a)}$")
    print(f"lambda t = {round(a)}:", [f"{e:.1e}" for e in errs])

ax.set_xticks(range(2, 21, 2))
ax.set_xlabel("Jacobi-Anger order $K$")
ax.set_ylabel(r"$\|U_{\mathrm{GQSP}} - e^{-iHt}\|_2$")
ax.set_title("GQSP error convergence")
ax.legend()
fig.tight_layout()
plt.show()
GQSP error convergence
lambda t = 1: ['3.3e-02', '4.8e-03', '2.9e-04', '3.9e-05', '3.2e-06', '1.5e-07', '1.4e-08', '5.6e-10', '2.9e-11', '1.6e-11', '5.0e-11', '1.7e-11', '3.9e-11', '6.4e-11', '5.6e-11', '1.8e-11', '3.9e-11', '6.4e-11', '5.6e-11']
lambda t = 3: ['6.8e-01', '3.0e-01', '9.4e-02', '2.8e-02', '4.7e-03', '9.1e-04', '1.7e-04', '3.6e-05', '3.7e-06', '7.9e-07', '4.9e-08', '5.3e-09', '7.0e-10', '2.7e-11', '5.3e-12', '1.4e-11', '6.7e-12', '4.7e-11', '3.1e-11']
lambda t = 5: ['1.4e+00', '1.1e+00', '6.2e-01', '4.8e-01', '9.0e-02', '4.0e-02', '8.7e-03', '4.1e-03', '5.8e-04', '2.8e-04', '4.1e-05', '7.8e-06', '1.6e-06', '6.0e-08', '3.4e-08', '5.5e-09', '4.1e-10', '9.2e-11', '4.9e-12']

Figure: GQSP error convergence. Spectral-norm distance between the GQSP evolution and the exact \(e^{-iHt}\), versus the Jacobi-Anger order \(K\), for three evolution times \(\lambda t = 1, 3, 5\). Each curve falls super-exponentially once \(K\) exceeds its \(\lambda t\), then floors at machine precision; larger \(\lambda t\) needs a larger \(K\) to reach the same accuracy.

Understanding the Results

Running GQSP() on the qubitization walk reproduced \(e^{-iHt}\) to within \(\sim 10^{-8}\) at polynomial degree \(K=8\), matching scipy.linalg.expm to machine precision. Because of the fast Bessel decay seen in the convergence plot, useful accuracy needs only \(K = \mathcal{O}(\lambda t + \log(1/\varepsilon))\) queries of the walk.

There are two practical things to keep in mind:

  • One control qubit, one polynomial. GQSP needs a single ancilla and a single complex polynomial, applied directly rather than as separate real and imaginary parts, which is what makes its angle synthesis simpler and more stable than ordinary QSP for this task.

  • Where the cost lives. The accuracy knob is the degree \(K\), and we can interpret each degree as one application of the walk \(W\). This correlation causes the circuit depth to increase quickly with increasing degree. The resource-estimation demo [3] is a good reference to explore this cost.

In practice, this makes GQSP a natural choice when you need an accurate \(e^{-iHt}\) over a longer evolution time \(t\). The super-exponential convergence means the degree \(K\) (and hence the number of walk applications) grows only mildly as you tighten the error, so the cost stays close to the \(\lambda t\) set by the evolution itself. This points to an accuracy-versus-resource trade-off rather than one method dominating: GQSP (and block-encoding methods generally) reach high accuracy with a query count that grows only mildly as the error shrinks, while product-formula (Trotter) approaches are often cheaper in qubit and gate counts for a coarse target but need rapidly more steps to reach the same accuracy. Within the block-encoding / quantum-signal-processing family, GQSP’s particular appeal is economy of ancillas, since a single control qubit carries one complex polynomial applied directly, instead of building the polynomial’s real and imaginary parts separately as ordinary QSP must.

References

[1]

Danial Motlagh, Nathan Wiebe, “Generalized Quantum Signal Processing”, PRX Quantum 5, 020368, 2024.

[2]

Guang Hao Low, Isaac L. Chuang, “Hamiltonian simulation by qubitization”, Quantum 3, 163, 2019.

[3] (1,2)

PennyLane demo, “Resource estimation for Hamiltonian simulation with GQSP”, pennylane.ai.

[4]

PennyLane demo, “Introduction to qubitization”, pennylane.ai.

About the author

Mostafa Atallah
Mostafa Atallah

Mostafa Atallah

PhD student and Graduate Research Assistant in quantum computation at the University of Tennessee, Knoxville. I work on quantum simulation, continuous-time quantum walks, quantum optimization (QAOA), and quantum machine learning.

Total running time of the script: (0 minutes 24.254 seconds)

Share demo

Ask a question on the forum

Related Demos

Resource estimation for Hamiltonian simulation with GQSP

Intro to qubitization

QSVT in Practice

Never miss a milestone

Get the latest quantum updates delivered to your inbox.

Join the list
PennyLane

PennyLane is an open-source quantum software platform for quantum computing, quantum machine learning, and quantum chemistry. Create meaningful quantum algorithms, from inspiration to implementation.

Created with ❤️ by Xanadu.

Research

  • Research

  • Performance

  • Hardware and simulators

  • Demos library

  • Compilation hub

  • Quantum datasets

Education

  • Teach

  • Learn

  • Codebook

  • Coding challenges

  • Videos

  • Glossary

Software

  • Install

  • Features

  • PennyLane documentation

  • Catalyst documentation

  • Development guide

  • How-to guides

  • API

  • GitHub


Xanadu

© Copyright 2026 | Xanadu | All rights reserved

TensorFlow, the TensorFlow logo and any related marks are trademarks of Google Inc.

Privacy policyTerms of serviceCookies policyCode of conduct