Skip to contents

zufast is consumed by including a header. This vignette states what a package that does so can rely on, and the two places where zufast deliberately differs from base R.

The consumption mode

A consumer declares

LinkingTo: zufast

and nothing else: no Imports:, no importFrom(), no configure, no PKG_LIBS. R puts zufast’s include/ directory on the compiler’s search path, so both #include <zufast.h> and #include "zufast.h" work.

Every function in the headers is static inline. Each translation unit that includes a zufast header gets its own copy of what it uses and nothing of what it does not; the compiler discards the rest. There is no implementation macro, no “exactly one translation unit” rule, and nothing to link. This is what makes the primitives fast: a call to zuf_load_be32() or zuf_write_i64() inlines into the caller’s loop, which an indirect call through R_GetCCallable() would forbid.

zufast need not be installed when the consumer runs, only when it is built. The fixture package in zufast’s repository proves this on every change by uninstalling zufast and running the fixture’s tests again.

Linkage rules

  1. Every symbol zufast emits is static, its own functions and the vendored libraries’ alike. A consumer’s shared object contains no global zuf_, ffc_, XXH or Ryu symbol, so two packages that both use zufast, loaded into one R session, can never bind to each other’s copies.
  2. Functions are static inline, never plain static, so a unit that includes a header without calling everything in it is not warned at under -Wall -Wextra -Werror. The vendored libraries are compiled with -Wunused-function, -Wpedantic and -Wmissing-field-initializers relaxed, and only inside the vendored headers.
  3. Consumers compile with PKG_CFLAGS = $(C_VISIBILITY), as the zu family does. It is defence in depth, not load-bearing: rule 1 is what prevents the collision.
  4. The headers reference nothing R CMD check forbids in compiled code: no printf, abort, exit, assert, rand, stdout or stderr. A violation would surface as a NOTE in every consumer, so it is audited in zufast’s CI by compiling a unit that calls every public function with R’s own flags.

Versions

Because nothing crosses a shared-object boundary, there is no ABI: a consumer built against an older zufast keeps working when zufast is updated, because it carries its own copy of the code.

Names prefixed zuf_int_ or ZUF_INT_ are internal: they are visible in the installed headers, but are not part of the public interface. Neither is anything reached by including a vendored header under zufast/vendor/ directly.

ZUFAST_VERSION_MAJOR, ZUFAST_VERSION_MINOR, ZUFAST_VERSION_PATCH, ZUFAST_VERSION_NUMBER and the string ZUFAST_VERSION let a consumer assert the version it is compiled against:

#include <zufast/version.h>
#if ZUFAST_VERSION_NUMBER < 100
#  error "this package needs zufast 0.1.0 or later"
#endif

zufast’s own copy of the headers reports itself from R:

fast_info()[c("version", "vendored")]
#> $version
#> [1] "0.1.0"
#> 
#> $vendored
#>        ffc        ryu     xxhash 
#> "26.09.01"     "v2.0"    "0.8.4"

The result model

Parsers return a zuf_result: a status and the position where parsing stopped. Its ptr follows std::from_chars: on ZUF_OK and ZUF_ERR_RANGE it is one past the last byte consumed, on ZUF_ERR_INVALID it equals first. A parser never requires the whole span to be consumed, so a reader that needs “the entire cell is a number” checks r.ptr == last. ZUF_OK is 0 and no status is negative, so if (r.status) means “not success”.

Formatters return the length the value needs, write at most cap bytes, never NUL-terminate, and measure when cap == 0. The ZUF_*_MAX_CHARS macros are capacities that always suffice.

Two differences from base R

Parsing doubles: correctly rounded, not as.numeric()

zuf_parse_f64() returns the double nearest to the decimal value, ties to even, on every platform and in every rounding mode: the algorithm of fast_float, which is also GCC’s std::from_chars. R’s as.numeric() accumulates digits in long double and scales by a power of ten; it is not correctly rounded, and its result depends on whether R was built with long double. Where R has an 80-bit long double, as on x86-64, the two differ in the last bit for roughly one ordinary decimal in five thousand, and "0.799012" below is one of them. Where it has none, as on Apple Silicon, they agree on decimals of up to fifteen significant digits and differ on many of 16 or 17:

x <- "0.799012"
fast_parse_double(x) == as.numeric(x)
#> [1] FALSE
sprintf("%a", c(zufast = fast_parse_double(x), base = as.numeric(x)))
#> [1] "0x1.991819d2391d5p-1" "0x1.991819d2391d6p-1"

zufast offers no “R-compatible” mode. A consumer that needs as.numeric()’s exact results calls R_strtod().

Formatting doubles: shortest round trip, not fifteen digits

zuf_format_f64() writes the fewest significant digits that parse back to exactly the same double, in the notation of ECMAScript’s Number::toString: positional for decimal exponents from -6 to 20, d.ddde+x otherwise. R’s as.character() writes fifteen significant digits, which is a different thing: it can print two different doubles identically, and print one double with digits it does not need.

x <- c(0.1 + 0.2, 0.3, 1e21, 1e-7, 1 / 3)
data.frame(zufast = fast_format_double(x), base = as.character(x))
#>                zufast              base
#> 1 0.30000000000000004               0.3
#> 2                 0.3               0.3
#> 3               1e+21             1e+21
#> 4                1e-7             1e-07
#> 5  0.3333333333333333 0.333333333333333
identical(fast_parse_double(fast_format_double(x)), x)
#> [1] TRUE