quantize(x: Tensor, scale: _ScaleLike, zero_point: _ZeroPointLike, qdtype: QDtype, ch_axis: int | None = None)Encode a real tensor into integer codes on an affine quantization grid.
quantize is the forward half of the affine quantization map and the
lowest-level primitive in the whole subsystem — observers, quantized
modules, and QAT all bottom out here. Given a real tensor x and an
already-chosen (scale, zero_point) pair, it snaps every value to the
nearest lattice point of the integer grid described by qdtype and
returns the integer codes that physically get stored. This is the step
that actually shrinks memory: a float32 weight becomes an int8 (or int4)
code tensor roughly 4x (or 8x) smaller, with the tiny scale /
zero_point sidecar carried alongside.
The map is affine — real values are divided by the step s, offset by the
integer zero_point z, rounded to the nearest integer, and clamped
into the representable [quant_min, quant_max] range before the storage
cast:
Rounding and clipping happen in the float domain; only the final cast
lands in qdtype.storage. That ordering is deliberate — it keeps the path
on the public lucid.* op surface (no external libraries, H4) so it is
bit-for-bit identical on the Accelerate (CPU) and MLX (GPU) streams, and it
sidesteps the missing CPU integer-clamp kernel. Pass ch_axis to quantize
per channel: a length-C scale / zero_point vector is reshaped to
broadcast along that axis, giving each channel its own step — the
accuracy-preserving default for conv / linear weights.
Parameters
scaleTensor or floats — the width of one code interval. A Python float
(or scalar tensor) for per-tensor; a length-C tensor when ch_axis
is given.zero_pointTensor, float, or intz mapping the real value 0 onto a code, so zero is
represented exactly (matters for zero-padded tensors).qdtypeQDtypequant_min / quant_max and the
storage dtype the codes are cast to (qint8 = [-128, 127] in
int8, quint8 = [0, 255], qint4 = [-8, 7]).ch_axisint= NoneNone (default) quantizes
the whole tensor with a single scalar scale / zero_point.Returns
TensorInteger codes with dtype getattr(lucid, qdtype.storage) and the same
shape as x.
Notes
- Pure encode with no gradient — it emits an integer tensor and is not
meant to sit inside a differentiable path. For a differentiable
simulate-quantization op (used by QAT), use
fake_quantize, which adds a straight-through estimator. quint8currently maps toint16storage (the engine has nouint8) andqint4packs intoint8; codes stay within the grid bounds regardless of the wider physical dtype.- Round-tripping
quantize→dequantizeis lossy by the rounding step; the per-element reconstruction error is at mosts / 2. - Device-agnostic: there is no stream-specific branch here, so CPU and Metal produce identical codes.
Examples
>>> import lucid
>>> import lucid.quantization as Q
>>> x = lucid.tensor([-1.0, 0.0, 0.5, 2.0])
>>> codes = Q.quantize(x, scale=0.05, zero_point=0, qdtype=Q.qint8)
>>> codes.shape
(4,)
Values outside the grid saturate at quant_min / quant_max rather than
wrapping — a common gotcha when scale is chosen too small:
>>> hot = Q.quantize(lucid.tensor([100.0]), scale=0.05, zero_point=0, qdtype=Q.qint8)
>>> int(hot.item()) # clamped to qint8's max, not 2000
127
Per-channel encode of a (out, in) weight — one step per output row:
>>> w = lucid.randn(4, 8)
>>> s = lucid.ones(4) * 0.02 # a scale for each output channel
>>> zp = lucid.zeros(4)
>>> Q.quantize(w, s, zp, Q.qint8, ch_axis=0).shape
(4, 8)See Also
- lucid.quantization.dequantize—The inverse affine decode
(q - z) * s. - lucid.quantization.fake_quantize—Differentiable quantize→dequantize round-trip.
- lucid.quantization.QDtype—Grid descriptor (bounds + storage dtype).