Data

一、位与字节

进制

十六进制以4位二进制为一组

进制C 的写法表示 42
十进制无前缀42
十六进制0x / 0X0x2a
八进制前导 0052
二进制0b / 0B0b101010

字节

类型及其大小

C 类型32 位平台64 位平台说明
char11一个字节,也用于表示字符
short / unsigned short22通常为 2 字节
int44各平台一致
long48随平台变化
long long88至少 8 字节
void *48宽度即字长
float / double4 / 84 / 8IEEE 754 单精度与双精度
long double通常 8 / 12通常 8 / 16由编译器和平台决定
bool / _Bool11逻辑值,通常为 1 字节
size_t48无符号,用于表示长度与大小
  • signed 与 unsigned 不改变对应整型的大小,只改变取值范围
  • 字节是最小的可寻址单位,其地址取这段地址的最小值

字节序

  • 小端:最低有效字节位于最低地址。x86-64 与 ARM 的默认配置
  • 大端:最低有效字节位于最高地址。TCP/IP 首部与部分平台采用
    byte-order
  • 文件格式:GGUF、ELF、PNG 各自规定字节序,跨机器读取才有确定结果
  • 网络传输:TCP/IP 首部采用大端,htons / htonl 完成本机序与网络序的转换
  • 强制类型转换:以另一种类型解释同一段字节时,排列方式随之暴露
  • 反汇编输出:指令编码中的立即数与地址按小端存放

二、整数

有符号数与无符号数

在使用相同位数存储时,有符号数需要用一位表示符号,因此与无符号数的取值范围不同。以下以常见的 w 位整数为例:

对比项有符号数(signed)无符号数(unsigned)
符号可以表示正数、负数和 0只能表示非负数
典型表示补码直接按二进制解释
ww 位取值范围−2w−1-2^{w-1} ~ 2w−1−12^{w-1}-100 ~ 2w−12^w-1
8 位示例-128 ~ 1270 ~ 255
C 语言类型示例signed int、intunsigned int
适用场景可能出现负值的整数、算术运算长度、大小、位掩码及不应为负的值

注意:有符号数溢出在 C/C++ 中属于未定义行为;无符号整数运算按模 2w2^w 回绕。比较有符号数和无符号数时,C 语言可能先将有符号数转换为无符号数,需要特别注意。

Two’s Complement

补码的本质是用模 2n2^n 的剩余类表示整数。设机器字长为 nn,其有符号整数范围为

In=[−2n−1, 2n−1−1].I_n=[-2^{n-1},\,2^{n-1}-1].

编码与解码

定义 nn 位补码编码函数

