DuckDB.NET 1.5.6.1: Reading Data Up to 12× Faster

Part 1 of the DuckDB.NET 1.5.6.1 performance series

Introduction

DuckDB.NET is the .NET client for DuckDB, an in-process analytical database. It consists of two libraries: DuckDB.NET.Bindings, a thin wrapper over the DuckDB C API, and DuckDB.NET.Data, an ADO.NET provider built on top of the Bindings library. The core features of DuckDB.NET.Data are querying DuckDB through ADO.NET or Dapper, writing data with prepared statements, importing data in bulk with the Appender, and defining scalar and table user-defined functions in C#.

DuckDB.NET 1.5.0 made DuckDBDataReader about 20% faster, and the Appender 20 to 40% faster with 22% less memory. It also added a simpler API for scalar and table functions. DuckDB.NET 1.5.6.1 brings even bigger improvements across all the core features: typed reads are 3.5 to 6 times faster, scalar functions 5 to 7.7 times, and table functions 2.5 to 6 times. Appending a DateTime is 26 times faster, with allocations down from 2.8 GB to under 0.1 MB for a million rows.

None of this comes from DuckDB itself. Every benchmark in this post runs DuckDB.NET 1.5.6 and 1.5.6.1 against the same DuckDB 1.5.6 engine, so the difference is the .NET code alone. All of these changes carry over to DuckDB.NET 2.0, and the new engine, DuckDB 2.0, adds its own gains. A preview benchmark measured TPC-H, a standard analytics benchmark, at scale factor 10 (about 10 GB of data) running 1.26 times faster than on DuckDB 1.5.6, and a recursive query about 34 times faster. Those gains are in query execution. The ones in this post are in moving data between DuckDB and .NET, so an application gets both, in different places.

This is the first post in a series about these changes. It covers reading data with DuckDBDataReader; the next posts cover appending, dates, times and decimals, and scalar and table functions.

Table of Contents

How it was measured

All the numbers in this post come from the benchmarks in the DuckDB.NET repository. Every benchmark runs twice, each time in its own process: once against the DuckDB.NET 1.5.6 package from NuGet and once against the 1.5.6.1 code. Both load the same native library, DuckDB 1.5.6, so the only thing that differs between the two runs is the .NET code.

The benchmarks ran with BenchmarkDotNet and .NET 10 on an Azure F8as_v7 virtual machine (AMD EPYC 9V45, 8 cores). Every case ran in 4 separate processes, with 5 warmup and 8 measured iterations in each, and the numbers in this post are BenchmarkDotNet’s results over all of them.

Running DuckDB.NET 1.5.6 against itself first showed that on this machine a difference of up to about 15% can be noise, far less than most of the gains in this post. Some benchmarks also work as controls: reading rows without reading their values, summing a column in SQL, and reading rows from a SQL table instead of a table function. They spend their time in DuckDB, not in DuckDB.NET, and both versions came out within 3% of each other on them.

The results

DuckDB.NET 1.5.6 and 1.5.6.1 on the same DuckDB 1.5.6 engine, .NET 10, AMD EPYC 9V45 (Zen 5). Speedup is the range across all benchmarks for that feature; the times and allocations are for the example, 1.5.6 then 1.5.6.1.

