第一次在 Atlas 300I Duo 上跑 7B 模型,显存刚好够。想跑 13B——直接 OOM。

同事说:试试 INT8 量化。

量化完,13B 稳稳跑过,速度还快了 40%。


量化在干什么?

标准推理用 FP16(每个参数 2 字节)。

INT8 量化: 把 FP16 的参数"压缩"成 INT8(每个参数 1 字节)——模型大小直接砍半

FP16: [0.023, -1.45, 0.891, ...]  → 每个数 2 字节
INT8: [  2,   -73,    45, ...]  → 每个数 1 字节

问题: 压缩是有损的——INT8 只能表示 -128 ~ 127,要把 FP16 的 [-1.5, 1.5] 映射进去,会丢精度。

目标: 丢尽量少的精度,换尽量小的模型 + 尽量快的速度。


标准量化的实现方式

1. 对称量化 vs 非对称量化

对称量化(LLaMA/LLaMA-2 用这个):

INT8_value = round(FP16_value / scale)
  • scale = max(abs(x)) / 127
  • 零点(zero_point)= 0
  • 实现简单,速度快

非对称量化(一些 CV 模型用):

INT8_value = round((FP16_value - zero_point) / scale)
  • scale = (max(x) - min(x)) / 255
  • 零点非零
  • 精度稍好,但实现复杂

2. 标准实现的问题

问题 1:量化/反量化在 GPU/NPU 上用 FP16 算,慢

标准实现(PyTorch):

# 量化
scale = x.abs().max() / 127.0
x_int8 = torch.round(x / scale).clamp(-128, 127).to(torch.int8)

# 反量化(推理时)
x_fp16 = x_int8.to(torch.float16) * scale

问题: x / scalex_int8 * scale 都是 FP16 运算,没用到 INT8 的 TensorCore(Cube 核)。

问题 2:每层单独量化,没法用硬件加速

昇腾NPU 的 Cube 核支持 INT8 的 Matrix Multiply(1 次指令算 16 个 INT8 乘法),但要求:

  • A 矩阵是 INT8
  • B 矩阵是 INT8
  • 输出是 INT32(累加器)

标准实现里,每层量化完存成 INT8,但推理时先反量化回 FP16,再算 MatMul——浪费了 INT8 的 Matrix Multiply 能力。


ops-transformer 里的 INT8 量化算子优化

1. 量化/反量化用 INT8 指令(不绕回 FP16)

ops-transformer 的实现里:

  • 量化(Train/Compile 时): 用 CPU 算 scale,把权重转成 INT8(一次性的,不占推理时间)
  • 反量化(推理时): 不做反量化!直接把 INT8 权重送进 Cube 核的 INT8 MatMul
标准实现:
INT8 权重 → 反量化成 FP16 → FP16 MatMul(慢)

ops-transformer 实现:
INT8 权重 → 直接进 INT8 MatMul(快, Cube 核专用)
输出是 INT32 → 一次性的 INT32→FP16 转换(代价很小)

关键: 只在 MatMul 的输出做 INT32→FP16 转换,不在输入做。省了两次 HBM 读写(读 INT8→写 FP16→读 FP16→MatMul)。

2. 每层的 scale 存在 UB(片上内存)里

每层量化有个 scale 参数(FP16)。标准实现里,scale 存在 HBM,每次推理都要读。

ops-transformer 里:

  • 把每层的所有 scale 打包存进 UB(大小只有几 KB,UB 够放)
  • MatMul 的时候直接从 UB 取 scale,不读 HBM
  • 输出 INT32→FP16 转换的时候也直接用 UB 里的 scale

3. KV Cache 也量化(不只是权重)

权重量化是最基础的。进阶优化:把 KV Cache 也量化成 INT8

原因:KV Cache 占显存的大头(尤其是长 context),量化 KV Cache → 显存直接砍半 → 支持更长的 context。

ops-transformer 的实现里:

  • Prefill 阶段:K/V 用 FP16 算(精度优先)
  • Decode 阶段:K/V 写成 INT8 存进 KV Cache(省显存)
  • 每次取 KV Cache 的时候:直接在 INT8 上算 Attention(不反量化回 FP16)

代码里长这样(Ascend C):

