| Title: | Lazy Chunked Arrays as ALTREP Vectors |
|---|---|
| Description: | A prototype exploring how far base R array indexing can drive planned reads of remote, chunked, encoded arrays when the array is an ALTREP vector with a 'dim' attribute and no class. Chunks come from a user-supplied fetch function, so any store (object storage, 'Zarr', 'GDAL') can sit behind it. |
| Authors: | Michael Sumner [aut, cre] |
| Maintainer: | Michael Sumner <[email protected]> |
| License: | MIT + file LICENSE |
| Version: | 0.1.0.9000 |
| Built: | 2026-10-15 00:46:05 UTC |
| Source: | https://github.com/hypertidy/altarr |
Returns an ordinary double, integer or logical vector with a dim
attribute and no class. It is an ALTREP object: values are obtained chunk
by chunk from fetch only when base R asks for them.
altarr( dim, chunk, fetch, dimnames = NULL, type = c("double", "integer", "logical") )altarr( dim, chunk, fetch, dimnames = NULL, type = c("double", "integer", "logical") )
dim |
integer dimensions. |
chunk |
integer chunk shape (recycled to |
fetch |
a function taking an integer matrix of 0-based chunk
coordinates (one row per chunk, one column per dimension) and returning
a list of vectors, one per row, in column-major order and clipped at the
array edge. Chunks should be of the array's |
dimnames |
optional dimnames. |
type |
the array's type: |
a lazy array.
## a 10 x 7 array in 4 x 3 chunks; each value is its own position d <- c(10L, 7L); cs <- c(4L, 3L) fetch <- function(chunks) { lapply(seq_len(nrow(chunks)), function(r) { start <- chunks[r, ] * cs + 1L end <- pmin(start + cs - 1L, d) # clipped at the edge i <- start[1]:end[1]; j <- start[2]:end[2] as.vector(outer(i, (j - 1) * d[1], "+")) }) } x <- altarr(d, cs, fetch) x[c(1, 70)] x[cbind(10, 7)] altarr_stats(x)[c("fetch_calls", "chunks_fetched")] ## the same chunks as an integer array (values are coerced) xi <- altarr(d, cs, fetch, type = "integer") typeof(xi) sum(xi)## a 10 x 7 array in 4 x 3 chunks; each value is its own position d <- c(10L, 7L); cs <- c(4L, 3L) fetch <- function(chunks) { lapply(seq_len(nrow(chunks)), function(r) { start <- chunks[r, ] * cs + 1L end <- pmin(start + cs - 1L, d) # clipped at the edge i <- start[1]:end[1]; j <- start[2]:end[2] as.vector(outer(i, (j - 1) * d[1], "+")) }) } x <- altarr(d, cs, fetch) x[c(1, 70)] x[cbind(10, 7)] altarr_stats(x)[c("fetch_calls", "chunks_fetched")] ## the same chunks as an integer array (values are coerced) xi <- altarr(d, cs, fetch, type = "integer") typeof(xi) sum(xi)
What a fetch function and the store behind it must guarantee, what
altarr does on its side, and what base R does with a lazy array.
It is called as fetch(chunks), where chunks is an integer
matrix with one row per chunk and one column per dimension, holding
0-based chunk coordinates. Rows may arrive in any order.
It returns a list with one vector per row, in row order. Each vector holds that chunk's values in column-major (R) order, clipped at the array edge: an edge chunk is shorter, never padded.
Each vector should be of the array's type ("double",
"integer" or "logical"); other numeric or logical vectors are
coerced as as.double(), as.integer() or as.logical() would.
One call is one round trip. altarr asks for as many chunks per call as it can plan, so a fetch that reads its chunks concurrently (object storage, GDAL) gets the full benefit.
It runs on R's main thread. Retries, timeouts and concurrency belong inside it, and any code it runs on other threads must not call the R API.
An error in fetch is an R error raised from wherever the read
happened: inside [, sum(), mean() and so on.
R assumes a vector's values never change. altarr re-fetches chunks after
they are evicted from the cache, and a saved recipe re-reads its source
when loaded later. So the source behind fetch must be immutable: a
snapshot (an Icechunk snapshot id, a versioned object), a fixed set of
chunk references (kerchunk, blocklist), or content-addressed chunks. A
mutable store can silently return different values for the same element
within one session.
saveRDS() writes the recipe (dim, chunk shape, type and the fetch
function), never the values. The fetch function travels with its
environment, which brings two traps:
A fetch defined at top level refers to global variables, and the global environment is saved as a reference, not its contents. The recipe loads, but reading fails in a new session with "object not found".
A fetch built inside a function carries that function's environment. Arguments are promises until used, so an unforced argument still points back at the caller's variable.
Build fetch functions in a small factory that force()s what it needs:
a path or URL and a few parameters, not data. See the example.
Measured on R 4.3 with materialization refused, so every attempt shows:
x[i] (including
logical and negative subscripts), x[cbind(i, j, k)],
altarr_extract(), sum(), min(), max(), mean(), prod(),
anyNA() (which stops at the first NA), which() on logical
arrays, and str().
x[i, j, k] (until R offers an array-subset hook), x[[i]],
head() on an array, and is.na(), so also the is.na() pass in
mean(na.rm = TRUE), summary() and quantile().
printing, arithmetic and
comparison (x + 1, x > 0), maths functions, range(), c(),
aperm(), apply(), colSums() and relatives, and assignment
(x[1] <- v). Refused above getOption("altarr.max_materialize")
values.
dim(), length(), dimnames<-, identical(),
saveRDS(), copying (y <- x), and as.vector(), which stays
lazy.
altarr.cache_byteschunk cache budget per array, in bytes (default 256 MiB). Least recently used chunks are evicted first; a copy shares its original's cache.
altarr.batch_chunkschunks per fetch call for whole-array passes: reductions, scans and materialization (default 64).
altarr.max_materializethe most values a whole-array operation may materialize (default 1e6). It decides only whether materializing is allowed; the operation triggers it, not the size.
Lazy reads, not lazy computation: arithmetic materializes.
x[i, j, k] reads element by element until R's ArraySubset
offers its subscripts to ALTREP; altarr_extract() shows what that
would give.
Dimension names must be character, as for any R array.
## a fetch factory: the recipe carries a path and parameters, not data make_fetch <- function(path, dim, chunk) { force(path); force(dim); force(chunk) function(chunks) { lapply(seq_len(nrow(chunks)), function(r) { start <- chunks[r, ] * chunk + 1L n <- pmin(chunk, dim - start + 1L) # clipped at the edge rep(nchar(path), prod(n)) # stand-in for reading 'path' }) } } d <- c(40L, 30L); cs <- c(16L, 16L) x <- altarr(d, cs, make_fetch("s3://bucket/array.zarr", d, cs)) x[cbind(c(1, 40), c(1, 30))] f <- tempfile(fileext = ".rds") saveRDS(x, f) file.size(f) # a few hundred bytes## a fetch factory: the recipe carries a path and parameters, not data make_fetch <- function(path, dim, chunk) { force(path); force(dim); force(chunk) function(chunks) { lapply(seq_len(nrow(chunks)), function(r) { start <- chunks[r, ] * chunk + 1L n <- pmin(chunk, dim - start + 1L) # clipped at the edge rep(nchar(path), prod(n)) # stand-in for reading 'path' }) } } d <- c(40L, 30L); cs <- c(16L, 16L) x <- altarr(d, cs, make_fetch("s3://bucket/array.zarr", d, cs)) x[cbind(c(1, 40), c(1, 30))] f <- tempfile(fileext = ".rds") saveRDS(x, f) file.size(f) # a few hundred bytes
Writes a zlib-compressed, C-order Zarr v2 array of doubles into dir
and returns the path, ready for altarr_zarr_v2(). The store is made on
demand rather than shipped, because Zarr v2 metadata files (.zarray)
are hidden files, which R packages should not carry.
altarr_example_zarr( dir = tempfile("altarr-example-", fileext = ".zarr"), dim = c(70L, 50L, 30L), chunk = c(20L, 16L, 7L) )altarr_example_zarr( dir = tempfile("altarr-example-", fileext = ".zarr"), dim = c(70L, 50L, 30L), chunk = c(20L, 16L, 7L) )
dir |
directory to write the store into (created if needed). |
dim |
R dimensions of the array (the Zarr shape is the reverse). |
chunk |
R chunk shape (the Zarr chunk shape is the reverse). Edge chunks are padded in the store, as Zarr requires. |
Every value is its own 0-based position in R's column-major order, so a
result can be checked by eye: in the array returned by
altarr_zarr_v2(), element x[i, j, k] is
(i - 1) + dim[1] * (j - 1) + dim[1] * dim[2] * (k - 1), and x[n] is
n - 1.
the store path, invisibly.
path <- altarr_example_zarr() x <- altarr_zarr_v2(path) dim(x) x[c(1, 2, 71)] # 0, 1, 70path <- altarr_example_zarr() x <- altarr_zarr_v2(path) dim(x) x[c(1, 2, 71)] # 0, 1, 70
Same semantics as x[i, j, k, drop = drop], but the per-dimension
subscripts are handed to the ALTREP object whole: the touched chunks are
planned as a cartesian product and fetched in one call. This is what
base R's ArraySubset could do if it offered its normalized subscripts to
an ALTREP method.
altarr_extract(x, ..., drop = TRUE)altarr_extract(x, ..., drop = TRUE)
x |
an altarr array |
... |
one subscript per dimension (missing means all) |
drop |
as for |
an ordinary (not lazy) array or vector.
x <- altarr_zarr_v2(altarr_example_zarr()) r <- altarr_extract(x, 1:40, 1:20, 1:3) dim(r) identical(r, x[1:40, 1:20, 1:3]) altarr_extract(x, 1, 1:3, 1) # dropped to a vector, as `[` wouldx <- altarr_zarr_v2(altarr_example_zarr()) r <- altarr_extract(x, 1:40, 1:20, 1:3) dim(r) identical(r, x[1:40, 1:20, 1:3]) altarr_extract(x, 1, 1:3, 1) # dropped to a vector, as `[` would
One row per chunk that x[i, j, k] would touch: 0-based chunk
coordinates, plus each chunk's 1-based start and its extent along every
dimension. The plan is a table; the array is what assembling it gives.
altarr_plan(x, ...)altarr_plan(x, ...)
x |
an altarr array |
... |
one subscript per dimension (missing means all) |
a data frame, one row per chunk.
x <- altarr_zarr_v2(altarr_example_zarr()) altarr_plan(x, 1:40, 1:20, 1:3) altarr_plan(x, 70, , 1) # a missing subscript means the whole dimensionx <- altarr_zarr_v2(altarr_example_zarr()) altarr_plan(x, 1:40, 1:20, 1:3) altarr_plan(x, 70, , 1) # a missing subscript means the whole dimension
Reset counters, and optionally drop the chunk cache
altarr_reset(x, cache = TRUE)altarr_reset(x, cache = TRUE)
x |
an altarr array |
cache |
drop cached chunks too? |
x, invisibly.
x <- altarr_zarr_v2(altarr_example_zarr()) x[1:5] x[1:5] # the second read comes from the cache altarr_stats(x)[c("extract_subset", "fetch_calls", "chunks_cached")] altarr_reset(x) altarr_stats(x)[c("fetch_calls", "chunks_cached")]x <- altarr_zarr_v2(altarr_example_zarr()) x[1:5] x[1:5] # the second read comes from the cache altarr_stats(x)[c("extract_subset", "fetch_calls", "chunks_cached")] altarr_reset(x) altarr_stats(x)[c("fetch_calls", "chunks_cached")]
elt counts per-element reads (the x[i, j, k] path), extract_subset
counts planned subset calls (x[i], x[cbind(...)]), hyperslab counts
altarr_extract() calls, fetch_calls counts calls to the fetch function
(round trips) and chunks_fetched the chunks they returned. reduce
counts whole-array sum(), min() and max() calls, which stream the
chunk grid in batches of getOption("altarr.batch_chunks", 64) chunks
(one fetch call per batch) without caching what they read.
region counts contiguous-run reads from functions R iterates by region
(mean(), prod(), anyNA(), and reductions on R's wrapper class).
evictions counts chunks dropped from the cache to keep it within
its budget. Also reported: cache_bytes (bytes currently cached) and
cache_budget (getOption("altarr.cache_bytes"), default 256 MiB).
The cache is least recently used first, and shared by an array and
its copies.
altarr_stats(x)altarr_stats(x)
x |
an altarr array |
a named numeric vector of counters and cache state.
x <- altarr_zarr_v2(altarr_example_zarr()) x[1:5] x[1:5] # the second read comes from the cache altarr_stats(x)[c("extract_subset", "fetch_calls", "chunks_cached")] altarr_reset(x) altarr_stats(x)[c("fetch_calls", "chunks_cached")]x <- altarr_zarr_v2(altarr_example_zarr()) x[1:5] x[1:5] # the second read comes from the cache altarr_stats(x)[c("extract_subset", "fetch_calls", "chunks_cached")] altarr_reset(x) altarr_stats(x)[c("fetch_calls", "chunks_cached")]
A deliberately small reader, to show that a real chunked, compressed
store fits behind the fetch contract in a few lines. Handles Zarr v2
arrays with dtype <f8, <f4, <i4, <i2, |i1, <u2, |u1 or
|b1, compressor null or zlib, order C or F, and missing chunk
files (read as fill_value). Zarr's padded edge chunks are clipped.
This is a demonstration, not a Zarr implementation: no Zarr v3, no
remote stores, no other codecs. For real stores, use a full Zarr reader
and hand its chunk reads to altarr() as the fetch function.
altarr_zarr_v2(path, unpack = TRUE)altarr_zarr_v2(path, unpack = TRUE)
path |
directory of the Zarr v2 array (containing |
unpack |
apply CF |
The array's type follows the dtype: floats give a double array, integers
an integer array, |b1 a logical array. With unpack = TRUE, CF
packing attributes found in .zattrs are applied inside the fetch:
values equal to _FillValue or missing_value become NA, and if
scale_factor or add_offset is present the array is double, holding
the unpacked values. The Zarr fill_value itself is not treated as
missing.
A C-order Zarr array of shape (s1, ..., sn) is returned with R dim
c(sn, ..., s1): C-order bytes are already column-major for the
reversed shape, so no transposition is ever done.
a lazy array, see altarr()
path <- altarr_example_zarr() x <- altarr_zarr_v2(path) dim(x) # the Zarr shape, reversed x[cbind(70, 50, 30)] str(x)path <- altarr_example_zarr() x <- altarr_zarr_v2(path) dim(x) # the Zarr shape, reversed x[cbind(70, 50, 30)] str(x)
Is this a (still lazy-capable) altarr array?
is_altarr(x)is_altarr(x)
x |
object |
TRUE if x is an altarr array, or R's wrapper around one.
x <- altarr_zarr_v2(altarr_example_zarr()) is_altarr(x) is_altarr(x[1:3]) # a subset is an ordinary vector is_altarr(array(1:4, c(2, 2)))x <- altarr_zarr_v2(altarr_example_zarr()) is_altarr(x) is_altarr(x[1:3]) # a subset is an ordinary vector is_altarr(array(1:4, c(2, 2)))