[RFC] Yet another strict FP

Motivation

LLVM currently supports non-default floating point environments in two ways. The first is the strictfp function attribute in conjunction with the llvm.constrained.* family of intrinsics, which covers:

  • Non-default dynamic rounding modes
  • Non-masked floating point exceptions
  • IEEE compliant handling of sNaN

Additionally, non-default denormal flushing environments are separately modeled using the denormal_fpenv function attribute, in conjunction with normal floating point instructions.

The current situation has a number of problems:

  • Having separate constrained FP intrinsics splits FP handling into two separate worlds, which have to duplicate functionality. Additionally, it is hard to extend to target-specific constrained FP operations.
  • The strict FP umbrella covers multiple aspects of non-default environments, while users don’t necessarily want to use all of them together. In particular, it’s worth highlighting that only non-masked FP exceptions (and FP status updates) impose truly onerous limitations on optimizations. Non-default rounding modes are more benign.
  • There is currently no support for static rounding modes. (Which are not the same thing as a known dynamic rounding mode.)
  • It’s not possible to opt-in to denormal flushing at instruction granularity (e.g. only for vector operations on ARM NEON).

A previous RFC proposed to resolve some of these issues by:

  • Removing the constrained FP intrinsics, and instead using the normal FP intrinsics with operand bundles like ["fp.control"(metadata !"round.tonearest")].
  • As operand bundles cannot be placed on normal instructions, to also introduce intrinsics like @llvm.fadd(), which will be used in place of fadd when FP operand bundles are needed.

This still leaves us in an unfortunate situation where the core FP operations are duplicated, both existing as instructions and as intrinsics.

I believe that if we want to improve on the current strict FP situation, and treat strict FP as more of a first class citizen, we need to avoid further half measures, and actually support first-class FP environment annotations on all floating-point operations, including basic FP instructions.

The tl;dr comparison between the current state, the previous RFC and this proposal goes something like this:

; Current:
%res = call float @llvm.experimental.constrained.fadd.f32(float %a, float %b, metadata !"round.dynamic", metadata !"fpexcept.strict")

; Previous proposal:
%res = call float @llvm.fadd.f32(float %a, float %b) ["fp.round"(metadata !"dynamic"), "fp.except"(metadata !"strict")]

; This proposal:
%res = fadd float %a, %b fpenv(rounding_mode: dynamic, strict_except: true, ...)

This obviously has major implications on floating-point optimizations: It requires all existing optimizations to be audited, and adjusted to honor non-default FP environments (though such handling can initially be a simple conservative bailout). The previous RFC already requires doing that for all the FP optimizations involving intrinsics.

Proposal

Memory effects

Introduce new memory effect locations:

  • fpenv: The floating point environment, containing information like the current rouding mode.
  • fpstatus: The floating point exception status.

By default, all floating-point operations have memory effects memory(fpenv: read, fpstatus: write). That is, they read from the FP environment (to determine the current rounding mode and exception mode) and write the FP exception status. Some of these effects can then be ignored based on knowledge about the current FP environment.

Floating-point operations here refers to both instructions like fadd and intrinsics like @llvm.sin, but excludes bitwise operations like fneg.

Instruction-level fpenv

Floating-point operations can be annotated with a per-instruction FP environment, which encodes various information. The FP environment is intentionally fine-grained, so that both frontends can precisely express their requirements, and optimizations precisely express their preconditions (if desired – in practice, I’d expect most handling to be rather crude).

The components of the FP environment are:

  • rounding_mode: One of dynamic, tonearest, downward, upward, upwardzero, tonearestaway. The rounding mode to use. Note that all values other than dynamic specify a static rounding mode.
  • dynamic_rounding_mode: One of unknown, tonearest, downward, upward, upwardzero, tonearestaway. This specifies the known dynamic rounding mode at the point the instruction is executed, otherwise the behavior is undefined.
  • except_mode: One of unknown, masked, unmasked. This specifies the known exception mode at the point the instruction is executed, otherwise the behavior is undefined.
  • strict_snan: One of true or false. Whether to handle sNaN according to IEEE rules or LLVM’s relaxed NaN propagation rules.
  • strict_except: One of true or false. If false, any cases that would produce an FP exception produce it non-deterministically instead (i.e. it may or may not occur). This means that the FP status is written non-deterministically. Additionally, if exceptions are unmasked, it means that the instruction traps non-deterministically.

The defaults of omitted flags are:

