|
SpECTRE
v2025.08.19
|
This page walks the user through the iterative process that led to SpECTRE's DataBox. At each stage, it discusses the advances and challenges that result from each improvement.
In a small C++ program, it is common to use the built-in fundamental types (bool, int, double, etc.) in computations, and to give variable names to objects of these types. For example, a section of a small program may look like this:
What changes as our program's size increases in scale? In SpECTRE, one of our driving design goals is modularity. In other words, functionality should be easy to swap in and out as desired. These smaller modules are easier to test, and keep the code flexible. We could wrap our calculation in such a module, which would then allow us to decouple the initial setup of the variables from where they are used in calculations:
Our small program can now be written as:
One advantage is immediate: we are free to add other computation modules that are independently testable and reusable. As the number of routines grows, we can even begin to write routines that work on top of existing ones.
While we have made progress, two problems arise. Our first problem is that as the number of quantities grows, it becomes more unwieldy to have to specify each function argument in our routine. The second problem is worse: the arguments passed to functions can be transposed and the program will still compile and run, but produce incorrect output. For example, the following two lines are equally well-formed from the point of view of the program:
Every time we call acceleration_compute we need to make sure we pass in the arguments in the correct order. In large programs where acceleration_compute is called many times, it becomes inevitable that the arguments will be accidentally transposed. We can address the two problems described above with a std::map, the first container we'll consider in this series.
We can encapsulate the variables we use in a std::map, and the first half of our small program example now looks like this:
We have not yet taken full advantage of the encapsulation that std::map provides. We do so by rewriting our other routines to take only a single argument, i.e. the std::map itself:
Notice that this solves the problem of having to provide the arguments in the correct order to every function call of acceleration_compute:
Our small program now looks like:
Within each function, we no longer need to worry about passing in the arguments in the correct order. This is a great improvement, but our reliance on proper names does leave us open to the following mistake:
In the above example, the map is asked to return a value given a key that does not exist! As written, however, the program is well-formed and no error is emitted. (In the case of std::map, [a value is created.] (https://en.cppreference.com/w/cpp/container/map/operator_at)) Because the keys are indistinguishable from their type alone, the mistake cannot be caught at compile time. In our example, the mistake won't even be caught at run time. The run time portion of a SpECTRE calculation will typically be much longer (up to thousands of times longer!) than the compile time portion, so it is critical to catch costly mistakes like this as early into the calculation as possible. Although names encoded as std::string cannot be distinguished by the compiler, names encoded as types can be. This is possible with C++'s static typing, and to take advantage of this we need a container that is heterogeneous, that is, capable of holding objects of different types.
A well-documented example of a fixed-size heterogeneous container of types is std::tuple:
The contents of the std_tuple are obtained using std::get:
In the above, we can see that we have promoted our keys from different values all of type std::string to different types entirely. We are not limited to fundamental types, we are free to make our own structs that serve as keys. These user-created types are called tags.
As the sole purpose of the tag is to provide the compiler with a type distinguishable from other types, they can be as simple as the following:
Note that we have now promoted Velocity, Radius, etc. from being values associated with std::strings at run time, to types distinguishable from other types at compile time. A large portion of SpECTRE is designed with the philosophy of enlisting the help of the compiler in assuring the correctness of our programs. An example of a std::tuple making use of these tags might look like:
Unfortunately, this will not work. The types passed as template parameters to std::tuple must also be the types of the arguments passed to std::make_tuple. Using a std::pair, we could write the above as:
What remains is to rewrite our functions to use std::tuple instead of std::map. Note that since we are now using a heterogeneous container of potentially unknown type, our functions must be templated on the pairs used to create the sophomore_databox. Our functions then look like:
Using all these std::pairs to get our std::tuple to work is a bit cumbersome. There is another way to package together the tagging ability of the struct names with the type information of the values we wish to store. To do this we need to make modifications to both our tags as well as our Databox implementation. This is what is done in SpECTRE's tuples::TaggedTuple, which is an improved implementation of std::tuple in terms of both performance and interface.
TaggedTuple is an implementation of a compile time container where the keys are tags.
Tags that are compatible with SpECTRE's tuples::TaggedTuple must have the type alias type in their structs. This type alias carries the type information of the data we wish to store in the databox. tuples::TaggedTuple is able to make use of this type information so we won't need auxiliary constructs such as std::pair to package this information together anymore. Our new tags now look like:
We are now able to create the junior_databox below in the same way we initially wished to create the sophomore_databox above:
Our functions similarly simplify:
In each of these iterations of the Databox, we started with initial quantities and computed subsequent quantities. Let us consider again force_compute, in which mass and acceleration are recomputed for every call to force_compute. If Mass and Acceleration were tags somewhow, that is, if we could compute them once, place them in the databox, and get them back out through the use of tags, we could get around this problem. We are now ready to consider SpECTRE's DataBox, which provides the solution to this problem in the form of ComputeTags.
A brief description of SpECTRE's DataBox: a TaggedTuple with compute-on-demand. For a detailed description of SpECTRE's DataBox, see the DataBox documentation.
Just as we needed to modify our tags to make them compatible with TaggedTuple, we need to again modify them for use with DataBox. Our ordinary tags become SpECTRE's SimpleTags:
As seen above, SimpleTags have a type and a name in their struct. When creating tags for use with a DataBox, we must make sure to the tag inherits from one of the existing DataBox tag types such as db::SimpleTag. We now create our first DataBox using these SimpleTags:
We can get our quantities out of the DataBox by using db::get:
So far, the usage of DataBox has been similar to the usage of TaggedTuple. To address the desire to combine the functionality of tags with the modularity of functions, DataBox provides ComputeTags.
ComputeTags are used to tag functions that are used in conjunction with a DataBox to produce a new quantity. ComputeTags look like:
ComputeTags inherit from db::ComputeTag, and it is convenient to have them additionally inherit from an existing SimpleTag (in this case Mass) so that the quantity MassCompute can be obtained through the SimpleTag Mass. We use the naming convention TagNameCompute so that TagNameCompute and TagName appear next to each other in documentation that lists tags in alphabetical order.
We have also added the type alias argument_tags, which is necessary in order to refer to the correct tagged quantities in the DataBox.
tmpl::list used in the type alias is a contiguous container only holding types. That is, there is no variable runtime data associated with it like there is for std::tuple, which is a container associating types with values. tmpl::lists are useful in situations when one is working with multiple tags at once.Using nested type aliases to pass around information at compile time is a common pattern in SpECTRE. Let us see how we can compute our beloved quantity of mass times acceleration:
And that's it! db::get utilizes the argument_tags specified in ForceCompute to determine which items to get out of the refined_databox. With the corresponding quantities in hand, db::get passes them as arguments to the function specified in ForceCompute. This is why every ComputeTag must have an argument_tags as well as a function specified; this is the contract with DataBox they must satisfy in order to enjoy the full benefits of DataBox's generality.
It is reasonable to expect that in a complicated calculation, we will encounter time-dependent or iteration-dependent variables. As a result, in addition to adding and retrieving items from our DataBox, we also need a way to mutate quantities already present in the DataBox. This can be done via db::mutate_apply and can look like the following:
db::mutate_apply, db::mutate. See the DataBox documentation for more details.not_nulls here are used to give us the assurance that the pointers time and falling_speed are not null pointers. Using raw pointers alone, we risk running into segmentation faults if we dereference null pointers. With not_nulls we instead run into a run time error that tells us what went wrong. For information on the usage of not_null, see the documentation for Gsl.hpp.In the above db::mutate_apply example, we are changing two values in the DataBox using four values from the DataBox. The mutated quantities must be passed in as gsl::not_nulls to the lambda. The non-mutated quantities are passed in as const references to the lambda.
db::mutate and post-db::mutate quantities. db::mutate provides this guarantee via a locking mechanism; within one mutate call, all initial pre-mutated quantities are obtained from the DataBox before performing a single mutation.From the above, we can see that the different kinds of tags are provided in two different tmpl::lists. The MutateTags, also called ReturnTags, refer to the quantities in the DataBox we wish to mutate, and the ArgumentTags refer to additional quantities we need from the DataBox to complete our computation. We now return to the recurring question of how to make this construction more modular.
We have now worked our way up to SpECTRE's DataBox, but as we can see in the above db::mutate_apply example, the lambda used to perform the mutation ends up being independent of any tags or template parameters! This means we can factor it out and place it in its own module, where it can be tested independently of the DataBox.
These constructs that exist independently of the DataBox are the precursors to SpECTRE's Actions. As they are designed to be used with db::mutate and db::mutate_apply, we give them the name Mutators. Here is the above lambda written as a Mutator-prototype, a struct-with-void-apply:
The call to db::mutate_apply has now been made much simpler:
There is a key step that we take here after this point, to make our struct-with-void-apply into a proper Mutator. As we will see, this addition will allow us to entirely divorce the internal details of IntendedMutation from the mechanism through which we update the DataBox. The key step is to add type aliases to IntendedMutation:
We are now able to write our call to db::mutate_apply in the following way:
As we saw earlier with QuantityCompute, we found that we were able to imbue structs with the ability to "read in" types specified in other structs, through the use of templates and member type aliases. This liberated us from having to hard-code in specific types. We notice immediately that IntendedMutation is a hard-coded type that we can factor out in favor of a template parameter:
Note how the return_tags and argument_tags are used as metavariables and are resolved by the compiler. Our call to db::mutate_apply has been fully wrapped and now takes the form:
The details of applying Mutators to the DataBox are entirely handled by MyFirstAction, with the details of the specific Mutator itself entirely encapsulated in IntendedMutation.
SpECTRE algorithms are decomposed into Actions which can depend on more things than we have considered here. Feel free to look at the existing actions that have been written. The intricacies of Actions at the level that SpECTRE uses them is the subject of a future addition to the Developer's Guide.