A shared cache is only as good as its notion of “the same
computation”. This vignette shows what tarshare puts into a computation
key, what it cannot see, and how to declare the rest. Every example
prints computing when the command really runs, so a missing
line means a hit.
library(targets)
library(tarshare)
root <- tempfile("tarshare-identity-")
dir.create(root)
cache_path <- file.path(root, "cache")
cache <- shared_cache(cache_path)What is in the key
The key is a SHA-256 of:
- the command’s code as an abstract syntax tree, with source references stripped;
- the values of the variables and the code of the functions that the
command refers to, found by static analysis with
codetoolsand followed recursively through your own functions; - the versions of the packages involved: the namespaces of functions
the command uses,
pkg::funreferences, their hard dependencies, and anything named inpackages; - the
format, the declaredcontextand theseed; - the R version and platform, and the tarshare version.
Not in the key: the target name, the store, the working directory, the time, and the retention owner.
Formatting does not matter, symbols do
x <- c(3, 5, 8, 13)
summarise <- function(v) { cat("computing\n"); c(mean(v), stats::sd(v)) }
shared_run(summarise(x), cache)
#> tarshare compute bc09afc16a95
#> computing
#> [1] 7.250000 4.349329Rewrite the helper with different layout and a comment. The abstract syntax tree is unchanged, so the second call is a hit:
summarise <- function(v) {
# The same code, laid out differently.
cat("computing\n")
c(mean(v), stats::sd(v))
}
shared_run(summarise(x), cache)
#> tarshare hit bc09afc16a95
#> [1] 7.250000 4.349329Refer to the same data through a different name and the command’s code is different, so the key is different:
y <- x
shared_run(summarise(y), cache)
#> tarshare compute 6b13b5032c4b
#> computing
#> [1] 7.250000 4.349329tarshare compares code, not meaning. It does not try to prove that two different expressions compute the same thing.
Referenced values and helpers are followed
Change one input value and the key changes:
x[1] <- 4
shared_run(summarise(x), cache)
#> tarshare compute 317f5df17c82
#> computing
#> [1] 7.500000 4.041452Helpers called by the helper are followed too.
summarise() has not changed here, but a function it calls
has:
centre <- function(v) mean(v)
summarise <- function(v) { cat("computing\n"); c(centre(v), stats::sd(v)) }
shared_run(summarise(x), cache)
#> tarshare compute 97298bfdf1ca
#> computing
#> [1] 7.500000 4.041452
centre <- function(v) stats::median(v)
shared_run(summarise(x), cache)
#> tarshare compute f56ee1449f32
#> computing
#> [1] 6.500000 4.041452Packages
A function from an installed package is identified by its package and
version rather than by its code, and the same goes for
pkg::fun references. If your command chooses a package at
run time, for example through a method registered by another package,
name it in packages so its version joins the key.
shared_context() does the same for method providers.
Random numbers
Random draws inside a shared command come from a stream seeded from the key, so a hit returns exactly the draws that were stored, and two sessions that compute the same key draw the same numbers:
shared_run(stats::rnorm(3), cache)
#> tarshare compute dfcc083d350e
#> [1] -0.3424294 -0.4487965 0.1880137
shared_run(stats::rnorm(3), cache)
#> tarshare hit dfcc083d350e
#> [1] -0.3424294 -0.4487965 0.1880137seed is part of the key. Use it to ask for an
independent replicate of an otherwise identical computation:
shared_run(stats::rnorm(3), cache, seed = 1)
#> tarshare compute 3c67c29f24cc
#> [1] 0.8164275 1.1001383 0.9863871The caller’s random number generator is left as it was:
What the key cannot see
Static analysis records the code that reads an option, but not the option’s value. The second call below is a hit, and the result is stale:
options(analysis.scale = 2)
scale_values <- function(v) { cat("computing\n"); v * getOption("analysis.scale") }
shared_run(scale_values(x), cache)
#> tarshare compute 520446500bc7
#> computing
#> [1] 8 10 16 26
options(analysis.scale = 10)
shared_run(scale_values(x), cache)
#> tarshare hit 520446500bc7
#> [1] 8 10 16 26The same applies to environment variables, the locale, executables found on the path, files opened by name, network and database state, and S3 or S4 methods chosen by dispatch.
Pass values in, or declare them
The simplest fix is to resolve the state outside the command and pass it in as a value. A referenced variable is part of the key:
scale_by <- function(v, scale) { cat("computing\n"); v * scale }
scale <- getOption("analysis.scale")
shared_run(scale_by(x, scale), cache)
#> tarshare compute 8924014b317d
#> computing
#> [1] 40 50 80 130When the state has to stay inside the command, declare it in
context. shared_context() snapshots the
option’s value, and the snapshot is part of the key:
shared_run(scale_values(x), cache, context = shared_context(options = "analysis.scale"))
#> tarshare compute d9bed6633290
#> computing
#> [1] 40 50 80 130shared_context() returns a plain list that you can
inspect. Besides options it takes named immutable values, file contents,
non-secret environment variables, provider packages and the locale:
model_file <- file.path(root, "model.rds")
saveRDS(list(coefficients = c(1.5, -0.2)), model_file)
Sys.setenv(ANALYSIS_VARIANT = "reference")
execution <- shared_context(
image = "sha256:3f1a...",
files = list(model = model_file),
options = "analysis.scale",
env = "ANALYSIS_VARIANT",
locale = TRUE
)
str(execution, max.level = 2)
#> List of 6
#> $ values :List of 1
#> ..$ image: chr "sha256:3f1a..."
#> $ files :List of 1
#> ..$ model: 'tarshare_file' chr "/tmp/RtmpOAoBKK/tarshare-identity-1c5b2e080669/model.rds"
#> .. ..- attr(*, "tarshare_id")= chr "d4cc48a6cd9dd88abbe96935aa2085d8bbffc098877c6ec5c2b63980d9a54d68"
#> $ options :List of 1
#> ..$ analysis.scale: num 10
#> $ env :List of 1
#> ..$ ANALYSIS_VARIANT: chr "reference"
#> $ packages: list()
#> $ locale : chr "LC_CTYPE=C.UTF-8;LC_NUMERIC=C;LC_TIME=C.UTF-8;LC_COLLATE=C.UTF-8;LC_MONETARY=C.UTF-8;LC_MESSAGES=C.UTF-8;LC_PAP"| __truncated__A declaration is an assertion by its owner. A version label such as
"model 2.1" does not identify a replaced file, so declare
the contents or an immutable id instead. Never put credentials in
context: targets writes the generated command into its metadata.
Files
A plain path is just a string. The file behind it can change without changing the key:
path <- file.path(root, "measurements.txt")
writeLines(c("3", "5", "8", "13"), path)
read_mean <- function(p) { cat("computing\n"); mean(scan(p, quiet = TRUE)) }
shared_run(read_mean(path), cache)
#> tarshare compute a249e13d28f4
#> computing
#> [1] 7.25
writeLines(c("30", "50"), path)
shared_run(read_mean(path), cache)
#> tarshare hit a249e13d28f4
#> [1] 7.25shared_file() wraps a path in a descriptor whose
identity is the SHA-256 of the contents. It still works as a path:
input <- shared_file(path)
shared_run(read_mean(input), cache)
#> tarshare compute 766619bd9b9f
#> computing
#> [1] 40Replace the contents and the key changes:
writeLines(c("1", "2"), path)
input <- shared_file(path)
shared_run(read_mean(input), cache)
#> tarshare compute 431e53723fd7
#> computing
#> [1] 1.5Move the file and the key does not change, because the contents did not:
moved <- file.path(root, "moved.txt")
invisible(file.rename(path, moved))
input <- shared_file(moved)
shared_run(read_mean(input), cache)
#> tarshare hit 431e53723fd7
#> [1] 1.5If a trusted hash or an immutable version id already exists, pass it
as id and the file is not read. Directories always need an
explicit id. See vignette("files") for descriptors with
several paths.
Context in a pipeline
In tar_shared(), context is evaluated once
while _targets.R runs to build the graph. tarshare embeds a
fingerprint of it in the generated command, so a change in declared
context invalidates the local target in the ordinary targets way, and
the shared key changes with it. Declare ambient state there:
tar_shared(
scaled, scale_values(values), cache,
context = shared_context(options = "analysis.scale")
)Values that come from other targets are different: they do not exist
yet when the graph is built. Put them in branch_context,
which stays an expression that targets can see. The pipeline below draws
two numbers per replicate. The replicate id is in the branch context, so
each branch has its own key and its own draws:
script <- file.path(root, "_targets.R")
tar_helper(script, {
library(targets)
library(tarshare)
cache <- shared_cache(!!cache_path)
list(
tar_target(replicate, 1:3),
tar_shared(
draws, stats::rnorm(2), cache,
pattern = map(replicate),
branch_context = list(replicate = replicate)
)
)
})
tar_make(script = script, store = file.path(root, "store-1"))
#> + replicate dispatched
#> ✔ replicate completed [0ms, 99 B]
#> + draws declared [3 branches]
#> tarshare compute f2c3dfb4c1e7
#> tarshare compute ce36c015699a
#> tarshare compute 91b98182afbb
#> ✔ draws completed [154ms, 191 B]
#> ✔ ended pipeline [305ms, 4 completed, 0 skipped]
tar_read(draws, store = file.path(root, "store-1"))
#> draws_b9f9cd8c0e23532e_1 draws_b9f9cd8c0e23532e_2 draws_3070b9ca6a0befd9_1
#> 2.1954871 -0.7177479 1.7308572
#> draws_3070b9ca6a0befd9_2 draws_4a4ad12a5814925f_1 draws_4a4ad12a5814925f_2
#> 0.2540403 1.5230591 1.2043800A second store runs the same pipeline and hits all three branches:
tar_make(script = script, store = file.path(root, "store-2"))
#> + replicate dispatched
#> ✔ replicate completed [0ms, 99 B]
#> + draws declared [3 branches]
#> tarshare hit f2c3dfb4c1e7
#> tarshare hit ce36c015699a
#> tarshare hit 91b98182afbb
#> ✔ draws completed [151ms, 191 B]
#> ✔ ended pipeline [303ms, 4 completed, 0 skipped]Without branch_context, the three branches would share
one key and one set of draws, because nothing else distinguishes them.
The same is true of replicates that differ only in a loop index: give
them distinct branch context or distinct seeds.
tar_shared_raw() is the programmatic form. It takes a
resolved value for context and a quoted expression for
branch_context.