Multidimensional arrays¶
C++ has no multidimensional array type (MDA) in its standard library. However, MDAs are crucial for the developmemt of scientific applications. One of the reasons for the continuing success of languages like Fortran or Python is their excellent support for MDAsfootnote{For Python arrays are introduced by the texttt{numpy} package.}. The lack of an MDA type in C++ was indeed the spark that initiated the developement of libpninexus. Before discussing libpninexus s array facilities some terminology should be defined:
- element type (ET)
referes to the data type of the individual elements stored in an MDA. For MDAs this will typically be a numeric type like an integer or a floating point number.
- rank \(r\)
denotes the number of dimensions of an MDA
- shape \(\mathbf{s}\)
is a vector of dimension \(r\) whose elements are the number of elements along each dimension. The elements of \(\mathbf{s}\) are denoted as \(s_i\) with \(i=0,\dots,r-1\)
The hart of libpninexus s MDA support is the mdarray
template.
mdarray
is extremely powerfull. Thus, using mdarray
directly to define array types is not for the faint harted. To simplify the
usage of multidimensional arrays the library provides three templates derived
form mdarray
which are easy to use.
template alias |
description |
|
---|---|---|
static_array<T,size_t …N> |
a static arrays whose shape, rank, and element type are fixed at compile time. |
|
fixed_dim_array<T,R> |
element type and rank are fixed at compile time but the shape can be changed at runtime. |
|
dynamic_array<T> |
a fully dynamic array type where only the element type must be known at compile time. The rank as well as the shape can be altered at runtime |
These types are all defined in pni/arrays.hpp
.
In addition to this two basic templates there are several utility classes and
templates like
template |
functionality |
|
---|---|---|
|
this template provides a view on a particular part of an array |
|
|
type erasure for an array |
All array types derived from mdarray
provide the following features
unary and binary arithmetics if the element type is a numeric type.
slicing to extract only a part of a large array
simple access to data elements using variadic operators.
all array types are full STL compliant containers and thus can be used along with STL algorithms.
Array construction and inquery¶
Constructing arrays is rather simple by means of the create()
function
provided by the array templates. As shown in array_create.cpp
examples/core/array_create.cpp
¶28#include <vector>
29#include <pni/types.hpp>
30#include <pni/arrays.hpp>
31
32using namespace pni;
33
34//some usefull type definitions
35typedef dynamic_array<float64> darray_type;
36
37int main(int ,char **)
38{
39 //construction from shape
40 auto a1 = darray_type::create(shape_t{1024,2048});
41
42 //construction from shape and buffer
43 auto a2 = darray_type::create(shape_t{1024,2048},
44 darray_type::storage_type(1024*2048));
45
46 //construction from initializer lists
47 auto a3 = darray_type::create({5},{1,2,3,4,5});
48
49 return 0;
50}
In line 35 a concrete array type is defined from the dynamic_array
utility template.
The create()
function comes in three flavors as shown in the previous
example
line |
description |
---|---|
40 |
Only the shape is passed as an argument. The storage container is allocated automatically by the constructor. |
43 |
The storage container with initial data is passed as a second
argument to |
47 |
shape as well as initial data are provided via initializer lists. |
After an array has been created we may want to retrieve some of its basic properties. In the next example we do exactly this
examples/core/array_inquery.cpp
for full code¶28#include <vector>
29#include <pni/types.hpp>
30#include <pni/arrays.hpp>
31
32using namespace pni;
33
34//some usefull type definitions
35typedef dynamic_array<float64> darray_type;
36
37template<typename ArrayT>
38void show_info(const ArrayT &a)
39{
40 std::cout<<"Data type: "<<type_id(a)<<std::endl;
41 std::cout<<"Rank : "<<a.rank()<<std::endl;
42 std::cout<<"Shape : (";
43 auto s = a.template shape<shape_t>();
44 for(auto n: s) std::cout<<" "<<n<<" ";
45 std::cout<<")"<<std::endl;
46 std::cout<<"Size : "<<a.size()<<std::endl;
47}
48
49int main(int ,char **)
50{
51 auto a1 = darray_type::create(shape_t{1024,2048});
52
53 show_info(a1);
54
55 return 0;
56}
The important part is the implementation of the show_info()
function
template starting at line 37. The function template type_id()
is used in
line 40 to retrieve the type ID of the arrays element type. rank()
in line
41 returns the number of dimension and size()
in line 46 the total
number of elements stored in the array.
The shape()
template function in line 43 returns the number of elements
along each dimension stored in a user provided container type.
The content of arrays can be copied to and from containers using the standard
std::copy()
template function from the STL. In addition a version of the
assignment operator is provided which allows assignment of values from an
initializer list. This is particularly useful for static arrays which basically
do not require construction.
using static_array_type = ....;
static_array_type a;
a = {1,2,3,4,5};
Linear access to data¶
As already mentioned in the first section of this chapter, the array types provided by libpninexus are fully STL compliant containers. They provided all the iterators required by the STL. Before we have a look on STL lets first investigate how to simply access data elements in an array
examples/core/array_linear_access.cpp
for full code¶28#include <random>
29#include <pni/types.hpp>
30#include <pni/arrays.hpp>
31
32using namespace pni;
33
34typedef uint16 channel_type;
35typedef fixed_dim_array<channel_type,1> mca_type;
36
37int main(int ,char **)
38{
39 auto mca = mca_type::create(shape_t{128});
40
41 //initialize
42 std::random_device rdev;
43 std::mt19937 generator(rdev());
44 std::uniform_int_distribution<channel_type> dist(0,65000);
45
46 //generate data
47 for(auto &channel: mca) channel = dist(generator);
48
49 //subtract some number
50 for(size_t i=0;i<mca.size();++i) if(mca[i]>=10) mca[i] -= 10;
51
52 //set the first and last element to 0
53 mca.front() = 0;
54 mca.back() = 0;
55
56 //output data
57 for(auto channel: mca) std::cout<<channel<<std::endl;
58
59 return 0;
60}
For all array types the new C++ for-each construction can be used as shown
in lines 47 and 57. Unchecked access (no index bounds are checked) is
provided via the [] operator as demonstrated in line 50 (use at()
for checked access).
Some of the operations in this example can be done much more efficient with STL
algorithms as demonstrated in the next example
examples/core/array_stl.cpp
for full code¶28#include <algorithm>
29#include <random>
30#include <pni/types.hpp>
31#include <pni/arrays.hpp>
32
33using namespace pni;
34
35typedef uint16 pixel_type;
36typedef fixed_dim_array<pixel_type,2> image_type;
37
38int main(int ,char **)
39{
40 auto image = image_type::create(shape_t{1024,512});
41
42 std::fill(image.begin(),image.end(),0); //use STL std::fill to initialize
43
44 std::random_device rdev;
45 std::mt19937 generator(rdev());
46 std::uniform_int_distribution<pixel_type> dist(0,65000);
47
48 std::generate(image.begin(),image.end(),
49 [&generator,&dist](){ return dist(generator); });
50
51 //get min and max
52 std::cout<<"Min: "<<*std::min_element(image.begin(),image.end())<<std::endl;
53 std::cout<<"Max: "<<*std::max_element(image.begin(),image.end())<<std::endl;
54 std::cout<<"Sum: ";
55 std::cout<<std::accumulate(image.begin(),image.end(),pixel_type(0));
56 std::cout<<std::endl;
57
58 return 0;
59}
In line 42 std::fill()
is used to initialize the array to 0 and
std::generate()
in line 48 fills it with random numbers using a lambda
expression . The rest of the example should be trivial (if not, please lookup a
good C++ STL reference).
Multidimensional access¶
Though being an important feature, linear access to multidimensional arrays is not always useful. In particular the last example where we pretended to work on image data implementing algorithms would be rather tedious if we would have had only linear access. It is natural for such objects to think in pixel coordinates (i,j) rather than the linear offset in memory. libpninexus provides easy multidimensional access to the data stored in an array. The next example shows how to use this feature to work only on a small region of the image data as defined in the last example
examples/core/array_multiindex.cpp
for full code¶28#include <algorithm>
29#include <random>
30#include <pni/types.hpp>
31#include <pni/arrays.hpp>
32
33using namespace pni;
34
35typedef uint16 pixel_type;
36typedef std::array<size_t,2> index_type;
37typedef fixed_dim_array<pixel_type,2> image_type;
38
39int main(int ,char **)
40{
41 auto image = image_type::create(shape_t{1024,512});
42
43 std::fill(image.begin(),image.end(),0); //use STL std::fill to initialize
44
45 std::random_device rdev;
46 std::mt19937 generator(rdev());
47 std::uniform_int_distribution<pixel_type> dist(0,65000);
48
49 std::generate(image.begin(),image.end(),
50 [&generator,&dist](){ return dist(generator); });
51
52
53 size_t zero_count = 0;
54 size_t max_count = 0;
55 for(size_t i=512;i<934;++i)
56 {
57 for(size_t j=128;j<414;++j)
58 {
59 if(image(i,j) == 0) zero_count++;
60 if(image(index_type{{i,j}}) >= 10000) max_count++;
61
62 }
63 }
64
65 std::cout<<"Found 0 in "<<zero_count<<" pixels!"<<std::endl;
66 std::cout<<"Found max in "<<max_count<<" pixels!"<<std::endl;
67
68 return 0;
69}
The interesting part here are lines 59 and 60. You can pass the multidimensional indexes either as a variadic argument list to the () operator of the array type (as in line 59) or you can use a container like in line 60. The former approach might look a bit more familiar, however, in some cases when decisions have to made at runtime the container approach might fits better. However, passing containers reduces access performance approximately by a factor of 2. Thus, as a rule of thumb you should always use the variadic form when you know the number of dimensions the array has and containers only in those cases where this information is only available at runtime.
Array views and slicing¶
In the previous example multiindex access was used to do work on only a small
part of the image data. libpninexus provides view types for arrays which would
make these operations easier. Views are created by passing instances of
slice
to the () operator of an array type. Slices in libpninexus work
pretty much the same as in python. Lets have a look on the following example
examples/core/array_view.cpp
for full code¶28#include <algorithm>
29#include <random>
30#include <pni/types.hpp>
31#include <pni/arrays.hpp>
32
33using namespace pni;
34
35typedef uint16 pixel_type;
36typedef std::array<size_t,2> index_type;
37typedef fixed_dim_array<pixel_type,2> image_type;
38
39int main(int ,char **)
40{
41 auto image = image_type::create(shape_t{1024,512});
42
43 std::fill(image.begin(),image.end(),0); //use STL std::fill to initialize
44
45 std::random_device rdev;
46 std::mt19937 generator(rdev());
47 std::uniform_int_distribution<pixel_type> dist(0,65000);
48
49 std::generate(image.begin(),image.end(),
50 [&generator,&dist](){ return dist(generator); });
51
52
53 auto roi = image(slice(512,934),slice(128,414));
54 auto zero_count = std::count_if(roi.begin(),roi.end(),
55 [](pixel_type &p){return p==0;});
56 auto max_count = std::count_if(roi.begin(),roi.end(),
57 [](pixel_type &p){return p>= 10000; });
58
59 std::cout<<"Found 0 in "<<zero_count<<" pixels!"<<std::endl;
60 std::cout<<"Found max in "<<max_count<<" pixels!"<<std::endl;
61
62 return 0;
63}
The view is created in line 53 where the slices are passed instead of integer
indices to the () operator. A slice selects an entire index range along a
dimension. The first argument to the slice
constructor is the starting
index and the last the stop index of the range. The stop index is not included
(just as it is the case with Python slices). If the () operator of an
array is called with any of its arguments being a slice a view object is
returned instead of a single value or reference to a single value.
View objects are pretty much like arrays themselves. However, they do not hold
data by themselves but only a reference to the original array.
Like arrays they are fully STL compliant containers and thus can be used with
STL algorithms as shown in lines 54 and 56.
View types can be copied and moved and thus can be stored in STL containers as shown in the next example
examples/core/array_view_container.cpp
for full code¶28#include <algorithm>
29#include <random>
30#include <pni/types.hpp>
31#include <pni/arrays.hpp>
32
33using namespace pni;
34
35typedef uint16 pixel_type;
36typedef std::array<size_t,2> index_type;
37typedef fixed_dim_array<pixel_type,2> image_type;
38typedef image_type::view_type roi_type;
39typedef std::vector<roi_type> roi_vector;
40
41int main(int ,char **)
42{
43 auto image = image_type::create(shape_t{1024,512});
44
45 std::fill(image.begin(),image.end(),0); //use STL std::fill to initialize
46
47 std::random_device rdev;
48 std::mt19937 generator(rdev());
49 std::uniform_int_distribution<pixel_type> dist(0,65000);
50
51 std::generate(image.begin(),image.end(),
52 [&generator,&dist](){ return dist(generator); });
53
54 roi_vector rois;
55 rois.push_back(image(slice(512,934),slice(128,414)));
56 rois.push_back(image(slice(0,128),slice(4,100)));
57 rois.push_back(image(200,slice(450,512)));
58
59 for(auto roi: rois)
60 {
61 auto zero_count = std::count_if(roi.begin(),roi.end(),
62 [](pixel_type &p){return p==0;});
63 auto max_count = std::count_if(roi.begin(),roi.end(),
64 [](pixel_type &p){return p>= 10000; });
65
66 std::cout<<std::endl;
67 std::cout<<"Found 0 in "<<zero_count<<" pixels!"<<std::endl;
68 std::cout<<"Found max in "<<max_count<<" pixels!"<<std::endl;
69 std::cout<<std::endl;
70 }
71
72 return 0;
73}
Here we apply the algorithms from the previous example not to a single but to several selections in the image. As shown in lines 55 to 57 we can safely store views in a container and later iterate over it.
In general views make algorithm development much easier as we have to develop algorithms only for entire arrays. If it should be applied to only a part of an array we can use a view and pass it to the algorithm. As views expose the same interface as an array the algorithm should work on views too.
Arithmetic expressions¶
Array and view types fully support the common arithmetic operators +, *, /, and - in their binary and unary forms. The binary versions are implemented as expression templates avoiding the allocation of unnecessary temporary and giving the compiler more possibilities to optimize the code. Views, arrays and scalars can be mixed within all arithmetic expressions. There is nothing magical with expression templates as they work entirely transparent to the user. Just use the arithmetic expressions as you are used to
examples/core/array_arithmetic1.cpp
¶28#include <algorithm>
29#include <random>
30#include <pni/types.hpp>
31#include <pni/arrays.hpp>
32
33using namespace pni;
34
35typedef float64 number_type;
36typedef fixed_dim_array<number_type,2> image_type;
37
38int main(int ,char **)
39{
40 shape_t s{1024,512};
41 auto image = image_type::create(s);
42 auto background = image_type::create(s);
43 number_type exp_time = 1.234;
44 number_type current = 98.3445;
45
46 //compute the corrected image
47 image = (image-background)/exp_time/current;
48
49 return 0;
50}
The important line here is 47 where arrays and scalars are mixed in an arithmetic expression. One can also mix arrays, selections, and scalars as the next examples shows
examples/core/array_arithmetic2.cpp
for full code¶28#include <algorithm>
29#include <random>
30#include <pni/types.hpp>
31#include <pni/arrays.hpp>
32
33using namespace pni;
34
35typedef float64 number_type;
36typedef fixed_dim_array<number_type,3> stack_type;
37typedef fixed_dim_array<number_type,2> image_type;
38typedef fixed_dim_array<number_type,1> data_type;
39
40int main(int ,char **)
41{
42 shape_t frame_shape{1024,512};
43 shape_t data_shape{100};
44 shape_t stack_shape{100,1024,512};
45 auto image_stack = stack_type::create(stack_shape);
46 auto background = image_type::create(frame_shape);
47 auto exp_time = data_type::create(data_shape);
48 auto current = data_type::create(data_shape);
49
50 for(size_t i = 0;i<data_shape[0];++i)
51 {
52 std::cout<<"Processing frame "<<i<<" ..."<<std::endl;
53 auto curr_frame = image_stack(i,slice(0,1024),slice(0,512));
54 curr_frame = (curr_frame - background)/exp_time[i]/current[i];
55 }
56
57 return 0;
58}
In line 53 a single image frame is selected from a stack of images and used in
line 54 in an arithmetic expression. In fact, what we are doing here is,
we are writing the corrected data back on the stack since curr_frame
is
just a view on the particular image in the stack.
Example: matrix-vector and matrix-matrix multiplication¶
In the last example matrix vector multiplications are treated. The full code can
be viewed in array_arithmetic3.cpp
in the source distribution. But lets
first start with the header
examples/core/array_arithmetic3.cpp
for full code¶ 28#include <iostream>
29#include <algorithm>
30#include <random>
31#include <pni/types.hpp>
32#include <pni/arrays.hpp>
33
34using namespace pni;
35
36// define the matrix and vector types
37template<typename ElementT,size_t TDimN> using matrix_temp = static_array<ElementT,TDimN,TDimN>;
38template<typename ElementT,size_t TDimN> using vector_temp = static_array<ElementT,TDimN>;
39
40//-----------------------------------------------------------------------------
41// print a matrix
42template<typename ElementT,size_t TDimN>
43std::ostream &operator<<(std::ostream &o,const matrix_temp<ElementT,TDimN> &m)
44{
45 for(size_t i = 0;i<3;++i)
46 {
47 o<<"| ";
48 for(size_t j=0;j<3;++j) o<<m(i,j)<<" ";
49 o<<"|"<<std::endl;
50 }
51
52 return o;
53}
54
55//-----------------------------------------------------------------------------
56// print a vector
57template<typename ElementT,size_t TDimN>
58std::ostream &operator<<(std::ostream &o,const vector_temp<ElementT,TDimN> &v)
59{
60 for(auto x: v) o<<"| "<<x<<" |"<<std::endl;
61 return o;
62}
63
64//-----------------------------------------------------------------------------
65// matrix-vector multiplication
66template<typename ElementT,size_t TDimN>
67vector_temp<ElementT,TDimN> mv_mult(const matrix_temp<ElementT,TDimN> &m,const vector_temp<ElementT,TDimN> &v)
68{
69 vector_temp<ElementT,TDimN> result;
70
71 size_t i = 0;
72 for(auto &r: result)
73 {
74 const auto row = m(i++,slice(0,TDimN));
75 r = std::inner_product(v.begin(),v.end(),row.begin(),ElementT(0));
76 }
77 return result;
78}
79
80//-----------------------------------------------------------------------------
81// vector-matrix multiplication
82template<typename ElementT,size_t TDimN>
83vector_temp<ElementT,TDimN> mv_mult(const vector_temp<ElementT,TDimN> &v,const matrix_temp<ElementT,TDimN> &m)
84{
85 vector_temp<ElementT,TDimN> result;
86
87 size_t i = 0;
88 for(auto &r: result)
89 {
90 const auto col = m(slice(0,TDimN),i++);
91 r = std::inner_product(col.begin(),col.end(),v.begin(),ElementT(0));
92 }
93 return result;
94}
95
96//-----------------------------------------------------------------------------
97// matrix-matrix multiplication
98template<typename ElementT,size_t TDimN>
99matrix_temp<ElementT,TDimN> mv_mult(const matrix_temp<ElementT,TDimN> &m1,const matrix_temp<ElementT,TDimN> &m2)
100{
101 matrix_temp<ElementT,TDimN> result;
102
103 for(size_t i=0;i<TDimN;++i)
104 {
105 for(size_t j=0;j<TDimN;++j)
106 {
107 const auto row = m1(i,slice(0,TDimN));
108 const auto col = m2(slice(0,TDimN),j);
109 result(i,j) = std::inner_product(row.begin(),row.end(),
110 col.begin(),ElementT(0));
111 }
112 }
113 return result;
114}
115
116//-----------------------------------------------------------------------------
117// define some local types
118typedef float64 number_type;
119typedef vector_temp<number_type,3> vector_type;
120typedef matrix_temp<number_type,3> matrix_type;
121
122
123int main(int ,char **)
124{
125 vector_type v;
126 matrix_type m1,m2;
127 m1 = {1,2,3,4,5,6,7,8,9};
128 m2 = {9,8,7,6,5,4,3,2,1};
129 v = {1,2,3};
130
131 std::cout<<"m1 = "<<std::endl<<m1<<std::endl;
132 std::cout<<"m2 = "<<std::endl<<m2<<std::endl;
133 std::cout<<"v = "<<std::endl<<v<<std::endl;
134 std::cout<<"m1.v = "<<std::endl<<mv_mult(m1,v)<<std::endl;
135 std::cout<<"v.m1 = "<<std::endl<<mv_mult(v,m1)<<std::endl;
136 std::cout<<"m1.m2 = "<<std::endl<<mv_mult(m1,m2)<<std::endl;
137
138 return 0;
139}
Besides including all required header files matrix and vector templates are defined in lines 37 and 38 using the new C++11 template aliasing.
Matrix vector multiplication¶
The implementation of the matrix vector multiplication is shown in the next block.
with \(\mathbf{A}\) denoting a \(N\times N\) matrix and \(\mathbf{r}\) and \(\mathbf{v}\) are \(N\)-dimensional vectors. In all formulas Einsteins sum convention is used.
66template<typename ElementT,size_t TDimN>
67vector_temp<ElementT,TDimN> mv_mult(const matrix_temp<ElementT,TDimN> &m,const vector_temp<ElementT,TDimN> &v)
68{
69 vector_temp<ElementT,TDimN> result;
70
71 size_t i = 0;
72 for(auto &r: result)
73 {
74 const auto row = m(i++,slice(0,TDimN));
75 r = std::inner_product(v.begin(),v.end(),row.begin(),ElementT(0));
76 }
77 return result;
78}
In line 74 we select the \(i\)-th row of the matrix and compute the inner product of the row vector and the input vector in line 75.
Vector matrix multiplication¶
The vector matrix multiplication
is computed analogously
82template<typename ElementT,size_t TDimN>
83vector_temp<ElementT,TDimN> mv_mult(const vector_temp<ElementT,TDimN> &v,const matrix_temp<ElementT,TDimN> &m)
84{
85 vector_temp<ElementT,TDimN> result;
86
87 size_t i = 0;
88 for(auto &r: result)
89 {
90 const auto col = m(slice(0,TDimN),i++);
91 r = std::inner_product(col.begin(),col.end(),v.begin(),ElementT(0));
92 }
93 return result;
94}
despite the fact that we are choosing the appropriate column instead of a row in line 90.
Matrix matrix multiplication¶
Finally we need an implementation for the matrix - matrix multiplication
98template<typename ElementT,size_t TDimN>
99matrix_temp<ElementT,TDimN> mv_mult(const matrix_temp<ElementT,TDimN> &m1,const matrix_temp<ElementT,TDimN> &m2)
100{
101 matrix_temp<ElementT,TDimN> result;
102
103 for(size_t i=0;i<TDimN;++i)
104 {
105 for(size_t j=0;j<TDimN;++j)
106 {
107 const auto row = m1(i,slice(0,TDimN));
108 const auto col = m2(slice(0,TDimN),j);
109 result(i,j) = std::inner_product(row.begin(),row.end(),
110 col.begin(),ElementT(0));
111 }
112 }
113 return result;
114}
The rows and columns are selected in lines 107 and 108 respectively. Line 109 finally computes the inner product of the row and column vector.
Putting it all together: the main function¶
Finally the main program shows a simple application of these template functions.
123int main(int ,char **)
124{
125 vector_type v;
126 matrix_type m1,m2;
127 m1 = {1,2,3,4,5,6,7,8,9};
128 m2 = {9,8,7,6,5,4,3,2,1};
129 v = {1,2,3};
130
131 std::cout<<"m1 = "<<std::endl<<m1<<std::endl;
132 std::cout<<"m2 = "<<std::endl<<m2<<std::endl;
133 std::cout<<"v = "<<std::endl<<v<<std::endl;
134 std::cout<<"m1.v = "<<std::endl<<mv_mult(m1,v)<<std::endl;
135 std::cout<<"v.m1 = "<<std::endl<<mv_mult(v,m1)<<std::endl;
136 std::cout<<"m1.m2 = "<<std::endl<<mv_mult(m1,m2)<<std::endl;
137
138 return 0;
139}
It is important to understand that the appropriate function is determined by the types of the arguments (vector or matrix). This is a rather nice example of how to use the typing system of C++ to add meaning to objects.
The output of the program is
>./array_arithmetic3
m1 =
| 1 2 3 |
| 4 5 6 |
| 7 8 9 |
m2 =
| 9 8 7 |
| 6 5 4 |
| 3 2 1 |
v =
| 1 |
| 2 |
| 3 |
m1.v =
| 14 |
| 32 |
| 50 |
v.m1 =
| 30 |
| 36 |
| 42 |
m1.m2 =
| 30 24 18 |
| 84 69 54 |
| 138 114 90 |