Skip to content

Quantum linear solver based on QSVT

View on GitHub

The code here can be integrated as part of a larger CFD solver, e.g., as in qc-cfd repository. In particular, instead of calling a classical solver, e.g., x = sparse.linalg.spsolve(mat_raw_scr, b_raw), one can call the quantum solver qsvt_solver(mat_raw_scr, b_raw,...).

We implemented two versions for block-encoding, one based on Pauli decomposition of the matrix, and another one based on decomposing the matrix to a finite set of diagonals.

import time

import matplotlib.pyplot as plt
import numpy as np
from cheb_utils import *
from classical_functions_be import *
from quantum_functions_be import *
from scipy import sparse

from classiq import *
from classiq.applications.chemistry.op_utils import qubit_op_to_pauli_terms

np.random.seed(53)

Define functions to get properties of the block-encoding.

def get_pauli_be(mat_raw_scr):
    """
    Get relevant block-encoding properties for `lcu_paulis_graycode` block encoding,

    Parameters
    ----------
    mat_raw_scr : scipy.sparse.spmatrix
        Square sparse matrix of shape (N, N), real or complex, to be block-encoded.

    Returns
    -------
    data_size : int
       Size of the data variable.
    block_size : int
        Size of the block variable.
    be_scaling_factor : float
        The scaling factor of the block-encoding unitary
    BlockEncodedState : QStruct
        QSVT-compatible QStruct holding the quantum variables, with fields:
          - data  : QNum[data_size]
          - block : QNum[block_size]
    be_qfunc : qfunc
        Quantum function that implements the block encoding. Signature:
        be_qfunc(be: BlockEncodedState) → None
    """
    rval = mat_raw_scr.data
    col = mat_raw_scr.indices
    rowstt = mat_raw_scr.indptr
    nr = mat_raw_scr.shape[0]

    raw_size = mat_raw_scr.shape[0]
    data_size = max(1, (raw_size - 1).bit_length())

    # Set to_symmetrize=False, since we are working with QSVT
    paulis_list, transform_matrix = initialize_paulis_from_csr(
        rowstt, col, data_size, to_symmetrize=False
    )

    qubit_op = eval_pauli_op(paulis_list, transform_matrix, rval)
    qubit_op.compress(1e-12)
    hamiltonian = qubit_op_to_pauli_terms(qubit_op)

    be_scaling_factor = sum([np.abs(term.coefficient) for term in hamiltonian.terms])
    block_size = size = max(1, (len(hamiltonian.terms) - 1).bit_length())

    hamiltonian = hamiltonian * (1 / be_scaling_factor)

    class BlockEncodedState(QStruct):
        data: QNum[data_size]
        block: QNum[block_size]

    @qfunc
    def be_qfunc(be: BlockEncodedState):
        lcu_paulis_graycode(hamiltonian.terms, be.data, be.block)

    return data_size, block_size, be_scaling_factor, BlockEncodedState, be_qfunc


def get_banded_diags_be(mat_raw_scr):
    """
    Get relevant block-encoding properties for `block_encode_banded` block encoding,

    Parameters
    ----------
    mat_raw_scr : scipy.sparse.spmatrix
        Square sparse matrix of shape (N, N), real or complex, to be block-encoded.

    Returns
    -------
    data_size : int
       Size of the data variable.
    block_size : int
        Size of the block variable.
    be_scaling_factor : float
        The scaling factor of the block-encoding unitary
    BlockEncodedState : QStruct
        QSVT-compatible QStruct holding the quantum variables, with fields:
          - data  : QNum[data_size]
          - block : QNum[block_size]
    be_qfunc : qfunc
        Quantum function that implements the block encoding. Signature:
        be_qfunc(be: BlockEncodedState) → None
    """
    raw_size = mat_raw_scr.shape[0]
    data_size = max(1, (raw_size - 1).bit_length())
    offsets, diags, diags_maxima, prepare_norm = get_be_banded_data(mat_raw_scr)
    block_size = int(np.ceil(np.log2(len(offsets)))) + 1
    be_scaling_factor = prepare_norm

    class BlockEncodedState(QStruct):
        data: QNum[data_size]
        block: QNum[block_size]

    @qfunc
    def be_qfunc(be: BlockEncodedState):
        block_encode_banded(
            offsets=offsets,
            diags=diags,
            prep_diag=diags_maxima,
            block=be.block,
            data=be.data,
        )

    return data_size, block_size, be_scaling_factor, BlockEncodedState, be_qfunc

