Porting a C++ hot loop from Rcpp to RZig

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.

Start from the behavior, not the wrapper

An Rcpp implementation might duplicate x, validate the inputs, and mutate the duplicate in place:

#include <Rcpp.h>

// [[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:

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: MIT",
    "Encoding: UTF-8"
  ),
  file.path(package_path, "DESCRIPTION")
)
rzig::use_rzig(package_path)

This creates the portable build files, the Zig source tree, and an initial generated wrapper. Put the native implementation in src/rzig/src/main.zig:

const std = @import("std");
const builtin = @import("builtin");
const rzig = @import("rzig");

pub const panic = if (builtin.is_test)
    std.debug.FullPanic(std.debug.defaultPanic)
else
    rzig.Panic;

/// Compute x + scale * y without changing either R input.
/// @param x The numeric vector to duplicate and update.
/// @param y A borrowed, read-only numeric vector.
/// @param scale The multiplier applied to y.
/// @return A new numeric vector containing the result.
/// @export
pub fn axpy(
    x: rzig.Mut([]f64),
    y: []const f64,
    scale: f64,
) rzig.Error!void {
    if (x.data.len != y.len) {
        return rzig.raise(
            "x and y must have equal lengths; got {d} and {d}",
            .{ x.data.len, y.len },
        );
    }

    for (x.data, y, 0..) |*result, value, index| {
        if (index % 100_000 == 0) try rzig.checkInterrupt();
        result.* += scale * value;
    }
}

comptime {
    rzig.registerModule(@This());
}

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.

Generate, install, and test

Regenerate the R wrapper whenever an exported Zig signature or its documentation changes:

rzig::document(package_path)
library_path <- file.path(tempdir(), "rzig-vignette-library")
dir.create(library_path)
system2(
  file.path(R.home("bin"), "R"),
  c(
    "CMD", "INSTALL",
    paste0("--library=", shQuote(library_path)),
    shQuote(package_path)
  )
)

The generated R function has the same three visible arguments:

library(yourpackage, lib.loc = library_path)

x <- c(1, 2, 3)
y <- c(10, 20, 30)
result <- axpy(x, y, 0.5)

stopifnot(identical(x, c(1, 2, 3)))
stopifnot(identical(result, c(6, 12, 18)))

try(axpy(1:2, c(1, 2, 3), 1))
# The native error is an R condition, and the session remains usable.
sum(1:10)

Remove the disposable package and installation when finished:

unloadNamespace("yourpackage")
unlink(c(package_path, library_path), recursive = TRUE)

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.

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.