RcppTrust: A Thread-Safe C++ Trust Region Optimizer

Matthew Fidler

RcppTrust is a C++ port of the trust-region optimizer in Charles J. Geyer’s CRAN package trust, written for use inside nlmixr2est. This vignette does not re-derive the algorithm – see Geyer’s original paper/vignette, or Nocedal and Wright (1999, Chapter 4) and Fletcher (1987, Section 5.1), for that. Instead it covers what a user of either package actually needs to know: what carries over unchanged, what’s new, and how to call the new thread-safe C interface from your own package.

Similarities: what carries over unchanged

RcppTrust::trust() is a drop-in replacement for trust::trust(). It has the same arguments, the same defaults, the same return value, and implements the exact same algorithm: the same Newton / easy-easy / hard-easy / hard-hard case split for the trust-region subproblem, the same accept/reject and radius-adjustment rule, and the same termination criteria.

library(RcppTrust)

# Rosenbrock's function, exactly the example from ?trust::trust
objfun <- function(x) {
  f <- expression(100 * (x2 - x1^2)^2 + (1 - x1)^2)
  g1 <- D(f, "x1"); g2 <- D(f, "x2")
  h11 <- D(g1, "x1"); h12 <- D(g1, "x2"); h22 <- D(g2, "x2")
  x1 <- x[1]; x2 <- x[2]
  list(
    value = eval(f), gradient = c(eval(g1), eval(g2)),
    hessian = rbind(c(eval(h11), eval(h12)), c(eval(h12), eval(h22)))
  )
}

out <- trust(objfun, c(3, 1), 1, 5)
out[c("value", "argument", "converged", "iterations")]
#> $value
#> [1] 5.165437e-15
#> 
#> $argument
#> [1] 1 1
#> 
#> $converged
#> [1] TRUE
#> 
#> $iterations
#> [1] 21