En(x)=x mod 2n={x,x≥0,2n+x,x<0.E_n(x)=x\bmod 2^n = \begin{cases} x, & x\ge 0,\\ 2^n+x, & x<0. \end{cases}

这里模运算的结果取在 [0,2n−1][0,2^n-1] 内。若一个 nn 位二进制串按无符号数解释为 uu,则补码解码函数为

Dn(u)={u,0≤u<2n−1,u−2n,2n−1≤u<2n.D_n(u)= \begin{cases} u, & 0\le u<2^{n-1},\\ u-2^n, & 2^{n-1}\le u<2^n. \end{cases}

对任意 x∈Inx\in I_n,都有

Dn(En(x))=x,D_n(E_n(x))=x,

所以编码与解码在可表示范围内互为逆映射。

加法正确性

nn 位加法器丢弃最高位之外的进位,执行的实际运算是模 2n2^n 加法:

u⊞v=(u+v) mod 2n.u\boxplus v=(u+v)\bmod 2^n.

对任意 x,y∈Inx,y\in I_n,

En(x)⊞En(y)=(En(x)+En(y)) mod 2n=(x+y) mod 2n=En(x+y).\begin{aligned} E_n(x)\boxplus E_n(y) &=(E_n(x)+E_n(y))\bmod 2^n\\ &=(x+y)\bmod 2^n\\ &=E_n(x+y). \end{aligned}

若 x+y∈Inx+y\in I_n,则

Dn ⁣(En(x)⊞En(y))=x+y.D_n\!\left(E_n(x)\boxplus E_n(y)\right)=x+y.

因此,只要不发生有符号溢出,补码加法与普通整数加法严格一致。发生溢出时,位模式仍与真实结果模 2n2^n 同余,但已不能按 nn 位有符号整数正确解码。

为什么按位取反加一等于相反数

对任意 nn 位无符号值 uu,按位取反满足

∼u=2n−1−u.\mathord{\sim}u=2^n-1-u.

所以

∼u+1=2n−u≡−u(mod2n).\mathord{\sim}u+1=2^n-u\equiv -u\pmod{2^n}.

若 u=En(x)u=E_n(x),便有

(∼u+1) mod 2n=En(−x).(\mathord{\sim}u+1)\bmod 2^n=E_n(-x).

因此补码中可以用“按位取反再加一”求相反数。唯一的例外是最小负数 −2n−1-2^{n-1},因为其相反数 2n−12^{n-1} 超出了可表示范围。

减法与乘法

由于模运算与加法、乘法相容,

En(x−y)≡En(x)−En(y)(mod2n),En(xy)≡En(x)En(y)(mod2n).\begin{aligned} E_n(x-y)&\equiv E_n(x)-E_n(y)\pmod{2^n},\\ E_n(xy)&\equiv E_n(x)E_n(y)\pmod{2^n}. \end{aligned}

因此减法可以转化为加上相反数:

x−y=x+(∼y+1).x-y=x+\left(\mathord{\sim}y+1\right).

乘法器只保留低 nn 位时,得到的也是乘积模 2n2^n 的补码。只要真实结果仍属于 InI_n,解码后便与普通整数运算一致。

符号扩展正确性

设负数 xx 的 nn 位补码按无符号数解释为

u=2n+x.u=2^n+x.

将其扩展到 mm 位时,在高位补 m−nm-n 个 1。新增高位的权值之和为

∑i=nm−12i=2m−2n.\sum_{i=n}^{m-1}2^i=2^m-2^n.

扩展后的无符号值为

u′=u+(2m−2n)=2m+x.u'=u+(2^m-2^n)=2^m+x.

按 mm 位补码解码可得

Dm(u′)=u′−2m=x,D_m(u')=u'-2^m=x,

所以符号扩展不会改变补码表示的整数值。

结论

补码正确性的核心是

En(x)=x mod 2n.\boxed{E_n(x)=x\bmod 2^n}.

它不是经验性的编码技巧,而是将整数映射到剩余类环 Z/2nZ\mathbb Z/2^n\mathbb Z 的自然结果。模运算保持加法、取负、减法和乘法,因此同一套二进制运算电路可以同时处理无符号数与补码有符号数。

有符号数与无符号数的转换

C 允许把一个类型的变量按另一个类型解释,转换有两种发生方式。

  • 显式转换:写出 (unsigned short) x
  • 隐式转换:赋值、函数传参、以及表达式中的类型提升都会发生
  • 有符号与无符号之间的转换不改变任何一位,改变的只是解释规则

混合比较:宽度不同的操作数进行比较

  1. 确定目标类型:先把窄于 int 的类型提升为 int;提升后宽度不同取较宽者,宽度相同、符号不同取无符号
  2. 补 0 还是补符号位由原类型决定,得到的位模式按目标类型解释
比较目标类型被转换的操作数结果
int −1 < unsigned short 1int1 补 0 得 111(真)
short −1 < unsigned short 1int两侧都提升为 int:−1 补符号位,1 补 01(真)
short −1 < unsigned 1unsigned int−1 转换为 232−12^{32}-10(假)
int −1 < unsigned long long 1unsigned long long−1 转换为 264−12^{64}-10(假)
long long −1 < unsigned 1long longunsigned int 的 1 转换为 111(真)

位掩码

掩码(mask)是按照特定模式构造的整数,用来选择、检查或修改另一个整数中的特定位。可以把它理解为一个二进制筛子:掩码中为 1 的位表示需要关注或操作的位置,为 0 的位表示忽略的位置。

例如,使用掩码 00001111200001111_2 提取 10110110210110110_2 的低 4 位:

101101102&000011112=000001102.10110110_2\mathbin{\&}00001111_2=00000110_2.

构造只选中第 kk 位的掩码:

mask=1≪k.\mathrm{mask}=1\ll k.
目的C 表达式原理
检查第 kk 位x & (1u << k)结果非 0 表示该位为 1
设置第 kk 位x |= (1u << k)与 1 按位或会得到 1
清除第 kk 位x &= ~(1u << k)与 0 按位与会得到 0
翻转第 kk 位x ^= (1u << k)与 1 按位异或会翻转
提取一组位x & mask保留掩码为 1 的位置

掩码本身不是特殊的数据类型,而是被赋予了位选择含义的普通整数。位掩码常用于权限标志、硬件寄存器、网络协议以及压缩存储多个布尔状态。

三、浮点数的编码

IEEE 浮点表示的数值形式与编码

bit-fields

对于规格化数,数值形式为

V=(−1)s×(1+f)×2E=(−1)s×(1+f)×2e−BiasV=(-1)^s\times(1+f)\times 2^E =(-1)^s\times(1+f)\times 2^{e-\mathrm{Bias}}

编码分为 ss、expexp、fracfrac 三个字段:

  • 符号位 ss 决定数的正负:s=0s=0 表示正数,s=1s=1 表示负数。
  • expexp 字段存储编码阶码 ee,实际阶码为 E=e−BiasE=e-\mathrm{Bias}。
  • fracfrac 字段编码尾数 ff,规格化数的有效数值为 1+f1+f,其中 f∈[0,1)f\in[0,1)。

其中,单精度的 Bias=127\mathrm{Bias}=127,双精度的 Bias=1023\mathrm{Bias}=1023。当 expexp 全为 0 或全为 1 时,还可表示 0、非规格化数、无穷大和 NaN 等特殊值。

为什么阶码需要偏置值

阶码字段使用无符号整数存储,但实际阶码可能为负数,因此采用偏置编码:

E=e−BiasE=e-\mathrm{Bias}

其中 ee 是 expexp 字段存储的无符号值,EE 是实际阶码。以单精度为例,expexp 有 8 位,Bias=127\mathrm{Bias}=127:

  • e=1e=1 时,E=1−127=−126E=1-127=-126;
  • e=254e=254 时,E=254−127=127E=254-127=127。

使用偏置值的好处是:

  1. 可以表示负阶码,而不必使用补码存储阶码;
  2. 阶码按无符号数比较即可判断大小,便于硬件实现;
  3. 可以保留特殊编码:e=0e=0 用于表示 0 或非规格化数,e=2k−1e=2^k-1 用于表示无穷大或 NaN,其中 kk 为阶码字段的位数。

偏置值不是数学上必需的,而是为了简化浮点数的编码、比较和硬件处理。

IEEE 754 中的特殊值

当 expexp 取特殊值时,浮点数不再按照普通的规格化公式解释:

expexpfracfrac表示数值或含义
全为 0全为 0+0+0 或 −0-0(−1)s×0(-1)^s\times0
全为 0非 0非规格化数(−1)s×f×21−Bias(-1)^s\times f\times2^{1-\mathrm{Bias}}
全为 1全为 0+∞+\infty 或 −∞-\infty表示溢出或除以 0 的结果
全为 1非 0NaNNot a Number,表示无效或未定义的运算结果

其中,s=0s=0 表示正值,s=1s=1 表示负值。NaN 不表示一个确定的数,因此与任何数(包括它自身)的比较通常都为假。

8 位浮点格式的编码示例

下面采用教学用 8 位浮点格式:1 位符号位、4 位阶码和 3 位尾数,即

s ∣ exp ∣ frac.s\ \vert\ exp\ \vert\ frac.

阶码偏置为

Bias=24−1−1=7.\mathrm{Bias}=2^{4-1}-1=7.

对于规格化数,E=e−7E=e-7,数值为

V=(−1)s(1+f)2E.V=(-1)^s(1+f)2^E.

对于非规格化数,数值为

V=(−1)sf21−7.V=(-1)^sf2^{1-7}.
位模式或数值类型转换过程结果
0 0110 1000\ 0110\ 100规格化数E=6−7=−1E=6-7=-1,1+f=1.1002=1.51+f=1.100_2=1.51.5×2−1=0.751.5\times2^{-1}=0.75
2.5102.5_{10}规格化数2.5=1.0102×212.5=1.010_2\times2^1,e=1+7=8=10002e=1+7=8=1000_20 1000 0100\ 1000\ 010
0.01120.011_2规格化数0.0112=1.1002×2−20.011_2=1.100_2\times2^{-2},e=−2+7=5=01012e=-2+7=5=0101_20 0101 1000\ 0101\ 100
0 0000 1000\ 0000\ 100非规格化数f=0.1002=12f=0.100_2=\frac12,E=1−7=−6E=1-7=-612×2−6=2−7=0.0078125\frac12\times2^{-6}=2^{-7}=0.0078125
0 1111 0000\ 1111\ 000正无穷大阶码全为 1,尾数全为 0+∞+\infty
0 1111 1000\ 1111\ 100NaN阶码全为 1,尾数不为 0NaN

判断时应先检查阶码字段:阶码既不全为 0 也不全为 1时,才使用规格化数公式;阶码全为 0 时表示零或非规格化数;阶码全为 1 时表示无穷大或 NaN。

精度总位数exp 位数frac 位数
单精度32823
双精度641152

四、浮点数的精度与舍入机制

实数轴上的非均匀离散分布与相对精度

ULP:浮点数的网格间距

ULP 是 Unit in the Last Place 的缩写,表示最低有效位的单位。对于给定阶码区间,它通常表示相邻两个可表示浮点数之间的距离,即浮点数在实数轴上的网格间距。

公式

设规格化浮点数有 tt 位尾数字段。若 ee 是编码阶码,E=e−BiasE=e-\mathrm{Bias} 是实际阶码,则

V=(−1)s(1+f)2E.V=(-1)^s(1+f)2^E.

在区间 [2E,2E+1)[2^E,2^{E+1}) 内,相邻规格化浮点数的间距为

ULP⁡(x)=2E−t.\boxed{\operatorname{ULP}(x)=2^{E-t}}.

IEEE 754 单精度有 t=23t=23,因此

ULP⁡(x)=2E−23.\boxed{\operatorname{ULP}(x)=2^{E-23}}.
证明

将 tt 位尾数字段按无符号整数解释为 FF,其中 0≤F<2t0\le F<2^t。尾数小数为

f=F2t.f=\frac{F}{2^t}.

所以浮点数可以写成

V(F)=(−1)s(1+F2t)2E.V(F)=(-1)^s\left(1+\frac{F}{2^t}\right)2^E.

同一阶码区间内,相邻浮点数的尾数字段相差 1,即 Fnext=F+1F_{\mathrm{next}}=F+1。忽略符号,只计算两者的绝对距离:

∣V(F+1)−V(F)∣=[(1+F+12t)−(1+F2t)]2E=12t2E=2E−t.\begin{aligned} \left|V(F+1)-V(F)\right| &=\left[\left(1+\frac{F+1}{2^t}\right)-\left(1+\frac{F}{2^t}\right)\right]2^E\\ &=\frac{1}{2^t}2^E\\ &=2^{E-t}. \end{aligned}

因此单精度取 t=23t=23 时,得到 2E−232^{E-23}。指数中的 −23-23 来自尾数字段最低位的权重 2−232^{-23}。

为什么不是 2E−242^{E-24}

单精度规格化数虽然具有 24 位二进制有效数字,但最高位 1 是固定的隐含位:

1.b1b2⋯b23.1.b_1b_2\cdots b_{23}.

真正能够变化的最低位是 b23b_{23},其权重为 2−232^{-23},所以网格间距是 2E−232^{E-23}。采用舍入到最近值时,最大舍入误差约为半个 ULP:

12ULP⁡(x)=2E−24.\frac12\operatorname{ULP}(x)=2^{E-24}.
几何意义

在单精度格式中:

数值区间实际阶码 EEULP
[1,2)[1,2)002−232^{-23}
[2,4)[2,4)112−222^{-22}
[4,8)[4,8)222−212^{-21}

每跨过一个 2 的整数次幂,实际阶码增加 1,ULP 随之翻倍。因此浮点数越大,绝对网格间距越大,但相对间距大致保持不变。

对于单精度非规格化数,没有隐含的最高位 1,所有相邻非规格化数的间距固定为

2−149.2^{-149}.

ulp-spacing

浮点加法:阶码对齐、GRS 位与吸收现象

阶码对齐

设两个规格化浮点数为

x=Mx2Ex,y=My2Ey,x=M_x2^{E_x},\qquad y=M_y2^{E_y},

并假设 Ex≥EyE_x\ge E_y。令阶码差

d=Ex−Ey.d=E_x-E_y.

将较小数改写为与较大数相同的阶码:

y=My2Ey=(My2−d)2Ex.y=M_y2^{E_y}=\left(M_y2^{-d}\right)2^{E_x}.

因此,浮点加法需要先把较小阶码操作数的尾数向右移动 dd 位,再执行尾数运算:

x+y=(Mx+My2−d)2Ex.x+y=\left(M_x+M_y2^{-d}\right)2^{E_x}.

阶码对齐的本质是统一二进制小数点的位置。右移并不改变操作数的数学值,但可能使低位超出尾数寄存器的物理位宽。

GRS 位

为正确判断舍入方向,硬件不会简单丢弃所有被移出的位,而是将其信息压缩为三个附加位:

  • G(Guard,保护位):保留尾数字段之后的第一位;
  • R(Round,舍入位):保留尾数字段之后的第二位;
  • S(Sticky,粘滞位):其余所有被移出位的逻辑或;只要其中任意一位为 1,便有 S=1S=1。

设保留结果的最低位为 LL。在“舍入到最近值,距离相同时取偶数”模式下,通常在

G=1且(R∨S∨L)=1G=1\quad\text{且}\quad(R\lor S\lor L)=1

时向上舍入。其中 G=1,R=0,S=0G=1,R=0,S=0 表示结果恰好位于两个浮点数的中点,此时由 LL 决定是否进位,从而使最终最低位为偶数。

位级舍入判决:与 GRS 的对应

设保留位数截止于第 LL 位(最后保留位),被截断部分的首位权重恰为末位的 12\tfrac12。因此

截断部分=0.5 ULP  ⟺  位模式为 1000…02.\text{截断部分}=0.5\,\mathrm{ULP}\iff\text{位模式为 }1000\ldots0_2.

「最高位为 1、其后全 0」正是「恰好落在两个可表示数正中间」的位级化身。它与 GRS 三位一一对应:

位级量GRS含义
截断段首位的 11GG是否越过半个 ULP 的门槛
截断段次位RR
剩余所有位的逻辑或SS后半段是否还有非零位
保留部分末位LL决定 tie 时取偶

硬件据此做三级裁决:

  1. 截断段 0xxx…0xxx\ldots(G=0G=0):小于中点,直接舍弃;
  2. 截断段 1xxx…1xxx\ldots(G=1 ∧ (R∨S)=1G=1\ \land\ (R\lor S)=1):大于中点,进位;
  3. 截断段 1000…01000\ldots0(G=1, R=0, S=0G=1,\ R=0,\ S=0):恰为中点,按 LL 定夺——L=1L=1 进位使其变偶,L=0L=0 舍弃。

三级可合并为一个纯组合逻辑表达式(即 GRS 判决式):

round_up=G∧(R∨S∨L).\boxed{\text{round\_up}=G\land(R\lor S\lor L)}.

进位传播:若保留部分各位全为 11,进位将逐位穿过并溢出尾数最高位。规格化表示把该溢出解释为小数点左移一位,于是尾数清零、阶码 +1+1,数值跨入下一个 22 的幂次区间(ULP 随之翻倍)。例如 1.111…12+0.000…12=10.000…02=1.000…02×2E+11.111\ldots1_2+0.000\ldots1_2=10.000\ldots0_2=1.000\ldots0_2\times2^{E+1}。

舍入判决算例(4 位尾数)

取 p=4p=4,有效数字为 1.f1f2f3f41.f_1f_2f_3f_4。对齐后,落在结果 LSB(小数点后第 44 位)右侧的位即截断位:截断位第 1 位给 GG、第 2 位给 RR、其余或起来给 SS。

例 A:进位(截断位 111111,G=1G=1 且 R∨S=1R\lor S=1)

x=1.1010×24,y=1.0111×21,d=3,y′=0.0010111×24.x=1.1010\times2^{4},\quad y=1.0111\times2^{1},\quad d=3,\quad y'=0.0010111\times2^{4}.
列号:   1 2 3 4 | 5 6 7
x   :   1 0 1 0 |
y'  :   0 0 1 0 | 1 1 1
1.1100111→截 4 位 1.1100 (L=0) → 1.1101×24.1.1100111\to \text{截 4 位 }1.1100\ (L=0)\ \to\ \boxed{1.1101\times2^{4}}.

例 B:平局(截断位 100100,G=1, R=S=0G=1,\ R=S=0,由 LL 定夺)

x=1.0101×25,y=1.1110×23,d=2,y′=0.011110×25.x=1.0101\times2^{5},\quad y=1.1110\times2^{3},\quad d=2,\quad y'=0.011110\times2^{5}.
列号:   1 2 3 4 | 5 6
x   :   0 1 0 1 |
y'  :   0 1 1 1 | 1 0
1.11001→截 4 位 1.1100,G∧(R∨S∨L)=1∧(0∨0∨0)=0,1.11001\to \text{截 4 位 }1.1100,\quad G\land(R\lor S\lor L)=1\land(0\lor0\lor0)=0,

L=0L=0 已为偶数,不进位,结果 1.1100×25\boxed{1.1100\times2^{5}}。

IEEE 754 舍入模式与无偏性机制

舍入模式总结

  • IEEE 754 标准规定的四种舍入模式:
    1. 向零截断:直接舍弃小数,方向始终朝向原点。
    2. 向下舍入:取不大于真值的最大浮点数。
    3. 向上舍入:取不小于真值的最小浮点数。
    4. 向最近偶数舍入:系统默认工作模式。
  • 传统四舍五入的系统缺陷:
    • 当真值恰好落在正中时一律向上取整,在对大规模正数序列进行持续累加时,会引入正向的系统性统计偏差。
  • 向偶数舍入的无偏性设计:
    • 仅在真值恰好处于两个可表示值正中时生效:强制选择最低有效位为 0(偶数)的一侧。
    • 在统计上,中点边界处向上与向下舍入的概率各为 50%,累积舍入误差的数学期望为 0。

浮点算术对代数公理的违背与灾难性抵消

  • 加法结合律的失效:(a+b)+c≠a+(b+c)(a+b)+c\neq a+(b+c)。
    • 运算顺序改变了中间结果的阶码对齐截断量,导致结合律与分配律在浮点运算下不再成立。
  • 灾难性抵消:
    • 当两个相近的浮点数执行减法时,高位有效数字完全抵消,尾部因有限精度产生的舍入误差被提升为主导有效位,导致有效数字位数断崖式下跌。

cancellation

  • 十进制有限小数与二进制循环小数的鸿沟:
    • 0.110=0.000110011…20.1_{10}=0.000110011\ldots_2,在二进制中为无限循环小数,无法用有限位精确表示,故字面量 0.1 存储在内存中即为不可逆的近似截断值。

C 语言浮点转换规则、精度损失与未定义行为

  • 转换矩阵与精度保真度分析:
    • int 转 double:保真。int 具有 31 位精度,double 尾数位宽为 53 位,可精确容纳。
    • int 转 float:有损。float 仅提供 24 位有效精度,超过 2242^{24}(16,777,216)的整型值将发生舍入。
    • float 转 double:精确无损扩展;double 转 float:面临数值溢出或低位舍入。
    • 浮点数转整数:统一采用向零截断。
  • 越界未定义行为与底层硬件处理:
    • 当浮点数数值超出目标整型表示范围(如 (int)3e9),ISO C 标准定义其为未定义行为。
    • x86-64 体系结构下,转换指令对越界或无效浮点数统一输出固定的特殊值:0x80000000(即 −231-2^{31},即 INT_MIN)。
      cast-paths

现代体系结构扩展:动态范围与相对精度的折中

位宽约束下的设计权衡公理:FP16 总位宽固定为 16 位,划分为 1+5+101+5+10,两种资源此消彼长:

  • 阶码字段决定动态范围(数值上下界之比):kk 位阶码使动态范围约为 22(2k−1−1)2^{2(2^{k-1}-1)};
  • 尾数字段决定相对精度(相邻可表示数的相对间距):tt 位尾数给出相对精度 ∼2−(t+1)\sim 2^{-(t+1)},其中 ULP[1,2)=2−t\mathrm{ULP}_{[1,2)}=2^{-t}。

由于 1+k+t=161+k+t=16 固定,拓宽动态范围必然牺牲相对精度。

FP16 的工业局限:阶码仅 5 位,Bias=15\mathrm{Bias}=15,实际阶码 E∈[−14, 15]E\in[-14,\,15],故最大表示值受限于

Vmax⁡=(2−2−10)×215=65504,V_{\max}=\left(2-2^{-10}\right)\times2^{15}=65504,

归一数下限为 2−14≈6.1×10−52^{-14}\approx6.1\times10^{-5}(非规格化最低到 2−242^{-24}),相对精度约 2−11≈4.9×10−42^{-11}\approx4.9\times10^{-4},即约 3 位十进制有效数字。

格式位分配 (ss/exp/frac)动态范围上界十进制有效位核心应用领域
FP321/8/23≈3.4×1038\approx3.4\times10^{38}∼7\sim7科学计算、基准训练
FP161/5/106550465504(易溢出)∼3\sim3图形学、边缘推理
BF161/8/7≈3.4×1038\approx3.4\times10^{38}(与 FP32 同)∼2\sim2大模型训练与推理

BF16 把 3 位尾数让给阶码,动态范围回到 FP32 同级而精度骤降,是”范围优先”的极端;FP16 则是相反的”精度优先”极端。

深度学习的上下溢与软件补偿:反向传播连乘梯度,数值尺度可跨越数十个数量级。FP16 两端狭窄,∣x∣>65504|x|>65504 上溢为 ±∞\pm\infty(进而产生 NaN),x<2−14x<2^{-14} 下溢为 00(参数停止更新)。对策:

  • Loss Scaling:放大 loss 使梯度落入可表示区间,反传后再缩回;
  • 混合精度(AMP):权重与累加器保留 FP32 兜住动态范围,热点运算用 FP16 换吞吐。

BF16 的位布局对齐优势:BF16 与 FP32 的符号位、阶码位(各 1+81+8 位)在物理位置上严格一致,BF16 的位 15..0 逐位等于 FP32 的位 31..16,阶码含义与 Bias\mathrm{Bias} 完全相同。

  • FP32 → BF16:仅需截取高 16 位(单条移位/截取指令);round-to-nearest-even 也只需按位 15 与粘滞位加一个小增量。
  • BF16 → FP32:低位补 16 个零即可,无需重算偏置、无需规格化。

对比 FP16:阶码宽度 5↔85\leftrightarrow8 不一致,转换必须动态重算偏置、判断溢出/下溢并处理非规格化移位,逻辑门开销远高于 BF16。因此 BF16 可直接复用 FP32 的阶码通路与累加器,这正是”范围优先”路线在硬件上高效落地的原因。

五、大模型量化的底层系统原理

量化动因:精度冗余、访存受限,硬件收益

高精度神话的破灭

IEEE 754 FP32 的历史使命

  • 23 位尾数带来约 7 位有效十进制精度:尾数最低位的间隔为 2−23≈1.2×10−72^{-23}\approx1.2\times10^{-7},即相对精度约 10−710^{-7}。
  • 设计初衷:为数值计算统一浮点格式与舍入规则;对误差要求更严的计算,通常改用 FP64。

大模型的参数与表征本质

  • 现代大模型是典型的超大规模过参数化系统,参数量从十亿级到万亿级。
  • 自注意力机制的核心逻辑:推理结果主要取决于 Softmax 归一化后注意力分布的相对大小,以及激活通道之间的相对强弱。
  • 实验观察:采用合适的量化方法,许多大模型的权重降到 8 位或 4 位后精度只小幅下降;但量化误差仍会改变 logit 与生成结果。

系统结论:在大模型时代强行沿用传统科学计算中高能耗、大位宽的 FP32,不仅在算力和显存上存在极大浪费,还会成倍增加自回归推理需要搬运的字节数,加剧「显存带宽」这一瓶颈。

硬件基础:内存带宽决定生成速度上限

基本量:计算量、访存量、带宽与算术强度

物理量符号含义单位
计算量WW一次任务执行的浮点运算次数,1 次乘加计 2 次FLOP
访存量QQ一次任务在内存与芯片之间传输的字节数Byte
峰值算力PP芯片每秒最多执行的浮点运算次数FLOP/s
带宽BB内存每秒最多向芯片传输的字节数Byte/s
算术强度I=W/QI=W/Q每传输 1 字节数据执行的运算次数FLOP/Byte

现状:单请求自回归推理时,每生成一个 token 都要把全部权重从内存搬进芯片一遍,而每个权重只参与 1 次乘加——算术强度极低。所以生成速度的上限由带宽决定:

Tmax⁡=BQ=峰值带宽权重数据量T_{\max}=\frac{B}{Q}=\frac{\text{峰值带宽}}{\text{权重数据量}}

(前提是每个 token 恰好读一遍全部权重;KV cache、激活、反量化与内核效率都未计入,故实测低于此值。)

以 Llama-3.2-1B(1.24B)为例,同一模型换成不同位宽:

量化格式平均位宽模型大小DDR5-7200(115.2 GB/s)RTX 5090(1792 GB/s)
BF16162.47 GB47 token/s725 token/s
Q8_08.51.31 GB88 token/s1365 token/s
Q4_K4.50.70 GB166 token/s2578 token/s

结论:Tmax⁡∝1/位宽T_{\max}\propto1/\text{位宽}——位宽减半,速度上限约翻倍。量化省下的不是算力,而是每个 token 必须搬运的字节数。

问题实测:自回归推理深陷访存受限

roofline-latency

IEEE Micro 2024 实测(序列长度 4096):

模型计算量访存量算术强度相对时延
BERT-Base1324 GFLOPs11.2 GB11884
GPT-21012 GFLOPs507.8 GB22344

两者计算量相近,访存量却差约 45 倍,时延差约 28 倍。根源在算术强度:

  • BERT(整段序列一次前向传播):权重被全部 token 复用 → 算术强度 117~266 → 算力受限,上限由峰值算力决定。
  • GPT-2(自回归逐 token 生成):每步只处理 1 个 token,却要遍历全部权重 → 算术强度仅 2 → 访存受限,上限由带宽决定,计算单元大部分时间在等数据。

以 RTX 5090 的平衡点(209.5 TFLOPS÷1792 GB/s≈117209.5\ \text{TFLOPS}\div1792\ \text{GB/s}\approx117)为参照,单请求推理的算术强度只有它的 1/1171/117,计算单元利用率上限不足 1%。

量化的作用:权重压到 4 位,数据量降至约 1/41/4,工作点在 Roofline 上右移、算术强度约提高 4 倍,直接缓解访存瓶颈。

roofline-shift

Roofline 模型(Williams et al., 2009):以算术强度 I=W/QI=W/Q 为横轴、性能为纵轴,上限为

Pmax⁡=min⁡(P, B×I).P_{\max}=\min\left(P,\ B\times I\right).

  • 斜线 B×IB\times I 是带宽屋顶,水平线 PP 是算力屋顶,交于拐点 I∗=P/B≈117I^*=P/B\approx117。
  • 拐点左侧访存受限(性能 =B×I=B\times I,算力闲置),右侧算力受限(性能 =P=P,带宽闲置)。
  • 单请求推理在 I=1I=1 处,远在拐点左侧。量化把工作点右移、性能沿斜线抬升,但并未翻过拐点——瓶颈仍是带宽。

微架构视角:整数单元又小又省电

浮点运算单元在硅片上又大又费电。45nm 工艺下的对照:

运算单元面积 (μm2\mu m^2)每次能耗 (pJ)相对能耗
FP32 乘法77003.7123×123\times
INT8 乘法2820.26.7×6.7\times
INT8 加法360.031.0×1.0\times(基准)
  • 8 位整数加法器只需一条进位链,面积约为 FP32 加法器的 1/1161/116;乘法器约为 1/271/27。
  • 做 1 次 FP32 乘法的电能,够做 120 多次 INT8 加法。

结论:整数运算在能效和面积上都远优于浮点,这正是量化能在硬件上兑现收益的物理基础。

量化方法

量化概览

量化即定点化:把连续的浮点数映射到有限个整数刻度上。它与第四节 FP16/BF16 的位数分配是同一权衡(范围 vs 精度),与第五节 ULP 的「格宽」思想同源——区别只是格宽由整数位宽决定,而非由阶码决定。

量化是将模型权重文件的数据进行映射,我们自然会产生以下问题:模型权重文件中的数据是什么?为什么将浮点数映射为整数就可以减少开销?量化为什么几乎不会损失模型的性能?

下面是对上述问题的回答:

原始权重文件只有两类内容:一是张量数据——各层的 Wq/Wk/Wv/WoW_q/W_k/W_v/W_o、MLP 的 gate/up/down、embedding 表、RMSNorm 的 γ\gamma,以 FP16 / BF16 / FP32 的浮点形式存放;二是元数据——格式版本,各张量的名字 / dtype / shape / 偏移,以及 n_layer、n_head、RoPE θ\theta、tokenizer 等超参。量化只做了两处改动:把张量的 dtype 由浮点换成整数(如 Q4_K),并新增一组量化参数(每个分组的 scale,非对称再多一个零点 zz)用于还原;元数据基本不变,只是 dtype 字段记录了新的整数类型。

需注意,无论量化与否,文件里都没有指令、也没有计算图,它只是带索引的字节,怎么算由外部推理程序决定。


节省的主要是访存:解码时每生成一个 token 都要把全部权重从显存搬进计算单元过一遍,瓶颈在带宽而非算力(Roofline 拐点左侧)。位宽 16→416\to4 后单次搬运字节数近乎 1/4,带宽受限的生成速度上限随之提高。主导项是每 token 的权重流量,文件加载与显存容量只是次要收益。

为什么能省:权重是静态的、分布平滑且近似对称的,冗余精度可换成更粗的格子——同一批数用更少的位记下,字节数自然变少。文件保存整数,计算时再映射回浮点近似值参与运算。大大减少了数据的访存开销。

代价与边界:还原值与原点之间有误差;且不一定省计算——权重-only(W4A16)仍在 FP16 上算、甚至多一步反量化,只有连激活一起量化(W8A8)或进入大 batch / prefill 的算力受限场景,整数单元的面积与能耗优势才显现。

model-load-path


为什么量化对模型性能的影响不大:知识存在高有效位(量级与方向),量化丢的是低有效位(尾数),而低位权重大多是被冗余掉的噪声。同时,加之三点:① 误差有界且近似零均值,矩阵乘求和时按 1/N1/\sqrt N 相互抵消;② 网络过参数化、分布式表征,功能不靠单个权重;③ 训练本身(dropout、批噪声)已把解推向平坦区,RMSNorm 与残差再吸收小扰动。故数据变了,端到端性能几乎不变——这就是「精度冗余」的定量表述。前提是位宽 ≥4\ge4、分组且有校准;≤3\le3 位或激活量化仍会明显掉点,且 PPL 不掉 ≠\ne 推理能力不掉。

均匀仿射量化

映射任务:把 [wmin⁡,wmax⁡][w_{\min},w_{\max}] 线性地贴到 bb 位整数集合 [qmin⁡,qmax⁡][q_{\min},q_{\max}] 上:均匀指格点等距,仿射指映射是「缩放 + 平移」w↦dq+mw\mapsto dq+m。

q=clip⁡ ⁣(⌊w−md⌉, qmin⁡,qmax⁡),w^=d×q+mq=\operatorname{clip}\!\left(\left\lfloor \frac{w-m}{d}\right\rceil,\ q_{\min},q_{\max}\right),\qquad \hat w=d\times q+m
  • 步长 d=wmax⁡−wmin⁡qmax⁡−qmin⁡d=\dfrac{w_{\max}-w_{\min}}{q_{\max}-q_{\min}}:尺子的格宽,等价于前面的 scale ss。
  • 偏移 m=wmin⁡−d qmin⁡m=w_{\min}-d\,q_{\min}:整数 qmin⁡q_{\min} 对应的浮点值。mm 不一定是 dd 的整数倍,所以实数 0 未必落在格点上。
  • clip 把超出量程的值截回端点,否则分布外的 outlier 会编码成越界整数。

取 s=d, z=−m/ds=d,\ z=-m/d,两式即可与原记号互换。对称量化取 m=0, qmin⁡=−qmax⁡m=0,\ q_{\min}=-q_{\max},格坐标系原点压在 w=0w=0 上;非对称量化取一般 m≠0m\ne 0,允许区间任意偏斜。

对称量化

核心:用刻度稀疏的「短尺子」表示浮点数——存下「落在第几格」的整数,计算时乘回格宽还原近似值。FP32 每数 32 位,而权重容忍微小误差,故可用 int4(16 个刻度)大幅压缩。

三步(设最大刻度 qmax⁡q_{\max},如 4 位取 77):

  1. 定格宽 scale=max⁡∣w∣qmax⁡\text{scale}=\dfrac{\max|w|}{q_{\max}}(取组内最大绝对值,保证不溢出)
  2. 量化 q=round⁡(w/scale)q=\operatorname{round}(w/\text{scale})
  3. 反量化 w^=q×scale\hat w=q\times\text{scale}

算例(scale=0.97/7≈0.14\text{scale}=0.97/7\approx0.14):

原值 ww整数 qq还原 w^\hat w
0.1210.14
−0.53−4−0.56
0.9770.97
−0.08−1−0.14

要点:存储只需整数 qq + 一个共享 scale;计算经反量化后进行,工程上多用整数乘法末尾统一缩放(等价且更快);误差来自舍入与尺子粗细,位数越少误差越大。

非对称量化

对称量化把 max⁡∣w∣\max|w| 顶到尺子端点,要求分布关于 0 大致对称;若分布明显偏斜,一半刻度空转。非对称量化改用实际的 [wmin⁡,wmax⁡][w_{\min},w_{\max}] 定尺子,并引入零点 zz:

s=wmax⁡−wmin⁡2b−1,z=round⁡ ⁣(−wmin⁡s),s=\frac{w_{\max}-w_{\min}}{2^b-1},\qquad z=\operatorname{round}\!\left(-\frac{w_{\min}}{s}\right), q=round⁡ ⁣(ws)+z,w^=(q−z)s.q=\operatorname{round}\!\left(\frac{w}{s}\right)+z,\qquad \hat w=(q-z)s.
维度对称非对称
零点固定为 0zz 随组变化
额外存储仅 scalescale + zz
适合分布近似对称(权重)偏斜、单边(ReLU 后的激活)
零点保真w=0w=0 精确映射为 q=0q=0w=0w=0 未必落在整数刻度的零点上
瓶颈对比
瓶颈对称量化非对称量化
精度分布偏斜时量程被撑大一倍、半数刻度空转,等效损失约 1 位;权重近似零均值故几乎无损量程贴合实际分布,同样位宽下格宽更细
存储每组仅 1 个 scale,≈b+32g\approx b+\dfrac{32}{g} 位每组多存零点 zz,≈b+64g\approx b+\dfrac{64}{g} 位
计算z=0z=0,反量化即乘 ss,退化为纯整数点积反量化多一步减零点;矩阵乘展开出三个一阶修正项
kernel可套用最简对称 int4 / int8 内核需带零点修正的分支,内核更复杂、更难向量化
校准只需统计 max⁡∣w∣\max\lvert w\rvert(一趟)需同时统计 min⁡\min 与 max⁡\max(两趟)
适用权重偏斜、单边的激活

kernel(计算内核)指算子在硬件上的底层实现代码(如 CUDA 发射到线程上执行的函数),而非操作系统的「内核」。

把仿射式代进矩阵乘,非对称多出的部分是一阶修正:

∑(a−za)(b−zb)=∑ab  −  za∑b  −  zb∑a  +  Nzazb,\sum (a-z_a)(b-z_b)=\sum ab\;-\;z_a\textstyle\sum b\;-\;z_b\sum a\;+\;N z_a z_b,

主项仍是最便宜的整数点积;∑b\sum b、∑a\sum a 可按行列预计算摊薄。故在访存受限的推理里,这点额外计算几乎不可见——非对称真正付出的是存储与内核复杂度,不是时间。

对称输在精度(浪费刻度),非对称输在工程(存储与 kernel)。 因此常规取舍是「权重对称、激活非对称」;若要全用对称内核,就先以 SmoothQuant / Hadamard 旋转把激活分布摊平,再上对称 int8——用对称的简单内核拿到非对称的精度收益。

离群点困境:量程与分辨率的零和

权重近似钟形分布:绝大多数数值挤在 0 附近,两翼伴随少量极端离群值。怎么定量程本身就是一对取舍——量程越宽,格宽越大、分辨率越低。

准则量程取法收益代价
MinMax覆盖全部极值:s=max⁡∣w∣qmax⁡s=\dfrac{\max\lvert w\rvert}{q_{\max}}无截断误差一个离群尖刺就把 ss 拉大,中间大量正常权重被挤进 0 附近少数几格,舍入误差剧增
饱和截断设阈值 α\alpha,超出 ±α\pm\alpha 的强行截断为该值:s(α)=αqmax⁡s(\alpha)=\dfrac{\alpha}{q_{\max}}ss 显著缩小,核心有效区网格致密两端尾部承担截断误差

核心权衡:选取 α\alpha,使截断误差与舍入误差之和最小。α\alpha 减小,截断误差升、舍入误差降,两者相抵处即为最优阈值。

工程实现就是在这一维上做搜索:按 percentile 截断,或直接在 α\alpha 上网格搜索取 MSE 最小者(TensorRT 的 INT8 校准即此思路)。对离群通道先用 SmoothQuant / 旋转把尾巴摊平,也正是为了让 MinMax 不被单个尖刺拉爆。

量化粒度

「一组」(指量化时把数据分组)共用一个 scale(步长),划法有三档:

粒度一组范围精度额外开销
per-tensor整个张量最低1 个 scale
per-channel每个输出通道(一列 / 一行)中通道数个 scale
per-group每 gg 个权重最高权重数 /g/g 个 scale

组越小,尺子越贴合局部分布、越准,但 scale 本身要占空间。平均每权重位数

bits/weight≈b+32g\text{bits/weight}\approx b+\frac{32}{g}

其中 bb 是量化位宽(每个权重本身占的位数,如 int4 时 b=4b=4),gg 是分组大小(每 gg 个权重共用一个 scale)。bb 是权重数据的开销,32g\dfrac{32}{g} 是分摊到每个权重上的 scale 开销。

(假定 scale 用 fp32,即每个 scale 占 32 位、由 gg 个权重分摊)。int4、g=128g=128 时约 4.254.25 位/权重,g=32g=32 时约 55 位——这正是 GPTQ / AWQ 默认取 g=128g=128 的原因:在精度与开销之间折中。

那么,真实格式里的「一组」长什么样?

以 GGUF 4 位为例:一个块覆盖 32 个权重,即 g=32g=32。块头是 fp16 的 d(scale)d(scale)(非对称再多一个 fp16 的 mm),其后 16 字节打包 32 个 4 位权重——一个字节装两个。

gguf-q4-blocks
对于每一个字节中装的两个权重在GGUF 4位量化中并不是相邻配对的,而是采用了交叉配对。

排法字节 jj 装抽低半字节抽高半字节
交叉配对w[j]w[j]、w[j+16]w[j+16]w[0..15]w[0..15] 连续w[16..31]w[16..31] 连续
相邻配对w[2j]w[2j]、w[2j+1]w[2j+1]w[0,2,4,… ]w[0,2,4,\dots]w[1,3,5,… ]w[1,3,5,\dots]
  • 交叉配对优势:向量指令一次只能写一整排连续的数,因此必须先在寄存器里把顺序摆正。交叉配对的低 / 高半字节天然就是输出的连续两半,一次 & 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 NF44 位是码本索引,解包靠查表而非按位算术
分组共享 scale各方案都有(group_size / blocksize),只是叫法不同

进阶:K-quants 超块设计与二级量化

Q4_0 对称省空间但非对称数据下精度差;Q4_1 精度好但多占了 11 % 的空间。 我们考虑以下问题:能否用 Q4_0 的体积,实现接近 Q4_1的非对称高精度?

q4k-superblock

  • Q4_K 的破局之道:超块与二级量化:
    • 超块规模:每个超块包含 256 个权重,细分为 8 个子块(各 32 个权重)。
    • 8 个子块各需一个步长与一个偏移量,共 16 个参数。若按 fp16 原样存,要占 16×2=3216\times2=32 字节——几乎赶上权重数据(128 字节)的四分之一。
    • 二级量化机制:不直接存这 16 个参数,而是对它们再量化一次,各自以超块级的基准为单位存成 6 位整数:
      • 步长以 d 为单位量化为 6 位整数 sc,偏移以 dmin 为单位量化为 6 位整数 m。
      • sc 与 m 是 16 个 6 位值,共 16×6=9616\times6=96 位 = 12 字节;d 与 dmin 为 FP16,共 4 字节;参数合计 16 字节。
      • 位级代价:6 ∤ 8,12 字节里字段无法与字节对齐——16 个 6 位值被劈进相邻字节。
    • 块总大小:128128(4 位权重)+ 1616(参数)= 144 字节 / 256 权重 = 4.5 位/权重,与 Q4_0 持平。
    • 反量化:w=d⋅sc⋅q−dmin⋅mw=d\cdot sc\cdot q-dmin\cdot m,其中 qq 为 4 位权重码;子块级步长为 d scd\,sc、偏移为 dmin mdmin\,m。
    • 收益:花掉与 Q4_0 相同的 4.5 位/权重,却换到每 32 个权重独立的一步长 + 一偏移(Q4_0 只有一步长、无偏移)。它比 Q4_0 准,又比同样带偏移的 Q4_1(5.0 位/权重)更省——省下的正是「把 16 个 fp16 参数压成 6 位码 + 两个共享 fp16 基准」这一步。

与 K-means 聚类的相通:量化本身就是一种聚类——每个可表示的格点就是一个「簇心」,把权重舍入到最近格点,等同于把样本归入最近簇心;bb 位均匀量化即一维、簇心被强制等距的 K-means(K=2bK=2^b 个簇)。K-quants 的二级结构则对应层次聚类:超块基准 d,dmind,dmin 是粗簇心,子块 sc,msc,m 是在各粗簇内的细分中心。更进一步,ggml 标定 d,dmin,sc,md,dmin,sc,m 时用的是迭代最小化(make_qkx* 系列),实质是交替「分配到最近格点 ←→ 重算最优尺度」的 Lloyd 迭代,正是 K-means 的 assign / update 循环。

三种格式在两类权重分布下的实测误差:

格式位/权重全正权重实测误差近似对称权重实测误差
Q4_04.504.25%8.42%
Q4_15.000.94%7.69%
Q4_K4.502.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):前向计算中的中间结果,如线性层的输出 xWxW、注意力的分数、FFN 的隐层值。它由输入决定、每个 token 都不同,不存文件。量化它记作 A。
  • KV Cache:自回归解码时,把每层注意力的 Key / Value 缓存下来,避免每生成一个新 token 都重算前面所有 token。它随已生成序列线性增长,是显存占用的大头,所以单独拿出来量化。

三者在一层里的关系可以粗略看成:

输入 ⟶ × W⏟用权重 ⟶ a⏟激活 ⟶ ⋯注意力额外把 K,V ⟶ KV Cache⏟随序列增长\text{输入}\ \longrightarrow\ \underbrace{\times\,W}_{\text{用权重}}\ \longrightarrow\ \underbrace{a}_{\text{激活}}\ \longrightarrow\ \cdots \qquad \text{注意力额外把}\ K,V\ \longrightarrow\ \underbrace{\text{KV Cache}}_{\text{随序列增长}}

同一套尺子,量不同对象难度天差地别:

对象特点量化难度常见位宽
权重静态、分布平滑易W4
激活动态、含 outlier难A8 / A16
KV Cache随序列增长、占显存大中KV4 / KV8
  • 命名约定:W4A16 指权重 4 位、激活 16 位;W8A8 指两者均 8 位。
  • 激活的 outlier 问题:少数通道数值比其它大几个数量级,若按 max⁡∣w∣\max|w| 定格宽,绝大多数通道会被压到接近 0,精度崩塌。对策是先把 outlier 摊平——SmoothQuant(按通道缩放搬移量化难度)、旋转(Hadamard,如 QuaRot / SpinQuant),再做量化。

量化误差的定量分析

设格宽(scale)为 ss,量化位宽为 bb:

  • 最大绝对误差:s2\dfrac{s}{2}(舍入到最近刻度的最坏情形)。
  • 量化噪声:误差近似服从 [−s2,s2][-\tfrac s2,\tfrac s2] 上的均匀分布,方差为 s212\dfrac{s^2}{12}。
  • 信噪比(SNR / SQNR):信号功率 ÷ 噪声功率,常用 dB 读作 10lg⁡10\lg 的比值;把「噪声」特指为量化误差就是 SQNR\mathrm{SQNR}(又称量化失真比 SQDR)。对满量程正弦信号 SQNR≈6.02b+1.76 dB\mathrm{SQNR}\approx 6.02b+1.76\ \mathrm{dB}:bb 位下台阶 s=2A2bs=\frac{2A}{2^b}、噪声功率 s212\frac{s^2}{12},故 SQNR=A2/2s2/12=1.5×22b\mathrm{SQNR}=\frac{A^2/2}{s^2/12}=1.5\times2^{2b},取 10lg⁡10\lg 即 1.76+6.02b1.76+6.02b。

量化的系统实现

与矩阵乘法的衔接

反量化后计算与「整数乘、末尾统一缩放」严格等价:

∑iw^ixi=∑i(s qi)xi=s∑iqixi.\sum_i \hat w_i x_i=\sum_i (s\,q_i)x_i=s\sum_i q_i x_i .

因此硬件可以只做整数乘加(qi×xiq_i\times x_i),最后统一乘一次 ss。两个要点:

  • 累加器需用 int32:低精度乘积累加会迅速超出 int8 的范围,必须用更宽的累加器兜住(与第四节「FP32 累加器兜动态范围」同理)。
  • 当两侧(权重和激活)都被量化(w≈swqww\approx s_wq_w、x≈sxqxx\approx s_xq_x)时,整块 GEMM 退化为 int×int\text{int}\times\text{int} 乘加加两次标量缩放,这正是低精度张量核(NVIDIA Tensor Core、CPU 的 DP4A)的工作方式。

从这一部分也可以看出来,对于量化而言,节省的算力是有限的,重点是节省访存开销。

权重的搬运路径:从磁盘到计算单元

quant-path

权重从磁盘走到计算单元,要经过三级存储,分三步:

  1. 装入主存(①,一次性):CPU 上的进程用 read / mmap 把模型文件读进主存(page cache)。
  2. 拷到显存(②,一次性):用 cudaMemcpy 经 PCIe 从主存写进显存,此后权重常驻显存。
  3. 每 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 位,③ 的访存流量随之降为约 1/41/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 相同)
  • NVIDIA Hopper 时代的 1 字节浮点:FP8 双子星规范:
    1. E4M3(1 位符号,4 位阶码,3 位尾数):
      • 尾数多 1 位,精度较高,被建议用于前向传播的权重与激活值。
      • 突破 IEEE 规范:废除无穷大(±∞\pm\infty)编码,将保留码段释放用来表示更大正常数(最大数达 448)。
    2. E5M2(1 位符号,5 位阶码,2 位尾数):
      • 阶码与 FP16 一致,动态范围较大;被建议用于反向传播的梯度。

非均匀量化:NF4 的信息论等熵分割

  • 均匀整数量化的局限:
    • 预训练权重按块归一化后,近似服从零均值正态分布(QLoRA 的前提)。
    • 等距网格在两端低概率区与中间高概率区分配同样多的格点,中间区域分辨率相对不足。
  • 分位数量化原则:
    • 让每个编码被取到的概率相等,4 位编码的信息熵达到最大。
  • NF4 量化实现机制:
    • 取标准正态分布的分位数作为 16 个格点,缩放到 [−1,1][-1,1];为使 0 能被精确表示,负半轴取 7 个、正半轴取 8 个,各区间只是近似等概率。
    • 查找表还原:
      • 4 位存储的不再是数值,而是 0 ~ 15 的查找表索引;
      • 运行时查一张 16 个常量的表,乘以块的缩放系数(absmax)后送入计算。

Code

一、硬件系统与程序映射

系统视图:取指、计算与输出 Token

这张图反映了 CPU 从内存中获取指令和数据计算并输出 Token 的过程:

system-view

磁盘上的程序与数据都只是比特序列:先载入内存,再由 CPU 逐条取指、取数、计算,最终输出 Token。

编译:源码到二进制指令

同一段代码的三种表示:

  1. 高级语言:面向程序员的控制结构与变量符号抽象。
  2. 汇编指令(Assembly):通过 objdump -d 反汇编或 gcc -S 直接生成,与机器码对应。
  3. 目标机器码(Object Code):经 gcc -c 生成的纯二进制目标文件(ELF),通过 hexdump / xxd 观察,呈现为十六进制机器字节流。

compile-mapping

编译流程:预处理、编译、汇编与链接

GNU 编译系统四阶段:

  1. 预处理(Preprocess):
    • 命令:gcc -E dot.c -o dot.i
    • 动作:展开以 # 开头的预处理指令(#include, #define),剔除注释,生成纯 C 文本文件 .i。
  2. 编译(Compile):
    • 命令:gcc -S dot.i -o dot.s
    • 动作:进行词法分析、语法分析等等,将高级语义翻译为机器级汇编文本 .s。
  3. 汇编(Assemble):
    • 命令:gcc -c dot.s -o dot.o
    • 动作:将汇编助记符翻译成二进制机器指令,打包为 ELF 格式可重定位目标文件 .o。
  4. 链接(Link):
    • 命令:gcc dot.o main.o -o dot_product
    • 动作:解析跨文件符号引用,重定位函数与全局变量地址,合并代码段与数据段,生成最终可执行二进制文件。

toolchain

指令集架构

程序编译之后得到的是二进制机器码。但 CPU 执行需要能够认识这段二进制所代表的指令,因此二进制编码与 CPU 指令之间的约定必不可少。

指令集架构(ISA: Instruction Set Architecture)的定义:

  • ISA 是软件与硬件电路之间达成的接口契约规范,在长期演进中保持严格的向后兼容性。
  • ISA包括如下:
    • 程序员可见状态:程序计数器(PC / %rip)、16 个通用寄存器、条件码寄存器(RFLAGS)、连续虚拟内存空间;
    • 指令编码与格式:每条机器指令的二进制操作码、操作数编码规则与指令长度;
    • 支持的数据类型与寻址模式。
    • …

ISA 是接口(Interface):规定处理器能执行什么; 微架构是实现(Implementation):规定处理器在物理电路层面如何执行。

isa-contract

二、执行计算

寄存器

程序员可见状态(Programmer-Visible State):

  • 指令集架构直接暴露给软件程序、能够被指令读取和修改的全部硬件实体:
    • 程序计数器(PC / %rip):存放下一条取指执行指令的虚拟地址;
    • 通用寄存器堆(Register File):16 个 64 位高速存储单元;
    • 条件标志位寄存器(RFLAGS):存放最近一次算术逻辑运算的状态结果;
    • 虚拟内存空间:通过地址总线访问的代码、全局变量与栈内存。

visible-state

为什么通用寄存器只有16个?

  • 指令编码字段限制:指令中寄存器字段的位数有限,在定长字段中指定一个寄存器,16 个寄存器只需 4 位二进制( 24=162^4=16 ),若扩展到上千个,指令编码将急剧膨胀;
  • 硬件布线与延迟瓶颈:寄存器数量过多会导致内部走线与多路选择器延迟增大,降低最高工作频率。

寄存器是 ALU(计算单元)的数据来源,内存数据被加载到 CPU 寄存器中参与计算。

寻址

如果说寄存器解决了数据”放到哪”的问题,那么寻址则解决了数据”从哪取”的问题。

寻址模式:x86-64 的八种操作数寻址形态

addressing-modes

操作数是指令作用的对象,按「值的来源」分两类,共八种形态。

非内存操作数(值就在指令或寄存器里,无需访存):

  • 立即数 $Imm:常数直接编码在指令中,对应 C 的常量。
  • 寄存器 r_a:操作数即寄存器中的值。

内存操作数(先算出地址,再去内存取):取值为 M[EA]M[\text{EA}],其中有效地址

EA=Imm+rb+ri×s\text{EA}=\text{Imm}+r_b+r_i\times s

三个部件均可缺省。

形态写法有效地址C 对应
间接(r_a)R[ra]R[r_a]*ptr
基址 + 偏移Imm(r_b)Imm+R[rb]\text{Imm}+R[r_b]p->field、栈变量
变址(r_b,r_i)R[rb]+R[ri]R[r_b]+R[r_i]b[i](char 数组)
比例变址Imm(r_b,r_i,s)Imm+R[rb]+R[ri]×s\text{Imm}+R[r_b]+R[r_i]\times sw[i](int 数组)
RIP 相对Imm(%rip)Imm+R[%rip]\text{Imm}+R[\%rip]全局变量
绝对ImmImm\text{Imm}现代 64 位程序极少使用
  • 括号即解引用:(%rax) 取的是 %rax 所指内存中的值,而非 %rax 本身——这正是内存操作数与寄存器操作数的分界。
  • 比例因子 s∈{1,2,4,8}s\in\{1,2,4,8\},即 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 寄存器。

ICS Study Note
作者
Guztchian
发布时间
2026 年 9 月 3 日