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 Processor 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 processor 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. Processors 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 in the Sampler 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. | Processor 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. | RemoteProcessor uses the same syntax to create linear optics experiments. It then connects to a Quandela compatible Cloud provider. It targets a computation platform which can be a remote simulator or an actual QPU. Available features are defined on a per-platform basis (e.g. a remote simulator based on a strong simulation algorithm can output exact probabilities, whereas a QPU can only acquire samples). |
On the receiving end of the experiment, a system has to compute results in order to send them back to the user. The computation 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, Processor
from perceval.algorithm import Sampler
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 Processor
using the Sampler
algorithm to compute a table of probabilities A Processor consists of a simulation backend and a circuit (or an experiment). Even though the Processor
is able to simulate noise, the default is to run perfect simulations.
[4]:
p = Processor("SLOS", circuit)
p.with_input(BasicState([1, 1]))
sampler = Sampler(p)
# The result of an algorithm, such as the Sampler, is a Python dictionary containing at least a "results" key.
# Other fields will be explained later in the tutorial.
pcvl.pdisplay(sampler.probs()["results"])
state | probability |
---|---|
|2,0> | 1/2 |
|0,2> | 1/2 |
[5]:
## 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(pcvl.Matrix.random_unitary(3))
input_state = BasicState([1, 1, 0])
p = Processor("SLOS", random_unitary)
p.with_input(input_state)
sampler = Sampler(p)
pcvl.pdisplay(sampler.probs()["results"])
state | probability |
---|---|
|2,0,0> | 0.466531 |
|0,2,0> | 0.455058 |
|0,1,1> | 0.045284 |
|1,0,1> | 0.031179 |
|1,1,0> | 0.001716 |
|0,0,2> | 0.000232062 |
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 |
---|
|0,2> |
|0,2> |
|0,2> |
|0,2> |
|2,0> |
|0,2> |
|0,2> |
|2,0> |
|2,0> |
|2,0> |
[7]:
p = Processor("CliffordClifford2017", circuit)
p.min_detected_photons_filter(0) # Do not filter out any output state
# A Processor input can be FockState, NoisyFockState, LogicalState (if ports are defined), StateVector, or SVDistribution
p.with_input(pcvl.BasicState([1, 1]))
# The sampler holds 'probs', 'sample_count' and 'samples' calls. You can use the one that fits your needs!
sampler = Sampler(p)
# A sampler call will return a Python dictionary containing sampling results, and a performance score
sample_count = sampler.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> | 511 |
|2,0> | 489 |
[8]:
## Exercise: Implement the code to compute 1000 samples from the 3x3 Unitary we used earlier
#Solution:
p = pcvl.Processor("CliffordClifford2017", random_unitary)
p.min_detected_photons_filter(0) # Do not filter out any output state
p.with_input(pcvl.BasicState([1, 1, 0]))
sampler = pcvl.algorithm.Sampler(p)
sample_count = sampler.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,2,0> | 468 |
|2,0,0> | 449 |
|0,1,1> | 57 |
|1,0,1> | 25 |
|1,1,0> | 1 |
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 Processors 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: > proc.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: > proc.min_detected_photons_filter(0)
Processor.min_detected_photons_filter method
Perceval aims at being an interface for the QPU and as such, proc.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_circuit = Circuit(4)
perfect_proc = Processor("SLOS", empty_circuit)
imperfect_proc = Processor("SLOS", empty_circuit, noise=pcvl.NoiseModel(brightness=0.3))
# We need to specify how many photons we want
perfect_proc.min_detected_photons_filter(2)
imperfect_proc.min_detected_photons_filter(2)
# Set the same input in both processors
input_state = BasicState([1, 0, 1, 0])
perfect_proc.with_input(input_state)
imperfect_proc.with_input(input_state)
# Enable the computation of physical performance
perfect_proc.compute_physical_logical_perf(True)
imperfect_proc.compute_physical_logical_perf(True)
perfect_sampler = Sampler(perfect_proc)
perfect_probs = perfect_sampler.probs()
imperfect_sampler = Sampler(imperfect_proc)
imperfect_probs = imperfect_sampler.probs()
print('Physical perf of perfect processor =', perfect_probs['physical_perf'])
print('Physical perf of imperfect processor =', imperfect_probs['physical_perf']) # source emission probability**2
# You can still disable output state filtering
imperfect_proc.min_detected_photons_filter(0)
imperfect_probs = imperfect_sampler.probs()
print('Physical perf of imperfect processor (without selection) =', imperfect_probs['physical_perf'])
Physical perf of perfect processor = 1.0
Physical perf of imperfect processor = 0.09
Physical perf of imperfect processor (without selection) = 0.9999999999999999
b. Logical performance
This performance computation is set up by heralded modes and/or post-selection function set in a processor.
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: > proc.min_detected_photons_filter(- sum(proc.heralds.values()))
Here is a quick example of the heralding / post-selection syntax in Perceval. You will see the result later on in this notebook.
[10]:
circuit = Circuit(3) // BS() // (1, BS()) // BS()
p = Processor("SLOS", circuit)
p.add_herald(2, 0) # Third mode is heralded (0 photon in, 0 photon expected out)
p.min_detected_photons_filter(1) # Only non-heralded modes must be taken into account in this filter
# Enable the computation of logical performance
p.compute_physical_logical_perf(True)
# After a mode is heralded, you must not take it into account when setting an input to the processor
p.with_input(pcvl.BasicState([1, 0]))
sampler = Sampler(p)
probs = sampler.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
p.set_postselection(postselect_func) # Add post-selection
probs = sampler.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.02859547920896832
|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.