Skip to content

Latest commit

 

History

History
211 lines (151 loc) · 6.54 KB

File metadata and controls

211 lines (151 loc) · 6.54 KB

mori mori website

CRAN status R-CMD-check Codecov test coverage

Ask DeepWiki

Shared Memory for R Objects

share() writes an R object into shared memory and returns a shared version

→ Compact ALTREP serialization — shared objects move through serialize() and mirai() as small references

→ Lazy access and automatic cleanup — data is read on demand and freed by R’s garbage collector

→ OS-level shared memory (POSIX / Win32) — pure C, no external dependencies


Installation

install.packages("mori")

Why mori

Diagram showing share() writing an object once into OS-backed shared memory, which is then memory-mapped by other processes using zero-copy ALTREP wrappers

Parallel computing multiplies memory. When 8 workers each need the same 200 MB dataset, that is 1.6 GB of serialization, transfer, and deserialization. RAM holds 8 separate copies.

share() writes the data into shared memory once. Each worker then maps the same physical pages. Per-worker copies become per-worker references.

library(mori)
library(mirai)
library(lobstr)

daemons(8)

# 200 MB data frame — 5 columns × 5M rows
df <- as.data.frame(matrix(rnorm(25e6), ncol = 5))
shared_df <- share(df)

Without mori, each worker holds the full data frame. With mori, each worker holds a small reference into the shared memory region:

mirai_map(1:8, \(i, data) format(lobstr::obj_size(data)),
          .args = list(data = df))[.flat] |> unique()
#> [1] "200.00 MB"

mirai_map(1:8, \(i, data) format(lobstr::obj_size(data)),
          .args = list(data = shared_df))[.flat] |> unique()
#> [1] "824 B"

The workers also skip 8 × 200 MB of serialization and deserialization, which gives a significant runtime saving:

boot_mean <- \(i, data) colMeans(data[sample(nrow(data), replace = TRUE), ])

# Without mori — each daemon deserializes a full copy
mirai_map(1:8, boot_mean, .args = list(data = df))[] |> system.time()
#>    user  system elapsed 
#>   0.672  13.222   8.483

# With mori — each daemon maps the same shared memory
mirai_map(1:8, boot_mean, .args = list(data = shared_df))[] |> system.time()
#>    user  system elapsed 
#>   0.002   0.004   4.736

daemons(0)

Usage

Workers must run on the same machine, because mori shares physical RAM.

Sharing by name

shared_name() returns the shared memory name of a shared object. map_shared() opens a region by this name. This passes a reference between processes without serialization:

x <- share(rnorm(1e6))

shared_name(x)
#> [1] "/mori_1574_9c6fbbc4"
# Another process can map the region by name
y <- map_shared(shared_name(x))
identical(x[], y[])
#> [1] TRUE

Sharing through serialization

The ALTREP serialization hooks emit the same identifier on the wire. The serialized form is a few bytes, regardless of the data size:

length(serialize(x, NULL))
#> [1] 131

This is transparent to any R serialization pathway. mirai, parallel, callr, and base R serialize() all carry shared objects as references, not copies.

Sub-elements of a shared list serialize as references too. In this case, each element travels as a path into the parent shared region, not as the full data:

daemons(3)

# Share a list — all 3 vectors in a single shared region
lst <- share(list(a = rnorm(1e6), b = rnorm(1e6), c = rnorm(1e6)))

# Each element arrives on the worker as a zero-copy reference
mirai_map(lst, \(v) format(lobstr::obj_size(v)))[.flat] |> unique()
#> [1] "904 B"

daemons(0)

How It Works

What gets shared

share() writes all atomic vector types, lists, and data frames directly into shared memory. Attributes are preserved end-to-end. Pairlists become lists. The returned ALTREP wrappers point into the shared memory region. There is no deserialization and no per-process memory allocation.

share() returns all other R objects (environments, closures, language objects) unchanged. It creates no shared memory region for them.

Lazy access

A data frame uses a single shared region. Workers read columns on demand. A worker that needs 3 of 100 columns loads only 3. Character strings load on demand, one element at a time.

df <- share(as.data.frame(matrix(rnorm(1e7), ncol = 100)))
shared_name(df)        # one region for all 100 columns
#> [1] "/mori_1574_9c6fbbc6"
shared_name(df[[50]])  # sub-path into the same region
#> [1] "/mori_1574_9c6fbbc6[50]"

Lifetime

R’s garbage collector manages the shared memory. A region stays alive while R holds a reference to any object backed by it. The reference can be the original from share(), or a column or sub-list extracted from it. This reference can be in the original or another process. When no references remain, or when the session exits cleanly, R frees the shared memory automatically.

Important: Make sure that R does not garbage-collect the return value of share() before a consumer maps its shared memory.

If a process dies before cleanup runs (a crash, SIGKILL, or the OOM killer), its region can be left behind. prune_shared() reclaims these orphans. It removes only regions whose creating process is no longer running.

Copy-on-write

Consumers map the shared data read-only. This prevents corruption of the shared region. Changes are always local. Copy-on-write makes sure that other processes continue to read the original shared data:

  • Structural changes to a list or data frame (add, remove, or reorder elements) produce a regular R list. The shared region stays unchanged.
  • Modifying values of a shared vector (for example, X[1] <- 0) materializes only that vector into a private copy. Other vectors in the same shared region stay zero-copy.

The mori project is released with a Contributor Code of Conduct. When you contribute to this project, you agree to obey its terms.