TCP K5: Add QBit(T, N) support - #579
Conversation
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! |
61685f2 to
ec823da
Compare
ec823da to
ec4b9df
Compare
ec4b9df to
c1b2015
Compare
c1b2015 to
bca51c5
Compare
bca51c5 to
46369ee
Compare
46369ee to
ec3f296
Compare
ec3f296 to
4b885c4
Compare
4b885c4 to
0c0fcc3
Compare
0c0fcc3 to
7de5d6f
Compare
7de5d6f to
40fc796
Compare
40fc796 to
853972f
Compare
853972f to
804ced3
Compare
804ced3 to
11a3758
Compare
b07a6a7 to
2dbb706
Compare
2dbb706 to
7b23f53
Compare
7b23f53 to
106b857
Compare
106b857 to
205b119
Compare
There was a problem hiding this comment.
Cursor Bugbot has reviewed your changes using high effort and found 1 potential issue.
❌ Bugbot Autofix is OFF. To automatically fix reported issues with cloud agents, have a team admin enable autofix in the Cursor dashboard.
Reviewed by Cursor Bugbot for commit 5d51002. Configure here.
5d51002 to
da62950
Compare
5d27157 to
e3c4bcc
Compare
e3c4bcc to
dd64887
Compare
dd64887 to
dfedf54
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>
Shorten comments and XML docs, consolidate redundant codec tests, and keep semantic coverage in the real-server corpus. Add the missing Int8 decode, nullable-type, cache, range, and feature-boundary coverage.
dfedf54 to
f3a2127
Compare

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