Quantized weight packs · .npy

Apple MLX weight files (npy / npz / safetensors) and mlx-lm quantization

MLX does not define a new container. mlx.core.save writes one array as .npy, mlx.core.savez / savez_compressed writes a ZIP of .npy members, and mlx.core.save_safetensors writes a safetensors file. mlx.core.load dispatches on the extension (npy, npz, safetensors, and also gguf). The MLX-specific parts are the dtype tag for bfloat16 inside npy ('<V2'), the safetensors dtype map (BF16 native; F8_E4M3 and F8_E8M0 collapse to uint8 on load), and the quantization convention: a weight matrix is stored as a packed uint32 tensor plus a scales tensor and, for affine mode, a biases tensor. Group size, bit width, and mode are not in the file; mlx-lm keeps them in config.json under "quantization" (copied to "quantization_config") and names the tensors "<module>.weight", "<module>.scales", and "<module>.biases". The physical bytes of an unquantized safetensors checkpoint are ordinary safetensors (see the safetensors spec). GGUF bytes are the GGUF spec; this spec only records what MLX's loader and saver do to them.

.npy .npz .safetensors

What it is
weight pack
Byte order
per array declared
Dimension order
per array flag
Compression
optional
Metadata
json
Loading
Loading it into a library does not execute code.

How to read it

mx.load picks the container from format= or from the extension after the last dot (a file object uses its .name). npy returns one array. npz, safetensors, and gguf return a dict. return_metadata is legal only for safetensors and gguf; npy and npz raise. An mlx-lm checkpoint is a directory, not one file.

npy
NpyHeader. single array, or one ZIP member
safetensors
bytes. the safetensors spec. The MLX writer always emits __metadata__. With no entries that value is null, not {}. It does not pad the header length to a multiple of 8, and writes tensor bytes in unordered_map iteration order (MLX-011)
quantization
QuantizedAffine. logical grouping across named tensors, not a contiguous record
mx.load(path)
  *.npy            -> one array (NpyHeader + bytes)
  *.npz            -> ZIP (ZIP_STORED or ZIP_DEFLATED), each member "<key>.npy"
  *.safetensors    -> [u64 N][JSON][bytes]     metadata["format"] = "mlx" when mlx-lm wrote it
  *.gguf           -> the GGUF spec, then the MLX dtype / Q4_0 repack below

mlx-lm directory
  config.json
  model.safetensors                          # one shard
  model-00001-of-0000N.safetensors ...       # when more than one shard
  model.safetensors.index.json               # always written, even for one shard
  tokenizer files copied from the source

quantized module "<path>" (affine), inside whichever container
  <path>.weight   uint32 [..., K * bits / 32]
  <path>.scales   same dtype as the original matrix [..., K / group_size]
  <path>.biases   same shape and dtype as scales
  <path>.bias     optional dense bias, not quantized

One tensor

name
npz: ZIP member with a trailing '.npy' removed (members without that suffix keep the full name). safetensors/gguf: the JSON or GGUF tensor name. npy: none (single array).
dtype
npy descr via dtype_from_array_protocol; safetensors string via dtype_from_safetensor_str; GGUF via gguf_type_to_dtype or float16 after dequant.
shape
npy header shape, outermost first. If fortran_order, the raw buffer is read as the reversed shape and then transposed. GGUF ne[] is reversed (ne[0] was innermost).
data_offset
npy: 8 + header_len_size + header_len. npz: inside the ZIP member. safetensors: 8 + N + data_offsets[0].
nbytes
prod(shape) * dtype.size, including a packed uint32 weight (the shape is already the packed shape).
alignment
1. MLX npy data is not guaranteed aligned. safetensors tensor offsets are not padded. GGUF uses its own alignment (default 32) on save.

NpyHeader

What mx.save writes. Magic 0x93 'NUMPY', version major 1 or 2 and minor 0, then a little-endian header length and a Python-dict literal. Padding is NOT numpy's 64-byte rule. The writer computes padding = (6 + 2 + (2 + 2*is_v1) + unpadded_header_len + 1) % 16 and appends that many spaces and one newline. That value is the remainder, not the pad that would reach a multiple of 16. is_v1 makes the parenthesized term 4, but a v1 length field is 2 bytes. A 0-d '<f4' array lands at offset % 16 == 2; a 1-d one lands at 72 (% 16 == 8); a 2-d one such as shape (2, 3) lands at 78 (% 16 == 14).

magic
bytes[6]. constant \x93NUMPY
major
u8. 1 if unpadded header + 15 < 65535, else 2
minor
u8. constant 0
header_len
u16le if major==1 else u32le
header
ascii[header_len]. {'descr': '<..', 'fortran_order': False, 'shape': (d0, d1, ), } plus spaces and a newline

QuantizedAffine

