Faster floating point math with Rust’s new API
39 points by hemitheconyx
39 points by hemitheconyx
I'm pretty sure standard floating point addition is commutative. But it's not associative.
The operational semantics of algebraic operations leave me confused, hopefully someone can explain it.
The docs say:
Because of the unpredictable nature of compiler optimizations, the same inputs may produce different results even within a single program run. Unsafe code must not rely on any property of the return value for soundness.
Surely there is at least a guarantee that the return value is at least fixed? I don't take it for granted because optimization passes may duplicate code, and algebraic operations are non-deterministic. Is their behavior close to freeze poison?
I'm getting confused because a.algebraic_add(b) can produce different results depending on where a comes from, even if it has the same value (e.g. if it's a constant vs if it's multiplication, which could result in FMA). Does that imply that floats have provenance? It sure looks similar, and that raises a question about whether visibly equal values with different provenances are equivalent and whether optimisation passes take that into account, e.g.
let sum = a + b;
let algebraic = sum.algebraic_sub(b);
let exact = sum - b;
if algebraic == exact { // (1)
assert_eq!(algebraic, a); // (2)
}
Individually, (1) could be reasonably optimized to true (assuming the algebraic difference is computed with IEEE-754 semantics, and no NaNs) and (2) could be reasonably optimized to true (assuming it's computed symbolically), but together they could result in a miscompilation. Last time I tried this, I couldn't get LLVM to miscompile this, but I don't know if that's for lack of trying or because this is deliberately taken into account in each pass -- and not a single discussion about Rust's algebraic arithmetic API I could find covers this.
My interpretation of the docs is that the example you give is not a potential miscompilation so much as code you shouldn't write in the first place, because it relies on optimization assumptions the code has deliberately opted out of. So yes, it might give some very surprising results, but also this is what you asked it to do.
Or I could be wrong and this feature will need to be turned off. Or perhaps needs better docs. Maybe reach out to the people who wrote it?
the example you give is not a potential miscompilation so much as code you shouldn't write in the first place
Well, here's the point, safe Rust code is supposed to not miscompile regardless of how clever it is, and every deviation from that is rightfully treated as a bug. That's a completely different culture from e.g. C. So I'm surprised to see no discussion about this in the context of Rust... Maybe I'm missing some LLVM thread. Probably not the best person to ping, but ~ralfj could you perhaps shine any light on this?
Surely there is at least a guarantee that the return value is at least fixed?
Yes. There is no such thing as a non-fixed float value in Rust.
What the docs are saying is that this assertion is allowed to fail:
let sum = a + b;
let algebraic1 = sum.algebraic_sub(b);
let algebraic2 = sum.algebraic_sub(b);
assert_eq!(algebraic1, algebraic2);
This is standard wording we use in a bunch of places to explain non-determinism. It's very hard to explain such a concept concisely and precisely...
I was already familiar with the problem space and had glanced at Rust's algebraic ops before, but I really liked the explanation given here. It was succint and made its points really well! I also appreciate the statistics on how many SIMD operations occurred (which I assume were collected with hardware perf counters), it's a nice touch.
Yeah, it's from hardware performance counters. I wrote a mini benchmarking Jupyter magic for my book, and the article is rendered via Quarto. So e.g. generating one of these benchmark tables looks like this, and the output is a Markdown table:
```{python}
#| echo: false
%%compare_timing --measure=instructions,simd_256bit
naive_sum(DATA)
np.sum(DATA)
pairwise_sum(DATA)
```
Is this equivalent to the -fastmath option in GCC/clang?
Somewhat, yes, but better:
-ffast-math has global side-effects that harm the behaviour of code that is not compiled with -ffast-math, because is messes with the floating point environment
https://simonbyrne.github.io/notes/fastmath/
The other dangerous thing -ffast-math does is break predicates that test for inf and nan, but that’s per-translation-unit rather than global.
-ffast-math has global side-effects that harm the behaviour of code that is not compiled with -ffast-math, because is messes with the floating point environment
As noted in the article GCC 13 no longer does that (by default?):
-Ofast,-ffast-mathand-funsafe-math-optimizationswill no longer add startup code to alter the floating-point environment when producing a shared object with -shared.
Clang 19 made a similar change as well (corresponding GitHub PR):
Shared libraries linked with either the
-ffast-math,-Ofast, or-funsafe-math-optimizationsflags will no longer enable flush-to-zero floating-point mode by default. This decision can be overridden with use of -mdaz-ftz. This behavior now matches GCC’s behavior.
Oh yay, that’s a great improvement! (I didn’t spot that the blog post had been updated since I previously read it.)