FeatureSpeedupExample1.5.61.5.6.1Allocated
Typed reads (GetInt32, GetFieldValue<T>, …)3.5–6×1M rows of eight INTEGER columns through GetInt3255.2 ms10.0 ms18 KB, unchanged
Integer column read as double12×1M rows of eight integer columns of different types211.7 ms17.3 ms366 MB → 18 KB
Short strings readup to 1.35×200k short ASCII strings4.97 ms3.66 ms6.9 MB, unchanged
Dates, times and timestamps read3–12×1M rows of four TIMESTAMP columns as DateTime118.1 ms11.2 ms17 KB, unchanged
UUID, DECIMAL and HUGEINT read1.7–7×1M rows of four UUID columns as Guid98.6 ms29.6 ms17 KB, unchanged
LIST, STRUCT and MAP readup to 6×200k lists of int?31.1 ms5.3 ms45.8 MB → 18.3 MB
Appending a DateTime15–26×1M rows, four TIMESTAMP columns772.0 ms29.4 ms2.8 GB → 93 KB
Appending BigInteger, lists, strings, enums, mapped appender1.3–4×1M records of mixed types, mapped appender339.3 ms86.8 ms738 MB → 117 KB
Appending numbers1.2–1.4×1M rows, eight numeric types106.3 ms83.5 ms185 KB, unchanged
Appending decimals up to 18 digits1.3× on AMD Zen 5, about 2× on Intel1M rows, four DECIMAL(18,4) columns87.3 ms67.8 ms108 KB → 139 KB
Appending decimals to a wide column2.3×1M rows, four DECIMAL(38,10) columns193.8 ms84.6 ms147 MB → 139 KB
Appending 28-digit decimals0.94× on AMD Zen 5 (slower), about 1.45× on Intel1M rows of quotients such as 1 / 7147.1 ms157.3 ms108 KB → 139 KB
Typed scalar functions5–7.7×1M calls with an int? argument21.2 ms4.0 ms20.7 MB → 104 KB
Table functions2.5–6×1M rows37.4 ms9.6 ms20.8 MB → 110 KB
Projection pushdown6.3×1 column of 4 read, 1M rows18.7 ms2.95 ms97 KB → 40 KB
Control: work done by DuckDB1.0×SUM over 1M rows in SQL0.508 ms0.506 ms1.1 KB, unchanged

On Intel, 1.5.6’s decimal arithmetic is much slower than on Zen 5, so appending decimals gains more: about 2× on an i7-12700H, against 1.3× on Zen 5. In the 28-digit case, 1.5.6.1 is slower on Zen 5 because it divides UInt128 in software, but it is about 1.45 times faster on the i7-12700H.

Improving DuckDBDataReader performance

The reader got five changes, each measured on its own:

  1. Sealing DuckDBDataReader, so the just-in-time (JIT) compiler can call and inline GetFieldValue<T> directly.
  2. A fast path that reads the stored type straight from memory.
  3. One reader class per numeric type and a generic conversion, which remove the hidden boxing.
  4. Plain virtual methods for typed reads such as ReadInt64.
  5. Nullable reads without an expression tree.

In DuckDB.NET 1.5.6, reading 8 million integers with DuckDBDataReader.GetInt32 took 55.2 milliseconds. DuckDB returns query results in chunks, with one vector (an array of values) per column, and DuckDBDataReader reads each value straight from that memory. Let’s look at the code path from GetInt32 to the point where the value is read from the vector.

public class DuckDBDataReader : DbDataReader
{
  public override int GetInt32(int ordinal)
  {
    return GetFieldValue<int>(ordinal);
  }

  public override T GetFieldValue<T>(int ordinal)
  {
    // vectorReaders inherit from VectorDataReaderBase class and know how to read their own data
    return vectorReaders[ordinal].GetValueStrict<T>(rowsReadFromCurrentChunk - 1);
  }
}

// VectorDataReaderBase
internal T GetValue<T>(ulong offset, bool strict)
{
  // GetValueStrict ends up calling GetValue<T>
  if (IsValid(offset))
  {
    return GetValidValue<T>(offset);
  }
  // ...
}

[MethodImpl(MethodImplOptions.AggressiveInlining)]
protected unsafe T GetFieldData<T>(ulong offset) where T : unmanaged
  => *((T*)DataPointer + offset);

// NumericVectorDataReader : VectorDataReaderBase
protected override T GetValidValue<T>(ulong offset)
{
  return DuckDBType switch
  {
    DuckDBType.Integer => GetUnmanagedTypeValue<int, T>(offset),
    // other numeric types
  };
}

