Skip to content

UInt64.CreateSaturating<Int128> truncates instead of saturates #94523

Closed
@menees

Description

@menees

Description

UInt64.CreateSaturating uses truncation behavior rather than saturation with Int128. For example, ulong.CreateSaturating((Int128)ulong.MaxValue + (Int128)10L)) returns 9 instead of 18446744073709551615 (ulong.MaxValue).

Internally, it's doing truncation because UInt64.CreateSaturating first calls TryConvertFromSaturating (here), which doesn't handle Int128, and then it falls back to Int128.TryConvertToSaturating. Unfortunately, that method does a truncating cast to ulong (here) rather than using MaxValue logic like it does for uint (here).

Here's a sample C# program that demonstrates this inconsistency with UInt64 and Int128. For contrast, it also shows that UInt32 correctly saturates with Int64, and it shows that UInt64 correctly saturates with Decimal.

namespace GenericMath;

using System;
using System.Runtime.CompilerServices;

internal class Program
{
    static void Main()
    {
        Show("----UInt32 Saturates With Int64----");
        Show(uint.CreateSaturating(-10L));
        long longValue;
        Show(longValue = uint.MaxValue + 10L);
        Show(uint.CreateSaturating(longValue));

        Show("\n----UInt64 Truncates With Int128!----");
        Show(ulong.CreateSaturating((Int128)(-10L)));
        Show(ulong.MaxValue);
        Int128 int128Value;
        Show(int128Value = (Int128)ulong.MaxValue + (Int128)10L);
        // Things go wrong here with Int128. ulong doesn't handle it correctly.
        Show(ulong.CreateSaturating<Int128>(int128Value));
        Show("     CreateSaturating should return ulong.MaxValue not 9.");
        Show("     CreateSaturating is incorrectly doing truncation.");
        Show(ulong.CreateTruncating<Int128>(int128Value));

        Show("\n----UInt64 Saturates With Decimal----");
        Show(ulong.CreateSaturating(-10m));
        decimal decimalValue;
        Show(decimalValue = ulong.MaxValue + 10m);
        Show(ulong.CreateSaturating(decimalValue));
    }

    static void Show(object value, [CallerArgumentExpression(nameof(value))] string? expression = null)
        => Console.WriteLine(value is string text ? text : $"{expression,-58}: {value,20}");
}

Output:

----UInt32 Saturates With Int64----
uint.CreateSaturating(-10L)                               :                    0
longValue = uint.MaxValue + 10L                           :           4294967305
uint.CreateSaturating(longValue)                          :           4294967295

----UInt64 Truncates With Int128!----
ulong.CreateSaturating((Int128)(-10L))                    :                    0
ulong.MaxValue                                            : 18446744073709551615
int128Value = (Int128)ulong.MaxValue + (Int128)10L        : 18446744073709551625
ulong.CreateSaturating<Int128>(int128Value)               :                    9
     CreateSaturating should return ulong.MaxValue not 9.
     CreateSaturating is incorrectly doing truncation.
ulong.CreateTruncating<Int128>(int128Value)               :                    9

----UInt64 Saturates With Decimal----
ulong.CreateSaturating(-10m)                              :                    0
decimalValue = ulong.MaxValue + 10m                       : 18446744073709551625
ulong.CreateSaturating(decimalValue)                      : 18446744073709551615

Reproduction Steps

If the sample program in the description is too verbose, here's the gist of the problem:

ulong.CreateSaturating((Int128)ulong.MaxValue + (Int128)10L)

Expected behavior

The repro line of code should return ulong.MaxValue (18446744073709551615) to be consistent with other CreateSaturating methods.

Actual behavior

The repro line of code returns 9, which is a bitwise truncation of the Int128 value. That's the same value that CreateTruncating returns, and it's inconsistent with other CreateSaturating methods.

Regression?

I've only tried this in .NET 7, which is where Int128 was introduced. .NET 8 hasn't been officially released yet.

Known Workarounds

For Int128 values that fit into Decimal, you can use ulong.CreateSaturating<decimal> instead.

Configuration

.NET 7.0.13 on Windows 11 x64 with .NET SDK 7.0.403.

Other information

Int128.TryConvertToSaturating does a truncating cast to ulong (here) rather than using MaxValue logic like it does for uint (here). For ulong, Int128.TryConvertToSaturating is only saturating on the lower bound not on the upper bound, so it's a half-saturating, half-truncating implementation. That's the heart of the bug, and that inconsistency makes CreateSaturating untrustworthy for my generic math usage.

Metadata

Metadata

Assignees

No one assigned

    Labels

    Type

    No type

    Projects

    No projects

    Milestone

    Relationships

    None yet

    Development

    No branches or pull requests

    Issue actions