Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
37 changes: 37 additions & 0 deletions src/Clast.DatabaseDecimal/Arithmetic/AddKernel.cs
Original file line number Diff line number Diff line change
Expand Up @@ -95,6 +95,31 @@ public static Decimal128 Subtract(Decimal128 left, DecimalType leftType, Decimal
return new Decimal128(DecimalRange.Enforce(checked(l - r), resultType, overflow));
}

/// <summary>
/// Subtract two 32-bit values, widening to 64-bit result.
/// Used when the result precision exceeds 9 digits.
/// </summary>
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));
}

/// <summary>
/// Subtract two 64-bit values, widening to 128-bit result.
/// </summary>
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,
Expand Down Expand Up @@ -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));
}

/// <summary>
/// Subtract two 128-bit values, widening to 256-bit result.
/// </summary>
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));
}
}
128 changes: 128 additions & 0 deletions src/Clast.DatabaseDecimal/Arithmetic/SpanAddKernel.cs
Original file line number Diff line number Diff line change
Expand Up @@ -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.
/// </summary>
/// <remarks>
/// 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
/// <see cref="System.Runtime.InteropServices.MemoryMarshal"/> reinterpretation
/// of one buffer as both element types, since the spans are differently typed.
/// </remarks>
public static class SpanAddKernel
{
// ================================================================
Expand Down Expand Up @@ -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<int> left, DecimalType leftType,
ReadOnlySpan<int> right, DecimalType rightType,
Span<long> result, DecimalType resultType,
Comment on lines +462 to +465

Copy link
Copy Markdown
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Checked this one carefully, and I do not think it is a defect in this PR — but it did catch an imprecise doc line, which I have now fixed (b8c8e0e).

Two reasons the code is not wrong:

  1. Not introduced here. The three SubtractWiden overloads are structurally identical to the AddWiden overloads directly above them — same shape, same loop, sign flipped. If widening broke the overlap guarantee, it broke it in 0.3.0 when AddWiden shipped. This PR mirrors that behaviour rather than changing it.

  2. The overlap it describes is not reachable by ordinary use. The result span is a different element type from the inputs (Span<long> vs ReadOnlySpan<int>), so the two cannot alias without a deliberate MemoryMarshal.Cast of one buffer into both types. There is no result === left call a caller can write here the way they can for the same-width overloads.

The deeper point is that a widening operation has no in-place formulation at all: each result element is twice the width of its inputs, so an in-place widening write has nowhere to put the second half. Staging inputs would not rescue an aliased call — it would just make a physically impossible operation silently produce something.

So the fix is on the documentation side, not the implementation: the class summary asserted the overlap guarantee unconditionally, which over-promised for every widening overload including the pre-existing AddWiden ones. It now scopes the guarantee to the same-width overloads and explains why widening is excluded.

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<long> left, DecimalType leftType,
ReadOnlySpan<long> right, DecimalType rightType,
Span<Int128> 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<Int128> left, DecimalType leftType,
ReadOnlySpan<Int128> right, DecimalType rightType,
Span<Int256> 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)
// ================================================================
Expand Down Expand Up @@ -915,6 +998,51 @@ private static bool AddWidenSameScale32To64(ReadOnlySpan<int> left, ReadOnlySpan
return outOfRangeSeen;
}

private static bool SubtractWidenSameScale32To64(ReadOnlySpan<int> left, ReadOnlySpan<int> right, Span<long> result, long lower, long upper)
{
int i = 0;
bool outOfRangeSeen = false;
#if NET5_0_OR_GREATER
if (Vector.IsHardwareAccelerated && left.Length >= Vector<int>.Count)
{
ReadOnlySpan<Vector<int>> lv = MemoryMarshal.Cast<int, Vector<int>>(left);
ReadOnlySpan<Vector<int>> rv = MemoryMarshal.Cast<int, Vector<int>>(right);
Span<Vector<long>> ov = MemoryMarshal.Cast<long, Vector<long>>(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<long> outOfRange = Vector<long>.Zero;
Vector<long> loVec = new Vector<long>(lower);
Vector<long> hiVec = new Vector<long>(upper);
for (int k = 0; k < chunks; k++)
{
Vector<int> a = lv[k];
Vector<int> b = rv[k];
Vector.Widen(a, out Vector<long> aLo, out Vector<long> aHi);
Vector.Widen(b, out Vector<long> bLo, out Vector<long> bHi);
Vector<long> low = aLo - bLo;
Vector<long> 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<long>.Zero;
i = chunks * Vector<int>.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<int> left, int right, Span<int> result, int lower, int upper)
{
int i = 0;
Expand Down
80 changes: 80 additions & 0 deletions tests/Clast.DatabaseDecimal.Tests/ArithmeticTests.cs
Original file line number Diff line number Diff line change
Expand Up @@ -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<OverflowException>(() => 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]
Expand Down
22 changes: 22 additions & 0 deletions tests/Clast.DatabaseDecimal.Tests/Decimal256Tests.cs
Original file line number Diff line number Diff line change
Expand Up @@ -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()
{
Expand Down
33 changes: 33 additions & 0 deletions tests/Clast.DatabaseDecimal.Tests/FusedRangeCheckTests.cs
Original file line number Diff line number Diff line change
Expand Up @@ -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<OverflowException>(() =>
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)]
Expand Down
Loading