fpenv(
  rounding_mode: dynamic,
  dynamic_rounding_mode: tonearest,
  except_mode: masked,
  strict_snan: false,
  strict_except: false,
)

The FP environment corresponding to maximal strict FP is:

fpenv(
  rounding_mode: dynamic,
  dynamic_rounding_mode: unknown,
  except_mode: unknown,
  strict_snan: true,
  strict_except: true,
)

The old "except.maytrap" corresponds to strict_except: false. The old "except.ignore" roughly corresponds to except_mode: masked plus absence of fpstatus_read (introduced later).

Because the FP environment can get quite large and will likely be the same for most instructions, it likely makes sense to support some kind of syntax to define it only once:

%strict = fpenv(
  rounding_mode: dynamic,
  dynamic_rounding_mode: unknown,
  except_mode: unknown,
  strict_snan: true,
  strict_except: true,
)

define float @test(float %a, float %b) {
  %res = fadd float %a, %b fpenv(%strict)
  
  ; Is the same as:
  %res = fadd float %a, %b fpenv(rounding_mode: dynamic, dynamic_rounding_mode: unknown, except_mode: unknown, strict_snan: true, strict_except: true)

  ret %res
}

For calls, it likely makes sense to store fpenv inside the call-site function attributes, which will make sure that it gets handled correctly as part of any generic call handling.

Function-level fpenv

At the function level, we provide the ability to specify that certain parts of the FP environment are known and will not change.

The fixed_fpenv attribute takes dynamic_rounding_mode and except_mode with the same values as fpenv. The listed values for the FP environment must not change throughout the function, except inside calls (in which case the environment has to be restored before the call returns).

Some examples:

  • fixed_fpenv(dynamic_rounding_mode: tonearest, except_mode: masked): Default FP environment, same as lack of attribute.
  • fixed_fpenv(): Both rounding mode and exception mode may change inside the function.
  • fixed_fpenv(dynamic_rounding_mode: unknown, except_mode: unknown): We don’t know what the rounding mode and exception mode are on entry to the function, but they cannot change inside the function.

In a strict FP context, the frontend may start out with a fixed_fpenv() annotation and then inference can determine that the FP environment does not actually change and convert it to fixed_fpenv(dynamic_rounding_mode: unknown, except_mode: unknown).

In addition to fixed_fpenv, the fpstatus_read attribute indicates that the FP status may be read. Absence of the attribute implies that the FP status may be non-deterministically modified at any point during the execution of the function.

Optimization implications

Derived properties

By default FP operations are non-willreturn and have memory(fpenv: read, fpstatus: write). However, these can be relaxed based on knowledge of the FP environment:

  • willreturn: If except_mode is masked or strict_except is false. The latter is under the premise that a nondet trap can be treated as willreturn.
  • memory(fpenv: none): If fixed_fpenv contains both dynamic_rounding_mode and except_mode, then we can ignore the fpenv: read effect for any reasoning inside the function. It needs to be preserved for inter-procedural reasoning.
  • memory(fpstatus: none): If fpstatus_read is not set on the function, we can ignore the fpstatus: write effect for any reasoning inside the function. It needs to be preserved for inter-procedural reasoning.

Optimization primitives

Here is how some optimization primitives interact with fpenv and fixed_fpenv:

  • DCE of FP op with unused result: Ok if strict_except is false, or if except_mode is masked and fpstatus_read is not set.
  • Propagation of UB across the operation: Ok if except_mode is masked or strict_except is false. (Implication of willreturn.)
  • Omission of canonicalization: Ok if strict_snan is false.
  • Constant folding: If the constant folding requires rounding (raises inexact), the effective rounding mode must be known, i.e. rounding_mode is static or dynamic_rounding_mode is not unknown. (To remove the instruction after constant folding, the previously mentioned DCE requirements apply.)
  • Speculation: Ok if except_mode is masked, and fixed_fpenv on the function has both dynamic_rounding_mode and except_mode, and fpstatus_read is not set.

Complex optimizations

Optimizations that create new FP instructions need to preserve the environment of the original instruction(s).

If multiple operations are involved, more care is required. In that case, we need the FP environment to be “compatible”. The simplest criterion for compatibility is that the environments are all equal, and the rounding/exception mode cannot change between the instructions. This is the case if either dynamic_rounding_mode and except_mode are both not unknown, or fixed_fpenv claims that the FP environment for both is fixed.

