diff --git a/src/Clast.DatabaseDecimal/Arithmetic/AddKernel.cs b/src/Clast.DatabaseDecimal/Arithmetic/AddKernel.cs index dd0d47a..c5be895 100644 --- a/src/Clast.DatabaseDecimal/Arithmetic/AddKernel.cs +++ b/src/Clast.DatabaseDecimal/Arithmetic/AddKernel.cs @@ -95,6 +95,31 @@ public static Decimal128 Subtract(Decimal128 left, DecimalType leftType, Decimal return new Decimal128(DecimalRange.Enforce(checked(l - r), resultType, overflow)); } + /// + /// Subtract two 32-bit values, widening to 64-bit result. + /// Used when the result precision exceeds 9 digits. + /// + public static Decimal64 SubtractWiden(Decimal32 left, DecimalType leftType, Decimal32 right, DecimalType rightType, DecimalType resultType, + DecimalRounding rounding = DecimalRounding.HalfEven, + DecimalOverflow overflow = DecimalOverflow.Throw) + { + long l = ScaleHelper.Widen32To64(left.Mantissa, leftType.Scale, resultType.Scale, rounding); + long r = ScaleHelper.Widen32To64(right.Mantissa, rightType.Scale, resultType.Scale, rounding); + return new Decimal64(DecimalRange.Enforce(checked(l - r), resultType, overflow)); + } + + /// + /// Subtract two 64-bit values, widening to 128-bit result. + /// + public static Decimal128 SubtractWiden(Decimal64 left, DecimalType leftType, Decimal64 right, DecimalType rightType, DecimalType resultType, + DecimalRounding rounding = DecimalRounding.HalfEven, + DecimalOverflow overflow = DecimalOverflow.Throw) + { + Int128 l = ScaleHelper.Widen64To128(left.Mantissa, leftType.Scale, resultType.Scale, rounding); + Int128 r = ScaleHelper.Widen64To128(right.Mantissa, rightType.Scale, resultType.Scale, rounding); + return new Decimal128(DecimalRange.Enforce(checked(l - r), resultType, overflow)); + } + // --- 256-bit --- public static Decimal256 Add(Decimal256 left, DecimalType leftType, Decimal256 right, DecimalType rightType, DecimalType resultType, @@ -126,4 +151,16 @@ public static Decimal256 Subtract(Decimal256 left, DecimalType leftType, Decimal Int256 r = ScaleHelper.Rescale256(right.Mantissa, rightType.Scale, resultType.Scale, rounding); return new Decimal256(DecimalRange.Enforce(checked(l - r), resultType, overflow)); } + + /// + /// Subtract two 128-bit values, widening to 256-bit result. + /// + public static Decimal256 SubtractWiden(Decimal128 left, DecimalType leftType, Decimal128 right, DecimalType rightType, DecimalType resultType, + DecimalRounding rounding = DecimalRounding.HalfEven, + DecimalOverflow overflow = DecimalOverflow.Throw) + { + Int256 l = ScaleHelper.Widen128To256(left.Mantissa, leftType.Scale, resultType.Scale, rounding); + Int256 r = ScaleHelper.Widen128To256(right.Mantissa, rightType.Scale, resultType.Scale, rounding); + return new Decimal256(DecimalRange.Enforce(checked(l - r), resultType, overflow)); + } } diff --git a/src/Clast.DatabaseDecimal/Arithmetic/SpanAddKernel.cs b/src/Clast.DatabaseDecimal/Arithmetic/SpanAddKernel.cs index 925117e..5d5fd2d 100644 --- a/src/Clast.DatabaseDecimal/Arithmetic/SpanAddKernel.cs +++ b/src/Clast.DatabaseDecimal/Arithmetic/SpanAddKernel.cs @@ -14,6 +14,15 @@ namespace Clast.DatabaseDecimal.Arithmetic; /// are pre-computed once before the loop. /// The result span may safely overlap with either input span. /// +/// +/// The overlap guarantee covers the same-width overloads, where an element is +/// written only after both operands at that index have been read. It does not +/// extend to the widening overloads: their result element is twice the width of +/// their inputs, so an in-place widening operation has nowhere to put the +/// second half. Reaching that case at all takes a deliberate +/// reinterpretation +/// of one buffer as both element types, since the spans are differently typed. +/// public static class SpanAddKernel { // ================================================================ @@ -446,6 +455,80 @@ public static void Subtract( DecimalRange.Validate(result.Slice(0, left.Length), resultType); } + // ================================================================ + // Subtract — column - column, widening + // ================================================================ + + public static void SubtractWiden( + ReadOnlySpan left, DecimalType leftType, + ReadOnlySpan right, DecimalType rightType, + Span result, DecimalType resultType, + DecimalRounding rounding = DecimalRounding.HalfEven, + DecimalOverflow overflow = DecimalOverflow.Throw) + { + ValidateLengths(left.Length, right.Length, result.Length); + + int ld = resultType.Scale - leftType.Scale; + int rd = resultType.Scale - rightType.Scale; + + if (ld == 0 && rd == 0) + { + DecimalRange.GetBounds(resultType, out long lower, out long upper); + if (SubtractWidenSameScale32To64(left, right, result, lower, upper) && overflow == DecimalOverflow.Throw) + DecimalRange.ThrowOutOfRange(resultType); + return; + } + else + { + for (int i = 0; i < left.Length; i++) + result[i] = checked(ScaleHelper.WidenByDelta32To64(left[i], ld, rounding) + - ScaleHelper.WidenByDelta32To64(right[i], rd, rounding)); + } + + if (overflow == DecimalOverflow.Throw) + DecimalRange.Validate(result.Slice(0, left.Length), resultType); + } + + public static void SubtractWiden( + ReadOnlySpan left, DecimalType leftType, + ReadOnlySpan right, DecimalType rightType, + Span result, DecimalType resultType, + DecimalRounding rounding = DecimalRounding.HalfEven, + DecimalOverflow overflow = DecimalOverflow.Throw) + { + ValidateLengths(left.Length, right.Length, result.Length); + + int ld = resultType.Scale - leftType.Scale; + int rd = resultType.Scale - rightType.Scale; + + for (int i = 0; i < left.Length; i++) + result[i] = checked(ScaleHelper.WidenByDelta64To128(left[i], ld, rounding) + - ScaleHelper.WidenByDelta64To128(right[i], rd, rounding)); + + if (overflow == DecimalOverflow.Throw) + DecimalRange.Validate(result.Slice(0, left.Length), resultType); + } + + public static void SubtractWiden( + ReadOnlySpan left, DecimalType leftType, + ReadOnlySpan right, DecimalType rightType, + Span result, DecimalType resultType, + DecimalRounding rounding = DecimalRounding.HalfEven, + DecimalOverflow overflow = DecimalOverflow.Throw) + { + ValidateLengths(left.Length, right.Length, result.Length); + + int ld = resultType.Scale - leftType.Scale; + int rd = resultType.Scale - rightType.Scale; + + for (int i = 0; i < left.Length; i++) + result[i] = checked(ScaleHelper.WidenByDelta128To256(left[i], ld, rounding) + - ScaleHelper.WidenByDelta128To256(right[i], rd, rounding)); + + if (overflow == DecimalOverflow.Throw) + DecimalRange.Validate(result.Slice(0, left.Length), resultType); + } + // ================================================================ // Subtract — column - scalar (broadcast) // ================================================================ @@ -915,6 +998,51 @@ private static bool AddWidenSameScale32To64(ReadOnlySpan left, ReadOnlySpan return outOfRangeSeen; } + private static bool SubtractWidenSameScale32To64(ReadOnlySpan left, ReadOnlySpan right, Span result, long lower, long upper) + { + int i = 0; + bool outOfRangeSeen = false; +#if NET5_0_OR_GREATER + if (Vector.IsHardwareAccelerated && left.Length >= Vector.Count) + { + ReadOnlySpan> lv = MemoryMarshal.Cast>(left); + ReadOnlySpan> rv = MemoryMarshal.Cast>(right); + Span> ov = MemoryMarshal.Cast>(result); + int chunks = lv.Length; + // As with the widening add, the difference of two widened 32-bit + // values cannot overflow 64 bits, so there is no overflow + // accumulator here — only the declared precision has to be + // enforced, on both halves of each widened pair. + Vector outOfRange = Vector.Zero; + Vector loVec = new Vector(lower); + Vector hiVec = new Vector(upper); + for (int k = 0; k < chunks; k++) + { + Vector a = lv[k]; + Vector b = rv[k]; + Vector.Widen(a, out Vector aLo, out Vector aHi); + Vector.Widen(b, out Vector bLo, out Vector bHi); + Vector low = aLo - bLo; + Vector high = aHi - bHi; + outOfRange |= Vector.LessThan(low, loVec) | Vector.GreaterThan(low, hiVec); + outOfRange |= Vector.LessThan(high, loVec) | Vector.GreaterThan(high, hiVec); + ov[k * 2] = low; + ov[k * 2 + 1] = high; + } + outOfRangeSeen |= outOfRange != Vector.Zero; + i = chunks * Vector.Count; + } +#endif + for (; i < left.Length; i++) + { + var value = (long)left[i] - right[i]; + result[i] = value; + outOfRangeSeen |= value < lower || value > upper; + } + + return outOfRangeSeen; + } + private static bool AddBroadcastSameScale32(ReadOnlySpan left, int right, Span result, int lower, int upper) { int i = 0; diff --git a/tests/Clast.DatabaseDecimal.Tests/ArithmeticTests.cs b/tests/Clast.DatabaseDecimal.Tests/ArithmeticTests.cs index 27fe26b..534cbc2 100644 --- a/tests/Clast.DatabaseDecimal.Tests/ArithmeticTests.cs +++ b/tests/Clast.DatabaseDecimal.Tests/ArithmeticTests.cs @@ -88,6 +88,86 @@ public void Subtract_NegativeResult() Assert.Equal("-1.50", result.ToString(resultType.Scale)); } + [Fact] + public void Subtract_Widening_32To64() + { + // Two NUMERIC(9,2) values whose difference exceeds 32-bit + var type = DecimalType.Numeric(9, 2); + var resultType = DecimalTypeRules.Subtract(type, type); // NUMERIC(10,2) => 64-bit + + Assert.Equal(DecimalWidth.W64, resultType.Width); + + var left = new Decimal32(999_999_999); // 9,999,999.99 + var right = new Decimal32(-999_999_999); // -9,999,999.99 + + var result = AddKernel.SubtractWiden(left, type, right, type, resultType); + Assert.Equal(1_999_999_998L, result.Mantissa); // 19,999,999.98 + } + + [Fact] + public void Subtract_Widening_64To128() + { + var type = DecimalType.Numeric(18, 0); + var resultType = DecimalTypeRules.Subtract(type, type); // NUMERIC(19,0) => 128-bit + + Assert.Equal(DecimalWidth.W128, resultType.Width); + + var left = new Decimal64(long.MaxValue / 2); + var right = new Decimal64(-(long.MaxValue / 2)); + + var result = AddKernel.SubtractWiden(left, type, right, type, resultType); + Assert.Equal((Int128)(long.MaxValue / 2) - -(Int128)(long.MaxValue / 2), result.Mantissa); + } + + [Fact] + public void SubtractWiden_MatchesPromotingBothOperandsFirst() + { + // The workaround SubtractWiden replaces: promote to the wider tier by + // hand and subtract there. Rescaling is monotone in the mantissa, so the + // two must agree — including when the operands carry different scales. + var leftType = DecimalType.Numeric(9, 2); + var rightType = DecimalType.Numeric(9, 4); + var resultType = DecimalTypeRules.Subtract(leftType, rightType); // NUMERIC(12,4) + + Assert.Equal(DecimalWidth.W64, resultType.Width); + + int[] mantissas = [0, 1, -1, 12_345, -12_345, 999_999_999, -999_999_999]; + foreach (int l in mantissas) + { + foreach (int r in mantissas) + { + var widened = AddKernel.SubtractWiden( + new Decimal32(l), leftType, new Decimal32(r), rightType, resultType); + var byHand = AddKernel.Subtract( + new Decimal64(l), leftType, new Decimal64(r), rightType, resultType); + + Assert.Equal(byHand.Mantissa, widened.Mantissa); + } + } + } + + [Fact] + public void SubtractWiden_PastResultPrecision_Throws() + { + var type = DecimalType.Numeric(9, 0); + + // The difference needs 10 digits, but the result type allows 9. + Assert.Throws(() => AddKernel.SubtractWiden( + new Decimal32(999_999_999), type, new Decimal32(-1), type, type)); + } + + [Fact] + public void SubtractWiden_PastResultPrecision_Ignore_DoesNotThrow() + { + var type = DecimalType.Numeric(9, 0); + + var result = AddKernel.SubtractWiden( + new Decimal32(999_999_999), type, new Decimal32(-1), type, type, + DecimalRounding.HalfEven, DecimalOverflow.Ignore); + + Assert.Equal(1_000_000_000L, result.Mantissa); + } + // --- Multiplication --- [Fact] diff --git a/tests/Clast.DatabaseDecimal.Tests/Decimal256Tests.cs b/tests/Clast.DatabaseDecimal.Tests/Decimal256Tests.cs index 9984408..17cd111 100644 --- a/tests/Clast.DatabaseDecimal.Tests/Decimal256Tests.cs +++ b/tests/Clast.DatabaseDecimal.Tests/Decimal256Tests.cs @@ -126,6 +126,28 @@ public void Subtract_256Bit() Assert.Equal((Int256)32500, result.Mantissa); // 325.00 } + [Fact] + public void Subtract_Widening_128To256() + { + var leftType = DecimalType.Numeric(38, 0); + var rightType = DecimalType.Numeric(38, 0); + var wideResultType = DecimalType.Numeric(39, 0); // 256-bit result + + Assert.Equal(DecimalWidth.W256, wideResultType.Width); + + var left = new Decimal128(Int128.MaxValue / 2); + var right = new Decimal128(-(Int128.MaxValue / 2)); + + var result = AddKernel.SubtractWiden(left, leftType, right, rightType, wideResultType); + var expected = (Int256)(Int128.MaxValue / 2) - (Int256)(-(Int128.MaxValue / 2)); + Assert.Equal(expected, result.Mantissa); + + // The by-hand workaround: promote both operands and subtract at 256-bit. + var byHand = AddKernel.Subtract( + (Decimal256)left, leftType, (Decimal256)right, rightType, wideResultType); + Assert.Equal(byHand.Mantissa, result.Mantissa); + } + [Fact] public void Multiply_Widening_128To256() { diff --git a/tests/Clast.DatabaseDecimal.Tests/FusedRangeCheckTests.cs b/tests/Clast.DatabaseDecimal.Tests/FusedRangeCheckTests.cs index f35a82c..5b0658a 100644 --- a/tests/Clast.DatabaseDecimal.Tests/FusedRangeCheckTests.cs +++ b/tests/Clast.DatabaseDecimal.Tests/FusedRangeCheckTests.cs @@ -137,6 +137,39 @@ public void AddWiden32To64_PastPrecision_Throws(int index) SpanAddKernel.AddWiden(left, T9, right, T9, new long[Length], T9)); } + [Theory] + [InlineData(InVectorBody)] + [InlineData(InScalarTail)] + public void SubtractWiden32To64_PastPrecision_Throws(int index) + { + // Widening cannot overflow the width, so only the declared precision + // can reject this: NUMERIC(9,0) in a 64-bit result. + int[] left = new int[Length]; + int[] right = new int[Length]; + left[index] = Max9; + right[index] = -1; + + Assert.Throws(() => + SpanAddKernel.SubtractWiden(left, T9, right, T9, new long[Length], T9)); + } + + [Theory] + [InlineData(InVectorBody)] + [InlineData(InScalarTail)] + public void SubtractWiden32To64_Ignore_WritesEverythingAndDoesNotThrow(int index) + { + int[] left = new int[Length]; + int[] right = new int[Length]; + left[index] = Max9; + right[index] = -1; + long[] result = new long[Length]; + + SpanAddKernel.SubtractWiden(left, T9, right, T9, result, T9, + DecimalRounding.HalfEven, DecimalOverflow.Ignore); + + Assert.Equal(Max9 + 1L, result[index]); + } + [Theory] [InlineData(InVectorBody)] [InlineData(InScalarTail)] diff --git a/tests/Clast.DatabaseDecimal.Tests/SpanAddKernelTests.cs b/tests/Clast.DatabaseDecimal.Tests/SpanAddKernelTests.cs index 79c5464..c6f7e74 100644 --- a/tests/Clast.DatabaseDecimal.Tests/SpanAddKernelTests.cs +++ b/tests/Clast.DatabaseDecimal.Tests/SpanAddKernelTests.cs @@ -100,6 +100,73 @@ public void AddWiden_64To128() Assert.Equal((Int128)3_000_000_000_000_000_000, result[1]); } + // ---------------------------------------------------------------- + // Subtract — column - column, widening + // ---------------------------------------------------------------- + + [Fact] + public void SubtractWiden_32To64() + { + var type = DecimalType.Numeric(9, 2); + var resultType = DecimalType.Numeric(10, 2); + + int[] left = [999_999_999, 500_000_000]; + int[] right = [-999_999_999, -500_000_000]; + long[] result = new long[2]; + + SpanAddKernel.SubtractWiden(left, type, right, type, result, resultType); + + Assert.Equal([1_999_999_998L, 1_000_000_000L], result); + } + + [Fact] + public void SubtractWiden_32To64_DifferentScales() + { + var leftType = DecimalType.Numeric(9, 0); + var rightType = DecimalType.Numeric(9, 2); + var resultType = DecimalType.Numeric(12, 2); + + int[] left = [1, -1]; + int[] right = [50, -50]; + long[] result = new long[2]; + + SpanAddKernel.SubtractWiden(left, leftType, right, rightType, result, resultType); + + Assert.Equal([50L, -50L], result); // 1.00 - 0.50, -1.00 - -0.50 + } + + [Fact] + public void SubtractWiden_64To128() + { + var type = DecimalType.Numeric(18, 0); + var resultType = DecimalType.Numeric(19, 0); + + long[] left = [long.MaxValue / 2, 1_000_000_000_000_000_000]; + long[] right = [-(long.MaxValue / 2), -2_000_000_000_000_000_000]; + Int128[] result = new Int128[2]; + + SpanAddKernel.SubtractWiden(left, type, right, type, result, resultType); + + Assert.Equal((Int128)(long.MaxValue / 2) + (long.MaxValue / 2), result[0]); + Assert.Equal((Int128)3_000_000_000_000_000_000, result[1]); + } + + [Fact] + public void SubtractWiden_128To256() + { + var type = DecimalType.Numeric(38, 0); + var resultType = DecimalType.Numeric(39, 0); + + Int128[] left = [Int128.MaxValue / 2]; + Int128[] right = [-(Int128.MaxValue / 2)]; + Int256[] result = new Int256[1]; + + SpanAddKernel.SubtractWiden(left, type, right, type, result, resultType); + + Int256 expected = (Int256)(Int128.MaxValue / 2) + (Int256)(Int128.MaxValue / 2); + Assert.Equal(expected, result[0]); + } + // ---------------------------------------------------------------- // Add — column + scalar (broadcast) // ---------------------------------------------------------------- @@ -470,6 +537,29 @@ public void AddWiden_32To64_SimdChunkedAndTail_PreservesElementOrder() Assert.Equal(expected, result); } + [Fact] + public void SubtractWiden_32To64_SimdChunkedAndTail_PreservesElementOrder() + { + // As with the widening add, Vector.Widen splits each Vector into + // lower/upper Vector halves; this checks every index, so a swap + // of the low/high writes cannot pass. + var type = DecimalType.Numeric(9, 2); + var resultType = DecimalType.Numeric(10, 2); + int n = 23; + int[] left = new int[n]; + int[] right = new int[n]; + long[] expected = new long[n]; + for (int i = 0; i < n; i++) + { + left[i] = int.MaxValue - i; + right[i] = int.MinValue + 2 * i; + expected[i] = (long)left[i] - right[i]; + } + long[] result = new long[n]; + SpanAddKernel.SubtractWiden(left, type, right, type, result, resultType); + Assert.Equal(expected, result); + } + // ---------------------------------------------------------------- // Broadcast (column + scalar / column - scalar / scalar - column) // SIMD chunked-path coverage. The broadcast helpers load the scalar