This vignette covers running
Rust as targets steps with rextendr / extendr. A Rust step is deliberately
different from a Python or Julia one: there is no live
interpreter. Instead the #[extendr] functions in
your script are compiled into a dynamic library and exposed as
ordinary R functions, which an R post-script then
calls. For a gentler tour start with
vignette("get_started").
Code blocks are illustrative and not executed when the vignette builds (compiling Rust needs a toolchain).
tar_target_rs()?Often not, and it is worth being honest about when it earns its keep.
Unlike Python and Julia, a Rust step has no live interpreter and no data
hand-off to abstract away: rextendr::rust_source() compiles
your #[extendr] functions and hands them back as ordinary R
functions. For the simple case you can call it directly inside a plain
tar_target(), keeping every native targets
convenience (automatic dependency detection, inline R, a single
file):
rs/scale.rs: one #[extendr] function:
#[extendr]
fn zscore(x: Vec<f64>) -> Vec<f64> {
let n = x.len() as f64;
let mean = x.iter().sum::<f64>() / n;
let sd = (x.iter().map(|v| (v - mean).powi(2)).sum::<f64>() / n).sqrt();
x.iter().map(|v| (v - mean) / sd).collect()
}library(targets)
list(
tar_target(petal_length, iris$Petal.Length),
tar_target(petal_z, {
rextendr::rust_source("rs/scale.rs") # compiles zscore() and puts it in scope
zscore(petal_length) # petal_length is auto-detected as a dependency
})
)This is the recommended starting point when your Rust toolchain is
already discoverable, which is the usual situation for local and
interactive runs. tar_target_rs() is a convenience wrapper
that becomes worth it when you want one or more of the following:
crew worker
processes. tar_target_rs() puts R,
cargo, and (on Windows) Rtools on PATH and
sets R_HOME for the compilation; a plain
tar_target() does not, so a Rust build in a fresh worker
often fails to find the toolchain unless you arrange that yourself.toolchain =, which sets RUSTUP_TOOLCHAIN for
that build only.tar_target_raw() argument set bundled in one call
(output = "file", tar_target_path(),
pattern, resources, cue, and so
on).tar_target_py() and
tar_target_jl(), so a polyglot pipeline reads the same way
across all three languages.If none of those apply, the plain tar_target() above is
simpler and gives up nothing. The rest of this vignette documents
tar_target_rs() for when they do.
tar_target_rs(): bare name (and unquoted
pattern), for direct use in _targets.R.tar_target_rs_raw(): string name, for use
inside targets factories.Both forward every targets::tar_target_raw() argument
and return a single targets target, except with a
tarpolyglot_*() pattern helper (see “Compile the Rust code
only once across branches”), where they return a compile target plus the
branched target.
These constructors depend on the rextendr R package. As a result, any limitation of rextendr is inherited by the constructors. Reading rextendr’s own documentation is recommended.
inputs are bound directly in the
post-script, where you call the compiled functions with
them.inputs). Its last expression is the
target value (object mode); it returns file paths (file mode).#[extendr] functions as you
like. A single script may define multiple functions; all of
them become R functions in scope in the post-script.output = "file".| Argument | Meaning |
|---|---|
script |
Path to the Rust file with #[extendr] functions
(required). |
post_script |
R script run after compilation; the compiled functions and
inputs are in scope. Required for object mode. |
inputs |
Named vector mapping in-step names to upstream targets,
e.g. c(x = "value"). |
output |
"object" (default) or "file". |
files |
Paths to return when there is no post-script (file mode). |
dependencies |
Crate dependencies as a named list, passed to
rextendr::rust_source(). |
features |
Cargo features, passed to rextendr::rust_source(). |
profile |
Build profile (e.g. "dev" or
"release"). |
toolchain |
Optional rustup toolchain
(e.g. "stable-x86_64-pc-windows-gnu"); sets
RUSTUP_TOOLCHAIN for the build. |
As with the other languages, script and
post_script may be a literal path or a
tar_target_path("name") reference to track the file.
Install the Rust toolchain with rustup. On Windows, use the GNU toolchain so it matches R’s mingw ABI:
Install rextendr and check it works:
run_rs_step() puts R, cargo, and (on
Windows) Rtools on PATH for the build itself, so a Rust
step usually works even in a bare crew worker, but the
toolchain must be installed.
R/scale_post.R: the compiled zscore() and
the input petal are in scope:
# `petal` is bound from inputs = c(petal = "petal_length").
scaled <- zscore(petal)
data.frame(petal = petal, z = scaled) # last expression = target value_targets.R:
rs/write.rs: writes a value to disk, the path itself is
built in the post-script:
use std::fs::File;
use std::io::Write;
#[extendr]
fn write_value(x: f64, path: String) {
let mut f = File::create(path).unwrap();
write!(f, "{}", x).unwrap();
}R/rs_files_post.R: builds the path, calls the compiled
function, then returns the path (file mode needs the post-script’s last
expression to be the path(s), not the function’s own return value):
Pass extra crates through dependencies (a named list),
and build options through features / profile.
rs/parse.rs:
#[extendr]
fn parse_sum(txt: String) -> f64 {
let v: serde_json::Value = serde_json::from_str(&txt).unwrap();
v["numbers"].as_array().unwrap().iter()
.map(|n| n.as_f64().unwrap())
.sum()
}R/parse_post.R:
Like the other constructors, inputs are real
dependencies, so pattern branches them (and
crew spreads the branches):
list(
tar_target(groups, split(iris$Petal.Length, iris$Species), iteration = "list"),
tar_target_rs(
name = z_by_group,
script = "rs/scale.rs",
inputs = c(petal = "groups"),
post_script = "R/scale_post.R",
pattern = map(groups),
iteration = "list"
)
)Plain map(groups) here recompiles the crate in
every branch: a Rust step has no live interpreter to
reuse, so each branch runs cargo from scratch. With many
branches that repeated compilation is the dominant cost.
tarpolyglot provides dynamic-branching pattern helpers
that mirror the targets patterns but compile the Rust crate
a single time and reuse it across all branches. Each is
named after its targets equivalent:
| tarpolyglot helper | targets pattern |
|---|---|
tarpolyglot_map() |
map() |
tarpolyglot_cross() |
cross() |
tarpolyglot_slice() |
slice() |
tarpolyglot_head() |
head() |
tarpolyglot_tail() |
tail() |
tarpolyglot_sample() |
sample() |
Use one exactly where you would use the plain pattern:
list(
tar_target(groups, split(iris$Petal.Length, iris$Species), iteration = "list"),
tar_target_rs(
name = z_by_group,
script = "rs/scale.rs",
inputs = c(petal = "groups"),
post_script = "R/scale_post.R",
pattern = tarpolyglot_map(groups), # was map(groups): now compiles once
iteration = "list"
)
)On a Rust step, tarpolyglot_map() expands that single
constructor call into two targets:
z_by_group_rust_lib: compiles the crate once (this
target is not branched). Its value is the compiled library, embedded so
any crew worker can reload it without a toolchain.z_by_group: branches over groups as
before, but each branch reloads the pre-compiled library (a
dyn.load(), in milliseconds) instead of recompiling.You still write one tar_target_rs(); the companion
<name>_rust_lib target appears automatically in the
pipeline (visible in tar_visnetwork()), and
targets recompiles it only when the Rust source changes. So
pattern = tarpolyglot_map(x) turns N x compile
into 1 x compile plus N near-instant
reloads.
The other pattern helpers behave the same way, differing only in
which branches targets builds
(tarpolyglot_cross() for all combinations of several
inputs, tarpolyglot_head() /
tarpolyglot_tail() / tarpolyglot_slice() /
tarpolyglot_sample() for a subset). The plain
targets patterns (map(), cross(),
and so on) keep working unchanged; the tarpolyglot_*
helpers are simply the opt-in that adds compile-once on Rust.
The same helpers are accepted by tar_target_py() and
tar_target_jl(), where they are rewritten to the plain
targets pattern: Python and Julia reuse a live interpreter,
so there is nothing to compile and nothing to reuse.
tarpolyglot_map() on a Python step is therefore exactly
map(). This lets one pipeline branch every language with
the same helper. The four files involved:
py/sq.py:
R/push.R (pre-script for the Python step,
to_py hands x over):
rs/square.rs:
R/post.R (post-script for the Rust step):
list(
tar_target(vals, c(10, 20, 30)),
# Python: behaves exactly as map(vals).
tar_target_py(
name = py_b, script = "py/sq.py", inputs = c(x = "vals"),
pre_script = "R/push.R", retrieve = "result",
pattern = tarpolyglot_map(vals)
),
# Rust: compiles once in py_b's sibling rs_b_rust_lib, reuses across branches.
tar_target_rs(
name = rs_b, script = "rs/square.rs", inputs = c(x = "vals"),
post_script = "R/post.R",
pattern = tarpolyglot_map(vals)
)
)The tarpolyglot_* helpers are recognised only inside the
tarpolyglot constructors, which rewrite them before handing the pattern
to targets. Used directly in a plain
targets::tar_target() or
targets::tar_target_raw() they raise an
error
(invalid dynamic branching pattern ... Illegal symbols found: tarpolyglot_map),
because targets validates a pattern against its own fixed
set of pattern functions and does not know these helpers. This cannot be
corrected from tarpolyglot: targets looks its pattern
functions up in a locked internal environment, so teaching it a new one
would require modifying the targets package itself. In a
plain targets target (which has no foreign code to compile,
so nothing to gain) use the native map() /
cross() / … directly.
A pipeline can contain as many tar_target_rs() steps as
you like, mixing branching (tarpolyglot_map() and friends)
and non-branching steps freely. Each step compiles its own library
independently (or once, with a pattern helper), and the libraries do not
interfere with one another, even when several run in the same
crew worker. z reuses rs/scale.rs
(content shown in “Object output” above); noise is a
second, unrelated step in the same pipeline, rs/rng.rs (a
small linear congruential generator, no extra crate needed):
#[extendr]
fn rng_normal(n: i32, seed: i32) -> Vec<f64> {
let mut state = seed as u64;
(0..n).map(|_| {
state = state.wrapping_mul(6364136223846793005).wrapping_add(1);
((state >> 33) as f64) / (u32::MAX as f64)
}).collect()
}R/rng_post.R:
list(
tar_target(vals, c(10, 20, 30)),
tar_target(seed, 42),
# Branching, compile-once.
tar_target_rs(z, script = "rs/scale.rs", inputs = c(petal = "vals"),
post_script = "R/scale_post.R", pattern = tarpolyglot_map(vals)),
# A second, non-branching Rust step in the same pipeline.
tar_target_rs(noise, script = "rs/rng.rs", inputs = c(seed = "seed"),
post_script = "R/rng_post.R")
)The library a Rust step compiles is an ordinary R object, so you can compile once in one target and reuse the functions in other, unrelated steps, with no recompilation. Two exported helpers make this explicit:
compile_rs_lib() compiles a script’s
#[extendr] functions and returns a self-contained library
object (the compiled code plus its R wrappers). It is what the
tarpolyglot_*() pattern helpers use under the hood.run_rs_step_prebuilt() reloads such a library (in
milliseconds, no toolchain needed) and evaluates a post-script against
it, exactly like run_rs_step() does after compiling.rs/math.rs:
R/use_math.R: the compiled add_one() and
the input x are in scope:
list(
tar_target(x, 5),
# Compile once; the result is stored like any target value (rds by default, or qs).
tar_target(mathlib, tarpolyglot::compile_rs_lib("rs/math.rs")),
# Reuse it in a different step, with no recompilation.
tar_target(y, tarpolyglot::run_rs_step_prebuilt(
mathlib, post_script = "R/use_math.R", inputs = list(x = x)))
)Because a script can define several #[extendr]
functions, one compiled library can serve many steps. This is the
recommended way to share Rust code across a pipeline: compile the
functions once, then call them wherever you need them.
A single step can also use several compiled libraries. Call
run_rs_step_prebuilt() once per library and combine the
results. libA reuses rs/math.rs above;
rs/lib_b.rs is a second, independent script:
R/a.R:
R/b.R:
list(
tar_target(x, 5),
tar_target(libA, tarpolyglot::compile_rs_lib("rs/math.rs")),
tar_target(libB, tarpolyglot::compile_rs_lib("rs/lib_b.rs")),
tar_target(combined, {
a <- tarpolyglot::run_rs_step_prebuilt(libA, post_script = "R/a.R", inputs = list(x = x))
b <- tarpolyglot::run_rs_step_prebuilt(libB, post_script = "R/b.R", inputs = list(x = x))
a + b
})
)The libraries are loaded one at a time (see “Current limitations”), so call them in sequence like this rather than trying to use functions from two libraries in the same expression.
rextendr<N> from a per-process counter, so two
libraries built in separate workers often share the name
rextendr1. tarpolyglot handles this by loading
the library you are calling and swapping it out when you switch to
another, so results are always correct. It does mean you cannot hold two
such libraries live at once to interleave their functions in a single
expression. When you need several functions available together, put them
in one script (a script may define any number of #[extendr]
functions) rather than in separate libraries.tarpolyglot_map() (and the other helpers) create a
<name>_rust_lib compile target alongside the branched
target. This is intentional, it is what makes compile-once work, and it
shows up in tar_visnetwork().rextendr requires the Rust GNU toolchain on Windows (to match R’s mingw ABI). Either set it as the rustup default (shown above) or pass it per step:
Because a Rust step compiles rather than using a live interpreter:
tarpolyglot_*() pattern
helper (compile-once across branches); to avoid recompiling across
steps, compile once with compile_rs_lib() and reuse it with
run_rs_step_prebuilt() (see the sections above).See ?tar_target_rs and ?run_rs_step for the
full argument reference, ?tarpolyglot_map,
?compile_rs_lib, and ?run_rs_step_prebuilt for
compile-once and library reuse, and vignette("get_started")
for how Rust steps fit alongside Python/Julia and crew.
tar_target_rs()?