RFC: Change of strict FP operation representation in IR

Background

Floating-point operations are unique in that they have different representations depending on the use case. In the default mode, FP operations are represented by instructions such as fadd or intrinsic functions like llvm.trunc, which appear to be pure functions. It is not true in the general case, as on many cores FP operations may depend on the contents of some hardware register that stores options like rounding mode and some other. Additionally, these operations can change bits in some register to report events that occur during the evaluation. In strict FP mode these interactions are not ignored and are represented as a side effects associated with the operation. Consequently, in this mode FP operations must be represented differently to reflect these side effects.

In the current LLVM implementation, FP operations in strict FP mode are represented by constrained intrinsic function calls, such as llvm.experimental.constrained.fadd or llvm.experimental.constrained.trunc. These functions always have side effects. They also have additional arguments, which are compiler hints.

The problem

While the current solution works well for typical cases, it faces challenges in more complex scenarios (Thought on strictfp support). In particular:

  • It requires two separate functions for each FP operation even though these functions are processed almost identically,
  • It is difficult to extend this solution to target intrinsics,
  • It cannot accommodate other FP options, such as the treatment of denormals,
  • It is hard to support FP control modes specified directly in instructions, such as static rounding (Static rounding mode in IR).

Proposal

The alternative solution is based on these assumptions:

  • Some intrinsics functions (floating-point operations) may have side effects depending on whether the containing function has the strictfp attribute. In the functions that use the default FP mode, these intrinsics behave as pure functions, if strict FP mode is required, they may have side effects.
  • Calls to these intrinsics may have special operand bundles, which may be, in particular, compiler hints, or may specify options for the operation, like rounding mode.

The use of operand bundles to pass compiler hints is not a new idea. There was an attempt to replace constrained intrinsics with operand bundles (⚙ D93455 Constrained fp OpBundles), but this effort was not completed.

Two kinds of FP operand bundles are defined, which generally reflect the arguments of existing constrained functions:

  • Effective control mode set. Now it only includes the rounding mode. It is the rounding mode used for evaluating the called function result. It may be a mode specified in the control register, if compiler can deduce it. It also can be static rounding mode, stored in the corresponding instruction. This kind of bundle is identified by the tag fp.control. In future it may be extended to include other control modes.
  • Exception handling. This bundle has exactly the same meaning as the corresponding argument in constrained function call. It is identified by the tag fp.except.

The operand bundles are represented as metadata:

call float @llvm.nearbyint.f32(float %x) [ "fp.control"(metadata !"dyn"), "fp.except"(metadata !"strict") ]

Rounding mode is specified by one of the strings: “dyn”, “rte”, “rtz”, “rtp”, “rtn”, “rmm”. Exception handling can take one of the values: “ignore”, “maytrap” and “strict”.

Operand bundles are optional. Unspecified parameters are assumed to have default values, which depends on the attributes of the enclosing function . For example, the following calls are equivalent in strict FP mode:

call float @llvm.nearbyint.f32(float %x) [ "fp.control"(metadata !"dyn"), "fp.except"(metadata !"strict") ]
call float @llvm.nearbyint.f32(float %x) [ "fp.except"(metadata !"strict")  ]
call float @llvm.nearbyint.f32(float %x) [ "fp.control"(metadata !"dyn") ]
call float @llvm.nearbyint.f32(float %x)

And the following are identical in default FP mode:

call float @llvm.nearbyint.f32(float %x) [ "fp.control"(metadata !"rte"), "fp.except"(metadata !"ignore") ]
call float @llvm.nearbyint.f32(float %x) [ "fp.except"(metadata !"ignore")  ]
call float @llvm.nearbyint.f32(float %x) [ "fp.control"(metadata !"rte") ]
call float @llvm.nearbyint.f32(float %x)

Advantages

The proposed solution has several advantages:

  • The same function call, such as call float @llvm.trunc.f32(float %x) may be used for both default and strict modes. No more need of separate functions for strict and default modes.
  • Any intrinsic function can get support in the strict mode with minimal changes.
  • Hints can be specified in any mode. In particular it allows using static rounding in default mode.
  • The set of supported hints and options can be easily expanded.

Implementation

There are the MRs, that try to implement this solution:
Implement operand bundles for floating-point operations by spavloff · Pull Request #109798 · llvm/llvm-project · GitHub, in which the operand bundles are introduced and exist together with the constrained functions.
Reimplement constrained 'trunc' using operand bundles by spavloff · Pull Request #118253 · llvm/llvm-project · GitHub, which is an example how an intrinsic can be modified to use the new solution only.

The implementation plan looks as follows:

  • Implement operand bundles, the !109798 does this,
  • Update intrinsics to use the operand bundles. It can be made gradually. After this step intrinsic functions use the new mechanism and only constrained counterparts of instructions, like fadd remain.
  • Introduce new intrinsics like llvm.fadd and use them in strictfp functions. After this step the transition is complete.

