>
Tech News

Rust 1.98 lets the compiler take algebraic liberties with floats

If you have ever wanted more performance from a numerical hot path without rewriting your arithmetic by hand, Rust 1.98 is the release to read carefully. The headline is a small family of algebraic methods on the floating-point primitive types that hand the optimizer permission to bend the usual rules for selected calculations, and several quieter library additions remove real rough edges.

What the algebraic methods actually do

Floating-point arithmetic (the math your CPU does for non-integer values, where the result of every operation is rounded to fit in a fixed number of bits) is not associative. In plain English, (a + b) + c does not always produce the same answer as a + (b + c). Rust preserves that behavior on purpose, because silently changing a result can break code that depends on a specific rounding and error pattern. A normal expression such as a + b + c + d is therefore evaluated from left to right, and the optimizer has to leave the order alone.

Rust 1.98 introduces a method family on f32 and f64 that relaxes that constraint in narrow places. The release material describes methods covering addition, subtraction, multiplication, division, and remainder. The headline method is algebraic_add, which lets the compiler reorganize a chain of additions, evaluate partial sums in a different order, and look harder for vectorization opportunities (where the CPU applies the same operation to many numbers in parallel using wider SIMD lanes, the Single Instruction Multiple Data registers that fit 4 or 8 doubles at once). The other methods in the family work the same way for their respective operations.

This is the part worth slowing down on. The algebraic methods are not a global flag. Rust is not turning the language into a blanket -ffast-math mode, where a compiler accepts a broad set of floating-point assumptions in one switch. Instead, each method is an explicit decision you place around a specific calculation. Anywhere you do not call the algebraic method, normal behavior stays. Anywhere you do call it, you have told the compiler that the result does not have to match the exact bit pattern of ordinary arithmetic.

I think this is the right shape for the change. A blanket mode is what C and C++ got, and the practical record of those modes is mixed. Programs that turn on fast-math end up with numbers that drift, and the drift usually shows up at the worst possible moment. Rust is choosing to expose the same set of optimizations one method at a time and let each call site decide for itself whether the trade is acceptable.

  • Algebraic methods are opt-in, called per calculation.
  • The optimizer gains freedom to reorder and vectorize selected work.
  • Calculations that do not call the methods keep their existing bit-exact behavior.
  • Bit-for-bit reproducibility is a property of the call site, not the build settings.

When the speed actually shows up

The reason to care about algebraic_add is that a long sum can be split into pieces. If the compiler can work on those pieces at the same time, the program can use modern CPU instructions more effectively and finish the calculation with less wall-clock time. The workload that benefits is the one that spends most of its runtime inside numerical loops: scientific computing, simulations, signal processing, batch data work, any path that adds a lot of values one at a time. For a one-off arithmetic expression the setup and measurement cost more than the calculation does, so the feature is less exciting there.

Performance gains are real but conditional. The compiler cannot reorder work it cannot see. Methods called on local variables inside a hot loop give the optimizer something to chew on. Methods called on values that have already been passed through opaque boundaries or wrapped in smart pointers (reference-counted or pointer-owning types like Rc or Box, which sit between you and the raw data) may end up constrained anyway. Treat the speedup as a possibility rather than a guarantee and benchmark the version of the code you actually ship.

Speed also depends on the CPU you are running on. The methods open up SIMD lane usage, and the lane width of the CPU determines how much the optimizer can do in parallel. A modern desktop or server chip has wider lanes than an older laptop. A workload that runs on a five-year-old server might not see the same gain as the same workload on a new one. Release mode matters too. Debug builds do not run the same optimization passes that release builds do, and the algebraic methods are aimed at release-mode code. If you want to know what the feature is worth on your hardware, build in release and measure on the hardware your users actually run.

  • Performance gains show up most in long numerical loops, not one-off arithmetic.
  • The optimizer can only reorder what it can see, so keep the algebraic call inside the hot path.
  • CPU lane width and release-profile optimization determine how much the methods help in practice.
  • Treat the speedup as a benchmarked fact, not a documented promise.

Integer formatting with a real buffer

When you assemble many values into a single output, the cost of allocating a fresh String for every formatted number adds up. Rust 1.98 ships a small API aimed at that exact workload. format_into works on every primitive integer type and writes the decimal representation into a NumBuffer, which is sized for the type you are formatting. The result comes back as a string slice that borrows from the buffer, so the formatted view does not need a separate owned string to live in. The borrowing relationship shows up in the type signature, which is what makes the API pleasant to reason about.