The solvers were developed in the framework of exploring their performance in hybrid CFD schemes. For simplicity, it is assumed that all the properties of the matrices are known explicitly. In particular, we calculate its sigular values for identyfing the range in which we apply the inversion polnomial.

def get_svd_range(mat_raw_scr):
    mat_raw = mat_raw_scr.toarray()
    svd = np.linalg.svd(mat_raw)[1]
    w_min = min(svd)
    w_max = max(svd)
    return w_min, w_max
def qsvt_solver(
    mat_raw_scr,
    b_raw,
    poly_degree,
    be_method="banded",
    cheb_approx_type="optimized",
    preferences=Preferences(),
    constraints=Constraints(),
    qmod_name=None,
):

    SCALE = 0.5
    b_norm = np.linalg.norm(b_raw)  # b normalization
    b_normalized = b_raw / b_norm

    # Define block encoding
    if be_method == "pauli":
        data_size, block_size, be_scaling_factor, BlockEncodedState, be_qfunc = (
            get_pauli_be(mat_raw_scr)
        )
        print(
            f"Pauli block encoding with block size {block_size} and scaling factor {be_scaling_factor}"
        )

    elif be_method == "banded":
        data_size, block_size, be_scaling_factor, BlockEncodedState, be_qfunc = (
            get_banded_diags_be(mat_raw_scr)
        )
        print(
            f"Banded diagonal block encoding with block size {block_size} and scaling factor {be_scaling_factor}"
        )

    # Get SVD range
    w_min, w_max = get_svd_range(mat_raw_scr / be_scaling_factor)
    # Get Chebyshev polynomial and the corresponding QSVT angles
    pcoefs = get_cheb_coeff(
        w_min, poly_degree, w_max, scale=SCALE, method=cheb_approx_type, epsilon=0.01
    )
    inv_phases = get_qsvt_phases(pcoefs)

    # Define QSVT projector
    @qfunc
    def projector(be: BlockEncodedState, res: QBit):
        res ^= be.block == 0

    @qfunc
    def main(
        qsvt_aux: Output[QBit],
        data: Output[QNum[data_size]],
        block: Output[QNum[block_size]],
    ):
        allocate(qsvt_aux)
        allocate(block)
        prepare_amplitudes(b_normalized.tolist(), 0, data)

        be_state = BlockEncodedState()

        within_apply(
            lambda: bind([data, block], be_state),
            lambda: qsvt_inversion(inv_phases, projector, be_qfunc, be_state, qsvt_aux),
        )

    if qmod_name is not None:
        write_qmod(main, qmod_name, symbolic_only=False)

    start_time_syn = time.time()
    qprog = synthesize(main, preferences=preferences, constraints=constraints)
    print("time to syn:", time.time() - start_time_syn)

    execution_preferences = ExecutionPreferences(
        num_shots=1,
        backend_preferences=ClassiqBackendPreferences(
            backend_name=ClassiqSimulatorBackendNames.SIMULATOR_STATEVECTOR
        ),
    )

    start_time_exe = time.time()
    with ExecutionSession(qprog, execution_preferences) as es:
        es.set_measured_state_filter("block", lambda state: state == 0.0)
        es.set_measured_state_filter("qsvt_aux", lambda state: state == 0.0)
        results = es.sample()
    print("time to exe:", time.time() - start_time_exe)

    resulting_state = get_projected_state_vector(results, "data")

    normalization_factor = (be_scaling_factor * SCALE * w_min) / b_norm

    return resulting_state / normalization_factor, qprog