Any feedback is appreciated.

1 Like

I like the direction of this proposal. The current approach of having separate constrained intrinsics is fundamentally not scalable, esp. when it comes to target intrinsics.

It would be nice to get some input here from some FP experts. cc @arsenm @jcranmer @andykaylor @efriedma-quic

Some notes from my side:

Are these well-known abbreviations? I’d consider spelling these out more fully (round_to_even, etc.)

You can’t do this for the strict FP case. The whole reason why the operand bundle approach works is that while the baseline intrinsics don’t have memory effects, operand bundles are allowed to add them, so having call @llvm.nearbyint() not have effects and have call @llvm.nearbyint() ["fp.control"(metadata !"dyn")] read the FP environment is coherent.

However, you can’t make call @llvm.nearbyint() by itself have additional effects just by dint of being inside a strictfp function.

Apart from the general IR design constraint, making the bundles optional would also make it hard to determine which operations are actually affected by strictfp and which are not.

Can you please clarify what the semantics of "fp.control"(metadata !"rte") are supposed to be? From earlier in your proposal I thought this is going to mean “static rte rounding mode”, but here (non-strictfp) it seems to mean “dyn rounding mode == rte as a precondition”.

I think static rounding mode and rounding more preconditions are very different things and shouldn’t be conflated in one representation.

Edit: Thinking more on the last point, I guess it’s “static rte rounding mode” in both cases, just that outside strictfp we can lower it to an implementation delying on dyn FP mode, because we know they’re equivalent. This still makes me somewhat uneasy. I think this may add some tricky interaction for inlining between strictfp and non-strictfp functions. Being able to specify a known value for the FP mode also seems generally useful, so I’d like to make sure we don’t close the door on that.

Sorry for the delay in response, I’ve been busy with other things as of
late.

Overall, as I believe I mentioned in my presentation last dev meeting,
I’m broadly in favor of this approach–constrained intrinsics haven’t
exactly proven to be a workable solution, and operand bundles seems a
much more promising solution.

Two kinds of FP operand bundles are defined, which generally reflect
the arguments of existing constrained functions:

  • Effective control mode set. Now it only includes the rounding
    mode. It is the rounding mode used for evaluating the called
    function result. It may be a mode specified in the control
    register, if compiler can deduce it. It also can be static
    rounding mode, stored in the corresponding instruction. This kind
    of bundle is identified by the tag fp.control. In future it may be
    extended to include other control modes.
  • Exception handling. This bundle has exactly the same meaning as
    the corresponding argument in constrained function call. It is
    identified by the tag fp.except.

I’m not going to insist on seeing implementation here, but I’d at least
like to see a sketch of what it would look like to bring some of the
other bits in the FP environment into the operand bundle
model–specifically things like DAZ/FTZ bits or x87’s precision control
bits or some of ARM’s weirder bits. “fp.control” at the moment feels
like it’s a synonym for rounding mode, and given that these bits are
orthogonal to rounding mode, I want to understand how they’d differ.

Technically, no, we can’t add effects to calls in strictfp functions. But we could remove effects from calls in non-strictfp functions, which has basically the same practical effect.

I also think that this general approach is good. We’ve had general consensus on this as the correct direction for about five years, and I appreciate that you are stepping up to work towards making it happen.

I have a few general concerns.


We need a plan to ensure that existing functionality (to the extent that strictfp mode currently works) won’t be disrupted as we transition to the new implementation. This probably needs to be implemented as a parallel approach, with the existing implementation left in place until the new solution is able to replace everything that currently works (or mostly works). I know this is very important to @kpneal.


I’d like to see more details about how this will work in practice. Specifically, the current approach was chosen based on the desire to have something that was conservatively correct by default. We used constrained intrinsics precisely because it meant that existing optimizations would ignore them. Do we have a similar guarantee with operand bundles?

The Language Reference says, “Calls and invokes with operand bundles have unknown read / write effect on the heap on entry and exit (even if the call target specifies a memory attribute), unless they’re overridden with callsite specific attributes.” I don’t see any mention of other side effects. What would prevent a pass that optimizes llvm.nearbyint (for example) from ignoring the operand bundle?


Can you clarify what you intend the “effective control mode” to mean? We’ve talked about this before, and I’m not sure how you currently view it. The use of “effective” here concerns me. As I’ve said before, I think this needs to be more like “assumed control mode” – that is, the compiler can assume that this mode is in effect and can select instructions that only conform to the assumed mode (such as those with explicit static rounding), but the compiler is not required to cause this mode to be in effect. Otherwise, we wouldn’t be able to select instructions that use the dynamic rounding mode without proving that the mode described by the operand bundle is in effect (or setting it).


I strongly agree with what @nikic said about not having the default behavior of the intrinsics change based on the strictfp mode. Depending on function attributes or global flags is very problematic.

I also think we need to keep the current rules for inlining non-strictfp functions into strictfp functions. Otherwise, there’s nothing to prevent code motion of non-strict operations relative to strict operations.


