Repository: nodatime/nodatime (csharp)

Issue:
Use Int128 in .NET 8 target
(e.g. for Duration.ToInt128Nanoseconds etc)

We could *experiment* with a conditional implementation, using Int128 in Duration when it's available - but I suspect it'll be too fiddly.

Pull request:
These are only available for the .NET 8 build (although in source code they're .NET 7+ as that's when Int128 was introduced; this is just in case we introduce a .NET 7 build).

Fixes #1777.

Benchmarks show that the Int128 operations are about the same as BigInteger in the worst cases, and about twice as fast in better cases.

Patch:
diff --git a/src/NodaTime.Benchmarks/NodaTime.Benchmarks.csproj b/src/NodaTime.Benchmarks/NodaTime.Benchmarks.csproj
index d93c480b4..681005ff8 100644
--- a/src/NodaTime.Benchmarks/NodaTime.Benchmarks.csproj
+++ b/src/NodaTime.Benchmarks/NodaTime.Benchmarks.csproj
@@ -3,7 +3,7 @@
   <PropertyGroup>
     <TargetFrameworks>net471;net6.0;net8.0</TargetFrameworks>
     <OutputType>Exe</OutputType>
-    <LangVersion>8</LangVersion>
+    <LangVersion>latest</LangVersion>
   </PropertyGroup>
 
   <ItemGroup>
diff --git a/src/NodaTime/Duration.cs b/src/NodaTime/Duration.cs
index a1d6c8281..d5508895f 100644
--- a/src/NodaTime/Duration.cs
+++ b/src/NodaTime/Duration.cs
@@ -71,6 +71,10 @@ namespace NodaTime
         internal const int MinDays = ~MaxDays;
         internal static readonly BigInteger MinNanoseconds = (BigInteger) MinDays * NanosecondsPerDay;
         internal static readonly BigInteger MaxNanoseconds = (MaxDays + BigInteger.One) * NanosecondsPerDay - BigInteger.One;
+#if NET7_0_OR_GREATER
+        private static readonly Int128 MinInt128Nanoseconds = (Int128) MinNanoseconds;
+        private static readonly Int128 MaxInt128Nanoseconds = (Int128) MaxNanoseconds;
+#endif
         internal static readonly decimal MinDecimalNanoseconds = (decimal) MinNanoseconds;
         internal static readonly decimal MaxDecimalNanoseconds = (decimal) MaxNanoseconds;
         private static readonly double MinDoubleNanoseconds = (double) MinNanoseconds;
@@ -363,7 +367,8 @@ public long BclCompatibleTicks
         /// <remarks>The result is always an integer, but may not be precise due to the limitations
         /// of the <c>Double</c> type. In other works, <c>Duration.FromNanoseconds(duration.TotalNanoseconds)</c>
         /// is not guaranteed to round-trip. To guarantee precision and round-tripping,
-        /// use <see cref="ToBigIntegerNanoseconds" /> and <see cref="FromNanoseconds(BigInteger)"/>.
+        /// use <see cref="ToBigIntegerNanoseconds" /> and <see cref="FromNanoseconds(BigInteger)"/>
+        /// (or the <c>Int128</c> equivalents where available).
         /// </remarks>
         /// <returns>This duration as a number of nanoseconds, represented as a <c>Double</c>.</returns>
         public double TotalNanoseconds => ((double) days) * NanosecondsPerDay + nanoOfDay;
@@ -1103,6 +1108,28 @@ public static Duration FromNanoseconds(BigInteger nanoseconds)
             return new Duration(days, nanoOfDay, noValidation: true);
         }
 
