面试问答准备:nano-vLLM 推理引擎优化
nano-vLLM 项目实测与面试问答
使用方式:每题先给「30 秒版」——面试官问完先说这段,把骨架立住;被追问时再用 「展开版」。所有数字都标注了仓库出处,可以当场翻给面试官看。
环境口径统一为 RTX 3060 Laptop 6 GiB + Qwen3-0.6B BF16。根
README.md里出现的 "RTX 4070 Laptop" 是上游继承的旧表,与本地实测环境不符,不要引用。
Q1 Prefill 阶段如何提升速率?
30 秒版
Prefill 有三条加速路径,按收益排序:
- 算子层:把原来逐层、逐序列读
cu_seqlens[i].item()做 Python slicing 的 写法,换成 packed varlen Triton kernel——一次 launch 覆盖整个 packed batch 的 所有 query token。 - 消除 GPU scalar 同步:调度元数据全部在 CPU 侧算好,
aten::_local_scalar_dense从 224 次降到 0 次。 - 减少实际要算的 token:prefix cache 命中后只 prefill 增量(实测 300-token prompt 第二轮只算 44 token)。
短 prompt 收益最大:8 请求 × 32 token 时 prefill 吞吐 1280.35 → 5382.16 tok/s (4.20×),TTFT 200.02 → 47.65 ms。
展开版
1. Packed varlen:一次 launch 覆盖整个批
nanovllm/layers/flash_attn.py:905 的 varlen_prefill_attention_kernel,
grid = (total_query_tokens, num_heads)。每个 program 对应一个
(query token, query head) 对,靠 CPU 侧构造的三个索引数组定位自己:
| 数组 | 含义 |
|---|---|
prefill_seq_ids |
这个 query token 属于哪条序列 |
prefill_query_positions |
它在完整序列中的绝对位置 |
prefill_key_starts |
非 paged 时对应的 K/V 起点 |
这三个数组在 model_runner.py:196 prepare_prefill 里,与已有的 CPU 调度循环一起
生成,随 input_ids / positions 一起 pin memory 后异步拷贝上卡。
2. 干掉 GPU scalar 同步(最容易被忽略、但最实的一刀)
阶段 0 的 profiler 里 aten::_local_scalar_dense 出现 224 次。根因是原实现
每层、每条序列都要读一次 cu_seqlens_q[i].item() / cu_seqlens_k[i].item() 来做
Python slicing,每次 .item() 都是一次 device→host 同步;28 层 × 8 序列 = 224 次。
现在两条路径都不读 GPU scalar:
- Triton 路径:靠上面三个索引数组,kernel 内部用
tl.load取; - SDPA fallback 路径:靠
cu_seqlens_q_cpu/cu_seqlens_k_cpu两个 CPU tuple 做 Python slicing(flash_attn.py:1111-1138)。
阶段 3 两组 profile(2×64 Triton、2×256 SDPA fallback)都是 0 次。
3. 减少实际要算的 token 数
- Prefix cache:
block_manager.hash_blocks把算完的完整 block 写进hash_to_block_id;下一次同 prefix 请求在num_cached_blocks()命中,只 prefill 增量。实测 2×300-token prompt 第二轮只处理 44 token。 - Chunked prefill:长 prompt 按 token budget 切片,避免一次性占满显存
(
_fit_resumed_prefill+reserve())。 - lm_head 只算最后一个 token:
ParallelLMHead.forward在 prefill 时执行x[context.cu_seqlens_q[1:] - 1],所以 lm_head 开销是 O(num_seqs) 而非 O(total_tokens)。
4. 明确的适用边界(必须主动说)
RTX 3060 Laptop、Qwen3-0.6B、8 请求、16 output,SDPA/Triton 同进程交替、每种 3 轮:
| Input/request | SDPA prefill | Triton prefill | 比值 | SDPA/Triton TTFT |
|---|---|---|---|---|
| 32 | 1280.35 tok/s | 5382.16 tok/s | 4.20× | 200.02 / 47.65 ms |
| 128 | 6045.64 tok/s | 9820.63 tok/s | 1.62× | 169.46 / 104.35 ms |
| 256 | 10978.75 tok/s | 6952.25 tok/s | 0.63× | 186.64 / 294.65 ms |
原因:这个 kernel 每个 query token 独立遍历历史 K/V——没有做 query 维分块
(BLOCK_M = 1),也没有用 tensor core(tl.sum(k * q[None, :], axis=1) 而不是
tl.dot)。prompt 短时收益来自「去掉 Python 循环 + SDPA 分发 + 同步」;prompt 变长
后 SDPA 的 query/key tile 复用优势反超。
自动策略因此是(attention.py:337-340):
max_seqlen_q <= 128 -> Triton varlen
max_seqlen_q > 128 -> PyTorch SDPA with CPU offsets
追问预案
「为什么不把 query 也分块,做成真正的 FlashAttention?」
这是当前 kernel 最大的改进空间,我明确记录为下一步。加 BLOCK_M > 1 的 query
tiling + tl.dot 就能把阈值往上推,但要重新处理 causal mask:现在靠循环上界
context_len = query_position + 1 免掉了 mask 矩阵,分块后同一 tile 内不同 query 的
context_len 不同,必须退回 tile 内掩码。另外本机只有一张 RTX 3060,没有做过对比
验证,所以我没把它写成已完成能力。
「prefix cache 怎么保证正确?」
- 哈希:
BlockManager.compute_hash用 xxhash64 链式哈希,前一块的 hash 作为prefix一起 update,所以哈希本身编码了整条前缀路径。 - 抗碰撞:哈希碰撞用 token_ids 二次校验兜住——
num_cached_blocks里if block_id == -1 or self.blocks[block_id].token_ids != token_ids: break。 - 一个反直觉点:
num_cached_blocks用range(seq.num_blocks - 1),故意跳过最后一个 block。必须留至少一个 block 重算,否则num_tokens会是 0,就没有 logits 可采样。 代价是 prompt 恰好为 block_size 整数倍时,最后一个满块不复用,最多多算block_size - 1个 token——这是 block 粒度 prefix cache 的固有代价,不是 bug。 - 引用计数:
Block有ref_count,多请求共享同一 prefix block;_allocate_block在 重新分配带 hash 的块时会从hash_to_block_id摘除并计入cache_evictions。
「chunked prefill 会不会白算 token?」
会。postprocess 里 if is_prefill and seq.num_cached_tokens < seq.num_tokens: continue
——中间 chunk 的采样结果被丢弃(因为 ParallelLMHead 只对每序列最后一个 token 算
logits,而 chunk 中间那一步的"最后一个 token"并不是真正的末 token)。所以 chunked
prefill 每轮会浪费一个 token 的采样,这是为支持流式 chunk 付出的代价。
Q2 Continuous Batching 如何调度?
30 秒版
核心是一次 schedule() 返回两个 batch,共享同一份 token budget:先排 decode
(每个 running 请求 1 token),剩余预算再排 prefill。这样长 prompt 不会阻塞已经在
跑的请求。running 队列用确定性 round-robin 防止饥饿;显存不够时只抢占本轮尚未
进入执行的 victim,释放后靠 prefix cache 或 recompute 恢复。
展开版
1. 一轮调度返回什么
Scheduler.schedule()(nanovllm/engine/scheduler.py:88)返回
[(decode_seqs, False), (prefill_seqs, True)],两批共享同一个 budget
(max_num_batched_tokens)和 seq_slots(max_num_seqs),不是各拿一份。
shared token/sequence budget
├─ decode batch: running round-robin, 1 token/request
└─ prefill batch: FIFO waiting, consume remaining budget
2. 为什么不合批(面试官最爱问)
三种 shape 的执行路径完全不同,强行合并得不偿失:
| 维度 | Decode | Prefill |
|---|---|---|
| 形状 | 等长 [bs, 1, H, D] |
变长 packed [total_tokens, H, D] |
| attention kernel | Paged Attention v1/v2(读 paged cache + block table) | varlen Triton 或 SDPA |
| 执行模式 | CUDA Graph(按 batch size 桶捕获,形状必须固定) | eager |
| lm_head | 每个 token 都要 logits | 只取每序列最后一个 token |
所以统一的是 admission 和预算,不是 kernel。
3. Decode 优先 + 抢占的一个关键细节
decode 阶段先把 self.running 整体搬进私有的 decode_candidates 并清空 running
(scheduler.py:97-99)。两个作用:
- 同一序列不可能在一轮里被选两次;
- 已经进入本轮 decode batch 的序列绝不会被选为 victim——victim 只从
decode_candidates的尾部取(本轮还没轮到的候选),避免 ModelRunner 正在用它的 block table 时把 cache 释放掉。
preempt()(:197)= 置 WAITING + is_prefill=True + deallocate() +
waiting.appendleft()。插到队首,所以被抢占的请求比新请求优先恢复。
4. Round-robin 的实现
:124-126:self.running = 未选中的候选 + 已选中的序列。下一步未选中的自然排在
前面。固定输入下顺序可复现,不会出现"连续轮次只服务同一批 request"。
5. Prefill 的两种形态(容易答错)
分支键是 not seq.block_table,不是"是不是新请求":
block_table为空 →_fit_new_prefill:先查 prefix 命中数,再can_allocate试探、逐步缩小 chunk 直到可行;allocate()会写num_cached_tokens = num_cached_blocks * block_size。block_table非空 →_fit_resumed_prefill+reserve():增量补新跨越的 block。
因为 deallocate() 会清空 block_table,被抢占的序列也走 _fit_new_prefill,
重新查 prefix cache——这正是"抢占后能便宜恢复"的原因:被释放的 block 只要没被重新
分配,hash_to_block_id 里的条目还在。
6. 增量 KV 分配
首次 prefill 只为 cached_tokens + scheduled_chunk_tokens 分配,而不是在 admission
时按完整 prompt 预留。确定性压测(block size 4、6 请求):一次性预分配需要
16 blocks,增量策略实测峰值 9 blocks → 占用 56%,约 1.78× 并发。
降级循环的一个性质:每轮只让出恰好一个 block
(num_tokens = (target_blocks - 1 - num_cached_blocks) * block_size),可行性对
num_new_blocks 单调,所以首次成功的轮次给出的就是最大可行 chunk。
7. 一个必须主动承认的边界
if seq.status == SequenceStatus.WAITING: break(:182-183)——一轮最多只调度一个
未完成的 chunked prefill。所以 waiting 队首的长 prompt 会阻塞后面的 prefill 请求
(head-of-line blocking)。这是为简化预算与 block 记账做的取舍,不是 bug,但确实是
可改进点。
追问预案
「抢占之后怎么恢复?」
两条路:
- 被释放的 block 还在
hash_to_block_id里 →num_cached_blocks()直接命中, 只需重算未缓存部分; - 完全被覆盖了 → 从头 recompute。
另外 can_append 只在 len(seq) % block_size == 1(正好跨块)时才需要新 block,
其余 decode 步不需要——所以抢占只在 decode 序列恰好跨块且空闲池为空时才触发。
「can_allocate 里的 free_cached_blocks 是不是重复计数了?」
不是。C = free_cached_blocks 是"缓存命中、但当前躺在空闲池里"的块。认领它们会把
它们移出空闲池,所以必须预留。判定式 F >= N + C 是对的:先认领 C 个(剩余 F−C),
再分配 N 个新块。
「12 请求的公平性测试怎么做的?」
这是 CPU 确定性仿真(bench_phase4_scheduler.py,block size 4、每请求生成 4 token):
| Case | Token budget | KV blocks | 请求数 | Rounds | Preemptions | Leak |
|---|---|---|---|---|---|---|
| mixed ample cache | 8 | 24 | 6 | 10 | 0 | 0 |
| mixed cache pressure | 6 | 7 | 6 | 12 | 0 | 0 |
| decode fairness | 4 | 12 | 12 | 12 | 0 | 0 |
| decode preemption pressure | 12 | 4 | 8 | 12 | 4 | 0 |
GPU 侧另有一次真实混合负载(6 请求,prompt 32/384/64/256/96/512,CUDA Graph), 结束 93/93 blocks free、0 preemption、0 leak。要如实说这些是小样本、以仿真为主。
Q3 量化和反量化如何进行,发生在哪些地方,会不会有精度损失
30 秒版
量化发生在 KV 写入时,融合在 Triton store kernel 里完成;反量化有两个地方: decode 在 Paged Attention kernel 里融合反量化(不物化浮点 cache),prefill 则是 物化 gather + 反量化再走 SDPA——后者正是 prefill 慢 25% 的根因。
粒度是 per-token / per-KV-head 对称量化,scale 存 FP16。精度上 attention 输出与基线 kernel 的 cosine similarity 是 0.999958,但我没有做 perplexity / 下游质量评估, 这是明确的边界。
展开版
1. 量化:发生在写入时,融合在 store kernel 里
代码:nanovllm/layers/attention.py:138 store_kvcache_int8_kernel,
grid = (num_tokens, num_kv_heads),每个 program 处理一个 head vector(head_dim=128)。
key_scale = tl.maximum(tl.max(tl.abs(key), axis=0) / 127.0, 1.0e-8)
quantized_key = key / key_scale
quantized_key = tl.where(quantized_key >= 0,
quantized_key + 0.5,
quantized_key - 0.5).to(tl.int8)
三个值得说的设计选择:
/127而不是/128:最大元素恰好映射到 ±127,+0.5后是 127.5,.to(tl.int8)向零截断仍回 127 → 永不溢出,整个 kernel 不需要 clamp。- 舍入是 round-half-away-from-zero,不是 PyTorch
round()的 half-to-even。写法是where(q >= 0, q + 0.5, q - 0.5)再向零截断——对称,对正负号一致。 - 粒度是 per-(token, KV head),而不是 per-tensor 或 per-block:在精度、scale 开销、decode 索引复杂度之间折中。per-tensor scale 精度差;粗化到整个 block (128 个 token 共用一个 scale)对长尾分布不友好。
存储收益:head_dim=128 时
BF16 : 128 × 2 bytes = 256 B
INT8 : 128 × 1 byte + 1 FP16 scale = 130 B
ratio: 130 / 256 = 50.78%
实测可用 block 数 92 → 179(1.95×)。注意 KV cache 是按剩余显存定容的,
所以 kv_storage_bytes 几乎没变(2.701 → 2.669 GB)——涨的是块数/容量,不是
省了显存。这个表述要准确。
2. 反量化路径 A:decode 融合在 Paged Attention 里("好"的那条)
代码:flash_attn.py:162 paged_attention_int8_kernel。它是 v1 kernel 的近乎克隆,
多出来的只有两处:
key_scale = tl.load(k_scale_ptr + k_scale_offsets, mask=mask_t, other=0.0)
key = tl.load(k_cache_ptr + k_offsets, mask=mask, other=0.0
).to(tl.float32) * key_scale[:, None]
即:K/V 从 INT8 cache 读进寄存器 → 转 FP32 → 乘 per-token scale → 直接进 online softmax。全程不分配 BF16 cache 临时副本,省下来的就是带宽。
RTX 3060 microbenchmark(batch 8、16 Q heads / 8 KV heads、head_dim 128、ctx 511):
| Backend | Latency | Cache bytes | Ratio |
|---|---|---|---|
| FP16 Paged Attention v1 | 88.06 µs | 16,777,216 | 1.000 |
| INT8 fused dequant | 72.29 µs | 8,519,680 | 0.508 |
INT8/FP16 延迟比 0.821,局部 kernel 提升约 1.22×。
注意 INT8 永远走 v1 形状的 kernel:paged_attention 里
if k_cache.dtype == torch.int8 提前 return(:793-805),绕过了
select_paged_attention_backend,所以 INT8 拿不到 v2 的 GQA KV 复用和 tensor core。
3. 反量化路径 B:prefill 是物化的("代价"的那条)
代码:flash_attn.py:859 gather_quantized_cache:
values = cache[block_ids, block_offsets].to(torch.float32)
token_scales = scales[block_ids, block_offsets].float()
return (values * token_scales.unsqueeze(-1)).to(dtype)
它按 block table 把当前请求实际引用的 KV 物化成一个 [seqlen, kv_heads, head_dim]
的浮点张量,再交给 SDPA。目的是保持 prefix/chunk 正确、显存上界清晰;代价是每层
都要重新 gather + 反量化 + 写一遍浮点张量。
这就是 INT8 端到端变慢的根因。 4×128 input / 16 output、CUDA Graph:
| KV dtype | Blocks | Prefill tok/s | Decode tok/s | E2E |
|---|---|---|---|---|
| BF16 auto | 92 | 7728.78 | 634.49 | 161.58 ms |
| INT8 | 179 | 5772.94 | 590.82 | 191.10 ms |
prefill −25.3%、decode 端到端 −6.9%、整体 +18.3% 慢。所以 INT8 在当前实现里 是「用吞吐换上下文容量」的可选模式,不设为默认。
4. 精度损失——分三层说,越往下越诚实
第一层 · 算子层(自动化测试)
CUDA 测试里 fused INT8 decode 对显式反量化的 PyTorch reference,atol=rtol=3e-2
通过;恢复值最大误差不超过约半个 quantization step。
第二层 · Attention 输出层(microbenchmark)
mean / max absolute error = 0.000520 / 0.002747,cosine similarity 0.999958 (batch 8、ctx 511、合成数据)。
第三层 · 端到端层(必须主动说)
64 个 autoregressive greedy token 的 exact agreement 只有 46.875%,首 token 是 4/4 一致。原因不是"量化错了",而是量化引入的极小 logits 差异在接近 tie 时会翻转 argmax,然后自回归 rollout 把分歧逐步放大。
exact token agreement 不是语言质量指标。 我目前只能声称 attention 数值接近、 执行正确,不足以宣称下游质量无损——生产使用前需要在目标数据集上测 perplexity / 任务指标。这个边界我明确写在报告里,没有包装。
追问预案
「为什么带宽减半,kernel 只快 1.22×?」
一个可观察的信号:v1 用 Triton 默认 num_warps=4,而 INT8 版本显式用 num_warps=8
——线程数翻倍,因为反量化把 [256, 128] 的 K/V tile 提到了 FP32 精度,寄存器
需求顶上去了。所以 INT8 decode 是「用寄存器压力换带宽」,收益被抵消了一部分。
这是从代码得出的分析,ncu 因权限(ERR_NVGPUCTRPERM)没跑成,我没把它写成结论。
可优化方向(代数等价,减少 ALU 与寄存器压力):
- K:
scores[t] = k_scale[t] × sum(k_int8[t] · q) × scale——先算点积再乘[block_size]的 scale 向量,而不是先缩放整个[block_size, head_dim]tile (乘法次数 32768 → 256)。 - V:把
v_scale折进p(保留未缩放的p供 normalizerl使用),同样约 128× 减少乘法。
「全零向量怎么办?」
tl.maximum(..., 1e-8) 防的是除零。但这里有个我知道的细节:1e-8 小于 FP16 的最小
次正规数(约 5.96e-8),所以全零向量的 scale 存进 FP16 会变成 0。数据同为 0,乘积
仍然是 0,结果正确;但测试里 assertTrue(all(flat_ks > 0)) 这个不变量严格说只对非零
向量成立。
「scale tensor 和 data tensor 的生命周期怎么管?」
两者共用相同的 physical block ID,prefix cache 的 key 仍然是 token block 元数据, 所以 scale 不改变哈希、引用计数和回收逻辑。2×300 input / 4 output 的 prefix smoke 里 BF16 与 INT8 都命中 2 blocks / 512 prefix tokens,结束时 179/179 blocks 回收, 无 leak。
通用追问预案
| 问题 | 答案要点 |
|---|---|
| 你的数字怎么来的? | 每个阶段都有五件套:① PyTorch / eager reference ② 边界、dtype、prefix/chunk/Graph 单元测试 ③ 同进程 microbenchmark 或端到端 workload ④ 硬件+模型+shape+模式+原始 JSON ⑤ 收益适用区间、失败实验和 fallback。全量 59 tests,24 份 baseline JSON。 |
| 硬件环境? | RTX 3060 Laptop 6 GiB + Qwen3-0.6B BF16。注意根 README 写的是 RTX 4070,那是上游继承的旧表。 |
| 为什么没做多卡? | 只有一张卡。TP 代码路径存在(world_size 参与 num_kv_heads 切分),但没有多卡 NCCL 实机验证,不作为已完成能力。 |
| 最大的不足? | ① INT8 prefill 未融合反量化 ② 没有 PPL / 下游质量评估 ③ varlen prefill 没有 query 维分块和 tensor core ④ chunked prefill 有 head-of-line blocking ⑤ Split-K 收益跨运行不稳定(0.96~1.34×,方向不定),只保留实验入口,不进 auto。 |
| Split-K 是什么?为什么没进 auto? | 把长 context 切成 4 段,各段输出局部 max / normalizer / FP32 accumulator,再用第二个 kernel 按 log-sum-exp 合并。数值正确,但 4095-token / batch 1 的多次独立运行观察到约 0.96~1.34×,方向不稳定,所以只留 backend="v2_split" 显式入口。 |
| CUDA Graph 和 v2 为什么不兼容? | Graph 只按 batch size 捕获,无法在 replay 时更换 context-specific kernel。为避免短请求被捕获时的最大 context bucket 拖累,Graph 明确捕获 v1;v2 heuristic 只作用于 eager。若后续做 (batch, context_bucket) 图池可以重新评估。 |
一页速记
| 主题 | 关键数字 | 关键限定 |
|---|---|---|
| Varlen prefill | 8×32 = 4.20×,TTFT 200.02 → 47.65 ms | 8×256 = 0.63×,回退 SDPA;无 query 分块 / 无 tensor core |
| GPU scalar 同步 | 224 → 0 次 aten::_local_scalar_dense |
靠 CPU 侧索引数组 + CPU tuple |
| Paged Attention v2 | batch 32 / ctx 4095 最高 1.87× | kernel 局部收益;E2E 比值 0.958,不声明整机加速 |
| 统一调度 | decode-first 共享 budget | 一轮只跑一个未完成 chunked prefill |
| 增量 KV | 16 → 9 blocks(56%,1.78× 并发) | CPU 确定性仿真,block size 4 |
| 公平性 / 抢占 | 12 请求 0 泄漏;8 请求 4 次抢占 0 泄漏 | CPU 仿真;GPU 侧是 6 请求 smoke |
| INT8 KV 容量 | 50.78% bytes/block;92 → 179 blocks(1.95×) | 涨的是块数,不是省显存 |
| INT8 精度 | cosine 0.999958;max abs err 0.002747 | 没有 PPL 评估;64-token agreement 46.875% |
| INT8 代价 | E2E +18.3%(prefill −25.3%) | 可选模式,非默认;prefill 未融合反量化 |
相关文档
- stage2_paged_attention_report.md 未收录
- stage3_varlen_prefill_report.md 未收录
- stage4_scheduler_cache_report.md 未收录
- stage8_int8_kv_report.md 未收录
- project_final_report.md 未收录
- resume_project_zh.md 未收录