rust-lang/rust

Tracking issue for `#![feature(const_fn_floating_point_arithmetic)]`

Centril opened this issue Β· 53 comments

Sub-tracking issue for rust-lang/rfcs#911.

This tracks arithmetic for floating point types in const fn. These operations are already stable in const/static, and are also being promoted on stable (&(1.2*2.1) has lifetime 'static). However, in all of these situations, the code is definitely executed at compile-time -- only const fn code could be executed both at compile-time and run-time, making possible differences in FP behavior observable. Also see #77745.

Open Questions

Can from_bits / to_bits be made const fn in respect that #53535 ?

One problem with stabilizing floats in consts is that their run-time semantics are broken, and likely unsound: #55131, #73288.

While one concern with const evaluation of floating point math is that the results may differ from the hardware (I think we are still using the host fpu for a lot of f32/f64 stuff in miri) there is a huge benefit to adding const evaluation of floating point arithmetic for no_std environments where runtime (soft or hard) fp math may be unavailable altogether.

In this context, constant evaluation can be a huge boon to embedded developers and significantly raise both ergonomics and code quality by allowing for compile-time substitution of static expressions involving floating point math based off of literal constant parameters without requiring opaque f32/f64 values followed by an outdated comment saying β€œthis is the result of the computation of ....” and without requiring lookup tables that will take up room in the ROM without ever being accessed because the ultimate results are computed dynamically and plugged in at compile-time.

This isn’t even arch-specific, eg currently there’s no way to use f64::powf64 without std except via the intrinsic, but that’s not marked const and shouldn’t be anywhere near the current version of core... except it’s actually perfectly fine to use in core/no_std if evaluated at compile-time (and only at compile-time). Would enabling something like that at compile time require an alternative implementation of the intrinsic or a full restructuring of the std/no_std primitives altogether?

(I think we are still using the host fpu for a lot of f32/f64 stuff in miri)

CTFE never uses the host FPU. (Miri-the-tool sometimes does but that is not relevant here.) Floats during CTFE are implemented entirely via "softfloats", i.e., a software-only implementation of IEEE floating point semantics. This means the result is the same no matter the host you use to compile. However, the result might still be different from what would happen on the target since the IEEE float spec doesn't always uniquely determine what happens (in particular around NaNs), and since some targets are not IEEE-compliant.

f64::powf64

pow is not available during CTFE even unstably, since our softlofat library does not support pow (or at least I couldn't find any support for this operation). So to get pow in const even just on nightly (disregarding all the issues that currently block stabilization), someone would have to first implement pow in softfloats.

All the issues around floating-point semantics aside, we do allow floating-point operations in const outside const fn. We also promote floating-point operations, so e.g. &(1.0*2.0) will be evaluated at compile time. So we basically already have to live with whatever our CTFE floating-point semantics are, and we have to be careful with changing it. I don't immediately see how allowing the same operations in const fn would make things any worse... @oli-obk was disallowing this in const fn just done out of abundance of caution, or was there some other concern?

Entirely done out of an abundance of caution. We just didn't want to have any discussion at min_const_fn stabilization time, so we avoided it. The only argument against stabilization is that running the same const fn at runtime and at compile time can yield different results, so technically optimizing away a const fn call by evaluating it at optimization time could change program behaviour. There are much worse things (like missing heap allocations) so I think we can safely ignore this argument and just assume that no one can make optimizations that solely depend on the constness of a function.

Doesn't ConstProp already use CTFE floats to optimize runtime code?

Also, if this is considered "changing program behavior", that would mean Rust floats would not be specified as "IEEE floats" but as "whatever-the-target-does floats". I don't think that decision was ever made, was it? It would arguably mean that Miri and CTFE are buggy if they diverge from the target behavior.

If Rust is specified as using IEEE floats, then the NaN bits are not specified, and it's okay for them to come out either way. With this view, there is no change in program behavior, just a different non-deterministic choice being made.

Doesn't ConstProp already use CTFE floats to optimize runtime code?

no, we explicitly opt out of floats.

. I don't think that decision was ever made, was it?

It was not, I was just reiterating the argument that was made here. I don't remember who made the argument or whether they thought it an important argument, I just remember that it was easier to punt the decision into the future. I personally think we should just stabilize floats in const fn

no, we explicitly opt out of floats.

Oh, interesting.
We do promote floating-point-ops though which is not too different from an optimization. (And I recently tried to disable that and found too many uses. Maybe we could disable it in fn/const fn only but still promote in static/const; not sure if that's worth it.)

@oli-obk

There are much worse things (like missing heap allocations) just assume that no one can make optimizations that solely depend on the constness of a function.

I am not sure I understand the first half of this sentence. What do you mean by missing heap allocations? And are there actually any such optimizations?

@RalfJung

since the IEEE float spec doesn't always uniquely determine what happens (in particular around NaNs),

Which are you thinking of in particular? IEEE754-2008 adds clauses that define a few NaN-related behaviors for floating point.

re: the overall subject, I think it is possible to make Rust floats have a reasonably Rust-defined behavior so I do not think we should blink here, at least not just yet.

Which are you thinking of in particular? IEEE754-2008 adds clauses that define a few NaN-related behaviors for floating point.

I'm not an expert, I am just echoing what I heard from experts.^^ See #73328 for a lot of discussion around this.

re: the overall subject, I think it is possible to make Rust floats have a reasonably Rust-defined behavior so I do not think we should blink here, at least not just yet.

I have no idea what blinking has to do with floating points... you lost me here.^^ Are you saying we should or should not stabilize things as-is?

I agree it is possible, but that work hasn't been done yet, so until it is there's still a risk associated with stabilizing floating-point operations -- namely that they behave differently at compile-time and at run-time.

Oh, sorry, figure of speech. I meant I don't think we should necessarily stabilize as-is.

Is it ever a soundness issue if a const fn produces a different result at compile-time than it does at runtime?

Is it ever a soundness issue if a const fn produces a different result at compile-time than it does at runtime?

A function that is not guaranteed to produce the same result depending on if the compiler decided to evaluate it at compile time or not seems like it would fall precisely into "undefined behavior".

I'm on mobile, but it's an open question whether const fn has to have the same behavior at compile time and runtime.

I'm on mobile, but it's an open question whether const fn has to have the same behavior at compile time and runtime.

Why would it be ok for them to have different behavior?

Not going to get into the discussion here; that's #77745. Just felt necessary to point out that there is not currently an answer to the original question.

A function that is not guaranteed to produce the same result depending on if the compiler decided to evaluate it at compile time or not seems like it would fall precisely into "undefined behavior".

No, it might just be non-determinism. We have that all the time.

Also, you are mixing up "constant propagation" and "compile-time function evaluation". This issue is about the latter. The compiler never decides to evaluate a function at compile time, it is explicitly told so by the programmer via keywords like const.

No, it might just be non-determinism. We have that all the time.

Out of curiosity where else is non-determinism allowed or accepted in rust. I had assumed this is counter to what a programmer would want with their code.

Non-determinism is of course permitted in Rust β€” how would you get a random number otherwise? It's a question of whether it should be allowed in const fn.

Random numbers are not the same as non-deterministic choice. I certainly don't want my crypto keys to be picked non-deterministically...

Examples of non-determinism in Rust are:

  • The exact integer address at which objects are allocated in memory.
  • The interleaving of instructions of concurrently running threads.

Examples of non-determinism in Rust are:

  • The exact integer address at which objects are allocated in memory.

  • The interleaving of instructions of concurrently running threads.

I can see how these are non-deterministic, but the language is designed so that these both are -- when using safe rust -- hidden and unimportant to the programmer. The result of a function that a programmer has created feels like it would be in a different class than these concepts. A function that can produce different results if in const position or not seems like it would be super unintuitive and backwards.

I can see how these are non-deterministic, but the language is designed so that these both are -- when using safe rust -- hidden and unimportant to the programmer.

That is not technically true for the allocator (ptr-to-int casts are safe), and not at all true for concurrency -- Atomic* types are easily used in safe code to observe concurrency non-determinism.

A function that can produce different results if in const position or not seems like it would be super unintuitive and backwards.

As has been said before, that discussion is taking place in #77745. Please do not continue this off-topic thread.

const fn transmute is already stabilized

@burrbull not sure I see the relevance?

@burrbull not sure I see the relevance?

Sorry, I was saying about #72447

We discussed this in today's @rust-lang/lang meeting. It sounds like we need to make a decision about what floating-point behavior we want to expose at compile time, and how much we need to match the runtime platform. We could require const { ... } and then state that we use IEEE with specific semantics, we could just use those semantics unconditionally and allow them, we could go to great lengths to match every target's behavior, or we could disallow this permanently.

one slight variant of the paths proposed above: I think some kind of opt-in for "I want the IEEE specific semantics" that is more self-documenting than just saying const { ... } could have general utility.

What that opt-in looks like is up for debate. (I was thinking of some kind of attribute, but @joshtriplett pointed out that wrapper-types (e.g. std::num::Saturating and std::num::Wrapping) are a common way for us to express similar variations in arithmetic semantics, for better or for worse).

Using const { ... } also means you cannot actually access function arguments, so I don't see how that is meaningfully different from disallowing the arithmetic in const fn.

Would it be possible to just use a #[soft_float] attribute for const fn to always use the IEEE semantics (or default to that and use #[hard_float] to override if an FPU is available at runtime) to distinguish between cases where const fn must always give the same result versus when it is just desirable to finish the computation at compile time when possible

Would it be possible to just use a #[soft_float] attribute for const fn to always use the IEEE semantics

part of the problem is that IEEE 754 semantics aren't fully defined, e.g. IEEE says nothing about what the sign bit of 0.0 / 0.0 is...x86 uses negative, RISC-V uses positive, they both conform to IEEE's rules.

I feel doubtful the 0.0 / 0.0 issue is material, even for soundness, except when LLVM is ignoring explicit copysigns (which is contra IEEE754 semantics). What's far more immediate, for instance, are things like denormal """optimizations""" in certain processors.

What's far more immediate, for instance, are things like denormal """optimizations""" in certain processors.

I'm not aware of any cpus that flush denormals by default for scalar operations (since correctly supporting denormals is required by ieee754), though that does happen for simd on certain cpus (e.g. arm neon) and can happen on gpus...

So, I skimmed this thread and understand the apprehension to stabilising this… but at the same time, is there any particular barrier to allowing the libstd float methods (trig functions, sqrt, etc.) in const contexts, assuming it's still unstable under this feature?

Since I'm under the impression that it would just run them through miri, which already supports this behaviour, but I could be totally wrong and this is something separate that has to be implemented.

So far, CTFE uses a fork of LLVM apfloat, a softfloat library -- meaning that CTFE behaves exactly the same no matter your host OS and architecture.

However, trig functions are not implemented in that softfloat library. Miri uses the host math library for that, but we deliberately kept that out of rustc because having the resulting binary depend on which host OS and architecture you built it sounds Not Good. It might even be unsound if you mix rustc artifacts built on different machine (though I am not sure if that is something we support).

From the bottom of https://gcc.gnu.org/wiki/FloatingPointMath, it looks like GCC uses MPFR in compile-time evaluation to guarantee correct rounding even for transcendentals, which I assume avoids the kind of reproducibility and unsoundness issues that you are alluding to (correct rounding must, by definition, be the same on every machine). Perhaps rustc could follow a similar strategy ?

Sure, someone could implement trig functions in softfloats for rustc. But that hasn't happened yet, and I have explained why just doing whatever Miri does is probably not a good idea.

Ah, so, short answer: no, it's hard, and it's not a simple case of just allowing it. Good to know.

Guess we'll just have to wait for the decision on the larger feature before we cross that bridge.

So if I get this correctly, the problem is mostly about how different targets represent the results of operations that have non-finite results, correct?

Then how about skipping this problem by making a FiniteFxx which is an fxx minus the -0.0, NaN values and infinities? I think these types could make a lot of sense for programs that want to avoid propagating infinies everywhere, and provide nice niches forOption<FiniteFxx>.

FiniteFxx could then have const arithmetic where NaN would be represented by None. fxx const arithmetic can then be implemented as unwrapping the result of FiniteFxx arithmetic.

FiniteFxx could then have const arithmetic where NaN would be represented by None. fxx const arithmetic can then be implemented as unwrapping the result of FiniteFxx arithmetic.

This is genius! This also improves the type safety of the language, as NaN is similar to null (in other langs), in the sense that it's a placeholder for any invalid or non-existent value. NaN could also be the result of an operation that returns a valid number in a non-Real set (like Complex numbers), so it makes sense that NaN should be represented as its own value, outside of the set of floats (because it shouldn't be a valid float)

FiniteFxx could then have const arithmetic where NaN would be represented by None. fxx const arithmetic can then be implemented as unwrapping the result of FiniteFxx arithmetic.

imho having FiniteFxx is fine (though I would keep both +0.0 and -0.0 to match IEEE 754's definition of finite).

imho using FiniteFxx to implement const fxx arithmetic is probably terrible, floats have infinities for a good reason, and conflating them with NaNs is not helpful.

if I were to design const floats for Rust and get consistent cross-architecture results and weren't trying to match any particular architecture, I'd follow more or less what RISC-V, JavaScript, and Java do, and keep all results identical to what IEEE 754 specifies except for NaNs. I'd always have non-pure-bitwise fp ops (so everything but neg, copysign, abs, and friends) produce a known canonical NaN value (e.g. iirc RISC-V picked 0x7FC00000 for f32) everytime they output any NaN.

+1 to keeping the infinities -- they're useful, don't have the ambiguity problems of NANs, and don't keep the type from being Ord.

My reason for trimming all these values was to make certain that not ambiguity ever exists, but I'm also fine with keeping -0.0 and infinities if the people with more knowledge of floats confirm that there aren't any operations that may pick arbitrarily between them.

They couldn't be called finite anymore though: OrdFxx? Any suggestions?

They couldn't be called finite anymore though: OrdFxx? Any suggestions?

I've seen them referred to as NonNanF32 (with casing TBD), similar to NonZeroU32

Note that we already do allow floating point operations in const/static initializers. It's only const fn where they are currently forbidden.

So something like only allowing NonNanF32 in const would either be a massive breaking change, or we'd still have the strange situation of having different rules in const vs const fn.

I'm all for f32/f64 support in const fn, once we figure out the exact semantics, which imho should either match LLVM (which is currently being discussed), or be specified kinda like RISC-V, Java, and JavaScript, as I explained in more detail above in #57241 (comment)

NonNanF32 is just a nice addition that can be pursued independently.

str4d commented

Would it be reasonable, as a stepping stone, to stabilise subsets of what is currently covered by #![feature(const_fn_floating_point_arithmetic)] that can be defined unambiguously (if such subsets exist)? For example, I'd be interested in knowing if impl PartialEq for {f32, f64} could be stabilised independently of the rest of the arithmetic.

My particular motivation is that I have a C FFI that takes a C struct containing an f32, which for lifetime reasons needs to be defined as &'static. I want to provide a nice wrapper type that users can define as constants, and I want to enforce range constraints (namely assert!(value >= 0.0 && value <= 1.0) because this particular value represents a percentage).

My current goal regarding solving this issue is to make progress on allowing to_bits and from_bits in const fn, without addressing any of the other issues involved, and without entailing e.g. resolving const-time NaN semantics or anything like that. This will allow people to build what solutions they may on top of that.

+1 to allowing from_bits and to_bits; for a ratio library I've been working on I craft an f64 by hand since I don't trust hardware to round correctly and having to transmute the result is the only unsafe code in the whole thing.

One small stabilizable subset is is_nan function --- regardless of which NaN value a particular implementation uses, they all agree on the set of values considered NaN.

In my use-case (implementing something like NonNanF64), is_nan is the only const fn I need.

I would prefer to just go all in and land rust-lang/rfcs#3514

Adding exceptions for individual functions requires extra logic and will just cause us to spend a lot of time on discussing individual items

edit: I "made progress" on that RFC by pinging all the T-lang members that have unchecked boxes πŸ˜†

We're finally moving ahead with stabilization here. :)
#128596