Short version for the impatient: Rust 1.98 gave f32 and f64 a set of algebraic_* methods that let the compiler reorder your floating-point math, and gave every integer type a format_into method that skips the write! machinery. Both are the kind of thing you only care about after you’ve stared at a profiler. If you want to know why I got excited about five new method names, read on.
I was updating a small pricing engine last week, the boring kind that sums a few thousand line items and multiplies by a rate. It’s been fine for two years. Then I ran rustup update, read the 1.98.0 release notes, and realised that a loop I’d hand-unrolled in 2024 to get it to vectorise could probably have been four characters longer and much less ugly. So I spent an evening writing a Rust benchmark instead of doing the actual ticket. This post is what I learned, plus the benchmark, so you can run it on your own hardware instead of trusting mine.
Why your float sum was never vectorised
Here’s the thing most of us learn once and then forget: floating-point addition isn’t associative. (a + b) + c and a + (b + c) can give different bits, because rounding happens at every step. Rust takes that seriously. When you write a + b + c + d, the compiler must evaluate it exactly as parsed, left to right, as ((a + b) + c) + d. No reordering, no tree of partial sums, no SIMD lanes each holding a running total. That’s correct, and it’s also why a plain iter().sum::<f64>() over a million floats is often a serial dependency chain of a million adds.
In C and C++ the escape hatch is -ffast-math, which is a blunt global flag that also lets the compiler assume there are no NaNs or infinities, which is how people get bitten. Rust never shipped that, on purpose. What 1.98 ships instead is five methods per float type: algebraic_add, algebraic_sub, algebraic_mul, algebraic_div and algebraic_rem. Per operation, opt in, nothing global. The std docs for f64::algebraic_add say the result may differ from plain addition because the compiler is free to use the algebraic properties of real numbers, and that the exact optimisations aren’t specified. Two things I want to underline from that page, because they matter more than the speedup:
The results are non-deterministic across compiler versions and optimisation levels. Not random at runtime, but you cannot assume the same source produces the same bits after an upgrade.
They never cause undefined behavior. This is the part that separates them from -ffast-math. You can get a slightly different number. You can’t get a miscompiled program.
Before and after, in actual code
The old way, if you wanted a sum that vectorises, was to do the reordering yourself so that the compiler didn’t have to. This is roughly what I had in the pricing engine:
// Rust 1.97 and earlier: manual partial sums so the CPU can keep
// several adds in flight. Ugly, but it's the only way to give the
// optimiser permission to reorder.
fn sum_lines(items: &[f64]) -> f64 {
let mut acc = [0.0f64; 8];
let chunks = items.chunks_exact(8);
let rest = chunks.remainder();
for chunk in chunks {
for i in 0..8 {
acc[i] += chunk[i];
}
}
let mut total: f64 = acc.iter().sum();
for x in rest {
total += x;
}
total
}
It works. It’s also eight accumulators of my own choosing, which is a guess about the target CPU baked into business logic. Here’s the 1.98 version:
// Rust 1.98: tell the compiler the order doesn't matter and let it pick.
fn sum_lines(items: &[f64]) -> f64 {
items.iter().fold(0.0f64, |acc, x| acc.algebraic_add(*x))
}
That’s the whole function. The fold is still written as a chain, but each link is algebraic_add, so the optimiser is allowed to turn the chain into a tree, split it across lanes, or do whatever it thinks is fastest for the target it’s compiling for. If you ship a generic x86-64 binary it’ll do one thing; with -C target-cpu=native it’ll likely do something wider.
The mul and div variants matter for the same reason in dot products and normalisation loops. The rem one I have not found a use for yet, and I’m okay admitting that.
The benchmark, and how to read it honestly
I’m not going to print numbers from my laptop and pretend they’re a law of nature. Float throughput depends on your CPU’s SIMD width, on whether the data is already in cache, and on what the rest of the loop is doing. What I’ll give you is the harness so the number you get is yours. Drop this in a fresh crate with criterion as a dev-dependency:
// benches/sum.rs
use criterion::{black_box, criterion_group, criterion_main, Criterion};
fn plain(items: &[f64]) -> f64 {
items.iter().sum()
}
fn algebraic(items: &[f64]) -> f64 {
items.iter().fold(0.0, |acc, x| acc.algebraic_add(*x))
}
fn bench(c: &mut Criterion) {
let data: Vec<f64> = (0..1_000_000).map(|i| (i as f64) * 0.001).collect();
c.bench_function("plain_sum", |b| b.iter(|| plain(black_box(&data))));
c.bench_function("algebraic_sum", |b| b.iter(|| algebraic(black_box(&data))));
}
criterion_group!(benches, bench);
criterion_main!(benches);
Run it twice. Once with cargo bench, once with RUSTFLAGS="-C target-cpu=native" cargo bench. The gap between plain_sum and algebraic_sum in the second run is the number that tells you whether this is worth touching in your codebase. If the gap is small in both runs, your bottleneck was never the add chain and you can close the tab.
Two traps I fell into. First, black_box is not optional; without it the compiler will happily constant-fold a million-element sum away at build time and you’ll be benchmarking a mov. Second, check the actual sums match to the precision you care about. On my data they agreed to about 1e-9 relative, which is fine for a pricing engine that rounds to cents and would be a real problem for anything that later compares floats for equality. If you want to see what the compiler did rather than infer it from timings, cargo asm from the cargo-show-asm crate will show you whether algebraic_sum picked up vaddpd instructions and plain_sum didn’t.
format_into is the quieter win
The other 1.98 change I care about is easy to skim past. Every primitive integer now has a format_into method that takes a &mut NumBuffer<Self> and returns a &str borrowed from that buffer. That’s it. No allocation, no Formatter, no dynamic dispatch through fmt::Write.
If you’ve ever written a hot logging path, a CSV writer, or anything that turns a lot of integers into text, you’ve probably pulled in the itoa crate for exactly this. The release notes point at the itoa-benchmark repo showing format_into now performs about the same as itoa, so it’s a candidate to replace that dependency. Here’s what that looks like in practice.
// Before: either allocate a String per number, or take a dependency.
fn write_id_old(out: &mut String, id: u64) {
use std::fmt::Write;
write!(out, "{id}").unwrap(); // goes through Formatter + dyn Write
}
// Rust 1.98: stack buffer, no allocation, no dyn dispatch.
use std::fmt::NumBuffer;
fn write_id_new(out: &mut String, id: u64) {
let mut buf = NumBuffer::new();
out.push_str(id.format_into(&mut buf));
}
I swapped this into the request-id path of a small axum service, the one I described in my post on how I think about Rust memory management, and it let me delete a dependency. The perf change was not measurable at my request volume, and I want to be clear about that. The win was one less crate to audit, which for a solo dev maintaining a service is worth more than a microsecond.
What I’d actually change, and what I’d leave alone
I’m not going to go through my code replacing + with .algebraic_add(). That would be the same mistake as turning on -ffast-math globally, just spelled out longhand. The methods are a scalpel, and the places that want the scalpel are obvious once you look: reductions over large slices, dot products, anything a profiler already flagged. Everywhere else, plain operators give you bit-for-bit reproducibility, and reproducibility is a feature you don’t miss until a test starts flaking after a toolchain update.
Two more rules I’ve settled on. Never use algebraic ops in code that feeds a hash, an equality check, or a snapshot test, because the bits are allowed to change under you. And never use them for money once you’ve left the “sum a big list” stage; the final rounding to cents should be done with plain arithmetic, in a known order, ideally on integers.
The other bits of 1.98 worth a glance: ManuallyDrop<Box<T>> moves after a manual drop are now documented as not undefined behaviour (they were fixed in 1.96, this release makes it a stable guarantee), str::strip_circumfix and String::from_utf16le landed, and there’s a new std::range::legacy module that signals the range type overhaul is moving. None of those changed how I write code this week. The float methods did.
Try it this week
Pick one function in your codebase that sums or dots a slice of floats. Copy the criterion harness above, point it at your real data shape, and run it with and without target-cpu=native. If algebraic_add buys you a meaningful gap, switch that one function and add a comment saying the result may differ across compiler versions. If it doesn’t, you’ve spent twenty minutes and learned your bottleneck is somewhere else, which is also useful. Either way you’ll have a real Rust benchmark for that path instead of a guess, which is more than I had last Tuesday.
If you’d like a second pair of eyes on a Rust service that’s slower than it should be, that’s the kind of work I take on; details are on abrarqasim.com.