It’s possible to allow some differences in the FP environments and merge them appropriately, e.g. strict_nans: true and strict_nans: false could legally combine to strict_nans: true. It’s not clear this would be useful in practice, and would add additional complexity to transforms.

Finally, if except_mode is not masked or fpstatus_read is set, we have to be careful about not introducing any new FP exceptions or status updates (and if strict_except is true, also about not removing any FP exceptions or status updates). We likely shouldn’t bother trying to optimize such cases.

Notes

Denormal FP env

I’ve omitted handling of denormal FP environment from this proposal to reduce the scope a bit. Supporting denormals would be matter of adding additional entries to fpenv:

  • denormal_mode_input and denormal_mode_output: One of dynamic, ieee, preservesign, positivezero, where everything but the first one indicate static denormal modes.
  • dynamic_denormal_mode_input and dynamic_denormal_mode_output: One of unknown, ieee, preservesign, positivezero.

And similarly dynamic_denormal_mode_input/dynamic_denormal_mode_output to fixed_fpenv.

The reason for both static and dynamic denormal mode is to capture cases like ARM NEON where certain instructions always flush subnormals, regardless of the dynamic FP env.

Similar to current dynamic_fpenv, the semantics for non-IEEE denormal modes would be nondet flushing. That is, you are never guaranteed FTZ/DTZ behavior, it is merely allowed.

FP status

The fpstatus_read attribute proposed here is the the odd duck out, that doesn’t really cleanly fit in with the rest. Our current handling for this is captured by this LangRef wording:

If this argument is “fpexcept.ignore” optimization passes may assume that the exception status flags will not be read and that floating-point exceptions will be masked. This allows transformations to be performed that may change the exception semantics of the original code. For example, FP operations may be speculatively executed in this case whereas they must not be for either of the other possible values of this argument.

This is very convenient in terms of what optimizations are allowed to do, but I don’t believe that what is specified here results in coherent operational semantics. “exception status will not be read” (esp. when combined with speculatability) is not really something we can promise at the level of an individual FP operation, at least in a context where strict FP and non-strict FP code may be mixed. I believe this needs to be a function-level property.

Migration

Some parts of this proposal can be implemented independently. In particular the memory effects can be implemented and used for the constrained FP intrinsics.

However, the instruction-specific parts of the proposal require that full support for the new mechanism (including audit of existing transforms) is implemented first, before we can start using it, and before the existing constrained FP intrinsics can be removed.

There are some shortcuts we can take to reduce initial scope, e.g. InstCombine (the kitchen sink of FP transforms) can, during worklist population, determine whether FP is “trivially optimizable” (fixed FP environment, no FP status reads, no non-default instruction FP environment) and then initially skip all the FP visit methods based on that.

Backend

In the future, we should migrate FP operations in SDAG to also store an explicit FPEnv, and to always have chain operands, where the chains are trivial (input=entry, output=unused) in the cases where the FP environment cannot change and FP exceptions are masked.

However, initially, we can map any cases that have non-default FPEnv to the STRICT opcode family.

History and References

I believe that this proposal has some similarity to how strict FP was originally proposed to be implemented (see [RFC] FP Environment and Rounding mode handling in LLVM), though that proposal integrated FPEnv in FMF (which I think is not the semantically correct modeling).

Back then, we ended up going into a different direction with a separate constraint FP intrinsic family. I think this was the right choice at the time, but it’s likely no longer the right choice nowadays.

Here are some more recent references for strict FP support:

There’s more than 15 open PRs related to changing the strict FP representation floating around right now, and these don’t ever seem to reach sufficient consensus to actually land. We have a consensus that the current situation is not good, we seem to have a rough consensus on the direction we want to move, but we don’t seem to have a consensus on the details.

9 Likes

I am curious about the arm (aarch32 I assume) behavior here. Gcc was changed a few years back not to vectorize for arm neon unless unsafe-math-optimizations was enabled specifically for the subnormal behavior.

Is llvm similar and why can’t it be controlled that way? Or is this RFC about that?

LLVM will also not vectorize for Arm Neon. However, generic vector ops do get lowered to Neon, even though they have different denormal behavior, and builtins may lower to plain vector ops as well.

So the current status is that everything is fine if you’re starting from scalar code, but you can’t (in a language that cares about soundness) expose portable SIMD operations (because they have the wrong denormal behavior) or FP vector builtins (because LLVM will assume the wrong denormal behavior) when Arm Neon is involved.

Properly exposing Arm Neon requires that normal vector ops get scalarized and there is the ability to annotate specific vectors ops as allowing denormal flushing, so those can be mapped to Neon instructions.