private TResult GetUnmanagedTypeValue<TQuery, TResult>(ulong offset) 
  where TQuery : unmanaged, INumberBase<TQuery>
{
  var value = GetFieldData<TQuery>(offset);

  try
  {
    return ConvertNumeric<TQuery, TResult>(value);
  }
  catch (OverflowException)
  {
    throw new InvalidCastException($"Cannot cast from ... in column {ColumnName}");
  }
}

Let’s see what the JIT compiler generates for this code. Set two environment variables and run any program that calls these methods in a loop. DOTNET_JitDisasm takes the names of the methods to print, and DOTNET_JitStdOutFile writes the listings to a file instead of the console:

dotnet build -c Release
$env:DOTNET_JitDisasm = "GetInt32 GetFieldValue GetValidValue GetUnmanagedTypeValue"
$env:DOTNET_JitStdOutFile = "jit.txt"
dotnet bin/Release/net10.0/MyApp.dll

The JIT compiles a method several times as it gets hot, so the file has a listing for each tier: Tier0, Instrumented Tier0 and Tier1. The header of each listing names its tier, and only the Tier1 listing is the code that runs once the program is warmed up. The JIT appends to the file, so delete it before each run. On .NET 10.0.13, x64, the Tier1 code for reading a single integer ends up looking like this (edited for brevity):

; DuckDBDataReader:GetInt32(int)
       call     CORINFO_HELP_VIRTUAL_FUNC_PTR      ; look up GetFieldValue<int>
       tail.jmp rax

; DuckDBDataReader:GetFieldValue[int](int)
       ...                                         ; row, bounds and NULL checks
       call     CORINFO_HELP_VIRTUAL_FUNC_PTR      ; look up GetValidValue<int>
       tail.jmp rax

; NumericVectorDataReader:GetValidValue[int](ulong)
       mov      ebx, dword ptr [rcx+0x30]          ; the column type
       lea      r8d, [rbx-0x02]
       cmp      r8d, 2                             ; is it INTEGER?
       jne      SHORT G_M000_IG06
       call     [NumericVectorDataReader:GetUnmanagedTypeValue[int,int](ulong):int:this]

; NumericVectorDataReader:GetUnmanagedTypeValue[int,int](ulong)
       mov      rax, qword ptr [rcx+0x28]
       mov      eax, dword ptr [rax+4*rdx]
       ret

Only two instructions do the work: the two mov instructions in GetUnmanagedTypeValue. The first, mov rax, qword ptr [rcx+0x28], loads the DataPointer field, the pointer to the column’s data in DuckDB’s vector, and the second, mov eax, dword ptr [rax+4*rdx], loads the value at the specified offset. Everything else is overhead:

  • two calls to CORINFO_HELP_VIRTUAL_FUNC_PTR, one in GetInt32 and one in GetFieldValue<int>
  • the tail.jmp rax after each, an indirect jump to whatever the lookup returned
  • a real call from GetValidValue<int> to GetUnmanagedTypeValue
  • the column-type check in GetValidValue (cmp r8d, 2)

Why are there lookups at all? For a normal virtual method, every class has a method table with one slot per virtual method, and an override puts its own code in its class’s slot. A call loads the object’s method table, loads the slot and jumps to the address in it, a few instructions with no lookup.

A generic virtual method such as GetValidValue<T> can’t work that way. It isn’t one method but a family: GetValidValue<int>, GetValidValue<long>, GetValidValue<DateTime>, and so on, and each value type T gets its own compiled code. The set of Ts is open, since any code can call the method with a new type at any time, so the runtime can’t reserve a slot for each one when it loads the class. Instead, on every call it has to find the code for this object’s actual class and this T, and that’s what CORINFO_HELP_VIRTUAL_FUNC_PTR does.

Measured in isolation, a normal virtual call costs 0.6 to 1.5 ns and a generic virtual call 5 to 9 ns. The JIT also can’t inline the target of a generic virtual call, or guess it from profile data as it does for normal virtual calls.

