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 CelloView needs 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 CelloView to coexist alongside code which use pointers in a more conventional way.

  • CelloView needs be able to represent a view of a (mostly contiguous) subarray of a pre-existing instance of CelloView. 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 of

  • Examples:

    • 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 pointer array which 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

  1. 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.

  2. The constructor accepts the NULL and nullptr as the stop argument and understands it to mean that the last element along the axis. For example, CSlice(1, NULL) and CSlice(1,nullptr) both represent slices from the second element through the last element of the dimension. CSlice(-3,NULL) and CSlice(-3,nullptr) both represent slices extending from the third-to-last element through the last element of a dimension. Additionally, if NULL or nullptr are passed as the start argument, 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.

Semantic Comparison Table

CelloView<T,D> Semantics

Container Semantics

Null-State

  • a CelloView<T, D> technically supports a “null” state, which signals that it’s uninitialized. (This is directly analogous to a nullptr).

  • the CelloView<T, D>::is_null() method is provided for checking this condition.

  • The default constructor will create an uninitialized CelloView

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

const correctness

  • like a float * const or a const std::shared_ptr<float>, a const CelloView<float, N> points to values at a fixed location in memory. While the memory address can’t be modified, the values stored at that address can still be mutated.

  • like a const float* or a std::shared_ptr<const float>, a CelloView<const float, N> points to a region in memory whose values cannot be modified. 1 In other words the compiler will raise errors if you try to modify any of the values within the array.

  • Note: a CelloView<float, N> can be implicitly converted to a CelloView<const float, N> (e.g. you can pass the former to a function that expecting the latter). It’s about as seemless as converting a float* to a const float*. 2

The contents of a const container are immutable. For example, a const std::vector<float>, won’t let you modify it’s values.

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 EFlt3DArray which is an alias for CelloView<enzo_float,3>.

  • We define the class EnzoFieldArrayFactory which drastically reduces the boilerplate code associated with the initialization of instances of CelloView that wrap Cello fields.

  • We define the class EnzoPermutedCoordinates convenience class which helps reduce boilerplate code associated with writing functions using instances of CelloView that 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_BOUNDS macro, 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_ELEMENTS macro will cause a check during retrieval of array elements that they are not NaN or inf

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 primitives w

  • An (mz,my,mx-1) array of left reconstructed values, wl

  • An (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 quantities u

  • An (mz,my,mx-1) array of x-face centered fluxes in the x-direction, xflux

  • An (mz,my-1,mx) array of y-face centered fluxes in the y-direction, yflux

  • An (mz-1,my,mx) array of y-face centered fluxes in the z-direction, zflux

  • The timestep is dt, and the size of cells along the x, y, and z directions are dx, dy, dz

  • We set place the updated values in out (which may be a reference to the same array as u or 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 ) bc

  • An 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).

EnzoEFltArrayMap

A class that is frequently used alongside CelloView is the EnzoEFltArrayMap class. As the name may suggest, these classes serve as a map/dictionary of instances of EFlt3DArray (or equivalently, instances of CelloView<enzo_float,3>). The keys of the map are always strings.

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 EFlt3DArray associated a with a key can’t be overwritten with a different EFlt3DArray

    • Of course, the elements of the contained EFlt3DArray can 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 replace this EnzoEFltArrayMap with a template class (e.g. ViewMap<T, D>) that can represent a map of CelloViews that have a datatype other than enzo_float and numbers of dimensions other than 3. In that case, we would probably define EnzoEFltArrayMap as an alias to maintain backwards compatability.

Basic Usage

Below, we provide a brief (non-exhaustive) overview of how the EnzoEFltArrayMap class is used. This is not as detailed as the description for the CelloView template class.

Creation

