This vignette covers running
C++ as targets steps with Rcpp. A C++ step is deliberately
different from a Python or Julia one: there is no live
interpreter. Instead the // [[Rcpp::export]]
tagged functions in your script are compiled into a shared
library and exposed as ordinary R functions, which an R
post-script then calls. This mirrors
tar_target_rs() (see vignette("rust"))
closely; a C++ step has no live interpreter to reuse either, so most of
the same ideas apply, with a couple of additions specific to C++ covered
below (extension packages, and bringing an R function into C++). For a
gentler tour start with vignette("get_started").
Code blocks are illustrative and not executed when the vignette builds (compiling C++ needs a compiler).
tar_target_cpp()?Often not, and it is worth being honest about when it earns its keep.
Unlike Python and Julia, a C++ step has no live interpreter and no data
hand off to abstract away: Rcpp::sourceCpp() compiles your
// [[Rcpp::export]] tagged 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, {
Rcpp::sourceCpp("cpp/scale.cpp") # compiles zscore() and puts it in scope
zscore(petal_length) # petal_length is auto-detected as a dependency
})
)cpp/scale.cpp defines zscore(), a small
function used throughout this vignette, its full content is shown in
“Object output” just below.
sourceCpp() needs a real C++ compiler, R CMD SHLIB runs
one just as surely as rextendr runs cargo, but it is not a separate
toolchain to install: it is the same compiler R itself already needs for
any package with compiled code (Rtools on Windows, Xcode’s command line
tools on macOS), so this is the recommended starting point whenever
install.packages() can already build a source package on
your machine, with nothing further to set up.
tar_target_cpp() is a convenience wrapper that becomes
worth it when you want one or more of the following:
crew worker
processes. tar_target_cpp() puts R’s own
bin directory, and on Windows Rtools, on PATH
for the compilation; a plain tar_target() does not, so a
build in a fresh worker can fail to find the compiler unless you arrange
that yourself.tar_target_raw() argument set bundled in one call
(output = "file", tar_target_path(),
pattern, resources, cue, and so
on).tarpolyglot_*() pattern helper (see “Compile the C++ code
only once across branches” below); this needs no manual orchestration
once you use the constructor.tar_target_py(),
tar_target_jl(), and tar_target_rs(), so a
polyglot pipeline reads the same way across all four 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_cpp() for when they do.
tar_target_cpp(): bare name (and unquoted
pattern), for direct use in _targets.R.tar_target_cpp_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 C++ code
only once across branches”), where they return a compile target plus the
branched target.
These constructors depend on the Rcpp R package. As a result, any limitation of Rcpp is inherited by the constructors. Reading Rcpp’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).// [[Rcpp::export]] 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".sourceCpp() compiles against whatever toolchain R
itself already uses (R CMD SHLIB), so there is no GNU
versus MSVC style concern on Windows, and tar_target_cpp()
has no toolchain argument.| Argument | Meaning |
|---|---|
script |
Path to the C++ file with // [[Rcpp::export]] 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). |
depends |
Extension packages
(e.g. c("RcppArmadillo", "RcppEigen")), passed straight
through as // [[Rcpp::depends(...)]] would be. |
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 Rcpp (if not already doneà and check it works:
On Windows, the compiler comes from Rtools, matched to your R version:
If that reports FALSE, install Rtools and restart R.
Unlike Rust’s GNU versus MSVC concern, there is nothing to choose here:
Rtools already matches R’s own ABI by construction, so there is no
toolchain argument to set.
On macOS, install Xcode’s command line tools
(xcode-select --install); on Linux, a
g++/clang++ and R’s own development package
(e.g. r-base-dev on Debian/Ubuntu) are normally already
present or a single package manager install away.
run_cpp_step() puts R’s own bin directory,
and on Windows Rtools, on PATH for the build itself, so a
C++ step usually works even in a bare crew worker, but the
compiler must be installed.
cpp/scale.cpp: one // [[Rcpp::export]]
function:
#include <Rcpp.h>
// [[Rcpp::export]]
Rcpp::NumericVector zscore(Rcpp::NumericVector x) {
double n = x.size();
double m = Rcpp::sum(x) / n;
double sd = std::sqrt(Rcpp::sum(Rcpp::pow(x - m, 2.0)) / n);
return (x - m) / sd;
}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:
cpp/write.cpp: writes a value to disk and returns
nothing, the path itself is built in the post-script:
#include <Rcpp.h>
#include <fstream>
// [[Rcpp::export]]
void write_value(double x, std::string path) {
std::ofstream out(path);
out << x;
}R/cpp_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):
Header only extension packages such as RcppArmadillo
and RcppEigen
need no tarpolyglot specific handling: they self declare with a
// [[Rcpp::depends(pkgname)]] source attribute that
sourceCpp() already knows how to parse.
depends is a convenience alternative, useful when you would
rather not repeat the attribute in the source itself:
// cpp/arma_stats.cpp
#include <RcppArmadillo.h>
// [[Rcpp::export]]
double arma_mean(arma::vec x) {
return arma::mean(x);
}R/arma_stats_post.R: the compiled
arma_mean() and the input x are in scope:
tar_target_cpp(
name = arma_demo,
script = "cpp/arma_stats.cpp",
inputs = c(x = "values"),
post_script = "R/arma_stats_post.R",
depends = "RcppArmadillo" # equivalent to // [[Rcpp::depends(RcppArmadillo)]] in the source
)RcppEigen behaves the same way. RcppParallel is different: it has a runtime threading component of its own, covered on its own below.
RcppParallel
works correctly inside a crew worker:
RcppParallel::defaultNumThreads() reports the machine’s
real core count from a worker exactly as it would in the main session, a
worker is a full OS process, not a slice of one, and
RcppParallel::parallelFor() genuinely parallelizes there.
Confirmed directly:
RcppParallel::setThreadOptions(numThreads = N) in the
post-script, called before the compiled function, controls how many
threads that call uses.
// [[Rcpp::depends(RcppParallel)]]
#include <RcppParallel.h>
#include <Rcpp.h>
struct SquareRoot : public RcppParallel::Worker {
const std::vector<double> input;
std::vector<double> output;
SquareRoot(const std::vector<double>& input) : input(input), output(input.size()) {}
void operator()(std::size_t begin, std::size_t end) {
for (std::size_t i = begin; i < end; i++) output[i] = std::sqrt(input[i]);
}
};
// [[Rcpp::export]]
Rcpp::NumericVector parallel_sqrt(std::vector<double> x) {
SquareRoot sr(x);
RcppParallel::parallelFor(0, x.size(), sr);
return Rcpp::wrap(sr.output);
}The one real risk is not access to cores, it is claiming the same
cores twice over. A crew worker is an ordinary OS process
with access to every core on the machine, the same as the main session,
it is not restricted to one core the way a single thread might be. That
means several workers running at once, each spawning its own full width
RcppParallel thread pool, can genuinely oversubscribe the machine:
N concurrent workers each grabbing every core adds up to
far more threads than the machine has, which degrades performance and,
on a machine with few cores, can exhaust it rather than merely slow it
down. Put the tar_target_cpp() step that does the parallel
work on deployment = "main" to avoid this:
targets only ever builds one main-deployed
target at a time, so parallel-heavy steps never compound with each
other, while ordinary worker-deployed steps keep running alongside
them:
tar_target_cpp(
name = heavy_parallel,
script = "cpp/parallel_sqrt.cpp",
inputs = c(x = "big_x", n_threads = "n_threads"),
post_script = "R/parallel_sqrt_post.R",
deployment = "main"
)If several different steps each need the full machine, keeping them
all on deployment = "main" serializes them relative to each
other, exactly like running them one after another in a plain R script,
which is the safe default. An alternative that keeps worker-based
isolation is to cap numThreads per step
(e.g. parallel::detectCores() / workers) so that even if
every worker’s step runs at once, the total across all of them does not
exceed the machine’s core count.
A compiled function can declare a parameter of type
Rcpp::Function, which accepts an ordinary R function,
including one the caller supplies, and calls it back from inside the
compiled code:
#include <Rcpp.h>
// [[Rcpp::export]]
double apply_r_function(Rcpp::NumericVector x, Rcpp::Function f) {
Rcpp::NumericVector result = f(x);
return result[0];
}Rcpp::Function is an ordinary argument, resolved at call
time. It is not captured into the compiled library, which
matters as soon as you compile once and reuse the result across steps or
branches (see “Compile once, reuse in other steps” below). Every step
that calls the function needs its own f in scope: a step
that has none with fails with
object '<name of indicated function>' not found, and
a step that supplies a different one gets that one instead, with no
recompilation.
lib <- compile_cpp_lib(script = "cpp/apply.cpp")
# Same library, different callbacks, no recompile.
run_cpp_step_prebuilt(lib, inputs = list(x = c(1, 2, 3)),
post_script = tar_code({
tripler <- function(v) v * 3
apply_r_function(x, tripler)
}))
run_cpp_step_prebuilt(lib, inputs = list(x = c(1, 2, 3)),
post_script = tar_code({
tripler <- function(v) v * 10
apply_r_function(x, tripler)
}))One caveat if you take option 2: a helper reached through the global
environment is not a tracked dependency, so editing it does not
invalidate the step and the pipeline will reuse a stale result. If the
definition of f should trigger a re-run, make it a
format = "file" target and wire it in with
inputs, as described in
vignette("scripts").
f is resolved when the function defined using Rcpp runs,
in the step’s own environment, whose parent is the global environment.
Three places can therefore define f:
# 1. Directly in the R scripts that use the function, before the call.
post_script = tar_code({
tripler <- function(v) v * 3
apply_r_function(x, tripler)
})
# 2. In a file sourced by _targets.R, the usual targets way of sharing helpers.
# _targets.R: tar_source() # or source("R/functions.R")
# R/functions.R: tripler <- function(v) v * 3
post_script = tar_code({ apply_r_function(x, tripler) })
# 3. Anything already available: a base function, or one from an attached package.
post_script = tar_code({ apply_r_function(x, mean) })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_cpp(
name = z_by_group,
script = "cpp/scale.cpp",
inputs = c(petal = "groups"),
post_script = "R/scale_post.R",
pattern = map(groups),
iteration = "list"
)
)Plain map(groups) here recompiles the library in
every branch: a C++ step has no live interpreter to
reuse, so each branch runs sourceCpp() 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 C++
library 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_cpp(
name = z_by_group,
script = "cpp/scale.cpp",
inputs = c(petal = "groups"),
post_script = "R/scale_post.R",
pattern = tarpolyglot_map(groups), # was map(groups): now compiles once
iteration = "list"
)
)On a C++ step, tarpolyglot_map() expands that single
constructor call into two targets:
z_by_group_cpp_lib: compiles the library once (this
target is not branched). Its value is the compiled library, embedded so
any crew worker can reload it without a compiler.z_by_group: branches over groups as
before, but each branch reloads the pre compiled library (in
milliseconds) instead of recompiling.You still write one tar_target_cpp(); the companion
<name>_cpp_lib target appears automatically in the
pipeline (visible in tar_visnetwork()), and
targets recompiles it only when the C++ 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 C++.
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(). On tar_target_rs() the same helpers
compile the Rust crate once instead, the same idea as here but for a
different toolchain (see vignette("rust")). 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):
cpp/square.cpp:
R/post.R (post-script for the C++ 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)
),
# C++: compiles once in cpp_b's sibling cpp_b_cpp_lib, reuses across branches.
tar_target_cpp(
name = cpp_b, script = "cpp/square.cpp", 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_cpp() 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 (following the same
fix Rust needed) tarpolyglot makes sure the libraries do
not interfere with one another, even when several run in the same
crew worker or share the same internal module name (see
“Current limitations”). z reuses cpp/scale.cpp
(content shown in “Object output” above); noise is a
second, unrelated step in the same pipeline,
cpp/rng.cpp:
#include <Rcpp.h>
#include <random>
// [[Rcpp::export]]
Rcpp::NumericVector rng_normal(int n, int seed) {
std::mt19937 gen(seed);
std::normal_distribution<double> dist(0.0, 1.0);
Rcpp::NumericVector out(n);
for (int i = 0; i < n; i++) out[i] = dist(gen);
return out;
}R/rng_post.R:
list(
tar_target(vals, c(10, 20, 30)),
tar_target(seed, 42),
# Branching, compile once.
tar_target_cpp(
z, script = "cpp/scale.cpp",
inputs = c(petal = "vals"),
post_script = "R/scale_post.R",
pattern = tarpolyglot_map(vals)
),
# A second, non branching C++ step in the same pipeline.
tar_target_cpp(
noise, script = "cpp/rng.cpp",
inputs = c(seed = "seed"),
post_script = "R/rng_post.R"
)
)The library a C++ 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_cpp_lib() compiles a script’s
// [[Rcpp::export]] 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_cpp_step_prebuilt() reloads such a library (in
milliseconds, no compiler needed) and evaluates a post-script against
it, exactly like run_cpp_step() does after compiling.cpp/math.cpp:
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_cpp_lib("cpp/math.cpp")
),
# Reuse it in a different step, with no recompilation.
tar_target(
y, tarpolyglot::run_cpp_step_prebuilt(
mathlib, post_script = "R/use_math.R",
inputs = list(x = x))
)
)Because a script can define several // [[Rcpp::export]]
functions, one compiled library can serve many steps. This is the
recommended way to share C++ code across a pipeline: compile the
functions once, then call them wherever you need them.
tar_target_cpp() companion library
elsewherey above does not have to come from a plain
tar_target(mathlib, compile_cpp_lib(...)): the
<name>_cpp_lib companion target a
tarpolyglot_*() pattern helper creates (see “Compile the
C++ code only once across branches”) is an ordinary target too, holding
an ordinary tp_cpp_lib value, so a completely unrelated
step can reuse it the exact same way, confirmed directly:
list(
tar_target(x, 5),
tar_target(groups, c(1, 2, 3)),
# Compiles cpp/math.cpp ONCE into the companion mathlib_branch_cpp_lib,
# then branches over groups, reloading that library each time.
tar_target_cpp(
name = mathlib_branch,
script = "cpp/math.cpp",
inputs = c(x = "groups"),
post_script = "R/use_math.R",
pattern = tarpolyglot_map(groups)
),
# A wholly separate, non-branching step reusing the SAME companion library,
# referenced by name (mathlib_branch_cpp_lib), with no recompilation.
tar_target(
y,
tarpolyglot::run_cpp_step_prebuilt(
mathlib_branch_cpp_lib, post_script = "R/use_math.R", inputs = list(x = x)
)
)
)mathlib_branch_cpp_lib is not a name you choose,
tarpolyglot builds it from
<name>_cpp_lib, so it is visible in
tar_visnetwork() and referenceable like any other target
once you know the convention.
A single step can also use several compiled libraries at once,
whether they come from plain compile_cpp_lib() targets,
tar_target_cpp() companions, or a mix of both. Call
run_cpp_step_prebuilt() once per library and combine the
results. cpp/lib_b.cpp (a second, independent script):
R/b.R:
list(
tar_target(x, 5),
tar_target(groups, c(1, 2, 3)),
# libA: a tar_target_cpp() companion (see above); libB: a plain compile_cpp_lib().
tar_target_cpp(
name = mathlib_branch,
script = "cpp/math.cpp",
inputs = c(x = "groups"),
post_script = "R/use_math.R",
pattern = tarpolyglot_map(groups)
),
tar_target(libB, tarpolyglot::compile_cpp_lib("cpp/lib_b.cpp")),
tar_target(combined, {
a <- tarpolyglot::run_cpp_step_prebuilt(
mathlib_branch_cpp_lib, post_script = "R/use_math.R", inputs = list(x = x)
)
b <- tarpolyglot::run_cpp_step_prebuilt(
libB, post_script = "R/b.R", inputs = list(x = x)
)
list(a = a, b = 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.
sourceCpp() names every compiled library
sourceCpp_<N> from a per session counter, so two
libraries built in separate workers often share the name
sourceCpp_1. 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
// [[Rcpp::export]] functions) rather than in separate
libraries.tarpolyglot_map() (and the other helpers) create a
<name>_cpp_lib compile target alongside the branched
target. This is intentional, it is what makes compile once work, and it
shows up in tar_visnetwork().crew worker
oversubscription. See “Parallel computing with RcppParallel”
above: a worker has full access to every core, so several workers each
running their own full width thread pool at once can oversubscribe the
machine. Use deployment = "main" on the parallel-heavy
step, or cap numThreads per step, to avoid it.C++ steps are covered by tar_polyglot_log(), with one caveat:
Rcpp::Rcout and Rcpp::Rcerr, the idiomatic
Rcpp printing calls, route through R’s own output connection system, so
they are captured the same way cat() output is. Raw
std::cout / std::cerr / printf()
write straight to the OS file descriptor and bypass that system
entirely, so they are not captured, use
Rcpp::Rcout / Rcpp::Rcerr in compiled code
instead of raw C++ streams if you want step output in these logs:
Unlike Rust’s GNU versus MSVC ABI matching concern, there is nothing
to choose on Windows: Rtools already matches R’s own ABI by
construction, so tar_target_cpp() has no
toolchain argument. Make sure Rtools is installed and
discoverable (pkgbuild::has_build_tools(debug = TRUE)), the
same way it already needs to be for any R package with compiled
code.
Because a C++ step compiles rather than using a live interpreter:
tarpolyglot_*() pattern helper
(compile once across branches); to avoid recompiling across steps,
compile once with compile_cpp_lib() and reuse it with
run_cpp_step_prebuilt() (see the sections above).See ?tar_target_cpp and ?run_cpp_step for
the full argument reference, ?tarpolyglot_map,
?compile_cpp_lib, and ?run_cpp_step_prebuilt
for compile once and library reuse, ?tar_polyglot_log for
step logging, and vignette("get_started") for how C++ steps
fit alongside Python/Julia/Rust and crew.
tar_target_cpp()?