From b3b30f69a00bef4d5711cb76ca8f29278a61f699 Mon Sep 17 00:00:00 2001 From: Uddoo <19704816+Uddoo@users.noreply.github.com> Date: Mon, 19 Oct 2026 20:22:45 +0800 Subject: [PATCH 1/3] bench: add array equality and decoding baselines for #142 --- HdrHistogram.Benchmarking/Program.cs | 2 + .../Utilities/ArrayEqualityBenchmark.cs | 32 +++++++++++++ .../ArrayEqualityDecodingBenchmark.cs | 46 +++++++++++++++++++ 3 files changed, 80 insertions(+) create mode 100644 HdrHistogram.Benchmarking/Utilities/ArrayEqualityBenchmark.cs create mode 100644 HdrHistogram.Benchmarking/Utilities/ArrayEqualityDecodingBenchmark.cs diff --git a/HdrHistogram.Benchmarking/Program.cs b/HdrHistogram.Benchmarking/Program.cs index 881c88e..1b817e6 100644 --- a/HdrHistogram.Benchmarking/Program.cs +++ b/HdrHistogram.Benchmarking/Program.cs @@ -24,6 +24,8 @@ static void Main(string[] args) typeof(LeadingZeroCount.LeadingZeroCount64BitBenchmark), typeof(LeadingZeroCount.LeadingZeroCount32BitBenchmark), typeof(Recording.Recording32BitBenchmark), + typeof(Utilities.ArrayEqualityBenchmark), + typeof(Utilities.ArrayEqualityDecodingBenchmark), }); switcher.Run(args, config); } diff --git a/HdrHistogram.Benchmarking/Utilities/ArrayEqualityBenchmark.cs b/HdrHistogram.Benchmarking/Utilities/ArrayEqualityBenchmark.cs new file mode 100644 index 0000000..142bd64 --- /dev/null +++ b/HdrHistogram.Benchmarking/Utilities/ArrayEqualityBenchmark.cs @@ -0,0 +1,32 @@ +/* + * This is a .NET port of the original Java version, which was written by + * Gil Tene as described in + * https://github.com/HdrHistogram/HdrHistogram + * and released to the public domain, as explained at + * http://creativecommons.org/publicdomain/zero/1.0/ + */ +using BenchmarkDotNet.Attributes; +using HdrHistogram.Utilities; + +namespace HdrHistogram.Benchmarking.Utilities +{ + /// + /// Measures equality checks over separate, equal long arrays. + /// + [MemoryDiagnoser] + public class ArrayEqualityBenchmark + { + private readonly long[] _source = new long[23552]; + private readonly long[] _other = new long[23552]; + + /// + /// Compares approximately 184 KiB of long values. + /// + /// Whether all elements are equal. + [Benchmark] + public bool EqualLongArrays() + { + return _source.IsSequenceEqual(_other); + } + } +} diff --git a/HdrHistogram.Benchmarking/Utilities/ArrayEqualityDecodingBenchmark.cs b/HdrHistogram.Benchmarking/Utilities/ArrayEqualityDecodingBenchmark.cs new file mode 100644 index 0000000..cf75124 --- /dev/null +++ b/HdrHistogram.Benchmarking/Utilities/ArrayEqualityDecodingBenchmark.cs @@ -0,0 +1,46 @@ +/* + * This is a .NET port of the original Java version, which was written by + * Gil Tene as described in + * https://github.com/HdrHistogram/HdrHistogram + * and released to the public domain, as explained at + * http://creativecommons.org/publicdomain/zero/1.0/ + */ +using BenchmarkDotNet.Attributes; +using HdrHistogram.Encoding; +using HdrHistogram.Utilities; + +namespace HdrHistogram.Benchmarking.Utilities +{ + /// + /// Measures histogram decoding, whose constructor lookup compares Type arrays. + /// + [MemoryDiagnoser] + public class ArrayEqualityDecodingBenchmark + { + private ByteBuffer _buffer = null!; + + /// + /// Encodes a histogram outside the measured decoding operation. + /// + [GlobalSetup] + public void Setup() + { + var histogram = new LongHistogram(3600000000, 3); + histogram.RecordValue(1000); + histogram.RecordValue(1000000); + _buffer = ByteBuffer.Allocate(histogram.GetNeededByteBufferCapacity()); + histogram.Encode(_buffer, HistogramEncoderV2.Instance); + } + + /// + /// Decodes a histogram through the public API. + /// + /// The decoded histogram. + [Benchmark] + public HistogramBase DecodeHistogram() + { + _buffer.Position = 0; + return HistogramEncoding.DecodeFromByteBuffer(_buffer, 0); + } + } +} From d19f3605c57cefc1719feaaa654705a0b82b205f Mon Sep 17 00:00:00 2001 From: Uddoo <19704816+Uddoo@users.noreply.github.com> Date: Mon, 19 Oct 2026 20:32:16 +0800 Subject: [PATCH 2/3] fix: avoid primitive array comparison boxing without changing custom equality --- .../Utilities/ArrayExtensionsTests.cs | 275 ++++++++++++++++++ HdrHistogram/Utilities/ArrayExtensions.cs | 7 + plan/benchmarks/baseline.md | 24 ++ 3 files changed, 306 insertions(+) create mode 100644 HdrHistogram.UnitTests/Utilities/ArrayExtensionsTests.cs create mode 100644 plan/benchmarks/baseline.md diff --git a/HdrHistogram.UnitTests/Utilities/ArrayExtensionsTests.cs b/HdrHistogram.UnitTests/Utilities/ArrayExtensionsTests.cs new file mode 100644 index 0000000..56573d4 --- /dev/null +++ b/HdrHistogram.UnitTests/Utilities/ArrayExtensionsTests.cs @@ -0,0 +1,275 @@ +/* + * This is a .NET port of the original Java version, which was written by + * Gil Tene as described in + * https://github.com/HdrHistogram/HdrHistogram + * and released to the public domain, as explained at + * http://creativecommons.org/publicdomain/zero/1.0/ + */ +using System; +using System.Runtime.CompilerServices; +using FluentAssertions; +using HdrHistogram.Utilities; +using Xunit; + +namespace HdrHistogram.UnitTests.Utilities +{ + public class ArrayExtensionsTests + { + [Theory] + [InlineData(0)] + [InlineData(1)] + [InlineData(3)] + [InlineData(31)] + [InlineData(32)] + [InlineData(33)] + [InlineData(23552)] + public void IsSequenceEqual_returns_true_for_equal_long_arrays(int length) + { + var source = new long[length]; + for (int i = 0; i < length; i++) + { + source[i] = long.MaxValue - i; + } + var other = (long[])source.Clone(); + + source.IsSequenceEqual(other).Should().BeTrue(); + } + + [Theory] + [InlineData(0)] + [InlineData(16)] + [InlineData(32)] + public void IsSequenceEqual_returns_false_for_different_long_elements(int index) + { + var source = new long[33]; + var other = new long[33]; + other[index] = long.MinValue; + + source.IsSequenceEqual(other).Should().BeFalse(); + } + + [Fact] + public void IsSequenceEqual_returns_false_for_different_lengths() + { + new long[] { 1 }.IsSequenceEqual(new long[] { 1, 2 }).Should().BeFalse(); + } + + [Fact] + public void IsSequenceEqual_returns_false_for_different_order() + { + new long[] { 1, 2 }.IsSequenceEqual(new long[] { 2, 1 }).Should().BeFalse(); + } + + [Fact] + public void IsSequenceEqual_compares_null_elements_in_reference_arrays() + { + string?[] source = { "first", null, "last" }; + string?[] other = { "first", null, "last" }; + + source.IsSequenceEqual(other).Should().BeTrue(); + other[1] = "different"; + source.IsSequenceEqual(other).Should().BeFalse(); + } + + [Fact] + public void IsSequenceEqual_compares_nullable_value_elements() + { + long?[] source = { long.MinValue, null, long.MaxValue }; + long?[] other = { long.MinValue, null, long.MaxValue }; + + source.IsSequenceEqual(other).Should().BeTrue(); + other[1] = 0; + source.IsSequenceEqual(other).Should().BeFalse(); + } + + [Fact] + public void IsSequenceEqual_compares_types_used_by_constructor_lookup() + { + Type[] source = { typeof(long), typeof(long), typeof(int) }; + Type[] other = { typeof(long), typeof(long), typeof(int) }; + + source.IsSequenceEqual(other).Should().BeTrue(); + other[2] = typeof(long); + source.IsSequenceEqual(other).Should().BeFalse(); + } + + [Fact] + public void IsSequenceEqual_compares_types_without_IEquatable() + { + ObjectEqualityValue[] source = { new ObjectEqualityValue(1) }; + ObjectEqualityValue[] other = { new ObjectEqualityValue(1) }; + + source.IsSequenceEqual(other).Should().BeTrue(); + } + + [Fact] + public void IsSequenceEqual_preserves_object_equality_for_equatable_classes() + { + EquatableReference[] source = { new EquatableReference() }; + EquatableReference[] other = { new EquatableReference() }; + + source.IsSequenceEqual(other).Should().BeFalse(); + } + + [Fact] + public void IsSequenceEqual_preserves_object_equality_for_equatable_structs() + { + EquatableValue[] source = { new EquatableValue(1, 2) }; + EquatableValue[] other = { new EquatableValue(1, 3) }; + + source.IsSequenceEqual(other).Should().BeFalse(); + } + + [Fact] + public void IsSequenceEqual_preserves_object_equality_for_nullable_structs() + { + EquatableValue?[] source = { null, new EquatableValue(1, 2) }; + EquatableValue?[] other = { null, new EquatableValue(1, 3) }; + + source.IsSequenceEqual(other).Should().BeFalse(); + } + + [Theory] + [InlineData(0)] + [InlineData(1)] + public void IsSequenceEqual_accepts_covariant_reference_arrays(int length) + { + object[] source = new string[length]; + object[] other = new string[length]; + if (length > 0) + { + source[0] = "same"; + other[0] = "same"; + } + + source.IsSequenceEqual(other).Should().BeTrue(); + } + + [Fact] + public void IsSequenceEqual_compares_floating_point_values_using_equality() + { + double[] source = { double.NaN, -0.0 }; + double[] other = { BitConverter.Int64BitsToDouble(unchecked((long)0xfff8000000000001)), 0.0 }; + float[] floatSource = { float.NaN, -0.0f }; + float[] floatOther = { float.NaN, 0.0f }; + + source.IsSequenceEqual(other).Should().BeTrue(); + floatSource.IsSequenceEqual(floatOther).Should().BeTrue(); + } + + [Fact] + public void IsSequenceEqual_rejects_null_source() + { + long[] source = null!; + Action compare = () => source.IsSequenceEqual(Array.Empty()); + + compare.Should().Throw(); + } + + [Fact] + public void IsSequenceEqual_rejects_null_other() + { + long[] other = null!; + Action compare = () => Array.Empty().IsSequenceEqual(other); + + compare.Should().Throw(); + } + + [Fact] + public void IsSequenceEqual_rejects_two_null_arrays() + { + long[] source = null!; + long[] other = null!; + Action compare = () => source.IsSequenceEqual(other); + + compare.Should().Throw(); + } + + [Fact] + public void IsSequenceEqual_does_not_allocate_for_long_elements() + { + var source = new long[1024]; + var other = new long[1024]; + bool equal = true; + for (int i = 0; i < 16; i++) + { + equal &= source.IsSequenceEqual(other); + } + + long before = GC.GetAllocatedBytesForCurrentThread(); + for (int i = 0; i < 16; i++) + { + equal &= source.IsSequenceEqual(other); + } + long allocated = GC.GetAllocatedBytesForCurrentThread() - before; + + equal.Should().BeTrue(); + allocated.Should().Be(0); + } + + private sealed class ObjectEqualityValue + { + private readonly int _value; + + public ObjectEqualityValue(int value) + { + _value = value; + } + + public override bool Equals(object? obj) + { + return obj is ObjectEqualityValue other && _value == other._value; + } + + public override int GetHashCode() + { + return _value; + } + } + + private sealed class EquatableReference : IEquatable + { + public bool Equals(EquatableReference? other) + { + return other is not null; + } + + public override bool Equals(object? obj) + { + return ReferenceEquals(this, obj); + } + + public override int GetHashCode() + { + return RuntimeHelpers.GetHashCode(this); + } + } + + private readonly struct EquatableValue : IEquatable + { + private readonly int _key; + private readonly int _detail; + + public EquatableValue(int key, int detail) + { + _key = key; + _detail = detail; + } + + public bool Equals(EquatableValue other) + { + return _key == other._key; + } + + public override bool Equals(object? obj) + { + return obj is EquatableValue other && _key == other._key && _detail == other._detail; + } + + public override int GetHashCode() + { + return HashCode.Combine(_key, _detail); + } + } + } +} diff --git a/HdrHistogram/Utilities/ArrayExtensions.cs b/HdrHistogram/Utilities/ArrayExtensions.cs index a5a9bc1..fb17658 100644 --- a/HdrHistogram/Utilities/ArrayExtensions.cs +++ b/HdrHistogram/Utilities/ArrayExtensions.cs @@ -1,3 +1,5 @@ +using System; + namespace HdrHistogram.Utilities { /// @@ -17,6 +19,11 @@ public static bool IsSequenceEqual(this T[] source, T[] other) if (source.Length != other.Length) return false; + // Primitive equality agrees with object.Equals; preserve custom equality + // and covariant reference arrays by keeping their existing comparison path. + if (typeof(T).IsPrimitive) + return source.AsSpan().SequenceEqual(other.AsSpan(), comparer: null); + for (int i = 0; i < other.Length; i++) { if (!Equals(source[i], other[i])) diff --git a/plan/benchmarks/baseline.md b/plan/benchmarks/baseline.md new file mode 100644 index 0000000..6991de8 --- /dev/null +++ b/plan/benchmarks/baseline.md @@ -0,0 +1,24 @@ +# Array equality baseline + +Base library: `023f13c77134b6e15626f6eb8325a8b365f4d62e`. +Benchmark-only commit: `b3b30f6`. +The implementation is unchanged. + +BenchmarkDotNet 0.15.8, default job settings, .NET 8.0.31 x64, SDK 10.0.401, Windows 11, Intel Core i9-13900K. +The same two benchmark classes and command are used before and after the change. +A temporary runner calls `BenchmarkSwitcher.FromTypes(new[] { typeof(ArrayEqualityBenchmark), typeof(ArrayEqualityDecodingBenchmark) }).Run(args)` with the default configuration. +This selects .NET 8 alone without the repository entry point adding its three explicit runtime jobs. + +```text +dotnet run -c Release --project /Runner.csproj -- --filter "*ArrayEquality*" --runtimes net8.0 --exporters json --artifacts +``` + +| Method | Mean (ns) | StdDev (ns) | Allocated (bytes/op) | Operations/s | +| --- | ---: | ---: | ---: | ---: | +| EqualLongArrays | 101983.71 | 5050.88 | 1130496 | 9805.49 | +| DecodeHistogram | 28356.44 | 764.77 | 189276 | 35265.36 | + +The microbenchmark compares two separate 23,552-element long arrays (184 KiB each). +The end-to-end benchmark decodes an encoded histogram through the public API and its constructor lookup. +That lookup compares `Type[]`, so it does not box primitive values. +Do not infer a histogram decoding speedup from the long-array microbenchmark. From 050803226c6d609ca8c357a4c25c43c57846b526 Mon Sep 17 00:00:00 2001 From: Uddoo <19704816+Uddoo@users.noreply.github.com> Date: Mon, 19 Oct 2026 20:34:04 +0800 Subject: [PATCH 3/3] docs: record array equality benchmark comparison --- plan/benchmarks/comparison.md | 30 ++++++++++++++++++++++++++++++ plan/benchmarks/post-change.md | 13 +++++++++++++ 2 files changed, 43 insertions(+) create mode 100644 plan/benchmarks/comparison.md create mode 100644 plan/benchmarks/post-change.md diff --git a/plan/benchmarks/comparison.md b/plan/benchmarks/comparison.md new file mode 100644 index 0000000..f173813 --- /dev/null +++ b/plan/benchmarks/comparison.md @@ -0,0 +1,30 @@ +# Array equality comparison + +Both runs use .NET 8.0.31 on the same machine with default BenchmarkDotNet settings. +Baseline and implementation revisions are recorded in the companion results files. + +| Method | Baseline mean (ns) | After mean (ns) | Delta (ns) | Delta (%) | Baseline bytes/op | After bytes/op | +| --- | ---: | ---: | ---: | ---: | ---: | ---: | +| EqualLongArrays | 101983.71 | 3457.48 | -98526.23 | -96.61% | 1130496 | 0 | +| DecodeHistogram | 28356.44 | 27770.42 | -586.02 | -2.07% | 189276 | 189276 | + +## Interpretation + +The primitive long-array comparison eliminates per-element boxing and its measured allocations. +Custom value/reference types retain the original `object.Equals` comparison. +This preserves typed versus object equality differences, nullable values and covariant reference arrays. +Floating-point comparisons retain equality semantics for NaN and signed zero. + +The existing histogram decoding caller compares `Type[]`, which does not box elements and uses the unchanged fallback. +Its allocations are unchanged and its timing delta is within the spread of these measurements. +No end-to-end histogram decoding improvement is claimed. +No benchmark showed a mean regression in this run. +Benchmarks were measured only on .NET 8; .NET 9 and .NET 10 are covered by correctness tests, not performance claims. + +## Regression evidence + +Before the implementation change, 25 compatibility cases passed and the new allocation case failed. +It measured 786,432 bytes for sixteen comparisons of two 1,024-element long arrays, against an expected zero. +After the change, all 26 targeted cases pass. +The complete suite passed 925 tests on each of .NET 8, .NET 9 and .NET 10. +All results described here are automated checks. diff --git a/plan/benchmarks/post-change.md b/plan/benchmarks/post-change.md new file mode 100644 index 0000000..fc9f03e --- /dev/null +++ b/plan/benchmarks/post-change.md @@ -0,0 +1,13 @@ +# Array equality after the change + +Measured at implementation commit `d19f360`. +Environment, temporary runner and default BenchmarkDotNet configuration match `baseline.md`. +No ShortRun job or reduced iteration settings were used. + +| Method | Mean (ns) | StdDev (ns) | Allocated (bytes/op) | Operations/s | +| --- | ---: | ---: | ---: | ---: | +| EqualLongArrays | 3457.48 | 16.24 | 0 | 289227.91 | +| DecodeHistogram | 27770.42 | 793.14 | 189276 | 36009.54 | + +BenchmarkDotNet reported a multimodal distribution for `DecodeHistogram` and removed one outlier. +The decoding timing difference is treated as noise, not as evidence of a speedup.