跳转至

Capstone:在单张 4090 上把 Qwen2.5-7B 吞吐拉满 —— 优化前 → 后报告

基线:vLLM 0.26.0 · 模型 Qwen2.5-7B-Instruct · 单张 RTX 4090 (24 GB)

本 Capstone 拼装的每个 flag 都已用 Context7 对照 vLLM 0.26.0 核实(ADR-0004):预构建 AWQ 检查点(Qwen/Qwen2.5-7B-Instruct-AWQ,量化自动检测、无需 --quantization)、--kv-cache-dtype fp8--gpu-memory-utilization(默认 0.92)、--max-num-seqs128)、--max-num-batched-tokens2048,自动调)、--enable-chunked-prefillTrue)、--enable-prefix-caching(由模型 config 解析)、--max-model-len--tensor-parallel-size(单卡为 1),以及 vllm bench throughputvllm bench serve作者不执行任何一步(ADR-0004):下文每个显存 / tokens-per-second / req/s / 准确率数字都是示例 / 量级参考。Capstone 的全部意义就在于:真正的优化前→后数字,是在自己的 AutoDL 机器上测出来的那些。


1 · 直觉 & 为什么重要

Part 0–8 里的一切都是一个旋钮。Capstone 就是你在单张 4090 上按顺序、一次一个地拧它们,并产出那个真正能帮你拿到 offer 的产物:一份优化前 → 后报告,说清楚你改了什么、移动了多少、以及你怎么知道它没把模型搞坏

新手掉进的坑是「我把它做快了」。这不是工程结论——是感觉。专业结论是一张带基线的表:FP16 做到 X output tokens/s、准确率 Y;上了 AWQ + FP8 KV + 容量调优后做到 3.2X、准确率 Y − 0.02,p99 TTFT 仍在 SLO 之下。每个数字都可归因到恰好一次改动。这张表就是面试官说「讲讲你做过的一次优化」时想听的东西,也正是本项目要搭出来的。

要内化的唯一纪律——就是 Part 5 里那个 sweep 循环,现在端到端地跑一遍:先测基线,只改一个东西,测量(质量、吞吐、延迟)三元组,归因这个 delta,然后保留或回退。 机制你早已掌握(量化、PagedAttention、continuous batching、prefix caching、各种旋钮);Capstone 是把它们变成可辩护结果的那套方法。→ 度量词汇见术语表

2 · 心智模型

抓住两件事:一条有序的优化阶梯,和一根在每一级都要跑的测量脊柱

  优化阶梯(按此顺序往上爬——最大、最安全的杠杆先上)              测量脊柱(每一级都跑)
                                                                     ┌───────────────────────────┐
  第0级  基线              FP16 Qwen2.5-7B,默认配置                 │ 1. 质量(eval A/B,       │
             │             ── 在这里把一切都测一遍 ──                │    greedy + seed,分类)  │
             ▼                                                       │ 2. 吞吐(output tok/s,   │
  第1级  量化              AWQ INT4 权重(自动检测)                 │    vllm bench)           │
             │             省下 ~10 GB,加速带宽受限的 decode        │ 3. 延迟/拐点(p99 TTFT/   │
             ▼                                                       │    TPOT,bench serve 扫)  │
  第2级  FP8 KV 缓存       --kv-cache-dtype fp8 → KV 容量 ~2×         └───────────────────────────┘
             │                                                                    │
             ▼                                                          每一级:
  第3级  花掉省下的显存    --gpu-memory-utilization ↑, --max-num-seqs ↑          只改一个东西
             │             (省下的显存 → 更大的 continuous batch)              ▼
             ▼                                                          测量三元组
  第4级  前缀缓存          --enable-prefix-caching(若有共享前缀)              ▼
             │                                                          归因 delta
             ▼                                                                    ▼
  第5级  批形状调优        --max-num-batched-tokens(TTFT ↔ ITL)      保留吗?(质量闸门)
             │                                                                    ▼
             ▼                                                          不过闸就回退
  第6级  (多卡:TP)      单卡 4090 范围之外——只标注,本项目不做(Part 7 / A100)
        最终报告           基线行 → 每保留一级一行 → 汇总

