Implementing kernel
C++ engine symbols that back this Python API.Quantized linear (fully-connected) layer — int8 weight, float compute.
The inference-time replacement that lucid.quantization.convert installs
in place of a calibrated float lucid.nn.Linear. It is the quantized
workhorse of every fully-connected stack — classifier heads, Transformer MLP
blocks, projection layers — and the layer whose weight memory dominates most
language models.
Representation (sidecar design B). The learned float weight is quantized
once, at from_float time, into an int8 code tensor plus a
per-output-channel scale / zero_point; the float weight is then dropped.
Only the int8 codes, the qparams, and the (still-float) bias live in the module,
so the checkpoint is roughly 4x smaller than the float layer's. Each forward
dequantizes the weight back to float, runs the ordinary F.linear, and
finally fake-quantizes the output onto the activation grid that calibration
observed. Keeping the matmul itself in float means the result matches a genuine
int8 kernel to within a rounding step and the layer runs unchanged on any
device, with no int8 GEMM kernel required.
Per-channel weights. The weight (out_features, in_features) is quantized
independently for every output channel (axis 0): each row carries its own scale
and zero-point. Because different output neurons often span very different
dynamic ranges, per-channel quantization tracks them far more tightly than a
single per-tensor scale, and is the standard accuracy-preserving choice for
linear and convolution kernels. The bias is small and precision-sensitive, so it
is left in float.
Encoding happens once (at from_float); decode + matmul run on every
forward:
where are the per-output-channel weight scale / zero-point and
the scalar calibrated output (scale, zero_point) with grid
bounds (0, 255 for the default quint8). The
decode → float-matmul → re-quantize round-trip is what makes the output bit-
equivalent to a true int8 kernel without needing one.
Parameters
in_featuresintin dimension of the weight.out_featuresintout dimension, quantized per-channel.biasbool= TrueTrue the layer keeps a learned float bias added after the matmul;
if False no bias term is stored.Attributes
weight_int8Tensorint8 weight codes, shape (out_features, in_features).weight_scaleTensor(out_features,).weight_zero_pointTensor(out_features,).scaleTensorzero_pointTensorbiasTensor or None(out_features,), or None.Notes
- Instances are normally produced by
lucid.quantization.convert(orfrom_float), not constructed directly: a freshly constructed layer has identity qparams (scale = 1,zero_point = 0) and zeroed weights, and only becomes meaningful oncefrom_float/load_state_dictfills the buffers. - Only int8 codes + qparams + float bias enter the
state_dict; the float weight never does. In practice the weight payload shrinks ~3.55x(int8) and a whole model checkpoint ~3.97x(measured onresnet_18). - This layer wins on memory, not compute — the GEMM runs in float, so on the
CPU stream it is roughly float-speed. For the genuine low-precision compute
speed-up on Metal, convert with the MLX backend active so the Linear family is
routed to
lucid.nn.quantized.QuantizedLinearMLX, which runs a real weight-only int4/int8 GEMM (skipping the dequantize hop) — measured3.15xfaster at the memory-bound decode shapeM = 1on a fully-Metal model. That win is bandwidth-bound: it lands in the inference / generation regime and fades toward parity (0.9-1x) in compute-bound training GEMMs. _activationis the identity here; the fusedlucid.nn.quantized.LinearReLUoverrides it to apply ReLU before the output fake-quant so calibration and inference see the same post-ReLU range.
Examples
>>> import lucid, lucid.nn as nn
>>> import lucid.quantization as Q
>>> model = nn.Sequential(nn.Linear(128, 64))
>>> model.qconfig = Q.get_default_qconfig()
>>> prepared = Q.prepare(model) # insert activation/weight observers
>>> _ = prepared(lucid.randn(32, 128)) # calibrate on representative data
>>> qmodel = Q.convert(prepared) # float Linear -> quantized Linear
>>> type(qmodel[0]).__name__
'Linear'
>>> y = qmodel(lucid.randn(1, 128)) # int8-weight inference
>>> y.shape
(1, 64)
Constructing the layer directly is **not** a substitute for that workflow — a
bare instance carries zeroed weights and identity qparams, so it returns garbage
until from_float / load_state_dict populates the buffers:
>>> broken = nn.quantized.Linear(128, 64) # zeroed weight, scale=1, zp=0
>>> bool((broken.weight_int8 == 0).all().item())
True
For the *compute* speed-up on Metal, route through the real low-precision GEMM:
>>> src = nn.Linear(512, 512)
>>> qlin = nn.quantized.QuantizedLinearMLX.from_float(src, bits=8)
>>> qlin(lucid.randn(1, 512).to("metal")).shape
(1, 512)See Also
- lucid.nn.quantized.QuantizedLinearMLX—Real Metal int4/int8 GEMM (compute win).
- lucid.nn.quantized.LinearReLU—Quantized Linear with a fused ReLU.
- lucid.nn.quantized.Conv2d—The convolutional analogue (per-channel int8 weight).
- lucid.quantization.convert—Installs this layer from a calibrated float model.
Used by 2
Constructors
1Construct a Tensor from Python data, a NumPy array, or another Tensor.
The data is funnelled through lucid._factories.converters._to_impl,
the canonical bridge between the outside world and Lucid's C++
TensorImpl. Python scalars become 0-d tensors; nested lists are
recursively flattened with shape inference; NumPy arrays cross the
single sanctioned host-to-engine boundary; and existing Tensor
sources are rewrapped (optionally with cast and/or device transfer).
Parameters
datandarray | list | int | float | bool | Tensorlucid._factories.converters; another Tensor
is shallow-copied to a new Tensor with possibly different
dtype / device / requires_grad.dtypedtype | str | NoneNone, inferred from data.devicedevice | str | None"cpu" or "metal"). If None, defaults
to the source device or "cpu" for host data.requires_gradboolReturns
TensorA freshly constructed tensor whose storage is owned by the engine.
Notes
Constructor is one of the six sanctioned host-to-engine bridge boundaries (rule H4). NumPy arrays cross the bridge exactly once here; afterwards the data is owned by Lucid's engine. The resulting layout is C-contiguous row-major:
where is the element size in bytes and are the dimension sizes.
Examples
>>> import lucid
>>> lucid.Tensor([1.0, 2.0, 3.0])
tensor([1., 2., 3.])
>>> lucid.Tensor([[1, 2], [3, 4]], dtype=lucid.int64).shape
(2, 2)
>>> x = lucid.Tensor(3.14, requires_grad=True)
>>> x.requires_grad
True