Skip to content

TCP: SIMD transpose for QBit encode and decode - #692

Open
polyglotAI-bot wants to merge 4 commits into
mainfrom
polyglot/qbit-simd-transpose
Open

polyglotAI-bot wants to merge 4 commits into
mainfrom
polyglot/qbit-simd-transpose

Conversation

@polyglotAI-bot

@polyglotAI-bot polyglotAI-bot commented Oct 19, 2026 •

Copy link
Copy Markdown
Collaborator

Summary

The native client's QBit codec spends most of its time in the bit transpose. On main, materializing 1024 rows of QBit(Float32, 1024) takes 200 ms or more, and transposing them for an insert about 15 ms. This PR rewrites the transpose in both directions. It does not change the wire bytes, the API, or the dense (read-then-reinsert) path.

Encode (QBitColumnCodec)

  • 32 elements per extract. A block of 32 elements is split into byte vectors (Vector256.Narrow). Each ExtractMostSignificantBits (vpmovmskb) then gives one plane's bit for all 32 elements, which is the block's four row bytes in that plane, stored as one big-endian uint. The old loop took one extract per 8 elements and wrote one byte per plane, so the JIT emitted about 9 µops per plane per 8 elements, plus a bounds check and an imul.
  • Row-block layout. Each row writes the same offset in every plane. In the body those offsets are rows * ceil(N/8) bytes apart, and for common shapes that is a multiple of 4 KiB. For 1024 × 1024, all 32 writes of a group fall in one L1 set, one L2 set, and one set of the 4-way L1 DTLB (the planes are exactly 32 pages apart). Rows are now transposed into a buffer of about 4 KiB, laid out like the body but holding only one block of rows per plane, and each plane's run is then copied into the body.
  • No Array.Clear. Every byte is written once. A partial last block is zero-padded and goes through a 4-byte-per-plane buffer, from which only its bytes are copied.
  • Int8 now has a SIMD path; before, it had only the scalar one.

Decode (QBitColumn.DetransposeRow)

The inverse. For each block and plane: load the plane's four bytes, spread the 32 bits into 32 byte masks with one in-lane vpshufb and a compare, and shift-accumulate eight planes into each byte vector. Then widen the bytes back into elements. Before, decode was a scalar loop with a data-dependent branch per bit, which mispredicts on mantissa bits.

Both

  • SIMD runs when Vector256.IsHardwareAccelerated. The kernels assume a little-endian host, which the client already requires (HostEndianness.RequireLittleEndian). The scalar fallbacks (ARM64, x64 without AVX2) are now branch-free and build each byte or element whole.
  • The SIMD kernels do one range check per run of eight 4-byte accesses, then use Unsafe.ReadUnaligned/WriteUnaligned. Each address is computed from the start of the span, so no reference points past its end. A bounds check per access cost about 30% of the encode.
  • A slice that fits in one row block is transposed straight into the body, with no second buffer.

Benchmarks

Codec only, 1024 rows × 1024 dimensions: WriteColumn of an IColumn<T[]>, and ReadColumnAsync plus every row through GetValue. Xeon Platinum 8175M (Skylake-SP), .NET 10, BenchmarkDotNet, main and this branch run back to back. The host has some steal time, so treat differences under about 10% as noise.

Type Encode main Encode PR Decode main Decode PR
Float32 14.4 ms 3.8 ms 3.8× 201 ms 5.5 ms 37×
BFloat16 9.2 ms 1.8 ms 5.0× 84 ms 3.5 ms 24×
Float64 35.3 ms 11.3 ms 3.1× 404 ms 12.9 ms 31×
Int8 55.3 ms 0.84 ms 66× 65 ms 1.2 ms 54×

About 0.9 ms of each Float32 decode is allocating the 1024 float[] rows. .NET 8 gives the same numbers. Allocations do not change.

Small widths, 100,000 rows:

Encode main Encode PR Decode main Decode PR
QBit(Float32, 4) 64 ms 22 ms 100 ms 28 ms
QBit(Float32, 17) 43 ms 31 ms 380 ms 36 ms
QBit(Float64, 17) 87 ms 67 ms 748 ms 81 ms
QBit(Int8, 17) 92 ms 10 ms 125 ms 12 ms

Scalar fallback (DOTNET_EnableAVX2=0), 1024 × 1024:

Type Encode main Encode PR Decode main Decode PR
Float32 219 ms 63 ms 387 ms 126 ms
Int8 71 ms 20 ms 69 ms 22 ms

