Skip to content

perf(types): drop two per-value allocations on the binary write path - #556

Open
polyglotAI-bot wants to merge 4 commits into
polyglot/cs549-residual-value-allocationsfrom
polyglot/cs553-bigint-write-and-wrapped-blit
Open

perf(types): drop two per-value allocations on the binary write path#556
polyglotAI-bot wants to merge 4 commits into
polyglot/cs549-residual-value-allocationsfrom
polyglot/cs553-bigint-write-and-wrapped-blit

Conversation

@polyglotAI-bot

@polyglotAI-bot polyglotAI-bot commented Aug 15, 2026

Copy link
Copy Markdown
Collaborator

Description

Fixes #553.

Two per-value heap allocations on the RowBinary write path. Neither changes a byte on the wire — the values were already correct, the garbage was not.

  1. AbstractBigIntegerType.WriteValue allocated BigInteger.ToByteArray() plus a new byte[Size] destination for every Int128/UInt128/Int256/UInt256 value. The read side of the same class already decodes from a stackalloc span; only the write side was left behind.
  2. The multidimensional blit fast path (Optimises blittable types for multi-dim arrays #390) was gated on the concrete leaf class via TryGetBlittableElementSize. A leaf behind a wire-transparent wrapper — LowCardinality(Int32), SimpleAggregateFunction(any, Int32) — is a different class, so the whole array fell back to WriteAxis, boxing every element through Array.GetValue(int[]). Those wrappers delegate Write straight to the underlying type, so the fallback bought nothing.

Changes

  • Types/AbstractBigIntegerType.cs — write into stackalloc byte[Size] with BigInteger.TryWriteBytes(buffer, out written, isUnsigned: !Signed), then Slice(written).Fill(sign < 0 ? 0xFF : 0x00). Size is 16 or 32, so the stack buffer is bounded. Same shape as DecimalType.WriteBigInteger (Reduce allocations in DecimalType.WriteBigInteger #432). isUnsigned replaces the old "trim BigInteger's trailing sign byte" step and reports the same count in the OverflowException message; the ArgumentException for a negative value on an unsigned type is unchanged.
  • Types/MultiDimArrayHelper.cs — the blit gate now looks the leaf up through TransparentWrapper.Unwrap (LowCardinality, SimpleAggregateFunction, Object). Nullable is deliberately not unwrapped: it prefixes a per-element null marker byte, so it is not wire-transparent and keeps the per-element path. Only the gate looks through the wrapper; the slow path still receives the original leaf, and the CLR-element-type check is unchanged.
  • ClickHouse.Driver.Benchmark/MultidimArrayInsert.cs — new Leaf parameter so the existing blit-vs-boxing comparison also covers the wrapped leaves.
  • ClickHouse.Driver.Benchmark/WideIntegerInsert.cs — new benchmark for the wide-integer write path, with Int64 as the baseline.
  • changelog.d/553-...improvements.md.

Test

Tests/Misc/SerialisationTests.cs — write-side mirror of the existing read-side sign tests: a parametrized boundary set (zero, ±1, -2, signed min/max, unsigned max, 2^(bits-1) unsigned) asserting the exact little-endian two's-complement bytes including the sign/zero extension fill, plus the preserved ArgumentException (negative on unsigned) and OverflowException on both the positive and the negative overflow boundary with the exact message, plus an allocation test.

Tests/Types/MultiDimArrayHelperTests.cs — the wrapped leaves are added to the existing multidim-vs-jagged equivalence source (the jagged form takes the untouched boxing path, so it is an independent oracle), plus an allocation test for both wrappers. LowCardinality(Nullable(Float64)) with a double?[,] pins that unwrapping stops at Nullable: adding NullableType to the unwrap set makes that case fail (verified by mutation).

Measured with GC.GetAllocatedBytesForCurrentThread (net10.0), before → after:

case before after
Int128 / UInt128 write 80 B/value 0 B
Int256 / UInt256 write ~96 B/value 0 B
int[500,500] into Array(Array(LowCardinality(Int32))) 24 B/element (6.00 MB) 0 B/element (32 B total)
int[500,500] into Array(Array(SimpleAggregateFunction(any, Int32))) 24 B/element (6.00 MB) 0 B/element (32 B total)
int?[2,3] into Array(Array(Nullable(Int32))) (control) unchanged unchanged

BenchmarkDotNet (BENCH_WARMUP=1 BENCH_ITERATIONS=8 BENCH_LAUNCHES=1, ENGINE Null table, net10.0):

WideIntegerInsert — 100 000 rows, Allocated per insert:

  • Int128: 7841 KB → 28.9 KB
  • Int256: 9404 KB → 28.6 KB
  • UInt128: 7841 KB → 28.6 KB
  • UInt256: 9404 KB → 28.6 KB
  • Int64 (baseline, untouched): 28.8 KB → 28.8 KB

MultidimArrayInsert — 100 rows of int[100,100], MultidimBlit, Allocated per insert (and ratio against the jagged boxing baseline in the same run):

  • Int32 (untouched): 29.4 KB → 29.4 KB, 0.68× mean
  • LowCardinality(Int32): 23 470 KB → 30.5 KB, 1.10× → 0.79× mean
  • SimpleAggregateFunction(any, Int32): 23 471 KB → 30.3 KB, 1.06× → 0.70× mean

Means are network-bound and noisy at this iteration count; the Allocated column is the signal.

Full unit suite on net10.0: 10 525 passed, 0 failed. No existing test was changed or removed.

Pre-PR validation gate

  • Deterministic repro confirmed (the new allocation tests fail on main, pass here)
  • Root cause documented above
  • Fix targets the root cause
  • Test fails without fix, passes with fix
  • No existing tests broken
  • Changelog fragment added (dotnet run scripts/changelog.cs -- --check OK)
  • Convention compliance verified per AGENTS.md

Note

Base: polyglot/cs549-residual-value-allocations (#550), per review request. #550 is itself stacked on #499, so this is the third PR in that perf stack; the diff above is only this PR's own change.

Stacking made the duplicate unwrap helper unnecessary: MultiDimArrayHelper now calls TransparentWrapper.Unwrap from #550 instead of keeping a private copy with the same semantics (LowCardinality / SimpleAggregateFunction / Object stripped, Nullable deliberately kept). Full unit suite green on the stacked base: 10 800 passed, 0 failed (net10.0).

AbstractBigIntegerType.WriteValue allocated BigInteger.ToByteArray() plus a
byte[Size] destination per value; it now writes into a stack buffer with
BigInteger.TryWriteBytes(isUnsigned: !Signed), mirroring the read side and
DecimalType.WriteBigInteger.

The multidimensional blit fast path was gated on the concrete leaf class, so a
leaf behind a wire-transparent wrapper (LowCardinality, SimpleAggregateFunction,
Object) fell back to the boxing walk although it writes identical bytes. The
gate now strips those wrappers; Nullable is deliberately not stripped.

Wire output is unchanged in both cases.

Fixes: #553
Copilot AI lite review requested due to automatic review settings August 15, 2026 12:32

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.

Copilot was unable to review this pull request because the user who requested the review has reached their quota limit.

@alex-clickhouse

Copy link
Copy Markdown
Collaborator

Stack this one on 550 too please.

@codecov

codecov Bot commented Aug 15, 2026

Copy link
Copy Markdown

Codecov Report

✅ All modified and coverable lines are covered by tests.

📢 Thoughts on this report? Let us know!

…locations' into polyglot/cs553-bigint-write-and-wrapped-blit
… gate

Now that this PR is stacked on #550, the private UnwrapTransparent helper in
MultiDimArrayHelper duplicates TransparentWrapper.Unwrap introduced there, with
identical semantics (LowCardinality / SimpleAggregateFunction / Object stripped,
Nullable deliberately kept). Drop the local copy and call the shared helper.

No behaviour change.
@polyglotAI-bot
polyglotAI-bot changed the base branch from main to polyglot/cs549-residual-value-allocations August 15, 2026 12:46
@polyglotAI-bot

Copy link
Copy Markdown
Collaborator Author

Done — retargeted onto polyglot/cs549-residual-value-allocations (#550), so this is now the third PR in the stack (#499#550#556). Merged the #550 branch in (no conflicts); the PR diff is only this PR's own change.

One follow-on from stacking: the private UnwrapTransparent helper here duplicated TransparentWrapper.Unwrap from #550 with identical semantics, so I dropped the local copy and call the shared helper (c04f91b). No behaviour change — Nullable is still not unwrapped.

Full unit suite on the stacked base: 10 800 passed, 0 failed (net10.0).

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

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

The typed `ITypedReader<Int128>`/`ITypedReader<UInt128>` readers exist so a POCO property of the
native CLR type avoids `BigInteger`'s heap array. Both decoded through `ReadBytes(int)`, which
returns a fresh `byte[16]` per value, so the path allocated 40 bytes per value on 64-bit — the
allocation the typed reader was added to remove.

Read into `stackalloc byte[Size]` via the existing `ReadBytes(Span<byte>)` overload instead, the
same shape the base `AbstractBigIntegerType` already uses. Decoded values are unchanged.

`Int256`/`UInt256` have no native CLR counterpart, so they only have the `BigInteger` reader and
were already allocation-free.

Test: `NativeWideIntegerRead_OfManyValues_ShouldNotAllocate`, the read-side mirror of the existing
write-side guard. It measures 40 B/value before this change and 0 after.

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

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

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

3 participants