home/tutorial/M5 mini-vLLM

Month 5 · 原创项目 · mini-vLLM

从零写一个能 serve 一个请求的 paged-attention engine。这是简历最重的那张牌。

到目前为止,你都在读别人的代码。这个月反过来: 把 M2-M4 学到的东西用自己的代码重新发明一遍。 发明的过程会暴露所有你以为懂、其实没懂的地方—— 这正是这一步的价值,也是这门课最贵的一张牌。

驱动问题
给你一台 A10 (24GB)、一个 Llama 3.1 8B Instruct、PyTorch 2.4 和 FlashAttention 2 现成 kernel。 不允许 import vllm。你要在 3 周内 serve 一个 OpenAI 兼容请求,能 streaming 输出。 从哪儿写第一行代码?
先想一想,再展开建议路径

反直觉答案:从最简单的 single-request loop 开始,先丑后美

不要先写 block manager、不要先想 batching、不要先用 FastAPI。 第一周的目标只是:"一个 hardcoded prompt 进去,几行 token id 出来"。

这就是 v0.1 → v0.5 的渐进路径:

  • v0.1:单请求、朴素 KV cache、贪婪 sampling。证明能跑文本。
  • v0.2:换成 block-based KV,但仍单请求。证明分页正确。
  • v0.3:加 scheduler,多请求 continuous batching。证明能并发。
  • v0.4:套 FastAPI + SSE。证明能跟外部协议接。
  • v0.5:跟 vLLM benchmark,写 README + blog。证明能讲清楚。

每一版都要跑通再前进。不要并行开 4 个分支同时写。

01项目意义 · 为什么"重新发明轮子"是顶投资

有人会说:vLLM 已经写完了,开源、文档全、社区活跃,你写一个慢 3 倍的 toy 有什么用? 这个问题问得对——但答案不在最终产物,而在制造过程

"读" 是识别,"写" 是召回

人脑对信息有两种掌握程度:识别 (recognition)召回 (recall)。 读一段代码、点头说"嗯我懂这个 if 在干嘛",是识别。 让你关掉编辑器、自己写一遍同样功能的代码,是召回。 这两个差一个数量级——你以为懂的 80%,召回时只剩 20%。

M1-M4 你都在做识别:读 vLLM 源码、看论文、画状态图。 M5 强制你切换到召回模式:合上 vLLM 的 tab,在空白的 engine.py 里从 0 写。 写出来你会发现一堆"咦,这里 vLLM 是怎么处理的来着?" 那些就是你的盲点

认知科学背景
这是教育研究里"generation effect" 的标准结论:自己生成内容比被动阅读,留存率高 2-3×。 Andy Matuschak 和 Michael Nielsen 在 How can we develop transformative tools for thought? 里反复强调这一点。M5 是这门课唯一逼你做"generation"的环节,所以它是最贵的一张牌。

你会发现的具体盲点

下面这些是历届做 mini-vLLM 的人真实写到的总结,几乎每个人都中招:

每个盲点都是 vLLM 源码里你滑过去没注意的一个细节。 你的笔记本会从这些"咦"积累出一份比任何博客都准的 vLLM 注解。

简历价值:可验证的 hard signal

在 infra/serving 的招聘市场里,最稀缺的不是"懂论文的人",而是能交付端到端系统的人。 "我读完了 vLLM 全部源码"——招聘方只能信你。 "我从零写了一个 500 行的 mini-vLLM,github 公开仓库,benchmark 表在 README"—— 招聘方可以 5 分钟自己验证。这个差别在 hiring 里叫 hard signal vs soft signal,重要性差一个数量级。

更进一步:当面试官问你"PagedAttention 跟 OS 虚拟内存的区别", 读过 vLLM 的人能答出 3 个;写过 BlockManager 的人能答出 3 个 + 自己踩过的 1 个反直觉点。 后者是 senior signal,能让你的薪资段跳一档。

时间预算:3 周够吗?

v0.5 的目标定得很谨慎,3 周(约 60-80 小时)对单人完全够。 历届做完的人里,最快 1.5 周 v0.1 跑通、3 周 v0.5 收尾; 最慢的人卡在 v0.1 一周,主要因为 HF 模型 load 不熟。 本章后面的"急救包"专门治这些卡点。

⚠ 时间陷阱
3 周不是"3 周自由探索",是 3 周交付 v0.5。 最常见的失败模式:第一周想做完美的 architecture,第二周还没写一行模型代码,第三周匆忙凑 demo。 反过来:第一天就写 main.py 那个最 dumb 的 forward 循环。 漂亮的架构在 v0.3 才需要。

02项目范围 · 明确做什么和不做什么

第一周开工前,把这张表抄一份贴在显示器边上。每次冒出"要不要加 X"的念头,看一眼。

✅ 做

  • 单卡 fp16 推理 Llama 3.1 8B Instruct
  • 自己的 BlockManager(固定 16 token block)
  • 自己的 continuous batching Scheduler(FCFS + recompute preempt)
  • OpenAI-compatible chat API (FastAPI)
  • SSE 流式输出
  • temperature + top_p + top_k sampling
  • 跟 vLLM 对照的 benchmark + 差距分析

❌ 不做(这版不要)

  • 多 GPU TP/PP(单卡先跑通,多卡 = M5+)
  • 量化(fp16 即可,量化 = LLM compression 另一条线)
  • 多 LoRA(用例太少,不值这周时间)
  • 自己写 CUDA attention kernel(用 FlashAttention 2 现成的)
  • 编译器优化(torch.compile 可选,CUDA graph 留给延伸)
  • 支持多种模型架构(只 Llama,泛化 = 工程问题不是学习问题)
  • prefix caching / speculative decoding(功能性优化,留给延伸)

为什么是这些"不做"?三条标准:

  1. 不在学习路径上:比如量化是另一个 6 个月的领域,强塞进来稀释主线。
  2. 工程量远大于学习量:比如多模型架构支持,写一遍 Mistral 适配跟 Llama 几乎一样,但要花一周做适配层。
  3. 依赖你还没掌握的东西:比如多 GPU TP 要 NCCL 通讯调试,先把单卡跑通再说。
💡 scope creep 是 1 号杀手
当 v0.3 跑通后你会有强烈冲动"要不顺手加个 prefix caching?反正都是 KV 操作"。 抑制住。先把 v0.5 交付完整,再开 v0.6 branch。 完整 v0.5 比半成品 v0.7 在简历和 blog 上都强 3 倍。

03架构 · 4 个核心模块