Through the client against a local 26.9 server, with the new TcpQBit benchmark (tcp-regression, 1024 rows):

main PR
Read Float32, 1024 329 ms 28 ms
Read Float32, 100 46 ms 12 ms
Read Int8, 1024 86 ms 16 ms
Read Int8, 100 22 ms 5 ms
Insert Int8, 1024 133 ms 71 ms
Insert Float32, 1024 92 ms 92 ms

The other inserts do not move because this server takes about 60 ms for any INSERT: one row of UInt64 takes 61 ms from this client and 55 to 66 ms from clickhouse-client, against 3.6 ms for SELECT 1. The codec's share of an insert is smaller than that floor.

Alternatives measured

On a copy of the kernel, Float32, relative to main's kernel:

1024×768 1024×1024 1000×1536 4096×1024 50000×1024 100000×32
Old kernel, planes padded by AlignUp(planeBytes, 4096) + 64 0.65 0.52 0.91 0.61 0.51 0.94
New kernel, plain body 0.33 0.33 0.48 0.31 0.30 0.47
New kernel, odd page count + one line between planes 0.21 0.25 0.49 0.28 0.23 0.52
New kernel, row-block layout (this PR) 0.24 0.22 0.37 0.22 0.20 0.55

Padding the planes fixes the cache sets but not the DTLB set, and its benefit depends on the cache geometry. The row-block layout is best or close to it on every shape, and does not depend on a particular CPU.

Tests

  • QBitColumnCodecTests: every element type at widths 1, 7, 8, 9, 31, 32, 33, 40, 63, 64, 65, 100 and 256, plus three shapes that fill several row blocks (the last one partly). Each width is checked against a reference encoder written from the documented layout, for the SIMD path (a slice with start > 0), the scalar encoder, SIMD decode and scalar decode. The scalar encoder test starts from a dirty buffer, so a byte it skips fails the test. Also an empty slice of row vectors.
  • InsertRoundTripCase: QBit(Float32/Float64/BFloat16/Int8, 100), which is three complete blocks plus a partial one. The existing cases have widths 3, 4, 17 and 768, so none of them had both kinds of block.
  • Ran: the full TCP suite on net10.0 against ClickHouse 26.9 (4427 passed); the QBit unit tests on net8.0, net9.0 and net10.0, and with DOTNET_EnableAVX2=0. The encoded bodies are byte-identical to main for all four types, on both paths.

Not in this PR

  • Arm64 runs the scalar path, because Vector256 is not accelerated there. A Vector128 path would be a follow-up.
  • ClickHouseBinaryWriter has no API to write into its own buffer, so the transposed body is still built in a rented buffer and copied: about 0.35 ms of the 3.8 ms Float32 encode.