Is llvm similar and why can’t it be controlled that way? Or is this RFC about that?

This RFC is mostly about handling non-default rounding and exception modes. Denormals are relevant insofar as they should benefit from the same general framework, though I’m not sure that my specific sketch of how denormals could be handled is the right one, which is why I separated it from the rest of the proposal. (The denormal situation is a bit different from the rest of the FP environment, in part because we’re not really interested in guaranteeing denormal flushing, and just want to allow it in some cases.)

SPIR-V supports DenormFlushToZero which requires flushing denormals. Also, flushing denormals is needed if you want to emulate Neon or other ISAs that are defined to flush zeros in some/all modes. So, I think when denormals are eventually handled like in this RFC that LLVM should support where flushing is required for correctness, not merely saying that that flushing is allowed.

1 Like

I think we should also add numerical exactness property to the fpenv. Right now it’s kind of implied with reassoc?

I would have assumed it gone down the same path which is how GCC works but oh well.

Overall, I’m broadly in favor of moving a lot of the strictfp logic
into somewhat more fine-grained MemoryEffects controls, especially as
there is definitely clear user demand for selective optimization to
apply in strictfp circumstances.

One thing to be explicit about is that the original design of strictfp
was to be something that made FP optimizations default-not-apply to
strictfp, and this proposal makes FP optimizations default-apply. This
is probably tolerable (especially as we do have some tools that didn’t
exist back then to make logic for generic optimizations safer), but it
does need to be made explicit.

Introduce new memory effect locations:

  • |fpenv|: The floating point environment, containing information
    like the current rouding mode.
  • |fpstatus|: The floating point exception status.

This is just pure bikeshedding at this point, but I feel like
fpcontrol is a better name than fpenv, since fpenv to me implies
both the implicit read and write dependencies of floating-point
operations whereas fpcontrol would only imply the implicit read
dependencies.

Floating-point operations can be annotated with a per-instruction FP
environment, which encodes various information. The FP environment is
intentionally fine-grained, so that both frontends can precisely
express their requirements, and optimizations precisely express their
preconditions (if desired – in practice, I’d expect most handling to
be rather crude).

Fine-grained orthogonal controls are generally fine, but I do worry that
sometimes you can get screwy semantics by chopping up things too fine or
some use-cases end up falling through the cracks. The list of user
models I’ve written up for myself does seem to largely be covered by the
current proposal.

The components of the FP environment are:

I am slightly concerned that this doesn’t fully cover the bits of FP
environment control. Discounting denormal flushing (as something that
doesn’t need to be part of the MVP, but does need to have a clear path
forward), there are still things like the x87 precision control that we
are probably never going to model in the compiler. I’d feel better with
a mode that treated the operation as an unknown function with unknown
side effects that was fully unoptimizable. That said, the use case is
perhaps best suited with just pure assembly.

  • |strict_except|: One of |true| or |false|. If |false|, any cases
    that would produce an FP exception produce it
    non-deterministically instead (i.e. it may or may not occur). This
    means that the FP status is written non-deterministically.
    Additionally, if exceptions are unmasked, it means that the
    instruction traps non-deterministically.

It may be worth breaking exceptions into none, only
invalid/overflow/div-by-zero, and all of them. But this may also be
splitting the possible environment stuff too finely.

The old |“except.maytrap”| corresponds to |strict_except: false|. The
old |“except.ignore”| roughly corresponds to |except_mode: masked|
plus absence of |fpstatus_read| (introduced later).

There’s another kind of exception model which is that FP operations
shouldn’t be speculated, but it’s okay to drop instruction traps if
they’re otherwise unused (something akin to the behavior of segfaulting
memory access). It looks like this corresponds to strict_except: false, except_mode: unmasked.

In addition to |fixed_fpenv|, the |fpstatus_read| attribute indicates
that the FP status may be read. Absence of the attribute implies that
the FP status may be non-deterministically modified at any point
during the execution of the function.

One of the things that came up in some of the newer replace-strictfp
patchsets is that there was a historical issue, which may still be
around, that floating-point instructions sometimes don’t live in a
function, so that querying based on a function attribute is a little bit
dicey. With that in mind, it may make sense for the attribute to be
specified as fpstatus_ignored, requiring a function to opt-in to FP
optimizations rather than opt-out.

  • Constant folding: Only possible if the effective rounding mode is
    known, i.e. |rounding_mode| is static or |dynamic_rounding_mode|
    is not |unknown|.

