This package implements some of the functionality of Qiskits circuits. The focus has been on implementing fundamental structures and functions, eg to represent a circuit as a DAG and to add and remove nodes. This required a bit of work, since there is no efficient labeled (or weighted) digraph implemented in Julia (AFAIK).
This document is a demo, but mostly explains some of the current design. I want to expose it to criticism as early as possible.
# This "activates" the project environment of the package `Qurt`. Normally you would use `Qurt`
# from an environment external to the package.
import Pkg
Pkg.activate(".");
Activating project at `~/myrepos/quantum_repos/Qurt`
# Now `Qurt` should be visible.
# `import` imports the package, but no other symbols.
import Qurt
# Names of objects that are not imported are printed fully qualified, which is verbose.
# So import more only so names are not printed fully qualified.
import Graphs.SimpleGraphs.SimpleDiGraph
import Qurt.NodeStructs.Node
using BenchmarkTools # This provides tools like Python %timeit
# `using` is similar to `import`. But this invocation imports all of the symbols on the export list of
# `Qurt.Circuits`
using Qurt.Circuits
using Qurt.Interface # many symbols go here as a catch all. They may live elsewhere in the future.
Create a circuit with 2 quantum wires and two classical wires. Inputs and outputs will be created and connected
qc = Circuit(2, 2)
circuit {nq=2, ncl=2, nv=8, ne=4} SimpleDiGraph{Int64} Node{Int64}
using Qurt.IOQDAGs
print_edges(qc)
6 => 8 ClInput((), (4,)) => ClOutput((), (4,)) 5 => 7 ClInput((), (3,)) => ClOutput((), (3,)) 2 => 4 Input(2) => Output(2) 1 => 3 Input(1) => Output(1)
The circuit is a represented in part as a digraph from the package Graphs.jl
. But this structure carries no payloads on vertices or edges. So information on wire connections is carried in a parallel structure. Everything else that lives on a node is also in this structure.
The Circuit
is an immutable struct with fixed fields.
propertynames(qc)
(:graph, :nodes, :param_table, :wires, :global_phase)
typeof(qc.graph) # digraph from `Graphs.jl`
SimpleDiGraph{Int64}
The vertices of a SimpleDiGraph
are the integers from 1
to $|V|$. We refer these with the words vertex
and vertices
rather than something like "vertex index". When possible vertex
refers to this integer and node
refers to the vertex
together all information on the circuit element applied there.
The information on the nodes is stored in several arrays indexed by vertex
. We can also get a view that collects the element from each of these arrays for a single vertex
.
qc.nodes
8-element StructArray(::Vector{Qurt.Elements.Element}, ::Vector{Tuple{Int64, Vararg{Int64}}}, ::Vector{Int32}, ::Vector{Vector{Int64}}, ::Vector{Vector{Int64}}, ::Vector{Tuple}) with eltype Node{Int64}: Node{Int64}(el=Input, wires=(1,), nq=1, in=Int64[], out=[3], params=()) Node{Int64}(el=Input, wires=(2,), nq=1, in=Int64[], out=[4], params=()) Node{Int64}(el=Output, wires=(1,), nq=1, in=[1], out=Int64[], params=()) Node{Int64}(el=Output, wires=(2,), nq=1, in=[2], out=Int64[], params=()) Node{Int64}(el=ClInput, wires=(3,), nq=0, in=Int64[], out=[7], params=()) Node{Int64}(el=ClInput, wires=(4,), nq=0, in=Int64[], out=[8], params=()) Node{Int64}(el=ClOutput, wires=(3,), nq=0, in=[5], out=Int64[], params=()) Node{Int64}(el=ClOutput, wires=(4,), nq=0, in=[6], out=Int64[], params=())
Add two gates and save the vertices (integers) that they occupy.
using Qurt.Builders
import .Qurt.Elements: H, CX, X, Y, RX
(nH, nCX) = @build qc H(1) CX(1,2);
print_edges(qc)
6 => 8 ClInput((), (4,)) => ClOutput((), (4,)) 5 => 7 ClInput((), (3,)) => ClOutput((), (3,)) 2 => 10 Input(2) => CX(1,2) 1 => 9 Input(1) => H(1) 9 => 10 H(1) => CX(1,2) 10 => 3 CX(1,2) => Output(1) 10 => 4 CX(1,2) => Output(2)
You can index into qc
with a vertex (again an integer) to get information on the operation (or element) at the vertex.
qc[nCX]
Node{Int64}(el=CX, wires=(1, 2), nq=2, in=[9, 2], out=[3, 4], params=())
The return value was a struct
. But for performance the data is actually stored as a struct of arrays. For example, the element ids are instances of a modified enum. The array of elements is essentially an array integers (say 32 or 64 bits).
Neither the internal nor external API access these arrays directly. But this is what one of them looks like.
qc.nodes.element
10-element Vector{Qurt.Elements.Element}: Input::Element Input::Element Output::Element Output::Element ClInput::Element ClInput::Element ClOutput::Element ClOutput::Element H::Element CX::Element
Integer(qc.nodes.element[1])
14003
import .Interface.getelement
getelement(qc, 1) # This is currently a way to access the element
Input::Element
Julia is compiled. In particular, getelement
is a zero-cost abstraction
@btime getelement($qc, 1) # Dollar sign is a detail of how @btime works
2.197 ns (0 allocations: 0 bytes)
Input::Element
@btime $qc.nodes.element[1] # This is no more or less efficient
1.981 ns (0 allocations: 0 bytes)
Input::Element
Making builder interfaces is easy with macros. I added a couple of simple macros to make development easier. These are handwritten, but you might use tools to develop them further.
qc = Circuit(2)
@build qc X(1) Y(2)
@build qc begin
X(1)
Y(2)
CX(1,2)
RX{1.5}(1)
end;
We use RX{1.5}(1)
rather than RX(1.5, 1)
because we don't want to require understanding what this gate means in order to insert it into a circuit. This pushes off the error if you do for example X{1.5, 2.0}(1,2,3)
. We will need to add the option to validate somewhere. Especially for user input.
Because we want maniupulating storage to be as efficient as possible, there is no gate object per se. You collect different attributes of a gate for different purposes.
g1 = @gate RX
RX::Element
g2 = @gate RX{1.5} # circuit element identity and parameters
RX{1.5}
g3 = @gate RX{1.5}(2) # Include wires
RX{1.5}(2)
These can be used like this, with information not included in the "gate" included when adding it to the circuit.
add_node!(qc, (g1, 1.5), (2,))
11
add_node!(qc, g2, (2,))
12
add_node!(qc, g3)
13
(qc[11:13])
3-element StructArray(view(::Vector{Qurt.Elements.Element}, 11:13), view(::Vector{Tuple{Int64, Vararg{Int64}}}, 11:13), view(::Vector{Int32}, 11:13), view(::Vector{Vector{Int64}}, 11:13), view(::Vector{Vector{Int64}}, 11:13), view(::Vector{Tuple}, 11:13)) with eltype Node{Int64}: Node{Int64}(el=RX, wires=(2,), nq=1, in=[9], out=[12], params=(1.5,)) Node{Int64}(el=RX, wires=(2,), nq=1, in=[11], out=[13], params=(1.5,)) Node{Int64}(el=RX, wires=(2,), nq=1, in=[12], out=[4], params=(1.5,))
Julia constructs elaborate types. We need to define the function (like Python repr
) that prints abbreviated type information above.