要记住的三个形状:

  • 顺序有讲究,且非任意。量化排第一,因为它是最大、最接近免费的杠杆——它同时抬高两道闸(省出显存给更多 KV cache同时加速带宽受限的 decode)。你从「两头都帮、还便宜」往下爬到「必须调的真实 trade-off」,于是每一级都踩在下一级省出的资源上。把显存花在 batch 宽度上(第3级)只有在量化省出显存(第1–2级)之后才说得通。
  • 脊柱在每一级都一样——这才让 delta 可归因。 同一个eval 集、同一套固定采样(temperature=0、固定 seed)、同一个 benchmark 形状。级与级之间改了 eval 或形状,你的优化前→后表就成了拿苹果比橘子。
  • 质量是闸门,不是填完就忘的一列。 一级若抬了吞吐却过不了 eval A/B(或某一分类塌了),就回退或退让——把这个决定说出口,是整份报告里最资深的动作。「我试了 FP8 KV,数学准确率掉了 8 分,所以我保留 BF16 KV」比一个更大的吞吐数字是更好的答案。

3 · 原理 —— 方法

Capstone 不教新机制;它用一套严谨的方法把你已有的机制组合起来。五条规则。

3.1 先测基线 —— delta 需要一个「前」

没有你改进之前的那个数字,你就报不出改进。第0级是未优化模型在三个轴上的测量:FP16 Qwen2.5-7B-Instruct、默认配置,跑eval 集vllm bench。之后的一切都是相对这一行的 delta。跳过基线,是「3× 加速」变成不可证伪的最常见方式。

3.2 三个轴,每次都用同样的方法测

  • 质量 —— 量化实操里的 A/B:用 temperature=0.0 + 固定 seed、经 LLM.chat小 eval 集,比较分类准确率。Greedy + seed 才让重跑可比;分类拆分才告诉你一级把什么搞坏了。
  • 吞吐 —— vllm bench throughput(或内联计时)给出的 output tokens/s,在一个固定的 decode-heavy 形状上。output tok/s 是整套栈想移动的那个 decode 数字。
  • 延迟 / 拐点 —— 你的 SLO 下的 p99 TTFT 与 TPOT,通过把 vllm bench serve --request-rate 往上扫得到(压测那一课)。拐点——仍满足 SLO 的最高供给负载——才是诚实的容量数字,而非裸吞吐。

3.3 一次只改一个,并归因

每一级只改一个旋钮。若你在同一步里既开 AWQ gpu-memory-utilization,吞吐翻倍你也学不到是哪个干的——而且如果质量掉了,你分不清该怪谁。这更慢,也是让报告有意义的唯一办法。

3.4 质量闸门

在每一级,eval A/B 都是一个通过/不通过的闸门,不是装饰。整体准确率小幅下降是预期内、也没问题;某个分类塌陷(如 math 1.0 → 0.3,或 bilingual 项掉链子——对一个中文能力基座模型很关键)是回退信号。一级过不了闸,你就退让:更多比特、保留 BF16 KV、换校准集——并且把你这么做了写下来。报告的可信度,既来自它的胜绩,也来自它的回退。

3.5 预算纪律 —— 这里大部分是免费的

¥500 预算(ADR-0001)很宽裕——前提是你别把 GPU 时间烧在不需要 GPU 的事上。所有下载和任何自量化都在 AutoDL 无卡模式(无 GPU,近乎免费)做。只在测量时开 GPU——eval A/B 和 vllm bench 运行。一份完整的优化前→后报告是几十分钟 GPU 时长、个位数 ¥——预算是给你迭代用的,不是给单次运行用的。

4 · 完整可跑代码 + 逐行讲解

产物是一份报告,所以代码是一个报告生成器:在固定 eval 集上测一个配置的(质量、吞吐),然后驱动阶梯、产出一张 Markdown 表。它复用小 eval 集里的 score.pyload_items/summarize)——别重造 scorer。你在 4090 上可跑;作者不执行——注释里的数字是示例。

第 1 步 —— 测一级(单个配置的质量 A/B + decode 吞吐):

