--- title: "Porting a C++ hot loop from Rcpp to RZig" output: rmarkdown::html_vignette vignette: > %\VignetteIndexEntry{Porting a C++ hot loop from Rcpp to RZig} %\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) ``` RZig is most useful when an R package has a small, computationally expensive loop that needs native performance but does not otherwise need C++. This vignette ports an `axpy`-style operation: return `x + scale * y` without modifying either R input. Every step up to the installation runs in R without a Zig compiler; the installation and the calls run when a supported compiler is found. ## Start from the behavior, not the wrapper An Rcpp implementation might duplicate `x`, validate the inputs, and mutate the duplicate in place: ```cpp #include // [[Rcpp::export]] Rcpp::NumericVector axpy_rcpp( Rcpp::NumericVector x, const Rcpp::NumericVector& y, double scale ) { if (x.size() != y.size()) Rcpp::stop("x and y must have equal lengths"); Rcpp::NumericVector result = Rcpp::clone(x); for (R_xlen_t i = 0; i < result.size(); ++i) { if (i % 100000 == 0) Rcpp::checkUserInterrupt(); result[i] += scale * y[i]; } return result; } ``` The important contract is not the C++ class structure. It is that `y` is read only, `x` is duplicated before mutation, unequal lengths become an R error, and long computations remain interruptible. ## Scaffold the Zig-backed package Set the package path explicitly, then run `use_rzig()` once. This disposable example stays inside R's session temporary directory; for a real project, replace `package_path` with its explicit package root: ```{r scaffold} package_path <- tempfile("yourpackage-", tmpdir = tempdir()) dir.create(package_path) writeLines( c( "Package: yourpackage", "Type: Package", "Title: A Small RZig Example", "Version: 0.0.1", "Authors@R: person('Your', 'Name', email = 'you@example.org', role = c('aut', 'cre'))", "Description: Demonstrates a native implementation written in Zig.", "License: GPL-3", "Encoding: UTF-8" ), file.path(package_path, "DESCRIPTION") ) scaffold <- use_rzig(package_path) scaffold ``` `use_rzig()` creates the portable build files, the Zig source tree with the framework, a starter `src/rzig/src/main.zig`, and the generated R wrapper for the starter function. The Zig implementation of the port is shipped with RZig as `inst/examples/axpy.zig`; copying it over the starter file is the only edit the port needs: ```{r write-zig, comment = ""} main_path <- file.path(package_path, "src", "rzig", "src", "main.zig") file.copy(system.file("examples", "axpy.zig", package = "rzig"), main_path, overwrite = TRUE) cat(readLines(main_path), sep = "\n") ``` `rzig.Mut([]f64)` is the direct expression of copy-on-modify semantics. RZig duplicates and protects `x` before Zig receives writable storage. The `y` slice borrows R's numeric storage as read-only memory. Because this function returns `void`, the generated boundary returns the mutated duplicate automatically. The function contains no `SEXP`, `PROTECT`, C++ exception, or registration table. The types and `@export` marker supply enough information for RZig to generate those parts. ## Inspect what R will see `scan_exports()` reads the Zig source and lists every exported function with its R arguments and the R value of each parameter and of the result: ```{r scan} scan_exports(package_path) ``` ## Generate the bindings `document()` writes the Zig manifest, the R wrapper and the `NAMESPACE` block. Run it whenever an exported Zig signature or its documentation changes. The returned object holds the generated texts: ```{r document} bindings <- document(package_path) cat(bindings$wrappers) cat(bindings$namespace) ``` `rzig_status()` checks every step and tells whether the generated bindings match the current Zig source and whether a supported Zig compiler is available for the installation: ```{r status} rzig_status(package_path) ``` ## Check the signature before compiling `rzig_status()` also rejects, before any compilation, the signatures the boundary cannot convert. A single-precision argument is one such case: ```{r unsupported} broken_path <- tempfile("brokenpackage-", tmpdir = tempdir()) dir.create(broken_path) writeLines(c("Package: brokenpackage", "Version: 0.0.1"), file.path(broken_path, "DESCRIPTION")) invisible(use_rzig(broken_path)) broken_main <- file.path(broken_path, "src", "rzig", "src", "main.zig") cat("/// @export\npub fn half(x: f32) f32 {\n return x / 2;\n}\n", file = broken_main, append = TRUE) rzig_status(broken_path) ``` ## Build and call `build_zig()` compiles and installs the package from a copy in the session temporary directory. It runs the checks of `rzig_status()` first, keeps the complete installation log, and on failure reports the first errors of the Zig compiler, the C compiler, the linker or the load test with their files and lines. The build needs a Zig compiler of a supported release series. The chunks below run when `find_zig()` finds one; this rendered copy of the vignette `r if (has_zig) "ran" else "skipped"` them. ```{r install, eval = has_zig} build <- build_zig(package_path) build package_name <- "yourpackage" loadNamespace(package_name, lib.loc = build$library) axpy <- getExportedValue(package_name, "axpy") ``` The generated R function has the same three visible arguments: ```{r call, eval = has_zig} x <- c(1, 2, 3) y <- c(10, 20, 30) result <- axpy(x, y, 0.5) x result try(axpy(1:2, c(1, 2, 3), 1)) sum(1:10) ``` The native error is an R condition, and the session remains usable. Integer vectors are rejected for numeric slices and mutable numeric inputs. Convert deliberately with `as.numeric()` in R rather than relying on an implicit native allocation. ```{r cleanup, include = FALSE} if (has_zig) unloadNamespace(package_name) unlink(c(package_path, broken_path), recursive = TRUE) if (has_zig) unlink(build$library, recursive = TRUE) ``` ## What changes in the port Rcpp's `NumericVector` combines an R object, indexing, allocation, and mutation in one C++ class. RZig separates those capabilities. A borrowed `[]const f64` cannot mutate its input. `rzig.Mut([]f64)` duplicates before exposing writable storage. `rzig.Ctx` is used only when Zig-owned scratch or result memory is needed. Errors are explicit `rzig.Error` values, and the outer boundary turns them into R conditions after Zig cleanup has finished. Generated packages compile in Zig's `ReleaseSafe` mode. Bounds checks and integer-overflow checks therefore remain enabled in installed code. A failure is routed through RZig's panic boundary instead of silently corrupting adjacent R memory. ## Measure the boundary separately Benchmark the algorithm and the language boundary as separate questions. For a hot loop, compare equivalent copy and mutation semantics, use the same compiler optimization level, warm each implementation, randomize execution order, and report distributions rather than one elapsed time. The RZig repository includes `tests/bench.R`, which checks and times equivalent RZig, handwritten C, and Rcpp round-trip copies at lengths 1, 1,000, and 1,000,000. It records medians and interquartile ranges from randomized batches. This isolates conversion overhead from the workload being ported. ## Port incrementally Move one native function at a time and keep the surrounding R interface stable. Tests should cover empty and large inputs, missing values, wrong R types, length mismatches, and continued session use after each error. C libraries can coexist with Zig during a gradual conversion. C++ classes and templates usually need to be redesigned as Zig structs and explicit data flow rather than translated line by line.