Everything you already know about trust::trust() – the objfun contract (return a list with value, gradient, hessian, or list(value = Inf)/list(value = -Inf) to signal an infeasible point), the rinit/rmax/parscale/iterlim/fterm/mterm/ minimize/blather arguments, the shape of the returned list, blather = TRUE’s extra argpath/steptype/rho/… components – applies unchanged. All 10 of upstream’s own tests/*.R scripts are ported into this package’s test suite and pass against the C++ core.

Differences

trust RcppTrust
Implementation pure R C++ (RcppArmadillo for the linear algebra)
Root-finder for the trust-region subproblem stats::uniroot() boost::math::tools::toms748_solve()
Callable from R yes yes, identically (trust())
Callable from thread-safe C/C++ no yes (trust_solve_c(), see below)
Usable from another package without linking against its shared library no C API at all yes, via a header-only function-pointer table
Authors Charles J. Geyer Charles J. Geyer (algorithm), Matthew Fidler (C++ port)

A few of these are worth expanding on:

Speed

Both implementations solve the same trust-region subproblem, so this is really comparing three things at once: R vs. C++ overhead per iteration, stats::uniroot() vs. boost::math::tools::toms748_solve(), and (per the note above) however many iterations each happens to take to converge on a given problem. RcppTrust::trust() still calls back into R for the objective function on every iteration – exactly like upstream – so none of this is about avoiding R call overhead; it’s the trust-region bookkeeping itself (the eigendecomposition and root-find) that’s faster in C++, and that saving grows with the number of parameters.

library(microbenchmark)

# same Rosenbrock objfun as above: 2 parameters, ~20 iterations
mb_small <- microbenchmark(
  trust = trust::trust(objfun, c(3, 1), 1, 5),
  RcppTrust = RcppTrust::trust(objfun, c(3, 1), 1, 5),
  times = 50
)
print(mb_small)
#> Unit: microseconds
#>       expr      min       lq      mean   median       uq      max neval cld
#>      trust 1131.952 1163.758 1371.1476 1218.235 1378.627 3109.315    50  a 
#>  RcppTrust  472.320  510.526  591.6843  534.388  568.651 2497.764    50   b

A larger problem – the restricted-domain log-barrier objective from ?trust::trust’s second example, at d = 30 parameters instead of 5 – makes the per-iteration bookkeeping cost (rather than the objective function itself) a bigger share of the total, and is the case behind the 28-vs-16-iteration example mentioned above:

d <- 30
mu <- seq_len(d)
barrier_objfun <- function(x) {
  normxsq <- sum(x^2)
  omnormxsq <- 1 - normxsq
  if (normxsq >= 1) return(list(value = Inf))
  f <- sum(x * mu) - log(omnormxsq)
  g <- mu + 2 * x / omnormxsq
  B <- 4 * outer(x, x) / omnormxsq^2 + 2 * diag(d) / omnormxsq
  list(value = f, gradient = g, hessian = B)
}

r1 <- trust::trust(barrier_objfun, rep(0, d), 1, 100)
r2 <- RcppTrust::trust(barrier_objfun, rep(0, d), 1, 100)
# same solution, different number of steps to get there (see above)
c(trust_iterations = r1$iterations, RcppTrust_iterations = r2$iterations)
#>     trust_iterations RcppTrust_iterations 
#>                   28                   16
max(abs(r1$argument - r2$argument))
#> [1] 3.787193e-12

mb_large <- microbenchmark(
  trust = trust::trust(barrier_objfun, rep(0, d), 1, 100),
  RcppTrust = RcppTrust::trust(barrier_objfun, rep(0, d), 1, 100),
  times = 30
)
print(mb_large)
#> Unit: microseconds
#>       expr      min       lq      mean    median       uq      max neval cld
#>      trust 3172.252 3283.852 3635.6086 3375.0595 3775.287 7036.877    30  a 
#>  RcppTrust  572.882  596.083  654.6888  648.7145  698.041  786.037    30   b

On this machine, RcppTrust::trust() comes out roughly 2x faster on the 2-parameter problem and roughly 4-5x faster on the 30-parameter one, despite taking more per-iteration R/C++ round trips than upstream (each iteration’s objective-function call additionally goes through the internal .trustTryEval() wrapper described below, so that objfun errors are still caught exactly the way upstream’s own try() catches them) – the win comes entirely from the trust-region bookkeeping itself, and should be expected to grow with the number of parameters. It says nothing about the thread-safe C path (trust_solve_c()), which additionally removes the R round trip altogether; see below.

When to use which interface

Using the C interface

The thread-safe entry point is declared in inst/include/trust_types.h (a plain C header with no R and no C++ dependency, so it’s safe to include from plain C code too):

typedef int (*trust_c_objfun_t)(int n, const double *par,
    double *value, double *gradient, double *hessian, void *userdata);

Your objective function fills *value (and gradient/hessian, each length n/n*n, unless the point is infeasible) and returns:

Options are a plain struct, built from trust_options_default() (which fills in trust()’s R-level defaults) and then adjusted as needed:

trust_options_t opts = trust_options_default(/* rinit = */ 1.0, /* rmax = */ 5.0);
opts.iterlim = 200;      // default 100
opts.minimize = 0;       // maximize instead
opts.blather = 1;        // collect per-iteration arrays, like blather = TRUE
opts.has_parscale = 1;
opts.parscale = my_parscale;  // a `const double *` of length n you own

The result is written into a caller-supplied trust_result_t, which owns its buffers (argument, gradient, hessian, and – if opts.blather – the argpath/argtry/steptype/rho/… arrays, laid out exactly like the R-level blather = TRUE output) until you free it:

trust_result_t res;
int rc = trust_solve_c(n, parinit, my_objfun, my_userdata, &opts, &res);
// rc == 0 on success; res.converged, res.iterations, res.argument, ...
trust_result_free(&res);

trust_solve_c() and trust_result_free() take no locks and touch no shared, mutable state – every argument is either an input or owned exclusively by the caller’s trust_result_t – so many threads can each be running their own trust_solve_c() call at the same time, on independent problems, with no coordination needed. That’s the piece trust::trust() cannot offer at all.

Calling it from your own package: the registration pattern

