|
| 1 | +""" |
| 2 | +\******************************************************************************** |
| 3 | +* Copyright (c) 2023 the Qrisp authors |
| 4 | +* |
| 5 | +* This program and the accompanying materials are made available under the |
| 6 | +* terms of the Eclipse Public License 2.0 which is available at |
| 7 | +* http://www.eclipse.org/legal/epl-2.0. |
| 8 | +* |
| 9 | +* This Source Code may also be made available under the following Secondary |
| 10 | +* Licenses when the conditions for such availability set forth in the Eclipse |
| 11 | +* Public License, v. 2.0 are satisfied: GNU General Public License, version 2 |
| 12 | +* with the GNU Classpath Exception which is |
| 13 | +* available at https://www.gnu.org/software/classpath/license.html. |
| 14 | +* |
| 15 | +* SPDX-License-Identifier: EPL-2.0 OR GPL-2.0 WITH Classpath-exception-2.0 |
| 16 | +********************************************************************************/ |
| 17 | +""" |
| 18 | + |
| 19 | +# This file implements a performance benchmark of simulating molecules using PySCF data |
| 20 | +# We compare the performance of the algorithm implemented in OpenFermion |
| 21 | +# (described here: https://quantumai.google/openfermion/tutorials/intro_workshop_exercises#hamiltonian_simulation_with_trotter_formulas) |
| 22 | +# vs. the Qrisp implementation https://www.qrisp.eu/general/tutorial/H2.html |
| 23 | + |
| 24 | +# The amount of RZ gates is proportional to the required computation effort |
| 25 | +# because Hamiltonian Simulation is an algorithm, which requires fault tolerant devices. |
| 26 | +# In this device model, arbitrary angle gates (like RZ, P or RX) need to be |
| 27 | +# synthesized by a sequence of Clifford+T gates. More details here: https://arxiv.org/abs/1403.2975 |
| 28 | + |
| 29 | +import numpy as np |
| 30 | +import openfermion as of |
| 31 | +import openfermionpyscf as ofpyscf |
| 32 | +import cirq |
| 33 | +from qrisp import QuantumCircuit, QuantumVariable |
| 34 | +from qrisp.operators import FermionicOperator |
| 35 | +from scipy.sparse import linalg |
| 36 | +from numpy.linalg import norm |
| 37 | + |
| 38 | +# Function to indicate whether an Operation object contributes |
| 39 | +# to the RZ-depth |
| 40 | +# For more details check "Gate-speed aware compilation" here: |
| 41 | +# https://www.qrisp.eu/reference/Core/generated/qrisp.QuantumSession.compile.html |
| 42 | +def rz_depth_indicator(op): |
| 43 | + if op.name in ["rz", "p", "u1", "rx", "ry"]: |
| 44 | + return 1 |
| 45 | + # Some u3 operations in the OpenFermion circuit have u3 gates where the parameters |
| 46 | + # are still Clifford. We filter out the ones where all three are Clifford |
| 47 | + # using some function that is defined below. For the other ones, we assume |
| 48 | + # that they can be implemented with two arbitrary angle rotations |
| 49 | + # (which is very conservative - normally you need three!) |
| 50 | + if op.name == "u3": |
| 51 | + return 2 |
| 52 | + else: |
| 53 | + return 0 |
| 54 | + |
| 55 | +# Function to benchmark simulation of molecules |
| 56 | +def benchmark_hamiltonian_simulation(molecule_data): |
| 57 | + geometry, basis, multiplicity, charge = molecule_data |
| 58 | + |
| 59 | + # Generate Hamiltonian |
| 60 | + hamiltonian = ofpyscf.generate_molecular_hamiltonian(geometry, basis, multiplicity, charge) |
| 61 | + hamiltonian_ferm_op = of.get_fermion_operator(hamiltonian) |
| 62 | + n_qubits = of.count_qubits(hamiltonian) |
| 63 | + |
| 64 | + # OpenFermion implementation taken from here: |
| 65 | + # https://quantumai.google/openfermion/tutorials/intro_workshop_exercises#hamiltonian_simulation_with_trotter_formulas |
| 66 | + def openfermion_circuit(): |
| 67 | + qubits = cirq.LineQubit.range(n_qubits) |
| 68 | + circuit = cirq.Circuit( |
| 69 | + of.simulate_trotter(qubits, hamiltonian, time=1.0, n_steps=1, order=0, algorithm=of.LOW_RANK) |
| 70 | + ) |
| 71 | + # Convert to Qrisp circuit via OpenQASM |
| 72 | + return QuantumCircuit.from_qasm_str(circuit.to_qasm()) |
| 73 | + |
| 74 | + # Qrisp implementation taken from here: |
| 75 | + # https://www.qrisp.eu/general/tutorial/H2.html |
| 76 | + def qrisp_circuit(): |
| 77 | + H = FermionicOperator.from_openfermion(hamiltonian_ferm_op) |
| 78 | + qv = QuantumVariable(n_qubits) |
| 79 | + U = H.trotterization() |
| 80 | + U(qv, steps=1, t=1) |
| 81 | + qc = qv.qs.compile(workspace = len(qv)//4, gate_speed = rz_depth_indicator) |
| 82 | + # Move the workspace qubits up to facilitate computation of the unitary |
| 83 | + i = qc.num_qubits() - 1 |
| 84 | + while i >= len(qv): |
| 85 | + qc.qubits.insert(0, qc.qubits.pop(-1)) |
| 86 | + i -= 1 |
| 87 | + return qc.transpile() |
| 88 | + |
| 89 | + # Function to determine the RZ count |
| 90 | + def count_rz(qc): |
| 91 | + ops_count_dic = qc.transpile().count_ops() |
| 92 | + return ops_count_dic.get("rz", 0) + ops_count_dic.get("u1", 0) + 2*ops_count_dic.get("u3", 0) + ops_count_dic.get("p", 0) |
| 93 | + |
| 94 | + # Function to count the CNOT gates |
| 95 | + # We don't count the SWAP gates within the OpenFermion circuit since these SWAP |
| 96 | + # gates ensure linear connectivity. |
| 97 | + # The Qrisp algorithm can also be run on linear connectivity with a minimal number of |
| 98 | + # SWAPS, which is however not yet implemented. |
| 99 | + def count_cnot(qc): |
| 100 | + ops_count_dic = qc.count_ops() |
| 101 | + return ops_count_dic.get("cx", 0) + ops_count_dic.get("cz", 0) + 3*ops_count_dic.get("compiled_gidney_mcx", 0) + 0.5*ops_count_dic.get("compiled_gidney_mcx_inv", 0) |
| 102 | + |
| 103 | + # Function to compute the RZ depth |
| 104 | + def rz_depth(qc): |
| 105 | + return qc.depth(depth_indicator = rz_depth_indicator) |
| 106 | + |
| 107 | + # Function to compute the precision of the quantum circuit to the exact unitary |
| 108 | + def unitary_distance(qc, exact_unitary): |
| 109 | + qc_unitary = qc.get_unitary() |
| 110 | + qc_unitary = qc_unitary[:exact_unitary.shape[0],:exact_unitary.shape[0]] |
| 111 | + |
| 112 | + # Since OpenFermion doesn't properly implement the global phase |
| 113 | + # (this is a bug!) we manually correct. |
| 114 | + argmax = np.argmax(np.abs(qc_unitary).ravel()) |
| 115 | + qc_phase = np.angle(qc_unitary.ravel()[argmax]) |
| 116 | + exact_phase = np.angle(exact_unitary.ravel()[0, argmax]) |
| 117 | + |
| 118 | + qc_unitary = np.exp(1j*(exact_phase-qc_phase))*qc_unitary |
| 119 | + |
| 120 | + # Compute the distance |
| 121 | + return norm(qc_unitary - exact_unitary) |
| 122 | + |
| 123 | + |
| 124 | + # Compute circuits |
| 125 | + qc_of = openfermion_circuit() |
| 126 | + qc_qrisp = qrisp_circuit() |
| 127 | + |
| 128 | + # Compute metrics |
| 129 | + |
| 130 | + # Some gates in the OpenFermion implementation are arbitrary angle but have a |
| 131 | + # parameter value, which indicates that they are clifford (for instance (RZ(pi) = Z)). |
| 132 | + # We filter out these gates since we only want to count arbitrary angle |
| 133 | + # gates that need to be synthesized since these are the costly resource. |
| 134 | + clifford_filtered_qc = filter_clifford(qc_of) |
| 135 | + |
| 136 | + of_rz_count = count_rz(clifford_filtered_qc) |
| 137 | + of_cx_count = count_cnot(clifford_filtered_qc) |
| 138 | + of_rz_depth = rz_depth(clifford_filtered_qc) |
| 139 | + |
| 140 | + qrisp_rz_count = count_rz(qc_qrisp) |
| 141 | + qrisp_cx_count = count_cnot(qc_qrisp) |
| 142 | + qrisp_rz_depth = rz_depth(qc_qrisp) |
| 143 | + |
| 144 | + # If the circuit has more than 10 qubits, computing the unitary is not |
| 145 | + # feasible. |
| 146 | + # For the cases below 10 qubits, both Qrisp and OpenFermion implement almost the exact same |
| 147 | + # precision. |
| 148 | + |
| 149 | + if qc_qrisp.num_qubits() < 10: |
| 150 | + # Compute exact unitary |
| 151 | + hamiltonian_jw_sparse = of.get_sparse_operator(of.jordan_wigner(hamiltonian_ferm_op)) |
| 152 | + exact_unitary = linalg.expm(-1j * hamiltonian_jw_sparse).todense() |
| 153 | + of_precision = unitary_distance(qc_of, exact_unitary) |
| 154 | + qrisp_precision = unitary_distance(qc_qrisp, exact_unitary) |
| 155 | + else: |
| 156 | + of_precision = 0 |
| 157 | + qrisp_precision = 0 |
| 158 | + |
| 159 | + return { |
| 160 | + "OpenFermion": { |
| 161 | + "RZ count": of_rz_count, |
| 162 | + "CX count": of_cx_count, |
| 163 | + "RZ depth": of_rz_depth, |
| 164 | + "Precision": of_precision |
| 165 | + }, |
| 166 | + "Qrisp": { |
| 167 | + "RZ count": qrisp_rz_count, |
| 168 | + "CX count": qrisp_cx_count, |
| 169 | + "RZ depth": qrisp_rz_depth, |
| 170 | + "Precision": qrisp_precision |
| 171 | + } |
| 172 | + } |
| 173 | + |
| 174 | +# Some gates in the OpenFermion implementation are arbitrary angle but have a |
| 175 | +# parameter value, which indicates that they are clifford. We filter out these |
| 176 | +# gates since we only want to count arbitrary angle gates that actually need to |
| 177 | +# be synthesized (Clifford gates don't). |
| 178 | +def filter_clifford(qc): |
| 179 | + |
| 180 | + qc_new = qc.clearcopy() |
| 181 | + |
| 182 | + # Filter out non-clifford operations |
| 183 | + for instr in qc.data: |
| 184 | + |
| 185 | + if len(instr.op.params) > 0: |
| 186 | + par = instr.op.params[0] |
| 187 | + |
| 188 | + if instr.op.name == "ry": |
| 189 | + if abs(par-np.pi/2) < 1E-4: |
| 190 | + qc_new.sx_dg(instr.qubits) |
| 191 | + qc_new.s(instr.qubits) |
| 192 | + qc_new.sx(instr.qubits) |
| 193 | + elif instr.op.name == "rx": |
| 194 | + if abs(par-np.pi/2) < 1E-4: |
| 195 | + qc_new.h(instr.qubits) |
| 196 | + qc_new.s(instr.qubits) |
| 197 | + qc_new.h(instr.qubits) |
| 198 | + elif instr.op.name in ["rz", "u1"]: |
| 199 | + if abs(par) < 1E-4: |
| 200 | + continue |
| 201 | + elif abs(abs(par) - np.pi) < 1E-4: |
| 202 | + qc_new.z(instr.qubits) |
| 203 | + elif abs(abs(par) - np.pi/2) < 1E-4: |
| 204 | + if par < 0: |
| 205 | + qc_new.s_dg(instr.qubits) |
| 206 | + else: |
| 207 | + qc_new.s(instr.qubits) |
| 208 | + elif abs(abs(par) - np.pi/4) < 1E-4: |
| 209 | + if par < 0: |
| 210 | + qc_new.t_dg(instr.qubits) |
| 211 | + else: |
| 212 | + qc_new.t(instr.qubits) |
| 213 | + else: |
| 214 | + qc_new.append(instr) |
| 215 | + elif instr.op.name == "u3": |
| 216 | + |
| 217 | + for par in instr.op.params: |
| 218 | + if par not in [i*np.pi/4 for i in range(8)]: |
| 219 | + break |
| 220 | + else: |
| 221 | + # For the case that all parameters are clifford, |
| 222 | + # we don't append any gate, so it is not counted. |
| 223 | + continue |
| 224 | + |
| 225 | + qc_new.append(instr) |
| 226 | + |
| 227 | + else: |
| 228 | + qc_new.append(instr) |
| 229 | + |
| 230 | + return qc_new |
| 231 | + |
| 232 | +# Compute the benchmark results |
| 233 | + |
| 234 | +molecule_list = [ |
| 235 | + ([("H", (0.0, 0.0, 0.0)), ("H", (0.0, 0.0, 0.8))], "sto-3g", 1, 0), |
| 236 | + ([("Li", (0.0, 0.0, 0.0)), ("H", (0.0, 0.0, 1.6))], "sto-3g", 1, 0), |
| 237 | + ([("Be", (0.0, 0.0, 0.0)), ("H", (0.0, 0.0, 1.3))], "sto-3g", 1, 1), |
| 238 | + ([("B", (0.0, 0.0, 0.0)), ("H", (0.0, 0.0, 1.2))], "sto-3g", 1, 0), |
| 239 | + ([("O", (0.0, 0.0, 0.0)), ("H", (0.757, 0.586, 0.0)), ("H", (-0.757, 0.586, 0.0))], "sto-3g", 1, 0), |
| 240 | + ([("N", (0.0, 0.0, 0.0)), |
| 241 | + ("H", (0.0, -0.934, -0.365)), |
| 242 | + ("H", (0.809, 0.467, -0.365)), |
| 243 | + ("H", (-0.809, 0.467, -0.365))], "sto-3g", 1, 0), |
| 244 | +] |
| 245 | + |
| 246 | +# Lists to store results |
| 247 | +molecules = [] |
| 248 | +of_rz_counts = [] |
| 249 | +of_rz_depths = [] |
| 250 | +of_precisions = [] |
| 251 | +qrisp_rz_counts = [] |
| 252 | +qrisp_rz_depths = [] |
| 253 | +qrisp_cx_count = [] |
| 254 | +of_cx_count = [] |
| 255 | +qrisp_precisions = [] |
| 256 | + |
| 257 | +# Run benchmarks for each molecule |
| 258 | +for idx, molecule_data in enumerate(molecule_list): |
| 259 | + print(molecule_data) |
| 260 | + results = benchmark_hamiltonian_simulation(molecule_data) |
| 261 | + |
| 262 | + molecules.append(f"Molecule {idx+1}") |
| 263 | + of_rz_counts.append(results["OpenFermion"]["RZ count"]) |
| 264 | + of_rz_depths.append(results["OpenFermion"]["RZ depth"]) |
| 265 | + of_precisions.append(results["OpenFermion"]["Precision"]) |
| 266 | + qrisp_rz_counts.append(results["Qrisp"]["RZ count"]) |
| 267 | + qrisp_rz_depths.append(results["Qrisp"]["RZ depth"]) |
| 268 | + qrisp_precisions.append(results["Qrisp"]["Precision"]) |
| 269 | + qrisp_cx_count.append(results["Qrisp"]["CX count"]) |
| 270 | + of_cx_count.append(results["OpenFermion"]["CX count"]) |
| 271 | + |
| 272 | + |
| 273 | +# Plot the results |
| 274 | + |
| 275 | +import matplotlib.pyplot as plt |
| 276 | + |
| 277 | +# Set up the plots |
| 278 | +fig, (ax1, ax2) = plt.subplots(2, 1, figsize=(7, 7)) |
| 279 | +x = np.arange(len(molecules)) |
| 280 | +width = 0.35 |
| 281 | +molecules = ["$H_2$", "LiH", "BeH", "BH", "$H_2O$", "$NH_3$"] |
| 282 | +ax1.grid() |
| 283 | +ax2.grid() |
| 284 | + |
| 285 | +# Plot RZ counts |
| 286 | +ax1.bar(x + width/2, qrisp_rz_counts, width, label="Qrisp 0.5", color='#20306f', alpha=1, zorder = 100) |
| 287 | +ax1.bar(x - width/2, of_rz_counts, width, label="Google: OpenFermion", color='#FFBA00', alpha=1, zorder = 100) |
| 288 | +ax1.set_ylabel('RZ Count') |
| 289 | +ax1.set_title('RZ Count Comparison') |
| 290 | +ax1.set_xticks(x) |
| 291 | +ax1.set_xticklabels(molecules) |
| 292 | +ax1.legend() |
| 293 | + |
| 294 | + |
| 295 | +# Plot RZ depths |
| 296 | +ax2.bar(x + width/2, qrisp_rz_depths, width, label="Qrisp 0.5", color='#20306f', alpha=1, zorder = 100) |
| 297 | +ax2.bar(x - width/2, of_rz_depths, width, label="Google: OpenFermion", color='#FFBA00', alpha=1, zorder = 100) |
| 298 | +ax2.set_ylabel('RZ Depth') |
| 299 | +ax2.set_title('RZ Depth Comparison') |
| 300 | +ax2.set_xticks(x) |
| 301 | +ax2.set_xticklabels(molecules) |
| 302 | +ax2.legend() |
| 303 | + |
| 304 | + |
| 305 | +plt.tight_layout() |
| 306 | +plt.show() |
| 307 | +plt.savefig("performance_comparison.png", dpi = 300) |
0 commit comments