Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
14 changes: 10 additions & 4 deletions src/main/java/dev/zarr/zarrjava/core/ArrayMetadata.java
Original file line number Diff line number Diff line change
Expand Up @@ -5,6 +5,7 @@
import com.fasterxml.jackson.annotation.JsonProperty;
import dev.zarr.zarrjava.ZarrException;
import dev.zarr.zarrjava.core.chunkkeyencoding.ChunkKeyEncoding;
import dev.zarr.zarrjava.utils.Float16;
import dev.zarr.zarrjava.utils.MultiArrayUtils;
import dev.zarr.zarrjava.utils.Utils;
import ucar.ma2.Array;
Expand Down Expand Up @@ -41,6 +42,8 @@ public static Object parseFillValue(Object fillValue, @Nonnull DataType dataType
boolean dataTypeIsLong = dataType == dev.zarr.zarrjava.v3.DataType.INT64 || dataType == dev.zarr.zarrjava.v2.DataType.INT64 || dataType == dev.zarr.zarrjava.v2.DataType.INT64_BE || dataType == dev.zarr.zarrjava.v3.DataType.UINT64 || dataType == dev.zarr.zarrjava.v2.DataType.UINT64 || dataType == dev.zarr.zarrjava.v2.DataType.UINT64_BE;
boolean dataTypeIsFloat = dataType == dev.zarr.zarrjava.v3.DataType.FLOAT32 || dataType == dev.zarr.zarrjava.v2.DataType.FLOAT32 || dataType == dev.zarr.zarrjava.v2.DataType.FLOAT32_BE;
boolean dataTypeIsDouble = dataType == dev.zarr.zarrjava.v3.DataType.FLOAT64 || dataType == dev.zarr.zarrjava.v2.DataType.FLOAT64 || dataType == dev.zarr.zarrjava.v2.DataType.FLOAT64_BE;
// float16 fill values are parsed to float, matching the in-memory representation.
boolean dataTypeIsHalfFloat = dataType.isHalfPrecisionFloat();

if (fillValue instanceof Boolean) {
Boolean fillValueBool = (Boolean) fillValue;
Expand All @@ -60,7 +63,7 @@ public static Object parseFillValue(Object fillValue, @Nonnull DataType dataType
return fillValueNumber.intValue();
} else if (dataTypeIsLong) {
return fillValueNumber.longValue();
} else if (dataTypeIsFloat) {
} else if (dataTypeIsFloat || dataTypeIsHalfFloat) {
return fillValueNumber.floatValue();
} else if (dataTypeIsDouble) {
return fillValueNumber.doubleValue();
Expand All @@ -69,23 +72,23 @@ public static Object parseFillValue(Object fillValue, @Nonnull DataType dataType
} else if (fillValue instanceof String) {
String fillValueString = (String) fillValue;
if (fillValueString.equals("NaN")) {
if (dataTypeIsFloat) {
if (dataTypeIsFloat || dataTypeIsHalfFloat) {
return Float.NaN;
} else if (dataTypeIsDouble) {
return Double.NaN;
}
throw new ZarrException(
"Invalid fill value '" + fillValueString + "' for data type '" + dataType + "'.");
} else if (fillValueString.equals("+Infinity")) {
if (dataTypeIsFloat) {
if (dataTypeIsFloat || dataTypeIsHalfFloat) {
return Float.POSITIVE_INFINITY;
} else if (dataTypeIsDouble) {
return Double.POSITIVE_INFINITY;
}
throw new ZarrException(
"Invalid fill value '" + fillValueString + "' for data type '" + dataType + "'.");
} else if (fillValueString.equals("-Infinity")) {
if (dataTypeIsFloat) {
if (dataTypeIsFloat || dataTypeIsHalfFloat) {
return Float.NEGATIVE_INFINITY;
} else if (dataTypeIsDouble) {
return Double.NEGATIVE_INFINITY;
Expand Down Expand Up @@ -116,6 +119,9 @@ public static Object parseFillValue(Object fillValue, @Nonnull DataType dataType
return buf.get() != 0;
} else if (dataTypeIsByte) {
return buf.get();
} else if (dataTypeIsHalfFloat) {
// The 2 bytes are a binary16 bit pattern, not a truncated float32.
return Float16.halfBitsToFloat(buf.getShort());
} else if (dataTypeIsShort) {
return buf.getShort();
} else if (dataTypeIsInt) {
Expand Down
15 changes: 15 additions & 0 deletions src/main/java/dev/zarr/zarrjava/core/DataType.java
Original file line number Diff line number Diff line change
Expand Up @@ -4,4 +4,19 @@ public interface DataType {
ucar.ma2.DataType getMA2DataType();

int getByteCount();

/**
* Whether elements are stored as IEEE 754 binary16 but held in memory as {@code float}.
*
* <p>For every other data type the encoded element width ({@link #getByteCount()}) matches the
* width of the in-memory {@link ucar.ma2.DataType}. Half precision is the exception: it encodes
* to 2 bytes but is held as a 4-byte {@code float}, because neither Java nor {@code ucar.ma2}
* has a 16-bit floating point type. The {@code bytes} codec uses this to convert at the
* encode/decode boundary via {@link dev.zarr.zarrjava.utils.Float16}.
*
* @return true for {@code float16}, false for every other data type
*/
default boolean isHalfPrecisionFloat() {
return false;
}
}
28 changes: 28 additions & 0 deletions src/main/java/dev/zarr/zarrjava/core/codec/core/BytesCodec.java
Original file line number Diff line number Diff line change
Expand Up @@ -3,6 +3,7 @@
import com.fasterxml.jackson.annotation.JsonValue;
import dev.zarr.zarrjava.ZarrException;
import dev.zarr.zarrjava.core.codec.ArrayBytesCodec;
import dev.zarr.zarrjava.utils.Float16;
import ucar.ma2.*;

import java.nio.ByteBuffer;
Expand Down Expand Up @@ -31,6 +32,21 @@ public Array decode(ByteBuffer chunkBytes) throws ZarrException {
Index index = Index.factory(shape);
return Array.factory(DataType.BOOLEAN, index, bools);
}

// float16 encodes to 2 bytes per element but is held as a 4-byte float, so it cannot be
// read straight into an ma2 array; widen each element instead.
if (arrayMetadata.dataType.isHalfPrecisionFloat()) {
int base = chunkBytes.position();
int size = chunkBytes.remaining() / Short.BYTES;
float[] floats = new float[size];
for (int i = 0; i < size; i++) {
floats[i] = Float16.halfBitsToFloat(chunkBytes.getShort(base + i * Short.BYTES));
}

Index index = Index.factory(shape);
return Array.factory(DataType.FLOAT, index, floats);
}

return Array.factory(dtype, shape, chunkBytes);
}

Expand All @@ -51,6 +67,18 @@ public ByteBuffer encode(Array chunkArray) throws ZarrException {
return bb;
}

// Float16. Checked before ArrayFloat, because float16 is held in memory as an ArrayFloat
// and must be narrowed to 2 bytes per element rather than written as float32.
if (arrayMetadata.dataType.isHalfPrecisionFloat()) {
float[] data = (float[]) chunkArray.get1DJavaArray(DataType.FLOAT);
ByteBuffer bb = ByteBuffer.allocate(data.length * Short.BYTES).order(order);
for (float f : data) {
bb.putShort(Float16.floatToHalfBits(f));
}
bb.flip();
return bb;
}

// Float32
if (chunkArray instanceof ArrayFloat) {
float[] data = (float[]) chunkArray.copyTo1DJavaArray();
Expand Down
155 changes: 155 additions & 0 deletions src/main/java/dev/zarr/zarrjava/utils/Float16.java
Original file line number Diff line number Diff line change
@@ -0,0 +1,155 @@
package dev.zarr.zarrjava.utils;

/**
* Conversions between IEEE 754 binary16 ("half precision") bit patterns and Java {@code float}.
*
* <p>Zarr's {@code float16} data type stores 2 bytes per element, but there is no 16-bit floating
* point primitive in Java and no half precision type in {@code ucar.ma2.DataType}. Half precision
* arrays are therefore held in memory as {@code float} (binary32) and converted at the codec
* boundary. Widening binary16 to binary32 is always exact; narrowing is not, see
* {@link #floatToHalfBits(float)}.
*
* <p>{@code Float.float16ToFloat} and {@code Float.floatToFloat16} would do this for us, but they
* were added in Java 20 and this project targets Java 8, so the conversions are implemented here.
* The results match {@code numpy.float16} for all inputs, including subnormals, signed zeros,
* infinities, NaN payloads and round-half-to-even ties.
*/
public final class Float16 {

/**
* Largest finite binary16 value, {@code 65504.0}. Finite {@code float} values greater than
* {@code MAX_VALUE + 16} round to infinity when narrowed.
*/
public static final float MAX_VALUE = 65504.0f;

/** Smallest positive normal binary16 value, {@code 2^-14}. */
public static final float MIN_NORMAL = 6.103515625E-5f;

/** Smallest positive subnormal binary16 value, {@code 2^-24}. */
public static final float MIN_VALUE = 5.9604645E-8f;

private Float16() {
}

/**
* Widens a binary16 bit pattern to the {@code float} of equal value. Exact for every input:
* every binary16 value is representable as a binary32.
*
* <p>Signed zeros and infinities are preserved. NaN payloads are shifted into the binary32
* mantissa, so a NaN stays a NaN rather than becoming an infinity.
*
* @param halfBits the 16 bits of a binary16 value, in the low half of the short
* @return the equal-valued float
*/
public static float halfBitsToFloat(short halfBits) {
final int h = halfBits & 0xFFFF;
final int signBit = (h >>> 15) & 0x1;
final int exponent = (h >>> 10) & 0x1F;
int mantissa = h & 0x3FF;

final int floatSign = signBit << 31;

if (exponent == 0) {
if (mantissa == 0) {
// Signed zero.
return Float.intBitsToFloat(floatSign);
}
// Subnormal binary16, which is normal as a binary32: shift the mantissa up until the
// implicit leading bit is set, decrementing the exponent for each shift.
int normalizedExponent = 1;
while ((mantissa & 0x400) == 0) {
mantissa <<= 1;
normalizedExponent--;
}
mantissa &= 0x3FF; // Drop the leading bit; it is implicit in binary32 too.
return Float.intBitsToFloat(
floatSign | ((normalizedExponent - 15 + 127) << 23) | (mantissa << 13));
}

if (exponent == 0x1F) {
// Infinity (mantissa 0) or NaN (mantissa non-zero); shifting keeps the payload non-zero.
return Float.intBitsToFloat(floatSign | 0x7F800000 | (mantissa << 13));
}

// Normal binary16.
return Float.intBitsToFloat(floatSign | ((exponent - 15 + 127) << 23) | (mantissa << 13));
}

/**
* Narrows a {@code float} to the nearest binary16 bit pattern, rounding to nearest with ties to
* even -- the same rounding {@code numpy.float16} and IEEE 754 use.
*
* <p>This conversion is lossy. binary16 carries 11 bits of significand against binary32's 24,
* and its finite range stops at {@value #MAX_VALUE}:
* <ul>
* <li>Values at or above {@code 65520.0} become infinity, not {@link #MAX_VALUE}. That
* threshold is the midpoint between {@link #MAX_VALUE} and the next power of two, and it
* rounds up because ties go to even. Writing {@code 1e8} to a {@code float16} array
* stores infinity.</li>
* <li>Values below {@code 2^-25} become signed zero; values between {@code 2^-25} and
* {@link #MIN_NORMAL} lose precision to the subnormal range.</li>
* <li>Other values are rounded to 11 significant bits, so {@code 0.1} stores as
* {@code 0.0999755859375}.</li>
* </ul>
* Callers writing data they cannot afford to round should not be using {@code float16}.
*
* @param value the float to narrow
* @return the 16 bits of the nearest binary16 value, in the low half of the short
*/
public static short floatToHalfBits(float value) {
final int f = Float.floatToRawIntBits(value);
final int sign = (f >>> 16) & 0x8000;
final int exponent = (f >>> 23) & 0xFF;
final int mantissa = f & 0x7FFFFF;

if (exponent == 0xFF) {
if (mantissa == 0) {
return (short) (sign | 0x7C00); // Infinity.
}
// NaN. Keep the top payload bits, but never let the payload round down to zero, which
// would silently turn the NaN into an infinity.
int payload = mantissa >>> 13;
if (payload == 0) {
payload = 1;
}
return (short) (sign | 0x7C00 | payload);
}

final int unbiasedExponent = exponent - 127;

if (unbiasedExponent > 15) {
return (short) (sign | 0x7C00); // Overflows the binary16 range.
}

if (unbiasedExponent >= -14) {
// Normal binary16. Keep the top 10 mantissa bits and round on the remaining 13.
final int halfMantissa = mantissa >>> 13;
final int roundBits = mantissa & 0x1FFF;
final int bits = sign | ((unbiasedExponent + 15) << 10) | halfMantissa;
if (roundBits > 0x1000 || (roundBits == 0x1000 && (halfMantissa & 1) != 0)) {
// A carry out of the mantissa increments the exponent, and a carry out of the
// maximum exponent yields infinity. Both are the correct IEEE 754 results.
return (short) (bits + 1);
}
return (short) bits;
}

if (unbiasedExponent >= -25) {
// Subnormal binary16, or a value small enough that rounding may still reach the
// smallest subnormal. Restore the implicit leading bit, then shift into place so that
// the result is interpreted as halfMantissa * 2^-24.
final int significand = mantissa | 0x800000;
final int shift = -unbiasedExponent - 1;
final int halfMantissa = significand >>> shift;
final int roundBits = significand & ((1 << shift) - 1);
final int tie = 1 << (shift - 1);
if (roundBits > tie || (roundBits == tie && (halfMantissa & 1) != 0)) {
return (short) (sign | (halfMantissa + 1));
}
return (short) (sign | halfMantissa);
}

// Underflows to signed zero: strictly less than half of the smallest subnormal.
return (short) sign;
}
}
14 changes: 14 additions & 0 deletions src/main/java/dev/zarr/zarrjava/v2/DataType.java
Original file line number Diff line number Diff line change
Expand Up @@ -12,6 +12,11 @@ public enum DataType implements dev.zarr.zarrjava.core.DataType {
UINT16("u2", Endianness.LITTLE),
UINT32("u4", Endianness.LITTLE),
UINT64("u8", Endianness.LITTLE),
/**
* IEEE 754 binary16. Encoded as 2 bytes, but held in memory as {@code float}; see
* {@link dev.zarr.zarrjava.utils.Float16}.
*/
FLOAT16("f2", Endianness.LITTLE),
FLOAT32("f4", Endianness.LITTLE),
FLOAT64("f8", Endianness.LITTLE),
INT16_BE("i2", Endianness.BIG),
Expand All @@ -20,6 +25,7 @@ public enum DataType implements dev.zarr.zarrjava.core.DataType {
UINT16_BE("u2", Endianness.BIG),
UINT32_BE("u4", Endianness.BIG),
UINT64_BE("u8", Endianness.BIG),
FLOAT16_BE("f2", Endianness.BIG),
FLOAT32_BE("f4", Endianness.BIG),
FLOAT64_BE("f8", Endianness.BIG);

Expand Down Expand Up @@ -68,6 +74,9 @@ public ucar.ma2.DataType getMA2DataType() {
case UINT64:
case UINT64_BE:
return ucar.ma2.DataType.ULONG;
case FLOAT16:
case FLOAT16_BE:
// No half precision type in ucar.ma2; widened to float, see Float16.
case FLOAT32:
case FLOAT32_BE:
return ucar.ma2.DataType.FLOAT;
Expand All @@ -84,4 +93,9 @@ public int getByteCount() {
return Integer.parseInt(dtype.substring(1));
}

@Override
public boolean isHalfPrecisionFloat() {
return this == FLOAT16 || this == FLOAT16_BE;
}

}
12 changes: 12 additions & 0 deletions src/main/java/dev/zarr/zarrjava/v3/DataType.java
Original file line number Diff line number Diff line change
Expand Up @@ -15,6 +15,11 @@ public enum DataType implements dev.zarr.zarrjava.core.DataType {
UINT16("uint16", 2),
UINT32("uint32", 4),
UINT64("uint64", 8),
/**
* IEEE 754 binary16. Encoded as 2 bytes, but held in memory as {@code float}; see
* {@link dev.zarr.zarrjava.utils.Float16}.
*/
FLOAT16("float16", 2),
FLOAT32("float32", 4),
FLOAT64(
"float64",
Expand Down Expand Up @@ -58,6 +63,8 @@ public ucar.ma2.DataType getMA2DataType() {
return ucar.ma2.DataType.UINT;
case UINT64:
return ucar.ma2.DataType.ULONG;
case FLOAT16:
// No half precision type in ucar.ma2; widened to float, see Float16.
case FLOAT32:
return ucar.ma2.DataType.FLOAT;
case FLOAT64:
Expand All @@ -66,4 +73,9 @@ public ucar.ma2.DataType getMA2DataType() {
throw new IllegalStateException("Unknown DataType: " + this);
}
}

@Override
public boolean isHalfPrecisionFloat() {
return this == FLOAT16;
}
}
Loading
Loading