RcppTrust’s shared library is never linked against directly. Instead – following the same header-only, positionally-indexed function-pointer pattern already used across the nlmixr2 ecosystem (rxode2, n1qn1, lbfgsb3c) – a consumer resolves trust_solve_c/trust_result_free as function pointers once, at load time. The full wiring is three pieces, which the example below reproduces in a single self-contained file for demonstration.

  1. Add RcppTrust to your DESCRIPTION’s LinkingTo (for the header) and Imports (for the R-level .RcppTrustPtr() getter).

  2. In one translation unit, include the registration header with a package-unique renaming #define so the generated init function doesn’t collide with any other consumer’s:

    extern "C" {
    #define iniRcppTrustPtrs _mypkg_iniRcppTrustPtrs
    #include <RcppTrust.h>
    iniRcppTrust
    }
  3. In your package’s .onLoad(), resolve the pointers once:

    .onLoad <- function(libname, pkgname) {
      .Call(`_mypkg_iniRcppTrustPtrs`, RcppTrust:::.RcppTrustPtr(), PACKAGE = "mypkg")
    }

    After that, trust_solve_c_ptr and trust_result_free_ptr are live function pointers with the same signatures as trust_solve_c()/trust_result_free(), usable anywhere in your package’s C++ – including inside an OpenMP loop fitting many subjects’ problems in parallel:

    #pragma omp parallel for
    for (int i = 0; i < nSubjects; i++) {
      trust_options_t opts = trust_options_default(1.0, 5.0);
      trust_result_t res;
      trust_solve_c_ptr(n, parinit[i], subject_objfun, &subjectData[i], &opts, &res);
      // ... use res.argument, res.value, res.converged ...
      trust_result_free_ptr(&res);
    }

The example below is the whole pattern collapsed into one file and run live, via Rcpp::sourceCpp(), exactly as it would work split across a real package’s src/init.c, R/zzz.R, and wherever the fit happens:

cpp_code <- '
// [[Rcpp::depends(RcppTrust)]]
#include <Rcpp.h>

extern "C" {
#define iniRcppTrustPtrs _vignette_iniRcppTrustPtrs
#include <RcppTrust.h>
iniRcppTrust
}

// A thread-safe C objective function: Rosenbrock again, this time
// filling value/gradient/hessian directly instead of returning a list.
extern "C" int rosenbrock_c(int n, const double *par, double *value,
                             double *gradient, double *hessian, void *ud) {
  double x1 = par[0], x2 = par[1];
  double t = x2 - x1 * x1;
  *value = 100.0 * t * t + (1.0 - x1) * (1.0 - x1);
  gradient[0] = -400.0 * x1 * t - 2.0 * (1.0 - x1);
  gradient[1] = 200.0 * t;
  hessian[0] = -400.0 * x2 + 1200.0 * x1 * x1 + 2.0;
  hessian[1] = hessian[2] = -400.0 * x1;
  hessian[3] = 200.0;
  return 0;
}

// [[Rcpp::export]]
Rcpp::List fit_rosenbrock(SEXP ptrTable, Rcpp::NumericVector parinit) {
  // normally done once, in .onLoad() -- see above
  _vignette_iniRcppTrustPtrs(ptrTable);

  trust_options_t opts = trust_options_default(1.0, 5.0);
  trust_result_t res;
  trust_solve_c_ptr(parinit.size(), parinit.begin(), rosenbrock_c, nullptr, &opts, &res);

  Rcpp::List out = Rcpp::List::create(
    Rcpp::_["argument"] = Rcpp::NumericVector(res.argument, res.argument + res.n),
    Rcpp::_["value"] = res.value,
    Rcpp::_["converged"] = res.converged != 0,
    Rcpp::_["iterations"] = res.iterations);
  trust_result_free_ptr(&res);
  return out;
}
'
Rcpp::sourceCpp(code = cpp_code)

fit_rosenbrock(RcppTrust:::.RcppTrustPtr(), c(3, 1))
#> $argument
#> [1] 1 1
#> 
#> $value
#> [1] 5.165437e-15
#> 
#> $converged
#> [1] TRUE
#> 
#> $iterations
#> [1] 21

Note that this compiled file never links against RcppTrust’s shared library at all – LinkingTo/Rcpp::depends() only adds its inst/include directory to the compiler’s include path. Every trust_* symbol used above except the two resolved pointers (trust_solve_c_ptr, trust_result_free_ptr) is a type or macro, not a function call, which is exactly the point: nothing here creates an ABI dependency on a specific build of RcppTrust, only on the stable, append-only pointer table it publishes.