QuantizedLinearMLX
ModuleQuantizedLinearMLX(in_features: int, out_features: int, bias: bool = True, bits: int = 8, group_size: int = 64, relu: bool = False)Weight-only int4/int8 linear backed by a real Metal low-precision GEMM.
The compute-and-memory quantized linear, and the layer
lucid.quantization.convert routes the Linear family to when the MLX
backend is active. Where lucid.nn.quantized.Linear dequantizes its
int8 weight back to float and runs an ordinary float matmul (a memory-only
win), QuantizedLinearMLX stores the weight in MLX's group-wise packed
format and runs the genuine low-precision kernel — the packed weight is
dequantized inside the GEMM, in one pass, so a full float weight is never
materialized. Build it from a float lucid.nn.Linear via
from_float, choosing bits (4 or 8).
Group-wise packed representation. The float weight (out_features, in_features) is split, along the input dim, into contiguous groups of
group_size elements; every (output-channel, group) block gets its own
affine scale and bias (an offset, distinct from
the layer's additive bias term), and the codes are bit-packed into a uint32
tensor (tagged I32). Finer groups track a channel's local dynamic range
more tightly than one per-row scale, at the cost of more metadata. This is the
packing MLX's quantized_matmul consumes directly.
Encoding happens once (at from_float); the packed decode + matmul run
fused on every forward:
where indexes out_features, the input dim,
the bit-width (grid 0 .. 2^b - 1), and maps input index
to its group. A ReLU is optionally fused after + b. In the
kernel, is never fully realized — the packed codes and the
per-group are streamed into the matmul directly.
Parameters
in_featuresintout_featuresintbiasbool= TrueTrue.bitsint= 84 (aggressive memory) or 8. Defaults to 8.group_sizeint= 64scale / bias block in
the packed layout. Must divide in_features. Smaller groups are tighter
but carry more metadata. Defaults to 64.relubool= FalseTrue, a ReLU is fused after the GEMM (+ bias). Defaults to
False.Attributes
packed_weightTensor(out_features, in_features·bits/32),
int32 (I32-tagged uint32) — bits/32 codes packed per word.scalesTensor(output-channel, group) affine scale, shape
(out_features, in_features/group_size).biasesTensor(output-channel, group) affine offset, shape
(out_features, in_features/group_size).biasTensor or None(out_features,), or None.Notes
- The kernel is Metal-only but the layer is device-transparent: a CPU-carried activation is moved onto the GPU for the GEMM and the result is moved back to the input's device, so this layer accelerates inside an otherwise-CPU model (or residual skip branch) without forcing the whole model onto Metal.
- Build via
from_float(which quantizes on Metal), not by constructing directly: a fresh instance has an all-zeropacked_weightand zeroedscales/biases, so it returns zeros untilfrom_float/load_state_dictpopulates the buffers. - The
state_dictcarries the packedint32weight + per-groupscales/biases+ the float bias — never a float weight. The weight payload shrinks ~3.55xatbits=8and ~6.40xatbits=4(the packed codes plus the small per-group metadata). - This is the layer that wins on compute, not just memory: it skips the
dequantize-to-float hop entirely. The speed-up is bandwidth-bound — it is
largest in the memory-bound decode / generation regime (measured
3.15xatM = 1) and fades toward parity (0.9-1x) in compute-bound (large-M) training GEMMs, where the matmul, not weight bandwidth, dominates. - The real int4/int8 kernels are an optional C++ submodule; gate on
lucid.quantization._qgemm.is_availablebefore relying on this fast path.lucid.quantization.convertroutes the Linear family here only when the MLX backend is active.
Examples
>>> import lucid, lucid.nn as nn
>>> from lucid.nn.quantized import QuantizedLinearMLX
>>> lin = nn.Linear(512, 256)
>>> qlin = QuantizedLinearMLX.from_float(lin, bits=8) # quantize on Metal
>>> x = lucid.randn(4, 512)
>>> y = qlin(x) # GEMM runs on Metal, y returns on x's device
>>> y.shape
(4, 256)
The layer is device-transparent — a CPU input is round-tripped through Metal
for the GEMM and the result comes back on the input's device, while a Metal
input stays on the GPU throughout:
>>> qlin(x).is_metal # CPU in -> CPU out
False
>>> qlin(x.to("metal")).is_metal # Metal in -> Metal out
True
Constructing directly (instead of via from_float) yields a zeroed,
no-op layer — the packed weight is all zero until the buffers are filled:
>>> broken = QuantizedLinearMLX(512, 256, bits=8)
>>> bool((broken.packed_weight == 0).all().item())
TrueSee Also
- lucid.nn.quantized.Linear—Sidecar int8 Linear (memory win, float compute).
- lucid.quantization.convert—Routes the Linear family here on the MLX backend.
lucid.quantization._qgemm.is_available—Whether the Metal kernels are built.
Used by 1
Constructors
1Class methods
1from_float(mod: nn.Module, bits: int = 8, group_size: int = 64, relu: bool = False)Quantize a float Linear's weight into MLX packed form (on Metal).
Instance methods
2Run the MLX low-precision GEMM x @ packed_wᵀ (+ bias, +ReLU).
The kernel is Metal-only, so a CPU-carried activation is moved onto the GPU for the GEMM and the result is moved back to the input's device. That keeps this layer device-transparent — it accelerates inside an otherwise-CPU model without forcing the whole model (or residual skip branches) onto Metal.