Transcendental functions off macOS (graphviz_pure_core::math)
Copied at build time from docs/porting/libm.md.
Measurements
Section titled “Measurements”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
hypotandcbrtare (they are now CORE-MATH-derived), so a CR port would not have helped. Apple’spowis close: its largest error is 0.5106 ulp, and it differs from CR only on near-halfway cases. Itssinandcosare off by up to 0.89 ulp. sqrtis IEEE everywhere.mul_addon wasm32 (compiler-builtins’ softwarefma) matched the hardwarefmaon 3,000,000 random, edge and subnormal-producing inputs.
Which function moves which corpus
Section titled “Which function moves which corpus”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 |
What Apple’s functions are
Section titled “What Apple’s functions are”Candidates were compared with the system results on the inputs above:
hypot: equalssqrt(fma(small, small, big * big))(withbig = max(|x|, |y|)) on every corpus and random input, and on extreme, subnormal and special-value pairs. The variantssqrt(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-sourceLibm-2026/Source/ARM/cbrt.ccompiled 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 thelibmcrate’s correctly roundedcbrt.pow,sin,cos,tan,atan2,acos,exp,log: no match. The published Libm’sSource/ARM/*.sfiles are empty, and the arm64libsystem_mroutines are hand-written assembly. Tried and rejected:- musl/fdlibm, with and without FMA. The fdlibm
__sin/__coskernels differ even for|x| < π/4. - musl’s pow, which is ARM optimized-routines (6,218 corpus mismatches). Apple’s
powindexes a 256-entry log table where ARM’s uses 128. - Apple’s Intel
sincostan.c, which is close fortan(2/205) and differs forsin/cos. - CR implementations. CR
atan2happens to fix the 3 fdp files.
- musl/fdlibm, with and without FMA. The fdlibm
Approach
Section titled “Approach”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
f64methods, so native output is unchanged. - On every other target, or with
--features graphviz-pure-core/portable-mathon macOS, they usemath::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 roundedpow(portable/pow/, see below). Apple’spowis within 0.511 ulp and differs from the correctly rounded result only near halfway cases, so a correctly roundedpowagrees with it far more often than musl’s.- everything else,
cbrtincluded: thelibmcrate 0.2.16. This is the same musl-derived code compiler-builtins uses for wasm32. Linux gets the same bits as wasm (glibc’spow/exppick FMA or non-FMA paths by CPU), andCargo.lockpins the results rather than the toolchain.
crates/graphviz-pure-core/src/math/tests.rs(macOS only) checks the portablehypotagainst the system libm on 1,000,000 random and edge inputs.
Results
Section titled “Results”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:
- Reproduce Apple’s
pow,sinandcos(andatan2) 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. - Correctly rounded functions (CORE-MATH, MIT). Measured on macOS with
portable-math(corpus of 2538 outputs, using thecore-mathcrate’s C build): CRpowalone gains 16 files (sfdp +13, neato +3) and loses none; CRatan2on top gains nothing (and lowers one fdp score); CRsin/cosis worse than musl (circo 274 of 280). Sopowwas ported and the others were not.
The CORE-MATH pow port
Section titled “The CORE-MATH pow port”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 results
Section titled “Portable results”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 |
Ratchet baselines per math profile
Section titled “Ratchet baselines per math profile”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.)
Linux and wasm32
Section titled “Linux and wasm32”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.mjsread inputs as JS strings, which replaced the non-UTF-8 bytes of the charset corpus files, so 9 of them differed from native. The newrenderAllBytesbinding (graphviz-pure-wasm) takes and returns raw bytes like the native harness (graphviz_pure_core::charset)..cargo/config.tomlused to setrustc-wrapper = "/opt/homebrew/bin/sccache", which does not exist on Linux; the containers usedRUSTC_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.
macOS: the gap is libm, all of it
Section titled “macOS: the gap is libm, all of it”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.
Linux: C does not agree with itself
Section titled “Linux: C does not agree with itself”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
qsortleaves equal elements in a different order than Apple’s, and itsrandis a different generator. With Apple’s (the port’s reproductions ingraphviz_pure_layout::cutil, exported bygraphviz-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 + cinto 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.
Provenance and licences
Section titled “Provenance and licences”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.