Quantum Arithmetic Data Types¶
In this tutorial, we will learn about the quantum arithmetic data types - quantum analogues of classical integer and real numbers.
The Qubits data type we've used until now represents "raw" qubit registers - arrays of qubits that can be interpreted as bit strings, numbers in any encoding, or any other data type. However, fault-tolerant quantum algorithms often involve qubit registers that encode a numeric value and performing arithmetic computations on superpositions of numeric values coherently. Same as introducing int and float data types simplifies expressing numeric calculations in classical programming, having specialized quantum data types simplifies such calculations in quantum programming.
Workbench supports four quantum arithmetic data types for representing signed and unsigned integer and fixed-point numbers, as well as aggregate data types for representing more complex data structures. We will introduce these data types in this tutorial and discuss using them in quantum arithmetic in the next one.
Quantum arithmetic data types in detail¶
All quantum arithmetic data types are subclasses of the Qubits class and share a lot of its features and functionality: they rely on the same allocation and release logic, support the same gates and the same operations for indexing, slicing and concatenation. Same as the Qubits type, objects of quantum arithmetic data types can have different sizes, specified when an object is created.
Let's go through the quantum arithmetic data types and take a closer look at each of them.
QUInt¶
The simplest quantum data type is QUInt, quantum unsigned integer. It interprets the binary string as an unsigned integer in little-endian notation.
This allows an $n$-bit QInt to represent all integers in the range $[ 0, 2^n-1 ]$:
$$ b_{n-1} \cdots b_2b_1b_0 = b_{n-1} \times 2^{n-1} + \cdots + b_2 \times 2^2 + b_1 \times 2^1 + b_0 \times 2^0 $$
For example,
$$1011_{bin} = 1_30_21_11_0 = 1\times2^3 + 0\times2^2 + 1\times2^1 + 1\times2^0 = 11_{dec}$$
QUInt behaves the same as the base type Qubits in most contexts, since Workbench interprets Qubits values as unsigned integers in little-endian notation too.
You can allocate a QUInt object similarly to how you allocate a Qubits object: by calling the constructor QUInt() with three arguments: the number of qubits, the object name, and the QPU.
my_quint = QUInt(5, "my_quint", qpu)
QInt¶
The next quantum data type is QInt, quantum signed integer. It interprets the binary string as a signed integer using the two's complement ⧉ representation for negative integers.
An $n$-bit QInt can be represented as the sum of an $n-1$-bit QUInt stored in the least significant bits and the negative number $-2^{n-1}$ stored in the most significant bit.
This allows an $n$-bit QInt to represent all integers in the range $[ -2^{n-1}, 2^{n-1}-1 ]$:
$$ b_{n-1}\cdots b_2b_1b_0 = \underbrace{-b_{n-1} \times 2^{n-1}}_\text{sign bit} + \underbrace{b_{n-2} \times 2^{n-2} + \cdots + b_2 \times 2^2 + b_1 \times 2^1 + b_0 \times 2^0}_{\text{unsigned } n-1 \text{-bit integer}} $$
For example,
$$1011_{bin} = 1_30_21_11_0 = -1\times2^3 + 0\times2^2 + 1\times2^1 + 1\times2^0 = -5_{dec}$$
Two's complement is the most common method of representing negative numbers in computer systems. A convenient way to negate a number in two's complement is to flip all bits and add $1$ to it.
You can allocate a QInt object similarly to how you allocate a Qubits or a QUInt object: by calling the constructor QInt() with three arguments: the number of qubits, the object name, and the QPU.
my_qint = QInt(5, "my_qint", qpu)
QUFixed¶
Workbench uses fixed-point representations for real numbers instead of floating-point numbers common for classical programming languages.
An unsigned fixed-point type QUFixed operates like an unsigned integer QUInt but includes an additional parameter, the radix, which specifies the number of binary digits in the fractional part of the number. An $n$-bit QUFixed with radix $r$ can be represented as an $n-r$-bit QUInt stored in the most significant bits and an $r$-bit fraction stored in the least significant bits:
\begin{align*} b_{n-1} \cdots b_2 b_1 b_0 &= \underbrace{b_{n-1} \times 2^{n-1-r} + \cdots + b_{r + 1} \times 2^1 + b_r \times 2^0}_{\text{unsigned integer}} \\ &+ \underbrace{b_{r-1} \times 2^{-1} + \cdots + b_1 \times 2^{1-r} + b_0 \times 2^{-r}}_{\text{fractional part}} \end{align*}
For example, with $n=4$ and $r=2$:
\begin{align*} 1011_{bin} = 1_30_21_11_0 &= 1 \times 2^{1} + 0 \times 2^{0} + 1 \times 2^{-1} + 1 \times 2^{-2} \\ &= 1 \times 2 + 0 \times 1 + 1 \times \tfrac{1}{2} + 1 \times \tfrac{1}{4} \\ &= 2.75_{dec} \end{align*}
You can allocate a QUFixed object similarly to how you allocate other quantum types, by calling the constructor QUFixed(), with four arguments: in addition to the number of qubits, the object name, and the QPU, you need to provide the radix value.
my_qufixed = QUFixed(5, 2, "my_qufixed", qpu)
Once you've created a QUFixed object, you can get the number of qubits in its fractional part using the radix field:
print(my_qufixed.radix)
QFixed¶
Finally, signed fixed-point type QFixed operates like a signed integer QInt with a fractional part specified by the radix. An $n$-bit QFixed with radix $r$ can be represented as an $n-r$-bit QInt stored in the most significant bits and an $r$-bit fraction stored in the least significant bits:
\begin{align*} b_{n-1} \cdots b_2 b_1 b_0 &= \underbrace{-b_{n-1} \times 2^{n-1-r} + b_{n-2} \times 2^{n-2-r} + \cdots + b_{r + 1} \times 2^1 + b_r \times 2^0}_{\text{signed integer}} \\ &+ \underbrace{b_{r-1} \times 2^{-1} + \cdots + b_1 \times 2^{1-r} + b_0 \times 2^{-r}}_{\text{fractional part}} \end{align*}
For example, with $n=4$ and $r=2$:
\begin{align*} 1011_{bin} = 1_30_21_11_0 &= -1 \times 2^{1} + 0 \times 2^{0} + 1 \times 2^{-1} + 1 \times 2^{-2} \\ &= -1 \times 2 + 0 \times 1 + 1 \times \tfrac{1}{2} + 1 \times \tfrac{1}{4} \\ &= -1.25_{dec} \end{align*}
The ranges of values represented by QUFixed and QFixed match the ranges represented by QUInt and QInt, divided by $2^{radix}$.
You can allocate a QFixed object similarly to how you allocate a QUFixed type, by calling the constructor QFixed() with four arguments: the number of qubits, the radix, the object name, and the QPU.
my_qfixed = QFixed(5, 2, "my_qfixed", qpu)
Once you've created a QFixed object, you can get the number of qubits in its fractional part using the radix field, same as for a QUFixed object:
print(my_qfixed.radix)
The following table shows the list of three-qubit bit strings and the numeric values they represent when treated as different arithmetic data types. Remember that all data types use little-endian notation, with the least significant bit stored first.
| Basis state (little-endian) | QUInt/Qubits | QInt | QUFixed(radix=1) | QFixed(radix=1) |
|---|---|---|---|---|
000 |
0 | 0 | 0.0 | 0.0 |
100 |
1 | 1 | 0.5 | 0.5 |
010 |
2 | 2 | 1.0 | 1.0 |
110 |
3 | 3 | 1.5 | 1.5 |
001 |
4 | -4 | 2.0 | -2.0 |
101 |
5 | -3 | 2.5 | -1.5 |
011 |
6 | -2 | 3.0 | -1.0 |
111 |
7 | -1 | 3.5 | -0.5 |
Re-typing quantum data types¶
You can create quantum arithmetic types without allocating qubits, same as you can with Qubits objects, by creating a reference to previously allocated qubits. You can use this feature to re-type an object, changing its data type. This is often useful when dealing with different data types in your program.
For example, you can convert an unsigned integer QUInt to a signed integer QInt which stores the same value by appending a sign bit to the object and re-typing the result. Remember that the sign bit should be the most significant one, that is, the last one in the new object:
quint = QUInt(3, "quint", qpu)
sign = Qubits(1, "sign", qpu)
qint = QInt(quint | sign, "qint")
Similarly, you can convert an integer data type into a real one, extend the size of the object on the fly to prevent overflow during addition, extract only the fractional or only the integer part of a real number, and so on.
Another scenario in which re-typing a qubits register is essential is allocating auxiliary qubits within a Qubrick. The alloc_temp_qreg used for it always returns a Qubits object, so if you want to use these qubits as a typed object, you'll need to re-type them explicitly.
Type-specific behaviors of quantum arithmetic data types¶
The differences in behavior between quantum arithmetic data types arise whenever they are manipulated as numbers.
The write method¶
To start with, the write method of each quantum data type accepts an argument of the corresponding classical data type and converts it into the sequence of gates to be applied to the underlying qubits. This classical data type also shows up on the circuit diagram representation of the write method. The same classical value, passed to the write method of different data types, might apply different quantum gates to the underlying qubits, and applying the same quantum gates using the write method might require passing different classical arguments.
The following code snippet illustrates this behavior. It allocates a three-qubit quantum register and sets all qubits except the third one to $1$, interpreting it as a different quantum data type each time. (Here, we use re-typing just to avoid allocating new qubits for each step.)
from psiqworkbench import QPU, Qubits, QUInt, QInt, QUFixed, QFixed
qpu = QPU(num_qubits=4)
reg = Qubits(4, "reg", qpu)
qpu.label("QUInt")
reg1 = QUInt(reg)
reg1.write(11)
qpu.label("QInt")
reg1 = QInt(reg)
reg1.write(-5)
qpu.label("QUFixed")
reg1 = QUFixed(reg, radix=2)
reg1.write(2.75)
qpu.label("QFixed")
reg1 = QFixed(reg, radix=2)
reg1.write(-1.25)
qpu.draw()
The read method¶
Similarly, the read method of each data type converts the measurement results on underlying qubits into a value of the corresponding classical data type and returns that value. The same quantum state, interpreted as different data types, will yield different return values in the read method.
The following code snippet illustrates this behavior by initializing a register to the basis state $|1101\rangle$ (the least significant bit stored first), and then re-typing it to each of the arithmetic data types and reading out its value.
from psiqworkbench import QPU, Qubits, QInt, QUInt, QFixed, QUFixed
qpu = QPU(num_qubits=4)
reg = Qubits(4, "reg", qpu)
reg.write(0b1011)
reg = QUInt(reg)
print(f"QUInt = {reg.read()}")
reg = QInt(reg)
print(f"QInt = {reg.read()}")
reg = QUFixed(reg, radix=2)
print(f"QUFixed = {reg.read()}")
reg = QFixed(reg, radix=2)
print(f"QFixed = {reg.read()}")
QUInt = 11 QInt = -5 QUFixed = 2.75 QFixed = -1.25
One corner case of using the read method for signed data types is reading a value stored in a single-bit QInt type. Technically, a QInt type of length $1$ can only store two values, $0$ and $-1$. However, if the result of measuring the one bit of QInt is 1, the read method will return $1$, behaving as if the register type was QUInt. The following code snippet illustrates this special case.
from psiqworkbench import QPU, QInt
qpu = QPU(num_qubits=1)
reg = QInt(1, "reg", qpu)
reg.x()
print(reg.read())
1
Rounding and overflows when writing arithmetic types¶
What happens when you try to write a number into an arithmetic data type which cannot represent it?
- If the number exceeds the storage capacity of the variable (that is, has more significant digits than what the variable can represent), an integer overflow happens: Workbench issues a warning about it and writes the value that is the result of discarding the bits that can't fit into the number.
- If you try to write a real number into an integer data type, it will be rounded to an integer with a warning.
- Similarly, if the real number has more fractional digits than the fixed-point variable, Workbench rounds it to the nearest fixed-point number that can be represented in this variable. By default, this happens without a warning, but you configure the
writemethod to show a warning in this case by passingwarn_on_rounding=True.
The following example shows these behaviors.
from psiqworkbench import QPU, QInt, QUFixed
import warnings
qpu = QPU(num_qubits=7)
reg1 = QInt(3, "reg1", qpu)
# Integer overflow when writing an integer: a warning
with warnings.catch_warnings(record=True) as caught_warnings:
reg1.write(7)
print(caught_warnings[0].message)
print(f"QInt 7 -> {reg1.read()}\n")
# Writing a real number to an integer: a warning
with warnings.catch_warnings(record=True) as caught_warnings:
reg1.write(1.2)
print(caught_warnings[0].message)
print(f"QInt 1.2 -> {reg1.read()}\n")
reg2 = QUFixed(4, 2, "reg2", qpu)
# Overflow when writing a fixed-point number: a warning
with warnings.catch_warnings(record=True) as caught_warnings:
reg2.write(7)
print(caught_warnings[0].message)
print(f"QUFixed 7 -> {reg2.read()}\n")
# Writing a real number to a fixed-point number that cannot represent it exactly: configurable warning
with warnings.catch_warnings(record=True) as caught_warnings:
reg2.write(1.2, warn_on_rounding=True)
print(caught_warnings[0].message)
print(f"QUFixed 1.2 -> {reg2.read()}")
value=7 does not fit in 3-qubit QInt QInt 7 -> -1 value_full=1.2 has fractional part but <class 'psiqworkbench.qubits.qint.QInt'> is an integer type. Some information will be lost, recommend casting to int or using QFixed/QUFixed type. QInt 1.2 -> 1 value=7 does not fit in 4-qubit QUFixed with radix 2 QUFixed 7 -> 3.0 value=1.2 was rounded to 1.25 on write with radix=2. QUFixed 1.2 -> 1.25
Applying arithmetic operations¶
Finally, applying the same arithmetic operations, such as addition, to arguments of different quantum data types will invoke different implementations.
For example, consider the following code snippet. It demonstrates first the circuit that adds two unsigned integers of different sizes and then the circuit that adds two signed integers. You can see that the second circuit is different: the extra gates in the end of the circuit, controlled on the sign bit of the right-hand integer, perform sign extension - the "virtual" increase in the number of bits in the shorter integer to match the number of bits in the longer one. This step is not necessary when the shorter integer is unsigned, since its sign bit is guaranteed to be $0$.
from psiqworkbench import QPU, QInt, QUInt
qpu = QPU(num_qubits=6)
# Allocate two unsigned integer
reg1, reg2 = QUInt(2, "reg1", qpu), QUInt(4, "reg2", qpu)
# Add two unsigned integers of different sizes
reg2 += reg1
# Re-type the registers to be interpreted as signed integers
reg1, reg2 = QInt(reg1), QInt(reg2)
# Add two signed integers of different sizes
reg2 += reg1
qpu.draw(show_qubricks=True)
We will discuss quantum arithmetic operations supported in Workbench and their behavior when applied to arguments of different types in the next tutorial.
Aggregate data types: VectorRegister and CompositeRegister¶
In addition to the Qubits data type and quantum arithmetic data types, Workbench has two aggregate qubit data types that allow you to combine individual qubit registers into more sophisticated structures.
VectorRegister¶
VectorRegister is an array of multiple qubit registers. The individual elements of VectorRegister can have different lengths (you can think of this type as a jagged array), but they must all have the same data type - one of the arithmetic data types.
You can create a VectorRegister by calling the function vector_register with several arguments, similar to those you provide to allocate a simple Qubits type:
- The QPU on which the qubits will be allocated.
- The name of the array. The names of individual registers within the array will be constructed from this name using subscript: if you have a VectorRegister named
v, the first register in it will be namedv_0, the second -v_1, and so on. - An array of bit sizes of the array elements
bit_sizes. - The type of the array elements
dtype: one of the valuesquint,qint,qufixed, andqfixed. - For arrays of fixed-point arithmetic data types, the additional argument
radixspecifies the radix used for them, either as a single integer to be used for all array elements or as an array of radixes of the same length asbit_sizes.
After you create a VectorRegister, you can access its elements the way you do for all Python sequences: by indexing, slicing, or iterating through them. You can also access all elements of the array concatenated together, though this can lead to less readable code.
The following example shows how to allocate an array of fixed-point numbers of different sizes with radix=1, initialize them with random values, and calculate their sum using a VectorRegister.
from random import randint, seed
from psiqworkbench import QPU, QUFixed
from psiqworkbench.vector_register_utils import vector_register
qpu = QPU(num_qubits=11)
array = vector_register(qpu, "v", [2, 3, 2], dtype='qufixed', radix=1)
sum = QUFixed(4, 1, "sum", qpu)
# Iterate through array elements
seed(7)
for element in array:
val = randint(0, 2 ** element.num_qubits - 1) / 2
element.write(val)
# Access individual array elements via their indices
for i in range(len(array)):
sum += array[i]
print(sum.read())
# Access and manipulate all qubits of the array together
array.x()
qpu.draw(show_qubricks=True)
3.5
CompositeRegister¶
CompositeRegister is a dictionary of multiple data fields. Unlike VectorRegister, the fields of a CompositeRegister can have different data types and are accessed by name rather than by indexing.
CompositeRegister is defined using a Python @dataclass decorator, which means that it should be primarily used for storing data and not for implementing any of the computation logic. CompositeRegister fields can be both quantum and classical, though in practice a CompositeRegister is more likely to be used for quantum data only, and be accompanied by another purely classical dataclass for storing classical problem parameters.
For each problem instance, you need to define a new class, a subclass of CompositeRegister, to specify the registers that make it up. We strongly recommended you to also implement the class method initialize that will allocate individual Qubits objects in a way tailored to the problem and construct the CompositeRegister from them.
Note that when defining a subclass of CompositeRegister, you need to use a
@dataclassdecorator for it as well and pass the keyword argumenteq=Falseto it. This makes sure that Python doesn't generate a (non-quantum) comparator method for this class automatically and the comparisons work as intended.
It is sometimes useful to make some fields of a CompositeRegister optional and allocate them at some point after the object is initialized. For example, if an operation allocates a Qubits object when it is applied and returns it as a result, you can define an optional field for this object as None initially and update this field later, once the operation is completed.
The following example shows how to define a CompositeRegister for a specific problem instance and use it.
from dataclasses import dataclass
from math import ceil, log2
from typing import Optional
from psiqworkbench import QPU, QUInt, QUFixed, CompositeRegister
from psiqworkbench.qubricks import Square, USP
@dataclass(eq=False)
class ProblemState(CompositeRegister):
m: QUInt
reg: QUFixed
name: str
m_square: Optional[QUInt] = None
@classmethod
def initialize(cls, qpu: QPU, name: str, max_m: int, size_reg: int):
# Allocate registers
m = QUInt(ceil(log2(max_m)), "m", qpu)
reg = QUFixed(size_reg, 1, "reg", qpu)
# Do not initialize the flag qubit yet
return cls(m, reg, name)
qpu = QPU(num_qubits=11)
# Get a CompositeRegister instance using initialize method
max_m = 3
ps = ProblemState.initialize(qpu, "ProblemState", max_m, 2)
# Use CompositeRegister fields in a problem-specific computation
usp = USP()
usp.compute(max_m, ps.m)
ps.reg.write(1.5)
# Update a CompositeRegister field based on qubits allocated by a routine
square = Square()
square.compute(ps.m)
ps.m_square = QUInt(square.get_result_qreg(), "m_square")
qpu.draw(show_qubricks=True)
Next steps¶
Now that you've learned about the quantum arithmetic data types, keep reading to learn how to do quantum arithmetic in Workbench.