Skip to content

Commit 1a1e0cc

Browse files
authored
Merge pull request #2934 from ClickHouse/polyglot/client-v2-bfloat16
feat(client-v2, jdbc-v2): add BFloat16 data type support
2 parents b09551d + 3941d1d commit 1a1e0cc

11 files changed

Lines changed: 483 additions & 3 deletions

File tree

CHANGELOG.md

Lines changed: 8 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -4,6 +4,14 @@
44

55
### New Features
66

7+
- **[client-v2, jdbc-v2]** Added support for the `BFloat16` data type (ClickHouse `24.11+`). `BFloat16` columns are read as
8+
Java `float` values (widening is lossless) and written from `float`/`Float` values, including through generic records, POJO
9+
binding, `Nullable(BFloat16)`, and `BFloat16` values held in `Dynamic`/`Variant` columns. On write the client keeps the
10+
high 16 bits of the `float`, matching the ClickHouse server's own `Float32``BFloat16` conversion. In the JDBC driver
11+
(`jdbc-v2`) `BFloat16` maps to `java.sql.Types.FLOAT` / `java.lang.Float` and is read and written through the standard
12+
`getFloat`/`setFloat` and `getObject` accessors, and reported as such by `ResultSetMetaData` and `DatabaseMetaData`.
13+
Previously reading or writing a `BFloat16` column failed with an
14+
unsupported-data-type error. (https://github.com/ClickHouse/clickhouse-java/issues/2279)
715
- **[client-v2, jdbc-v2]** Added TLS cipher suite selection. `Client.Builder.setSSLCipherSuites(String...)` (client-v2)
816
and the comma-separated `ssl_cipher_suites` connection property (client-v2 and jdbc-v2) restrict the cipher suites
917
enabled on secure connections; when unset, the transport defaults are used. Cipher-suite selection is independent of the

clickhouse-data/src/main/java/com/clickhouse/data/ClickHouseDataType.java

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -202,6 +202,7 @@ static Map<ClickHouseDataType, Set<Class<?>>> dataTypeClassMap() {
202202
map.put(String, setOf(String.class));
203203
map.put(Float64, setOf(float.class, Float.class, double.class, Double.class));
204204
map.put(Float32, setOf(float.class, Float.class));
205+
map.put(BFloat16, setOf(float.class, Float.class));
205206
map.put(Decimal, setOf(float.class, Float.class, double.class, Double.class, BigDecimal.class));
206207
map.put(Decimal256, setOf(float.class, Float.class, double.class, Double.class, BigDecimal.class));
207208
map.put(Decimal128, setOf(float.class, Float.class, double.class, Double.class, BigDecimal.class));

clickhouse-data/src/main/java/com/clickhouse/data/format/BinaryStreamUtils.java

Lines changed: 15 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -985,6 +985,21 @@ public static void writeFloat32(OutputStream output, float value) throws IOExcep
985985
writeInt32(output, Float.floatToIntBits(value));
986986
}
987987

988+
/**
989+
* Write a bfloat16 value to given output stream. {@code BFloat16} occupies the
990+
* high 16 bits of the IEEE-754 {@code float} representation, so the low 16 bits of
991+
* the mantissa are dropped (truncated toward zero), which matches how the ClickHouse
992+
* server converts {@code Float32} to {@code BFloat16}.
993+
*
994+
* @param output non-null output stream
995+
* @param value float value to be stored as bfloat16
996+
* @throws IOException when failed to write value to output stream or reached
997+
* end of the stream
998+
*/
999+
public static void writeBFloat16(OutputStream output, float value) throws IOException {
1000+
writeInt16(output, (short) (Float.floatToIntBits(value) >>> 16));
1001+
}
1002+
9881003
/**
9891004
* Read a double value from given input stream.
9901005
*

client-v2/src/main/java/com/clickhouse/client/api/data_formats/internal/AbstractBinaryFormatReader.java

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -323,6 +323,7 @@ protected void setSchema(TableSchema schema) {
323323
case Int256:
324324
case UInt128:
325325
case UInt256:
326+
case BFloat16:
326327
case Float32:
327328
case Float64:
328329
case Decimal:

client-v2/src/main/java/com/clickhouse/client/api/data_formats/internal/BinaryStreamReader.java

Lines changed: 15 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -164,6 +164,8 @@ public <T> T readValue(ClickHouseColumn column, Class<?> typeHint) throws IOExce
164164
return (T) readDecimal(ClickHouseDataType.Decimal128.getMaxPrecision(), scale);
165165
case Decimal256:
166166
return (T) readDecimal(ClickHouseDataType.Decimal256.getMaxPrecision(), scale);
167+
case BFloat16:
168+
return (T) (Float)readBFloat16LE();
167169
case Float32:
168170
return (T) (Float)readFloatLE();
169171
case Float64:
@@ -509,6 +511,18 @@ public float readFloatLE() throws IOException {
509511
return Float.intBitsToFloat(readIntLE());
510512
}
511513

514+
/**
515+
* Reads a little-endian {@code BFloat16} value from the internal input stream and
516+
* widens it to a {@code float}. {@code BFloat16} carries the high 16 bits of the
517+
* IEEE-754 {@code float} representation, so widening (shifting them back into the
518+
* high bits) is lossless.
519+
* @return float value
520+
* @throws IOException when IO error occurs
521+
*/
522+
public float readBFloat16LE() throws IOException {
523+
return Float.intBitsToFloat(readUnsignedShortLE() << 16);
524+
}
525+
512526
private static final byte[] B1 = new byte[8];
513527
/**
514528
* Reads a double value from the internal input stream.
@@ -1268,6 +1282,7 @@ public static boolean isReadToPrimitive(ClickHouseDataType dataType) {
12681282
case Int32:
12691283
case UInt32:
12701284
case Int64:
1285+
case BFloat16:
12711286
case Float32:
12721287
case Float64:
12731288
case Bool:
@@ -1473,8 +1488,6 @@ private ClickHouseColumn readDynamicData() throws IOException {
14731488
}
14741489
case AggregateFunction:
14751490
throw new ClientException("Aggregate functions are not supported yet");
1476-
case BFloat16:
1477-
throw new ClientException("BFloat16 is not supported yet");
14781491
default:
14791492
return ClickHouseColumn.of("v", type, false, 0, 0);
14801493
}

client-v2/src/main/java/com/clickhouse/client/api/data_formats/internal/SerializerUtils.java

Lines changed: 8 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -554,6 +554,9 @@ private static void serializePrimitiveData(OutputStream stream, Object value, Cl
554554
case UInt256:
555555
BinaryStreamUtils.writeUnsignedInt256(stream, NumberConverter.toBigInteger(value));
556556
break;
557+
case BFloat16:
558+
BinaryStreamUtils.writeBFloat16(stream, (float) value);
559+
break;
557560
case Float32:
558561
BinaryStreamUtils.writeFloat32(stream, NumberConverter.toFloat(value));
559562
break;
@@ -1112,6 +1115,11 @@ private static void binaryReaderMethodForType(MethodVisitor mv, Class<?> targetT
11121115
readerMethodReturnType = Type.getDescriptor(long.class);
11131116
convertOpcode = longToOpcode(targetType);
11141117
break;
1118+
case BFloat16:
1119+
readerMethod = "readBFloat16LE";
1120+
readerMethodReturnType = Type.getDescriptor(float.class);
1121+
convertOpcode = floatToOpcode(targetType);
1122+
break;
11151123
case Float32:
11161124
readerMethod = "readFloatLE";
11171125
readerMethodReturnType = Type.getDescriptor(float.class);

client-v2/src/test/java/com/clickhouse/client/api/data_formats/internal/SerializerUtilsPrimitiveSerializationTests.java

Lines changed: 41 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -111,13 +111,53 @@ public void testSerializeFloatColumnRejectsUnsupportedValue() {
111111
() -> serializeSingleValue("Float64", new Object()));
112112
}
113113

114+
@Test
115+
public void testSerializeBFloat16RoundTripAllValues() throws IOException {
116+
// Exhaustively round-trip every one of the 2^16 BFloat16 bit patterns. For pattern b the
117+
// canonical float32 is intBitsToFloat(b << 16); the client writes its high 16 bits and reads
118+
// them back widened. Every non-NaN pattern - including +/-0, subnormals and +/-Infinity -
119+
// must round-trip bit-for-bit. NaN inputs collapse to a single NaN because Float#floatToIntBits
120+
// normalizes the payload on write, so they are only required to read back as a NaN.
121+
final int count = 1 << 16;
122+
Object[] inputs = new Object[count];
123+
for (int b = 0; b < count; b++) {
124+
inputs[b] = Float.intBitsToFloat(b << 16);
125+
}
126+
127+
RowBinaryWithNamesAndTypesFormatReader reader = serializeColumn("BFloat16", inputs);
128+
129+
for (int b = 0; b < count; b++) {
130+
Assert.assertNotNull(reader.next(), "missing row for BFloat16 pattern " + hex(b));
131+
float actual = reader.getFloat("value");
132+
if (Float.isNaN((Float) inputs[b])) {
133+
Assert.assertTrue(Float.isNaN(actual), "BFloat16 pattern " + hex(b) + " must read back as NaN");
134+
} else {
135+
Assert.assertEquals(Float.floatToRawIntBits(actual), b << 16,
136+
"BFloat16 pattern " + hex(b) + " did not round-trip");
137+
}
138+
}
139+
Assert.assertNull(reader.next(), "unexpected extra row after all 65,536 BFloat16 patterns");
140+
}
141+
142+
private static String hex(int bFloat16Bits) {
143+
return String.format("0x%04X", bFloat16Bits);
144+
}
145+
114146
private RowBinaryWithNamesAndTypesFormatReader serializeSingleValue(String type, Object value)
115147
throws IOException {
148+
return serializeColumn(type, new Object[]{value});
149+
}
150+
151+
private RowBinaryWithNamesAndTypesFormatReader serializeColumn(String type, Object[] values)
152+
throws IOException {
116153
ByteArrayOutputStream out = new ByteArrayOutputStream();
117154
BinaryStreamUtils.writeVarInt(out, 1);
118155
BinaryStreamUtils.writeString(out, "value");
119156
BinaryStreamUtils.writeString(out, type);
120-
SerializerUtils.serializeData(out, value, ClickHouseColumn.of("value", type));
157+
ClickHouseColumn column = ClickHouseColumn.of("value", type);
158+
for (Object value : values) {
159+
SerializerUtils.serializeData(out, value, column);
160+
}
121161

122162
return new RowBinaryWithNamesAndTypesFormatReader(
123163
new ByteArrayInputStream(out.toByteArray()),

client-v2/src/test/java/com/clickhouse/client/datatypes/DataTypeTests.java

Lines changed: 178 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -23,6 +23,7 @@
2323
import lombok.NoArgsConstructor;
2424
import org.apache.commons.lang3.StringUtils;
2525
import org.testng.Assert;
26+
import org.testng.SkipException;
2627
import org.testng.annotations.AfterMethod;
2728
import org.testng.annotations.BeforeMethod;
2829
import org.testng.annotations.DataProvider;
@@ -154,6 +155,183 @@ public static String tblCreateSQL(String table) {
154155
}
155156
}
156157

158+
@Data
159+
@AllArgsConstructor
160+
@NoArgsConstructor
161+
public static class DTOForBFloat16Tests {
162+
private int rowId;
163+
private float bFloat16;
164+
private Float bFloat16Nullable;
165+
166+
public static String tblCreateSQL(String table) {
167+
return tableDefinition(table, "rowId Int32", "bFloat16 BFloat16",
168+
"bFloat16Nullable Nullable(BFloat16)");
169+
}
170+
}
171+
172+
// BFloat16 was introduced in ClickHouse 24.11.
173+
private static final String BFLOAT16_UNSUPPORTED_VERSIONS = "(,24.10]";
174+
175+
@Test(groups = {"integration"})
176+
public void testBFloat16() throws Exception {
177+
if (isVersionMatch(BFLOAT16_UNSUPPORTED_VERSIONS)) {
178+
throw new SkipException("BFloat16 requires ClickHouse 24.11+");
179+
}
180+
181+
// Exhaustively cover every one of the 2^16 BFloat16 bit patterns through a full client
182+
// write -> server -> client read round-trip (POJO path, incl. Nullable(BFloat16)). For
183+
// pattern b the value is Float.intBitsToFloat(b << 16): non-NaN values (incl. +/-0,
184+
// subnormals, +/-Infinity) must return bit-for-bit, while NaN inputs collapse to a single
185+
// canonical NaN on write and are only required to read back as NaN. Truncation of
186+
// non-representable float inputs is covered by testBFloat16TruncationMatchesServer.
187+
final String table = "test_bfloat16";
188+
final int count = 1 << 16;
189+
List<DTOForBFloat16Tests> data = new ArrayList<>(count);
190+
for (int b = 0; b < count; b++) {
191+
float value = Float.intBitsToFloat(b << 16);
192+
// Row 0 exercises the null path; every other row round-trips a non-null Nullable(BFloat16).
193+
data.add(new DTOForBFloat16Tests(b, value, b == 0 ? null : value));
194+
}
195+
196+
writeReadVerify(table,
197+
DTOForBFloat16Tests.tblCreateSQL(table),
198+
DTOForBFloat16Tests.class,
199+
data,
200+
(all, dto) -> {
201+
DTOForBFloat16Tests expected = all.get(dto.getRowId());
202+
assertBFloat16Equals(dto.getBFloat16(), expected.getBFloat16());
203+
assertBFloat16Equals(dto.getBFloat16Nullable(), expected.getBFloat16Nullable());
204+
});
205+
}
206+
207+
// BFloat16 keeps the high 16 bits of a float32, so every non-NaN value round-trips bit-for-bit;
208+
// NaN inputs collapse to a single canonical NaN on write and are only required to read back as NaN.
209+
private static void assertBFloat16Equals(Float actual, Float expected) {
210+
if (expected == null) {
211+
Assert.assertNull(actual);
212+
} else if (Float.isNaN(expected)) {
213+
Assert.assertNotNull(actual);
214+
Assert.assertTrue(Float.isNaN(actual), "expected a NaN but got " + actual);
215+
} else {
216+
Assert.assertNotNull(actual);
217+
Assert.assertEquals(Float.floatToRawIntBits(actual), Float.floatToRawIntBits(expected));
218+
}
219+
}
220+
221+
@Test(groups = {"integration"})
222+
public void testBFloat16ReadFromServerWrittenValues() throws Exception {
223+
if (isVersionMatch(BFLOAT16_UNSUPPORTED_VERSIONS)) {
224+
throw new SkipException("BFloat16 requires ClickHouse 24.11+");
225+
}
226+
227+
// Exhaustively cover reading every one of the 2^16 BFloat16 bit patterns. The dataset is
228+
// written with SQL text literals so the *server* produces the stored bytes; this decouples
229+
// the read path under test from the client's binary write path. Row b holds the BFloat16
230+
// whose bit pattern is b, so a read must widen it to Float.intBitsToFloat(b << 16) (NaN
231+
// inputs collapse to a single canonical NaN on the server and only read back as NaN).
232+
final String table = "test_bfloat16_read";
233+
final int count = 1 << 16;
234+
final int batchSize = 4096; // keep each INSERT well under max_query_size
235+
client.execute("DROP TABLE IF EXISTS " + table).get();
236+
client.execute("CREATE TABLE " + table
237+
+ " (rowId Int32, v BFloat16, vNull Nullable(BFloat16)) ENGINE = MergeTree ORDER BY rowId").get();
238+
239+
for (int start = 0; start < count; start += batchSize) {
240+
int end = Math.min(start + batchSize, count);
241+
StringBuilder insert = new StringBuilder("INSERT INTO ").append(table).append(" VALUES ");
242+
for (int b = start; b < end; b++) {
243+
String literal = bFloat16Literal(b);
244+
if (b > start) {
245+
insert.append(',');
246+
}
247+
insert.append('(').append(b).append(',').append(literal).append(',')
248+
.append(b == 0 ? "NULL" : literal).append(')');
249+
}
250+
client.execute(insert.toString()).get();
251+
}
252+
253+
// The server's stored bits are the ground truth for the read: reinterpretAsUInt16(v) yields
254+
// the exact 16-bit pattern the server wrote, so a correct read must widen it to
255+
// Float.intBitsToFloat(bits << 16). Deriving the expectation from the stored bits (rather than
256+
// from rowId) keeps the read assertion correct regardless of how the server converts the text
257+
// literal to BFloat16 - in particular the server flushes BFloat16 subnormals to zero, so those
258+
// patterns are stored as +/-0 even though the literal named a tiny subnormal.
259+
AtomicInteger rowCount = new AtomicInteger(0);
260+
client.queryAll("SELECT rowId, v, reinterpretAsUInt16(v) AS bits, vNull FROM " + table + " ORDER BY rowId")
261+
.forEach(row -> {
262+
int b = row.getInteger("rowId");
263+
float expected = Float.intBitsToFloat(row.getInteger("bits") << 16);
264+
assertBFloat16Equals(row.getFloat("v"), expected);
265+
if (b == 0) {
266+
Assert.assertNull(row.getObject("vNull"));
267+
} else {
268+
assertBFloat16Equals((Float) row.getObject("vNull"), expected);
269+
}
270+
rowCount.incrementAndGet();
271+
});
272+
Assert.assertEquals(rowCount.get(), count);
273+
}
274+
275+
// Text form of the exact BFloat16 whose bit pattern is {@code pattern}, suitable for use as a
276+
// SQL numeric literal. Non-finite patterns use ClickHouse's nan/inf/-inf tokens; every other
277+
// pattern uses the shortest round-trippable decimal of Float.intBitsToFloat(pattern << 16),
278+
// which is exactly representable in BFloat16 (its low 16 mantissa bits are zero).
279+
private static String bFloat16Literal(int pattern) {
280+
float value = Float.intBitsToFloat(pattern << 16);
281+
if (Float.isNaN(value)) {
282+
return "nan";
283+
}
284+
if (value == Float.POSITIVE_INFINITY) {
285+
return "inf";
286+
}
287+
if (value == Float.NEGATIVE_INFINITY) {
288+
return "-inf";
289+
}
290+
return Float.toString(value);
291+
}
292+
293+
@Test(groups = {"integration"})
294+
public void testBFloat16TruncationMatchesServer() throws Exception {
295+
if (isVersionMatch(BFLOAT16_UNSUPPORTED_VERSIONS)) {
296+
throw new SkipException("BFloat16 requires ClickHouse 24.11+");
297+
}
298+
299+
// (a) The client must decode a BFloat16 produced by the server (Float32 -> BFloat16
300+
// drops the low mantissa bits) to the same value.
301+
List<GenericRecord> cast = client.queryAll(
302+
"SELECT CAST(3.14 AS BFloat16) AS v, CAST(0.1 AS BFloat16) AS v2");
303+
Assert.assertEquals(cast.get(0).getFloat("v"), 3.125f, 0.0f); // 0x4048F5C3 -> 0x40480000
304+
Assert.assertEquals(cast.get(0).getFloat("v2"), 0.099609375f, 0.0f); // 0x3DCCCCCD -> 0x3DCC0000
305+
306+
// (b) A value written by the client must be stored byte-for-byte identically to the
307+
// server's own Float32 -> BFloat16 conversion of the same value.
308+
final String table = "test_bfloat16_truncation";
309+
writeReadVerify(table,
310+
DTOForBFloat16Tests.tblCreateSQL(table),
311+
DTOForBFloat16Tests.class,
312+
Arrays.asList(new DTOForBFloat16Tests(0, 3.14f, 0.1f)),
313+
(data, dto) -> {
314+
Assert.assertEquals(dto.getBFloat16(), 3.125f, 0.0f);
315+
Assert.assertEquals(dto.getBFloat16Nullable(), Float.valueOf(0.099609375f));
316+
});
317+
List<GenericRecord> parity = client.queryAll(
318+
"SELECT reinterpretAsUInt16(bFloat16) AS written, " +
319+
"reinterpretAsUInt16(CAST(toFloat32(3.14) AS BFloat16)) AS server FROM " + table);
320+
Assert.assertEquals(parity.get(0).getInteger("written"), parity.get(0).getInteger("server"));
321+
}
322+
323+
@Test(groups = {"integration"})
324+
public void testBFloat16InDynamicColumn() throws Exception {
325+
if (isVersionMatch("(,24.8]") || isVersionMatch(BFLOAT16_UNSUPPORTED_VERSIONS)) {
326+
throw new SkipException("BFloat16 requires ClickHouse 24.11+");
327+
}
328+
329+
// Reading a BFloat16 held in a Dynamic column exercises the dynamic type-tag read path.
330+
List<GenericRecord> rows = client.queryAll(
331+
"SELECT CAST(CAST(1.5 AS BFloat16) AS Dynamic) AS v SETTINGS allow_experimental_dynamic_type = 1");
332+
Assert.assertEquals(rows.get(0).getObject("v"), Float.valueOf(1.5f));
333+
}
334+
157335
@Test(groups = {"integration"})
158336
public void testVariantWithSimpleDataTypes() throws Exception {
159337
if (isVersionMatch("(,24.8]")) {

0 commit comments

Comments
 (0)