--- title: "Threads, random numbers and CRAN's two-core rule" output: rmarkdown::html_vignette vignette: > %\VignetteIndexEntry{Threads, random numbers and CRAN's two-core rule} %\VignetteEngine{knitr::rmarkdown} %\VignetteEncoding{UTF-8} --- ```{r setup, include = FALSE} knitr::opts_chunk$set(collapse = TRUE, comment = "#>") library(rzig) compiler <- find_zig(required = FALSE) has_zig <- isTRUE(compiler$supported) ``` R's C API may be called only from the thread that entered R, while Zig code can run pure computation on several threads. RZig provides parallel loops whose workers receive plain Zig data only, reductions whose result does not depend on the number of threads, random streams that follow `set.seed()`, and a thread count that respects the limits of R CMD check. The package below uses all of them; the build and the calls run when a supported Zig compiler is found (this copy `r if (has_zig) "ran" else "skipped"` them). ```{r create} path <- file.path(tempdir(), "parallelpkg") create_rzig_package(path, tests = FALSE) main_zig <- file.path(path, "src", "rzig", "src", "main.zig") ``` ## A parallel loop `rzig.parallelFor(ctx, count, &state, worker)` calls `worker(state, i)` for every index on worker threads. The state is a pointer to plain Zig data: the compiler rejects R objects, the call context, pointers to functions and error unions in it, so a worker cannot call R. The output slice is allocated before the loop, and each index writes its own element: ```{r loop, comment = ""} cat( "", "const Squares = struct {", " input: []const f64,", " output: []f64,", "", " fn run(state: *Squares, index: usize) void {", " state.output[index] = state.input[index] * state.input[index];", " }", "};", "", "/// The squares of the values, computed on worker threads.", "/// @param x A numeric vector.", "/// @return A numeric vector.", "/// @export", "pub fn squares(ctx: *rzig.Ctx, x: []const f64) rzig.Error![]f64 {", " const output = try ctx.alloc(f64, x.len);", " var state = Squares{ .input = x, .output = output };", " try rzig.parallelFor(ctx, x.len, &state, Squares.run);", " return output;", "}", sep = "\n", file = main_zig, append = TRUE ) ``` A user interrupt while the loop runs stops the workers after their current index and reaches R as an interrupt; a failed safety check on a worker (an index out of bounds, say) becomes an R error, and the session continues. ## A reproducible reduction A floating-point sum depends on the order of its terms. `rzig.parallelReduce` splits the indices into chunks that depend on the count alone and adds the chunk results in chunk order, so the sum is the same for one thread and for eight: ```{r reduce, comment = ""} cat( "", "const Values = struct {", " values: []const f64,", "", " fn value(state: *const Values, index: usize) f64 {", " return state.values[index];", " }", "", " fn add(left: f64, right: f64) f64 {", " return left + right;", " }", "};", "", "/// The sum of the values, reduced on worker threads.", "/// @param x A numeric vector.", "/// @return A number.", "/// @export", "pub fn parallel_sum(ctx: *rzig.Ctx, x: []const f64) rzig.Error!f64 {", " const state = Values{ .values = x };", " return rzig.parallelReduce(ctx, f64, x.len, &state, 0, Values.value, Values.add);", "}", sep = "\n", file = main_zig, append = TRUE ) ``` ## Random numbers `ctx.rng()` draws from R's generator on the thread that entered R, so draws follow `set.seed()` exactly as `runif()` does. Workers cannot use R's generator; `ctx.rngStreams()` draws a seed from R's stream and gives index `i` its own stream, so parallel draws follow `set.seed()` and do not depend on the number of threads (they are not the numbers of R's generator): ```{r rng, comment = ""} cat( "", "/// Uniform draws from R's generator.", "/// @param n The number of draws.", "/// @return A numeric vector.", "/// @export", "pub fn r_uniforms(ctx: *rzig.Ctx, n: usize) rzig.Error![]f64 {", " const generator = try ctx.rng();", " const out = try ctx.alloc(f64, n);", " for (out) |*slot| slot.* = generator.uniform();", " return out;", "}", "", "const Draws = struct {", " streams: rzig.RngStreams,", " output: []f64,", "", " fn draw(state: *Draws, index: usize) void {", " var stream = state.streams.stream(index);", " state.output[index] = stream.normal();", " }", "};", "", "/// Normal draws on worker threads, one stream per index.", "/// @param n The number of draws.", "/// @return A numeric vector.", "/// @export", "pub fn stream_normals(ctx: *rzig.Ctx, n: usize) rzig.Error![]f64 {", " const output = try ctx.alloc(f64, n);", " var state = Draws{ .streams = try ctx.rngStreams(), .output = output };", " try rzig.parallelFor(ctx, n, &state, Draws.draw);", " return output;", "}", sep = "\n", file = main_zig, append = TRUE ) document(path) ``` ## The number of threads The loops use one thread per core, at most 32 and at most one per index. `RZIG_NUM_THREADS` lowers the limit, and `OMP_THREAD_LIMIT` is respected. While R CMD check limits the cores, which CRAN does for every submission (`_R_CHECK_LIMIT_CORES_` set to anything but `false`), at most two threads run, so a package's examples and tests stay within CRAN's two-core rule without code of their own. ## Build and run ```{r build, eval = has_zig} build <- build_zig(path) library(build$package, character.only = TRUE, lib.loc = build$library) squares(c(1, 2, 3)) set.seed(1) x <- rnorm(1e5) Sys.setenv(RZIG_NUM_THREADS = "1") one <- parallel_sum(x) Sys.setenv(RZIG_NUM_THREADS = "8") eight <- parallel_sum(x) Sys.unsetenv("RZIG_NUM_THREADS") identical(one, eight) all.equal(one, sum(x)) set.seed(42) ours <- r_uniforms(3) set.seed(42) identical(ours, runif(3)) set.seed(7) first <- stream_normals(5) set.seed(7) identical(first, stream_normals(5)) ``` ```{r cleanup, include = FALSE} if (has_zig) unloadNamespace("parallelpkg") unlink(path, recursive = TRUE) ```