rml / Quantization

Quantization

rml/quant reads the weight formats llama.cpp's GGUF files use: ggml's type table, the byte layout of every block type, scalar dequantization, round-to-nearest quantization, and the integer dot products the decoder's matrix-vector products run on.

On this page
  1. The type table
  2. Block layouts
  3. Unpacking the packed fields
  4. Dequantization
  5. Quantization
  6. A worked example: Q4_0
  7. Quantized dot products
  8. Kernels and references
  9. The benchmark
  10. Fixtures from gguf-py
  11. Who uses it
  12. Tests

In short: a quantized row is a run of fixed-size blocks, 32 elements for the legacy types (Q4_0 to Q8_1) and 256 for the K-quants (Q2_K to Q8_K), each carrying its own f16 scale and, in some types, a minimum. The K-quants split a block into 8 or 16 sub-blocks with small integer scales of their own, scaled again by the block's f16 d. DequantizeRow turns any of them into f32 one block at a time, written to mirror the format rather than to be fast, and is checked against gguf-py. For decoding, VecDot never expands a weight to f32: the activations are quantized to int8 once (Q8_0 or Q8_K blocks that line up with the weight's), every product inside a block is an exact int32 sum, and only the block scales are float. That keeps the optimized kernel exactly comparable with its scalar reference, and on Apple Silicon each 16 multiply-adds are one sdot.

The concepts are on /ml-models: quantization and decoder-only LLMs. The module is four libraries, ggml_type, blocks (a header), dequantize and dot, with three tests and one benchmark (BUILD.bazel). rml's checklist records the module (progress.md L51) and the quantized GEMV on top of it (L65).

The type table

GgmlType numbers each element type exactly as ggml does, because that number is what a GGUF tensor record stores (ggml_type.h L18–40). A row is always whole blocks, so n elements of a type take n / elems · bytes bytes (L7–10). One private Layout switch holds the name, block elements and block bytes of every known id and returns nullopt for the rest (ggml_type.cc L17–61).

ItemSignatureMeaningSource
GgmlTypeIsKnownbool(uint32_t type)False for ids the switch does not list: 4, 5, 16–23, 29 and anything above 30..h L42–43, .cc L65
GgmlTypeNamestring_view(GgmlType)"Q4_K" and so on; "unknown" otherwise..cc L67–70
GgmlBlockElems, GgmlBlockBytessize_t(GgmlType)1, 32 or 256 elements, and the block's bytes; 0 for unknown types, which callers treat as "unsupported"..h L45–47, .cc L72–80

Every type the switch knows, with bits per weight worked out as block bytes · 8 / block elements:

TypeIdElementsBytesBits per weightDequantizeQuantized dot
F32, F16, BF160, 1, 3014, 2, 232, 16, 16yesno (f32 path)
Q4_0232184.5yesagainst Q8_0
Q4_1332205yesno
Q5_0632225.5yesno
Q5_1732246yesno
Q8_0832348.5yesagainst Q8_0
Q8_1932369yesno
Q2_K10256842.625yesno
Q3_K112561103.4375yesno
Q4_K122561444.5yesagainst Q8_K
Q5_K132561765.5yesagainst Q8_K
Q6_K142562106.5625yesagainst Q8_K
Q8_K152562929.125yes(the activation side)
I8, I16, I32, I64, F6424–2811, 2, 4, 8, 8—Unsupportedno

Q4_K costs the same 4.5 bits as Q4_0 yet spends them better, which is the argument the module's header makes for K-quants (blocks.h L11–17); rml's memory notes use the same figures and put a llama.cpp Q4_K_M file, a mix of K-quant types, at about 4.9 (models.md L12–13, more on Models and memory).

Block layouts

blocks.h declares each block as a plain struct whose bytes match a GGUF file exactly, pinned by a static_assert on every size (blocks.h L111–120). Q8_0 and Q4_0 live in rml/core/dtype.h instead, because Tensor stores them as native dtypes (Tensors and dtypes, dtype.h L65–82).

