Skip to content

Transcendental functions off macOS (graphviz_pure_core::math)

Copied at build time from docs/porting/libm.md.

The native aarch64-apple-darwin build matches C Graphviz byte for byte on the whole corpus. The wasm32 build did not: 279 of the 2526 corpus files rendered differently (counting multi-graph files; 197 without them). The cause is libm. On macOS, Rust's `f64::hypot`, `powf`, `sin`, ... call the system libm (`libsystem_m`), the same one C Graphviz uses. On wasm32 they come from compiler-builtins' musl port, and on Linux from glibc. These disagree with Apple in the last bit on some inputs, and one differing bit is enough to change a force-directed layout.

Arguments came from a native run over every corpus file with the math-trace feature (GV_MATH_TRACE=dir, distinct argument tuples, capped at 4,000,000 per function), plus 1,000,000 random inputs per function. Correctly rounded (CR) results were computed with MPFR 4.2.2. Apple means macOS 27 arm64 libsystem_m, and wasm means Rust’s wasm32 f64 methods run under node. Each cell counts calls whose result differs from Apple’s.

function corpus args Apple ≠ CR wasm ≠ CR wasm ≠ Apple random: Apple ≠ CR random: wasm ≠ Apple
hypot 4,000,000 637,069 (15.9%) 0 637,069 141,280 141,280
pow 4,000,000 5,224 (0.13%) 106,530 110,356 1,303 88,984
atan2 361,216 5,771 (1.6%) 68,614 69,391 2,900 159,839
sin 17,303 495 (2.9%) 467 566 33,390 36,977
cos 1,061,293 49,777 (4.7%) 34,160 45,709 35,761 38,432
tan 205 63 5 62 392,982 390,921
asin 6 0 0 0 30,984 49,874
acos 47,255 5,074 2,785 6,097 206,546 220,971
exp 135 0 14 14 1,719 66,536
log 4 0 0 0 71 11,527
log2 113 0 0 0 34 431
cbrt 2,273,583 177,250 (7.8%) 0 177,250 79,233 79,233

Findings:

  • Apple’s libm is not correctly rounded. The wasm hypot and cbrt are (they are now CORE-MATH-derived), so a CR port would not have helped. Apple’s pow is close: its largest error is 0.5106 ulp, and it differs from CR only on near-halfway cases. Its sin and cos are off by up to 0.89 ulp.
  • sqrt is IEEE everywhere. mul_add on wasm32 (compiler-builtins’ software fma) matched the hardware fma on 3,000,000 random, edge and subnormal-producing inputs.

On macOS with --features graphviz-pure-core/portable-math, one function at a time was swapped for the libm crate’s version, which is the code wasm32 runs. With every function swapped, the native build failed on the same 135 files the wasm build does. Swapping one function at a time:

function swapped files that change
hypot fdp 143, main 1 (fixed by the port below)
pow sfdp 127, neato 3, main 1
atan2 fdp 3
sin fdp 2
cos circo 1, fdp 3
tan, asin, acos, exp, log, log2 none

