The Quantization Process: Weights, Activations, and Calibration
Quantization in SKaiNET comes in two flavours that are easy to conflate. This page draws the line between them, then walks the activation post-training quantization (PTQ) pipeline β calibration, scales, and the int8 matmul shape β that an int8 NPU or accelerator target needs.
For the weight side, this article is an overview; the depth lives in How quantized SIMD kernels are built and TurboQuant KV-cache compression.
Two regimes, one word
| Weight-only quantization | Activation PTQ | |
|---|---|---|
What is quantized |
The weights only. Activations stay fp32. |
Both operands of a matmul β activations and weights β are int8. |
Where it runs |
Eager CPU/JVM kernels ( |
Integer accelerators (NPUs) that do |
The matmul |
|
|
Needs calibration? |
No. Block scales are derived from the weights at quantize time. |
Yes. Activation ranges are data-dependent and must be measured. |
In the codebase |
|
Calibration in the framework; int8 emission in the target backend codegen. |
The rest of this page is about the second column.
The int8 matmul shape
An integer accelerator computes a matmul as i8 Γ i8 β i32, so a single fp32 projection
y = x Β· Wα΅ becomes a three-part pattern:
q_x = clamp(round(x / Sa), -127, 127) # quantize activation (fp32 -> i8)
acc = matmul_i8(q_x, q_w) # i8 x i8 -> i32 accumulator
y = acc * (Sa * Sw) # requantize (i32 -> fp32)
with symmetric per-tensor scales
-
Sa = max|x| / 127β the activation scale (data-dependent β calibrated), -
Sw = max|W| / 127β the weight scale (static β computed from the weights directly), -
So = max|y| / 127β the output scale, used when the result feeds another int8 matmul.
Zero-point is 0 under symmetric quantization, which keeps the requantize a single multiply.
Softmax, LayerNorm and GELU stay in fp32 β only the matmuls are quantized.
Sw is free: the weights are known ahead of time. Sa and So are the problem β they depend on
the activations, which depend on the input. That is what calibration measures.
Calibration: measuring activation ranges
Calibration runs the model eagerly in fp32 over a handful of representative inputs and records,
per matmul, the absolute-max of each operand and of the result. The per-tensor scale is then
S = max|x| / 127, accumulated (by max) across all calibration inputs.
The clean way to tap the activation stream is a TensorOps decorator that intercepts matmul and
delegates everything else, using Kotlin interface delegation:
class ObservingTensorOps(
private val base: TensorOps,
private val onMatmul: (a: Tensor<*, *>, b: Tensor<*, *>, out: Tensor<*, *>) -> Unit,
) : TensorOps by base { (1)
override fun <T : DType, V> matmul(a: Tensor<T, V>, b: Tensor<T, V>): Tensor<T, V> {
val out = base.matmul(a, b)
onMatmul(a, b, out) (2)
return out
}
}
| 1 | Every op except matmul flows straight through to the real backend. |
| 2 | a is the activation, b the weight (or, for attention score matmuls, a second activation);
out is the result. Read their values with tensor.data.copyToFloatArray() and fold into a
running max|Β·|. |
Wrap a context so the model’s ctx.ops.matmul(…) calls route through the tap β the model itself
is unchanged:
class ObservingExecutionContext(base: ExecutionContext, onMatmul: MatmulTap)
: ExecutionContext by base {
override val ops: TensorOps = ObservingTensorOps(base.ops, onMatmul)
}
// drive it:
val ranges = CalibrationRanges()
val ctx = ObservingExecutionContext(DirectCpuExecutionContext.create(), ranges::observe)
for (input in representativeInputs) model.forward(input, ctx) // fp32 eager, taps every matmul
val scales = ranges.symmetricInt8Scales() // S = max|x| / 127 per tensor
|
SKaiNET also exposes the |
The output is one scale triple per matmul β keyed by deterministic op-invocation order so the emitter can line them up with the graph:
# op_index Sa Sb So absmax_act absmax_rhs absmax_out
op0 0.124454215 0.003971611 0.053741008 15.805685 0.504395 6.825108
op1 0.124454215 0.004852055 0.063222170 15.805685 0.616211 8.029216
...
Sb is the second-operand scale. For the q/k/v/out/MLP projections it simply matches the static
Sw; for the attention score matmuls (QΒ·Kα΅, attnΒ·V) the second operand is also an
activation, so Sb is a genuine calibrated scale β which is why the calibrator records it for
every matmul rather than assuming operand two is a weight.
Why QuantizedMatmul is not the activation-int8 op
A natural question: can the existing QuantizedMatmul carry this? No β and the reason is worth
stating, because it shapes where the int8 emission has to live.
-
It is weight-only and eager.
QuantizedMatmuldequant-fusesQ8_0/Q4_Kweight blocks against fp32 activations and returns a materialised fp32 tensor. It never records into the tape, so it produces no int8 graph for a backend to lower. -
The tape can’t express the pattern either.
TensorOps.matmulis dtype-homogeneous βmatmul(a: Tensor<T, V>, b: Tensor<T, V>): Tensor<T, V>. There is noi8 Γ i8 β i32accumulation form, and there is noroundprimitive (onlyclamp/convert/sign). So thequantize β i8 matmul β requantizepattern cannot be written at the op level today.
The consequence is a clean split of responsibilities:
-
The framework calibrates β a pure read-side tap over the existing fp32 eager run, fully expressible with today’s API (above).
-
The target backend emits int8 β the
quantize / i8-matmul / requantizelowering belongs in the accelerator’s codegen, parameterised by theSa/Sb/Sothe calibrator produced.
The pipeline end to end
representative inputs
β
βΌ
fp32 eager run ββtap every matmulβββΆ per-tensor max|Β·|
(ObservingExecutionContext) β
βΌ
symmetric scales Sa, Sb, So (= max|x| / 127)
β
static weights βββΆ Sw, int8 weight constants
β
βΌ
backend codegen: quantize β i8 matmul (i32) β requantize
β
βΌ
int8 model on the accelerator
Calibration is the SKaiNET-side half and is target-agnostic: the same scales drive any int8 backend.
Follow-ups
-
A tape-level int8 matmul op (
i8 Γ i8 β i32) plus aroundprimitive would let the int8 pattern be expressed in the graph itself, rather than only in backend codegen. -
Per-channel weight scales (a
Swper output column) and percentile / KL calibration (instead of plainmax|Β·|) are the usual accuracy levers once the per-tensor path works. -
Wiring the
ExecutionObservernotifyOpdispatch on the eager contexts would let calibration use the observer hooks directly instead of aTensorOpsdecorator.