Suggestion on implementation of floating-point computations with different rounding mode

Hello everyone!

Some FPUs support different rounding mode, so if the floating-point operation yields a value that cannot be represented exactly, it is rounded according to the rounding mode. As far as I understand, the Rust compiler expects a "round to nearest" mode, and the behavior of a Rust program in any other rounding mode is undefined. Currently, the only way to perform computations in different rounding mode is with inline assembly, something like that (an example for x86-64):

unsafe {
   core::arch::asm!(
         "push rax",
         "stmxcsr [r10]",
         "mov eax, [r10]",
         "or eax, 0x00004000", // setting the "round towards positive infinity" mode
         "mov [r9], eax",
         "ldmxcsr [r9]",
         "addsd xmm0, xmm1",
         "ldmxcsr [r10]",
         "pop rax",
         inout("xmm0") /*variable name*/,
         in("xmm1") /*variable name*/,
         in("r10") /*a pointer to variable to hold the old floating-point environment*/,
         in("r9") /*a pointer to variable to hold the floating-point environment used during computation*/,
   )
}

The algorithm is roughly like that:

  1. Save the old floating-point environment
  2. Set the required rounding mode
  3. Perform computations
  4. Restore the old floating-point environment

The problem in wrapping such operations in Rust functions is that when we have two operations, the algorithm becomes like:

  1. Save the old floating-point environment
  2. Set the required rounding mode
  3. Perform computations
  4. Restore the old floating-point environment
  5. Save the old floating-point environment again
  6. Set the required rounding mode again
  7. Perform other computations
  8. Restore the old floating-point environment

I propose implementing it in a way similar to algebraic math optimizations -- by making the compiler aware of such methods so it could optimize away steps 4-6 (provided that steps 3 and 7 use the same rounding mode). However, as far as I understand, that can require the compiler to reorder operations with the same rounding mode close to each other to avoid unnecessary rounding-mode switching. What do you think about this idea?

P.S. As far as I know, some C compilers handle possibility of changing rounding mode by providing a special compilation mode that disables optimizations that break programs that change rounding mode in runtime. In my opinion, this has disadvantages of missed optimization opportunities and making floating-point operations impure (the same operation may yield one result in one part of the program and another in the other part).

2 Likes

Beyond optimizations, given that this would alter what is considered UB, it basically amounts to adding language variants (orthogonal to editions).

2 Likes

It's not even clear that your assembly is sound because signal handlers could trigger in the middle and execute unrelated Rust code under the modified fp environment.

3 Likes

Note that because of how IEEE754 defines reproducibility of results[1], the compiler is very restricted on reordering floating point operations. This is why the algebraic operations exist, because they are explicitly non-reproducible, and thus allow the compiler much more room for optimizing differently.

It might be worth considering whether a block structure of some form (e.g. a function that executes a closure with a different rounding mode) is better for representing a different rounding mode than individual functions; that's closer to how IEEE754 works (and thus how compliant hardware works), and unlike the algebraic operations, where you're telling the compiler it can stray further from IEEE754, this is a case where you could well benefit from being close to how IEEE754 actually works.

In either case, you'd need to think about how this all interacts with things like signal handlers, Rust panics and other non-linear flow control; you may still decide that separate functions, and the compiler optimizing out the mode switch, is the right answer, but you may not.


  1. Reproducible operations in IEEE754 are defined as equivalent to "compute to infinite precision, then round according to the current rounding mode". This restricts reordering of FP operations, because round(round(a + b) + c) is different to round(a + round(b + c)). ↩︎

What about interrupt handlers or (as Alice pointed out) signal handlers? Do all targets guarantee that FP flags are saved/reset/restored when those happen? I'm pretty surr that isn't the case on embedded ARM at least, but maybe it is on Linux and similar. The fact that this is stateful on most architectures is pretty bad.

What about them? At the hardware level, regardless of whether the Rust definition is as special functions - round_add(rounding_mode: FpRoundingMode, a: f32, b: f32) and equivalent, or as a block structure - fp_mode(rounding_mode: FpRoundingMode, impl FnOnce() -> f32 - you have to handle the fact that rounding mode cannot be changed atomically for a single instruction in most modern hardware, and thus you have to:

Ultimately, IEEE754 is rather FORTRAN 77 brained, and assumes that you can set and forget the rounding mode at the beginning of your program (on the first punched card in the deck!), and we have to live with that; thus, the question is whether, for typical uses of changing rounding mode, a block or special functions makes for better language ergonomics.

Sorry, i somehow missed the last paragraph of your post. But it doesn't seem feasible to add this (safely) without declaring existing code for interrupt handlers unsound. That doesn't seem compatible with the Rust backward compatibility guarantee.

they are already unsound on some targets because some of the fp operations are implemented by changing the rounding mode and then changing it back, e.g. converting float to integer on i586-unknown-linux-gnu without additional target features works by temporarily changing the rounding mode using fldcw to round-towards-zero, converting to an integer using fistp, and then changes the rounding mode back using another fldcw.

3 Likes

It might be possible to declare that the FP rounding mode at entry to a signal handler, interrupt handler, or other non-linear control flow is unspecified. The hard question for that is whether, on IEEE754 compliant platforms (i.e. not i586/i686), the existing Rust floating point functions change rounding mode anyway - which would make it effectively unspecified already - or whether we can change the declared Rust ABI for signal handlers, interrupt handlers etc to state that rounding mode is unspecified without causing too much ecosystem pain (e.g. because nobody actually cared about rounding mode in signal handlers or interrupt handlers anyway, and can tolerate their FP result being (0.25_f32).next_up() instead of 0.25_f32.

This is still a very different situation to the algebraic functions, where you were opting out of certain guarantees for your code; you're instead changing the guarantees for everyone, and that means that the algebraic functions are not automatically a good model for rounding modes.

Oh, interesting. Always exciting to learn about new ways in which the non-SSE x86-32 targets are broken...

Yeah I hope they guarantee that, everything else sounds kind of broken.

Yes, global state is bad. Sadly hardware engineers love global state so here we are...

I think it is already generally understood that it's fine to temporarily change the rounding mode in an inline asm block as long as you change it back before returning from that block.

So I would argue that any setup where interrupt handlers inherit the rounding mode from whatever previous code that ran is already using a non-standard contract and has to manually ensure that all the code they link in will never change the rounding mode.

1 Like

Linux saves and restores when entering/existing signal handlers I believe, but I'm not sure if it resets them before running user code in the signal handler.

1 Like