How do you plan to handle standard operations like fadd/fsub/fmul/fdiv, etc.? Will there be new intrinsics to represent those so that operand bundles can be optionally attached?


This is less critical, but I’d like to see a discussion of how we can align the new implementation with possible constrained floating point representation in MLIR. @szakharin raised this issue several years ago ( Modelling strict floating point behavior in math-like dialects - MLIR - LLVM Discussion Forums), and I don’t think it has been resolved on the MLIR side, but as long as we’re redesigning things, this seems like a good time to try to move towards a solution there too.

1 Like

Values “rte”, “rtz”, “rtp”, “rtn” are defined by OpenCL specification (The OpenCL™ C Specification). This naming is used for some cores, with variations. The specification does not define a value for the IEEE-754 mode roundTiesToAway, so it was adopted from RISC-V modes (RISC-V Instruction Set Manual, Volume I: RISC-V User-Level ISA | Five EmbedDev), along with dyn.

Other possible encodings could be derived from, for example:

C library names (https://www.iso-9899.info/n3047.html#6.10.7):

  • FE_DOWNWARD
  • FE_TOWARDZERO
  • FE_TONEAREST
  • FE_UPWARD
  • FE_TONEARESTFROMZERO
  • FE_DYNAMIC

Probably without the prefix FE_ and using lowercase,

LLVM values used in constrained intrinsic calls (LLVM Language Reference Manual — LLVM 21.0.0git documentation).

  • “round.dynamic”
  • “round.tonearest”
  • “round.downward”
  • “round.upward”
  • “round.towardzero”
  • “round.tonearestaway”

Descriptive names help new developers understand the code but increase verbosity, while shorter names enhance readability. Choosing between long and short names for rounding modes will likely influence other control modes as well. For example, compare these:

call float @llvm.nearbyint.f32(float %x) [ "fp.control"(metadata !"rte", metadata !"daz", metadata !"ftz") ]

and

call float @llvm.nearbyint.f32(float %x) [ "fp.control"(metadata !"round_to_even", metadata !"denormal_as_zero"), metadata !"flush_to_zero" ]

We just need to choose the more convenient naming.

All FP computational operations have side effects, there is no way to turn them off. Thus, the basic form of an FP instruction is the form with the side effects. Only if the function code obeys to certain restrictions, these side effects may be ignored, and the FP operations can behave as pure functions. This means that the side effects of FP operations are determined solely by strictfp function attribute. Operand bundles, as well as metadata arguments of constrained intrinsics, cannot alter it. Even if a constrained function call has an argument fpexcept.ignore, it still has side effects.

Using default values for unspecified bundles has some advantages:

  • The same IR is valid for both strict and default modes. This simplifies some transformations and analyses. For example, inlining could be made uniformly.
  • Bitcode size is reduced.
  • Textual representation becomes more readable, because it does not contain data that are trivially derived from function attributes.

The obvious disadvantage is that operations with and without side effects are visually indistinguishable. However:

  • It is not clear how useful is this information. Anyway, mixing operations with and without side effect is not possible, and the side effect can be easily deduced from the function attributes.
  • The side effect can be printed in IR via comments. Such solution has advantages if, in future, the side effects of FP operation are split into control and status bit access.

Nor constrained intrinsics, neither this RFC provide such a possibility. But such feature could be beneficial. For example trunc(x) in general has side effects because it raises an ‘Invalid’ exception if x is signaling NaN. But in trunc(x*y) it never raises exception because x*y cannot produce SNaN. Removing side effects from such calls could facilitate optimizations.

This cannot be achieved with constrained intrinsics, as they always have side effects, but it can be realized with operand bundles.

It requires two simple checks:

The ability to ignore FP side effects is not a property of a separate instruction. These side effects may only be ignored if no other instructions observe them, so this is a property of entire function. What problems are caused by dependence on function attributes?

The rounding mode in all cases is the effective rounding mode, - the mode used for instruction evaluation. In most cases, it does not matter if the rounding mode is stored along with the instruction or is taken from a register.

Currently, only dynamic rounding mode is available. In the future, static rounding should also be supported to implement C23 #pragma STDC FENV_ROUND and to take advantage of static rounding available on some platforms.

This future implementation of static rounding could be based on the assumption that the difference between the two ways of specifying rounding mode can be considered as a low-level detail, which does not need to be represented in IR. The compiler has some freedom in how the rounding is specified. Meaning of "fp.control"(metadata !"rte") is preserved, it represents the rounding mode used in the evaluation. If rounding mode is known (not dynamic), it is static rounding. Execution of this instruction does not depend on its position inside a function. Assuming FP exceptions are ignored, such instruction behaves as a pure function and may be used in the default mode. Thus, the call:

call float @llvm.nearbyint.f32(float %x) [ "fp.control"(metadata !"rtp") ]

is allowed in default mode and may be used to implement the C code:

{
  #pragma STDC FENV_ROUND FE_UPWARD
  y = nearbyint(x);
}

Such implementation has advantages:

• As mentioned above it allows using non-default rounding mode in the default mode.
• It enables seamless implementation of #pragma STDC FENV_ROUND, which generally does not require strict FP handling.
• Instructions with static rounding mode behave as pure functions (assuming FP exceptions are ignored), it could facilitate optimizations.

If the target does not support static rounding, or if the instruction is not available with the required static rounding, compiler must use dynamic rounding. There must be a pass that inserts llvm.set_rounding and llvm.get_rounding to ensure instructions evaluate with the specified rounding. The function becomes strictfp, and floating-point instructions acquire side effects to maintain proper ordering with llvm.{get,set}_rounding.

It should be emphasized that such pass is required in any case, no matter how FP instructions are implemented. It is because pragma FENV_ROUND only specifies static rounding mode for some FP operations, while other still use the dynamic rounding. For example, the code:

float func(double x, double y) {
  #pragma STDC FENV_ACCESS ON
  #pragma STDC FENV_ROUND FE_DOWNWARDS
  return (sin)(x * y);
}

should be implemented with the instructions:

%mul = call @llvm.fmul.f64(double %x, double %y) [ "fp.control"(metadata !"rtn") ]
%sin = call double @llvm.sin.f64(double %mul) [ "fp.control"(metadata !"dyn") ]
%conv = call float @llvm.fptrunc.f32.f64(double %conv) [ "fp.control"(metadata !"rtn") ]

The compiler should switch between the static and dynamic rounding modes and the points, where such switches occur, must be determined by the compiler.

If there are cases where difference between static and dynamic rounding must be explicitly represented in IR, an additional specifier like !"dynamic=round" could be introduced. Similar specifier for static rounding does not look useful, because the described implementation already tries to use static rounding if the rounding mode is known, and it is not possible to use static rounding in all cases.

These are thoughts about possible implementation of static rounding support. Transition from constrained intrinsics to operand bundles does not depend on it because static rounding is not available right now.

The effective rounding mode refers to the mode used during the instruction evaluation. In other words, it is either static or dynamic rounding mode. The term “effective” was added following the discussion with @kpneal (Reimplement constrained 'trunc' using operand bundles by spavloff · Pull Request #118253 · llvm/llvm-project · GitHub), in which it was realized that the rounding mode argument cannot be strictly classified as static or dynamic rounding in all scenarios.

An important difference with constrained intrinsics is that the rounding mode bundle is not an “assumed” mode. It is compiler responsibility to ensure that the instruction is evaluated with the specified mode. In particular, the compiler may put llvm.set_rounding before it. It provides the compiler with some freedom and simplifies implementation of pragma FENV_ROUND.

Bundle operands to specify denormal modes by spavloff · Pull Request #136501 · llvm/llvm-project · GitHub demonstrates how operand bundles can represent denormal behavior.

I hope Minimal support of floating-point operand bundles by spavloff · Pull Request #135658 · llvm/llvm-project · GitHub can serve as a foundation for the parallel approach you mentioned. It introduces FP operand bundles without modifying the constrained intrinsics. The two mechanisms can coexist and evolve independently. An operation may be represented in either of them or both.

Both approaches use the same side-effect representation to describe interaction with FP environment, ensuring they do not break each other functionality. In fact, constrained intrinsics themselves may be declared as FP Operations and thus may have FP bundles, Add constrained fadd to FP operations by spavloff · Pull Request #136499 · llvm/llvm-project · GitHub is an example of such case. It also adds a code that treats metadata argument as if they were bundles, thus allowing realization of algorithms, which would correctly work with any representation.

An operation could be added to FP operations, its support be adapted to operand bundles and then it can be removed from constrained intrinsics, this could be considered as a step in the new mechanism implementation.

Based on the discussion with @kpneal I understand that ensuring expected behavior of strictfp function attribute is a key requirement. However, this solution does not use this attribute at all. The existing mechanism that creates and propagates the attribute is not changed. Anyway, any feedback on this topic is highly appreciated.

The operand bundles implement a different approach. The same intrinsic can now be used in both default and strictfp environment, so optimizations are enabled even in strictfp environment. When an intrinsic is added to FP Operations, we must verify whether optimizations remain valid in strictfp environment. For most operations the optimizations rely on mathematical properties of these intrinsics and typically hold in strictfp case. Instructions, like fadd, are more complex case, but the intrinsics to represent these operations (like llvm.fadd) initially will also be ignored by optimization.

Operand bundles implementation should be fixed accordingly. The implementation in https://github.com/llvm/llvm-project/pull/109798 indeed missed a case, in Minimal support of floating-point operand bundles by spavloff · Pull Request #135658 · llvm/llvm-project · GitHub it is fixed.

This solution as well as the existing mechanism use only “read/write access to a memory that is not accessible by pointers”. In future this access could be refined to distinguish access to control and status bits. This possibility is promising but now is not considered.

Passes must respect the memory effects reported by getMemoryEffects for FP operations. Other information provided by the bundles is floating-point specific, it is needed only by transformations that are aware of floating-point numbers. Usually it may be safely ignored by other transformations. There are exceptions (like dead code elimination), but they are pertinent to the both mechanisms and necessary code should be already present.

Under the proposed default behavior based on strictfp function property it is impossible to mix non-strictfp operation with strictfp code. When an FP call is moved from non-strictfp code to strictfp function, it automatically acquires side effects and behaves according to the strictfp rules.

For what it’s worth, I prefer the second variant in terms of verbosity (without commenting on the specific strings used here).

Especially as there already is a lot of syntactic overhead anyway, I don’t think saving a handful of characters here at the expense of clarity is worthwhile.


I don’t really agree with this view, at least in terms of how we want to model these operations in LLVM IR. Note that there are multiple effects involved here. For most FP operations, the most general set of effects will be something like memory(fp_rounding_mode: read, fp_denormal_mode: read, ..., fp_status: write).

Let’s just look at fp_rounding_mode: read to start. @fadd() ["fp.control"(metadata !"round_to_nearest")] does not have a fp_rounding_mode: read effect, because the (effective) rounding mode is fixed. Importantly, this is true regardless of whether the operation is inside a strictfp function or not.

If we have ["fp.control"(metadata !"dynamic")] instead, then the situation is a bit more nuanced. Taken by itself, this operation has a fp_rounding_mode: read effect. However, inside a non-strictfp function, we know that the rounding mode is fixed to round-to-even, so the effect is unobservable and can be modeled as fp_rounding_mode: none.

So when it comes to the FP control effects, the operation has an intrinsic effect determined by operand bundles, and then on top of that (the absence of) strictfp may allow us to ignore some effects.

For fp_status: write the situation is more along the lines of what you said, in that the operation always (potentially) writes the FP status, but fpexcept.ignore allows us to ignore this effect. But regardless of what the underlying hardware behavior is, the way we’d be modelling this at the IR level is still fp_status: none.

All this is to say that I don’t think that that the strictfp should be implicitly changing the effects or behavior of FP operations. The meaning of an undecorated operation inside a strictfp function should remain round-to-nearest + fpexcept.ignore, same as in a non-strictfp function. (Though I would accept the argument to make the decorations required inside strictfp functions to make bugs less likely.)


Regarding the discussion on static rounding modes, I generally agree with what you’re saying, but the part I’m still missing is how the current functionality of constrained FP intrinsics can be represented under the new model.

As I understand it, your proposal allows specifying either a dynamic rounding mode or a static rounding mode, but it has no way to encode an assumed rounding mode.

If you specify a static rounding mode and the target does not allow per-instruction rounding modes, then LLVM will be forced to change the dynamic rounding mode around the instruction to lower it, which would be very inefficient – especially if there is prior knowledge that the dynamic rounding mode is already set to the correct value.

Is this just not a use-case anyone cares about? Otherwise we should have a replacement for it. (A possible replacement would be to make the strictfp attribute more fine grained, so we can express things like “the rounding mode inside this functions is known to be xyz”. If function-granularity is sufficient for this.)


A question related to this one: Why do we use a single fp.control operand bundle rather than separate bundles for different FP control options? The PR does something like "fp.control"(metadata !"denorm.in=ieee"), which we could also spell "fp.denorm.in"(metadata !"ieee").

I don’t think this makes much difference either way, just seemed like an odd choice.

No problem. We can use the same encoding as we do now for constrained intrinsics, it is familiar to developers. Taking into account the note below about splitting “fp.control” and making similar changes in denorm bundles, the syntax could be:

call float @llvm.nearbyint.f32(float %x) [ "fp.round"(metadata !"tonearest"), "fp.denorm.in"(metadata !"preserve-sign"), "fp.denorm.out"(metadata !"positive_zero"), "fp.except"(metadata !"ignore") ]

It depends on how we want to model these operations in LLVM IR. if @fadd() ["fp.round"(metadata !"round_to_nearest")] does not have a side effect, it means that rounding mode in this case is considered as static. This representation is slightly more abstract than the existing constrained intrinsics. But it has advantages including:

  • more possibilities for optimizations,
  • natural implementation of pragma STDC FENV_ROUND,
  • possibility to use static rounding in default mode.

It however requires a dedicated pass that would insert necessary set_/get_rounding calls to preserve the semantics on targets without static rounding.

Using static rounding for specific (non-dynamic) modes seems like a good approach. It allows us to use static rounding freely in LLVM IR for any target, but eventually we should replace static rounding with dynamic before the instruction selection.

The key point is that exceptions cannot be ignored in strictfp environment, and fpexcept.ignore cannot change that. For example:

inline float divide(float x, float y) {
  // default fp mode.
  return x / y;
}

float calculate() {
  #pragma STDC FENV_ACCESS ON
  …
  y = sin(x);
  exc = fetestexcept(MASK))
  …
  c = divide(a, b);
  …
}

In this case, the divide function produces a division instruction with fpexcept.ignore flag, since it originates from a default-mode function. If this instruction had fp_status: none attribute, nothing would prevent the compiler from placing the instruction between the instruction that use exceptions:

  y = sin(x);
  c = divide(a, b);
  exc = fetestexcept(MASK))

