--- title: "Exact summation" output: rmarkdown::html_vignette vignette: > %\VignetteIndexEntry{Exact summation} %\VignetteEngine{knitr::rmarkdown} %\VignetteEncoding{UTF-8} --- ```{r, include = FALSE} knitr::opts_chunk$set(collapse = TRUE, comment = "#>") ``` ```{r setup} library(zuexact) ``` ## Why `sum()` differs between machines Every floating-point addition rounds. Base R's `sum()` keeps its running total in a `long double` to round less often. On x86-64 Linux and Windows that is an 80-bit number with 64 bits of precision. On Apple Silicon, on Windows ARM64, and on R built without long doubles, it is an ordinary double. So the same call gives different answers on different machines: ```{r} x <- c(1e16, 1, -1e16) sum(x) # 1 with 80-bit long doubles, 0 without capabilities("long.double") ``` Neither kind of accumulator gets this one right: ```{r} sum(c(1e20, 1, -1e20)) ``` The order of the terms matters too, because each partial sum is rounded: ```{r} y <- c(1, 1e100, 1, -1e100) c(sum(y), sum(rev(y))) ``` ## One rounding, at the end zuexact adds the numbers exactly, in a superaccumulator wide enough to hold any sum of doubles, and rounds once. The result is the correctly rounded value of the true sum. Because nothing is rounded along the way, order, platform and build cannot change it. ```{r} xsum(x) xsum(c(1e20, 1, -1e20)) c(xsum(y), xsum(rev(y))) ``` The superaccumulators are Radford Neal's 'xsum' library (Neal, 2015, ). They make exact summation cheap: on long vectors `xsum()` costs about one and a half times `sum()`. Means divide the exact sum by the count, with one rounding: ```{r} z <- c(1e15, -1e15, 0.1) mean(z) xsum_mean(z) ``` Dot products include the rounding error of each product, computed exactly with a fused multiply-add, so they are correctly rounded too: ```{r} a <- c(1e8 + 1, 1) b <- c(1e8 - 1, -1e16) sum(a * b) xsum_dot(a, b) ``` `xsum_cumsum()`, `xsum_rowsums()`, `xsum_colsums()` and `xsum_by()` (the exact `rowsum()`) round each of their results once in the same way. ## Special values R tells `NA` from other `NaN`s, and base `sum()` propagates them in ways that depend on the platform. zuexact applies one table everywhere: | Input contains | Result | | --- | --- | | nothing | `0` | | any `NA` | `NA` | | no `NA`, any other `NaN` | `NaN` | | both `Inf` and `-Inf` | `NaN` | | `Inf` (and finite values) | `Inf` | | `-Inf` (and finite values) | `-Inf` | | finite values whose exact sum is out of range | `Inf` or `-Inf` | | only zeros, of any sign | `0` (never `-0`) | ```{r} xsum(c(1, NaN, NA)) xsum(c(Inf, -Inf)) xsum(c(1e308, 1e308)) xsum(c(1e308, 1e308, -1e308)) # the intermediate overflow does not matter ``` ## Sums in pieces, and across processes An accumulator holds an exact sum in progress. Add data to it in pieces, in any order, and read the rounded value when you need it: ```{r} acc <- xsum_acc() acc <- xsum_add(acc, c(1e20, 1)) acc <- xsum_add(acc, -1e20) xsum_value(acc) ``` Accumulators are ordinary R values: every operation returns a new one, and they serialise. An accumulator built in another process comes back and merges, and the result is the same bits as summing everything at once: ```{r} set.seed(1) v <- (2 * runif(1e5) - 1) * 10^sample(-15:15, 1e5, TRUE) chunks <- split(v, rep(1:8, length.out = length(v))) accs <- lapply(chunks, xsum_acc) # or parallel::mclapply(), mirai, future identical(xsum_value(Reduce(xsum_merge, rev(accs))), xsum(v)) ``` The same pattern sums Arrow record batches, DuckDB result chunks or files read in pieces. `xsum_merge(a, xsum_negate(b))` is the exact difference of two sums, rounded once, which is the comparison reconciliation needs: ```{r} xsum_value(xsum_merge(xsum_acc(c(0.1, 0.2)), xsum_negate(xsum_acc(0.3)))) ``` ## From C, in other packages zuexact registers a table of C functions so that other packages' compiled code can use the same accumulators. In the consumer's `DESCRIPTION`: ``` LinkingTo: zuexact Imports: zuexact ``` and in its C: ```c #include const zuexact_api *zx = zuexact_api_get(); double acc[128]; /* zx->acc_size bytes, aligned as a double */ zx->init(acc); zx->addv(acc, x, n); /* exact */ zx->add_dot(acc, x, y, n); /* exact, products included */ double s = zx->round(acc); /* correctly rounded */ ``` An accumulator is plain memory owned by the caller, so the functions are safe from any thread with its own accumulator. The table follows IEEE rules for special values; the R functions add R's rules on top. See `inst/include/zuexact.h` for the full interface and its versioning rules.