diff --git a/src/Microsoft.ML.FastTree/Dataset/DenseIntArray.cs b/src/Microsoft.ML.FastTree/Dataset/DenseIntArray.cs index 6e3faac9a0..26754346c3 100644 --- a/src/Microsoft.ML.FastTree/Dataset/DenseIntArray.cs +++ b/src/Microsoft.ML.FastTree/Dataset/DenseIntArray.cs @@ -111,6 +111,123 @@ protected static unsafe void SumupCPlusPlusDense(SumupInputData input, FeatureHi } } + /// + /// Managed equivalent of , used on platforms where the + /// native FastTree library is not available (e.g. arm64). This mirrors the native C_Sumup + /// loop in src/Native/FastTreeNative (Sumup.h / SumupNibbles.h) exactly, including the + /// per-document iteration and accumulation order, so histogram results are bit-identical + /// to the native implementation. Reads go through fixed pointers to avoid the per-element + /// bounds checks and interface-indexer dispatch of the generic . + /// + protected static unsafe void SumupManagedDense(SumupInputData input, FeatureHistogram histogram, + byte* data, int numBits) + { + using (Timer.Time(TimerEvent.SumupCppDense)) + { + fixed (FloatType* pSumTargetsByBin = histogram.SumTargetsByBin) + fixed (FloatType* pSampleOutputs = input.Outputs) + fixed (double* pSumWeightsByBin = histogram.SumWeightsByBin) + fixed (double* pSampleWeights = input.Weights) + fixed (int* pIndices = input.DocIndices) + fixed (int* pCountByBin = histogram.CountByBin) + { + int count = input.TotalCount; + ushort* data16 = (ushort*)data; + int* data32 = (int*)data; + + // numBits is switched outside the loop (it never varies within a call) so the + // hot loop stays a tight scalar accumulation matching the native code. The + // "pIndices == null ? i : pIndices[i]" ternary is loop-invariant and free. + if (pSumWeightsByBin != null) + { + switch (numBits) + { + case 4: + for (int i = 0; i < count; i++) + { + int p = pIndices == null ? i : pIndices[i]; + int featureBin = (data[p >> 1] >> ((~(p << 2)) & 4)) & 0xf; + pSumTargetsByBin[featureBin] += pSampleOutputs[i]; + pSumWeightsByBin[featureBin] += pSampleWeights[i]; + ++pCountByBin[featureBin]; + } + break; + case 8: + for (int i = 0; i < count; i++) + { + int featureBin = data[pIndices == null ? i : pIndices[i]]; + pSumTargetsByBin[featureBin] += pSampleOutputs[i]; + pSumWeightsByBin[featureBin] += pSampleWeights[i]; + ++pCountByBin[featureBin]; + } + break; + case 16: + for (int i = 0; i < count; i++) + { + int featureBin = data16[pIndices == null ? i : pIndices[i]]; + pSumTargetsByBin[featureBin] += pSampleOutputs[i]; + pSumWeightsByBin[featureBin] += pSampleWeights[i]; + ++pCountByBin[featureBin]; + } + break; + case 32: + for (int i = 0; i < count; i++) + { + int featureBin = data32[pIndices == null ? i : pIndices[i]]; + pSumTargetsByBin[featureBin] += pSampleOutputs[i]; + pSumWeightsByBin[featureBin] += pSampleWeights[i]; + ++pCountByBin[featureBin]; + } + break; + default: + throw Contracts.Except("Unsupported bits per item {0}", numBits); + } + } + else + { + switch (numBits) + { + case 4: + for (int i = 0; i < count; i++) + { + int p = pIndices == null ? i : pIndices[i]; + int featureBin = (data[p >> 1] >> ((~(p << 2)) & 4)) & 0xf; + pSumTargetsByBin[featureBin] += pSampleOutputs[i]; + ++pCountByBin[featureBin]; + } + break; + case 8: + for (int i = 0; i < count; i++) + { + int featureBin = data[pIndices == null ? i : pIndices[i]]; + pSumTargetsByBin[featureBin] += pSampleOutputs[i]; + ++pCountByBin[featureBin]; + } + break; + case 16: + for (int i = 0; i < count; i++) + { + int featureBin = data16[pIndices == null ? i : pIndices[i]]; + pSumTargetsByBin[featureBin] += pSampleOutputs[i]; + ++pCountByBin[featureBin]; + } + break; + case 32: + for (int i = 0; i < count; i++) + { + int featureBin = data32[pIndices == null ? i : pIndices[i]]; + pSumTargetsByBin[featureBin] += pSampleOutputs[i]; + ++pCountByBin[featureBin]; + } + break; + default: + throw Contracts.Except("Unsupported bits per item {0}", numBits); + } + } + } + } + } + public override IIntArrayForwardIndexer GetIndexer() { return this; @@ -389,21 +506,21 @@ public Dense8BitIntArray(int len) : base(len) { _data = new byte[len]; - SetupSumupHandler(SumupNative, base.Sumup); + SetupSumupHandler(SumupNative, SumupManaged); } public Dense8BitIntArray(byte[] buffer, ref int position) : base(buffer.ToInt(ref position)) { _data = buffer.ToByteArray(ref position); - SetupSumupHandler(SumupNative, base.Sumup); + SetupSumupHandler(SumupNative, SumupManaged); } public Dense8BitIntArray(int len, IEnumerable values) : base(len) { _data = values.Select(i => (byte)i).ToArray(len); - SetupSumupHandler(SumupNative, base.Sumup); + SetupSumupHandler(SumupNative, SumupManaged); } /// @@ -457,6 +574,17 @@ private void SumupNative(SumupInputData input, FeatureHistogram histogram) } } + internal void SumupManaged(SumupInputData input, FeatureHistogram histogram) + { + unsafe + { + fixed (byte* pData = _data) + { + SumupManagedDense(input, histogram, pData, 8); + } + } + } + public override void Sumup(SumupInputData input, FeatureHistogram histogram) => SumupHandler(input, histogram); } @@ -476,14 +604,14 @@ public Dense4BitIntArray(int len) : base(len) { _data = new byte[(len + 1) / 2]; // Even length = half the bytes. Odd length = half the bytes+0.5. - SetupSumupHandler(SumupNative, base.Sumup); + SetupSumupHandler(SumupNative, SumupManaged); } public Dense4BitIntArray(int len, IEnumerable values) : base(len) { _data = new byte[(len + 1) / 2]; - SetupSumupHandler(SumupNative, base.Sumup); + SetupSumupHandler(SumupNative, SumupManaged); int currentIndex = 0; bool upper = true; @@ -508,7 +636,7 @@ public Dense4BitIntArray(byte[] buffer, ref int position) : base(buffer.ToInt(ref position)) { _data = buffer.ToByteArray(ref position); - SetupSumupHandler(SumupNative, base.Sumup); + SetupSumupHandler(SumupNative, SumupManaged); } /// @@ -580,6 +708,17 @@ public void SumupNative(SumupInputData input, FeatureHistogram histogram) } } + internal void SumupManaged(SumupInputData input, FeatureHistogram histogram) + { + unsafe + { + fixed (byte* pData = _data) + { + SumupManagedDense(input, histogram, pData, 4); + } + } + } + public override void Sumup(SumupInputData input, FeatureHistogram histogram) => SumupHandler(input, histogram); } @@ -596,21 +735,21 @@ public Dense16BitIntArray(int len) : base(len) { _data = new ushort[len]; - SetupSumupHandler(SumupNative, base.Sumup); + SetupSumupHandler(SumupNative, SumupManaged); } public Dense16BitIntArray(int len, IEnumerable values) : base(len) { _data = values.Select(i => (ushort)i).ToArray(len); - SetupSumupHandler(SumupNative, base.Sumup); + SetupSumupHandler(SumupNative, SumupManaged); } public Dense16BitIntArray(byte[] buffer, ref int position) : base(buffer.ToInt(ref position)) { _data = buffer.ToUShortArray(ref position); - SetupSumupHandler(SumupNative, base.Sumup); + SetupSumupHandler(SumupNative, SumupManaged); } public override unsafe void Callback(Action callback) @@ -668,6 +807,18 @@ public void SumupNative(SumupInputData input, FeatureHistogram histogram) } } + internal void SumupManaged(SumupInputData input, FeatureHistogram histogram) + { + unsafe + { + fixed (ushort* pData = _data) + { + byte* pDataBytes = (byte*)pData; + SumupManagedDense(input, histogram, pDataBytes, 16); + } + } + } + public override void Sumup(SumupInputData input, FeatureHistogram histogram) => SumupHandler(input, histogram); } @@ -685,21 +836,21 @@ public Dense32BitIntArray(int len) : base(len) { _data = new int[len]; - SetupSumupHandler(SumupNative, base.Sumup); + SetupSumupHandler(SumupNative, SumupManaged); } public Dense32BitIntArray(int len, IEnumerable values) : base(len) { _data = values.ToArray(len); - SetupSumupHandler(SumupNative, base.Sumup); + SetupSumupHandler(SumupNative, SumupManaged); } public Dense32BitIntArray(byte[] buffer, ref int position) : base(buffer.ToInt(ref position)) { _data = buffer.ToIntArray(ref position); - SetupSumupHandler(SumupNative, base.Sumup); + SetupSumupHandler(SumupNative, SumupManaged); } public override unsafe void Callback(Action callback) @@ -757,6 +908,18 @@ public void SumupNative(SumupInputData input, FeatureHistogram histogram) } } + internal void SumupManaged(SumupInputData input, FeatureHistogram histogram) + { + unsafe + { + fixed (int* pData = _data) + { + byte* pDataBytes = (byte*)pData; + SumupManagedDense(input, histogram, pDataBytes, 32); + } + } + } + public override void Sumup(SumupInputData input, FeatureHistogram histogram) => SumupHandler(input, histogram); } } diff --git a/src/Microsoft.ML.FastTree/Dataset/SegmentIntArray.cs b/src/Microsoft.ML.FastTree/Dataset/SegmentIntArray.cs index eb70897fed..2515834900 100644 --- a/src/Microsoft.ML.FastTree/Dataset/SegmentIntArray.cs +++ b/src/Microsoft.ML.FastTree/Dataset/SegmentIntArray.cs @@ -73,7 +73,7 @@ public SegmentIntArray(int length, IEnumerable values) { using (Timer.Time(TimerEvent.SparseConstruction)) { - SetupSumupHandler(SumupCPlusPlus, base.Sumup); + SetupSumupHandler(SumupCPlusPlus, SumupManaged); uint[] vals = new uint[length]; uint pos = 0; @@ -576,6 +576,106 @@ public unsafe void SumupCPlusPlus(SumupInputData input, FeatureHistogram histogr } } } + + /// + /// Managed equivalent of , used on platforms where the native + /// FastTree library is not available (e.g. arm64). This mirrors the native SumupSegment / + /// SumupSegment_noindices templates in src/Native/FastTreeNative/SumupSegment.h exactly, + /// including the segment bit-unpacking and accumulation order, so histogram results are + /// bit-identical to the native implementation. Reads go through fixed pointers to avoid the + /// per-element bounds checks and interface-indexer dispatch of the generic + /// fallback. + /// + public unsafe void SumupManaged(SumupInputData input, FeatureHistogram histogram) + { + // Note: timing is handled by the public Sumup override which wraps SumupHandler in + // Timer.Time(TimerEvent.SumupSegment); do not add a nested timer here or it double-counts. + fixed (FloatType* pSumTargetsByBin = histogram.SumTargetsByBin) + fixed (FloatType* pSampleOutputs = input.Outputs) + fixed (double* pSumWeightsByBin = histogram.SumWeightsByBin) + fixed (double* pSampleOutputWeights = input.Weights) + fixed (uint* pDataFixed = _data) + fixed (byte* pSegTypeFixed = _segType) + fixed (int* pSegLengthFixed = _segLength) + fixed (int* pIndicesFixed = input.DocIndices) + fixed (int* pCountByBin = histogram.CountByBin) + { + int count = input.TotalCount; + + if (pIndicesFixed == null) + { + // Sequential (root) case: SumupSegment_noindices. + uint* pData = pDataFixed; + byte* pSegType = pSegTypeFixed; + int* pSegLength = pSegLengthFixed; + + ulong workingBits = pData[0] | ((ulong)pData[1] << 32); + int bitsOffset = 0; + pData += 2; + + int i = 0; + while (i < count) + { + int segEnd = *(pSegLength++); + int segType = *(pSegType++); + uint mask = (uint)(~((-1) << segType)); + + while (segEnd-- > 0) + { + int featureBin = (int)((workingBits >> bitsOffset) & mask); + pSumTargetsByBin[featureBin] += pSampleOutputs[i]; + if (pSumWeightsByBin != null) + pSumWeightsByBin[featureBin] += pSampleOutputWeights[i]; + ++pCountByBin[featureBin]; + ++i; + bitsOffset += segType; + if (bitsOffset >= 32) + { + workingBits = (workingBits >> 32) | ((ulong)*(pData++) << 32); + bitsOffset &= 31; + } + } + } + } + else + { + // Leaf case with document indices: SumupSegment. + uint* pData = pDataFixed; + byte* pSegType = pSegTypeFixed; + int* pSegLength = pSegLengthFixed; + int* pIndices = pIndicesFixed; + + long globalBitOffset = 0; + int currIndex = 0; + int segEnd = *(pSegLength++); + int nextIndex = segEnd; + int segType = *(pSegType++); + uint mask = (uint)(~((-1) << segType)); + + for (int i = 0; i < count; i++) + { + int index = *(pIndices++); + while (index >= nextIndex) + { + globalBitOffset += (long)segEnd * segType; + currIndex = nextIndex; + segEnd = *(pSegLength++); + nextIndex += segEnd; + segType = *(pSegType++); + mask = (uint)(~((-1) << segType)); + } + long bitOffset = globalBitOffset + (long)(index - currIndex) * segType; + int major = (int)(bitOffset >> 5); + int minor = (int)(bitOffset & 0x1f); + int featureBin = (int)(((((ulong)pData[major]) >> minor) | (((ulong)pData[major + 1]) << (32 - minor))) & mask); + pSumTargetsByBin[featureBin] += pSampleOutputs[i]; + if (pSumWeightsByBin != null) + pSumWeightsByBin[featureBin] += pSampleOutputWeights[i]; + ++pCountByBin[featureBin]; + } + } + } + } public static void ManagedSegmentFindOptimalPath(uint[] array, int len, int bitsNeeded, out long bits, out int transitions) { uint max; diff --git a/test/Microsoft.ML.Tests/FastTreeSumupParityTests.cs b/test/Microsoft.ML.Tests/FastTreeSumupParityTests.cs new file mode 100644 index 0000000000..606960c709 --- /dev/null +++ b/test/Microsoft.ML.Tests/FastTreeSumupParityTests.cs @@ -0,0 +1,230 @@ +// Licensed to the .NET Foundation under one or more agreements. +// The .NET Foundation licenses this file to you under the MIT license. +// See the LICENSE file in the project root for more information. + +using System; +using System.Collections.Generic; +using Microsoft.ML.TestFramework; +using Microsoft.ML.TestFramework.Attributes; +using Microsoft.ML.Trainers.FastTree; +using Xunit; +using Xunit.Abstractions; + +namespace Microsoft.ML.Tests +{ + /// + /// Verifies the optimized managed Sumup implementations added for platforms without the + /// native FastTree library (e.g. arm64). Two properties are checked for Dense 4/8/16/32-bit and + /// Segment arrays, in both the root (no doc indices) and leaf (with doc indices) cases: + /// + /// the managed histogram matches an independent brute-force reference (runs everywhere), and + /// the managed histogram is bit-identical to the native histogram (runs where FastTreeNative exists, + /// i.e. x64 CI legs), giving the "native and managed side by side" coverage requested in the PR review. + /// + /// + public sealed class FastTreeSumupParityTests : BaseTestClass + { + private const int Length = 2000; + + public FastTreeSumupParityTests(ITestOutputHelper output) : base(output) + { + } + + // kind, useWeights, useIndices (leaf case). + public static IEnumerable Cases() + { + foreach (var kind in new[] { "Dense4", "Dense8", "Dense16", "Dense32", "Segment" }) + foreach (var useWeights in new[] { false, true }) + foreach (var useIndices in new[] { false, true }) + yield return new object[] { kind, useWeights, useIndices }; + } + + [Theory] + [MemberData(nameof(Cases))] + public void ManagedSumupMatchesReference(string kind, bool useWeights, bool useIndices) + { + var arr = CreateIntArray(kind, seed: 1, out int numBins); + var input = CreateInput(seed: 2, useWeights, useIndices, out double[] outputs, out double[] weights, out int[] docIndices, out int count); + + var managed = new FeatureHistogram(arr, numBins, useWeights); + CallManaged(arr, input, managed); + + ComputeReference(arr, numBins, outputs, weights, docIndices, count, + out double[] refTargets, out double[] refWeights, out int[] refCounts); + + AssertHistogramEqual(refCounts, refTargets, refWeights, managed, useWeights); + } + + [NativeDependencyTheory("FastTreeNative")] + [MemberData(nameof(Cases))] + public void ManagedSumupMatchesNative(string kind, bool useWeights, bool useIndices) + { + // This attribute guarantees the native FastTree library is available, so the native + // handlers below run and are compared against the managed handler. + Assert.True(IntArray.UseFastTreeNative); + + var arr = CreateIntArray(kind, seed: 1, out int numBins); + var input = CreateInput(seed: 2, useWeights, useIndices, out double[] outputs, out double[] weights, out int[] docIndices, out int count); + + var native = new FeatureHistogram(arr, numBins, useWeights); + CallNative(arr, input, native); + + var managed = new FeatureHistogram(arr, numBins, useWeights); + CallManaged(arr, input, managed); + + // Managed must match native exactly, and both must match the independent reference so a + // shared decode mistake can't hide behind an equal-but-wrong comparison. + ComputeReference(arr, numBins, outputs, weights, docIndices, count, + out double[] refTargets, out double[] refWeights, out int[] refCounts); + AssertHistogramEqual(refCounts, refTargets, refWeights, native, useWeights); + AssertHistogramEqual(native.CountByBin, native.SumTargetsByBin, native.SumWeightsByBin, managed, useWeights); + } + + private static IntArray CreateIntArray(string kind, int seed, out int numBins) + { + IntArrayBits bits; + switch (kind) + { + case "Dense4": bits = IntArrayBits.Bits4; numBins = 16; break; + case "Dense8": bits = IntArrayBits.Bits8; numBins = 256; break; + case "Dense16": bits = IntArrayBits.Bits16; numBins = 2048; break; + case "Dense32": bits = IntArrayBits.Bits32; numBins = 5000; break; + case "Segment": bits = IntArrayBits.Bits8; numBins = 64; break; + default: throw new ArgumentOutOfRangeException(nameof(kind), kind, null); + } + + var rand = new Random(seed); + var values = new int[Length]; + for (int i = 0; i < Length; i++) + values[i] = rand.Next(numBins); + + if (kind == "Segment") + return CreateManagedSegment(values); + + return IntArray.New(Length, IntArrayType.Dense, bits, values); + } + + // Builds a SegmentIntArray using the managed segment encoder explicitly. The public + // IntArray.New(..., Segmented, ...) path would pick the native encoder on x64, and the native + // C_SegmentFindOptimalPath declares its buffer as `unsigned long*`, which is 64-bit on LP64 + // (Linux/macOS x64) while the managed array is 32-bit — a pre-existing native buffer overrun + // that is unrelated to the Sumup decoders under test here. Encoding managed-side avoids it while + // still producing an array that both the managed and native Sumup decoders read identically. + private static SegmentIntArray CreateManagedSegment(int[] values) + { + var work = new uint[values.Length]; + uint max = 0; + for (int i = 0; i < values.Length; i++) + { + work[i] = (uint)values[i]; + if (work[i] > max) + max = work[i]; + } + int maxBits = SegmentIntArray.BitsForValue(max); + SegmentIntArray.ManagedSegmentFindOptimalPath(work, work.Length, maxBits, out long bits, out int transitions); + return SegmentIntArray.FromWorkArray(work, work.Length, bits, transitions); + } + + private static SumupInputData CreateInput(int seed, bool useWeights, bool useIndices, + out double[] outputs, out double[] weights, out int[] docIndices, out int count) + { + var rand = new Random(seed); + + outputs = new double[Length]; + for (int i = 0; i < Length; i++) + outputs[i] = rand.NextDouble() * 2 - 1; + + weights = null; + if (useWeights) + { + weights = new double[Length]; + for (int i = 0; i < Length; i++) + weights[i] = rand.NextDouble(); + } + + docIndices = null; + if (useIndices) + { + // Leaf case: a strictly increasing subset of document indices, as required by the + // segment decoder (it walks segments forward assuming ascending indices). + var list = new List(); + for (int i = 0; i < Length; i++) + { + if (rand.Next(2) == 0) + list.Add(i); + } + docIndices = list.ToArray(); + } + + count = useIndices ? docIndices.Length : Length; + + double sumTargets = 0; + double sumWeights = 0; + for (int i = 0; i < count; i++) + { + sumTargets += outputs[i]; + if (useWeights) + sumWeights += weights[i]; + } + + return new SumupInputData(count, sumTargets, sumWeights, outputs, weights, docIndices); + } + + private static void ComputeReference(IntArray arr, int numBins, double[] outputs, double[] weights, + int[] docIndices, int count, out double[] sumTargets, out double[] sumWeights, out int[] counts) + { + sumTargets = new double[numBins]; + sumWeights = weights == null ? null : new double[numBins]; + counts = new int[numBins]; + + var indexer = arr.GetIndexer(); + for (int i = 0; i < count; i++) + { + int doc = docIndices == null ? i : docIndices[i]; + int bin = indexer[doc]; + sumTargets[bin] += outputs[i]; + if (sumWeights != null) + sumWeights[bin] += weights[i]; + counts[bin]++; + } + } + + private static void CallManaged(IntArray arr, SumupInputData input, FeatureHistogram histogram) + { + switch (arr) + { + case Dense4BitIntArray a: a.SumupManaged(input, histogram); break; + case Dense8BitIntArray a: a.SumupManaged(input, histogram); break; + case Dense16BitIntArray a: a.SumupManaged(input, histogram); break; + case Dense32BitIntArray a: a.SumupManaged(input, histogram); break; + case SegmentIntArray a: a.SumupManaged(input, histogram); break; + default: throw new InvalidOperationException($"Unexpected IntArray type {arr.GetType().Name}"); + } + } + + private static void CallNative(IntArray arr, SumupInputData input, FeatureHistogram histogram) + { + // For SegmentIntArray we call the native decoder (SumupCPlusPlus) directly rather than via + // arr.Sumup: this array is built with the managed encoder through FromWorkArray, whose + // constructor does not wire up SumupHandler, so arr.Sumup would NullReference. Dense arrays + // set up their handler in their constructor, so arr.Sumup dispatches to the native handler. + if (arr is SegmentIntArray seg) + seg.SumupCPlusPlus(input, histogram); + else + arr.Sumup(input, histogram); + } + + private static void AssertHistogramEqual(int[] expectedCounts, double[] expectedTargets, double[] expectedWeights, + FeatureHistogram actual, bool useWeights) + { + for (int bin = 0; bin < expectedCounts.Length; bin++) + { + Assert.Equal(expectedCounts[bin], actual.CountByBin[bin]); + // Accumulation order is mirrored between the implementations, so the sums are bit-identical. + Assert.Equal(expectedTargets[bin], actual.SumTargetsByBin[bin]); + if (useWeights) + Assert.Equal(expectedWeights[bin], actual.SumWeightsByBin[bin]); + } + } + } +}