tatami
C++ API for different matrix representations
|
tatami is a spiritual successor to the beachmat C++ API that provides read access to different matrix representations. Specifically, applications can use tatami to read rows and/or columns of a matrix without any knowledge of the specific matrix representation. This allows application developers to write a single piece of code that will work seamlessly with different inputs, even if the underlying representation varies at run-time. In particular, tatami is motivated by analyses of processed genomics data, where matrices are often interpreted as a collection of row- or column-wise vectors. Many applications involve looping over rows or columns to compute some statistic or summary - for example, testing for differential expression within each row of the matrix. tatami aims to optimize this access pattern across a variety of different matrix representations, depending on how the data is provided to the application.
tatami is a header-only library, so it can be easily used by just #include
ing the relevant source files:
The key idea here is that, once mat
is created, the application does not need to worry about the exact format of the matrix referenced by the pointer. Application developers can write code that works interchangeably with a variety of different matrix representations.
tatami::Matrix
Users can create an instance of a concrete tatami::Matrix
subclass by using one of the constructors or the equivalent make_*
utility:
Description | Class |
---|---|
Dense matrix | DenseMatrix |
Compressed sparse matrix | CompressedSparseMatrix |
List of lists sparse matrix | FragmentedSparseMatrix |
Delayed isometric unary operation | DelayedUnaryIsometricOperation |
Delayed isometric binary operation | DelayedBinaryIsometricOperation |
Delayed combination | DelayedBind |
Delayed subset | DelayedSubset |
Delayed transpose | DelayedTranspose |
Delayed cast | DelayedCast |
Delayed cast | DelayedCast |
Constant matrix | ConstantMatrix |
For example, to create a compressed sparse matrix from sparse triplet data, we could do:
We typically create a shared_ptr
to a tatami::Matrix
to leverage run-time polymorphism. This enables downstream applications to accept many different matrix representations by compiling against the tatami::Matrix
interface. Alternatively, applications may use templating to achieve compile-time polymorphism on the different tatami subclasses, but this is rather restrictive without providing obvious performance benefits.
We use templating to define the type of values returned by the interface. This includes the type of the data (most typically double
) as well as the type of row/column indices (default int
, but one could imagine using, e.g., size_t
). It is worth noting that the storage type does not need to be the same as the interface type. For example, developers could store a matrix of small counts as uint16_t
while returning double
s for compatibility with downstream mathematical code.
The delayed operations are ~stolen from~ inspired by those in the DelayedArray package. Isometric operations are particularly useful as they accommodate matrix-scalar/vector arithmetic and various mathematical operations. For example, we could apply a sparsity-breaking delayed operation to our sparse matrix smat
without actually creating a dense matrix:
Some libraries in the @tatami-inc organization implement further extensions of tatami's interface, e.g., for HDF5-backed matrices and R-based matrices.
Given an abstract tatami::Matrix
, we create an Extractor
instance to actually extract the matrix data. Each Extractor
object can store intermediate data for re-use during iteration through the matrix, which is helpful for some matrix implementations that do not easily support random access. For example, to perform extract dense rows from our mat
:
The tatami::MyopicDenseExtractor::fetch()
method returns a pointer to the row's contents. In some matrix representations (e.g., DenseMatrix
), the returned pointer directly refers to the matrix's internal data store. However, this is not the case in general so we need to allocate a buffer of appropriate length (buffer
) in which the dense contents can be stored; if this buffer is used, the returned pointer refers to the start of the buffer.
Alternatively, we could extract sparse columns via tatami::MyopicSparseExtractor::fetch()
, which returns a tatami::SparseRange
containing pointers to arrays of (structurally non-zero) values and their row indices. This provides some opportunities for optimization in algorithms that only need to operate on non-zero values. The fetch()
call requires buffers for both arrays - again, this may not be used for matrix subclasses with contiguous internal storage of the values/indices.
In both the dense and sparse cases, we can restrict the values that are extracted by fetch()
. This provides some opportunities for optimization by avoiding the unnecessary extraction of uninteresting data. To do so, we specify the start and length of a contiguous block of interest, or we supply a vector containing the indices of the elements of interest:
In performance-critical sections, it may be desirable to customize the extraction based on properties of the matrix. This is supported with the following methods:
tatami::Matrix::is_sparse()
indicates whether a matrix is sparse.tatami::Matrix::prefer_rows()
indicates whether a matrix is more efficiently accessed along its rows (e.g., row-major dense matrices).Users can then write dedicated code paths to take advantage of these properties. For example, we might use different algorithms for dense data, where we don't have to look up indices; and for sparse data, if we can avoid the uninteresting zero values. Similarly, if we want to compute a row-wise statistic, but the matrix is more efficiently accessed by column according to prefer_rows()
, we could iterate on the columns and attempt to compute the statistic in a "running" manner (see colsums.cpp
for an example). In the most complex cases, this leads to code like:
Of course, this assumes that it is possible to provide sparse-specific optimizations as well as running calculations for the statistic of interest. In most cases, only a subset of the extraction patterns are actually feasible so special code paths would not be beneficial.
The mutable nature of an Extractor
instance means that the fetch()
calls themselves are not const
. This means that the same extractor cannot be safely re-used across different threads as each call to fetch()
will modify the extractor's contents. Fortunately, the solution is simple - just create a separate Extractor
(and the associated buffers) for each thread. With OpenMP, this looks like:
Users may also consider using the tatami::parallelize()
function, which accepts a function with the range of jobs (in this case, rows) to be processed in each thread. This automatically falls back to the standard <thread>
library if OpenMP is not available. Applications can also set the TATAMI_CUSTOM_PARALLEL
macro to override the parallelization scheme in all tatami::parallelize()
calls.
When constructing an Extractor
, users can supply an Oracle
that specifies the sequence of rows/columns to be accessed. Knowledge of the future access pattern enables optimizations in some Matrix
implementations, e.g., file-backed matrices can reduce the number of disk reads by pre-fetching the right data for future accesses. The most obvious use case involves accessing consecutive rows/columns:
In fact, this use case is so common that we can just use the tatami::consecutive_extractor()
wrapper to construct the oracle and pass it to tatami::Matrix
. This will return a tatami::OracularDenseExtractor
instance that contains the oracle's predictions.
Alternatively, we can use the FixedOracle
class with an array of row/column indices that are known ahead of time. Advanced users can also define their own Oracle
subclasses to generate predictions on the fly.
As previously mentioned, tatami is primarily designed to pull out rows or columns of a matrix. Some support is provided for computing basic statistics via the tatami_stats library, in the same vein as the matrixStats R package. Matrix multiplication is similarly implemented via the tatami_mult library.
tatami does not currently support more sophisticated matrix operations like decompositions. If these are required, we suggest copying data from tatami into other frameworks like Eigen, effectively trading the diversity of representations for a more comprehensive suite of operations. For example, we often use tatami to represent the large input datasets in a custom memory-efficient format; process it into a much smaller submatrix, e.g., by selecting features of interest in a genome-scale analysis; and then copy the data into a Eigen::MatrixXd
or Eigen::SparseMatrix
for the desired operations. In practice, many standard decompositions do not scale well for large matrices, so our applications end up using approximate methods like ILRBA that only require a multiplication operator.
tatami does not directly support modification of the matrix contents. Instead, "modifications" are performed by adding delayed operations on top of an immutable matrix. This avoids difficult bugs where the hypothetical modification of matrix contents via one shared_ptr
affects all references to the same matrix across the application. Delayed operations are also more appropriate for matrices referring to read-only data sources, e.g., remote stores or files. That said, if delayed operations are undesirable, we can use functions like tatami::convert_to_dense()
to realize our modifications into a new matrix instance.
FetchContent
If you're using CMake, you just need to add something like this to your CMakeLists.txt
:
Then you can link to tatami to make the headers available during compilation:
By default, this will use FetchContent
to fetch all external dependencies. Applications are advised to pin the versions of these dependencies themselves - see extern/CMakeLists.txt
for suggested (minimum) versions. If you want to install them manually, use -DTATAMI_FETCH_EXTERN=OFF
.
find_package()
You can install the library by cloning a suitable version of this repository and running the following commands:
Then you can use find_package()
as usual:
Again, this will use FetchContent
to fetch all external dependencies, so see advice above.
If you're not using CMake, the simple approach is to just copy the files the include/
subdirectory - either directly or with Git submodules - and include their path during compilation with, e.g., GCC's -I
. This also requires the external dependencies listed in extern/CMakeLists.txt
.
Check out the reference documentation for more details on each function and class.
The gallery contains worked examples for common operations based on row/column traversals.
The tatami_stats repository computes some common statistics on tatami matrices.
The tatami_hdf5 repository contains tatami bindings for HDF5-backed matrices.
The tatami_r repository contains tatami bindings for matrix-like objects in R.
The beachmat package vendors the tatami headers for easy use by other R packages.