In a strictfp function, an IR instruction fadd with fp_status: none cannot be realized with just a single target fadd instruction - it requires at least a pair of fegetenv and fesetenv. This makes the use of fp_status: none in strictfp functions a source of misunderstanding. Most transformation based on this property in LLVM seem to be incorrect. The only valid use is eliminating instructions with unused results, even if the instruction may raise exceptions. For example, the following call may be removed by compiler, although it raises an exception:

    %unused = call double @llvm.experimental.constrained.fdiv.f64(double 0.0, double 0.0, metadata !"round.upward", metadata !"fpexcept.ignore" 

Some users think this behavior is incorrect. If they are right, fp_status: none in strictfp function is useless, instructions with and without this attribute behave identically. Consequently the entire parameter “fpexcept” can be removed, as the exception handling would be determined only by the function attribute ‘strictfp’.

If we choose to maintain per-instruction exception handling flags, we must define its semantics more precisely and establish a set of potential use cases for this flags. I would drop it completely, - exception handling is defined by the entire code, it is not a choice of an individual function. It however could be made later, no need to tie this decision with this proposal.

The current implementation supports only two models: default mode and strictfp mode with dynamic rounding. It is possible to create an IR with instructions that use specific rounding modes, but clang does not produce such code and it is unlikely that some MLIR or other LLVM-based code generator creates strictfp code that uses non-dynamic rounding.

So the current functionality of constrained FP intrinsics consists of only dynamic rounding. All constrained intrinsics have side effect to prevent undesirable reorder and this is vital for strictfp implementation. All these properties are preserved in the proposed implementation.

I would propose to consider difference between static and assumed modes as a low-level implementation detail and leave the choice between them to the compiler. fadd executes identically (produces the same results) no matter if rounding mode is specified in the instruction or is read from a register.

If a case arises where it is really necessary to distinguish between them, rounding mode bundle could be amended with an appropriate flag, for example:

call float @llvm.nearbyint.f32(float %x) [ "fp.round"(metadata !"tonearest,dynamic" ]

Yes, you are right. Support of static rounding mode requires some IR transformation that would insert set_rounding and get_rounding to emulate semantics of static rounding. However it is required anyway if we want to implement pragma FENV_ROUND, this is not a drawback of this proposal. The C standard explicitly points out that static rounding may be implemented in this way: https://www.iso-9899.info/n3047.html#7.6.2p5. And yes, a target with hardware support of static rounding would have benefits.

Most of the information needed for the transformation, that converts static rounding to dynamic, can be obtained from analyzing the function code. If pragma FENV_ROUND is used without FENV_ACCESS, we know that no user function modifies rounding mode, and we can insert functions that change rounding mode optimally. Otherwise the user intends to mix static and dynamic modes and the things are more complicated. Compiler intrinsics and standard C functions have known behavior and the pass can use this information. The main challenge lies with user functions, they can change rounding mode and should be treated as barriers making rounding mode unknown. It this is not sufficient, a special function attribute indicating that the function changes rounding mode, can be introduced.

Yeah, the general approach here sounds good to me.


I think I generally agree with your notion that the fp_status effects should be determined just by strictfp at the function level, not the individual operations.

For context, here is how LangRef describes the non-strict options:

The wording is a bit fuzzy, but I think the most coherent interpretation I see is that fpexpect.maytrap allows you to remove exceptions and fpexcept.ignore both add and remove – but they still cannot be moved past operations that either a) write the FP exception mode or b) read the FP status.