Constant folding also needs to know that fpstatus_read is not set, if
the operation being folded causes an exception (which many operations
will cause an inexact exception).

If multiple operations are involved, more care is required. In that
case, we need the FP environment to be “compatible”. The simplest
criterion for compatibility is that the environments are all equal,
and the rounding/exception mode cannot change between the
instructions. This is the case if either |dynamic_rounding_mode| and
|except_mode| are both not |unknown|, or |fixed_fpenv| claims that the
FP environment for both is fixed.

One of the things I found with the fast-math flags is that it’s very
easy to specify that all of the operations must have compatible flags,
but the machinery we have makes it rather difficult to actually get
everything to match up. I thought about trying to write a matcher that
would only match if the entire matched expression shared the same common
flags, but the pattern matcher framework really isn’t set up to let you
do that.

The |fpstatus_read| attribute proposed here is the the odd duck out,
that doesn’t really cleanly fit in with the rest. Our current handling
for this is captured by this LangRef wording:

If this argument is “fpexcept.ignore” optimization passes may
assume that the exception status flags will not be read and that
floating-point exceptions will be masked. This allows
transformations to be performed that may change the exception
semantics of the original code. For example, FP operations may be
speculatively executed in this case whereas they must not be for
either of the other possible values of this argument.

This is very convenient in terms of what optimizations are allowed to
do, but I don’t believe that what is specified here results in
coherent operational semantics. “exception status will not be read”
(esp. when combined with speculatability) is not really something we
can promise at the level of an individual FP operation, at least in a
context where strict FP and non-strict FP code may be mixed. I believe
this needs to be a function-level property.

The understanding I have here is perhaps best described by reference to
C’s definition of the FENV_ACCESS pragma:

If part of a program tests floating-point status flags or establishes
non-default floating-point mode settings using any means other than the
FENV_ROUND pragmas, but was translated with the state for the
FENV_ACCESS pragma “off”, the behavior is undefined. The default state
(“on” or “off”) for the pragma is implementation-defined. (When
execution passes from a part of the program translated with FENV_ACCESS
“off” to a part translated with FENV_ACCESS “on”, the state of the
floating-point status flags is unspecified and the floating-point
control modes have their default settings.)

I wouldn’t go with full UB for testing flags in FENV_ACCESS=off state,
but I would use the convenient refinement that the floating-point status
bits have nondeterministic value. It does need to be a function-level
property to properly account for speculation.

There’s more than 15 open PRs related to changing the strict FP
representation floating around right now, and these don’t ever seem to
reach sufficient consensus to actually land. We have a consensus that
the current situation is not good, we seem to have a rough consensus
on the direction we want to move, but we don’t seem to have a
consensus on the details.

I don’t think it’s so much a problem of consensus on details as the fact
that there are so many not-quite-orthogonal patches here that it’s a bit
hard to get a grasp on the right way to move forward. There are
definitely some patches that outright have consensus against (e.g.,
optional chain on SDAG). But there’s also like three PRs on adding
operand bundles here, and then other PRs that depend on none of them to
do stuff (like add FP instruction intrinsics) that don’t make sense
without adding operand bundles, which makes it hard to know which
reviews to focus on to actually move things forward.

I think we should also add numerical exactness property to the
|fpenv|. Right now it’s kind of implied with |reassoc|?

Numerical exactness is a property of fast-math flags, which is related
to strictfp in the sense that they are both properties of
floating-point operations. We have !fpmath metadata to indicate
desired operation accuracy (not that it’s used by much), and afn
theoretically offers a very coarse-grained control over math library
function accuracy. reassoc is mostly (in this sense) just an abuse for
“this is a fast-math transform that doesn’t fall into any of the other
flags.” There’s a related issue about needing to select between
different levels of accuracy for the math functions when linking against
math libraries that provide multiple versions (see e.g.
[RFC] Floating-point accuracy control).

And if they don’t raise inexact then you can fold them regardless of rounding mode, which should cover a lot of trivial cases like folding 2.0 + 2.0 to 4.0.

Yes, that’s correct. I believe that taking this step is necessary if want to support non-trivial optimizations for non-default environments, but it does come with risks, and it does require significant upfront work to adjust existing transforms.

I guess we could have some kind of “other” property in the fpenv that allows indicating that the fpenv can contain other options that affect FP math in unknown ways. But I’m not sure how much sense it really makes to support this, if the operation ends up being essentially entirely opaque…

