Byte-identity¶
rust-MAFFT's output matches C MAFFT 7.526 byte-for-byte across BAliBASE 3 (1930/1930 fixtures) for every supported mode, when both are built on the same platform. This page explains why that's the bar, what it cost to hit it, and why the "same platform" qualifier is not a hedge but a property of the C reference itself.
Why hold to byte-identity¶
MAFFT is the de facto MSA reference. Every downstream tool and pipeline — phylogenetics, structure prediction, comparative genomics — treats its output as ground truth. A Rust port that's "close" forces every downstream pipeline to choose:
- Re-validate the rewrite on their own data, accepting that alignment differences may cascade through downstream analyses.
- Maintain a parallel C dependency so they can verify rust-MAFFT isn't introducing drift.
Both are non-starters for adoption. Byte-identity removes the choice: the Rust binary is a drop-in replacement, no warm-up trust period required.
Byte-identity is per platform build¶
C MAFFT 7.526 is not bit-identical across CPU architectures. The
upstream Makefile compiles with -O3 and no -ffp-contract setting,
which leaves the floating-point contraction policy to the compiler:
| C reference build | Compiler | Fused multiply-adds per binary |
|---|---|---|
arm64 (Apple Silicon, make in-tree) |
clang | on the order of a thousand — 1025 in the census that motivated this section; 882 scalar fmadd-family (1139 including vector fmla/fmls) with Apple clang 21, ~1140 in CI |
x86-64 (make in-tree on Linux) |
gcc | 0 |
clang fuses a*b + c into a single fmadd (one rounding); gcc on
x86-64 emits a multiply followed by an add (two roundings). Both are
valid C, and the resulting binaries disagree on some inputs. The known
sentinel case is --bl 50 --retree 2 --maxiterate 0 on the 36-sequence
mafft-upstream/test/sample:
| Platform | C MAFFT columns | rust-MAFFT columns (default policy) |
|---|---|---|
| arm64 / clang | 712 | 712 |
| x86-64 / gcc | 738 | 738 |
So "byte-identical to C" is always relative to a C build made on the same platform, and rust-MAFFT tracks that platform rather than picking one universal answer.
The reference build¶
The reference is defined precisely: the pinned mafft-upstream source,
built in-tree with the upstream Makefile's default flags
(make -C mafft-upstream/core), on the platform you run on. No fixture
may be generated from a downloaded binary — a bioconda, Homebrew or
prebuilt-tarball mafft was compiled with someone else's toolchain and
flags, and it is not what the tests, the FFI cross-validation or CI compare
against.
The fp policy module¶
mafft_types::fp centralises the decision:
mafft_types::fp::CONTRACTS_FMA— aconst bool. Defaults totrueonaarch64(mirroring clang's behaviour there) andfalseelsewhere.mafft_types::fp::fmadd(a, b, c)— computesa*b + cwith a single rounding whenCONTRACTS_FMAis set, and as two separate operations otherwise. Every hot loop that has to match a contracted C expression goes through it rather than callingf64::mul_adddirectly.- Cargo features
fp-contract-fma/fp-contract-noneoverride the default. They are defined onmafft-typesand forwarded by every crate in the workspace, socargo test -p mafft-core --features fp-contract-nonebuilds the whole dependency graph under the x86-64 policy on any host. (--featurescannot be passed at the virtual-workspace root; use-p <crate>.)
Fixture convention¶
Where a C-canonical fixture differs between the two policies it is
committed twice, as <name>.fma and <name>.nofma, and the test
helper fixture_path_fp(name) picks the one matching CONTRACTS_FMA.
Fixtures without a suffix are identical under both policies.
Each pair is described by a line in
crates/mafft-core/tests/fixtures/policy_fixtures.tsv
(fixture_basename<TAB>input_relpath<TAB>mafft_args) and regenerated by
scripts/regen_policy_fixtures.sh, which detects the policy from
uname -m (arm64/aarch64 => .fma, else .nofma; override with
MAFFT_FP_POLICY=fma|nofma):
make -C mafft-upstream/core
scripts/regen_policy_fixtures.sh mafft-upstream/scripts/mafft check # diff against the checked-in variant
scripts/regen_policy_fixtures.sh mafft-upstream/scripts/mafft write # overwrite this host's variant
A .fma fixture therefore comes from the in-tree build on an arm64 host
and a .nofma one from the in-tree build on an x86-64 host; never from
the other, and never from a binary you did not build from the pinned
source.
What CI checks¶
- The
Build (x86-64)andBuild (arm64)jobs each build the C submodule in-tree and run the full suite (lib, integration and FFIcross_validate_*tests) against it, so the claim is checked on both reference platforms. Each job prints an FMA census of the C binaries it just built and runs the--bl 50sentinel above, diffingmafft-rsagainst the same-platform Cmafftwrapper. It then runsscripts/regen_policy_fixtures.sh ... check, regenerating every policy-sensitive fixture from that in-tree C build and failing on any byte difference from the checked-in variant for the runner's policy (.nofmaon x86-64,.fmaon arm64) — so the committed fixtures are continuously re-derived from the reference definition rather than trusted. - A
policy-cross-checkjob runs the fixture-based (non-FFI) test binaries on the arm64 runner with--features fp-contract-none, which checks the.nofmafixtures from an arm64 host. The FFI tests are deliberately excluded there: they compare against the C compiled on the same runner, which contracts, so underfp-contract-nonethe two sides would be running different policies.
Design decisions that fall out¶
No global state¶
The C code uses ~400 extern globals. The Rust modules use owned
ScoringContext, Topology, Profile structs passed explicitly. The
engine is reentrant, embeddable, and trivially thread-safe at the
call-site boundary.
This wasn't done for ergonomic reasons — it was forced by byte-identity.
C MAFFT's globals get assigned in subtle orderings (e.g., gapfaclocal
depends on whether --allowshift is parsed before or after
unalignlevel). Replicating that in Rust required making the
"set-it-now" semantics explicit.
Hand-ported Cooley-Tukey FFT¶
mafft-fft/src/fft_c_compat.rs is a hand port of MAFFT's core/fft.c,
matching the C rounding exactly. Off-the-shelf FFT libraries (rustfft,
realfft, FFTW) vary in butterfly grouping order, producing 1-ULP
correlation differences. On flat-landscape similarity matrices those
ULPs flip FFT-anchor selection — which propagates into different
alignment segmentation and ultimately different output bytes.
FP order matters¶
In several hot loops the C compiler's contraction policy decides the
result: with clang on arm64, a*b + c becomes a single fused
multiply-add (one rounding); with gcc on x86-64 it stays two operations
(two roundings). rust-MAFFT reproduces whichever the host's C build
does through mafft_types::fp::fmadd, which fuses only when
CONTRACTS_FMA is set (see above).
One concrete case (calcW in mafft-tree/src/weighting.rs), matching
clang's fmadd d2, b, c, d3; fmadd d0, a, b, d2 sequence:
// a*b + (b*c + a*c), fused exactly where clang fuses it; under the
// x86-64 policy `fmadd` degrades to the unfused mul + add gcc emits.
let s = fmadd(a, b, fmadd(b, c, a * c));
Doing this as a naive a*b + b*c + a*c on arm64 produces visibly
different weights on BB40043, BB30018, BB40010, BB30010,
BB40004 — and doing it fused on x86-64 would drift from the gcc build
the same way.
Tie-break ordering¶
Tied scores in Needleman-Wunsch / Smith-Waterman traceback are resolved
in C via if (new >= best) (note the >= — equality favors the new
candidate). Several constraint-mode scanners use a different inequality
(if (new > best)). Mixing them up flips one trace direction on one
column, which propagates downstream. We mirror each C usage site
exactly.
Per-step weight normalization¶
C MAFFT's Falign (the FFT-anchored DP entry) expects the per-group
weight vectors to sum to 1.0. The Rust progressive-alignment caller
normalizes before invocation. Skipping this on a single merge step
produces an off-by-one column on BB30013.
Cross-validation harness¶
The byte-identity bar is held in place by 82 FFI test functions across
15 files (crates/*/tests/cross_validate*.rs; 99 including the three
forensic binaries) that compile MAFFT's C source in-tree via
mafft-c-bindings and call both implementations on the same inputs,
comparing outputs byte-for-byte. Because the C is
compiled on the same machine, these tests check Rust's default policy
against the matching C build on whichever platform they run.
This is documented in detail on the Cross-validation page.
What this means for changes¶
Any change to mafft-core / mafft-align / mafft-fft / mafft-tree
/ mafft-scoring MUST keep the test suite green. New behaviour goes
behind a feature flag or a new function — not as a tweak to an existing
hot path. If you're tempted to "clean up" a slightly weird-looking
arithmetic in the engine, check the cross-validate test for that
function first; it's probably weird because the C output requires it.
What this DOESN'T mean¶
- Algorithmic improvements are still possible — they just need their own modes / flags, and the byte-identical existing modes must keep working.
- Performance optimisation is fine wherever it doesn't change the
observable output. We've shaved cycles via pre-computed boundary
tables and LTO+fat codegen — none of which alter the alignment.
Collapsing arithmetic into
f64::mul_addis not in this category: it changes rounding, so it must go throughfp::fmaddand be justified by the C assembly on the matching platform. - The Python and CLI surfaces can evolve independently of the engine. Pretty-printing, progress callbacks, alternate output formats — all on the table.