So the memory effects always exist in a strictfp context, but they can be ignored in certain cases. E.g. it would be legal to reorder multiple consecutive fpexcept.ignore instructions. It would also be valid to treat the fpexcept.ignore operations as willreturn.

In that sense, I do see some value in the instruction-level annotations, even if they don’t affect the generic memory effect modelling.

One unfortunate thing about this model is that the use of strictfp is required to use a dynamic FP environment, but it would also force stricter FP exception/status handling even if fpexcept.ignore is used. These should really be orthogonal things…


Okay, thanks. If we don’t have active users for the assumed rounding mode right now, then not supporting it is fine.

I don’t really agree that it’s an implementation detail. There is a big difference both in terms of semantics (one is “perform operation in static mode” and the other is “if the dynamic mode is not X, the behavior is undefined”) and lowering (one requires changing dynamic rounding mode, the other doesn’t).

Something along those lines makes sense to me, if it becomes necessary.

Regarding “fpexcept.ignore”, I think you are right. It marks code intended to be evaluated in the default FP mode. The creator of that code did not consider exception, and this fact could help to optimize the code. The name is still misleading, but it is already established. The compiler already uses this attributes, - it allows removal of instructions whose results are unused, even if they might raise FP exceptions.

As for fpexpect.maytrap, it is unclear when this attribute would be useful. The cited text is its only distinguishing feature of this mode. Currently, the compiler implements this mode almost identically to 'fpexpect.strict` with the only apparent difference being constant folding.