Result of mx.quantize(..., mode='affine') and of nn.QuantizedLinear / QuantizedEmbedding in affine mode. Three tensors, same rank (>= 2). Logical last dimension K must be divisible by group_size. weight.shape[-1] = K * bits / 32, scales.shape[-1] = biases.shape[-1] = K / group_size. scales and biases have the original floating dtype of the matrix (they are cast back to w.dtype). Reconstruct with w_hat = q * scale + bias, q an integer in 0 .. 2^bits-1.

weight
u32. packed codes. Bit widths 1, 2, 4, and 8 pack 32/bits consecutive elements along the last axis, element 0 in the low bits. Widths 3, 5, and 6 are an LSB-first bit stream packed into uint32 words. FP8 is four bytes per word, first element in the low byte.
scales
f16|bf16|f32|f64. one scale per group
biases
f16|bf16|f32|f64. one bias per group; required for affine, absent for the fp modes

QuantizedFp

mxfp4, nvfp4, and mxfp8. No biases tensor (mx.quantize returns two arrays). scales are uint8. mxfp4: group_size 32, bits 4, scales are E8M0 (stored exponent, value 2^(e-127)), codes are FP4 E2M1 indices. nvfp4: group_size 16, bits 4, scales are FP8 E4M3, optional per-tensor global_scale (float32). mxfp8: group_size 32, bits 8, weight bytes are FP8 E4M3 viewed as uint32, scales are E8M0.

weight
u32
scales
u8
global_scale
f32. optional; nvfp4 only. One scalar, or one per expert for QuantizedSwitchLinear.

MlxLmConfig

config.json written by mlx_lm.utils.save_config. Keys are sorted. Non-finite floats are tagged {"__float__": "Infinity"|"-Infinity"|"NaN"}. _name_or_path and vision_config are dropped. quantization and quantization_config are the same object.

quantization
QuantizationMap
quantization_config
QuantizationMap. duplicate of quantization on save. On load, consulted only when quantization is absent.
quantize_activations
bool. optional. If true, QuantizedLinear is rebuilt as QQLinear (nvfp4 or mxfp8, no bias).

QuantizationMap

Default parameters live next to per-module overrides. A module path maps to false (do not quantize) or to {group_size, bits, mode, global_scale?}. Paths not listed use the defaults. On load, a listed path returns that value from the class predicate; a layer that has a .scales tensor but no path entry is recovered by infer_quant_config.

group_size
i32. affine default 64; mxfp4/mxfp8 32; nvfp4 16
bits
i32. affine default 4; mxfp8 8; mxfp4 and nvfp4 4
mode
string. affine | mxfp4 | nvfp4 | mxfp8. Omitted on some importers; load defaults to affine.
<module path>
bool | object. false skips the layer. A dict may set global_scale true (nvfp4 experts).

Reader

The procedure that finds each tensor's bytes.

read

Dispatch and the npy/npz/safetensors readers. Quantized tensors are returned as ordinary arrays; interpreting them is dequantize_affine or dequantize_fp plus config.json.

pseudo-python
def read_mlx(path, format=None, return_metadata=False):
    if format is None:
        format = path.name.rsplit(".", 1)[-1]          # file objects use the .name attribute
    if format == "npy":
        if return_metadata: raise Invalid("metadata not supported")
        return read_mlx_npy(path)
    if format == "npz":
        if return_metadata: raise Invalid("metadata not supported")
        z = ZipFile(path)
        out = {}
        for name in z.namelist():
            key = name[:-4] if name.endswith(".npy") else name
            out[key] = read_mlx_npy(z.open(name))
        return out
    if format == "safetensors":
        arrays, meta = read_safetensors(path)          # safetensors spec, plus MLX-003 (header length) and MLX-004 (data_offsets)
        return (arrays, meta) if return_metadata else arrays
    if format == "gguf":
        return read_mlx_gguf(path, return_metadata)    # this spec's GGUF reader: reverse ne, copy plain types, Q4_0/Q4_1/Q8_0 become affine triplets
    raise Invalid("unknown format")

def read_mlx_npy(f):
    magic, major, minor = read_exact(f, 8)
    if magic != b"\x93NUMPY" or major not in (1, 2):
        raise Invalid("bad npy header")
    hlen = u16(f) if major == 1 else u32(f)            # host endian, not swapped
    header = read_exact(f, hlen).decode("ascii")
    descr = header[11:14]                              # bytes [11, 14); assumes the 3-character descr this writer emits (MLX-010)
    dtype = DTYPE_FROM_DESCR[descr]                    # 'V2' -> bfloat16
    fortran = header[34] == "T"
    shape = parse_ints_between_last_parens(header)     # rejects negatives
    raw = read_exact(f, prod(shape) * itemsize(dtype))
    if descr[0] == ">" and host_is_little():
        raw = byteswap(raw, itemsize(dtype))
    if fortran:
        shape = list(reversed(shape))
        arr = Tensor(dtype, shape, raw)
        arr = transpose(arr)
    else:
        arr = Tensor(dtype, shape, raw)
    return arr

