Describe the bug
Two per-value heap allocation sites remain on the binary write path, in the same
class as #549 but not among the three sites that issue names. Both were found while
working on #549 / #550, verified by measurement on main (24398b0), and both already
have an in-repo precedent for the fix.
Neither changes a byte on the wire — the values are correct, the garbage is not.
1. AbstractBigIntegerType.WriteValue allocates two arrays per value.
ClickHouse.Driver/Types/AbstractBigIntegerType.cs:41-70 does
byte[] bigIntBytes = value.ToByteArray(); // heap
byte[] decimalBytes = new byte[Size]; // heap
...
writer.Write(decimalBytes);
so every Int128 / UInt128 / Int256 / UInt256 value written costs two arrays.
The read side of the very same class (:15-20) already uses stackalloc — only the
write side was missed.
2. The multidimensional blit fast path declines on leaves that are wire-identical to a
blittable one.
ClickHouse.Driver/Types/MultiDimArrayHelper.cs:64 gates the fast path added in #390
(for #367) on TryGetBlittableElementSize(leafType), which matches on the concrete
leaf class (Int32Type or UInt32Type or Float32Type => 4, …). A leaf wrapped in a
transparent wrapper — LowCardinality(Int32), SimpleAggregateFunction(any, Int32) —
is a different class, so the whole array falls back to WriteAxis (:149), which boxes
every element through Array.GetValue(int[]). Those wrappers delegate Write straight
to the underlying type (LowCardinalityType.cs:31), so the emitted bytes are identical
— verified below — and the fallback buys nothing.
The same line also means leaves that do have an ITypedWriter<T> (UUID, the
DateTime family, Decimal) get no typed dispatch at all in a multidim write; they box
per element too.
Steps to reproduce
- Check out
main (24398b0).
- Measure allocations with
GC.GetAllocatedBytesForCurrentThread() around
ClickHouseType.Write — the same idiom as
ClickHouse.Driver.Tests/Types/MultiDimArrayHelperTests.cs:117-141.
- See the numbers below.
Expected behaviour
Int128/UInt128/Int256/UInt256 writes allocate 0 B/value, like every other
fixed-width scalar (Int64 already measures 0 B/value).
int[400,400] into Array(Array(LowCardinality(Int32))) allocates the same as into
Array(Array(Int32)) (~0 B/element), because the two produce byte-identical output.
Code example
Measured on main @ 24398b0, .NET 10.0.400, Release, server-free:
| case |
allocation |
Int128 write, 10 000 values |
72.0 B/value |
UInt128 write, 10 000 values |
72.0 B/value |
Int256 write, 10 000 values |
88.8 B/value |
UInt256 write, 10 000 values |
88.0 B/value |
Int64 write (control, already fixed) |
0.0 B/value |
int[400,400] (160 000 elements) into… |
allocation |
Array(Array(Int32)) — blit path |
0.0 B/element (2 072 B total) |
Array(Array(LowCardinality(Int32))) |
24.0 B/element (3 840 032 B) |
Array(Array(SimpleAggregateFunction(any, Int32))) |
24.0 B/element (3 840 032 B) |
Array(Array(Nullable(Int32))) — control, legitimately not blittable |
24.0 B/element |
Guid[400,400] into Array(Array(UUID)) |
72.0 B/element |
DateTime[400,400] into Array(Array(DateTime)) |
24.0 B/element |
Wire parity check (int[3,4], negative and positive values), byte-for-byte:
Array(Array(LowCardinality(Int32))) equal=True len=52/52
Array(Array(SimpleAggregateFunction(any, Int32))) equal=True len=52/52
So the wrapped-leaf fallback costs 3.8 MB of boxes per 160 k elements to emit exactly the
same 52-byte-per-shape output the blit path emits for free.
Suggested fix
Site 1 — mirror DecimalType.WriteBigInteger
(ClickHouse.Driver/Types/DecimalType.cs:141-156), which was rewritten for exactly this
in #432: stackalloc byte[Size] + BigInteger.TryWriteBytes + explicit
Slice(bytesWritten).Fill(sign < 0 ? 0xFF : 0x00). Size is 16 or 32 here, so the stack
buffer is safe. Two behaviours must be preserved: the ArgumentException for a negative
value on an unsigned type, and the OverflowException when the mantissa does not fit
(today \"Got {n} bytes, {Size} expected\") — the current code's unsigned path drops a
trailing zero sign byte, which TryWriteBytes never emits, so the byte output is
unchanged.
Site 2 — resolve the leaf through its transparent wrappers before the
TryGetBlittableElementSize / FrameworkType check, so LowCardinality(T) and
SimpleAggregateFunction(f, T) reach the blit path. Nullable(T) must not be
unwrapped (it carries a per-element marker byte, so its 24 B/element is correct and is
the control above). A typed-writer dispatch for UUID/DateTime/Decimal leaves is a
larger, separate change and is deliberately not proposed here.
Error log
n/a — no error; correct values, excess allocation.
Configuration
Environment
- Client version:
main @ 24398b0
- Language version: C# 13
- .NET version: 10.0.400 (measured on
net10.0)
- OS: Linux (Debian, container)
ClickHouse server
- ClickHouse Server version: 26.7.3.19 (the measurements are server-free; both paths are
pure client-side serialization)
- Non-default settings: none
CREATE TABLE: n/a — measured directly on ClickHouseType.Write
Notes
Describe the bug
Two per-value heap allocation sites remain on the binary write path, in the same
class as #549 but not among the three sites that issue names. Both were found while
working on #549 / #550, verified by measurement on
main(24398b0), and both alreadyhave an in-repo precedent for the fix.
Neither changes a byte on the wire — the values are correct, the garbage is not.
1.
AbstractBigIntegerType.WriteValueallocates two arrays per value.ClickHouse.Driver/Types/AbstractBigIntegerType.cs:41-70doesso every
Int128/UInt128/Int256/UInt256value written costs two arrays.The read side of the very same class (
:15-20) already usesstackalloc— only thewrite side was missed.
2. The multidimensional blit fast path declines on leaves that are wire-identical to a
blittable one.
ClickHouse.Driver/Types/MultiDimArrayHelper.cs:64gates the fast path added in #390(for #367) on
TryGetBlittableElementSize(leafType), which matches on the concreteleaf class (
Int32Type or UInt32Type or Float32Type => 4, …). A leaf wrapped in atransparent wrapper —
LowCardinality(Int32),SimpleAggregateFunction(any, Int32)—is a different class, so the whole array falls back to
WriteAxis(:149), which boxesevery element through
Array.GetValue(int[]). Those wrappers delegateWritestraightto the underlying type (
LowCardinalityType.cs:31), so the emitted bytes are identical— verified below — and the fallback buys nothing.
The same line also means leaves that do have an
ITypedWriter<T>(UUID, theDateTimefamily,Decimal) get no typed dispatch at all in a multidim write; they boxper element too.
Steps to reproduce
main(24398b0).GC.GetAllocatedBytesForCurrentThread()aroundClickHouseType.Write— the same idiom asClickHouse.Driver.Tests/Types/MultiDimArrayHelperTests.cs:117-141.Expected behaviour
Int128/UInt128/Int256/UInt256writes allocate 0 B/value, like every otherfixed-width scalar (
Int64already measures 0 B/value).int[400,400]intoArray(Array(LowCardinality(Int32)))allocates the same as intoArray(Array(Int32))(~0 B/element), because the two produce byte-identical output.Code example
Measured on
main@ 24398b0, .NET 10.0.400, Release, server-free:Int128write, 10 000 valuesUInt128write, 10 000 valuesInt256write, 10 000 valuesUInt256write, 10 000 valuesInt64write (control, already fixed)int[400,400](160 000 elements) into…Array(Array(Int32))— blit pathArray(Array(LowCardinality(Int32)))Array(Array(SimpleAggregateFunction(any, Int32)))Array(Array(Nullable(Int32)))— control, legitimately not blittableGuid[400,400]intoArray(Array(UUID))DateTime[400,400]intoArray(Array(DateTime))Wire parity check (
int[3,4], negative and positive values), byte-for-byte:So the wrapped-leaf fallback costs 3.8 MB of boxes per 160 k elements to emit exactly the
same 52-byte-per-shape output the blit path emits for free.
Suggested fix
Site 1 — mirror
DecimalType.WriteBigInteger(
ClickHouse.Driver/Types/DecimalType.cs:141-156), which was rewritten for exactly thisin #432:
stackalloc byte[Size]+BigInteger.TryWriteBytes+ explicitSlice(bytesWritten).Fill(sign < 0 ? 0xFF : 0x00).Sizeis 16 or 32 here, so the stackbuffer is safe. Two behaviours must be preserved: the
ArgumentExceptionfor a negativevalue on an unsigned type, and the
OverflowExceptionwhen the mantissa does not fit(today
\"Got {n} bytes, {Size} expected\") — the current code's unsigned path drops atrailing zero sign byte, which
TryWriteBytesnever emits, so the byte output isunchanged.
Site 2 — resolve the leaf through its transparent wrappers before the
TryGetBlittableElementSize/FrameworkTypecheck, soLowCardinality(T)andSimpleAggregateFunction(f, T)reach the blit path.Nullable(T)must not beunwrapped (it carries a per-element marker byte, so its 24 B/element is correct and is
the control above). A typed-writer dispatch for
UUID/DateTime/Decimalleaves is alarger, separate change and is deliberately not proposed here.
Error log
n/a — no error; correct values, excess allocation.
Configuration
Environment
main@ 24398b0net10.0)ClickHouse server
pure client-side serialization)
CREATE TABLE: n/a — measured directly onClickHouseType.WriteNotes
scope out
MultiDimArrayHelper.WriteAxis; Blit-based fast path for multidimensional array writes (blittable leaves) #367/Optimises blittable types for multi-dim arrays #390 added the blit fast path thisreport says is gated too narrowly; Reduce allocations in DecimalType.WriteBigInteger #432 fixed the same
BigIntegerwrite shape inDecimalTypeonly.measurement on a devbox build rather than by inspection.