Hiding exceptions when trapping is enabled (as the name suggests) could be problematic. For example, a program might enable trapping on SNaN and assume all such cases are intercepted. If the corresponding exception is hidden (possibly due to constant evaluation), the missed trap could lead to incorrect behavior.

I would propose to drop the support of fpexpect.maytrap in the new implementation, unless there are ideas how it could work.

It can be solved by introducing distinct memory kinds for status and control bits instead of a single “inaccessible memory”. It indeed is an orthogonal thing and can be made later.

Generally you are right, sometimes this distinction is articulated. Actually the default FP mode should use dynamic rounding, according to the C standard (https://www.iso-9899.info/n3047.html#7.6.2p3). Thus, in the default FP mode rounding eventually should be set to "fp.round"(metadata !"tonearest,dynamic"). However the two ways of rounding specification result in the same observable behavior, so the choice between them can be considered as an optimization decision.

Default values

What about default values for rounding and exception behavior? Obviously, in the default FP mode they are "fp.round"(metadata !"tonearest") and "fp.except"(metadata !"ignore") respectively, they are for compatibility. But what about strict FP mode? Should every FP operation carry out explicit specifications of them? Or "fp.round"(metadata !"dynamic") and "fp.except"(metadata !"strict") could be used as default values, applied when no explicit specification are provided?

Having default values in strictfp mode has advantages:

  • smaller bitcode,
  • more readable IR textual representation,
  • The same IR can work in both default and strict modes.

The same question applies to denormal behavior, but in this case defaults seem essential, hardy it makes sense to decorated each instruction with the bundles like:

"fp.denorm.in"(metadata !"ieee"), "fp.denorm.out"(metadata !"ieee"), "fp.denorm.f32.in"(metadata !"ieee"), "fp.denorm.f32.out"(metadata !"ieee")

We use fpexpect.maytrap when we want optimization to be allowed without introducing new traps. That’s when it is useful. We’re taking advantage of it not just in constant folding, but also in EarlyCSE and the other optimization work I’ve already done. Optimization under fpexpect.strict is nearly impossible, and that’s a bridge too far for some users. We must have a middle ground in between not being allowed to use FP traps and not being allowed to have optimizations.

Sometimes this will result in code being optimized in surprising ways, yes. This is not limited to FP optimizations, and it’s pretty normal for workarounds to go into place when programmers and compilers disagree on “the right thing to do”. Code that is compiled with maytrap will sometimes need to be modified when switching to maytrap. That’s normal and not a reason to eliminate the possibility of optimization nearly entirely.

One thing we decided on a few years ago is we never constant fold an SNaN into a QNaN. It’s a balancing act. Keeping maytrap is part of that balancing.

No they don’t – one produces posion/UB where the other produces a defined result. You have to consider the behavior on the LLVM IR level, not on the hardware level, since that’s where frontends and optimizations will have to deal with these operations.

There are fundamentally two different operations here, and LLVM IR needs to either have both of them or say very clearly which one it picked so that frontends, optimizations, and backends consistently give the same meaning to the same program:

  • An operation that has the semantics of “will always round this way, never UB”; here it is the backend’s responsibility to ensure the correct rounding mode is used. On most hardware this may be awfully expensive…
  • An operation that has the semantics of “this is UB unless the hardware is set up in some target-specific way”; here it is the frontend’s / programmer’s responsibility to ensure a correct setup. The exact mechanism for the setup is of course target-specific, and for targets where LLVM guarantees the use of instructions with a statically encoded rounding mode, the setup can be omitted. This is what LLVM already uses by default, in the “round-to-nearest” variant.

You are absolutely right that “Optimization under `fpexpect.strict` is nearly impossible.” But there are possibilities to get better performance, other than constant folding, for example:

  • To ignore Imprecise and Underflow and pay attention only to errors,

  • To assume that trapping is disabled.

Would they also be supported as new exception behavior modes? It is also possible to combine them, how this would be expressed?

Exception behavior means per-instruction specification. Does this property indeed require such fine-grained control? Per instruction specification “ignore” vs “strict” appears naturally because C Standard pragma “FENV_ACCESS” has block-level scope. Does similar reason exist for “maytrap”?

Can these relaxed exception behaviors be represented as “basic” behavior (ignore or strict) plus function attributes (“strictfp-constfold”, “trapping-math”, “only-error-exceptions”, “signaling-nans” and so on)? It looks like more flexible solution and still can provide the same level of optimization.

You are right, but here is no problem. That statement was about default floating-point mode, in which mode other than “to nearest ties to even” is UB according to C Standard.

Inlining, possibly with LTO. A function that is strict about exceptions can call a function that’s maytrap, and we probably shouldn’t block inlining or block optimization of the inlined code in this case. Depending on the flags we introduce we might even have correctness issues with inlined code. So function-level information about maytrap, for example, won’t work.

There’s also the case where a function, or a basic block?, might be optimized when it isn’t attached to a function. I know I’ve seen cases where the function definition couldn’t be reached during an optimization, and the reason I saw it was because it mattered for strictfp. This may have been fixed by now, but I don’t think we can craft tests to prevent it from returning.

Drive-by comment: this overall proposal would be very helpful to MemorySanitizer. MemorySanitizer needs to model each intrinsic (either explicitly or heuristically) in order to correctly propagate the initialization state. It currently doesn’t support constrained floating-point intrinsics. AIUI this proposal would unify the intrinsic functions with the non-strict variants, hence MemorySanitizer would support strictfp almost for free.

This is a problem of merging function attributes during inlining, it is not specific for exception behavior. In this case a maytrap function could be treated as strictfp. It might result in some lost optimization, but correctness must not be compromized. Unlike strictfp and ignore , which reflects semantics influenced by user, maytrap is purely an optimization facility. Compilers are generally allowed to drop such optimizations.

The main concern about maytrap is extensibility. How additional options like trapping-math or other mentioned above would be handled? It looks more reasonable to separate semantic properties (strictfp vs ignore) from optimization hints like maytrap.

This is a very interesting case. If the block is really isolated, all SSA values in such block must be defined inside it, and even global values cannot be used. Probably you mean a basic block in a temporary state, when it is being constructed or transferred. In such case the target function usually is known beforehand, so the basic block could be placed into the correct function before the transformation. Alternatively, It is possible to define a placeholder function, which would only provide necessary function attributes. It is also possible to run constant folding separately. Making optimizations on an unfinished object can be tricky.

This is the essential problem of the current implementation: it requires significant effort to get faster code.

Constrained intrinsics were not a bad solution. They were not flexible enough, but this could have been mitigated by extending their semantics and implementing a more complex interface. However, the decision to prevent optimizations resulted in very poor performance and made the entire solution unsuitable for practical use. Consequently, the implementation has not been developed enough.

FP operations in default and strictfp environments are fundamentally the same, the difference is in additional ordering restrictions imposed by strictfp environment. Although some optimizations would require additional care, many of them, based on mathematical properties of the operations should also be valid for strictfp environment.

If we don’t move a fpexcept.maytrap instruction past a fpexcept.strict instruction then we can preserve all possible optimizations while still inlining. It’ll also behave the same as if the inlining wasn’t done.

I’m not expecting real user code to be a mismash of “ignore”, “maytrap”, and “strict” all mixed together. I’m expecting real code to have whole, contiguous sequences of instructions all with the same settings for rounding, exception behavior, denormals, whatever. Which means that not moving instructions past each other when the settings are different won’t be a problem in practice.

And I can see users being surprised when adding an inline keyword causes FP traps to start appearing that weren’t there without the inline keyword. Plus inlining would cause degraded performance due to the new lack of optimization of the inlined code. Surprising all around.

What’s the benefit of giving up the ability to optimize function bodies that are inlined?

My hope is that operand bundles will give us more flexibility in specifying allowed optimizations.

We’ll still need new intrinsics to replace the regular FP instructions because we can’t attach bundles to instructions. This means we still don’t get optimization of formerly-FP-instructions which is the same behavior as with the constrained intrinsics.

I’m not wedded to the word “maytrap”. If we have some other way, probably with bundles, to state that optimizations are allowed so long as we don’t introduce new traps then I’m fine with that. What we should not be doing is dropping the ability to optimize because we’re treating “maytrap” the same as “strict”.

Now, …

#include <math.h>
#include <fenv.h>

inline float divide(float x, float y) {
  // default fp mode.
  return x / y;
}

float calculate(float x) {
  #pragma STDC FENV_ACCESS ON

  float y = sin(x);
  int exc = fetestexcept(FE_ALL_EXCEPT);

  float c = divide(x, y);

  return c;
}

Here’s your example from earlier in the thread except we can compile it to see the IR. It demonstrates that when divide() is inlined the fdiv instruction is changed into a constrained intrinsic that assumes default rounding mode and no exception traps. (This is with llvm 20). This seems correct to me since calling a function when the environment is not what the function is expecting seems like a user error. Don’t do that. I wouldn’t worry about unexpected transformations happening when the user makes this error while using strictfp.

If we are worried about this then we need to change the fdiv into something that assumes the most conservative case with dynamic rounding assumed and FP traps assumed to be enabled.

Is calling a function that is FENV_ACCESS OFF from a function that is FENV_ACCESS ON even allowed in C, or would that be UB?