--- title: Large data output: rmarkdown::html_vignette: css: albers.css includes: in_header: - albers-header.inc - albers-header.html toc: yes toc_depth: 2.0 resource_files: - albers.css - albers-fonts.css - albers.js - albers-header.inc - albers-header.html - fonts params: family: red preset: interaction vignette: | %\VignetteIndexEntry{Large data} %\VignetteEngine{knitr::rmarkdown} %\VignetteEncoding{UTF-8} --- ```{r setup, include = FALSE} if (requireNamespace("ragg", quietly = TRUE)) knitr::opts_chunk$set(dev = "ragg_png") if (requireNamespace("systemfonts", quietly = TRUE)) albersdown::albers_register_fonts() if (requireNamespace("ggplot2", quietly = TRUE) && requireNamespace("albersdown", quietly = TRUE)) ggplot2::theme_set(albersdown::theme_albers(family = params$family, preset = params$preset)) source("_common.R") ``` ```{r albers-classes, echo=FALSE, results='asis'} cat(sprintf( paste0( '' ), params$family, params$preset )) ``` ```{r load} library(neuroim2) ``` Dense is the default because it is the simplest thing that works, not because it is the right thing at scale. An R array holds `double`s whatever the file said, so a 56 MB `int16` run is 236 MB the moment you read it, and a session of runs will not fit. The two out-of-core routes below are not a consolation prize for running out of memory: for the scattered, voxel-at-a-time access that searchlight, ROI and connectivity work actually perform, they are also *faster* than loading everything first. On an ordinary run (64 × 64 × 36 × 200), pulling 5,000 scattered voxel time courses measured **44 ms** through `read_vec(path, mask = brain)` and **144 ms** through `mode = "mmap"` including the open, against **4.8 s** for reading the whole image and then indexing it. So: - **Pass a `mask`** whenever the analysis is confined to one — which is nearly always. It is the cheapest option on both axes. - **Use `mode = "mmap"`** when the access order is scattered and unknown ahead of time. - **Stay dense** when you genuinely need every voxel of a small image in memory at once. What follows measures each backend so the choice is yours rather than a recommendation you have to take on faith. The backends differ enormously in *which* access pattern they make cheap, and picking the wrong one is worse than any of the defaults. Everything below measures the same dataset, written to an uncompressed NIfTI so that every backend can read it: ```{r data} mask <- demo_mask() brain <- as.mask(mask) bold <- demo_bold(n_time = 60) path <- tempfile(fileext = ".nii") write_vec(bold, path) dim(bold) round(file.size(path) / 1e6, 1) ``` The file is uncompressed. Every backend can read `.nii.gz` too, but two of them pay for it: memory mapping silently decompresses to a temporary uncompressed copy, and file-backed access constructs happily and then fails at the first read. Write uncompressed if you intend to map. ## What each backend costs ```{r backends} dense <- read_vec(path) sparse <- read_vec(path, mask = brain) mapped <- read_vec(path, mode = "mmap") filebacked <- read_vec(path, mode = "filebacked") mb <- function(x) round(as.numeric(object.size(x)) / 1e6, 1) data.frame( backend = c("DenseNeuroVec", "SparseNeuroVec", "MappedNeuroVec", "FileBackedNeuroVec"), memory_MB = c(mb(dense), mb(sparse), mb(mapped), mb(filebacked)) ) ``` They all hold the same data: ```{r agreement} ref <- as.numeric(series(dense, 32, 32, 12)) vapply( list(sparse = sparse, mapped = mapped, filebacked = filebacked), function(x) isTRUE(all.equal(as.numeric(series(x, 32, 32, 12)), ref)), logical(1) ) ``` ## The number that actually decides it Memory is the easy axis. The one that catches people is access *pattern*. Here is the same work — twenty whole volumes, then twenty voxel time courses — on each backend: ```{r timing} per_call <- function(f, n) unname(system.time(for (i in seq_len(n)) f(i))["elapsed"] / n) bench <- function(x, n = 20) { c( volumes = per_call(function(i) invisible(x[[i]]), n), series = per_call(function(i) series(x, 32, 32, 12), n) ) } timings <- rbind( dense = bench(dense), sparse = bench(sparse), mapped = bench(mapped), filebacked = bench(filebacked, n = 5) ) signif(timings, 2) ``` ```{r timing-ratio} round(timings["filebacked", "series"] / timings[c("dense", "sparse", "mapped"), "series"]) ``` Seconds per call, and the ratio is computed rather than quoted, so it stays honest on your machine. Fetching one voxel's time course from a `FileBackedNeuroVec` costs three to four orders of magnitude more than it costs anywhere else, because each query materialises the whole 4D array as doubles before discarding all but one row — the disk read is a small part of it. **Use it only when you genuinely work volume at a time.** `MappedNeuroVec` is cheap on both patterns and is the better default. ## The ladder **Dense** is what you get with no arguments. Predictable arithmetic, indexing and plotting, and nothing to think about — at the cost of holding every voxel as a `double`. Right for a single small image, wrong for a session. **Sparse** stores only the voxels a mask admits. Here the brain is 29% of the grid, and the object is about a third the size: ```{r sparse} c(in_mask = sum(brain), total = prod(dim(brain))) c(dense_MB = mb(dense), sparse_MB = mb(sparse)) ``` Dimensions are unchanged — sparsity is storage, not shape. Reach for it when a mask defines your analysis anyway. **Memory-mapped** leaves the data on disk and lets the operating system page it in. Pointed at a `.nii.gz` it decompresses to a temporary uncompressed copy first, so you pay the disk either way — better to store uncompressed deliberately than to have a full copy appear in `tempdir()`. ```{r mmap} class(mapped)[1] dim(mapped) ``` `as_mmap()` converts an in-memory object, writing an uncompressed file if it has to: ```{r as-mmap} mmap_path <- tempfile(fileext = ".nii") converted <- as_mmap(dense, file = mmap_path, overwrite = TRUE) class(converted)[1] isTRUE(all.equal(as.numeric(series(converted, 32, 32, 12)), ref)) ``` Give it an explicit `file` when you can. Left to itself it writes into `tempdir()` and you have a second full copy of the data with no obvious owner. **BigNeuroVec** backs a masked series with an on-disk `bigstatsr` matrix, which is the option for data larger than RAM that still needs voxelwise access: ```{r bigvec} big <- read_vec(path, mode = "bigvec", mask = brain) class(big)[1] round(mb(big), 2) ``` ### What object.size() does not see The two on-disk backends report near-zero above, and that is `object.size()` telling you the *R object* is a handle. It is not telling you the data is free: mapped pages are charged to the process as you touch them, and `BigNeuroVec` keeps a backing file — about 14 MB here — beside the original. Measure resident memory and disk when the difference matters. ## Dropping to parcels Every backend so far keeps one time course per voxel. The largest reduction available comes from deciding you do not need them: a `ClusteredNeuroVec` stores one series per *parcel*. Parcels have to be spatially contiguous for any of this to mean anything. Assigning labels at random would put all 100 parcels everywhere at once and collapse their centroids onto the same point, so cluster them by position: ```{r clustered} set.seed(1) coords_in <- index_to_grid(mask, which(as.vector(brain))) km <- kmeans(coords_in, centers = 100, iter.max = 30) parcels <- ClusteredNeuroVol(brain, km$cluster) cvec <- ClusteredNeuroVec(bold, parcels) num_clusters(cvec) dim(cvec) round(apply(centroids(cvec), 2, sd), 1) ``` Those centroid standard deviations are voxels: the parcels are spread across the brain, which is what makes centroid distances meaningful below. It still behaves like a 4D image — `dim()` is unchanged and indexing works — but only 100 time courses exist behind it: ```{r clustered-size} dim(as.matrix(cvec, by = "cluster")) c(dense_MB = mb(dense), clustered_MB = mb(cvec)) ``` ```{r clustered-access} length(series(cvec, 32, 32, 12)) dim(cvec[, , , 1]) c(voxel_series = sum(brain), stored_series = num_clusters(cvec), fold = round(sum(brain) / num_clusters(cvec))) ``` Every voxel in a parcel returns that parcel's series: roughly a 300-fold reduction in stored series here, and no within-parcel detail at all. For atlas-based connectivity that is exactly right; for anything needing voxelwise variation it is not. One sharp edge: voxels outside the mask have no parcel, and `series()` returns `NA` for them rather than zero or an error. Restrict to the mask before doing arithmetic over the whole grid. Cluster-level searchlights come with it, using centroid distances rather than voxel neighbourhoods: ```{r cluster-searchlight} windows <- cluster_searchlight_series(cvec, k = 3) length(windows) dim(values(windows[[1]])) ``` ```{r cleanup, include = FALSE} unlink(c(path, mmap_path, big@data$backingfile)) ``` ## Choosing | Situation | Backend | Why | |:--|:--|:--| | It fits in memory | `DenseNeuroVec` | simplest, fastest, no caveats | | A mask defines the analysis | `SparseNeuroVec` | stores only admitted voxels | | Large uncompressed file, mixed access | `MappedNeuroVec` | cheap on both access patterns | | Large file, strictly volume at a time | `FileBackedNeuroVec` | never for voxel series | | Larger than RAM, needs voxel access | `BigNeuroVec` | on-disk matrix | | Parcel-level analysis is enough | `ClusteredNeuroVec` | orders of magnitude fewer series | If you are unsure: read with `read_vec()`, and change only when a measurement tells you to. `object.size()` and `system.time()` will get you most of the way, as long as you remember that the first does not see mapped pages or backing files. ## Where to go next - `vignette("volumes-and-vectors")` — the containers these all implement - `vignette("regions-and-searchlights")` — parcels and searchlights in depth - `?read_vec`, `?as_mmap`, `?ClusteredNeuroVec`, `?BigNeuroVec`