ProjectQ Mapper Tutorial

The aim of this short tutorial is to give an introduction to the ProjectQ mappers.

ProjectQ allows a user to write a quantum program in a high-level language. For example, one can apply quantum operations on n-qubits, e.g., QFT, and the compiler will decompose this operations into two-qubit and single-qubit gates. See the compiler_tutorial for an introduction.

After decomposing a quantum program into two-qubit and single-qubit gates which a quantum computer supports, we have to take the physical layout of these qubits into account. Two-qubit gates are only possible if the qubits are next to each other. For example the qubits could be arranged in a linear chain or a two-dimensional grid and only nearest neighbour qubits can perform a two-qubit gate. ProjectQ uses mappers which move the positions of the qubits close to each other using Swap operations in order that we can execute a two-qubit gate.

The implementation and some results of ProjectQ's mappers are discussed in our paper (section 3C)

Example: Mapping to a linear chain

Let's look at an example of a quantum fourier transform (QFT) compiled into single-qubit gates and CNOTs. First, we look at the resources required if the qubits have an all-to-all connectivity, i.e., any pairs of qubits can execute a CNOT:


In [1]:
import projectq
from projectq.backends import ResourceCounter
from projectq.ops import CNOT, QFT
from projectq.setups import restrictedgateset

engine_list = restrictedgateset.get_engine_list(one_qubit_gates="any",
                                                two_qubit_gates=(CNOT,))
resource_counter = ResourceCounter()
eng = projectq.MainEngine(backend=resource_counter, engine_list=engine_list)

qureg = eng.allocate_qureg(15)
QFT | qureg
eng.flush()

print(resource_counter)


Gate class counts:
    AllocateQubitGate : 15
    CXGate : 210
    HGate : 15
    R : 105
    Rz : 210

Gate counts:
    Allocate : 15
    CX : 210
    H : 15
    R(0.000191747598) : 2
    R(0.000383495197) : 3
    R(0.000766990394) : 4
    R(0.001533980788) : 5
    R(0.003067961576) : 6
    R(0.006135923151) : 7
    R(0.012271846303) : 8
    R(0.024543692606) : 9
    R(0.049087385213) : 10
    R(0.098174770424) : 11
    R(0.196349540849) : 12
    R(0.392699081698) : 13
    R(0.785398163398) : 14
    R(9.5873799e-05) : 1
    Rz(0.000191747598) : 2
    Rz(0.000383495197) : 3
    Rz(0.000766990394) : 4
    Rz(0.001533980788) : 5
    Rz(0.003067961576) : 6
    Rz(0.006135923151) : 7
    Rz(0.012271846303) : 8
    Rz(0.024543692606) : 9
    Rz(0.049087385213) : 10
    Rz(0.098174770424) : 11
    Rz(0.196349540849) : 12
    Rz(0.392699081698) : 13
    Rz(0.785398163398) : 14
    Rz(11.780972451) : 14
    Rz(12.1736715327) : 13
    Rz(12.3700210735) : 12
    Rz(12.4681958439) : 11
    Rz(12.5172832291) : 10
    Rz(12.5418269218) : 9
    Rz(12.5540987681) : 8
    Rz(12.5602346912) : 7
    Rz(12.5633026528) : 6
    Rz(12.5648366336) : 5
    Rz(12.565603624) : 4
    Rz(12.5659871192) : 3
    Rz(12.5661788668) : 2
    Rz(12.5662747406) : 1
    Rz(9.5873799e-05) : 1

Max. width (number of qubits) : 15.

Now let's assume our qubits are arrange on a linear chain, we can use an already predefined compiler setup to compile to this architecture:


In [2]:
from projectq.setups import linear

engine_list2 = linear.get_engine_list(num_qubits=15, cyclic=False,
                                      one_qubit_gates="any",
                                      two_qubit_gates=(CNOT,))
resource_counter2 = ResourceCounter()
eng2 = projectq.MainEngine(backend=resource_counter2, engine_list=engine_list2)

qureg2 = eng2.allocate_qureg(15)
QFT | qureg2
eng2.flush()

print(resource_counter2)


Gate class counts:
    AllocateQubitGate : 15
    CXGate : 888
    HGate : 15
    R : 105
    Rz : 210

