量化块按 float 遍历:C++ 习惯为什么全错
版权与内容来源声明
本文为原创整理。文中涉及官方文档、开源仓库、论文与公开报道的内容,均在附表 A 中标注来源;引用官方原文保持原样,不作改写。文中命令、版本号与界面截图以本文成文时的实测/核验结果为准,标注「待验证」的部分请以你本地环境实际输出为判断依据。本文不推荐任何不合规的软件获取方式,也不对任何收益结果作承诺。转载请注明出处。
这篇只回答一个问题:一个写了多年 C++ 的人,把「连续内存 + 按下标遍历 + 自己 memcpy」这套习惯搬到 llama.cpp 的量化权重上,为什么第一次读就拿到 -3.85e31 和 nan 这种数。读完你能判断三件事:手上的代码该不该按 float 读、显存估算该按几个 bit 算、自己写反量化时先把哪一行钉死。
一、一个块 144 字节,装的却是 256 个权重
1.1 先看清这个结构体
block_q4_K 是 ggml 里 4bit K-quant 的最小打包单位。人话解释:量化块就是「把一批浮点权重压进一小段定长字节」的容器,读的时候必须整块读出来再整块解开,不能从中间取一个元素;K-quant 则是这套分级量化的名字——一个超级块管 256 个权重,里面再切成 8 个子块,scale 分两级给。它的字段在 ggml/src/ggml-common.h 第 327 到 337 行:
偏移 0..1 d ggml_half 超级块的 scale(这一整块的缩放基准)
偏移 2..3 dmin ggml_half 超级块的 min 偏移
偏移 4..15 scales 12 字节 8 个 6bit 的 scale + 8 个 6bit 的 min
偏移 16..143 qs 128 字节 256 个 4bit 量化值
合计 144 字节 = 256 个权重
ggml_half 是 2 字节的半精度浮点,源码里就是 typedef uint16_t ggml_half(同一文件第 6 行,C 编译路径)。它不是 float,也不是 bf16——这一点后面会让一段代码直接读错。
还有一处容易忽略:qs 里那 256 个 4bit 值是无符号的 0 到 15,它本身不是权重,要靠子块的 scale 乘、min 减才能还原;而 q4_0 里的 4bit 是减 8 之后再乘 scale 的对称形式。同样是「4bit」,一个有偏置、一个无偏置,公式抄错就是整体偏移。
| 偏移(字节) | 长度 | 字段 | 里面到底是什么 |
|---|---|---|---|
| 0–1 | 2 | d | 超级块 scale,半精度存 |
| 2–3 | 2 | dmin | 超级块 min 偏移,半精度存 |
| 4–15 | 12 | scales | 8 个 6bit scale + 8 个 6bit min,共 96 bit |
| 16–143 | 128 | qs | 256 个 4bit 量化值,一个字节装两个 |
| 合计 | 144 | — | 对应 256 个权重,4.5 bit/权重 |
为什么一个块是 256 个权重,而不是 32 个或 1024 个?这是两头挤出来的:块越小,块头和 scales 的固定开销摊不开,每权重的位宽就越高;块越大,块内 256 个权重的分布差异越大,一个 scale 管不过来,精度掉得厉害。q4_0 选了 32 的块、只用一个 scale,q4_K 选了 256 的超级块、再切成 8 个 32 的子块各给一组 scale,都是在同一个取舍上站了不同的位置。
1.2 编译器替你把尺寸钉死了
结构体定义下面紧跟着一行断言(第 338 行):
⚠️ 代码待验证(本机没有 C++ 工具链,这段是从源码原样摘录,未编译)
static_assert(sizeof(block_q4_K) == 2*sizeof(ggml_half) + K_SCALE_SIZE + QK_K/2, "wrong q4_K block size/padding");
static_assert 是编译期断言:条件不成立就直接编译失败,不生成二进制。人话解释:这行代码的意思是「这个块只能是 144 字节,多一个填充字节都不许」。QK_K 是 256(第 89 行),K_SCALE_SIZE 是 12(第 90 行)。
这条断言的价值在于它把「结构不能按 float 读」变成了编译期事实,而不是一句经验。你哪天在结构体里加个字段、或者换个平台让对齐规则变了导致编译器插入填充字节,这行断言会先炸,不会等到线上输出变成乱码才发现。
想自己核一遍,直接用下面的命令把这个文件抓到本地。raw.githubusercontent.com 直连在本机会超时,走 ghproxy 前缀返回 200:
curl -s -o ggml-common.h -w "%{http_code} %{size_download}\n" \
"https://ghproxy.net/https://raw.githubusercontent.com/ggml-org/llama.cpp/master/ggml/src/ggml-common.h"
# 实测输出:200 135161
grep -n "QK_K 256\|K_SCALE_SIZE\|} block_q4_K" ggml-common.h
1.3 4.5 bit 是算出来的,不是估出来的
144 字节 × 8 = 1152 bit,除以 256 个权重,正好 4.5。源码注释也这么写(第 326 行:Effectively 4.5 bits per weight)。
拆开看更清楚:144 = 128 + 16。128 字节是纯粹的 4bit 量化值,16 字节是固定开销(块头 4 字节 + scales 12 字节)。16 字节 = 128 bit,摊到 256 个权重上刚好 0.5 bit。这 0.5 bit 不是浪费,是「每 32 个权重一组、每组一个 scale 一个 min」这个设计必须付的账。
同一族的其他类型把这个账算得更直白:q3_K 是 110 字节(3.4375 bit,第 314 行注释),q5_K 是 176 字节(5.5 bit,第 343 行),q6_K 是 210 字节(6.5625 bit,第 361 行)。q4_K 和 q5_K 的固定开销都是 16 字节、都是 0.5 bit,因为它们结构相同、只差一个 32 字节的高位平面。看到「5.5 bit」这种带小数的数,不用怀疑,那是固定开销摊出来的。
老一代的单块类型也是同一规律:q8_0 是 34 字节 32 个权重,合 8.5 bit(第 256 行断言),块头 2 字节摊到 32 个权重正好 0.5 bit;q3_K 的固定开销是 14 字节(110 − 96),摊到 256 个权重才是 0.4375 bit。名义位宽加上这一档,才是该拿去算显存的数。
反过来看,一个块按 float 算只有 36 个位置(144 ÷ 4)。你要 256 个值,就得读 1024 字节——是块本身长度的 7.1 倍。这不是「读得慢一点」的问题,是从第 37 个下标起就在读别人的内存。
二、C++ 的三个默认习惯,在这里逐条失效
2.1 习惯一:数组元素按固定步长排
C++ 里 float* p 的第 i 个元素在 4*i 字节处,步长恒为 4。这套心智模型来自「元素大小相同且连续排列」。
量化块里不成立。qs 那 128 字节的组织方式是:第 0 到 31 字节,低半字节(低 4 位)放权重 0 到 31,高半字节放权重 32 到 63;第 32 到 63 字节管权重 64 到 127,依此类推。也就是说,权重 0 和权重 32 挤在同一个字节的两半里,而权重 31 和权重 32 隔着 31 个字节。
手推一遍第 100 个权重在哪:它在第 3 个子块(96 到 127)里,是子块内下标 4 的那个。第 3 个子块属于第 2 组 64 个元素(64 到 127),这组占 qs 的第 32 到 63 字节。所以落在块内偏移 16 + 32 + 4 = 52 这个字节,取高半字节。同一位置的低半字节是第 68 个权重。这种「两个下标共用一个字节、取哪半由子块序号的奇偶决定」的关系,任何按下标乘 4 的公式都推不出来。
2.2 习惯二:读不出来就 memcpy 到 float 数组
这是最常见的一步错棋。memcpy 只搬字节,不改变编码。源端是「半精度标量 + 6bit 打包 + 4bit 打包」,目标端是 IEEE754 单精度,两边对同一串 bit 的解释完全不同。
⚠️ 代码待验证(本机没有 C++ 工具链,下面这段是错误写法的示意,未编译)
// 典型错法:把块当 float 数组,按下标乘 4 去读
const float* p = reinterpret_cast<const float*>(blocks);
for (int i = 0; i < n; ++i) {
sum += p[i] * v[i]; // p[i] 落在 4*i 字节处,块内根本没有这个元素
}
本机脚本把这个错法复现了一遍:拿一个真实的 144 字节块,按 4 字节步长读 float32,和真值对比。
| 下标 | 真值 | 按 float32 硬读 |
|---|---|---|
| 0 | +0.260000 | +0.000000 |
| 1 | +0.553910 | -3.850e+31 |
| 2 | +0.802490 | -2.475e+27 |
| 3 | +0.971139 | nan |
| 4 | +1.035448 | +2.740e+38 |
| 5 | +0.984527 | -0.023277 |
块内能读到的 36 个下标里,误差在 1e-3 以内的个数是 0。下标 0 读到 +0 是因为前两个字节恰好是半精度的 0x1945,按 float32 解释落在极小指数区间;下标 3 那个 nan 也不是意外——float32 的指数段 8 位全 1 且尾数非 0 就定义为 nan,而 scales 区里连续几个 0xFF 很容易凑出这个模式;下标 5 那个 -0.023 看着像个数,其实只是指数位恰好凑成了小数值,和原权重没有任何关系。这就是为什么这种错法最坑:它偶尔会吐出一个「看起来正常」的数,让你以为只是精度差。
2.3 习惯三:对齐想到 4 或 8 就够了
ggml/include/ggml.h 第 233 到 243 行给了三个分支:指针宽度 32 位时 GGML_MEM_ALIGN 是 4,emscripten 下是 8,其余情况是 16(截至 2026-11-07 的 master 分支)。人话解释:GGML_MEM_ALIGN 是 ggml 分配张量内存时要求的最小对齐字节数,所有缓冲区起点必须是它的整数倍。
ggml_nbytes_pad()(ggml/src/ggml.c 第 1340 行)就是按这个值向上补齐:
⚠️ 代码待验证(摘录自源码,本机未编译)
size_t ggml_nbytes_pad(const struct ggml_tensor * tensor) {
return GGML_PAD(ggml_nbytes(tensor), GGML_MEM_ALIGN);
}
GGML_PAD 的定义在 ggml.h 第 267 行,是标准的向上取整到 2 的幂。对齐之所以重要,是因为 SIMD 指令按 lane 并行:一条 16 字节的向量载入固定读 4 个 float,也就是 4 个 lane,起点不在 16 的倍数上就得拆成两次载入再拼接。取 16 而不是 8,是因为 SSE 寄存器就是 16 字节宽、AVX2 是 32 字节;16 是能覆盖所有平台的保守下界,AVX-512 的 64 字节不能当默认前提。
补齐不是洁癖:一块 144 字节,144 = 16×9,本身是 16 的倍数;但 block_q4_0 是 18 字节,一行只有一个块时要补到 32 字节,多出 14 字节。你若自己按 n*18 算缓冲区长度再按 16 对齐去读,行首就错位了。
| C++ 里的默认习惯 | 在这套结构上的现实 | 直接后果 |
|---|---|---|
| 元素等长、步长固定 | 一个字节装两个权重,步长不固定 | 下标算错或越界 |
sizeof(float) == 4 | ggml_half 是 2 字节 | 偏移全部翻倍 |
读不出就 memcpy 成 float | 源端不是 IEEE754 | 解出 1e38 与 nan |
| 对齐按 4 或 8 考虑 | 默认 16,且行尾要补齐 | 非对齐载入,SIMD 变慢 |
std::vector 拷贝一份再算 | 块是定长结构,按行连续 | 拷贝破坏块边界 |
三、模型的五条建议,逐条错在不同的层
把上面那段结构体贴给模型,问「在 C++ 里怎么读这 256 个权重」,常见的回答模式是这样:4bit 就是每权重 4 bit、用 float* 强转按下标访问、memcpy 到 float 数组、按 256 个元素一组写循环、对齐到 8 字节就够。五条建议听起来都合理,但错在五个不同的层。
它们的共同点值得先说:这些建议都是按「常见张量格式」的统计规律给出的,而不是按你贴过去的那 144 个字节给出的。 模型不会去数 scales 为什么是 12 字节。
3.1 第一条错在算术:4bit 不等于 4.0 bit
「4bit 量化」指的是量化值的位宽,不是每权重的平均占用。块头 4 字节加 scales 12 字节是固定开销,摊下来每权重 4.5 bit。
这个差别不决定「放不放得下」,决定的是上下文和并发还剩多少。7.6155B 参数按 4.0 bit 算是 3807750000 字节(3.5462 GiB),按 4.5 bit 算是 4283718750 字节(3.9895 GiB),前者比后者少 11.11%。
举个具体的账:以 32 层、8 个 KV 头、头维 128 的 7B 级配置为例,KV Cache(把历史 token 的 K/V 缓存下来,免得每生成一个词都重算前面)每 token 约 128 KiB(2 × 32 × 8 × 128 × 2 字节),一路 32k 上下文就是 32768 × 128 KiB = 4 GiB。0.4433 GiB 折合 3632 个 token,也就是 0.11 路 32k 会话——补不满一路,但足够让「刚好算满」的方案崩掉。
这条错不会在加载时报错,只在你把预算算到贴着上限时显形:权重 3.9895 GiB 加 5 路 32k 会话正好 23.99 GiB,按 4.5 bit 算你知道只剩 0.01 GiB;按 4.0 bit 算会以为还剩 0.45 GiB,顺手把上下文再拉长三千多个 token,一拉就超。
3.2 第二、三条错在层级:块内还有一层
「用 float* 强转」和「memcpy 到 float 数组」是同一个错误的两种写法,都假设「块内 256 个元素是平的」。实际块内是 8 个 32 元素的子块,每个子块有自己的 scale 和 min,两个 6bit 值压在 scales 的 12 字节里。
模型给的建议里通常没有「8 个子块」这一层。少了这一层,你按 256 个元素写循环时,前 32 个用第一组 scale 是对的,第 33 个开始就取错了 scale——误差不是随机的,是按 32 个一档阶梯式偏移,看起来「有点像原值但整体偏了」。这种错最难查,因为它不像 nan 那样立刻暴露。
「按 256 个元素一组自己写循环」还有第二个问题:qs 的读取指针每处理 64 个元素才前进 32 字节,不是每 32 个元素前进 32 字节。反量化函数里那个 q += 32 写在内层循环之外,位置很关键;照抄成每 32 个元素前进 32 字节,第二轮开始就整体错位。
3.3 第四、五条错在平台与类型:对齐和可互换性
「对齐到 8 字节够了」在 32 位平台成立(GGML_MEM_ALIGN 是 4),在 emscripten 下是 8,其余平台是 16。你在 x86-64 上按 8 对齐分配缓冲区,编译器不会报错,但向量化载入会退化成多次标量读,或者在对齐指令上触发异常。这条错的症状是「慢」,不是「错」,所以最容易被当成「量化本来就慢」放过去。
「q4_0 和 q4_K 都是 4bit,可以互换」是最贵的一条。两者位宽确实一样:block_q4_0 是 18 字节 32 个权重,18×8÷32 也是 4.5 bit。但布局和公式都不同——q4_0 只有一个 scale、没有 min,反量化是 (nibble - 8) * d,两个半字节分别对应下标 j 和 j+16;q4_K 是 d*sc*q - dmin*m,两个半字节对应相邻的两个 32 元素子块。同样 256 个权重、同样 144 字节,一个 q4_K 块和 8 个 q4_0 块尺寸一模一样,字节含义没有一个位置相同。
| 模型给的建议 | 错在哪一层 | 后果 |
|---|---|---|
| 「4bit 就是每权重 4 bit」 | 把固定开销当零 | 7.6155B 少算 0.4433 GiB |
「float* 强转后按下标访问」 | 段内不是 IEEE754 | 36 个下标里 0 个落在 1e-3 内 |
「memcpy 到 float 数组再算」 | 拷贝不改变编码 | 同上,还多一次全量拷贝 |
| 「按 256 个元素一组写循环」 | 少一层:8 个 32 元素子块 | scale 取错,整段阶梯偏移 |
| 「对齐到 8 字节够了」 | 默认是 16 | 向量化载入退化 |
| 「q4_0 与 q4_K 可互换」 | 布局与公式都不同 | 读出乱码 |
四、12 字节里塞进 16 个数
4.1 6bit 是怎么切的
ggml/src/ggml-quants.c 第 889 行的 get_scale_min_k4 只有五行,但它是整件事的核心。下面这段是我按它的分支逻辑用 Python 重写的等价实现,连同它的逆过程一起:
def get_scale_min_k4(j, q): # q 是那 12 个字节
if j < 4:
d = q[j] & 63
m = q[j + 4] & 63
else:
d = (q[j + 4] & 0xF) | ((q[j - 4] >> 6) << 4)
m = (q[j + 4] >> 4) | ((q[j] >> 6) << 4)
return d, m
def put_scale_min_k4(sc, mn): # 逆过程:8 个 scale + 8 个 min -> 12 字节
q = bytearray(12)
for j in range(4):
q[j] = (sc[j] & 63) | (((sc[j + 4] >> 4) & 3) << 6)
q[4 + j] = (mn[j] & 63) | (((mn[j + 4] >> 4) & 3) << 6)
q[8 + j] = (sc[j + 4] & 0xF) | ((mn[j + 4] & 0xF) << 4)
return bytes(q)
读法是这样的:前 4 个 scale 直接取 q[0..3] 的低 6 位,前 4 个 min 取 q[4..7] 的低 6 位;第 5 到第 8 个 scale,低 4 位藏在 q[8..11] 的低半字节,高 2 位藏回 q[0..3] 的第 6、7 位。min 同理,高 2 位藏在 q[4..7] 的第 6、7 位。
在别处抄一段通用的「6bit 打包」代码,位序对不上就是全错。
实测结果:输入 scale 为 [17, 62, 0, 63, 33, 5, 48, 21]、min 为 [63, 1, 40, 12, 7, 59, 26, 0],打包成 12 字节 91 3e c0 7f 3f c1 68 0c 71 b5 a0 05,再按 get_scale_min_k4 解回来,16 个数一个不差。8×6 + 8×6 = 96 bit,正好 12 字节,一个 bit 不剩也不欠。
为什么不干脆一个数用一个字节?那样 16 个数要 16 字节,块变成 148 字节,148×8÷256 = 4.625 bit,每权重多 0.125 bit,7.6155B 参数上多出 0.11 GiB。6bit 这个数不是随手定的,是「够用」和「省字节」之间刚好落在 96 bit 上的那个点。
| scales 内下标 | 存的位 | 归属 |
|---|---|---|
| 0–3 | 低 6 位 | scale[0…3] |
| 4–7 | 低 6 位 | min[0…3] |
| 0–3 | 第 6、7 位 | scale[4…7] 的高 2 位 |
| 4–7 | 第 6、7 位 | min[4…7] 的高 2 位 |
| 8–11 | 低半字节 / 高半字节 | scale[4…7] 低 4 位 / min[4…7] 低 4 位 |
4.2 反量化公式要逐项对
dequantize_row_q4_K(同文件第 1539 行)的循环结构是 for (j = 0; j < QK_K; j += 64),每轮处理 64 个元素:前 32 个用低半字节、减去第一组 min,后 32 个用高半字节、减去第二组 min。公式落在这一行:
y = d * sc * q - dmin * m
四个量各有来处:d 和 dmin 来自块头两个半精度数,sc 和 m 来自 scales 解出来的两个 6bit 数,q 是那个 4bit 量化值。少乘一个 d、少减一个 m,结果都会「看起来差不多但整体偏」。
我按这个结构造了 256 个权重、量化、再反量化,256 个值的最大绝对误差 0.091068,均方根误差 0.039953。这两个数是我这套简化量化器的误差,不是 ggml 官方量化器的精度——官方走的是另一套求 scale 的搜索过程,本机没有 C++ 编译器,跑不了那段。把它当成「结构复现的验算」,不要当成「量化精度的评测」。
顺带说一句:这个循环你其实不该自己写。ggml 的类型属性表(ggml.c 第 799 到 805 行)里,Q4_K 那一项把 to_float 指针指向 dequantize_row_q4_K,调用方按张量类型查表、再调函数指针就行。自己重写一遍的风险不在算法,全在细节——位序、半字节归属、指针步进,任何一处抄错都不会报错,只会得到一段「看起来像权重」的数。
4.3 块头那 4 个字节,解错了差二十个数量级
块头是两个半精度数。如果你按 bf16 去解同一串字节,45 19 从 0.002573 变成 1.018e-23,8f 25 从 0.021713 变成 2.481e-16。这不是精度问题,是解释方式错了:半精度是 5 位指数,bf16 是 8 位指数,同一串 bit 的指数段位置都不一样。
移植后输出全是乱码时,第一个该查的就是这 4 个字节:写一行打印,把块头两个数按半精度解出来,看是不是一个正常大小的正数;如果解出来是 1e-23 这种,说明偏移已经错了,后面的计算都不用看。
五、显存估算:4.5 和 4.0 差的不是四舍五入
5.1 11.11% 是怎么来的
0.5 字节对 0.5625 字节,差 0.0625,占 0.5625 的 11.11%。这个比例与模型大小无关,只与量化类型有关,所以它能直接套到任何参数量上。
7.6155B 参数的四个口径摆在一起:
| 口径 | 每权重字节 | 字节数 | GiB | 与 4.5bit 相比 |
|---|---|---|---|---|
| 4.0 bit | 0.5000 | 3807750000 | 3.5462 | 少 11.11% |
| 4.5 bit | 0.5625 | 4283718750 | 3.9895 | 基准 |
| bf16 | 2.0000 | 15231000000 | 14.1850 | 多 3.5556 倍 |
| fp32 | 4.0000 | 30462000000 | 28.3699 | 多 7.1111 倍 |
按 4.0 bit 估会少算 475968750 字节,也就是 0.4433 GiB。这个数在纸面上不疼,落到「模型 + 上下文 + 临时缓冲」的合计里就是能不能塞进一张卡的差别。
5.2 反过来用这张表:从实测占用反推位数
更有用的方向是倒过来。你手上只有「加载后显存涨了 4.1 GiB」这一个观测值,参数 7.6155B,可以反推每权重位数:4.1 × 1024³ ÷ 7.6155e9 × 8 ≈ 4.62 bit。这个值贴着 4.5 而不是 4.0,说明权重确实是 q4_K 这一类;如果算出来是 4.0 出头,那多半是把它当成了「纯 4bit」在解释,或者中间还有别的张量混进来了。
反推的判据很清楚:算出来落在 4.4 到 4.7 之间,是 q4_K;落在 4.0 上下,是你在用名义位宽代替实际位宽;超过 5.0,说明模型里混了别的量化类型(比如 q5_K 或 q6_K 的层)。
同一台机器上换一个观测值就能验证这条判据。假设实测权重占 4.4 GiB,同样的除法得到约 4.96 bit——比 4.5 高一档,说明这个模型不是纯 q4_K,很可能把嵌入层或输出层单独留在了更高位宽(这两层对精度最敏感,常见的混合量化会这么配)。这时候再按 4.5 bit 去规划显存,又会差出几百兆。
5.3 这个数还不包括什么
表里只是权重本身。实际显存还要加上 KV Cache、计算中间结果、以及每个张量按 16 字节补齐的余量。补齐的余量通常很小:一行 256 个权重正好 144 字节、本身是 16 的倍数,补 0;行宽不是 256 的整数倍时每行都要补,累加起来就不容忽略。
判断方法很简单:差额集中在「行数 × 每行补齐字节」这个量上,就是补齐;差额随上下文长度线性增长,那是 KV Cache,跟量化无关。
这份资料是什么:一份把 ggml 量化类型、块尺寸、每权重位数整理成对照表的速查页(q4_0 / q4_1 / q5_K / q6_K 都在里面)。对照本文第一节的字段表看,能省掉反复翻源码的时间。放在资料包里,扫码即可获取:
六、省 71.9% 的字节,为什么不等于快 4 倍
6.1 大家以为的:小 4 倍所以快 4 倍
「4bit 量化」这个说法很容易推出一条结论:bf16 是 16 bit,4bit 是它的四分之一,所以权重读取量少四分之三,速度就该快四倍。
实际是 4.5 bit 对 16 bit,比值 0.28125,省 71.875%。省下的不到四分之三。 剩下的 0.5 bit 是块头和 scales,它不参与「压缩」,但每次读块都得把它读进来——它在内存里是实打实占位置的。
把它换算成 decode(自回归逐 token 生成的那个阶段)的耗时:7.6155B 参数、4.5 bit,每个 token 要读 4.284 GB;同样模型用 bf16 要读 15.231 GB。按有效带宽 100 GB/s 算,前者 42.84 毫秒(23.34 token/s),后者 152.31 毫秒(6.57 token/s),倍率 3.5556 倍,不是 4 倍。
| 有效带宽 | 4.5bit 单 token 读取 | 4.5bit 单 token 耗时 | bf16 单 token 耗时 | 倍率 |
|---|---|---|---|---|
| 100 GB/s | 4.284 GB | 42.84 ms | 152.31 ms | 3.5556 |
| 200 GB/s | 4.284 GB | 21.42 ms | 76.16 ms | 3.5556 |
两行的倍率一模一样,因为倍率就等于两个位宽的比值 16 ÷ 4.5,带宽在分子分母上约掉了。这也是一条判据:如果你实测出来的倍率明显小于 3.5,问题就不在位宽上,而在你根本没有把带宽吃满。 这张表是算术换算,不是实测:真实机器上有效带宽随 batch、随是否命中 L2 变化,倍率也会浮动。
6.2 真正决定速度的:有没有按块连续读、有没有对齐
decode 阶段 batch 通常很小,瓶颈在「每个 token 必须把权重从头到尾过一遍」的读取量上。这时候速度由两件事决定,位宽只是其中一件。
第一件是连续读。一个 q4_K 块 144 字节,跨 3 条 64 字节的 cache line(cache line 是 CPU 从内存往缓存搬数据的最小单位,x86 上常见 64 字节;144÷64 = 2.25,向上取整 3)。按块顺序读,硬件预取器能提前把下一条拉进来;按元素跳着读,每次都在等内存。qs 那个「低半字节管前 32 个、高半字节管后 32 个」的布局,本身就是为「一次读 32 字节、两边都用上」设计的——你按元素遍历,等于把这个设计浪费掉。
第二件是对齐。缓冲区起点不在 16 的倍数上,向量化载入要么拆成多次标量读,要么走非对齐指令。同样 4.5 bit 的数据,对齐和不对齐的差距可能超过位宽带来的差距。
顺便说清 prefill 为什么不能套这个结论:prefill 阶段一次处理几百上千个 token,矩阵乘的算力需求上来了,瓶颈从「读多少字节」变成「算多少次乘加」。这时候 4.5 bit 省的是显存,不是时间——反量化本身还要额外算。
所以那句「量化模型小 4 倍所以读取快 4 倍」有两处不准:倍率不是 4 而是 3.5556,而且真正决定你手上这版代码快慢的,是有没有按块连续读、有没有对齐。位宽是上限,访存模式决定你能不能摸到这个上限。
七、边界:这套结论什么时候不成立
7.1 换成 GPTQ / AWQ 就不成立
本文所有结论的前提是「ggml 的 K-quant 布局」。换成 GPTQ 或 AWQ,块大小、scale 的打包方式、对齐要求全都不一样。
GPTQ(arXiv:2210.17323,ICLR 2023)走的是逐层重构加分组量化,每组共享一组量化参数,配套的是自己的 kernel;AWQ(arXiv:2306.00978,MLSys 2024 最佳论文)走的是按激活分布挑显著通道、做等效缩放,权重的打包方式又是另一套。这两套的「一个块多少元素」「scale 怎么存」「要不要按 16 补齐」都与 ggml 不同。
所以「4.5 bit/权重」这个数不能套到 GPTQ 的 4bit 模型上——那里的每权重占用取决于分组大小和是否带 zero-point(非对称量化里那个零点偏移),得按那套格式自己的定义算。同样,你在 ggml 里踩到的「按 float 读会读到 nan」,在 GPTQ 的 kernel 里根本不会发生,因为那边的权重是按另一套规则排布的。
7.2 换到 GPU 上,16 字节对齐不是主角
ggml 的 GGML_MEM_ALIGN 管的是它自己分配的缓冲区。到了 GPU 上,决定性能的是显存合并访问(同一个 warp 的 32 个线程访问的地址能不能并成一次事务)和 tensor core(GPU 上的矩阵乘专用单元)的 tile 形状。
一个 144 字节的块,在 CPU 上「对齐到 16」就够了;在 GPU 上,线程块怎么切、每个线程读哪几个字节、能不能凑成 128 字节的事务,才是主要矛盾。把 CPU 这套对齐经验直接搬到 kernel 里,通常不会变快。
7.3 判据表:遇到这几类现象先查什么
先给一条停手线。如果实测 decode 每 token 耗时已经贴近「权重字节数 ÷ 有效带宽」这个理论值——假如 7.6155B 的 q4_K 模型实测 48 毫秒、而按带宽算出来是 42.84 毫秒——那说明访存已经吃满,再调块布局、再抠对齐,收益都很有限,该转去看 batch 与并行。反过来,实测 120 毫秒、按带宽只要 42.84 毫秒,差出三倍,那就不要怀疑位宽,先怀疑自己是不是按元素在读、是不是每行都错位了 16 字节。
| 如果 | 就先做 | 通过判据 |
|---|---|---|
| 要自己实现反量化 | 先写 static_assert 钉住块尺寸 | 编译通过 = 尺寸与源码一致 |
| 显存估算与实际差 10% 以上 | 查是不是按 4.0 bit 算的 | 4.5÷4.0 = 1.125,差 11.11% |
| 移植后输出乱码 | 先看块头 4 字节按半精度解出来是否合理 | 0.0026 对 1e-23,差二十个数量级 |
| 同一模型换台机器变慢 | 查 GGML_MEM_ALIGN 分支与行尾补齐 | 缓冲区起点是 16 的倍数 |
| 想把 q4_0 换成 q4_K | 先比对 blck_size 与反量化公式 | 32 对 256,(q-8)*d 对 d*sc*q-dmin*m |
| 怀疑是位宽拖慢了 decode | 先算每 token 读取字节数除以带宽 | 42.84 ms 对 152.31 ms,倍率 3.5556 |
| 在 GPU 上照搬这套对齐 | 先看 warp 内的地址能不能合并 | 一次事务覆盖 128 字节 |
表中每一条都能在没有目标硬件的机器上先算出来,算完再决定要不要改代码。这套判据的价值不在「知道得更细」,而在把一个看起来像性能问题的东西,拆成三个可以先算、后改的量:每权重几个 bit、一个块多少字节、缓冲区起点在哪。
这份资料是什么:一份量化格式对照与反量化公式的速查表,把本文第四节的字节布局、公式和常见错法并排列出。放在资料包里,扫码即可获取:
附表 A:本文引用事实与出处对照表
| 事实 | 出处(文件 / 发布方 / 行号 / 日期 / 链接) | 本文位置 |
|---|---|---|
QK_K 为 256、K_SCALE_SIZE 为 12 | ggml-common.h L89、L90;llama.cpp(ggml-org);2026-11-07 抓取,返回 200、135161 字节;https://ghproxy.net/https://raw.githubusercontent.com/ggml-org/llama.cpp/master/ggml/src/ggml-common.h | 1.2 |
block_q4_K 四字段,共 144 字节 | 同上 L327–337 | 1.1 |
| 「4bit、8 个 32 元素子块、等效 4.5 bit」 | 同上 L323–326 | 1.3 |
static_assert 钉死块尺寸 | 同上 L338 | 1.2 |
| q3_K 3.4375、q4_K 4.5、q5_K 5.5、q6_K 6.5625 bit | 同上 L314、L326、L343、L361 及 L321、L356、L368 断言 | 1.3 |
block_q4_0 18 字节 / 32 权重,QK4_0 为 32 | 同上 L194–199 | 3.3 |
GGML_MEM_ALIGN 为 4 / 8 / 16 三分支 | ggml.h L233–243(L236、L241、L243);2026-11-07 抓取,返回 200、119369 字节;https://ghproxy.net/https://raw.githubusercontent.com/ggml-org/llama.cpp/master/ggml/include/ggml.h | 2.3 |
GGML_PAD 宏、ggml_nbytes_pad 声明 | 同上 L267、L762 | 2.3 |
ggml_nbytes_pad 按 GGML_MEM_ALIGN 补齐 | ggml.c L1340–1342;2026-11-07 抓取,返回 200、267312 字节;https://ghproxy.net/https://raw.githubusercontent.com/ggml-org/llama.cpp/master/ggml/src/ggml.c | 2.3 |
GGML_TYPE_Q4_K 的 blck_size 为 QK_K | 同上 L799–805 | 7.3 |
dequantize_row_q4_K 的循环与公式 | ggml-quants.c L1539–1560;2026-11-07 抓取,返回 200、228271 字节;https://ghproxy.net/https://raw.githubusercontent.com/ggml-org/llama.cpp/master/ggml/src/ggml-quants.c | 4.2 |
get_scale_min_k4 的 6bit 位切分 | 同上 L889–896 | 4.1 |
q4_0 用 (nibble-8)*d,半字节对应 j 与 j+16 | 同上 L462–479 | 3.3 |
| GPTQ 为逐层重构、分组共享量化参数 | arXiv:2210.17323,Frantar 等,2022-10-31 提交,ICLR 2023;https://arxiv.org/abs/2210.17323 | 7.1 |
| AWQ 为按激活挑显著通道的权重量化 | arXiv:2306.00978,Lin 等,2023-06-01 提交,MLSys 2024 最佳论文;https://arxiv.org/abs/2306.00978 | 7.1 |
| 4.5 bit 换算、12 字节往返、硬读误差、显存与带宽数字 | 本机脚本 work/2026-11-07-02_脚本.py 与日志 work/2026-11-07-02_脚本.log;2026-11-07 运行;仅用 Python 标准库 | 1.3、2.2、4.1、5.1、6.1 |
附表 B:术语速查表
| 术语 | 一句话解释 | 本文位置 |
|---|---|---|
| 量化块 | 把一批权重压进定长字节的容器,必须整块读整块解 | 1.1 |
block_q4_K | 4bit K-quant 的块结构,144 字节装 256 个权重 | 1.1 |
| K-quant | 用一个超级块管多个子块、scale 分层的量化方案 | 1.1 |
ggml_half | 2 字节半精度浮点,块头两个 scale 都用它存 | 1.1 |
| scale / min | 一组权重的缩放系数与偏移,分超级块级和子块级 | 3.2 |
| 反量化 | 把 4bit 整数按公式还原成近似浮点值 | 4.2 |
static_assert | 编译期断言,条件不成立就不生成二进制 | 1.2 |
GGML_MEM_ALIGN | ggml 分配张量内存要求的最小对齐字节数,默认 16 | 2.3 |
| KV Cache | 缓存历史 token 的 K/V,免得每生成一个词重算前面 | 3.1 |
| decode | 自回归逐 token 生成的阶段,一次只出 1 个 token | 6.1 |
| zero-point | 非对称量化里的零点偏移,决定 0 对应哪个真实值 | 7.1 |
| 显存合并访问 | GPU 上同一批线程的地址能并成一次显存事务 | 7.2 |
写在最后:这篇用到的资料
写这篇文章时,把相关的官方文档和源码又翻了一遍,顺手也整理了几份配套的东西:
- 大模型学习路线图:从零基础到能自己动手做 Agent,按阶段说明每一步该学什么、哪些可以先跳过
- 《LangChain + LangGraph + MCP 智能体开发实战》视频课:7 个模块,从私有化部署、Embedding+RAG 到 MCP+Agent 全流程
- AI 大模型知识库(在线可查):Agent Skills 从入门到落地、Claude Skills 完全指南等专题,按目录浏览即可
- 640 套 AI 大模型行业报告 + 经典 PDF 书籍:看行业落地案例和别人怎么做的时候用得上
- 大模型零基础到精通教学视频:跟着敲一遍,比只读文档快得多
资料是我自己整理的,放在下面这个码上,扫码即可获取:
添加时备注「AI」,优先通过。
资料按「先路线、再动手、最后查漏」的顺序整理好了,建议先看学习路线那一份,照着它挑一条适合自己当前基础的路径再往下看。
更多推荐








所有评论(0)