Do you have some use case in mind that might benefit from making this more fine-grained?

Yes, that’s right. This is basically the configuration that allows removing traps but not adding them.

Historically, we had floating point constant expressions which do not live inside a function. I have since removed all of those, so that problem does not exist. We also have the problem that sometimes we operate on instructions that have not yet been inserted – in that case, we have to make conservative assumptions (i.e. FP status read, FP env not fixed).

This is independent of the polarity of the actual attribute: Even with fpstatus_read, the assumption if we don’t have a Function must be “the FP status is read”. The polarity of the attribute in this proposal is just chosen based on the premise that if you don’t add any attributes / fpenv annotations, you get the current default behavior, which is the assumption of the default FP environment. There is an argument to be made that this not the right default, because LLVM generally prefers to have the most conservative default and then relax that with attributes. But I think it makes sense to start from the current status quo – changing the default is something we could still do once all of this is actually implemented.

Yes, we’ll have to make some changes here to make sure that doing the correct thing is easy and not hard. One possibility would be to change all the FP matchers to also match the FP environment, so that you have something like m_FAdd(m_FPEnv(Env), m_Value(A), m_FAdd(m_SpecificFPEnv(Env), m_Value(B), m_Value(C))). That is, the PatternMatch framework directly capture the FPEnv on the instruction, and makes sure both are the same.

That was a bit unclear: I distinguish between “constant folding” and “DCE of the constant folded instruction”. You can still constant fold (and RAUW the result) even if you can’t remove the operation because it has side effects on the FP status. (Of course, constant folding without DCE is of somewhat limited use…)

Yeah, my original statement here was incorrect: Of course the rounding mode only needs to be known if rounding actually occurs…

+1 Thanks for working on this, @nikic. It’s clear that FPEnv information needs to be a “first class citizen” to avoid the pitfalls of the constrained FP intrinsic implementation. It has been a long time coming.

1 Like

Thanks for taking the time to write this RFC. I have some initial questions about the new direction:

We will already have to do a lot of plumbing for this feature, so why not address this now? I understand the desire to reduce the scope, but my concern is that this feature won’t be useful to some targets (NVPTX, perhaps others) without denormal mode.

I’m also wondering how this will interact with target intrinsics that already encode the FP environment. For example, we encode PTX rounding modifiers on NVVM intrinsics along with denormal mode: @llvm.nvvm.add.{rn,rz,rp,rm}{.ftz,}. We’d like to be able to drop these intrinsics in favor of the new strict FP implementation.

Splitting memory effects between control modes and status bits would be a really great improvement, as these are entirely different sides of the FP environment. However, memory effects of fpstatus are more complex, than those of control modes. The action performed by an FP operation is not a write access but a read-OR-write. It still allows reordering of such instructions in the same way as if they only read memory location. The function llvm.set_fpenv has write access, the previous value of fpstatus is ignored in this case. There is also read-AND-write realized by feclearexcept. And, of course, the read access is represented by fetestexcept. Memory access mapping for fpstatus is not a trivial task.

I don’t think strict_snan deserves to be in an instruction-level attributes. If sNaNs are supported on the platform, setting strict_snan to false is a user promise, that the data processed by the function do not contain sNaNs. Like any user promise (e.g. nnan or ninf), this is not a reliable guarantee. If exceptions are observed, sNaNs cannot be ignored, as they raise Invalid exceptions, the same as any other error. If the code with strict_snan=false is mixed with code where strict_snan=true (due to inlining), it means no such promise exists for the combined function. It make no sense to have a function where different instructions have different settings of strict_snan.

It is possible, of course, that inside a function trapping are first enabled, then some calculations are performed and finally trapping is disabled. This is not a typical scenario however, usually entire application is executed with trapping enabled. The previous case can be implemented using enabled trapping for entire function. So trapping does not look like a candidate for instruction-level property.

I do think the denormal mode should be included in this mechanism, it may just benefit from a separate discussion, because the design space for denormals is a bit complicated. We have static and dynamic denormal modes, different handling for inputs and outputs, different handling depending on type, differences in the interpretation of “underflow”, required vs optional flushing, etc.

I’d expect these to switch to using the per-instruction fpenv specifying a static rounding mode and static denormal mode in the future.

Yes, the FP status writes by FP operations are “saturating” in the sense that they can only set, but not unset, status bits. Thus some FP operations can be reordered (depending on which FP status bits they can set). We can’t model it this precisely using memory effects. (We might be able to model this more precisely in AA between two FP operations.)