FastAPI server ~80 行 Engine + Scheduler ~150 行 BlockManager ~80 行 ModelRunner ~150 行 Llama (HF) + FlashAttn 外部依赖 M2 学的 M3 学的
总计 < 500 行 Python 跑通最小可用。简洁是目的,不是约束。

模块清单 · 每个一段

开工前把每个模块的 spec 写在 design.md 里,不要写完一个改一个。下面是参考规格。

模块职责关键状态关键方法行数预算
BlockManager 管理固定大小的 KV block 池;分配/释放/查询 block_table free_blocks: deque[int]
block_tables: dict[req_id, list[int]]
ref_count: dict[int, int]
allocate(req_id, n)
append(req_id)
free(req_id)
can_allocate(n)
~80
Scheduler 每个 step 决定谁参与、prefill/decode、是否抢占 waiting: deque[Request]
running: list[Request]
token_budget: int
add_request(req)
schedule() -> SchedulerOutput
_preempt(req)
~150
ModelRunner 把 SchedulerOutput 翻译成 forward 调用;管 GPU tensor model: LlamaForCausalLM
kv_cache: list[tensor] (paged)
execute(sched_out) -> logits
sample(logits, params)
~150
Engine 主循环:拉 request → schedule → execute → detokenize → emit token scheduler
runner
tokenizer
add_request(prompt)
step()
run_forever()
~80
API server HTTP 接收 OpenAI 协议 → 推到 engine → SSE 回 FastAPI app · async queue chat_completions()
stream_response()
~80

请求生命周期 · 一图看穿

API Engine Scheduler ModelRunner POST /v1/chat add_request() schedule() execute() token SSE chunk execute() next step 每 step 循环:每 token 都走 schedule → execute → 回 API → SSE → 浏览器
一个请求的全程:FastAPI 把 prompt 推给 Engine;Engine 主循环每个 step 调一次 schedule 和 execute;每生成一个 token 沿原路反推回浏览器。

04每周里程碑 · v0.1 → v0.5

下面这张时间线是硬性目标。每周末必须有一个可演示的版本。 "差不多写完了,差一点收尾"不算通过——下一周开始前能 python demo.py 跑出预期输出才算。

Week 1
单请求跑通 (v0.1)
交付物main.py 一个文件,python main.py "解释一下 PagedAttention" 能流式打印 200 个 token。
关键步骤:load HF Llama 3.1 8B → 套 chat template → 朴素 contiguous KV cache → naive decode loop → greedy sampling → 打印。
验收:(1) 输出文字通顺 (2) 显存占用 ~17GB (3) ~30 tok/s。
常见卡点:chat template 没套;fp16 vs bf16 选错;attention mask 维度错;EOS token id 错。这周不开 BlockManager、不开 batching。
Week 1.5
加 BlockManager (v0.2)
交付物block_manager.py + paged_attention.py,main.py 改用 paged KV。
关键步骤:预分配 N 个 block 的全局 KV pool;写 allocate / append / free;attention forward 时按 block_table gather;用 FlashAttention 2 的 flash_attn_with_kvcache 或自己 PyTorch gather。
验收:(1) 输出跟 v0.1 完全一致 (greedy 模式下) (2) 显存利用率提升、能塞更长 context (3) 单元测试覆盖 allocate / free / OOM。
常见卡点:block_table 的 last_block 总是要 partial 写入;free 后没清 ref;attention 输入的 cache_seqlens 算错。
Week 2
加 Scheduler (v0.3)
交付物scheduler.py + engine.py,支持 engine.add_request() 并发塞 32 个请求,全部正确生成。
关键步骤:Request dataclass;FCFS waiting queue;每 step 算 token_budget;prefill 和 decode 混跑;KV 不够时 preempt (recompute)。
验收:(1) 32 并发完成,输出跟单独跑一致 (2) throughput 提升至少 5× (3) 没有死锁、没有泄漏 (运行 10 分钟显存稳定)。
常见卡点:scheduler 和 model 的 token 顺序对不上;preempt 后 KV 没释放干净;request finish 后 block 没回收。这是整个项目最难的一周
Week 2.5
加 HTTP layer (v0.4)
交付物server.pypython -m mini_vllm.server 起服务;用 openai-python SDK 流式调用成功。
关键步骤:FastAPI + uvicorn;/v1/chat/completions 路由;async queue 桥接 HTTP 和 Engine;SSE format 写对 (data: {...}\n\n);OpenAI 协议字段 (model, choices, delta) 全覆盖。
验收:(1) curl 能拿到完整非流式响应 (2) openai SDK stream=True 能逐 chunk 回 (3) 多客户端并发 SDK 调用正常。
常见卡点:HTTP request lifecycle 跟 engine step 不同步;async generator 没 yield 给 FastAPI;client 提前断连时 engine 没清理。
Week 3
Benchmark + 写 README (v0.5)
交付物benchmarks/ 目录脚本 + 数据 + 画图;README.md 包含架构图 + benchmark 表 + 差距分析 + 限制列表;blog 草稿。
关键步骤:用同 model 同硬件跑 vLLM + mini-vLLM 对照;ShareGPT 5K prompts;记 throughput / TTFT / TPOT / 显存利用率;分析 2-5× 差距来源。
验收:(1) benchmark 数字能被别人复现 (2) README 里有 3 个差距来源具体讨论 (3) blog 第一稿至少 1500 字。
常见卡点:benchmark 跑不动—— vLLM 装错版本;mini-vLLM tail latency 巨高没注意;写 README 摊大饼写不完。这周时间最容易超,准备多投半周。
⚠ 别在 v0.1 卡住超过 3 天
Week 1 卡 3 天 = 整个项目偏移 1 周 = v0.5 交不上 = 没 blog 没 benchmark。 这是历届最常见的失败模式。3 天还没出第一个 token,停下来对照 vLLM examples/offline_inference.py 看它怎么 load 怎么 decode。抄结构不丢人,抄完再换成自己的代码。

05关键设计决策 · 每个都要算一遍

开工前把这 6 个决策都写下来 + 算清楚,存进 design.md。 不算清楚就开写,第 2 周一定要回头改架构。

5.1 block_size 选多少 (= 16)

vLLM 默认 16。你也选 16,但要能讲清楚为什么。trade-off 的两端:

block_size内部碎片外部碎片block_table 大小attention kernel 适配
10%0%巨大 (每 token 一项)退化成 unpaged
8~6%tile 对齐有压力
16~12%对齐 32/64 head_dim tile 友好
32~25%kernel 友好
128~50%很小退化成几乎 contiguous

