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
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).