Local Computing
This tutorial shows how to simulate a quantum experiment with Perceval running locally, on the user computer.
Up to this point, we have focused on creating circuits. It’s time to learn how to sample from them or describe their output distribution, from one or many different input states.
I. Different layers of simulation
Perceval computation classes are organised in three different layers, each having a specific role and set of features:
A SimulatedComputer relies on a Simulator which uses a Backend in order to compute results.
Let’s describe the intent behind each layer.
Layer description | Local simulations | Remote computations |
|---|---|---|
The computer layer is the high level interface designed as Perceval entry point to describe and run linear optics experiments. It supports the optical hardware capabilities available in actual single photons-based QPU. As such, only sampling and measurements are available and not probability amplitudes or state evolutions. They also handle error mitigation that requires running several simulations. SimulatedComputers are meant to be used by quantum algorithm classes that would use them once or multiple times to achieve a goal. The simplest use is to perform sampling using the ExecutionFactory class. This layer is designed so that a user doesn’t have to change all their code to swap from a local to a remote computation. | SimulatedComputer is the go-to class for a simulation directly on the user machine. It can simulate any unitary circuit, a small set of non-unitary components (TimeDelay, LossChannel) and feed-forward. It simulates real life noise. It is aimed at being usable by a Perceval beginner, up to an expert. | RemoteComputer uses the same syntax to create linear optics experiments. It then connects to a compatible Cloud provider. It targets a computation platform which can be a remote simulator or an actual QPU. |
On the receiving end of the experiment, a system has to compute results in order to send them back to the user. The computing layer is very diverse as it can be a raw “perfect” back-end, the implementation of an algorithm simulating a real life setup, or even an actual QPU. | Simulator classes are the Swiss army knife of the local simulation algorithms. They are the most versatile, and as such, the harder to use. They target advanced users. They can simulate a large variety of noise, any type of input (noisy, superposed, mixed, etc.) and can compute a sampling as well as a state evolution (using strong simulation). They are built as an onion, where different Simulator layers can handle a particular non-unitary problem so that it can be extended by expert developers writing their own simulator class. | Cloud platforms (simulator or actual QPU) embody this layer to produce results. |
The back-end is the low level method to compute data. | Locally, Backend classes are state-of-the-art implementations of published algorithms able to simulate perfect results, from a perfect input state in a purely unitary linear optics circuit. These are optimised algorithms, which can definitely be used by a Perceval beginner as long as they only require perfect simulations. | Cloud QPU use their hardware to acquire data. Cloud simulators rely on algorithms that are specifically optimized for a given classical hardware (multithreaded, GPU, etc.). |
[1]:
import perceval as pcvl
from perceval import BS, BasicState, Circuit, SimulatedComputer, ExecutionFactory, Experiment, acquire
II. Computing probabilities
For this part, we will take the Hong-Ou-Mandel experience as an example.
It’s one of the simplest experiments and yet it is very useful.
Making two indistinguishable photons, one in each mode, enter one balanced beamsplitter \(BS=\frac{1}{\sqrt{2}} \left[\begin{matrix}1 & 1\\1& -1\end{matrix}\right]\), we expect the outcome to be:
We will show how to verify this in the next steps using the Naive backend to recover the full probability distribution.
[2]:
# Such a circuit can be created as follows
circuit = BS.H()
pcvl.pdisplay(circuit.compute_unitary())
[3]:
# Let's compute the amplitudes in a perfect simulation (no noise) allowing us to use the low level SLOS back-end
backend = pcvl.BackendFactory.get_backend("SLOS")
backend.set_circuit(circuit)
backend.set_input_state(BasicState([1, 1])) # Only FockStates are accepted here
print("Amplitude of |1,1> giving |2,0> through H =", backend.prob_amplitude(BasicState([2, 0]))) # note that it's the amplitude !
print("Amplitude of |1,1> giving |0,2> through H =", backend.prob_amplitude(BasicState([0, 2])))
print("Probability of |1,1> giving |0,2> through H =", backend.probability(BasicState([0, 2])))
Amplitude of |1,1> giving |2,0> through H = (0.7071067811865477+0j)
Amplitude of |1,1> giving |0,2> through H = (-0.7071067811865477+0j)
Probability of |1,1> giving |0,2> through H = 0.5000000000000002
The same simulation can also be achieved through a SimulatedComputer using the ExecutionFactory to compute a table of probabilities A SimulatedComputer consists of a simulation backend. Even though the SimulatedComputer is able to simulate noise, the default is to run perfect simulations. We insert the circuit into an Experiment that contains all the information about the optical table.
[ ]:
# An Experiment input can be FockState, NoisyFockState, LogicalState (if ports are defined), StateVector, or SVDistribution
experiment = Experiment(circuit)
experiment.with_input(BasicState([1, 1]))
computer = SimulatedComputer("SLOS")
factory = ExecutionFactory(computer, experiment)
# The result of an execution, such as one given by the ExecutionFactory, is a Python dictionary containing at least a "results" key.
# Other fields will be explained later in the tutorial.
with acquire(computer): # The purpose of this line will be explained in the RemoteComputing tutorial
pcvl.pdisplay(factory.probs()["results"])
[ ]:
## Exercise: Create a random 3x3 unitary, and output the table probabilities when the input |1,1,0> passes through the LO-Circuit it represents.
## Solution:
random_unitary = pcvl.Unitary.random(3)
input_state = BasicState([1, 1, 0])
random_experiment = Experiment(random_unitary)
random_experiment.with_input(input_state)
computer = SimulatedComputer("SLOS") # The object already exists, but we remind how to build it for the exercise
factory = ExecutionFactory(computer, random_experiment)
with acquire(computer):
pcvl.pdisplay(factory.probs()["results"])
III. Sampling
Although it’s sometimes crucial to compute the output distribution, it’s not what we can expect from a photonic chip. Indeed, realistically, we only can obtain a single sample from the distribution each time we run the circuit. From any strong simulation result, sampling data can be extrapolated. However, specialised sampling algorithms, such as Clifford & Clifford 2017, are more optimised computing samples on bigger systems rather than “solving the whole quantum system” as strong simulation would.
[6]:
# As before, the low level back-end can be used directly
sampling_backend = pcvl.Clifford2017Backend()
sampling_backend.set_circuit(circuit)
sampling_backend.set_input_state(BasicState([1, 1]))
pcvl.pdisplay(sampling_backend.samples(10))
| states |
|---|
| |2,0> |
| |0,2> |
| |2,0> |
| |2,0> |
| |0,2> |
| |2,0> |
| |0,2> |
| |0,2> |
| |2,0> |
| |0,2> |
[7]:
computer = SimulatedComputer("CliffordClifford2017")
# The factory holds 'probs', 'sample_count' and 'samples' calls. You can use the one that fits your needs!
factory = ExecutionFactory(computer, experiment)
# An Execution call will return a Python dictionary containing sampling results, and a performance score
with acquire(computer):
sample_count = factory.sample_count(1000)
# sample_count contains {'results': <actual count>, 'global_perf': float [0.0 - 1.0]}
pcvl.pdisplay(sample_count['results'])
# Only FockStates (they are the result of a measure on detectors)
| state | count |
|---|---|
| |0,2> | 516 |
| |2,0> | 484 |
[8]:
## Exercise: Implement the code to compute 1000 samples from the 3x3 Unitary we used earlier
#Solution:
computer = SimulatedComputer("CliffordClifford2017") # This line was not needed, but reminded for the exercise
# We could also have changed the experiment directly in the factory: factory.experiment = random_experiment
factory = ExecutionFactory(computer, random_experiment)
with acquire(computer):
sample_count = factory.sample_count(1000)
pcvl.pdisplay(sample_count['results'])
## Question: How many states do we have for 3 modes and 2 photons?
## Answer: There are 6 different states
## Question: How many states do we have for m modes and n photons?
## Answer: There are m+n-1 choose n different states (cf Bar and Star problems).
| state | count |
|---|---|
| |0,0,2> | 431 |
| |1,1,0> | 252 |
| |0,2,0> | 208 |
| |2,0,0> | 52 |
| |1,0,1> | 47 |
| |0,1,1> | 10 |
Note : to approximate with decent precision a distribution over \(M\) different states, we would need \(M^2\) samples. This can be shown by Hoeffding’s inequality.
IV. Performance and output state filtering
Perceval Computers have a built-in way of computing performance scores.
There are three different performance scores:
Global performance
Physical performance
Logical performance
These performance scores help measure the real duration of a data acquisition on a real QPU.
The global performance, which is the product of both physical and logical performances, is always returned. The physical and logical performances can be returned by setting:
computer.compute_physical_logical_perf(True)
Leaving this parameter as default can speed up the computation is some situations.
a. Physical performance
This score is related to the number of detections (on a QPU: number of clicks). It drops output states where photons have been lost, or finish in the same mode.
For instance, an imperfect source makes this score drop.
However, you can choose not to filter any output state by lowering the expected clicks with:
experiment.min_detected_photons_filter(0)
Experiment.min_detected_photons_filter method
Perceval aims at being an interface for the QPU and as such, experiment.min_detected_photons_filter(int k) post selects on having at least k photons detected (for threshold detection: photons on at least k different modes). By default, this value is set to n where n is the expected number of input photons. This is useful for retrieving a logical interpretation, making sure that no photon has been lost due to noise and coherent with the use of threshold detectors. However, for various applications (for instance machine learning where we use the full Fock space and resolve the number of photons), you will have to set it to 0 (and you may introduce you own post selection scheme if needed).
[9]:
# Create an empty circuit (each input mode is directly connected to a detector without interacting with any other)
empty_experiment = Experiment(4) # Equivalent to Experiment(Circuit(4))
# We need to specify how many photons we want
empty_experiment.min_detected_photons_filter(2)
input_state = BasicState([1, 0, 1, 0])
empty_experiment.with_input(input_state)
# Make two computers - one perfect and one noisy
perfect_computer = SimulatedComputer("SLOS")
imperfect_computer = SimulatedComputer("SLOS")
imperfect_computer.noise = pcvl.NoiseModel(brightness=0.3)
# Enable the computation of physical performance
perfect_computer.compute_physical_logical_perf(True)
imperfect_computer.compute_physical_logical_perf(True)
factory = ExecutionFactory(perfect_computer, empty_experiment)
with acquire(perfect_computer):
perfect_probs = factory.probs()
print('Physical perf of perfect computer =', perfect_probs['physical_perf'])
factory.computer = imperfect_computer
with acquire(imperfect_computer):
imperfect_probs = factory.probs()
print('Physical perf of imperfect computer =', imperfect_probs['physical_perf']) # source emission probability**2
# You can still disable output state filtering
empty_experiment.min_detected_photons_filter(0)
imperfect_probs = factory.probs()
print('Physical perf of imperfect computer (without selection) =', imperfect_probs['physical_perf'])
Physical perf of perfect computer = 1.0
Physical perf of imperfect computer = 0.09
Physical perf of imperfect computer (without selection) = 0.9999999999999999
b. Logical performance
This performance computation is set up by heralded modes and/or post-selection function set in an experiment.
Depending on the circuit used, on the post-selection function, you may observe that physical and logical performance score interact. So, if you’re interested on a theoretical gate performance, you should disable physical post-selection with:
experiment.min_detected_photons_filter(- sum(experiment.heralds.values()))
Here is a quick example of the heralding / post-selection syntax in Perceval.
[10]:
circuit = Circuit(3) // BS() // (1, BS()) // BS()
experiment = Experiment(circuit)
experiment.add_herald(2, 0) # Third mode is heralded (0 photon in, 0 photon expected out)
experiment.min_detected_photons_filter(1) # Only non-heralded modes must be taken into account in this filter
# After a mode is heralded, you must not take it into account when setting an input to the experiment
experiment.with_input(pcvl.BasicState([1, 0]))
# Enable the computation of logical performance
computer = SimulatedComputer("SLOS")
computer.compute_physical_logical_perf(True)
factory = ExecutionFactory(computer, experiment)
with computer.acquire():
probs = factory.probs()
print("With herald only")
print("Logical perf =", probs['logical_perf'])
print(probs['results'])
# A post-selection function can be created like this:
postselect_func = pcvl.PostSelect("[1] == 1") # meaning we required 1 photon detection in mode #1
experiment.set_postselection(postselect_func) # Add post-selection
probs = factory.probs()
print("With herald + post-selection function")
print("Logical perf =", probs['logical_perf'])
print(probs['results'])
With herald only
Logical perf = 0.7500000000000003
{
|1,0>: 0.028595479208968305
|0,1>: 0.9714045207910317
}
With herald + post-selection function
Logical perf = 0.7285533905932741
{
|0,1>: 1
}
Now that you know the basics of local computing, let’s see how to execute jobs remotely using Perceval.