From e8a3892ed23343814a41846e66b9084814659cfc Mon Sep 17 00:00:00 2001 From: electricessence <5899455+electricessence@users.noreply.github.com> Date: Sat, 14 Nov 2026 14:47:02 -0800 Subject: [PATCH 1/2] Read the allocation tests' rows from arrays, so the fixture no longer boxes an int per row The two allocation tests compared bytes measured back to back through a DataTableReader, whose GetInt32 boxes each value (24 B per row). Tiered JIT removes that box part way through a measurement, so one side of a comparison could be 24 B per row cheaper than the other and the test failed at random. An ArrayReader serves typed getters straight from int[]/long[]/string[], so the measured bytes are the library's own at every tier. The thresholds, the warm-up and the tests' claims are unchanged. Co-Authored-By: Claude Sonnet 5.5 --- .../MappedQuerySelectTests.cs | 12 ++-- .../SingleValueAllocationTests.cs | 21 ++++-- .../TestHelpers/ArrayReader.cs | 72 +++++++++++++++++++ 3 files changed, 95 insertions(+), 10 deletions(-) create mode 100644 Tests/Open.Database.Extensions.Core.Tests/TestHelpers/ArrayReader.cs diff --git a/Tests/Open.Database.Extensions.Core.Tests/MappedQuerySelectTests.cs b/Tests/Open.Database.Extensions.Core.Tests/MappedQuerySelectTests.cs index 75d2473..9648108 100644 --- a/Tests/Open.Database.Extensions.Core.Tests/MappedQuerySelectTests.cs +++ b/Tests/Open.Database.Extensions.Core.Tests/MappedQuerySelectTests.cs @@ -974,10 +974,14 @@ public static async Task AQueryFactory_LetsEachCallerPickTheTerminal() static PlainReaderCommand Ints(int count) { - DataTable table = new(); - table.Columns.Add("v", typeof(int)); - for (int i = 0; i < count; i++) table.Rows.Add(i); - return new PlainReaderCommand(() => new DataTableReader(table)); + // An ArrayReader serves the ints from an array without boxing them + // (a DataTableReader boxes each one, and the JIT removes that box + // part way through a measurement). + int[] values = new int[count]; + for (int i = 0; i < count; i++) values[i] = i; + string[] names = ["v"]; + Array[] columns = [values]; + return new PlainReaderCommand(() => new ArrayReader(names, columns)); } // Bytes per call: after a warm-up, diff --git a/Tests/Open.Database.Extensions.Core.Tests/SingleValueAllocationTests.cs b/Tests/Open.Database.Extensions.Core.Tests/SingleValueAllocationTests.cs index 783cabc..6289bee 100644 --- a/Tests/Open.Database.Extensions.Core.Tests/SingleValueAllocationTests.cs +++ b/Tests/Open.Database.Extensions.Core.Tests/SingleValueAllocationTests.cs @@ -36,19 +36,28 @@ static long BytesPerCall(Action call) // the two differ by no more than a rounding of the measurement. const int Slack = 16; - // A DataTableReader is a DbDataReader that reports its column type + // An ArrayReader is a DbDataReader that reports its column type // and has typed getters, // so the typed (unboxed) path runs. + // It reads straight from the arrays and allocates nothing per row + // (a DataTableReader boxes every int it serves, + // and the JIT removes that box part way through a measurement). static PlainReaderCommand Rows( Type column, int count, Func value) { - var table = new DataTable(); - table.Columns.Add("v", column); - table.Columns.Add("other", typeof(string)); - for (int i = 0; i < count; i++) table.Rows.Add(value(i), "x"); - return new PlainReaderCommand(() => new DataTableReader(table)); + Array values = Array.CreateInstance(column, count); + string[] others = new string[count]; + for (int i = 0; i < count; i++) + { + values.SetValue(value(i), i); + others[i] = "x"; + } + + string[] names = ["v", "other"]; + Array[] columns = [values, others]; + return new PlainReaderCommand(() => new ArrayReader(names, columns)); } static void NoMoreThanByHand( diff --git a/Tests/Open.Database.Extensions.Core.Tests/TestHelpers/ArrayReader.cs b/Tests/Open.Database.Extensions.Core.Tests/TestHelpers/ArrayReader.cs new file mode 100644 index 0000000..2c97dac --- /dev/null +++ b/Tests/Open.Database.Extensions.Core.Tests/TestHelpers/ArrayReader.cs @@ -0,0 +1,72 @@ +using System.Collections; +using System.Data.Common; + +namespace Open.Database.Extensions.Tests; + +#nullable enable + +/// +/// A over typed column arrays (int[], long[], string[]). +/// Its typed getters read straight from the arrays, so serving a row allocates nothing: +/// unlike , whose GetInt32 boxes the value, the bytes a test +/// measures are the library's own and do not change when the JIT promotes the code. +/// throws, so a mapper that falls back to a boxed read fails loudly. +/// +[ExcludeFromCodeCoverage] +internal sealed class ArrayReader(string[] names, Array[] columns) : DbDataReader +{ + int _row = -1; + + int Count => columns.Length == 0 ? 0 : columns[0].Length; + + public override bool Read() => ++_row < Count; + + public override int FieldCount => columns.Length; + public override string GetName(int ordinal) => names[ordinal]; + public override Type GetFieldType(int ordinal) + => columns[ordinal].GetType().GetElementType()!; + public override string GetDataTypeName(int ordinal) + => GetFieldType(ordinal).Name; + + public override int GetOrdinal(string name) + { + for (int i = 0; i < names.Length; i++) + if (string.Equals(names[i], name, StringComparison.OrdinalIgnoreCase)) return i; + throw new IndexOutOfRangeException(name); + } + + public override bool IsDBNull(int ordinal) + => columns[ordinal] is string[] strings && strings[_row] is null; + + public override int GetInt32(int ordinal) => ((int[])columns[ordinal])[_row]; + public override long GetInt64(int ordinal) => ((long[])columns[ordinal])[_row]; + public override string GetString(int ordinal) => ((string[])columns[ordinal])[_row]; + + public override bool GetBoolean(int ordinal) => throw new NotSupportedException(); + public override byte GetByte(int ordinal) => throw new NotSupportedException(); + public override char GetChar(int ordinal) => throw new NotSupportedException(); + public override DateTime GetDateTime(int ordinal) => throw new NotSupportedException(); + public override decimal GetDecimal(int ordinal) => throw new NotSupportedException(); + public override double GetDouble(int ordinal) => throw new NotSupportedException(); + public override float GetFloat(int ordinal) => throw new NotSupportedException(); + public override Guid GetGuid(int ordinal) => throw new NotSupportedException(); + public override short GetInt16(int ordinal) => throw new NotSupportedException(); + public override object GetValue(int ordinal) => throw new NotSupportedException(); + public override int GetValues(object[] values) => throw new NotSupportedException(); + + public override long GetBytes(int ordinal, long dataOffset, byte[]? buffer, int bufferOffset, int length) + => throw new NotSupportedException(); + + public override long GetChars(int ordinal, long dataOffset, char[]? buffer, int bufferOffset, int length) + => throw new NotSupportedException(); + + public override object this[int ordinal] => throw new NotSupportedException(); + public override object this[string name] => throw new NotSupportedException(); + + public override int Depth => 0; + public override bool HasRows => Count > 0; + public override bool IsClosed => false; + public override int RecordsAffected => -1; + public override bool NextResult() => false; + public override IEnumerator GetEnumerator() => throw new NotSupportedException(); +} From f0cc9afbd298bb2a2f700ce60ff7699b45e049be Mon Sep 17 00:00:00 2001 From: electricessence <5899455+electricessence@users.noreply.github.com> Date: Sat, 14 Nov 2026 15:12:52 -0800 Subject: [PATCH 2/2] Use the existing TypedReader for the allocation tests' rows and drop ArrayReader TypedReader already serves typed getters over cells boxed once when the rows are built, so a row read allocates nothing; ArrayReader duplicated it. Ints and Rows now build a TypedReader. Thresholds and assertions are unchanged. Co-Authored-By: Claude Sonnet 5.5 --- .../MappedQuerySelectTests.cs | 14 ++-- .../SingleValueAllocationTests.cs | 18 ++--- .../TestHelpers/ArrayReader.cs | 72 ------------------- 3 files changed, 11 insertions(+), 93 deletions(-) delete mode 100644 Tests/Open.Database.Extensions.Core.Tests/TestHelpers/ArrayReader.cs diff --git a/Tests/Open.Database.Extensions.Core.Tests/MappedQuerySelectTests.cs b/Tests/Open.Database.Extensions.Core.Tests/MappedQuerySelectTests.cs index 9648108..45fee45 100644 --- a/Tests/Open.Database.Extensions.Core.Tests/MappedQuerySelectTests.cs +++ b/Tests/Open.Database.Extensions.Core.Tests/MappedQuerySelectTests.cs @@ -974,14 +974,12 @@ public static async Task AQueryFactory_LetsEachCallerPickTheTerminal() static PlainReaderCommand Ints(int count) { - // An ArrayReader serves the ints from an array without boxing them - // (a DataTableReader boxes each one, and the JIT removes that box - // part way through a measurement). - int[] values = new int[count]; - for (int i = 0; i < count; i++) values[i] = i; - string[] names = ["v"]; - Array[] columns = [values]; - return new PlainReaderCommand(() => new ArrayReader(names, columns)); + // A TypedReader serves cells boxed once, here, outside the measurement + // (a DataTableReader boxes each int again on every read, + // and the JIT removes that box part way through a measurement). + ImmutableArray columns = [("v", typeof(int))]; + object?[][] rows = [.. Enumerable.Range(0, count).Select(i => (object?[])[i])]; + return new PlainReaderCommand(() => TypedReader.Create(columns, rows)); } // Bytes per call: after a warm-up, diff --git a/Tests/Open.Database.Extensions.Core.Tests/SingleValueAllocationTests.cs b/Tests/Open.Database.Extensions.Core.Tests/SingleValueAllocationTests.cs index 6289bee..743838e 100644 --- a/Tests/Open.Database.Extensions.Core.Tests/SingleValueAllocationTests.cs +++ b/Tests/Open.Database.Extensions.Core.Tests/SingleValueAllocationTests.cs @@ -36,10 +36,10 @@ static long BytesPerCall(Action call) // the two differ by no more than a rounding of the measurement. const int Slack = 16; - // An ArrayReader is a DbDataReader that reports its column type + // A TypedReader is a DbDataReader that reports its column type // and has typed getters, // so the typed (unboxed) path runs. - // It reads straight from the arrays and allocates nothing per row + // Its cells are boxed once, here, outside the measurement // (a DataTableReader boxes every int it serves, // and the JIT removes that box part way through a measurement). static PlainReaderCommand Rows( @@ -47,17 +47,9 @@ static PlainReaderCommand Rows( int count, Func value) { - Array values = Array.CreateInstance(column, count); - string[] others = new string[count]; - for (int i = 0; i < count; i++) - { - values.SetValue(value(i), i); - others[i] = "x"; - } - - string[] names = ["v", "other"]; - Array[] columns = [values, others]; - return new PlainReaderCommand(() => new ArrayReader(names, columns)); + ImmutableArray columns = [("v", column), ("other", typeof(string))]; + object?[][] rows = [.. Enumerable.Range(0, count).Select(i => (object?[])[value(i), "x"])]; + return new PlainReaderCommand(() => TypedReader.Create(columns, rows)); } static void NoMoreThanByHand( diff --git a/Tests/Open.Database.Extensions.Core.Tests/TestHelpers/ArrayReader.cs b/Tests/Open.Database.Extensions.Core.Tests/TestHelpers/ArrayReader.cs deleted file mode 100644 index 2c97dac..0000000 --- a/Tests/Open.Database.Extensions.Core.Tests/TestHelpers/ArrayReader.cs +++ /dev/null @@ -1,72 +0,0 @@ -using System.Collections; -using System.Data.Common; - -namespace Open.Database.Extensions.Tests; - -#nullable enable - -/// -/// A over typed column arrays (int[], long[], string[]). -/// Its typed getters read straight from the arrays, so serving a row allocates nothing: -/// unlike , whose GetInt32 boxes the value, the bytes a test -/// measures are the library's own and do not change when the JIT promotes the code. -/// throws, so a mapper that falls back to a boxed read fails loudly. -/// -[ExcludeFromCodeCoverage] -internal sealed class ArrayReader(string[] names, Array[] columns) : DbDataReader -{ - int _row = -1; - - int Count => columns.Length == 0 ? 0 : columns[0].Length; - - public override bool Read() => ++_row < Count; - - public override int FieldCount => columns.Length; - public override string GetName(int ordinal) => names[ordinal]; - public override Type GetFieldType(int ordinal) - => columns[ordinal].GetType().GetElementType()!; - public override string GetDataTypeName(int ordinal) - => GetFieldType(ordinal).Name; - - public override int GetOrdinal(string name) - { - for (int i = 0; i < names.Length; i++) - if (string.Equals(names[i], name, StringComparison.OrdinalIgnoreCase)) return i; - throw new IndexOutOfRangeException(name); - } - - public override bool IsDBNull(int ordinal) - => columns[ordinal] is string[] strings && strings[_row] is null; - - public override int GetInt32(int ordinal) => ((int[])columns[ordinal])[_row]; - public override long GetInt64(int ordinal) => ((long[])columns[ordinal])[_row]; - public override string GetString(int ordinal) => ((string[])columns[ordinal])[_row]; - - public override bool GetBoolean(int ordinal) => throw new NotSupportedException(); - public override byte GetByte(int ordinal) => throw new NotSupportedException(); - public override char GetChar(int ordinal) => throw new NotSupportedException(); - public override DateTime GetDateTime(int ordinal) => throw new NotSupportedException(); - public override decimal GetDecimal(int ordinal) => throw new NotSupportedException(); - public override double GetDouble(int ordinal) => throw new NotSupportedException(); - public override float GetFloat(int ordinal) => throw new NotSupportedException(); - public override Guid GetGuid(int ordinal) => throw new NotSupportedException(); - public override short GetInt16(int ordinal) => throw new NotSupportedException(); - public override object GetValue(int ordinal) => throw new NotSupportedException(); - public override int GetValues(object[] values) => throw new NotSupportedException(); - - public override long GetBytes(int ordinal, long dataOffset, byte[]? buffer, int bufferOffset, int length) - => throw new NotSupportedException(); - - public override long GetChars(int ordinal, long dataOffset, char[]? buffer, int bufferOffset, int length) - => throw new NotSupportedException(); - - public override object this[int ordinal] => throw new NotSupportedException(); - public override object this[string name] => throw new NotSupportedException(); - - public override int Depth => 0; - public override bool HasRows => Count > 0; - public override bool IsClosed => false; - public override int RecordsAffected => -1; - public override bool NextResult() => false; - public override IEnumerator GetEnumerator() => throw new NotSupportedException(); -}