This does not match how sNaN support currently works (outside strict FP), and which we want to be able to match inside strict FP. We can’t make a promise that sNaN will just not occur at all in the same way that nnan does – we could do that as well, e.g. by adding a new nsnan FMF flag, but it would be a different feature. What strict_snan: false says is that sNaN can occur, but we don’t require it to be handled in an IEEE compliant way (and in particular, we can treat it as a qNaN or fail to quieten it).

As for this being per-instruction or not, my general thinking is that if we have per-instruction properties, it makes sense to always use them unless it’s not possible (e.g. for the FP status read, which can’t be per-instruction). Defining this per-instruction allows more expressiveness, and shouldn’t have any significant additional cost once the general system is in place.

To provide an example where the per-instruction property could be pratically useful is when exposing target intrinsics. We had a discussion on the Rust side recently, and people thought that e.g. target intrinsics corresponding to a minnum operation should actually have guaranteed sNaN semantics, because that’s what the hardware does and what the intrinsic is specified to do. I’m not sure I personally agree with that view, but it’s a case where we could end up with a single strict_snan: true operation in code that is otherwise strict_snan: false.

The per-instruction property just encodes a known exception mode at the point of the instruction, which e.g. allows preserving information during function inlining. I don’t think there is any relevant distinction here between e.g. a known dynamic rounding mode and a known exception mode. (There might be an argument here that we don’t need to encode a known dynamic fpenv at the instruction level at all, and rely exclusively on fixed_fpenv at the function level, but I don’t think there should be a distinction between different parts of the fpenv. If we allow specifying a known dynamic rounding mode, we should also be able to define a known exception mode.)

I really like the direction of this RFC. It’s ambitious, but we’ve been kicking the can down the road far too long with the existing implementation.

This touches on something that has been one of my biggest sticking points with previous attempts to solve rounding-mode-related problems, particularly with implementing support for C’s FENV_ROUND pragma. For some targets, such as x86 (with caveats for modern instruction sets), the rounding mode is always dynamic and static rounding modes can only be emulated. For other targets, the rounding mode can always be encoded in individual instructions, so supporting static rounding mode is far simpler.

There has been frequent confusion in the past as to whether the rounding mode argument of the constrained intrinsics is prescriptive (enforces the stated rounding mode) or descriptive (allows assumption of the stated rounding mode). We implemented this intrinsics with that parameter intended to be descriptive, with the idea that in situations where we could deduce the rounding mode, it could be set to something other than dynamic. I thought that was necessary to make these intrinsics implementable for x86, because a prescriptive interpretation would require inserting a call to set MXCSR before any constrained instruction where the dynamic state of the rounding mode couldn’t be proven. Unfortunately, that makes the constrained intrinsics next to useless for implementing static rounding mode features like FENV_ROUND.

I like your distinction between rounding_modeand dynamic_rounding_mode, but I’m not clear on how you expect it to be implemented. For example, suppose I compile the following C23 code for an x86-64 target:

#pragma FENV_ROUND FE_TOWARD_ZERO

static float staticFunc(float x, float y) {
  float t1 = cosf(x);
  float t2 = cosf(y);
  return t1 + t2;
}

float otherFunc() {
  return staticFunc(1.0f, 2.0f) + staticFunc(3.0f, 4.0f);
}

I believe the C23 standard says that this would be evaluated as if there were calls to explicitly set the rounding mode to FE_TOWARD_ZEROat the beginning of each of these functions and after each of the calls to staticFuncand to explicitly restore the rounding mode to whatever it was on function entry at the end of each of these functions and before each of the calls to staticFunc.

Obviously, we don’t want to do that because setting the rounding mode is expensive. I would hope that because we can prove that staticFunc is only ever called from otherFunc we could get rid of the explicit calls to save and restore the rounding mode within staticFunc and around the calls to it. My question is, how would you expect this to be represented?

I think to implement this feature, Clang would need to generate calls to llvm.get.rounding and llvm.set.rounding and we’d need the optimizer to be able to eliminate them. Because we’re calling llvm.set.roundingwe’d have to use fixed_fpenv(), right?

Would you expect Clang to generate the faddinstructions and the cosfcalls with fixed_rounding_modebecause the pragma makes it static, or with dynamic_rounding_mode: towardzero because that’s the reality of how x86 implements it?

I think this is an important detail, because we want the x86 backend to be able to generate call and instructions without having to look around to see if the rounding mode has already been set.

