TCP K5: Add QBit(T, N) support - #579
Draft
alex-clickhouse wants to merge 3 commits into
Draft
Conversation
Contributor
There was a problem hiding this comment.
Pull request overview
Adds native/TCP QBit codec support with transposed plane access and SIMD-accelerated writes.
Changes:
- Implements QBit decoding, encoding, and dense reinsertion.
- Adds public
IQBitColumnplane access. - Adds unit, round-trip, and asymmetric integration coverage.
Reviewed changes
Copilot reviewed 7 out of 7 changed files in this pull request and generated 3 comments.
Show a summary per file
| File | Description |
|---|---|
QBitColumn.cs |
Implements decoded QBit columns. |
IQBitColumn.cs |
Defines public plane-access API. |
ColumnCodecRegistry.cs |
Registers the QBit codec. |
QBitColumnCodec.cs |
Implements parsing and wire serialization. |
InsertRoundTripCase.cs |
Adds QBit corpus cases. |
QBitColumnCodecTests.cs |
Tests codec layout and errors. |
QBitIntegrationTests.cs |
Verifies client/server interoperability. |
Suppressed comments (2)
ClickHouse.Driver.Tcp/Types/Codecs/QBitColumnCodec.cs:75
- Released ClickHouse v26.6.3.62 now accepts
QBit(T, dimension, stride)and reports that three-argument type in block metadata. This check therefore makes aSELECTof a valid strided QBit column fail during codec resolution. Because supported server versions include newer 26.6 patches, the codec needs to parse the stride and implement the group-major strided wire layout rather than treating this form as permanently invalid.
if (node.Arguments.Count != 2)
{
throw new FormatException(
$"QBit type '{node}' must have exactly two arguments: the element type and the vector length.");
ClickHouse.Driver.Tcp/Types/Codecs/QBitColumnCodec.cs:94
- The current released ClickHouse v26.6.3.62 also permits
QBit(Int8, N). Rejecting it here means any query returning that valid server type fails before reading the block. Add the 8-plane codec/column surface (normallyIColumn<sbyte[]>) and corresponding native round-trip coverage.
return element switch
{
"BFloat16" => new QBitFloatColumnCodec(typeName, dimension, bitWidth: 16),
"Float32" => new QBitFloatColumnCodec(typeName, dimension, bitWidth: 32),
"Float64" => new QBitDoubleColumnCodec(typeName, dimension),
💡 Add a code-review agent skill or configure MCP servers for context-aware, tailored reviews. Learn more in the docs.
Codecov Report✅ All modified and coverable lines are covered by tests. 📢 Thoughts on this report? Let us know! |
alex-clickhouse
force-pushed
the
tcp/epic-k5-qbit
branch
from
August 21, 2026 13:07
61685f2 to
ec823da
Compare
alex-clickhouse
force-pushed
the
tcp/epic-k5-qbit
branch
from
August 22, 2026 16:57
ec823da to
ec4b9df
Compare
alex-clickhouse
force-pushed
the
tcp/epic-k5-qbit
branch
from
August 22, 2026 17:07
ec4b9df to
c1b2015
Compare
alex-clickhouse
force-pushed
the
tcp/epic-k5-qbit
branch
from
August 22, 2026 17:25
c1b2015 to
bca51c5
Compare
alex-clickhouse
force-pushed
the
tcp/epic-k5-qbit
branch
from
August 26, 2026 08:59
bca51c5 to
46369ee
Compare
alex-clickhouse
force-pushed
the
tcp/epic-k5-qbit
branch
from
August 26, 2026 15:24
46369ee to
ec3f296
Compare
alex-clickhouse
force-pushed
the
tcp/epic-k5-qbit
branch
from
August 26, 2026 15:40
ec3f296 to
4b885c4
Compare
alex-clickhouse
force-pushed
the
tcp/epic-k5-qbit
branch
from
August 26, 2026 16:40
4b885c4 to
0c0fcc3
Compare
alex-clickhouse
force-pushed
the
tcp/epic-k5-qbit
branch
from
August 26, 2026 19:00
0c0fcc3 to
7de5d6f
Compare
alex-clickhouse
force-pushed
the
tcp/epic-k5-qbit
branch
from
August 28, 2026 11:07
7de5d6f to
40fc796
Compare
alex-clickhouse
force-pushed
the
tcp/epic-k5-qbit
branch
from
August 28, 2026 12:18
40fc796 to
853972f
Compare
alex-clickhouse
force-pushed
the
tcp/epic-k5-qbit
branch
from
August 28, 2026 16:22
853972f to
804ced3
Compare
QBit stores an N-element vector with its bit planes transposed, which is what lets a vector search read only the high-order planes and compute an approximate distance at reduced precision. The client refused the type outright, so a SELECT that returned one failed the whole query. The Native body is plane-major and has no state prefix: bits(T) planes, most significant first, each holding one ceil(N/8)-byte bitmap per row with element i at bit i%8 of byte i/8. Verified byte-exact against a 26.6 server. This is not the RowBinary shape, where the same type is a plain array — which is why the HTTP driver reads a length prefix. The decoded column keeps the blob transposed rather than de-transposing on read, so a column read from the server and inserted straight back is a plane copy with no transposition at all. That is the common shape for a vector workload, where the distance is computed server-side and the client never looks at a vector, so the per-row vector view is materialized lazily into a pooled cache. IQBitColumn exposes the planes themselves, indexed by bit significance rather than wire order. Co-Authored-By: Claude <noreply@anthropic.com>
The bytes within one row's bitmap run in the reverse of the element order: element i is at bit i%8 of byte ceil(N/8)-1-i/8, so elements 0-7 are in the last byte. Equivalently the bitmap is the big-endian encoding of a ceil(N/8)-byte integer whose bit i is element i. Confirmed on 26.6 with QBit(Float32, 72): element 0 lands in byte 8, element 64 in byte 0. Everything verified so far used N <= 8, where a row is one byte and the order cannot be seen, so both the wire notes and the first commit had it the other way round. An insert round-trip cannot catch it either: it writes and reads with the same mapping, so a self-consistent error is invisible. Two asymmetric integration tests now pin each direction against the server -- the client writes and the server renders the value with toString(), then the server writes and the client decodes -- plus a unit fixture of real server bytes for a 16-element vector, the narrowest that spans two bytes. Reverting the fix fails exactly those three and none of the 65 symmetric cases. The write transpose is now vectorized. Vector256<uint>.Extract- MostSignificantBits gathers the top bit of 8 lanes into a byte, which is one plane byte for 8 Float32 elements in the order the wire wants, so a plane costs one extract plus one shift instead of 8 test-and-sets, and walking planes most significant first is just shifting left. BFloat16 needs no narrowing on this path: its 16 planes are the float's top 16. Float64 takes two extracts per byte, since a Vector256 holds 4 lanes. Guarded on Vector256.IsHardwareAccelerated with the scalar path as the fallback and for the sub-8 tail, as UuidColumnCodec does. Ad-hoc timing, 200 rows, Release, net9.0, AVX2 on vs off: dim 1024 85.9 ms -> 7.3 ms (11.7x) dim 768 55.1 ms -> 3.3 ms (16.5x) Co-Authored-By: Claude <noreply@anthropic.com>
ClickHouse 26.7 widened QBit in two ways. The driver floors at 25.8 and CI runs 26.7 and latest, so both were reachable, and neither was covered: the corpus only builds two-argument float forms, which 26.7 normalizes the same way as 26.6. Int8 (server PR 108105) is supported here. Its 8 planes are the element's raw two's-complement byte, most significant first — the existing shape with bitWidth 8 — so it surfaces as IColumn<sbyte[]>. Left scalar; the vector path is filed as a follow-up rather than guessed at. Gated on TcpFeature.QBitInt8. Int16 and UInt8 are still rejected by the server, and a test pins that. The strided form QBit(T, N, stride) (server PR 108103) is not decoded. It now throws NotSupportedException naming the stride, rather than the misleading "must have exactly two arguments". Its group-major layout is verified and written up, but the codec is deferred. What could not be deferred is the public surface, because the strided layout does not fit the one this PR was about to ship: - BytesPerRow was defined as ceil(Dimension / 8). Under a stride it has to become ceil(Stride / 8) — a silent meaning change to a shipped property. It is now defined in group terms, the same value while GroupCount is 1. - Stride, GroupCount and GetPlane(bit, group) are added, so a plane reader can be written against the general layout. - GetPlane(bit) now throws when GroupCount != 1. Unreachable today and kept on purpose: the alternative is that existing callers silently receive one group's bytes — a shorter span, misindexed — the day striding lands. The dense-copy guard compares Stride as well as Dimension and BitWidth. Those are not redundant: QBit(Float32, 16, 8) and QBit(Float32, 16) agree on both and on total body size, so a strided source would otherwise blit a group-major body into an unstrided column undetected. Also replaces (dimension + 7) / 8 with an overflow-free ceiling in the two places it appeared. Unreachable — the server caps N at 8 * 0xFFFFFF and type strings only ever come from the server — so this is hygiene, not a fix. Verified against real servers: 3210 pass on 26.7.3, 3199 on 26.6.1, the 11-case delta being the version-gated Int8 coverage. Co-Authored-By: Claude <noreply@anthropic.com>
alex-clickhouse
force-pushed
the
tcp/epic-k5-qbit
branch
from
August 28, 2026 16:32
804ced3 to
11a3758
Compare
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
Sign up for free
to join this conversation on GitHub.
Already have an account?
Sign in to comment
Add this suggestion to a batch that can be applied as a single commit.This suggestion is invalid because no changes were made to the code.Suggestions cannot be applied while the pull request is closed.Suggestions cannot be applied while viewing a subset of changes.Only one suggestion per line can be applied in a batch.Add this suggestion to a batch that can be applied as a single commit.Applying suggestions on deleted lines is not supported.You must change the existing code in this line in order to create a valid suggestion.Outdated suggestions cannot be applied.This suggestion has been applied or marked resolved.Suggestions cannot be applied from pending reviews.Suggestions cannot be applied on multi-line comments.Suggestions cannot be applied while the pull request is queued to merge.Suggestion cannot be applied right now. Please check back later.
Stacked on
tcp/epic-o-sessions(#578). Implements K5QBit(T, N)(read + write) for the native/TCP client — the last real codec in the type epic. Not an alias and not a refusal: it has a layout of its own that the client has to transpose in both directions.What QBit is
QBit(T, N)stores anN-element vector with its bit planes transposed, which is what lets a vector search read only the high-order planes and compute an approximate distance at reduced precision (L2DistanceTransposed,cosineDistanceTransposed). Before this, the type resolved to nothing and aSELECTreturning one failed the whole query.TisBFloat16,Float32orFloat64only — the server rejects everything else. The three-argument stride form documented in the binary type encoding is rejected by 26.6 ("must have exactly two argument"), so only the two-argument form is built.Wire layout
No state prefix. The body is
bits(T)planes, most significant bit first; each plane holds oneceil(N/8)-byte bitmap per row, rows contiguous within a plane. So the body is plane-major and exactlybits(T) * num_rows * ceil(N/8)bytes — every row the same width.Within a row's bitmap the bits run LSB-first, but the bytes run in the reverse of the element order: element
iis at biti % 8of byteceil(N/8) - 1 - i / 8. Equivalently the bitmap is the big-endian encoding of aceil(N/8)-byte integer whose bitiis elementi.That byte order is the one thing here worth reviewing carefully — see below.
CLR surface
QBit(Float32, N)andQBit(BFloat16, N)surface asIColumn<float[]>,QBit(Float64, N)asIColumn<double[]>. BFloat16 is widened tofloatand narrowed by dropping the low 16 bits, consistent withBFloat16ColumnCodec(G2).The decoded column keeps the blob transposed rather than de-transposing on read, so a column read from the server and inserted straight back is a plane copy with no transposition at all. That is the common shape for a vector workload — the distance is computed server-side and the client never looks at a vector — so the per-row vector view is a lazily materialized pooled cache, never eager. De-transposing one row costs
bits × ceil(N/8)byte fetches, 4 KiB for a 1024-dimension Float32 embedding.IQBitColumnis new public API:Dimension,BitWidth,BytesPerRow, andGetPlane(bit). It is indexed by bit significance (31 is the sign bit), not wire order, because the high planes are what a reduced-precision distance wants; the accessor hides the MSB-first storage. It is non-generic — planes are raw bits, so plane access does not depend on the element type.Write paths
QBitColumnof the same geometry): one copy per plane. Not one copy for the whole range — the body is plane-major, so a row range is contiguous within a plane but the planes are strided by the source's row count.IColumn<float[]>): transposed into a rented scratch. Plane-major output cannot be streamed a row at a time, since plane 0 needs every row before plane 1 begins, so the scratch is the size of the slice's wire bytes. The dense path avoids it entirely.SIMD, write direction only
Vector256<uint>.ExtractMostSignificantBits()gathers the top bit of 8 lanes into a byte, which is one plane byte for 8Float32elements in the order the wire wants. So a plane costs one extract plus one shift instead of 8 test-and-sets, and walking planes most significant first is just shifting the vector left. BFloat16 needs no narrowing on this path — its 16 planes are the float's top 16. Float64 takes two extracts per byte, since aVector256holds only 4 lanes.Guarded on
Vector256.IsHardwareAccelerated, hoisted out of the row loop, with the scalar path as both the fallback and the handler for the sub-8 tail — theUuidColumnCodec(G9) shape. No#if: the project floors at net8.0.Ad-hoc timing, 200 rows, Release, net9.0, AVX2 on vs off:
The read direction is deliberately left scalar. Movemask has no cheap inverse before AVX-512, the portable sequence is only ~1.5× on op count, and by the type's own usage model the read path only runs when a caller materializes a vector — which most never do. Filed as a follow-up behind a real benchmark project rather than guessed at here.
The byte order — and why the round-trip corpus could not catch it
The first commit had element
iat bytei / 8, matching what the wire notes said. That is wrong forN > 8, and it is worth understanding how it survived: every earlier verification usedN = 4orN = 8, where a row is a single byte and the order is unobservable.An insert round-trip cannot catch it either. It writes and reads with the same client mapping, so a self-consistent layout error round-trips perfectly while putting bytes on the wire the server reads as a different vector. All the
QBit(Float32, 9)andQBit(Float32, 17)corpus cases passed against a real server with the bug present.What catches it is asymmetry — one side done by the client, the other by the server:
QBitIntegrationTestsinserts through the client and has the server render the value withtoString(), then inserts through the server and has the client decode it.Reverting the fix fails exactly those three and none of the 65 symmetric cases.
The mapping was pinned with
QBit(Float32, 72)(9 bytes per row) by making one element at a time negative and reading the sign plane: element 0 → byte 8 bit 0, element 8 → byte 7 bit 0, element 64 → byte 0 bit 0, element 71 → byte 0 bit 7.native-format.mdis corrected to match.Tests
InsertRoundTripCase, gated onTcpFeature.QBit): Float32 at dimensions 4, 9 and 17; Float64 at 3 and 17; BFloat16 at 4; andNullable(QBit(Float32, 4)), which is the only thing that reads the codec's all-zero placeholder. Dimensions 17 cross two whole 8-element groups plus a tail, so they reach the vector path; anything narrower than 9 is handled entirely by the scalar tail. Signed zero, infinity and NaN pin the sign and exponent planes.QBitColumnCodecTests): server-captured bytes for dimensions 4 and 16, the significance ordering ofGetPlane, plane bounds, the row-slice write, the pooledValuescache, zero rows, BFloat16 narrowing,CanWriterejections, and the resolution error paths.QBitIntegrationTests): the two directions above, plusGetPlaneagainst the server's own value.3187 tests pass against a local 26.6.
Notes for review
ClickHouse.Driver.Tcphas noPublicAPI/*.txt, soIQBitColumnis not analyzer-tracked yet; that lands with R1.🤖 Generated with Claude Code