16 是 attention kernel tile size 跟内部碎片的甜点。 内部碎片公式:(block_size / 2) / avg_seq_len,假设 avg_seq_len = 256,block 16 浪费 ≈ 3%。 没什么动力换。

5.2 KV cache 池多大 (Llama 3.1 8B fp16 on A10 24GB)

这是最值得手算一遍的题。算式拆成几步:

(a) 单 token KV 大小

Llama 3.1 8B 架构:32 layers, 8 KV heads (GQA), head_dim = 128, fp16 (2 bytes)。

per_token_kv = 2 (K+V) × 32 × 8 × 128 × 2 = 131072 bytes ≈ 128 KB

(b) 单 block 大小 (block_size=16)

per_block = 128 KB × 16 = 2 MB

(c) 可用 KV 显存

(d) block 总数

num_blocks = 6 GB / 2 MB = 3072 块

(e) 等价并发能力

把这一坨算完写进 design.md,再写成函数 compute_kv_pool_size(gpu_mem, model)。 启动时打印这些数字——出问题第一时间就知道是 OOM 边界。

💡 留一点 headroom
实际工程里 KV pool 通常留 90% 上限:num_blocks = int(0.9 × 6GB / 2MB)。 剩下 10% 给 CUDA workspace、cuBLAS 工作区、CUDA graph buffer。 你写 prototype 可以拉到 95%,但跑 benchmark 时压力大会 OOM 别意外。

5.3 token budget · 一步算多少 token

scheduler 的核心数。设过小 → 每步 batch 小、吞吐低;设过大 → 单 step 太久、tail latency 高。 vLLM 默认 2048-8192,依硬件而定。

从 prefill throughput 反推合理值:

但 decode 是 memory-bound,500 token 的 decode batch 几乎不增加耗时; 所以实际值定 1024-2048 之间,先用 2048,benchmark 后再调。

5.4 preempt 策略 · recompute vs swap

维度recomputeswap
实现复杂度低 (free block + 状态回 waiting)高 (写 cudaMemcpyAsync + host 池)
显存彻底释放❌ (block 还占着 host 内存)
抢占代价O(seq_len) 重算O(seq_len) 拷贝 (PCIe)
长上下文成本4K prompt 重 prefill 可能 200ms4K KV 拷贝 ~50ms (16 GB/s PCIe)
v0.5 选什么✅ recompute留给 v0.6

选 recompute 的理由:你已经有 prefill path,只需要清掉 block 让 request 回到 waiting, 20 行代码以内能搞定。swap 要写 host 内存池、异步拷贝、跨 step 状态机,单独是一周工作量。

5.5 attention backend

三种可选。建议组合:

阶段kernel原因
prefillFlashAttention 2 flash_attn_funccontiguous 输入,标准用法,~3× 朴素
decode (paged)flash_attn_with_kvcache原生支持 page_table 输入,省去自己 gather
fallback (debug)PyTorch F.scaled_dot_product_attention调试时换上,确认输入对不对

不要自己写 paged attention CUDA kernel——M4 学的 Triton 知识够你看懂 FlashAttention, 不够你写一个生产级 paged kernel。把这个时间留给 scheduler 和 benchmark。

5.6 sampling · temperature + top_p + top_k

完整 sampling 公式:

  1. logits → logits / temperature (temperature=0 → greedy)
  2. top_k 截断:保留 top k 个 logits,其余设为 -inf
  3. softmax → 概率分布
  4. top_p (nucleus) 截断:累积概率超过 p 的低概率位置设为 0,重归一
  5. multinomial 采样

PyTorch 全部能搞,~20 行。OpenAI 协议默认 temperature=1, top_p=1。 注意 top_k 在 top_p 之前,符合 HuggingFace transformers 的惯例 (尽管 vLLM 顺序略有差异——cross-check)。

06完整代码骨架 · v0.1 单请求版

下面这段 ~150 行是可以直接 copy-paste 跑起来的 (Linux + CUDA + transformers + flash-attn)。 v0.1 不追求美,追求跑出文本。读完每一节再去敲。

6.1 imports 与 setup

# mini_vllm/v01.py
"""mini-vLLM v0.1 — naive single-request decoder.
No paging, no batching, no HTTP. Just prove the model speaks.
"""
import torch
from transformers import AutoTokenizer, AutoModelForCausalLM

MODEL_NAME = "meta-llama/Llama-3.1-8B-Instruct"
DEVICE = "cuda"
DTYPE = torch.float16
MAX_NEW_TOKENS = 200

注意 torch.float16 在 A10 上跑得通;如果你拿到的卡是 A100/H100, 推荐 torch.bfloat16 数值更稳。Llama 官方权重对 bf16 更友好。

6.2 load model · 一次性

def load_model():
    tokenizer = AutoTokenizer.from_pretrained(MODEL_NAME)
    model = AutoModelForCausalLM.from_pretrained(
        MODEL_NAME,
        torch_dtype=DTYPE,
        attn_implementation="flash_attention_2",  # 关键:用 FA2
        device_map=DEVICE,
    )
    model.eval()
    return tokenizer, model

tokenizer, model = load_model()
config = model.config
print(f"Loaded {MODEL_NAME} · {sum(p.numel() for p in model.parameters())/1e9:.1f}B params")

attn_implementation="flash_attention_2" 在 transformers 4.40+ 是支持的; 如果你的版本不支持,换成 "sdpa",慢一点但跑得通。

6.3 prepare prompt · chat template

def prepare_prompt(user_msg: str) -> torch.Tensor:
    messages = [
        {"role": "system", "content": "You are a helpful assistant."},
        {"role": "user", "content": user_msg},
    ]
    # 套 chat template 是关键的一步;缺了 instruct 模型输出会乱
    prompt_ids = tokenizer.apply_chat_template(
        messages,
        add_generation_prompt=True,   # 末尾加 assistant 的 prompt header
        return_tensors="pt",
    ).to(DEVICE)
    return prompt_ids  # shape: [1, prompt_len]

prompt_ids = prepare_prompt("用一句话解释 PagedAttention。")
print("Prompt tokens:", prompt_ids.shape[1])
⚠ 这一步最容易踩
add_generation_prompt=True 必须加,否则模型不知道现在轮到它说话。 少了它输出会自言自语补 user turn。如果你的输出像 "Human: ... Assistant: ..." 这种乱讲,先查这里。

6.4 朴素 KV cache · 最 dumb 版

