This page describes the structure and operation of FleCSI in developers’ terms but at a high level. It is meant to provide context for the details found when reading a source file (especially for the first time).
FleCSI performs only a few fundamental actions, each of which corresponds to one of the namespaces/directories under
data: Allocate distributed one-dimensional arrays of arbitrary trivial types (fields).
topo: Organize those arrays into topologies that represent computational physics domains of several kinds.
exec: Call user-specified functions (tasks) with pointers to the allocated memory (in the form of accessors).
io: Save and restore the contents of fields to and from disk.
log: Aggregate diagnostic output from multiple processes (
flogin macro names).
The implementation of these components is divided between the “front end” and one of a number of backends that leverage some external mechanism for allocating memory, transferring distributed data, and executing tasks.
The common backend API comprises a small set of classes and function templates that are sufficient to implement the front end; each is called an entry point.
Most entry points are defined in files named
policy.hh in a backend-specific directory in a component.
log are implemented entirely in the front end.
topo, each of these has a single header for application developers with a similar (if longer) name.
The reference backend uses Legion for these purposes, which imposes stringent requirements on the application because of its implicit operation across processors and memory spaces. The conceit is that code (in FleCSI and its clients) that is compatible with Legion will also work with most other backends.
The MPI backend uses MPI to transfer data; since MPI does not facilitate general allocation and task launches, these are implemented in terms of ordinary C++ constructs. It is not intended that it support every FleCSI feature, since some would require implementing a task system of complexity comparable to that of Legion. Neither is it required for using existing MPI-based libraries; Legion itself uses MPI and can be made compatible with them.
Other components provide support for the above activities:
run: Maintain internal state, communicate with the external mechanism, and invoke structured sequences of callback functions to perform a simulation.
util: Organize local data, support compile-time computation, and implement unit-testing assertions akin to those in Google Test.
util is implemented entirely in the front end.
Since the facilities provided by FleCSI are general-purpose, many of them are used by (other parts of) FleCSI itself. As a result, there are several circular dependencies among components (when ignoring their internal structure), but there is also a general hierarchy among them.
io is highest because it provides a user-level service in terms of the other components.
topo is high because non-trivial topologies themselves need to allocate and use field data.
exec is higher than
data because launching a task involves obtaining access to allocated data, but the latter is the most dispersed among the layers.
run is lower because it provides certain global variables and the access to the underlying communication system, except that its support for callbacks is outside the hierarchy altogether.
log is lower still, since it relies merely on MPI for communication.
util is the lowest, generic level.
There is also an expected hierarchy beyond FleCSI. In particular, it is expected that the topology specializations required as policies for the core topology categories (e.g., to specify the number of spatial dimensions) are provided by intermediate clients like FleCSI-sp that also provide domain-specific interfaces to application code. (Topology specializations are also called just topology types; they are not C++ explicit or partial specializations, although they may be generated by a class template.)
FleCSI’s design is meant to support a client simulation program that comprises a number of subsystems with minimal coordination among them. For example, fields are registered on merely a topology type, endowing all instances of that topology type with the field in question without requiring changes to its definition. (Memory is allocated only if the field is used, so clients can err on the side of registering whatever fields might be useful.) Additionally, the registrations take the form of non-local variables such that any source file in the program can contribute them independently.
Another example is the callback-based control flow system, which allows actions (and ordering constraints) to be associated with each part of the overall simulation cycle structure. Again, each such action is a non-local variable that can be supplied separately anywhere in the program.
Field data appears as actual, borrowed arrays in a task but as owning, opaque handles in the caller. As such, certain types are used only in one context or the other, and the types of some task arguments differ from the types of the parameters they indirectly initialize. Conveniently, this separation also guarantees thread safety in the event that tasks execute concurrently with their callers in the same process.
The FleCSI data model is based on that of Legion, with certain simplifications. Each field has a value at each of several index points, each of which is a pair of abstract integers. The first indicates the color to which the index point pertains, which corresponds (under some permutation) to the process that typically operates on the associated field values. The second is an index within the index points for that color; both are zero-based. (This structure, where field values at certain index points are periodically copied to other colors for access by other processes, is known as an “Extended Index Space”.)
A region is the domain for some number of fields: a rectangular subregion of the two-dimensional integer lattice whose lower-left corner is always (0,0). (Unlike in Legion, FleCSI uses a separate list of fields for each index space.) The rows of that rectangle, one per color, are often considered separately.
To support memory allocations that vary among rows or over time, fields are accessed via a partition, which is simply a prefix of each row of a region. (Often, the length of each such prefix is itself stored in a field that is accessed via a preexisting partition.) Several partitions may exist for a single region, although it is common for there to be just one or else just one at any time. All access the same field values, which survive as long as the region does. The backends are expected to allocate memory only as necessary for partitions that are actually used with a given field.
Each topology is a fixed-size set of index spaces (e.g., the cells, vertices, and faces of a mesh), each of which is backed by a region (and a partition). Often, other regions and partitions are included, organized into subtopologies that handle common kinds of data like the count of mesh elements.
partition are entry points.
The other entry points for data handling all concern copy engines, which are uniquely responsible for transferring data between rows.
(FleCSI does not use Legion’s support for overlapping partitions as a means of transferring data.)
Each is defined in terms of a field whose value at a destination of the copy is its source index points.
(Since an index point may be copied to more than one destination simultaneously, the inverse function does not exist.)
All of these types are caller-only; their APIs are defined in
Since class members cannot be declared without defining the class, those declarations are discarded by the preprocessor except when building API documentation.
The class template
partitioned addresses the common case where a
region and a
partition are needed together.
Copy plans wrap copy engines and the initialization of their associated fields; they are defined in
copy_plan.hh along with
buffers, a topology that transfers dynamic amounts of data with datagrams.
The backend is expected merely to provide uninitialized storage arrays for each field and
memcpy it appropriately.
sizeof(T) and the partition size is sufficient information to allocate it, but the type must be self-contained and trivially relocatable.
(This is not a formal C++ classification; note that
std::tuple<int> is not trivially copyable.)
This support is called the
Higher-level layouts are implemented in terms of it:
Tobjects in a
singleprovides syntactic sugar for the case of arrays of length 1 (per color).
raggedstores a resizable array of
Tat each index point, as if the field type were
std::vector<T>. The elements of the arrays are packed in an underlying
rawfield (with slack space to reduce reallocations, as with
std::vectoritself); the offsets of the beginning of each array are stored in a separate
sparsestores a mapping from integers to
Tat each index point, as if the field type were
std::map<std::size_t,T>. The implementation is simply a
std::pair<std::size_t,T>, with each row sorted by the key.
This enumeration is defined in
The various types used for working with a field are exposed as members of
T is the field data type and
L is the layout (which defaults to
Application code, topology specializations, and topologies alike register fields by defining non-local, caller-only objects of type
P is the topology type (specialization, sometimes called a “Policy”) and
S is the index space (of the type
P::index_space, which is typically an enumeration).
raw, the field is registered on the global FleCSI context with a field ID drawn from a global counter, organized by topology type and index space.
definition recursively registers appropriate underlying fields (via specializations of the helper class templates
These types are defined in
field.hh (but, as a principal name used by application code,
field appears directly in the
Topology objects are also caller-only; those at the top level are created by the class template
It defers the initialization of the topology instance, allowing it to be defined as a non-local variable if desired by application code.
It also provides a second phase of initialization that can be used to launch tasks operating on the new topology object.
Because it operates entirely on dependent types, its header
topology_slot.hh includes nothing but
Topology objects are constructed from colorings, which are descriptions of the computational domain as ordinary C++ data rather than fields.
For reasons of efficiency and interoperability, these are often constructed by special “MPI tasks” (described below).
The class template
coloring_slot, defined in
coloring.hh automates invoking such tasks.
Access to a field is requested with a caller-only field reference which identifies a field and a topology instance.
A field reference may be passed as an argument for an accessor parameter of a task.
Accessors are task-only; their types are usually spelled
field<T,L>::accessor<P,...>, where each
P is a privilege that specifies read and write permissions for some part of the field.
Where more than one privilege is supplied, the next-to-last refers to index points shared with other colors, and the last refers to ghosts that may be copied automatically from pre-specified shared points. Ghost copies are performed only when ghosts are read and not written and shared points have been written more recently than the previous read or write. (There is no mechanism at present to overlap the ghost copies with a task that does not require access to ghosts or write access to shared points.)
Internally, all of the (variable number of) privileges for an accessor are combined into a privilege pack.
field<T,L>::accessor1<P> may be used to reuse such a pack.
The actual type of an accessor is
data::accessor<L,T,P>, which must be used for template argument deduction to apply.
When the arrays for one or more index points in a
sparse field are resized, they must be repacked.
To do so efficiently, the interface for such resizing is provided by accessor variants called mutators, which use temporary storage (from the ordinary heap) to track changes made by a task and then apply those changes when it finishes.
They automatically resize such fields (according to a policy set by the topology) to maintain slack space for insertions, but the process simply fails if that guess is overrun.
Mutators also have permissions, used to distinguish those that trigger ghost copies from those that implement them.
Accessors of different layouts form a hierarchy parallel to that of field definitions.
The ultimately underlying
raw accessors merely store a
util::span<T>, along with a field ID used to look up the storage.
Higher-level accessors implement additional behavior, including certain automatic task launches.
ragged mutators are implemented in terms of the same underlying accessors as
ragged accessors, and
sparse mutators are in turn a wrapper around them.
All these types are defined in
accessor.hh; because it must include the task-execution headers, the (undefined) primary templates are declared in the lower-level
Because the structural information about a topology is often necessary for using the physics fields defined on it, each topology defines a topology accessor type that packages accessors for the fields that hold that structural information (registered by the topology itself), further extending the hierarchy of composite accessors.
Topology accessors are of course also task-only; a topology accessor parameter is matched by a topology slot argument.
access type is used wrapped in the
topology_accessor class template wrapper defined in
To help specify the members of topology accessors, which typically are accessors for preselected fields,
field.hh also defines the class template
accessor_member that accepts (a reference to) the field as a template argument and automatically initializes the accessor with the correct field ID.
(The field ID is not known until runtime, but the location where it will be stored is known at compile time.)
The execution of a FleCSI-based application is divided into three levels:
The outer program calls
starton the FleCSI context.
The synchronous callback passed to
startis the top-level action, which creates topologies and executes tasks.
The tasks perform all field access (including some needed during topology creation).
The first two are executed as normal SPMD jobs; only the top-level action can launch tasks. (With Legion, it is the top-level task, but we reserve that word for the leaf tasks.) When arguments that describe data across multiple colors (e.g., field references) are passed to a task, an index launch takes place that executes an instance of the task (a point task) for each color. (Often, there are the same number of colors as MPI ranks.) Otherwise, a single launch of the task is performed, which can be useful for custom I/O operations.
Inherited from Legion is the requirement that all interactions with FleCSI be identical in every instance of the first two levels (called a shard in the second case). (This is akin to the rules for collective communication operations with MPI.) This includes that the arguments passed to tasks must be identical across shards, which sometimes necessitates providing extra information from which the point task may select according to its color.
In general, task parameters must be serializable (as supported by
util/serialize.hh), since (when it is in use) Legion may execute tasks on other memory spaces and passes arguments to them only as byte arrays.
However, special conversions are first applied to certain arguments like field references recognized via the
replace_argument mechanism in
For all types, all task parameters exist on the caller side, though perhaps only long enough to be serialized.
partial class template is provided to allow a partial function application (as often implemented using a lambda expression) to be passed as a task argument.
In addition to converting arguments that identify resources, those resources are recruited for the task’s use.
For fields, this involves identifying the responsible
partition from the topology on the caller side.
(For Legion, its associated Legion handles are then identified as resources needed for the task launch, controlling data movement and parallelism discovery.)
The global topology (described further below) is a special case: it uses a (single-point)
region directly and requires that a task that writes to it be a single launch.
On the task side, the recruited resources and the accessors’ field IDs are consulted to obtain values for the contained
Because a task’s parameters are destroyed as soon as it returns, state accumulated by mutators is stored in separate buffers that can be processed afterwards.
On both sides, various tag base classes are used to recognize relevant FleCSI types;
send_tag in particular identifies types that can decompose themselves into simpler parameters via a
send member function template.
This function template accepts a callback that is used to process the subcomponents and which itself accepts a callback that, on the caller side only, is used to transform the task arguments.
The MPI backend handles both sides (for a single argument/parameter) in a single pass, transforming the arguments and initializing the (single copy of the) parameters immediately.
A call to
execute<F> can return before the task does; it returns a future that can be used to wait on the task to finish and obtain its return value (if any).
(Legion provides a mechanism for non-trivial class types to serialize themselves when so returned.)
A call that performs an index launch returns a
future<T,launch_type_t::index> that can access the value returned from each point task.
Such an object can also be passed as a task argument for a
future<T> parameter, which produces an index launch that processes all values in parallel.
The values returned by an index launch may also be reduced to a single value (still expressed as a future).
The reduction operation is expressed as a type, passed as a template argument to
reduce, with members
identity, which may optionally be templates.
The most common reduction operations are provided in the
exec::fold namespace, defined in
fold.hh; the generic interface is adapted to each backend in
The function template
execute simply forwards to
void as the (non-)reduction type; both are defined in
reduce performs periodic log aggregation and then calls the
reduce_internal entry point defined in
Certain implementations of
send may themselves execute tasks to prepare field data for the requested task, which means that
reduce_internal is in general reentrant.
Common portions of the argument and parameter handling are defined in
The undefined primary template for
future is declared in
launch.hh, along with documentation-only definitions of the single- and index-launch specializations, and the
The backend-specific implementations are in
Tasks and the top-level action cannot in general use parallel communication because it might collide with implicit or concurrent communication on the part of the backend and because any two of them may be executed sequentially or concurrently.
For cases where such communication is needed (e.g., to use an MPI-based library), a task can be executed as an MPI task via the optional template argument to
Like the top-level action, an MPI task is executed in parallel on every MPI rank; moreover, no other tasks or internal communication are executed concurrently, and the call is synchronous.
Because the call is not made in an ordinary task context,
context_t::color must not be used by an MPI task;
context_t::process, which is always available, has the desired meaning in that context.
Because the execution of an MPI task has largely the same semantics as an ordinary function call, arbitrary arguments may be passed to it. The usual argument replacements still apply, which allows MPI tasks to have access to both fields and objects created by the caller. Arguments that are not so interpreted need not have the same value on every shard. However, return values must follow the ordinary rules (so as to support futures and reductions).
FleCSI also provides, in
kernel_interface.hh, a wrapper interface for simple Kokkos parallel loops and reductions, including macros
reduceall that are followed by a lambda body (and a semicolon, since the lambda is an expression).
The same reduction types as for index launches are supported.
The basic responsibility of a (core) topology type is to provide access to fields.
The client machinery for task launches is backend-specific but not topology-specific; it uses a common interface specified by documentation-only example in
The interface also enables specializations to be defined; the core topology class template accepts the specialization as a template parameter (but neither inherits from the other as in the CRTP).
By inheriting from
specialization, a topology type is assigned an ID (for field registration), becomes non-constructible to prevent confusion with the core topology type, and gains several type aliases, several of which are meant for application code to use in preference to the formal names.
In turn, the topology type can specify index spaces, provide a factory function for coloring objects, extend the core
access type, and supply other category-specific parameters, as also presented in
The coloring type, however, is defined by the category (independent of specialization).
unstructured topology is a useful example of the metaprogramming techniques used to define index spaces and fields that are parametrized by the policy.
Its unit tests also provide examples of specializations.
For constructing complex, user-facing topologies, a number of simple topologies are defined for use as subtopologies (typically as data members of type
Some of these are so trivial as to merely inherit from the appropriate specialization of
specialization with an empty class body.
The most fundamental of these is
resize, which holds the sizes needed to construct a non-trivial
It is defined in
size.hh, along with the
color_category machinery to define fields with a single value per color.
data::partition with a
resize and schedules a task to initialize them properly; it too can be combined with a
region with the ed suffix.
It is defined in
index.hh, along with higher-level subtopologies that provide the backing store for the
ragged layout as well as the user-level
index topology that supports
ragged_topology is itself a class template, parametrized with the (user) topology type to distinguish
ragged field registrations on each.
For several of these types, there is a helper class (template) of the same name prefixed with
with_ to be used as a base class.
Several templates are defined in
utility_types.hh to assist in defining topologies.
topo::id serves to distinguish in user-facing interfaces the indices for different index spaces.
Because the global and index topologies do not need user-defined specializations and are generally useful for defining job- and color-global variables (respectively), a global instance of each is defined in
(Their colorings are trivial, so no coloring slots are used.)
Each backend’s initialization code uses the
data_guard type to manage their lifetimes.
util is self-contained and has little internal interaction, there is little need for centralized, prose description.
However, a few utilities have sufficiently wide relevance as to deserve mention.
Simplified backports of several range utilities from C++20 are provided in
The intent is to switch to the
std versions (with only trivial code changes) when compiler support becomes available.
Fixed-size containers that associate an enumeration value with each element are provided in
The class template
key_array derives from
key_tuple derives from
An idiomatic C++ interface for certain MPI routines is provided in
A wide variety of types is supported via automatic
MPI_Datatype creation or via serialization.
Unit-testing macros that can be used in tasks are provided by
They use return values (rather than global variables like Google Test), so the driver function must check the return value of a task with its own assertion.
A main program to be linked with each unit test source file is provided.