The .NET team is working on this in dotnet/runtime#112596. In .NET 11 the JIT can devirtualize most generic virtual calls when it knows the exact class of the object, and then inline the target ( dotnet/runtime#122023). That doesn’t help here: in neither call does the JIT know the exact class, and on .NET 11 RC 2 the code of DuckDB.NET 1.5.6 still has both lookups. Guessing the target from profile data, which could help, is planned but hasn’t shipped. So DuckDB.NET has to avoid these calls itself.

Getting rid of the generic virtual method lookups

Removing the CORINFO_HELP_VIRTUAL_FUNC_PTR call from GetInt32 is easy: a single-word change in the DuckDBDataReader class. Making the DuckDBDataReader class sealed guarantees that no class inherits from it, so GetFieldValue can’t be overridden and the JIT can call DuckDBDataReader.GetFieldValue<int> directly and inline it into GetInt32. With this change alone, reading the integers is 1.5 times as fast:

Case1.5.6SealedSpeedup
GetInt32 on eight INTEGER columns59.8 ms39.8 ms1.51×
GetFieldValue<int?> on eight nullable INTEGER columns70.4 ms45.5 ms1.54×
GetInt64 on eight integer columns of different types60.3 ms39.2 ms1.54×
GetDouble on eight integer columns of different types215.3 ms197.6 ms1.09×
Control: reading the rows without their values4.1 ms4.2 ms0.98×

From DataReaderBenchmark and MixedNumericReaderBenchmark, 1M rows each. The tables in this section come from a separate run that also measured each commit, so their times differ from the results table by up to about 10%.

Removing the second CORINFO_HELP_VIRTUAL_FUNC_PTR call needs more changes. Even though VectorDataReaderBase can read the actual data from DataPointer with the GetFieldData<T> method, every read goes through one of the VectorDataReaderBase subclasses because they know how to convert the read value to the requested type. For example, NumericVectorDataReader handles numeric conversions (when reading an INTEGER column as long or double), StringVectorDataReader knows how to create a string from DuckDB’s UTF-8 bytes, and so on. But in most cases there is a shortcut: when the column is INTEGER and the caller asks for an int, no conversion is needed, and the call to the subclass can be skipped entirely. For this, 1.5.6.1 adds a Type? StorageType property to VectorDataReaderBase that says which .NET type the vector holds:

// VectorDataReaderBase

// The .NET type whose memory layout is exactly what the vector holds,
// or null when every read needs a conversion.
private protected Type? StorageType { get; init; }

internal T GetValue<T>(ulong offset, bool strict)
{
  // ...
  if (IsValid(offset))
  {
    return ReadValidValue<T>(offset);   // was: GetValidValue<T>(offset)
  }
  // ...
}

[MethodImpl(MethodImplOptions.AggressiveInlining)]
private unsafe T ReadValidValue<T>(ulong offset)
{
  // If the requested type is StorageType, skip the subclass call.
  // The JIT turns typeof(T).IsValueType into a constant,
  // and for reference types the branch disappears.
  if (typeof(T).IsValueType && typeof(T) == StorageType)
  {
    return Unsafe.Read<T>((byte*)DataPointer + offset * (ulong)Unsafe.SizeOf<T>());
  }

  return GetValidValue<T>(offset);
}

// NumericVectorDataReader constructor, the subclass says what it stores

StorageType = columnType switch
{
  DuckDBType.TinyInt => typeof(sbyte),
  DuckDBType.SmallInt => typeof(short),
  DuckDBType.Integer => typeof(int),
  DuckDBType.BigInt => typeof(long),
  // ... the unsigned types, FLOAT and DOUBLE
  _ => null
};

With this change, reading INTEGER columns as int is 4 times as fast. The same commit also changed the nullable read path to go through ReadValidValue<T>, so it got faster too:

CaseSealedFast pathSpeedup
GetInt32 on eight INTEGER columns39.8 ms9.9 ms4.00×
GetFieldValue<int?> on eight nullable INTEGER columns45.5 ms21.9 ms2.08×
GetInt64 on eight integer columns of different types39.2 ms39.0 ms1.00×
GetDouble on eight integer columns of different types197.6 ms211.0 ms0.94×
Control: reading the rows without their values4.2 ms4.0 ms1.05×

