From b54c5caf5b155097c6e29ab16d8cb1ddf99d63d7 Mon Sep 17 00:00:00 2001 From: James Jackson-South Date: Sun, 26 Jul 2026 09:41:53 +1000 Subject: [PATCH] Fix tensor negation on ARM64 --- .../Helpers/TensorPrimitives_.Negate.cs | 52 ++++++++++- .../Common/TensorPrimitivesTests.cs | 91 +++++++++++++++++-- 2 files changed, 134 insertions(+), 9 deletions(-) diff --git a/src/ImageSharp/Common/Helpers/TensorPrimitives_.Negate.cs b/src/ImageSharp/Common/Helpers/TensorPrimitives_.Negate.cs index eb90a3903..99dc80184 100644 --- a/src/ImageSharp/Common/Helpers/TensorPrimitives_.Negate.cs +++ b/src/ImageSharp/Common/Helpers/TensorPrimitives_.Negate.cs @@ -264,12 +264,58 @@ internal static partial class TensorPrimitives_ public static T Invoke(T x) => -x; /// - public static Vector128 Invoke(Vector128 x) => -x; + public static Vector128 Invoke(Vector128 x) + { + if (typeof(T) == typeof(float)) + { + // IEEE-754 negation toggles the sign bit. Expressing that operation explicitly avoids the + // subtraction-based ARM64 code generated by .NET 8 for generic vector negation, which loses + // the sign when +0F is negated and therefore differs from both scalar and runtime-main behavior. + return x ^ Vector128.Create(-0F).As(); + } + + if (typeof(T) == typeof(double)) + { + // Double-precision values use the same sign-bit representation, with the sign in bit 63. + return x ^ Vector128.Create(-0D).As(); + } + + return -x; + } /// - public static Vector256 Invoke(Vector256 x) => -x; + public static Vector256 Invoke(Vector256 x) + { + if (typeof(T) == typeof(float)) + { + // Keep the operation bitwise at every width so ARM64 preserves signed zero exactly. + return x ^ Vector256.Create(-0F).As(); + } + + if (typeof(T) == typeof(double)) + { + return x ^ Vector256.Create(-0D).As(); + } + + return -x; + } /// - public static Vector512 Invoke(Vector512 x) => -x; + public static Vector512 Invoke(Vector512 x) + { + if (typeof(T) == typeof(float)) + { + // Vector512 can be hardware accelerated directly or decomposed by the runtime; the explicit + // bit operation provides identical IEEE-754 behavior in either case. + return x ^ Vector512.Create(-0F).As(); + } + + if (typeof(T) == typeof(double)) + { + return x ^ Vector512.Create(-0D).As(); + } + + return -x; + } } } diff --git a/tests/ImageSharp.Tests/Common/TensorPrimitivesTests.cs b/tests/ImageSharp.Tests/Common/TensorPrimitivesTests.cs index 1fe64893b..5ce5401f8 100644 --- a/tests/ImageSharp.Tests/Common/TensorPrimitivesTests.cs +++ b/tests/ImageSharp.Tests/Common/TensorPrimitivesTests.cs @@ -2,16 +2,14 @@ // Licensed under the Six Labors Split License. using SixLabors.ImageSharp.Common.Helpers; +using SixLabors.ImageSharp.Tests.TestUtilities; namespace SixLabors.ImageSharp.Tests.Common; public class TensorPrimitivesTests { - /// - /// Gets lengths that exercise scalar execution, every SIMD width, overlapping tails, and the unrolled loop. - /// - public static TheoryData SpanLengths => new() - { + private static readonly int[] SpanLengthValues = + [ 0, 1, 3, @@ -33,7 +31,52 @@ public class TensorPrimitivesTests 128, 129, 2048 - }; + ]; + + /// + /// Gets lengths that exercise scalar execution, every SIMD width, overlapping tails, and the unrolled loop. + /// + public static TheoryData SpanLengths => new(SpanLengthValues); + + /// + /// Verifies every compatibility operation while forcing the supported SIMD feature tiers in isolated processes. + /// + [Fact] + public void OperationsMatchScalarFormulasAcrossHardwareIntrinsicFeatures() + => FeatureTestRunner.RunWithHwIntrinsicsFeature( + RunOperationsAcrossHardwareIntrinsicFeatures, + HwIntrinsics.AllowAll + | HwIntrinsics.DisableAVX512F + | HwIntrinsics.DisableAVX + | HwIntrinsics.DisableArm64Sve + | HwIntrinsics.DisableHWIntrinsic); + + /// + /// Runs the TensorPrimitives compatibility assertions inside a process configured for one hardware-intrinsic tier. + /// + private static void RunOperationsAcrossHardwareIntrinsicFeatures() + { + TensorPrimitivesTests tests = new(); + + // Reuse the focused assertions so the remote feature matrix cannot drift from the normal test coverage. + foreach (int length in SpanLengthValues) + { + tests.AddByteMatchesScalarFormula(length); + tests.AddUInt32MatchesScalarFormula(length); + tests.AddScalarInt32MatchesScalarFormula(length); + tests.NegateSingleMatchesScalarFormula(length); + tests.NegateDoubleMatchesScalarFormula(length); + tests.ClampInt32MatchesScalarFormula(length); + tests.ClampSingleMatchesRuntimeFormula(length); + tests.DivideSingleMatchesScalarFormula(length); + tests.MaxSingleMatchesRuntimeFormula(length); + tests.MultiplySingleMatchesScalarFormula(length); + tests.NormalizeMatchesScalarFormula(length); + } + + tests.ClampSinglePreservesRuntimeSpecialValueSemantics(); + tests.ClampDoublePreservesRuntimeSpecialValueSemantics(); + } /// /// Verifies that byte addition wraps modulo 256 and supports either input as the in-place destination. @@ -153,6 +196,42 @@ public class TensorPrimitivesTests AssertSingleBitsEqual(expected, source); } + /// + /// Verifies that double-precision negation preserves the scalar operator's exact bit-level behavior. + /// + /// The input length. + [Theory] + [MemberData(nameof(SpanLengths))] + public void NegateDoubleMatchesScalarFormula(int length) + { + double[] values = + { + double.NaN, + -0D, + 0D, + -1D, + 1D, + double.NegativeInfinity, + double.PositiveInfinity + }; + + double[] source = new double[length]; + double[] expected = new double[length]; + + for (int i = 0; i < source.Length; i++) + { + source[i] = values[i % values.Length]; + expected[i] = -source[i]; + } + + double[] destination = new double[length]; + TensorPrimitives_.Negate(source, destination); + AssertDoubleBitsEqual(expected, destination); + + TensorPrimitives_.Negate(source, source); + AssertDoubleBitsEqual(expected, source); + } + /// /// Verifies that integer clamping produces identical results for separate and in-place destinations. ///