+#if NET7_0_OR_GREATER
+        /// <summary>
+        /// Converts a number of nanoseconds expressed as an <see cref="Int128"/> into a duration.
+        /// </summary>
+        /// <param name="nanoseconds">The number of nanoseconds to represent.</param>
+        /// <returns>A duration with the given number of nanoseconds.</returns>
+        public static Duration FromNanoseconds(Int128 nanoseconds)
+        {
+            if (nanoseconds < MinInt128Nanoseconds || nanoseconds > MaxInt128Nanoseconds)
+            {
+                throw new ArgumentOutOfRangeException(nameof(nanoseconds), $"Value should be in range [{MinNanoseconds}-{MaxNanoseconds}]");
+            }
+
+            int days = nanoseconds >= Int128.Zero
+                ? (int) (nanoseconds / NanosecondsPerDay)
+                : (int) ((nanoseconds + Int128.One) / NanosecondsPerDay) - 1;
+
+            long nanoOfDay = (long) (nanoseconds - ((Int128) days) * NanosecondsPerDay);
+            return new Duration(days, nanoOfDay, noValidation: true);
+        }
+#endif
+
         internal static Duration FromNanoseconds(decimal nanoseconds)
         {
             if (nanoseconds < MinDecimalNanoseconds || nanoseconds > MaxDecimalNanoseconds)
@@ -1211,6 +1238,15 @@ public long ToInt64Nanoseconds() =>
         [Pure]
         public BigInteger ToBigIntegerNanoseconds() => IsInt64Representable ? ToInt64NanosecondsUnchecked() : ((BigInteger) days) * NanosecondsPerDay + nanoOfDay;
 
+#if NET7_0_OR_GREATER
+        /// <summary>
+        /// Conversion to a <see cref="Int128"/> number of nanoseconds, as a convenient built-in numeric
+        /// type which can always represent values in the range we need.
+        /// </summary>
+        /// <returns>This duration as a number of nanoseconds, represented as a <c>BigInteger</c>.</returns>
+        [Pure]
+        public Int128 ToInt128Nanoseconds() => IsInt64Representable ? ToInt64NanosecondsUnchecked() : ((Int128) days) * NanosecondsPerDay + nanoOfDay;
+#endif
         [Pure]
         internal decimal ToDecimalNanoseconds() => IsInt64Representable ? ToInt64NanosecondsUnchecked() : ((decimal) days) * NanosecondsPerDay + nanoOfDay;
 


Test patch:
diff --git a/src/NodaTime.Benchmarks/NodaTimeTests/DurationBenchmarks.cs b/src/NodaTime.Benchmarks/NodaTimeTests/DurationBenchmarks.cs
index e63deb01b..66f28b4c8 100644
--- a/src/NodaTime.Benchmarks/NodaTimeTests/DurationBenchmarks.cs
+++ b/src/NodaTime.Benchmarks/NodaTimeTests/DurationBenchmarks.cs
@@ -32,6 +32,12 @@ public class DurationBenchmarks
         private static readonly BigInteger BigInteger50Years = 50L * 365 * NodaConstants.NanosecondsPerDay;
         private static readonly BigInteger BigInteger3000Years = ((BigInteger) 365 * NodaConstants.NanosecondsPerDay) * 3000;
 
+#if NET7_0_OR_GREATER
+        private static readonly Int128 Int128_50 = 50;
+        private static readonly Int128 Int128_50Years = 50L * 365 * NodaConstants.NanosecondsPerDay;
+        private static readonly Int128 Int128_3000Years = ((Int128) 365 * NodaConstants.NanosecondsPerDay) * 3000;
+#endif
+
         [Benchmark]
         public Duration FromDays() =>
 #if !V1
@@ -107,7 +113,17 @@ public Duration FromDays() =>
         public Duration FromNanoseconds_LargeBigInteger() => Duration.FromNanoseconds(BigInteger3000Years);
 
         // No equivalent for Int64 as it won't fit...
+#endif
+
+#if NET7_0_OR_GREATER
+        [Benchmark]
+        public Duration FromNanoseconds_TinyInt128() => Duration.FromNanoseconds(Int128_50);
+
+        [Benchmark]
+        public Duration FromNanoseconds_MediumInt128() => Duration.FromNanoseconds(Int128_50Years);
 
+        [Benchmark]
+        public Duration FromNanoseconds_LargeInt128() => Duration.FromNanoseconds(Int128_3000Years);
 #endif
 
         [Benchmark]
@@ -167,6 +183,17 @@ public Duration FromDays() =>
         [Benchmark]
         public BigInteger ToBigIntegerNanoseconds_Large() => LargeDuration.ToBigIntegerNanoseconds();
 
+#if NET7_0_OR_GREATER
+        [Benchmark]
+        public BigInteger ToInt128Nanoseconds_Small() => SmallDuration.ToInt128Nanoseconds();
+
+        [Benchmark]
+        public BigInteger ToInt128Nanoseconds_Medium() => MediumDuration.ToInt128Nanoseconds();
+
+        [Benchmark]
+        public BigInteger ToInt128Nanoseconds_Large() => LargeDuration.ToInt128Nanoseconds();
+#endif
+
         [Benchmark]
         public double TotalNanoseconds_Small() => SmallDuration.TotalNanoseconds;
 
diff --git a/src/NodaTime.Test/DurationTest.Construction.cs b/src/NodaTime.Test/DurationTest.Construction.cs
index 5764d60f5..e5d2824ff 100644
--- a/src/NodaTime.Test/DurationTest.Construction.cs
+++ b/src/NodaTime.Test/DurationTest.Construction.cs
@@ -225,6 +225,20 @@ public void FromNanoseconds_BigInteger()
             Assert.AreEqual(-Duration.OneDay + Duration.Epsilon, Duration.FromNanoseconds(-NanosecondsPerDay + BigInteger.One));
         }
 