BlockFieldsValue of element iSource
BlockQ8_0Float16 d; int8 qs[32]d · qs[i]dtype.h L67–71
BlockQ4_0Float16 d; uint8 qs[16]d · (q − 8), q in [0, 16): low nibble of qs[i] for i < 16, high nibble of qs[i − 16] afterdtype.h L73–78
BlockQ4_1d, m; qs[16]d · q + m, same nibble orderL28–33
BlockQ5_0d; qh[4]; qs[16]d · (q − 16), q in [0, 32); bit i of the 32-bit qh is element i's fifth bitL35–40
BlockQ5_1d, m; qh[4]; qs[16]d · q + m, q in [0, 32)L42–48
BlockQ8_1d, s; int8 qs[32]d · qs[i]; s = d · Σq is cached for dot productsL50–55
BlockQ2_Kscales[16]; qs[64]; d, dmin16 sub-blocks of 16; each scale byte packs a 4-bit scale (low) and 4-bit min (high): d · scale · q − dmin · min, q in [0, 4)L57–64
BlockQ3_Khmask[32]; qs[64]; scales[12]; d16 sub-blocks of 16 with 6-bit signed scales (offset 32): d · (scale − 32) · (q − 4), the low two bits of q in qs and the third in hmask, a set bit meaning no −4L66–74
BlockQ4_Kd, dmin; scales[12]; qs[128]8 sub-blocks of 32 with 6-bit scales and mins: d · scale · q − dmin · min, q in [0, 16)L76–83
BlockQ5_Kd, dmin; scales[12]; qh[32]; qs[128]Q4_K plus a fifth bit per element in qh, q in [0, 32)L85–92
BlockQ6_Kql[128]; qh[64]; int8 scales[16]; d16 sub-blocks of 16 with int8 scales: d · scale · (q − 32), the low four bits of q in ql and the high two in qhL94–101
BlockQ8_Kfloat d; int8 qs[256]; int16 bsums[16]d · qs[i]; the sum of each group of 16 q is kept for K-quant dot products. It is the activation side of those products.L103–109
The 144 bytes of a Q4_K block and how element i is decoded A Q4_K block holds 256 elements in 144 bytes: an f16 d, an f16 dmin, 12 bytes of packed scales and 128 bytes of nibbles. The 12 scale bytes hold eight 6-bit scale and min pairs, one per sub-block of 32 elements; pairs 0 to 3 are the low six bits of bytes 0 to 3 and 4 to 7, pairs 4 to 7 take low nibbles from bytes 8 to 11 and top bits from the spare bits of bytes 0 to 7. The nibbles are four 64-element chunks of 32 bytes each, the low nibble holding the chunk's first 32 elements and the high nibble the second 32. Element i uses pair i / 32 and nibble i, and decodes to d times scale times q minus dmin times min. BlockQ4_K: 256 elements in 144 bytes d f16, 2 B dmin f16, 2 B scales[12] 8 × (6-bit scale, min) qs[128] 256 nibbles, q in [0, 16) pairs 0–3: bytes 0–3 & 63 (scale), bytes 4–7 & 63 (min) pairs 4–7: low 4 bits from bytes 8–11, top 2 bits from bytes 0–7's bits 6–7 4 chunks of 64 elements, 32 bytes each: low nibble = elements 0–31 of the chunk, high nibble = elements 32–63 byte (i / 64) · 32 + i % 32 x[i] = d · scalej · q[i] − dmin · minj j = i / 32, the sub-block of element i Q5_K is the same plus qh[32]: bit k of qh byte l is the fifth bit of element 32 k + l.
The most common K-quant, field by field (blocks.h L76–83), with the unpacking from ScaleMin and Nibble (dequantize.cc L116–135).

Unpacking the packed fields

Every bit trick lives in small helpers in dequantize.cc, each with a comment stating the layout it reads. They are the readable definition of the formats:

HelperUsed byReadsSource
HighBit(qh, j)Q5_0, Q5_1Copies the 4 qh bytes into a uint32_t and moves bit j to bit 4, so elements j and j + 16 get their fifth bit from bits j and j + 16.L42–47
TwoBits(qs, i)Q2_K, Q3_KWithin each 128-element half, byte l of its 32 bytes holds elements l, l + 32, l + 64 and l + 96 in bit pairs 0–1, 2–3, 4–5, 6–7.L78–86
Q3Scale(s, j)Q3_KThe low four bits of scale j are a nibble of bytes 0–7 (low nibbles for j < 8), the high two come from bytes 8–11; minus 32 for the sign.L98–104
ScaleMin(s, j, scale, min)Q4_K, Q5_KThe figure's eight 6-bit pairs. dot.cc keeps an identical copy for the kernels.L116–128, dot.cc L46–57
Nibble(qs, i)Q4_K, Q5_KThe figure's 64-element chunks.L130–135
inline in Q6_KQ6_KEach 128-element half uses 64 ql bytes and 32 qh bytes: its quarters take the low nibbles of ql bytes 0–31 and 32–63, then the high nibbles of the same bytes; quarter k's top two bits are bit pair k of qh.L163–179