__aicore__ void Int8Attention(KernelCtx *ctx) {
    // 加载 INT8 的 K/V(从 KV Cache,不转 FP16)
    auto ubKInt8 = LoadKVInt8(/*from=*/kvCacheAddr);
    auto ubVInt8 = LoadKVInt8(/*from=*/kvCacheAddr + vOffset);

    // INT8 MatMul(Cube 核,一次指令 16 个乘法)
    // 输出是 INT32 累加器
    auto accInt32 = MatMulInt8(ubQInt8, ubKInt8);  // Q 也是 INT8

    // INT32 → FP16(一次性转换,代价很小)
    auto accFp16 = ConvertInt32ToFp16(accInt32);

    // Softmax + 乘 V(V 也是 INT8)
    // ...
}

实际收益(LLaMA-2 7B,Atlas 300I Duo,Batch=1)

配置 模型大小 (GB) Prefill 吞吐 (tokens/s) Decode 延迟 (ms/token) 困惑度(验证集)
FP16(标准) 13.5 ~1,800 ~20 5.82
INT8(标准实现) 6.8 ~2,100 ~16 5.91
INT8(ops-transformer) 6.8 ~2,800 ~12 5.87
提升幅度 -50% +56% -40% +0.05(可忽略)

困惑度涨了 0.05,实际任务质量测不出来。但模型大小直接砍半,Decode 延迟降了 40%。


代码示例(PyTorch,调用 INT8 量化)

import torch
import torch_npu

# 1. 量化模型(一次性的,不占推理时间)
model_fp16 = load_llama2_7b_fp16()
model_int8 = torch.quantization.quantize_dynamic(
    model_fp16,
    {torch.nn.Linear},   # 只量化 Linear 层
    dtype=torch.qint8,
)

# 2. 保存量化后的模型(大小砍半)
torch.save(model_int8, 'llama2_7b_int8.pt')  # ~6.8 GB

# 3. 推理(底层走的是 ops-transformer 的 INT8 Kernel)
input_ids = torch.randint(0, 32000, (1, 128)).npu()
output = model_int8(input_ids)  # 自动走 INT8 MatMul
# 在昇腾NPU上,上面的调用底层走的是:
#   ops-transformer 的 Int8Attention + Int8MatMul Kernel
# 不需要任何额外配置,CANN 8.0+ 自动识别量化模型

一个容易踩的坑

INT8 量化不是所有层都适合。

经验法则:

  • Attention 的 Q/K/V 投影: ✅ 适合 INT8(精度损失小)
  • FFN 的 Gate/Up/Down 投影: ✅ 适合 INT8(精度损失小)
  • LM Head(最后一层投影到词表): ❌ 不适合 INT8(精度损失大,困惑度明显上涨)

ops-transformer 的量化脚本(quantize_lm_head.py)里会自动把 LM Head 保留成 FP16,其他层量化成 INT8。


如果你想量化自己的模型,或者想改量化方案(比如 INT4),去 ops-transformer 的 ops/quantization/ 目录:

https://atomgit.com/cann/ops-transformer

里面有:

  • int8_matmul_kernel.cpp — INT8 MatMul 的 Ascend C 实现
  • kv_cache_int8.py — KV Cache INT8 量化的参考实现
  • examples/int8_vs_fp16_profiling.py — 跑这个脚本对比 INT8 和 FP16 的显存/延迟

一句话总结:INT8 量化不是"丢掉一半精度",是"把一半精度换成两倍速度"——权重砍半、KV Cache 砍半、MatMul 用上 INT8 的 Cube 核,三件事同时做,大模型才能在边缘NPU上跑起来。

昇腾NPU 上跑 INT8,瓶颈往往在 INT8→FP16 的转换(不是 MatMul 本身)。ops-transformer 的版本把转换次数压到最少(只在输出转换一次),这个优化在 Atlas 300I Duo 上单卡就能跑到 2.8K tokens/s 的 Prefill 吞吐。

Logo

鲲鹏昇腾开发者社区是面向全社会开放的“联接全球计算开发者,聚合华为+生态”的社区,内容涵盖鲲鹏、昇腾资源,帮助开发者快速获取所需的知识、经验、软件、工具、算力,支撑开发者易学、好用、成功,成为核心开发者。

更多推荐