Skip to content
New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

Breaking Change in .NET 9.0 in int-to-fp conversion causes unit tests to fail #228

Closed
kpreisser opened this issue Nov 21, 2024 · 7 comments
Labels

Comments

@kpreisser
Copy link
Collaborator

kpreisser commented Nov 21, 2024

.NET 9.0 introduced breaking changes in JIT behavior about floating-point-to-integer conversion, which causes a number of unit tests to fail, e.g. BitwiseAnd. This may also be the reason why the TypeScript Compiler (when running with Jurassic) is producing incorrect errors about interfaces being extended incorrectly; see dotnet/runtime#110004.

For example, the BitwiseAnd test uses the following IL to load -7 and convert it to uint:

generator.Emit(OpCodes.Ldc_I4, 7);
generator.Emit(OpCodes.Conv_R8);
generator.Emit(OpCodes.Neg);
generator.Emit(OpCodes.Conv_U4);

or in C#:

double d2 = -7;
uint u2 = unchecked((uint)d2);

Previously (up to .NET 8.0), this produced 0xFFFFFFF9, but in .NET 9.0, this results in 0. It looks like the conversion from double to int/uint and other integer types might need to be adjusted, for example in ReflectionEmitILGenerator.ConvertToUnsignedInteger() and in TypeConverter.To[U]Int...() methods.

@tannergooding
Copy link

Previously (up to .NET 8.0), this produced 0xFFFFFFF9

It's worth noting that this isn't strictly true. It would not produce this result on Arm64 or WASM machines. It similarly would not produce this result on an x64 machine with AVX512 support.

Historically the behavior of floating-point to integer conversions has been "undefined" when the source value cannot be represented in the destination after truncation. This is explicitly called out in the C# language spec, the runtime spec (ECMA-335), the IEEE 754 floating-point specification, and is typical in other languages as well. Newer languages and runtimes have recognized this issue and started normalizing the behavior towards saturation instead. This best fits with the more general IEEE 754 behavior that exists for almost all other functions where "the result is computed as if to infinite precision and unbounded range, prior to rounding to the nearest representable result". This is correspondingly required by languages like Rust or platforms like WASM. It is similarly the behavior that platforms like Arm64 have opted to implement by default.

.NET correspondingly made a push towards determinism and opted to mirror the behavior the industry is standardizing against. We did expose some new TInteger ConvertToInteger<TInteger> (double value) where TInteger : System.Numerics.IBinaryInteger<TInteger> APIs on double and float, but these primarily exist for perf and likewise do not guarantee the old behavior will strictly occur. Rather instead they leave the behavior undefined for such "out of range" results and do whatever is most efficient for the underlying platform. Thus will appear to have the same behavior on most x64 hardware, but may still deviate such as on an x64 CPU with AVX512 support (where they will differ for unsigned inputs) or the yet to be released CPUs with AVX10.2 where native instructions for doing the conversion with saturation exist.

@tannergooding
Copy link

More details about this break can be seen here: https://learn.microsoft.com/en-us/dotnet/core/compatibility/jit/9.0/fp-to-integer

I'm also happy to answer any additional questions on the topic if they exist.

@paulbartrum
Copy link
Owner

Thanks for reporting this @kpreisser, I'll take a look.

@paulbartrum
Copy link
Owner

@tannergooding Is it expected that I get different results in .NET 9 depending on whether the cast is a compile-time constant or not?

i.e.

[MethodImpl(MethodImplOptions.NoInlining)]
double GetNegativeInfinity() => double.NegativeInfinity;

[MethodImpl(MethodImplOptions.NoInlining)]
double GetPositiveInfinity() => double.PositiveInfinity;

// .NET 9 (expected saturating behavior)
Assert.AreEqual(2147483647, unchecked((int)GetPositiveInfinity()));
Assert.AreEqual(-2147483648, unchecked((int)GetNegativeInfinity()));
Assert.AreEqual(4294967295u, unchecked((uint)GetPositiveInfinity()));
Assert.AreEqual(0u, unchecked((uint)GetNegativeInfinity()));

// .NET 9 (compile-time constants)
Assert.AreEqual(0, unchecked((int)double.PositiveInfinity));
Assert.AreEqual(0, unchecked((int)double.NegativeInfinity));
Assert.AreEqual(0u, unchecked((uint)double.PositiveInfinity));
Assert.AreEqual(0u, unchecked((uint)double.NegativeInfinity));

Here's the behavior when running on .NET 6:

// .NET 6 (old behavior)
Assert.AreEqual(-2147483648, unchecked((int)GetPositiveInfinity()));
Assert.AreEqual(-2147483648, unchecked((int)GetNegativeInfinity()));
Assert.AreEqual(0u, unchecked((uint)GetPositiveInfinity()));
Assert.AreEqual(0u, unchecked((uint)GetNegativeInfinity()));

// .NET 6 (compile-time constants)
Assert.AreEqual(0, unchecked((int)double.PositiveInfinity));
Assert.AreEqual(0, unchecked((int)double.NegativeInfinity));
Assert.AreEqual(0u, unchecked((uint)double.PositiveInfinity));
Assert.AreEqual(0u, unchecked((uint)double.NegativeInfinity));

It's surprising to me that these should be different, but I guess at least it's not a regression... :-)

@tannergooding
Copy link

Yes at least for the time being.

Roslyn needed to ensure cross-machine determinism before the runtime made a decision and opted to just make all undefined results produce 0. I logged dotnet/roslyn#73084 early in .NET 9 to suggest that Roslyn update to match the "new standard" that we've standardized around and any next steps (potentially including Roslyn keeping its existing behavior) are on the compiler team to decide.

@paulbartrum
Copy link
Owner

paulbartrum commented Nov 22, 2024

Seems a shame you (edit: or rather, the .NET team) couldn't get both breaking changes out of the way in one release. Ah well. Thanks for taking the time to reply.

@paulbartrum
Copy link
Owner

@kpreisser I've published a fix to NuGet under the version 3.2.8. Let me know if you have any issues.

Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment
Labels
Projects
None yet
Development

No branches or pull requests

3 participants