Another aspect of this that I think needs to be spelled out in the proposal is how the function attributes will affect inlining. Can a function with fixed_fpenv(dynamic_rounding_mode: unknown, except_mode: unknown)call a function with fixed_fpenv()if the called function saves the fp environment on entry and restores it before returning. If so, is it the responsibility of the inliner to update this attribute on the calling function if the call is inlined. (I suppose maybe the inliner should somehow consider this in its decision.)

I think we also need a way to indicate that a function assumes the default FP environment on entry and does not alter the caller’s FP control modes while still allowing it to modify the FP environment internally. This is the default assumption of the C standard.

Also, I would love to see an RFC for MLIR representation of strict floating-point modes that is tightly coupled with this proposal. I happen to be about to start working on strict FP support for CIR, so I’d be happy to put something together.

2 Likes

I believe that Clang should generate code using a static rounding_mode, and LLVM should then legalize this by inserting necessary llvm.set.rounding calls in the backend (at which point we also switch to rounding_mode: dynamic, dynamic_rounding_mode: xyz). This should happen pre-SDAG for cross-block dataflow analysis. In principle, the placement of the rounding mode changes could also be made inter-procedural for functions with local linkage.

I don’t think it’s a good idea to insert these directly in Clang, because LLVM will have a better view on whether these are necessary or not. For example, if we’re compiling for AVX512 (and SAE is acceptable), we can use static rounding modes instead.

Yes, none of the attributes prevent inlining, they just need to be updated appropriately. E.g. in your example the resulting attribute would be fixed_fpenv(), because we no longer know that the FP environment is constant throughout the function after inlining. (We already do this kind of attribute fixup during inlining for many other attributes.)

That sounds like a reasonable thing to support, though not sure how to best encode this. It seems like there are three properties here: Whether the environment is known on function entry, whether the environment can be changed inside the function, and whether that change can persist past function exit. I’d be tempted to represent the latter by memory(fpcontrol: none) to indicate that (from the caller perspective) the environment remains unmodified, as that would directly give us useful optimization properties. (Not sure we can actually do this though, it stretches the definition of none somewhat…)

These two form a pair, and it seems like a similar pair will be necessary for denormal handling (or two pairs, one for inputs and one for outputs). Is there any way to make this pattern more structured?

Also it seems like violating dynamic_rounding_mode is UB even if rounding_mode is non-dynamic. I guess that is useful to avoid unnecessarily altering the dynamic rounding mode if that’s how the backend has to compile a static rounding mode? Might be worth calling this out explicitly.

And finally, there is a parallel between dynamic_rounding_mode and except_mode but the name makes it sound like the parallel would be between rounding_mode and dynamic_rounding_mode. Maybe except_mode should be renamed to dynamic_except_mode? Then we’re also ready for a future where some target has static exception modes, should that ever become a thing.

Per-instruction properties also make inlining trivially correct.

Yes, fpcontrol: none seems to me that it would be saying that the function doesn’t read the fpcontrol modes (so it could have any value on entry). I would think fpcontrol: preserve is more what we’re after here. The default assumptions of the C standard treat the control mode in some ways like a callee preserved register, except that it has the additional expectation that the input value will be known.

I agree that many aspects of this can be better handled by the backend for the reasons that you state, but the front end will have a better view of some aspects, like whether a function was translated within the scope of a pragma that sets a fixed rounding mode. I suppose we can work out the details of that in review when we implement support for those pragmas in Clang. My main concern here is to make sure we have a sufficient way to encode the semantics we need.

At the risk of making this too general, I can imagine use cases where you’d want to be able to indicate that specific exceptions will be masked while others might not be. The INVALID exception, for instance, has a substantially different character than INEXACT and one might want INVALID unmasked for an entire program while enabling optimizations that could trigger INEXACT.

Do you have some use case in mind that might benefit from making this
more fine-grained?

Not specifically, no. It’s more a reflection of the fact that
essentially every operation will raise the inexact exception, which
leaves that exception generally ignorable, whereas the other exceptions
are more indicative of “the results are meaningless, something went
wrong” and are usually not ignorable. So I can see someone drawing a
distinction between the two kinds of exceptions.

On the other hand, it’s a bit difficult to see the optimizer taking
advantage of this difference without a more sophisticated FP range
analysis to prove that overflows won’t happen, or possibly combining
fast-math flags to assert poison on overflow, but FMF introduces its own
cans of worms…