GetInt64 and GetDouble don’t get any faster because the shortcut doesn’t apply to them: the eight columns are TINYINT, SMALLINT, INTEGER, BIGINT and their unsigned counterparts, so only the BIGINT column stores a long and none stores a double. The 0.94× for GetDouble is within the noise described in How it was measured.

Hidden boxing

Boxing copies a value type such as int into a new object on the heap, so it can be stored as object. Each box is an allocation, and the garbage collector has to clean it up later.

The table shows another problem: GetDouble takes 211 ms, more than five times as long as GetInt64 on the same columns, and allocates 366 MB. Here is what happens in 1.5.6 when GetDouble reads a value from an INTEGER column:

  1. GetDouble calls GetFieldValue<double>, which calls GetValidValue<double> on the column’s reader, a NumericVectorDataReader.
  2. NumericVectorDataReader.GetValidValue<T> converts between integer types, and reads a FLOAT or DOUBLE column as its own type. An integer column read as double is neither, so it passes the read to the base class.
  3. VectorDataReaderBase.GetValidValue<T> has no generic code for conversions. It calls the non-generic GetValue(offset, typeof(double)), passing the requested type, double, as an argument. This method returns object.
  4. NumericVectorDataReader.GetValue reads the value the column stores, an int, and keeps it in an object variable. That’s the first box. To turn it into the requested double, it calls Convert.ChangeType, which also returns object, so the double it produces is boxed too. That’s the second box, and GetValue returns it.
  5. Back in VectorDataReaderBase.GetValidValue<T>, the object is cast to double, which unboxes it.

So converting an int to a double allocates two objects. The allocations confirm it: 366 MB for 8M values is 48 bytes per value, two 24-byte boxes. Here is the code for steps 2 to 4:

// NumericVectorDataReader (1.5.6)

protected override T GetValidValue<T>(ulong offset)
{
  var isFloatingNumericType = TypeExtensions.IsFloatingNumericType<T>();   // true for double
  var isIntegralNumericType = TypeExtensions.IsIntegralNumericType<T>();   // false for double
  // ...
  if (isIntegralNumericType)
  {
    // ... the integer switch, not taken
  }

  return DuckDBType switch
  {
    DuckDBType.Float => (T)(object)GetFieldData<float>(offset),
    DuckDBType.Double => (T)(object)GetFieldData<double>(offset),
    _ => base.GetValidValue<T>(offset)           // an INTEGER column ends up here
  };
}

// VectorDataReaderBase
protected virtual T GetValidValue<T>(ulong offset) => (T)GetValue(offset, typeof(T));   // unbox

// NumericVectorDataReader
internal override object GetValue(ulong offset, Type targetType)
{
  var value = DuckDBType switch                 // object: every arm boxes
  {
    DuckDBType.TinyInt => GetFieldData<sbyte>(offset),
    DuckDBType.Integer => GetFieldData<int>(offset),
    // ...
    _ => base.GetValue(offset, targetType)
  };

  if (value.GetType() == targetType)
  {
    return value;
  }

  if (targetType.IsNumeric())
  {
    try
    {
      return Convert.ChangeType(value, targetType);   // IConvertible.ToDouble, boxed again
    }
    // ...
  }
  // ...
}

Why does NumericVectorDataReader.GetValue box? One NumericVectorDataReader class serves every numeric column type, so the type the column stores is known only at run time, from DuckDBType. That’s why GetValue uses a switch on DuckDBType to decide how to read the value: GetFieldData<sbyte> for a TINYINT column, GetFieldData<int> for an INTEGER column, and so on. Each arm returns a different type (sbyte, short, int, long, the unsigned types, float, double, BigInteger), and the default arm returns object. The only type that can hold the result of every arm is object, so the compiler gives the switch that type and every arm boxes its value. The var hides it, which is why it’s easy to miss.

