Roslyn: IDE0004 false positive when explicitly casting to float to generate conv.r4

Created on 9 Apr 2019  路  14Comments  路  Source: dotnet/roslyn

Version Used:
Tested in Visual Studio 2017 Professional and Visual Studio 2019 Professional

Steps to Reproduce:
Write the following code:

float f1 = 0.00000000002f;
float f2 = 1 / f1;
double d = (float)f2;
Console.WriteLine(d);

Expected Behavior:
The final explicit cast is not considered redundant: As I'm reading this StackOverflow answer from Eric Lippert, the explicit cast ensures that the operation is carried out at lower precision, so that removing the cast will allow the optimizer the freedom to potentially change results. Indeed, I see the following results when compiling with x86 as my target platform:

           With the cast | Without the cast
  Debug      49999998976 | 49999998976  
Release      49999998976 | 50000000199,7901

Now, according to the StackOverflow answer, this behavior is not part of the spec, so I can see how IDE0004 might rightfully pick it up, even if it's de facto wrong. When I'm still not entirely convinced, it's because the relevant part of the C# spec (ignoring the CLR spec) seems to only be concerned with individual floating point operations where in the example above, we have a few different ones.

Actual Behavior:
The cast is considered redundant.

Area-IDE Bug IDE-CodeStyle Resolution-Fixed

All 14 comments

Initially assigned to compilers to provide an interpretation of the language specification. If the language does not _require_ that the insertion of (float) cause a behavior change, then this would not be considered a bug in the analyzer.

My interpretation of the ECMA language spec (based on text that is not present in the Microsoft version) is that we are permitted to perform operations and store local variables at higher precision than the declared type, but that we are required to obey a cast to a floating-point type, even when redundant, by rounding to precisely the specified precision of the cast. In other words a cast like (float) and (double) on a local variable or computation is rarely redundant. The only time it would be reasonable to conclude that a cast to a floating-point type is redundant (and the IDE could be justified in producing a warning) is when it is applied to a value immediately fetched from a field of the same type.

11.2.2 Identity conversion

An identity conversion converts from any type to the same type. One reason this conversion exists is so that a type T or an expression of type T can be said to be convertible to T itself.

Because object and dynamic are considered equivalent there is an identity conversion between object and dynamic, and between constructed types that are the same when replacing all occurrences of dynamic with object.

In most cases, an identity conversion has no effect at runtime. However, since floating point operations may be performed at higher precision than prescribed by their type (搂9.3.7), assignment of their results may result in a loss of precision, and explicit casts are guaranteed to reduce precision to what is prescribed by the type.

@gafter: Thanks for the reference; that's much more clear-cut. Why is the relevant paragraph left out in the Microsoft version?

One minor thing that could make this easy to miss is that identity conversions (cf. 搂11.2.2 of the ECMA document) are only discussed in the context of implicit conversions (搂11.2) and not in that of explicit conversions (搂11.3) even though the paragraph you cite explicitly deals with explicit casts.

Microsoft never went through the exercise of merging the ECMA spec into its own version. We hope to do that for the next revision.

The spec says that all implicit conversions are explicit conversions too.

@gafter, doesn't this impact determinism?

That is, given that the Roslyn compilers are written in C#, that (as far as I know) Roslyn doesn't have a software implementation for float/double computation, and given that the legacy 32-bit JIT uses the x87 FPU stack and does not correctly round at each operation (unlike RyuJIT used in .NET Core), you could potentially get different results via constant folding when running the desktop csc vs running the .NET core csc?

Looking at 搂9.3.7 in ECMA 334; it looks like the only reason the compiler added this note is because 20 years ago the 32-bit legacy JIT decided it was too much of a perf hit to emit code against the x87 FPU stack that would correctly round a given operation to the correct precision and therefore maintain IEEE compliance (such functionality is built into the hardware, but it requires an explicit FLDCW before executing a sequence of instructions in a different precision; than the previous sequence).

I would think that, for more consistent results/determinism, it would be desirable for Roslyn to always compute the operations as given. So, in the example above, you have four operations:

  1. Parsing 0.00000000002f to a binary32
  2. Converting the integer 1 to a binary32
  3. Dividing the result of step 2 by the result of step 1
  4. Upcasting the result of step 3 to a binary64

However, It is worth noting that, under IEEE 754:2008 - 10. Expression evaluation, it is allowed for a language standard to implicitly convert operands to a common format; however, it also states:

Language standards should disallow, or provide warnings for, mixed-format operations that would cause implicit conversion that might change operand values.

