Repository navigation
TCP: SIMD transpose for QBit encode and decode - #692
polyglotAI-bot wants to merge 4 commits into
Conversation
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>
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
There was a problem hiding this comment.
🔵 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 Report❌ Patch coverage is 📢 Thoughts on this report? Let us know! |
|
CI status: 15 of 16 checks pass. The Code Coverage (TCP) job passed on the re-run; its first failure was Cloud (TCP) stopped at its 30-minute limit three times. The failures come from the Cloud service, not from this change:
The last TCP Cloud job that ran before this PR passed (2026-10-11). A run of the Cloud job on |
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>
Summary
The native client's
QBitcodec spends most of its time in the bit transpose. Onmain, materializing 1024 rows ofQBit(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)Vector256.Narrow). EachExtractMostSignificantBits(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-endianuint. 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 animul.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.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.Int8now 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
vpshufband 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
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.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.Benchmarks
Codec only, 1024 rows × 1024 dimensions:
WriteColumnof anIColumn<T[]>, andReadColumnAsyncplus every row throughGetValue. Xeon Platinum 8175M (Skylake-SP), .NET 10, BenchmarkDotNet,mainand this branch run back to back. The host has some steal time, so treat differences under about 10% as noise.mainmainFloat32BFloat16Float64Int8About 0.9 ms of each
Float32decode is allocating the 1024float[]rows. .NET 8 gives the same numbers. Allocations do not change.Small widths, 100,000 rows:
mainmainQBit(Float32, 4)QBit(Float32, 17)QBit(Float64, 17)QBit(Int8, 17)Scalar fallback (
DOTNET_EnableAVX2=0), 1024 × 1024:mainmainFloat32Int8Through the client against a local 26.9 server, with the new
TcpQBitbenchmark (tcp-regression, 1024 rows):mainFloat32, 1024Float32, 100Int8, 1024Int8, 100Int8, 1024Float32, 1024The other inserts do not move because this server takes about 60 ms for any
INSERT: one row ofUInt64takes 61 ms from this client and 55 to 66 ms fromclickhouse-client, against 3.6 ms forSELECT 1. The codec's share of an insert is smaller than that floor.Alternatives measured
On a copy of the kernel,
Float32, relative tomain's kernel:AlignUp(planeBytes, 4096) + 64Padding 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 withstart > 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.DOTNET_EnableAVX2=0. The encoded bodies are byte-identical tomainfor all four types, on both paths.Not in this PR
Vector256is not accelerated there. AVector128path would be a follow-up.ClickHouseBinaryWriterhas 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 msFloat32encode.Overlap with the converter stack (#665 to #678)
The decode half (
QBitColumn.cs) does not overlap the stack. The encode half does: #677 movesWriteTransposedand its kernels unchanged fromQBitColumnCodectoTypes/Converters/QBitWriters.cs. Whichever lands second has to move this PR's encode kernels (TransposeRow,TransposeBlocks,StorePlanes,StoreWords,HighBytes/LowBytes,TransposeScalarand the row-block loop) to the new place. The tests do not depend on where the kernels live, exceptTransposeScalar_*, which calls the scalar kernel directly. If the stack lands first, I will rebase this PR onto it.Checklist
🤖 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
Vector256transpose kernels while keeping wire layout and public API unchanged.Encode (
QBitColumnCodec): per-rowTransposeRowwith 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 (noArray.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;RowPlanesexposes a row’s plane span for decoding.Coverage: broad
QBitColumnCodecTestsmatrix (dimensions/block shapes, reference layout), extra insert round-trips at width 100, newTcpQBittcp-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.