def make_kv_cache(max_len: int):
    """Naive contiguous KV cache: [num_layers, 2, max_len, n_kv_heads, head_dim]
    Reality check: layout 必须跟 HF Llama forward 接受的 past_key_values 形状匹配。
    HF 用 list[tuple[K, V)] 而不是单 tensor — 我们包一层。
    """
    n_layers = config.num_hidden_layers
    n_kv_heads = config.num_key_value_heads
    head_dim = config.hidden_size // config.num_attention_heads
    # 每层一对 (K, V),shape [batch=1, n_kv_heads, max_len, head_dim]
    kv = []
    for _ in range(n_layers):
        K = torch.zeros(1, n_kv_heads, max_len, head_dim, dtype=DTYPE, device=DEVICE)
        V = torch.zeros(1, n_kv_heads, max_len, head_dim, dtype=DTYPE, device=DEVICE)
        kv.append((K, V))
    return kv

这个 layout 跟 vLLM 的 paged layout 完全不同。v0.1 用的是 HF 标准 contiguous, v0.2 才换成 paged。一步一步来。

6.5 prefill · 一次性塞 prompt

@torch.no_grad()
def prefill(prompt_ids):
    """跑完整 prompt 一次 forward, 返回 logits[最后 token] 和填好的 KV。"""
    out = model(
        input_ids=prompt_ids,
        use_cache=True,
        return_dict=True,
    )
    logits = out.logits[:, -1, :]            # [1, vocab]
    past_kv = out.past_key_values            # tuple of (K, V)
    return logits, past_kv

logits, past_kv = prefill(prompt_ids)
print("Prefill done, KV layers:", len(past_kv), "K shape:", past_kv[0][0].shape)

注意 past_key_values 是 HF 模型 forward 的第二份返回。 v0.1 直接信任 HF 的 layout,自己不动它;v0.2 才会自己管。

6.6 decode loop · 一次一个 token

@torch.no_grad()
def decode_step(token_id: int, past_kv):
    """单 token forward,返回 next logits 和更新后的 KV。"""
    input_ids = torch.tensor([[token_id]], device=DEVICE)
    out = model(
        input_ids=input_ids,
        past_key_values=past_kv,
        use_cache=True,
        return_dict=True,
    )
    return out.logits[:, -1, :], out.past_key_values

def sample_greedy(logits):
    return int(logits.argmax(dim=-1).item())

def generate(prompt_ids, max_new_tokens=MAX_NEW_TOKENS):
    logits, past_kv = prefill(prompt_ids)
    eos = tokenizer.eos_token_id
    out_tokens = []
    for _ in range(max_new_tokens):
        next_tok = sample_greedy(logits)
        if next_tok == eos:
            break
        out_tokens.append(next_tok)
        # 流式打印
        print(tokenizer.decode([next_tok], skip_special_tokens=True), end="", flush=True)
        logits, past_kv = decode_step(next_tok, past_kv)
    print()
    return out_tokens

generate(prompt_ids)

跑通这段就是 v0.1 验收通过。跑通之后再继续,不要先跳到 v0.2。

✓ v0.1 验收清单
  • 能稳定输出连贯文字 (不是乱码或重复)
  • 遇到 EOS 能停 (不会无限输出)
  • 显存占用稳定在 ~17 GB
  • throughput ~30 tok/s (粗糙没关系)

07完整代码骨架 · v0.2 加 BlockManager

v0.1 用的是 HF 的 contiguous KV。v0.2 换成 paged。核心改动: (a) 自己管 KV pool tensor;(b) 写 BlockManager;(c) 改 forward 路径不再用 past_key_values,而是自己 gather。

7.1 BlockManager · ~80 行

# mini_vllm/block_manager.py
from collections import deque
from dataclasses import dataclass, field
from typing import Dict, List

BLOCK_SIZE = 16

@dataclass
class BlockManager:
    num_blocks: int
    free_blocks: deque = field(default_factory=deque)
    block_tables: Dict[str, List[int]] = field(default_factory=dict)
    seq_lens: Dict[str, int] = field(default_factory=dict)

    def __post_init__(self):
        self.free_blocks = deque(range(self.num_blocks))

    def can_allocate(self, n_tokens: int) -> bool:
        n_blocks = (n_tokens + BLOCK_SIZE - 1) // BLOCK_SIZE
        return len(self.free_blocks) >= n_blocks

    def allocate(self, req_id: str, n_tokens: int) -> List[int]:
        """初次分配 (prefill 时调一次)"""
        n_blocks = (n_tokens + BLOCK_SIZE - 1) // BLOCK_SIZE
        if len(self.free_blocks) < n_blocks:
            raise RuntimeError(f"OOM: need {n_blocks} blocks, have {len(self.free_blocks)}")
        blocks = [self.free_blocks.popleft() for _ in range(n_blocks)]
        self.block_tables[req_id] = blocks
        self.seq_lens[req_id] = n_tokens
        return blocks

    def append_token(self, req_id: str) -> int:
        """decode 时每生成一个 token 调一次; 必要时新增一个 block."""
        seq_len = self.seq_lens[req_id]
        new_len = seq_len + 1
        # 当前 block 还没满 → 不用新分配
        if new_len % BLOCK_SIZE != 1 or new_len == 1:
            self.seq_lens[req_id] = new_len
            return self.block_tables[req_id][-1]
        # 需要新 block
        if not self.free_blocks:
            raise RuntimeError("OOM during append")
        new_block = self.free_blocks.popleft()
        self.block_tables[req_id].append(new_block)
        self.seq_lens[req_id] = new_len
        return new_block

    def free(self, req_id: str):
        for b in self.block_tables.pop(req_id, []):
            self.free_blocks.append(b)
        self.seq_lens.pop(req_id, None)

    def get_block_table(self, req_id: str) -> List[int]:
        return self.block_tables[req_id]

    def stats(self):
        return {
            "free": len(self.free_blocks),
            "used": self.num_blocks - len(self.free_blocks),
            "util": 1 - len(self.free_blocks) / self.num_blocks,
        }

7.2 paged KV pool · 全局一坨大 tensor

# mini_vllm/kv_pool.py
import torch

class PagedKVCache:
    def __init__(self, num_blocks, num_layers, n_kv_heads, head_dim, dtype, device):
        # Layout: [num_layers, 2 (K/V), num_blocks, BLOCK_SIZE, n_kv_heads, head_dim]
        # 这是 FlashAttention 2 的 page_table API 友好的 layout
        self.cache = torch.zeros(
            num_layers, 2, num_blocks, BLOCK_SIZE, n_kv_heads, head_dim,
            dtype=dtype, device=device,
        )

    def get_k(self, layer):
        return self.cache[layer, 0]  # [num_blocks, BLOCK_SIZE, n_kv_heads, head_dim]

    def get_v(self, layer):
        return self.cache[layer, 1]

