跳到主要内容

机器学习推理基础

学习目标

  • 理解模型推理从输入到输出的完整链路, 而不是只看模型前向计算。
  • 区分 latency, throughput, batch size, warmup, memory footprint 和 end-to-end time。
  • 能把 LLM 的 prefill, decode, first-token latency 和 tokens/s 放到通用推理框架里理解。
  • 知道为什么量化, runtime 优化和硬件加速都必须回到真实设备验证。
  • 能设计一个不编造数字, 可复现, 可对比的基础推理实验。
提示

推理基础的核心问题不是“模型能不能运行”, 而是“模型在指定设备, 指定输入, 指定服务形态下能否稳定地满足指标”。

问题背景

端侧部署关心的是推理, 不是训练。训练阶段强调梯度, 优化器, 数据增强和收敛; 推理阶段强调输入预处理, 算子执行, 内存移动, 后处理, 服务接口和稳定性。很多部署项目失败, 不是因为模型精度不够, 而是端到端链路中某个环节拖慢, 某个算子 fallback, 某个输入格式错误, 或某个指标口径没有统一。

在服务器上, 问题常表现为:

  • GPU 显存足够, 但首 token 很慢。
  • 模型文件已经变小, 但 tokens/s 没有明显提升。
  • 单次 CLI 推理正常, 但服务接口延迟不稳定。
  • batch 增大后吞吐提升, 但交互体验变差。

在 Jetson 上, 问题还会增加:

  • CPU, GPU 和内存共享资源, 峰值内存更敏感。
  • 功耗模式和温度会影响持续性能。
  • 同样的模型格式在服务器和 Jetson 上可能表现不同。

本章的作用是建立统一语言: 先定义测量边界, 再讨论优化方法。

图示讲解

通用推理链路

如果只测 E: 模型前向推理, 很容易低估真实业务延迟。端侧应用常见的瓶颈可能在图像 resize, tokenizer, CPU/GPU 拷贝, JSON 编解码, 后处理 NMS, 或服务队列等待。

LLM 推理链路

LLM 与传统分类模型的不同在于它会持续生成。一次请求通常包含:

  • 模型加载: 加载权重和初始化 runtime。
  • Prompt 处理: 对输入 token 做 prefill。
  • 首 token: 从请求开始到第一个输出 token。
  • Decode 循环: 每次生成一个 token, 直到达到停止条件。
  • 服务返回: CLI 输出, HTTP JSON 或流式响应。

核心概念

Latency

Latency 是单个请求的耗时。它可以有多个边界:

口径起点终点适用场景
Kernel latency单个算子开始单个算子结束算子优化, kernel 对比
Model latency输入张量就绪模型输出张量完成runtime 对比
End-to-end latency原始输入进入系统业务结果返回产品体验评估
First-token latency用户请求开始LLM 第一个 token 输出对话, Agent, 流式输出

课程实验默认记录端到端口径, 同时从 llama.cpp 日志中拆出 prompt eval 和 eval 统计。

Throughput

Throughput 是单位时间处理量。传统 CV/NLP 模型常用 samples/s, LLM 常用 tokens/s。吞吐和延迟不总是一致:

  • 增大 batch 可能提升吞吐, 但单个请求等待更久。
  • 并发请求可能提升设备利用率, 但增加排队时间。
  • 在 Jetson 上, 长时间高负载可能受功耗和温度影响, 吞吐不稳定。

Batch size

Batch size 是一次推理处理的样本数量。端侧交互式 LLM 通常 batch 不大, 更关心单请求响应。离线批处理或网关服务则可能通过 batching 提高吞吐。

Warmup

Warmup 指首次运行前后的初始化成本, 可能包括:

  • 动态库加载。
  • GPU context 初始化。
  • kernel 编译或选择。
  • 内存池初始化。
  • 模型权重和 tokenizer 缓存。

因此第一次运行不能直接代表稳定性能。实验应至少区分冷启动和稳定运行。

Memory footprint

推理内存不是模型文件大小。它通常由下面几部分组成:

内存部分说明LLM 场景
Weights模型权重量化主要降低这一部分
Activations中间激活与 batch, shape, runtime 策略相关
KV Cacheattention 历史缓存与层数, heads, hidden size, context length 相关
Runtime buffersworkspace, 临时 buffer与后端和 kernel 实现相关
Service overheadtokenizer, Python, HTTP, 日志服务化时不可忽略
警告

权重量化后, 模型文件变小, 但长上下文下 KV Cache 仍会增长。不能用“GGUF 文件大小”直接推断端到端显存。

End-to-end

End-to-end 是从业务输入到业务输出的完整链路。课程强调端到端, 因为端侧部署的最终约束来自用户体验和设备资源, 而不是单个算子分数。

指标口径表

指标单位怎么测注意事项
模型加载时间sCLI 日志或手动计时不要混入首 token
首 token 延迟s/ms请求开始到第一个 token流式输出时尤其重要
tokens/stokens/sdecode 阶段 token 数 / 时间固定 prompt 和生成长度
峰值显存MiB/GiBnvidia-smi, runtime 日志Jetson 需看 shared memory
CPU 占用%top, htop, pidstattokenizer 和 fallback 常见
GPU 利用率%nvidia-smi dmon, tegrastats采样频率影响判断
温度/功耗C/WJetson tegrastats, nvpmodel边缘设备必记
质量备注文本人工检查或任务指标不要只看速度