capstone_stage.py
"""在固定 eval 集上测一个配置:分类质量 + decode 吞吐。
复用小 eval 集的 load_items/summarize。API 已对照 vLLM 0.26.0 核实。
作者不执行(ADR-0004);返回的数字是你的。"""
import time
from vllm import LLM, SamplingParams
from score import load_items, summarize          # 来自小 eval 集页面

ITEMS = load_items("small_eval.jsonl")            # 固定输入——级与级之间绝不改
CONVOS = [[{"role": "user", "content": it["prompt"]}] for it in ITEMS]
SP = SamplingParams(temperature=0.0, max_tokens=128, seed=0)   # greedy + seed => 各级可比

def measure(model: str, **engine_kwargs) -> dict:
    """用这些旋钮构建引擎、跑 eval、返回一行报告。"""
    llm = LLM(model=model, max_model_len=4096, **engine_kwargs)   # vLLM 从 config 自动检测 AWQ
    t0 = time.perf_counter()
    outs = llm.chat(CONVOS, SP)                   # chat() 套用 Instruct 模板
    dt = time.perf_counter() - t0
    texts = [o.outputs[0].text for o in outs]
    report = summarize(ITEMS, texts)              # 整体 + 分类准确率
    out_tokens = sum(len(o.outputs[0].token_ids) for o in outs)
    row = {
        "accuracy": round(report["accuracy"], 3),
        "by_category": report["by_category"],
        "throughput_tok_s": round(out_tokens / dt, 1),   # 这个小集上的粗略 decode tok/s
    }
    del llm                                        # 下一级前释放显存(两个 7B 无法共存)
    return row

逐行(第 1 步): ITEMS/CONVOS/SP 放在模块级,好让每一级共享完全相同的输入与采样——这是 delta 可归因的前提(§3.2)。measure 用该级的 engine_kwargs 构建引擎,跑 LLM.chat(套模板——generate 会喂给 Instruct 模型一个畸形 prompt,为错误的原因拉垮质量),返回一行:整体准确率、分类拆分(闸门,§3.4)、粗略 output tok/s。del llm 释放显存,好让下一级的 LLM(...) 不 OOM。这个小集吞吐是快速的相对信号;权威的吞吐/拐点数字来自 vllm bench(第 3 步)。

第 2 步 —— 驱动阶梯、产出报告表:

capstone_report.py
"""跑优化阶梯,打印优化前->后的 Markdown 表。
每一级相对上一行只改一个东西(§3.3)。数字是示例。"""
from capstone_stage import measure

FP16 = "Qwen/Qwen2.5-7B-Instruct"
AWQ  = "Qwen/Qwen2.5-7B-Instruct-AWQ"     # 预构建 INT4;无卡模式下载

# 阶梯:(标签, 模型, engine_kwargs) —— 每行相对上一行只改恰好一个杠杆。
LADDER = [
    ("0 · 基线 (FP16)",          FP16, {}),
    ("1 · AWQ INT4 权重",        AWQ,  {}),
    ("2 · + FP8 KV 缓存",        AWQ,  {"kv_cache_dtype": "fp8"}),
    ("3 · + 花掉省下的显存",     AWQ,  {"kv_cache_dtype": "fp8", "gpu_memory_utilization": 0.94,
                                        "max_num_seqs": 256}),
    ("4 · + 前缀缓存",           AWQ,  {"kv_cache_dtype": "fp8", "gpu_memory_utilization": 0.94,
                                        "max_num_seqs": 256, "enable_prefix_caching": True}),
]

rows = []
baseline_tps = None
for label, model, kw in LADDER:
    r = measure(model, **kw)
    if baseline_tps is None:
        baseline_tps = r["throughput_tok_s"]
    speedup = r["throughput_tok_s"] / baseline_tps
    rows.append((label, r["accuracy"], r["throughput_tok_s"], speedup))

print("| 阶段 | eval 准确率 | output tok/s | 相对基线加速 |")
print("|---|---|---|---|")
for label, acc, tps, sp in rows:
    print(f"| {label} | {acc:.3f} | {tps:.0f} | {sp:.2f}× |")