Gate counts:
    Allocate : 15
    CX : 888
    H : 15
    R(0.000191747598) : 2
    R(0.000383495197) : 3
    R(0.000766990394) : 4
    R(0.001533980788) : 5
    R(0.003067961576) : 6
    R(0.006135923151) : 7
    R(0.012271846303) : 8
    R(0.024543692606) : 9
    R(0.049087385213) : 10
    R(0.098174770424) : 11
    R(0.196349540849) : 12
    R(0.392699081698) : 13
    R(0.785398163398) : 14
    R(9.5873799e-05) : 1
    Rz(0.000191747598) : 2
    Rz(0.000383495197) : 3
    Rz(0.000766990394) : 4
    Rz(0.001533980788) : 5
    Rz(0.003067961576) : 6
    Rz(0.006135923151) : 7
    Rz(0.012271846303) : 8
    Rz(0.024543692606) : 9
    Rz(0.049087385213) : 10
    Rz(0.098174770424) : 11
    Rz(0.196349540849) : 12
    Rz(0.392699081698) : 13
    Rz(0.785398163398) : 14
    Rz(11.780972451) : 14
    Rz(12.1736715327) : 13
    Rz(12.3700210735) : 12
    Rz(12.4681958439) : 11
    Rz(12.5172832291) : 10
    Rz(12.5418269218) : 9
    Rz(12.5540987681) : 8
    Rz(12.5602346912) : 7
    Rz(12.5633026528) : 6
    Rz(12.5648366336) : 5
    Rz(12.565603624) : 4
    Rz(12.5659871192) : 3
    Rz(12.5661788668) : 2
    Rz(12.5662747406) : 1
    Rz(9.5873799e-05) : 1

Max. width (number of qubits) : 15.

One can see that once we restricted the hardware to a linear chain, the same program requires a lot more CNOT (also called CX) gates. This is due to additionals Swap operations to move the qubits around (a Swap gate can be constructed out of three CX gates).

Example: Mapping to a two-dimensional grid

ProjectQ also has a predefined setup to map to a two-dimensional grid.


In [3]:
from projectq.setups import grid

engine_list3 = grid.get_engine_list(num_rows=3, num_columns=5,
                                      one_qubit_gates="any",
                                      two_qubit_gates=(CNOT,))
resource_counter3 = ResourceCounter()
eng3 = projectq.MainEngine(backend=resource_counter3, engine_list=engine_list3)

qureg3 = eng3.allocate_qureg(15)
QFT | qureg3
eng3.flush()

print(resource_counter3)


Gate class counts:
    AllocateQubitGate : 15
    CXGate : 741
    HGate : 15
    R : 105
    Rz : 210

Gate counts:
    Allocate : 15
    CX : 741
    H : 15
    R(0.000191747598) : 2
    R(0.000383495197) : 3
    R(0.000766990394) : 4
    R(0.001533980788) : 5
    R(0.003067961576) : 6
    R(0.006135923151) : 7
    R(0.012271846303) : 8
    R(0.024543692606) : 9
    R(0.049087385213) : 10
    R(0.098174770424) : 11
    R(0.196349540849) : 12
    R(0.392699081698) : 13
    R(0.785398163398) : 14
    R(9.5873799e-05) : 1
    Rz(0.000191747598) : 2
    Rz(0.000383495197) : 3
    Rz(0.000766990394) : 4
    Rz(0.001533980788) : 5
    Rz(0.003067961576) : 6
    Rz(0.006135923151) : 7
    Rz(0.012271846303) : 8
    Rz(0.024543692606) : 9
    Rz(0.049087385213) : 10
    Rz(0.098174770424) : 11
    Rz(0.196349540849) : 12
    Rz(0.392699081698) : 13
    Rz(0.785398163398) : 14
    Rz(11.780972451) : 14
    Rz(12.1736715327) : 13
    Rz(12.3700210735) : 12
    Rz(12.4681958439) : 11
    Rz(12.5172832291) : 10
    Rz(12.5418269218) : 9
    Rz(12.5540987681) : 8
    Rz(12.5602346912) : 7
    Rz(12.5633026528) : 6
    Rz(12.5648366336) : 5
    Rz(12.565603624) : 4
    Rz(12.5659871192) : 3
    Rz(12.5661788668) : 2
    Rz(12.5662747406) : 1
    Rz(9.5873799e-05) : 1

Max. width (number of qubits) : 15.

We can see that mapping a QFT to a two-dimensional grid layout requires fewer CX gates than mapping to a linear chain as expected.

Inspecting the current mapping of logical qubits to physical qubits

A qubit which you obtain by calling the allocate_qubit() function of the compiler (MainEngine) is just an abstract objects which has a unique ID.