7.3 paged attention 调用

# mini_vllm/paged_attention.py
# 调 FlashAttention 2 的 with_kvcache 接口 —— 它原生支持 page_table。
# 注意:这是真实 API;具体签名请对照你装的 flash-attn 版本。
from flash_attn import flash_attn_with_kvcache

def paged_decode_attention(
    q,                  # [1, 1, n_q_heads, head_dim]  当前 token 的 query
    k_cache,            # [num_blocks, BLOCK_SIZE, n_kv_heads, head_dim]
    v_cache,            # 同上
    k_new, v_new,       # [1, 1, n_kv_heads, head_dim]  当前 token 的 K/V (写入用)
    block_table,        # [1, max_blocks]  本 batch 的 block 索引
    cache_seqlens,      # [1]              本 batch 已写入的 token 数
):
    """FA2 自动:
       1. 把 k_new/v_new 写入 k_cache/v_cache 的 cache_seqlens 位置
       2. 用 block_table 做 paged gather
       3. 算 attention 输出
    """
    out = flash_attn_with_kvcache(
        q=q,
        k_cache=k_cache,
        v_cache=v_cache,
        k=k_new,
        v=v_new,
        block_table=block_table,
        cache_seqlens=cache_seqlens,
        causal=True,
    )
    return out  # [1, 1, n_q_heads, head_dim]
💡 不强求 FA2
如果 flash_attn_with_kvcache 装不上,写一个 PyTorch fallback:
  1. 按 block_table gather K/V 到 contiguous tensor
  2. F.scaled_dot_product_attention(q, K, V, is_causal=True)
慢 5-10×,但能跑通逻辑。v0.5 benchmark 再换 FA2。

7.4 改造 Llama forward · 替换 attention 层

HF Llama 自带 attention 层不支持 page_table。两种选项:

v0.2 推荐 monkey-patch,等 v0.3 scheduler 稳定后再 refactor。 具体怎么 patch 不在这一节展开——这是 transformers 内部 API, 建议读 vLLM 的 vllm/model_executor/models/llama.py看它如何把 attention 接到自己的 paged kernel。

08完整代码骨架 · v0.3 加 Scheduler

v0.3 是整个项目最难的一步。这周不要碰 HTTP、不要碰 sampling 复杂逻辑, 全力打磨调度器。

8.1 Request dataclass

# mini_vllm/request.py
from dataclasses import dataclass, field
from enum import Enum
from typing import List, Optional
import uuid

class RequestState(Enum):
    WAITING = "waiting"          # 还没 prefill
    RUNNING = "running"          # 已经在 batch 里 decode
    PREEMPTED = "preempted"      # 被抢占,回到 waiting
    FINISHED = "finished"

@dataclass
class SamplingParams:
    temperature: float = 1.0
    top_p: float = 1.0
    top_k: int = -1              # -1 表示不截断
    max_tokens: int = 256
    stop_token_ids: List[int] = field(default_factory=list)

@dataclass
class Request:
    request_id: str
    prompt_token_ids: List[int]
    sampling_params: SamplingParams
    state: RequestState = RequestState.WAITING
    output_token_ids: List[int] = field(default_factory=list)
    # KV cache 长度 = prompt + output 已 commit 到 cache 的 token 数
    num_computed_tokens: int = 0
    arrival_time: float = 0.0

    @property
    def total_len(self) -> int:
        return len(self.prompt_token_ids) + len(self.output_token_ids)

    @property
    def is_prefill(self) -> bool:
        return self.num_computed_tokens < len(self.prompt_token_ids)

    @staticmethod
    def new(prompt_ids: List[int], params: SamplingParams) -> "Request":
        return Request(
            request_id=str(uuid.uuid4()),
            prompt_token_ids=prompt_ids,
            sampling_params=params,
        )

8.2 SchedulerOutput · 一次 step 的计划

# mini_vllm/scheduler.py
from dataclasses import dataclass, field
from collections import deque
from typing import List, Dict
from .request import Request, RequestState

@dataclass
class SchedulerOutput:
    # 本 step 要跑的 requests
    scheduled_reqs: List[Request] = field(default_factory=list)
    # 每个请求本步要算的 token 数(prefill 时可能 chunked)
    num_tokens_per_req: Dict[str, int] = field(default_factory=dict)
    # 本步抢占了谁
    preempted_req_ids: List[str] = field(default_factory=list)
    # 本步完成了谁 (EOS / max_tokens)
    finished_req_ids: List[str] = field(default_factory=list)

8.3 Scheduler 主体 · ~120 行

class Scheduler:
    def __init__(self, block_manager, token_budget: int = 2048, max_running: int = 64):
        self.block_manager = block_manager
        self.token_budget = token_budget
        self.max_running = max_running
        self.waiting: deque[Request] = deque()
        self.running: List[Request] = []

    def add_request(self, req: Request):
        self.waiting.append(req)

    def schedule(self) -> SchedulerOutput:
        out = SchedulerOutput()
        budget_remaining = self.token_budget

        # ----- 阶段 1: running 队列继续 decode -----
        new_running = []
        for req in self.running:
            if budget_remaining < 1:
                break
            # decode 一定要先确保 KV 有空间 (需要 append 一个 token)
            if not self.block_manager.can_allocate(1):
                # 显存压力 → 抢占队尾
                victim = self.running[-1] if self.running[-1] is not req else None
                if victim is not None:
                    self._preempt(victim)
                    out.preempted_req_ids.append(victim.request_id)
                    new_running = [r for r in new_running if r.request_id != victim.request_id]
                else:
                    break
            out.scheduled_reqs.append(req)
            out.num_tokens_per_req[req.request_id] = 1
            budget_remaining -= 1
            new_running.append(req)
        self.running = new_running

        # ----- 阶段 2: waiting 队列入场 (prefill) -----
        while self.waiting and budget_remaining > 0 and len(self.running) < self.max_running:
            req = self.waiting[0]
            need = len(req.prompt_token_ids) - req.num_computed_tokens
            # chunked prefill: 一次最多塞 budget_remaining 个 token
            chunk = min(need, budget_remaining)
            if not self.block_manager.can_allocate(chunk):
                break
            if req.state == RequestState.WAITING:
                # 第一次 prefill,分配 block
                self.block_manager.allocate(req.request_id, chunk)
                req.state = RequestState.RUNNING
                self.running.append(req)
            self.waiting.popleft()
            out.scheduled_reqs.append(req)
            out.num_tokens_per_req[req.request_id] = chunk
            budget_remaining -= chunk

        return out

    def _preempt(self, req: Request):
        """recompute preempt: 丢 KV,回 waiting,重置 num_computed_tokens."""
        self.block_manager.free(req.request_id)
        req.state = RequestState.PREEMPTED
        req.num_computed_tokens = 0
        # 已生成的 output tokens 保留 (假设它们值得保留)
        # 简化:直接当 prompt 部分重 prefill
        self.waiting.appendleft(req)

    def mark_finished(self, req: Request):
        req.state = RequestState.FINISHED
        self.block_manager.free(req.request_id)
        self.running = [r for r in self.running if r.request_id != req.request_id]