# 示例输出(你的会不同):
#   | 0 · 基线 (FP16)       | 0.95 |  620 | 1.00× |
#   | 1 · AWQ INT4 权重     | 0.90 | 1180 | 1.90× |   <- decode 提速 + 省出显存
#   | 2 · + FP8 KV 缓存     | 0.90 | 1210 | 1.95× |   <- 更多 KV 空间;检查分类质量!
#   | 3 · + 花掉省下的显存  | 0.90 | 1820 | 2.94× |   <- 更大 batch 填满省出的显存
#   | 4 · + 前缀缓存        | 0.90 | 2050 | 3.31× |   <- 仅当工作负载共享前缀时

逐行(第 2 步): LADDER 就是把 §2 的阶梯写成数据——顺着 engine_kwargs 往下看,每行相对上一行只加恰好一个 key(§3.3),于是该行的 delta 可归因到那个 key。循环测每一级、算相对基线行的加速、打印一张可直接粘进报告的 Markdown 表。质量列是闸门:横着看它——若某级准确率骤降或某分类塌陷(查 r["by_category"]),这级就回退(§3.4),不上线。前缀缓存(第4级)只在你的流量真的共享前缀(系统提示 / few-shot)时才帮忙;在唯一 prompt 上它是空操作——测量,别假设。注意这个离线阶梯止于第4级:第0–4级是能从这个离线批处理上读出的容量/吞吐杠杆,而第5级(批形状调优,max_num_batched_tokens)是一个 TTFT↔ITL 的服务旋钮——它的效果显现在 vllm bench serve 拐点扫描(第3步)里,而非离线 decode tok/s,所以你在那里对着 p99 调它,并用那个测量填报告的第5级行。

第 3 步 —— 权威的吞吐与拐点(shell;pip install vllm[bench]):

bench.sh
# decode 吞吐,基线 vs 全调优。两次同形状 => 可比。(vllm bench throughput)
vllm bench throughput --model Qwen/Qwen2.5-7B-Instruct       --num-prompts 200 --input-len 256 --output-len 256
vllm bench throughput --model Qwen/Qwen2.5-7B-Instruct-AWQ   --num-prompts 200 --input-len 256 --output-len 256 \
    --kv-cache-dtype fp8
# 输出行:"Throughput: X requests/s, Y total tokens/s, Z output tokens/s" —— 比较两次的 Z。

# 你 SLO 下的拐点:serve 调优后的配置,然后扫到达率(见压测那一课)。
vllm serve Qwen/Qwen2.5-7B-Instruct-AWQ \
    --kv-cache-dtype fp8 --gpu-memory-utilization 0.94 --max-num-seqs 256 --enable-prefix-caching &
#   然后:python sweep_knee.py   (来自压测课——步进 --request-rate,读 p99 TTFT/goodput)

逐行(第 3 步): vllm bench throughput 是权威的 decode 数字(真实的批处理负载,而非那个小 eval 集)——在基线和调优配置上用相同--input-len/--output-len/--num-prompts 跑,比较 output tokens/s。用 decode-heavy 形状(长 --output-len);prefill-heavy 形状会低估 AWQ 的收益,因为 weight-only 量化不削减 prefill FLOPs。至于延迟那一半,serve 调优配置并跑拐点扫描sweep_knee.py)得到 p99 TTFT/TPOT 与 SLO 受限的 req/s——那才是你真正上报的容量数字。

5 · Lab —— 产出你的优化前 → 后报告

Capstone Lab(单卡,预算有界)

  • 最低显存: 24 GB。FP16 Qwen2.5-7B(~15 GB 权重 + KV)是最紧的一级;每个优化后的级都装得下、还有富余。
  • 建议 AutoDL 卡型: RTX 4090(24 GB)—— ADR-0001 基线。不用多卡(第6级 / TP 出范围;为 Part 7 标注)。
  • 预估耗时 / 花费: 下载 + 任何自量化在无卡模式(近乎免费);GPU 时长 ≈ 30–60 分钟跑完整阶梯 A/B + 两次 bench + 一次短拐点扫描 · ≈ ¥3–8 GPU 时费(示例;远在 ¥500 内,而 ¥500 是给你迭代用的,不是单次跑)。
  • 平台: NVIDIA CUDA(默认)。AWQ Marlin + FP8 tensor-core 路径需 Ampere+/Ada——4090(Ada)没问题。
  • 非 NVIDIA: scorer 与报告生成器是纯 Python、哪都能跑;LLM(...) / vllm bench 步骤需要受支持的 vLLM 后端,FP8 KV 需要实现了它的后端。

