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).
Do you need 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):
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:
-
Robust builds in bare or
crewworker processes.tar_target_rs()puts R,cargo, and (on Windows) Rtools onPATHand setsR_HOMEfor the compilation; a plaintar_target()does not, so a Rust build in a fresh worker often fails to find the toolchain unless you arrange that yourself. -
Per-step toolchain selection through
toolchain =, which setsRUSTUP_TOOLCHAINfor that build only. -
File output, script tracking, and the full
tar_target_raw()argument set bundled in one call (output = "file",tar_target_path(),pattern,resources,cue, and so on). -
API symmetry with
tar_target_py()andtar_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.
The two constructors
-
tar_target_rs(): barename(and unquotedpattern), for direct use in_targets.R. -
tar_target_rs_raw(): stringname, for use inside targets factories.
Both return a single targets target and forward every
targets::tar_target_raw() argument.
How a Rust step differs
-
No pre-script. There is no foreign session to push
variables into. The
inputsare bound directly in the post-script, where you call the compiled functions with them. -
The post-script is where the work happens. After
compilation, the Rust functions are in scope in the post-script’s
environment (alongside
inputs). Its last expression is the target value (object mode); it returns file paths (file mode). -
Real type conversion (via extendr), not JSON, but
return values still have to be R-representable, or use
output = "file".
Arguments
| 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.
Installing Rust and rextendr
-
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:
install.packages("rextendr") rextendr::rust_source(code = "#[extendr] fn double(x: f64) -> f64 { x * 2.0 }") double(21) # 42
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.
Object output (iris example)
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()
}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:
library(targets)
library(tarpolyglot)
list(
tar_target(petal_length, iris$Petal.Length),
tar_target_rs(
name = petal_z,
script = "rs/scale.rs",
inputs = c(petal = "petal_length"),
post_script = "R/scale_post.R"
)
)File output
tar_target_rs(
name = rs_file,
script = "rs/write.rs", # a fn that writes a file and returns its path
inputs = c(x = "value"),
post_script = "R/rs_files_post.R", # returns the path(s)
output = "file"
)Crate dependencies, features, profile
Pass extra crates through dependencies (a named list),
and build options through features /
profile:
tar_target_rs(
name = parsed,
script = "rs/parse.rs", # uses serde_json
inputs = c(txt = "raw_json"),
post_script = "R/parse_post.R",
dependencies = list(`serde_json` = "1"),
profile = "release"
)Dynamic branching
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.
Compile the Rust code only once across branches
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 anycrewworker can reload it without a toolchain. -
z_by_group: branches overgroupsas before, but each branch reloads the pre-compiled library (adyn.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.
They fall back to the plain pattern on Python and Julia
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:
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. A plain targets::tar_target()
does not know them, so there you use the native map() /
cross() / … directly (a plain step has no foreign code to
compile anyway).
Windows toolchain note
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:
tar_target_rs(
name = petal_z,
script = "rs/scale.rs",
inputs = c(petal = "petal_length"),
post_script = "R/scale_post.R",
toolchain = "stable-x86_64-pc-windows-gnu" # sets RUSTUP_TOOLCHAIN for the build
)Trade-offs vs Python/Julia
Because a Rust step compiles rather than using a live interpreter:
- No interpreter-per-session or global-state issues. Each step compiles and loads its own library, so different steps can freely use different crates/toolchains in the same session.
- Compilation cost instead of interpreter start-up. The first build compiles the extendr crate and your code (tens of seconds); cargo caches artefacts, so re-runs are faster.
See ?tar_target_rs and ?run_rs_step for the
full argument reference, and vignette("get_started") for
how Rust steps fit alongside Python/Julia and crew.