# Inference Scheduler Lab：先预测，再测量，最后归因

你要解释同一组 8 个请求为何在四种调度下经历不同等待。先记录预测，再运行代码；遇到预测不符，就沿请求时间线找原因。这里不需要 GPU、API key 或第三方 Python 包，Python 3.10+ 即可。

这是 **simulation**：单位是抽象 tick。没有真实模型、质量评分、KV 容量/回收、preemption、网络或 GPU kernel。`prefix_key` 是精确兼容 token 前缀的教学代理，缓存无限且每个配置从冷缓存开始；它不实现 vLLM 的 block hash。输出不能支持真实毫秒、GPU Token/s、成本或“已解决 KV 抢占”的结论。

## 0. 准备与预测（10 分钟）

网页读者先[下载完整实验 ZIP](../inference-scheduler-lab.zip)，其中包含代码、输入材料、测试和 `expected/` 参考输出，无需获取整个仓库。需要 Python 3.10+，无需 GPU、API key 或第三方包。把 ZIP 保存到任意工作目录，在终端进入该目录后执行：

```bash
python3 -m zipfile -e inference-scheduler-lab.zip .
cd inference-scheduler-lab
python3 infer_sched_lab.py --artifacts runs/baseline
python3 -m unittest -v
```

也可双击解压，再在解压出的 `inference-scheduler-lab` 目录打开终端。预期 16 项测试通过；接下来所有命令都在这个目录运行。已持有完整站点的读者可直接进入 `labs/inference-scheduler-lab`，跳过下载和解压。

在 `prediction.md` 写三句话，并给出理由：

1. static → continuous：S1 的准入等待会怎样变化？
2. continuous → APC：第一次遇到 SYS 前缀的请求是否立即命中？哪一阶段可能减少？
3. APC → chunk32：S1 首 token 更早，是否足以推出全体 P95 更好？

先读输入，不先看答案。固定负载为：

| 请求 | 到达 tick | 前缀 | 前缀/后缀 token | 输出 token |
|---|---:|---|---:|---:|
| L0 | 0 | SYS | 128 / 384 | 24 |
| S1、S2 | 1、2 | SYS | 128 / 16 | 各 16 |
| U3 | 3 | 无 | 0 / 96 | 16 |
| S4、S5 | 9、10 | SYS | 128 / 16 | 各 16 |
| U6 | 11 | 无 | 0 / 160 | 16 |
| S7 | 12 | SYS | 128 / 16 | 16 |

每 tick 有 64 单位 budget；每个 decode token 消耗 8 单位。先按活跃请求顺序 decode，再处理 prefill。最多 4 个活跃请求；static 只在整批结束后补入，continuous 每轮可补入。prefill 本身已经可以跨 tick；chunk32 是每请求每轮最多 32 token，**不等于真实引擎“关闭/启用 chunked prefill”的等价模拟**。

## 1. 跑一次固定对照（10 分钟）

```bash
python3 infer_sched_lab.py --artifacts runs/baseline
python3 -m unittest -v
```

四种配置共用同一输入。输出包括 `manifest.json`（负载 hash 和配置）、`workload.json`（可重放输入）、`summary.csv/json`、`requests.jsonl`（每个请求的准入、token 时间戳和状态）和 `timeline.jsonl`（每 tick 的队列、prefill/decode 与超时）。

以下数值来自随附代码的实际模拟运行；完整输出保存在 [expected/baseline/table.txt](expected/baseline/table.txt)。时间列为 tick，吞吐为抽象输出 token/tick。

| 配置 | P95 TTFT | P95 ITL | P95 TPOT | 输出/tick | 命中 token | 成功/到达 | 完成时刻 |
|---|---:|---:|---:|---:|---:|---:|---:|
| static | 56 | 1 | 1 | 1.64 | 0 | 8/8 | 83 |
| continuous | 30 | 1 | 1 | 2.39 | 0 | 8/8 | 57 |
| continuous+APC | 26 | 1 | 1 | 2.62 | 384 | 8/8 | 52 |
| continuous+APC+chunk32 | 29 | 1 | 1 | 2.43 | 384 | 8/8 | 56 |