It also covers some other nuances of how a language standard should correctly handle expression evaluation and 11. Reproducible floating-point results covers how a language standard might support a guarantee of reproducible results.

  • It is worth noting that IEEE 754:1985, unlike IEEE 754:2008, does not look to have a section on proper expression evaluation
  • It is worth noting that ISO/IEC/IEEE 60559 is identical to IEEE 754, but was approved/rationalized separately a few years later. For reference, this is 1989, rather than 1985; and 2011, rather than 2008. The latest draft (preliminary IEEE 754:2019) will likely be ratified separately by ISO/IEC as well.

you could potentially get different results via constant folding when running the desktop csc vs running the .NET core csc?

No. The compiler is careful to always either cast to the proper floating-point type, or store in a field of the proper floating-point type when folding constant expressions.

Language standards should disallow, or provide warnings for, mixed-format operations that would cause implicit conversion that might change operand values.

I don't think this recommendation applies to the issue here. We don't have any mixed-format operations in the original code. However I get your point that what we do is not in the spirit of the IEEE recommendation. I agree and acknowledge that the C# language-specified behavior is not what IEEE would endorse.

No. The compiler is careful to always either cast to the proper floating-point type, or store in a field of the proper floating-point type when folding constant expressions.

Right, that is the concern.

Say the source code has float r = literal1 op literal2; The compiler will first parse literal1 and literal2 using the known IEEE compliant parser. It will then internally perform constant folding on the two operations. Given that Roslyn is written in managed code, runs the code against the JIT, and does not have a software implementation; it will perform this constant folding according to the code the JIT produces.

On RyuJIT (.NET Core and the 64-bit Desktop JIT), this is all fine since it uses the SSE/SSE2 instructions which are known to be IEEE compliant and it should always be executing the operations to the correct precision.

However, on the 32-bit legacy JIT; this will execute on the JIT using the x87 FPU stack which will:

  1. Upcast the results to 80-bits (this is lossless)
  2. Perform the operation to 80-bits of precision (since it doesn't explicitly set the precision to binary32 or binary64). Internally, this computes to infinite precision and then rounds down to 80-bits; according to the IEEE spec
  3. Round that result down to the correct precision (32-bit or 64-bit)

This is potentially problematic as you have two stages of rounding; which can cause minor variations in the end result.

This isn't generally a problem for most numbers since the rounding direction is either nearest or to even on ties. However, it is possible to be impactful in some scenarios.

Take for example a case where you have an operation that computes an 80-bit result that happens to be a "tie" (the rounding digit is 5 with no remaining trailing bits). For this, it will round to even (setting the 80th bit to zero). Due to the round to even behavior, you have lost the fact that the infinitely precise result had trailing bits past the 80th; this means that if the rounding digit of the 64-bit result also ends in 5 and all remaining trailing bits are zero; you may be rounding in the wrong direction.

This type of problem is actually covered briefly by the Exploring Binary blog here: https://www.exploringbinary.com/double-rounding-errors-in-floating-point-conversions/

@tannergooding That's interesting. Can you construct a concrete example of the compiler folding constants incorrectly (or differently on different platforms) because of this?

I've been able to construct a few concrete examples for the native compiler; but can't seem to reproduce it for the Roslyn compiler.

It also looks like the x86 legacy JIT defaults to double-precision (rather than extended precision) for its rounding mode; so the failure case (if I can find a repro case for Roslyn) would exist for 32-bit implicitly upcasting to 64-bit (rather than both implicitly upcasting to 80-bit).

It's also worth noting that the current compiler bits we ship are AnyCPU and don't have Prefer 32-bit checked, so it is increasingly unlikely that someone would hit this case if a repro can be found.

Looks like Roslyn might be fine due to the default precision being double rather than extended and since it explicitly rounds to the destination precision after each operation.

There are a few papers, with proofs, that suggest that individual addition, subtraction, multiplication, and division operations do not suffer from the double rounding problem if the intermediate precision is at least twice as big as the destination precision.

However, I am still going over the papers and proofs given to see if I can validate that is the case.

@tannergooding Back when I was working on the double/float parser I found the same thing but I didn't save the references to those papers and proofs, and I was unable to construct a counterexample.

Found a proof: 4359-12373-1-PB.pdf

Yes, that is one of the ones I found as well.

It looks like we are good then and the issue only needs to cover the scenario given in the OP :smile:

Was this page helpful?
0 / 5 - 0 ratings