The release notes do not include a benchmark, so the win is not guaranteed. The cases where the API helps are the ones where the calling code already owns a buffer that outlives the formatted view, or where the alternative is a chain of allocations and intermediate conversions that obscure what the code is doing. In those paths, swapping in format_into removes one allocation per call and makes the lifetime visible at the call site. In paths where the surrounding code already allocates once and reads the formatted view, the gain is small and not worth the refactor.

NumBuffer has a sized, plain-English contract. There is no hidden unlimited formatter underneath, and there is no conversion pretending that an integer is permanently text. The capacity is the capacity of the type you are formatting, and the lifetime of the borrowed view is the lifetime of the buffer. That surface area is small enough to keep in your head, which is the part that pays off when the alternative would have been a private helper assembled from format!, intermediate String allocations, and a comment explaining what the code is doing.

  • format_into works across the primitive integer types.
  • NumBuffer provides type-appropriate decimal storage sized for the integer.
  • The returned &str borrows from the buffer and only outlives the buffer itself.
  • Adopt it where a buffer already exists or where one allocation per number is real cost.

Smaller changes that remove rough edges

The string and soundness additions in this release earn their keep in narrow places. String::from_utf16le and String::from_utf16be are the cleanest way to construct a String when the data you are reading has an explicit endianness as part of the file format. The lossy variants are useful for input that is not strictly valid UTF-16; if you reach for the lossy version, leave a comment that the replacement behavior is intentional, because the call alone does not make the intent obvious to the next reader.

For the new strip_circumfix method, the use case is narrow on purpose. The method removes a matching pair of circumfix characters from the start and end of a string or slice. Reach for it when the wrapper is a fixed pair you know in advance, like the brackets in a templated token, and skip it when the wrapper varies. The same caveat applies if the pair is loaded from configuration: pin it down before you adopt the method, because the alternative is a hand-rolled strip that drifts.

The soundness clarification is the change worth a real read for low-level ownership code. ManuallyDrop<Box<_>> had a long-standing treatment where moving the value after dropping the inner Box was undefined behavior because of a compiler issue. The compiler bug was fixed in Rust 1.96, and Rust 1.98 locks in the corrected behavior in the documentation. If your code relies on this pattern, that is a contract improvement, but it is the kind of contract you should exercise against the new compiler before depending on the documented guarantee.

  • The new UTF-16 constructors cover explicit little-endian and big-endian cases for cross-system input.
  • Lossy variants are useful for invalid input but deserve a comment explaining why.
  • strip_circumfix removes matching wrappers; pin the pair down before adopting it.
  • The ManuallyDrop<Box<_>> behavior is now part of the documented contract for the pattern.

Trade-offs

Rust 1.98 is a low-drama release, but the algebraic methods are not free in attention. The speedup is real for long numerical loops on modern hardware in release mode, and the bit-pattern drift is real for any calculation that has to reproduce the same result across runs. The methods reward a disciplined before-and-after measurement and punish any code that adopts them by default. If your program processes sensor readings, currency amounts, geographic coordinates, or any other value where a small drift is unacceptable, the default expression is still the safer choice.

There is also a real documentation cost. The release notes describe the methods clearly, but the safe-use guidance lives in scattered places, and a casual reader will not catch the bit-pattern point without slowing down. I would expect most teams to discover the methods through a benchmark that happens to show a gain, then ask the obvious question about why the gain exists. The answer is in the algebra. That conversation is good for the codebase, but it takes time the first time you have it.

The smaller library changes are lower risk. The new formatting method needs a review of buffer lifetimes, the UTF-16 constructors need a comment about byte order and lossiness, and strip_circumfix needs a definition of what counts as the pair. None of those is a footgun on its own. The combined review across the release is what costs the time.

Migration cost is short. rustup update stable updates the compiler in minutes. Re-running the existing test suite on the new compiler is the part that costs the time. If you maintain a numerical codebase, budget an afternoon for the algebraic-method experiments on top of the upgrade itself.

Bottom line

Update the compiler first, then run the existing test suite on the new release without touching the arithmetic. That step alone is worth the upgrade because it confirms nothing in your codebase is sensitive to the documented behavior changes around ManuallyDrop<Box<_>> and the standard library additions. Once the baseline is clean, pick one numerical loop that is already a bottleneck, mirror its calculation in a copy, and try the matching algebraic method. Compare the output to a fixture you trust, run the benchmark in release mode on the hardware your users actually run, and decide whether the result is fast enough to keep. If the answer drifts or the gain is small, leave the original expression alone and move on. The new formatting and string APIs are lower risk. Adopt them where they make a specific call site clearer, and skip them where the existing code is already easy to read. The whole review of this release fits in an afternoon if you already know which loops matter to your workload.