RFA-321 · Case file with fixtures · Case 293 of 694 · Runtime evidence
f64::mul_add Can Differ From x * a + b
f64::mul_add computes the exact product and sum before rounding once. Separate multiplication and addition normally round twice, so cancellation can expose different answers.
- Reviewed
- Rust
- Rust 1.98.1, edition 2024
- Targets
- targets with IEEE 754 f64
- Profiles
- dev, release, test
Direct answer
What this Rust failure means
- Why it happens
- mul_add rounds only the combined result, while separate operators round the product and then round the addition, making cancellation observably different.
- First discriminating check
- Run the epsilon cancellation example and specify whether the numerical contract requires fused semantics, separate operations, or only a documented tolerance.
I have seen mul_add introduced as a faster spelling of x * a + b. That description hides the part that matters most: the two forms are allowed to produce different floating-point answers.
The failing program uses the example around machine epsilon from Rust's documentation. The separate expression becomes zero. The fused operation keeps a tiny negative value.
Fused means one final rounding
f64::mul_add calculates (self * a) + b with only one rounding operation. Conceptually, the product and addition are evaluated with infinite precision, and the combined result is rounded to f64 once.
An ordinary multiplication followed by subtraction stores the rounded product first. The subtraction then rounds again. When two values almost cancel, the information discarded by the first rounding can decide whether the final result is zero or a small residual.
This is not Rust evaluating algebra incorrectly. Real-number algebra and finite floating-point evaluation are different models.
The epsilon example makes the lost information visible
Rust defines f64::EPSILON as the difference between 1.0 and the next representable number above it.
The fixture multiplies 1 + EPSILON by 1 - EPSILON, then subtracts one. In exact arithmetic the residual is -EPSILON * EPSILON. The separately rounded product becomes exactly one before the subtraction, so the later result is zero. mul_add preserves the exact intermediate relationship until its final rounding and returns the residual.
The repaired program asserts both results rather than pretending they must match.
Choose the numerical contract before choosing the syntax
For dot products, polynomial evaluation, geometry, signal processing, and some statistics, reduced rounding error is normally desirable. mul_add can express that intention directly.
For compatibility work, changing the last bits may be a breaking behaviour even when the new result is more accurate. A protocol fixture, a historical model, another runtime, or a stored checksum may depend on the older operation sequence.
I therefore treat an automatic rewrite from x * a + b to x.mul_add(a, b) as a numerical change. It needs tests against domain tolerances and reference data, not only a performance benchmark.
Compiler contraction is a related but separate question
Source-level mul_add requests fused semantics. Whether a target uses one hardware instruction is an implementation and target-feature question; the semantic reason to call the method is the single-round result.
Conversely, I do not base a correctness argument on looking at one assembly listing for an ordinary arithmetic expression. Optimisation settings, target features, and floating-point transformation rules can change generated instructions. The Rust Reference documents the operators, while the method documents its stronger rounding contract.
When instruction selection matters, I record the toolchain, target, CPU features, profile, and benchmark. When numerical meaning matters, I test outputs around difficult inputs.
Exact equality is useful in this evidence case
General advice often says never compare floats with ==. That advice is too broad. Exact comparison is correct when I am testing a precisely documented representation-level result, detecting signed zero, or preserving a wire-format contract.
Approximate comparison is appropriate when the domain accepts an error tolerance. The tolerance should have a unit and a reason. A single global epsilon can be meaningless across values with very different magnitudes.
RFA-321 uses exact assertions because it is proving the extra rounding step itself. A scientific application would probably validate error against a trusted higher-precision computation over a larger input set.
Test the places where rounding becomes observable
Random ordinary values often make fused and separate evaluation look identical. I include adversarial cases:
- near cancellation, as in this fixture;
- values near representable boundaries;
- very large products plus an opposite-signed addend;
- subnormal values and underflow;
- infinities, zeros, and NaNs according to the domain policy;
- long accumulations where small local differences compound.
I also test algorithm-level properties. A tiny residual may change a branch, sign classification, termination condition, or bucket assignment. The operational effect can be much larger than the numerical difference.
Reproducibility needs an operation model
“Same formula” is not enough for bitwise reproducibility across languages and machines. I specify whether fused operations are required, allowed, or forbidden; which precision is stored between steps; and whether fast-math-style transformations are acceptable.
If bitwise identity is unnecessary, I say that too and test a domain tolerance. This avoids paying for a promise the product does not need.
The core principle is that an intermediate rounding is an observable operation. mul_add is not merely a shorter arithmetic expression: it removes one rounding boundary. That can improve accuracy, alter compatibility, and change control flow, so I make the choice explicit.