运行顺序:(1)无卡模式下,下载 Qwen/Qwen2.5-7B-InstructQwen/Qwen2.5-7B-Instruct-AWQ,并从eval 集拷来 small_eval.jsonl + score.py;(2)开 GPU,跑 capstone_report.py(阶梯 A/B),逐级读质量列;(3)跑 bench.sh 拿权威 decode 吞吐 + 一次短拐点扫描;(4)填下面的模板;(5)关机。

拷走它,填进你的实测数字——空格是你的,示例值仅供参考:

before_after_report.md
# 优化前 → 后:单张 RTX 4090 上的 Qwen2.5-7B

SLO:p99 TTFT ≤ ____ ms,p99 TPOT ≤ ____ ms   ·   工作负载:____-in / ____-out,共享前缀?____
基线 vLLM 0.26.0 · greedy(temperature=0, seed=0)· eval = 小集(20 项)

| 阶段(每级只改一个)      | eval 准确率 | 分类回归        | output tok/s | 加速   | SLO 下拐点 req/s | 保留? |
|---------------------------|-----------:|-----------------|-------------:|-------:|-----------------:|:-----:|
| 0 · 基线 (FP16)           |    ____    | —               |     ____     |  1.00× |       ____        |  n/a  |
| 1 · AWQ INT4 权重         |    ____    | ____            |     ____     |  ____  |       ____        | ____  |
| 2 · + FP8 KV 缓存         |    ____    | ____            |     ____     |  ____  |       ____        | ____  |
| 3 · + 花掉省下的显存      |    ____    | ____            |     ____     |  ____  |       ____        | ____  |
| 4 · + 前缀缓存            |    ____    | ____            |     ____     |  ____  |       ____        | ____  |
| 5 · 批形状调优            |    ____    | ____            |     ____     |  ____  |       ____        | ____  |

## 我保留了什么、为什么
- 第 __ 级:保留——<吞吐收益> 换 <质量代价>,为 <SLO> 值得。
- 第 __ 级:回退——<什么退化了>(如 FP8 KV 掉了数学 8 分);退让到 <备选>。

## 一句话结论(可辩护)
从 FP16(___ tok/s,准确率 ___)起步,保留的栈达到 ___ tok/s(___× )、准确率 ___,
p99 TTFT ___ ms / p99 TPOT ___ ms、___ req/s —— 在我的 4090 上测得,花费 ≈ ¥___。

那句结论就是面试答案:一个基线、一个倍数、一个准确率 delta、一个延迟预算、一个花费——每个数字都是你的,每一级都可归因。

6 · 常见坑 / 反直觉点

  • 没有基线行。 相对「无」的「3× 加速」不可证伪。第0级(FP16、默认)不容商量——它是每个 delta 的分母。
  • 每级改好几个旋钮。 AWQ gpu-memory-utilization 一起翻,你就无法归因结果——若质量掉了,也分不清怪哪个杠杆。永远一级一改(§3.3)。
  • 跳过质量闸门。 量化和 FP8 KV 悄悄地退化——流利但更错。「看着还行」不是信号;greedy + seed 的 eval A/B 才是。某分类塌陷(盯 mathformatbilingual 项)就回退。
  • 叠容量旋钮引发的 OOM 连锁。 第2–3级先省出、再花掉显存;把 gpu-memory-utilization 推到 1.0 或 max-num-seqs 太高,你会在启动时或突发下 OOM。小步抬、盯着「# GPU blocks」那行。
  • 报吞吐却没 SLO。「2050 tok/s」没有「p99 TTFT ≤ X」就没意义。诚实的容量数字是拐点处的 goodput(有效吞吐)压测),不是 --request-rate inf 的饱和值。
  • 测冷启动的 server / 跑太短。 冷的 CUDA graphs 和热身瞬态不是稳态。先热身 server、用足够多的 prompt,再信一个数字。
  • 追别人的魔法值。 某博客的 max-num-batched-tokens=16384 是为他们的模型/GPU/流量调的。抄方法(阶梯 + 扫描),永远别抄量级——那是你机器的属性。
  • 在单卡上试 TP。 --tensor-parallel-size > 1 需要 ≥2 GPU;单张 4090 上就是 1。多卡是 Part 7(A100 地界,ADR-0001),明确在单卡 Capstone 范围之外——标为未来工作,别假装做了。
  • 在有利形状上宣布胜利。 AWQ 的 decode 收益在长输出上显现;prefill-heavy 形状会藏住它。固定并上报 input/output 拆分,否则数字不可迁移。

