课 06 我们建立了量化的直觉——"每一小块权重共享一个 scale,块内只存相对差"。这一课我们钻进字节级:打开 ggml/src/ggml-common.h, 看 block_q4_0、block_q4_K 这些块在内存里到底每个字节装了什么,再看解量化函数怎么把这堆字节还原成浮点, 最后看 ggml_type_traits 怎么把几十种量化类型统一接进引擎。
L06 回答的是"为什么能压、压完省多少",这一课回答的是"压完在内存里长什么样、源码怎么把它读回来"—— 两课正好是同一件事的"直觉面"与"实现面"。如果 L06 的块量化直觉你还有点模糊,建议先回头扫一眼;这一课会直接落到结构体的每一个字段上。
先看最经典的两个块。q4_0 把 32 个权重打成一块:开头 2 字节是一个 half(FP16 精度)的 scale,紧跟着 16 字节装下 32 个 4-bit 量化值 (每字节塞两个 nibble)。算下来一块 18 字节。q8_0 同样 32 个权重一块,但每个权重用一整字节的 int8 存、不打包——一块 34 字节 (2 字节 scale + 32 字节量化值),更准也更大。
这些 block 在文件和内存里是一块紧挨着一块、连续排放的:一个权重矩阵就是一长串 block。知道了"一块多少字节",就能算出"第 n 块在哪"——这正是后面解量化时能随机定位、并行处理的基础。 布局规整带来的好处,远不止省空间——它让"按需取一小块来解量化"成为可能,这也是后面"边算边解"能做到的前提。
对应到源码,结构体几乎和上图一一对应(ggml/src/ggml-common.h):
// 简化自 ggml/src/ggml-common.h #define QK4_0 32 typedef struct { ggml_half d; uint8_t qs[QK4_0/2]; } block_q4_0; // 2 + 16 = 18 B typedef struct { ggml_half d; int8_t qs[QK8_0]; } block_q8_0; // 2 + 32 = 34 B
逐个字段看:d 是那个块共享的 scale,ggml_half 就是 2 字节的半精度浮点(L06 说的"基准");qs 是量化值数组。 q4_0 的 qs[QK4_0/2]=qs[16]——32 个值只占 16 字节,因为每个值才 4 bit,两个挤进一个字节(一个放高 nibble、一个放低 nibble)。 q8_0 的 qs[QK8_0]=qs[32]——32 个值占满 32 字节,一个值独占一字节,不用拆位。
为什么 q8_0 更准也更大?因为 4-bit 只能表示 16 个档位、8-bit 能表示 256 个档位,同一个权重,8-bit 能贴得更近,量化误差更小。代价是体积翻倍:每权重从 0.5 字节涨到 1 字节。 这正是 L06 那张"显存对照表"背后的字节级真相——你在命令行里选 Q4_0 还是 Q8_0,本质就是在这两种 block 布局之间二选一。
还有个常被忽略的细节:q4_0 一块 18 字节、装 32 个权重,平均每权重 4.5 bit,而不是正好 4 bit。多出来的 0.5 bit,就是那 2 字节 scale 摊到 32 个权重头上的开销。 块越大,这份"管理开销"摊得越薄——这也为下一节 K-quant 用 256 的大超块埋下了伏笔。
q4_0 每 32 个权重配一个 scale,已经不错了,但还能更准。K-quant(带 K 的格式,如 q4_K、q6_K)的思路是:用一个 256 个权重的"超块", 里面再切成若干个小子块,做成两层 scale——超块给一个"整体基准",每个子块再各自记一个"细调 scale"。这样既摊薄了基准开销,又保住了局部精度。
以 q4_K 为例:256 个权重切成 8 个子块(每子块 32 个权重)。超块层有 d 和 dmin 两个 half;子块层有 8 组 6-bit 的 scale/min, 打包进 12 字节的 scales[];再加 128 字节装 256 个 4-bit 量化值。
那 12 字节的 scales[] 是怎么来的?8 个子块、每块要存一个 scale 和一个 min,本可以各用一字节、共 16 字节;但 K-quant 把它们再压成 6-bit, 8×(6+6)=96 bit=12 字节,又省下 4 字节。连"管理用的 scale"本身都要量化——这种把每一处冗余都榨干的抠门劲,正是量化格式设计的日常。
两层 scale 是 K-quant 的灵魂。第一层(超块的 d/dmin)定个大致范围;第二层(子块的 6-bit scale/min)在这个范围里做局部微调—— 哪一小段权重偏大偏小,子块 scale 立刻跟上。这就是 L06 spark 说的"同样的 bit 数,K-quant 往往更准"的字节级来源:精度不是靠多花 bit,而是靠更聪明地分配 scale。
// 简化自 ggml/src/ggml-common.h(QK_K = 256, K_SCALE_SIZE = 12) typedef struct { ggml_half d; // 超块整体 scale ggml_half dmin; // 超块整体 min uint8_t scales[K_SCALE_SIZE];// 8 个子块的 6-bit scale/min(无 qh) uint8_t qs[QK_K/2]; // 256 个 4-bit 量化值 } block_q4_K;
把 q4_K 的字节数也算一下:2(d)+ 2(dmin)+ 12(scales)+ 128(qs)= 144 字节装 256 个权重,平均每权重 144×8/256 = 4.5 bit——和 q4_0 一样的位宽,精度却更高。 这就是"两层 scale"最直接的回报:没多花一个 bit,纯靠结构更精巧把误差压了下去。
顺带认识一下整个 K-quant 家族:从 q2_K、q3_K 一直到 q6_K,名字里的数字是大致的位宽,K 则代表都用"超块 + 两层 scale"这套结构。 位宽越低(如 q2_K)压得越狠、精度越险,往往只敢用在不那么敏感的层上;位宽越高(如 q6_K)越接近原始精度。这也是为什么实际下载模型时,你会看到 Q4_K_M、Q5_K_S 这种带后缀的名字—— 它们是把不同层用不同 K-quant 档位混搭出来的方案,在体积和质量之间取不同的平衡点。
为什么超块要选 256 这么大?因为超块越大,"整体 d/dmin"这份固定开销摊到的权重越多、每权重的开销越小;而局部精度由更细的子块 scale 兜底,不会因为块大而变糊。 大块负责"高压缩"、子块负责"高精度",两者兼得——这就是 K-quant 在相同位宽下能比 q4_0 更准的设计动机(详见深挖 1)。
权重以量化字节存着,可真要参与计算(比如喂进 L11 的 mul_mat),得先还原成浮点。这一步叫解量化(dequantize),由每种格式各自的 dequantize_row_* 函数负责 (在 ggml/src/ggml-quants.c)。以 q4_0 为例,逻辑出奇地简单:
// 伪代码, 对应 ggml/src/ggml-quants.c 的 dequantize_row_q4_0 for each block: d = half_to_float(block.d) // 取回这块的 scale for j in 0..15: // 每字节两个 nibble q0 = (block.qs[j] & 0x0F) - 8 // 低 4 位, 减 8 回到有符号 q1 = (block.qs[j] >> 4) - 8 // 高 4 位, 减 8 回到有符号 y[j] = q0 * d // x = (q - 8) * d y[j + 16] = q1 * d
拿一个真实的块走一遍,上面这条链路就具体了:
核心就一行:x = (q - 8) * d。q 是那个 0..15 的 4-bit 量化值,减 8 把它平移成 -8..7 的有符号数,再乘上这块的 scale d, 就还原出近似的原始浮点。q8_0 更直接:x = q * d,因为 int8 本身就是有符号的,不用减偏移。
反过来,量化(把浮点压成字节)就是解量化的逆运算。q4_0 的参考实现 quantize_row_q4_0_ref 里,scale 取 d = max / -8——找出这块绝对值最大的权重, 让它对应到量化区间的端点(-8),其余权重按比例缩放取整。除以 -8 而不是 8 是个容易看走眼的细节:它和解量化时的"减 8"配套,保证一来一回数值对得上。
还有一点值得记住:解量化不一定提前一次性把整个权重张量铺开成浮点(那会占很多内存)。在矩阵乘这种热点里,ggml 常常是边算边解——在内层循环里即时把用到的那一小块还原成浮点、立刻参与点积, 算完就丢。所以量化省的不只是显存,连"解压后的浮点"也大多不落地,带宽和缓存都跟着受益(呼应 L06 说的"省显存又提速")。
问题来了:q4_0、q8_0、q4_K、q6_K…… 几十种量化格式,字节布局各不相同,难道每种都要在 mul_mat、解量化里写一遍特判?当然不。ggml 用一张 类型特征表(ggml_type_traits) 把每种类型的"基本参数 + 怎么转 float"登记成一行,算子只管查表,不关心具体是哪种量化。
张量只在 type 字段记着"我是 Q4_K"(L05 的 type 字段,到这里终于派上大用场)。算子要用它时,拿这个 type 去查 type_traits 表,就能拿到"这种类型一块多少元素、多少字节、用哪个函数转 float", 照着做即可——算子代码里没有一个 if 在判断量化类型。
// 简化自 ggml/include/ggml.h(每种 type 登记一行) struct ggml_type_traits { const char * type_name; // "q4_K" int64_t blck_size; // 一块多少元素, 如 256 size_t type_size; // 一块多少字节, 如 144 bool is_quantized; ggml_to_float_t to_float; // 解量化函数指针 ggml_from_float_t from_float_ref; // 量化函数指针(参考实现) };
关键就在那两个函数指针:to_float 指向这种类型的解量化函数,from_float_ref 指向量化(参考)函数。算子拿到张量,按 traits 调 to_float 解量化、再计算; 要存盘时按 from_float_ref 量化。注意字段名是 from_float_ref(带 _ref,参考实现),别记成 from_float。于是加一种新量化格式,主要工作就是填一行 traits + 写好这两个函数, mul_mat、解量化那些算子代码基本不用动——这正是 L05 "结构不变、类型可换"在量化上的兑现。
退一步看,这一课其实把"量化"这件事讲全了三个层次:布局(block 里每个字节装什么)、还原(解量化怎么把字节变回浮点)、接入(traits 表怎么让算子统一处理)。 这三层正好对应你用 llama.cpp 时会碰到的三种场景:挑量化档位(布局决定体积与精度)、跑推理(解量化在背后默默进行)、以及读懂源码(traits 是所有量化类型的总入口)。 把这三层串起来,你就不再把 Q4_K_M 这种名字当成黑盒,而能说清它在内存里到底是什么、算的时候发生了什么。把一个量化名字拆解到字节这一层,你对"模型怎么落到磁盘和内存里"的理解,就又扎实了一截。
核心是摊薄固定开销 + 不牺牲局部精度。q4_0 每 32 个权重就得花 2 字节存一个 scale;超块做到 256,"整体 d/dmin"这份固定开销摊到的权重多了 8 倍,每权重的管理开销明显下降。
但只把块放大、还用单层 scale 的话,局部精度会变差——256 个权重共享一个 scale,太粗了。K-quant 的解法是再加一层子块 scale:超块定大范围、子块定细节,于是"块大带来的高压缩"和"子块带来的高精度"同时拿到。
这也解释了为什么不无脑把超块做到更大(比如 1024):子块 scale 本身也要占空间,块太大、子块太多,第二层开销又上来了。256 + 8 子块是工程上压缩率与精度的一个甜点,经过大量实测调出来的。
q4_K 没有 qh。它的字段只有四个:d、dmin、scales[12]、qs[128]。4-bit 量化值正好一个 nibble,直接打包进 qs,不需要再拆高低位。
q6_K 才有 qh。6-bit 一个值放不进一个 nibble,于是拆开:低 4 位存进 ql、高 2 位存进 qh,再加 16 个 8-bit 的子块 scale 和一个超块 d。 所以 q6_K 的结构和 q4_K 长得很不一样。
这是初学者最容易栽的坑之一:以为所有 K-quant 字段都一样、把 q4_K 想象成"带 qh"。读这些结构体时一定对着源码逐字段核对,别凭格式名想当然——位宽不同、打包方式就不同,字段自然不同。
靠 type_traits 里的 to_float / from_float_ref 这两个函数指针。算子拿到一个量化张量,不去判断"这是 q4_0 还是 q4_K",而是直接调它 traits 里登记的 to_float 解量化,再算。
在 mul_mat 这种热点里更讲究:往往不是先整块解量化、再乘,而是在内层循环里即时解一小段、立刻点积,甚至为某些量化类型配了专门的高速点积内核。但对外暴露的接口是统一的——都是"按 traits 拿到怎么转 float"。
结果就是:加一种新量化类型,算子代码一行都不用改,只要在 traits 表里填一行、写好解/量化函数。这种"用函数指针把差异收进一张表"的做法,正是 ggml 能容纳几十种量化格式还不臃肿的关键,和 L11 "switch(op) 派发算子"是同一种解耦思路。
Lesson 06 built the intuition of quantization - "each small block of weights shares one scale, and stores only relative offsets inside the block". This lesson goes down to the byte level: we open ggml/src/ggml-common.h to see what every byte of blocks like block_q4_0 and block_q4_K actually holds, then how the dequantize functions restore those bytes back to floats, and finally how ggml_type_traits wires dozens of quantization types uniformly into the engine.
L06 answered "why we can compress, and how much it saves"; this lesson answers "what it looks like in memory afterward, and how the source reads it back" - the two are the "intuition side" and the "implementation side" of one thing. If L06's block-quantization intuition is still fuzzy, glance back first; this lesson lands directly on every struct field.
Start with the two most classic blocks. q4_0 packs 32 weights into a block: the first 2 bytes are a half (FP16) scale, followed by 16 bytes holding 32 4-bit quantized values (two nibbles per byte). That is 18 bytes a block. q8_0 also groups 32 weights, but stores each as a full byte of int8, unpacked - 34 bytes a block (2-byte scale + 32-byte values), more accurate and larger.
These blocks are laid out one right after another, contiguously in file and memory: a weight matrix is just a long run of blocks. Knowing "how many bytes per block" lets you compute "where block n is" - exactly the basis for random addressing and parallel processing during dequantization later. A regular layout brings benefits far beyond saving space - it makes "fetch one small block on demand and dequantize it" possible, the very precondition for the "dequantize on the fly" mentioned earlier.
In source, the struct matches the figure almost one-to-one (ggml/src/ggml-common.h):
// simplified from ggml/src/ggml-common.h #define QK4_0 32 typedef struct { ggml_half d; uint8_t qs[QK4_0/2]; } block_q4_0; // 2 + 16 = 18 B typedef struct { ggml_half d; int8_t qs[QK8_0]; } block_q8_0; // 2 + 32 = 34 B
Field by field: d is the block's shared scale, and ggml_half is exactly a 2-byte half-precision float (L06's "baseline"); qs is the array of quantized values. q4_0's qs[QK4_0/2]=qs[16] - 32 values in only 16 bytes, because each value is just 4 bits, two squeezed into one byte (one high nibble, one low). q8_0's qs[QK8_0]=qs[32] - 32 values filling 32 bytes, one value per byte, no bit-splitting.
Why is q8_0 both more accurate and larger? Because 4 bits can represent only 16 levels while 8 bits represent 256 levels, so for the same weight 8-bit can sit much closer, with smaller quantization error. The cost is double the size: from 0.5 byte per weight to 1 byte. This is the byte-level truth behind L06's "VRAM table" - choosing Q4_0 vs Q8_0 on the command line is, in essence, picking between these two block layouts.
One often-missed detail: a q4_0 block is 18 bytes for 32 weights, averaging 4.5 bits per weight, not exactly 4. The extra 0.5 bit is the 2-byte scale amortized across 32 weights. The bigger the block, the thinner this "management overhead" spreads - which already foreshadows why the next section's K-quant uses a big 256-weight super-block.
q4_0's one scale per 32 weights is already decent, but it can be more accurate. The idea of K-quant (the K formats, such as q4_K and q6_K) is to use a 256-weight "super-block", sliced into several sub-blocks, with a two-level scale - the super-block gives an "overall baseline", and each sub-block records its own "fine-tuning scale". This both thins the baseline overhead and preserves local precision.
Take q4_K: 256 weights split into 8 sub-blocks (32 weights each). The super-block level has two halves, d and dmin; the sub-block level has 8 groups of 6-bit scale/min packed into a 12-byte scales[]; plus 128 bytes holding 256 4-bit quantized values.
Where does the 12-byte scales[] come from? 8 sub-blocks, each needing a scale and a min, could use one byte each for 16 bytes total; but K-quant squeezes them to 6 bits, 8x(6+6)=96 bits=12 bytes, saving another 4 bytes. Even the "management" scales themselves are quantized - this squeezing of every last bit of redundancy is the daily business of quantization-format design.
The two-level scale is the soul of K-quant. The first level (the super-block's d/dmin) sets a rough range; the second level (the sub-block's 6-bit scale/min) fine-tunes locally within that range - wherever a small run of weights skews high or low, the sub-block scale follows immediately. This is the byte-level source of L06's spark "for the same bit count, K-quant is usually more accurate": precision comes not from spending more bits, but from allocating scale more cleverly.
// simplified from ggml/src/ggml-common.h (QK_K = 256, K_SCALE_SIZE = 12) typedef struct { ggml_half d; // super-block overall scale ggml_half dmin; // super-block overall min uint8_t scales[K_SCALE_SIZE];// 8 sub-blocks' 6-bit scale/min (no qh) uint8_t qs[QK_K/2]; // 256 4-bit quantized values } block_q4_K;
Compute q4_K's byte count too: 2 (d) + 2 (dmin) + 12 (scales) + 128 (qs) = 144 bytes for 256 weights, averaging 144*8/256 = 4.5 bits per weight - the same bit width as q4_0, yet higher precision. That is the most direct payoff of the "two-level scale": not one extra bit spent, error pushed down purely by a smarter structure.
Worth meeting the whole K-quant family: from q2_K and q3_K up to q6_K, the number in the name is the rough bit width, while K means they all use this "super-block + two-level scale" structure. The lower the bit width (like q2_K) the harder the compression and the riskier the precision, so it is often used only on less sensitive layers; the higher (like q6_K) the closer to original precision. This is why, downloading real models, you see suffixed names like Q4_K_M and Q5_K_S - schemes that mix different K-quant levels across different layers, striking different balances between size and quality.
Why pick a super-block as big as 256? Because the bigger the super-block, the more weights the fixed "overall d/dmin" overhead spreads across, and the smaller the per-weight overhead; meanwhile local precision is backstopped by the finer sub-block scales, so a big block does not get blurry. The big block handles "high compression", the sub-blocks handle "high precision", and you get both - this is the design motive for K-quant being more accurate than q4_0 at the same bit width (see Dig deeper 1).
Weights sit in memory as quantized bytes, but to actually take part in computation (say, fed into L11's mul_mat) they must first be restored to floats. That step is dequantization, handled by each format's own dequantize_row_* function (in ggml/src/ggml-quants.c). For q4_0 the logic is surprisingly simple:
// pseudocode, mirrors dequantize_row_q4_0 in ggml/src/ggml-quants.c for each block: d = half_to_float(block.d) // fetch this block's scale for j in 0..15: // two nibbles per byte q0 = (block.qs[j] & 0x0F) - 8 // low 4 bits, minus 8 back to signed q1 = (block.qs[j] >> 4) - 8 // high 4 bits, minus 8 back to signed y[j] = q0 * d // x = (q - 8) * d y[j + 16] = q1 * d
Run one real block through it and the chain above gets concrete:
The core is one line: x = (q - 8) * d. q is the 0..15 4-bit value; subtracting 8 shifts it to a signed -8..7, then multiplying by this block's scale d restores the approximate original float. q8_0 is even more direct: x = q * d, since int8 is already signed and needs no offset.
In reverse, quantization (compressing floats to bytes) is the inverse. In q4_0's reference implementation quantize_row_q4_0_ref, the scale is d = max / -8 - find the weight with the largest magnitude in the block, map it to the endpoint of the quantization range (-8), and scale-and-round the rest proportionally. Dividing by -8 rather than 8 is an easy-to-misread detail: it pairs with the "minus 8" at dequantize time, ensuring the round trip lines up numerically.
One more thing worth remembering: dequantization does not necessarily expand the whole weight tensor to floats up front (that would cost a lot of memory). In hotspots like matmul, ggml often dequantizes on the fly - restoring just the small block it needs in the inner loop, feeding it into the dot product immediately, and discarding it. So quantization saves not only VRAM; even the "decompressed floats" mostly never land, benefiting bandwidth and cache too (echoing L06's "saves VRAM and speeds up").
Here is the problem: q4_0, q8_0, q4_K, q6_K... dozens of quantization formats, each with a different byte layout - must every one be special-cased inside mul_mat and dequantize? Of course not. ggml uses a type-traits table (ggml_type_traits) to register each type as one row of "basic parameters + how to convert to float", and operators just look it up, never caring which quantization it actually is.
A tensor only records "I am Q4_K" in its type field (L05's type field finally earns its keep here). When an operator needs it, it looks that type up in the type_traits table to get "how many elements per block, how many bytes, which function converts to float" and just follows it - there is not a single if in the operator code branching on quantization type.
// simplified from ggml/include/ggml.h (one row per type) struct ggml_type_traits { const char * type_name; // "q4_K" int64_t blck_size; // elements per block, e.g. 256 size_t type_size; // bytes per block, e.g. 144 bool is_quantized; ggml_to_float_t to_float; // dequantize function pointer ggml_from_float_t from_float_ref; // quantize function pointer (reference) };
The key lies in those two function pointers: to_float points to this type's dequantize function, from_float_ref to its (reference) quantize function. An operator takes a tensor, calls to_float per its traits to dequantize, then computes; when saving, it quantizes via from_float_ref. Note the field name is from_float_ref (with _ref, the reference implementation), not from_float. So adding a new quantization format is mainly filling one traits row + writing these two functions, while mul_mat and the dequantize operators need almost no change - exactly L05's "structure fixed, type swappable" cashed out for quantization.
Stepping back, this lesson actually covers "quantization" at three levels: layout (what each byte in a block holds), restoration (how dequantize turns bytes back into floats), and wiring (how the traits table lets operators handle everything uniformly). These three map neatly onto the three situations you meet using llama.cpp: picking a quantization level (layout decides size and precision), running inference (dequantization happens quietly behind the scenes), and reading the source (traits is the single entry point for all quantization types). String the three together and you stop treating a name like Q4_K_M as a black box - you can say exactly what it is in memory and what happens when it computes. Breaking a quantization name down to the byte level makes your grasp of "how a model lands on disk and in memory" that much more solid.
The core is amortizing fixed overhead without sacrificing local precision. q4_0 spends 2 bytes on a scale for every 32 weights; bringing the super-block to 256 spreads the fixed "overall d/dmin" overhead over 8x as many weights, clearly lowering per-weight management cost.
But simply enlarging the block with a single-level scale would hurt local precision - 256 weights sharing one scale is too coarse. K-quant's answer is to add a second sub-block scale: the super-block sets the broad range, the sub-blocks set details, so you get both "high compression from the big block" and "high precision from the sub-blocks".
This also explains why we do not blindly make the super-block even bigger (say 1024): sub-block scales themselves take space, and too big a block with too many sub-blocks raises the second-level overhead again. 256 + 8 sub-blocks is an engineering sweet spot between compression ratio and precision, tuned through extensive measurement.
q4_K has no qh. It has only four fields: d, dmin, scales[12], qs[128]. A 4-bit value is exactly one nibble, packed straight into qs, with no need to split high and low bits.
q6_K is the one that has qh. A 6-bit value does not fit in one nibble, so it is split: the low 4 bits go into ql, the high 2 bits into qh, plus 16 8-bit sub-block scales and one super-block d. So q6_K's struct looks quite different from q4_K's.
This is one of the most common beginner traps: assuming all K-quant fields are identical and imagining q4_K "with a qh". When reading these structs, always check field by field against the source, never guessing from the format name - different bit widths mean different packing, and thus different fields.
Through the two function pointers to_float / from_float_ref in type_traits. Given a quantized tensor, an operator does not test "is this q4_0 or q4_K"; it directly calls the to_float registered in its traits to dequantize, then computes.
In hotspots like mul_mat it is subtler: rather than dequantizing the whole block first and then multiplying, it often dequantizes a small run on the fly and dot-products immediately, even with dedicated fast dot-product kernels for certain quantization types. But the exposed interface is uniform - all "get how to convert to float from traits".
The result: adding a new quantization type needs not a single line changed in operator code, only one traits row plus the dequant/quant functions. This "fold the differences into a table of function pointers" approach is the key to ggml hosting dozens of quantization formats without bloat - the same decoupling idea as L11's "switch(op) dispatch".