On the other hand, GetInt64 doesn’t allocate. It takes the integer branch of GetValidValue<T> and converts with ConvertNumeric<TSource, TResult>, which also uses (TResult)(object)long.CreateChecked(value), but the JIT removes a box that is immediately unboxed to the same type. It still pays for the switch on DuckDBType and the call to GetUnmanagedTypeValue on every value.

The fix does two things. First, the conversion stays generic: NumericConverter.Convert<TStorage, T>, the former ConvertNumeric moved into a class of its own, got cases for float, double and decimal, so an integer read as double no longer falls back to the non-generic GetValue, and nothing is boxed. Every typeof check in it is a constant for the JIT, so for Convert<int, double> only double.CreateChecked(value) is left, and the (TResult)(object) pair is removed, as it is for long.

Second, the reader is generic over the type the column stores. The factory now creates a NumericVectorDataReader<int> for an INTEGER column, a NumericVectorDataReader<long> for a BIGINT column, and so on. TStorage is a value type, so the JIT compiles separate code for each of these classes, and in the code for NumericVectorDataReader<int>, TStorage is int. The reader no longer needs a switch to find out what the column stores:

// One closed type per column type, so what the vector holds is a constant for the JIT.
internal sealed class NumericVectorDataReader<TStorage> : VectorDataReaderBase
  where TStorage : unmanaged, INumberBase<TStorage>
{
  // constructor: StorageType = typeof(TStorage);

  protected override T GetValidValue<T>(ulong offset)
  {
    // ...
    var value = GetFieldData<TStorage>(offset);   // an int for an INTEGER column
    // ...
    try
    {
      return NumericConverter.Convert<TStorage, T>(value);
    }
    catch (OverflowException)
    {
      throw CannotCast(typeof(TStorage), typeof(T));
    }
  }
}

// NumericConverter
public static TResult Convert<TSource, TResult>(TSource value)
  where TSource : INumberBase<TSource>
{
  if (typeof(TSource) == typeof(TResult))
    return Unsafe.As<TSource, TResult>(ref value);

  if (typeof(TResult) == typeof(byte)) return (TResult)(object)byte.CreateChecked(value);
  // ... the other integer types and float
  if (typeof(TResult) == typeof(double)) return (TResult)(object)double.CreateChecked(value);
  // ... decimal and BigInteger
}

GetDouble is now 6.7 times as fast and allocates 18 KB instead of 366 MB, and GetInt64 is 1.37 times as fast. There’s no switch on the column type anymore, and the JIT inlines the whole conversion instead of calling GetUnmanagedTypeValue for every value.

CaseFast pathOne class per typeSpeedup
GetInt32 on eight INTEGER columns9.9 ms10.7 ms0.93×
GetFieldValue<int?> on eight nullable INTEGER columns21.9 ms21.3 ms1.03×
GetInt64 on eight integer columns of different types39.0 ms28.5 ms1.37×
GetDouble on eight integer columns of different types211.0 ms31.4 ms6.71×
Control: reading the rows without their values4.0 ms4.1 ms0.97×

GetInt32 takes the fast path from the previous step, which this commit doesn’t touch, so its 0.93× is noise.

Removing the remaining generic virtual calls

After the boxing fix, GetInt64 and GetDouble on eight integer columns of different types are about three times as slow as GetInt32. In both cases, StorageType doesn’t match the requested type, so the slow CORINFO_HELP_VIRTUAL_FUNC_PTR call still happens. The fix adds dedicated virtual methods to VectorDataReaderBase, such as ReadInt64, ReadDouble and ReadString, that read one type directly. For this, ReadValidValue<T> gets one line per type after the fast path:

// VectorDataReaderBase.ReadValidValue<T>, after the fast path
if (typeof(T) == typeof(long)) return (T)(object)ReadInt64(offset);
// ... one line per type

// VectorDataReaderBase
internal virtual long ReadInt64(ulong offset) => GetValidValue<long>(offset);
// ... one method per type

// NumericVectorDataReader<TStorage>, sealed
internal override long ReadInt64(ulong offset) => GetValidValue<long>(offset);

