Skip to content

perf(types): two more per-value allocations — AbstractBigIntegerType.WriteValue (72-88 B/value) and the multidim blit gate (24-72 B/element) #553

Description

@polyglotAI-bot

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

  1. Check out main (24398b0).
  2. Measure allocations with GC.GetAllocatedBytesForCurrentThread() around
    ClickHouseType.Write — the same idiom as
    ClickHouse.Driver.Tests/Types/MultiDimArrayHelperTests.cs:117-141.
  3. 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

Metadata

Metadata

Labels

bugSomething isn't working

Type

No type

Projects

No projects

Milestone

No milestone

Relationships

None yet

Development

No branches or pull requests

Issue actions