P95 用 nearest-rank：8 个值排序后取第 `ceil(0.95×8)=8` 个，就是最大值。这只能解释这 8 个请求，不是生产尾延迟估计。默认 budget 足够每轮服务全部活跃 decode，因此间隔都为 1；真实 ITL 的抖动并未被模拟。

重放输入应得到完全相同结果：

```bash
python3 infer_sched_lab.py --workload runs/baseline/workload.json --artifacts runs/replay
```

## 2. 从汇总回到一个请求（15 分钟）

```bash
python3 - <<'PY'
import json
from pathlib import Path
for row in map(json.loads, Path('runs/baseline/requests.jsonl').read_text().splitlines()):
    if row['name'] in ('S1', 'S7'):
        print(row['case'], row['name'], 'arrival/admit/first/done=',
              row['arrival'], row['admitted_at'], row['first_token_at'], row['done_at'])
PY
```

**答案片段：** static 的 S1 在 1 到达，32 才准入，36 得到首 token，所以 TTFT=35，准入前等待=31。continuous 中 S1 在 1 准入、12 得到首 token，TTFT=11。它先被 L0 的大 prefill 占住 budget；连续准入消除了整批等待，没有消除所有 prefill 等待。

APC+chunk32 把 S1 的首 token 提早到 7（TTFT=6），但 L0 从 32 到 40 才完成；S7 从 APC 的 32 准入/37 首 token，变为 39 准入/41 首 token。S7 的 TTFT 从 25 变 29，成为新的尾部。回到 `timeline.jsonl` 的 tick 32–39，检查哪些请求仍占 admission slot。**“一个短请求更快”不推出“总体 P95 更好”。**

自检：APC 的 8 个 TTFT 是 `[9,11,13,14,20,22,26,25]`，排好序再找 P95；U6 没有共享前缀，仍会因别人更早释放资源而间接受益。不要把所有改善都归给该请求的 cache hit。

## 3. 注入前缀失效（10 分钟）

先预测哪些列会变，再运行：

```bash
python3 infer_sched_lab.py --scenario prefix-broken --artifacts runs/prefix-broken
```

这次只把 SYS 换成每请求独有 key，保持到达时刻和输入/输出长度不变，模拟动态字段破坏前缀复用。它不是修改 prompt 后质量仍相同的证明，因为这里没有模型。

**自检答案：** APC 命中从 384 降为 0；APC 行与 continuous 行相同，P95 TTFT=30、makespan=57。若命中不是 0，先查看导出的 workload，再检查是否重用了上一轮的缓存状态。对照 [expected/prefix-broken/summary.json](expected/prefix-broken/summary.json)。回滚就是恢复 baseline 工作负载，并用原 workload hash 重放。

## 4. 注入 deadline：漂亮 P95 为什么不能通过（15 分钟）

```bash
python3 infer_sched_lab.py --timeout-ticks 30 --artifacts runs/deadline30
```

deadline 从 arrival 算，排队也消耗预算；到截止时刻仍未完成的请求是 timeout。代码保留已生成 token 和终止状态，不补造 last token。恰在截止时刻完成的请求算成功。

**自检答案：** APC 的成功请求 P95 TTFT 从 26 降到 14，但只完成 3/8，失败率是 `5/8=62.5%`。static 为 0/8，其延迟显示 `NA`、JSON 为 `null`，不是“零延迟”。对照 [expected/deadline30/summary.json](expected/deadline30/summary.json)。

统计合同：

- 错误率分母是全部 8 个到达请求，包括排队超时；实验没有 retry，真实 retry 应同时报告逻辑请求和尝试次数。
- TTFT/E2E 的主表只统计成功完成的请求，并同时给出 `latency_samples`；失败没有完整 E2E，不能填 0 混进 P95。其等待到失败的时间仍可从 raw 复算。
- ITL 收集成功请求中全部相邻 token gaps；TPOT 先在每个请求内平均，再跨请求取 P95。只输出 1 token 的请求没有这两个指标。
- `throughput` 含超时前吐出的部分 token；`completed_output_tps` 只计成功完成的输出。二者都不是 quality-qualified goodput：模拟器没有质量评分。