Candidates were compared with the system results on the inputs above:

  • hypot: equals sqrt(fma(small, small, big * big)) (with big = max(|x|, |y|)) on every corpus and random input, and on extreme, subnormal and special-value pairs. The variants sqrt(x*x + y*y), sqrt(fma(big, big, small²)), x*sqrt(1 + r²) and musl all differ on 4–40% of inputs.
  • cbrt: equals Apple’s open-source Libm-2026/Source/ARM/cbrt.c compiled with clang’s default FMA contraction, with 0 differences. Without contraction it differs on 1,038 of the corpus arguments. The port does not use that code (see Provenance): the corpus output is the same with the libm crate’s correctly rounded cbrt.
  • pow, sin, cos, tan, atan2, acos, exp, log: no match. The published Libm’s Source/ARM/*.s files are empty, and the arm64 libsystem_m routines are hand-written assembly. Tried and rejected:
    • musl/fdlibm, with and without FMA. The fdlibm __sin/__cos kernels differ even for |x| < π/4.
    • musl’s pow, which is ARM optimized-routines (6,218 corpus mismatches). Apple’s pow indexes a 256-entry log table where ARM’s uses 128.
    • Apple’s Intel sincostan.c, which is close for tan (2/205) and differs for sin/cos.
    • CR implementations. CR atan2 happens to fix the 3 fdp files.

graphviz_pure_core::math (crates/graphviz-pure-core/src/math/) is the single entry point. Every .hypot(, .powf(, .sin(, … call in the workspace became math::hypot(a, b) and so on, the way C writes hypot(a, b). The rewrite was mechanical, done with a syn-based rewriter.

  • On Apple targets the functions call the f64 methods, so native output is unchanged.
  • On every other target, or with --features graphviz-pure-core/portable-math on macOS, they use math::portable:
    • hypot: the formula above, with exact power-of-two scaling outside [2^-500, 2^500].
    • pow: a Rust port of CORE-MATH’s correctly rounded pow (portable/pow/, see below). Apple’s pow is within 0.511 ulp and differs from the correctly rounded result only near halfway cases, so a correctly rounded pow agrees with it far more often than musl’s.
    • everything else, cbrt included: the libm crate 0.2.16. This is the same musl-derived code compiler-builtins uses for wasm32. Linux gets the same bits as wasm (glibc’s pow/exp pick FMA or non-FMA paths by CPU), and Cargo.lock pins the results rather than the toolchain.
  • crates/graphviz-pure-core/src/math/tests.rs (macOS only) checks the portable hypot against the system libm on 1,000,000 random and edge inputs.

node scripts/wasm-e2e.mjs (see its header for the build steps) checks the wasm build against C’s hashes, rendering every graph in multi-graph files. The first graphviz_pure_core::math change (musl pow, corpus of 2526 files at the time) moved wasm from “before” to “after”:

corpus files before after native
main 514 512 513 514
neato 438 435 435 438
sfdp 297 170 170 297
circo 280 279 279 280
twopi 279 279 279 279
fdp 379 233 376 379
osage 178 178 178 178
patchwork 140 140 140 140
render 21 21 21 21
all 2526 2247 2391 2526

gv-diff e2e on macOS with portable-math failed on exactly the same 135 files as wasm, so the remaining gap was entirely pow, sin, cos and atan2. Two ways to close it:

  1. Reproduce Apple’s pow, sin and cos (and atan2) from the binary. This is the only way to get bit-identical output. It means reverse-engineering closed Apple code, which raises a licensing question for the project. Not done.
  2. Correctly rounded functions (CORE-MATH, MIT). Measured on macOS with portable-math (corpus of 2538 outputs, using the core-math crate’s C build): CR pow alone gains 16 files (sfdp +13, neato +3) and loses none; CR atan2 on top gains nothing (and lowers one fdp score); CR sin/cos is worse than musl (circo 274 of 280). So pow was ported and the others were not.

crates/graphviz-pure-core/src/math/portable/pow/ ports src/binary64/pow/pow.c, pow.h, dint.h and qint.h from CORE-MATH (as vendored by core-math-sys 1.4.0) line by line: the double-double first phase, the 128-bit second phase with the exact/midpoint test of Lauter and Lefèvre, and the 256-bit third phase. tables.rs was converted from the C initializers by a script. Differences from the C: only rounding to nearest (Rust has no other mode), no floating-point flags or errno, shifts that are undefined in C by 64 or more use the hardware’s modulo-64 behaviour, and the “unexpected worst case” exit returns the 256-bit result instead. add_dint_11 keeps the low limb of its destination, as the C does.

Checked bit for bit against the C cr_pow, out of tree:

inputs count differ
random bit patterns, ranges typical of the corpus, exact and midpoint cases, x near 1 100,000,000 0
(of which second phase / exact-midpoint test / third phase) 358,025 / 93,638 / 131,720
corpus pow arguments (math-trace) 4,000,000 0
random inputs with the C built as POW_ITERATION=8 and the port forced likewise (third phase only) 10,000,000 0

The debug build (overflow-checked shifts and arithmetic) ran 22,000,000 of these without a panic. pow/tests.rs keeps 32 C results, 8 per phase, and the special values. The ported output equals the C build’s on all 2538 corpus outputs (gv-diff e2e --hashes).

portable-math on macOS, Linux and wasm32 give the same output (see Linux below). Identity with C, by corpus (gv-diff e2e):

corpus files Apple libm portable, musl pow portable, CORE-MATH pow
main 526 526 525 525
neato 438 438 435 438
sfdp 297 297 170 183
circo 280 280 279 279
twopi 279 279 279 279
fdp 379 379 376 376
osage 178 178 178 178
patchwork 140 140 140 140
render 21 21 21 21
all 2538 2538 2403 2419

graphviz_pure_core::math::PROFILE is Profile::Apple when the platform libm is used (Apple targets without portable-math) and Profile::Portable otherwise. Every corpus ratchet keeps one baseline per profile: gv-diff maps --baseline X.json to X.portable.json on portable builds (graphviz_pure_diff::profile_baseline), for reading and for --update, and prints which file it used. The Apple files stay the record of exact C parity; the portable files guard regressions on Linux and wasm, and their numbers document the gap to C. scripts/update-baselines.sh [--portable] refreshes all of them for one profile.

These counts are from when the portable profile was introduced; the corpora have grown since. The current figures are on the documentation site’s Fidelity page (scripts/site-data.py).

ratchet baseline Apple portable
main (dot) tests/golden/baseline.json exact 526/526 525/526
neato neato-baseline.json 438/438 438/438
sfdp sfdp-baseline.json 297/297 208/297
circo circo-baseline.json 280/280 279/280
twopi twopi-baseline.json 280/287 279/287
fdp fdp-baseline.json 383/383 379/383
osage osage-baseline.json 178/178 178/178
patchwork patchwork-baseline.json 140/140 140/140
render render/baseline.json svg 547/547, xdot 547/547 same
e2e e2e-baseline.json 2538/2538 2419/2538

(The layout ratchets count gv-diff’s geometric “exact” files and also guard the total score; twopi’s 7 non-exact files are not math-related.)

Checked in Ubuntu 24.04 containers (colima on an arm64 Mac, Rust 1.99.0), with the worktree copied in, against the portable baselines generated on macOS. The hash comparison covers every corpus output through the whole pipeline, matching C or not:

platform cargo test --workspace baselines regenerated by scripts/update-baselines.sh gv-diff e2e --hashes vs macOS portable-math
Linux aarch64 (native VM) pass (debug) byte-identical to the committed *.portable.json all 2538 hashes identical
Linux x86_64 (QEMU user emulation) pass (--release; about 2 hours) not run (several more hours under QEMU) all 2538 hashes identical
wasm32 (node, scripts/wasm-e2e.mjs --hashes) n/a n/a all 2538 hashes identical

So Linux and wasm compute the same bits as macOS with portable-math: no platform dependence was found (the math goes through graphviz_pure_core::math, sqrt/mul_add are exact everywhere, and the libm crate has no CPU-dependent paths on these targets). Two harness issues were fixed:

  • scripts/wasm-e2e.mjs read inputs as JS strings, which replaced the non-UTF-8 bytes of the charset corpus files, so 9 of them differed from native. The new renderAllBytes binding (graphviz-pure-wasm) takes and returns raw bytes like the native harness (graphviz_pure_core::charset).
  • .cargo/config.toml used to set rustc-wrapper = "/opt/homebrew/bin/sccache", which does not exist on Linux; the containers used RUSTC_WRAPPER= (empty). The setting has since moved out of the repository.

CI (.github/workflows/ci.yml, ubuntu-latest): the verify job runs fmt, clippy, cargo test --workspace (so every ratchet against its portable baseline) and the wasm32 check. The wasm-e2e job installs wasm-bindgen-cli at the wasm-bindgen version in Cargo.lock (0.2.129, cached), builds the package, runs scripts/wasm-e2e.mjs against e2e-baseline.portable.json, and cmps the wasm output hashes with native Linux’s.

Is the gap only libm? C with the port’s math functions

Section titled “Is the gap only libm? C with the port’s math functions”

The claim above, that every portable difference comes from libm, can be tested directly: run C with the port’s portable math functions and compare. crates/graphviz-pure-portable-libm is a small library that replaces every transcendental function C Graphviz imports (sin, cos, tan, asin, acos, atan, atan2, exp, log, log2, log10, pow, hypot, cbrt, and clang’s combined __sincos_stret) with the ones graphviz_pure_core::math uses off macOS. It is loaded into the unmodified C dot with DYLD_INSERT_LIBRARIES on macOS (dyld interposition) and LD_PRELOAD on Linux; scripts/dot-portable-libm.sh does this. The remaining libm imports (sqrt, sqrtf, fmod) are exact in IEEE arithmetic.

gv-diff e2e --generate --runs 5 --c-dot scripts/dot-portable-libm.sh --golden tests/golden/e2e.libm-matched.json records C’s output with the portable functions. Against it:

build vs C with Apple’s libm (e2e.json) vs C with the portable functions (e2e.libm-matched.json)
macOS, Apple profile 2591 / 2591 —
macOS portable-math, Linux 2471 / 2591 2592 / 2592
wasm32 (scripts/wasm-e2e.mjs --golden tests/golden/e2e.libm-matched.json) 2471 / 2591 2592 / 2592

The denominators differ because each oracle has its own set of stable inputs: an input counts only if C gave the same output on every run of that oracle, and C with Apple’s libm and C with the portable functions are unstable on slightly different files (12 and 11). The same holds for the Linux table below.

So with the same math functions on both sides, the portable build and the browser build are byte-identical to C on every stable input, including all 119 files that differ from stock C. The ratchet e2e_svg_identity_with_matched_libm_does_not_regress (crates/graphviz-pure-diff/tests/e2e_fidelity.rs, portable builds only, so it runs in CI) keeps it so. 11 inputs are unstable in C (its output changed within 5 runs): the twopi arrows graphs and a few fdp/sfdp files whose C layout depends on heap addresses (issues #34, #41). --runs 5 replaced the two-run check because two runs of twopi/fx_arrows sometimes agree by chance.

scripts/c-oracle-linux/ builds C Graphviz 16.1.0 on Linux (Debian, arm64, in Docker) the way Homebrew builds it on macOS: clang, LLVM’s libc++ for the C++ parts, GTS for triangulation, and the same three plugins. run.sh records C’s output three ways and compares the port’s Linux build with each (gv-diff e2e, inputs stable over 5 runs):

C on Linux vs stock C on macOS vs the port (portable)
stock (glibc libm, qsort, rand) 2005 / 2586 2005 / 2590
with the port’s math functions 2150 / 2589 2150 / 2591
with the port’s math functions and Apple’s qsort, rand, srand (feature apple-libc) 2488 / 2589 2488 / 2590

Stock C on Linux matches stock C on macOS on 77.5% of the corpus; the port’s portable build (the browser build) matches stock C on macOS on 95.4%. The Linux differences come from three sources:

  • libm (glibc’s functions are not Apple’s): 145 files.
  • libc (338 files): glibc’s qsort leaves equal elements in a different order than Apple’s, and its rand is a different generator. With Apple’s (the port’s reproductions in graphviz_pure_layout::cutil, exported by graphviz-pure-portable-libm --features apple-libc) circo, osage, patchwork, sfdp and twopi match on every file.
  • the rest: 102 files, 97 of them neato. The ones checked differ by 0.01 in a spline control point, a last-bit rounding difference with every node in the same place; the likely cause is the compilers contracting a*b + c into fused multiply-adds in different places (Apple clang 21 against Debian clang 14 and 19). Not confirmed.

Two things that were checked and are not the cause: the clang version (14 and 19 give the same results), and long double, which is 128-bit on Linux aarch64 and 64-bit on Apple arm64 (the only use, neato’s stress_majorization_kD_mkernel accumulator, makes no difference on the corpus). The first Linux build lacked GTS, which changes neato’s and sfdp’s overlap removal; the Dockerfile now checks that GTS is configured.

Everything under crates/graphviz-pure-core/src/math/:

file what origin licence
mod.rs the math::* entry points, PORTABLE, PROFILE written for this port project licence
portable/mod.rs re-exports of the libm crate written for this port project licence
portable/pow/ correctly rounded pow Rust port of CORE-MATH src/binary64/pow/ (Tom Hubrecht, Paul Zimmermann; Copyright (c) 2022-2025 CERN and Inria), tables converted mechanically; licence text kept in pow/mod.rs MIT
portable/hypot.rs Apple-equivalent hypot an original formula identified by black-box comparison with the system libm; no code copied project licence
trace.rs math-trace argument logging written for this port project licence
tests.rs, portable/pow/tests.rs portable hypot vs system libm; pow regression values from the C written for this port project licence
libm crate 0.2.16 (dependency) atan2, sin, cos, tan, asin, acos, exp, log, log2, cbrt rust-lang/compiler-builtins: musl/fdlibm ports; cbrt is CORE-MATH’s (Alexei Sibidanov) MIT

No Apple source is shipped. An earlier version ported Apple Libm’s Source/ARM/cbrt.c, which has no licence header and comes from an APSL release; it was replaced by the libm crate’s cbrt. On macOS with portable-math, the swap left every corpus output unchanged: the per-file scores of all eight layout ratchets, the render ratchet, and the gv-diff e2e --hashes hashes of all 2538 outputs were identical before and after. Only pathplan’s cubic solver calls cbrt.

Measurement tools: scripts/libm-probe.c (Apple vs MPFR) and the math-trace feature.