TGI 混合精度推理时 fp16 下 loss 波动与 eval 误差的可视化验证

文章导读
在 TGI 这类混合精度推理服务下,fp16 的 loss 波动和 eval 误差不等于模型坏了。先要区分你看到的是「服务端生成阶段的采样波动」「评测脚本回算的 teacher-forcing 损失」还是「与 fp32 基准相比的逐样本差异」。没有这两个口径的清晰定义,叠出来的曲线只能说明波动存在,不能说明波动来自哪里。建议先把 eval 误差定义为同一评测集在 fp16 与 fp32/bf16
📋 目录
  1. Ⅰ 先确认 loss 是从哪一层拿到的
  2. Ⅱ 可视化验证的最小闭环
  3. Ⅲ 三类波动曲线的排除方向
  4. Ⅳ TGI 服务侧参数核对表
A A

在 TGI 这类混合精度推理服务下,fp16 的 loss 波动和 eval 误差不等于模型坏了。先要区分你看到的是「服务端生成阶段的采样波动」「评测脚本回算的 teacher-forcing 损失」还是「与 fp32 基准相比的逐样本差异」。没有这两个口径的清晰定义,叠出来的曲线只能说明波动存在,不能说明波动来自哪里。建议先把 eval 误差定义为同一评测集在 fp16 与 fp32/bf16 下的逐样本 loss 差;如果没有基准精度,就把多次 fp16 的组间方差作为波动。

用固定样本集、固定设备和固定 batch 重复评测,优先对比 fp16 与 bf16/fp32 的逐样本 loss。若同一样本多次偏离来自 kernel 累加顺序或 padding,通常只在长尾样本上放大;若采样参数不固定,整条曲线都会抖。先分离评测管线变量,再做精度判断。

先确认 loss 是从哪一层拿到的

TGI 的服务端推理本身不会输出 loss,除非接入方把 labels 传给模型,或在客户端用返回的 logprob 回算。两种口径不能混用:teacher-forcing loss 是每一步都看到真实 token 时的平均负对数似然;生成 logprob 是模型按自己的采样路径继续生成时的累计概率。前者用于评估模型对数据的拟合,后者用于观察生成过程。若要验证 fp16 对 eval 误差的影响,应该用 teacher-forcing 口径,且与训练时的 loss 定义保持一致。

如果你只能拿到 `/generate` 接口返回的 logprob,也要注意:多数实现只返回采样 token 的 logprob,不返回完整词表分布。因此回算出来的序列 loss 只能作为相对趋势,不能与训练 loss 直接比较。

可视化验证的最小闭环

建议从一段「逐样本记录 loss 的脚本」开始。以下代码按单条样本计算 causal LM loss,适合先排除 padding 和 batch 差异。放在评测机上执行前,先把样本 id 和文本固定成 JSONL,并且保证三次运行读入顺序一致。

# eval_fp16.py
import json, torch
from transformers import AutoModelForCausalLM, AutoTokenizer

model = AutoModelForCausalLM.from_pretrained(
    'your-model-id',
    torch_dtype=torch.float16,
    device_map='cuda',
)
tokenizer = AutoTokenizer.from_pretrained('your-model-id')

with open('eval_samples.jsonl', 'r', encoding='utf-8') as f:
    samples = [json.loads(line) for line in f]

for sample in samples:
    enc = tokenizer(sample['text'], return_tensors='pt').to('cuda')
    labels = enc['input_ids'].clone()
    with torch.no_grad(), torch.autocast('cuda', dtype=torch.float16):
        out = model(**enc, labels=labels)
    sample['loss'] = round(out.loss.item(), 6)
    sample['tokens'] = enc['input_ids'].size(1)
    with open('eval_fp16_run1.jsonl', 'a', encoding='utf-8') as f:
        f.write(json.dumps(sample, ensure_ascii=False) + '\n')

这段代码只验证「模型对象在 fp16 下的 loss」。如果你要贴近 TGI 服务行为,需要把服务端启动参数中的 dtype、batch 大小和 padding 策略都对齐,否则你验证的是离线模型,不是服务端路径。

跑完至少三次后,把逐样本 loss 按 sample id 对齐画在同一张图里:

TGI 混合精度推理时 fp16 下 loss 波动与 eval 误差的可视化验证
import matplotlib.pyplot as plt

runs = []
for path in ['eval_fp16_run1.jsonl', 'eval_fp16_run2.jsonl', 'eval_fp16_run3.jsonl']:
    with open(path, 'r', encoding='utf-8') as f:
        runs.append({json.loads(line)['id']: json.loads(line)['loss'] for line in f})

ids = sorted(runs[0].keys())
for run in runs:
    plt.plot(ids, [run[i] for i in ids], lw=0.8, alpha=0.6)
plt.xlabel('sample id')
plt.ylabel('loss')
plt.legend(['run1', 'run2', 'run3'])
plt.show()

三类波动曲线的排除方向

叠图后不要急着改模型。按下面三种形态逐步缩小范围:

  • 三条线高度重合但整体高于 fp32 基线:这是 fp16 动态范围带来的系统偏移,属于预期误差。可以继续观察偏移是否集中在长序列上,但不需要当作故障处理。
  • 同一样本在三次运行里互相差很多:先固定随机种子,检查是否在 eval 模式下。若固定 seed 后仍然抖动,怀疑非确定性 kernel,例如 FlashAttention 或 fused matmul 的累加顺序。
  • loss 随样本 id 逐渐抬高,或者只在特定长样本出现尖峰:优先检查 padding 策略、KV cache 上限和 prefill 拆批。把 batch size 调成 1 与当前 batch 叠图,如果尖峰消失,说明与 batch 组合有关,而不是 fp16 loss 本身异常。

为了验证 kernel 累加顺序的影响,可以在脚本里临时设置 `torch.use_deterministic_algorithms(True)`,并设置环境变量 `CUBLAS_WORKSPACE_CONFIG=:4096:8` 后重跑。注意这个设置会改变 kernel 选择,可能降低吞吐,只适合用来定位,不要直接推到生产服务。

TGI 服务侧参数核对表

如果离线脚本稳定,但 TGI 服务路径仍然出现波动,差异大概率在服务侧配置。建议逐项核对以下位置:

核对项检查方式判断作用
dtype 口径服务启动参数和评测脚本是否都显式使用 float16避免一边 fp16、另一边 bf16 造成系统误差
采样参数评估时是否固定 temperature / top_p,或使用 greedy采样会放大逐次差异,和 loss 计算精度无关
batch / prefill用 batch_size=1 与当前 batch 叠图padding 位置变化会改变 attention mask,影响 loss
attention backend对比开启与关闭 FlashAttention 等 kernel 的两次运行kernel 累加顺序不同会带来少量数值差异
随机种子固定 PyTorch seed;TGI 支持 seed 参数时一并固定排除采样和 dropout 状态导致的抖动

最后保留一份运行清单:样本 id 文件、dtype、batch、采样参数、kernel 开关、每次运行的 JSONL。以后再出现 eval 误差时,直接用同一份清单对照,比重新画一条曲线更能定位变化点。fp16 下的微小波动通常不直接影响功能,但如果你把 eval 误差当作上线依据,就先让误差可复现,再谈可解释。