Triangular matrices
A triangular matrix is a kind of sparse square matrix where all elements above or below the diagonal are zero. In the latter case, we call it a lower triangular matrix. While we can always represent an n² triangular matrix as an ordinary n² matrix where we store the zeroes, this is wasteful of memory. Instead, we can use a representation where we store only the possibly nonzero elements. Since Futhark only supports regular arrays, we must encode this as a one-dimensional array. To hide the complexity of the encoding from the user, we define triangular matrices as an abstract data type.
We start out by defining a module type describing a minimal interface for lower triangular matrices.
module type triangular = {The type of triangular matrices is parameterised by the edge length and the element type.
type~ triangular [n] 'aWe define a size-lifted type with type~ for technical reasons, as our
implementation will eventually contain an array whose size is not
literally n. The Futhark type checker is very rigid, and does
not allow us to “hide” arrays of sizes that are not present in the
type.
val get [n] 'a :
a -> (i64,i64) -> triangular [n] a -> aget zero (i,j) m should produce the element at logical position
(i,j) in the triangular matrix m, returning zero whenever we
are above the diagonal. The explicit zero is needed because our
type is fully polymorphic - we have no idea what a this is.
val from_array [n] 'a :
[n][n]a -> triangular [n] aThe from_array function constructs a triangular matrix from a
dense array. All elements above the diagonal are ignored.
We also provide a way to convert a triangular matrix back into a
dense array. The caller must provide a zero value, for the same
reason as with get above:
val to_array [n] 'a :
a -> triangular [n] a -> [n][n]aFinally, as an example of a higher-order operation, we provide a
map function for triangular matrices:
val map [n] 'a 'b :
(a -> b) -> triangular [n] a -> triangular [n] bThis is a very sparse interface, and would probably need to be extended if we wanted to use it in real applications.
}Now let’s implement that module type.
module triangular : triangular = {First thing, we define our representation of triangular arrays.
What we need is a one-dimensional array to store the elements
(the data array). However, whenever we have a type with a size
parameter (the [n]), then we must also have an array of that
size in the definition of type type. The data array will not have
the right size, so we add an n-by-zero array of empty tuples. In
effect, the size field acts acts as a “witness” for the size
parameter. By making the inner dimension have size zero, we ensure
that the memory usage of this array will be constant.
type~ triangular [n] 'a =
{ size: [n][0](),
data: []a
}The size of the data array given n is given by this formula:
def elements (n: i64) =
(n * (1+n))/2We now define a handful of utility functions for converting between
flat indexes into the data array and the two-dimensional
indexes that we use for ordinary arrays. For n=4, the indexes of
the elements are as follows:
0
1 2
3 4 5
6 7 8 9
First a function that determines which row a flat index into the
data array conceptually belongs to. Don’t worry about the opaque
formula - it’s essentially just a solution to a certain
second-degree equation (I’ll admit it’s a bit odd to see square
roots in index calculations).
def row (i: i64) =
i64.f64 (f64.ceil ((f64.sqrt(f64.i64(9+8*i))-1)/2))-1It behaves like this:
> row 0
0i64
> row 1
1i64
> row 2
1i64
> row 3
2i64
Now can define ranking, which turns a two-dimensional index (i,j) into a flat index:
def rank i j =
elements i + jAnd the other way around:
def unrank (p: i64) =
let i = row p
let j = p - elements i
in (i,j)Finally we can define the API functions, which are mostly
straightforward. First get, where we manually check whether we
are trying to index above the diagonal, and return the zero value
if so:
def get [n] 'a zero (i,j) (tri: triangular [n] a) =
if j > i then zero else tri.data[rank i j]Converting between ordinary arrays and triangular arrays is also straightforward.
def from_array arr =
{ size = map (const []) arr,
data = map (\p -> let (i,j) = unrank p
in arr[i,j])
(iota (elements (length arr)))
}
def to_array [n] 'a zero (tri: triangular [n] a) =
tabulate_2d n n (\i j -> get zero (i,j) tri)In fact, to_array does not have to be part of the module, as it
is defined in terms of the standard prelude function tabulate_2d,
and the exposed get function.
Finally, defining map is very simple, as it doesn’t have to care
about the sizes at all.
def map f {size, data} =
{size, data = map f data}And we’re done!
}This module is probably too small to be useful. In practice, I’d
expect we’d also need zip functions as a minimum, and some kind
of reduce also seems like it might be useful. We can always use
from_array and to_array to convert back and forth between
ordinary arrays as needed, though.