Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
Show all changes
16 commits
Select commit Hold shift + click to select a range
5a3bb05
feat(poco): read wire-transparent wrapper types on the box-free fast …
alex-clickhouse Aug 2, 2026
1ca63c9
perf(ado): decode rows into typed column slots instead of a boxed obj…
alex-clickhouse Aug 2, 2026
8223e0d
perf(ado): read GetFieldValue<T> straight out of the typed slot
alex-clickhouse Aug 2, 2026
20a2881
perf(ado): read the typed accessors straight out of the slot
alex-clickhouse Aug 2, 2026
3ee53de
bench(ado): add the ADO read-path benchmark
alex-clickhouse Aug 2, 2026
049c0ab
test(ado): guard the slot factory against evaluating FrameworkType to…
alex-clickhouse Aug 2, 2026
bb8dde9
fix(ado)!: throw when a column value is read with no current row
alex-clickhouse Aug 2, 2026
be6e988
refactor(ado): select column slots without runtime generic construction
alex-clickhouse Aug 2, 2026
ed246f5
perf(ado): stop boxing populated nullable Bool and Decimal cells
alex-clickhouse Aug 3, 2026
f6c3848
perf(ado): build column slots on the first Read() rather than in the …
alex-clickhouse Aug 3, 2026
23c2242
test(poco): materialize the SimpleAggregateFunction column, don't jus…
alex-clickhouse Aug 3, 2026
9119b39
docs: say which converter overload each read path uses
alex-clickhouse Aug 4, 2026
33f9938
docs: trim the slot commentary to what the code does not already say
alex-clickhouse Aug 4, 2026
45015c6
docs: add the changelog fragments for the typed column slots
alex-clickhouse Aug 6, 2026
3223c02
docs(ado): clarify slot allocation tradeoffs
alex-clickhouse Aug 7, 2026
ba8461c
perf(ado): convert typed accessor reads on the generic overload
alex-clickhouse Aug 12, 2026
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
8 changes: 5 additions & 3 deletions AGENTS.md
Original file line number Diff line number Diff line change
Expand Up @@ -22,7 +22,7 @@ ClickHouse.Driver.sln
│ ├── Types/ # 60+ ClickHouse type implementations + TypeConverter.cs
│ ├── Copy/ # Binary serialization (used internally by ClickHouseClient)
│ ├── Http/ # HTTP layer & connection pooling
│ └── PublicAPI/ # Public API surface tracking (analyzer-enforced)
│ └── PublicAPI/ # Public API surface tracking (hand-maintained)
├── ClickHouse.Driver.Tests/ # NUnit tests (multi-framework)
├── ClickHouse.Driver.IntegrationTests/ # Integration tests (net10.0)
└── ClickHouse.Driver.Benchmark/ # BenchmarkDotNet performance tests
Expand All @@ -35,7 +35,9 @@ Prefer using LSP to grep when navigating the codebase.
- **Type system**: `Types/TypeConverter.cs` (14KB, complex), `Types/Grammar/` (type parsing)
- **ADO.NET layer**: `ADO/ClickHouseConnection.cs`, `ADO/ClickHouseCommand.cs`, `ADO/Readers/`
- **Feature detection**: `Utility/ClickHouseFeatureMap.cs` (version-based capabilities)
- **Public API**: `PublicAPI/*.txt` (Roslyn analyzer enforces shipped signatures)
- **Public API**: `PublicAPI/*.txt` (hand-maintained record of shipped signatures; the
`Microsoft.CodeAnalysis.PublicApiAnalyzers` package is *not* referenced, so nothing checks these
files at build time — keep them in sync yourself)
- **Config**: `.editorconfig` (file-scoped namespaces, StyleCop suppressions)

### API Architecture
Expand Down Expand Up @@ -241,7 +243,7 @@ If the value is null/`DBNull` and no explicit type or hint is provided, resoluti
- **Connection state**: Clear logging of connection lifecycle events

### Public API Surface
- **Breaking changes**: Must update `PublicAPI/*.txt` files (analyzer enforces)
- **Breaking changes**: Must update `PublicAPI/*.txt` files (by hand — no analyzer enforces this)
- **ADO.NET compliance**: Follow ADO.NET patterns and interfaces correctly
- **Dispose patterns**: Proper `IDisposable` implementation, no resource leaks

Expand Down
135 changes: 135 additions & 0 deletions ClickHouse.Driver.Benchmark/AdoReadPathBenchmark.cs
Original file line number Diff line number Diff line change
@@ -0,0 +1,135 @@
using System;
using System.Threading.Tasks;
using BenchmarkDotNet.Attributes;
using BenchmarkDotNet.Engines;
using ClickHouse.Driver.ADO;
using ClickHouse.Driver.ADO.Readers;
using ClickHouse.Driver.Utility;

namespace ClickHouse.Driver.Benchmark;

