Skip to content

Must a const fn behave exactly the same at runtime as at compile-time? #77745

Closed

Description

@oli-obk

TLDR: should we allow floating point types in const fn?

Basically the question is whether the following const fn

const fn foo(a: f32, b: f32) -> f32 {
    a / b
}

must yield the same results for the same arguments if it is invoked at runtime or compile-time:

const RES1: f32 = foo(1.0, 0.0);

fn main() {
  let res2: f32 = foo(1.0, 0.0);
  assert_eq!(RES1.to_bits(), res2.to_bits());
}

Depending on the platform's NaN behavior, the result will differ between runtime and compile-time execution of foo(1.0, 0.0). Compile-time execution is determined by the Rust port of apfloat (a soft-float implementation); runtime behavior depends on the actual NaN patterns used by the hardware which are not always fully determined by the IEEE specification.

Note that this is entirely independent of any optimizations; we are discussing here the relationship between code that the user explicitly requests to be executed at compile-time, and regular run-time code. Optimizations apply to all code equally and they treat fn and const fn the same, so the the questions of how floating-point operations can be optimized is an entirely separate from and off-topic for this issue.

cc @rust-lang/wg-const-eval

Activity

  1. added
    A-const-evalArea: Constant evaluation, covers all const contexts (static, const fn, ...)
    C-discussionCategory: Discussion or questions that doesn't represent real issues.
    on Oct 9, 2020
  2. RalfJung commented on Oct 9, 2020

    @RalfJung
    Member

    (Replying to an older version of the OP)

    I think you are asking two different questions here and treat them as if they are the same. Let me explain. :)

    More concretely, when we optimize the following code, are we allowed to const propagate the foo call?

    Note that this is a different question from the issue title. The behavior at runtime could be non-deterministic according to the spec, so even if you see one particular runtime behavior and a different compile-time behavior, it would still be correct to do const-propagation. Floating-point operations likely are non-deterministic.

    I feel rather strongly that const fn must behave in a way that is allowed to occur at runtime; that is just a different way of saying that CTFE must implement the Rust spec. It would be rather strange if that was not the case. From this alone it already follows that const propagation like you are asking is allowed. I cannot see any reasonable way (assuming a bug-free CTFE engine) in which this optimization is not allowed.

    But then there is the separate question, do we want to allow non-deterministic operations in const fn? CTFE inherently has to make some choice to resolve the non-determinism(*), and that choice might be different from what codegen+LLVM happen to currently do, which could be surprising for programmers that do not expect such non-determinism to actually be observable (even though it could, in theory, be observable even without any CTFE being involved). I think this is really the question you are asking here, but it is unrelated to const propagation. Unlike "is this optimization correct", this question cannot be answered by proving a theorem; this is a judgment call we could make either way as part of language design.

    (*) Actually that is not entirely true -- allocation base addresses are another example of non-determinism, and there CTFE uses a form of symbolic execution to basically track all possible non-deterministic choices at once, and halt evaluation for cases where that is not possible. This is required because the non-deterministic choice made for a certain allocation must be consistent for a given Rust program across const-time and run-time execution. For runtime code that choice is only made when the program actually starts (thanks to ASLR) and thus CTFE has to be done in a way that is compatible with every possible choice made later. We do not need such a heavy hammer for floating-point operations because their non-deterministic choice is much more local, confined to each individual operation.

  3. bugadani commented on Oct 9, 2020

    @bugadani
    Contributor

    I wonder in what real-world use case would somebody want to do complex calculations with NaN-s in compile time. I also wonder if the answer is the same if a compile-time calculation results in a NaN - is it something somebody actually wants, or does it hide an error?

  4. est31 commented on Oct 9, 2020

    @est31
    Member

    If NaN is the only concern, then the const fn implementation could just error out every time a NaN is encountered/obtained from computation. I also worry about floating point implementations having minuscle differences even for well-defined floats, but can't come up with an example. I see miri gives an error when a dangling pointer is returned in a constant as well (requires nightly to trigger it), so it's doable.

  5. oli-obk commented on Oct 9, 2020

    @oli-obk
    ContributorAuthor

    So.. if we have a const F: f32 = A + B; and at runtime a let f: f32 = A + black_box(B);, then it is not necessary for F.to_bits() == f.to_bits(), but it must at least be possible for that to be equal for some execution of the runtime code (even if not observable in practice due to CPU bugs).

    I also worry about floating point implementations having minuscle differences even for well-defined floats, but can't come up with an example.

    I always thought that even non-NaN floating point math can differ (in miniscule ways) between hardware even if it's the same target triple.

    the const fn implementation could just error out every time a NaN is encountered/obtained from computation

    That's one way, but it may be expensive. We specifically do not validate in const eval like we do in miri, because that validation is expensive. Doing it for floats may be cheaper, but if we also have to look at floats in large structs or arrays it can get expensive very quickly again. We could of course just check during each operation, which is much more direct and should only impact float ops. I think that if nondeterministic operations include non-NaN numbers, then that doesn't help us a lot though.

    I think that we can just make a judgement call on how we make such nondetermism deterministic (by choosing one possibility), even if that choice changes between target platforms, compiler versions, optimization levels or other compiler flags.

    The main question is then, how to make that choice I guess. Just put it on the const eval roadmap that I should really really finish and have T-lang sign it off?

  6. RalfJung commented on Oct 9, 2020

    @RalfJung
    Member

    So.. if we have a const F: f32 = A + B; and at runtime a let f: f32 = A + black_box(B);, then it is not necessary for F.to_bits() == f.to_bits(), but it must at least be possible for that to be equal for some execution of the runtime code (even if not observable in practice due to CPU bugs).

    No CPU bugs involved. If the spec says that non-deterministically, A or B can happen, then it is completely okay for runtime execution to always do A. Or to do A on Tuesdays and B every other day of the week. Or to do A only in crates whose name starts with a vowel. Or whatever. And CTFE can make its own choice of A or B completely independently on that.

    So in your case, both f.to_bits() and F.to_bits() must be results that are allowed by the spec, but since the spec might allow multiple results, the two do not have to be the same. And that's really all we can say; there is no requirement that running the program many times must eventually produce all possible results or so. (This distinguishes non-determinism from randomness.)

    I always thought that even non-NaN floating point math can differ (in miniscule ways) between hardware even if it's the same target triple.

    AFAIK IEEE fully specifies what happens for primitive FP operations (except for NaN bits). Basically the operation has to return the best possible approximation to the result of the computation if it were carried out on actual rational numbers. (Transcendental functions are a different game.) The remaining differences here really are down to CPU bugs -- 32bit x86 is notorious here, and I hear some architectures handle subnormals (values very close to 0) incorrectly.

    See rust-lang/unsafe-code-guidelines#237 for some more open questions about "what even are our floating-point semantics".

    That's one way, but it may be expensive. We specifically do not validate in const eval like we do in miri, because that validation is expensive. Doing it for floats may be cheaper, but if we also have to look at floats in large structs or arrays it can get expensive very quickly again. We could of course just check during each operation, which is much more direct and should only impact float ops.

    If the latest analysis in #73328 is correct, then only floating-point operations are non-deterministic, but copying around FP values is deterministic. I do not think it would be expensive to check at each floating point addition etc whether the result is deterministic (basically, if the result is non-NaN, but we can easily add further conditions), and raise an error if it is not. This has nothing to do with checking the validity invariant.

    I think that if nondeterministic operations include non-NaN numbers, then that doesn't help us a lot though.

    Why that? I think it would work just as well. Unless of course all operations are non-deterministic, then this would be equivalent to ruling out FP entirely.

  7. oli-obk commented on Oct 9, 2020

    @oli-obk
    ContributorAuthor

    Why that? I think it would work just as well. Unless of course all operations are non-deterministic, then this would be equivalent to ruling out FP entirely.

    What I mean is if we error whenever any operation returns a NaN, but still have other nondeterministic ops, then we haven't gained anything and should either keep ruling out FP ops or just allowing NaN and not checking anything.

    If the latest analysis in #73328 is correct, then only floating-point operations are non-deterministic, but copying around FP values is deterministic.

    Oooh, neat. That is an improvement to the info I had when we created min_const_fn

  8. RalfJung commented on Oct 9, 2020

    @RalfJung
    Member

    What I mean is if we error whenever any operation returns a NaN, but still have other nondeterministic ops, then we haven't gained anything and should either keep ruling out FP ops or just allowing NaN and not checking anything.

    Sure, we'd have to capture every possible form of non-determinism. Also everything I say on this topic should be checked by an FP expert, which I am not.^^

    Oooh, neat. That is an improvement to the info I had when we created min_const_fn

    Yes, new info came up since then. Basically I think we should just copy whatever WebAssembly does -- it has an exhaustive and precise spec, and I am sure they involved enough FP experts to make sure the spec is also realistically implementable. Also this means if LLVM does soemthing else we can complain that they are incompatible with WebAssembly, which gives our complaint more weight. ;)

  9. ecstatic-morse commented on Oct 9, 2020

    @ecstatic-morse
    Contributor

    Transcendental functions (e.g. trigonometry and non-integral exponentiation), can give different results between platforms. I don't think we can take a "hard-line" approach, since then functions like f32::tan that will take advantage of hardware support if it exists can never be const fn; Users would have to opt-in to a software emulated version (either in std or in the ecosystem). I view CTFE as just another platform on which floating point is supported. Users don't (or at least shouldn't) expect cross-platform consistency at runtime, why is CTFE any different?

    Even trying to promise that the result given by CTFE is consistent between compiler versions would be foolish, since it would lock us into a particular software emulation strategy that may become outdated. I think we should guarantee that CTFE engine for a given compiler at a given optimization level is deterministic between runs and nothing more. I think we might already have this for NaN payloads, although it would be nice to have an actual set of rules for how they get handled instead of "whatever LLVM does".

  10. workingjubilee commented on Oct 9, 2020

    @workingjubilee
    Member

    If NaN is the only concern, then the const fn implementation could just error out every time a NaN is encountered/obtained from computation. I also worry about floating point implementations having minuscle differences even for well-defined floats, but can't come up with an example.
    -@est31

    An example of what you may be thinking about is, for instance, ARMv7 Neon (the SIMD vector unit) flushing subnormal float values to zero. A floating-point operation in a register that flushes subnormal values to zero would potentially cause an alteration of the value even when the mantissa is not supposed to change (e.g. f32x4::abs). This is no longer a concern on ARMv8 Neon (usually "aarch64"), which will not do that if you don't ask it to, as far as I'm aware (and I am reasonably sure I would have found out by now).

    Part of the reason that x86_64 is well-behaved while x86 is not is that, as @thomcc told me, on x86_64 the XMM registers (introduced as SIMD registers) are fully compliant and compilers exhibit a preference for them even for "scalar" floating point ops. That may sound like a niche oddity but I have been finding in my reading and experiments that "The floating point registers and SIMD registers are actually the same" is actually a fairly common hardware implementation approach... correct FP math is expensive in transistors to implement, so I suppose one saves some surface area on the die by not doing so twice.

    In fact, this got me curious enough to feed some code into Godbolt...

    pub fn f32_add(a: f32, b: f32) -> f32 {
        a + b
    }
    
    pub fn f32_abs(a: f32) -> f32 {
        a.abs()
    }
    ; Compiled with rustc -C opt-level=3 -Cdebuginfo=0 -Ccodegen-units=1 --target armv7-unknown-linux-gnueabihf
    example::f32_add:
            vadd.f32        s0, s0, s1
            bx      lr
    
    example::f32_abs:
            vabs.f32        s0, s0
            bx      lr

    Yes, those are vector instructions. I had developed a slightly more thorough platform comparison for x86 and Arm targets at the link (nothing particularly exciting, mind) but I excerpted that because it does seem that at least on some ARMv7 triples there is a similar default to using the (potentially buggy, as mentioned) vector unit for floating point math... a sound choice elsewhere, an implementation concern here.

    There's more extensive documentation on all the nuances of Arm's various FP implementations spread across Arm's website but I found a relatively succinct explanation of some of the details on Debian's wiki in relation to their own support choices: https://wiki.debian.org/ArmHardFloatPort#Background_information

  11. tavianator commented on Oct 10, 2020

    @tavianator
    Contributor

    This GCC bug is possibly related to this discussion: https://gcc.gnu.org/bugzilla/show_bug.cgi?id=93681. LLVM may have similar issues, I'm not sure.

    The moral is that it's important that compile-time float evaluation can't lead the optimizer to make assumptions that may be contradicted at runtime.

  12. 61 remaining items

  13. indolering commented on Jun 10, 2021

    @indolering

    That will still mean, however, that we give up on const fn being deterministic functions at runtime. There are some situations where it'd be really nice to say "this function must be pure" and have the compiler check that (DerefPure anyone?). I am not sure if it was ever realistic or a good idea to use const fn for this purpose, but it seems at least possible in principle.

    AFAIK IIRC const functions were the rationale to reject adding a remove the pure keyword, as const was the only tractable definition of purity. Edit: a lot of people were expecting to use const functions for specifying contracts and formal methods work.

    At the risk of spamming this thread, couldn't you use the WASM interpretation by default but give programmers the option of matching runtime behavior by whitelisting deterministic functions like augmentedMath and (I think) trigPi? I'm a big fan of Watt and would love to see WASM leveraged for more compile time operations. I'll take this to the chat.

  14. memoryruins commented on Jun 10, 2021

    @memoryruins
    Contributor

    @indolering pure was a keyword before and during const fn, and was unreserved as a keyword ~2 years ago with the rationale in https://github.com/rust-lang/rfcs/blob/master/text/2421-unreservations-2018.md#rationale-for-pure (but this is unrelated to this issue so let's leave it at that)

  15. thomcc commented on Aug 5, 2022

    @thomcc
    Member

    I am going to use this space to talk about const_eval_select because it has no tracking issue. Basically, I'm not fond of the safety requirement for const_eval_select.

    Here's an example where safe code might want to use it: it seems desirable to have some kind of debug_or_const_assert! in certain cases -- in fact, stdlib already uses this pattern all over the place to check for soundness violations.

    Unfortunately, currently this is only useful for checking unsafe code, despite it being pretty reasonable to have the result of violating the condition to be "incorrect behavior" rather than "unsoundness" (if it's useful I can come up with several concrete places I'd want to use this).

    It also seems to create some weird incentives to make/keep invariants as safety invariants (rather than correctness invariants). For example, let's imagine that say I have an unsafe const fn that checks it a safety invariant in this manner (that is, this is using the hypothetical debug_or_const_assert! implemented using const_eval_select).

    If it turns out that after some time, the initial reason for making the function unsafe is no longer valid (this is common in my experience, as rustc improves we need less unsafe to get good performance), I still can't make the function safe unless I either give up performing the check at compile time, or accept doing it at runtime too (which may not be acceptable). This is a big bummer, and encourages keeping this function unsafe.


    And all that is ignoring how easy it seems to violate the requirement, especially if they only differ in some edge case that the author may not have considered, either because one of them has a bug, or because they use some platform intrinsic that is only mostly identical to the operation the const code performs (due to the author not reading the documentation precisely enough, perhaps).

    To be clear: having them differ semantically would certainly result in confusing and undesirable behavior, and I'm not saying it should be encouraged -- just the opposite! What I am saying is that we have a lot of precedent in std for not considering it to be unsound for users to do confusing/undesirable things, especially in the case where they're likely to do so inadvertently.

    In other words, I feel that differing const/runtime behavior with const_eval_select feels... kinda similar to having a buggy Ord impl -- it's confusing and undesirable, but also will inevitably occur by accident.


    I'll also note that this part of the documentation...

    Safe code in other crates may assume that $reasonable_assumption_that_isnt_always_true

    ... is true for all sorts of things (including the implementation of traits like Ord). This isn't a problem, since bugs happen. And then the example goes on to show a case where unsafe code makes this the assumption.

    It seems totally reasonable to me that it's fine to push the safety requirement onto the code that contains unreachable_unchecked() -- it's the thing that needs the assumption to hold for soundness.

    This would mean that unsafe code can't rely on this being true for external code, similar to how unsafe code shouldn't assume traits in third-party crates are correctly implemented.

  16. RalfJung commented on Aug 6, 2022

    @RalfJung
    Member

    The current requirements on const_eval_select reflect an abundance of caution. I am not aware of there being anything fundamentally wrong with having functions that behave differently at compile-time and run-time, and allowing such differences would also bring float-in-const a lot closer to stabilization. However, that should be done very explicitly, not as a byproduct of introducing an intrinsic that was needed for a quick hack. That's why we went with the super paranoid version for now.

    Would you like to draft an RFC to officially declare that we are okay with the same function behaving differently on the same input in runtime and const contexts? I think that is the kind of official process we need. (Possibly a T-lang FCP could also be enough, but I don't know what the FCP would be on, so an RFC seems better.)

  17. Lokathor commented on Aug 6, 2022

    @Lokathor
    Contributor

    For something of this magnitude the additional publicity of an RFC would be essential.

  18. RalfJung commented on Aug 6, 2022

    @RalfJung
    Member

    Another question that the RFC probably needs to settle at the same time: must a const fn, when called at runtime in a way that it could also have been called at compile-time, always behave deterministically and without side-effects (other than writing to references explicitly given to the function)?

    This is currently true, and it is potentially useful for unsafe code: given a const fn, unsafe code might never actually want to call this function in const context, but it might want to do things which are only correct when that function is deterministic and side-effect-free. But overall I feel that the costs of this restriction (which effectively tries to use const as an effect system for ensuring some form of purity) outweigh the benefits. Still, abolishing that rule should be done explicitly and with intent, not accidentally.

  19. thomcc commented on Aug 7, 2022

    @thomcc
    Member

    Would you like to draft an RFC to officially declare that we are okay with the same function behaving differently on the same input in runtime and const contexts...

    Uh, hm. Sure, I guess I have some time for this. I likely will need someone more familiar with const eval to actually look over it after I have something, do you mind?

    (This may be in a bit since I have actually promised another RFC at rustconf)


    Re: const fn purity, in general I'm not a fan of telling unsafe code they can rely on the behavior of things like this. IMO you should be pretty careful when unsafe and only trust your code, and code that has made a promise to uphold the behavior that you need (possibly an implicit promise in the case of std, for example).

    In particular, while it's not supported for this to happen (and may even be UB), things like the floating point environment may change externally, which could influence the results. If Rust code is in a cdylib, this could happen externally across two separate calls. So, promising that unsafe code may rely on this sounds a little negligent -- it's not a promise we can actually keep in all cases. (While float env changes might be a form of UB, they definitely will happen, and the things unsafe code could get up to if it believes this is impossible might be worse (exploitable) UB -- who knows).

    Also this sounds like it would end up limiting the functionality that can be provided by const fn in the future, which could end up being a bummer.

    (Also, people who want purity should consider what that actually looks like, and try to get it added to the language. Tacking it on to "what constant evaluation functions do at runtime" is not the way to go about it IMO)

  20. RalfJung commented on Aug 7, 2022

    @RalfJung
    Member

    Uh, hm. Sure, I guess I have some time for this. I likely will need someone more familiar with const eval to actually look over it after I have something, do you mind?

    Yeah just ping @rust-lang/wg-const-eval and we'll be happy to give feedback. :) AFAIK we all want something like this to happen (@oli-obk @lcnr stop me if I am wrong).

    While float env changes might be a form of UB, they definitely will happen

    They must happen in a way that Rust code never sees it, i.e., changing it back to the previous configuration before control flows back to Rust. Then it's fine. Otherwise it's UB and you might have bigger problems.

    Also this sounds like it would end up limiting the functionality that can be provided by const fn in the future, which could end up being a bummer.

    Yes indeed that is my main reason for wanting to drop this restriction. I am not sure how const-heap support will look like but creating a heap allocation is not pure...

    (Also, people who want purity should consider what that actually looks like, and try to get it added to the language. Tacking it on to "what constant evaluation functions do at runtime" is not the way to go about it IMO)

    👍

  21. dlight commented on Aug 7, 2022

    @dlight

    I was thinking about this and the only way this issue can ever be addressed is if Rust had a software floatng point implementation that perfectly emulates each supported platform (not only nan shenanigans but also x87 weirdness etc). Then it would work even if cross compiling

    On one hand this would introduce a great burden to every supported platform, to the point that many platforms would need to be marked as "const fn differs from fn". On the other hand, it's probably possible to borrow code from projects like qemu or emulators in general, so it seems at least doable.

  22. RalfJung commented on Aug 7, 2022

    @RalfJung
    Member

    Not allowing float operations in const fn also "addresses" the problem. ;) Not in a way that makes anyone happy, though...

    Note that there has been tons of prior discussion on this specific problem, also considering e.g. disallowing NaNs in const.

  23. Noratrieb commented on Oct 13, 2022

    @Noratrieb
    Member

    I'm interested in writing an RFC for allowing this (assuming no one else is working on one already). I'll reach out on Zulip once I have the first draft for feedback (feel free to reach out to me before that if you want to tell me something important about it) 🙂

  24. 823984418 commented on Feb 7, 2023

    @823984418
    Contributor

    I have an idea:

    Disallow constant floating-point f32 operations, through the standard library built-in a soft floating-point SoftF32, and provide constant conversion between the two?

    The user performs the calculation in a constant context by converting the floating point to a soft floating point, thus sensing possible platform differences.

    Is there already a third-party repository for this?

  25. 823984418 commented on Feb 7, 2023

    @823984418
    Contributor

    I simply copied the soft floating point implementation in compiler_builtins:
    const_soft_float
    Is it possible?

  26. RalfJung commented on Aug 4, 2024

    @RalfJung
    Member

    With rust-lang/rfcs#3514 having been accepted, part of the answer here is given: it is okay for a const fn to return different answers for the same input and compiletime and runtime, at least as long as both invocations could have given the same answer (but the non-deterministic choice ended up being different).

    What is still open is whether we want a safe stable way of accessing const_eval_select, but that's tracked at #124625.

Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Metadata

Metadata

Assignees

No one assigned

    Labels

    A-const-evalArea: Constant evaluation, covers all const contexts (static, const fn, ...)A-floating-pointArea: Floating point numbers and arithmeticC-discussionCategory: Discussion or questions that doesn't represent real issues.T-langRelevant to the language team

    Type

    No type

    Projects

    No projects

      Milestone

      No milestone

      Relationships

      None yet

      Development

      No branches or pull requests

      Issue actions