There are 2 primary ways to construct a new EnzoEFltArrayMap instance.

  1. The following code snippet illustrates how to construct an instance that holds existing CelloView instances.

    // let's assume we have arrays holding density and velocity_x
    // (it does NOT matter whether any of these arrays 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>> arr_l = {density_arr,
                                                  velocity_x_arr,
                                                  velocity_y_arr,
                                                  velocity_z_arr};
    EnzoEFltArrayMap wrapper_arr_map(map_name, key_l, arr_l);
    

    In the above example, we gave our array 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:

    EnzoEFltArrayMap unnamed_wrapper_arr_map(key_l, arr_l);
    

    Note: If key_l and arr_l did not have the same number of entries OR one of the arrays in arr_l had a shape that differed from any of the arrays in the list, the program would abort with an error message.

  2. The other way to construct a new EnzoEFltArrayMap has the constructor allocate memory for all of the arrays 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};
    EnzoEFltArrayMap scratch_arr_map(map_name, key_l, shape);
    

    In the above code-block, we gave our array map the name "My Scratch Map". scratch_arr_map contains the same keys as wrapper_arr_map and each of the contained arrays have the same shape. The values inside each array of scratch_arr_map were set by the constructor of CelloView.

    If we didn’t want to name our array map, we could alternatively use:

    EnzoEFltArrayMap unnamed_scratch_arr_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_arr_map(map_name, key_l, shape);

CelloView<enzo_float,3> my_arr1 = scratch_arr_map["density"];
CelloView<enzo_float,3> my_arr2 = scratch_arr_map.at("density");

Due to the pointer-semantics of CelloView, my_arr1 and my_arr2 are shallow-copies of one-another. For the same reason, other_arr1 and other_arr2 in the following snipet are also shallow copies of density_arr.

CelloView<enzo_float,3> density_arr(4,5,6);
CelloView<enzo_float,3> velocity_x_arr(4,5,6);
std::vector<std::string> key_l = {"density", "velocity_x"};
std::vector<CelloView<enzo_float,3>> arr_l = {density_arr, velocity_x_arr};
EnzoEFltArrayMap other_arr_map(key_l, arr_l);

CelloView<enzo_float,3> other_arr1 = scratch_arr_map["density"];
CelloView<enzo_float,3> other_arr2 = scratch_arr_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).

EnzoEFltArrayMap also supports index-access to it’s contents. scratch_arr_map[i] accesses the CelloView associated with the i``th key (using the order specified during construction). Note that we don't support passing an integer value to ``EnzoEFltArrayMap::at.

Copy and const Semantics

Making a copy of an EnzoEFltArrayMap 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 EnzoEFltArrayMap is effectively read-only. For reference, element-access of an EnzoEFltArrayMap instance yields a CelloView<enzo_float,3> instance (whose elements can be modified). In comparison, element-access of a const EnzoEFltArrayMap yields a CelloView<const enzo_float,3> which prevents direct modification of array elements.

Other Utilities

EnzoEFltArrayMap also provides a series of methods to query information about an instance’s contents. We describe these methods for a hypothetical instance, arr_map:

  • arr_map.size() specifies the number of key-value pairs in arr_map.

  • arr_map.contains(const std::string& key) returns whether arr_map holds some key, key.

  • arr_map.array_shape(unsigned int dim) returns the value that would be returned by calling arr.shape(dim) for any array contained within arr_map.

Some other utilities include:

  • the EnzoEFltArrayMap::subarray_map method. This constructs a new EnzoEFltArrayMap object that holds subarrays.

  • the EnzoEFltArrayMap::name method specifies the name associated with an array map. If there isn’t an associated name, an empty string is returned.

Internal Data Organization

This class currently supports two approaches for internally storing the values of the map:

  1. The default, flexible approach stores the CelloView values in a vector. This storage approach is analogous to having an array of pointers. This is the approach that is used when a EnzoEFltArrayMap is constructed that wraps pre-existing CelloView instances.

  2. The secondary, more specialized approach stores the individual CelloView values in a single CelloView<enzo_float, 4> instance. Access of individual CelloView values is accomplished with the overload of the CelloView<T,D>::subarray method. This approach is used when you construct an EnzoEFltArrayMap that allocates memory for the contained CelloViews.

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 EnzoEFltArrayMap::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 EnzoEFltArrayMap::contiguous_arrays() instance method let’s you determine which approach is being used.

Note

The EnzoEFltArrayMap::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 EnzoEFltArrayMap 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 EnzoEFltArrayMap 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, the way that EnzoEFltArrayMap implements key-lookups could be refactored and sped up considerably.