⚠ 真实的 scheduler 比这复杂得多
上面这版省略了:(a) prefill 和 decode 的 token 在同一个 forward 里的 attention mask 拼接; (b) 多种 sampling param 的 batch;(c) 优先级、admission control。 v0.3 这一版能跑通就够;想看完整的看 M3 scheduler对照 vLLM 源码。

8.4 Engine 主循环 · 串起来

# mini_vllm/engine.py
from .scheduler import Scheduler
from .block_manager import BlockManager
from .request import Request, SamplingParams, RequestState
from .runner import ModelRunner

class Engine:
    def __init__(self, model_name: str, num_blocks: int):
        self.runner = ModelRunner(model_name, num_blocks)
        self.block_manager = BlockManager(num_blocks=num_blocks)
        self.scheduler = Scheduler(self.block_manager)
        self.tokenizer = self.runner.tokenizer

    def add_request(self, prompt: str, params: SamplingParams) -> str:
        # 套 chat template
        prompt_ids = self.tokenizer.apply_chat_template(
            [{"role": "user", "content": prompt}],
            add_generation_prompt=True,
        )
        req = Request.new(prompt_ids, params)
        self.scheduler.add_request(req)
        return req.request_id

    def step(self) -> List[tuple]:
        """跑一步, 返回 [(req_id, new_token_id, finished_bool), ...]"""
        sched_out = self.scheduler.schedule()
        if not sched_out.scheduled_reqs:
            return []
        # 执行 forward
        logits_per_req = self.runner.execute(sched_out)
        # 采样
        outputs = []
        for req in sched_out.scheduled_reqs:
            if req.is_prefill:
                # prefill chunk 没结束就不采样
                req.num_computed_tokens += sched_out.num_tokens_per_req[req.request_id]
                if req.num_computed_tokens < len(req.prompt_token_ids):
                    continue
            new_tok = self.runner.sample(logits_per_req[req.request_id], req.sampling_params)
            req.output_token_ids.append(new_tok)
            self.block_manager.append_token(req.request_id)
            finished = (
                new_tok == self.tokenizer.eos_token_id
                or len(req.output_token_ids) >= req.sampling_params.max_tokens
            )
            if finished:
                self.scheduler.mark_finished(req)
            outputs.append((req.request_id, new_tok, finished))
        return outputs

09完整代码骨架 · v0.4 加 FastAPI

v0.4 把 Engine 套上 OpenAI 协议。重点是异步——HTTP request 等 streaming 时, engine 主循环还在跑 step;两边用 asyncio queue 桥接。

9.1 server 主体

# mini_vllm/server.py
import asyncio
import json
import time
import uuid
from typing import AsyncIterator
from fastapi import FastAPI, Request as HTTPRequest
from fastapi.responses import StreamingResponse
from pydantic import BaseModel
from .engine import Engine
from .request import SamplingParams

app = FastAPI()
engine: Engine = None  # 启动时填
# req_id -> asyncio.Queue 用来推 token 给等待中的 HTTP 处理函数
output_queues: dict[str, asyncio.Queue] = {}

class ChatMessage(BaseModel):
    role: str
    content: str

class ChatCompletionRequest(BaseModel):
    model: str
    messages: list[ChatMessage]
    temperature: float = 1.0
    top_p: float = 1.0
    max_tokens: int = 256
    stream: bool = False

@app.on_event("startup")
async def startup():
    global engine
    engine = Engine("meta-llama/Llama-3.1-8B-Instruct", num_blocks=3000)
    asyncio.create_task(engine_loop())

async def engine_loop():
    """背景任务:不停 step, 把 token 推到对应 req 的 queue"""
    while True:
        outputs = engine.step()
        for req_id, tok, finished in outputs:
            q = output_queues.get(req_id)
            if q is not None:
                await q.put((tok, finished))
        if not outputs:
            await asyncio.sleep(0.001)  # 让出 event loop

@app.post("/v1/chat/completions")
async def chat_completions(req: ChatCompletionRequest):
    user_msg = req.messages[-1].content  # 简化
    params = SamplingParams(
        temperature=req.temperature,
        top_p=req.top_p,
        max_tokens=req.max_tokens,
    )
    req_id = engine.add_request(user_msg, params)
    output_queues[req_id] = asyncio.Queue()

    if req.stream:
        return StreamingResponse(
            stream_sse(req_id, req.model),
            media_type="text/event-stream",
        )
    # 非流式:等到 finished
    tokens = []
    while True:
        tok, finished = await output_queues[req_id].get()
        tokens.append(tok)
        if finished:
            break
    output_queues.pop(req_id, None)
    text = engine.tokenizer.decode(tokens, skip_special_tokens=True)
    return {
        "id": f"chatcmpl-{uuid.uuid4().hex[:8]}",
        "object": "chat.completion",
        "created": int(time.time()),
        "model": req.model,
        "choices": [{"index": 0, "message": {"role": "assistant", "content": text}, "finish_reason": "stop"}],
    }

async def stream_sse(req_id: str, model_name: str) -> AsyncIterator[str]:
    cmpl_id = f"chatcmpl-{uuid.uuid4().hex[:8]}"
    try:
        while True:
            tok, finished = await output_queues[req_id].get()
            text = engine.tokenizer.decode([tok], skip_special_tokens=True)
            chunk = {
                "id": cmpl_id,
                "object": "chat.completion.chunk",
                "created": int(time.time()),
                "model": model_name,
                "choices": [{"index": 0, "delta": {"content": text}, "finish_reason": "stop" if finished else None}],
            }
            yield f"data: {json.dumps(chunk)}\n\n"
            if finished:
                yield "data: [DONE]\n\n"
                break
    finally:
        output_queues.pop(req_id, None)

跑起来:

uvicorn mini_vllm.server:app --host 0.0.0.0 --port 8000

验证:

import openai
client = openai.OpenAI(base_url="http://localhost:8000/v1", api_key="not-needed")
stream = client.chat.completions.create(
    model="mini-vllm",
    messages=[{"role": "user", "content": "为什么 KV cache 要分页?"}],
    stream=True,
)
for chunk in stream:
    print(chunk.choices[0].delta.content or "", end="", flush=True)

10"对账"方法论 · 跟 vLLM 对照

这是这个月最有价值的副产物:每写完一个模块,立刻打开 vLLM 同位置的代码对照。 盲目读 vLLM 学到的是知识;写完自己版本再对账,学到的是设计 trade-off

对账清单 · 文件级别

你的vLLM 对应重点对照
block_manager.py vllm/v1/core/block_pool.py
vllm/v1/core/kv_cache_manager.py
是否有 ref_count? prefix sharing? hash-based dedup?
scheduler.py vllm/v1/core/sched/scheduler.py chunked prefill? finish_token_ids 处理? admission control?
engine.py vllm/v1/engine/core.py
vllm/v1/engine/llm_engine.py
多 worker 进程模型? IPC?
runner.py vllm/v1/worker/gpu_model_runner.py CUDA graph capture? prepare_inputs 优化?
server.py vllm/entrypoints/openai/serving_chat.py tool calls? guided decoding? logprobs?

你会在对账中发现的典型差异

每一条都写进 NOTES.md这份 NOTES.md 就是你 blog 第二篇的草稿

11Benchmark · 跟 vLLM 跑同样测试

这是 v0.5 的硬通货。没有 benchmark,整个项目少 80% 价值

11.1 benchmark protocol

11.2 预期数字 · 跟 vLLM 对比

指标mini-vLLM (你)vLLM 0.6.x差距倍数主要差距来源
单请求 throughput~50-100 tok/s~120-180 tok/s1.5-2×没 CUDA graph / Python overhead
32 并发 throughput~500-1000 tok/s~2000-3500 tok/s3-4×kernel 优化 / Triton prepare_inputs / 多 worker
TTFT p50 (1K prompt)~150 ms~80 ms1.8×prefill kernel / chunked prefill 调度
TPOT p95~30-50 ms~15-25 msdecode kernel / batch 组织
显存利用率~85%~92%没 prefix caching / 没 swap

11.3 差距来源 · 4 个真正可分析的

  1. CUDA graph 缺失 (~30% 损失)。 vLLM 默认在 decode 阶段用 CUDA graph capture,把 launch overhead 从 ~50us/op 压到 ~5us。 decode 一步要 launch 几十个 kernel,graph capture 直接省一半。 mitigation:v0.6 加 torch.cuda.graph
  2. Triton prepare_inputs (~20% 损失)。 把 SchedulerOutput → flat GPU tensors 这一步,Python 跑 ~5ms/step。 vLLM 写了 Triton kernel,~0.5ms/step。32 并发时这是大头。 mitigation:先 profile 确认是瓶颈,再决定是否值得写。
  3. Kernel 选择差异 (~15% 损失)。 vLLM 在 H100 上用 FlashAttention 3, 在 A100/A10 上用 FA2 但有 fused QKV projection; 你只用了 stock FA2。 mitigation:v0.6 适配 FA3 + 自己写 fused projection。
  4. 多 worker GIL 影响 (~10% 损失)。 tokenization 和 detokenization 在主线程跑会跟 engine step 抢 GIL。 vLLM 把 tokenizer worker 独立进程。 mitigation:v0.6 加 tokenizer subprocess。

把上面 4 点写进 README 的 "Performance Gap Analysis" 一节。能讲清楚 4 个差距 = 你比 90% 候选人懂 serving

💡 差距是优势,不是缺点
"我比 vLLM 慢 3×" 不是 disclaimer,是 framework。 慢 3× + 知道为什么慢 + 知道怎么补 = senior infra engineer。 慢 3× + 不知道为什么 = junior。差别在分析,不在性能本身。

12README 模板

README 是你的项目对外的脸。下面这份模板照着写,能在 1-2 小时内出第一版。

# mini-vLLM

A 500-line educational LLM serving engine, built from scratch in 3 weeks.
Implements paged KV cache and continuous batching à la vLLM.

**Not a production engine.** Built to demonstrate I can build one.

![demo gif](docs/demo.gif)

## Why this exists

vLLM is ~50K lines and hard to learn from. This is a minimal implementation
of the same three core ideas — PagedAttention, continuous batching, OpenAI API —
in <500 lines, designed to be read in one sitting.

## Architecture

[insert architecture SVG/PNG here, same as §03 in tutorial]

- `block_manager.py` — fixed-size KV blocks, allocate/free/append
- `scheduler.py` — FCFS continuous batching, recompute preempt
- `runner.py` — model forward + sampling
- `engine.py` — main loop wiring above
- `server.py` — OpenAI-compatible FastAPI server

## Quickstart

```bash
pip install -r requirements.txt
python -m mini_vllm.server  # localhost:8000
```

```python
import openai
client = openai.OpenAI(base_url="http://localhost:8000/v1", api_key="x")
stream = client.chat.completions.create(...)
```

## Benchmarks (A10 24GB, Llama 3.1 8B, ShareGPT 1K)

| Metric | mini-vLLM | vLLM 0.6 | Gap |
|---|---|---|---|
| Throughput @ 32 concurrent | 900 tok/s | 3000 tok/s | 3.3× |
| TTFT p50 (1K prompt) | 150 ms | 80 ms | 1.9× |
| TPOT p95 | 35 ms | 20 ms | 1.8× |

## Performance gap analysis

