Skip to content

Box-free POCO read fast path (#509) - #449

Open
alex-clickhouse wants to merge 5 commits into
mainfrom
poco-read-boxfree
Open

Box-free POCO read fast path (#509)#449
alex-clickhouse wants to merge 5 commits into
mainfrom
poco-read-boxfree

Conversation

@alex-clickhouse

@alex-clickhouse alex-clickhouse commented Jul 20, 2026

Copy link
Copy Markdown
Collaborator

What

Makes ClickHouseClient.QueryAsync<T> materialize rows without per-value boxing, and adds multiple read representations per column. Mirrors the box-free write path (#434).

Today the POCO read path boxes twice per value-type cell: once in Read() (into the shared object[] row buffer) and once unboxing in the compiled Action<T,object> MapTo<T> setter. QueryAsync<T> now compiles a per-column delegate that reads each value straight from the stream into the strongly-typed property, eliminating both.

How

  • ITypedReader<T> (analog of ITypedWriter<T>) implemented across every scalar/value type. Each type's boxed Read() delegates to its typed ReadValue, so the two paths are byte-identical by construction.
  • PocoReadExpressionFactory dispatches via ITypedReader<targetClrType>, transparently unwrapping Nullable(T) and LowCardinality(T).
  • PocoTypeRegistry compiles + caches a per-wire-column delegate array, keyed by (T, wire-column signature). Per-column mixing: box-free typed read, a per-column boxed fallback for composites (byte-identical to MapTo, with fail-fast validation), or a discard for unmapped columns — so one composite column doesn't disable the fast path for its scalar siblings.
  • Shared PocoColumnAssignment keeps MapTo and the fallback's validation/errors identical.
  • A read value converter or unregistered type falls back entirely to the existing path.

Multiple read representations (selected by property type; QueryAsync<T> only — MapTo<T> keeps strict exact-type rules)

Column Property types
DateTime / DateTime64 / Date DateTime, DateTimeOffset, DateOnly
String / FixedString string, byte[]
Decimal decimal, ClickHouseDecimal
Enum8 / Enum16 string (label), int (stored numeric value)
Int128 / UInt128 BigInteger, or native System.Int128/UInt128 (net8+)

Results (PocoReadBenchmark, long/string/double, local WSL)

Count Before After Δ Gen0/1k
100k 12.23 MB 7.66 MB −37% 2000→1000
500k 61.06 MB 38.17 MB −38% 10000→6000

GetValue/GetFieldValue<T> (ADO contract) unchanged.

Testing

  • New PocoReadFastPathTests (23 tests): all-scalar parity vs the boxed path, every multi-type binding, Nullable variants, LowCardinality, per-column mixing with composite fallback, unmapped-column alignment, fail-fast validation, unregistered-type error.
  • Enum coverage: Enum8/Enum16 read as both label and int over the same column (negative and beyond-sbyte values pin each wire width), Nullable(Enum8) into int?, an int-property insert round-tripping through a real Enum8 column, and the fail-fast error for a non-nullable int on a Nullable(Enum) column (which needs int?).
  • Sub-agent review: no blockers, byte-identity confirmed across all ~25 types.

QueryAsync<T> now materializes rows with compiled per-column delegates that
read each value straight from the response stream into the target property,
bypassing the reader's shared object[] row buffer and the boxing/unboxing
MapTo<T> setter. This removes the box (on decode) and the unbox (on assign)
per value-type property, per row: ~38% fewer managed allocations and roughly
half the Gen0 collections on a 500k-row 3-column read.

Mirrors the box-free write path (#434): a new ITypedReader<T> interface,
implemented across every scalar/value type; PocoReadExpressionFactory dispatches
on it (transparently unwrapping Nullable(T)/LowCardinality(T)); PocoTypeRegistry
compiles and caches a per-wire-column delegate array. Columns are mixed
per-column: typed box-free read, a boxed fallback (byte-identical to MapTo, with
fail-fast validation) for composites, or a discard for unmapped columns.

Also adds multiple read representations, selected by the property type
(QueryAsync<T> only; MapTo<T> keeps its strict exact-type rules):
- DateTime/DateTime64/Date -> DateTime, DateTimeOffset, or DateOnly
- String/FixedString -> string or byte[]
- Decimal -> decimal or ClickHouseDecimal
- Int128/UInt128 -> BigInteger or native System.Int128/UInt128 (net8+)

Each type's boxed Read() delegates to its typed ReadValue, so both paths are
byte-identical by construction. A read value converter or unregistered type
falls back entirely to the existing path.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
@github-actions

github-actions Bot commented Jul 20, 2026

Copy link
Copy Markdown

Triage

Category: perfRisk: high

Summary
Adds a box-free POCO read fast path for ClickHouseClient.QueryAsync<T>. Today the POCO read path boxes every value-type column into object[] (in Read()) and immediately unboxes it (in the MapTo<T> compiled setter); this PR eliminates both operations by compiling per-column delegates (ITypedReader<T>) that read each value straight from the binary stream into the typed property, bypassing the shared row buffer. The PR also introduces multiple CLR-type bindings per column (DateTime→DateTimeOffset/DateOnly, String→byte[], Decimal→ClickHouseDecimal, Int128→System.Int128), and a new Map(K,V) → List<KVP<K,V>>/KVP<K,V>[] materialization path. Reported allocation reduction: ~38% in a 500k-row/3-column POCO read.

What this impacts

  • ClickHouse.Driver/Types/ — 25+ type files gain ITypedReader<T> implementations; the existing Read() on each now delegates to ReadValue(), changing the boxed read path as well
  • ADO/Readers/ClickHouseDataReader.cs — two new internal methods (TryGetRowMaterializer<T>, TryMaterializeNextRow<T>) drive the fast path from the stream
  • ClickHouseClient.QueryAsync<T> — branching logic selects fast vs. fallback per result shape; observable behaviour changes (new type coercions, Map→KVP collection, per-column mixing)
  • Poco/ — three new source files: PocoReadExpressionFactory, PocoColumnAssignment, MapMaterializer; delegate compilation and caching in PocoTypeRegistry

Concerns

  • Type system — High rule fires: 25+ files in Types/ modified; read path split into typed fast and boxed fallback branches. Byte-identity is asserted by construction but any divergence in stream alignment would silently corrupt all subsequent columns — exactly the class of bugs integration tests can miss if column ordering in test queries doesn't hit edge cases.
  • Cross-module refactor — High rule fires: touches ADO/ (DataReader), Types/ (25+ scalar types), and Utility/-level ClickHouseClient.cs — three or more hot-path modules.
  • TryMaterializeNextRow stream state: the fast path sets hasCurrentRow = false only on EOF, not on success; the state is not consumed by this path, but it should be verified that no code path can interleave MapTo<T> and TryMaterializeNextRow on the same reader and see stale state.
  • Reflection in delegate compilation: PocoReadExpressionFactory.TryBuildTypedRead calls typeof(ITypedReader<>).MakeGenericType(clrType) and .GetMethod(...) — acceptable because results are cached per (T, wire-column signature), but adds GC pressure on first use per distinct POCO shape.
  • Behavioural surface expansion beyond perf: new type coercions (DateOnly, DateTimeOffset, byte[], KVP collections) are user-visible feature additions bundled into a perf PR; reviewers should check whether these belong in a separate issue/PR or whether the scope is intentional.

Required reviewer action

  • PR body must include an architectural description before review.

Copilot AI left a comment

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Pull request overview

This PR introduces a new box-free materialization fast path for ClickHouseClient.QueryAsync<T>(), reducing per-cell allocations by reading values directly from the response stream into POCO properties. It extends the type system with typed read support (ITypedReader<T>) and adds selective per-column fallback so composite columns don’t disable fast reads for scalar siblings.

Changes:

  • Add ITypedReader<T> and implement typed, non-boxing reads across scalar/value ClickHouse types (and selected multi-representation reads like DateTimeOffset, DateOnly, byte[], native Int128/UInt128 on .NET 8+).
  • Implement a POCO read fast-path pipeline: expression factory dispatch (PocoReadExpressionFactory), assignment rule sharing (PocoColumnAssignment), and cached per-shape row materializers (PocoTypeRegistry).
  • Update QueryAsync<T>() to use the new fast path when available, and add a dedicated test suite validating parity and binding behavior.

Reviewed changes

Copilot reviewed 39 out of 39 changed files in this pull request and generated no comments.

Show a summary per file
File Description
RELEASENOTES.md Document allocation/perf impact and new QueryAsync<T> binding capabilities.
CHANGELOG.md Changelog entry for the new POCO read fast path and multi-representation bindings.
ClickHouse.Driver/Types/ITypedReader.cs Introduce ITypedReader<T> to enable non-boxing deserialization.
ClickHouse.Driver/Types/AbstractDateTimeType.cs Add typed reads for DateTime/DateTimeOffset/DateOnly and centralize canonical boxed read.
ClickHouse.Driver/Types/DateType.cs Adapt Date decoding to the new AbstractDateTimeType read model.
ClickHouse.Driver/Types/Date32Type.cs Adapt Date32 decoding to the new AbstractDateTimeType read model.
ClickHouse.Driver/Types/DateTimeType.cs Split instant decoding and add typed DateTimeOffset support via base hooks.
ClickHouse.Driver/Types/DateTime64Type.cs Share wire read between DateTime and DateTimeOffset typed reads.
ClickHouse.Driver/Types/AbstractBigIntegerType.cs Add typed BigInteger read path to support fast materialization.
ClickHouse.Driver/Types/Int128Type.cs Add native Int128 typed read on .NET 8+.
ClickHouse.Driver/Types/UInt128Type.cs Add native UInt128 typed read on .NET 8+.
ClickHouse.Driver/Types/DecimalType.cs Add typed reads for decimal and ClickHouseDecimal, keeping boxed behavior canonical.
ClickHouse.Driver/Types/StringType.cs Provide both string and byte[] typed representations while preserving boxed mode selection.
ClickHouse.Driver/Types/FixedStringType.cs Provide both string and byte[] typed representations while preserving boxed mode selection.
ClickHouse.Driver/Types/UuidType.cs Add typed Guid read for fast POCO assignment.
ClickHouse.Driver/Types/IPv4Type.cs Add typed IPAddress read for fast POCO assignment.
ClickHouse.Driver/Types/IPv6Type.cs Add typed IPAddress read for fast POCO assignment.
ClickHouse.Driver/Types/BooleanType.cs Add typed bool read for fast POCO assignment.
ClickHouse.Driver/Types/BFloat16Type.cs Add typed float read for fast POCO assignment.
ClickHouse.Driver/Types/Int8Type.cs Add typed sbyte read for fast POCO assignment.
ClickHouse.Driver/Types/Int16Type.cs Add typed short read for fast POCO assignment.
ClickHouse.Driver/Types/Int32Type.cs Add typed int read for fast POCO assignment.
ClickHouse.Driver/Types/Int64Type.cs Add typed long read for fast POCO assignment.
ClickHouse.Driver/Types/UInt8Type.cs Add typed byte read for fast POCO assignment.
ClickHouse.Driver/Types/UInt16Type.cs Add typed ushort read for fast POCO assignment.
ClickHouse.Driver/Types/UInt32Type.cs Add typed uint read for fast POCO assignment.
ClickHouse.Driver/Types/UInt64Type.cs Add typed ulong read for fast POCO assignment.
ClickHouse.Driver/Types/Float32Type.cs Add typed float read for fast POCO assignment.
ClickHouse.Driver/Types/Float64Type.cs Add typed double read for fast POCO assignment.
ClickHouse.Driver/Types/Enum8Type.cs Add typed string read to avoid boxing for enum materialization.
ClickHouse.Driver/Types/Enum16Type.cs Add typed string read to avoid boxing for enum materialization.
ClickHouse.Driver/Poco/PocoReadExpressionFactory.cs Build per-column, typed read expressions driven by ITypedReader<T>, with Nullable/LowCardinality handling.
ClickHouse.Driver/Poco/PocoColumnAssignment.cs Centralize assignment validation/error text used by both MapTo<T> and fast-path fallback.
ClickHouse.Driver/Poco/PocoTypeRegistry.cs Cache and build per-shape per-column row materializers with mixed fast/fallback/discard behavior.
ClickHouse.Driver/ADO/Readers/ClickHouseDataReader.cs Expose internal fast-path hooks (TryGetRowMaterializer, TryReadRow) and reuse shared assignment logic.
ClickHouse.Driver/ClickHouseClient.cs Switch QueryAsync<T> to use fast path when available, otherwise retain the existing boxed loop.
ClickHouse.Driver.Tests/Copy/PocoReadFastPathTests.cs Add coverage for parity, multi-type bindings, Nullable/LowCardinality, mixed composite fallback, and failure modes.

@alex-clickhouse
alex-clickhouse marked this pull request as ready for review July 20, 2026 11:34
@alex-clickhouse
alex-clickhouse requested a review from mzitnik as a code owner July 20, 2026 11:34
alex-clickhouse and others added 4 commits July 20, 2026 15:57
AllScalarsPoco tested Float32/Float64 but not BFloat16 (which also
materializes into a float via its own ITypedReader<float>). Add a
BFloat16 column using an exactly-representable value.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
Clearer than TryReadRow (which collided conceptually with the public
Read()): the method advances to and materializes the next row into T,
pairing with TryGetRowMaterializer.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
Read: QueryAsync<T> can materialize a Map(K,V) column into a
List<KeyValuePair<K,V>> or KeyValuePair<K,V>[] property (in addition to
Dictionary<K,V>), via a MapType special case in PocoReadExpressionFactory
that dispatches to a generic MapMaterializer closed over K,V. The list/array
form preserves on-wire entry order and duplicate keys, which a Dictionary
collapses. Dictionary properties are unchanged (boxed path).

Write: MapType.Write now also accepts an ordered collection of
KeyValuePair<,> (e.g. List / array), not just IDictionary — so such a POCO
property round-trips into a Map column on binary insert.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
Enum8Type and Enum16Type now implement ITypedReader<int> alongside
ITypedReader<string>, so a QueryAsync<T> property can bind to either the
enum label or the stored numeric value. Both overloads differ only by
return type, so they become explicit interface impls sharing a private
ReadLabel helper — the pattern StringType and AbstractDateTimeType
already use for their multiple representations.

The numeric read consumes the same bytes as the label read (1 for Enum8,
2 for Enum16), keeping it byte-identical to the boxed path, and skips the
label lookup: the raw value cannot fail on a member absent from the
column's enum definition.

An int property on an enum column previously threw at plan time, since
the boxed fallback yields a label string that is not assignable to int.
A Nullable(Enum) column still needs an int? property.

Co-Authored-By: Claude <noreply@anthropic.com>
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Projects

None yet

Development

Successfully merging this pull request may close these issues.

2 participants