The JIT folds the typeof checks, so for GetInt64 only the call to ReadInt64 is left, and as in NumericConverter, the JIT removes the (T)(object) pair. Because the line is in ReadValidValue<T>, GetFieldValue<long> and nullable reads take it too. ReadInt64 is a plain virtual method, so the call goes through the method-table slot, with no lookup. Each override in the numeric reader is one line that calls the reader’s own GetValidValue<long>. That’s still a generic virtual method, but the NumericVectorDataReader<TStorage> class is sealed, so, as with DuckDBDataReader in the first step, the JIT knows the exact class and calls it directly. The conversion code itself doesn’t change. This is less elegant than one generic method, but of the alternatives I tried, it was the only one that was fast on both one column type and eight.

CaseOne class per typePlain virtual methodsSpeedup
GetInt32 on eight INTEGER columns10.7 ms10.6 ms1.01×
GetFieldValue<int?> on eight nullable INTEGER columns21.3 ms20.5 ms1.04×
GetInt64 on eight integer columns of different types28.5 ms15.4 ms1.85×
GetDouble on eight integer columns of different types31.4 ms17.4 ms1.81×
Control: reading the rows without their values4.1 ms4.2 ms0.98×

GetInt32 and GetFieldValue<int?> read the stored type and take the fast path, so this commit doesn’t affect them; the 1.04× is within noise.

GetString takes the same route through ReadString. The step tables don’t cover strings, but the results table at the top does: reading 200k short ASCII strings is 1.35 times as fast (4.97 ms against 3.66 ms). That also includes a change in how the UTF-8 bytes are decoded into a string (Encoding.UTF8.GetString). The longer the strings, the smaller the gain: medium ones are 1.16 times as fast, and long ones take the same time as before, because decoding the bytes and allocating the string take most of the time.

Making nullable reads faster

GetFieldValue<int?> has a problem of its own. Its only type parameter is int?, so it can’t call ReadValidValue<int>: in generic code there’s no way to name the int inside T. Before this fix, NullableHandler<T> got around this with a delegate per nullable type, built once from an expression tree:

// NullableHandler<T>, before the fix

// For T = int?, builds a delegate equivalent to:
//   (VectorDataReaderBase reader, ulong offset) =>
//       reader.IsValid(offset)
//           ? (int?)reader.ReadValidValue<int>(offset)
//           : default(int?)
private static Func<VectorDataReaderBase, ulong, T> Compile()
{
  // ...
  var getValidValue = Expression.Call(reader, genericGetValidValue, offset);
  var body = Expression.Condition(isValid, Expression.Convert(getValidValue, type), Expression.Default(type));

  return Expression.Lambda<Func<VectorDataReaderBase, ulong, T>>(body, reader, offset).Compile();
}

The delegate avoids boxing, but a delegate compiled from an expression tree is compiled once and never optimized again. Unlike ordinary methods, it doesn’t go through tiered compilation: there’s no Tier 0, no profile data and no Tier 1 recompile, so the JIT never learns which path is hot. The fix replaces the expression tree with an ordinary generic class and binds the delegate to an instance of it. The call site doesn’t change; only what the delegate points to does:

private sealed class NullableReader<TValue> where TValue : struct
{
  public TValue? Read(VectorDataReaderBase reader, ulong offset)
  {
    return reader.IsValid(offset) ? reader.ReadValidValue<TValue>(offset) : null;
  }
}

// VectorDataReaderBase.GetValue<T>, which GetFieldValue<T> calls
if (NullableHandler<T>.IsNullableValueType)
{
  return NullableHandler<T>.Read(this, offset);
}

// NullableHandler<T> builds Read once for each T. For T = int?, it's
//   Read = new NullableReader<int>().Read;
// a delegate for the Read method of a NullableReader<int>.

Read is ordinary code, so the JIT compiles it in tiers with profile data, like any other method. It’s an instance method because a delegate for a static method was slower to call in local measurements. The real code names the common types one by one (int, long, DateTime, Guid and so on), so that trimmed and Native AOT applications have a NullableReader for each of them. Any other type is found through reflection.

