class bfloat16 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:
| Type | Bits | Bytes | Range | Inf | NaN |
|---|---|---|---|---|---|
nk.float16 | 1+5+10 | 2 | ±65504 | yes | yes |
nk.bfloat16 | 1+8+7 | 2 | ±3.4×10³⁸ | yes | yes |
nk.float8_e4m3 | 1+4+3 | 1 | ±448 | no | yes |
nk.float8_e5m2 | 1+5+2 | 1 | ±57344 | yes | yes |
nk.float6_e2m3 | 1+2+3 | 1 | ±7.5 | no | no |
nk.float6_e3m2 | 1+3+2 | 1 | ±28 | no | no |
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 outAny 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 worksNumKong scalars also work as NumPy dtype specifiers:
arr = np.array([1.0, 2.0, 3.0], dtype=nk.bfloat16)
float(arr[0]) # → 1.0Type name mapping between the two libraries:
| ml_dtypes | NumKong | Status |
|---|---|---|
ml_dtypes.bfloat16 | nk.bfloat16 / "bfloat16" | Identical format |
ml_dtypes.float8_e4m3 | nk.float8_e4m3 / "e4m3" | Identical (IEEE E4M3) |
ml_dtypes.float8_e4m3fn | nk.float8_e4m3 / "e4m3" | Identical (E4M3FN = no inf) |
ml_dtypes.float8_e5m2 | nk.float8_e5m2 / "e5m2" | Identical format |
ml_dtypes.float6_e2m3fn | nk.float6_e2m3 / "e2m3" | Identical (MX E2M3) |
ml_dtypes.float6_e3m2fn | nk.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.
| Format | Block | Element | Scale | Global |
|---|---|---|---|---|
"nvfp4" | 16 | e2m1 | ue4m3 | float32 |
"mxfp4" | 32 | e2m1 | ue8m0 | … |
"mxfp6e2m3" | 32 | e2m3 | ue8m0 | … |
"mxfp6e3m2" | 32 | e3m2 | ue8m0 | … |
"mxfp8e4m3" | 32 | e4m3 | ue8m0 | … |
"mxfp8e5m2" | 32 | e5m2 | ue8m0 | … |
"mxint8" | 32 | i8 | ue8m0 | … |
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:
| Attribute | Type | Meaning |
|---|---|---|
.elements | Tensor | Element codes (e.g. e2m1), in the logical dense shape |
.block_scales | Tensor | One scale byte per block (ue4m3 / ue8m0), last axis = n / block |
.tensor_scale | float / None | NVFP4 per-tensor multiplier; None for the MX family |
.block_size | int | Elements per block (16 for NVFP4, 32 for MX) |
.dtype | str | Composite format name (e.g. "nvfp4") |
.shape | tuple | Logical (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 bytesAPI
Types
bfloat16BFloat16 scalar (sign + 8-bit exponent + 7-bit mantissa).
float16IEEE 754 half-precision scalar.
class float16 float8_e4m3FP8 E4M3 scalar.
class float8_e4m3 float8_e5m2FP8 E5M2 scalar.
class float8_e5m2 float6_e2m3FP6 E2M3 scalar.
class float6_e2m3 float6_e3m2FP6 E3M2 scalar.
class float6_e3m2