read_gguf

mx.load of a .gguf file. Element types F32, F16, I8, I16, I32 are copied and the ggml dimension order is reversed into MLX shape. Q4_0, Q4_1, and Q8_0 become an affine triplet at group_size 32 (see below) instead of one tensor. BF16, Q2_K, Q4_K, and Q6_K are dequantized to float16 by gguflib gguf_tensor_to_f16 (commit 8fa6eb652366). Every other ggml type makes that call return NULL and the load throws. That includes Q5_0, Q5_1, Q8_1, Q3_K, Q5_K, Q8_K, the IQ types, I64, F64, and types added after BF16 (MXFP4, NVFP4, Q1_0, Q2_0), which this gguflib does not know. save_gguf writes only F32, F16, I8, I16, I32, row-major, with shape reversed back to ne[0]-innermost. It does not write quantized ggml blocks.

pseudo-python
def read_mlx_gguf(path, return_metadata=False):
    weights, meta = parse_gguf(path)               # GGUF spec
    out = {}
    for name, t in weights.items():
        shape = list(reversed(t.ne))               # ne[0] was innermost
        if t.type in (F32, F16, I8, I16, I32):
            out[name] = Tensor(GGUF_TO_MLX[t.type], shape, t.data)
        elif t.type in (Q4_0, Q4_1, Q8_0):
            w, s, b = gguf_quant_to_affine(t)       # below
            prefix = name[:-7]                      # drop the last 7 characters, assumed to be ".weight" (MLX-012)
            out[name] = w; out[prefix+".scales"] = s; out[prefix+".biases"] = b
        elif t.type in (BF16, Q2_K, Q4_K, Q6_K):
            out[name] = Tensor(float16, shape, gguflib_to_f16(t))   # lossy
        else:
            raise Invalid("gguf_tensor_to_f16 failed")
    return (out, meta) if return_metadata else out

def gguf_quant_to_affine(t):
    # blocks of 32 along the innermost ggml axis, which is MLX's last axis
    if t.type == Q4_0:          # block: f16 scale, then 16 bytes of nibbles
        # ggml byte j = elem[j] | elem[j+16] << 4
        # MLX wants consecutive nibbles, low nibble = earlier element
        scale = f16_at(block, 0)
        codes = repack_q4_nibbles(block[2:18])      # 32 codes in 0..15, then pack_pow2(..., 4)
        bias = -8 * scale
        bits = 4
    elif t.type == Q4_1:        # f16 scale, f16 bias, 16 nibble bytes
        scale, bias = f16_at(block, 0), f16_at(block, 2)
        codes = repack_q4_nibbles(block[4:20]); bits = 4
    else:                       # Q8_0: f16 scale, 32 int8 codes
        scale = f16_at(block, 0); bias = -128 * scale
        codes = (int8_bytes ^ 0x80).astype(uint8)    # signed -128..127 -> 0..255
        bits = 8
    # dequant is then scale * code + bias, which restores
    # Q4_0: scale * (nibble - 8); Q4_1: scale * nibble + min; Q8_0: scale * int8
    return pack_pow2(codes, bits), scales_f16, biases_f16

Number formats

The name in the file, and the canonical dtype it maps to.

In the fileCanonicalBits
bool_bool8
uint8uint88
uint16uint1616
uint32uint3232
uint64uint6464
int8int88
int16int1616
int32int3232
int64int6464
float16float1616
float32float3232
float64float6464
bfloat16bfloat1616
complex64complex6464

Reject the file when

Listed in the spec's order. Only rules marked MUST that reject the file are shown. The other IDs are warnings or implementation limits.

  • MLX-001. An npy member starts with magic 0x93 NUMPY and major version 1 or 2
  • MLX-002. descr kind/size is one of the 14 MlxDtype rows; 'V2' is bfloat16 and no other void width is accepted
  • MLX-003. safetensors header length is in (0, 100000000) and the file contains the header plus every data_offsets span
  • MLX-004. each safetensors tensor has data_offsets of length 2 and end-begin == prod(shape)*dtype.size
  • MLX-005. mx.quantize input is floating, rank >= 2, and K = shape[-1] is divisible by group_size. mx.dequantize weight is uint32, rank >= 2
  • MLX-006. affine group_size is 32, 64, or 128 and bits is 1, 2, 3, 4, 5, 6, or 8 (7 is rejected)
  • MLX-007. packed weight.shape[-1] * 32 / bits == scales.shape[-1] * group_size, and scales has the same rank and the same leading dimensions as weight
  • MLX-008. affine dequantize requires biases with the same shape as scales. mxfp4 is only (group_size 32, bits 4), nvfp4 only (16, 4), mxfp8 only (32, 8); their scales are uint8 and they have no biases

Sources

Nick Reynolds · @goldnode · rev 2.3