CasePlain virtual methodsOrdinary methodSpeedup
GetInt32 on eight INTEGER columns10.6 ms10.3 ms1.03×
GetFieldValue<int?> on eight nullable INTEGER columns20.5 ms15.9 ms1.29×
GetInt64 on eight integer columns of different types15.4 ms15.3 ms1.01×
GetDouble on eight integer columns of different types17.4 ms17.3 ms1.00×
Control: reading the rows without their values4.2 ms4.3 ms1.00×

Only the nullable read changes.

All the reader changes together

Together, the five changes make these reads 4 to 12 times as fast as in 1.5.6:

Case1.5.61.5.6.1Speedup
GetInt32 on eight INTEGER columns59.8 ms10.5 ms5.69×
GetFieldValue<int?> on eight nullable INTEGER columns70.4 ms15.4 ms4.57×
GetInt64 on eight integer columns of different types60.3 ms15.2 ms3.96×
GetDouble on eight integer columns of different types215.3 ms17.8 ms12.11×
Control: reading the rows without their values4.1 ms3.9 ms1.04×

GetDouble also allocates 18 KB instead of 366 MB. The control doesn’t move, so all of the time saved is in DuckDB.NET.

Here is the Tier1 code of GetInt32 in 1.5.6.1, from the same program built against the 1.5.6.1 package, on .NET 10.0.13 (edited for brevity):

; DuckDBDataReader:GetInt32(int), DuckDB.NET 1.5.6.1
       cmp      qword ptr [rbx+0x40], 0            ; has Read() been called?
       je       SHORT G_M000_IG06
       mov      rcx, gword ptr [rbx+0x28]          ; the column readers
       cmp      esi, dword ptr [rcx+0x08]          ; bounds check of the ordinal
       jae      G_M000_IG10
       mov      edx, esi
       mov      rsi, gword ptr [rcx+8*rdx+0x10]    ; the column's reader
       mov      rdx, qword ptr [rbx+0x40]
       dec      rdx                                ; the row within the chunk
       cmp      qword ptr [rsi+0x28], 0            ; does the column have NULLs?
       jne      SHORT G_M000_IG07                  ; then check the row's validity bit
       mov      rcx, 0x15BA4D83120                 ; typeof(int)
       cmp      gword ptr [rsi+0x20], rcx          ; StorageType == typeof(int)?
       jne      SHORT G_M000_IG08
       mov      rcx, qword ptr [rsi+0x30]          ; DataPointer
       mov      eax, dword ptr [rcx+4*rdx]         ; the value
       ...
       ret

G_M000_IG08:                                       ; any other column type
       mov      rax, qword ptr [rsi]               ; the reader's method table
       mov      rax, qword ptr [rax+0x48]
       call     [rax+0x20]                         ; ReadInt32, a plain virtual call

GetFieldValue<int> and ReadValidValue<int> are inlined into GetInt32, and there’s no CORINFO_HELP_VIRTUAL_FUNC_PTR left. The two mov instructions that do the work are still there, now next to the row, bounds and NULL checks, and the fast path costs one comparison of StorageType with typeof(int). For any other column type, GetInt32 calls ReadInt32 through the method-table slot: two loads and a call, as described for normal virtual methods above.

What’s next

Reading is only one part of DuckDB.NET 1.5.6.1. The next posts in this series cover appending data, including the DateTime append that went from 2.8 GB of allocations to 93 KB, then dates, times and decimals, and scalar and table functions. All of these changes are in DuckDB.NET 1.5.6.1 on NuGet, and they carry over to DuckDB.NET 2.0. To upgrade, run:

dotnet add package DuckDB.NET.Data.Full --version 1.5.6.1

DuckDB.NET.Data.Full includes the native DuckDB library. If you supply the library yourself, use DuckDB.NET.Data instead.

Avatar
Giorgi Dalakishvili
Backend Engineer & Software Architect · 3x Microsoft MVP · International Speaker

Related