Skip to content

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:

  1. Re-validate the rewrite on their own data, accepting that alignment differences may cascade through downstream analyses.
  2. 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 — a const bool. Defaults to true on aarch64 (mirroring clang's behaviour there) and false elsewhere.
  • mafft_types::fp::fmadd(a, b, c) — computes a*b + c with a single rounding when CONTRACTS_FMA is set, and as two separate operations otherwise. Every hot loop that has to match a contracted C expression goes through it rather than calling f64::mul_add directly.
  • Cargo features fp-contract-fma / fp-contract-none override the default. They are defined on mafft-types and forwarded by every crate in the workspace, so cargo test -p mafft-core --features fp-contract-none builds the whole dependency graph under the x86-64 policy on any host. (--features cannot 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) and Build (arm64) jobs each build the C submodule in-tree and run the full suite (lib, integration and FFI cross_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 50 sentinel above, diffing mafft-rs against the same-platform C mafft wrapper. It then runs scripts/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 (.nofma on x86-64, .fma on arm64) — so the committed fixtures are continuously re-derived from the reference definition rather than trusted.
  • A policy-cross-check job runs the fixture-based (non-FFI) test binaries on the arm64 runner with --features fp-contract-none, which checks the .nofma fixtures from an arm64 host. The FFI tests are deliberately excluded there: they compare against the C compiled on the same runner, which contracts, so under fp-contract-none the 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_add is not in this category: it changes rounding, so it must go through fp::fmadd and 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.