The intent of Selene is to be an emulation platform for quantum/classical hybrid programs. The primary usecase is for HUGR programs targeting the Helios Quantum Instruction Set, but as the project has grown it has been clear that we need more than one hardcoded pipeline. Instead, we need a flexible pipeline that can handle a wide range of inputs, configuration, and various intermediate stages. It may be the case that users wish to start a build already having LLVM bitcode for the user program, or an object file, or a pytket circuit.
To cater for this, we formalise the build pipeline with three main concepts: ArtifactKind, Artifact and Step.
Before we go into the details of these concepts, we will first describe the general concepts used in building a selene instance.
It is a key assumption that hybrid programs target a quantum control system, and are likely
to be compiled rather than interpreted. It is also assumed that the classical code runs
natively on the CPU, and the quantum aspects of the code are achieved by calling functions
defined on the target platform. The program may also call library functions available on
the platform, such as malloc or BLAS' gemm function.
There may be several ways of building such a program, but a manner that I am most familiar with looks like:
- The user writes a program in Guppy.
- They compile this program to HUGR.
- This HUGR gets compiled to one of:
- An object file, making use of external quantum function calls such as
my_quantum_platform_do_rz(qubit: u64, theta: f64). - LLVM IR or LLVM bitcode
- This is then compiled in a subsequent step into an object file with the above calling convention
- An object file, making use of external quantum function calls such as
- The object file is linked against a "shim" library which maps the appropriate
platform calls to selene functions, e.g.
selene_rz(qubit: u64, theta: f64). - The object file is linked against
libselene.so, which contains the selene hooks (such asselene_rz) for invoking quantum operations. - If any non-quantum external functions are used (such as
gemm), then the program is further linked against the appropriate libraries.
The result is an executable that can provide configurable runs of a hybrid program for the purpose of emulation.
Although this pipeline is the original inspiration for Selene, we aim to generalise
the concept of a build pipeline to allow for alternative workflows, including alternative
steps and intermediate artifacts. It may be possible that users wish to provide a
pytket Circuit object as an input, compile it to QIR, and build that into a selene executable.
An implementer may work on creating the steps to make this possible, expose it either in
Selene itself or in an external repository, and after an appropriate import a general user
may be able to invoke selene_sim.build with a pytket Circuit as input without needing
to get bogged down in implementation detail.
To achieve this, we need a formal, configurable build pipeline.
When the Selene build pipeline is invoked, it is given an arbitrary object (which we call the input resource). Selene needs to understand what this object is in the context of a build, and what to do with it.
The specific term we use for "what this object is in the context of a build" is kind, and is
represented by the ArtifactKind class.
ArtifactKind comprises a name (str), a priority (int), and a function matches that is used to
determine whether a resource (of type any) is a resource of that kind. When selene_sim's build pipeline
is invoked with some object (which we call a resource), the pipeline will check the resource against
a set of known ArtifactKinds (see the Build Planner section below), in descending order of priority,
until it finds an ArtifactKind that is satisfied by the resource. The first match is the chosen kind.
Examples of ArtifactKind provided in ./builtin.py include:
| ArtifactKind | Resource Type |
|---|---|
| HUGR Package | hugr.package.Package |
| HUGR Package Pointer | hugr.package.PackagePointer |
| HUGR File | pathlib.Path (.hugr) |
| LLVM IR File | pathlib.Path (.llvm) |
| LLVM Bitcode File | pathlib.Path (.bc) |
| Helios Object File | pathlib.Path (.o) |
| Selene Object File | pathlib.Path (.o) |
| Final Executable | pathlib.Path (.x) |
While it is clear that many ArtifactKinds represent paths to files, they each have very different actions that should be performed on them.
- the
HUGR Filekind may simply check that the path points to an existing file with the.hugrextension, satisfied that a corrupt file will fail in the following stage. - the
Selene Object Filehas the same extension as theHelios Object File, but thematchesfunction will check that the file is a valid selene object file by checking that the external calls that it makes are valid selene calls.
An artifact is a manifestation of an ArtifactKind. It comprises a resource object (of
type any), a kind (of type ArtifactKind) that the resource resembles, and metadata
(of type dict[str, str]).
Artifacts have a validate method that verifies that the resource it holds is valid according
to kind.
Once the input resource has successfully matched against an ArtifactKind, an input artifact
is created.
A step defines a mapping from one ArtifactKind to another. For example, a HUGR file may be transformed into an LLVM Bitcode file, which may be transformed into a Helios Object file. It comprises:
- an input kind (of type
ArtifactKind), - an output kind (of type
ArtifactKind), - a cost (of type
float), - a metadata filter (which may reject Artifacts with certain metadata), and
- a function that performs the transformation.
The build planner is responsible for maintaining a directed graph, with nodes representing
ArtifactKinds and edges representing Steps that transform from the source kind to the
destination kind. This acts as the registry for ArtifactKinds and Steps that are visible
to the build pipeline. A global DEFAULT_BUILD_PLANNER is provided by builtins.py that
covers a reasonable set of possible input types and intermediate artifacts, as well as steps
that transform between them.
selene_sim's build function (defined in init.py) accepts a planner
argument of type BuildPlanner. If one is not provided, then the DEFAULT_BUILD_PLANNER is used
instead.
This allows external libraries to define their own ArtifactKinds and Steps, and either
register them with the DEFAULT_BUILD_PLANNER or create their own BuildPlanner instance
for users to use.