|
From Learn Quantum Computing with Python and Q# by Sarah Kaiser and Chris Granade This article covers using the Quantum Development Kit to write quantum programs in Q# |
Take 37% off Learn Quantum Computing with Python and Q# by entering fcckaiser into the discount code box at checkout at manning.com.
You can use Python to implement your own software stack to simulate quantum programs. In this article, we’ll be writing more intricate quantum programs that benefit from specialized language features which are hard to implement by embedding our software stack inside Python. As we explore quantum algorithms, it’s helpful to have a language tailor-made for quantum programming at our disposal. We’ll also get started with Q#, Microsoft’s domain-specific language for quantum programming, included with the Quantum Development Kit.
Introducing the Quantum Development Kit
The Quantum Development Kit provides a new language, Q#, for writing quantum programs and simulating them using classical resources. Quantum programs written in Q# are run in a way that the quantum devices act as a kind of accelerator, similar to how you might run code on a graphics card OpenCL, this is a similar model.
Figure 1. Q# software stack on a classical computer.
Let’s take a look at this software stack for Q#. Our Q# program itself consists of operations and functions that instruct quantum and classical hardware to do certain things. A number of libraries which are provided with Q# are helpful, pre-made operations and functions to use in our programs.
Once the Q# program is written, we need a way for it to pass instructions to the hardware. A classical program, sometimes called a “driver” or a “host program,” is responsible for allocating a target machine and running a Q# operation on that machine.
The Quantum Development Kit provides a plugin for Jupyter Notebook called IQ# which makes it easy to get started with Q# by providing host programs automatically. Using the IQ# plugin for Jupyter Notebook, we can use one of two different target machines to run Q# code. The first is the QuantumSimulator
target machine, which is similar to a Python simulator. It’s a lot faster than Python code at simulating qubits.
The second is the ResourcesEstimator
target machine which allows us to estimate how many qubits and quantum instructions we need to run it, without having to fully simulate it. This is useful for getting an idea of the resources you need to run a Q# program for your application
Figure 2. Getting started with IQ# and Jupyter Notebook
To get a sense for how everything works, let’s start by writing out a purely classical Q# “hello, world” application. First, start Jupyter Notebook by running the following in a terminal:
jupyter notebook
This automatically opens a new tab in your browser with the home page for your Jupyter Notebook session. From the New ↓ menu, select “Q#” to make a new Q# notebook. Type the following into the first empty cell in the notebook and press Control + Enter or !
+ Enter to run it.
function HelloWorld() : Unit { ❶ Message("Hello, classical world!"); ❷ }
❶ This line defines a new function which takes no arguments, and returns the empty tuple, whose type is written as Unit
.
❷ The Message
function tells the target machine to collect a diagnostic message. The QuantumSimulator
target machine prints all diagnostics to the screen, and we can use Message
in the same way as print
in Python.
TIP Watch out for semicolons! Unlike Python, Q# uses semicolons rather than newlines to end statements. If you get a lot of compiler errors, make sure you remembered your semicolons.
You should get a response back listing that the HelloWorld
function was successfully compiled. To run our new function, we can use the %simulate
command in a new cell.
%simulate HelloWorld
TIP A bit of classical magic: The %simulate
command we used above as an example of a magic command, in that it’s not a part of Q# itself, but it’s an instruction to the Jupyter Notebook environment. If you’re familiar with the IPython plugin for Jupyter, you may have used similar magic commands to tell Jupyter how to handle Python plotting functionality. The magic commands we use in this book all start with %
to make them easy to tell apart from Q# code.
In this example, %simulate
allocates a target machine for us and sends a Q# function or operation to that new target machine.
The Q# program is sent to the simulator, but in this case, the simulator runs the classical logic, because there’s no quantum instructions to worry about yet.
Functions and Operations in Q#
Now that the Quantum Development Kit is up and running with Jupyter Notebook, let’s use Q# to write some quantum programs. One useful thing to do with a qubit is to generate random numbers one classical bit at a time. Revisiting that application makes a great place to start with Q#, because random numbers are useful if you want to play games.
Long ago in Camelot, Morgana le Fay shared our love for playing games. Being a clever mathematician with skills well beyond her own day, Morgana was even known to use qubits from time to time as a part of her games. One day, as Sir Lancelot lay sleeping under a tree, Morgana trapped him and challenged him to a little game: each of them must try to guess the outcome of measuring one of Morgana’s qubits.
If the result of measuring along the "
axis is a 0, then Lancelot wins their game and gets to return to Genevieve. If the result is a 1, though, Morgana wins and Lancelot has to stay and play again. We’ll measure a qubit to generate random numbers for the purpose of playing this game. Morgana and Lancelot could have also flipped a more traditional coin, but where’s the fun in that?
Morgana’s side game
- Prepare a qubit in the |0# state
- Apply the Hadamard operation (recall that the unitary operator
takes |0# to |+# and vice versa)
- Measure the qubit in the ” axis. If the measurement result is a 0, then Lancelot can go home. Otherwise, he must stay and play again!
Sitting at a coffee shop watching the world go by, we can use our laptops to predict what happens in Morgana’s game with Lancelot by writing a quantum program in Q#. Unlike the ClassicalHello
function that we wrote above, our new program needs to work with qubits; let’s take a moment to see how to do this with the Quantum Development Kit.
The primary way to interact with qubits in Q# is by calling operations that represent quantum instructions. To understand how these operations work, it’s helpful to understand the difference between Q# operations and the functions that we saw in the ClassicalHello
example above.
- Functions in Q# represent predictable classical logic, things like mathematical functions (
Sin
,Log
). Functions always return the same output when given the same input. - Operations in Q# represent code which can have side effects, such as sampling random numbers, or issuing quantum instructions which modify the state of one or more qubits.
This separation helps the compiler figure out how to automatically transform your code as a part of larger quantum programs; we’ll see more about this later.
For instance, suppose that Morgana and Lancelot prepare their qubit in the |+#
state using the Hadamard instruction. We can predict the outcome of their game by writing out the quantum random number generator (QRNG) example listed below.
def qrng(device : QuantumDevice) -> bool: with device.using_qubit() as q: q.h() return q.measure()
NOTE: There may be side effects to this operation… when we want to send instructions to our target machine to do something with our qubits, we need to do this from an operation, because sending an instruction is a kind of side effect. When we run an operation, we aren’t only computing something, we’re doing something. Running an operation twice isn’t the same as running it once, even if we get the same output both times. Side effects aren’t deterministic or predictable, and we can’t use functions to send instructions on how to manipulate our qubits.
In Listing 1, we’ll do exactly that, starting by writing an operation called NextRandomBit
to simulate each round of Morgana’s game. Note that because NextRandomBit
needs to work with qubits, it has to be an operation and not a function. We can ask the target machine for one or more fresh qubits with the using
block.
NOTE Allocating qubits in Q#: The using statement is one of the only two ways we can ask the target machine for qubits. The number of using statements that we can have in our Q# programs is unlimited, other than the number of qubits that each target machine can allocate. At the end of each using block, the qubits then go back to the target machine; using blocks ensure that each qubit which is allocated is “owned” by a particular operation. This makes it impossible to “leak” qubits within a Q# program, which is helpful given that qubits are likely to be expensive resources on quantum hardware.
Q# offers one other way to allocate qubits, known as borrowing. Unlike when we allocate qubits with using
statements, the borrowing
statement lets us borrow qubits which are owned by different operations without knowing what state they start in. The borrowing
statement works similarly to the using
statement in that it makes it impossible for us to forget that we’ve borrowed a qubit.
By convention, all qubits start off in the |0#
state right after we get them, and we promise the target machine that we’ll put them back into the |0#
state at the end of the block to be ready for the target machine to give to the next operation that needs them.
Listing 1. Simulating one round of Morgana’s game using Q#
operation NextRandomBit() : Result { ❶ mutable result = Zero; ❷ using (qubit = Qubit()) { ❸ H(qubit); ❹ set result = M(qubit); ❺ Reset(qubit); ❻ } return result; ❼ }
❶ This time, because we want to use a qubit, we declare an operation instead of a function. Because our operation needs to return a result to its caller, we denote by changing the return type to the Q# type Result
.
❷ The using
keyword in Q# asks the target machine for one or more qubits. Here, we ask for a single value of type Qubit
, which we store in the new variable qubit
.
❸ Quantum operations such as the Hadamard operation can be found in the Microsoft.Quantum.Intrinsic
namespace. For instance, we can call Hadamard using the Microsoft.Quantum.Intrinsic.H
operation. After calling H
, qubit
is in thestate.
❹ Next, we use the M
operation to measure our qubit in the basis, saving the result to the
result
variable we declared earlier. Because we’re in an equal superposition of and
,
result
are either Zero
or One
with equal probability.
❺ Before returning our qubit to the target machine, we use the Microsoft.Quantum.Intrinsic.Reset
operation to return it to the state. Because we’ve already stored the classical data we got from our measurement into the
result
variable, we can safely reset the qubit without losing any information that we care about.
❻ We finish our operation by returning the measurement result back to the caller.
We finish our operation by returning the measurement result back to to the caller.
Next, we need to see how many rounds it takes for Lancelot to get the Zero
he needs to go home. Let’s write an operation to play rounds until we get a Zero
. Because this operation simulates playing Morgana’s game, we’ll call it PlayMorganasGame
.
Listing 2. Simulating many rounds of Morgana’s game using Q#
operation PlayMorganasGame() : Unit { mutable nRounds = 0; ❶ mutable done = false; repeat { ❷ set nRounds = nRounds + 1; set done = (NextRandomBit() == Zero); ❸ } until (done) ❹ fixup {} Message($"It took Lancelot {nRounds} turns to get home."); ❺ }
❶ All Q# variables are immutable by default — we can use the mutable
keyword to declare a variable which we can change later with the set
keyword. Here, we start by initializing a mutable variable indicating how many rounds have already passed, and a mutable variable we’ll use to exit the loop.
❷ Q# allows operations to use a kind of loop called a “repeat-until-success” (RUS) loop. Unlike a while
-loop, RUS loops allow us to specify a “fixup” that runs if the condition to exit the loop isn’t met. Note that the fixup
block is required, even if it’s empty.
❸ Inside our loop, we call the QRNG that we wrote above as the NextRandomBit
operation. We check to see if the result is a Zero
(if Lancelot wins and can leave), and if true, set done
to be true
.
❹ If we got a Zero
, then we can stop the loop.
❺ Finally, we use Message
again to print the number of rounds to the screen. To do this, we use $""
strings which, similar to $""
strings in C# and f""
strings in Python, and include variables in the diagnostic message by using {}
placeholders inside the string.
We can run this new operation with the %simulate
command in a similar fashion as the ClassicalHello
example. When we do this, we can see how long Lancelot has to stay:
Listing 3. Output from running the Qrng
application
In []: %simulate PlayMorganasGame It took Lancelot 1 turns to get home. Out[]: ()
Looks like Lancelot got lucky that time! Or perhaps unlucky, if he was bored of hanging ’round the table in Camelot.
Passing Operations as Arguments
Let’s suppose that in Morgana’s game, we’re interested in sampling random bits with nonuniform probability. After all, Morgana didn’t tell Lancelot how she prepared the qubit that they are measuring; she can keep him playing longer if she makes a biased coin with their qubit instead of a fair coin.
The easiest way to modify Morgana’s game is to, instead of calling H
directly, take as an input an operation representing what Morgana does to prepare for their game. To take an operation as input, we need to write down the type of the input, as can write down qubit : Qubit
to declare an input qubit
of type Qubit
. Operation types are indicated by thick arrows (=>
) from their input type to their output type. For instance, H
has type Qubit => Unit
because H
takes a single qubit as input and returns an empty tuple as its output.
Listing 4. Using operations as inputs in order to predict Morgana’s game.
operation PrepareFairCoin(qubit : Qubit) : Unit { H(qubit); } operation NextRandomBit( statePreparation : (Qubit => Unit) ❶ ) : Result { using (qubit = Qubit()) { statePreparation(qubit); ❷ return result = MResetZ(qubit); ❸ } }
❶ This time, we’ve added a new input called statePreparation to NextRandomBit that represents the operation we want to use to prepare the state we use as a coin. In this case, Qubit => Unit is the type of any operation which takes a single qubit and returns the empty tuple type Unit.
❷ Within NextRandomBit, the operation passed as statePreparation can be called in the same way as any other operation.
❸ The Q# standard libraries provide MResetZ as a convenience for measuring and resetting a qubit in one step. This is equivalent to the set result = M(qubit); Reset(qubit); statements we saw in the previous example, but requires one less measurement to perform.
Figure 3. Representing operations with a single input and a single output
Figure 4. Unit
versus void
In this example, we see that NextRandomBit
treats its input statePreparation
as a “black box.” The only way to learn anything about Morgana’s preparation strategy is to run it.
Put differently, we don’t want to do anything with statePreparation
that implies we know what it does or what it is. The only way that NextRandomBit
can interact with statePreparation
is by calling it, passing it a Qubit
to act on.
This allows us to reuse the logic in NextRandomBit
for many different kinds of state preparation procedures which Morgana might use to cause Lancelot a bit of trouble. For example, suppose she wants a biased coin that returns a One
¾ of the time and a Zero
¼ of the time. Then, we might run something like the following to predict this new strategy:
Listing 5. Passing different state preparation strategies to the PlayMorganasGame
example.
open Microsoft.Quantum.Math; ❶ operation PrepareQuarterCoin(qubit : Qubit) : Unit { Ry(2.0 * PI() / 3.0, qubit); ❷ }
❶ Classical math functions such as Sin, Cos, Sqrt, and ArcCos, as well as constants like PI() are provided by the Microsoft.Quantum.Math namespace, and we open it as well as the intrinsics.
❷ The Ry operation implements the Y -axis rotation that we saw in Chapter 2. Q# uses radians rather than degrees to express rotations, so this is a rotation of 120° about the Y -axis. Thus, if qubit starts in |0〉, this prepares qubit in the state Ry(-120◦)|0〉 = √3/4 |0〉 + √1/4 |1〉, such that the probability of observing 1 when we measure is √3/42 = 3/4.
We can make this example even more general, allowing Morgana to specify an arbitrary bias for her coin (which is implemented by their shared qubit):
Listing 6. Passing operations to implement PlayMorganasGame with arbitrary coin biases.
operation PrepareBiasedCoin(morganaWinProbability : Double, qubit : Qubit) : Unit { let rotationAngle = -2.0 * ArcCos(Sqrt(morganaWinProbability)); ❶ Ry(rotationAngle, qubit); } operation PrepareMorganasCoin(qubit : Qubit) : Unit { ❷ PrepareBiasedCoin(0.62, qubit); }
❶ We need to find out what angle we rotate the input qubit by in order to get the right probability of seeing a Zero as our result. This takes a little bit of trigonometry, see the sidebar below for the details.
❷ This operation has the right type signature (Qubit => Unit) and we can see that the probability Morgana wins each round is 62%.
Figure 5. How Morgana can choose θ to control how her game plays out
This is somewhat unsatisfying, though, in that the operation PrepareMorganasCoin
introduces a lot of boilerplate to lock down the value of 0.62
for the input argument headsProbability
to PrepareBiasedCoin
. If Morgana changes her strategy to have a different bias, then using this approach means we’ll need another new boilerplate operation to represent it. Taking a step back, let’s look at what PrepareMorganasCoin
does. It starts with an operation PrepareBiasedCoin : (Double, Qubit) => Unit
, and wraps it into an operation of type Qubit => Unit
by locking down the Double
argument to 0.62. It removes one of the arguments to PrepareBiasedCoin
by fixing the value of that input to 0.62.
Thankfully, Q# provides a convenient shorthand for making new functions and operations by locking down some (but not all!) of the inputs. Using this shorthand, known as partial application, we can rewrite the above in a more readable form:
Listing 7. Using partial application to make it easier to vary Morgana’s strategy.
let flip = NextRandomBit(PrepareBiasedCoin(0.62, _));
The _
here indicates that a part of the input to PrepareBiasedCoin
is missing. We say that PrepareBiasedCoin
has been partially applied. Whereas PrepareBiasedCoin
had type (Double, Qubit) => Unit
, because we filled in the Double
part of the input, PrepareBiasedCoin(0.62, _)
has type Qubit => Unit
, making it compatible with our modifications to NextRandomBit
. Partial application in Q# is similar to functools.partial
in Python and the _
keyword in Scala.
Another way to think of partial application is as a way to make new functions and operations by specializing existing functions and operations:
function BiasedPreparation(headsProbability : Double) : (Qubit => Unit) { ❶ return PrepareBiasedCoin(headsProbability, _); ❷ }
❶ Here, the output type of BiasedPreparation is an operation that takes a Qubit and returns the empty tuple. BiasedPreparation is a function that makes new operations!
❷ We make the new operation by passing along headsProbability, but leaving a blank
(_) for the target qubit. This gives us an operation that takes a single Qubit and substitutes in the blank.
It may seem a bit confusing that BiasedPreparation
returns an operation from a function, but this is completely consistent with the split between functions and operations described above, because BiasedPreparation
is still predictable. In particular, BiasedPreparation(p)
always returns the same operation for a given p
, no matter how many times you call the function. We can assure ourselves that this is the case by noticing that BiasedPreparation
only partially applies operations, but never calls them.
That’s all for this article.
If you want to learn more about the book, check it out on our browser-based liveBook reader here and see this slide deck.