# For faster results use the commented out preferences

# prefs = Preferences(
#     transpilation_option="none", optimization_level=0, debug_mode=False, qasm3=True
# )
prefs = Preferences()
poly_degree = 101

We run two examples, for the two different block encoding, but using the same polynomial degree.

import pathlib

path = (
    pathlib.Path(__file__).parent.resolve()
    if "__file__" in locals()
    else pathlib.Path(".")
)
mat_name = "nozzle_small_scr"
matfile = "matrices/" + mat_name + ".npz"
mat_raw_scr = sparse.load_npz(path / matfile)

b_raw = np.load(path / "matrices/b_nozzle_small.npy")
raw_mat_qubits = len(b_raw).bit_length() - 1  # matrix raw size

print(f"Raw matrix size: {raw_mat_qubits} qubits.")
Raw matrix size: 3 qubits.
qsol_banded, qprog_banded = qsvt_solver(
    mat_raw_scr,
    b_raw,
    poly_degree,
    be_method="banded",
    preferences=prefs,
    constraints=Constraints(optimization_parameter="width"),
    qmod_name="qsvt_solver_banded_be",
)
show(qprog_banded)
Banded diagonal block encoding with block size 3 and scaling factor 3.8927056451476174
For error 0.01, and given kappa, the needed polynomial degree is: 2*295+1
The Chebyshev LCU requires 9 qubits
Performing convex optimization for the Chebyshev interpolation, with degree 101
Max relative error value: 0.017025127162188403
time to syn: 146.27435111999512
time to exe: 33.56966781616211
Quantum program link: https://platform.classiq.io/circuit/32BPbtdbOD4uIQw80Xgel5Ejvjt
qsol_pauli, qprog_pauli = qsvt_solver(
    mat_raw_scr,
    b_raw,
    poly_degree,
    be_method="pauli",
    preferences=prefs,
    constraints=Constraints(optimization_parameter="width"),
    qmod_name="qsvt_solver_pauli_be",
)
show(qprog_pauli)
Pauli block encoding with block size 5 and scaling factor 5.6428068318664355
For error 0.01, and given kappa, the needed polynomial degree is: 2*449+1
The Chebyshev LCU requires 9 qubits
Performing convex optimization for the Chebyshev interpolation, with degree 101
Max relative error value: 0.06140234945729006
time to syn: 180.07041907310486
time to exe: 36.84007692337036
Quantum program link: https://platform.classiq.io/circuit/32BQ5DoazyihYKRCXX8zQCo9htb
mat_raw = mat_raw_scr.toarray()
expected_sol = np.linalg.solve(mat_raw, b_raw)
plt.plot(expected_sol, "o", label="Classical")
ext_idx = np.argmax(np.abs(expected_sol))
correct_sign = np.sign(expected_sol[ext_idx]) / np.sign(qsol_pauli[ext_idx])
qsol_pauli *= correct_sign
plt.plot(qsol_pauli, ".", label=f"QSVT-inv; Pauli BE; degree {poly_degree}")

correct_sign = np.sign(expected_sol[ext_idx]) / np.sign(qsol_banded[ext_idx])
qsol_banded *= correct_sign
plt.plot(qsol_banded, ".", label=f"QSVT-inv; Banded BE; degree {poly_degree}")
plt.legend()
<matplotlib.legend.Legend at 0x312817c10>

png

We can see that the results is better for the banded diagonal block-encoding. This is because it has a better scaling factor, and thus a better approximation for a given polynomial degree.

assert np.linalg.norm(qsol_banded - expected_sol) < 0.2
assert np.linalg.norm(qsol_pauli - expected_sol) < 0.2