Announcing Rust 1.98.0
33 points by FedericoSchonborn
33 points by FedericoSchonborn
The new algebraic math methods are an interesting way to do --ffast-math style optimizations while also keeping them compartmentalized.
Broader loop-vectorization is often enabled by using these algebraic methods as well.
This stood out to me because in my mind, the primary use case was to optimize big hairy math expressions. But maybe that's just part of it? Maybe out in the wild, there's more situations where you're doing a little bit of math in a hot loop?
I'm trying to think of an example where reordering floating point operations would allow you to use something like SIMD where you couldn't before.
There was an article a couple of weeks ago about these same operations and it had an example where adopting the algebraic operations allowed for SIMD optimization.
Having read that recently, I was interested by yesterday’s article on double-double which warns that reassociation and fma fusion break the kind of careful control over rounding errors that double-double relies on.
I'm trying to think of an example where reordering floating point operations would allow you to use something like SIMD where you couldn't before.
This isn't just for vectorization. A fundamental throughput optimization (whether done manually or automatically) is to restructure dataflow graphs to reduce or eliminate latency bottlenecks. As an example, you can replace ((a + b) + c) + d with (a + b) + (c + d) if addition is associative (or you treat it as associative as with Rust's algebraic_add).
When applied to loops (where it really matters) and optimizing for instruction-level parallelism, you usually also exploit commutativity, so you'd compute (a + c) + (b + d) rather than (a + b) + (c + d). Commutativity is also important for efficiently vectorizing a summation loop since you otherwise waste time in the inner loop on shuffles or horizontal adds. But the fundamental enabler for parallelism is associativity, not commutativity. [1]
Just to drive home the point about how this can be relevant for purely scalar code. An fadd execution unit is usually fully pipelined (each unit can sustain 1 fadd per cycle) while fadd latency is usually 2-4 cycles depending on the chip. Let's take a slightly older but representative micro-architecture like Skylake with 4-cycle fadd (addps) latency and 2 fadd ports. Then a left-chaining fadd loop with n additions (with n large enough to enter a steady state) will take ~4n cycles. That is 8x slower than the scalar throughput limit (8x rather than 4x since there are 2 ports).
[1] Incidentally, IEEE 754 addition is commutative outside of corner cases like adding two NaNs where the NaN bit patterns are different, which isn't relevant here.
Yeah, restructuring data dependencies makes sense. I already knew that reordering operations could speed up multi-operation expressions (the "big hairy math expressions" from my previous comment), and I think your example is good to show that you can shave off a few cycles even if your expression is relatively small.
But the root of my surprise was my belated realization that a loop is also a type of multi-operation expression. I was so locked into thinking about optimizing stuff like x = 1+2+3+4, it hadn't even occurred to me that stuff like total += item could also benefit.
Also see https://doc.rust-lang.org/beta/core/primitive.f32.html#algebraic-operators for slightly improved docs that unfortunately didn't make it into this release.
Does anyone know if something like scoped replacement of infix operators has been considered? It would be notationally nice to be able to say that within a certain scope, any occurrence of + means algebraic_add (or saturating_add or whatever other special variant). Especially these new algebraic math functions are likely to occur in quite long and complicated expressions that would end up a lot more readable if the operators could be temporarily overloaded.
I'm imagining something like
fn foo(x: f64, y: f64) -> f64 {
let z = {
#![infix_replace(+, algebraic_add)]
x+y // Actually x.algebraic_add(y). Imagine a long and complicated expression here.
};
z+x+y // Normal addition
}
More likely, you'd have an AlgebraicAdd trait that mirrors Add, and an attribute like #![add_replace(AlgebraicAdd)] for each of the infix operators.
It would also let you turn on things like explicit checked arithmetic (in every compilation mode) for certain specified functions/scopes without getting very verbose.
(I know operator overloading is a contentious topic. While I'm personally a huge fan of freely overloadable/extensible infix operators à la Haskell, I do understand why Rust isn't considering that. A limited, scoped variant might still be worth discussing though.)
this could have been solved with making an Algebraic<T> wrapper for integers, as was done with Wrapping<T>. this was considered and rejected, although it could be done in a user library.
Proc macros might get you partway there. I can imagine something like algebraic!(x + y + z) that rewrites an expression in terms of algebraic_add calls. But I think that only works if you assume everything inside is a float, because proc macros run before types are resolved.
That can be solved with some traits I suppose, which call the right methods for integers and (algebraic) floats. Seems a nice idea for a little utility crate
The Deref trick paired with token munching lets you do it with a proc macro: https://gist.github.com/mitsuhiko/41251bf17b0903c6433fd61d528e024d
This all can be made work:
#[test]
fn preserves_precedence_and_parentheses_for_integers() {
assert_eq!(algebratic!(2i64 + 3 * 4), 14);
assert_eq!(algebratic!((2i64 + 3) * 4), 20);
assert_eq!(algebratic!(100i64 / 5 / 2), 10);
assert_eq!(algebratic!(20i64 - 5 - 3), 12);
assert_eq!(algebratic!(3i64 * -4 + 20), 8);
}
#[test]
fn supports_every_algebraic_float_operator() {
let a = 13.5f64;
let b = 2.25f64;
let _ = algebratic!((a + b) * (a - b) / b % 7.0);
}
#[test]
fn falls_back_for_mixed_user_types() {
let p = Vec2 { x: 1.0, y: 2.0 };
let v = Vec2 { x: 4.0, y: -2.0 };
assert_eq!(algebratic!(p + v * 0.5f64), Vec2 { x: 3.0, y: 1.0 });
}