Element 200 of a Q4_K block, traced through the helpers: it is in sub-block j = 200 / 32 = 6, so ScaleMin takes the pair-4-to-7 branch, scale = (s[10] & 0xF) | ((s[2] >> 6) << 4) and min = (s[10] >> 4) | ((s[6] >> 6) << 4). It is in chunk 200 / 64 = 3 at position 200 % 64 = 8, which is below 32, so q is the low nibble of qs[3 · 32 + 8] = qs[104]. Then x = d · scale · q − dmin · min (dequantize.cc L137–147).

Dequantization

The header calls these "the scalar definitions every optimized kernel is tested against": one block at a time, shaped like the format description, not tuned (dequantize.h L4–9).

FunctionSignatureBehaviourSource
DequantizeRowStatus(GgmlType, span<const byte> src, span<float> dst)Decodes dst.size() elements from whole blocks packed back to back. InvalidArgument for an unknown type, or when dst is not whole blocks or src is not exactly their bytes (the message gives both counts); Unsupported for I8–I64 and F64. F32, F16 and BF16 are converted element by element..h L22–25, .cc L210–272
DequantizeExpected<Tensor>(GgmlType, span<const byte>, const Shape&)A new contiguous f32 tensor of the shape, filled by DequantizeRow; the size check rejects a shape that does not match the bytes..h L27–28, .cc L274–279

Block data comes straight from a memory-mapped file, where a 34-byte or 210-byte block need not sit on its struct's alignment. So the generic DecodeBlocks copies each block into a local struct with memcpy before decoding it, and DecodeScalars does the same per element (L185–206). The dot products below make the opposite choice and require aligned pointers.

Quantization

rml quantizes only what it must produce itself: Q8_0 and Q4_0 rows, by round to nearest as ggml's reference does, and the activation formats the dot products need. It does not write K-quants; those come from GGUF files.

FunctionScaleRoundingSource
QuantizeRowQ8_0d = max|x| / 127, stored as f16; an all-zero block has d = 0 and inverse 0lround(x / d): halves away from zerodequantize.cc L281–294
QuantizeRowQ4_0d = xm / −8, where xm is the first value of largest magnitude, sign includedmin(15, int(x / d + 8.5)): the argument is never negative, so the truncating cast rounds halves upL296–317
QuantizeRowQ8_Kd = max|x| / 127, kept in f32nearbyint(x / d), then each group of 16 q summed into bsumsdot.cc L299–318
QuantizeActivationsDispatches to Q8_0 or Q8_K; InvalidArgument for any other type, a source that is not whole blocks, or a destination that is not exactly their bytesdot.h L36–38, dot.cc L320–337

A worked example: Q4_0

Take a block whose first four values are −2.0, 1.3, 2.0 and 0.1 and whose other 28 are 0. The first value of largest magnitude is −2.0 (the later 2.0 ties and does not replace it, since the test is strict), so d = −2.0 / −8 = 0.25, exact in f16, and 1 / d = 4:

xx · 4 + 8.5qDecoded d · (q − 8)Error
−2.00.50−2.00
1.313.7131.250.05
2.016.515 (clamped from 16)1.750.25
0.18.9800.1
08.5800

Mapping the extreme onto q = 0 uses all sixteen levels, −8 to +7 steps, so the value with the largest magnitude is exact; the price is that the opposite sign reaches only seven steps, and the 2.0 here comes back as 1.75 (dequantize.h L30–35). Packed, element j goes in the low nibble of qs[j] and element j + 16 in the high one, so with d's f16 bits 0x3400 stored little-endian the block begins 00 34 80 8D 8F 88: each high nibble is 8, element j + 16's zero.

Quantized dot products

VecDot(weight, w, x, n) is one weight row in its stored format against activations already quantized to int8, the inner loop of a decode-time matrix-vector product (dot.h L4–15). Each weight type is paired with an activation format whose blocks line up with its own, and within a block every product is an exact int32 sum:

WeightActivationsPer blockKernel
Q8_0Q8_0w.d · x.d · Σ wi xi over 32DotQ8_0 L88–96
Q4_0Q8_0the 16 bytes unpacked to 32 int8 (q − 8), then as Q8_0DotQ4_0 L98–111
Q4_KQ8_Kx.d · (d · Σj scalej · (q · a)j − dmin · Σj minj · (bsums[2j] + bsums[2j+1]))SumWithMins, DotQ4_K L124–148
Q5_KQ8_Kas Q4_K after OR-ing each element's fifth bit in from qhDotQ5_K L150–166
Q6_KQ8_Kx.d · d · Σs scales · (q · a)s over 16 sub-blocks of 16, q − 32 in [−32, 32)DotQ6_K L168–198

DotActivationType returns that pairing and nullopt for every other type (dot.cc L285–297), and VecDot on an unpaired type is a programmer error (RML_CHECK, L339–356). The bsums are what make the K-quant minimum cheap: the sub-block's min term is min · Σa, and Σa over 32 activations is two of the stored 16-sums, one multiply instead of 32 (kernels.md L117–123).

One Q4_K block of VecDot against Q8_K activations On the left, the f32 activations are quantized once per input row by QuantizeRowQ8_K into a BlockQ8_K with an f32 scale, 256 int8 values and 16 group sums. On the right, the weight row's Q4_K block, read in place from the file mapping, is unpacked by UnpackNibbles into 256 int8 values in element order. For each of the 8 sub-blocks, DotI8 of 32 lanes gives an int32 sum that is multiplied by the sub-block's scale and added to sumi, and the sub-block's min times two group sums is added to summs; on Apple Silicon DotI8 is two sdot instructions. KBlockWithMin then combines them in float: x.d times d times sumi minus dmin times summs, added to the row's running sum. f32 activations x[256 · nb] once per input row weight row, BlockQ4_K × nb in place in the file mapping QuantizeRowQ8_K → BlockQ8_K float d = max|x| / 127, int8 qs[256] int16 bsums[16]: Σ of each 16 q UnpackNibbles → int8 q[256] shifts and masks, element order, on the stack, 16-byte aligned for each sub-block j = 0 … 7 sumi += scalej · DotI8<32>(q + 32j, x.qs + 32j) summs += minj · (bsums[2j] + bsums[2j+1]) exact int32; DotI8<32> is two sdot + one add on Apple Silicon KBlockWithMin: sum += x.d · (d · sumi − dmin · summs) the only float step, shared with VecDotRef scale_j and min_j come from ScaleMin on the block's 12 scale bytes; d, dmin are its f16 fields.
The work for one Q4_K block (dot.cc L113–148). The activation side is done once per input row and reused for every weight row.

Kernels and references

The kernels and the references share one float step per block (LegacyBlock, KBlockWithMin, KBlock, dot.cc L25–38), so they can differ only in how they reach the int32 sums. The design is one comment block (L59–68):

  • Unpack, then dot. Each kernel unpacks a block into a small aligned int8 array in element order, a few shifts and masks per byte that the compiler vectorizes, then takes int8 × int8 → int32 dot products over 16 or 32 lanes.
  • sdot where it exists. DotI8<N> uses vdotq_s32 when the compiler targets ARMv8.4 dotprod, which every Apple Silicon core has: one instruction multiplies 16 int8 pairs and adds each group of four into an int32 lane, so a 32-element block is two instructions and a horizontal add. Elsewhere it is a plain widening loop, which baseline SSE2 vectorizes through int16 and pmaddwd (L70–86). The sdot path is compiled and tested only on rml's macos-14 CI (kernels.md L130–136).
  • References decode element by element. VecDotRef computes each q on its own, exactly as blocks.h describes it, and accumulates the same int32 sums; the K-quant reference sums min · ai one element at a time instead of using bsums (L200–281, L358–375).
  • Aligned input. Unlike DequantizeRow, the dot products cast the pointers to block arrays, so both must be aligned for their block types, as file mappings and vector buffers are; a debug check catches a misaligned pointer (dot.h L40–44, dot.cc L40–44).
  • No overflow. rml's notes bound Q6_K's worst sub-block sum at 16 · 32 · 128 · 128, far inside int32 (kernels.md L120–123).

The benchmark

dot_benchmark times one 4096-element row against quantized activations, kernel and reference, with random block bytes (masked with 0x3B) so the result means nothing but the work is real (dot_benchmark.cc L13–41). rml's published numbers (kernels.md L138–147):