7 · 面试连线

8 · 小结 & 延伸阅读

一句话: Capstone 就是一次大型的优化前 → 后 sweep——爬一条有序阶梯(量化 → FP8 KV → 把省出的显存花在 batch 宽度 → 前缀缓存 → 批形状调优;多卡/TP 出单卡 4090 范围),每级只改一个杠杆,每级都跑同一根脊柱(在固定eval 集上用 greedy+seed 做质量 A/B、用 vllm bench throughput 测 output tok/s、用 vllm bench serve 扫得 p99/拐点),每级都以质量为闸(某分类塌陷就回退、并说出来),最后产出一份结论可辩护的报告——基线 → 倍数 → 准确率 delta → 延迟预算 → 花费——每个数字都在你自己的 4090 上、¥500 之内测得。

延伸阅读:

9 · 自测小问

同事秀出一个 benchmark:「AWQ + FP8 KV + 大 batch 配置做到 2050 output tok/s」。在相信这是一次胜利之前,你会问哪三件事?

(1)相对什么基线? 2050 tok/s 没有它改进之前的 FP16 默认数字、以及两者相同的 benchmark 形状,就毫无意义——否则不可证伪(§3.1)。(2)在什么质量下? 量化和 FP8 KV 悄悄退化;要 greedy + seed 的 eval A/B分类拆分——一个塌了 mathbilingual 项的 2050-tok/s 配置是回归,不是胜利(§3.4)。(3)在什么 SLO 下? 裸吞吐忽略延迟;要 p99 TTFT/TPOT 和拐点(SLO 下的 goodput / 有效吞吐),因为饱和的 server 吞吐很大、可每个用户都在等。还有第四件:是一次改动还是好几个? 若 AWQ、FP8 KV、batch 旋钮一起翻,这数字无法归因——你不知道哪个杠杆挣来的、也不知道回退哪个会崩。

为什么阶梯把量化放第一、把「花掉省出的显存」放后面,而不是反过来?

因为这些级有依赖顺序,不是随意挑。量化第一,因为它是最大、最接近免费的杠杆:它一次抬高两道闸——AWQ 省出 ~10 GB 权重显存(更多空间给 KV cache),同时缩小带宽受限 decode 的权重读取(更快出字)——只付一点点可测量的质量代价。「花掉省出的显存」(抬 gpu-memory-utilization / max-num-seqs 换更大的 continuous batch)是量化产出的资源的消费者:先做它、在 FP16 上,要么 OOM、要么只装下少得多的序列。所以你从「两头都帮、便宜、还产出资源」往下爬到「花掉那资源的 trade-off 旋钮」。每一级踩在下一级上——这也是为什么每级之后都要测:第3级的收益之所以存在,正因为第1–2级为它腾出了空间。

面试里你展示这份报告,并提到你回退了 FP8 KV 缓存、因为它掉了数学准确率。承认一次被回退的优化是弱点吗?它证明了什么?

恰恰相反——这是报告里最资深的信号。它证明你(1)测的是质量、不只是速度——否则你根本抓不到一次悄悄的、分类特定的退化;(2)把质量当闸门,于是你优化出的是可上线的配置、而非一个更大但坏掉的数字;(3)能把回归归因到一个杠杆,而这只有在你一次只改一个东西时才可能。一个只报单调胜利的候选人,要么运气好、要么没看——真实的优化工作满是不划算的杠杆,知道该丢哪个、还有数字佐证,才是本事。结论「3.3×、准确率 −0.02,且因 8 分数学下降而否掉了 FP8 KV」远比一句光秃秃的「3.5×」有力。