代码/命令示例

Python 最小计时器

import statistics
import time

def measure(fn, warmup=2, repeat=5):
for _ in range(warmup):
fn()

values = []
for _ in range(repeat):
start = time.perf_counter()
fn()
values.append(time.perf_counter() - start)

return {
"min": min(values),
"median": statistics.median(values),
"max": max(values),
"all": values,
}

def workload():
text = "端侧模型部署需要同时观察速度, 显存和质量。"
return "|".join(text)

print(measure(workload))

这个示例不代表真实模型性能, 但它提供了实验习惯:

  • 先 warmup。
  • 多次重复。
  • 记录 min/median/max, 不只记录一次。
  • 明确 workload。

llama.cpp 固定 workload

./build/bin/llama-cli \
-m ~/edge-ai-lab/models/qwen/qwen2.5-1.5b-instruct-q4_k_m.gguf \
-p "请用三点说明端侧部署为什么要同时看速度、显存和质量。" \
-n 128 \
--ctx-size 2048 \
-ngl 99

记录时至少写清楚:

  • 模型文件。
  • prompt。
  • 生成长度 -n
  • 上下文长度 --ctx-size
  • GPU offload 参数 -ngl
  • llama.cpp commit。
  • 设备型号和驱动/JetPack 版本。

HTTP 服务端到端计时

如果使用本地 OpenAI-compatible API, 可以用下面的 Python 结构做 smoke test:

import json
import time
import urllib.request

payload = {
"model": "local-qwen",
"messages": [
{"role": "user", "content": "用一句话解释什么是首 token 延迟。"}
],
"max_tokens": 64,
}

start = time.perf_counter()
request = urllib.request.Request(
"http://127.0.0.1:8080/v1/chat/completions",
data=json.dumps(payload).encode("utf-8"),
headers={"Content-Type": "application/json"},
)

with urllib.request.urlopen(request, timeout=60) as response:
body = response.read().decode("utf-8")

elapsed = time.perf_counter() - start
print(f"end_to_end={elapsed:.3f}s")
print(body[:500])

这段代码用于验证服务链路, 不用于替代系统 profiling。

配套实作

实作 1: 拆解一次 Qwen 推理日志

对应章节: Qwen 基线推理

步骤:

  1. 固定一个 prompt 和 -n 128
  2. 分别运行 CPU 路径和 GPU offload 路径。
  3. 保存完整终端日志。
  4. 从日志中标注模型加载, prompt eval, eval/decode。
  5. 记录输出质量备注。

结果表:

设备/路径模型ctxngl加载时间prompt evaldecode tokens/s质量备注
Ubuntu GPU待填待填待填待填待填待填待填
Jetson待填待填待填待填待填待填待填

实作 2: 观察上下文长度对内存的影响

对应章节: Transformer 与 LLM 基础, Profiling 与结果记录

固定模型和 prompt, 分别设置:

--ctx-size 1024
--ctx-size 2048
--ctx-size 4096

每次记录:

  • 峰值显存或内存。
  • 首 token 延迟。
  • tokens/s。
  • 是否出现 OOM 或明显降速。

实作 3: 对比 CLI 与 API

对应章节: 本地服务与 OpenAI-compatible API

同一个 prompt, 分别用 CLI 和 HTTP API 调用, 对比:

  • 端到端耗时。
  • 输出是否一致。
  • 服务日志中是否有错误。
  • 是否能进行流式输出。

验收结果

产物验收标准
推理链路图能解释预处理, tokenizer, 前向计算, 后处理和服务层的关系
指标口径表能区分模型 latency, end-to-end latency, first-token latency 和 tokens/s
Qwen 日志标注能从一次运行日志中指出 prompt eval 和 decode 指标
内存拆分说明能说明权重, activation, KV Cache, runtime buffer 的差别
实验记录模板不编造数字, 但预留字段完整, 能支持后续填数

常见问题

为什么我用低比特模型后速度没有变快?

可能原因包括:

  • 设备瓶颈不在权重读取, 而在 decode kernel, tokenizer 或服务层。
  • runtime 没有使用对应的低比特优化 kernel。
  • 低比特格式需要 dequant, 抵消了部分收益。
  • GPU offload 参数没有正确启用。
  • Jetson 上受内存带宽, 功耗模式或温度影响。

为什么第一次推理特别慢?

常见原因是冷启动: 加载权重, 初始化 GPU context, 分配内存池, 加载 tokenizer 和选择 kernel。实验中要区分冷启动和稳定运行。

为什么 tokens/s 高, 但用户仍然觉得慢?

用户首先感知的是首 token 延迟。如果 prefill 很慢, 或服务队列等待很长, decode tokens/s 再高也不能完全改善体验。

为什么要固定 prompt?

LLM 的 prompt 长度, 语言, 模板和生成长度都会影响结果。比较模型格式或运行参数时, 必须尽量只改变一个变量。

可以只看平均值吗?

不建议。至少记录 min, median, max。端侧设备可能有温度, 后台进程或服务队列带来的波动, 只看平均值容易掩盖问题。

参考资料