Using CelloView
Background
Historically, to access elements at a given location, (ix, iy, iz) of
an array in Enzo, the developer would need to explicitly calculate the
index of the pointer using knowledge of the underlying shape of the
array represented by the pointer.
To simplify and enhance the readability of code in Enzo-E, we have
implemented CelloView, which encapsulates the operations associated with
Multi-dimensional data. This class template draws loose inspiration
from Athena++’s AthenaArray and numpy’s ndarray. This class
was initially called CelloArray, but the name was changed for
reasons described in Why is it named CelloView.
See the first two cases listed in Examples
for comparisons of snipets written using CelloView and traditional
pointer operations. These examples reflect operations performed in
Enzo-E.
Throughout the Enzo portion of the codebase, we extensively use the type
EFlt3DArray which acts is an alias for CelloView<enzo_float,3>.
Design Goals
The design of the class was primarily driven by the following specifications:
Emphasize fast access to array elements by passing the index along each dimension to the
operator()method.
This method can be inlined within for-loops and for 3D arrays it has the same complexity as that of
AthenaArray.Simple benchmarks show that the current implementation achieves performance comparable to c-style array access
The
CelloViewneeds to be able to allocate and manage its own memory AND wrap existing pointers (namely the pointers allocated by the Cello’s Field framework)
This allows code using the
CelloViewto coexist alongside code which use pointers in a more conventional way.
CelloViewneeds be able to represent a view of a (mostly contiguous) subarray of a pre-existing instance ofCelloView. This facilitates the encapsulation of a directional mesh operation in a single generalized function (e.g. writing a single flux function for all directions rather than separate functions to compute flux along the x, y, and z directions).
User Interface
The class template is formally defined as CelloView<T,D> where
T is the element type (frequently enzo_float) and D is
the number of dimensionsions of the array.
At a high-level, this class template has semantics like a pointer or
std::shared_ptr (there are also similarities to numpy’s
ndarray). These objects serve as a
smart-pointer with methods for treating the data as a specialized
array. These semantics explicitly differ from the C++ standard
library containers (like std::vector).
In other words, CelloView<T,D> acts as an address
for the underlying data. The copy constructor and copy
assignment operation effectively make shallow copies and
deepcopies are made by explicitly invoking special methods. A
consequnce of this is that any modifications made to the elements of
an array within a function, where the array had been passed by value,
will affect the value of the array outside of the function.
We will return to this topic below in
Pointer Semantics
To provide a more detailed description of CelloView<T,D>’s
user interface it is most straightforward to describe the different
operations with examples (rather than providing a detailed API).
Array Creation
Simplest initialization:
Use the constructor
CelloView(Args... args)to construct an array of 0s of shape(arg0, arg1, ... arg{D-1}). The resulting array owns the underlying memory and deallocation is entirely taken care ofExamples:
Construct an array of shape
(2,3,4)that holds doubles:CelloView<double,3> arr(2,3,4);
Construct an array of shape
(5,)that holds ints:CelloView<int,1> arr(5); CelloView<int,1> arr2 = CelloView<int,1>(5); // yields same result
Wrap a pre-existing pointer:
Use the constructor
CelloView(T* array, Args... args);to wrap the pointerarraywhich represents an array with shape(arg0, arg1, ... arg{D-1}).Example: Construct an array representing
[[0,1,2],[3,4,5]]:int data[] = {0,1,2,3,4,5}; CelloView<int,2> arr(data,2,3);
We can also forward declare an array and assign values to it later.
int data[] = {0,1,2,3,4,5};
CelloView<int,2> arr;
arr = CelloView<int,2>(data,2,3);
Dimension Size
To get the length along a dimension (or axis), call
arr.shape(unsigned int dim), where dim is the number of the
dimension. Dimensions numbers start at 0 and are ordered with
increasing indexing speed (dim=D-1 is the dimension with fastest
indexing).
Element Access
To access an element pass indices to the operator()(Args... args)
method. As many indices should be specified as there are dimensions in
the array (the number of args must match the number of dimensions.
The operator()(Args... args) method returns a reference or copy
(depending on the circumstance) of the element.
Example: print element (0,2) of the array [[0,1,2],[3,4,5]]:
int data[] = {0,1,2,3,4,5};
CelloView<int,2> arr(data,2,3);
printf("%d\n", arr(0,2)); // prints "2"
// printf("%d\n", arr(2)); This would fail to compile
// printf("%d\n", arr(0,0,2)); This would fail to compile
Simple Assignment - Shallow/Deep Copies
Shallow copies are produced via ordinary assignment.
int data[] = {0,1,2,3,4,5};
CelloView<int,2> a(data,2,3);
CelloView<int,2> b = a; // b is now a shallow copy of a
CelloView<int,2> c(2,2); // c represents [[0,0],[0,0]]
CelloView<int,2> d = c; // d is now a shallow copy of c
c = a; // c is now a shallow copy of a
When c is assinged the contents of a, c becomes a shallow
copy of a. However the contents of d are unaffected. It still
represents the array [[0,0],[0,0]].
To perform a deepcopy, assign the the results of the deepcopy method.
int data[] = {0,1,2,3,4,5};
CelloView<int,2> a(data,2,3);
CelloView<int,2> e = a.deepcopy(); // e is now a deep copy of a
Modifications to the contents of e will not be reflected in a
or data (and vice-versa)
Creating Subarrays
Calling arr.subarray(Args... args) returns a (mostly contiguous) view
of a subarray specified by args, where args represent the slices
along each dimension. Each arg should be an instance of CSlice and
the number of args must match the number of dimensions of the array.
CSlice is a class that represents the start and stop points
along a given dimension. The standard constructor is simply:
CSlice(int start, int stop).
As an aside, when arr has 2 or more dimensions, arr.subarray
has an overload that accepts a single integer argument i. The
returned subarray is roughly equivalent to the view returned by
arr.subarray(CSlice(i,i+1), ...) where the omitted arguments are
slices that include all of the elements along the corresponding
dimensions. The only difference is that the resulting array has 1
fewer dimensions than arr.
Subarray Examples
We present an extended example below. We start by defining a subarray,
sub of an array arr (which wraps an existing pointer of data
and represents the array [[0,1,2],[3,4,5]]).
int data[] = {0,1,2,3,4,5};
CelloView<int,2> arr(data,2,3);
CelloView<int,2> sub = arr.subarray(CSlice(0,2),CSlice(1,3));
printf("%d\n", sub(1,0)) // prints "4";
At this point sub represents the subarray [[1,2],[4,5]]
of the full array held by arr. sub is truly a “view” of
arr. Modifications to the elements of sub and
modifications to elements in arr (if it lies in the subarray),
are reflected in both locations.
arr(1,3) *= -3;
sub(0,0) = -100;
After executing the above block of code, arr now represents
[[0,-100,2],[3,4,-15]] and sub represents the subarray
[[-100,2],[4,-15]].
CelloView also provides support for taking subarrays of
subarrays (or taking subarrays of shallow copies). If we define
a subarray of sub the result will represent a view of the
same underlying data
CelloView<int,2> sub_of_sub = sub.subarray(CSlice(0,2),CSlice(0,1));
sub_of_sub(1,0) +=8;
After the above operations, arr now reflects the full array
[[0,-100,2],[3,12,-15]], while sub and sub_of_sub
represent the subarrays [[-100,2],[12,-15]] and [[-100],[12]].
Continuing to make shallow copies or subarrays of sub_of_sub and
its derivatives will still yield views of the original array.
If we assign arr the value of an unrelated array, the data
tracked by all subarrays and subcopies are unaffected.
CelloView<int,2> sub2 = arr.subarray(CSlice(1,2),CSlice(0,3));
arr = CelloView<int, 2>(3,3); // setting arr equal to another array
sub(1,0) /= -2;
After execution of the preceeding block of code, sub represents
[[-100,2],[-6,-15]] of the full array,
sub_of_sub represents [[-100],[-6]], and sub2 represents
[[3,-6,-15]] (at this point the data pointer holds
[0, -100, 2, 3, -6, -15]).
The fact that arr originally wrapped data has no bearing on
the outcomes described above for each instance of CelloView.
We illustrate this below with an analogous abreviated example, where
the analog to arr, called array, originally owns its data.
CelloView<int,2> array(2,3);
array(0,0) = 0; array(0,1) = 1; array(0,2) = 2;
array(1,0) = 3; array(1,1) = 4; array(1,2) = 5;
CelloView<int,2> subarray = array.subarray(CSlice(0,2), CSlice(1,3));
array(1,3) *= -3;
subarray(0,0) = -100;
CelloView<int,2> subarray_of_subarray = subarray.subarray(CSlice(0,2),
CSlice(0,1));
subarray_of_subarray(1,0) += 8;
After executing the preceeding block of code, array reflects
[[0,-100,2],[3,12,-15]], while subarray and
subarray_of_subarray represent the subarrays
[[-100,2],[12,-15]] and [[-100],[12]]. If this was all the
code we executed, the memory of array would be freed after its
destructor and the destructors of all of subarrays or shallowcopies
are called.
If we reassign array to a different array, just like before, the values
of its subarrays and shallow copies will be unaffected.
CelloView<int,2> subarray2 = array.subarray(CSlice(1,2),CSlice(0,3));
array = CelloView<int, 2>(3,3);
subarray(1,0) /= -2;
Now, subarray represents [[-100,2],[-6,-15]] from the full
array, subarray_of_subarray represents [[-100],[-6]], and
subarray2 represents [[3,-6,-15]]. We note that no memory
has been deallocated. The memory will only be deallocated after
subarray, subarray_of_subarray, and subarray2 have
all had their deconstructor called and/or been assigned unrelated
arrays, assuming no additional subarrays or shallowcopies of any of
the 3 variables are made in the meantime (in that case the memory
would still not be deallocated until any additional
subarrays/shallowcopies that view the original data are destroyed).
Additional CSlice features
CSlice provides two additional features to simplify code when
the generating subarrays of a CelloView instance. These are
The constructor supports negative indexing. For example
CSlice(1,-1)represents a slice starting at the second element and stopping at (does not include) the last element along a dimension. Additionally,CSlice(-3,-1)represents starting from the third-to-last and stopping at the last element along a given dimension.The constructor accepts the
NULLandnullptras thestopargument and understands it to mean that the last element along the axis. For example,CSlice(1, NULL)andCSlice(1,nullptr)both represent slices from the second element through the last element of the dimension.CSlice(-3,NULL)andCSlice(-3,nullptr)both represent slices extending from the third-to-last element through the last element of a dimension. Additionally, ifNULLornullptrare passed as thestartargument, they are understood to mean that the slice starts at the first element (CSlice(0,NULL),CSlice(0,nullptr),CSlice(NULL,NULL), &CSlice(nullptr,nullptr)are all equivalent).
Finally, we note that CSlice provides a default constructor to
simplify the construction of arrays of slices. However, to help avoid
bugs, we require that any default-constructed CSlice must be
assigned a non-default constructed value (or an error will be raised).
Copying Elements between arrays
We also provide the copy_to instance method in order to copy
elements between elements between two CelloView instances.
An example is illustrated below:
int data[] = {0,1,2,3,4,5,6,7,8,9,10,11};
CelloView<int,2> arr(data,3,4);
// arr reflects: [[0,1,2,3],[4,5,6,7],[8,9,10,11]]
CelloView<int,2> arr2(2,2); // arr2 is initially [[0,0],[0,0]]
arr2(0,0) = 7;
arr2(0,1) = 7;
arr2(1,0) = 7;
arr2(1,1) = 7; // arr2 is now [[7,7],[7,7]]
arr2.copy_to(arr.subarray(CSlice(1,3), CSlice(0,2)));
// arr now reflects: [[0,1,2,3],[7,7,6,7],[7,7,10,11]]
arr2(0,1) = 4; // arr2 is now [[7,4],[7,7]] and arr is unaffected
Pointer Semantics
The following table is provided to highlight some of the differences
between the CelloView’s semantics and the semantics of a standard
library container.
|
Container Semantics |
|
|---|---|---|
Null-State |
|
A container always has a valid state. A default-constructed container is simply an empty container. |
Copy constructor & assignment |
These are shallow copies |
These are deep copies |
|
|
The contents of a |
1 For completeness, we note that there’s technically
nothing stopping you from having a CelloView<float, N> that
aliases the same data as a CelloView<const float, N>. In that
case, you are could modify the values using the CelloView<float,
N>.
2 In contrast, std::const_pointer_cast is
required for converting a std::shared_ptr<float> to a
std::shared_ptr<const float>
Convenience
In the Enzo layer of the codebase, we provide several short-cuts for
performing frequent actions related to the CelloView to reduce
boilerplate code.
We define and make extensive use of the type
EFlt3DArraywhich is an alias forCelloView<enzo_float,3>.We define the class
EnzoFieldArrayFactorywhich drastically reduces the boilerplate code associated with the initialization of instances ofCelloViewthat wrap Cello fields.We define the class
EnzoPermutedCoordinatesconvenience class which helps reduce boilerplate code associated with writing functions using instances ofCelloViewthat are generalized with respect to dimension.
Two additional, features that can be enabled at compile-time to assist
with debugging by defining macros before the inclusion of the CelloView
header file.
Defining the
CHECK_BOUNDSmacro, will cause checks of the validity of indices every time an element is accessed and will raise an error when it detects that an element that lies outside of the array bounds.Defining the
CHECK_FINITE_ELEMENTSmacro will cause a check during retrieval of array elements that they are notNaNorinf
Examples
Below, we show some factored out, simplified examples, ways in which how
CelloView might simplify code:
Copying Elements
This example illustrates how CelloView simplifies the code
required to copy elements between arrays. (We illustrate how one might
write Nearest Neighbor reconstruction along the x-direction).
This code assumes a mesh with shape (mz, my, mx). These are the
dimensions of the entire mesh, including the ghost zones. Suppose we
have:
An
(mz,my,mx)array of cell-centered primitiveswAn
(mz,my,mx-1)array of left reconstructed values,wlAn
(mz,my,mx-1)array of right reconstructed values,wr
First is an the CelloView version:
typedef double enzo_float;
typedef CelloView<enzo_float,3> EFlt3DArray;
void reconstruct_NN_x(EFlt3DArray &w, EFlt3DArray &wl,
EFlt3DArray &wr){
w.subarray(CSlice(0,w.shape(0)),
CSlice(0,w.shape(1)),
CSlice(0,-1)).copy_to(wl);
w.subarray(CSlice(0,w.shape(0)),
CSlice(0,w.shape(1)),
CSlice(1,w.shape(2))).copy_to(wr);
}
The analogous code using conventional pointer operations is:
typedef double enzo_float;
void reconstruct_NN_x(enzo_float *w, enzo_float *wl, enzo_float *wr,
int mx, int my, int mz){
int offset = 1;
for (int iz=0; iz<mz-1; iz++) {
for (int iy=0; iy<my-1; iy++) {
for(int ix=0; ix<mx-1; ix++) {
int i = (iz*my + iy)*mx + ix;
int i_xf = (iz*my + iy)*(mx-1) + ix;
wl[i_xf] = w[i];
Wr[i_xf] = w[i + offset];
}
}
}
}
Adding Flux Divergence
We show a factored out, slightly simplified version of the code used
to add the flux divergence in an unsplit manner. This example is one
of the more notable cases where the CelloView leads to more
transparent code.
This code assumes a mesh with shape (mz, my, mx). Suppose we have:
An
(mz,my,mx)array of cell-centered conserved quantitiesuAn
(mz,my,mx-1)array of x-face centered fluxes in the x-direction,xfluxAn
(mz,my-1,mx)array of y-face centered fluxes in the y-direction,yfluxAn
(mz-1,my,mx)array of y-face centered fluxes in the z-direction,zfluxThe timestep is
dt, and the size of cells along the x, y, and z directions aredx,dy,dzWe set place the updated values in
out(which may be a reference to the same array asuor to a different array)
typedef double enzo_float;
typedef CelloView<enzo_float,3> EFlt3DArray;
void update_cons(EFlt3DArray &u, EFlt3DArray &out,
EFlt3DArray &xflux, EFlt3DArray &yflux,
EFlt3DArray &zflux, enzo_float dt, enzo_float dx,
enzo_float dy, enzo_float dz){
enzo_float dtdx = dt/dx;
enzo_float dtdy = dt/dy;
enzo_float dtdz = dt/dz;
for (int iz=1; iz<u.shape(0)-1; iz++) {
for (int iy=1; iy<u.shape(1)-1; iy++) {
for (int ix=1; ix<u.shape(2)-1; ix++) {
out(iz,iy,ix) = (u(iz,iy,ix) -
dtdx*(xflux(iz,iy,ix) - xflux(iz,iy,ix-1)) -
dtdy*(yflux(iz,iy,ix) - yflux(iz,iy-1,ix)) -
dtdz*(zflux(iz,iy,ix) - zflux(iz-1,iy,ix)));
}
}
}
}
The analogous function using conventional pointer operations is provided below:
typedef double enzo_float;
typedef CelloView<enzo_float,3> EFlt3DArray;
void update_cons(enzo_float *u, enzo_float *out,
enzo_float *xflux, enzo_float *yflux,
enzo_float *zflux, enzo_float dt,
enzo_float dx, enzo_float dy, enzo_float dz,
int mx, int my, int mz){
enzo_float dtdx = dt/dx;
enzo_float dtdy = dt/dy;
enzo_float dtdz = dt/dz;
int x_offset = 1;
int y_offset = mx;
int z_offset = my*mx;
for (int iz=1; iz<mz-1; iz++) {
for (int iy=1; iy<my-1; iy++) {
for (int ix=1; ix<mx-1; ix++) {
int i = (iz*my + iy)*mx + ix;
int i_zf = i;
int i_yf = (iz*(my-1) + iy) * mx + ix;
int i_xf = (iz*my + iy) * (mx-1) + ix;
out[i] = (u[i]
- dtdx * (xflux[i_xf] - xflux[i_xf - x_offset])
- dtdy * (yflux[i_yf] - yflux[i_yf - y_offset])
- dtdz * (zflux[i_zf] - zflux[i_zf - z_offset]));
}
}
}
}
Direction Generalized Functions
This example illustrates how subarrays allows functions using
CelloView to be written so that they are generalized with respect
to Cartesian direction. Due to the simplicity of the example, code
with conventional pointer operations is comparable to the code using
arrays (however arrays make more complex examples more understandable)
In the van Leer + Constrained Transport scheme, we need to update update the cell-centered B-field component along a given direction by averaging the same components of the B-field stored at cell interfaces. We track Bx at the x-faces, By at the y-faces and Bz at the z-faces.
This code assumes a mesh with shape (mz, my, mx). Suppose we have:
An array of cell-centered B-field values (along a given component )
bcAn array of interface B-field values (for the same component)
bi. This array includes values of cell faces on the exterior of the mesh (e.g. for values centered along the x-axis the shape would be(mz,my,mx+1)).The direction of the component of the B-field is passed in with
dim. The values 0,1 & 2 map to x, y, and z
typedef double enzo_float;
typedef CelloView<enzo_float,3> EFlt3DArray;
void calc_center_bfield(EFlt3DArray &bc, EFlt3DArray &bi, int dim){
EFlt3DArray bi_l = bi;
// The following is a repeating pattern that gets factored out into
// a helper function
EFlt3DArrau bi_r;
if (dim == 0) {
bi_r = bi.subarray(CSlice(0,NULL), CSlice(0,NULL), CSlice(1,NULL));
} else if (dim == 1) {
bi_r = bi.subarray(CSlice(0,NULL), CSlice(1,NULL), CSlice(0,NULL));
} else {
bi_r = bi.subarray(CSlice(1,NULL), CSlice(0,NULL), CSlice(0,NULL));
}
for (int iz=0; iz<bc.shape(0); iz++) {
for (int iy=0; iy<bc.shape(1); iy++) {
for(int ix=0; ix<bc.shape(2); ix++) {
bc(iz,iy,ix) = 0.5 * (bi_l(iz,iy,ix) + bi_r(iz,iy,ix));
}
}
}
}
Why is it named CelloView
CelloView was originally called CelloArray, but the name was
changed to reflect subtle differences between CelloView and what
is typically called an array in C and in C++’s standard library.
For context, when we declare a variable as a C-style array (e.g. int
data[4];), or a std::array, the lifetime of the associated data
is tied to the variable’s lifetime (when the variable leaves scope,
the data is deallocated). In other words, these arrays manage the
lifetime of the associated data.
While a CelloView can manage the lifetime of the associated
data, that is not a hard requirement. It really acts as a “view” of a
region of memory (that it may or may not own): it provides useful methods
for probing that memory and associates useful metadata with it (i.e. the
shape/layout).
A consequence of this difference is that CelloView has different
semantics from standard library containers (described above).
The ViewMap Class Template
ViewMap<T> is a class template frequently used alongside
CelloView. As the name may suggest, it implements as a
map/dictionary of instances of CelloView<T,3>. The keys of the
map are always strings.
Note
As a historical note, the ViewMap class template replaces an
older concrete class known as EnzoEFltArrayMap, which was a
map/dictionary of instances of CelloView<enzo_float,3> (or
equivalently, instances of EFlt3DArray). To facillitate
backwards compatability, EnzoEFltArrayMap is now defined as a
type alias for ViewMap<enzo_float>.
New code should prefer to use ViewMap<enzo_float> in place of
EnzoEFltArrayMap.
Overview
This class provides some features that are atypical of maps, but are useful for our applications:
All values have the same shape.
All key-value pairs must be specified at construction. After construction:
key-value pairs can’t be inserted/deleted.
the
CelloViewassociated a with a key can’t be overwritten with a differentCelloViewOf course, the elements of the contained
CelloViewcan still be modified.The user specifies the ordering of the keys at construction.
As a result of these features this class act like a dynamically configurable “struct of arrays”.
Note
In the future, we may there may be a reason to make the
dimensionality of the contained views into a template parameter of
ViewMap. In other words, we would replace ViewMap<T> with
ViewMap<T,D>.
Basic Usage
Below, we provide a brief (non-exhaustive) overview of how the
ViewMap class template is used. This is not as detailed as the
description for the CelloView class template.
Creation
There are 2 primary ways to construct a new ViewMap instance.
The following code snippet illustrates how to construct an instance that holds existing
CelloViewinstances.// let's assume we have views holding density and velocity_x // (it does NOT matter whether any of these views allocate their own // data or wrap a pre-existing pointer) CelloView<enzo_float,3> density_arr(4,5,6); CelloView<enzo_float,3> velocity_x_arr(4,5,6); CelloView<enzo_float,3> velocity_y_arr(4,5,6); CelloView<enzo_float,3> velocity_z_arr(4,5,6); std::string map_name = "My Wrapper Map"; std::vector<std::string> key_l = {"density", "velocity_x", "velocity_y", "velocity_z"}; std::vector<CelloView<enzo_float,3>> view_l = {density_arr, velocity_x_arr, velocity_y_arr, velocity_z_arr}; ViewMap<enzo_float> wrapper_view_map(map_name, key_l, view_l);In the above example, we gave our view map the name
"My Wrapper Map". This is completely optional and primarily for debugging purposes. We could replace the last line from the above block with the following, if we didn’t want to name the map:ViewMap<enzo_float> unnamed_wrapper_view_map(key_l, view_l);Note: If
key_landview_ldid not have the same number of entries OR one of the views inview_lhad a shape that differed from any of the views in the list, the program would abort with an error message.The other way to construct a new
ViewMaphas the constructor allocate memory for all of the views in the map. This is illustrated below:std::string map_name = "My Scratch Map"; std::vector<std::string> key_l = {"density", "velocity_x", "velocity_y", "velocity_z"}; std::array<int,3> shape = {4,5,6}; ViewMap<enzo_float> scratch_view_map(map_name, key_l, shape);In the above code-block, we gave our view map the name
"My Scratch Map".scratch_view_mapcontains the same keys aswrapper_view_mapand each of the contained views have the same shape. The initial values inside each view ofscratch_arr_mapwere set by the constructor ofCelloView.If we didn’t want to name our view map, we could alternatively use:
ViewMap<enzo_float> unnamed_scratch_view_map(key_l, shape);
Element Access
The following snippet shows two ways to access a
CelloView<enzo_float,3> associated with a given key
std::vector<std::string> key_l = {"density", "velocity_x",
"velocity_y", "velocity_z"};
std::array<int,3> shape = {4,5,6};
EnzoEFltArrayMap scratch_view_map(map_name, key_l, shape);
CelloView<enzo_float,3> my_view1 = scratch_view_map["density"];
CelloView<enzo_float,3> my_view2 = scratch_view_map.at("density");
Due to the pointer-semantics of CelloView, my_view1 and
my_view2 are shallow-copies of one-another. For the same reason,
other_view1 and other_view2 in the following snipet are also
shallow copies of density_view.
CelloView<enzo_float,3> density_view(4,5,6);
CelloView<enzo_float,3> velocity_x_view(4,5,6);
std::vector<std::string> other_key_l = {"density", "velocity_x"};
std::vector<CelloView<enzo_float,3>> view_l = {density_view, velocity_x_view};
EnzoEFltArrayMap other_view_map(other_key_l, view_l);
CelloView<enzo_float,3> other_view1 = other_view_map["density"];
CelloView<enzo_float,3> other_view2 = other_view_map.at("density");
Unlike the element access methods of something like
std::map<std::string, CelloView<enzo_float,3>, these methods
cannot be used to add new key-value pairs to an EnzoEFltArrayMap
or to replace the CelloView associated with a given
key. (naturally, you can still change elements within the retrieved
CelloView instances).
ViewMap also supports index-access to it’s contents.
scratch_view_map[i] and other_view_map[i] respectively access
the CelloView associated with the ith key (using the order
specified during construction). These expressions are respectively
equivalent to scratch_view_map[key_l[i]] and
other_view_map[other_key_l[i]], in the context of the preceding
snippets. Note that we don’t support passing an integer value to
ViewMap::at.
Copy and const Semantics
Making a copy of a ViewMap instance (e.g. with a copy
constructor) always effectively produces a shallow copy. This is a
natural consequnce of the CelloView's pointer semantics. For
example, each element in a copy of a std::vector<CelloView<T,D>>
would be a shallow copy of the corresponding element in the orginal
vector.
A const ViewMap holds very little meaning. If you declare and
assign a variable, var, as
const ViewMap<double> var = /*... */;,
then the fact that you used const ViewMap effectively tells the
compiler to forbid you from performing var = other_view_map; in a
subsequent statement. You are still free to mutate the elements held
by any contained CelloView.
If you want to denote that a ViewMap is read-only, you should
declare the type as ViewMap<const double> instead. Note
that a ViewMap<float> can be implicitly converted to a
ViewMap<const float> (e.g. you can pass the former to a
function that expecting the latter). It’s analogous to an implicit
conversion from float* to a const float*.
The following snippet shows an example where we might want to use this
property. Imagine we are writing a function that computes pressure
from the contents of a ViewMap and stores it in a freshly
allocated CelloView. Suppose that during the calculation it
applies a density floor (but it doesn’t actually mutate the input
ViewMap. In that case, we might declare the following function
signature:
CelloView<enzo_float,3> calculate_pressure(ViewMap<const enzo_float> arg,
double density_floor);
This function signature makes a very clear promise to other developers
that arg is not mutated inside of the function. In fact the
compiler will not compile any implementation of the function where the
contents of arg are mutated (this can be a very useful reminder to any
future developer that alters the implementation of
calculate_pressure - they are of course able to change the
function signature, but it’s a good reminder to make sure that they
aren’t breaking other areas of the code). For reference, the primary
alternative to the above signature is identical, except that arg
is associated with the ViewMap<enzo_float> type. It’s important to
note that both function signatures are equally easy to call.
Note
At the time of writing, be aware that ViewMaps that internally
store their contents like an array of pointers will currently
perform a heap allocation during the implicit conversion from
ViewMap<T> to ViewMap<const T>.
The main purpose of this comment, is to serve as a reminder to fix
this after GitHub PR #325 is merged. A
description of how to fix this is provided as a comment within
Cello/view_ViewCollec.hpp. Please don’t let this influence
whether you use ViewMap<const T> in your code. This will be
fixed very soon.
Note
Historically, we tried to implement the EnzoEFltArrayMap with
different const-semantics. Essentially, we tried to treat a
const EnzoEFltArrayMap as read-only. However, there were
soundness problems with this approach.
Other Utilities
ViewMap also provides a series of methods to query
information about an instance’s contents. We describe these methods
for a hypothetical instance, view_map:
view_map.size()specifies the number of key-value pairs inview_map.
view_map.contains(const std::string& key)returns whetherview_mapholds some key,key.
view_map.array_shape(unsigned int dim)returns the value that would be returned by callingarr.shape(dim)for any view contained withinview_map.
Some other utilities include:
the
ViewMap::subarray_mapmethod. This constructs a newViewMapobject that holds subviews.the
ViewMap::namemethod specifies the name associated with a view map. If there isn’t an associated name, an empty string is returned.
Internal Data Organization
At the time of writing, ViewMap currently supports two
approaches for internally storing the values of the map:
The default, flexible approach stores the
CelloViewvalues in a data-structure resembling avector. This storage approach is analogous to having an array of pointers. This is the approach that is used when aViewMapis constructed that wraps pre-existingCelloViewinstances.The secondary, more specialized approach stores the individual
CelloViewvalues in a singleCelloView<T, 4>instance. Access of individualCelloViewvalues is accomplished with the overload of theCelloView<T,D>::subarraymethod. This approach is used when you construct aViewMapthat allocates memory for the containedCelloViews.
From an API-perspective, both approaches are nearly interchangable. However, the second approach should theoretically provide better data locality.
The only API difference introduced by these approaches is the
instances using the latter one supports the
ViewMap::get_backing_array() method, which provides
access to the underlying CelloView<enzo_float, 4>. If that
method is invoked on an instance that uses the first approach, the
program will abort and print an error message. To that end, the
ViewMap::contiguous_arrays() instance method let’s you
determine which approach is being used.
Note
The ViewMap::get_backing_array() method was introduced
as an “escape-hatch” to facillitate optimizations in particularly
performance critical parts of the code (e.g. a Riemann Solver).
Whenever this function is used, it introduces implicit assumptions
about the properties of an ViewMap instance (in addition
to requiring a particular data organization, it usually introduces an
assumption about the underlying key ordering).
We strongly advise that you avoid using this method unless you
deem it absolutely necessary. In many cases, the API of
ViewMap is sufficiently fast for retrieving the
required CelloViews before an expensive nested for-loop or in
the outermost level of a nested for-loop.
As an aside, there is room for optimizing the way that ViewMap
implements key-lookups (which may produce a small speedup).