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 offaddwhen 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 ofdynamic,tonearest,downward,upward,upwardzero,tonearestaway. The rounding mode to use. Note that all values other thandynamicspecify a static rounding mode.dynamic_rounding_mode: One ofunknown,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 ofunknown,masked,unmasked. This specifies the known exception mode at the point the instruction is executed, otherwise the behavior is undefined.strict_snan: One oftrueorfalse. Whether to handle sNaN according to IEEE rules or LLVM’s relaxed NaN propagation rules.strict_except: One oftrueorfalse. Iffalse, 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: Ifexcept_modeismaskedorstrict_exceptis false. The latter is under the premise that a nondet trap can be treated as willreturn.memory(fpenv: none): Iffixed_fpenvcontains bothdynamic_rounding_modeandexcept_mode, then we can ignore thefpenv: readeffect for any reasoning inside the function. It needs to be preserved for inter-procedural reasoning.memory(fpstatus: none): Iffpstatus_readis not set on the function, we can ignore thefpstatus: writeeffect 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_exceptisfalse, or ifexcept_modeismaskedandfpstatus_readis not set. - Propagation of UB across the operation: Ok if
except_modeismaskedorstrict_exceptisfalse. (Implication of willreturn.) - Omission of canonicalization: Ok if
strict_snanisfalse. - Constant folding: If the constant folding requires rounding (raises inexact), the effective rounding mode must be known, i.e.
rounding_modeis static ordynamic_rounding_modeis notunknown. (To remove the instruction after constant folding, the previously mentioned DCE requirements apply.) - Speculation: Ok if
except_modeismasked, andfixed_fpenvon the function has bothdynamic_rounding_modeandexcept_mode, andfpstatus_readis 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_inputanddenormal_mode_output: One ofdynamic,ieee,preservesign,positivezero, where everything but the first one indicate static denormal modes.dynamic_denormal_mode_inputanddynamic_denormal_mode_output: One ofunknown,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:
- RFC: Change of strict FP operation representation in IR
- RFC: Optional support for signaling NaNs
- [IR] Allow non-constrained math intrinsics in strictfp functions
- [ConstantFolding] Non-constrained functions in strictfp
- [LLVM] Add FP instruction intrinsics
- [IR][CodeGen] Replace constrained FP intrinsics with fp.control/fp.except operand bundles
- Open PR’s by spavloff includes a lot of previous strict FP patch iterations
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.