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).
| Item | Signature | Meaning | Source |
|---|---|---|---|
GgmlTypeIsKnown | bool(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 |
GgmlTypeName | string_view(GgmlType) | "Q4_K" and so on; "unknown" otherwise. | .cc L67–70 |
GgmlBlockElems, GgmlBlockBytes | size_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:
| Type | Id | Elements | Bytes | Bits per weight | Dequantize | Quantized dot |
|---|---|---|---|---|---|---|
| F32, F16, BF16 | 0, 1, 30 | 1 | 4, 2, 2 | 32, 16, 16 | yes | no (f32 path) |
| Q4_0 | 2 | 32 | 18 | 4.5 | yes | against Q8_0 |
| Q4_1 | 3 | 32 | 20 | 5 | yes | no |
| Q5_0 | 6 | 32 | 22 | 5.5 | yes | no |
| Q5_1 | 7 | 32 | 24 | 6 | yes | no |
| Q8_0 | 8 | 32 | 34 | 8.5 | yes | against Q8_0 |
| Q8_1 | 9 | 32 | 36 | 9 | yes | no |
| Q2_K | 10 | 256 | 84 | 2.625 | yes | no |
| Q3_K | 11 | 256 | 110 | 3.4375 | yes | no |
| Q4_K | 12 | 256 | 144 | 4.5 | yes | against Q8_K |
| Q5_K | 13 | 256 | 176 | 5.5 | yes | against Q8_K |
| Q6_K | 14 | 256 | 210 | 6.5625 | yes | against Q8_K |
| Q8_K | 15 | 256 | 292 | 9.125 | yes | (the activation side) |
| I8, I16, I32, I64, F64 | 24–28 | 1 | 1, 2, 4, 8, 8 | — | Unsupported | no |
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).
| Block | Fields | Value of element i | Source |
|---|---|---|---|
BlockQ8_0 | Float16 d; int8 qs[32] | d · qs[i] | dtype.h L67–71 |
BlockQ4_0 | Float16 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] after | dtype.h L73–78 |
BlockQ4_1 | d, m; qs[16] | d · q + m, same nibble order | L28–33 |
BlockQ5_0 | d; qh[4]; qs[16] | d · (q − 16), q in [0, 32); bit i of the 32-bit qh is element i's fifth bit | L35–40 |
BlockQ5_1 | d, m; qh[4]; qs[16] | d · q + m, q in [0, 32) | L42–48 |
BlockQ8_1 | d, s; int8 qs[32] | d · qs[i]; s = d · Σq is cached for dot products | L50–55 |
BlockQ2_K | scales[16]; qs[64]; d, dmin | 16 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_K | hmask[32]; qs[64]; scales[12]; d | 16 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 −4 | L66–74 |
BlockQ4_K | d, 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_K | d, dmin; scales[12]; qh[32]; qs[128] | Q4_K plus a fifth bit per element in qh, q in [0, 32) | L85–92 |
BlockQ6_K | ql[128]; qh[64]; int8 scales[16]; d | 16 sub-blocks of 16 with int8 scales: d · scale · (q − 32), the low four bits of q in ql and the high two in qh | L94–101 |
BlockQ8_K | float 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 |
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:
| Helper | Used by | Reads | Source |
|---|---|---|---|
HighBit(qh, j) | Q5_0, Q5_1 | Copies 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_K | Within 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_K | The 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_K | The 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_K | The figure's 64-element chunks. | L130–135 |
inline in Q6_K | Q6_K | Each 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).
| Function | Signature | Behaviour | Source |
|---|---|---|---|
DequantizeRow | Status(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 |
Dequantize | Expected<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.
| Function | Scale | Rounding | Source |
|---|---|---|---|
QuantizeRowQ8_0 | d = max|x| / 127, stored as f16; an all-zero block has d = 0 and inverse 0 | lround(x / d): halves away from zero | dequantize.cc L281–294 |
QuantizeRowQ4_0 | d = xm / −8, where xm is the first value of largest magnitude, sign included | min(15, int(x / d + 8.5)): the argument is never negative, so the truncating cast rounds halves up | L296–317 |
QuantizeRowQ8_K | d = max|x| / 127, kept in f32 | nearbyint(x / d), then each group of 16 q summed into bsums | dot.cc L299–318 |
QuantizeActivations | Dispatches 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 bytes | dot.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:
| x | x · 4 + 8.5 | q | Decoded d · (q − 8) | Error |
|---|---|---|---|---|
| −2.0 | 0.5 | 0 | −2.0 | 0 |
| 1.3 | 13.7 | 13 | 1.25 | 0.05 |
| 2.0 | 16.5 | 15 (clamped from 16) | 1.75 | 0.25 |
| 0.1 | 8.9 | 8 | 0 | 0.1 |
| 0 | 8.5 | 8 | 0 | 0 |
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:
| Weight | Activations | Per block | Kernel |
|---|---|---|---|
| Q8_0 | Q8_0 | w.d · x.d · Σ wi xi over 32 | DotQ8_0 L88–96 |
| Q4_0 | Q8_0 | the 16 bytes unpacked to 32 int8 (q − 8), then as Q8_0 | DotQ4_0 L98–111 |
| Q4_K | Q8_K | x.d · (d · Σj scalej · (q · a)j − dmin · Σj minj · (bsums[2j] + bsums[2j+1])) | SumWithMins, DotQ4_K L124–148 |
| Q5_K | Q8_K | as Q4_K after OR-ing each element's fifth bit in from qh | DotQ5_K L150–166 |
| Q6_K | Q8_K | x.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).
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.
sdotwhere it exists.DotI8<N>usesvdotq_s32when 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 andpmaddwd(L70–86). Thesdotpath is compiled and tested only on rml'smacos-14CI (kernels.md L130–136).- References decode element by element.
VecDotRefcomputes each q on its own, exactly asblocks.hdescribes it, and accumulates the same int32 sums; the K-quant reference sums min · ai one element at a time instead of usingbsums(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 thread | Reference | Kernel |
|---|---|---|
| Q8_0 | 1.2 µs | 1.2 µs |
| Q4_0 | 3.9 µs | 1.3 µs |
| Q4_K | 12.9 µs | 1.1 µs |
| Q5_K | — | 1.4 µs |
| Q6_K | 13.7 µs | 2.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
| Caller | Uses | How | Source |
|---|---|---|---|
io::GgufReader | GgmlTypeIsKnown, block sizes | Rejects 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::WeightMatrix | block sizes, DequantizeRow | A [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 friends | QuantizeActivations, VecDot | With 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::Vector | DequantizeRow | Norm 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
| File | Tests | What they pin down |
|---|---|---|
ggml_type_test.cc | 2 | Block 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.cc | 7 (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.cc | 7 (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).