1. 项目概述:为什么亲手造一个8位量化器,比直接调用现成库更有价值
你有没有盯着 bitsandbytes 或 AWQ 的文档发过呆?不是不会用,而是总在想:它到底在模型内部干了什么?那个 quantize_4bit=True 的开关一按下去,权重张量的内存地址里究竟发生了怎样一场精密的“数字压缩手术”?我试过直接读源码,但层层嵌套的C++绑定和CUDA核函数像一堵高墙;我也试过看论文,可公式推导再漂亮,也架不住实际跑起来时 RuntimeError: expected scalar type Half but found Float 这种报错来得猝不及防。直到我把 facebook/opt-350m 模型从662MB硬生生压到359MB,推理输出还和原来一模一样——那一刻我才真正摸到了量化技术的脉搏。这不是炫技,而是一次对大模型底层运行机制的“解剖式学习”。我们今天要做的,就是完全抛开任何第三方量化库,只用PyTorch原生API,从零开始构建一个叫 MYQ 8-bit 的定制化量化器。它不追求工业级的极致性能,但每一个 register_buffer 、每一行 torch.clamp 、每一次 scale.unsqueeze(1) 的广播操作,都清清楚楚地暴露在你眼前。这个过程会强制你理解:为什么是 int8 而不是 int7 ?为什么 scale 必须按通道计算而不是全局统一?为什么 lm_head 层必须被排除在外?这些答案,不会藏在抽象的API背后,而就写在你亲手敲下的每一行代码注释里。如果你的目标是未来能快速评估、调试甚至魔改任何新的量化方案,那么亲手造轮子,就是绕不开的第一课。
2. 核心原理拆解:8位对称量化不是“四舍五入”,而是一场精确的坐标映射
2.1 量化本质:浮点世界与整数世界的“海关通关”
把一个FP16权重张量变成INT8,绝不是简单地把每个数乘以100再取整。这就像把上海外滩的实景照片压缩成一张8位色深的GIF图——你丢失的不是像素点,而是色彩的连续性。量化真正的核心,是建立两个数值空间之间的 可逆线性映射关系 。在对称量化(Symmetric Quantization)中,这个关系由一个关键参数定义: 缩放因子(Scale) 。它的数学表达非常干净: Q = round(W / S) ,其中 Q 是量化后的整数, W 是原始浮点权重, S 就是那个神奇的 scale 。但这个公式背后藏着一个铁律: S 的选择必须确保所有 Q 的值都严格落在 [-128, 127] 这个INT8的合法取值范围内。这就引出了 scale 的计算逻辑: S = max(|W|) / 127 。注意,这里分母是127,不是128。因为INT8的对称范围是 -128 到 127 ,其正向最大绝对值是127。所以, max(|W|) 代表了原始权重数据的“动态范围”,而 127 则是量化后整数空间的“最大刻度”。两者相除,就得到了一个能把整个浮点动态范围,刚好、严丝合缝地“拉伸”或“压缩”进INT8刻度盘的“标尺”。我第一次看到这个公式时很困惑:为什么不能用 128 ?实测下来,如果用 128 ,当原始权重的最大绝对值恰好等于 128 * S 时, round(128 * S / S) = round(128) = 128 ,这已经超出了INT8的上限 127 ,会导致溢出错误。这个细节,是无数人在调试量化时踩过的坑,也是为什么我们代码里必须写 Qmax = torch.iinfo(torch.int8).max 而不是硬编码 127 。
2.2 为什么必须是“按通道”(Per-Channel)而非“按张量”(Per-Tensor)
想象一下,一个 Linear 层的权重是一个 1024 x 768 的矩阵。如果采用“按张量”量化,我们只计算一个 scale ,它要同时照顾到所有1024行、768列共786,432个权重值。问题来了:不同行的权重分布可能天差地别。比如第1行的权重全在 [-0.1, 0.1] 之间,而第512行的权重却在 [-5.0, 5.0] 之间。一个 scale 怎么办?如果 scale 取得小,第1行的量化结果就会充满大量重复的 0 和 1 ,精度损失惨重;如果 scale 取得大,第512行的量化结果又会因 round() 操作而严重失真。这就是“按张量”量化的阿喀琉斯之踵。而“按通道”量化,相当于给每一行(即每个输出神经元)都配了一把专属的“标尺”。我们的代码 weight_f32.abs().max(dim=-1).values 正是干这件事: dim=-1 表示沿着最后一维(即列方向)求最大值,结果得到一个长度为 out_features (1024)的一维张量,里面每个元素都是对应那一行权重的绝对值最大值。这样,第1行有自己的小 scale ,第512行有自己的大 scale ,各司其职,互不干扰。代价是内存里要多存1024个 scale 值,但对于精度提升而言,这点开销完全值得。我在 opt-350m 上做过对比实验:按张量量化后,生成文本的连贯性明显下降,出现了更多无意义的重复词;而按通道量化,几乎看不出差异。这个选择,是精度与效率之间一次非常务实的权衡。
2.3 为什么 lm_head 层是量化禁区
lm_head 层,即语言模型的“头”,是模型最后一层将隐藏状态映射回词汇表概率的线性层。它的权重形状通常是 [vocab_size, hidden_size] ,对于 opt-350m 来说, vocab_size 是50272,这是一个极其庞大的维度。量化它,理论上能进一步压缩模型体积。但实践告诉我们,这是个危险的雷区。原因在于它的功能特殊性:它直接决定了模型下一个词预测的最终概率分布。哪怕是最微小的量化误差,在经过 softmax 函数的指数放大后,也可能让一个本该排在第1位的正确词,跌落到第10位之外。我曾经莽撞地把 lm_head 也加进了量化列表,结果模型生成的句子开头还正常,但越往后越离谱,最后变成了毫无逻辑的字符堆砌。后来查阅Hugging Face Optimum文档才明白,官方推荐的INT8量化方案,也明确将 lm_head 列为默认排除项。这背后的工程直觉是:模型的“大脑”可以适度压缩,但它的


429

被折叠的 条评论
为什么被折叠?



