Python · Scalar Types

Scalar Types

import numkong 8.0.0-rc.1 · b1f866ab

NumKong exposes two different low-precision stories in Python. It exposes Python scalar objects for a few formats. And it exposes tensor dtypes for the broader buffer-oriented path.

The six scalar types have stable payload sizes even though Python object headers are not:

TypeBitsBytesRangeInfNaN
nk.float161+5+102±65504yesyes
nk.bfloat161+8+72±3.4×10³⁸yesyes
nk.float8_e4m31+4+31±448noyes
nk.float8_e5m21+5+21±57344yesyes
nk.float6_e2m31+2+31±7.5nono
nk.float6_e3m21+3+21±28nono

The Bits column shows sign + exponent + mantissa bit counts. The Bytes column is the stable payload size; float8_* and float6_* both store 1 byte because the sub-byte formats are padded to byte alignment.

The full object footprint is interpreter-dependent. Use sys.getsizeof(nk.float16(1.0)) if you need the heap footprint of the Python wrapper object itself. Use Tensor.itemsize and Tensor.nbytes for the stable payload sizes of array storage.

ml_dtypes matters here because NumKong explicitly interoperates with the formats that NumPy still does not model well. The test suite compares bfloat16, float8_e4m3, float8_e5m2, float6_e2m3, and float6_e3m2 behavior against ml_dtypes where that comparison is meaningful.

Promotion is intentional. Mixed exotic floats are routed through wider compute types rather than pretending a same-width accumulator is good enough.

§ Buffer-First Casting

nk.astype converts a CPU buffer in one native pass. Unlike nk.Tensor(a).astype(dtype), it never stages a through an intermediate NumKong Tensor. The input may have arbitrary rank and strides, and the returned Tensor preserves its shape and is C-contiguous.

image_u8 = np.random.randint(0, 256, size=(1080, 1920, 3), dtype=np.uint8)
image_f32 = nk.astype(image_u8, "float32")

# Reuse an existing allocation — writes in place and hands `out` back.
out = np.empty(image_u8.shape, dtype=np.float32)
assert nk.astype(image_u8, "float32", out=out) is out

Any writable buffer works as out, whether a NumPy array, a memoryview, or a NumKong Tensor. It must be C-contiguous, exactly the input shape, already of the requested dtype, and must not overlap the input. A NumKong Tensor is the way to name a dtype NumPy cannot express, such as bfloat16, uint1, int4, or uint4. The native conversion runs without the GIL.

Packed dtypes — uint1, int4, uint4, and e2m1 — count logical dimensions in a Tensor shape. Dimension count must be a multiple of the values per byte: 8 for uint1 and 2 for the nibble types, with element 0 in the high nibble. The buffer protocol, __array_interface__, nbytes, and DLPack expose the whole bytes, so a raw NumPy byte array passed with dtype="int4" holds two dimensions per byte.

codes = np.arange(16, dtype=np.uint8) % 8
packed = nk.astype(codes, "int4")
assert packed.shape == (16,) and packed.nbytes == 8
assert memoryview(packed).shape == (8,)             # whole bytes
assert nk.Tensor(np.asarray(memoryview(packed)), dtype="int4").shape == (16,)
assert packed[3] == 3 and nk.dot(packed, packed) == int(codes.astype(np.int64) @ codes)

The conversion policy is NumKong’s core policy. Same-dtype conversion makes an independent copy, and narrower floating formats use round-to-nearest, ties-to-even. Float-to-integer conversion rounds ties to even, saturates finite overflows and infinities, and maps NaN to zero. All NumKong dtype names are accepted as targets.

§ ml_dtypes Interoperability

NumKong accepts ml_dtypes arrays directly — no .view(np.uint8) workaround needed:

import ml_dtypes
a = np.random.randn(100, 768).astype(np.float32).astype(ml_dtypes.bfloat16)
b = np.random.randn(100, 768).astype(np.float32).astype(ml_dtypes.bfloat16)
result = nk.cdist(a, b, "dot")  # just works

NumKong scalars also work as NumPy dtype specifiers:

arr = np.array([1.0, 2.0, 3.0], dtype=nk.bfloat16)
float(arr[0])  # → 1.0

Type name mapping between the two libraries:

ml_dtypesNumKongStatus
ml_dtypes.bfloat16nk.bfloat16 / "bfloat16"Identical format
ml_dtypes.float8_e4m3nk.float8_e4m3 / "e4m3"Identical (IEEE E4M3)
ml_dtypes.float8_e4m3fnnk.float8_e4m3 / "e4m3"Identical (E4M3FN = no inf)
ml_dtypes.float8_e5m2nk.float8_e5m2 / "e5m2"Identical format
ml_dtypes.float6_e2m3fnnk.float6_e2m3 / "e2m3"Identical (MX E2M3)
ml_dtypes.float6_e3m2fnnk.float6_e3m2 / "e3m2"Identical (MX E3M2)
ml_dtypes.float8_e4m3fnuz…Rejected: different bias, NaN, and zero
ml_dtypes.float8_e5m2fnuz…Rejected: different NaN and zero encoding
ml_dtypes.float8_e4m3b11fnuz…Rejected: bias=11, incompatible encoding
ml_dtypes.float8_e8m0fnu"float8_e8m0" / "e8m0"MX block scale byte (see ScaledTensor)
ml_dtypes.float8_e3m4…Not supported: no NumKong kernel
ml_dtypes.float4_e2m1fn"float4_e2m1" / "e2m1"NVFP4/MXFP4 element (see ScaledTensor)
ml_dtypes.int4"int4"Compatible via buffer protocol
ml_dtypes.uint4"uint4"Compatible via buffer protocol
ml_dtypes.int2…Not supported
ml_dtypes.uint2…Not supported

§ Block-Scaled Formats (OCP MX & NVIDIA NVFP4)

Block-scaled formats group elements into fixed-size blocks, each carrying its own scale byte.

FormatBlockElementScaleGlobal
"nvfp4"16e2m1ue4m3float32
"mxfp4"32e2m1ue8m0…
"mxfp6e2m3"32e2m3ue8m0…
"mxfp6e3m2"32e3m2ue8m0…
"mxfp8e4m3"32e4m3ue8m0…
"mxfp8e5m2"32e5m2ue8m0…
"mxint8"32i8ue8m0…

Quantization happens through the same astype verb used for dense casts. Passing a block-scaled dtype name to a dense Tensor.astype(...) returns a ScaledTensor; calling astype(...) on a ScaledTensor with a dense dtype materializes it back. Quantization is always along the last axis, which must be a whole multiple of the block size.

A ScaledTensor exposes plain read attributes, but no getter/setter methods:

AttributeTypeMeaning
.elementsTensorElement codes (e.g. e2m1), in the logical dense shape
.block_scalesTensorOne scale byte per block (ue4m3 / ue8m0), last axis = n / block
.tensor_scalefloat / NoneNVFP4 per-tensor multiplier; None for the MX family
.block_sizeintElements per block (16 for NVFP4, 32 for MX)
.dtypestrComposite format name (e.g. "nvfp4")
.shapetupleLogical (dense) shape
import numkong, numpy as np

x = np.random.randn(64).astype(np.float32)
t = numkong.Tensor(x)

# Encode → ScaledTensor. For NVFP4 the per-tensor scale is auto-derived and surfaced as `.tensor_scale`.
q = t.astype("nvfp4")
assert q.dtype == "nvfp4" and q.block_size == 16
assert isinstance(q.tensor_scale, float)          # NVFP4 carries a per-tensor float
assert q.block_scales.shape == (4,)               # 64 / 16 blocks

# Decode / materialize back to a dense Tensor of any standard dtype.
out = q.astype("float32")                          # also "float16", "bf16", ...

# MX formats carry no per-tensor scale.
qm = t.astype("mxfp4")
assert qm.tensor_scale is None and qm.block_size == 32

# Block-aligned slicing returns a ScaledTensor view sharing storage; both children slice in lockstep.
X = numkong.Tensor(np.random.randn(3, 64).astype(np.float32)).astype("nvfp4")
row = X[1]            # → ScaledTensor of shape (64,)
columns = X[:, 16:48]  # → ScaledTensor of shape (3, 32); start/stop must be block-aligned
materialized = row.astype("float32")

The .elements and .block_scales tensors are DLPack-exportable for zero-copy hand-off to PyTorch, CuPy, and friends. The e2m1, e8m0, and ue4m3 bytes exchange as kDLFloat4_e2m1fn, kDLFloat8_e8m0fnu, and kDLFloat8_e4m3fn, with the capsule’s last extent counting bytes:

import torch
scales = torch.from_dlpack(q.block_scales)   # zero-copy view of the scale bytes

API

Types

bfloat16BFloat16 scalar (sign + 8-bit exponent + 7-bit mantissa).
__init__.pyi:93 ↗
class bfloat16
float16IEEE 754 half-precision scalar.
__init__.pyi:113 ↗
class float16
float8_e4m3FP8 E4M3 scalar.
__init__.pyi:133 ↗
class float8_e4m3
float8_e5m2FP8 E5M2 scalar.
__init__.pyi:153 ↗
class float8_e5m2
float6_e2m3FP6 E2M3 scalar.
__init__.pyi:173 ↗
class float6_e2m3
float6_e3m2FP6 E3M2 scalar.
__init__.pyi:193 ↗
class float6_e3m2