From 2894aca374fbc9e10be175c605b2c1623573ce79 Mon Sep 17 00:00:00 2001 From: Skuirrels Date: Sat, 1 Aug 2026 17:20:18 +0100 Subject: [PATCH] Optimize list materialization across element types --- .../ListMaterializationBenchmark.cs | 200 ++++++++++++++++++ DuckDB.NET.Benchmarks/Program.cs | 1 + .../DataChunk/Reader/ListVectorDataReader.cs | 187 +++++++++++++--- DuckDB.NET.Test/DuckDBDataReaderEnumTests.cs | 29 ++- DuckDB.NET.Test/DuckDBDataReaderListTests.cs | 24 +++ 5 files changed, 409 insertions(+), 32 deletions(-) create mode 100644 DuckDB.NET.Benchmarks/ListMaterializationBenchmark.cs diff --git a/DuckDB.NET.Benchmarks/ListMaterializationBenchmark.cs b/DuckDB.NET.Benchmarks/ListMaterializationBenchmark.cs new file mode 100644 index 00000000..e9f2f298 --- /dev/null +++ b/DuckDB.NET.Benchmarks/ListMaterializationBenchmark.cs @@ -0,0 +1,200 @@ +using BenchmarkDotNet.Attributes; +using DuckDB.NET.Data; + +namespace DuckDB.NET.Benchmarks; + +[MemoryDiagnoser] +public class ListMaterializationBenchmark +{ + private const int RowCount = 100_000; + private const int ItemCount = 16; + private const int OperationsPerInvocation = 10; + + private readonly List commands = []; + private DuckDBConnection connection = null!; + private DuckDBCommand int32Command = null!; + private DuckDBCommand nullableInt32Command = null!; + private DuckDBCommand int64Command = null!; + private DuckDBCommand nullableInt64Command = null!; + private DuckDBCommand floatCommand = null!; + private DuckDBCommand nullableFloatCommand = null!; + private DuckDBCommand doubleCommand = null!; + private DuckDBCommand nullableDoubleCommand = null!; + private DuckDBCommand decimalCommand = null!; + private DuckDBCommand nullableDecimalCommand = null!; + private DuckDBCommand stringCommand = null!; + private DuckDBCommand timestampCommand = null!; + private DuckDBCommand uuidCommand = null!; + private DuckDBCommand nestedInt32Command = null!; + private DuckDBCommand int32ArrayCommand = null!; + private DuckDBCommand nullableInt32ArrayCommand = null!; + + [GlobalSetup] + public void Setup() + { + connection = PreparedCommandWorkload.OpenVerifiedConnection(); + int32Command = CreateCommand("INTEGER"); + nullableInt32Command = CreateNullableCommand("INTEGER"); + int64Command = CreateCommand("BIGINT"); + nullableInt64Command = CreateNullableCommand("BIGINT"); + floatCommand = CreateCommand("REAL"); + nullableFloatCommand = CreateNullableCommand("REAL"); + doubleCommand = CreateCommand("DOUBLE"); + nullableDoubleCommand = CreateNullableCommand("DOUBLE"); + decimalCommand = CreateCommand("DECIMAL(18, 2)"); + nullableDecimalCommand = CreateNullableCommand("DECIMAL(18, 2)"); + stringCommand = CreateCommand("VARCHAR"); + timestampCommand = CreateCommand( + $"list_transform(range(0, {ItemCount}), value -> TIMESTAMP '2026-01-01' + value * INTERVAL '1 second')", + expressionIsComplete: true); + uuidCommand = CreateCommand( + $"list_transform(range(0, {ItemCount}), value -> '00112233-4455-6677-8899-aabbccddeeff'::UUID)", + expressionIsComplete: true); + nestedInt32Command = CreateCommand( + "[[0, 1, 2, 3], [4, 5, 6, 7], [8, 9, 10, 11], [12, 13, 14, 15]]::INTEGER[][]", + expressionIsComplete: true); + int32ArrayCommand = CreateCommand( + $"range(0, {ItemCount})::INTEGER[{ItemCount}]", + expressionIsComplete: true); + nullableInt32ArrayCommand = CreateNullableCommand("INTEGER", fixedArray: true); + } + + [GlobalCleanup] + public void Cleanup() + { + foreach (var command in commands) + { + command.Dispose(); + } + connection.Dispose(); + } + + [Benchmark(Baseline = true, OperationsPerInvoke = OperationsPerInvocation)] + public long ReadInt32Lists() => Consume(int32Command); + + [Benchmark(OperationsPerInvoke = OperationsPerInvocation)] + public long ReadNullableInt32Lists() => Consume(nullableInt32Command); + + [Benchmark(OperationsPerInvoke = OperationsPerInvocation)] + public long ReadInt64Lists() => Consume(int64Command); + + [Benchmark(OperationsPerInvoke = OperationsPerInvocation)] + public long ReadNullableInt64Lists() => Consume(nullableInt64Command); + + [Benchmark(OperationsPerInvoke = OperationsPerInvocation)] + public long ReadFloatLists() => Consume(floatCommand); + + [Benchmark(OperationsPerInvoke = OperationsPerInvocation)] + public long ReadNullableFloatLists() => Consume(nullableFloatCommand); + + [Benchmark(OperationsPerInvoke = OperationsPerInvocation)] + public long ReadDoubleLists() => Consume(doubleCommand); + + [Benchmark(OperationsPerInvoke = OperationsPerInvocation)] + public long ReadNullableDoubleLists() => Consume(nullableDoubleCommand); + + [Benchmark(OperationsPerInvoke = OperationsPerInvocation)] + public long ReadDecimalLists() => Consume(decimalCommand); + + [Benchmark(OperationsPerInvoke = OperationsPerInvocation)] + public long ReadNullableDecimalLists() => Consume(nullableDecimalCommand); + + [Benchmark(OperationsPerInvoke = OperationsPerInvocation)] + public long ReadStringLists() => Consume(stringCommand); + + [Benchmark(OperationsPerInvoke = OperationsPerInvocation)] + public long ReadTimestampLists() => Consume(timestampCommand); + + [Benchmark(OperationsPerInvoke = OperationsPerInvocation)] + public long ReadUuidLists() => Consume(uuidCommand); + + [Benchmark(OperationsPerInvoke = OperationsPerInvocation)] + public long ReadNestedInt32Lists() => ConsumeNested(nestedInt32Command); + + [Benchmark(OperationsPerInvoke = OperationsPerInvocation)] + public long ReadInt32Arrays() => Consume(int32ArrayCommand); + + [Benchmark(OperationsPerInvoke = OperationsPerInvocation)] + public long ReadNullableInt32Arrays() => Consume(nullableInt32ArrayCommand); + + private DuckDBCommand CreateNullableCommand(string itemType, bool fixedArray = false) + { + var collectionType = fixedArray ? $"{itemType}[{ItemCount}]" : $"{itemType}[]"; + return CreateCommand( + $"list_transform(range(0, {ItemCount}), value -> CASE WHEN value % 4 = 0 THEN NULL ELSE value END)::{collectionType}", + expressionIsComplete: true); + } + + private DuckDBCommand CreateCommand(string itemTypeOrExpression, bool expressionIsComplete = false) + { + var command = connection.CreateCommand(); + var expression = expressionIsComplete + ? itemTypeOrExpression + : $"range(0, {ItemCount})::{itemTypeOrExpression}[]"; + command.CommandText = $"SELECT {expression} FROM range({RowCount})"; + commands.Add(command); + return command; + } + + private static long Consume(DuckDBCommand command) + { + long totalChecksum = 0; + + for (var operation = 0; operation < OperationsPerInvocation; operation++) + { + using var reader = command.ExecuteReader(); + long checksum = 0; + var rowsRead = 0; + + while (reader.Read()) + { + checksum += reader.GetFieldValue>(0).Count; + rowsRead++; + } + + if (rowsRead != RowCount || checksum != RowCount * ItemCount) + { + throw new InvalidOperationException( + $"Expected {RowCount} rows and {RowCount * ItemCount} values, but read {rowsRead} rows and {checksum} values."); + } + + totalChecksum += checksum; + } + + return totalChecksum; + } + + private static long ConsumeNested(DuckDBCommand command) + { + long totalChecksum = 0; + + for (var operation = 0; operation < OperationsPerInvocation; operation++) + { + using var reader = command.ExecuteReader(); + long checksum = 0; + var rowsRead = 0; + + while (reader.Read()) + { + var lists = reader.GetFieldValue>>(0); + checksum += lists.Count; + foreach (var list in lists) + { + checksum += list.Count; + } + rowsRead++; + } + + const int ValuesPerRow = 20; + if (rowsRead != RowCount || checksum != RowCount * ValuesPerRow) + { + throw new InvalidOperationException( + $"Expected {RowCount} rows and checksum {RowCount * ValuesPerRow}, but read {rowsRead} rows and checksum {checksum}."); + } + + totalChecksum += checksum; + } + + return totalChecksum; + } +} diff --git a/DuckDB.NET.Benchmarks/Program.cs b/DuckDB.NET.Benchmarks/Program.cs index 198112cd..7d74eba1 100644 --- a/DuckDB.NET.Benchmarks/Program.cs +++ b/DuckDB.NET.Benchmarks/Program.cs @@ -23,6 +23,7 @@ #if !DUCKDB_NET_BASELINE_1_5_3 typeof(AppenderBenchmark), typeof(ListAppenderBenchmark), + typeof(ListMaterializationBenchmark), typeof(MappedAppenderBenchmark), #endif typeof(PreparedCommandBenchmark), diff --git a/DuckDB.NET.Data/DataChunk/Reader/ListVectorDataReader.cs b/DuckDB.NET.Data/DataChunk/Reader/ListVectorDataReader.cs index a1bc8a61..ad8d5365 100644 --- a/DuckDB.NET.Data/DataChunk/Reader/ListVectorDataReader.cs +++ b/DuckDB.NET.Data/DataChunk/Reader/ListVectorDataReader.cs @@ -6,6 +6,7 @@ internal sealed class ListVectorDataReader : VectorDataReaderBase private readonly VectorDataReaderBase listDataReader; private Type? cachedListType; private IListFactory? cachedListFactory; + private IListMaterializer? cachedListMaterializer; public bool IsList => DuckDBType == DuckDBType.List; @@ -50,58 +51,63 @@ internal override unsafe object GetValue(ulong offset, Type targetType) private object GetList(Type returnType, ulong listOffset, ulong length) { var listType = returnType.GetGenericArguments()[0]; - var allowNulls = listType.AllowsNullValue(out _, out var nullableType); - var list = CreateList(returnType, length); - //Special case for specific types to avoid boxing + // Keep the established fast paths free of an interface dispatch. Other List + // shapes use the cached typed materializer below to avoid per-element boxing. return list switch { - List theList => BuildList(theList), - List theList => BuildList(theList), - List theList => BuildList(theList), - List theList => BuildList(theList), - List theList => BuildList(theList), - List theList => BuildList(theList), - List theList => BuildList(theList), - List theList => BuildList(theList), + List typedList => BuildList(typedList), + List typedList => BuildList(typedList), + List typedList => BuildList(typedList), + List typedList => BuildList(typedList), + List typedList => BuildList(typedList), + List typedList => BuildList(typedList), + List typedList => BuildList(typedList), + List typedList => BuildList(typedList), + _ when cachedListMaterializer is { } materializer => + materializer.Materialize(list, listDataReader, listOffset, length, allowNulls), _ => BuildListCommon(list, nullableType ?? listType) }; List BuildList(List result) { - for (ulong i = 0; i < length; i++) + for (ulong index = 0; index < length; index++) { - var childOffset = listOffset + i; + var childOffset = listOffset + index; if (listDataReader.IsValid(childOffset)) { - var item = listDataReader.GetValueStrict(childOffset); - result.Add(item); + result.Add(listDataReader.GetValueStrict(childOffset)); } else { - result.Add(allowNulls ? default! : throw new InvalidCastException("The list contains null value")); + result.Add(allowNulls + ? default! + : throw new InvalidCastException("The list contains null value")); } } + return result; } IList BuildListCommon(IList result, Type targetType) { - for (ulong i = 0; i < length; i++) + for (ulong index = 0; index < length; index++) { - var childOffset = listOffset + i; + var childOffset = listOffset + index; if (listDataReader.IsValid(childOffset)) { - var item = listDataReader.GetValue(childOffset, targetType); - result.Add(item); + result.Add(listDataReader.GetValue(childOffset, targetType)); } else { - result.Add(allowNulls ? null : throw new InvalidCastException("The list contains null value")); + result.Add(allowNulls + ? null + : throw new InvalidCastException("The list contains null value")); } } + return result; } } @@ -111,18 +117,22 @@ private IList CreateList(Type returnType, ulong length) if (returnType != cachedListType) { cachedListType = returnType; - cachedListFactory = returnType.IsGenericType && returnType.GetGenericTypeDefinition() == typeof(List<>) - ? CreateListFactory(returnType) - : null; - } - - if (cachedListFactory != null) - { - return cachedListFactory.Create(checked((int)length)); + if (returnType.IsGenericType && returnType.GetGenericTypeDefinition() == typeof(List<>)) + { + cachedListFactory = CreateListFactory(returnType); + cachedListMaterializer = CreateListMaterializer(returnType); + } + else + { + cachedListFactory = null; + cachedListMaterializer = null; + } } - return Activator.CreateInstance(returnType) as IList - ?? throw new ArgumentException($"The type '{returnType.Name}' specified in parameter {nameof(returnType)} cannot be instantiated as an IList."); + return cachedListFactory?.Create(checked((int)length)) + ?? Activator.CreateInstance(returnType) as IList + ?? throw new ArgumentException( + $"The type '{returnType.Name}' specified in parameter {nameof(returnType)} cannot be instantiated as an IList."); } private static IListFactory CreateListFactory(Type returnType) @@ -131,6 +141,31 @@ private static IListFactory CreateListFactory(Type returnType) return (IListFactory)Activator.CreateInstance(factoryType)!; } + private static IListMaterializer CreateListMaterializer(Type returnType) + { + var elementType = returnType.GetGenericArguments()[0]; + var nullableElementType = Nullable.GetUnderlyingType(elementType); + + Type materializerType; + if (nullableElementType != null) + { + materializerType = (nullableElementType.IsEnum + ? typeof(NullableConvertedListMaterializer<>) + : typeof(NullableListMaterializer<>)) + .MakeGenericType(nullableElementType); + } + else if (elementType.IsEnum || (!elementType.IsValueType && elementType != typeof(string))) + { + materializerType = typeof(ConvertedListMaterializer<>).MakeGenericType(elementType); + } + else + { + materializerType = typeof(ListMaterializer<>).MakeGenericType(elementType); + } + + return (IListMaterializer)Activator.CreateInstance(materializerType)!; + } + internal override void Reset(IntPtr vector) { base.Reset(vector); @@ -155,4 +190,94 @@ private sealed class ListFactory : IListFactory { public IList Create(int capacity) => new List(capacity); } + + private interface IListMaterializer + { + object Materialize(IList list, VectorDataReaderBase reader, ulong offset, ulong length, bool allowNulls); + } + + private sealed class ListMaterializer : IListMaterializer + { + public object Materialize(IList list, VectorDataReaderBase reader, ulong offset, ulong length, bool allowNulls) + { + var result = (List)list; + + for (ulong index = 0; index < length; index++) + { + var childOffset = offset + index; + if (reader.IsValid(childOffset)) + { + result.Add(reader.GetValueStrict(childOffset)); + } + else + { + result.Add(allowNulls + ? default! + : throw new InvalidCastException("The list contains null value")); + } + } + + return result; + } + } + + private sealed class NullableListMaterializer : IListMaterializer where T : struct + { + public object Materialize(IList list, VectorDataReaderBase reader, ulong offset, ulong length, bool allowNulls) + { + var result = (List)list; + + for (ulong index = 0; index < length; index++) + { + var childOffset = offset + index; + result.Add(reader.IsValid(childOffset) + ? reader.GetValueStrict(childOffset) + : allowNulls + ? null + : throw new InvalidCastException("The list contains null value")); + } + + return result; + } + } + + private sealed class NullableConvertedListMaterializer : IListMaterializer where T : struct + { + public object Materialize(IList list, VectorDataReaderBase reader, ulong offset, ulong length, bool allowNulls) + { + var result = (List)list; + + for (ulong index = 0; index < length; index++) + { + var childOffset = offset + index; + result.Add(reader.IsValid(childOffset) + ? (T)reader.GetValue(childOffset, typeof(T)) + : allowNulls + ? null + : throw new InvalidCastException("The list contains null value")); + } + + return result; + } + } + + private sealed class ConvertedListMaterializer : IListMaterializer + { + public object Materialize(IList list, VectorDataReaderBase reader, ulong offset, ulong length, bool allowNulls) + { + var result = (List)list; + + for (ulong index = 0; index < length; index++) + { + var childOffset = offset + index; + result.Add(reader.IsValid(childOffset) + ? (T)reader.GetValue(childOffset, typeof(T)) + : allowNulls + ? default! + : throw new InvalidCastException("The list contains null value")); + } + + return result; + } + } } diff --git a/DuckDB.NET.Test/DuckDBDataReaderEnumTests.cs b/DuckDB.NET.Test/DuckDBDataReaderEnumTests.cs index 5db83916..ef88ce04 100644 --- a/DuckDB.NET.Test/DuckDBDataReaderEnumTests.cs +++ b/DuckDB.NET.Test/DuckDBDataReaderEnumTests.cs @@ -48,6 +48,33 @@ public void SelectEnumList() list.Should().BeEquivalentTo(new List { Mood.Happy, Mood.Ok }); } + [Fact] + public void SelectNullableEnumList() + { + Command.CommandText = """ + SELECT list_transform( + range(0, 32), + value -> CASE value % 4 + WHEN 0 THEN NULL + WHEN 1 THEN 'sad'::mood + WHEN 2 THEN 'ok'::mood + ELSE 'happy'::mood + END) + """; + using var reader = Command.ExecuteReader(); + reader.Read(); + + var list = reader.GetFieldValue>(0); + + list.Should().Equal(Enumerable.Range(0, 32).Select(index => (index % 4) switch + { + 0 => (Mood?)null, + 1 => Mood.Sad, + 2 => Mood.Ok, + _ => Mood.Happy + })); + } + [Fact] public void SelectEnumValuesAsNullable() { @@ -99,4 +126,4 @@ public enum Mood { Sad, Ok, Happy } -} \ No newline at end of file +} diff --git a/DuckDB.NET.Test/DuckDBDataReaderListTests.cs b/DuckDB.NET.Test/DuckDBDataReaderListTests.cs index 32686930..33d45eda 100644 --- a/DuckDB.NET.Test/DuckDBDataReaderListTests.cs +++ b/DuckDB.NET.Test/DuckDBDataReaderListTests.cs @@ -15,6 +15,30 @@ public void PreSizesListResults() list.Capacity.Should().Be(17); } + [Fact] + public void PreSizesNonSpecializedListResults() + { + Command.CommandText = "SELECT range(17)::BIGINT[];"; + using var reader = Command.ExecuteReader(); + + reader.Read(); + var list = reader.GetFieldValue>(0); + + list.Should().HaveCount(17); + list.Capacity.Should().Be(17); + } + + [Fact] + public void ReadListOfLongsWithNulls() + { + Command.CommandText = "SELECT [1::BIGINT, NULL, 3::BIGINT];"; + using var reader = Command.ExecuteReader(); + + reader.Read(); + reader.GetFieldValue>(0).Should().Equal(1, null, 3); + reader.Invoking(current => current.GetFieldValue>(0)).Should().Throw(); + } + [Fact] public void ReadListOfIntegers() {