在 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 对齐画在同一张图里:
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 误差当作上线依据,就先让误差可复现,再谈可解释。