Linux x86_64 container, GCC 13 -O3, SSE2, 4096 elements, 1 threadReferenceKernel
Q8_01.2 µs1.2 µs
Q4_03.9 µs1.3 µs
Q4_K12.9 µs1.1 µs
Q5_K—1.4 µs
Q6_K13.7 µs2.9 µs

The unpack-then-dot shape is what turns Q4_K from the slowest reference into the fastest kernel. SSE2 has no int8 multiply, so every x86 product goes through int16; rml calls these numbers a floor, with the sdot path as the target. No Apple Silicon numbers for VecDot are published at this commit. The benchmark registers no reference for Q5_K, hence the dash.

Fixtures from gguf-py

tools/make_quant_fixtures.py checks rml against an independent implementation: gguf-py, MIT, from llama.cpp (L1–12). With NumPy's generator seeded at 0 it writes, for each of ten block types, two blocks of random bytes whose f16 scale fields (listed by byte offset in BLOCKS) are overwritten with finite values in ±0.05, and the f32 values gguf-py dequantizes them to; then, for Q8_0 and Q4_0, 96 normal values scaled by 2, with element 5 and elements 40–71 set to zero, and the bytes gguf-py quantizes them to (L23–35, L38–65). The result, testdata/gguf_quants.json, is about 55 KB and committed, so the C++ tests need no Python.

The script's comment on that range says an all-zero block has d = 0, but elements 40–71 straddle the second block (32–63) and the third (64–95), so neither is all zero and the d = 0 case is not in the fixture (L55–62). The C++ ZeroActivationsGiveZero test does quantize an all-zero row, through QuantizeActivations.

Who uses it

CallerUsesHowSource
io::GgufReaderGgmlTypeIsKnown, block sizesRejects a tensor of unknown type as Unsupported and one whose rows are not whole blocks, and computes each tensor's byte size.gguf.cc L470–483
llm::WeightMatrixblock sizes, DequantizeRowA [rows, cols] view of any ggml type in place; MakeWeightMatrix checks whole blocks and the byte count and dequantizes one probe block, which rejects integer types.kernels.h L22–37, kernels.cc L43–64
llm::MatMul and friendsQuantizeActivations, VecDotWith caller scratch of QuantizedInputBytes, the input is quantized once for every matrix that shares its activation format (q, k and v; gate and up), then each output is one VecDot. Without scratch, or for float weights, each row is dequantized 256 elements at a time and reused for every token.kernels.h L42–60, kernels.cc L93–145
llm::WeightSource::VectorDequantizeRowNorm weights and other vectors, from any stored type to f32.weight_source.cc L199–208

Rounding activations to int8 moves the toy models' logits by about 1e-2 against the float reference, which rml notes is what llama.cpp does too (kernels.md L149–155). The decoder that drives these products is on The decoder, the GGUF reader on Model files, and the threading and fusions of the products on LLM kernels.

Tests

FileTestsWhat they pin down
ggml_type_test.cc2Block elements and bytes for F32, Q4_K and Q6_K; Q8_0 and Q4_0 sizes equal Tensor's dtype storage; names; ids 4 and 99 unknown with 0 bytes, 30 (BF16) known.
dequantize_test.cc7 (one parameterized over 10 types)Every block type dequantizes to gguf-py's values within 1e-6 + 1e-6 |x|; Q8_0 and Q4_0 quantize to gguf-py's bytes exactly; a Q8_0 round trip stays within half a step plus the scale's f16 rounding; F16 and BF16, Q8_K and Q8_1 decode exactly; Dequantize fills a tensor and rejects mismatched shapes; integer types are Unsupported and id 99 InvalidArgument.
dot_test.cc7 (four parameterized over the 5 paired types)Kernel equals reference (EXPECT_FLOAT_EQ) for 1, 2, 3, 8 and 17 blocks; the reference equals the f64 dot of the dequantized rows within 1e-5 of the magnitude; the kernel is within 2% of the magnitude of the true float dot; zero activations give exactly 0; the pairing; Q8_K rounding and group sums; QuantizeActivations size and type errors.

The random weights in dot_test overwrite every f16 scale with a finite value in [0.01, 0.06) so the product is a number (dot_test.cc L28–55). The kernel test runs under ASan and UBSan as well (kernels.md L135–136).