Learning Functional Level Design: Arithmetics¶
In this example you enrich your toolset using a quantum adder that coherently adds two numbers, and further explore the capabilities of the synthesis engine!
The first example explains what is meant by coherently adding two quantum numbers. Let the two 1-qubit quantum states to add be $|x> = \frac{1}{\sqrt{2}}(|0>+|1>)$ and $|y>=|1>$. Then, $|x+y>$ indicates that you want $|x>|y>|x+y>=\frac{1}{\sqrt{2}}(|0>|1>|1>+|1>|1>|2>)$.
Now, take it one step further. Represent the two numbers by a superposition of two states of two qubits:
$$ |a> = \frac{1}{\sqrt{2}}(|0> + |3>) = \frac{1}{\sqrt{2}}(|00> + |11>) $$
$$ |b> = \frac{1}{\sqrt{2}}(|1> + |2>) = \frac{1}{\sqrt{2}}(|01> + |10>) $$
This is the solution to expect:
$$'|a+b>'= \frac{1}{2}(|1> + |2> + |4> + |5>)$$
A more accurate statement is that model represents the unitary, acting as follows:
$$ U(|a>|b>|0>) = |a>|b>|a+b> $$
such that:
$$ U(|a>|b>|0>) = \frac{1}{2}(|0>|1>|1>+|0>|2>|2>+|3>|1>|4>+|3>|2>|5>)$$
A Final Circuit¶
This example synthesizes several different circuits for the same functional model. They differ in the number of qubits, the depth, the number of 1- and 2-qubit gates, and which gates are used. However, the structure of all the circuits will be the same.
One of the circuits:
which represents the overall structure: two state preparations, and then the adder.
How To Build It?¶
As in the previous example, first build the functional model, then synthesize it into a circuit (with some surprises in the synthesis part ;)) and then execute the circuits to get the results.
The four steps for building the functional model:
Defining the functional blocks
Defining a high-level functional model that contains the functional blocks
Wiring the blocks in the high-level functional model
Defining how to execute the resulting quantum circuit
1. Defining The Functional Blocks¶
State Preparations¶
The first two building blocks are the state preparations:
First, define the probability distributions of the two registers. The state $|a>$ is an equal superposition of the computational states $|0>$ and $|3>$, while the state $|b>$ is an equal superposition of the computational states $|1>$ and $|2>$.
Therefore, these are the probability distributions:
prob_a = [0.5, 0, 0, 0.5]
prob_b = [0, 0.5, 0.5, 0]
Import the StatePreparation
object, and initiate two instances of it with the corresponding probability distributions and the desired upper bound for the error value:
from classiq.builtin_functions import StatePreparation
sp_a = StatePreparation(
probabilities=prob_a, error_metric={"KL": {"upper_bound": 0.01}}
)
sp_b = StatePreparation(
probabilities=prob_b, error_metric={"KL": {"upper_bound": 0.01}}
)
In these two state preparations there is no reason for an error, so you can expect the synthesis engine to result in the desired state perfectly.
Adder¶
The second component of the algorithm is the adder:
For this, import the Adder
object and initiate one instance of it. The parameters this object receives are the two arguments it receives. The arguments to add are stored in a quantum register, hence they are received as RegisterUserInput
:
from classiq import RegisterUserInput
from classiq.builtin_functions import Adder
adder = Adder(
left_arg=RegisterUserInput(size=2),
right_arg=RegisterUserInput(size=2),
)
The size of the RegisteUserInput
is the number of qubits in the register input. Here each register contains two qubits.
2. Defining the Model¶
Initiate an empty Model
object:
from Classiq import Model
model = Model()
3. Wiring The Blocks in the Model¶
Wire the two StatePreparations
objects into the model:
a = model.StatePreparation(params=sp_a)
b = model.StatePreparation(params=sp_b)
The outputs of the wirings ($a,b$) are used as input wires for the next component, the adder.
To wire the adder, provide its parameters (the actual adder object you initiated earlier) and specify the actual inputs. These are the outputs of the wiring of the state preparations:
adder_out = model.Adder(
params=adder, in_wires={"left_arg": a["OUT"], "right_arg": b["OUT"]}
)
The last part of the wiring stage is to set the outputs. The wiring of the adder results in a dictionary:
print(adder_out)
There are three elements, where the last one is the sum
; the register where the desired result is stored. Tell the model that this is the register of the sum
, as follows:
model.set_outputs(
{"a": adder_out["left_arg"], "b": adder_out["right_arg"], "sum": adder_out["sum"]}
)
4. Defining How to Execute the Resulting Quantum Circuit¶
The current example deals with a simple execution, asking for measurements of all the circuit outputs (in the computational basis).
model.sample()
Define the execution preferences:
from classiq.execution import ExecutionPreferences, IBMBackendPreferences
from classiq.synthesis import set_execution_preferences
backend_preferences = IBMBackendPreferences(
backend_service_provider="IBM Quantum", backend_name="aer_simulator_statevector"
)
serialized_model = model.get_model()
serialized_model = set_execution_preferences(
serialized_model,
execution_preferences=ExecutionPreferences(backend_preferences=backend_preferences),
)
Now the model is complete, and you can synthesize circuits to implement the functionality of the model!
Synthesizing the First Circuit¶
Take the model and synthesize quantum circuits that implement it. As part of the synthesizing process (generating the process of the quantum programs) optimization methods are used to generate the best quantum circuit according to the given constraints. Some of these optimization procedures involve random processes.
To receive the same results repeatedly, as it is common for any numerical method, set the seed of the synthesis engine random number generator as follows:
from classiq.model import Preferences
from classiq.synthesis import set_preferences
seed = 206755496
preferences = Preferences(random_seed=seed)
serialized_model = set_preferences(serialized_model, preferences=preferences)
These preferences are passed to the synthesis engine. Direct the engine to optimize over depth.
from classiq.model import Constraints
from classiq.synthesis import set_constraints
constraints = Constraints(optimization_parameter="depth")
serialized_model = set_constraints(serialized_model, constraints=constraints)
Call the synthesize
function to get the quantum program:
from classiq import synthesize
qprog = synthesize(serialized_model)
Before looking at the circuit, check the mapping between the variables and the qubits; that is, which registers and qubits actually store the values of the computation:
from classiq import GeneratedCircuit
circuit = GeneratedCircuit.from_qprog(qprog)
print(circuit.data.qubit_mapping.logical_outputs)
Now view the circuit:
from Classiq import show
show(qprog)
This is how it looks:
Interesting tips: Saving and reloading circuits¶
The platform allows you to save your synthesized circuits and reload them for visualization and even execution at a later time -
circuit.save_results("my_arithmetic_circuit.json")
Reload the saved circuit via the platform's web application by dragging and dropping "my_arithmetic_circuit.json" to the platform's web application, as follows:
Furthermore, the platform's web application supports "dragging and dropping" QASM files! A QASM file can easily be produced from the circuit object, as follows:
my_file = open("my_arithmetic_circuit.qasm", "w").write(circuit.qasm)
Executing the First Circuit with a Simulator¶
To see the results, execute the circuit you received by calling the execute
function:
from Classiq import execute
results_raw = execute(qprog)
from classiq.execution import ExecutionDetails
results = results_raw[0].value
print(results.counts)
The results.counts
gives the raw results of the execution in a dictionary format. To understand the specific results in terms of the task of adding $|a>}$ and $|b>$ coherently:
output_results = results.counts_of_multiple_outputs(["a", "b", "sum"])
print(output_results)
That is, the output_results
splits the total result counts into the relevant results per register of interest. Remember that for the binary representation, the LSB is on the left and the MSB is on the right.
A small function converts the results into decimal numbers and plots them:
def str2num(str):
return int(str[::-1], 2)
for tupple in output_results.keys():
print(str2num(tupple[0]), "+", str2num(tupple[1]), "=", str2num(tupple[2]))
SUCCESS :)
Indeed, the quantum algorithm succeeded in calculating all four addition operations simultaneously. This is the meaning of coherent addition!
Executing the First Circuit on the Platform's Web Application¶
You can execute the circuit via the web application.
Once your circuit is loaded in the web application-either using the show
function (used above) or by uploading it from a file-the Execution icon appears on the sidebar on the left. To go to the execution page, click this icon. This page contains two sections: Quantum Devices and Execution Management. Execute the circuit on the Azur Quantum Ionq.Simulator
with 1000 shots:
When your execution preferences are ready, click the Run
button on the bottom right.
A progress bar appears. When the execution is complete, view the results:
To download our results for post-processing, click the Export as JSON
button. The results file can be parsed through the results object, as follows:
from classiq.execution import ExecutionDetails
my_downloaded_results = ExecutionDetails.parse_file(
"my_ionq_simulator_arithmetic_circuit_execusion_results.json"
)
print(my_downloaded_results.counts)
Is This the Minimal Number of Qubits Required?¶
Check the width (i.e., the number of qubits) of the generated circuit:
print(
"circuit width: ",
circuit.data.width,
" circuit depth: ",
circuit.transpiled_circuit.depth,
)
Ask the Classiq platform to generate a new circuit with the minimal number of qubits possible:
constraints = Constraints(optimization_parameter="width")
serialized_model_optimized_for_width = set_constraints(
serialized_model, constraints=constraints
)
qprog_optimized_for_width = synthesize(serialized_model_optimized_for_width)
The circuit is reduced from eight qubits to seven:
circuit_optimized_for_width = GeneratedCircuit.from_qprog(qprog_optimized_for_width)
print(
"circuit width: ",
circuit_optimized_for_width.data.width,
" circuit depth: ",
circuit_optimized_for_width.transpiled_circuit.depth,
)
Wow! Note that two circuits with exactly the same functionality of adding two quantum registers can differ in their width and depth. This is highly important when generating the optimal quantum algorithm possible.
How is this algorithm mapped into real hardware? Import the Analyzer
to help analyze the circuit:
from classiq import Analyzer
analyzer = Analyzer(circuit=circuit_optimized_for_width)
analyzer.get_hardware_comparison_table(["Azure Quantum"])
analyzer.plot_hardware_comparison_table()
Is This the Best Circuit for the Specific Hardware?¶
You have optimized the depth of the circuit. Now, to execute on an ion-trapped quantum computer, optimize the circuit for this specific hardware. The generated circuit might be the shortest one when ignoring hardware, but for specific targeted hardware, its
- connectivity,
- number of qubits, and
- native gate-set
might be better suited for a different circuit.
Synthesize a new circuit according to the IonQ hardware, optimizing for the shortest possible:
from classiq.model import Preferences
azure_preferences = Preferences(
backend_service_provider="Azure Quantum", backend_name="ionq", random_seed=seed
)
serialized_model_optimized_for_ionq = set_preferences(
serialized_model, preferences=azure_preferences
)
serialized_model_optimized_for_ionq = set_constraints(
serialized_model_optimized_for_ionq, constraints=constraints
)
qprog_optimized_for_ionq = synthesize(serialized_model_optimized_for_ionq)
circuit_optimized_for_ionq = GeneratedCircuit.from_qprog(qprog_optimized_for_ionq)
Check what has changed from the hardware-agnostic optimized circuit, remembering that the multi-qubit gate count is 26 and the total gate count is 68:
analyzer_circuit_optimized_ionq = Analyzer(circuit=circuit_optimized_for_ionq)
analyzer_circuit_optimized_ionq.get_hardware_comparison_table(["Azure Quantum"])
analyzer_circuit_optimized_ionq.plot_hardware_comparison_table()
The received circuit is more suited for IonQ.
You saved $24\%$ in the total gate count and $12\%$ in the 2-qubit gate count, which are significant numbers on their own. Be aware that the resources saved-both in numbers and in percentages-grow as the circuit grows!
Comparing the Optimized Circuits¶
Examine the circuits to see what has changed. First, the hardware-agnostic optimized circuit:
show(qprog_optimized_for_width)
and zooming in on the adder:
And now for the IonQ-optimized circuit:
show(qprog_optimized_for_ionq)
Zoom in on the adder:
These two adders implement the exact same thing, but are completely different! This optimization is at the heart of the Classiq platform, and it could not be handled manually as circuits grow.
Executing Via Azure¶
Execute the above circuit on the Azure simulator or hardware. Set the corresponding execution preferences in the quantum program:
from classiq.execution import (
AzureBackendPreferences,
set_quantum_program_execution_preferences,
)
hardware_preferences = AzureBackendPreferences(
backend_name="ionq.simulator"
) # ionq.simulator, ionq.qpu
qprog_optimized_for_ionq = set_quantum_program_execution_preferences(
qprog_optimized_for_ionq,
preferences=ExecutionPreferences(backend_preferences=hardware_preferences),
)
Then we can execute our circuit:
results_raw = execute(qprog_optimized_for_ionq)
And the results:
from classiq.execution import ExecutionDetails
results = results_raw[0].value
print("Run via Azure counts:", results.counts)
All the Code Together¶
from classiq import (
Analyzer,
GeneratedCircuit,
Model,
RegisterUserInput,
execute,
show,
synthesize,
)
from classiq.builtin_functions import Adder, StatePreparation
from classiq.execution import (
AzureBackendPreferences,
ExecutionDetails,
ExecutionPreferences,
IBMBackendPreferences,
set_quantum_program_execution_preferences,
)
from classiq.model import Constraints, Preferences
from classiq.synthesis import (
set_constraints,
set_execution_preferences,
set_preferences,
)
# defining function
def str2num(str):
return int(str[::-1], 2)
# defining probabilities
prob_a = [0.5, 0, 0, 0.5]
prob_b = [0, 0.5, 0.5, 0]
# defining state preparation
sp_a = StatePreparation(
probabilities=prob_a, error_metric={"KL": {"upper_bound": 0.01}}
)
sp_b = StatePreparation(
probabilities=prob_b, error_metric={"KL": {"upper_bound": 0.01}}
)
# defining the adder
adder = Adder(
left_arg=RegisterUserInput(size=2),
right_arg=RegisterUserInput(size=2),
)
# initiating a model
model = Model()
# wiring state preparations
a = model.StatePreparation(params=sp_a)
b = model.StatePreparation(params=sp_b)
# wiring the adder
adder_out = model.Adder(
params=adder, in_wires={"left_arg": a["OUT"], "right_arg": b["OUT"]}
)
print(adder_out)
# setting the outputs
model.set_outputs(
{"a": adder_out["left_arg"], "b": adder_out["right_arg"], "sum": adder_out["sum"]}
)
model.sample()
backend_preferences = IBMBackendPreferences(
backend_service_provider="IBM Quantum", backend_name="aer_simulator_statevector"
)
serialized_model = model.get_model()
serialized_model = set_execution_preferences(
serialized_model,
execution_preferences=ExecutionPreferences(backend_preferences=backend_preferences),
)
# fixing the seed
seed = 206755496
preferences = Preferences(random_seed=seed)
serialized_model = set_preferences(serialized_model, preferences=preferences)
# synthesizing the first circuit
constraints = Constraints(optimization_parameter="depth")
serialized_model = set_constraints(serialized_model, constraints=constraints)
qprog = synthesize(serialized_model)
# printing the output mapping
circuit = GeneratedCircuit.from_qprog(qprog)
print(circuit.data.qubit_mapping.logical_outputs)
# vizualizing the circuit
show(qprog)
# save results
circuit.save_results("my_arithmetic_circuit.json")
# save qasm file
my_file = open("my_arithmetic_circuit.qasm", "w").write(circuit.qasm)
# executing the first circuit with a simulator
from classiq import execute
results_raw = execute(qprog)
results = results_raw[0].value
print(results.counts)
# output results
output_results = results.counts_of_multiple_outputs(["a", "b", "sum"])
print(output_results)
# understanding the results
for tupple in output_results.keys():
print(str2num(tupple[0]), "+", str2num(tupple[1]), "=", str2num(tupple[2]))
# loading results from IDE executor
my_downloaded_results = ExecutionDetails.parse_file(
"my_ionq_simulator_arithmetic_circuit_execusion_results.json"
)
print(my_downloaded_results.counts)
# motivation for shalower circuit
print(
"circuit width: ",
circuit.data.width,
" circuit depth: ",
circuit.transpiled_circuit.depth,
)
# synthesizing the second circuit
constraints = Constraints(optimization_parameter="width")
serialized_model_optimized_for_width = set_constraints(
serialized_model, constraints=constraints
)
qprog_optimized_for_width = synthesize(serialized_model_optimized_for_width)
circuit_optimized_for_width = GeneratedCircuit.from_qprog(qprog_optimized_for_width)
print(
"circuit width: ",
circuit_optimized_for_width.data.width,
" circuit depth: ",
circuit_optimized_for_width.transpiled_circuit.depth,
)
# analyzing the 2nd circuit
analyzer = Analyzer(circuit=circuit_optimized_for_width)
analyzer.get_hardware_comparison_table(["Azure Quantum"])
analyzer.plot_hardware_comparison_table()
# synthesizing the 3rd circuit
azure_preferences = Preferences(
backend_service_provider="Azure Quantum", backend_name="ionq", random_seed=seed
)
serialized_model_optimized_for_ionq = set_preferences(
serialized_model, preferences=azure_preferences
)
serialized_model_optimized_for_ionq = set_constraints(
serialized_model_optimized_for_ionq, constraints=constraints
)
qprog_optimized_for_ionq = synthesize(serialized_model_optimized_for_ionq)
circuit_optimized_for_ionq = GeneratedCircuit.from_qprog(qprog_optimized_for_ionq)
# analyzing the 3rd circuit
analyzer_circuit_optimized_ionq = Analyzer(circuit=circuit_optimized_for_ionq)
analyzer_circuit_optimized_ionq.get_hardware_comparison_table(["Azure Quantum"])
analyzer_circuit_optimized_ionq.plot_hardware_comparison_table()
# viewing the 2nd and 3rd circuits
show(qprog_optimized_for_width)
show(qprog_optimized_for_ionq)
# Azure execution
hardware_preferences = AzureBackendPreferences(
backend_name="ionq.simulator"
) # ionq.simulator, ionq.qpu
qprog_optimized_for_ionq = set_quantum_program_execution_preferences(
qprog_optimized_for_ionq,
preferences=ExecutionPreferences(backend_preferences=hardware_preferences),
)
results_raw = execute(qprog_optimized_for_ionq)
results = results_raw[0].value
print("Run via Azure counts:", results.counts)