+#if NET7_0_OR_GREATER
+        [Test]
+        public void FromNanoseconds_Int128()
+        {
+            Assert.AreEqual(Duration.OneDay - Duration.Epsilon, Duration.FromNanoseconds(NanosecondsPerDay - Int128.One));
+            Assert.AreEqual(Duration.OneDay, Duration.FromNanoseconds(NanosecondsPerDay + Int128.Zero));
+            Assert.AreEqual(Duration.OneDay + Duration.Epsilon, Duration.FromNanoseconds(NanosecondsPerDay + Int128.One));
+
+            Assert.AreEqual(-Duration.OneDay - Duration.Epsilon, Duration.FromNanoseconds(-NanosecondsPerDay - Int128.One));
+            Assert.AreEqual(-Duration.OneDay, Duration.FromNanoseconds(-NanosecondsPerDay + Int128.Zero));
+            Assert.AreEqual(-Duration.OneDay + Duration.Epsilon, Duration.FromNanoseconds(-NanosecondsPerDay + Int128.One));
+        }
+#endif
+
         [Test]
         public void FromNanoseconds_Double()
         {
@@ -262,10 +276,20 @@ public void FactoryMethods_OutOfRange()
             AssertOutOfRange(Duration.FromMilliseconds,  bigBadDoubles);
             AssertOutOfRange(Duration.FromTicks, bigBadDoubles);
             AssertOutOfRange(Duration.FromNanoseconds, bigBadDoubles);
-            
+            AssertOutOfRange(Duration.FromNanoseconds,
+                // You might have expected -1L here, but the layout of double means
+                // this really is the next value *lower* than MinNanoseconds.
+                NextDouble((double) Duration.MinNanoseconds, 1L),
+                NextDouble((double) Duration.MaxNanoseconds, 1L));
+
             // No such concept as BigInteger.Min/MaxValue, so use the values we know to be just outside valid bounds.
             AssertOutOfRange(Duration.FromNanoseconds, Duration.MinNanoseconds - 1, Duration.MaxNanoseconds + 1);
 
+#if NET7_0_OR_GREATER
+            AssertOutOfRange(Duration.FromNanoseconds, (Int128) Duration.MinNanoseconds - 1, (Int128) Duration.MaxNanoseconds + 1);
+            AssertOutOfRange(Duration.FromNanoseconds, Int128.MinValue, Int128.MaxValue);
+#endif
+
             void AssertLimitsInt32(Func<int, Duration> factoryMethod, int[] allCases) =>
                 AssertOutOfRange(factoryMethod, allCases.First() - 1, allCases.Last() + 1);
 
@@ -279,6 +303,10 @@ void AssertOutOfRange<T>(Func<T, Duration> factoryMethod, params T[] values)
                     Assert.Throws<ArgumentOutOfRangeException>(() => factoryMethod(value));
                 }
             }
+
+            // Use the bitwise pattern of a double to ensure we get a different value.
+            double NextDouble(double start, long change) =>
+                BitConverter.Int64BitsToDouble(BitConverter.DoubleToInt64Bits(start) + change);
         }
     }
 }
diff --git a/src/NodaTime.Test/DurationTest.cs b/src/NodaTime.Test/DurationTest.cs
index d933074d6..e33268ef3 100644
--- a/src/NodaTime.Test/DurationTest.cs
+++ b/src/NodaTime.Test/DurationTest.cs
@@ -85,6 +85,34 @@ public void BigIntegerConversions(long int64Nanos)
             Assert.AreEqual(bigIntegerNanos, nanoseconds.ToBigIntegerNanoseconds());
         }
 
+#if NET7_0_OR_GREATER
+        [Test]
+        [TestCase(long.MinValue)]
+        [TestCase(long.MinValue + 1)]
+        [TestCase(-NodaConstants.NanosecondsPerDay - 1)]
+        [TestCase(-NodaConstants.NanosecondsPerDay)]
+        [TestCase(-NodaConstants.NanosecondsPerDay + 1)]
+        [TestCase(-1)]
+        [TestCase(0)]
+        [TestCase(1)]
+        [TestCase(NodaConstants.NanosecondsPerDay - 1)]
+        [TestCase(NodaConstants.NanosecondsPerDay)]
+        [TestCase(NodaConstants.NanosecondsPerDay + 1)]
+        [TestCase(long.MaxValue - 1)]
+        [TestCase(long.MaxValue)]
+        public void Int128Conversions(long int64Nanos)
+        {
+            Int128 int128Nanos = int64Nanos;
+            var nanoseconds = Duration.FromNanoseconds(int128Nanos);
+            Assert.AreEqual(int128Nanos, nanoseconds.ToInt128Nanoseconds());
+
+            // And multiply it by 100, which proves we still work for values out of the range of Int64
+            int128Nanos *= 100;
+            nanoseconds = Duration.FromNanoseconds(int128Nanos);
+            Assert.AreEqual(int128Nanos, nanoseconds.ToInt128Nanoseconds());
+        }
+#endif
+
         [Test]
         public void ConstituentParts_Positive()
         {