手算另一组时间戳 `[2,3,4,14]`：gaps 为 `[1,1,10]`，TPOT=4，而该请求的 P95 ITL=10。真实压测若一个 stream chunk 含多个 token，不能把包间隔冒充精确 token 间隔；记录工具实际可观测边界。

## 5. 迁移到陌生负载（20 分钟）

先保存预测，再运行输出长度全部变为四倍的 `output-heavy`。到达和 prompt 保持不变，但它是**新的 workload**，不能与 baseline 混成同一个性能比较：

```bash
python3 infer_sched_lab.py --scenario output-heavy --artifacts runs/output-heavy
```

先只读新 `workload.json`，对该输入重新比较四个配置，解释 APC 的绝对/相对收益是否一样。参考结果放在 [expected/output-heavy/summary.json](expected/output-heavy/summary.json)，建议完成自己的分析后再打开。答案片段：continuous makespan=172，APC=169；默认是 57→52。更多 decode 工作不由 APC 消除，不能把默认负载收益百分比直接套过来。

然后复制导出的 workload，自选一次变化（例如把 U6 的 suffix_tokens 从 160 改为 640），保存为新文件。用 `--workload` 固定重放，写出预测、实际结果、反例和仍未知的瓶颈。验收不要求某个配置获胜，要求结论能定位到请求与调度 tick。

## 6. 按资源继续，不跳级宣称

| 资源 | 下一步实验 | 可以声明的结论 | 尚缺证据 |
|---|---|---|---|
| 无 GPU | 完成以上三组对照、故障与陌生 workload；或分析已获授权的原始 trace | 模拟机制、统计口径、对给定 trace 的归因 | GPU 延迟、KV 抢占、模型质量、容量 |
| 单 GPU | 在固定模型/tokenizer/template、quality set、到达序列和 timeout 下，只改 APC 或 admission；保存 client token/stream 时间、队列、KV 和 preemption | 这一硬件与版本上的实测差异 | 多 GPU 通信和故障域；不得外推 |
| 多 GPU | 先复现单机基线，再比较同总 GPU 数的 TP 与 replicas；固定质量和负载，记录通信与 N−1 变化 | 已测拓扑下的扩展与故障代价 | 未测模型、网络和新工作负载 |

单 GPU 的第一份产物是实际环境 manifest 和失败保留完整的 raw 数据。到 [Ch25](https://learning-field-guides.pages.dev/ai-infra/chapters/ch25.html) 再做 rate sweep、质量门、goodput 与成本选择；无需现在先搭一整套集群。RAG 的证据相关性、Agent 的工具选择由各自应用测试提供固定质量门，Infra 在该门不退化的条件下研究资源和服务行为。

## 交付与验收

提交 `prediction.md`、四次运行目录、`diagnosis.md` 与一份自己修改的新 workload。通过线：能从 S1/S7 raw 重算 TTFT；解释 chunk32 的反例；抓住 5/8 timeout 的分母；指出模拟器没有 KV 模型与质量评分；迁移结论有适用条件。现有与新增测试共 16 项，包括手算时间线、空统计、单 token、超时、前缀失效和重放。

如果卡住：结果不一致先比 workload hash 和参数；没有首 token 先看 status；看到 P95 改善先检查成功样本数；不要先加更多调度开关。

## 一手核对路径（访问于 2026-09-30）

- [vLLM Automatic Prefix Caching](https://docs.vllm.ai/en/latest/features/automatic_prefix_caching/)：读前问“复用省掉哪一阶段”；读后写出为什么输出很长时收益可能变小。
- [vLLM Optimization and Tuning](https://docs.vllm.ai/en/latest/configuration/optimization/)：对照实际版本的 decode 优先、token budget 与 preemption，列出本模拟器没有实现的两项机制。
- [vLLM Production Metrics](https://docs.vllm.ai/en/latest/usage/metrics/)：核对实际采集指标及统计边界，不把客户端流式包、服务端 token 和平均 TPOT 自动视为同一个测量。