Overlap with the converter stack (#665 to #678)

The decode half (QBitColumn.cs) does not overlap the stack. The encode half does: #677 moves WriteTransposed and its kernels unchanged from QBitColumnCodec to Types/Converters/QBitWriters.cs. Whichever lands second has to move this PR's encode kernels (TransposeRow, TransposeBlocks, StorePlanes, StoreWords, HighBytes/LowBytes, TransposeScalar and the row-block loop) to the new place. The tests do not depend on where the kernels live, except TransposeScalar_*, which calls the scalar kernel directly. If the stack lands first, I will rebase this PR onto it.

Checklist

  • Unit and integration tests covering the common scenarios were added
  • A human-readable description of the changes was provided to include in CHANGELOG

🤖 Generated with Claude Code


Note

Medium Risk
Large, performance-critical changes to QBit serialization with wire-format parity enforced by tests; SIMD/scalar paths and partial-block handling increase regression surface on non-AVX2 hosts.

Overview
Rewrites QBit native TCP encode and decode around AVX2-style Vector256 transpose kernels while keeping wire layout and public API unchanged.

Encode (QBitColumnCodec): per-row TransposeRow with 32-element SIMD blocks (TransposeBlocks, StorePlanes/StoreWords), a ~4 KiB row-block staging buffer to avoid plane-strided cache/TLB thrashing, and branch-free scalar fallbacks that write every bitmap byte (no Array.Clear). Int8 gains a SIMD path alongside Float32/BFloat16/Float64.

Decode (QBitColumn): inverse kernels (DetransposeBlocks, GatherPlanes/GatherWords) replace per-bit scalar loops when hardware acceleration is available; RowPlanes exposes a row’s plane span for decoding.

Coverage: broad QBitColumnCodecTests matrix (dimensions/block shapes, reference layout), extra insert round-trips at width 100, new TcpQBit tcp-regression benchmark, and a changelog note on faster QBit on x64 AVX2.

Reviewed by Cursor Bugbot for commit 90bea24. Bugbot is set up for automated code reviews on this repo. Configure here.

Encode: split each 32-element block into byte vectors and take one plane
of all 32 elements per vpmovmskb, then store the four plane bytes as one
big-endian word. Rows are transposed in blocks into an L1-sized buffer
laid out like the body and each plane's run is copied out, so the
writes do not land a plane apart. Every byte is written once, so the
scratch buffer is no longer cleared.

Decode: the inverse. Load a plane's four bytes for a block, expand the
32 bits to 32 byte masks with one in-lane shuffle, and shift-accumulate
eight planes into each byte vector, then widen the bytes back into
elements.

The SIMD kernels check the range of each run of eight accesses once.
Both scalar fallbacks build each byte or element without branches.
Adds a TcpQBit benchmark (tcp-regression).

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Copilot AI balanced review requested due to automatic review settings October 19, 2026 16:54
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>

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.

🔵 Needs a closer look

The unsafe SIMD rewrite of both wire-codec directions warrants final human validation despite extensive reported testing.

No open issues 🎉

What changed in this PR

Optimizes the native TCP client’s QBit encoding and decoding without changing the public API or wire format.

Changes:

  • Adds 32-element SIMD kernels, row-block buffering, and branch-free scalar fallbacks.
  • Expands reference-layout and server round-trip coverage.
  • Adds end-to-end benchmarks and a changelog fragment.
File Description
ClickHouse.Driver.Tcp/​Types/​QBitColumn.cs Adds SIMD decoding and scalar fallback improvements.
ClickHouse.Driver.Tcp/​Types/​Codecs/​QBitColumnCodec.cs Adds SIMD encoding and row-block buffering.
ClickHouse.Driver.Tcp.Tests/​Utilities/​InsertRoundTripCase.cs Adds mixed full/partial-block round trips.
ClickHouse.Driver.Tcp.Tests/​Types/​QBitColumnCodecTests.cs Tests layouts, slices, block boundaries, and scalar paths.
ClickHouse.Driver.Benchmark/​TcpQBit.cs Benchmarks TCP reads and inserts.
changelog.d/​692-qbit-transpose-simd.improvements.md Records the performance improvement.

Review effort: Balanced

@codecov

codecov Bot commented Oct 19, 2026 •

Copy link
Copy Markdown

Codecov Report

❌ Patch coverage is 94.82759% with 12 lines in your changes missing coverage. Please review.

Files with missing lines Patch % Lines
...ckHouse.Driver.Tcp/Types/Codecs/QBitColumnCodec.cs 95.04% 3 Missing and 3 partials ⚠️
ClickHouse.Driver.Tcp/Types/QBitColumn.cs 94.59% 3 Missing and 3 partials ⚠️

📢 Thoughts on this report? Let us know!

@polyglotAI-bot

Copy link
Copy Markdown
Collaborator Author

CI status: 15 of 16 checks pass. The Code Coverage (TCP) job passed on the re-run; its first failure was StreamAsync_QueryLongerThanReadTimeout_SurvivesBecauseTheTimeoutMeasuresSilence, a timing test that passed in all eight version-matrix jobs.

Cloud (TCP) stopped at its 30-minute limit three times. The failures come from the Cloud service, not from this change:

  • Every CREATE TABLE waits out distributed_ddl_task_timeout: "State update to version … is not finished on 2 of 3 hosts". Each insert test takes 3 minutes because of this.
  • StreamAsync_CancelledMidResult_StopsTheQueryOnTheServer and StreamAsync_EnumerationAbandonedEarly_StopsTheQueryOnTheServer fail with "All connection tries failed" and "Sending a distributed query with unknown (zero) client version".
  • No QBit test is in the failures.

The last TCP Cloud job that ran before this PR passed (2026-10-11). A run of the Cloud job on main would confirm the cause.

polyglotAI-bot and others added 2 commits October 19, 2026 19:49
The block kernels now byte-swap each plane mask unconditionally, and
QBitLayout.TransposesBlocks (Vector256 acceleration and little-endian)
selects them for encode and decode alike.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
ClickHouseTcpClient and ClickHouseTcpConnection already refuse a
big-endian host (HostEndianness.RequireLittleEndian), so the SIMD paths
check only Vector256.IsHardwareAccelerated, as the other codecs do.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>

This branch has not been deployed

No deployments
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.

2 participants