In [4]:
from projectq.backends import CommandPrinter
from projectq.ops import X, Swap

engine_list4 = linear.get_engine_list(num_qubits=3, cyclic=False,
                                      one_qubit_gates="any",
                                      two_qubit_gates=(CNOT, Swap))

eng4 = projectq.MainEngine(backend=CommandPrinter(), engine_list=engine_list4)

# For instructional purposes we change that the eng4 gives logical ids starting
# from 10. This could e.g. be the case if a previous part of the program
# already allocated 10 qubits
eng4._qubit_idx = 10

qubit0 = eng4.allocate_qubit()
qubit1 = eng4.allocate_qubit()
qubit2 = eng4.allocate_qubit()

X | qubit0

# Remember that allocate_qubit returns a quantum register (Qureg) of size 1,
# so accessing the qubit requires qubit[0]
print("This logical qubit0 has the unique ID: {}".format(qubit0[0].id))
print("This logical qubit1 has the unique ID: {}".format(qubit1[0].id))
print("This logical qubit2 has the unique ID: {}".format(qubit2[0].id)) 

eng4.flush()


This logical qubit0 has the unique ID: 10
This logical qubit1 has the unique ID: 11
This logical qubit2 has the unique ID: 12
Allocate | Qureg[0]
X | Qureg[0]
Allocate | Qureg[1]
Allocate | Qureg[2]

As we can see qubit0 has a logical ID equal to 10. The LinearMapper in this compiler setup then places these qubits on a linear chain with the following physical qubit ID ordering:

0 -- 1 -- 2

where -- indicates that these two qubits can perform a CNOT gate. If you are interested in knowing where a specific logical qubit is currently placed, you can access this information via the current_mapping property of the mapper:


In [5]:
# eng.mapper gives back the mapper in the engine_list
current_mapping = eng4.mapper.current_mapping
# current_mapping is a dictionary with keys being the
# logical qubit ids and the values being the physical ids on
# on the linear chain
print("Physical location of qubit0: {}".format(current_mapping[qubit0[0].id]))
print("Physical location of qubit1: {}".format(current_mapping[qubit1[0].id]))
print("Physical location of qubit2: {}".format(current_mapping[qubit2[0].id]))


Physical location of qubit0: 0
Physical location of qubit1: 1
Physical location of qubit2: 2

Suppose we now perform a CNOT between qubit0 and qubit2, then the mapper needs to swap these two qubits close to each other to perform the operation:


In [6]:
CNOT | (qubit0, qubit2)
eng4.flush()
# Get current mapping:
current_mapping = eng4.mapper.current_mapping
print("\nPhysical location of qubit0: {}".format(current_mapping[qubit0[0].id]))
print("Physical location of qubit1: {}".format(current_mapping[qubit1[0].id]))
print("Physical location of qubit2: {}".format(current_mapping[qubit2[0].id]))


Swap | ( Qureg[1], Qureg[2] )
CX | ( Qureg[0], Qureg[1] )

Physical location of qubit0: 0
Physical location of qubit1: 2
Physical location of qubit2: 1

We see that the compiler added a Swap gate to change the location of the logical qubits in this chain so that the CNOT can be performed.

Measurements, probabilities, and amplitudes

While the compiler automatically remaps logical qubits to different physical locations, how does this affect the high-level programmer?

The short answer is not at all.

If you want to measure a logical qubit, just apply a measurement gate as before and the compiler will automatically find the correct physical qubit to measure:


In [7]:
from projectq.backends import Simulator
from projectq.ops import Measure

engine_list5 = linear.get_engine_list(num_qubits=3, cyclic=False,
                                      one_qubit_gates="any",
                                      two_qubit_gates=(CNOT, Swap))

eng5 = projectq.MainEngine(backend=Simulator(), engine_list=engine_list5)

qubit0 = eng5.allocate_qubit()
qubit1 = eng5.allocate_qubit()
qubit2 = eng5.allocate_qubit()

X | qubit0
Measure | qubit0
eng5.flush()

print("qubit0 was measured in state: {}".format(int(qubit0)))


qubit0 was measured in state: 1

All the simulator functionalities, e.g., get_probability or get_amplitude work as usual because they take logical qubits as arguments so the programmer does not need to worry about at which physical location qubit0 is at the moment:


In [8]:
eng5.backend.get_probability('1', qubit0)


Out[8]:
1.0

In [ ]: