Skip to content

TCP K5: Add QBit(T, N) support - #579

Draft
alex-clickhouse wants to merge 3 commits into
tcp/epic-o-sessionsfrom
tcp/epic-k5-qbit
Draft

TCP K5: Add QBit(T, N) support#579
alex-clickhouse wants to merge 3 commits into
tcp/epic-o-sessionsfrom
tcp/epic-k5-qbit

Conversation

@alex-clickhouse

Copy link
Copy Markdown
Collaborator

Stacked on tcp/epic-o-sessions (#578). Implements K5 QBit(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 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 (L2DistanceTransposed, cosineDistanceTransposed). Before this, the type resolved to nothing and a SELECT returning one failed the whole query.

T is BFloat16, Float32 or Float64 only — 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 one ceil(N/8)-byte bitmap per row, rows contiguous within a plane. So the body is plane-major and exactly bits(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 i is at bit i % 8 of byte ceil(N/8) - 1 - i / 8. Equivalently the bitmap is the big-endian encoding of a ceil(N/8)-byte integer whose bit i is element i.

That byte order is the one thing here worth reviewing carefully — see below.

CLR surface

QBit(Float32, N) and QBit(BFloat16, N) surface as IColumn<float[]>, QBit(Float64, N) as IColumn<double[]>. BFloat16 is widened to float and narrowed by dropping the low 16 bits, consistent with BFloat16ColumnCodec (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.

IQBitColumn is new public API: Dimension, BitWidth, BytesPerRow, and GetPlane(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

  • Dense (a QBitColumn of 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.
  • Ergonomic (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 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 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 a Vector256 holds 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 — the UuidColumnCodec (G9) shape. No #if: the project floors at net8.0.

Ad-hoc timing, 200 rows, Release, net9.0, AVX2 on vs off:

dimension scalar vector
1024 85.9 ms 7.3 ms 11.7×
768 55.1 ms 3.3 ms 16.5×

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 i at byte i / 8, matching what the wire notes said. That is wrong for N > 8, and it is worth understanding how it survived: every earlier verification used N = 4 or N = 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) and QBit(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:

  • QBitIntegrationTests inserts through the client and has the server render the value with toString(), then inserts through the server and has the client decode it.
  • A unit fixture holds real server bytes for a 16-element vector, the narrowest that spans two bytes per row.

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.md is corrected to match.

Tests

  • Corpus (InsertRoundTripCase, gated on TcpFeature.QBit): Float32 at dimensions 4, 9 and 17; Float64 at 3 and 17; BFloat16 at 4; and Nullable(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.
  • Unit (QBitColumnCodecTests): server-captured bytes for dimensions 4 and 16, the significance ordering of GetPlane, plane bounds, the row-slice write, the pooled Values cache, zero rows, BFloat16 narrowing, CanWrite rejections, and the resolution error paths.
  • Asymmetric integration (QBitIntegrationTests): the two directions above, plus GetPlane against the server's own value.

3187 tests pass against a local 26.6.

Notes for review

  • No changelog fragment, per the convention for this epic stack.
  • ClickHouse.Driver.Tcp has no PublicAPI/*.txt, so IQBitColumn is not analyzer-tracked yet; that lands with R1.
  • The vector path's stores are scattered across the plane-major scratch and it manages only ~0.1 GB/s of plane output even so — dimension 1024 costs more per row than 768, which looks like a power-of-two stride conflict. Filed as a follow-up; blocking the rows should recover most of it.

🤖 Generated with Claude Code

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

Adds native/TCP QBit codec support with transposed plane access and SIMD-accelerated writes.

Changes:

  • Implements QBit decoding, encoding, and dense reinsertion.
  • Adds public IQBitColumn plane 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 a SELECT of 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 (normally IColumn<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.

Comment thread ClickHouse.Driver.Tcp/Types/Codecs/QBitColumnCodec.cs Outdated
Comment thread ClickHouse.Driver.Tcp/Types/QBitColumn.cs Outdated
Comment thread ClickHouse.Driver.Tcp.Tests/Types/QBitColumnCodecTests.cs Outdated
@codecov

codecov Bot commented Aug 21, 2026

Copy link
Copy Markdown

Codecov Report

✅ All modified and coverable lines are covered by tests.

📢 Thoughts on this report? Let us know!

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>
alex-clickhouse and others added 2 commits August 28, 2026 18:28
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>
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