Note: This page does not support English, using the default language version
Data
一、位与字节
进制
十六进制以4位二进制为一组
| 进制 | C 的写法 | 表示 42 |
|---|---|---|
| 十进制 | 无前缀 | 42 |
| 十六进制 | 0x / 0X | 0x2a |
| 八进制 | 前导 0 | 052 |
| 二进制 | 0b / 0B | 0b101010 |
字节
类型及其大小
| C 类型 | 32 位平台 | 64 位平台 | 说明 |
|---|---|---|---|
char | 1 | 1 | 一个字节,也用于表示字符 |
short / unsigned short | 2 | 2 | 通常为 2 字节 |
int | 4 | 4 | 各平台一致 |
long | 4 | 8 | 随平台变化 |
long long | 8 | 8 | 至少 8 字节 |
void * | 4 | 8 | 宽度即字长 |
float / double | 4 / 8 | 4 / 8 | IEEE 754 单精度与双精度 |
long double | 通常 8 / 12 | 通常 8 / 16 | 由编译器和平台决定 |
bool / _Bool | 1 | 1 | 逻辑值,通常为 1 字节 |
size_t | 4 | 8 | 无符号,用于表示长度与大小 |
signed与unsigned不改变对应整型的大小,只改变取值范围- 字节是最小的可寻址单位,其地址取这段地址的最小值
字节序
- 小端:最低有效字节位于最低地址。x86-64 与 ARM 的默认配置
- 大端:最低有效字节位于最高地址。TCP/IP 首部与部分平台采用
- 文件格式:GGUF、ELF、PNG 各自规定字节序,跨机器读取才有确定结果
- 网络传输:TCP/IP 首部采用大端,
htons/htonl完成本机序与网络序的转换 - 强制类型转换:以另一种类型解释同一段字节时,排列方式随之暴露
- 反汇编输出:指令编码中的立即数与地址按小端存放
二、整数
有符号数与无符号数
在使用相同位数存储时,有符号数需要用一位表示符号,因此与无符号数的取值范围不同。以下以常见的 w 位整数为例:
| 对比项 | 有符号数(signed) | 无符号数(unsigned) |
|---|---|---|
| 符号 | 可以表示正数、负数和 0 | 只能表示非负数 |
| 典型表示 | 补码 | 直接按二进制解释 |
| 位取值范围 | ~ | ~ |
| 8 位示例 | -128 ~ 127 | 0 ~ 255 |
| C 语言类型示例 | signed int、int | unsigned int |
| 适用场景 | 可能出现负值的整数、算术运算 | 长度、大小、位掩码及不应为负的值 |
注意:有符号数溢出在 C/C++ 中属于未定义行为;无符号整数运算按模 回绕。比较有符号数和无符号数时,C 语言可能先将有符号数转换为无符号数,需要特别注意。
Two’s Complement
补码的本质是用模 的剩余类表示整数。设机器字长为 ,其有符号整数范围为
编码与解码
定义 位补码编码函数
这里模运算的结果取在 内。若一个 位二进制串按无符号数解释为 ,则补码解码函数为
对任意 ,都有
所以编码与解码在可表示范围内互为逆映射。
加法正确性
位加法器丢弃最高位之外的进位,执行的实际运算是模 加法:
对任意 ,
若 ,则
因此,只要不发生有符号溢出,补码加法与普通整数加法严格一致。发生溢出时,位模式仍与真实结果模 同余,但已不能按 位有符号整数正确解码。
为什么按位取反加一等于相反数
对任意 位无符号值 ,按位取反满足
所以
若 ,便有
因此补码中可以用“按位取反再加一”求相反数。唯一的例外是最小负数 ,因为其相反数 超出了可表示范围。
减法与乘法
由于模运算与加法、乘法相容,
因此减法可以转化为加上相反数:
乘法器只保留低 位时,得到的也是乘积模 的补码。只要真实结果仍属于 ,解码后便与普通整数运算一致。
符号扩展正确性
设负数 的 位补码按无符号数解释为
将其扩展到 位时,在高位补 个 1。新增高位的权值之和为
扩展后的无符号值为
按 位补码解码可得
所以符号扩展不会改变补码表示的整数值。
结论
补码正确性的核心是
它不是经验性的编码技巧,而是将整数映射到剩余类环 的自然结果。模运算保持加法、取负、减法和乘法,因此同一套二进制运算电路可以同时处理无符号数与补码有符号数。
有符号数与无符号数的转换
C 允许把一个类型的变量按另一个类型解释,转换有两种发生方式。
- 显式转换:写出
(unsigned short) x - 隐式转换:赋值、函数传参、以及表达式中的类型提升都会发生
- 有符号与无符号之间的转换不改变任何一位,改变的只是解释规则
混合比较:宽度不同的操作数进行比较
- 确定目标类型:先把窄于
int的类型提升为int;提升后宽度不同取较宽者,宽度相同、符号不同取无符号 - 补 0 还是补符号位由原类型决定,得到的位模式按目标类型解释
| 比较 | 目标类型 | 被转换的操作数 | 结果 |
|---|---|---|---|
int −1 < unsigned short 1 | int | 1 补 0 得 | 1(真) |
short −1 < unsigned short 1 | int | 两侧都提升为 int:−1 补符号位,1 补 0 | 1(真) |
short −1 < unsigned 1 | unsigned int | −1 转换为 | 0(假) |
int −1 < unsigned long long 1 | unsigned long long | −1 转换为 | 0(假) |
long long −1 < unsigned 1 | long long | unsigned int 的 1 转换为 | 1(真) |
位掩码
掩码(mask)是按照特定模式构造的整数,用来选择、检查或修改另一个整数中的特定位。可以把它理解为一个二进制筛子:掩码中为 1 的位表示需要关注或操作的位置,为 0 的位表示忽略的位置。
例如,使用掩码 提取 的低 4 位:
构造只选中第 位的掩码:
| 目的 | C 表达式 | 原理 |
|---|---|---|
| 检查第 位 | x & (1u << k) | 结果非 0 表示该位为 1 |
| 设置第 位 | x |= (1u << k) | 与 1 按位或会得到 1 |
| 清除第 位 | x &= ~(1u << k) | 与 0 按位与会得到 0 |
| 翻转第 位 | x ^= (1u << k) | 与 1 按位异或会翻转 |
| 提取一组位 | x & mask | 保留掩码为 1 的位置 |
掩码本身不是特殊的数据类型,而是被赋予了位选择含义的普通整数。位掩码常用于权限标志、硬件寄存器、网络协议以及压缩存储多个布尔状态。
三、浮点数的编码
IEEE 浮点表示的数值形式与编码
对于规格化数,数值形式为
编码分为 、、 三个字段:
- 符号位 决定数的正负: 表示正数, 表示负数。
- 字段存储编码阶码 ,实际阶码为 。
- 字段编码尾数 ,规格化数的有效数值为 ,其中 。
其中,单精度的 ,双精度的 。当 全为 0 或全为 1 时,还可表示 0、非规格化数、无穷大和 NaN 等特殊值。
为什么阶码需要偏置值
阶码字段使用无符号整数存储,但实际阶码可能为负数,因此采用偏置编码:
其中 是 字段存储的无符号值, 是实际阶码。以单精度为例, 有 8 位,:
- 时,;
- 时,。
使用偏置值的好处是:
- 可以表示负阶码,而不必使用补码存储阶码;
- 阶码按无符号数比较即可判断大小,便于硬件实现;
- 可以保留特殊编码: 用于表示 0 或非规格化数, 用于表示无穷大或 NaN,其中 为阶码字段的位数。
偏置值不是数学上必需的,而是为了简化浮点数的编码、比较和硬件处理。
IEEE 754 中的特殊值
当 取特殊值时,浮点数不再按照普通的规格化公式解释:
| 表示 | 数值或含义 | ||
|---|---|---|---|
| 全为 0 | 全为 0 | 或 | |
| 全为 0 | 非 0 | 非规格化数 | |
| 全为 1 | 全为 0 | 或 | 表示溢出或除以 0 的结果 |
| 全为 1 | 非 0 | NaN | Not a Number,表示无效或未定义的运算结果 |
其中, 表示正值, 表示负值。NaN 不表示一个确定的数,因此与任何数(包括它自身)的比较通常都为假。
8 位浮点格式的编码示例
下面采用教学用 8 位浮点格式:1 位符号位、4 位阶码和 3 位尾数,即
阶码偏置为
对于规格化数,,数值为
对于非规格化数,数值为
| 位模式或数值 | 类型 | 转换过程 | 结果 |
|---|---|---|---|
| 规格化数 | , | ||
| 规格化数 | , | ||
| 规格化数 | , | ||
| 非规格化数 | , | ||
| 正无穷大 | 阶码全为 1,尾数全为 0 | ||
| NaN | 阶码全为 1,尾数不为 0 | NaN |
判断时应先检查阶码字段:阶码既不全为 0 也不全为 1时,才使用规格化数公式;阶码全为 0 时表示零或非规格化数;阶码全为 1 时表示无穷大或 NaN。
| 精度 | 总位数 | exp 位数 | frac 位数 |
|---|---|---|---|
| 单精度 | 32 | 8 | 23 |
| 双精度 | 64 | 11 | 52 |
四、浮点数的精度与舍入机制
实数轴上的非均匀离散分布与相对精度
ULP:浮点数的网格间距
ULP 是 Unit in the Last Place 的缩写,表示最低有效位的单位。对于给定阶码区间,它通常表示相邻两个可表示浮点数之间的距离,即浮点数在实数轴上的网格间距。
公式
设规格化浮点数有 位尾数字段。若 是编码阶码, 是实际阶码,则
在区间 内,相邻规格化浮点数的间距为
IEEE 754 单精度有 ,因此
证明
将 位尾数字段按无符号整数解释为 ,其中 。尾数小数为
所以浮点数可以写成
同一阶码区间内,相邻浮点数的尾数字段相差 1,即 。忽略符号,只计算两者的绝对距离:
因此单精度取 时,得到 。指数中的 来自尾数字段最低位的权重 。
为什么不是
单精度规格化数虽然具有 24 位二进制有效数字,但最高位 1 是固定的隐含位:
真正能够变化的最低位是 ,其权重为 ,所以网格间距是 。采用舍入到最近值时,最大舍入误差约为半个 ULP:
几何意义
在单精度格式中:
| 数值区间 | 实际阶码 | ULP |
|---|---|---|
每跨过一个 2 的整数次幂,实际阶码增加 1,ULP 随之翻倍。因此浮点数越大,绝对网格间距越大,但相对间距大致保持不变。
对于单精度非规格化数,没有隐含的最高位 1,所有相邻非规格化数的间距固定为
浮点加法:阶码对齐、GRS 位与吸收现象
阶码对齐
设两个规格化浮点数为
并假设 。令阶码差
将较小数改写为与较大数相同的阶码:
因此,浮点加法需要先把较小阶码操作数的尾数向右移动 位,再执行尾数运算:
阶码对齐的本质是统一二进制小数点的位置。右移并不改变操作数的数学值,但可能使低位超出尾数寄存器的物理位宽。
GRS 位
为正确判断舍入方向,硬件不会简单丢弃所有被移出的位,而是将其信息压缩为三个附加位:
- G(Guard,保护位):保留尾数字段之后的第一位;
- R(Round,舍入位):保留尾数字段之后的第二位;
- S(Sticky,粘滞位):其余所有被移出位的逻辑或;只要其中任意一位为 1,便有 。
设保留结果的最低位为 。在“舍入到最近值,距离相同时取偶数”模式下,通常在
时向上舍入。其中 表示结果恰好位于两个浮点数的中点,此时由 决定是否进位,从而使最终最低位为偶数。
位级舍入判决:与 GRS 的对应
设保留位数截止于第 位(最后保留位),被截断部分的首位权重恰为末位的 。因此
「最高位为 1、其后全 0」正是「恰好落在两个可表示数正中间」的位级化身。它与 GRS 三位一一对应:
| 位级量 | GRS | 含义 |
|---|---|---|
| 截断段首位的 | 是否越过半个 ULP 的门槛 | |
| 截断段次位 | ||
| 剩余所有位的逻辑或 | 后半段是否还有非零位 | |
| 保留部分末位 | 决定 tie 时取偶 |
硬件据此做三级裁决:
- 截断段 ():小于中点,直接舍弃;
- 截断段 ():大于中点,进位;
- 截断段 ():恰为中点,按 定夺—— 进位使其变偶, 舍弃。
三级可合并为一个纯组合逻辑表达式(即 GRS 判决式):
进位传播:若保留部分各位全为 ,进位将逐位穿过并溢出尾数最高位。规格化表示把该溢出解释为小数点左移一位,于是尾数清零、阶码 ,数值跨入下一个 的幂次区间(ULP 随之翻倍)。例如 。
舍入判决算例(4 位尾数)
取 ,有效数字为 。对齐后,落在结果 LSB(小数点后第 位)右侧的位即截断位:截断位第 1 位给 、第 2 位给 、其余或起来给 。
例 A:进位(截断位 , 且 )
列号: 1 2 3 4 | 5 6 7
x : 1 0 1 0 |
y' : 0 0 1 0 | 1 1 1
例 B:平局(截断位 ,,由 定夺)
列号: 1 2 3 4 | 5 6
x : 0 1 0 1 |
y' : 0 1 1 1 | 1 0
已为偶数,不进位,结果 。
IEEE 754 舍入模式与无偏性机制
舍入模式总结
- IEEE 754 标准规定的四种舍入模式:
- 向零截断:直接舍弃小数,方向始终朝向原点。
- 向下舍入:取不大于真值的最大浮点数。
- 向上舍入:取不小于真值的最小浮点数。
- 向最近偶数舍入:系统默认工作模式。
- 传统四舍五入的系统缺陷:
- 当真值恰好落在正中时一律向上取整,在对大规模正数序列进行持续累加时,会引入正向的系统性统计偏差。
- 向偶数舍入的无偏性设计:
- 仅在真值恰好处于两个可表示值正中时生效:强制选择最低有效位为 0(偶数)的一侧。
- 在统计上,中点边界处向上与向下舍入的概率各为 50%,累积舍入误差的数学期望为 0。
浮点算术对代数公理的违背与灾难性抵消
- 加法结合律的失效:。
- 运算顺序改变了中间结果的阶码对齐截断量,导致结合律与分配律在浮点运算下不再成立。
- 灾难性抵消:
- 当两个相近的浮点数执行减法时,高位有效数字完全抵消,尾部因有限精度产生的舍入误差被提升为主导有效位,导致有效数字位数断崖式下跌。
- 十进制有限小数与二进制循环小数的鸿沟:
- ,在二进制中为无限循环小数,无法用有限位精确表示,故字面量
0.1存储在内存中即为不可逆的近似截断值。
- ,在二进制中为无限循环小数,无法用有限位精确表示,故字面量
C 语言浮点转换规则、精度损失与未定义行为
- 转换矩阵与精度保真度分析:
int转double:保真。int具有 31 位精度,double尾数位宽为 53 位,可精确容纳。int转float:有损。float仅提供 24 位有效精度,超过 (16,777,216)的整型值将发生舍入。float转double:精确无损扩展;double转float:面临数值溢出或低位舍入。- 浮点数转整数:统一采用向零截断。
- 越界未定义行为与底层硬件处理:
- 当浮点数数值超出目标整型表示范围(如
(int)3e9),ISO C 标准定义其为未定义行为。 - x86-64 体系结构下,转换指令对越界或无效浮点数统一输出固定的特殊值:
0x80000000(即 ,即INT_MIN)。
- 当浮点数数值超出目标整型表示范围(如
现代体系结构扩展:动态范围与相对精度的折中
位宽约束下的设计权衡公理:FP16 总位宽固定为 16 位,划分为 ,两种资源此消彼长:
- 阶码字段决定动态范围(数值上下界之比): 位阶码使动态范围约为 ;
- 尾数字段决定相对精度(相邻可表示数的相对间距): 位尾数给出相对精度 ,其中 。
由于 固定,拓宽动态范围必然牺牲相对精度。
FP16 的工业局限:阶码仅 5 位,,实际阶码 ,故最大表示值受限于
归一数下限为 (非规格化最低到 ),相对精度约 ,即约 3 位十进制有效数字。
| 格式 | 位分配 (/exp/frac) | 动态范围上界 | 十进制有效位 | 核心应用领域 |
|---|---|---|---|---|
| FP32 | 1/8/23 | 科学计算、基准训练 | ||
| FP16 | 1/5/10 | (易溢出) | 图形学、边缘推理 | |
| BF16 | 1/8/7 | (与 FP32 同) | 大模型训练与推理 |
BF16 把 3 位尾数让给阶码,动态范围回到 FP32 同级而精度骤降,是”范围优先”的极端;FP16 则是相反的”精度优先”极端。
深度学习的上下溢与软件补偿:反向传播连乘梯度,数值尺度可跨越数十个数量级。FP16 两端狭窄, 上溢为 (进而产生 NaN), 下溢为 (参数停止更新)。对策:
- Loss Scaling:放大 loss 使梯度落入可表示区间,反传后再缩回;
- 混合精度(AMP):权重与累加器保留 FP32 兜住动态范围,热点运算用 FP16 换吞吐。
BF16 的位布局对齐优势:BF16 与 FP32 的符号位、阶码位(各 位)在物理位置上严格一致,BF16 的位 15..0 逐位等于 FP32 的位 31..16,阶码含义与 完全相同。
- FP32 → BF16:仅需截取高 16 位(单条移位/截取指令);round-to-nearest-even 也只需按位 15 与粘滞位加一个小增量。
- BF16 → FP32:低位补 16 个零即可,无需重算偏置、无需规格化。
对比 FP16:阶码宽度 不一致,转换必须动态重算偏置、判断溢出/下溢并处理非规格化移位,逻辑门开销远高于 BF16。因此 BF16 可直接复用 FP32 的阶码通路与累加器,这正是”范围优先”路线在硬件上高效落地的原因。
五、大模型量化的底层系统原理
量化动因:精度冗余、访存受限,硬件收益
高精度神话的破灭
IEEE 754 FP32 的历史使命
- 23 位尾数带来约 7 位有效十进制精度:尾数最低位的间隔为 ,即相对精度约 。
- 设计初衷:为数值计算统一浮点格式与舍入规则;对误差要求更严的计算,通常改用 FP64。
大模型的参数与表征本质
- 现代大模型是典型的超大规模过参数化系统,参数量从十亿级到万亿级。
- 自注意力机制的核心逻辑:推理结果主要取决于 Softmax 归一化后注意力分布的相对大小,以及激活通道之间的相对强弱。
- 实验观察:采用合适的量化方法,许多大模型的权重降到 8 位或 4 位后精度只小幅下降;但量化误差仍会改变 logit 与生成结果。
系统结论:在大模型时代强行沿用传统科学计算中高能耗、大位宽的 FP32,不仅在算力和显存上存在极大浪费,还会成倍增加自回归推理需要搬运的字节数,加剧「显存带宽」这一瓶颈。
硬件基础:内存带宽决定生成速度上限
基本量:计算量、访存量、带宽与算术强度
| 物理量 | 符号 | 含义 | 单位 |
|---|---|---|---|
| 计算量 | 一次任务执行的浮点运算次数,1 次乘加计 2 次 | FLOP | |
| 访存量 | 一次任务在内存与芯片之间传输的字节数 | Byte | |
| 峰值算力 | 芯片每秒最多执行的浮点运算次数 | FLOP/s | |
| 带宽 | 内存每秒最多向芯片传输的字节数 | Byte/s | |
| 算术强度 | 每传输 1 字节数据执行的运算次数 | FLOP/Byte |
现状:单请求自回归推理时,每生成一个 token 都要把全部权重从内存搬进芯片一遍,而每个权重只参与 1 次乘加——算术强度极低。所以生成速度的上限由带宽决定:
(前提是每个 token 恰好读一遍全部权重;KV cache、激活、反量化与内核效率都未计入,故实测低于此值。)
以 Llama-3.2-1B(1.24B)为例,同一模型换成不同位宽:
| 量化格式 | 平均位宽 | 模型大小 | DDR5-7200(115.2 GB/s) | RTX 5090(1792 GB/s) |
|---|---|---|---|---|
| BF16 | 16 | 2.47 GB | 47 token/s | 725 token/s |
| Q8_0 | 8.5 | 1.31 GB | 88 token/s | 1365 token/s |
| Q4_K | 4.5 | 0.70 GB | 166 token/s | 2578 token/s |
结论:——位宽减半,速度上限约翻倍。量化省下的不是算力,而是每个 token 必须搬运的字节数。
问题实测:自回归推理深陷访存受限
IEEE Micro 2024 实测(序列长度 4096):
| 模型 | 计算量 | 访存量 | 算术强度 | 相对时延 |
|---|---|---|---|---|
| BERT-Base | 1324 GFLOPs | 11.2 GB | 118 | 84 |
| GPT-2 | 1012 GFLOPs | 507.8 GB | 2 | 2344 |
两者计算量相近,访存量却差约 45 倍,时延差约 28 倍。根源在算术强度:
- BERT(整段序列一次前向传播):权重被全部 token 复用 → 算术强度 117~266 → 算力受限,上限由峰值算力决定。
- GPT-2(自回归逐 token 生成):每步只处理 1 个 token,却要遍历全部权重 → 算术强度仅 2 → 访存受限,上限由带宽决定,计算单元大部分时间在等数据。
以 RTX 5090 的平衡点()为参照,单请求推理的算术强度只有它的 ,计算单元利用率上限不足 1%。
量化的作用:权重压到 4 位,数据量降至约 ,工作点在 Roofline 上右移、算术强度约提高 4 倍,直接缓解访存瓶颈。
Roofline 模型(Williams et al., 2009):以算术强度 为横轴、性能为纵轴,上限为
- 斜线 是带宽屋顶,水平线 是算力屋顶,交于拐点 。
- 拐点左侧访存受限(性能 ,算力闲置),右侧算力受限(性能 ,带宽闲置)。
- 单请求推理在 处,远在拐点左侧。量化把工作点右移、性能沿斜线抬升,但并未翻过拐点——瓶颈仍是带宽。
微架构视角:整数单元又小又省电
浮点运算单元在硅片上又大又费电。45nm 工艺下的对照:
| 运算单元 | 面积 () | 每次能耗 (pJ) | 相对能耗 |
|---|---|---|---|
| FP32 乘法 | 7700 | 3.7 | |
| INT8 乘法 | 282 | 0.2 | |
| INT8 加法 | 36 | 0.03 | (基准) |
- 8 位整数加法器只需一条进位链,面积约为 FP32 加法器的 ;乘法器约为 。
- 做 1 次 FP32 乘法的电能,够做 120 多次 INT8 加法。
结论:整数运算在能效和面积上都远优于浮点,这正是量化能在硬件上兑现收益的物理基础。
量化方法
量化概览
量化即定点化:把连续的浮点数映射到有限个整数刻度上。它与第四节 FP16/BF16 的位数分配是同一权衡(范围 vs 精度),与第五节 ULP 的「格宽」思想同源——区别只是格宽由整数位宽决定,而非由阶码决定。
量化是将模型权重文件的数据进行映射,我们自然会产生以下问题:模型权重文件中的数据是什么?为什么将浮点数映射为整数就可以减少开销?量化为什么几乎不会损失模型的性能?
下面是对上述问题的回答:
原始权重文件只有两类内容:一是张量数据——各层的 、MLP 的 gate/up/down、embedding 表、RMSNorm 的 ,以 FP16 / BF16 / FP32 的浮点形式存放;二是元数据——格式版本,各张量的名字 / dtype / shape / 偏移,以及 n_layer、n_head、RoPE 、tokenizer 等超参。量化只做了两处改动:把张量的 dtype 由浮点换成整数(如 Q4_K),并新增一组量化参数(每个分组的 scale,非对称再多一个零点 )用于还原;元数据基本不变,只是 dtype 字段记录了新的整数类型。
需注意,无论量化与否,文件里都没有指令、也没有计算图,它只是带索引的字节,怎么算由外部推理程序决定。
节省的主要是访存:解码时每生成一个 token 都要把全部权重从显存搬进计算单元过一遍,瓶颈在带宽而非算力(Roofline 拐点左侧)。位宽 后单次搬运字节数近乎 1/4,带宽受限的生成速度上限随之提高。主导项是每 token 的权重流量,文件加载与显存容量只是次要收益。
为什么能省:权重是静态的、分布平滑且近似对称的,冗余精度可换成更粗的格子——同一批数用更少的位记下,字节数自然变少。文件保存整数,计算时再映射回浮点近似值参与运算。大大减少了数据的访存开销。
代价与边界:还原值与原点之间有误差;且不一定省计算——权重-only(W4A16)仍在 FP16 上算、甚至多一步反量化,只有连激活一起量化(W8A8)或进入大 batch / prefill 的算力受限场景,整数单元的面积与能耗优势才显现。
为什么量化对模型性能的影响不大:知识存在高有效位(量级与方向),量化丢的是低有效位(尾数),而低位权重大多是被冗余掉的噪声。同时,加之三点:① 误差有界且近似零均值,矩阵乘求和时按 相互抵消;② 网络过参数化、分布式表征,功能不靠单个权重;③ 训练本身(dropout、批噪声)已把解推向平坦区,RMSNorm 与残差再吸收小扰动。故数据变了,端到端性能几乎不变——这就是「精度冗余」的定量表述。前提是位宽 、分组且有校准; 位或激活量化仍会明显掉点,且 PPL 不掉 推理能力不掉。
均匀仿射量化
映射任务:把 线性地贴到 位整数集合 上:均匀指格点等距,仿射指映射是「缩放 + 平移」。
- 步长 :尺子的格宽,等价于前面的 scale 。
- 偏移 :整数 对应的浮点值。 不一定是 的整数倍,所以实数 0 未必落在格点上。
clip把超出量程的值截回端点,否则分布外的 outlier 会编码成越界整数。
取 ,两式即可与原记号互换。对称量化取 ,格坐标系原点压在 上;非对称量化取一般 ,允许区间任意偏斜。
对称量化
核心:用刻度稀疏的「短尺子」表示浮点数——存下「落在第几格」的整数,计算时乘回格宽还原近似值。FP32 每数 32 位,而权重容忍微小误差,故可用 int4(16 个刻度)大幅压缩。
三步(设最大刻度 ,如 4 位取 ):
- 定格宽 (取组内最大绝对值,保证不溢出)
- 量化
- 反量化
算例():
| 原值 | 整数 | 还原 |
|---|---|---|
| 0.12 | 1 | 0.14 |
| −0.53 | −4 | −0.56 |
| 0.97 | 7 | 0.97 |
| −0.08 | −1 | −0.14 |
要点:存储只需整数 + 一个共享 scale;计算经反量化后进行,工程上多用整数乘法末尾统一缩放(等价且更快);误差来自舍入与尺子粗细,位数越少误差越大。
非对称量化
对称量化把 顶到尺子端点,要求分布关于 0 大致对称;若分布明显偏斜,一半刻度空转。非对称量化改用实际的 定尺子,并引入零点 :
| 维度 | 对称 | 非对称 |
|---|---|---|
| 零点 | 固定为 0 | 随组变化 |
| 额外存储 | 仅 scale | scale + |
| 适合分布 | 近似对称(权重) | 偏斜、单边(ReLU 后的激活) |
| 零点保真 | 精确映射为 | 未必落在整数刻度的零点上 |
瓶颈对比
| 瓶颈 | 对称量化 | 非对称量化 |
|---|---|---|
| 精度 | 分布偏斜时量程被撑大一倍、半数刻度空转,等效损失约 1 位;权重近似零均值故几乎无损 | 量程贴合实际分布,同样位宽下格宽更细 |
| 存储 | 每组仅 1 个 scale, 位 | 每组多存零点 , 位 |
| 计算 | ,反量化即乘 ,退化为纯整数点积 | 反量化多一步减零点;矩阵乘展开出三个一阶修正项 |
| kernel | 可套用最简对称 int4 / int8 内核 | 需带零点修正的分支,内核更复杂、更难向量化 |
| 校准 | 只需统计 (一趟) | 需同时统计 与 (两趟) |
| 适用 | 权重 | 偏斜、单边的激活 |
kernel(计算内核)指算子在硬件上的底层实现代码(如 CUDA 发射到线程上执行的函数),而非操作系统的「内核」。
把仿射式代进矩阵乘,非对称多出的部分是一阶修正:
主项仍是最便宜的整数点积;、 可按行列预计算摊薄。故在访存受限的推理里,这点额外计算几乎不可见——非对称真正付出的是存储与内核复杂度,不是时间。
对称输在精度(浪费刻度),非对称输在工程(存储与 kernel)。 因此常规取舍是「权重对称、激活非对称」;若要全用对称内核,就先以 SmoothQuant / Hadamard 旋转把激活分布摊平,再上对称 int8——用对称的简单内核拿到非对称的精度收益。
离群点困境:量程与分辨率的零和
权重近似钟形分布:绝大多数数值挤在 0 附近,两翼伴随少量极端离群值。怎么定量程本身就是一对取舍——量程越宽,格宽越大、分辨率越低。
| 准则 | 量程取法 | 收益 | 代价 |
|---|---|---|---|
| MinMax | 覆盖全部极值: | 无截断误差 | 一个离群尖刺就把 拉大,中间大量正常权重被挤进 0 附近少数几格,舍入误差剧增 |
| 饱和截断 | 设阈值 ,超出 的强行截断为该值: | 显著缩小,核心有效区网格致密 | 两端尾部承担截断误差 |
核心权衡:选取 ,使截断误差与舍入误差之和最小。 减小,截断误差升、舍入误差降,两者相抵处即为最优阈值。
工程实现就是在这一维上做搜索:按 percentile 截断,或直接在 上网格搜索取 MSE 最小者(TensorRT 的 INT8 校准即此思路)。对离群通道先用 SmoothQuant / 旋转把尾巴摊平,也正是为了让 MinMax 不被单个尖刺拉爆。
量化粒度
「一组」(指量化时把数据分组)共用一个 scale(步长),划法有三档:
| 粒度 | 一组范围 | 精度 | 额外开销 |
|---|---|---|---|
| per-tensor | 整个张量 | 最低 | 1 个 scale |
| per-channel | 每个输出通道(一列 / 一行) | 中 | 通道数个 scale |
| per-group | 每 个权重 | 最高 | 权重数 个 scale |
组越小,尺子越贴合局部分布、越准,但 scale 本身要占空间。平均每权重位数
其中 是量化位宽(每个权重本身占的位数,如 int4 时 ), 是分组大小(每 个权重共用一个 scale)。 是权重数据的开销, 是分摊到每个权重上的 scale 开销。
(假定 scale 用 fp32,即每个 scale 占 32 位、由 个权重分摊)。int4、 时约 位/权重, 时约 位——这正是 GPTQ / AWQ 默认取 的原因:在精度与开销之间折中。
那么,真实格式里的「一组」长什么样?
以 GGUF 4 位为例:一个块覆盖 32 个权重,即 。块头是 fp16 的 (非对称再多一个 fp16 的 ),其后 16 字节打包 32 个 4 位权重——一个字节装两个。
| 排法 | 字节 装 | 抽低半字节 | 抽高半字节 |
|---|---|---|---|
| 交叉配对 | 、 | 连续 | 连续 |
| 相邻配对 | 、 |
- 交叉配对优势:向量指令一次只能写一整排连续的数,因此必须先在寄存器里把顺序摆正。交叉配对的低 / 高半字节天然就是输出的连续两半,一次
& 0x0F加一次>> 4直接可用;相邻配对抽出来的是奇偶两半,还要多一条交错(shuffle)指令还原顺序,而洗牌指令常只能走受限端口,比普通位运算更「金贵」。 - 边界:在标量代码里二者毫无差别(
y[j+16]与y[2j+1]同样是一条 store),省下的这条指令只在向量化内核里存在,且访存受限时还常被带宽掩护。故它是「存储时零成本、运行时白赚」的小优化,而非结构性优势。
其他量化方法的配对与布局:规则随后端而异,用意相同——让存储布局贴合硬件原生访问方式。
| 方法 / 后端 | 配对与布局方式 |
|---|---|
| ggml Q4_0 / Q4_1 / Q5_0 / Q5_1 | 低 / 高半字节交叉配对(上图) |
| ggml K-quant(Q4_K / Q5_K / Q6_K) | 同一思路:每 64 个权重里低半字节管前 32、高半字节管后 32;Q5_K 另设独立的高位平面 qh |
| 1 位 / 位平面(如 BitNet) | 符号位单独打包成位平面,一个字装 32 个符号 |
| DP4A / DP2A(INT8 点积指令) | 按点积指令的操作数宽度分组打包 |
| GPU tensor core(Marlin / AWQ / CUTLASS INT4) | 按 MMA 的寄存器分片重排(permute),规则与 CPU 完全不同 |
| bitsandbytes NF4 | 4 位是码本索引,解包靠查表而非按位算术 |
| 分组共享 scale | 各方案都有(group_size / blocksize),只是叫法不同 |
进阶:K-quants 超块设计与二级量化
Q4_0 对称省空间但非对称数据下精度差;Q4_1 精度好但多占了 11 % 的空间。 我们考虑以下问题:能否用 Q4_0 的体积,实现接近 Q4_1的非对称高精度?
- Q4_K 的破局之道:超块与二级量化:
- 超块规模:每个超块包含 256 个权重,细分为 8 个子块(各 32 个权重)。
- 8 个子块各需一个步长与一个偏移量,共 16 个参数。若按 fp16 原样存,要占 字节——几乎赶上权重数据(128 字节)的四分之一。
- 二级量化机制:不直接存这 16 个参数,而是对它们再量化一次,各自以超块级的基准为单位存成 6 位整数:
- 步长以
d为单位量化为 6 位整数sc,偏移以dmin为单位量化为 6 位整数m。 sc与m是 16 个 6 位值,共 位 = 12 字节;d与dmin为 FP16,共 4 字节;参数合计 16 字节。- 位级代价:6 ∤ 8,12 字节里字段无法与字节对齐——16 个 6 位值被劈进相邻字节。
- 步长以
- 块总大小:(4 位权重)+ (参数)= 144 字节 / 256 权重 = 4.5 位/权重,与 Q4_0 持平。
- 反量化:,其中 为 4 位权重码;子块级步长为 、偏移为 。
- 收益:花掉与 Q4_0 相同的 4.5 位/权重,却换到每 32 个权重独立的一步长 + 一偏移(Q4_0 只有一步长、无偏移)。它比 Q4_0 准,又比同样带偏移的 Q4_1(5.0 位/权重)更省——省下的正是「把 16 个 fp16 参数压成 6 位码 + 两个共享 fp16 基准」这一步。
与 K-means 聚类的相通:量化本身就是一种聚类——每个可表示的格点就是一个「簇心」,把权重舍入到最近格点,等同于把样本归入最近簇心; 位均匀量化即一维、簇心被强制等距的 K-means( 个簇)。K-quants 的二级结构则对应层次聚类:超块基准 是粗簇心,子块 是在各粗簇内的细分中心。更进一步,ggml 标定 时用的是迭代最小化(make_qkx* 系列),实质是交替「分配到最近格点 ←→ 重算最优尺度」的 Lloyd 迭代,正是 K-means 的 assign / update 循环。
三种格式在两类权重分布下的实测误差:
| 格式 | 位/权重 | 全正权重实测误差 | 近似对称权重实测误差 |
|---|---|---|---|
| Q4_0 | 4.50 | 4.25% | 8.42% |
| Q4_1 | 5.00 | 0.94% | 7.69% |
| Q4_K | 4.50 | 2.16% | 7.70% |
全正权重(非对称)一列,有无偏移定胜负——4.50 位的 Q4_K(2.16%)远胜同体积的 Q4_0(4.25%),逼近 5.00 位的 Q4_1(0.94%);近似对称一列三者打平(8.4%→7.7%),偏移已无发挥空间。 结论:Q4_K 用 Q4_0 的体积(4.50 位),在非对称数据上拿到了接近 Q4_1 的精度。
量化对象:权重、激活与 KV Cache
先分清三个概念:
- 权重(weights):训练学出来、存在文件里的参数。一次推理中固定不变,就是前面说的「张量数据」——量化的主要对象,记作 W。
- 激活(activations):前向计算中的中间结果,如线性层的输出 、注意力的分数、FFN 的隐层值。它由输入决定、每个 token 都不同,不存文件。量化它记作 A。
- KV Cache:自回归解码时,把每层注意力的 Key / Value 缓存下来,避免每生成一个新 token 都重算前面所有 token。它随已生成序列线性增长,是显存占用的大头,所以单独拿出来量化。
三者在一层里的关系可以粗略看成:
同一套尺子,量不同对象难度天差地别:
| 对象 | 特点 | 量化难度 | 常见位宽 |
|---|---|---|---|
| 权重 | 静态、分布平滑 | 易 | W4 |
| 激活 | 动态、含 outlier | 难 | A8 / A16 |
| KV Cache | 随序列增长、占显存大 | 中 | KV4 / KV8 |
- 命名约定:
W4A16指权重 4 位、激活 16 位;W8A8指两者均 8 位。 - 激活的 outlier 问题:少数通道数值比其它大几个数量级,若按 定格宽,绝大多数通道会被压到接近 0,精度崩塌。对策是先把 outlier 摊平——SmoothQuant(按通道缩放搬移量化难度)、旋转(Hadamard,如 QuaRot / SpinQuant),再做量化。
量化误差的定量分析
设格宽(scale)为 ,量化位宽为 :
- 最大绝对误差:(舍入到最近刻度的最坏情形)。
- 量化噪声:误差近似服从 上的均匀分布,方差为 。
- 信噪比(SNR / SQNR):信号功率 ÷ 噪声功率,常用 dB 读作 的比值;把「噪声」特指为量化误差就是 (又称量化失真比 SQDR)。对满量程正弦信号 : 位下台阶 、噪声功率 ,故 ,取 即 。
量化的系统实现
与矩阵乘法的衔接
反量化后计算与「整数乘、末尾统一缩放」严格等价:
因此硬件可以只做整数乘加(),最后统一乘一次 。两个要点:
- 累加器需用 int32:低精度乘积累加会迅速超出 int8 的范围,必须用更宽的累加器兜住(与第四节「FP32 累加器兜动态范围」同理)。
- 当两侧(权重和激活)都被量化(、)时,整块 GEMM 退化为 乘加加两次标量缩放,这正是低精度张量核(NVIDIA Tensor Core、CPU 的
DP4A)的工作方式。
从这一部分也可以看出来,对于量化而言,节省的算力是有限的,重点是节省访存开销。
权重的搬运路径:从磁盘到计算单元
权重从磁盘走到计算单元,要经过三级存储,分三步:
- 装入主存(①,一次性):CPU 上的进程用
read/mmap把模型文件读进主存(page cache)。 - 拷到显存(②,一次性):用
cudaMemcpy经 PCIe 从主存写进显存,此后权重常驻显存。 - 每 token 一次(③):计算单元把权重从显存读进来,反量化(乘回格宽)后参与矩阵乘。
为什么 ① ② 必须经过 CPU 的内存? 三个层面:
- 语义:磁盘上只有扇区,没有「文件」。目录、inode、块映射这套抽象由内核(CPU 侧)解释,GPU 指令集里没有
open/read。 - 硬件:磁盘 / NVMe 控制器靠 DMA 搬运,目标地址必须是宿主物理地址;显存有独立的物理地址空间,由 GPU 的 MMU 管理,IOMMU 默认不把它映射给块设备。
- API:把数据送进显存的入口
cudaMemcpy,默认源地址就是 host memory。
例外(需专门硬件与驱动,默认不开):GPUDirect Storage(NVMe 直接 DMA 到显存)、GPUDirect RDMA、统一内存。它们通过 PCIe BAR 把显存映射进设备地址空间,让设备能把显存当作 DMA 目标。
与量化的关系:① ② 只在加载时各走一遍,收益是一次性的(文件 1.9 GB 变小);③ 每生成一个 token 都走一遍。LLM 的 decode 阶段是 memory-bound——每生成一个 token 都要把整个权重张量从显存读一遍,所以权重降到 4 位,③ 的访存流量随之降为约 ,decode 吞吐直接提升。这正是显存装得下时,int4 依然能明显加速的原因。
六、One More Thing
浮点重构:从通用规范到专用 AI 浮点
- 位域演变对比:
- FP32:
[1s]+[8 exp]+[23 mantissa] - FP16:
[1s]+[5 exp]+[10 mantissa](动态范围较小,易溢出) - BF16:
[1s]+[8 exp]+[7 mantissa](保全 8 位阶码,动态范围与 FP32 相同)
- FP32:
- NVIDIA Hopper 时代的 1 字节浮点:FP8 双子星规范:
- E4M3(1 位符号,4 位阶码,3 位尾数):
- 尾数多 1 位,精度较高,被建议用于前向传播的权重与激活值。
- 突破 IEEE 规范:废除无穷大()编码,将保留码段释放用来表示更大正常数(最大数达 448)。
- E5M2(1 位符号,5 位阶码,2 位尾数):
- 阶码与 FP16 一致,动态范围较大;被建议用于反向传播的梯度。
- E4M3(1 位符号,4 位阶码,3 位尾数):
非均匀量化:NF4 的信息论等熵分割
- 均匀整数量化的局限:
- 预训练权重按块归一化后,近似服从零均值正态分布(QLoRA 的前提)。
- 等距网格在两端低概率区与中间高概率区分配同样多的格点,中间区域分辨率相对不足。
- 分位数量化原则:
- 让每个编码被取到的概率相等,4 位编码的信息熵达到最大。
- NF4 量化实现机制:
- 取标准正态分布的分位数作为 16 个格点,缩放到 ;为使 0 能被精确表示,负半轴取 7 个、正半轴取 8 个,各区间只是近似等概率。
- 查找表还原:
- 4 位存储的不再是数值,而是 0 ~ 15 的查找表索引;
- 运行时查一张 16 个常量的表,乘以块的缩放系数(absmax)后送入计算。
Code
一、硬件系统与程序映射
系统视图:取指、计算与输出 Token
这张图反映了 CPU 从内存中获取指令和数据计算并输出 Token 的过程:
磁盘上的程序与数据都只是比特序列:先载入内存,再由 CPU 逐条取指、取数、计算,最终输出 Token。
编译:源码到二进制指令
同一段代码的三种表示:
- 高级语言:面向程序员的控制结构与变量符号抽象。
- 汇编指令(Assembly):通过
objdump -d反汇编或gcc -S直接生成,与机器码对应。 - 目标机器码(Object Code):经
gcc -c生成的纯二进制目标文件(ELF),通过hexdump/xxd观察,呈现为十六进制机器字节流。
编译流程:预处理、编译、汇编与链接
GNU 编译系统四阶段:
- 预处理(Preprocess):
- 命令:
gcc -E dot.c -o dot.i - 动作:展开以
#开头的预处理指令(#include,#define),剔除注释,生成纯 C 文本文件.i。
- 命令:
- 编译(Compile):
- 命令:
gcc -S dot.i -o dot.s - 动作:进行词法分析、语法分析等等,将高级语义翻译为机器级汇编文本
.s。
- 命令:
- 汇编(Assemble):
- 命令:
gcc -c dot.s -o dot.o - 动作:将汇编助记符翻译成二进制机器指令,打包为 ELF 格式可重定位目标文件
.o。
- 命令:
- 链接(Link):
- 命令:
gcc dot.o main.o -o dot_product - 动作:解析跨文件符号引用,重定位函数与全局变量地址,合并代码段与数据段,生成最终可执行二进制文件。
- 命令:
指令集架构
程序编译之后得到的是二进制机器码。但 CPU 执行需要能够认识这段二进制所代表的指令,因此二进制编码与 CPU 指令之间的约定必不可少。
指令集架构(ISA: Instruction Set Architecture)的定义:
- ISA 是软件与硬件电路之间达成的接口契约规范,在长期演进中保持严格的向后兼容性。
- ISA包括如下:
- 程序员可见状态:程序计数器(PC /
%rip)、16 个通用寄存器、条件码寄存器(RFLAGS)、连续虚拟内存空间; - 指令编码与格式:每条机器指令的二进制操作码、操作数编码规则与指令长度;
- 支持的数据类型与寻址模式。
- …
- 程序员可见状态:程序计数器(PC /
ISA 是接口(Interface):规定处理器能执行什么; 微架构是实现(Implementation):规定处理器在物理电路层面如何执行。
二、执行计算
寄存器
程序员可见状态(Programmer-Visible State):
- 指令集架构直接暴露给软件程序、能够被指令读取和修改的全部硬件实体:
- 程序计数器(PC /
%rip):存放下一条取指执行指令的虚拟地址; - 通用寄存器堆(Register File):16 个 64 位高速存储单元;
- 条件标志位寄存器(RFLAGS):存放最近一次算术逻辑运算的状态结果;
- 虚拟内存空间:通过地址总线访问的代码、全局变量与栈内存。
- 程序计数器(PC /
为什么通用寄存器只有16个?
- 指令编码字段限制:指令中寄存器字段的位数有限,在定长字段中指定一个寄存器,16 个寄存器只需 4 位二进制( ),若扩展到上千个,指令编码将急剧膨胀;
- 硬件布线与延迟瓶颈:寄存器数量过多会导致内部走线与多路选择器延迟增大,降低最高工作频率。
寄存器是 ALU(计算单元)的数据来源,内存数据被加载到 CPU 寄存器中参与计算。
寻址
如果说寄存器解决了数据”放到哪”的问题,那么寻址则解决了数据”从哪取”的问题。
寻址模式:x86-64 的八种操作数寻址形态
操作数是指令作用的对象,按「值的来源」分两类,共八种形态。
非内存操作数(值就在指令或寄存器里,无需访存):
- 立即数
$Imm:常数直接编码在指令中,对应 C 的常量。 - 寄存器
r_a:操作数即寄存器中的值。
内存操作数(先算出地址,再去内存取):取值为 ,其中有效地址
三个部件均可缺省。
| 形态 | 写法 | 有效地址 | C 对应 |
|---|---|---|---|
| 间接 | (r_a) | *ptr | |
| 基址 + 偏移 | Imm(r_b) | p->field、栈变量 | |
| 变址 | (r_b,r_i) | b[i](char 数组) | |
| 比例变址 | Imm(r_b,r_i,s) | w[i](int 数组) | |
| RIP 相对 | Imm(%rip) | 全局变量 | |
| 绝对 | Imm | 现代 64 位程序极少使用 |
- 括号即解引用:
(%rax)取的是%rax所指内存中的值,而非%rax本身——这正是内存操作数与寄存器操作数的分界。 - 比例因子 ,即 C 基本类型的字节数(
char/short/int/double);它把「首地址 + 下标 × 元素大小」固化成一条指令,是数组访问的硬件支持。 - RIP 相对以「与当前指令的距离」表示位置:编译器只记相对偏移、不记绝对地址,使模块加载到任意位置都无需重定位,即位置无关代码(PIC)。
- 有效地址的加法由 地址生成单元(AGU) 完成,独立于 ALU,不占用运算节拍。
地址计算:lea 指令与硬件 AGU 的电路支持
硬件地址生成单元(AGU: Address Generation Unit):
- CPU 内部包含独立的组合逻辑电路,在单周期内完成有效地址的乘法移位与三数相加。
lea(Load Effective Address)指令: - 语法:
leal Disp(Base, Index, Scale), Dest⟺Dest ← Disp + Base + Index × Scale - 硬件机制:利用 AGU 计算出有效地址后,直接把计算得到的数值写入目的寄存器,不发生任何内存总线访问。
CPU 指令
数据搬运:mov 指令
语义:mov S, D,把源操作数 S 的值复制到目的操作数 D(AT&T 语法源在前、目的在后)。两个操作数各自取立即数、寄存器、内存操作数三种形态之一;后缀 b / w / l / q 指定搬运宽度(1 / 2 / 4 / 8 字节)。
寄存器名字与宽度:%rdx / %edx / %dx / %dl 是同一个 64 位寄存器的四个视图,分别取 64 / 32 / 16 / 8 位,正对应指令后缀 q / l / w / b;它们共享同一块存储,写 %dl 即改变 %rdx 的低 8 位。
执行路径:地址 → TLB 完成虚拟地址到物理地址的转换 → L1 Cache → 读数据通路取出 32 位数据 → 写回寄存器。
L1 是 CPU 核心内的 SRAM 缓存:命中时约 4 周期即可返回,未命中则沿 L2 → L3 → DRAM 逐级下探(DRAM 约 200 周期)。
操作数约束:
- 源与目的不能同时为内存操作数,
movl (%rsi), (%rdi)是非法指令; - 立即数不能作为目的操作数;
- 内存到内存的搬运必须拆成「内存 → 寄存器 → 内存」两条连续指令。
写入子寄存器的位宽规则(x86-64 独有):
- 写 32 位子寄存器(
%eax、%r8d):高 32 位自动清零。%rax = 0xFFFFFFFFFFFFFFFF执行movl $1, %eax后变为0x0000000000000001; - 写 8 位或 16 位子寄存器(
%al、%ax):只改低 8 / 16 位,其余位保留原值。%rax = 0xFFFFFFFFFFFFFFFF执行movb $1, %al后变为0xFFFFFFFFFFFFFF01。
浮点标量用
movss/movsd搬运,语义与movl相同,只是目的从通用寄存器换成%xmm寄存器。