Resources Optimization in Q#
The end of the year and the holidays1 is the best occasion to compose a fresh blog post about Q#. In this post, I will review the quantum trace simulator that comes built-in with the quantum programming language Q# and its Quantum Development Kit (QDK). We will employ the simulator to explore quantum resources optimization of Q# programs.
⚛️ Quantum Computers and Resources
Quantum computers, just like any standard computer, have limited resources. This shortage of resources is accentuated by today’s prototype quantum computers and is a core gap that is being addressed for future quantum computers. Two essential resources are the number of qubits in a quantum computer, and the time it takes to execute a quantum program.
The number of qubits is easier to grasp, whereas the time it takes to execute a quantum program is more abstract and depends on the quantum code and underlying operations speed. Time is critical - the longer it takes to run, the more error-prone the results will be without applying error correction. This phenomenon is known as the decoherence of quantum states. Interestingly, there is a trade-off between the number of qubits and the time it takes to execute a quantum program.
📐 Basic Resources Estimation in Q#
We begin by creating a new Q# project. There are two main files for a Q# project.
The first one is the .qs
(Q#) file that contains the quantum code to be run on a
quantum computer or a simulator. Below is a simple Q# program:
// Program.qs
namespace Quantum.ResourcesTutorial {
open Microsoft.Quantum.Canon;
open Microsoft.Quantum.Intrinsic;
open Microsoft.Quantum.Math;
@EntryPoint()
operation QuantumOperation() : Unit {
use qubits = Qubit[3];
ApplyToEachCA(H, qubits);
X(qubits[0]);
Y(qubits[1]);
Rz(PI() / 2.0, qubits[2]);
CNOT(qubits[0], qubits[2]);
CNOT(qubits[1], qubits[2]);
HY(qubits[1]);
CNOT(qubits[0], qubits[1]);
}
}
Although this program is not clever or solves any significant problem, it is
enough for demonstrating resource estimation. The
@EntryPoint()
attribute
tells the Q# compiler that the QuantumOperation
operation is the program’s entry point -
the “main” function.
The second ingredient is the project’s configuration file - the .csproj
file.
This file contains the following information:
<!-- qsharp-project.csproj -->
<Project Sdk="Microsoft.Quantum.Sdk/0.27.244707">
<PropertyGroup>
<OutputType>Exe</OutputType>
<TargetFramework>net6.0</TargetFramework>
</PropertyGroup>
</Project>
At this point, we have a complete Q# project. This quantum program is small enough to
be run on a local machine. The command dotnet run
will compile
the project and run the program with the default simulator2: QuantumSimulator
.
But how to estimate the resources needed to execute the program? (and later optimize them)
To estimate resources, we use the ResourcesEstimator
simulator, also known
as the trace simulator.
The key distinction between a standard simulator and the trace simulator is that the
latter only walks the quantum program’s execution tree and estimates the resources
needed to execute it. Therefore, it is feasible to run the trace simulator on
a classical computer and mimic the execution of the quantum program, even if the
quantum program has hundreds or thousands of qubits.
To run the trace simulator on the project, use
dotnet run -s ResourcesEstimator
3,
which prints to the console the following table:
Metric | Sum | Max |
---|---|---|
CNOT | 3 | 3 |
QubitClifford | 7 | 7 |
R | 1 | 1 |
Measure | 0 | 0 |
T | 0 | 0 |
Depth | 0 | 0 |
Width | 3 | 3 |
QubitCount | 3 | 3 |
BorrowedWidth | 0 | 0 |
Each row in the table corresponds to a quantum resource. The first column is the
resource’s name, the second column is the sum of the resource over all the operations
in the program, and the third column is the maximal value of the resource used by a
single operation.
For the rest of the post, we will focus on two resources: “Depth” and “Width”.
Depth corresponds to the time it takes to execute the program, which is related to
the sequence of intrinsic operators that cannot be performed in parallel. Width is
the number of qubits used in the program, including any qubits that are temporarily
allocated and released during the execution of the program.
A critic will complain about the reported depth - why is it zero when the program is not empty? The answer is straightforward - by default, the trace simulator counts the T-depth, which is the program’s depth in terms of the T gates. This approach is geared towards fault-tolerant quantum computing where the T gates have central importance for error correction and mask out all the other gates in terms of execution time.
💻 Calling Q# from C#
The resources estimator simulator from the previous section is essentially the
trace simulator
with a default configuration.
If we want to customize the trace simulator, we need to call it from C#.
This section describes how to do that before modifying any trace
simulator’s configuration.
We will add a new .cs
(C#) file to the project that will serve as the main
entry point, known as the classical host program of the quantum program.
The first step is removing the @EntryPoint()
attribute from the Q# operation.
The second step is to add the host program:
// ResourcesEstimation.cs
using System;
using System.Threading.Tasks;
using Microsoft.Quantum.Simulation.Simulators.QCTraceSimulators;
using Quantum.ResourcesTutorial; // The namespace of the Q# program
namespace Host
{
static class ResourcesProgram
{
static QCTraceSimulatorConfiguration GetConfig()
{
var config = new QCTraceSimulatorConfiguration();
config.UseWidthCounter = true;
config.UseDepthCounter = true;
return config;
}
static async Task Main(string[] args)
{
var sim = new QCTraceSimulator(GetConfig());
await QuantumOperation.Run(sim);
double depth = sim.GetMetric<QuantumOperation>(MetricsNames.DepthCounter.Depth);
double width = sim.GetMetric<QuantumOperation>(MetricsNames.WidthCounter.ExtraWidth);
Console.WriteLine(sim.Name);
Console.WriteLine($"Depth: {depth}, width: {width}.");
}
}
}
The Main
method is now the program’s entry point and is called with
the command dotnet run
. The Main
method creates a new instance of the
trace simulator, configures it, and runs the Q# operation. The GetConfig
method configures the trace simulator to count the T-depth and the width.
We have yet to change the configuration from the previous section. We only
introduced a new way to call the trace simulator. Conveniently, this will
allow us to modify the configuration later on.
At this point, the project structure looks like this4:
/
|-- Program.qs
|-- ResourcesEstimation.cs
`-- qsharp-project.csproj
We will not add new files to the project for the rest of the post.
⚙️ Configuring the Trace Simulator
How do we make the trace simulator count the standard depth instead of the T-depth?
Add the following code to the ResourcesProgram
C# class:
// ResourcesEstimation.cs (snippet)
static int DefaultPrimitiveTime = 1;
static void SetPrimitiveTimes(QCTraceSimulatorConfiguration config)
{
foreach (var primitive in Enum.GetNames<PrimitiveOperationsGroups>())
{
config.TraceGateTimes[Enum.Parse<PrimitiveOperationsGroups>(primitive)] =
DefaultPrimitiveTime;
}
}
static QCTraceSimulatorConfiguration GetConfig()
{
var config = new QCTraceSimulatorConfiguration();
config.UseWidthCounter = true;
config.UseDepthCounter = true;
SetPrimitiveTimes(config); // <-- new line
return config;
}
The SetPrimitiveTimes
method sets the time it takes to execute each
primitive operation to DefaultPrimitiveTime
, and is used in the GetConfig
method.
If you add the snippet
Console.WriteLine("Primitive times:");
foreach (var kvp in config.TraceGateTimes)
{
Console.WriteLine(kvp);
}
after the SetPrimitiveTimes
method, you will see the following output:
Primitive times:
[CNOT, 1]
[Measure, 1]
[QubitClifford, 1]
[R, 1]
[T, 1]
This means that we treat all the primitive operations equally - i.e., taking the same time and contributing to the depth in the same way.
To validate that the trace simulator is now calculating the standard depth,
run dotnet run
, and you will see the following output:
Tracer
Depth: 7, width: 3.
The depth count jumped from zero to 7.
⏱️ Optimization
A depth of 7 might seem like the final answer, the best can be done.
However, when you come across the OptimizeDepth
configuration value
of the trace simulator, you might be tempted to set it to true
and
see what happens.
From the trace simulator’s code,
one can see that OptimizeDepth
<–> not EncourageReuse
. Namely, when the depth is optimized,
the reuse of qubits is discouraged, and vice versa - when the reuse of qubits is encouraged (i.e.,
the width is optimized), the depth is not optimized.
The reuse of qubits throughout the program is achievable with auxiliary qubits.
Auxiliary qubits are “helper” qubits taken off the shelf for a quantum operation and
then returned to the pool of available qubits upon the operation’s completion.
We can modify the configuration of the trace simulator to control the OptimizeDepth
configuration:
// ResourcesEstimation.cs (partial)
using System;
using System.Threading.Tasks;
using System.Collections.Generic; // <-- new line
using Microsoft.Quantum.Simulation.Simulators.QCTraceSimulators;
using Quantum.ResourcesTutorial;
namespace Host
{
static class ResourcesProgram
{
/*
previous class members that are not changed
*/
static QCTraceSimulatorConfiguration GetConfig(bool optimizeDepth = false)
{
var config = new QCTraceSimulatorConfiguration();
config.UseWidthCounter = true;
config.UseDepthCounter = true;
SetPrimitiveTimes(config);
config.OptimizeDepth = optimizeDepth; // <-- new line
return config;
}
static async Task Main(string[] args)
{
var optimizeDepthSimulator = new QCTraceSimulator(GetConfig(optimizeDepth: true));
var encourageReuseSimulator = new QCTraceSimulator(GetConfig(optimizeDepth: false));
await Task.WhenAll(
QuantumOperation.Run(optimizeDepthSimulator),
QuantumOperation.Run(encourageReuseSimulator)
);
foreach (var sim in new List<QCTraceSimulator> { optimizeDepthSimulator, encourageReuseSimulator })
{
double depth = sim.GetMetric<QuantumOperation>(MetricsNames.DepthCounter.Depth);
double width = sim.GetMetric<QuantumOperation>(MetricsNames.WidthCounter.ExtraWidth);
Console.WriteLine(sim.Name);
Console.WriteLine($"Depth: {depth}, width: {width}.");
}
}
}
}
The host program now runs two instances of the trace simulator - one with the depth optimization enabled and one with qubit reuse favored. This is the last modification of the host program5. From this point, we will only revise the Q# code.
When we run the host program, we see no difference in the depth of the two differently configured simulators - it remains the same as in the previous section. This finding is disappointing - the optimization configuration did not affect the quantum resources consumed by the quantum program. However, depth optimization can generally reduce the depth of a Q# program.
💡 Use Cases
In this section, we highlight more sophisticated cases of depth optimization, in which the depth-optimized program is shorter than the reuse-encouraged program. To make things more transparent, let us think about two identical rectangles of the following shape:
_____________
q0-->| |-->q0
q1-->| |-->q1
|_____aux_____|
The rectangle is a quantum operation that takes two qubits, q0
and q1
,
as input and returns them as the output. They are the main qubits, and
aux
is an auxiliary qubit used only inside the rectangle.
The operation takes an auxiliary qubit from the pool of available
qubits and returns it to the pool after when it finishes.
There are two ways to stitch the two rectangles together. The first option is to execute the two rectangles completely in parallel:
Parallel execution (depth-optimized):
_____________
q0-->| |-->q0
q1-->| |-->q1
|_____aux0____|
_____________
q2-->| |-->q2
q3-->| |-->q3
|_____aux1____|
The parallel execution allocates a separate auxiliary qubit for each rectangle,
resulting in two auxiliary qubits: aux0
and aux1
.
The second approach is to execute the two rectangles partially sequentially:
Sequential execution (reuse-encouraged):
_____________
q0-->| |-->q0
q1-->| |-->q1 _____________
|_____aux_____|-----------| aux |
q2-->| |-->q2
q3-->|_____________|-->q3
In the sequential execution, the rectangles share a single auxiliary qubit aux
.
The parallel execution is shorter (less deep) than the sequential execution, but it demands more qubits (wider). The sequential execution is longer (deeper) but operates on fewer qubits (less wide). The trade-off between the quantum program’s resources - depth and width, is now evident.
This trade-off is not limited to the two rectangles. It is a general property of quantum programs that use auxiliary qubits, and almost every interesting quantum algorithm requires auxiliary qubits. We can engineer a Q# program demonstrating the trade-off with this insight.
// Program.qs
namespace Quantum.ResourcesTutorial {
open Microsoft.Quantum.Canon;
open Microsoft.Quantum.Intrinsic;
operation QuantumOperation() : Unit {
let numSummands = 3;
use (summands1, summands2) = (Qubit[numSummands], Qubit[numSummands]);
use (target1, target2) = (Qubit(), Qubit());
SumQubits(summands1, target1);
SumQubits(summands2, target2);
}
operation SumQubits(summands : Qubit[], target : Qubit) : Unit is Adj + Ctl {
use aux = Qubit();
CNOT(summands[0], target);
within {
for i in 1..Length(summands) - 1 {
CNOT(summands[i], aux);
}
} apply {
CNOT(aux, target);
}
}
}
And there it is! Now when we run the host program, we see that the depth of the depth-optimized program is 5, while the depth of the reuse-encouraged program is 10. The optimization effect is even more pronounced for larger quantum operations.
📃 Summary
We learned step-by-step how to open a new Q# project with a C# host program and how to work with the trace simulator. It is more straightforward than it seems and provides a unique path to learning about quantum computing.
The conceptual outcome of this tutorial is to acknowledge the classical trade-off when executing quantum programs: do we have enough qubits and prefer shorter execution time/depth, or are the qubits more scarce, and can we tolerate longer execution time? This question may have different answers depending on the quantum algorithm and the quantum hardware or simulator it runs on. Choosing the suitable trade-off is a core part of larger-scale quantum algorithms, and fortunately, we can explore it with the Q# language.
-
This blog post is a part of Q# Holiday Calendar 2022 - check it out for more great content. ↩
-
I omitted the measurement or reset operations for brevity. They are required when running the program with the standard simulator, but when combined with the trace simulator, the
AssertMeasurementProbability
operation must follow them, resulting in a less concise program. ↩ -
See Ways to run a Q# program - Different target machines for the
-s
(--simulator
) option, and SubstitutableOnTarget - TargetName for theResourcesEstimator
target name. ↩ -
Note that this file structure is the simplest possible. It is adapted from the Q# console application template created with the command
dotnet new console -lang Q#
(we use this template in the section “Basic Resources Estimation in Q#”). However, for more complex projects, it is recommended to separate the quantum library (Q#) from the host application (C#).
A typical project structure would look like this:/ |-- host/ | |-- Program.cs | `-- host.csproj |-- quantum/ | |-- Library.qs | `-- quantum.csproj `-- quantum-dotnet.sln
The
.csproj
files will be different from each other and the one shown above. For more details, see Develop with Q# and .NET program. ↩ -
For a complete reference, see the accompanying GitHub repository. ↩