One repo, one argument: computation should close over typed storage without forcing every representation through a dense R object.
Out-of-core computation is not a file format. It is the refusal to treat RAM as the boundary of an array. Rfmalloc begins with a patched fmalloc allocator behind R’s ALTREP, so a memory-mapped file can be an ordinary R vector whenever an ordinary pointer is a truthful representation. RAM then stops being a hard cutoff and becomes one fast level in a spectrum that continues through the page cache and storage.
That first move exposes its own limit. A PLINK hardcall occupies two
bits, a GGUF weight may occupy four and a half, and a phased haplotype
is not a number waiting to become a double. None of them can honestly
masquerade as a double*. The abstraction that survives is therefore
larger than mmap: a storage span carries bytes, extent, ownership and
runtime context; a codec gives those bytes numeric meaning when a
numeric algorithm asks for bounded panels; a typed accessor preserves
non-numeric meaning when it does not; and a compute backend chooses how
to consume the representation. These decisions remain independent.
The independence is enforced by a small but decisive contract. A backend may decline any product. Rfmalloc then decodes bounded panels and hands them to BLAS. Specialization may change where bytes live and how quickly a result arrives, but never whether the result exists. The abstraction was not imposed in advance. It is what remained when the same materialization problem appeared in scientific arrays, genotype formats and quantized language models.
A cells by genes matrix can live in an fmalloc mapping while R continues to see a matrix. Variance reduction and PCA stream over it without constructing a second dense payload, and their products still pass through the backend registry.
library(Rfmalloc)
rt <- open_fmalloc(tempfile(fileext = ".bin"), size_gb = 1)
set.seed(1)
factors <- matrix(rnorm(3000 * 4), 3000, 4)
loadings <- matrix(rnorm(4 * 600), 4, 600)
X <- create_fmalloc_matrix("numeric", nrow = 3000, ncol = 600, runtime = rt)
X[] <- 3 * (factors %*% loadings) + matrix(rnorm(3000 * 600), 3000, 600)
vars <- fmalloc_colVars(X)
length(which(vars > quantile(vars, 0.9)))
#> [1] 60
pca <- fmalloc_pca(X, k = 6)
round(pca$sdev, 2)
#> [1] 77.74 74.67 72.43 66.61 1.44 1.44
dim(pca$x)
#> [1] 3000 6
The more revealing case is code that knows nothing about this
repository. bigPCAcpp and bigPLSR link bigmemory, while
pcaone takes an
Eigen::Map<MatrixXd>. They consume a bare pointer. An fmalloc ALTREP
matrix is such a pointer into an mmap, so the same randomized SVD can
work over a payload larger than the R heap without being rewritten
around a new container.
library(pcaone)
rt2 <- open_fmalloc(tempfile(fileext = ".bin"), size_gb = 3)
m <- 2e6L
n <- 40L
G <- create_fmalloc_matrix("numeric", nrow = m, ncol = n, runtime = rt2)
set.seed(1)
for (j in seq_len(n)) G[, j] <- rnorm(m)
invisible(gc(reset = TRUE))
sv_mapped <- pcaone(G, k = 5)$d
peak_mapped <- gc()["Vcells", "max used"] * 8 / 2^20
invisible(gc(reset = TRUE))
sv_heap <- pcaone(matrix(G[], m, n), k = 5)$d
peak_heap <- gc()["Vcells", "max used"] * 8 / 2^20
round(c(
payload_MB = m * n * 8 / 2^20,
peak_heap_MB = peak_heap,
peak_mmap_MB = peak_mapped
))
#> payload_MB peak_heap_MB peak_mmap_MB
#> 610 697 87
identical(sv_mapped, sv_heap)
#> [1] TRUE
The payload never enters the R heap, and pcaone never has to know why.
This is the clean pointer case. Compressed storage begins exactly where
that case ends.
rt3 <- open_fmalloc(tempfile(fileext = ".bin"), size_gb = 1)
set.seed(2)
g <- matrix(sample(c(0L, 1L, 2L), 5e6, replace = TRUE), 5000, 1000)
tn <- fmalloc_bed(g, runtime = rt3)
c(
bits_per_genotype = 8 * length(unclass(tn)) / length(g),
vs_double = length(g) * 8 / length(unclass(tn))
)
#> bits_per_genotype vs_double
#> 2.000038 31.999386
Omega <- matrix(rnorm(1000 * 5), 1000, 5)
observed <- matrix((tn %*% Omega)[], 5000, 5)
reference <- matrix(as.numeric(g), 5000, 1000) %*% Omega
max(abs(observed - reference))
#> [1] 0
No pointer into the encoded payload could have given BLAS the matrix it
expects. The codec instead decodes only the panel needed by the product.
fmalloc_bed stores PLINK hardcalls at two bits and fmalloc_dosage
stores fractional dosages at one byte. Both can fuse centering, mean
imputation and scaling into the lookup that decodes each value. Missing
calls map directly to zero after centering, so standardization does not
require an intermediate genotype matrix.
Phase and multiple alleles force the next distinction. They are not
merely compressed numbers, so fmalloc_haplotypes is deliberately not a
matrix codec. It stores locus-major phased rows at one bit per haplotype
and exposes typed access for Li and Stephens or
kalis-class HMM kernels. The
physical layout follows the consumer’s traversal instead of pretending
every future algorithm wants column-major doubles. Numeric panels and
haplotype rows are two interfaces over the same storage substrate.
Rpgen closes the reader side of this argument. Its native PLINK2
import closure reads PGEN, BED, PED/MAP, TPED/TFAM, BGEN, VCF/BCF, GEN,
HAPS/legend, EIGENSTRAT and legacy dosage. Each parser emits bounded
records into the same destination context, which chooses hardcall,
dosage, phased-haplotype or dense layout. A format is parsed once; it is
not serialized through a temporary PGEN merely to be decoded again.
GGUF presents the same contradiction with different economics. Its
tensor directory describes weights whose encoded bytes already live in a
model file. Copying them into a second backing store gains nothing.
Rgguf uses GGML’s official GGUF implementation, and Rllm borrows
each tensor’s exact read-only span from the original mapping. The model
keeps quantized weights quantized; GGML consumes those blocks directly;
only the KV cache and transient graph state require new storage.
library(Rllm)
local({
backing <- tempfile(fileext = ".bin")
rt <- Rfmalloc::open_fmalloc(backing, mode = "scratch", size_gb = 2)
on.exit({
Rfmalloc::cleanup_fmalloc(rt)
unlink(backing)
}, add = TRUE)
model_path <- Sys.getenv(
"RLLM_README_GGUF",
"LFM2.5-8B-A1B-Q4_K_M.gguf"
)
model <- rllm_gguf_model(model_path, runtime = rt)
gen <- rllm_generate(
model,
charToRaw("The capital of France is"),
n_new = 16L,
runtime = rt
)
rawToChar(gen$raw)
})
The evaluated run uses LFM2.5-8B-A1B Q4_K_M, not a toy model shaped to
emit a convenient answer. Set RLLM_README_GGUF when rendering the Rmd.
If the file is absent, the chunk is not evaluated and no transcript is
invented. Knitr keys its cache by the model file’s size and modification
time.
The I/O boundary is bytes, not a hidden theory of text. The tokenizer is
an edge codec built from GGUF metadata, generation returns raw output,
and rawToChar() is the caller’s interpretation. On the recorded
SmolLM2-135M Q4_K_M run on the RTX 5050 rig, a 12-token prompt
followed by 128 greedy decode tokens reached a median 40.2 tokens per
second on CPU and 69.7 on CUDA after the one-time weight upload. These
are medians of three complete generations, not a kernel microbenchmark.
The logits are checked against a pure-R reference forward to the
tolerance imposed by the quantized weights, and cached CUDA logits are
checked against both whole-batch CUDA and CPU cache handoffs.
The real Q4_K_M probe separates cache correctness from invariance to
batch shape. For an identical prefix shape, cached and uncached logits
are bit-identical on CPU and CUDA. CPU is also invariant when a
four-token prefix is evaluated whole or one token at a time. CUDA is
not: on the structural token sequence 1, 5, 9, 2, prefix-versus-whole
cosine similarity ranges from 0.761 to 0.997 and two of four argmaxes
differ. Individual products at the model’s real q5_0, q4_k, q8_0
and q6_k shapes remain within 1.25e-4 NMSE of CPU, while CUDA
one-column and four-column products agree exactly. This rules out cache
layout and weight upload as the source of that split. The graph-level
numerical contract against upstream GGML and llama.cpp across batch
shapes is therefore an explicit frontier, not a hidden correctness
claim.
ALP is a useful counterexample to protecting a beautiful idea from a bad measurement. The decimal ALP codec is lossless and effective for analytical values with decimal structure. On a model-like 2048 by 2048 Q4_K weight, however, it expands the values to 64.2 bits each and its scalar decode plus BLAS takes 20.75 ms for batch one. Native Q4_K occupies 4.5 bits and its GGML product takes 0.30 ms. The LLM version of the lossless-compressed bet has to earn its way back through ALP-RD, SIMD decode or a fused compressed dot kernel. The failed form is evidence, not an API to preserve.
The graph description follows the same separation. GGUF metadata is first normalized into named tensor roles, shapes, operators, state and outputs. An R architecture program then expresses modules, residual branches, representation taps and structured recurrence with ordinary functions and the base pipe. Several typed inputs and several named results stay explicit instead of being smuggled through string attributes. The frozen program is data only. Rllm binds its declared parameters directly to mapped storage and compiles its validated block grammar to the GGML operator vocabulary. C does not receive a second model plan and does not need another model-family class; it needs reusable lowerings for attention, normalization, convolution, routing and whatever operators survive the next model stress test.
This language is intentionally closer to torch::nn_module() and luz’s
pipe cadence than to a C++ architecture switch. EmbeddingGemma forces
non-causal and symmetric-window attention, post-branch normalization,
pooling and projection. The complete ESM-2 8M topology forces typed
token and padding inputs, attention maps as second results, arbitrary
representation taps, tied embeddings and a contact head over those maps.
OpenSpliceAI forces residual dilated 1-D convolutions, inference batch
normalization, accumulated skip projections, context cropping and
per-position softmax. The Evo 2 7B topology forces the real 32-layer
schedule: 27 HCS, HCM and HCL cascades with different FIR or IIR state,
plus five interleaved attention layers. Tiny Recursive Models force
shared modules and nested carried state.
rllm_execute() interprets the same program’s dataflow and loops
through an explicit operator table. A dense oracle executes the TRM
z_H and z_L recurrence across its three nested symbolic loops and
agrees with direct R iteration. It is the semantic oracle for the native
bound-program lowering, not a second GGUF runtime. Official ESM-2 and
OpenSpliceAI checkpoints now cross complete dense numerical oracles
through Rgguf; their native GGML lowerings remain explicit work. Evo
still needs a checkpoint importer and numerical lowering, while TRM
still needs its full bidirectional-attention lowering. Those absences
remain named operator failures instead of becoming architecture
switches.
Rtinycc gives this separation a useful experimental path. A data-only program can generate a small C reference lowering or ABI adapter, compile it in memory and compare it with the R oracle before any operator is promoted into Rggml. TinyCC is not thereby declared a tensor compiler, and it does not replace tuned GGML kernels. It shortens the distance between a semantic operator and a falsifiable native prototype.
The storage representation does not dictate one machine. Dense products use R’s BLAS. Quantized CPU products use GGML kernels, with runtime CPUID dispatch on x86 and the mandatory NEON baseline on aarch64. The ISA-specific flags are confined to staged objects and never leak into R’s recorded package flags. Both paths operate over bounded or mapped storage rather than demanding that the entire problem enter RAM.
Vulkan is the first device backend. It is built explicitly with
configure.args = "--with-vulkan" because compiling GGML’s embedded
SPIR-V set is itself expensive. rggml_vulkan_info() reports visible
devices, while a build without Vulkan reports none and leaves callers
free to fall back. The same correctness graph can run on a real GPU or,
with GGML_VK_ALLOW_CPU=1, through Mesa’s software driver. Device
allocation, tensor upload, graph execution and download pass through
backend-neutral GGML interfaces rather than Vulkan-shaped application
code.
CUDA comes from the exact same pinned GGML source as the core. It is an
opt-in nvcc build, and Rllm’s model-owned context uploads codec-native
weights once and reuses them while the host GGUF mapping remains
authoritative. The RTX 5050 rig passes the full Rggml, Rgguf and Rllm
suites, including whole-graph CUDA, plain and fmalloc KV caches, and
CPU-to-CUDA and CUDA-to-CPU cache handoffs.
The remaining gap is measured rather than euphemized. Upstream
llama-bench on the same model and rig reaches 156.1 tokens per second
with CUDA graphs disabled and 460.1 with them enabled. This stack
reaches 69.7. Replacing each pass’s activation buffer with GGML’s
persistent backend scheduler reduced it to 63.7, while enabling graph
capture reached 62.6 because the graph and attention extent change on
every token. That machinery was removed. Closing the gap requires
reusable execution, most likely through stable attention shapes and
device-resident mutable cache state; another storage API cannot solve
it.
The package boundaries separate bets, not teams. Rfmalloc owns mapped storage, typed spans, codecs, backend dispatch, bounded panel products and eviction. Rggml is the compute and format authority: it carries the pinned GGML core, official GGUF implementation, CPU kernels, BLAS and optional device backends. Rgguf turns GGUF metadata and tensor spans into an R-facing storage layer without maintaining another parser. Rllm composes those pieces into quantized products, semantic architecture programs, persistent model state, embeddings and byte-level generation.
The statistical genetics side pushes the same contracts in directions an LLM does not. Rpgen owns the native format-reader closure and bounded record transfer. RfmallocStatgen owns streamed regression and PCA, banded LD, LDpred2 and matrix-form colocalisation. When one experiment changes a shared contract, every consumer changes in the same commit. There is no external consumer that justifies compatibility theatre while the abstraction is still being discovered.
This is also why the repository keeps experiments/ beside packages/.
Experiments are falsifiable probes of storage and compute bets; tests/
contains the cross-package consequences; tools/ records how
third-party sources and fixtures are reproduced. Synthesis is allowed to
delete a failed materialization or merge two accidental interfaces. The
monorepo makes that movement visible and testable.
Every quantized decoder is checked bit-for-bit against GGML’s own
to_float reference through fixtures in Rgguf and live cross-validation
in Rllm. That discipline caught a real Q4_K dequantization bug in the
earlier C port. The inference graphs are checked against pure-R
forwards; hermetic f32 state must equal whole-batch execution at every
position. A real quantized MoE adds a discontinuous top-k router after
batch-width-dependent kernels, so its real-file invariant is the
selected token, logit direction and upstream continuation rather than
bitwise equality across GEMV and GEMM geometries. Lossless codecs must
round-trip exactly, and every specialized backend must be free to
decline into the decode-and-BLAS reference path. Performance claims come
from measured paths, not from the presence of a backend name in a build.
The packages are available from
r-universe. Installing Rllm
pulls the composed CPU stack.
install.packages(
"Rllm",
repos = c("https://sounkou-bioinfo.r-universe.dev", getOption("repos"))
)
A package can also be installed directly from its monorepo subdirectory,
for example pak::pak("sounkou-bioinfo/Rfmalloc/packages/Rllm"). Linux,
macOS, Windows, Apple Silicon and Linux aarch64 remain part of the
ordinary test surface. Platform shims exist where the operating system
really differs, such as CreateFileMapping in Rgguf on Windows, but the
storage and compute contracts remain shared.
The vendored GGML core is generated from pinned sources by
tools/vendor-ggml/vendorggml.R; it is never edited in place. GGML,
fmalloc, pgenlib, PCAone and ALP retain their upstream licenses and
provenance in each package’s inst/COPYRIGHTS. The runtime SIMD staging
pattern is developed separately in
RsimdDispatch and
used here where it survives contact with the larger stack.
The stack is GPL (>= 2). Rpgen is GPL-3 because it combines PLINK2’s GPL-3 import closure with LGPL (>= 3) pgenlib. RfmallocStatgen is GPL-3 because its pinned PCAone workhorse is GPL-3. Vendored components keep their own MIT, BSD, LGPL or GPL terms as recorded in the package copyright files.