/// <summary>
/// The four ways a caller drives <see cref="ClickHouseDataReader"/>, over one realistic wide row.
///
/// <para><see cref="ReadValueBenchmark"/> measures single-column reads with and without a converter, which
/// isolates per-accessor cost but hides the thing typed column slots actually changed: how much a row costs
/// to decode <i>before</i> anyone looks at it, and how that scales with the fraction of columns read. The
/// old <c>object[]</c> row buffer boxed every cell during <c>Read()</c>, so all five variants below allocated
/// identically — even <see cref="Scan"/>, which reads nothing.</para>
///
/// <list type="bullet">
/// <item><see cref="Scan"/> — the floor: decode cost with no accessor calls at all.</item>
/// <item><see cref="TypedAccessors"/> — the linq2db path. Its compiled mapper inlines
/// <c>GetInt64</c>/<c>GetDouble</c>/<c>GetString</c>/<c>GetDateTime</c>/<c>GetGuid</c> per column per row.</item>
/// <item><see cref="GenericAccessor"/> — hand-written <c>GetFieldValue&lt;T&gt;</c> code.</item>
/// <item><see cref="UntypedAccessor"/> — the Dapper path. Its emitted IL calls the <c>this[int]</c> indexer,
/// i.e. <c>GetValue</c>, so it still boxes and pays one fixed slot allocation per returned column; this
/// variant is the "must not regress" control.</item>
/// <item><see cref="TypedAccessorsProjected"/> — reads 2 of 10 columns, the case the old eager boxing
/// punished hardest.</item>
/// </list>
/// </summary>
[Config(typeof(ComparisonConfig))]
[MemoryDiagnoser(true)]
public class AdoReadPathBenchmark
{
private readonly Consumer consumer = new();
private ClickHouseConnection connection;

// The short cases expose the fixed per-reader slot cost that a 200k-row allocation total rounds away.
[Params(1, 10, 200000)]
public int Count { get; set; }

// Ten columns, eight of them value types — the shape that used to box eight times per row.
private string Sql => $@"
SELECT toInt64(number) AS c0,
toInt64(number * 2) AS c1,
toInt64(number * 3) AS c2,
toInt64(number * 5) AS c3,
toFloat64(number) * 0.5 AS c4,
toFloat64(number) * 1.5 AS c5,
concat('s', toString(number % 8)) AS c6,
concat('t', toString(number % 4)) AS c7,
toDateTime(1700000000 + (number % 65536), 'UTC') AS c8,
toUUID(concat('00000000-0000-0000-0000-', leftPad(toString(number % 1000), 12, '0'))) AS c9
FROM system.numbers LIMIT {Count}";

[GlobalSetup]
public void Setup()
{
var connectionString = Environment.GetEnvironmentVariable("CLICKHOUSE_CONNECTION") ?? "Host=localhost";
connection = new ClickHouseConnection(new ClickHouseClientSettings(connectionString));
}

[GlobalCleanup]
public void Cleanup() => connection?.Dispose();

[Benchmark(Baseline = true)]
public async Task UntypedAccessor()
{
using var reader = await connection.ExecuteReaderAsync(Sql);
while (reader.Read())
{
for (var i = 0; i < 10; i++)
consumer.Consume(reader.GetValue(i));
}
}

[Benchmark]
public async Task Scan()
{
using var reader = await connection.ExecuteReaderAsync(Sql);
while (reader.Read())
{
}
}

[Benchmark]
public async Task TypedAccessors()
{
using var reader = (ClickHouseDataReader)await connection.ExecuteReaderAsync(Sql);
while (reader.Read())
{
consumer.Consume(reader.GetInt64(0));
consumer.Consume(reader.GetInt64(1));
consumer.Consume(reader.GetInt64(2));
consumer.Consume(reader.GetInt64(3));
consumer.Consume(reader.GetDouble(4));
consumer.Consume(reader.GetDouble(5));
consumer.Consume(reader.GetString(6));
consumer.Consume(reader.GetString(7));
consumer.Consume(reader.GetDateTime(8));
consumer.Consume(reader.GetGuid(9));
}
}

[Benchmark]
public async Task GenericAccessor()
{
using var reader = (ClickHouseDataReader)await connection.ExecuteReaderAsync(Sql);
while (reader.Read())
{
consumer.Consume(reader.GetFieldValue<long>(0));
consumer.Consume(reader.GetFieldValue<long>(1));
consumer.Consume(reader.GetFieldValue<long>(2));
consumer.Consume(reader.GetFieldValue<long>(3));
consumer.Consume(reader.GetFieldValue<double>(4));
consumer.Consume(reader.GetFieldValue<double>(5));
consumer.Consume(reader.GetFieldValue<string>(6));
consumer.Consume(reader.GetFieldValue<string>(7));
consumer.Consume(reader.GetFieldValue<DateTime>(8));
consumer.Consume(reader.GetFieldValue<Guid>(9));
}
}

[Benchmark]
public async Task TypedAccessorsProjected()
{
using var reader = (ClickHouseDataReader)await connection.ExecuteReaderAsync(Sql);
while (reader.Read())
{
consumer.Consume(reader.GetInt64(0));
consumer.Consume(reader.GetString(6));
}
}
}
Loading
Loading