--- title: "Parallelism: who owns the cores" output: rmarkdown::html_vignette: toc: true css: vignette_styles.css includes: before_body: vignette_nav.html bibliography: references.bib csl: ieee.csl link-citations: true vignette: > %\VignetteIndexEntry{Parallelism: who owns the cores} %\VignetteEngine{knitr::rmarkdown} %\VignetteEncoding{UTF-8} --- ```{r setup, include = FALSE} knitr::opts_chunk$set(collapse = TRUE, comment = "#>", fig.align = "center", fig.width = 6, fig.height = 4, fig.retina = 2) options(width = 80) library(TRexSelector) old_threads <- trex_set_num_threads(min(2L, trex_get_max_threads())) ``` # Two layers of work A T-Rex run has two layers that could use several cores. The outer layer is the $K$ random experiments, which are independent of each other and share the design. The inner layer is the work inside one T-solver: the passes over the design that refresh the correlations, the search for the next knot of the path, the standardization. The rule of the package is that one layer owns the cores at a time. Either the experiments run in parallel and every solver runs serially, or the experiments run in sequence and a solver may split its passes. Nested teams of threads are never formed. The default is serial. A selector runs its experiments one after the other, every solver runs on one thread, and a T-solver used on its own runs serially. Parallel work is opt-in through two options of `trex_control()`: `max_outer_threads` for the experiments and `max_inner_threads` for the solvers' passes. # The default thread budget of the package The package is single-threaded by default on every machine. Every parallel region of the backend runs on an explicit budget, and the methods without an option of their own (the scalers, the clustering, the ML regressions, `trex_normalize_design()`, the dummy and data generators) take the package's default budget, which starts at 1. `trex_get_num_threads()` reads that default, `trex_set_num_threads()` sets it for the session and returns the previous value, and the `n_threads` argument of those methods overrides it per object. Inside a selector the same kernels take `max_inner_threads`. `trex_get_max_threads()` reports the threads OpenMP can run on this machine, an upper bound for a budget. In a build without OpenMP every setting is a no-op and the machine count is one. ```{r threads} trex_get_num_threads() trex_get_max_threads() ``` The selectors and the solvers do not read the default budget. Their budget comes from the control. # The solvers' passes `trex_control(max_inner_threads = m)` gives every solver inside the random experiments a budget of `m` threads for its passes over the design (the correlation refreshes, the step-length search, the standardization). The default is 1. A pass is split only when it touches at least a million entries of the design, because the cost of forming a team of threads exceeds the gain below that, so small problems run serially whatever the budget says. The seeded generators (the dummies, the synthetic designs) count their work in the same unit with the measured cost of a random draw (about 80 entries) and of seeding an engine (up to 32,000), so they split from a few thousand entries. The budget is live when the experiments run in sequence. Inside parallel experiments the solvers stay serial. A T-solver used on its own takes the same budget through `tsolver_options(n_threads = )`, and `$set_num_threads()` changes it on a live solver. # Parallel random experiments `trex_control(parallel_rnd_experiments = TRUE, max_outer_threads = m)` runs the random experiments on `m` threads (at most $K$). Inside the parallel experiments every solver runs serially, so the number of threads in use is `max_outer_threads` and nothing more. Both options are whole numbers of at least 1. The lean L-loop strategies (`"SEEDED"`, `"PERMUTATION"`, `"PERMUTATION_SEEDED"`) run their experiments in sequence in every case and give the cores to the solvers. ```{r data} set.seed(12) n <- 100 p <- 150 beta <- c(rep(1, 5), rep(0, p - 5)) X <- matrix(rnorm(n * p), n, p) y <- as.numeric(X %*% beta + rnorm(n)) ``` ```{r parallel} serial <- TRexSelector$new(X, y, tFDR = 0.1, seed = 42, verbose = FALSE, control = trex_control(K = 10)) serial$select() parallel <- TRexSelector$new(X, y, tFDR = 0.1, seed = 42, verbose = FALSE, control = trex_control(K = 10, parallel_rnd_experiments = TRUE, max_outer_threads = 2)) parallel$select() budget <- TRexSelector$new(X, y, tFDR = 0.1, seed = 42, verbose = FALSE, control = trex_control(K = 10, max_inner_threads = 2)) budget$select() identical(serial$selected_indices, parallel$selected_indices) identical(serial$selected_indices, budget$selected_indices) ``` # Determinism A seeded selector derives one seed per random experiment from `seed` and the experiment index, so the dummies of experiment $k$ do not depend on which thread draws them or on how many threads there are. The selection of a seeded run is therefore the same with experiments in sequence and in parallel, and the same for any thread count of the inner passes. Every reduction inside the solvers is summed in a fixed order for the same reason. # Combining with your own parallel loops A Monte Carlo study usually parallelizes over trials. Then the trials own the cores, and the package should run serially inside every worker, which is the default. Two details matter. - With `parallel::mclapply()` the workers are forks of the session and inherit its default budget. Keep it at one in the session, or set it to one in the workers with `trex_set_num_threads(1)`, so that the forks do not oversubscribe the machine. - With `parallel::parLapply()` or the future framework the workers are separate R processes with the default budget of a fresh session, one thread. Load the package in the workers and keep the defaults. ```{r mc, eval = FALSE} trials <- parallel::mclapply(seq_len(40), function(mc) { trex_set_num_threads(1) set.seed(mc) X <- matrix(rnorm(n * p), n, p) y <- as.numeric(X %*% beta + rnorm(n)) sel <- TRexSelector$new(X, y, tFDR = 0.1, verbose = FALSE) # seed = -1 per trial sel$select() trex_fdp(sel$selected_indices, which(beta != 0)) }, mc.cores = 4) mean(unlist(trials)) ``` # Builds without OpenMP On Linux and Windows R supplies the OpenMP flags [@rcore2026admin]. CRAN's R for macOS has none [@cran2026macopenmp], and the package then links the OpenMP runtime R ships with the header of Homebrew's libomp, or compiles single-threaded when no header is found. `TREX_OPENMP=no` at installation forces the single-threaded build. A single-threaded build selects the same variables, it only runs the experiments and the passes in sequence. What a package may rely on is stated in Writing R Extensions [@rcore2026exts], and the page "OpenMP on your platform" of the repository [@trex2026openmp] walks through the build on each platform. # The CRAN rule CRAN checks packages on at most two cores. The vignettes and the tests of this package follow the rule with the idiom of their setup chunk, which caps the count for the duration of the page and restores it at the end. ```{r cap, eval = FALSE} old_threads <- trex_set_num_threads(min(2L, trex_get_max_threads())) # ... the work ... trex_set_num_threads(old_threads) ``` ```{r teardown, include = FALSE} trex_set_num_threads(old_threads) ``` # Bibliography