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

arrayview

this template provides a view on a particular part of an array

arrayerasure

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

see 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 create() along with the shape of the array.

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

see 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

see 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

see 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

see 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

see 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

see 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

see 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

see 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

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

\[\mathbf{r} = A\mathbf{v} \mbox{ or } r_j = A_{j,i}v_i\]

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

\[\mathbf{r} = \mathbf{v}A\mbox{ or } r_i = v_j A_{j,i}\]

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

\[C = AB \mbox{ or } C_{i,j} = A_{i,k}B_{k,j}\]
 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 |