From 490cdd12312dd3e6de6ddbce0de0f9a986dd9795 Mon Sep 17 00:00:00 2001 From: Alex Soffronow-Pagonidis Date: Thu, 20 Aug 2026 08:43:37 +0200 Subject: [PATCH 1/4] Add QBit(T, N) support MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 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 --- .../Types/QBitColumnCodecTests.cs | 303 ++++++++++++++++ .../Utilities/InsertRoundTripCase.cs | 63 ++++ .../Types/Codecs/QBitColumnCodec.cs | 339 ++++++++++++++++++ .../Types/ColumnCodecRegistry.cs | 4 + ClickHouse.Driver.Tcp/Types/IQBitColumn.cs | 65 ++++ ClickHouse.Driver.Tcp/Types/QBitColumn.cs | 321 +++++++++++++++++ 6 files changed, 1095 insertions(+) create mode 100644 ClickHouse.Driver.Tcp.Tests/Types/QBitColumnCodecTests.cs create mode 100644 ClickHouse.Driver.Tcp/Types/Codecs/QBitColumnCodec.cs create mode 100644 ClickHouse.Driver.Tcp/Types/IQBitColumn.cs create mode 100644 ClickHouse.Driver.Tcp/Types/QBitColumn.cs diff --git a/ClickHouse.Driver.Tcp.Tests/Types/QBitColumnCodecTests.cs b/ClickHouse.Driver.Tcp.Tests/Types/QBitColumnCodecTests.cs new file mode 100644 index 000000000..69f9dc8ef --- /dev/null +++ b/ClickHouse.Driver.Tcp.Tests/Types/QBitColumnCodecTests.cs @@ -0,0 +1,303 @@ +using System; +using System.Threading.Tasks; +using ClickHouse.Driver.Tcp.Protocol; +using ClickHouse.Driver.Tcp.Tests.Utilities; +using ClickHouse.Driver.Tcp.Types; + +namespace ClickHouse.Driver.Tcp.Tests.Types; + +/// +/// Unit coverage for QBit(T, N), limited to what a server round-trip cannot observe: the exact plane +/// layout, the significance ordering imposes on top of it, the type-resolution +/// and write error paths, and the pooled Values cache. Per-type values are covered by +/// against a real server. +/// +[TestFixture] +public class QBitColumnCodecTests +{ + private const string Float32X4 = "QBit(Float32, 4)"; + + // Captured from a ClickHouse 26.6 `SELECT v FROM t FORMAT Native` where v is QBit(Float32, 4) holding one row + // of [1.0, 2.0, 3.0, 4.0] — the example documented on QBitColumnCodec. 32 planes, one byte per plane (one row + // of ceil(4/8) = 1 byte), most significant bit first. + private static readonly byte[] DocumentedBytes = + { + 0x00, // bit 31 (sign): none of the four is negative + 0x0E, // bit 30: set for 2.0, 3.0, 4.0 -> elements 1, 2, 3 -> 0b1110 + 0x01, // bit 29: only 1.0 (0x3F800000) + 0x01, // bit 28 + 0x01, // bit 27 + 0x01, // bit 26 + 0x01, // bit 25 + 0x01, // bit 24 + 0x09, // bit 23: 1.0 and 4.0 -> elements 0 and 3 -> 0b1001 + 0x04, // bit 22: only 3.0 -> element 2 -> 0b0100 + 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, + 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, + }; + + private static IColumnCodec Codec(string type) => ColumnCodecRegistry.Default.Resolve(type, ResolveContext.ForWrite); + + [Test] + public async Task WriteColumn_DocumentedExample_ProducesTheServersOwnBytes() + { + IColumnCodec codec = Codec(Float32X4); + using var column = new ArrayColumn("v", Float32X4, new[] { new[] { 1f, 2f, 3f, 4f } }); + + byte[] bytes = await CodecTestHarness.WriteAsync(w => codec.WriteColumn(w, column)); + + CollectionAssert.AreEqual(DocumentedBytes, bytes); + } + + [Test] + public async Task ReadColumnAsync_TheServersOwnBytes_DecodesTheDocumentedVector() + { + IColumnCodec codec = Codec(Float32X4); + using ClickHouseBinaryReader reader = CodecTestHarness.ReaderOver(DocumentedBytes); + + using IColumn read = await codec.ReadColumnAsync(reader, "v", Float32X4, 1, CodecTestHarness.None); + + CollectionAssert.AreEqual(new[] { 1f, 2f, 3f, 4f }, (float[])read.GetValue(0)); + } + + [Test] + public async Task WriteColumn_RowSlice_EmitsEachPlaneStridedByTheSourceRowCount() + { + // The body is plane-major, so a row range is contiguous within a plane but the planes are strided by the + // *source* column's row count. Slicing the middle row of three is the shape that catches a write which + // strides by the slice length instead. Whole-column re-inserts never reach it. + IColumnCodec codec = Codec(Float32X4); + using var source = new ArrayColumn("v", Float32X4, new[] + { + new[] { 0f, 0f, 0f, 0f }, + new[] { 1f, 2f, 3f, 4f }, + new[] { 0f, 0f, 0f, 0f }, + }); + + byte[] dense = await CodecTestHarness.WriteAsync(w => codec.WriteColumn(w, source)); + using ClickHouseBinaryReader reader = CodecTestHarness.ReaderOver(dense); + using IColumn read = await codec.ReadColumnAsync(reader, "v", Float32X4, 3, CodecTestHarness.None); + + byte[] sliced = await CodecTestHarness.WriteSliceAsync(codec, read, start: 1, length: 1); + + CollectionAssert.AreEqual(DocumentedBytes, sliced); + } + + [Test] + public async Task GetPlane_ReadColumn_IndexesPlanesBySignificanceNotWireOrder() + { + // The wire stores planes most significant first; GetPlane takes the bit's significance, so bit 30 is the + // second plane on the wire. Nothing about this ordering is observable through a round-trip. + IColumnCodec codec = Codec(Float32X4); + using ClickHouseBinaryReader reader = CodecTestHarness.ReaderOver(DocumentedBytes); + using IColumn read = await codec.ReadColumnAsync(reader, "v", Float32X4, 1, CodecTestHarness.None); + + var qbit = (IQBitColumn)read; + + Assert.Multiple(() => + { + Assert.That(qbit.Dimension, Is.EqualTo(4)); + Assert.That(qbit.BitWidth, Is.EqualTo(32)); + Assert.That(qbit.BytesPerRow, Is.EqualTo(1)); + Assert.That(qbit.GetPlane(31)[0], Is.EqualTo(0x00), "sign plane"); + Assert.That(qbit.GetPlane(30)[0], Is.EqualTo(0x0E), "bit 30: 2.0, 3.0, 4.0"); + Assert.That(qbit.GetPlane(23)[0], Is.EqualTo(0x09), "bit 23: 1.0 and 4.0"); + Assert.That(qbit.GetPlane(22)[0], Is.EqualTo(0x04), "bit 22: 3.0"); + Assert.That(qbit.GetPlane(0)[0], Is.EqualTo(0x00), "no value has a bit that low set"); + }); + } + + [Test] + public async Task GetPlane_MultipleRows_ReturnsEveryRowsBitmapForThatPlane() + { + // Rows are contiguous within a plane, so one plane spans the whole column. -0.0 sets only the sign bit, + // which makes the sign plane the one place the three rows differ. + IColumnCodec codec = Codec("QBit(Float32, 8)"); + using var column = new ArrayColumn("v", "QBit(Float32, 8)", new[] + { + new float[8], + new[] { 1f, 1f, 1f, 1f, 1f, 1f, 1f, 1f }, + new[] { -0f, -0f, -0f, -0f, -0f, -0f, -0f, -0f }, + }); + + using IColumn read = await CodecTestHarness.RoundTripAsync(codec, column, "QBit(Float32, 8)", 3); + var qbit = (IQBitColumn)read; + + CollectionAssert.AreEqual(new byte[] { 0x00, 0x00, 0xFF }, qbit.GetPlane(31).ToArray()); + } + + [TestCase(-1)] + [TestCase(32)] + public async Task GetPlane_BitOutsideTheWidth_Throws(int bit) + { + IColumnCodec codec = Codec(Float32X4); + using ClickHouseBinaryReader reader = CodecTestHarness.ReaderOver(DocumentedBytes); + using IColumn read = await codec.ReadColumnAsync(reader, "v", Float32X4, 1, CodecTestHarness.None); + + Assert.That(() => ((IQBitColumn)read).GetPlane(bit), Throws.InstanceOf()); + } + + [Test] + public async Task Values_ReadColumn_MaterializesEveryRowThroughThePooledCache() + { + // GetValue delegates to the same de-transpose, but Values is a separately materialized pooled cache that + // the round-trip's per-row comparison never touches. + IColumnCodec codec = Codec(Float32X4); + using var column = new ArrayColumn("v", Float32X4, new[] + { + new[] { 1f, 2f, 3f, 4f }, + new[] { -1f, -2f, -3f, -4f }, + }); + + using IColumn read = await CodecTestHarness.RoundTripAsync(codec, column, Float32X4, 2); + ReadOnlySpan values = ((IColumn)read).Values; + + Assert.That(values.Length, Is.EqualTo(2)); + CollectionAssert.AreEqual(new[] { 1f, 2f, 3f, 4f }, values[0]); + CollectionAssert.AreEqual(new[] { -1f, -2f, -3f, -4f }, values[1]); + } + + [Test] + public async Task Values_ReadTwice_ReturnsTheSameCachedArrays() + { + IColumnCodec codec = Codec(Float32X4); + using var column = new ArrayColumn("v", Float32X4, new[] { new[] { 1f, 2f, 3f, 4f } }); + + using IColumn read = await CodecTestHarness.RoundTripAsync(codec, column, Float32X4, 1); + var typed = (IColumn)read; + + Assert.That(typed.Values[0], Is.SameAs(typed.Values[0])); + } + + [Test] + public async Task ReadColumnAsync_ZeroRows_ReadsNoBytesAndDecodesAnEmptyColumn() + { + IColumnCodec codec = Codec(Float32X4); + using ClickHouseBinaryReader reader = CodecTestHarness.ReaderOver(Array.Empty()); + + using IColumn read = await codec.ReadColumnAsync(reader, "v", Float32X4, 0, CodecTestHarness.None); + + Assert.That(read.RowCount, Is.Zero); + Assert.That(((IColumn)read).Values.Length, Is.Zero); + } + + [Test] + public async Task GetValue_RowPastTheRowCount_Throws() + { + IColumnCodec codec = Codec(Float32X4); + using ClickHouseBinaryReader reader = CodecTestHarness.ReaderOver(DocumentedBytes); + using IColumn read = await codec.ReadColumnAsync(reader, "v", Float32X4, 1, CodecTestHarness.None); + + Assert.That(() => read.GetValue(1), Throws.InstanceOf()); + } + + [Test] + public void Resolve_Float64_SurfacesDoubleVectorsAndSixtyFourPlanes() + { + IColumnCodec codec = Codec("QBit(Float64, 3)"); + + Assert.Multiple(() => + { + Assert.That(codec.ElementType, Is.EqualTo(typeof(double[]))); + Assert.That(codec.TypeName, Is.EqualTo("QBit(Float64, 3)")); + Assert.That(codec.NullPlaceholder, Is.EqualTo(new double[3])); + }); + } + + [Test] + public void Resolve_BFloat16_SurfacesWidenedFloatVectors() + { + IColumnCodec codec = Codec("QBit(BFloat16, 4)"); + + Assert.Multiple(() => + { + Assert.That(codec.ElementType, Is.EqualTo(typeof(float[]))); + Assert.That(codec.NullPlaceholder, Is.EqualTo(new float[4])); + }); + } + + [Test] + public async Task WriteThenRead_BFloat16_DropsTheLowMantissaBits() + { + // A brain-float keeps only the float's high 16 bits, so a value needing the low half comes back narrowed. + // The server normalizes nothing here — this is the client's own lossy narrowing, so no round-trip shows it. + const string Type = "QBit(BFloat16, 2)"; + IColumnCodec codec = Codec(Type); + using var column = new ArrayColumn("v", Type, new[] { new[] { 1.0001f, 2f } }); + + using IColumn read = await CodecTestHarness.RoundTripAsync(codec, column, Type, 1); + var value = (float[])read.GetValue(0); + + Assert.Multiple(() => + { + Assert.That(value[0], Is.EqualTo(1f), "1.0001f narrows to 1f in a brain-float"); + Assert.That(value[1], Is.EqualTo(2f), "2f is exactly representable"); + }); + } + + [Test] + public void CanWrite_ColumnOfAnotherElementType_IsRefused() + { + IColumnCodec codec = Codec(Float32X4); + + Assert.Multiple(() => + { + Assert.That(codec.CanWrite(new ArrayColumn("v", Float32X4, new[] { new[] { 1f, 2f, 3f, 4f } })), Is.True); + Assert.That(codec.CanWrite(new ArrayColumn("v", Float32X4, new[] { new[] { 1d } })), Is.False); + Assert.That(codec.CanWrite(PrimitiveColumn.FromValues("v", "Float32", new[] { 1f })), Is.False); + Assert.That(codec.CanWriteElementType(typeof(float[])), Is.True); + Assert.That(codec.CanWriteElementType(typeof(double[])), Is.False); + }); + } + + [Test] + public void WriteColumn_VectorOfTheWrongLength_ThrowsNamingTheRow() + { + IColumnCodec codec = Codec(Float32X4); + using var column = new ArrayColumn("v", Float32X4, new[] + { + new[] { 1f, 2f, 3f, 4f }, + new[] { 1f, 2f }, + }); + + Assert.That( + async () => await CodecTestHarness.WriteAsync(w => codec.WriteColumn(w, column)), + Throws.ArgumentException.With.Message.Contains("row 1").And.Message.Contains("exactly 4")); + } + + [Test] + public void WriteColumn_NullVector_ThrowsPointingAtNullable() + { + IColumnCodec codec = Codec(Float32X4); + using var column = new ArrayColumn("v", Float32X4, new float[][] { null }); + + Assert.That( + async () => await CodecTestHarness.WriteAsync(w => codec.WriteColumn(w, column)), + Throws.ArgumentException.With.Message.Contains("Nullable")); + } + + [TestCase("QBit(Float32)", TestName = "one argument")] + [TestCase("QBit(Float32, 4, 2)", TestName = "the stride form no server accepts")] + public void Resolve_WrongArgumentCount_ThrowsFormatException(string type) + { + Assert.That(() => Codec(type), Throws.InstanceOf().With.Message.Contains("exactly two")); + } + + [TestCase("QBit(Float32, 0)")] + [TestCase("QBit(Float32, -1)")] + [TestCase("QBit(Float32, x)")] + public void Resolve_InvalidDimension_ThrowsFormatException(string type) + { + Assert.That(() => Codec(type), Throws.InstanceOf().With.Message.Contains("vector length")); + } + + [TestCase("QBit(Int32, 4)")] + [TestCase("QBit(String, 4)")] + [TestCase("QBit(Float16, 4)")] + public void Resolve_ElementTypeTheServerRejects_ThrowsNotSupportedException(string type) + { + Assert.That( + () => Codec(type), + Throws.InstanceOf().With.Message.Contains("BFloat16, Float32 and Float64")); + } +} diff --git a/ClickHouse.Driver.Tcp.Tests/Utilities/InsertRoundTripCase.cs b/ClickHouse.Driver.Tcp.Tests/Utilities/InsertRoundTripCase.cs index f7ba48e74..6f748dcb1 100644 --- a/ClickHouse.Driver.Tcp.Tests/Utilities/InsertRoundTripCase.cs +++ b/ClickHouse.Driver.Tcp.Tests/Utilities/InsertRoundTripCase.cs @@ -1393,6 +1393,69 @@ public static IEnumerable Cases() yield return Same("Geometry", "Geometry", name => BuildGeometryColumn(name)); } + // QBit(T, N): the vector's bit planes transposed, so the values a row round-trips through are spread one + // bit at a time across the whole body. The insert source is the ergonomic ArrayColumn, which + // takes the transposing write path; the dense read-back is re-inserted by the shared dense case below, + // which is what covers the plane-copy path. Signed zero, infinity and NaN pin the sign and exponent + // planes, which an all-positive vector leaves untouched. + if (TcpServerFeatures.Has(TcpFeature.QBit)) + { + yield return Same( + "QBit(Float32, 4)", + "QBit(Float32, 4)", + name => new ArrayColumn(name, "QBit(Float32, 4)", new[] + { + new[] { 1f, 2f, 3f, 4f }, + new[] { 0f, -0f, float.MaxValue, float.MinValue }, + new[] { float.Epsilon, float.PositiveInfinity, float.NegativeInfinity, float.NaN }, + })); + + // A dimension that is not a multiple of 8 leaves the high bits of each row's last plane byte unused; + // 9 spans two bytes so a mis-set stride shows up as a shifted element rather than a lost one. + yield return Same( + "QBit(Float32, 9)", + "QBit(Float32, 9)", + name => new ArrayColumn(name, "QBit(Float32, 9)", new[] + { + new[] { 1f, 2f, 3f, 4f, 5f, 6f, 7f, 8f, 9f }, + new[] { -1f, 0f, -0f, 0.5f, -0.5f, 1e10f, -1e10f, 1e-10f, -1e-10f }, + })); + + // Float64 is the 64-plane path and its own accumulator width. + yield return Same( + "QBit(Float64, 3)", + "QBit(Float64, 3)", + name => new ArrayColumn(name, "QBit(Float64, 3)", new[] + { + new[] { 1d, -2d, 3.5d }, + new[] { double.MaxValue, double.MinValue, double.Epsilon }, + new[] { 0d, -0d, double.NaN }, + })); + + // BFloat16 keeps only the float's high 16 bits, so every value here is one a brain-float represents + // exactly — otherwise the round-trip would compare the narrowed value against the original. + yield return Same( + "QBit(BFloat16, 4)", + "QBit(BFloat16, 4)", + name => new ArrayColumn(name, "QBit(BFloat16, 4)", new[] + { + new[] { 1f, 2f, -3f, 0f }, + new[] { -0f, 0.5f, -0.5f, 256f }, + })); + + // Nullable(QBit(...)) is accepted by the server and round-trips NULL, which is the only thing that + // reads the codec's all-zero placeholder vector. + yield return Same( + "Nullable(QBit(Float32, 4))", + "Nullable(QBit(Float32, 4))", + name => new ArrayColumn(name, "Nullable(QBit(Float32, 4))", new[] + { + new[] { 1f, 2f, 3f, 4f }, + null, + new[] { -1f, -2f, -3f, -4f }, + })); + } + // SimpleAggregateFunction(func, T) encodes as a bare T — the function only tells the server how to merge // rows — so these cases prove the alias is transparent, including when T is itself composite or nullable // and when the function carries parameters. diff --git a/ClickHouse.Driver.Tcp/Types/Codecs/QBitColumnCodec.cs b/ClickHouse.Driver.Tcp/Types/Codecs/QBitColumnCodec.cs new file mode 100644 index 000000000..74d5e8f9c --- /dev/null +++ b/ClickHouse.Driver.Tcp/Types/Codecs/QBitColumnCodec.cs @@ -0,0 +1,339 @@ +using System; +using System.Buffers; +using System.Globalization; +using System.Threading; +using System.Threading.Tasks; +using ClickHouse.Driver.Tcp.Protocol; + +namespace ClickHouse.Driver.Tcp.Types.Codecs; + +/// +/// A codec for the ClickHouse QBit(T, N) column: an N-element vector stored with its bit planes +/// transposed, so a vector search can read only the high-order planes and compute an approximate distance at +/// reduced precision (L2DistanceTransposed, cosineDistanceTransposed). +/// +/// +/// The column carries no state prefix. Its body is bits(T) planes, ordered from the most +/// significant bit of T down to bit 0; each plane holds one ceil(N / 8)-byte bitmap per row, rows +/// contiguous within the plane, and element i sits at bit i % 8 of byte i / 8 +/// (least-significant bit first). So the body is plane-major and exactly +/// bits(T) * num_rows * ceil(N / 8) bytes — every row the same width. +/// +/// +/// +/// T is BFloat16, Float32 or Float64 only; the server rejects any other element +/// type. Note this is the Native layout: over RowBinary the same type is a plain array, which is +/// why the HTTP driver's QBitType reads a length-prefixed run of values instead. +/// +/// +internal abstract class QBitColumnCodec : IColumnCodec +{ + /// Initializes the shared geometry. + /// The canonical type string. + /// The vector length N. + /// The stored element's bit width — the number of planes. + protected QBitColumnCodec(string typeName, int dimension, int bitWidth) + { + TypeName = typeName; + Dimension = dimension; + BitWidth = bitWidth; + BytesPerRow = (dimension + 7) / 8; + } + + /// + public string TypeName { get; } + + /// + public abstract Type ElementType { get; } + + /// + public abstract object NullPlaceholder { get; } + + /// The vector length N. + protected int Dimension { get; } + + /// The number of bit planes — the stored element's bit width. + protected int BitWidth { get; } + + /// The bytes one row occupies within a single plane, ceil(N / 8). + protected int BytesPerRow { get; } + + /// Builds a QBit(T, N) codec from its element type and dimension arguments. + /// The parsed QBit type node. + /// The codec. + /// The type does not have exactly one element type and one positive integer dimension. + /// The element type is not one the server allows. + public static QBitColumnCodec Create(TypeNode node) + { + if (node.Arguments.Count != 2) + { + throw new FormatException( + $"QBit type '{node}' must have exactly two arguments: the element type and the vector length."); + } + + string typeName = node.ToString(); + string element = node.Arguments[0].Name.Trim(); + string token = node.Arguments[1].Name.Trim(); + + if (!int.TryParse(token, NumberStyles.None, CultureInfo.InvariantCulture, out int dimension) || dimension <= 0) + { + throw new FormatException( + $"QBit type '{node}' has an invalid vector length '{token}'; expected a positive integer."); + } + + // The same three the server allows; anything else is rejected at CREATE TABLE, so a column of one can + // only reach us from a server that has changed, not from a table a user could have made today. + return element switch + { + "BFloat16" => new QBitFloatColumnCodec(typeName, dimension, bitWidth: 16), + "Float32" => new QBitFloatColumnCodec(typeName, dimension, bitWidth: 32), + "Float64" => new QBitDoubleColumnCodec(typeName, dimension), + _ => throw new NotSupportedException( + $"QBit type '{node}' has element type '{element}'; only BFloat16, Float32 and Float64 are supported."), + }; + } + + /// + public async ValueTask ReadColumnAsync(ClickHouseBinaryReader reader, string columnName, string columnType, int rowCount, CancellationToken cancellationToken) + { + if (rowCount == 0) + { + return CreateColumn(columnName, columnType, Array.Empty(), rowCount: 0, pooled: false); + } + + int byteCount = checked(BitWidth * rowCount * BytesPerRow); + byte[] blob = ArrayPool.Shared.Rent(byteCount); + try + { + await reader.ReadBytesAsync(blob.AsMemory(0, byteCount), cancellationToken).ConfigureAwait(false); + } + catch + { + // The column never took ownership of the rent, so return it rather than leak it on a read failure. + ArrayPool.Shared.Return(blob); + throw; + } + + return CreateColumn(columnName, columnType, blob, rowCount, pooled: true); + } + + /// + public abstract bool CanWrite(IColumn column); + + /// + public void WriteColumn(ClickHouseBinaryWriter writer, IColumn column, int start, int length) + { + // A QBit column of the same geometry already holds the planes the wire wants, so the range is copied out + // plane by plane with no transposition — the hot path when a column read from the server is inserted + // straight back. One copy per plane rather than one for the whole range: the body is plane-major, so a + // row range is contiguous *within* a plane but the planes themselves are strided by the source's own row + // count, which is not this range's length unless the whole column is being written. + if (column is QBitColumn dense && dense.Dimension == Dimension && dense.BitWidth == BitWidth) + { + for (int wireIndex = 0; wireIndex < BitWidth; wireIndex++) + { + writer.WriteBytes(dense.WirePlane(wireIndex, start, length)); + } + + return; + } + + WriteTransposed(writer, column, start, length); + } + + /// Builds the decoded column over a plane blob. + /// The column name. + /// The ClickHouse type string from the block header. + /// The plane blob. + /// The number of rows. + /// Whether was rented. + /// The column. + protected abstract IColumn CreateColumn(string name, string typeName, byte[] blob, int rowCount, bool pooled); + + /// + /// Transposes an ergonomic per-row vector column into the plane-major body. Rents a scratch the size of the + /// slice's wire bytes, because plane-major output cannot be streamed a row at a time: plane 0 needs every row + /// before plane 1 begins. The dense path above avoids this entirely. + /// + /// The writer to encode into. + /// The column to transpose. + /// The zero-based first row to write. + /// The number of rows to write. + protected abstract void WriteTransposed(ClickHouseBinaryWriter writer, IColumn column, int start, int length); + + /// + /// Rents a zeroed scratch buffer for the slice's plane-major body. Rented memory is dirty and the transpose + /// only ever sets bits, so the used region must be cleared first. + /// + /// The number of rows the slice covers. + /// The used size of the returned buffer. + /// The rented buffer, zeroed over bytes. + protected byte[] RentScratch(int length, out int byteCount) + { + byteCount = checked(BitWidth * length * BytesPerRow); + byte[] scratch = ArrayPool.Shared.Rent(byteCount); + Array.Clear(scratch, 0, byteCount); + return scratch; + } + + /// + /// Validates one row's vector and returns it, blaming the row when it is null or the wrong length. A QBit row + /// is never null on the wire — Nullable carries that and substitutes the placeholder at a null + /// position — and the vector length is fixed by the type, so neither can be silently padded. + /// + /// The row's vector. + /// The row index, for the message. + /// The validated vector. + /// The vector is null or not elements. + protected T[] Validate(T[] vector, int row) + { + if (vector is null) + { + throw new ArgumentException( + $"A {TypeName} column cannot hold a null vector (at row {row}); wrap the type in Nullable to write nulls.", + nameof(vector)); + } + + if (vector.Length != Dimension) + { + throw new ArgumentException( + $"A {TypeName} vector at row {row} has {vector.Length} element(s); every vector must have exactly {Dimension}.", + nameof(vector)); + } + + return vector; + } +} + +/// +/// The QBit(Float32, N) and QBit(BFloat16, N) codec. Both surface as []: a +/// brain-float is the top 16 bits of an IEEE-754 , so on write the low 16 bits are dropped — +/// the same narrowing does for a plain BFloat16 column. +/// +internal sealed class QBitFloatColumnCodec : QBitColumnCodec +{ + private float[] nullPlaceholder; + + /// Initializes the single-precision codec. + /// The canonical type string. + /// The vector length N. + /// 16 for BFloat16, 32 for Float32. + public QBitFloatColumnCodec(string typeName, int dimension, int bitWidth) + : base(typeName, dimension, bitWidth) + { + } + + /// + public override Type ElementType => typeof(float[]); + + /// + /// The placeholder for a null row is an all-zero vector, so the values stream stays aligned at a + /// Nullable(QBit(T, N)) null position — the width every row occupies. Built on first use: a codec is + /// resolved per column per block, so a pure read would otherwise allocate a vector per block that only the + /// Nullable write path ever touches. + /// + public override object NullPlaceholder => nullPlaceholder ??= new float[Dimension]; + + /// + public override bool CanWrite(IColumn column) => column is IColumn; + + /// + protected override IColumn CreateColumn(string name, string typeName, byte[] blob, int rowCount, bool pooled) + => new QBitFloatColumn(name, typeName, Dimension, BitWidth, blob, rowCount, pooled); + + /// + protected override void WriteTransposed(ClickHouseBinaryWriter writer, IColumn column, int start, int length) + { + var typed = (IColumn)column; + byte[] scratch = RentScratch(length, out int byteCount); + try + { + for (int r = 0; r < length; r++) + { + float[] vector = Validate(typed[start + r], start + r); + for (int i = 0; i < vector.Length; i++) + { + // A brain-float keeps only the float's high half, so shift it down to sit in bits 15..0. + uint raw = BitConverter.SingleToUInt32Bits(vector[i]); + uint stored = BitWidth == 16 ? raw >> 16 : raw; + + int slot = i >> 3; + byte bit = (byte)(1 << (i & 7)); + for (int wireIndex = 0; wireIndex < BitWidth; wireIndex++) + { + if (((stored >> (BitWidth - 1 - wireIndex)) & 1) != 0) + { + scratch[((((wireIndex * length) + r) * BytesPerRow) + slot)] |= bit; + } + } + } + } + + writer.WriteBytes(scratch.AsSpan(0, byteCount)); + } + finally + { + ArrayPool.Shared.Return(scratch); + } + } +} + +/// The QBit(Float64, N) codec: 64 planes over each element's IEEE-754 double pattern. +internal sealed class QBitDoubleColumnCodec : QBitColumnCodec +{ + private double[] nullPlaceholder; + + /// Initializes the double-precision codec. + /// The canonical type string. + /// The vector length N. + public QBitDoubleColumnCodec(string typeName, int dimension) + : base(typeName, dimension, bitWidth: 64) + { + } + + /// + public override Type ElementType => typeof(double[]); + + /// The all-zero placeholder vector; see . + public override object NullPlaceholder => nullPlaceholder ??= new double[Dimension]; + + /// + public override bool CanWrite(IColumn column) => column is IColumn; + + /// + protected override IColumn CreateColumn(string name, string typeName, byte[] blob, int rowCount, bool pooled) + => new QBitDoubleColumn(name, typeName, Dimension, blob, rowCount, pooled); + + /// + protected override void WriteTransposed(ClickHouseBinaryWriter writer, IColumn column, int start, int length) + { + var typed = (IColumn)column; + byte[] scratch = RentScratch(length, out int byteCount); + try + { + for (int r = 0; r < length; r++) + { + double[] vector = Validate(typed[start + r], start + r); + for (int i = 0; i < vector.Length; i++) + { + ulong stored = BitConverter.DoubleToUInt64Bits(vector[i]); + int slot = i >> 3; + byte bit = (byte)(1 << (i & 7)); + for (int wireIndex = 0; wireIndex < BitWidth; wireIndex++) + { + if (((stored >> (BitWidth - 1 - wireIndex)) & 1) != 0) + { + scratch[((((wireIndex * length) + r) * BytesPerRow) + slot)] |= bit; + } + } + } + } + + writer.WriteBytes(scratch.AsSpan(0, byteCount)); + } + finally + { + ArrayPool.Shared.Return(scratch); + } + } +} diff --git a/ClickHouse.Driver.Tcp/Types/ColumnCodecRegistry.cs b/ClickHouse.Driver.Tcp/Types/ColumnCodecRegistry.cs index 69f0cf236..736ef3596 100644 --- a/ClickHouse.Driver.Tcp/Types/ColumnCodecRegistry.cs +++ b/ClickHouse.Driver.Tcp/Types/ColumnCodecRegistry.cs @@ -96,6 +96,10 @@ private static ColumnCodecRegistry CreateDefault() // FixedString(N): N contiguous bytes per row, the length parsed from the type argument. AddFactory("FixedString", static (TypeNode node, in ResolveContext _, ColumnCodecRegistry _) => FixedStringColumnCodec.Create(node)); + // QBit(T, N): an N-element vector stored as bits(T) transposed bit planes, most significant first, each + // plane holding one ceil(N/8)-byte bitmap per row. Fixed width per row, no state prefix. + AddFactory("QBit", static (TypeNode node, in ResolveContext _, ColumnCodecRegistry _) => QBitColumnCodec.Create(node)); + // Dates and times. AddConstant(DateColumnCodec.Instance); AddConstant(Date32ColumnCodec.Instance); diff --git a/ClickHouse.Driver.Tcp/Types/IQBitColumn.cs b/ClickHouse.Driver.Tcp/Types/IQBitColumn.cs new file mode 100644 index 000000000..39c864020 --- /dev/null +++ b/ClickHouse.Driver.Tcp/Types/IQBitColumn.cs @@ -0,0 +1,65 @@ +using System; + +namespace ClickHouse.Driver.Tcp.Types; + +/// +/// The columnar read surface of a decoded QBit(T, N) column. A QBit row is an N-element vector +/// stored with its bit planes transposed: rather than the elements of a row sitting together, the column +/// holds one bitmap per bit position, and a bitmap carries that one bit of every element of every row. That is +/// what lets a vector search read only the high-order planes and compute an approximate distance at reduced +/// precision, which is how the server's L2DistanceTransposed / cosineDistanceTransposed work. +/// +/// +/// The default view undoes the transposition and hands back a per-row +/// [] (or [] for QBit(Float64, N)), which is convenient but +/// reverses the layout the type exists to provide. This interface exposes the planes as stored, so a caller +/// computing a reduced-precision distance can read the few planes it needs without materializing any vector. +/// +/// +/// +/// Obtain it by pattern-matching a column, e.g. if (column is IQBitColumn qbit). It is not generic: the +/// planes are raw bits, so plane access does not depend on whether the elements surface as +/// or . +/// +/// +public interface IQBitColumn : IColumn +{ + /// The number of elements in each row's vector — the N of QBit(T, N). + int Dimension { get; } + + /// + /// The number of bit planes, which is the width of one element on the wire: 16 for BFloat16, 32 for + /// Float32, 64 for Float64. + /// + int BitWidth { get; } + + /// + /// The bytes one row occupies within a single plane — ceil(Dimension / 8). Element i of a row + /// sits at bit i % 8 of byte i / 8 of the row's slice, least-significant bit first. When + /// is not a multiple of 8 the high bits of the last byte are unused. + /// + int BytesPerRow { get; } + + /// + /// One bit plane: the bit at position of every element of every row, as + /// consecutive -byte bitmaps. Row r's bitmap is + /// the slice [r * BytesPerRow, (r + 1) * BytesPerRow). + /// + /// + /// is the bit's significance within the stored element, so + /// BitWidth - 1 is the sign bit and 0 the least significant mantissa bit; the most significant planes + /// are the ones a reduced-precision distance wants. (The wire stores the planes in the opposite order, most + /// significant first — this accessor hides that.) For QBit(BFloat16, N) the positions are those of the + /// 16-bit brain-float, not of the widened the values surface as. + /// + /// + /// + /// A borrowed span over the owning block's storage, valid only while the block is alive: read it in place and + /// copy out only what must outlive the block. + /// + /// + /// The bit position, from 0 (least significant) to - 1 (the sign bit). + /// The plane's bitmaps, RowCount * BytesPerRow bytes. + /// is outside [0, ). + ReadOnlySpan GetPlane(int bit); +} diff --git a/ClickHouse.Driver.Tcp/Types/QBitColumn.cs b/ClickHouse.Driver.Tcp/Types/QBitColumn.cs new file mode 100644 index 000000000..dceb55156 --- /dev/null +++ b/ClickHouse.Driver.Tcp/Types/QBitColumn.cs @@ -0,0 +1,321 @@ +using System; +using System.Buffers; +using System.Runtime.InteropServices; + +namespace ClickHouse.Driver.Tcp.Types; + +/// +/// A decoded QBit(T, N) column: the bit-plane blob exactly as it arrived, plus the geometry needed to +/// read it. The blob is BitWidth planes, each holding one ceil(N / 8)-byte bitmap per row, and the +/// planes are stored most-significant first — so the plane for bit b is at wire index +/// BitWidth - 1 - b. See for the layout a caller sees. +/// +/// +/// The blob is kept transposed rather than de-transposed on read, so a column read from the server and inserted +/// straight back is a byte copy with no transposition at all — the common shape for a vector workload, where the +/// distance is computed server-side and the client never looks at a vector. The per-row vector view is +/// therefore materialized lazily by , never eagerly. +/// +/// +/// +/// This non-generic base carries everything that does not depend on whether the elements surface as +/// or , which is what lets the codec's dense write path recognise a +/// QBit column and copy its planes without knowing the element type. +/// +/// +/// +/// The blob is rented from and returned on ; like every column, +/// the bytes and any span returned by are borrowed for the block's lifetime. +/// +/// +internal abstract class QBitColumn : IQBitColumn +{ + private readonly int rowCount; + private readonly bool pooled; + private byte[] blob; + + /// Initializes a column over a bit-plane blob. + /// The column name. + /// The ClickHouse type string (e.g. QBit(Float32, 4)). + /// The vector length N. + /// The number of planes — the stored element's bit width. + /// The plane blob (may be longer than used). + /// The number of rows. + /// Whether was rented and should be returned on dispose. + protected QBitColumn(string name, string typeName, int dimension, int bitWidth, byte[] blob, int rowCount, bool pooled) + { + Name = name; + TypeName = typeName; + Dimension = dimension; + BitWidth = bitWidth; + BytesPerRow = (dimension + 7) / 8; + this.blob = blob ?? throw new ArgumentNullException(nameof(blob)); + this.rowCount = rowCount; + this.pooled = pooled; + } + + /// + public string Name { get; } + + /// + public string TypeName { get; } + + /// + public int RowCount => rowCount; + + /// + public int Dimension { get; } + + /// + public int BitWidth { get; } + + /// + public int BytesPerRow { get; } + + /// + public ReadOnlySpan GetPlane(int bit) + { + if ((uint)bit >= (uint)BitWidth) + { + throw new ArgumentOutOfRangeException( + nameof(bit), + $"Bit {bit} is outside the {BitWidth} plane(s) of column '{Name}' ({TypeName})."); + } + + return WirePlane(BitWidth - 1 - bit, 0, rowCount); + } + + /// + public abstract object GetValue(int row); + + /// + public virtual void Dispose() + { + if (pooled && blob.Length != 0) + { + ArrayPool.Shared.Return(blob); + } + + blob = Array.Empty(); + } + + /// + /// The rows [start, start + length) of the plane at — plane order as + /// stored, most significant first — as a zero-copy slice of the blob. The write path emits planes in this + /// order, and a row range within one plane is contiguous, so a dense re-insert is one copy per plane. + /// + /// The plane's index in stored order, 0 being the most significant bit. + /// The zero-based first row of the range. + /// The number of rows in the range. + /// The range's bytes within that plane, length * BytesPerRow bytes. + /// The range lies outside the column's rows. + internal ReadOnlySpan WirePlane(int wireIndex, int start, int length) + { + // Bound the range against rowCount, not the blob: the blob is rented and may be longer, so slicing it + // directly would let an over-long range read a stale pooled region instead of failing fast. The products + // cannot overflow — the read path sized the blob with a checked total, and this range fits in it. + if (start < 0 || length < 0 || start + (long)length > rowCount) + { + throw new ArgumentOutOfRangeException( + length < 0 ? nameof(length) : nameof(start), + $"Rows [{start}, {start + (long)length}) lie outside the {rowCount} row(s) of column '{Name}'."); + } + + return blob.AsSpan(((wireIndex * rowCount) + start) * BytesPerRow, length * BytesPerRow); + } + + /// The bitmap of one row within the plane at . + /// The plane's index in stored order, 0 being the most significant bit. + /// The zero-based row index. + /// The row's BytesPerRow bytes within that plane. + protected ReadOnlySpan WirePlaneRow(int wireIndex, int row) => WirePlane(wireIndex, row, 1); + + /// Bounds a row index against the column's rows. + /// The zero-based row index. + /// The row lies outside the column. + protected void CheckRow(int row) + { + if ((uint)row >= (uint)rowCount) + { + throw new IndexOutOfRangeException(); + } + } +} + +/// +/// The typed per-row view over a : each row's vector as a +/// [], de-transposed on demand and cached. +/// +/// The CLR element type a row's vector surfaces as — or . +internal abstract class QBitColumnBase : QBitColumn, IColumn + where T : struct +{ + private T[][] cache; + + /// Initializes the typed view. + /// The column name. + /// The ClickHouse type string. + /// The vector length N. + /// The number of planes. + /// The plane blob. + /// The number of rows. + /// Whether was rented. + protected QBitColumnBase(string name, string typeName, int dimension, int bitWidth, byte[] blob, int rowCount, bool pooled) + : base(name, typeName, dimension, bitWidth, blob, rowCount, pooled) + { + } + + /// + /// The rows as per-row vectors, materialized once and cached. Every row costs + /// BitWidth * BytesPerRow byte fetches to de-transpose — 4 KiB for a 1024-dimension + /// Float32 embedding — so this is built on first use, not on read. Prefer + /// where the planes themselves are what is wanted. + /// + public ReadOnlySpan Values + { + get + { + if (cache is null) + { + // Rent rather than allocate: this is a convenience view consumers copy out of, so it only needs + // to live until Dispose returns it to the pool. Single-consumer per connection, so the lazy fill + // needs no synchronization. The rented buffer may be longer than RowCount; Values slices to it. + // + // De-transposed one whole row at a time, rather than one plane across all rows: a row's output + // vector then stays in cache for all BitWidth planes that write into it, where sweeping + // plane-by-plane would re-traverse the entire materialized output once per plane. + T[][] decoded = ArrayPool.Shared.Rent(RowCount); + for (int i = 0; i < RowCount; i++) + { + decoded[i] = DetransposeRow(i); + } + + cache = decoded; + } + + return cache.AsSpan(0, RowCount); + } + } + + /// + // The cache is rented and may be longer than RowCount, so slice before indexing to keep an out-of-range row + // failing fast rather than returning a stale slot; the uncached path is bounded by DetransposeRow. + public T[] this[int row] => cache is not null ? cache.AsSpan(0, RowCount)[row] : DetransposeRow(row); + + /// + public override object GetValue(int row) => this[row]; + + /// + public override void Dispose() + { + base.Dispose(); + + if (cache is not null) + { + // The elements are array references, so clear on return to avoid the pool pinning decoded rows. + ArrayPool.Shared.Return(cache, clearArray: true); + cache = null; + } + } + + /// Rebuilds one row's vector from the planes. + /// The zero-based row index. + /// The row's -element vector. + protected abstract T[] DetransposeRow(int row); +} + +/// +/// A QBit(Float32, N) or QBit(BFloat16, N) column. Both surface as : a +/// brain-float is the top 16 bits of an IEEE-754 , so its 16 planes rebuild the high half of +/// the 32-bit pattern and the low half stays zero — the same widening BFloat16ColumnCodec does for a +/// plain BFloat16 column. +/// +internal sealed class QBitFloatColumn : QBitColumnBase +{ + /// Initializes a single-precision QBit column. + /// The column name. + /// The ClickHouse type string. + /// The vector length N. + /// 16 for BFloat16, 32 for Float32. + /// The plane blob. + /// The number of rows. + /// Whether was rented. + public QBitFloatColumn(string name, string typeName, int dimension, int bitWidth, byte[] blob, int rowCount, bool pooled) + : base(name, typeName, dimension, bitWidth, blob, rowCount, pooled) + { + } + + /// + protected override float[] DetransposeRow(int row) + { + CheckRow(row); + + var vector = new float[Dimension]; + + // Accumulate through the float's own storage rather than a separate integer scratch: the bits being + // gathered *are* the IEEE-754 pattern, so the vector holds the finished values once the last plane is in. + Span bits = MemoryMarshal.Cast(vector.AsSpan()); + for (int wireIndex = 0; wireIndex < BitWidth; wireIndex++) + { + ReadOnlySpan plane = WirePlaneRow(wireIndex, row); + uint mask = 1u << (BitWidth - 1 - wireIndex); + for (int i = 0; i < bits.Length; i++) + { + if ((plane[i >> 3] & (1 << (i & 7))) != 0) + { + bits[i] |= mask; + } + } + } + + // A brain-float's 16 bits are the float's high half, so shift them up into it. + if (BitWidth == 16) + { + for (int i = 0; i < bits.Length; i++) + { + bits[i] <<= 16; + } + } + + return vector; + } +} + +/// A QBit(Float64, N) column: 64 planes rebuilding each element's IEEE-754 double pattern. +internal sealed class QBitDoubleColumn : QBitColumnBase +{ + /// Initializes a double-precision QBit column. + /// The column name. + /// The ClickHouse type string. + /// The vector length N. + /// The plane blob. + /// The number of rows. + /// Whether was rented. + public QBitDoubleColumn(string name, string typeName, int dimension, byte[] blob, int rowCount, bool pooled) + : base(name, typeName, dimension, bitWidth: 64, blob, rowCount, pooled) + { + } + + /// + protected override double[] DetransposeRow(int row) + { + CheckRow(row); + + var vector = new double[Dimension]; + Span bits = MemoryMarshal.Cast(vector.AsSpan()); + for (int wireIndex = 0; wireIndex < BitWidth; wireIndex++) + { + ReadOnlySpan plane = WirePlaneRow(wireIndex, row); + ulong mask = 1UL << (BitWidth - 1 - wireIndex); + for (int i = 0; i < bits.Length; i++) + { + if ((plane[i >> 3] & (1 << (i & 7))) != 0) + { + bits[i] |= mask; + } + } + } + + return vector; + } +} From 50fde8089c336986c09f493e12bdfb10c4504184 Mon Sep 17 00:00:00 2001 From: Alex Soffronow-Pagonidis Date: Thu, 20 Aug 2026 09:02:30 +0200 Subject: [PATCH 2/4] Correct the QBit row byte order, and vectorize the transpose 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.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 --- .../Integration/QBitIntegrationTests.cs | 155 ++++++++++++++++++ .../Types/QBitColumnCodecTests.cs | 30 ++++ .../Utilities/InsertRoundTripCase.cs | 37 +++++ .../Types/Codecs/QBitColumnCodec.cs | 113 +++++++++++-- .../Types/ColumnCodecRegistry.cs | 2 +- ClickHouse.Driver.Tcp/Types/IQBitColumn.cs | 12 +- ClickHouse.Driver.Tcp/Types/QBitColumn.cs | 28 +++- 7 files changed, 356 insertions(+), 21 deletions(-) create mode 100644 ClickHouse.Driver.Tcp.Tests/Integration/QBitIntegrationTests.cs diff --git a/ClickHouse.Driver.Tcp.Tests/Integration/QBitIntegrationTests.cs b/ClickHouse.Driver.Tcp.Tests/Integration/QBitIntegrationTests.cs new file mode 100644 index 000000000..8b999b1b6 --- /dev/null +++ b/ClickHouse.Driver.Tcp.Tests/Integration/QBitIntegrationTests.cs @@ -0,0 +1,155 @@ +using System; +using System.Globalization; +using System.Linq; +using System.Threading; +using System.Threading.Tasks; +using ClickHouse.Driver.Tcp.Format; +using ClickHouse.Driver.Tcp.Tests.Utilities; +using ClickHouse.Driver.Tcp.Types; + +namespace ClickHouse.Driver.Tcp.Tests.Integration; + +/// +/// The two asymmetric checks on QBit(T, N) that the insert round-trip corpus cannot make. A +/// round-trip writes and reads with the same client code, so a layout error that is self-consistent — the bit +/// order within a row, say — round-trips perfectly while putting bytes on the wire the server reads as different +/// values. Each test here has exactly one side done by the client and the other by the server. +/// +/// +/// This is not hypothetical: the byte order within a row runs opposite to the element order, and every fixture +/// narrower than 9 elements is one byte per row, where that is unobservable. These tests use a dimension wide +/// enough to see it. +/// +/// +[TestFixture] +[Category("Integration")] +[RequiresServerFeature(TcpFeature.QBit)] +public class QBitIntegrationTests +{ + private static readonly CancellationToken None = CancellationToken.None; + + // 17 elements is two whole 8-element groups plus a tail, so it spans three bytes per row and pins the byte + // order. The values are distinct and asymmetric across the group boundary, so a swapped byte is a wrong value + // rather than a coincidence. + private const int Dimension = 17; + private const string Float32Type = "QBit(Float32, 17)"; + + private static float[] Vector() => Enumerable.Range(0, Dimension).Select(i => (i * 3f) - 20f).ToArray(); + + [Test] + public async Task InsertAsync_QBitWrittenByTheClient_IsReadBackByTheServerAsTheSameVector() + { + // The client transposes; the server de-transposes. toString() makes the server do that work and hand back + // its own rendering, so nothing about the client's read path is involved in the comparison. + await using var client = TcpServerFixture.CreateClient(); + string table = UniqueTableName(); + + await Drain(client, $"CREATE TABLE {table} (v {Float32Type}) ENGINE = Memory"); + try + { + float[] vector = Vector(); + using var column = new ArrayColumn("v", Float32Type, new[] { vector }); + await client.InsertAsync($"INSERT INTO {table} (v) VALUES", new IColumn[] { column }, cancellationToken: None); + + string rendered = await ScalarStringAsync(client, $"SELECT toString(v) FROM {table}"); + + Assert.That(rendered, Is.EqualTo(Expected(vector))); + } + finally + { + await Drain(client, $"DROP TABLE IF EXISTS {table}"); + } + } + + [Test] + public async Task StreamAsync_QBitWrittenByTheServer_DecodesToTheSameVector() + { + // The mirror: the server transposes (it parses the VALUES literal), the client de-transposes. Together + // with the test above this pins both directions against an independent implementation. + await using var client = TcpServerFixture.CreateClient(); + string table = UniqueTableName(); + + await Drain(client, $"CREATE TABLE {table} (v {Float32Type}) ENGINE = Memory"); + try + { + float[] vector = Vector(); + string literal = string.Join(",", vector.Select(v => v.ToString("R", CultureInfo.InvariantCulture))); + await Drain(client, $"INSERT INTO {table} VALUES ([{literal}])"); + + float[] decoded = null; + await foreach (Block block in client.StreamAsync($"SELECT v FROM {table}", cancellationToken: None)) + { + decoded = (float[])block[0].GetValue(0); + } + + CollectionAssert.AreEqual(vector, decoded); + } + finally + { + await Drain(client, $"DROP TABLE IF EXISTS {table}"); + } + } + + [Test] + public async Task GetPlane_QBitWrittenByTheServer_AgreesWithTheServersOwnBitExtraction() + { + // IQBitColumn is the whole point of the type — a caller reading the high planes to approximate a distance + // — and no round-trip touches it. bitTest on the server's own value is the independent answer for whether + // element i has bit b set, so the plane the client hands out can be checked bit by bit against it. + await using var client = TcpServerFixture.CreateClient(); + string table = UniqueTableName(); + + await Drain(client, $"CREATE TABLE {table} (v {Float32Type}) ENGINE = Memory"); + try + { + float[] vector = Vector(); + string literal = string.Join(",", vector.Select(v => v.ToString("R", CultureInfo.InvariantCulture))); + await Drain(client, $"INSERT INTO {table} VALUES ([{literal}])"); + + byte[] signPlane = null; + await foreach (Block block in client.StreamAsync($"SELECT v FROM {table}", cancellationToken: None)) + { + signPlane = ((IQBitColumn)block[0]).GetPlane(31).ToArray(); + } + + // The sign bit of element i, straight from the client's plane. + for (int i = 0; i < Dimension; i++) + { + int slot = signPlane.Length - 1 - (i / 8); + bool negativeInPlane = (signPlane[slot] & (1 << (i % 8))) != 0; + Assert.That(negativeInPlane, Is.EqualTo(vector[i] < 0 || float.IsNegative(vector[i])), $"element {i}"); + } + } + finally + { + await Drain(client, $"DROP TABLE IF EXISTS {table}"); + } + } + + private static string Expected(float[] vector) + => "[" + string.Join(",", vector.Select(v => v.ToString("R", CultureInfo.InvariantCulture))) + "]"; + + private static string UniqueTableName() => $"tcp_qbit_test_{Guid.NewGuid():N}"; + + private static async Task ScalarStringAsync(ClickHouseTcpClient client, string sql) + { + string value = null; + await foreach (Block block in client.StreamAsync(sql, cancellationToken: None)) + { + if (block.RowCount > 0) + { + value = (string)block[0].GetValue(0); + } + } + + return value; + } + + private static async Task Drain(ClickHouseTcpClient client, string sql) + { + await foreach (Block block in client.StreamAsync(sql, cancellationToken: None)) + { + _ = block; + } + } +} diff --git a/ClickHouse.Driver.Tcp.Tests/Types/QBitColumnCodecTests.cs b/ClickHouse.Driver.Tcp.Tests/Types/QBitColumnCodecTests.cs index 69f9dc8ef..bb868523d 100644 --- a/ClickHouse.Driver.Tcp.Tests/Types/QBitColumnCodecTests.cs +++ b/ClickHouse.Driver.Tcp.Tests/Types/QBitColumnCodecTests.cs @@ -36,6 +36,19 @@ public class QBitColumnCodecTests 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, }; + // Captured the same way, for QBit(Float32, 16) holding one row of [1, -2, 3, -4, ... 15, -16]. Two bytes per + // row per plane, so this is the only fixture that spans more than one 8-element group — the unit the vector + // write path works in. The signs alternate, which makes the sign plane 0xAA 0xAA. + private static readonly byte[] DocumentedBytes16 = + { + 0xAA, 0xAA, 0xFF, 0xFE, 0x00, 0x01, 0x00, 0x01, 0x00, 0x01, 0x00, 0x01, + 0x00, 0x01, 0xFF, 0x81, 0x80, 0x79, 0x78, 0x64, 0x66, 0x50, 0x55, 0x00, + 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, + 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, + 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, + 0x00, 0x00, 0x00, 0x00, + }; + private static IColumnCodec Codec(string type) => ColumnCodecRegistry.Default.Resolve(type, ResolveContext.ForWrite); [Test] @@ -60,6 +73,23 @@ public async Task ReadColumnAsync_TheServersOwnBytes_DecodesTheDocumentedVector( CollectionAssert.AreEqual(new[] { 1f, 2f, 3f, 4f }, (float[])read.GetValue(0)); } + [Test] + public async Task WriteColumn_SpanningSeveralGroupsOfEight_ProducesTheServersOwnBytes() + { + // 16 elements is two whole 8-element groups, which is the unit the vector write path works in; the + // dimension-4 fixture above never leaves the scalar tail. Pins the group loop against real server bytes. + const string Type = "QBit(Float32, 16)"; + IColumnCodec codec = Codec(Type); + using var column = new ArrayColumn("v", Type, new[] + { + new[] { 1f, -2f, 3f, -4f, 5f, -6f, 7f, -8f, 9f, -10f, 11f, -12f, 13f, -14f, 15f, -16f }, + }); + + byte[] bytes = await CodecTestHarness.WriteAsync(w => codec.WriteColumn(w, column)); + + CollectionAssert.AreEqual(DocumentedBytes16, bytes); + } + [Test] public async Task WriteColumn_RowSlice_EmitsEachPlaneStridedByTheSourceRowCount() { diff --git a/ClickHouse.Driver.Tcp.Tests/Utilities/InsertRoundTripCase.cs b/ClickHouse.Driver.Tcp.Tests/Utilities/InsertRoundTripCase.cs index 6f748dcb1..8dd183d66 100644 --- a/ClickHouse.Driver.Tcp.Tests/Utilities/InsertRoundTripCase.cs +++ b/ClickHouse.Driver.Tcp.Tests/Utilities/InsertRoundTripCase.cs @@ -1432,6 +1432,30 @@ public static IEnumerable Cases() new[] { 0d, -0d, double.NaN }, })); + // Dimensions of 8 and above reach the vector write path, which works a group of 8 elements at a time; + // everything narrower is handled entirely by its scalar tail. 17 is two whole groups plus one element, + // so it covers the group loop and the tail together, and it is an embedding-shaped width rather than + // the hand-checked fixtures above. + yield return Same( + "QBit(Float32, 17)", + "QBit(Float32, 17)", + name => new ArrayColumn(name, "QBit(Float32, 17)", new[] + { + Ramp(17, i => (i * 0.5f) - 4f), + Ramp(17, i => i % 2 == 0 ? float.MaxValue : float.MinValue), + })); + + // The Float64 group loop takes two extracts per plane byte, where the Float32 one takes a single + // extract, so it needs its own multi-group case. + yield return Same( + "QBit(Float64, 17)", + "QBit(Float64, 17)", + name => new ArrayColumn(name, "QBit(Float64, 17)", new[] + { + Ramp(17, i => (i * 0.25d) - 2d), + Ramp(17, i => i % 2 == 0 ? double.MaxValue : double.MinValue), + })); + // BFloat16 keeps only the float's high 16 bits, so every value here is one a brain-float represents // exactly — otherwise the round-trip would compare the narrowed value against the original. yield return Same( @@ -1703,6 +1727,19 @@ private static ClickHouseDecimal ParseWide(string text) private static InsertRoundTripCase Same(string label, string clickHouseType, Func build, IReadOnlyDictionary settings = null) => new(label, clickHouseType, build, build, settings); + /// A vector of values from their index — for the wider QBit dimensions, + /// where spelling out every element would obscure the width being tested. + private static T[] Ramp(int length, Func value) + { + var values = new T[length]; + for (int i = 0; i < length; i++) + { + values[i] = value(i); + } + + return values; + } + /// Enables the experimental BFloat16 type for the round-trip. private static readonly IReadOnlyDictionary BFloat16Settings = new Dictionary(StringComparer.Ordinal) { diff --git a/ClickHouse.Driver.Tcp/Types/Codecs/QBitColumnCodec.cs b/ClickHouse.Driver.Tcp/Types/Codecs/QBitColumnCodec.cs index 74d5e8f9c..a84f1ae57 100644 --- a/ClickHouse.Driver.Tcp/Types/Codecs/QBitColumnCodec.cs +++ b/ClickHouse.Driver.Tcp/Types/Codecs/QBitColumnCodec.cs @@ -1,6 +1,9 @@ using System; using System.Buffers; using System.Globalization; +using System.Runtime.CompilerServices; +using System.Runtime.InteropServices; +using System.Runtime.Intrinsics; using System.Threading; using System.Threading.Tasks; using ClickHouse.Driver.Tcp.Protocol; @@ -15,9 +18,10 @@ namespace ClickHouse.Driver.Tcp.Types.Codecs; /// /// The column carries no state prefix. Its body is bits(T) planes, ordered from the most /// significant bit of T down to bit 0; each plane holds one ceil(N / 8)-byte bitmap per row, rows -/// contiguous within the plane, and element i sits at bit i % 8 of byte i / 8 -/// (least-significant bit first). So the body is plane-major and exactly -/// bits(T) * num_rows * ceil(N / 8) bytes — every row the same width. +/// contiguous within the plane. Element i sits at bit i % 8 of the bitmap byte +/// names — the bytes run in the reverse of the element order, so group 0 is +/// the last byte. The body is plane-major and exactly bits(T) * num_rows * ceil(N / 8) bytes — every row +/// the same width. /// /// /// @@ -248,22 +252,35 @@ protected override void WriteTransposed(ClickHouseBinaryWriter writer, IColumn c byte[] scratch = RentScratch(length, out int byteCount); try { + // Hoisted out of the row loop, as UuidColumnCodec does: the answer is the same for every row. + bool simd = Vector256.IsHardwareAccelerated; + int planeStride = length * BytesPerRow; for (int r = 0; r < length; r++) { float[] vector = Validate(typed[start + r], start + r); - for (int i = 0; i < vector.Length; i++) + int rowBase = (r * BytesPerRow); + int whole = simd ? Dimension >> 3 : 0; + + if (whole != 0) { - // A brain-float keeps only the float's high half, so shift it down to sit in bits 15..0. - uint raw = BitConverter.SingleToUInt32Bits(vector[i]); - uint stored = BitWidth == 16 ? raw >> 16 : raw; + TransposeGroups(scratch, vector, whole, rowBase, planeStride); + } - int slot = i >> 3; + // The elements past the last whole group of 8, and every element when there is no hardware + // acceleration. They occupy byte `whole` of the row, which the vector path never writes, so the + // two cannot collide. + for (int i = whole << 3; i < vector.Length; i++) + { + uint raw = BitConverter.SingleToUInt32Bits(vector[i]); + int slot = QBitLayout.ByteOfGroup(i >> 3, BytesPerRow); byte bit = (byte)(1 << (i & 7)); for (int wireIndex = 0; wireIndex < BitWidth; wireIndex++) { - if (((stored >> (BitWidth - 1 - wireIndex)) & 1) != 0) + // Plane `wireIndex` is bit 31 - wireIndex of the float, for a brain-float too: its 16 + // bits *are* the float's high half, so its planes are the float's top 16. + if (((raw >> (31 - wireIndex)) & 1) != 0) { - scratch[((((wireIndex * length) + r) * BytesPerRow) + slot)] |= bit; + scratch[(wireIndex * planeStride) + rowBase + slot] |= bit; } } } @@ -276,6 +293,33 @@ protected override void WriteTransposed(ClickHouseBinaryWriter writer, IColumn c ArrayPool.Shared.Return(scratch); } } + + /// + /// Transposes the first groups of 8 elements of one row. + /// gathers the top bit of 8 lanes into a byte, which + /// is one plane byte for 8 s in the order the wire wants (element i at bit + /// i), so a plane costs one extract plus one shift rather than 8 test-and-sets. Walking the planes + /// most significant first is then just shifting the vector left one bit each step. + /// + /// The zeroed plane-major slice buffer. + /// The row's vector. + /// The number of complete 8-element groups. + /// The row's byte offset within a plane. + /// The bytes one plane occupies for the whole slice. + private void TransposeGroups(byte[] scratch, float[] vector, int whole, int rowBase, int planeStride) + { + ref uint source = ref Unsafe.As(ref MemoryMarshal.GetArrayDataReference(vector)); + for (int group = 0; group < whole; group++) + { + Vector256 lanes = Vector256.LoadUnsafe(ref source, (nuint)(group << 3)); + int slot = rowBase + QBitLayout.ByteOfGroup(group, BytesPerRow); + for (int wireIndex = 0; wireIndex < BitWidth; wireIndex++) + { + scratch[(wireIndex * planeStride) + slot] = (byte)lanes.ExtractMostSignificantBits(); + lanes <<= 1; + } + } + } } /// The QBit(Float64, N) codec: 64 planes over each element's IEEE-754 double pattern. @@ -311,19 +355,29 @@ protected override void WriteTransposed(ClickHouseBinaryWriter writer, IColumn c byte[] scratch = RentScratch(length, out int byteCount); try { + bool simd = Vector256.IsHardwareAccelerated; + int planeStride = length * BytesPerRow; for (int r = 0; r < length; r++) { double[] vector = Validate(typed[start + r], start + r); - for (int i = 0; i < vector.Length; i++) + int rowBase = r * BytesPerRow; + int whole = simd ? Dimension >> 3 : 0; + + if (whole != 0) + { + TransposeGroups(scratch, vector, whole, rowBase, planeStride); + } + + for (int i = whole << 3; i < vector.Length; i++) { - ulong stored = BitConverter.DoubleToUInt64Bits(vector[i]); - int slot = i >> 3; + ulong raw = BitConverter.DoubleToUInt64Bits(vector[i]); + int slot = QBitLayout.ByteOfGroup(i >> 3, BytesPerRow); byte bit = (byte)(1 << (i & 7)); for (int wireIndex = 0; wireIndex < BitWidth; wireIndex++) { - if (((stored >> (BitWidth - 1 - wireIndex)) & 1) != 0) + if (((raw >> (63 - wireIndex)) & 1) != 0) { - scratch[((((wireIndex * length) + r) * BytesPerRow) + slot)] |= bit; + scratch[(wireIndex * planeStride) + rowBase + slot] |= bit; } } } @@ -336,4 +390,33 @@ protected override void WriteTransposed(ClickHouseBinaryWriter writer, IColumn c ArrayPool.Shared.Return(scratch); } } + + /// + /// Transposes the first groups of 8 elements of one row. A + /// of holds only 4 lanes, so a plane byte takes two extracts + /// — the low group in bits 3..0 and the high group in bits 7..4 — against the single extract the + /// path needs. + /// + /// The zeroed plane-major slice buffer. + /// The row's vector. + /// The number of complete 8-element groups. + /// The row's byte offset within a plane. + /// The bytes one plane occupies for the whole slice. + private void TransposeGroups(byte[] scratch, double[] vector, int whole, int rowBase, int planeStride) + { + ref ulong source = ref Unsafe.As(ref MemoryMarshal.GetArrayDataReference(vector)); + for (int group = 0; group < whole; group++) + { + Vector256 low = Vector256.LoadUnsafe(ref source, (nuint)(group << 3)); + Vector256 high = Vector256.LoadUnsafe(ref source, (nuint)((group << 3) + 4)); + int slot = rowBase + QBitLayout.ByteOfGroup(group, BytesPerRow); + for (int wireIndex = 0; wireIndex < BitWidth; wireIndex++) + { + uint bits = low.ExtractMostSignificantBits() | (high.ExtractMostSignificantBits() << 4); + scratch[(wireIndex * planeStride) + slot] = (byte)bits; + low <<= 1; + high <<= 1; + } + } + } } diff --git a/ClickHouse.Driver.Tcp/Types/ColumnCodecRegistry.cs b/ClickHouse.Driver.Tcp/Types/ColumnCodecRegistry.cs index 736ef3596..703ccbfd6 100644 --- a/ClickHouse.Driver.Tcp/Types/ColumnCodecRegistry.cs +++ b/ClickHouse.Driver.Tcp/Types/ColumnCodecRegistry.cs @@ -97,7 +97,7 @@ private static ColumnCodecRegistry CreateDefault() AddFactory("FixedString", static (TypeNode node, in ResolveContext _, ColumnCodecRegistry _) => FixedStringColumnCodec.Create(node)); // QBit(T, N): an N-element vector stored as bits(T) transposed bit planes, most significant first, each - // plane holding one ceil(N/8)-byte bitmap per row. Fixed width per row, no state prefix. + // plane holding one big-endian ceil(N/8)-byte bitmap per row. Fixed width per row, no state prefix. AddFactory("QBit", static (TypeNode node, in ResolveContext _, ColumnCodecRegistry _) => QBitColumnCodec.Create(node)); // Dates and times. diff --git a/ClickHouse.Driver.Tcp/Types/IQBitColumn.cs b/ClickHouse.Driver.Tcp/Types/IQBitColumn.cs index 39c864020..86eeb7ae3 100644 --- a/ClickHouse.Driver.Tcp/Types/IQBitColumn.cs +++ b/ClickHouse.Driver.Tcp/Types/IQBitColumn.cs @@ -34,9 +34,15 @@ public interface IQBitColumn : IColumn int BitWidth { get; } /// - /// The bytes one row occupies within a single plane — ceil(Dimension / 8). Element i of a row - /// sits at bit i % 8 of byte i / 8 of the row's slice, least-significant bit first. When - /// is not a multiple of 8 the high bits of the last byte are unused. + /// The bytes one row occupies within a single plane — ceil(Dimension / 8). + /// + /// + /// Element i of a row sits at bit i % 8 of byte BytesPerRow - 1 - i / 8: the bits within + /// a byte run least significant first, but the bytes run in the reverse of the element order, so + /// elements 0-7 are in the last byte. Equivalently, the row's bitmap is the big-endian encoding of a + /// BytesPerRow-byte integer whose bit i is element i. When is not + /// a multiple of 8 the unused bits are the high bits of byte 0. + /// /// int BytesPerRow { get; } diff --git a/ClickHouse.Driver.Tcp/Types/QBitColumn.cs b/ClickHouse.Driver.Tcp/Types/QBitColumn.cs index dceb55156..97ba1927f 100644 --- a/ClickHouse.Driver.Tcp/Types/QBitColumn.cs +++ b/ClickHouse.Driver.Tcp/Types/QBitColumn.cs @@ -4,6 +4,30 @@ namespace ClickHouse.Driver.Tcp.Types; +/// +/// Where an element sits inside one row's bitmap of a QBit plane. Shared by the read and write paths so +/// the two cannot disagree about it. +/// +internal static class QBitLayout +{ + /// + /// The byte, within a row's ceil(N / 8)-byte bitmap, holding the group of 8 elements starting at + /// group * 8. The bytes run in the reverse of the element order — the row's bitmap is the + /// big-endian encoding of a ceil(N / 8)-byte integer whose bit i is element i — so group + /// 0 is the last byte. Verified against a 26.6 server with QBit(Float32, 72): element 0 lands + /// in byte 8 and element 64 in byte 0. + /// + /// + /// This is invisible whenever N <= 8, where a row is a single byte — which is why the layout notes + /// in native-format.md describe it the other way round. + /// + /// + /// The element's group index, element / 8. + /// The row's bitmap width, ceil(N / 8). + /// The byte offset within the row's bitmap. + public static int ByteOfGroup(int group, int bytesPerRow) => bytesPerRow - 1 - group; +} + /// /// A decoded QBit(T, N) column: the bit-plane blob exactly as it arrived, plus the geometry needed to /// read it. The blob is BitWidth planes, each holding one ceil(N / 8)-byte bitmap per row, and the @@ -261,7 +285,7 @@ protected override float[] DetransposeRow(int row) uint mask = 1u << (BitWidth - 1 - wireIndex); for (int i = 0; i < bits.Length; i++) { - if ((plane[i >> 3] & (1 << (i & 7))) != 0) + if ((plane[QBitLayout.ByteOfGroup(i >> 3, BytesPerRow)] & (1 << (i & 7))) != 0) { bits[i] |= mask; } @@ -309,7 +333,7 @@ protected override double[] DetransposeRow(int row) ulong mask = 1UL << (BitWidth - 1 - wireIndex); for (int i = 0; i < bits.Length; i++) { - if ((plane[i >> 3] & (1 << (i & 7))) != 0) + if ((plane[QBitLayout.ByteOfGroup(i >> 3, BytesPerRow)] & (1 << (i & 7))) != 0) { bits[i] |= mask; } From 10903ce28cf90c23c882b3d9923ccfb7fabf1a97 Mon Sep 17 00:00:00 2001 From: Alex Soffronow-Pagonidis Date: Fri, 21 Aug 2026 14:22:00 +0200 Subject: [PATCH 3/4] Add QBit(Int8, N), and shape IQBitColumn for the strided form MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 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. 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 --- .../Integration/QBitIntegrationTests.cs | 32 ++++ .../Types/QBitColumnCodecTests.cs | 143 +++++++++++++++++- .../Utilities/InsertRoundTripCase.cs | 28 ++++ .../Utilities/TcpFeature.cs | 4 + .../Types/Codecs/QBitColumnCodec.cs | 112 ++++++++++++-- ClickHouse.Driver.Tcp/Types/IQBitColumn.cs | 72 +++++++-- ClickHouse.Driver.Tcp/Types/QBitColumn.cs | 102 +++++++++++-- 7 files changed, 460 insertions(+), 33 deletions(-) diff --git a/ClickHouse.Driver.Tcp.Tests/Integration/QBitIntegrationTests.cs b/ClickHouse.Driver.Tcp.Tests/Integration/QBitIntegrationTests.cs index 8b999b1b6..dc406e047 100644 --- a/ClickHouse.Driver.Tcp.Tests/Integration/QBitIntegrationTests.cs +++ b/ClickHouse.Driver.Tcp.Tests/Integration/QBitIntegrationTests.cs @@ -33,9 +33,14 @@ public class QBitIntegrationTests // rather than a coincidence. private const int Dimension = 17; private const string Float32Type = "QBit(Float32, 17)"; + private const string Int8Type = "QBit(Int8, 17)"; private static float[] Vector() => Enumerable.Range(0, Dimension).Select(i => (i * 3f) - 20f).ToArray(); + // Spans the sign, both whole bytes and the one-element tail, with MinValue pinning the all-ones pattern. + private static sbyte[] Int8Vector() + => Enumerable.Range(0, Dimension).Select(i => i == 16 ? sbyte.MinValue : (sbyte)((i * 7) - 60)).ToArray(); + [Test] public async Task InsertAsync_QBitWrittenByTheClient_IsReadBackByTheServerAsTheSameVector() { @@ -126,6 +131,33 @@ public async Task GetPlane_QBitWrittenByTheServer_AgreesWithTheServersOwnBitExtr } } + [Test] + [RequiresServerFeature(TcpFeature.QBitInt8)] + public async Task InsertAsync_Int8QBitWrittenByTheClient_IsReadBackByTheServerAsTheSameVector() + { + // QBit(Int8, N) has its own hand-written transpose loop, so the Float32 check above does not cover it. The + // frozen byte fixture in the unit suite is 16 elements — two whole bytes — and this is the non-multiple-of-8 + // width, where the last byte is partly unused. Server-side toString() keeps the client's read path out of it. + await using var client = TcpServerFixture.CreateClient(); + string table = UniqueTableName(); + + await Drain(client, $"CREATE TABLE {table} (v {Int8Type}) ENGINE = Memory"); + try + { + sbyte[] vector = Int8Vector(); + using var column = new ArrayColumn("v", Int8Type, new[] { vector }); + await client.InsertAsync($"INSERT INTO {table} (v) VALUES", new IColumn[] { column }, cancellationToken: None); + + string rendered = await ScalarStringAsync(client, $"SELECT toString(v) FROM {table}"); + + Assert.That(rendered, Is.EqualTo("[" + string.Join(",", vector.Select(v => v.ToString(CultureInfo.InvariantCulture))) + "]")); + } + finally + { + await Drain(client, $"DROP TABLE IF EXISTS {table}"); + } + } + private static string Expected(float[] vector) => "[" + string.Join(",", vector.Select(v => v.ToString("R", CultureInfo.InvariantCulture))) + "]"; diff --git a/ClickHouse.Driver.Tcp.Tests/Types/QBitColumnCodecTests.cs b/ClickHouse.Driver.Tcp.Tests/Types/QBitColumnCodecTests.cs index bb868523d..1a3b034d2 100644 --- a/ClickHouse.Driver.Tcp.Tests/Types/QBitColumnCodecTests.cs +++ b/ClickHouse.Driver.Tcp.Tests/Types/QBitColumnCodecTests.cs @@ -1,4 +1,5 @@ using System; +using System.IO; using System.Threading.Tasks; using ClickHouse.Driver.Tcp.Protocol; using ClickHouse.Driver.Tcp.Tests.Utilities; @@ -8,7 +9,7 @@ namespace ClickHouse.Driver.Tcp.Tests.Types; /// /// Unit coverage for QBit(T, N), limited to what a server round-trip cannot observe: the exact plane -/// layout, the significance ordering imposes on top of it, the type-resolution +/// layout, the significance ordering imposes on top of it, the type-resolution /// and write error paths, and the pooled Values cache. Per-type values are covered by /// against a real server. /// @@ -49,6 +50,21 @@ public class QBitColumnCodecTests 0x00, 0x00, 0x00, 0x00, }; + // Captured from a ClickHouse 26.7 `SELECT v FROM t FORMAT Native` where v is QBit(Int8, 16) holding one row of + // [1, -2, 3, -4, ... 15, -16] — the same input values as DocumentedBytes16, over a two's-complement encoding + // rather than IEEE-754. 8 planes of two bytes, most significant (the sign) first. Two bytes per row is what + // makes the reversed byte order within a bitmap observable at all. + private static readonly byte[] DocumentedInt8Bytes16 = + { + 0xAA, 0xAA, 0xAA, 0xAA, 0xAA, 0xAA, 0xAA, 0xAA, + 0x55, 0xAA, 0x5A, 0x5A, 0x66, 0x66, 0x55, 0x55, + }; + + private static readonly sbyte[] DocumentedInt8Vector = + { + 1, -2, 3, -4, 5, -6, 7, -8, 9, -10, 11, -12, 13, -14, 15, -16, + }; + private static IColumnCodec Codec(string type) => ColumnCodecRegistry.Default.Resolve(type, ResolveContext.ForWrite); [Test] @@ -306,13 +322,130 @@ public void WriteColumn_NullVector_ThrowsPointingAtNullable() Throws.ArgumentException.With.Message.Contains("Nullable")); } + [Test] + public async Task WriteColumn_Int8Vector_ProducesTheServersOwnBytes() + { + const string Type = "QBit(Int8, 16)"; + IColumnCodec codec = Codec(Type); + using var column = new ArrayColumn("v", Type, new[] { DocumentedInt8Vector }); + + byte[] bytes = await CodecTestHarness.WriteAsync(w => codec.WriteColumn(w, column)); + + CollectionAssert.AreEqual(DocumentedInt8Bytes16, bytes); + } + + [Test] + public async Task ReadColumnAsync_Int8ServerBytes_DecodesTheTwosComplementVector() + { + // The negative values are what a de-transpose that rebuilt the byte through a signed accumulator would + // get wrong; the sign is just plane 0's bit, with no widening involved. + const string Type = "QBit(Int8, 16)"; + IColumnCodec codec = Codec(Type); + using ClickHouseBinaryReader reader = CodecTestHarness.ReaderOver(DocumentedInt8Bytes16); + + using IColumn read = await codec.ReadColumnAsync(reader, "v", Type, 1, CodecTestHarness.None); + + CollectionAssert.AreEqual(DocumentedInt8Vector, (sbyte[])read.GetValue(0)); + Assert.That(((IQBitColumn)read).BitWidth, Is.EqualTo(8)); + } + + [Test] + public void CanWrite_Int8Codec_AcceptsOnlySByteVectors() + { + IColumnCodec codec = Codec("QBit(Int8, 4)"); + + Assert.Multiple(() => + { + Assert.That(codec.ElementType, Is.EqualTo(typeof(sbyte[]))); + Assert.That(codec.CanWrite(new ArrayColumn("v", "QBit(Int8, 4)", new[] { new sbyte[4] })), Is.True); + Assert.That(codec.CanWrite(new ArrayColumn("v", "QBit(Int8, 4)", new[] { new float[4] })), Is.False); + Assert.That(codec.NullPlaceholder, Is.EqualTo(new sbyte[4])); + }); + } + + [Test] + public async Task GetPlane_UnstridedColumn_ReportsOneGroupAndAgreesWithTheGroupOverload() + { + // Stride and GroupCount describe the strided QBit(T, N, stride) layout 26.7 added, which is not decoded + // yet — so every column reports a single group, and GetPlane(bit) is GetPlane(bit, 0). Pinning that keeps + // the two accessors from drifting apart when the strided layout does land. + IColumnCodec codec = Codec(Float32X4); + using ClickHouseBinaryReader reader = CodecTestHarness.ReaderOver(DocumentedBytes); + using IColumn read = await codec.ReadColumnAsync(reader, "v", Float32X4, 1, CodecTestHarness.None); + var qbit = (IQBitColumn)read; + + Assert.Multiple(() => + { + Assert.That(qbit.Stride, Is.EqualTo(qbit.Dimension)); + Assert.That(qbit.GroupCount, Is.EqualTo(1)); + Assert.That(qbit.GetPlane(30, 0).ToArray(), Is.EqualTo(qbit.GetPlane(30).ToArray())); + }); + } + + [Test] + public async Task GetPlane_GroupPastTheOnlyGroup_ThrowsArgumentOutOfRange() + { + IColumnCodec codec = Codec(Float32X4); + using ClickHouseBinaryReader reader = CodecTestHarness.ReaderOver(DocumentedBytes); + using IColumn read = await codec.ReadColumnAsync(reader, "v", Float32X4, 1, CodecTestHarness.None); + var qbit = (IQBitColumn)read; + + Assert.Multiple(() => + { + Assert.That(() => qbit.GetPlane(0, 1).ToArray(), Throws.InstanceOf()); + Assert.That(() => qbit.GetPlane(0, -1).ToArray(), Throws.InstanceOf()); + }); + } + + [TestCase(1, 3, TestName = "length runs past the last row")] + [TestCase(3, 1, TestName = "start is past the last row")] + [TestCase(0, -1, TestName = "negative length")] + public async Task WriteColumn_DenseSliceOutsideTheColumn_ThrowsArgumentOutOfRange(int start, int length) + { + // The dense path slices the blob per plane. The blob is rented and may be longer than the column, so an + // over-long range has to be bounded against RowCount rather than against the array — otherwise it would + // quietly emit stale pooled bytes instead of failing. Only the dense path can reach this. + IColumnCodec codec = Codec(Float32X4); + using ClickHouseBinaryReader reader = CodecTestHarness.ReaderOver(DocumentedBytes); + using IColumn dense = await codec.ReadColumnAsync(reader, "v", Float32X4, 1, CodecTestHarness.None); + + Assert.That( + async () => await CodecTestHarness.WriteSliceAsync(codec, dense, start, length), + Throws.InstanceOf()); + } + + [Test] + public void ReadColumnAsync_TruncatedPlaneBody_ThrowsEndOfStream() + { + // A body shorter than BitWidth * rows * BytesPerRow must fail rather than decode whatever the rented blob + // happened to contain. This also drives the catch that hands the rent back before rethrowing — that half + // is not observable from here, since ArrayPool gives no way to ask whether an array came home. + IColumnCodec codec = Codec(Float32X4); + using ClickHouseBinaryReader reader = CodecTestHarness.ReaderOver(new byte[] { 0x00, 0x0E }); + + Assert.That( + async () => await codec.ReadColumnAsync(reader, "v", Float32X4, 1, CodecTestHarness.None), + Throws.InstanceOf()); + } + [TestCase("QBit(Float32)", TestName = "one argument")] - [TestCase("QBit(Float32, 4, 2)", TestName = "the stride form no server accepts")] + [TestCase("QBit(Float32, 4, 2, 1)", TestName = "four arguments")] public void Resolve_WrongArgumentCount_ThrowsFormatException(string type) { Assert.That(() => Codec(type), Throws.InstanceOf().With.Message.Contains("exactly two")); } + [Test] + public void Resolve_TheStrideFormAddedIn267_ThrowsNotSupportedException() + { + // 26.7 added QBit(T, N, stride), whose body is N / stride groups each carrying a full set of planes. The + // server prints the third argument only when stride != N, so this is always a genuinely strided column. + // Not decoded yet, and the error has to say which of the two it is rather than "wrong argument count". + Assert.That( + () => Codec("QBit(Float32, 16, 8)"), + Throws.InstanceOf().With.Message.Contains("strided")); + } + [TestCase("QBit(Float32, 0)")] [TestCase("QBit(Float32, -1)")] [TestCase("QBit(Float32, x)")] @@ -321,6 +454,10 @@ public void Resolve_InvalidDimension_ThrowsFormatException(string type) Assert.That(() => Codec(type), Throws.InstanceOf().With.Message.Contains("vector length")); } + // Int16 and UInt8 are the near misses: 26.7 widened the element type to Int8 only, so the neighbouring integer + // widths stay rejected and a codec that matched on "any integer" would let them through. + [TestCase("QBit(Int16, 4)")] + [TestCase("QBit(UInt8, 4)")] [TestCase("QBit(Int32, 4)")] [TestCase("QBit(String, 4)")] [TestCase("QBit(Float16, 4)")] @@ -328,6 +465,6 @@ public void Resolve_ElementTypeTheServerRejects_ThrowsNotSupportedException(stri { Assert.That( () => Codec(type), - Throws.InstanceOf().With.Message.Contains("BFloat16, Float32 and Float64")); + Throws.InstanceOf().With.Message.Contains("Int8, BFloat16, Float32 and Float64")); } } diff --git a/ClickHouse.Driver.Tcp.Tests/Utilities/InsertRoundTripCase.cs b/ClickHouse.Driver.Tcp.Tests/Utilities/InsertRoundTripCase.cs index 8dd183d66..8bbff921e 100644 --- a/ClickHouse.Driver.Tcp.Tests/Utilities/InsertRoundTripCase.cs +++ b/ClickHouse.Driver.Tcp.Tests/Utilities/InsertRoundTripCase.cs @@ -1478,6 +1478,34 @@ public static IEnumerable Cases() null, new[] { -1f, -2f, -3f, -4f }, })); + + // QBit(Int8, N) arrived in 26.7. Its 8 planes are the element's raw two's-complement byte, so + // MinValue/-1 pin the sign plane and the all-ones pattern that a widening bug would drop. Dimension 17 + // is three bytes per row and crosses two whole 8-element groups plus a tail. The byte order itself is + // *not* what these prove — writing and reading share QBitLayout.ByteOfGroup, so a reversed convention + // round-trips clean; DocumentedInt8Bytes16 and QBitIntegrationTests are what pin it. + if (TcpServerFeatures.Has(TcpFeature.QBitInt8)) + { + yield return Same( + "QBit(Int8, 17)", + "QBit(Int8, 17)", + name => new ArrayColumn(name, "QBit(Int8, 17)", new[] + { + Ramp(17, i => (sbyte)(i - 8)), + Ramp(17, i => i % 2 == 0 ? sbyte.MaxValue : sbyte.MinValue), + Ramp(17, i => i == 0 ? (sbyte)-1 : (sbyte)0), + })); + + yield return Same( + "Nullable(QBit(Int8, 4))", + "Nullable(QBit(Int8, 4))", + name => new ArrayColumn(name, "Nullable(QBit(Int8, 4))", new[] + { + new sbyte[] { 1, -2, sbyte.MaxValue, sbyte.MinValue }, + null, + new sbyte[] { 0, -1, 0, -1 }, + })); + } } // SimpleAggregateFunction(func, T) encodes as a bare T — the function only tells the server how to merge diff --git a/ClickHouse.Driver.Tcp.Tests/Utilities/TcpFeature.cs b/ClickHouse.Driver.Tcp.Tests/Utilities/TcpFeature.cs index 2dfd68621..d35c0685b 100644 --- a/ClickHouse.Driver.Tcp.Tests/Utilities/TcpFeature.cs +++ b/ClickHouse.Driver.Tcp.Tests/Utilities/TcpFeature.cs @@ -42,6 +42,10 @@ public enum TcpFeature [SinceVersion("26.6")] NullableTuple = 1 << 6, + /// The Int8 element type of QBit, which arrived later than the type itself. + [SinceVersion("26.7")] + QBitInt8 = 1 << 7, + /// Every capability. What an unrecognised or unpinned server version resolves to. All = ~None, } diff --git a/ClickHouse.Driver.Tcp/Types/Codecs/QBitColumnCodec.cs b/ClickHouse.Driver.Tcp/Types/Codecs/QBitColumnCodec.cs index a84f1ae57..c4cb3c5b8 100644 --- a/ClickHouse.Driver.Tcp/Types/Codecs/QBitColumnCodec.cs +++ b/ClickHouse.Driver.Tcp/Types/Codecs/QBitColumnCodec.cs @@ -25,9 +25,10 @@ namespace ClickHouse.Driver.Tcp.Types.Codecs; /// /// /// -/// T is BFloat16, Float32 or Float64 only; the server rejects any other element -/// type. Note this is the Native layout: over RowBinary the same type is a plain array, which is -/// why the HTTP driver's QBitType reads a length-prefixed run of values instead. +/// T is Int8, BFloat16, Float32 or Float64 only — Int8 since 26.7 — +/// and the server rejects any other element type. Note this is the Native layout: over RowBinary +/// the same type is a plain array, which is why the HTTP driver's QBitType reads a length-prefixed run +/// of values instead. /// /// internal abstract class QBitColumnCodec : IColumnCodec @@ -41,7 +42,7 @@ protected QBitColumnCodec(string typeName, int dimension, int bitWidth) TypeName = typeName; Dimension = dimension; BitWidth = bitWidth; - BytesPerRow = (dimension + 7) / 8; + BytesPerRow = QBitLayout.BytesPerRow(dimension); } /// @@ -62,13 +63,29 @@ protected QBitColumnCodec(string typeName, int dimension, int bitWidth) /// The bytes one row occupies within a single plane, ceil(N / 8). protected int BytesPerRow { get; } + /// + /// The elements one group of planes covers. Always : the strided QBit(T, N, stride) + /// form is rejected in , so every column here is a single group. + /// + protected int Stride => Dimension; + /// Builds a QBit(T, N) codec from its element type and dimension arguments. /// The parsed QBit type node. /// The codec. /// The type does not have exactly one element type and one positive integer dimension. - /// The element type is not one the server allows. + /// The element type is not one this client decodes, or the type is strided. public static QBitColumnCodec Create(TypeNode node) { + // ClickHouse 26.7 added an optional third argument, the stride, which splits a row into N / stride groups + // that each carry their own full set of planes. The server only prints it when stride != N, so a + // three-argument type is always a genuinely strided column, whose group-major body this client does not + // decode yet. + if (node.Arguments.Count == 3) + { + throw new NotSupportedException( + $"QBit type '{node}' is strided; this client does not support the strided QBit layout yet."); + } + if (node.Arguments.Count != 2) { throw new FormatException( @@ -85,15 +102,16 @@ public static QBitColumnCodec Create(TypeNode node) $"QBit type '{node}' has an invalid vector length '{token}'; expected a positive integer."); } - // The same three the server allows; anything else is rejected at CREATE TABLE, so a column of one can - // only reach us from a server that has changed, not from a table a user could have made today. + // The same four the server allows: BFloat16/Float32/Float64 from the start, and Int8 from 26.7. Every one + // is stored the same way — bits(T) planes over the element's raw bit pattern, most significant first. return element switch { + "Int8" => new QBitSByteColumnCodec(typeName, dimension), "BFloat16" => new QBitFloatColumnCodec(typeName, dimension, bitWidth: 16), "Float32" => new QBitFloatColumnCodec(typeName, dimension, bitWidth: 32), "Float64" => new QBitDoubleColumnCodec(typeName, dimension), _ => throw new NotSupportedException( - $"QBit type '{node}' has element type '{element}'; only BFloat16, Float32 and Float64 are supported."), + $"QBit type '{node}' has element type '{element}'; only Int8, BFloat16, Float32 and Float64 are supported."), }; } @@ -132,7 +150,11 @@ public void WriteColumn(ClickHouseBinaryWriter writer, IColumn column, int start // straight back. One copy per plane rather than one for the whole range: the body is plane-major, so a // row range is contiguous *within* a plane but the planes themselves are strided by the source's own row // count, which is not this range's length unless the whole column is being written. - if (column is QBitColumn dense && dense.Dimension == Dimension && dense.BitWidth == BitWidth) + // The stride has to be compared too, not just the dimension and plane count: QBit(Float32, 16, 8) and + // QBit(Float32, 16) agree on both and even on total body size (2 groups x 32 planes x 1 byte against + // 32 planes x 2 bytes), so without this a strided source would blit a group-major body into an unstrided + // column with nothing to catch it. Always true today — no strided column resolves — and cheap to keep. + if (column is QBitColumn dense && dense.Dimension == Dimension && dense.BitWidth == BitWidth && dense.Stride == Stride) { for (int wireIndex = 0; wireIndex < BitWidth; wireIndex++) { @@ -209,6 +231,78 @@ protected T[] Validate(T[] vector, int row) } } +/// +/// The QBit(Int8, N) codec, added by ClickHouse 26.7: 8 planes over each element's raw two's-complement +/// byte, most significant first, so plane 0 on the wire is the sign bit. +/// +/// +/// Not vectorized yet. Vector256<byte>.ExtractMostSignificantBits would yield four plane bytes per +/// extract against the path's one, so the headroom is real; it wants a benchmark rather +/// than a guess, and the follow-up is filed with the read-direction one. +/// +/// +internal sealed class QBitSByteColumnCodec : QBitColumnCodec +{ + private sbyte[] nullPlaceholder; + + /// Initializes the 8-bit integer codec. + /// The canonical type string. + /// The vector length N. + public QBitSByteColumnCodec(string typeName, int dimension) + : base(typeName, dimension, bitWidth: 8) + { + } + + /// + public override Type ElementType => typeof(sbyte[]); + + /// The all-zero placeholder vector; see . + public override object NullPlaceholder => nullPlaceholder ??= new sbyte[Dimension]; + + /// + public override bool CanWrite(IColumn column) => column is IColumn; + + /// + protected override IColumn CreateColumn(string name, string typeName, byte[] blob, int rowCount, bool pooled) + => new QBitSByteColumn(name, typeName, Dimension, blob, rowCount, pooled); + + /// + protected override void WriteTransposed(ClickHouseBinaryWriter writer, IColumn column, int start, int length) + { + var typed = (IColumn)column; + byte[] scratch = RentScratch(length, out int byteCount); + try + { + int planeStride = length * BytesPerRow; + for (int r = 0; r < length; r++) + { + sbyte[] vector = Validate(typed[start + r], start + r); + int rowBase = r * BytesPerRow; + for (int i = 0; i < vector.Length; i++) + { + // Reinterpreted, not converted: a negative element is its two's-complement byte. + uint raw = unchecked((byte)vector[i]); + int slot = QBitLayout.ByteOfGroup(i >> 3, BytesPerRow); + byte bit = (byte)(1 << (i & 7)); + for (int wireIndex = 0; wireIndex < BitWidth; wireIndex++) + { + if (((raw >> (7 - wireIndex)) & 1) != 0) + { + scratch[(wireIndex * planeStride) + rowBase + slot] |= bit; + } + } + } + } + + writer.WriteBytes(scratch.AsSpan(0, byteCount)); + } + finally + { + ArrayPool.Shared.Return(scratch); + } + } +} + /// /// The QBit(Float32, N) and QBit(BFloat16, N) codec. Both surface as []: a /// brain-float is the top 16 bits of an IEEE-754 , so on write the low 16 bits are dropped — diff --git a/ClickHouse.Driver.Tcp/Types/IQBitColumn.cs b/ClickHouse.Driver.Tcp/Types/IQBitColumn.cs index 86eeb7ae3..ff8a3a294 100644 --- a/ClickHouse.Driver.Tcp/Types/IQBitColumn.cs +++ b/ClickHouse.Driver.Tcp/Types/IQBitColumn.cs @@ -11,15 +11,15 @@ namespace ClickHouse.Driver.Tcp.Types; /// /// /// The default view undoes the transposition and hands back a per-row -/// [] (or [] for QBit(Float64, N)), which is convenient but +/// [] ([] for QBit(Float64, N), [] for +/// QBit(Int8, N)), which is convenient but /// reverses the layout the type exists to provide. This interface exposes the planes as stored, so a caller /// computing a reduced-precision distance can read the few planes it needs without materializing any vector. /// /// /// /// Obtain it by pattern-matching a column, e.g. if (column is IQBitColumn qbit). It is not generic: the -/// planes are raw bits, so plane access does not depend on whether the elements surface as -/// or . +/// planes are raw bits, so plane access does not depend on which CLR type the elements surface as. /// /// public interface IQBitColumn : IColumn @@ -28,20 +28,42 @@ public interface IQBitColumn : IColumn int Dimension { get; } /// - /// The number of bit planes, which is the width of one element on the wire: 16 for BFloat16, 32 for - /// Float32, 64 for Float64. + /// The number of bit planes, which is the width of one element on the wire: 8 for Int8, 16 for + /// BFloat16, 32 for Float32, 64 for Float64. /// int BitWidth { get; } /// - /// The bytes one row occupies within a single plane — ceil(Dimension / 8). + /// The elements one group of planes covers — the stride of QBit(T, N, stride), and equal to + /// for the two-argument type, which is the only form this client decodes today. /// /// - /// Element i of a row sits at bit i % 8 of byte BytesPerRow - 1 - i / 8: the bits within - /// a byte run least significant first, but the bytes run in the reverse of the element order, so - /// elements 0-7 are in the last byte. Equivalently, the row's bitmap is the big-endian encoding of a - /// BytesPerRow-byte integer whose bit i is element i. When is not - /// a multiple of 8 the unused bits are the high bits of byte 0. + /// ClickHouse 26.7 added an optional stride that splits a row into independent + /// groups of stride elements, each carrying its own full set of planes. A + /// column of that shape currently fails to resolve, so always reports + /// — it exists so a caller reading planes is written against the general layout + /// rather than against the single-group special case. + /// + /// + int Stride { get; } + + /// + /// The number of plane groups a row is split into, Dimension / Stride. Always 1 today; see + /// . + /// + int GroupCount { get; } + + /// + /// The bytes one row occupies within a single plane of a single group — ceil(Stride / 8), which is + /// ceil(Dimension / 8) while is 1. + /// + /// + /// Within group g, element i of a row sits at bit i % 8 of byte + /// BytesPerRow - 1 - i / 8, counting i from the start of the group: the bits within a byte run + /// least significant first, but the bytes run in the reverse of the element order, so elements 0-7 are + /// in the last byte. Equivalently, the row's bitmap is the big-endian encoding of a + /// BytesPerRow-byte integer whose bit i is element i. When is not a + /// multiple of 8 the unused bits are the high bits of byte 0. /// /// int BytesPerRow { get; } @@ -52,6 +74,12 @@ public interface IQBitColumn : IColumn /// the slice [r * BytesPerRow, (r + 1) * BytesPerRow). /// /// + /// Defined only for a single-group column, which is every column this client decodes today. On a strided + /// column a plane is disjoint runs and no single span can be "the plane", so this + /// throws rather than quietly hand back one group's worth; use there. + /// + /// + /// /// is the bit's significance within the stored element, so /// BitWidth - 1 is the sign bit and 0 the least significant mantissa bit; the most significant planes /// are the ones a reduced-precision distance wants. (The wire stores the planes in the opposite order, most @@ -67,5 +95,27 @@ public interface IQBitColumn : IColumn /// The bit position, from 0 (least significant) to - 1 (the sign bit). /// The plane's bitmaps, RowCount * BytesPerRow bytes. /// is outside [0, ). + /// is not 1, so a plane is not one contiguous run. ReadOnlySpan GetPlane(int bit); + + /// + /// One bit plane of one group: the bit at position of every element in group + /// , for every row, as consecutive + /// -byte bitmaps. Group g covers elements + /// [g * Stride, (g + 1) * Stride). + /// + /// + /// This is the general form; is the == 1 shorthand for + /// it. Prefer this overload in code that should keep working when strided columns become readable. + /// + /// + /// + /// A borrowed span over the owning block's storage, valid only while the block is alive. + /// + /// + /// The bit position, from 0 (least significant) to - 1 (the sign bit). + /// The group index, from 0 to - 1. + /// The group's plane bitmaps, RowCount * BytesPerRow bytes. + /// or is out of range. + ReadOnlySpan GetPlane(int bit, int group); } diff --git a/ClickHouse.Driver.Tcp/Types/QBitColumn.cs b/ClickHouse.Driver.Tcp/Types/QBitColumn.cs index 97ba1927f..850aaef75 100644 --- a/ClickHouse.Driver.Tcp/Types/QBitColumn.cs +++ b/ClickHouse.Driver.Tcp/Types/QBitColumn.cs @@ -18,14 +18,22 @@ internal static class QBitLayout /// in byte 8 and element 64 in byte 0. /// /// - /// This is invisible whenever N <= 8, where a row is a single byte — which is why the layout notes - /// in native-format.md describe it the other way round. + /// This is invisible whenever N <= 8, where a row is a single byte, so only a fixture wider than + /// that pins it. /// /// /// The element's group index, element / 8. /// The row's bitmap width, ceil(N / 8). /// The byte offset within the row's bitmap. public static int ByteOfGroup(int group, int bytesPerRow) => bytesPerRow - 1 - group; + + /// + /// The bytes a row's bitmap occupies, ceil(span / 8). Written as (span - 1) / 8 + 1 rather than + /// the usual (span + 7) / 8 so it cannot overflow for any positive . + /// + /// The number of elements the bitmap covers; must be positive. + /// The bitmap width in bytes. + public static int BytesPerRow(int span) => ((span - 1) / 8) + 1; } /// @@ -42,14 +50,14 @@ internal static class QBitLayout /// /// /// -/// This non-generic base carries everything that does not depend on whether the elements surface as -/// or , which is what lets the codec's dense write path recognise a -/// QBit column and copy its planes without knowing the element type. +/// This non-generic base carries everything that does not depend on which CLR type the elements surface as, +/// which is what lets the codec's dense write path recognise a QBit column and copy its planes without knowing +/// the element type. /// /// /// /// The blob is rented from and returned on ; like every column, -/// the bytes and any span returned by are borrowed for the block's lifetime. +/// the bytes and any span returned by are borrowed for the block's lifetime. /// /// internal abstract class QBitColumn : IQBitColumn @@ -72,7 +80,7 @@ protected QBitColumn(string name, string typeName, int dimension, int bitWidth, TypeName = typeName; Dimension = dimension; BitWidth = bitWidth; - BytesPerRow = (dimension + 7) / 8; + BytesPerRow = QBitLayout.BytesPerRow(dimension); this.blob = blob ?? throw new ArgumentNullException(nameof(blob)); this.rowCount = rowCount; this.pooled = pooled; @@ -96,8 +104,31 @@ protected QBitColumn(string name, string typeName, int dimension, int bitWidth, /// public int BytesPerRow { get; } + /// + /// Always : the strided QBit(T, N, stride) form that 26.7 added is rejected at + /// codec resolution, so every column that reaches here is a single group. + /// + public int Stride => Dimension; + + /// Always 1; see . + public int GroupCount => 1; + /// public ReadOnlySpan GetPlane(int bit) + { + // Unreachable while GroupCount is 1, and deliberately so: when the strided layout lands, a caller of this + // overload would otherwise get one group's bytes — a shorter span, silently misindexed — instead of an error. + if (GroupCount != 1) + { + throw new InvalidOperationException( + $"Column '{Name}' ({TypeName}) has {GroupCount} plane groups, so a plane is not one contiguous run; use GetPlane(bit, group)."); + } + + return GetPlane(bit, group: 0); + } + + /// + public ReadOnlySpan GetPlane(int bit, int group) { if ((uint)bit >= (uint)BitWidth) { @@ -106,7 +137,14 @@ public ReadOnlySpan GetPlane(int bit) $"Bit {bit} is outside the {BitWidth} plane(s) of column '{Name}' ({TypeName})."); } - return WirePlane(BitWidth - 1 - bit, 0, rowCount); + if ((uint)group >= (uint)GroupCount) + { + throw new ArgumentOutOfRangeException( + nameof(group), + $"Group {group} is outside the {GroupCount} group(s) of column '{Name}' ({TypeName})."); + } + + return WirePlane(((group * BitWidth) + BitWidth - 1 - bit), 0, rowCount); } /// @@ -170,7 +208,7 @@ protected void CheckRow(int row) /// The typed per-row view over a : each row's vector as a /// [], de-transposed on demand and cached. /// -/// The CLR element type a row's vector surfaces as — or . +/// The CLR element type a row's vector surfaces as — , or . internal abstract class QBitColumnBase : QBitColumn, IColumn where T : struct { @@ -193,7 +231,7 @@ protected QBitColumnBase(string name, string typeName, int dimension, int bitWid /// The rows as per-row vectors, materialized once and cached. Every row costs /// BitWidth * BytesPerRow byte fetches to de-transpose — 4 KiB for a 1024-dimension /// Float32 embedding — so this is built on first use, not on read. Prefer - /// where the planes themselves are what is wanted. + /// where the planes themselves are what is wanted. /// public ReadOnlySpan Values { @@ -305,6 +343,50 @@ protected override float[] DetransposeRow(int row) } } +/// +/// A QBit(Int8, N) column: 8 planes rebuilding each element's raw two's-complement byte. +/// +internal sealed class QBitSByteColumn : QBitColumnBase +{ + /// Initializes an 8-bit integer QBit column. + /// The column name. + /// The ClickHouse type string. + /// The vector length N. + /// The plane blob. + /// The number of rows. + /// Whether was rented. + public QBitSByteColumn(string name, string typeName, int dimension, byte[] blob, int rowCount, bool pooled) + : base(name, typeName, dimension, bitWidth: 8, blob, rowCount, pooled) + { + } + + /// + protected override sbyte[] DetransposeRow(int row) + { + CheckRow(row); + + var vector = new sbyte[Dimension]; + + // Gather through the unsigned view, as the float paths do: the bits being collected are the raw + // two's-complement pattern, so setting bit 7 gives the negative value without any signed cast. + Span bits = MemoryMarshal.Cast(vector.AsSpan()); + for (int wireIndex = 0; wireIndex < BitWidth; wireIndex++) + { + ReadOnlySpan plane = WirePlaneRow(wireIndex, row); + byte mask = (byte)(1 << (BitWidth - 1 - wireIndex)); + for (int i = 0; i < bits.Length; i++) + { + if ((plane[QBitLayout.ByteOfGroup(i >> 3, BytesPerRow)] & (1 << (i & 7))) != 0) + { + bits[i] |= mask; + } + } + } + + return vector; + } +} + /// A QBit(Float64, N) column: 64 planes rebuilding each element's IEEE-754 double pattern. internal sealed class QBitDoubleColumn : QBitColumnBase { From 584054e72966c9409d90ccf661a7e35b99127119 Mon Sep 17 00:00:00 2001 From: Alex Soffronow-Pagonidis Date: Tue, 1 Sep 2026 20:12:20 +0200 Subject: [PATCH 4/4] Trim QBit documentation and focus test coverage 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. --- .../Integration/QBitIntegrationTests.cs | 56 +++--- .../Types/QBitColumnCodecTests.cs | 140 ++++---------- .../Utilities/InsertRoundTripCase.cs | 71 ++++--- .../Utilities/TcpFeature.cs | 2 +- .../Utilities/TcpServerFeaturesTests.cs | 2 + .../Types/Codecs/QBitColumnCodec.cs | 183 +++--------------- .../Types/ColumnCodecRegistry.cs | 3 +- ClickHouse.Driver.Tcp/Types/IQBitColumn.cs | 118 +++-------- ClickHouse.Driver.Tcp/Types/QBitColumn.cs | 173 +++-------------- 9 files changed, 191 insertions(+), 557 deletions(-) diff --git a/ClickHouse.Driver.Tcp.Tests/Integration/QBitIntegrationTests.cs b/ClickHouse.Driver.Tcp.Tests/Integration/QBitIntegrationTests.cs index dc406e047..e99741480 100644 --- a/ClickHouse.Driver.Tcp.Tests/Integration/QBitIntegrationTests.cs +++ b/ClickHouse.Driver.Tcp.Tests/Integration/QBitIntegrationTests.cs @@ -9,18 +9,6 @@ namespace ClickHouse.Driver.Tcp.Tests.Integration; -/// -/// The two asymmetric checks on QBit(T, N) that the insert round-trip corpus cannot make. A -/// round-trip writes and reads with the same client code, so a layout error that is self-consistent — the bit -/// order within a row, say — round-trips perfectly while putting bytes on the wire the server reads as different -/// values. Each test here has exactly one side done by the client and the other by the server. -/// -/// -/// This is not hypothetical: the byte order within a row runs opposite to the element order, and every fixture -/// narrower than 9 elements is one byte per row, where that is unobservable. These tests use a dimension wide -/// enough to see it. -/// -/// [TestFixture] [Category("Integration")] [RequiresServerFeature(TcpFeature.QBit)] @@ -28,24 +16,19 @@ public class QBitIntegrationTests { private static readonly CancellationToken None = CancellationToken.None; - // 17 elements is two whole 8-element groups plus a tail, so it spans three bytes per row and pins the byte - // order. The values are distinct and asymmetric across the group boundary, so a swapped byte is a wrong value - // rather than a coincidence. + // Spans two complete bitmap bytes and a partial third. private const int Dimension = 17; private const string Float32Type = "QBit(Float32, 17)"; private const string Int8Type = "QBit(Int8, 17)"; private static float[] Vector() => Enumerable.Range(0, Dimension).Select(i => (i * 3f) - 20f).ToArray(); - // Spans the sign, both whole bytes and the one-element tail, with MinValue pinning the all-ones pattern. private static sbyte[] Int8Vector() => Enumerable.Range(0, Dimension).Select(i => i == 16 ? sbyte.MinValue : (sbyte)((i * 7) - 60)).ToArray(); [Test] public async Task InsertAsync_QBitWrittenByTheClient_IsReadBackByTheServerAsTheSameVector() { - // The client transposes; the server de-transposes. toString() makes the server do that work and hand back - // its own rendering, so nothing about the client's read path is involved in the comparison. await using var client = TcpServerFixture.CreateClient(); string table = UniqueTableName(); @@ -69,8 +52,6 @@ public async Task InsertAsync_QBitWrittenByTheClient_IsReadBackByTheServerAsTheS [Test] public async Task StreamAsync_QBitWrittenByTheServer_DecodesToTheSameVector() { - // The mirror: the server transposes (it parses the VALUES literal), the client de-transposes. Together - // with the test above this pins both directions against an independent implementation. await using var client = TcpServerFixture.CreateClient(); string table = UniqueTableName(); @@ -98,9 +79,6 @@ public async Task StreamAsync_QBitWrittenByTheServer_DecodesToTheSameVector() [Test] public async Task GetPlane_QBitWrittenByTheServer_AgreesWithTheServersOwnBitExtraction() { - // IQBitColumn is the whole point of the type — a caller reading the high planes to approximate a distance - // — and no round-trip touches it. bitTest on the server's own value is the independent answer for whether - // element i has bit b set, so the plane the client hands out can be checked bit by bit against it. await using var client = TcpServerFixture.CreateClient(); string table = UniqueTableName(); @@ -117,7 +95,6 @@ public async Task GetPlane_QBitWrittenByTheServer_AgreesWithTheServersOwnBitExtr signPlane = ((IQBitColumn)block[0]).GetPlane(31).ToArray(); } - // The sign bit of element i, straight from the client's plane. for (int i = 0; i < Dimension; i++) { int slot = signPlane.Length - 1 - (i / 8); @@ -135,9 +112,6 @@ public async Task GetPlane_QBitWrittenByTheServer_AgreesWithTheServersOwnBitExtr [RequiresServerFeature(TcpFeature.QBitInt8)] public async Task InsertAsync_Int8QBitWrittenByTheClient_IsReadBackByTheServerAsTheSameVector() { - // QBit(Int8, N) has its own hand-written transpose loop, so the Float32 check above does not cover it. The - // frozen byte fixture in the unit suite is 16 elements — two whole bytes — and this is the non-multiple-of-8 - // width, where the last byte is partly unused. Server-side toString() keeps the client's read path out of it. await using var client = TcpServerFixture.CreateClient(); string table = UniqueTableName(); @@ -158,6 +132,34 @@ public async Task InsertAsync_Int8QBitWrittenByTheClient_IsReadBackByTheServerAs } } + [Test] + [RequiresServerFeature(TcpFeature.QBitInt8)] + public async Task StreamAsync_Int8QBitWrittenByTheServer_DecodesToTheSameVector() + { + await using var client = TcpServerFixture.CreateClient(); + string table = UniqueTableName(); + + await Drain(client, $"CREATE TABLE {table} (v {Int8Type}) ENGINE = Memory"); + try + { + sbyte[] vector = Int8Vector(); + string literal = string.Join(",", vector.Select(v => v.ToString(CultureInfo.InvariantCulture))); + await Drain(client, $"INSERT INTO {table} VALUES ([{literal}])"); + + sbyte[] decoded = null; + await foreach (Block block in client.StreamAsync($"SELECT v FROM {table}", cancellationToken: None)) + { + decoded = (sbyte[])block[0].GetValue(0); + } + + CollectionAssert.AreEqual(vector, decoded); + } + finally + { + await Drain(client, $"DROP TABLE IF EXISTS {table}"); + } + } + private static string Expected(float[] vector) => "[" + string.Join(",", vector.Select(v => v.ToString("R", CultureInfo.InvariantCulture))) + "]"; diff --git a/ClickHouse.Driver.Tcp.Tests/Types/QBitColumnCodecTests.cs b/ClickHouse.Driver.Tcp.Tests/Types/QBitColumnCodecTests.cs index 1a3b034d2..7e12d675c 100644 --- a/ClickHouse.Driver.Tcp.Tests/Types/QBitColumnCodecTests.cs +++ b/ClickHouse.Driver.Tcp.Tests/Types/QBitColumnCodecTests.cs @@ -7,39 +7,31 @@ namespace ClickHouse.Driver.Tcp.Tests.Types; -/// -/// Unit coverage for QBit(T, N), limited to what a server round-trip cannot observe: the exact plane -/// layout, the significance ordering imposes on top of it, the type-resolution -/// and write error paths, and the pooled Values cache. Per-type values are covered by -/// against a real server. -/// [TestFixture] public class QBitColumnCodecTests { private const string Float32X4 = "QBit(Float32, 4)"; - // Captured from a ClickHouse 26.6 `SELECT v FROM t FORMAT Native` where v is QBit(Float32, 4) holding one row - // of [1.0, 2.0, 3.0, 4.0] — the example documented on QBitColumnCodec. 32 planes, one byte per plane (one row - // of ceil(4/8) = 1 byte), most significant bit first. + // Server-produced Native body for one QBit(Float32, 4) row containing [1, 2, 3, 4]. Planes are + // ordered most-significant first. private static readonly byte[] DocumentedBytes = { - 0x00, // bit 31 (sign): none of the four is negative - 0x0E, // bit 30: set for 2.0, 3.0, 4.0 -> elements 1, 2, 3 -> 0b1110 - 0x01, // bit 29: only 1.0 (0x3F800000) - 0x01, // bit 28 - 0x01, // bit 27 - 0x01, // bit 26 - 0x01, // bit 25 - 0x01, // bit 24 - 0x09, // bit 23: 1.0 and 4.0 -> elements 0 and 3 -> 0b1001 - 0x04, // bit 22: only 3.0 -> element 2 -> 0b0100 + 0x00, // Bit 31: no elements set. + 0x0E, // Bit 30: elements 1, 2, and 3. + 0x01, // Bits 29 through 24: element 0. + 0x01, + 0x01, + 0x01, + 0x01, + 0x01, + 0x09, // Bit 23: elements 0 and 3. + 0x04, // Bit 22: element 2. 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, }; - // Captured the same way, for QBit(Float32, 16) holding one row of [1, -2, 3, -4, ... 15, -16]. Two bytes per - // row per plane, so this is the only fixture that spans more than one 8-element group — the unit the vector - // write path works in. The signs alternate, which makes the sign plane 0xAA 0xAA. + // Server-produced Native body for one QBit(Float32, 16) row containing [1, -2, ..., 15, -16]. Each plane + // contains a two-byte row bitmap. private static readonly byte[] DocumentedBytes16 = { 0xAA, 0xAA, 0xFF, 0xFE, 0x00, 0x01, 0x00, 0x01, 0x00, 0x01, 0x00, 0x01, @@ -50,10 +42,8 @@ public class QBitColumnCodecTests 0x00, 0x00, 0x00, 0x00, }; - // Captured from a ClickHouse 26.7 `SELECT v FROM t FORMAT Native` where v is QBit(Int8, 16) holding one row of - // [1, -2, 3, -4, ... 15, -16] — the same input values as DocumentedBytes16, over a two's-complement encoding - // rather than IEEE-754. 8 planes of two bytes, most significant (the sign) first. Two bytes per row is what - // makes the reversed byte order within a bitmap observable at all. + // Server-produced Native body for one QBit(Int8, 16) row containing [1, -2, ..., 15, -16]. The eight planes + // encode two's-complement bytes, most-significant plane first. private static readonly byte[] DocumentedInt8Bytes16 = { 0xAA, 0xAA, 0xAA, 0xAA, 0xAA, 0xAA, 0xAA, 0xAA, @@ -90,50 +80,29 @@ public async Task ReadColumnAsync_TheServersOwnBytes_DecodesTheDocumentedVector( } [Test] - public async Task WriteColumn_SpanningSeveralGroupsOfEight_ProducesTheServersOwnBytes() + public async Task WriteColumn_DenseRowSlice_ProducesTheServersOwnBytes() { - // 16 elements is two whole 8-element groups, which is the unit the vector write path works in; the - // dimension-4 fixture above never leaves the scalar tail. Pins the group loop against real server bytes. const string Type = "QBit(Float32, 16)"; IColumnCodec codec = Codec(Type); - using var column = new ArrayColumn("v", Type, new[] + using var source = new ArrayColumn("v", Type, new[] { + new float[16], new[] { 1f, -2f, 3f, -4f, 5f, -6f, 7f, -8f, 9f, -10f, 11f, -12f, 13f, -14f, 15f, -16f }, - }); - - byte[] bytes = await CodecTestHarness.WriteAsync(w => codec.WriteColumn(w, column)); - - CollectionAssert.AreEqual(DocumentedBytes16, bytes); - } - - [Test] - public async Task WriteColumn_RowSlice_EmitsEachPlaneStridedByTheSourceRowCount() - { - // The body is plane-major, so a row range is contiguous within a plane but the planes are strided by the - // *source* column's row count. Slicing the middle row of three is the shape that catches a write which - // strides by the slice length instead. Whole-column re-inserts never reach it. - IColumnCodec codec = Codec(Float32X4); - using var source = new ArrayColumn("v", Float32X4, new[] - { - new[] { 0f, 0f, 0f, 0f }, - new[] { 1f, 2f, 3f, 4f }, - new[] { 0f, 0f, 0f, 0f }, + new float[16], }); byte[] dense = await CodecTestHarness.WriteAsync(w => codec.WriteColumn(w, source)); using ClickHouseBinaryReader reader = CodecTestHarness.ReaderOver(dense); - using IColumn read = await codec.ReadColumnAsync(reader, "v", Float32X4, 3, CodecTestHarness.None); + using IColumn read = await codec.ReadColumnAsync(reader, "v", Type, 3, CodecTestHarness.None); byte[] sliced = await CodecTestHarness.WriteSliceAsync(codec, read, start: 1, length: 1); - CollectionAssert.AreEqual(DocumentedBytes, sliced); + CollectionAssert.AreEqual(DocumentedBytes16, sliced); } [Test] public async Task GetPlane_ReadColumn_IndexesPlanesBySignificanceNotWireOrder() { - // The wire stores planes most significant first; GetPlane takes the bit's significance, so bit 30 is the - // second plane on the wire. Nothing about this ordering is observable through a round-trip. IColumnCodec codec = Codec(Float32X4); using ClickHouseBinaryReader reader = CodecTestHarness.ReaderOver(DocumentedBytes); using IColumn read = await codec.ReadColumnAsync(reader, "v", Float32X4, 1, CodecTestHarness.None); @@ -156,8 +125,6 @@ public async Task GetPlane_ReadColumn_IndexesPlanesBySignificanceNotWireOrder() [Test] public async Task GetPlane_MultipleRows_ReturnsEveryRowsBitmapForThatPlane() { - // Rows are contiguous within a plane, so one plane spans the whole column. -0.0 sets only the sign bit, - // which makes the sign plane the one place the three rows differ. IColumnCodec codec = Codec("QBit(Float32, 8)"); using var column = new ArrayColumn("v", "QBit(Float32, 8)", new[] { @@ -186,8 +153,6 @@ public async Task GetPlane_BitOutsideTheWidth_Throws(int bit) [Test] public async Task Values_ReadColumn_MaterializesEveryRowThroughThePooledCache() { - // GetValue delegates to the same de-transpose, but Values is a separately materialized pooled cache that - // the round-trip's per-row comparison never touches. IColumnCodec codec = Codec(Float32X4); using var column = new ArrayColumn("v", Float32X4, new[] { @@ -211,8 +176,14 @@ public async Task Values_ReadTwice_ReturnsTheSameCachedArrays() using IColumn read = await CodecTestHarness.RoundTripAsync(codec, column, Float32X4, 1); var typed = (IColumn)read; + float[] first = typed.Values[0]; + float[] second = typed.Values[0]; - Assert.That(typed.Values[0], Is.SameAs(typed.Values[0])); + Assert.Multiple(() => + { + Assert.That(second, Is.SameAs(first)); + Assert.That(read.GetValue(0), Is.SameAs(first)); + }); } [Test] @@ -262,25 +233,6 @@ public void Resolve_BFloat16_SurfacesWidenedFloatVectors() }); } - [Test] - public async Task WriteThenRead_BFloat16_DropsTheLowMantissaBits() - { - // A brain-float keeps only the float's high 16 bits, so a value needing the low half comes back narrowed. - // The server normalizes nothing here — this is the client's own lossy narrowing, so no round-trip shows it. - const string Type = "QBit(BFloat16, 2)"; - IColumnCodec codec = Codec(Type); - using var column = new ArrayColumn("v", Type, new[] { new[] { 1.0001f, 2f } }); - - using IColumn read = await CodecTestHarness.RoundTripAsync(codec, column, Type, 1); - var value = (float[])read.GetValue(0); - - Assert.Multiple(() => - { - Assert.That(value[0], Is.EqualTo(1f), "1.0001f narrows to 1f in a brain-float"); - Assert.That(value[1], Is.EqualTo(2f), "2f is exactly representable"); - }); - } - [Test] public void CanWrite_ColumnOfAnotherElementType_IsRefused() { @@ -337,8 +289,6 @@ public async Task WriteColumn_Int8Vector_ProducesTheServersOwnBytes() [Test] public async Task ReadColumnAsync_Int8ServerBytes_DecodesTheTwosComplementVector() { - // The negative values are what a de-transpose that rebuilt the byte through a signed accumulator would - // get wrong; the sign is just plane 0's bit, with no widening involved. const string Type = "QBit(Int8, 16)"; IColumnCodec codec = Codec(Type); using ClickHouseBinaryReader reader = CodecTestHarness.ReaderOver(DocumentedInt8Bytes16); @@ -366,9 +316,6 @@ public void CanWrite_Int8Codec_AcceptsOnlySByteVectors() [Test] public async Task GetPlane_UnstridedColumn_ReportsOneGroupAndAgreesWithTheGroupOverload() { - // Stride and GroupCount describe the strided QBit(T, N, stride) layout 26.7 added, which is not decoded - // yet — so every column reports a single group, and GetPlane(bit) is GetPlane(bit, 0). Pinning that keeps - // the two accessors from drifting apart when the strided layout does land. IColumnCodec codec = Codec(Float32X4); using ClickHouseBinaryReader reader = CodecTestHarness.ReaderOver(DocumentedBytes); using IColumn read = await codec.ReadColumnAsync(reader, "v", Float32X4, 1, CodecTestHarness.None); @@ -397,14 +344,11 @@ public async Task GetPlane_GroupPastTheOnlyGroup_ThrowsArgumentOutOfRange() }); } - [TestCase(1, 3, TestName = "length runs past the last row")] - [TestCase(3, 1, TestName = "start is past the last row")] + [TestCase(-1, 1, TestName = "negative start")] [TestCase(0, -1, TestName = "negative length")] + [TestCase(0, 2, TestName = "length runs past the last row")] public async Task WriteColumn_DenseSliceOutsideTheColumn_ThrowsArgumentOutOfRange(int start, int length) { - // The dense path slices the blob per plane. The blob is rented and may be longer than the column, so an - // over-long range has to be bounded against RowCount rather than against the array — otherwise it would - // quietly emit stale pooled bytes instead of failing. Only the dense path can reach this. IColumnCodec codec = Codec(Float32X4); using ClickHouseBinaryReader reader = CodecTestHarness.ReaderOver(DocumentedBytes); using IColumn dense = await codec.ReadColumnAsync(reader, "v", Float32X4, 1, CodecTestHarness.None); @@ -414,12 +358,21 @@ public async Task WriteColumn_DenseSliceOutsideTheColumn_ThrowsArgumentOutOfRang Throws.InstanceOf()); } + [Test] + public async Task WriteColumn_EmptySliceAtTheEnd_WritesNoBytes() + { + IColumnCodec codec = Codec(Float32X4); + using ClickHouseBinaryReader reader = CodecTestHarness.ReaderOver(DocumentedBytes); + using IColumn dense = await codec.ReadColumnAsync(reader, "v", Float32X4, 1, CodecTestHarness.None); + + byte[] bytes = await CodecTestHarness.WriteSliceAsync(codec, dense, start: 1, length: 0); + + Assert.That(bytes, Is.Empty); + } + [Test] public void ReadColumnAsync_TruncatedPlaneBody_ThrowsEndOfStream() { - // A body shorter than BitWidth * rows * BytesPerRow must fail rather than decode whatever the rented blob - // happened to contain. This also drives the catch that hands the rent back before rethrowing — that half - // is not observable from here, since ArrayPool gives no way to ask whether an array came home. IColumnCodec codec = Codec(Float32X4); using ClickHouseBinaryReader reader = CodecTestHarness.ReaderOver(new byte[] { 0x00, 0x0E }); @@ -438,9 +391,6 @@ public void Resolve_WrongArgumentCount_ThrowsFormatException(string type) [Test] public void Resolve_TheStrideFormAddedIn267_ThrowsNotSupportedException() { - // 26.7 added QBit(T, N, stride), whose body is N / stride groups each carrying a full set of planes. The - // server prints the third argument only when stride != N, so this is always a genuinely strided column. - // Not decoded yet, and the error has to say which of the two it is rather than "wrong argument count". Assert.That( () => Codec("QBit(Float32, 16, 8)"), Throws.InstanceOf().With.Message.Contains("strided")); @@ -454,12 +404,8 @@ public void Resolve_InvalidDimension_ThrowsFormatException(string type) Assert.That(() => Codec(type), Throws.InstanceOf().With.Message.Contains("vector length")); } - // Int16 and UInt8 are the near misses: 26.7 widened the element type to Int8 only, so the neighbouring integer - // widths stay rejected and a codec that matched on "any integer" would let them through. [TestCase("QBit(Int16, 4)")] [TestCase("QBit(UInt8, 4)")] - [TestCase("QBit(Int32, 4)")] - [TestCase("QBit(String, 4)")] [TestCase("QBit(Float16, 4)")] public void Resolve_ElementTypeTheServerRejects_ThrowsNotSupportedException(string type) { diff --git a/ClickHouse.Driver.Tcp.Tests/Utilities/InsertRoundTripCase.cs b/ClickHouse.Driver.Tcp.Tests/Utilities/InsertRoundTripCase.cs index 8bbff921e..1670be2d0 100644 --- a/ClickHouse.Driver.Tcp.Tests/Utilities/InsertRoundTripCase.cs +++ b/ClickHouse.Driver.Tcp.Tests/Utilities/InsertRoundTripCase.cs @@ -27,7 +27,12 @@ public sealed class InsertRoundTripCase private readonly Func buildInsert; private readonly Func buildExpected; - private InsertRoundTripCase(string label, string clickHouseType, Func buildInsert, Func buildExpected, IReadOnlyDictionary settings) + private InsertRoundTripCase( + string label, + string clickHouseType, + Func buildInsert, + Func buildExpected, + IReadOnlyDictionary settings) { Label = label; ClickHouseType = clickHouseType; @@ -1393,11 +1398,6 @@ public static IEnumerable Cases() yield return Same("Geometry", "Geometry", name => BuildGeometryColumn(name)); } - // QBit(T, N): the vector's bit planes transposed, so the values a row round-trips through are spread one - // bit at a time across the whole body. The insert source is the ergonomic ArrayColumn, which - // takes the transposing write path; the dense read-back is re-inserted by the shared dense case below, - // which is what covers the plane-copy path. Signed zero, infinity and NaN pin the sign and exponent - // planes, which an all-positive vector leaves untouched. if (TcpServerFeatures.Has(TcpFeature.QBit)) { yield return Same( @@ -1410,18 +1410,6 @@ public static IEnumerable Cases() new[] { float.Epsilon, float.PositiveInfinity, float.NegativeInfinity, float.NaN }, })); - // A dimension that is not a multiple of 8 leaves the high bits of each row's last plane byte unused; - // 9 spans two bytes so a mis-set stride shows up as a shifted element rather than a lost one. - yield return Same( - "QBit(Float32, 9)", - "QBit(Float32, 9)", - name => new ArrayColumn(name, "QBit(Float32, 9)", new[] - { - new[] { 1f, 2f, 3f, 4f, 5f, 6f, 7f, 8f, 9f }, - new[] { -1f, 0f, -0f, 0.5f, -0.5f, 1e10f, -1e10f, 1e-10f, -1e-10f }, - })); - - // Float64 is the 64-plane path and its own accumulator width. yield return Same( "QBit(Float64, 3)", "QBit(Float64, 3)", @@ -1432,10 +1420,6 @@ public static IEnumerable Cases() new[] { 0d, -0d, double.NaN }, })); - // Dimensions of 8 and above reach the vector write path, which works a group of 8 elements at a time; - // everything narrower is handled entirely by its scalar tail. 17 is two whole groups plus one element, - // so it covers the group loop and the tail together, and it is an embedding-shaped width rather than - // the hand-checked fixtures above. yield return Same( "QBit(Float32, 17)", "QBit(Float32, 17)", @@ -1445,8 +1429,6 @@ public static IEnumerable Cases() Ramp(17, i => i % 2 == 0 ? float.MaxValue : float.MinValue), })); - // The Float64 group loop takes two extracts per plane byte, where the Float32 one takes a single - // extract, so it needs its own multi-group case. yield return Same( "QBit(Float64, 17)", "QBit(Float64, 17)", @@ -1456,19 +1438,21 @@ public static IEnumerable Cases() Ramp(17, i => i % 2 == 0 ? double.MaxValue : double.MinValue), })); - // BFloat16 keeps only the float's high 16 bits, so every value here is one a brain-float represents - // exactly — otherwise the round-trip would compare the narrowed value against the original. - yield return Same( + yield return new InsertRoundTripCase( "QBit(BFloat16, 4)", "QBit(BFloat16, 4)", name => new ArrayColumn(name, "QBit(BFloat16, 4)", new[] + { + new[] { 1.0001f, 2f, -3f, 0f }, + new[] { -0f, 0.5f, -0.5f, 256f }, + }), + name => new ArrayColumn(name, "QBit(BFloat16, 4)", new[] { new[] { 1f, 2f, -3f, 0f }, new[] { -0f, 0.5f, -0.5f, 256f }, - })); + }), + settings: null); - // Nullable(QBit(...)) is accepted by the server and round-trips NULL, which is the only thing that - // reads the codec's all-zero placeholder vector. yield return Same( "Nullable(QBit(Float32, 4))", "Nullable(QBit(Float32, 4))", @@ -1479,11 +1463,26 @@ public static IEnumerable Cases() new[] { -1f, -2f, -3f, -4f }, })); - // QBit(Int8, N) arrived in 26.7. Its 8 planes are the element's raw two's-complement byte, so - // MinValue/-1 pin the sign plane and the all-ones pattern that a widening bug would drop. Dimension 17 - // is three bytes per row and crosses two whole 8-element groups plus a tail. The byte order itself is - // *not* what these prove — writing and reading share QBitLayout.ByteOfGroup, so a reversed convention - // round-trips clean; DocumentedInt8Bytes16 and QBitIntegrationTests are what pin it. + yield return Same( + "Nullable(QBit(BFloat16, 4))", + "Nullable(QBit(BFloat16, 4))", + name => new ArrayColumn(name, "Nullable(QBit(BFloat16, 4))", new[] + { + new[] { 1f, 2f, -3f, 0f }, + null, + new[] { -0f, 0.5f, -0.5f, 256f }, + })); + + yield return Same( + "Nullable(QBit(Float64, 3))", + "Nullable(QBit(Float64, 3))", + name => new ArrayColumn(name, "Nullable(QBit(Float64, 3))", new[] + { + new[] { 1d, -2d, 3.5d }, + null, + new[] { 0d, -0d, double.NaN }, + })); + if (TcpServerFeatures.Has(TcpFeature.QBitInt8)) { yield return Same( @@ -1755,8 +1754,6 @@ private static ClickHouseDecimal ParseWide(string text) private static InsertRoundTripCase Same(string label, string clickHouseType, Func build, IReadOnlyDictionary settings = null) => new(label, clickHouseType, build, build, settings); - /// A vector of values from their index — for the wider QBit dimensions, - /// where spelling out every element would obscure the width being tested. private static T[] Ramp(int length, Func value) { var values = new T[length]; diff --git a/ClickHouse.Driver.Tcp.Tests/Utilities/TcpFeature.cs b/ClickHouse.Driver.Tcp.Tests/Utilities/TcpFeature.cs index d35c0685b..25e22b5b6 100644 --- a/ClickHouse.Driver.Tcp.Tests/Utilities/TcpFeature.cs +++ b/ClickHouse.Driver.Tcp.Tests/Utilities/TcpFeature.cs @@ -42,7 +42,7 @@ public enum TcpFeature [SinceVersion("26.6")] NullableTuple = 1 << 6, - /// The Int8 element type of QBit, which arrived later than the type itself. + /// QBit columns with Int8 elements. [SinceVersion("26.7")] QBitInt8 = 1 << 7, diff --git a/ClickHouse.Driver.Tcp.Tests/Utilities/TcpServerFeaturesTests.cs b/ClickHouse.Driver.Tcp.Tests/Utilities/TcpServerFeaturesTests.cs index 5d34ecb6c..cb725f0ae 100644 --- a/ClickHouse.Driver.Tcp.Tests/Utilities/TcpServerFeaturesTests.cs +++ b/ClickHouse.Driver.Tcp.Tests/Utilities/TcpServerFeaturesTests.cs @@ -11,6 +11,8 @@ public class TcpServerFeaturesTests [TestCase("25.8", TcpFeature.Json, ExpectedResult = true, TestName = "The oldest matrix server has Json")] [TestCase("25.11", TcpFeature.QBit, ExpectedResult = true, TestName = "QBit arrives in 25.11")] [TestCase("25.10", TcpFeature.QBit, ExpectedResult = false, TestName = "QBit is gated one release later than it shipped")] + [TestCase("26.6", TcpFeature.QBitInt8, ExpectedResult = false, TestName = "QBit Int8 is unavailable before 26.7")] + [TestCase("26.7", TcpFeature.QBitInt8, ExpectedResult = true, TestName = "QBit Int8 arrives in 26.7")] [TestCase("26.5", TcpFeature.NullableTuple, ExpectedResult = false, TestName = "Nullable Tuple Beta is unavailable before 26.6")] [TestCase("26.6", TcpFeature.NullableTuple, ExpectedResult = true, TestName = "Nullable Tuple Beta arrives in 26.6")] [TestCase("26.6", TcpFeature.Geometry, ExpectedResult = true, TestName = "A recent server has Geometry")] diff --git a/ClickHouse.Driver.Tcp/Types/Codecs/QBitColumnCodec.cs b/ClickHouse.Driver.Tcp/Types/Codecs/QBitColumnCodec.cs index c4cb3c5b8..68e7ab074 100644 --- a/ClickHouse.Driver.Tcp/Types/Codecs/QBitColumnCodec.cs +++ b/ClickHouse.Driver.Tcp/Types/Codecs/QBitColumnCodec.cs @@ -11,32 +11,14 @@ namespace ClickHouse.Driver.Tcp.Types.Codecs; /// -/// A codec for the ClickHouse QBit(T, N) column: an N-element vector stored with its bit planes -/// transposed, so a vector search can read only the high-order planes and compute an approximate distance at -/// reduced precision (L2DistanceTransposed, cosineDistanceTransposed). -/// -/// -/// The column carries no state prefix. Its body is bits(T) planes, ordered from the most -/// significant bit of T down to bit 0; each plane holds one ceil(N / 8)-byte bitmap per row, rows -/// contiguous within the plane. Element i sits at bit i % 8 of the bitmap byte -/// names — the bytes run in the reverse of the element order, so group 0 is -/// the last byte. The body is plane-major and exactly bits(T) * num_rows * ceil(N / 8) bytes — every row -/// the same width. -/// -/// -/// -/// T is Int8, BFloat16, Float32 or Float64 only — Int8 since 26.7 — -/// and the server rejects any other element type. Note this is the Native layout: over RowBinary -/// the same type is a plain array, which is why the HTTP driver's QBitType reads a length-prefixed run -/// of values instead. -/// +/// Encodes and decodes the Native layout of QBit(T, N). The body has no state prefix and contains bits(T) +/// planes in most-significant-first order. Each plane contains one big-endian ceil(N / 8)-byte bitmap per +/// row. Element i is bit i % 8 of byte ceil(N / 8) - 1 - i / 8. The body size is +/// bits(T) * rowCount * ceil(N / 8) bytes. Supported element types are Int8, BFloat16, +/// Float32, and Float64. /// internal abstract class QBitColumnCodec : IColumnCodec { - /// Initializes the shared geometry. - /// The canonical type string. - /// The vector length N. - /// The stored element's bit width — the number of planes. protected QBitColumnCodec(string typeName, int dimension, int bitWidth) { TypeName = typeName; @@ -45,41 +27,27 @@ protected QBitColumnCodec(string typeName, int dimension, int bitWidth) BytesPerRow = QBitLayout.BytesPerRow(dimension); } - /// public string TypeName { get; } - /// public abstract Type ElementType { get; } - /// public abstract object NullPlaceholder { get; } - /// The vector length N. protected int Dimension { get; } - /// The number of bit planes — the stored element's bit width. protected int BitWidth { get; } - /// The bytes one row occupies within a single plane, ceil(N / 8). protected int BytesPerRow { get; } - /// - /// The elements one group of planes covers. Always : the strided QBit(T, N, stride) - /// form is rejected in , so every column here is a single group. - /// + /// Gets the plane-group width. Strided types are rejected, so this equals . protected int Stride => Dimension; - /// Builds a QBit(T, N) codec from its element type and dimension arguments. - /// The parsed QBit type node. - /// The codec. - /// The type does not have exactly one element type and one positive integer dimension. - /// The element type is not one this client decodes, or the type is strided. + /// Creates a codec for an unstrided QBit(T, N) type. + /// The argument count or dimension is invalid. + /// The element type or layout is unsupported. public static QBitColumnCodec Create(TypeNode node) { - // ClickHouse 26.7 added an optional third argument, the stride, which splits a row into N / stride groups - // that each carry their own full set of planes. The server only prints it when stride != N, so a - // three-argument type is always a genuinely strided column, whose group-major body this client does not - // decode yet. + // The three-argument form uses a group-major strided layout that this codec cannot decode. if (node.Arguments.Count == 3) { throw new NotSupportedException( @@ -102,8 +70,6 @@ public static QBitColumnCodec Create(TypeNode node) $"QBit type '{node}' has an invalid vector length '{token}'; expected a positive integer."); } - // The same four the server allows: BFloat16/Float32/Float64 from the start, and Int8 from 26.7. Every one - // is stored the same way — bits(T) planes over the element's raw bit pattern, most significant first. return element switch { "Int8" => new QBitSByteColumnCodec(typeName, dimension), @@ -115,7 +81,6 @@ public static QBitColumnCodec Create(TypeNode node) }; } - /// public async ValueTask ReadColumnAsync(ClickHouseBinaryReader reader, string columnName, string columnType, int rowCount, CancellationToken cancellationToken) { if (rowCount == 0) @@ -131,7 +96,7 @@ public async ValueTask ReadColumnAsync(ClickHouseBinaryReader reader, s } catch { - // The column never took ownership of the rent, so return it rather than leak it on a read failure. + // No column owns the rented buffer after a failed read. ArrayPool.Shared.Return(blob); throw; } @@ -139,21 +104,12 @@ public async ValueTask ReadColumnAsync(ClickHouseBinaryReader reader, s return CreateColumn(columnName, columnType, blob, rowCount, pooled: true); } - /// public abstract bool CanWrite(IColumn column); - /// public void WriteColumn(ClickHouseBinaryWriter writer, IColumn column, int start, int length) { - // A QBit column of the same geometry already holds the planes the wire wants, so the range is copied out - // plane by plane with no transposition — the hot path when a column read from the server is inserted - // straight back. One copy per plane rather than one for the whole range: the body is plane-major, so a - // row range is contiguous *within* a plane but the planes themselves are strided by the source's own row - // count, which is not this range's length unless the whole column is being written. - // The stride has to be compared too, not just the dimension and plane count: QBit(Float32, 16, 8) and - // QBit(Float32, 16) agree on both and even on total body size (2 groups x 32 planes x 1 byte against - // 32 planes x 2 bytes), so without this a strided source would blit a group-major body into an unstrided - // column with nothing to catch it. Always true today — no strided column resolves — and cheap to keep. + // A row range is contiguous within each plane, but planes are spaced by the source column's full row + // count. Dense copies require identical plane grouping; equal body sizes do not imply equal layouts. if (column is QBitColumn dense && dense.Dimension == Dimension && dense.BitWidth == BitWidth && dense.Stride == Stride) { for (int wireIndex = 0; wireIndex < BitWidth; wireIndex++) @@ -167,33 +123,14 @@ public void WriteColumn(ClickHouseBinaryWriter writer, IColumn column, int start WriteTransposed(writer, column, start, length); } - /// Builds the decoded column over a plane blob. - /// The column name. - /// The ClickHouse type string from the block header. - /// The plane blob. - /// The number of rows. - /// Whether was rented. - /// The column. protected abstract IColumn CreateColumn(string name, string typeName, byte[] blob, int rowCount, bool pooled); - /// - /// Transposes an ergonomic per-row vector column into the plane-major body. Rents a scratch the size of the - /// slice's wire bytes, because plane-major output cannot be streamed a row at a time: plane 0 needs every row - /// before plane 1 begins. The dense path above avoids this entirely. - /// - /// The writer to encode into. - /// The column to transpose. - /// The zero-based first row to write. - /// The number of rows to write. + /// Transposes row vectors into a plane-major body. protected abstract void WriteTransposed(ClickHouseBinaryWriter writer, IColumn column, int start, int length); /// - /// Rents a zeroed scratch buffer for the slice's plane-major body. Rented memory is dirty and the transpose - /// only ever sets bits, so the used region must be cleared first. + /// Rents scratch space and clears its used region because transpose implementations only set bits. /// - /// The number of rows the slice covers. - /// The used size of the returned buffer. - /// The rented buffer, zeroed over bytes. protected byte[] RentScratch(int length, out int byteCount) { byteCount = checked(BitWidth * length * BytesPerRow); @@ -203,14 +140,9 @@ protected byte[] RentScratch(int length, out int byteCount) } /// - /// Validates one row's vector and returns it, blaming the row when it is null or the wrong length. A QBit row - /// is never null on the wire — Nullable carries that and substitutes the placeholder at a null - /// position — and the vector length is fixed by the type, so neither can be silently padded. + /// Returns a non-null vector with exactly elements. /// - /// The row's vector. - /// The row index, for the message. - /// The validated vector. - /// The vector is null or not elements. + /// The vector is null or its length differs from . protected T[] Validate(T[] vector, int row) { if (vector is null) @@ -232,41 +164,27 @@ protected T[] Validate(T[] vector, int row) } /// -/// The QBit(Int8, N) codec, added by ClickHouse 26.7: 8 planes over each element's raw two's-complement -/// byte, most significant first, so plane 0 on the wire is the sign bit. -/// -/// -/// Not vectorized yet. Vector256<byte>.ExtractMostSignificantBits would yield four plane bytes per -/// extract against the path's one, so the headroom is real; it wants a benchmark rather -/// than a guess, and the follow-up is filed with the read-direction one. -/// +/// Handles QBit(Int8, N) as eight most-significant-first planes over each element's two's-complement byte. /// internal sealed class QBitSByteColumnCodec : QBitColumnCodec { private sbyte[] nullPlaceholder; - /// Initializes the 8-bit integer codec. - /// The canonical type string. - /// The vector length N. public QBitSByteColumnCodec(string typeName, int dimension) : base(typeName, dimension, bitWidth: 8) { } - /// public override Type ElementType => typeof(sbyte[]); - /// The all-zero placeholder vector; see . + /// Gets the all-zero vector used for null positions in a nullable column. public override object NullPlaceholder => nullPlaceholder ??= new sbyte[Dimension]; - /// public override bool CanWrite(IColumn column) => column is IColumn; - /// protected override IColumn CreateColumn(string name, string typeName, byte[] blob, int rowCount, bool pooled) => new QBitSByteColumn(name, typeName, Dimension, blob, rowCount, pooled); - /// protected override void WriteTransposed(ClickHouseBinaryWriter writer, IColumn column, int start, int length) { var typed = (IColumn)column; @@ -280,7 +198,7 @@ protected override void WriteTransposed(ClickHouseBinaryWriter writer, IColumn c int rowBase = r * BytesPerRow; for (int i = 0; i < vector.Length; i++) { - // Reinterpreted, not converted: a negative element is its two's-complement byte. + // Preserve the element's two's-complement bit pattern. uint raw = unchecked((byte)vector[i]); int slot = QBitLayout.ByteOfGroup(i >> 3, BytesPerRow); byte bit = (byte)(1 << (i & 7)); @@ -304,49 +222,34 @@ protected override void WriteTransposed(ClickHouseBinaryWriter writer, IColumn c } /// -/// The QBit(Float32, N) and QBit(BFloat16, N) codec. Both surface as []: a -/// brain-float is the top 16 bits of an IEEE-754 , so on write the low 16 bits are dropped — -/// the same narrowing does for a plain BFloat16 column. +/// Handles QBit(Float32, N) and QBit(BFloat16, N) as arrays. BFloat16 +/// retains only the high 16 bits of each value. /// internal sealed class QBitFloatColumnCodec : QBitColumnCodec { private float[] nullPlaceholder; - /// Initializes the single-precision codec. - /// The canonical type string. - /// The vector length N. - /// 16 for BFloat16, 32 for Float32. public QBitFloatColumnCodec(string typeName, int dimension, int bitWidth) : base(typeName, dimension, bitWidth) { } - /// public override Type ElementType => typeof(float[]); - /// - /// The placeholder for a null row is an all-zero vector, so the values stream stays aligned at a - /// Nullable(QBit(T, N)) null position — the width every row occupies. Built on first use: a codec is - /// resolved per column per block, so a pure read would otherwise allocate a vector per block that only the - /// Nullable write path ever touches. - /// + /// Gets the lazily allocated all-zero vector used for null positions in a nullable column. public override object NullPlaceholder => nullPlaceholder ??= new float[Dimension]; - /// public override bool CanWrite(IColumn column) => column is IColumn; - /// protected override IColumn CreateColumn(string name, string typeName, byte[] blob, int rowCount, bool pooled) => new QBitFloatColumn(name, typeName, Dimension, BitWidth, blob, rowCount, pooled); - /// protected override void WriteTransposed(ClickHouseBinaryWriter writer, IColumn column, int start, int length) { var typed = (IColumn)column; byte[] scratch = RentScratch(length, out int byteCount); try { - // Hoisted out of the row loop, as UuidColumnCodec does: the answer is the same for every row. bool simd = Vector256.IsHardwareAccelerated; int planeStride = length * BytesPerRow; for (int r = 0; r < length; r++) @@ -360,9 +263,7 @@ protected override void WriteTransposed(ClickHouseBinaryWriter writer, IColumn c TransposeGroups(scratch, vector, whole, rowBase, planeStride); } - // The elements past the last whole group of 8, and every element when there is no hardware - // acceleration. They occupy byte `whole` of the row, which the vector path never writes, so the - // two cannot collide. + // Handle the tail, or the entire vector when SIMD is unavailable. for (int i = whole << 3; i < vector.Length; i++) { uint raw = BitConverter.SingleToUInt32Bits(vector[i]); @@ -370,8 +271,7 @@ protected override void WriteTransposed(ClickHouseBinaryWriter writer, IColumn c byte bit = (byte)(1 << (i & 7)); for (int wireIndex = 0; wireIndex < BitWidth; wireIndex++) { - // Plane `wireIndex` is bit 31 - wireIndex of the float, for a brain-float too: its 16 - // bits *are* the float's high half, so its planes are the float's top 16. + // BFloat16 planes are the high 16 bits of the widened float. if (((raw >> (31 - wireIndex)) & 1) != 0) { scratch[(wireIndex * planeStride) + rowBase + slot] |= bit; @@ -389,17 +289,9 @@ protected override void WriteTransposed(ClickHouseBinaryWriter writer, IColumn c } /// - /// Transposes the first groups of 8 elements of one row. - /// gathers the top bit of 8 lanes into a byte, which - /// is one plane byte for 8 s in the order the wire wants (element i at bit - /// i), so a plane costs one extract plus one shift rather than 8 test-and-sets. Walking the planes - /// most significant first is then just shifting the vector left one bit each step. + /// Transposes complete eight-element groups. Each call + /// produces one plane byte; shifting the lanes left exposes the next plane. /// - /// The zeroed plane-major slice buffer. - /// The row's vector. - /// The number of complete 8-element groups. - /// The row's byte offset within a plane. - /// The bytes one plane occupies for the whole slice. private void TransposeGroups(byte[] scratch, float[] vector, int whole, int rowBase, int planeStride) { ref uint source = ref Unsafe.As(ref MemoryMarshal.GetArrayDataReference(vector)); @@ -416,33 +308,26 @@ private void TransposeGroups(byte[] scratch, float[] vector, int whole, int rowB } } -/// The QBit(Float64, N) codec: 64 planes over each element's IEEE-754 double pattern. +/// Handles QBit(Float64, N) as 64 planes over each element's IEEE-754 bit pattern. internal sealed class QBitDoubleColumnCodec : QBitColumnCodec { private double[] nullPlaceholder; - /// Initializes the double-precision codec. - /// The canonical type string. - /// The vector length N. public QBitDoubleColumnCodec(string typeName, int dimension) : base(typeName, dimension, bitWidth: 64) { } - /// public override Type ElementType => typeof(double[]); - /// The all-zero placeholder vector; see . + /// Gets the all-zero vector used for null positions in a nullable column. public override object NullPlaceholder => nullPlaceholder ??= new double[Dimension]; - /// public override bool CanWrite(IColumn column) => column is IColumn; - /// protected override IColumn CreateColumn(string name, string typeName, byte[] blob, int rowCount, bool pooled) => new QBitDoubleColumn(name, typeName, Dimension, blob, rowCount, pooled); - /// protected override void WriteTransposed(ClickHouseBinaryWriter writer, IColumn column, int start, int length) { var typed = (IColumn)column; @@ -486,16 +371,8 @@ protected override void WriteTransposed(ClickHouseBinaryWriter writer, IColumn c } /// - /// Transposes the first groups of 8 elements of one row. A - /// of holds only 4 lanes, so a plane byte takes two extracts - /// — the low group in bits 3..0 and the high group in bits 7..4 — against the single extract the - /// path needs. + /// Transposes complete eight-element groups. Each four-lane vector contributes one nibble to a plane byte. /// - /// The zeroed plane-major slice buffer. - /// The row's vector. - /// The number of complete 8-element groups. - /// The row's byte offset within a plane. - /// The bytes one plane occupies for the whole slice. private void TransposeGroups(byte[] scratch, double[] vector, int whole, int rowBase, int planeStride) { ref ulong source = ref Unsafe.As(ref MemoryMarshal.GetArrayDataReference(vector)); diff --git a/ClickHouse.Driver.Tcp/Types/ColumnCodecRegistry.cs b/ClickHouse.Driver.Tcp/Types/ColumnCodecRegistry.cs index 703ccbfd6..fea7c6a97 100644 --- a/ClickHouse.Driver.Tcp/Types/ColumnCodecRegistry.cs +++ b/ClickHouse.Driver.Tcp/Types/ColumnCodecRegistry.cs @@ -96,8 +96,7 @@ private static ColumnCodecRegistry CreateDefault() // FixedString(N): N contiguous bytes per row, the length parsed from the type argument. AddFactory("FixedString", static (TypeNode node, in ResolveContext _, ColumnCodecRegistry _) => FixedStringColumnCodec.Create(node)); - // QBit(T, N): an N-element vector stored as bits(T) transposed bit planes, most significant first, each - // plane holding one big-endian ceil(N/8)-byte bitmap per row. Fixed width per row, no state prefix. + // QBit(T, N): no state prefix; most-significant-first planes containing one big-endian bitmap per row. AddFactory("QBit", static (TypeNode node, in ResolveContext _, ColumnCodecRegistry _) => QBitColumnCodec.Create(node)); // Dates and times. diff --git a/ClickHouse.Driver.Tcp/Types/IQBitColumn.cs b/ClickHouse.Driver.Tcp/Types/IQBitColumn.cs index ff8a3a294..4b44c261f 100644 --- a/ClickHouse.Driver.Tcp/Types/IQBitColumn.cs +++ b/ClickHouse.Driver.Tcp/Types/IQBitColumn.cs @@ -3,119 +3,59 @@ namespace ClickHouse.Driver.Tcp.Types; /// -/// The columnar read surface of a decoded QBit(T, N) column. A QBit row is an N-element vector -/// stored with its bit planes transposed: rather than the elements of a row sitting together, the column -/// holds one bitmap per bit position, and a bitmap carries that one bit of every element of every row. That is -/// what lets a vector search read only the high-order planes and compute an approximate distance at reduced -/// precision, which is how the server's L2DistanceTransposed / cosineDistanceTransposed work. -/// -/// -/// The default view undoes the transposition and hands back a per-row -/// [] ([] for QBit(Float64, N), [] for -/// QBit(Int8, N)), which is convenient but -/// reverses the layout the type exists to provide. This interface exposes the planes as stored, so a caller -/// computing a reduced-precision distance can read the few planes it needs without materializing any vector. -/// -/// -/// -/// Obtain it by pattern-matching a column, e.g. if (column is IQBitColumn qbit). It is not generic: the -/// planes are raw bits, so plane access does not depend on which CLR type the elements surface as. -/// +/// Exposes the transposed bit planes of a decoded QBit(T, N) column without materializing row vectors. +/// The corresponding typed column exposes [] for BFloat16 and Float32, +/// [] for Float64, or [] for Int8. /// public interface IQBitColumn : IColumn { - /// The number of elements in each row's vector — the N of QBit(T, N). + /// Gets the number of elements in each row. int Dimension { get; } /// - /// The number of bit planes, which is the width of one element on the wire: 8 for Int8, 16 for - /// BFloat16, 32 for Float32, 64 for Float64. + /// Gets the number of planes: 8 for Int8, 16 for BFloat16, 32 for Float32, or 64 for + /// Float64. /// int BitWidth { get; } /// - /// The elements one group of planes covers — the stride of QBit(T, N, stride), and equal to - /// for the two-argument type, which is the only form this client decodes today. - /// - /// - /// ClickHouse 26.7 added an optional stride that splits a row into independent - /// groups of stride elements, each carrying its own full set of planes. A - /// column of that shape currently fails to resolve, so always reports - /// — it exists so a caller reading planes is written against the general layout - /// rather than against the single-group special case. - /// + /// Gets the number of elements represented by each plane group. Columns decoded by this driver report + /// because strided QBit(T, N, stride) columns are unsupported. /// int Stride { get; } - /// - /// The number of plane groups a row is split into, Dimension / Stride. Always 1 today; see - /// . - /// + /// Gets Dimension / Stride, the number of plane groups in each row. int GroupCount { get; } /// - /// The bytes one row occupies within a single plane of a single group — ceil(Stride / 8), which is - /// ceil(Dimension / 8) while is 1. - /// - /// - /// Within group g, element i of a row sits at bit i % 8 of byte - /// BytesPerRow - 1 - i / 8, counting i from the start of the group: the bits within a byte run - /// least significant first, but the bytes run in the reverse of the element order, so elements 0-7 are - /// in the last byte. Equivalently, the row's bitmap is the big-endian encoding of a - /// BytesPerRow-byte integer whose bit i is element i. When is not a - /// multiple of 8 the unused bits are the high bits of byte 0. - /// + /// Gets the size of one row bitmap, ceil(Stride / 8) bytes. Element i within a group is bit + /// i % 8 of byte BytesPerRow - 1 - i / 8. Unused bits are the high bits of the first byte. /// int BytesPerRow { get; } /// - /// One bit plane: the bit at position of every element of every row, as - /// consecutive -byte bitmaps. Row r's bitmap is - /// the slice [r * BytesPerRow, (r + 1) * BytesPerRow). - /// - /// - /// Defined only for a single-group column, which is every column this client decodes today. On a strided - /// column a plane is disjoint runs and no single span can be "the plane", so this - /// throws rather than quietly hand back one group's worth; use there. - /// - /// - /// - /// is the bit's significance within the stored element, so - /// BitWidth - 1 is the sign bit and 0 the least significant mantissa bit; the most significant planes - /// are the ones a reduced-precision distance wants. (The wire stores the planes in the opposite order, most - /// significant first — this accessor hides that.) For QBit(BFloat16, N) the positions are those of the - /// 16-bit brain-float, not of the widened the values surface as. - /// - /// - /// - /// A borrowed span over the owning block's storage, valid only while the block is alive: read it in place and - /// copy out only what must outlive the block. - /// + /// Gets one plane as consecutive row bitmaps. Row r occupies + /// [r * BytesPerRow, (r + 1) * BytesPerRow). The bit number denotes significance, with 0 the least + /// significant stored bit and BitWidth - 1 the sign bit. For BFloat16, it refers to the 16-bit + /// stored value rather than the widened . The returned span borrows the column's storage + /// and is valid only for the owning block's lifetime. /// - /// The bit position, from 0 (least significant) to - 1 (the sign bit). - /// The plane's bitmaps, RowCount * BytesPerRow bytes. - /// is outside [0, ). - /// is not 1, so a plane is not one contiguous run. + /// The bit position in the stored element. + /// RowCount * BytesPerRow bytes. + /// is outside + /// [0, ). + /// The column contains more than one plane group. ReadOnlySpan GetPlane(int bit); /// - /// One bit plane of one group: the bit at position of every element in group - /// , for every row, as consecutive - /// -byte bitmaps. Group g covers elements - /// [g * Stride, (g + 1) * Stride). - /// - /// - /// This is the general form; is the == 1 shorthand for - /// it. Prefer this overload in code that should keep working when strided columns become readable. - /// - /// - /// - /// A borrowed span over the owning block's storage, valid only while the block is alive. - /// + /// Gets one plane for one group as consecutive row bitmaps. Group g covers elements + /// [g * Stride, (g + 1) * Stride). Bit numbering matches . The returned span + /// borrows the column's storage and is valid only for the owning block's lifetime. /// - /// The bit position, from 0 (least significant) to - 1 (the sign bit). - /// The group index, from 0 to - 1. - /// The group's plane bitmaps, RowCount * BytesPerRow bytes. - /// or is out of range. + /// The bit position in the stored element. + /// The zero-based group index. + /// RowCount * BytesPerRow bytes. + /// or is out + /// of range. ReadOnlySpan GetPlane(int bit, int group); } diff --git a/ClickHouse.Driver.Tcp/Types/QBitColumn.cs b/ClickHouse.Driver.Tcp/Types/QBitColumn.cs index 850aaef75..839f4d205 100644 --- a/ClickHouse.Driver.Tcp/Types/QBitColumn.cs +++ b/ClickHouse.Driver.Tcp/Types/QBitColumn.cs @@ -5,60 +5,26 @@ namespace ClickHouse.Driver.Tcp.Types; /// -/// Where an element sits inside one row's bitmap of a QBit plane. Shared by the read and write paths so -/// the two cannot disagree about it. +/// Maps elements to bytes within a QBit row bitmap. /// internal static class QBitLayout { /// - /// The byte, within a row's ceil(N / 8)-byte bitmap, holding the group of 8 elements starting at - /// group * 8. The bytes run in the reverse of the element order — the row's bitmap is the - /// big-endian encoding of a ceil(N / 8)-byte integer whose bit i is element i — so group - /// 0 is the last byte. Verified against a 26.6 server with QBit(Float32, 72): element 0 lands - /// in byte 8 and element 64 in byte 0. - /// - /// - /// This is invisible whenever N <= 8, where a row is a single byte, so only a fixture wider than - /// that pins it. - /// + /// Returns the byte containing an eight-element group. Row bitmaps are big-endian, so group 0 occupies the + /// last byte. /// - /// The element's group index, element / 8. - /// The row's bitmap width, ceil(N / 8). - /// The byte offset within the row's bitmap. public static int ByteOfGroup(int group, int bytesPerRow) => bytesPerRow - 1 - group; /// - /// The bytes a row's bitmap occupies, ceil(span / 8). Written as (span - 1) / 8 + 1 rather than - /// the usual (span + 7) / 8 so it cannot overflow for any positive . + /// Returns ceil(span / 8) without overflowing when is positive. /// - /// The number of elements the bitmap covers; must be positive. - /// The bitmap width in bytes. public static int BytesPerRow(int span) => ((span - 1) / 8) + 1; } /// -/// A decoded QBit(T, N) column: the bit-plane blob exactly as it arrived, plus the geometry needed to -/// read it. The blob is BitWidth planes, each holding one ceil(N / 8)-byte bitmap per row, and the -/// planes are stored most-significant first — so the plane for bit b is at wire index -/// BitWidth - 1 - b. See for the layout a caller sees. -/// -/// -/// The blob is kept transposed rather than de-transposed on read, so a column read from the server and inserted -/// straight back is a byte copy with no transposition at all — the common shape for a vector workload, where the -/// distance is computed server-side and the client never looks at a vector. The per-row vector view is -/// therefore materialized lazily by , never eagerly. -/// -/// -/// -/// This non-generic base carries everything that does not depend on which CLR type the elements surface as, -/// which is what lets the codec's dense write path recognise a QBit column and copy its planes without knowing -/// the element type. -/// -/// -/// -/// The blob is rented from and returned on ; like every column, -/// the bytes and any span returned by are borrowed for the block's lifetime. -/// +/// Stores a decoded Native QBit(T, N) body in its transposed form. Planes are ordered most-significant +/// first, and each plane contains one fixed-width bitmap per row. A rented body is returned on +/// ; plane spans borrow the body's storage. Typed row vectors are materialized lazily. /// internal abstract class QBitColumn : IQBitColumn { @@ -66,14 +32,6 @@ internal abstract class QBitColumn : IQBitColumn private readonly bool pooled; private byte[] blob; - /// Initializes a column over a bit-plane blob. - /// The column name. - /// The ClickHouse type string (e.g. QBit(Float32, 4)). - /// The vector length N. - /// The number of planes — the stored element's bit width. - /// The plane blob (may be longer than used). - /// The number of rows. - /// Whether was rented and should be returned on dispose. protected QBitColumn(string name, string typeName, int dimension, int bitWidth, byte[] blob, int rowCount, bool pooled) { Name = name; @@ -86,38 +44,24 @@ protected QBitColumn(string name, string typeName, int dimension, int bitWidth, this.pooled = pooled; } - /// public string Name { get; } - /// public string TypeName { get; } - /// public int RowCount => rowCount; - /// public int Dimension { get; } - /// public int BitWidth { get; } - /// public int BytesPerRow { get; } - /// - /// Always : the strided QBit(T, N, stride) form that 26.7 added is rejected at - /// codec resolution, so every column that reaches here is a single group. - /// public int Stride => Dimension; - /// Always 1; see . public int GroupCount => 1; - /// public ReadOnlySpan GetPlane(int bit) { - // Unreachable while GroupCount is 1, and deliberately so: when the strided layout lands, a caller of this - // overload would otherwise get one group's bytes — a shorter span, silently misindexed — instead of an error. if (GroupCount != 1) { throw new InvalidOperationException( @@ -127,7 +71,6 @@ public ReadOnlySpan GetPlane(int bit) return GetPlane(bit, group: 0); } - /// public ReadOnlySpan GetPlane(int bit, int group) { if ((uint)bit >= (uint)BitWidth) @@ -147,10 +90,8 @@ public ReadOnlySpan GetPlane(int bit, int group) return WirePlane(((group * BitWidth) + BitWidth - 1 - bit), 0, rowCount); } - /// public abstract object GetValue(int row); - /// public virtual void Dispose() { if (pooled && blob.Length != 0) @@ -162,20 +103,12 @@ public virtual void Dispose() } /// - /// The rows [start, start + length) of the plane at — plane order as - /// stored, most significant first — as a zero-copy slice of the blob. The write path emits planes in this - /// order, and a row range within one plane is contiguous, so a dense re-insert is one copy per plane. + /// Returns a zero-copy row range from a plane indexed in wire order, most-significant plane first. /// - /// The plane's index in stored order, 0 being the most significant bit. - /// The zero-based first row of the range. - /// The number of rows in the range. - /// The range's bytes within that plane, length * BytesPerRow bytes. - /// The range lies outside the column's rows. + /// The requested range is outside the logical row count. internal ReadOnlySpan WirePlane(int wireIndex, int start, int length) { - // Bound the range against rowCount, not the blob: the blob is rented and may be longer, so slicing it - // directly would let an over-long range read a stale pooled region instead of failing fast. The products - // cannot overflow — the read path sized the blob with a checked total, and this range fits in it. + // Validate against the logical row count because a rented blob can contain unused trailing bytes. if (start < 0 || length < 0 || start + (long)length > rowCount) { throw new ArgumentOutOfRangeException( @@ -186,15 +119,8 @@ internal ReadOnlySpan WirePlane(int wireIndex, int start, int length) return blob.AsSpan(((wireIndex * rowCount) + start) * BytesPerRow, length * BytesPerRow); } - /// The bitmap of one row within the plane at . - /// The plane's index in stored order, 0 being the most significant bit. - /// The zero-based row index. - /// The row's BytesPerRow bytes within that plane. protected ReadOnlySpan WirePlaneRow(int wireIndex, int row) => WirePlane(wireIndex, row, 1); - /// Bounds a row index against the column's rows. - /// The zero-based row index. - /// The row lies outside the column. protected void CheckRow(int row) { if ((uint)row >= (uint)rowCount) @@ -205,33 +131,21 @@ protected void CheckRow(int row) } /// -/// The typed per-row view over a : each row's vector as a -/// [], de-transposed on demand and cached. +/// Provides lazily materialized [] rows over a transposed . /// -/// The CLR element type a row's vector surfaces as — , or . internal abstract class QBitColumnBase : QBitColumn, IColumn where T : struct { private T[][] cache; - /// Initializes the typed view. - /// The column name. - /// The ClickHouse type string. - /// The vector length N. - /// The number of planes. - /// The plane blob. - /// The number of rows. - /// Whether was rented. protected QBitColumnBase(string name, string typeName, int dimension, int bitWidth, byte[] blob, int rowCount, bool pooled) : base(name, typeName, dimension, bitWidth, blob, rowCount, pooled) { } /// - /// The rows as per-row vectors, materialized once and cached. Every row costs - /// BitWidth * BytesPerRow byte fetches to de-transpose — 4 KiB for a 1024-dimension - /// Float32 embedding — so this is built on first use, not on read. Prefer - /// where the planes themselves are what is wanted. + /// Gets row vectors, materializing and caching all rows on first access. Use to + /// inspect transposed data without this allocation. /// public ReadOnlySpan Values { @@ -239,13 +153,6 @@ public ReadOnlySpan Values { if (cache is null) { - // Rent rather than allocate: this is a convenience view consumers copy out of, so it only needs - // to live until Dispose returns it to the pool. Single-consumer per connection, so the lazy fill - // needs no synchronization. The rented buffer may be longer than RowCount; Values slices to it. - // - // De-transposed one whole row at a time, rather than one plane across all rows: a row's output - // vector then stays in cache for all BitWidth planes that write into it, where sweeping - // plane-by-plane would re-traverse the entire materialized output once per plane. T[][] decoded = ArrayPool.Shared.Rent(RowCount); for (int i = 0; i < RowCount; i++) { @@ -259,63 +166,44 @@ public ReadOnlySpan Values } } - /// - // The cache is rented and may be longer than RowCount, so slice before indexing to keep an out-of-range row - // failing fast rather than returning a stale slot; the uncached path is bounded by DetransposeRow. + // Slice to RowCount so indexing cannot reach unused entries in the pooled array. public T[] this[int row] => cache is not null ? cache.AsSpan(0, RowCount)[row] : DetransposeRow(row); - /// public override object GetValue(int row) => this[row]; - /// public override void Dispose() { base.Dispose(); if (cache is not null) { - // The elements are array references, so clear on return to avoid the pool pinning decoded rows. + // Clear references so the pool does not retain decoded row arrays. ArrayPool.Shared.Return(cache, clearArray: true); cache = null; } } - /// Rebuilds one row's vector from the planes. - /// The zero-based row index. - /// The row's -element vector. protected abstract T[] DetransposeRow(int row); } /// -/// A QBit(Float32, N) or QBit(BFloat16, N) column. Both surface as : a -/// brain-float is the top 16 bits of an IEEE-754 , so its 16 planes rebuild the high half of -/// the 32-bit pattern and the low half stays zero — the same widening BFloat16ColumnCodec does for a -/// plain BFloat16 column. +/// Decodes QBit(Float32, N) and QBit(BFloat16, N) rows as arrays. A +/// BFloat16 value fills the high 16 bits of the widened result. /// internal sealed class QBitFloatColumn : QBitColumnBase { - /// Initializes a single-precision QBit column. - /// The column name. - /// The ClickHouse type string. - /// The vector length N. - /// 16 for BFloat16, 32 for Float32. - /// The plane blob. - /// The number of rows. - /// Whether was rented. public QBitFloatColumn(string name, string typeName, int dimension, int bitWidth, byte[] blob, int rowCount, bool pooled) : base(name, typeName, dimension, bitWidth, blob, rowCount, pooled) { } - /// protected override float[] DetransposeRow(int row) { CheckRow(row); var vector = new float[Dimension]; - // Accumulate through the float's own storage rather than a separate integer scratch: the bits being - // gathered *are* the IEEE-754 pattern, so the vector holds the finished values once the last plane is in. + // Build each IEEE-754 pattern directly in the destination array. Span bits = MemoryMarshal.Cast(vector.AsSpan()); for (int wireIndex = 0; wireIndex < BitWidth; wireIndex++) { @@ -330,7 +218,7 @@ protected override float[] DetransposeRow(int row) } } - // A brain-float's 16 bits are the float's high half, so shift them up into it. + // BFloat16 occupies the high half of the widened float. if (BitWidth == 16) { for (int i = 0; i < bits.Length; i++) @@ -344,31 +232,22 @@ protected override float[] DetransposeRow(int row) } /// -/// A QBit(Int8, N) column: 8 planes rebuilding each element's raw two's-complement byte. +/// Decodes QBit(Int8, N) rows from each element's two's-complement bit pattern. /// internal sealed class QBitSByteColumn : QBitColumnBase { - /// Initializes an 8-bit integer QBit column. - /// The column name. - /// The ClickHouse type string. - /// The vector length N. - /// The plane blob. - /// The number of rows. - /// Whether was rented. public QBitSByteColumn(string name, string typeName, int dimension, byte[] blob, int rowCount, bool pooled) : base(name, typeName, dimension, bitWidth: 8, blob, rowCount, pooled) { } - /// protected override sbyte[] DetransposeRow(int row) { CheckRow(row); var vector = new sbyte[Dimension]; - // Gather through the unsigned view, as the float paths do: the bits being collected are the raw - // two's-complement pattern, so setting bit 7 gives the negative value without any signed cast. + // Use an unsigned view so the sign plane sets bit 7 without numeric conversion. Span bits = MemoryMarshal.Cast(vector.AsSpan()); for (int wireIndex = 0; wireIndex < BitWidth; wireIndex++) { @@ -387,22 +266,14 @@ protected override sbyte[] DetransposeRow(int row) } } -/// A QBit(Float64, N) column: 64 planes rebuilding each element's IEEE-754 double pattern. +/// Decodes QBit(Float64, N) rows from each element's IEEE-754 bit pattern. internal sealed class QBitDoubleColumn : QBitColumnBase { - /// Initializes a double-precision QBit column. - /// The column name. - /// The ClickHouse type string. - /// The vector length N. - /// The plane blob. - /// The number of rows. - /// Whether was rented. public QBitDoubleColumn(string name, string typeName, int dimension, byte[] blob, int rowCount, bool pooled) : base(name, typeName, dimension, bitWidth: 64, blob, rowCount, pooled) { } - /// protected override double[] DetransposeRow(int row) { CheckRow(row);