1. **No CUDA graph (~30%)** — see [issue #1]
2. **Python prepare_inputs (~20%)** — see [issue #2]
3. **Stock FA2, no fused QKV (~15%)** — see [issue #3]
4. **Single-process tokenizer (~10%)** — see [issue #4]

## Design decisions

See [`design.md`](./design.md) — block_size choice, KV pool sizing,
token budget, preempt policy, attention backend.

## Limitations (intentional)

- No multi-GPU / TP / PP
- No quantization (fp16 only)
- No LoRA
- No prefix caching
- No speculative decoding
- Llama 3.1 8B only

## What I learned

See blog post: ["I wrote a mini-vLLM in 3 weeks"](https://...)

## Credits

Inspired by [vLLM](https://github.com/vllm-project/vllm).
Built as part of [Infra Learning Path](https://weishuz.com/infra-learning-path/).

填空就是了。重点是不要藏短板——把不做的写清楚,反而让做了的部分显得有重量。

13blog 第 2 篇 · 写作模板

blog 是 README 之外的叙事。READE 答"是什么",blog 答"为什么 + 怎么做的 + 你能学到什么"。

结构 · 5 个段落

  1. 钩子 (200 字)。 举例:"我花了 3 周写一个 500 行 Python 的 LLM serving engine,比 vLLM 慢 3 倍,但学到了 10 倍。下面是 3 个发现。" 钩子的目的是让人想读下去,不是让人佩服你
    技巧:开头给一个具体数字 + 一个反直觉 claim。
  2. 对照 (600 字)。 vLLM 在内存 / 调度 / kernel 三层各做了什么,你做了什么。 画一张表(你的 vs vLLM)。这一节让读者知道你不是 vibes,而是真懂
    技巧:写实际的代码 diff 或对比代码片段——比文字描述强 5×。
  3. 差距分析 (800 字)。 把 §11.3 的 4 个差距源展开成 4 段,每段:现象 → profiler 看到的图 → 原因 → 怎么补。 这一节是全文 hardest signal,能写完 = 你真做过。
    技巧:贴 nsight / py-spy / nvprof 截图,比文字可信度高 10×。
  4. 意外发现 (500 字)。 写的过程中发现自己 M2-M4 漏掉的至少 3 个细节。 举例:"我以为 vLLM 的 schedule() 一直返回 100% 利用率的 batch,写完才发现它经常吐 partial batch 来避免长尾。" 这一节最 humble,但最让面试官印象深
    技巧:用第一人称 + 具体 commit hash,不用"我们"或"大家都知道"。
  5. 结论 (200 字)。 "用到再学"在这个项目里的实操:哪些事是必须用到才学得会的,哪些事是读论文就够的。 这一节给读者带走点东西,决定他/她会不会分享这篇
    技巧:结尾给读者一个具体下一步("如果你想自己写一个,从这个 repo fork 开始")。
💡 投递 timing
blog 上线后立刻发:
  • Twitter / X:tag @vllm_project, @code_star, @lqiao (Together CEO)
  • r/LocalLLaMA reddit (周日晚 PT 上 hot)
  • vLLM Slack #showcase channel
  • Hacker News (周二早 PT 最稳)
reach 比你想的高——这类"我重新发明轮子"内容是 infra 圈最爱的格式。 你的下一个工作面试很可能就来自这篇。

14常见卡点 · 急救包

下面是历届做这个项目最常踩的 6 个坑,按出现频率排序。卡住时先查这里。

14.1 "模型 load 不了 / 巨慢"

症状from_pretrained 跑 10 分钟还没出 progress bar;或报 401。

诊断

14.2 "fp16 输出乱码"

症状:模型能跑,但输出全是 !!!!!### ### ### 或乱码 token。

诊断顺序

  1. chat template 套了吗? 90% 的乱码是这个。打印 tokenizer.decode(prompt_ids[0]) 看 prompt 是不是带了 <|begin_of_text|><|start_header_id|> 这些标记。
  2. 试 bf16。Llama 官方权重对 bf16 数值更稳。如果你在 A10 上 (支持 bf16) 直接换。
  3. attention_mask 对吗? prefill 时要 [[1, 1, 1, ..., 1]],decode 时要 [[1, ..., 1]] 长度 = 累积已 cache + 当前 token。
  4. position_ids 对吗? decode 时是 [[cur_pos]] 不是 [[0]]

14.3 "KV cache 越来越慢"

症状:第 10 个 token 100ms, 第 100 个 token 500ms, 第 500 个 token 几秒。

诊断:99% 是你在每 step 做 torch.cat 拼 KV,O(n²) 复杂度。

修法:预分配最大长度的 KV tensor,每 step 原地写一行 (kv[:, :, cur_pos:cur_pos+1, :] = new_kv)。 这是 v0.1 → v0.2 升级的核心动机:paged 天然是预分配。

14.4 "throughput 不增长"

症状:v0.3 加了 scheduler,benchmark 32 并发跟单请求一样慢。

诊断

最常见错误:scheduler 选了 16 个 req,但 runner 用了 for req in reqs: forward(req)。 正确做法是把 16 个 req 的 input torch.cat 成一个 batch forward。

14.5 "OOM 在第二个请求"

症状:第一个 req 跑完,第二个 req 来了立刻 OOM。

诊断:KV 没释放。检查:

调试技巧:每 step 末尾打印 block_manager.stats()。util 应该波动而不是单调上升。

14.6 "FastAPI streaming 客户端断了 engine 还在跑"

症状:用户 curl ctrl-C,engine 继续生成到 max_tokens,浪费算力。

修法:在 stream_sse 的 finally 里 abort engine 的 request:

try:
    ...
finally:
    output_queues.pop(req_id, None)
    engine.abort_request(req_id)  # 你需要在 Engine 实现这个

abort_request 内部:找到对应 Request,标记 FINISHED,让 scheduler 下次 step 时 free 掉。

15后续延伸 · v0.6 及以后

v0.5 交付后,下面这些方向哪个对你的下一份工作价值最高?挑 1-2 个继续做。

方向难度预计时间简历价值说明
多 GPU TP ★★★★ 2 周 ★★★★★ Tensor Parallel 切模型;用 PyTorch dist 或 nccl。打通后能跑 70B Llama。
LoRA 多租户 ★★★ 1 周 ★★★ 同一 base + 多个 LoRA adapter 切换。S-LoRA 论文是参考。
Speculative decoding ★★★ 1.5 周 ★★★★ 用小模型预测 + 大模型验证,2-3× decode 加速。论文:Medusa, Lookahead。
INT8/INT4 量化 ★★★ 1 周 ★★★ 用 bitsandbytes 或 AWQ。8B INT4 显存 ~5GB,能跑长 context。
Prefix caching ★★ 0.5 周 ★★★ BlockManager 加 ref_count 和 hash dedup。多用户共享 system prompt 时 2-3× 吞吐。
CUDA graph ★★ 0.5 周 ★★★ decode 阶段 capture graph, 减 50% launch overhead。直接补 §11.3 的 #1 差距。
💡 优先级
如果目标是 infra 工作 offer,M5 之后不要只在 toy engine 里加功能。 先去读 M6 前沿 serving 系统,学会用 TTFT、ITL、KV transfer、goodput 判断 多 GPU TP、speculative decoding、量化、prefix caching 分别在什么 workload 下真的值得做。

16仓库交付物 · checklist

v0.5 收尾时对照这张表。少一项扣 README 一颗星。

17本页自检

Month 5 结束时这些应该全部 ✓

勾选状态会保存在你的浏览器 localStorage 里,下次打开继续。