<?xml version="1.0" encoding="UTF-8"?><rss version="2.0"
	xmlns:content="http://purl.org/rss/1.0/modules/content/"
	xmlns:wfw="http://wellformedweb.org/CommentAPI/"
	xmlns:dc="http://purl.org/dc/elements/1.1/"
	xmlns:atom="http://www.w3.org/2005/Atom"
	xmlns:sy="http://purl.org/rss/1.0/modules/syndication/"
	xmlns:slash="http://purl.org/rss/1.0/modules/slash/"
	>

<channel>
	<title>Blog of Code</title>
	<atom:link href="https://www.cztcode.com/feed/" rel="self" type="application/rss+xml" />
	<link>https://www.cztcode.com</link>
	<description>模型推理加速与编程实践</description>
	<lastBuildDate>Sat, 12 Sep 2026 01:00:05 +0000</lastBuildDate>
	<language>zh-Hans</language>
	<sy:updatePeriod>
	hourly	</sy:updatePeriod>
	<sy:updateFrequency>
	1	</sy:updateFrequency>
	

<image>
	<url>https://www.cztcode.com/wp-content/uploads/2024/02/cropped-logo-32x32.webp</url>
	<title>Blog of Code</title>
	<link>https://www.cztcode.com</link>
	<width>32</width>
	<height>32</height>
</image> 
<site xmlns="com-wordpress:feed-additions:1">217219486</site>	<item>
		<title>张量并行该开几卡：从每层两次 All-Reduce 算清加速边界</title>
		<link>https://www.cztcode.com/2026/5377/</link>
					<comments>https://www.cztcode.com/2026/5377/#respond</comments>
		
		<dc:creator><![CDATA[Jellow]]></dc:creator>
		<pubDate>Sat, 12 Sep 2026 01:00:05 +0000</pubDate>
				<category><![CDATA[模型推理加速]]></category>
		<category><![CDATA[All-Reduce]]></category>
		<category><![CDATA[NCCL]]></category>
		<category><![CDATA[分布式推理]]></category>
		<category><![CDATA[张量并行]]></category>
		<guid isPermaLink="false">https://www.cztcode.com/2026/5377/</guid>

					<description><![CDATA[从活跃 token 数、隐藏维度和实测集合通信延迟出发，判断张量并行该开几卡，并比较单卡多副本与流水线并行的代价，附标准库验证和排障顺序。]]></description>
										<content:encoded><![CDATA[<div id="bsf_rt_marker"></div><blockquote>
<p><strong>Jellow 编辑发布，AI 辅助生成并完成技术核对。</strong> 计算示例和实测结果会在文中分别说明。</p>
</blockquote>
<p>模型刚好能放进一张 GPU，但 Decode 的每 token 延迟没有达标。把 <code>tensor_parallel_size</code> 从 1 改成 4，会接近四倍快吗？另一种情况更直接：模型单卡放不下，究竟该用几路张量并行（Tensor Parallelism，TP），还是沿层切成流水线并行？</p>
<p>答案不能只看 GPU 数量。张量并行把矩阵乘法分给多张卡，同时把原来留在卡内的数据依赖变成集合通信。本文从一次调度步骤中的激活形状 <code>[T,H]</code> 出发，算出标准 Megatron 式稠密 Transformer 的通信账，再给出可测量的加速条件。</p>
<p>核对日期为 2026-09-12。讨论范围是采用一维列并行/行并行的稠密 Decoder 推理；MoE、Sequence Parallel、Context Parallel 和特殊融合实现放在失效边界中处理。</p>
<h2>先把“放得下”和“跑得快”拆开</h2>
<p>对每个候选 TP 度数 <code>p</code>，先检查容量约束：</p>
<p>[<br />
M<em>{rank}(p)=M</em>{weight}(p)+M<em>{KV}(p)+M</em>{runtime}(p)\le M_{usable}<br />
]</p>
<p><code>M_usable</code> 是扣除安全余量后的单卡可用显存。权重通常可按 <code>p</code> 分片，但 KV Cache、词表、临时工作区和框架保留内存是否等比例缩小取决于实现，不能直接把总显存除以 <code>p</code>。</p>
<p>容量检查只产生“可运行的候选集合”，不证明更多卡更快。如果模型能在单卡运行，以吞吐为目标时，合理基线不是一组 TP=<code>p</code>，而是相同 <code>p</code> 张卡上的 <code>p</code> 个 TP=1 副本。vLLM 的官方部署指南也把“模型能放入单卡”作为优先采用单卡推理的起点；节点内模型过大时才优先 TP，缺少 NVLink 的节点则建议考虑流水线并行以降低通信压力。<a href="https://docs.vllm.ai/en/latest/serving/parallelism_scaling/" target="_blank" rel="noopener">vLLM Parallelism and Scaling</a></p>
<p>如果模型单卡放不下，最小可行 <code>p</code> 是容量基线。此时“成功”可能只是让模型能够运行，不能把它表述成相对单卡的实测加速。</p>
<h2>为什么一个 MLP 只在末尾归并</h2>
<p>设当前调度步骤有 <code>T</code> 个活跃 token，隐藏维度为 <code>H</code>，SwiGLU 中间维度为 <code>I</code>：</p>
<p>[<br />
X\in\mathbb{R}^{T\times H},\quad<br />
W_g,W_u\in\mathbb{R}^{H\times I},\quad<br />
W_d\in\mathbb{R}^{I\times H}<br />
]</p>
<p>Decode 每条活跃序列通常贡献一个新 token，因此此时 <code>T</code> 近似活跃批大小；Prefill 或 Chunked Prefill 中，<code>T</code> 是本步骤实际调度的 prompt token 总数，不等于请求数。</p>
<p>把前两个权重沿输出维切成 <code>p</code> 份，把下降投影沿输入维切成对应的 <code>p</code> 份：</p>
<p>[<br />
W<em>g=[W</em>{g,1},\ldots,W_{g,p}],\quad<br />
W<em>u=[W</em>{u,1},\ldots,W_{u,p}],\quad<br />
W<em>d=\begin{bmatrix}W</em>{d,1}\ \vdots \ W_{d,p}\end{bmatrix}<br />
]</p>
<p>第 <code>r</code> 张卡计算：</p>
<p>[<br />
Z<em>r=\operatorname{SiLU}(XW</em>{g,r})\odot(XW_{u,r})<br />
\in\mathbb{R}^{T\times I/p}<br />
]</p>
<p>[<br />
P_r=Z<em>rW</em>{d,r}\in\mathbb{R}^{T\times H},\qquad<br />
Y=\sum_{r=1}^{p}P_r<br />
]</p>
<p>激活函数和逐元素乘法都留在本地分片内，只有最后的 <code>[T,H]</code> 部分和需要 All-Reduce。Megatron Core 的官方 API 仍以 <code>ColumnParallelLinear</code> 和 <code>RowParallelLinear</code> 表达这两种切分。<a href="https://docs.nvidia.com/megatron-core/developer-guide/latest/apidocs/core/core.tensor_parallel.layers.html" target="_blank" rel="noopener">Megatron Core 张量并行层文档</a></p>
<p>自注意力采用相同配对：Q/K/V 投影按头或输出列切分，注意力在各卡本地计算，输出投影按行切分后归并。原始 Megatron-LM 论文由此得到一个标准 Transformer 层前向两次 All-Reduce：注意力一次，MLP 一次。<a href="https://arxiv.org/abs/1909.08053" target="_blank" rel="noopener">Megatron-LM 论文</a></p>
<p>这里的“两次”是本文成本模型的关键假设，不是所有推理引擎和模型结构的常数。</p>
<h2>从 <code>[T,H]</code> 得到通信账</h2>
<p>设激活通信数据类型每元素占 <code>e_a</code> 字节。一次 All-Reduce 的逻辑载荷为：</p>
<p>[<br />
S=THe_a\quad\text{bytes}<br />
]</p>
<p>对 <code>p</code> 个 rank，NVIDIA <code>nccl-tests</code> 给出的 All-Reduce 总线带宽修正因子是 <code>2(p-1)/p</code>。按每个 rank 的出口带宽折算，一次集合通信的数据项为：</p>
<p>[<br />
V_{AR}=\frac{2(p-1)}{p}S<br />
]</p>
<p>一个有 <code>L</code> 层的步骤包含 <code>2L</code> 次这样的归并，所以总线等价通信量为：</p>
<p>[<br />
V_{step}=4L\frac{p-1}{p}THe_a<br />
]</p>
<p><a href="https://github.com/NVIDIA/nccl-tests/blob/master/doc/PERFORMANCE.md" target="_blank" rel="noopener">NVIDIA 对该因子的推导</a>同时提醒：小消息应直接观察操作时间；消息足够大之后，时间才近似为固定开销加字节数除以带宽。因此可用下式做初筛：</p>
<p>[<br />
\tau_{AR}(S,p)\approx \alpha_p+<br />
\frac{2(p-1)S}{pB_p}<br />
]</p>
<p><code>α_p</code> 是一次集合通信的固定延迟，<code>B_p</code> 是指定 rank 布局下的有效总线带宽。NCCL 可能选择树、环、NVLS 或其他路径，这个式子不是替代实测的硬件承诺；生产判断应直接代入相同拓扑、相同载荷下测得的 <code>τ_AR</code>。</p>
<p>看一个纯计算示例。假定 <code>H=8192</code>、<code>L=80</code>、<code>T=32</code>，激活以 BF16 通信，<code>p=4</code>：</p>
<ul>
<li>每次载荷 <code>S=32×8192×2=524288</code> 字节，即 512 KiB；</li>
<li>每步有 <code>2L=160</code> 次集合通信；</li>
<li>每个 rank 的总线等价通信量为 <code>4×80×3/4×512 KiB=120 MiB</code>。</li>
</ul>
<p>120 MiB 看起来不大，但它被拆成 160 次有依赖关系的调用，固定延迟可能比峰值带宽更重要。这也是小 Decode 批量经常无法按卡数线性加速的原因。</p>
<h2>加速成立需要满足什么不等式</h2>
<p>令 GQA 的 KV 投影总宽度为 <code>H_KV</code>。忽略 bias 后，注意力投影 Q、K、V、O 的主要 FLOPs 为：</p>
<p>[<br />
F<em>{attn}=4T(H^2+HH</em>{KV})<br />
]</p>
<p>SwiGLU 三个线性层的 FLOPs 为：</p>
<p>[<br />
F_{mlp}=6THI<br />
]</p>
<p>因此每层主要投影计算量近似为：</p>
<p>[<br />
F<em>{proj}=4T(H^2+HH</em>{KV})+6THI<br />
]</p>
<p>这些式子按一次乘加计 2 FLOPs。它们没有包括注意力分数、归一化、采样和调度开销；长上下文会增加注意力计算，从而改变计算与通信的比例。</p>
<p>先作一个乐观假设：分片后的 GEMM 效率不下降，投影计算可按 <code>p</code> 等分。则：</p>
<p>[<br />
t<em>p\approx \frac{t</em>{proj,1}}{p}+2L\tau_{AR}(THe<em>a,p)+t</em>{other,p}<br />
]</p>
<p>TP 比单卡快的必要条件是：</p>
<p>[<br />
\left(1-\frac{1}{p}\right)t_{proj,1}</p>
<blockquote>
<p>2L\tau<em>{AR}+(t</em>{other,p}-t_{other,1})<br />
]</p>
</blockquote>
<p>左侧是理想情况下省下的计算时间，右侧是新增通信及其他开销。代价并没有消失，而是从单卡 GEMM 转移到了集合通信、更小的本地 GEMM、同步等待和多卡资源占用。</p>
<p>还能得到一个活跃 token 数阈值。若单卡全部投影时间写成 <code>t_proj,1=aT</code>，并暂时忽略 <code>t_other</code> 的差值，则：</p>
<p>[<br />
T&gt;<br />
\frac{2L\alpha_p}<br />
{a(1-1/p)-4L\frac{p-1}{p}\frac{He_a}{B_p}}<br />
]</p>
<p>分母必须为正；否则仅带宽项就已经吃掉理想计算收益，在这个简化模型中不存在能获益的 <code>T</code>。<code>a</code> 不应从 GPU 标称峰值推测，最好由 TP=1 的应用 trace 按 <code>T</code> 分桶拟合。</p>
<h2>用纯 Python 核对分片恒等式和阈值直觉</h2>
<p>下面程序只用 Python 标准语法。前半段以 ReLU 代替 SiLU，使整数结果可以精确比较；任何逐元素激活都不改变分片恒等式。后半段枚举一个明确标注的成本模型，结果不是 GPU 实测。</p>
<pre><code class="language-python">def mm(a, b):
    return [[sum(x*y for x, y in zip(row, col))
             for col in zip(*b)] for row in a]

def elem(a, b, fn):
    return [[fn(x, y) for x, y in zip(ar, br)]
            for ar, br in zip(a, b)]

def add(a, b):
    return elem(a, b, lambda x, y: x + y)

def col_shard(a, rank, p):
    n = len(a[0])
    assert n % p == 0
    q = n // p
    return [row[rank*q:(rank+1)*q] for row in a]

def row_shard(a, rank, p):
    n = len(a)
    assert n % p == 0
    q = n // p
    return a[rank*q:(rank+1)*q]

X = [[1, -1, 2, 0], [0, 2, -1, 1]]  # [T=2, H=4]
Wg = [[(r + 2*c) % 5 - 2 for c in range(6)] for r in range(4)]
Wu = [[(2*r + c) % 7 - 3 for c in range(6)] for r in range(4)]
Wd = [[(r - c) % 5 - 2 for c in range(4)] for r in range(6)]

gate = mm(X, Wg)
up = mm(X, Wu)
z = elem(gate, up, lambda g, u: max(0, g) * u)
dense = mm(z, Wd)

p = 2
partial = []
for rank in range(p):
    g = mm(X, col_shard(Wg, rank, p))
    u = mm(X, col_shard(Wu, rank, p))
    z_rank = elem(g, u, lambda x, y: max(0, x) * y)
    partial.append(mm(z_rank, row_shard(Wd, rank, p)))
reduced = add(partial[0], partial[1])
assert reduced == dense
print("algebra: OK", dense)

# 以下全是示例假设，不是硬件实测。
H, I, H_KV, L = 8192, 28672, 1024, 80
ACT_BYTES, P_EFF = 2, 120e12       # BF16；单卡投影有效 120 TFLOP/s
ALPHA, BUS_BW = 8e-6, 150e9        # 每次 8 us；总线带宽 150 GB/s

payload = 32*H*ACT_BYTES
traffic = 4*L*(4-1)*payload//4
assert payload == 512*1024
assert traffic == 120*1024**2
print("TP=4,T=32:", payload//1024, "KiB/call,",
      traffic//1024**2, "MiB/step/rank")

def estimate(T, p):
    f_layer = 4*T*(H*H + H*H_KV) + 6*T*H*I
    t1 = L*f_layer/P_EFF
    if p == 1:
        return t1, 1.0
    payload = T*H*ACT_BYTES
    t_ar = ALPHA + payload*2*(p-1)/p/BUS_BW
    tp = t1/p + 2*L*t_ar
    return tp, t1/tp

print("T  " + "  ".join(f"TP={p}" for p in (2, 4, 8)))
for T in (1, 4, 8, 16, 32):
    print(f"{T:&lt;2} &quot; + &quot;  &quot;.join(
        f&quot;{estimate(T, p)[1]:.2f}x&quot; for p in (2, 4, 8)))</code></pre>
<p>执行输出：</p>
<pre><code class="language-text">algebra: OK [[-14, -20, 4, 38], [86, 40, -6, -52]]
TP=4,T=32: 512 KiB/call, 120 MiB/step/rank
T  TP=2  TP=4  TP=8
1  0.61x  0.72x  0.79x
4  1.26x  1.81x  2.31x
8  1.53x  2.42x  3.42x
16 1.71x  2.91x  4.51x
32 1.82x  3.25x  5.35x</code></pre>
<p>这个枚举故意给分片 GEMM 理想的 <code>1/p</code> 加速，<code>T=1</code> 仍然变慢。真实小矩阵还可能因每卡的 <code>I/p</code> 过窄而降低计算效率，所以模型通常偏乐观。改变 <code>ALPHA</code>、<code>BUS_BW</code> 和 <code>P_EFF</code>，可以先判断哪些 TP 度数值得进入 GPU 压测，而不能据此宣布性能结果。</p>
<h2>公平基线不是只有 TP=1</h2>
<table>
<thead>
<tr>
<th>方案</th>
<th>回答的问题</th>
<th>必须观察的代价</th>
</tr>
</thead>
<tbody>
<tr>
<td>单个 TP=1 副本</td>
<td>单请求或单批的本地基线</td>
<td>是否满足容量与延迟 SLO</td>
</tr>
<tr>
<td><code>p</code> 个 TP=1 副本</td>
<td>相同 GPU 数下的吞吐基线</td>
<td>路由、各副本批量与负载均衡</td>
</tr>
<tr>
<td>一个 TP=<code>p</code> 副本</td>
<td>TP 是否降低单步延迟或解决显存不足</td>
<td>All-Reduce、GEMM 变窄、同步等待</td>
</tr>
<tr>
<td>PP=<code>p</code> 或节点内 TP+跨节点 PP</td>
<td>弱互连、切分不整齐或跨节点时的替代方案</td>
<td>流水线气泡、阶段不均衡、激活点对点传输</td>
</tr>
</tbody>
</table>
<p>线上比较应报告卡数归一化 goodput：</p>
<p>[<br />
G_{card}=\frac{\text{满足延迟 SLO 的输出 token 数}}<br />
{\text{时间}\times\text{GPU 数}}<br />
]</p>
<p>TP 可能让一个请求更快，却让每卡 goodput 下降；如果模型原本能单卡运行，这正是多副本基线不可省略的原因。</p>
<p>权重量化也不会自动等比例降低这里的通信量。公式中的 <code>e_a</code> 是归并激活的数据类型，不是权重位宽。4 bit 权重配 BF16 激活时，权重读取减少了，而 <code>[T,H]</code> 的 All-Reduce 仍可能按每元素 2 字节进行；必须从 trace 核对实际通信 dtype。</p>
<h2>可复现的压测设计</h2>
<p>先固定模型、权重与 KV 精度、框架提交版本、CUDA/NCCL 版本、GPU 型号与 rank 布局。对 TP=1、2、4、8 中满足容量和可整除条件的候选执行两阶段测试。</p>
<p>第一阶段隔离组件。记录真实 <code>T</code> 分布，并在目标拓扑上扫描覆盖 <code>THe_a</code> 的消息大小。例如单节点 4 卡可使用：</p>
<pre><code class="language-bash">./build/all_reduce_perf -b 16K -e 4M -f 2 -g 4 \
  -d half -n 200 -w 50 -I 1 -K 20</code></pre>
<p><code>nccl-tests</code> 官方仓库说明了消息范围、预热、逐迭代时延和正确性检查参数；跨节点测试需改用与部署相同的 MPI rank 布局。<a href="https://github.com/NVIDIA/nccl-tests" target="_blank" rel="noopener">NVIDIA nccl-tests</a></p>
<p>同时在完整引擎 trace 中记录：</p>
<ul>
<li>每步活跃 token 数 <code>T</code>，并区分 Prefill 与 Decode；</li>
<li>每次 collective 的载荷、dtype、耗时和每层调用数；</li>
<li>collective 位于关键路径上的时间，而不只是所有 kernel 时间之和；</li>
<li>分片 GEMM 的形状、耗时、SM 利用率与显存带宽；</li>
<li>TPOT/ITL、TTFT、端到端延迟的 p50、p95、p99；</li>
<li>输出 token/s、满足 SLO 的 goodput，以及每卡归一化结果。</li>
</ul>
<p>第二阶段回放相同到达过程与输入、输出长度分布。先保持调度参数一致以隔离 TP，再允许每个候选独立调优批量上限，比较各自能达到的最佳 SLO—goodput 前沿。只报离线大批吞吐会掩盖小 <code>T</code> 时的集合通信固定开销。</p>
<h2>什么时候公式会失效</h2>
<p>以下任一条件成立，都应从 trace 重新建立通信账：</p>
<ul>
<li><code>p</code> 不能整除注意力头数、KV 头数或 MLP 中间维度，框架发生 padding、复制 KV 头或拒绝加载；</li>
<li>GQA/MQA 的 KV Cache 分片策略与假设不同，尤其 <code>p</code> 大于 KV 头数；</li>
<li>开启 Sequence Parallel 后，部分 All-Reduce 被 All-Gather 与 Reduce-Scatter 替代；</li>
<li>MoE 引入 token dispatch 和 All-to-All，负载不均衡也进入关键路径；</li>
<li>引擎融合了归并、残差或归一化，或者通过自定义 collective 改变调用次数；</li>
<li>跨节点链路、并发通信或 rank 放置使 <code>α_p</code>、<code>B_p</code> 与微基准不同；</li>
<li>长上下文的注意力计算占主导，此时只计算投影 FLOPs 会低估可并行计算；</li>
<li>本地 GEMM 过窄，实际计算时间明显高于 <code>t_proj,1/p</code>。</li>
</ul>
<p>最直接的校验是：若 trace 中每步 collective 数不接近 <code>2L</code>，或载荷不是约 <code>THe_a</code>，就停止套用本文的简化公式。</p>
<h2>排障要按因果链走</h2>
<ol>
<li><strong>先核对正确性与形状。</strong> 确认实际 <code>p</code>、rank 数、dtype、头数和中间维度切分；检查是否有 padding、KV 复制或意外降级。</li>
<li><strong>再核对物理路径。</strong> 运行 <code>NCCL_RUN_DIAGNOSTICS=1</code>，查看 <code>nvidia-smi topo -m</code> 和 P2P 状态。NCCL 2.31 的官方诊断可在通信器初始化时验证 GPU 间实际路径。<a href="https://docs.nvidia.com/deeplearning/nccl/user-guide/docs/troubleshooting/diagnostics.html" target="_blank" rel="noopener">NCCL Diagnostics</a></li>
<li><strong>用相同载荷测 collective。</strong> 如果 <code>nccl-tests</code> 本身就慢，先处理 NVLink、PCIe、NIC、NUMA 或 GPUDirect RDMA；不要先怪模型。</li>
<li><strong>比较微基准与应用 trace。</strong> 微基准正常而应用中的 All-Reduce 慢，检查 rank 放置、并发 stream、CPU 亲和性、框架同步及其他通信争用。</li>
<li><strong>通信正常再看 GEMM。</strong> 若 collective 关键路径占比不高但加速仍差，检查 <code>I/p</code>、每卡头数和实际 kernel 效率，修正理想 <code>1/p</code> 假设。</li>
<li><strong>最后回到调度与排队。</strong> 离线步骤变快而线上 p99 或 goodput 没改善，通常要检查 <code>T</code> 分布、排队、批量形成速度和副本负载，而不是继续增加 TP。</li>
</ol>
<p>NCCL 的官方性能排障文档也建议先区分硬件路径、NCCL 和应用问题，并提醒不要因单一微基准收益就把环境变量强制带入生产。<a href="https://docs.nvidia.com/deeplearning/nccl/archives/nccl_2312/user-guide/docs/troubleshooting/performance_and_tuning.html" target="_blank" rel="noopener">NCCL 2.31.2 Performance and Tuning</a></p>
<h2>把卡数选择落成一条规则</h2>
<p>先选出能放下模型且形状可分的 <code>p</code>。对每个候选，用真实 <code>T</code> 分桶测量 <code>τ_AR(THe_a,p)</code>，再检查：</p>
<p>[<br />
\text{实测计算节省} &gt; 2L\tau_{AR}+\text{其他新增开销}<br />
]</p>
<p>模型能单卡运行时，只有 TP 在目标延迟下的每卡 goodput 优于多副本基线，或多副本无法满足单请求延迟 SLO，才值得占用多张卡。模型不能单卡运行时，默认选择满足容量和 SLO 的最小 TP 度数；跨越弱链路之前，先比较节点内 TP 加跨节点 PP。</p>
<p>卡数不是越多越好。真正的分界由活跃 token 数、每层 <code>[T,H]</code> 的归并延迟、本地 GEMM 退化和服务 SLO 一起决定。</p>
<h2>继续阅读</h2>
<ul>
<li><a href="https://www.cztcode.com/2026/5375/">在线推理能扛多少请求：别用固定并发替代到达率压测</a></li>
<li><a href="https://www.cztcode.com/2026/5372/">Codex 怎么和子 agent 协作：从一次任务分派看消息、上下文与状态管理</a></li>
<li><a href="https://www.cztcode.com/2026/5370/">大模型推理加速（四）：推测解码为什么不改变分布，什么时候反而更慢</a></li>
</ul>
<hr />
<p><a href="https://www.cztcode.com/ai-content-policy/">本站的内容生成、审核与纠错说明</a></p>
]]></content:encoded>
					
					<wfw:commentRss>https://www.cztcode.com/2026/5377/feed/</wfw:commentRss>
			<slash:comments>0</slash:comments>
		
		
		<post-id xmlns="com-wordpress:feed-additions:1">5377</post-id>	</item>
		<item>
		<title>在线推理能扛多少请求：别用固定并发替代到达率压测</title>
		<link>https://www.cztcode.com/2026/5375/</link>
					<comments>https://www.cztcode.com/2026/5375/#comments</comments>
		
		<dc:creator><![CDATA[Jellow]]></dc:creator>
		<pubDate>Fri, 11 Sep 2026 01:00:04 +0000</pubDate>
				<category><![CDATA[模型推理加速]]></category>
		<category><![CDATA[SLO]]></category>
		<category><![CDATA[vLLM]]></category>
		<category><![CDATA[在线推理]]></category>
		<category><![CDATA[性能基准]]></category>
		<guid isPermaLink="false">https://www.cztcode.com/2026/5375/</guid>

					<description><![CDATA[固定并发能测饱和吞吐，却回答不了线上安全 RPS。本文从张量工作量、开放环排队和请求级联合 SLO 出发，用多个独立重复运行的保守下包络判定单副本容量。]]></description>
										<content:encoded><![CDATA[<div id="bsf_rt_marker"></div><blockquote>
<p><strong>Jellow 编辑发布，AI 辅助生成并完成技术核对。</strong> 计算示例和实测结果会在文中分别说明。</p>
</blockquote>
<p>假设固定并发压测显示一个副本能完成 19 req/s，而线上流量是 6 req/s，能否据此断定容量充足？不能。固定并发中的新请求由旧请求完成触发：服务越慢，客户端发得越慢；真实用户却不会因为 GPU 忙就自动停止到达。</p>
<p>本文要解决的工程问题是：给定请求长度分布、到达模式和延迟 SLO，单副本可以安全接收多大的外生到达率。相关算子、缓存与服务文章汇总在<a href="https://www.cztcode.com/llm-inference-acceleration/">模型推理加速专题页</a>；本文只讨论容量测量。资料和接口核对日期为 2026-09-11，实际复现仍应固定 vLLM 版本或提交哈希。</p>
<h2>从张量形状看“一个请求”有多重</h2>
<p>先假设普通稠密因果注意力、单请求、<code>h</code> 个注意力头，每头维度为 <code>d_h</code>；输入长度 <code>L≥1</code>，实际生成长度 <code>O≥1</code>。</p>
<p>Prefill 中，<code>Q</code> 和 <code>K</code> 的形状都是 <code>[h,L,d_h]</code>。由缩放点积注意力 <code>softmax(QKᵀ/√d_h)V</code> 和因果遮罩可知，每层每头需要处理一个三角形。<a href="https://arxiv.org/abs/1706.03762" target="_blank" rel="noopener">Transformer 原论文</a>给出这一注意力定义。有效 query-key 点积数为：</p>
<p><code>A_prefill = h × L(L+1)/2</code></p>
<p>第一个输出 token 可以从 prefill 的 logits 取得。为了得到其余 <code>O-1</code> 个 token，decode 依次让形状 <code>[h,1,d_h]</code> 的 query 读取长度为 <code>L+1</code> 到 <code>L+O-1</code> 的 KV Cache：</p>
<p><code>A_decode = h × [(O-1)L + O(O-1)/2]</code></p>
<p>所以每层有效点积总数为：</p>
<p><code>A(L,O) = h × [L(L+1)/2 + (O-1)L + O(O-1)/2]</code></p>
<p>每个点积约含 <code>2d_h</code> 次浮点运算。这个式子没有计入投影、MLP、内核填充、批处理和调度，因此不是实际延迟模型；它只解释为什么容量实验必须保留 <code>(L,O)</code> 的联合分布。</p>
<p>一个反例是 <code>(L,O)=(2048,128)</code> 与 <code>(512,1664)</code>。两者都有 <code>L+O-1=2175</code> 个实际参与注意力的位置，每头总计都是 <code>2175×2176/2=2,366,400</code> 个有效点积；但前者的 prefill/decode 分别为 <code>2,098,176/268,224</code>，后者则为 <code>131,328/2,235,072</code>。总工作量相同，不代表 TTFT、TPOT 或批处理效率相同。</p>
<p>NVIDIA 的<a href="https://docs.nvidia.com/nim/benchmarking/llm/latest/parameters.html" target="_blank" rel="noopener">参数说明</a>也要求按业务保留输入和输出长度分布：长输入主要增加 prefill 压力与 TTFT，长输出增加生成阶段压力与 ITL。固定长度测试可以用 <code>ignore_eos</code> 控制输出长度，但容量验收还应回放生产停止条件。若对 TTFT、TPOT、ITL 的口径不熟悉，可先看站内的<a href="https://www.cztcode.com/2026/5348/">推理指标入门</a>。</p>
<h2>固定并发把等待移到了哪里</h2>
<p>用单服务器 FCFS 队列隔离这个问题。第 <code>i</code> 个请求的预定到达时间、服务时间、开始时间和完成时间分别为 <code>a_i、s_i、b_i、f_i</code>：</p>
<p><code>b_i = max(a_i, f_{i-1})</code></p>
<p><code>f_i = b_i + s_i</code></p>
<p><code>w_i = b_i - a_i</code></p>
<p>开放环压测先产生与完成事件无关的 <code>a_i</code>。在未条件化的泊松过程中，间隔独立服从 <code>Exp(λ)</code>，其中 <code>λ</code> 是目标 RPS。闭环并发为 1 时，下一个请求恰在前一个完成后发出，因此 <code>a_i=f_{i-1}</code>、<code>w_i=0</code>：它测到的是 <code>1/E[s]</code>，没有测到外生 <code>λ</code> 下的排队尾延迟。</p>
<p>并发上限大于 1 时同样可能发生代价转移。信号量、网关或连接池会把多余请求留在客户端；若计时从真正发往模型服务时才开始，用户等待就从指标中消失。vLLM 将 <code>--request-rate</code> 与 <code>--max-concurrency</code> 分开，并明确指出并发帽可能使实际执行率低于设定值。<a href="https://docs.vllm.ai/en/latest/cli/bench/serve/" target="_blank" rel="noopener">CLI 参数定义</a>还提供 <code>client_queue_time</code> 和 <code>e2el_including_client_queue</code> 来显示这部分等待。</p>
<p>MLPerf LoadGen 的 Server 场景同样采用随机泊松到达，允许请求保持 outstanding，并按延迟阈值判定；Offline 场景报告吞吐。<a href="https://github.com/mlcommons/inference/blob/master/loadgen/test_settings.h" target="_blank" rel="noopener">MLPerf 官方实现说明</a>描述的是两个不同问题，不能互相替代。</p>
<h2>vLLM 的有限速率不是未条件化泊松过程</h2>
<p>截至 2026-09-11，vLLM 文档把 <code>--burstiness=1</code> 描述为泊松到达，其他正值对应 Gamma 间隔；但源码还有一个必须说明的实现细节。</p>
<p>在固定有限 <code>request-rate</code>、没有 ramp-up、且没有使用 self-timed trace 的路径中，vLLM 会先采样 Gamma 间隔；当 <code>burstiness=1</code> 时，这一步确实是指数间隔。随后程序累加所有间隔，并统一乘以：</p>
<p><code>normalize_factor = (num_requests/request_rate) / last_cumulative_delay</code></p>
<p>因此最后一个请求的计划偏移会精确对齐 <code>num_requests/request_rate</code>。<a href="https://github.com/vllm-project/vllm/blob/main/vllm/benchmarks/serve.py" target="_blank" rel="noopener">当前 serve.py</a>直接展示了采样与缩放逻辑。缩放以后，各间隔共同受总时长约束，不再是相互独立的未条件化指数变量；它与未条件化泊松过程存在实现差异。长时间测试中差异可能很小，但本文研究的正是到达过程，不能省略这个边界。</p>
<p>若生产突发和相关性很重要，最终验收应使用带真实时间戳的 <code>timed_trace --self-timed</code>，或使用能够按原始时间戳发送的外部负载发生器。本文后面的标准库例子使用未缩放的独立指数间隔，验证的是未条件化泊松队列，不是 vLLM 调度器的逐行复刻。</p>
<h2>容量应由请求级联合 SLO 定义</h2>
<p>令第 <code>r</code> 个独立重复运行中，第 <code>i</code> 个请求的联合指示量为：</p>
<p><code>I_{r,i} = 1[请求成功 且 TTFT≤B_f 且 TPOT≤B_d 且 E2E≤B_e]</code></p>
<p>未设置的约束可以删去，错误和超时必须记为 0。该次运行的达标率和合格请求吞吐为：</p>
<p><code>Â_r(λ) = Σ_i I_{r,i}/N_r</code></p>
<p><code>Ĝ_r(λ) = Σ_i I_{r,i}/T_r</code></p>
<p>分别查看 TTFT p99 和 TPOT p99 不等价于联合达标。两类违规若发生在不同请求上，各自有 99% 的请求通过时，联合通过率仍可能只有 98%。vLLM 对同一成功请求的全部 <code>--goodput</code> 条件执行逻辑 AND，再计算 <code>good_completed/duration</code>；它输出的是 request goodput，不是把错误纳入分母后的联合达标率。<a href="https://github.com/vllm-project/vllm/blob/main/vllm/benchmarks/serve.py" target="_blank" rel="noopener">官方源码</a>可以核对这一区别。DistServe 论文则把 per-GPU goodput 定义为达到 SLO attainment 目标时可承载的最大请求率。<a href="https://www.usenix.org/system/files/osdi24-zhong-yinmin.pdf" target="_blank" rel="noopener">论文原文</a></p>
<p>FCFS 等待时间会向后传播，所以同一次运行中的 <code>I_{r,i}</code> 通常存在序列相关性。不能把几万个请求直接视为独立 Bernoulli 样本，再用逐请求 IID Wilson 区间决定 PASS/FAIL。</p>
<p>本文采用多个相互独立的完整运行。预先指定 <code>R</code> 组 seed，每次重新建立负载随机过程并按实验策略重置服务状态，以整次运行作为实验单位。定义保守下包络：</p>
<p><code>A_floor(λ) = min_{r=1..R} Â_r(λ)</code></p>
<p>只有每次运行都满足全部验收条件，候选 RPS 才通过：</p>
<p><code>λ* = max{λ | 对所有 r：Â_r≥q，错误率_r≤ε，实际发送率合格，且队列无持续上升}</code></p>
<p>这条 <code>min</code> 规则是预先声明的保守验收规则，不是“95% 置信下界”，也不声称具有某个未证明的覆盖率。如果无法做独立重启，可以按时间分块报告最差块，但块长度必须覆盖业务突发与队列相关时间；相邻小块不能被冒充为独立样本。</p>
<h2>一个验证排队效应和重复判定的标准库例子</h2>
<p>下面不是 GPU 实测。它运行 12 个相互独立的单服务器队列：80% 请求服务 20 ms，20% 服务 180 ms；E2E SLO 为 250 ms，目标达标率为 95%。不同重复分别生成服务序列与未缩放的泊松到达；不同 RPS 使用配对 seed，便于把负载随机性对齐后观察变化。</p>
<pre><code class="language-python">import math
import random

N, WARM, RUNS = 50_000, 5_000, 12
SLO, TARGET = 0.250, 0.95
SEEDS = tuple(range(101, 101 + RUNS))

def percentile(xs, p):
    ys = sorted(xs)
    return ys[max(0, math.ceil(p / 100 * len(ys)) - 1)]

def one_run(rate, seed):
    service_rng = random.Random(seed)
    arrival_rng = random.Random(seed + 10_000)
    service = [0.020 if service_rng.random() = WARM:
            waits.append(start - arrival)
            passed += (finish - arrival &lt;= SLO)
    measured = N - WARM
    actual_rate = (measured - 1) / (arrival - first_measured_arrival)
    return percentile(waits, 95), passed / measured, actual_rate

closed_rates = []
for seed in SEEDS:
    rng = random.Random(seed)
    service = [0.020 if rng.random() = TARGET else "FAIL"
    print(f"{rate:&gt;4} {rate*0.052:&gt;4.2f} "
          f"{min(actuals):.2f}-{max(actuals):.2f} "
          f"{max(q95s)*1000:&gt;14.1f} "
          f"{min(attains):.2%}-{max(attains):.2%} "
          f"{min(attains):&gt;7.2%} {check}")</code></pre>
<p>Python 3 标准库的实际输出为：</p>
<pre><code class="language-text">closed-loop: rate_range=19.07-19.43 req/s, min_attain=100.00%
rate rho actual_rps_range worst_p95q_ms attain_range       floor   slo_check
   2 0.10 1.99-2.01           64.2 98.85%-99.00%  98.85% PASS
   4 0.21 3.98-4.02          137.7 97.22%-97.50%  97.22% PASS
   6 0.31 5.96-6.03          170.7 94.70%-95.26%  94.70% FAIL
   8 0.42 7.95-8.04          232.7 90.84%-91.78%  90.84% FAIL
  10 0.52 9.94-10.05          314.9 85.52%-86.58%  85.52% FAIL
  14 0.73 13.91-14.07          673.9 64.74%-67.65%  64.74% FAIL
  18 0.94 17.89-18.10         3889.7 19.30%-26.52%  19.30% FAIL</code></pre>
<p>固定并发基线在 12 次运行中得到 19.07—19.43 req/s，并且因为单次服务时间都小于 250 ms，最差达标率仍是 100%。开放环中，4 RPS 的 12 次运行全部超过 95%，而 6 RPS 的保守下包络只有 94.70%，因此这里只得到 4—6 RPS 的 SLO 夹点，不能把 4 RPS 杜撰成精确容量。</p>
<p><code>rho</code> 使用理论均值 <code>E[s]=0.8×0.020+0.2×0.180=0.052 s</code>。即使平均利用率只有约 31%，服务时间方差和到达重叠也已让 6 RPS 的部分运行失守。这个模型没有模拟 GPU、网络或<a href="https://www.cztcode.com/2026/5360/">连续批处理</a>；实际服务时间会随批次和调度变化，示例阈值不能搬到生产。</p>
<h2>可复现的 vLLM 容量实验</h2>
<p>先固定模型与软件版本、权重精度、并行度、调度参数、GPU 与驱动、客户端机器、网络、采样参数和缓存状态。数据集至少保留 <code>(输入 token 数,实际输出 token 数,缓存命中条件)</code> 的联合分布。</p>
<table>
<thead>
<tr>
<th>层次</th>
<th>负载</th>
<th>回答的问题</th>
</tr>
</thead>
<tbody>
<tr>
<td>隔离基线</td>
<td>并发 1、低到达率</td>
<td>无排队时各长度桶的服务时间</td>
</tr>
<tr>
<td>饱和基线</td>
<td><code>request-rate=inf</code>，扫描固定并发</td>
<td>峰值吞吐和批处理拐点</td>
</tr>
<tr>
<td>容量扫描</td>
<td>有限外生 RPS，先不设并发帽</td>
<td>队列和联合 SLO 在哪里失守</td>
</tr>
<tr>
<td>生产验收</td>
<td>真实时间戳、网关限制和缓存策略</td>
<td>突发、相关性及代价转移后的体验</td>
</tr>
</tbody>
</table>
<p>截至核对日，下面命令可作为固定 <code>(1024,128)</code> 长度桶的模板。<code>800/50 ms</code> 是占位 SLO，必须替换；它没有在本文环境中连接模型服务，也不是 GPU 实测。</p>
<pre><code class="language-bash">vllm bench serve \
  --backend openai \
  --base-url http://127.0.0.1:8000 \
  --model MODEL_ID \
  --dataset-name random \
  --input-len 1024 \
  --output-len 128 \
  --ignore-eos \
  --temperature 0 \
  --request-rate 8 \
  --burstiness 1.0 \
  --num-prompts 10000 \
  --num-warmups 32 \
  --seed 11 \
  --percentile-metrics ttft,tpot,e2el,client_queue_time,e2el_including_client_queue \
  --metric-percentiles 50,95,99 \
  --goodput ttft:800 tpot:50 \
  --save-result \
  --save-detailed</code></pre>
<p>当前 vLLM 的一个 <code>--seed</code> 不是独立的“到达种子”。<code>serve.py</code> 用它设置 Python <code>random</code> 和 NumPy RNG；数据集代码又把同一个值用于随机请求生成、抽样或顺序洗牌，具体作用取决于数据集和 <code>--disable-shuffle</code>；NumPy RNG 还用于到达间隔调度。<a href="https://github.com/vllm-project/vllm/blob/main/vllm/benchmarks/serve.py" target="_blank" rel="noopener">serve.py</a>与<a href="https://github.com/vllm-project/vllm/blob/main/vllm/benchmarks/datasets/datasets.py" target="_blank" rel="noopener">数据集源码</a>共同给出了这一语义。</p>
<p>因此比较配置 A/B 时应使用配对设计：重复 1 的 A 和 B 都用 seed 11，重复 2 都用 seed 23，之后继续使用多组不同 seed。每一组 seed 是一个新的完整重复，同时改变该重复的请求随机性、顺序和到达调度；同一组中的 A/B 则共享这些随机条件。不能把换 seed 描述成只换“到达种子”。若必须固定请求序列而只改变到达时间，应准备固定顺序的数据或 timed trace，并由外部调度器控制时间戳。</p>
<p>不同重复之间还要按实验目标重置状态。vLLM 的<a href="https://docs.vllm.ai/en/latest/benchmarking/cli/" target="_blank" rel="noopener">基准说明</a>提醒，重复请求可能命中上一次留下的前缀缓存并虚增吞吐；生产不依赖这种复用时，应重置缓存、重启服务或使用会重置缓存的 sweep 流程。</p>
<p>先粗扫得到一过一败的 RPS 夹点，再在夹点中加密。边界候选建议增加到至少 10—12 个预先指定的独立重复，并用更长生产时间窗复验。每次都要单独保存实际发送率、错误、联合达标率和队列时间序列，不能先合并所有请求再计算一个 IID 区间。</p>
<p>若生产网关限制并发，再增加相同的 <code>--max-concurrency</code>，同时报告客户端排队、用户视角 E2E、拒绝和超时。当前 <code>--goodput</code> 只接受 <code>ttft、tpot、e2el</code>，不接受客户端排队指标；有外部背压时必须从详细记录重新计算联合指示量，并把失败请求放回分母。</p>
<h2>指标和排障顺序</h2>
<p>一次容量扫描至少保存：设定与实际发送 RPS、成功/错误/超时数、客户端排队、TTFT/TPOT/E2E 的 p50/p95/p99、逐运行联合达标率及其最小值，并按 <code>(L,O)</code> 桶分解。</p>
<p>NVIDIA 的<a href="https://docs.nvidia.com/nim/benchmarking/llm/latest/metrics.html" target="_blank" rel="noopener">指标定义</a>中，TTFT 通常包含排队、prefill 和网络，E2E 还覆盖 batching。数学上的 TPOT 只在实际输出 token 数大于 1 时定义，公式为 <code>(E2E-TTFT)/(输出 token 数-1)</code>；当 <code>O=1</code> 时分母为零，应从 TPOT 统计中排除或明确采用工具约定。当前 vLLM 在 goodput 检查中把这类请求的 TPOT 置为 0，属于实现约定，不能当作数学定义。工具实现仍可能不同。根据 <a href="https://docs.vllm.ai/en/latest/benchmarking/cli/" target="_blank" rel="noopener">vLLM Benchmark CLI</a>，推测解码一次流式返回多个 token 时，ITL 记录的是流式事件间隔，而 TPOT 会把时间摊到 token 上，两者尤其不能混用。</p>
<p>排障按请求生命周期进行：</p>
<ol>
<li><strong>负载发生器</strong>：实际发送率是否达到设定值；客户端 CPU、连接数或网络是否先饱和；排队是否藏在信号量之前。</li>
<li><strong>工作量</strong>：核对实际 <code>(L,O)</code>、停止原因、采样参数、前缀命中率和失败请求，先排除两次实验负载不同。</li>
<li><strong>服务队列</strong>：观察等待请求数和 queue time 是否持续上升。若上升，先降低 RPS 或增加副本，不要先调算子。</li>
<li><strong>阶段拆分</strong>：队列平稳但 TTFT 变差，检查 prompt 长度、缓存命中和 prefill；TPOT 变差则检查 decode、活动序列数和长输出干扰。</li>
<li><strong>资源证据</strong>：最后结合 KV Cache 使用率、抢占次数、GPU/CPU、显存带宽和网络判断瓶颈。vLLM 的<a href="https://docs.vllm.ai/en/latest/usage/metrics/" target="_blank" rel="noopener">生产指标清单</a>提供等待请求数、queue time、prefill/decode 时间、KV Cache 使用率和抢占计数。</li>
</ol>
<p>安全余量不是固定的 70% 或 80%。它取决于长度方差、突发程度、SLO、缓存和扩容反应时间。固定并发仍是有价值的饱和基线；只有开放环到达率、多个独立重复的联合 SLO、稳定队列和真实背压同时进入验收，测出的 RPS 才能用于容量决策。</p>
<h2>继续阅读</h2>
<ul>
<li><a href="https://www.cztcode.com/2026/5372/">Codex 怎么和子 agent 协作：从一次任务分派看消息、上下文与状态管理</a></li>
<li><a href="https://www.cztcode.com/2026/5370/">大模型推理加速（四）：推测解码为什么不改变分布，什么时候反而更慢</a></li>
<li><a href="https://www.cztcode.com/2026/5368/">大模型推理加速（三）：FlashAttention 为什么更快，又没有省掉哪些计算</a></li>
</ul>
<hr />
<p><a href="https://www.cztcode.com/ai-content-policy/">本站的内容生成、审核与纠错说明</a></p>
]]></content:encoded>
					
					<wfw:commentRss>https://www.cztcode.com/2026/5375/feed/</wfw:commentRss>
			<slash:comments>1</slash:comments>
		
		
		<post-id xmlns="com-wordpress:feed-additions:1">5375</post-id>	</item>
		<item>
		<title>Codex 怎么和子 agent 协作：从一次任务分派看消息、上下文与状态管理</title>
		<link>https://www.cztcode.com/2026/5372/</link>
					<comments>https://www.cztcode.com/2026/5372/#respond</comments>
		
		<dc:creator><![CDATA[Jellow]]></dc:creator>
		<pubDate>Wed, 09 Sep 2026 01:00:05 +0000</pubDate>
				<category><![CDATA[AI Agent 工程]]></category>
		<category><![CDATA[AI Agent]]></category>
		<category><![CDATA[Codex]]></category>
		<category><![CDATA[多智能体]]></category>
		<category><![CDATA[系统设计]]></category>
		<guid isPermaLink="false">https://www.cztcode.com/?p=5372</guid>

					<description><![CDATA[从 Codex MultiAgentV2 的公开 Rust 实现出发，追踪主 agent 如何创建子线程、投递消息、追加任务和等待活动。用一个可运行的状态模型解释消息与执行、等待与完成的区别，再讨论上下文继承、过期结果与文件冲突。]]></description>
										<content:encoded><![CDATA[<div id="bsf_rt_marker"></div><blockquote>
<p><strong>Jellow 编辑发布，AI 辅助生成并完成技术核对。</strong> 计算示例和实测结果会在文中分别说明。</p>
</blockquote>
<p>让三个 agent 一起改一个项目，看起来只需要三段提示词。真正接到工程里，很快就会遇到更具体的问题：任务交出去以后，主 agent 怎么知道对方开始了？中途补一句话，会不会触发第二个任务？等到了消息，是否意味着可以收尾？子 agent 已经修改的文件，取消以后会自动恢复吗？</p>
<p>这些问题决定多 agent 协作能不能稳定运行。模型负责理解任务、选择动作，运行时则需要把动作落成可追踪的线程、输入、事件和状态变化。</p>
<p>本文选取 OpenAI 开源 Codex 的 <strong><code>rust-v0.153.2</code> 标签、MultiAgentV2 路径</strong>作为阅读对象，核对日期为 2026-09-08。该版本同时保留旧的多 agent 实现；本文不把两套工具混用，也不把内部函数当成所有客户端都稳定开放的 API。示例程序是解释设计的教学模型，不是 Codex 的 Python 移植版。</p>
<h2>先把三个“身份”分开</h2>
<p>假设主 agent 在排查一次推理服务延迟回退，把工作分成两块：一个子 agent 读调度器，另一个读注意力内核，主 agent 自己整理基准条件。</p>
<p>至少有三种东西需要分别命名：</p>
<table>
<thead>
<tr>
<th>概念</th>
<th>要解决的问题</th>
<th>示例</th>
</tr>
</thead>
<tbody>
<tr>
<td>agent 的任务路径</td>
<td>人和模型在协作树里如何称呼它</td>
<td><code>/root/scheduler</code></td>
</tr>
<tr>
<td>thread 标识</td>
<td>运行时把输入路由到哪条会话</td>
<td>一个 <code>ThreadId</code></td>
</tr>
<tr>
<td>turn 标识</td>
<td>这条会话当前进行的是哪一轮工作</td>
<td>某次开始、补充和完成事件关联的轮次</td>
</tr>
</tbody>
</table>
<p>路径并不意味着操作系统真的有一个同名进程。在线程解析入口中，Codex 会先尝试把目标解析成 <code>ThreadId</code>，否则再解析 agent 引用。<a href="https://github.com/openai/codex/blob/rust-v0.153.2/codex-rs/core/src/agent/agent_resolver.rs" target="_blank" rel="noopener">目标解析源码</a>展示了这个分工。</p>
<p>对外集成时还会遇到 App Server 的 <code>thread/start</code>、<code>turn/start</code>、<code>turn/steer</code> 等方法。它们属于客户端与 Codex 运行时之间的协议；本文后面讲的 <code>spawn_agent</code> 等，则是供 agent 调用的协作工具，二者不能直接互换。官方 App Server 文档说明，<code>turn/steer</code> 为正在进行的 turn 补充输入，还要求匹配 <code>expectedTurnId</code>。<a href="https://learn.chatgpt.com/docs/app-server" target="_blank" rel="noopener">协议说明</a></p>
<p>可以由此提炼一个自己的设计原则：<strong>定位参与者与定位某次工作，要使用不同的标识。</strong> 一个长期存在的 worker 可以连续处理多次任务；它叫“scheduler”，不代表它的所有历史结果都属于当前这次排查。</p>
<h2>一次 spawn，在运行时里经过哪些步骤</h2>
<p>主 agent 生成工具调用，只是流程的起点。沿着 V2 的 <a href="https://github.com/openai/codex/blob/rust-v0.153.2/codex-rs/core/src/tools/handlers/multi_agents_v2/spawn.rs" target="_blank" rel="noopener">spawn 处理器</a>往下读，可以看到几类工作：</p>
<ol>
<li>解析参数，检查任务信息和上下文继承选项。</li>
<li>根据父线程与角色配置构造子 agent 的运行配置，建立任务路径及父子关系。</li>
<li>把初始任务封装为 agent 间通信，并标记为需要触发工作。</li>
<li>调用 <code>AgentControl</code> 创建或派生子线程，然后返回可以继续引用的任务名。</li>
</ol>
<p>再进入 <a href="https://github.com/openai/codex/blob/rust-v0.153.2/codex-rs/core/src/agent/control/spawn.rs" target="_blank" rel="noopener">AgentControl 的创建路径</a>，还能看到执行容量检查、相关槽位预留、线程创建、父子关系持久化以及初始输入投递。V2 中驻留资源与执行容量也有相应管理，不能把一个“agent 数量”粗略理解成唯一资源限制。</p>
<p>把这条路径压缩成便于阅读的结构，就是：</p>
<pre><code class="language-text">主 agent 的工具调用
        ↓
spawn 处理器：参数、角色、上下文、任务路径
        ↓
AgentControl：容量与线程生命周期
        ↓
子线程：接收初始任务，运行自己的 agent 循环</code></pre>
<p>这里的创建成功回执不是任务结果。它告诉调用方“现在有了这个参与者，可以继续与它交互”，并不证明对方已经查到原因、改完代码或通过测试。</p>
<p>如果自己实现类似系统，日志最好也分开记录“请求创建”“线程已建立”“初始任务已投递”和“任务已完成”。否则用户只会看到一句“派发成功”，而运行时卡在哪一层完全不可见。这个日志拆分是本文的工程建议，不是对 Codex 所有事件名称的照抄。</p>
<h2>send_message 和 followup_task，差别不在消息文字</h2>
<p>这个版本里最值得细读的是两个看起来几乎相同的工具：</p>
<table>
<thead>
<tr>
<th>工具</th>
<th>核心语义</th>
<th>不应据此推断的事情</th>
</tr>
</thead>
<tbody>
<tr>
<td><code>spawn_agent</code></td>
<td>创建子 agent 并交付初始任务</td>
<td>子任务已经完成</td>
</tr>
<tr>
<td><code>send_message</code></td>
<td>给已有 agent 补充消息，不触发新 turn</td>
<td>空闲 agent 一定马上重新工作</td>
</tr>
<tr>
<td><code>followup_task</code></td>
<td>给已有非 root agent 追加任务；空闲时触发工作</td>
<td>无论对方状态如何都新建一轮</td>
</tr>
<tr>
<td><code>wait_agent</code></td>
<td>等待当前输入队列的相关活动或超时</td>
<td>已经收齐所有子任务结果</td>
</tr>
<tr>
<td><code>list_agents</code></td>
<td>查询协作树中的 agent 情况</td>
<td>一次查询之后状态不会再变</td>
</tr>
<tr>
<td><code>interrupt_agent</code></td>
<td>请求中断目标 agent 当前工作</td>
<td>已执行的修改会自动回滚</td>
</tr>
</tbody>
</table>
<p>这些是 V2 工具集合中的语义概要。具体工具契约集中在 <a href="https://github.com/openai/codex/blob/rust-v0.153.2/codex-rs/core/src/tools/handlers/multi_agents_spec.rs" target="_blank" rel="noopener">工具定义文件</a>，不同版本或客户端应重新核对。</p>
<p><code>send_message</code> 与 <code>followup_task</code> 最终共用一个消息处理入口，只是前者选择 <code>QueueOnly</code>，后者选择 <code>TriggerTurn</code>。该入口解析接收者、确认 agent 已知且可加载，再把区别放入通信对象的 <code>trigger_turn</code> 字段；不是靠模型从“请尽快处理”几个字里猜测是否开始工作。<a href="https://github.com/openai/codex/blob/rust-v0.153.2/codex-rs/core/src/tools/handlers/multi_agents_v2/message_tool.rs" target="_blank" rel="noopener">共享消息处理器</a></p>
<p>于是，前面的排查场景可以这样交互：</p>
<pre><code class="language-text">子 agent 正在检查调度器：
  send_message：补充“回退只发生在长输入，短输入正常”。

子 agent 已经结束这一轮：
  followup_task：要求“根据刚才的结论，再核对一个具体提交”。</code></pre>
<p>对正在运行的接收者，followup 的契约是及时补充输入：采样期间在消息边界处理，若正有工具调用则等到该调用完成。它不是凭空再并发开启一份完全相同的 worker。</p>
<p>再往下一层，<a href="https://github.com/openai/codex/blob/rust-v0.153.2/codex-rs/core/src/agent/control.rs" target="_blank" rel="noopener">控制入口</a>把通信作为 <code>Op::InterAgentCommunication</code> 投递给目标线程；需要启动工作时还会经过容量检查。</p>
<p>对自己的系统而言，这种区分很有价值：可以让“补充证据”与“安排下一次工作”拥有不同的触发规则、配额和审计记录。否则一段只想同步进展的消息，也可能意外唤醒一个模型，增加调用和成本。</p>
<h2>wait_agent 等到的不是一张“全部完成”证明</h2>
<p>沿着 V2 的 <a href="https://github.com/openai/codex/blob/rust-v0.153.2/codex-rs/core/src/tools/handlers/multi_agents_v2/wait.rs" target="_blank" rel="noopener">等待实现</a>看，处理器会订阅输入队列活动，同时接收已经存在的待处理活动。等待函数使用截止时间，并区分三类结束原因：邮箱活动、新输入打断、超时。</p>
<p>这与 <code>join(all_workers)</code> 的含义不同。调度器 agent 发来一句“我找到一个可疑分支”，就可能成为值得主 agent 处理的消息；注意力内核 agent 此时仍可能在工作。等待返回，只意味着控制流程需要重新检查收到的内容和任务状态。</p>
<p>超时也一样。它表示这次等待窗口内没有取得预期活动，不应自动解释成“子任务失败”，更不能当成所有 worker 已被取消。</p>
<p>这里还有一个很适合讲并发设计的细节：源码把订阅与已存在活动一起交给等待逻辑。自己实现邮箱时，也需要防范这个竞态：先检查发现邮箱为空，随后消息到达，最后才开始订阅，结果把刚刚那次通知漏掉。可用锁保护的检查与订阅、序列号或条件变量等办法处理，但不能只写一句 <code>sleep</code> 就当作等待机制。</p>
<p>如果业务要求收齐两个检查结果，聚合端应单独维护集合：</p>
<pre><code class="language-text">expected = {scheduler 的本轮结果, kernel 的本轮结果}
completed = 已收到且验证过身份的结果

只有 expected 全部被 completed 覆盖，才进入最终汇总。</code></pre>
<p>还需要把“成功完成”“失败”“取消”分别表示。结果里一句自然语言“差不多好了”，不足以充当机器可判定的成功状态。</p>
<h2>一个不调用模型的小实验</h2>
<p>下面的程序只模拟消息与工作状态的区别，以及结果聚合时的一次版本检查。<code>epoch</code> 是<strong>本文示例自己增加的任务代次</strong>，不是宣称 Codex 的公开工具就有这个参数；<code>deliver</code> 也不是 Codex SDK 方法。</p>
<pre><code class="language-python">from collections import deque
from dataclasses import dataclass, field

@dataclass
class Worker:
    name: str
    state: str = "idle"
    epoch: int = 0
    inbox: list = field(default_factory=list)

    def deliver(self, text, trigger=False):
        self.inbox.append(text)
        if trigger and self.state == "idle":
            self.epoch += 1
            self.state = "running"

    def finish(self, result):
        if self.state != "running":
            raise RuntimeError("no running task")
        self.state = "idle"
        return ("result", self.name, self.epoch, result)

worker = Worker("scheduler")
worker.deliver("长输入才发生回退")
print("after_message:", worker.state)
assert worker.state == "idle"

worker.deliver("检查调度逻辑", trigger=True)
worker.deliver("补查长输入分支", trigger=True)
assert worker.epoch == 1  # 已在运行：补充输入，不增加代次
old_result = worker.finish("第一轮结论")

worker.deliver("按更新后的基准重新检查", trigger=True)
new_result = worker.finish("第二轮结论")

expected = {"scheduler": 2, "kernel": 1}
events = deque([
    old_result,
    ("progress", "kernel", 1, "正在检查"),
    new_result,
    ("result", "kernel", 1, "内核检查完成"),
    new_result,  # 重复事件不重复计数
])
accepted = {}
ignored = 0
first_event = True
while events:
    kind, name, epoch, result = events.popleft()
    if (kind == "result" and expected.get(name) == epoch
            and name not in accepted):
        accepted[name] = result
    else:
        ignored += 1
    if first_event:
        print("done_after_first_event:", len(accepted) == len(expected))
        first_event = False

print("accepted:", sorted(accepted))
print("ignored_events:", ignored)
assert accepted["scheduler"] == "第二轮结论"
assert len(accepted) == 2 and ignored == 3</code></pre>
<p>输出是：</p>
<pre><code class="language-text">after_message: idle
done_after_first_event: False
accepted: ['kernel', 'scheduler']
ignored_events: 3</code></pre>
<p>旧结果、进度消息和重复结果都没有被算成新的完成项。这里没有网络，也没有真实并发，因此它只验证状态转移与聚合条件，不验证消息中间件的可靠性、取消竞态或多进程一致性。</p>
<p>把这个想法接入自己的服务时，任务代次还应与输入版本、代码提交或需求快照关联。主 agent 更新了基准条件，旧 agent 的一份正确报告也可能已经不适用于当前问题。需要拒绝的是过期证据，不是这个 agent 本身。</p>
<h2>fork 上下文，并不等于 fork 整个工作环境</h2>
<p>V2 的 spawn 参数支持 <code>fork_turns</code>，该标签里可以选择 <code>none</code>、<code>all</code> 或正整数形式的字符串。这个选项影响继承的会话历史范围；<code>none</code> 不代表子 agent 没有系统规则、运行配置或工具环境。<a href="https://github.com/openai/codex/blob/rust-v0.153.2/codex-rs/core/src/tools/handlers/multi_agents_v2/spawn.rs" target="_blank" rel="noopener">参数与分支处理</a></p>
<p>完整继承与截取最近历史也不是单纯的数组复制。源码的派生线程路径会处理历史项、参考上下文与指令，完整历史分支还考虑保留可复用的提示前缀。因此，不能把“分了一个子 agent”解释成它持续自动看到父线程之后的每条新消息。<a href="https://github.com/openai/codex/blob/rust-v0.153.2/codex-rs/core/src/agent/control/spawn.rs" target="_blank" rel="noopener">历史派生实现</a></p>
<p>在应用层选择时，可以从依赖关系出发：</p>
<table>
<thead>
<tr>
<th>子任务情况</th>
<th>更合适的输入方式</th>
</tr>
</thead>
<tbody>
<tr>
<td>能靠几个文件、明确问题独立完成</td>
<td>精简任务说明与必要证据</td>
</tr>
<tr>
<td>强依赖刚才的讨论</td>
<td>给足相关历史，并明确新的任务边界</td>
</tr>
<tr>
<td>历史很长，重要约束散落其中</td>
<td>先整理约束摘要，再决定继承多少历史</td>
</tr>
</tbody>
</table>
<p>输入分开以后，文件系统仍要单独判断。若多个 agent 指向同一个工作目录，一个 agent 的写入会影响另一个；不能用“它们有独立会话”推断代码修改也自动隔离。</p>
<p>OpenAI 的子 agent 文档建议先从读操作较多的任务开始，并提醒并行写代码会产生冲突和协调开销。<a href="https://learn.chatgpt.com/docs/agent-configuration/subagents" target="_blank" rel="noopener">官方子 agent 指南</a></p>
<p>就前面的延迟排查而言，让两个子 agent 分别返回文件位置、调用链和可复现证据，由主 agent 汇总后再安排修改，通常更容易检查。若确实需要并行改代码，可以明确文件归属或使用独立 worktree；合并与验证仍然要有负责人。</p>
<h2>中断任务，只处理生命周期的一部分</h2>
<p>V2 的 <a href="https://github.com/openai/codex/blob/rust-v0.153.2/codex-rs/core/src/tools/handlers/multi_agents_v2/interrupt_agent.rs" target="_blank" rel="noopener">中断处理器</a>先获取目标状态，再请求中断，并把先前状态返回。底层控制入口投递的是 <code>Op::Interrupt</code>。这不能作为文件事务已经回滚的证明。</p>
<p>假设子 agent 已经修改两个文件，第三个修改前被中断。接下来主 agent 需要查看实际 diff、确认哪些验证完成，再决定保留、继续还是回退。取消一个调度动作与撤销已经产生的副作用，是两套机制。</p>
<p>此外，不要拿旧版本某个 completion watcher 的一段代码，直接描述新版本所有完成通知。在这个标签的创建路径里，V2 与旧路径对 watcher 的处理就存在分支。本文明确跟踪的是 V2 的创建、投递与等待入口，不对未逐条验证的通知重试、投递次数或崩溃恢复作保证。</p>
<p>对自己设计的系统，可以补上三个独立的约定：任务如何结束、结果如何确认接收、工作区如何恢复。尤其是有外部写入的任务，要在执行前确定可逆边界，而不是取消以后才寻找“撤销所有操作”的按钮。</p>
<h2>真正值得借鉴的是一份清楚的协作契约</h2>
<p>如果要把这套思路用到自己的 agent 系统，我会先定义每个子任务的交付格式：</p>
<pre><code class="language-text">任务：检查长输入下的调度变化
输入版本：指定提交、指定基准条件
范围：只读哪些目录；哪些文件允许修改
交付：结论、证据位置、验证方法、尚未排除的解释
完成条件：明确需要覆盖哪些分支或测试</code></pre>
<p>随后才决定要开几个 agent。可以分别测量任务派发到真正开始工作的等待时间、子任务执行时间、父线程等待时间，以及整合和返工时间。并行部分再快，如果主 agent 花很久修复冲突，端到端仍可能更慢。</p>
<p>比较单 agent 与多 agent 时，还要用同一验收标准：检查覆盖率、结果是否可复现、修改是否通过测试。子 agent 的模型调用和工具工作会增加消耗，不能把墙钟时间降低直接写成总成本降低。</p>
<p>一套能维护的协作系统，应当能回答：这条消息发给了谁，属于哪次工作，是否触发执行，等待为什么结束，以及这个结果现在是否仍然有效。提示词帮助模型把任务做好，明确的协议则让其他部分知道它到底做到了哪一步。</p>
<hr />
<p>本文基于公开文档及 <code>rust-v0.153.2</code> 的指定源码路径，代码仅在 CPU 上验证教学状态模型。未调用真实子 agent 做性能基准，也未把本地源码阅读当作端到端系统测试。后续版本的工具名、返回值和调度行为需重新核对。</p>
<p><a href="https://www.cztcode.com/ai-content-policy/">内容生成、审核与纠错说明</a></p>
<h2>继续阅读</h2>
<ul>
<li><a href="https://www.cztcode.com/2026/5370/">大模型推理加速（四）：推测解码为什么不改变分布，什么时候反而更慢</a></li>
<li><a href="https://www.cztcode.com/2026/5368/">大模型推理加速（三）：FlashAttention 为什么更快，又没有省掉哪些计算</a></li>
<li><a href="https://www.cztcode.com/2026/5366/">大模型推理加速（二）：KV Cache 到底占多少显存，PagedAttention 又解决了什么</a></li>
</ul>
<hr />
<p><a href="https://www.cztcode.com/ai-content-policy/">本站的内容生成、审核与纠错说明</a></p>
]]></content:encoded>
					
					<wfw:commentRss>https://www.cztcode.com/2026/5372/feed/</wfw:commentRss>
			<slash:comments>0</slash:comments>
		
		
		<post-id xmlns="com-wordpress:feed-additions:1">5372</post-id>	</item>
		<item>
		<title>大模型推理加速（四）：推测解码为什么不改变分布，什么时候反而更慢</title>
		<link>https://www.cztcode.com/2026/5370/</link>
					<comments>https://www.cztcode.com/2026/5370/#comments</comments>
		
		<dc:creator><![CDATA[Jellow]]></dc:creator>
		<pubDate>Tue, 08 Sep 2026 01:00:04 +0000</pubDate>
				<category><![CDATA[模型推理加速]]></category>
		<category><![CDATA[LLM推理]]></category>
		<category><![CDATA[Speculative Sampling]]></category>
		<category><![CDATA[延迟优化]]></category>
		<category><![CDATA[推测解码]]></category>
		<guid isPermaLink="false">https://www.cztcode.com/2026/5370/</guid>

					<description><![CDATA[用三 token 概率例子推导推测解码的接受与修正分布，再用接受率和轮次成本判断它何时加速、何时变慢。]]></description>
										<content:encoded><![CDATA[<div id="bsf_rt_marker"></div><blockquote>
<p><strong>Jellow 编辑发布，AI 辅助生成并完成技术核对。</strong> 计算示例和实测结果会在文中分别说明。</p>
</blockquote>
<p>自回归生成有一条难以绕开的依赖：确定第 t 个 token 后，才能知道第 t+1 个位置的输入。推测解码的做法听起来有些冒险——先让一个便宜的模型猜几个 token，再让目标模型一次检查。</p>
<p>如果只是“猜对就留，猜错就重来”，采样分布会被改变。真正关键的是接受概率和拒绝后的修正分布。它们保证输出仍服从目标模型，而不是悄悄变成草稿模型的偏好。</p>
<p>本文使用最基础的 speculative sampling 解释这件事。不同推理框架还有 n-gram、Medusa 等提议方式，具体接口和限制不能直接套用。</p>
<h2>1. 一轮推测解码做了什么</h2>
<p>记目标模型的条件分布为 p，草稿模型的条件分布为 q。一轮生成可以分成四步：</p>
<ol>
<li>草稿模型自回归提出 k 个候选 token，并保存各步的 q 概率；</li>
<li>目标模型在已知候选前缀上一次前向，得到对应位置的 p 概率；</li>
<li>从第一个候选开始依次验收，候选 x 的接受概率是 <code>min(1, p(x) / q(x))</code>；</li>
<li>第一次拒绝时，从修正分布 <code>normalize(max(p - q, 0))</code> 采样一个 token，然后结束本轮。如果 k 个候选全部接受，再从目标模型验证结果中采样一个额外 token。</li>
</ol>
<p>目标模型虽然不能提前知道最终会接受几个候选，但这一轮的候选前缀已经由草稿模型给出。它可以并行计算这些位置的 logits。接受判断仍要按顺序进行，因为后一个位置的 p、q 建立在前面候选都被保留的条件上。</p>
<p>这一算法由两组同期工作系统化提出，可参阅 <a href="https://proceedings.mlr.press/v202/leviathan23a.html" target="_blank" rel="noopener">Leviathan 等人的论文</a>和 <a href="https://arxiv.org/abs/2302.01318" target="_blank" rel="noopener">Chen 等人的论文</a>。</p>
<h2>2. 为什么修正后仍然服从目标分布</h2>
<p>先看只有一个位置、三个 token 的例子：</p>
<pre><code class="language-text">token       A     B     C
p          0.6   0.3   0.1
q          0.2   0.5   0.3
min(p,q)   0.2   0.3   0.1
max(p-q,0) 0.4   0.0   0.0</code></pre>
<p>候选从 q 采样后被接受为 token x 的总概率是：</p>
<pre><code class="language-text">q(x) × min(1, p(x)/q(x)) = min(p(x), q(x))</code></pre>
<p>三种 token 的接受概率之和为 0.6，所以拒绝概率是 0.4。拒绝后，<code>max(p-q, 0)</code> 归一化得到 <code>[1, 0, 0]</code>，也就是一定选择 A。</p>
<p>最终分布为：</p>
<pre><code class="language-text">接受部分 [0.2, 0.3, 0.1]
+ 拒绝后 [0.4, 0.0, 0.0]
= 目标分布 [0.6, 0.3, 0.1]</code></pre>
<p>一般情况下也有逐项恒等式：</p>
<pre><code class="language-text">min(p, q) + max(p - q, 0) = p</code></pre>
<p>这就是修正分布不能省略的原因。工程实现还要处理 <code>q(x)=0</code>、数值误差、EOS 和截断等边界。</p>
<p>这里的“分布相同”不等于同一个随机种子会输出完全相同的文本。推测解码消耗随机数的顺序不同。temperature、top-p 等采样变换也必须体现在用于验收的实际 p、q 中；目标与草稿通常还需要兼容的 tokenizer 和词表。</p>
<h2>3. 接受率怎样影响一轮产出</h2>
<p>为了建立直觉，假设每个候选在给定前面都接受的条件下，接受概率恒为 α。第 i 个候选被接受，需要前 i 个全部通过，因此概率为 αⁱ。</p>
<p>忽略 EOS，一轮平均产出的 token 数是：</p>
<pre><code class="language-text">E[token/round] = 1 + α + α² + ... + αᵏ</code></pre>
<p>末尾的 1 来自第一次拒绝后的修正 token，或者全部接受后的额外 token。</p>
<p>例如 k = 4、α = 0.8：</p>
<pre><code class="language-text">E = 1 + 0.8 + 0.64 + 0.512 + 0.4096
  = 3.3616 token/round</code></pre>
<p>不能直接用 <code>1 + k × α</code>，因为后面的候选只有在前面全部接受时才有机会保留。实际每一步的接受概率会随上下文变化，这个等比模型只是估算。</p>
<h2>4. 多产出 token 还不等于更快</h2>
<p>设普通目标模型每生成一个 token 的时间为 Tbase，一轮推测解码需要：</p>
<pre><code class="language-text">Tround = Tdraft(k) + Tverify(k) + Toverhead</code></pre>
<p>只有当 <code>Tround / E[token/round] &lt; Tbase</code> 时，单请求平均每 token 时间才下降。</p>
<p>构造一个纯计算示例：Tbase 为 10 ms；草稿生成 4 个候选共 4 ms；目标验证用 14 ms；调度和采样额外 2 ms。使用上一节的平均产出：</p>
<pre><code class="language-text">20 ms / 3.3616 ≈ 5.95 ms/token
10 ms / 5.95 ms ≈ 1.68 倍</code></pre>
<p>若接受率降到 0.3，同样 k = 4 时平均只产出约 1.43 个 token，20 ms / 1.43 已经慢于普通解码。上面的时间全部是假设值，只说明决策方法。</p>
<p>高接受率也不是充分条件。草稿模型太大、验证形状不适合硬件、跨设备传输昂贵，都会吃掉收益。在高并发服务中，目标模型原本就能通过 batching 保持忙碌；推测解码增加的计算可能降低总体吞吐，即使单请求延迟有所改善。</p>
<h2>5. 草稿模型应该怎样选</h2>
<p>好的草稿模型需要同时满足两件事：提出候选足够便宜，候选又与目标分布足够接近。只比较参数量无法判断结果。</p>
<p>可以按以下顺序做实验：</p>
<ol>
<li>固定目标模型、tokenizer、采样参数和请求集合；</li>
<li>先测普通解码的 TTFT、TPOT、吞吐和显存；</li>
<li>对每个草稿方案记录候选长度、各位置接受率、每轮接受数分布；</li>
<li>分别测低并发延迟与高并发吞吐，不混成一个结论；</li>
<li>按任务拆分结果，例如代码、翻译、开放问答和长上下文。</li>
</ol>
<p>接受率低时，先确认草稿与目标是否使用同一模板、相同采样变换和兼容 tokenizer，再调整候选长度。k 越大，单轮潜在产出越多，目标验证和草稿生成的成本也更高，没有对所有负载通用的最佳值。</p>
<p><a href="https://jaykmody.com/blog/speculative-sampling/" target="_blank" rel="noopener">Jay Mody 的实现讲解</a>把自回归基线、验收步骤与复杂度放在同一篇文章中，适合对照伪代码理解。接入服务前，还可以把接受率与分布距离联系起来，并用边际成本选择候选长度。</p>
<h2>6. 接受率其实对应两个分布的重叠</h2>
<p>对单个位置，候选被接受的总概率是：</p>
<pre><code class="language-text">α = Σx min(p(x), q(x))</code></pre>
<p>总变差距离定义为：</p>
<pre><code class="language-text">TV(p, q) = 1/2 × Σx |p(x) - q(x)|</code></pre>
<p>利用概率和都为 1，可以得到：</p>
<pre><code class="language-text">α = 1 - TV(p, q)</code></pre>
<p>因此“草稿模型接近目标模型”可以变成一个更具体的判断：经过实际 temperature、top-p 等变换后，两个条件分布重叠得越多，这一步的接受概率越高。</p>
<p>这个关系只描述当前条件前缀上的一个位置。生成任务里，每次接受都会改变后续条件分布，不能用少数 prompt 的平均 α 代表所有位置。更有解释力的监控应该按候选位置记录接受率：第一个候选经常通过、第四个候选经常失败，说明继续增大 k 的边际收益已经很低。</p>
<p>一般情况下，一轮产出的期望可以写成：</p>
<pre><code class="language-text">E[token/round]
= 1 + P(A1) + P(A1∩A2) + ... + P(A1∩...∩Ak)</code></pre>
<p>只有在每一步条件接受概率都近似相同且为 α 时，才化成前面的等比数列。这也是为什么服务应记录“每轮接受 token 数分布”，而不只记录把所有位置混在一起的平均接受率。</p>
<h2>7. 用边际收益选择候选长度 k</h2>
<p>将 k 从 4 增加到 5，多出的潜在收益只有“前 5 个候选全部被接受”的那一项；成本却一定包含草稿模型再生成一步，并可能增加目标验证工作和临时张量。</p>
<p>可以直接测量相邻配置：</p>
<pre><code class="language-text">边际产出 = E[token/round | k+1] - E[token/round | k]
边际成本 = Tround(k+1) - Tround(k)</code></pre>
<p>当 <code>边际成本 / 边际产出</code> 已高于普通目标模型每 token 的成本时，继续增加 k 没有意义。这个比较比“接受率超过某个固定百分比就启用”更可靠，因为不同硬件和实现中的验证成本并不相同。</p>
<p>假设各位置的条件接受率都是 0.8，从 k=4 增到 k=5，只增加 <code>0.8⁵≈0.328</code> 个期望 token。如果多一个候选让轮次增加 2 ms，那么边际成本约为 <code>2/0.328≈6.1 ms/token</code>。它是否值得，仍要和基线每 token 成本以及高并发吞吐损失比较。这些数字仍是公式示例。</p>
<h2>8. 怎样验证“分布没变”</h2>
<p>逐次比较同一随机种子的文本不是有效验证，因为随机数消耗顺序可以不同。更合理的测试分三层：</p>
<ol>
<li><strong>枚举小词表</strong>：像本文三 token 例子一样，精确求出接受与修正后的概率，逐项比较目标 p；</li>
<li><strong>随机分布测试</strong>：生成许多归一化的 p、q，检查 <code>min(p,q)</code> 加拒绝质量后是否恢复 p，并覆盖零概率和浮点极值；</li>
<li><strong>模型统计测试</strong>：在固定 prompt 集上大量采样，比较 token 频率或序列统计量，同时保留显著性阈值和样本量。</li>
</ol>
<p>端到端实现还要专门测试：EOS 出现在草稿中间、目标和草稿词表映射、top-k/top-p 截断后 q 为零、批次中各请求拒绝位置不同，以及流式接口一次返回多个 token。数学公式正确，不代表这些状态机边界自动正确。</p>
<h2>9. 部署时看这张决策表</h2>
<table>
<thead>
<tr>
<th>现象</th>
<th>判断</th>
<th>下一步</th>
</tr>
</thead>
<tbody>
<tr>
<td>低并发 TPOT 高，目标模型单步很贵</td>
<td>有潜在价值</td>
<td>测 k、各位置接受率与轮次成本</td>
</tr>
<tr>
<td>接受率高，但总体没变快</td>
<td>草稿或验证开销过高</td>
<td>分解 Tdraft、Tverify、调度和传输</td>
</tr>
<tr>
<td>k 越大，后部接受率快速下降</td>
<td>候选过长</td>
<td>按边际成本缩短 k 或动态调整</td>
</tr>
<tr>
<td>单请求变快，高并发 goodput 下降</td>
<td>额外计算挤占 batching 收益</td>
<td>按线上负载决定是否仅对部分请求启用</td>
</tr>
<tr>
<td>输出统计偏离目标模型</td>
<td>实现正确性问题</td>
<td>检查修正分布、采样变换、EOS 与词表</td>
</tr>
<tr>
<td>不同任务收益差异很大</td>
<td>p、q 接近程度依赖任务</td>
<td>按任务路由，避免使用全局开关</td>
</tr>
</tbody>
</table>
<p>推测解码最值得启用的组合是：目标模型单步昂贵、草稿足够便宜、实际任务上的分布重叠高，而且目标验证一段候选的成本没有随 k 等比例增长。只缺其中一项，都可能让一个数学上正确的算法在系统上变慢。</p>
<h2>继续阅读</h2>
<ul>
<li><a href="https://www.cztcode.com/2026/5368/">大模型推理加速（三）：FlashAttention 为什么更快，又没有省掉哪些计算</a></li>
<li><a href="https://www.cztcode.com/2026/5366/">大模型推理加速（二）：KV Cache 到底占多少显存，PagedAttention 又解决了什么</a></li>
<li><a href="https://www.cztcode.com/2026/5360/">吞吐涨了，用户却觉得更卡：连续批处理的延迟账怎么算</a></li>
</ul>
<hr />
<p><a href="https://www.cztcode.com/ai-content-policy/">本站的内容生成、审核与纠错说明</a></p>
]]></content:encoded>
					
					<wfw:commentRss>https://www.cztcode.com/2026/5370/feed/</wfw:commentRss>
			<slash:comments>1</slash:comments>
		
		
		<post-id xmlns="com-wordpress:feed-additions:1">5370</post-id>	</item>
		<item>
		<title>大模型推理加速（三）：FlashAttention 为什么更快，又没有省掉哪些计算</title>
		<link>https://www.cztcode.com/2026/5368/</link>
					<comments>https://www.cztcode.com/2026/5368/#comments</comments>
		
		<dc:creator><![CDATA[Jellow]]></dc:creator>
		<pubDate>Mon, 07 Sep 2026 01:00:04 +0000</pubDate>
				<category><![CDATA[模型推理加速]]></category>
		<category><![CDATA[Attention]]></category>
		<category><![CDATA[FlashAttention]]></category>
		<category><![CDATA[GPU]]></category>
		<category><![CDATA[性能优化]]></category>
		<guid isPermaLink="false">https://www.cztcode.com/2026/5368/</guid>

					<description><![CDATA[从注意力中间矩阵和显存 IO 出发，用在线 softmax 代码解释 FlashAttention 如何得到精确结果，以及端到端收益为何不等比。]]></description>
										<content:encoded><![CDATA[<div id="bsf_rt_marker"></div><blockquote>
<p><strong>Jellow 编辑发布，AI 辅助生成并完成技术核对。</strong> 计算示例和实测结果会在文中分别说明。</p>
</blockquote>
<p>“FlashAttention 把注意力从平方复杂度降成了线性复杂度”是一个很常见的说法，也很容易让人误判优化效果。</p>
<p>标准的精确注意力确实包含与序列长度平方相关的工作。FlashAttention 没有删掉这些乘加运算，也不是用近似结果换速度。它改变了计算的组织方式：分块读取 Q、K、V，在更靠近计算单元的存储中完成中间步骤，避免反复把庞大的注意力矩阵写回显存。</p>
<p>本文先从一个放不下的中间矩阵说起，再用几十行 Python 验证分块 softmax 的核心状态。代码只用于解释算法，不是 GPU kernel，也不能用来测试 FlashAttention 的速度。</p>
<h2>1. 真正麻烦的是中间矩阵</h2>
<p>单个注意力头可以写成：</p>
<pre><code class="language-text">S = QKᵀ / √d
P = softmax(S)
O = PV</code></pre>
<p>序列长度为 N 时，S 和 P 都有 N × N 个元素。假设 N = 8,192、每个元素 2 字节，单个矩阵在单个头上就有：</p>
<pre><code class="language-text">8192 × 8192 × 2 bytes = 128 MiB</code></pre>
<p>32 个头对应 4 GiB。这只是一个中间张量的逻辑大小，没有计入 batch、反向传播或其他工作区，也不代表某个框架一定会同时保留全部张量。</p>
<p>矩阵大只是表面现象。GPU 计算单元附近的片上存储容量小但带宽高，显存容量大但数据搬运更贵。如果把 S 写到显存，随后为了 softmax 读回，再把 P 写出、读回，很多时间会花在搬运中间结果上。</p>
<p><a href="https://arxiv.org/abs/2205.14135" target="_blank" rel="noopener">FlashAttention 论文</a>把注意力描述为 IO-aware 的精确算法，重点正是减少不同存储层级之间的数据读写。</p>
<h2>2. 分块之后，softmax 怎么保持正确</h2>
<p>不能简单地对每个块单独做 softmax，再把结果相加。因为一个位置的 softmax 分母包含这一行里的所有 key：</p>
<pre><code class="language-text">softmax(sᵢ) = exp(sᵢ) / Σⱼ exp(sⱼ)</code></pre>
<p>稳定实现还需要先减去全局最大值。如果后面的块出现更大的分数，前面已经累加的结果必须重新缩放。</p>
<p>处理一段分数时，只需保存三个状态：</p>
<ul>
<li><code>m</code>：目前看到的最大分数；</li>
<li><code>l</code>：以 <code>m</code> 为基准的指数和；</li>
<li><code>u</code>：同一基准下，指数权重与 value 的加权和。</li>
</ul>
<p>读到新分数 <code>s</code> 和对应的 <code>v</code> 后，令 <code>m_new = max(m, s)</code>：</p>
<pre><code class="language-text">l_new = exp(m - m_new) × l + exp(s - m_new)
u_new = exp(m - m_new) × u + exp(s - m_new) × v</code></pre>
<p>最后的输出是 <code>u / l</code>。最大值变化时，旧状态与新项被换算到同一基准，因此无需保存全部分数。这个思路与 <a href="https://arxiv.org/abs/1805.02867" target="_blank" rel="noopener">online normalizer 的推导</a>一致。</p>
<p>下面用标量 value 展示。真实 attention 的 value 是向量，状态 <code>u</code> 也相应变成向量。</p>
<pre><code class="language-python">from math import exp

def stable_attention(scores, values):
    m = max(scores)
    weights = [exp(s - m) for s in scores]
    return sum(w * v for w, v in zip(weights, values)) / sum(weights)

def online_attention(scores, values):
    m = float("-inf")
    l = 0.0
    u = 0.0

    for s, v in zip(scores, values):
        new_m = max(m, s)
        old_scale = 0.0 if m == float("-inf") else exp(m - new_m)
        new_scale = exp(s - new_m)
        l = old_scale * l + new_scale
        u = old_scale * u + new_scale * v
        m = new_m

    return u / l

scores = [1000.0, 999.0, 1002.0, 998.0]
values = [2.0, -1.0, 4.0, 3.0]

a = stable_attention(scores, values)
b = online_attention(scores, values)
print(a, b, abs(a - b))
assert abs(a - b) &lt; 1e-12</code></pre>
<p>这里故意使用接近 1,000 的分数。直接计算 <code>exp(1000)</code> 会溢出，减去当前最大值后则可以稳定计算。</p>
<h2>3. 从逐元素状态到二维分块</h2>
<p>FlashAttention 处理的不是一个分数列表，而是 Q 与 K、V 的二维块。对某一组 query，kernel 依次载入若干 K、V 块，算出当前分数块，并更新每一行的最大值、指数和与输出累加值。</p>
<p>假设 A、B 两个块已经分别得到状态 <code>(mA, lA, uA)</code> 和 <code>(mB, lB, uB)</code>。它们也可以合并：</p>
<pre><code class="language-text">m = max(mA, mB)
l = exp(mA - m) × lA + exp(mB - m) × lB
u = exp(mA - m) × uA + exp(mB - m) × uB</code></pre>
<p>这说明算法可以在不物化完整 S、P 的情况下得到同一个数学结果。浮点运算顺序不同，输出不保证逐比特相同；这里的“精确”指它没有把注意力机制改成近似模型。</p>
<p>反向传播还可以利用保存的归一化统计量重算部分中间值，以计算换显存。在纯推理场景里，关注点主要是前向计算，但不要把训练阶段的显存数字直接套到推理服务上。</p>
<h2>4. 它省的是 IO，不是 QKᵀ 的平方工作量</h2>
<p>对标准全注意力，计算 QKᵀ 和 PV 的乘加量仍随 N² 增长。FlashAttention 改善的是访存复杂度和 kernel 融合方式。因此，下面两句话可以同时成立：</p>
<ol>
<li>长序列时，中间矩阵的显存占用不再按 N² 物化；</li>
<li>标准全注意力的算术工作量仍然包含 N² 项。</li>
</ol>
<p>这一区分会影响容量规划。采用 FlashAttention 后可以把更长输入放入显存，不代表序列长度翻倍后 Prefill 用时只翻倍。实际增幅还取决于硬件、维度、mask、精度和 kernel 是否覆盖当前形状。</p>
<p><a href="https://arxiv.org/abs/2307.08691" target="_blank" rel="noopener">FlashAttention-2</a>进一步减少非矩阵乘运算，并调整 thread block 与 warp 之间的工作划分。作者的<a href="https://github.com/Dao-AILab/flash-attention" target="_blank" rel="noopener">开源实现</a>列出了支持的硬件、数据类型和 head dimension；部署时应以所用版本的支持范围为准。</p>
<h2>5. 为什么服务总耗时不会等比例下降</h2>
<p>假设一次请求中，attention 占原始耗时的 30%，其余 70% 来自线性层、通信、采样和调度。即使 attention 恰好加速两倍，整体加速比也只有：</p>
<pre><code class="language-text">1 / (0.70 + 0.30 / 2) ≈ 1.18</code></pre>
<p>这是 Amdahl 定律下的计算示例，不是任何实现的实测结果。长 Prefill 中 attention 的占比可能更高，单 token Decode 中读取权重、KV Cache 和调度也可能成为主因。</p>
<p>因此验证 FlashAttention 时，至少分开看两层数据：</p>
<ul>
<li>kernel 或算子层：相同形状、精度和 mask 下的耗时与显存；</li>
<li>服务层：固定输入输出长度和请求率下的 TTFT、TPOT、吞吐与失败率。</li>
</ul>
<p>如果算子基准明显变快，而服务指标变化很小，先看 attention 原本占总耗时多少，再排查是否走到了预期 kernel。还要把 Prefill 与 Decode 分开，因为两者交给 attention kernel 的形状并不相同。</p>
<h2>6. Prefill 和 Decode 不能套用同一个收益预期</h2>
<p>长 Prefill 中有很多 query 位置，完整分数矩阵会很大。分块避免物化中间矩阵，通常能同时改善峰值显存和数据搬运。因果 mask 还允许跳过整个位于对角线上方的块，但对角线下方的有效注意力工作仍随序列长度平方增长。</p>
<p>单条序列 Decode 时，每一步往往只有一个新 query。此时分数形状接近 <code>1 × 历史长度</code>，本来就没有 <code>N × N</code> 的新分数矩阵要保存。算子仍要读取历史 K、V，FlashAttention 类 kernel 可以改善融合和访问方式，但收益来源与长 Prefill 不同。</p>
<p>大 batch Decode 又会改变形状：query 长度仍短，但同时处理的序列多，历史长度还可能各不相同。因此，下面三个 benchmark 不能互相替代：</p>
<table>
<thead>
<tr>
<th>场景</th>
<th style="text-align: right">query 长度</th>
<th style="text-align: right">KV 长度</th>
<th>主要想回答的问题</th>
</tr>
</thead>
<tbody>
<tr>
<td>长 Prefill</td>
<td style="text-align: right">8,192</td>
<td style="text-align: right">8,192</td>
<td>是否减少中间矩阵 IO 和显存</td>
</tr>
<tr>
<td>单请求 Decode</td>
<td style="text-align: right">1</td>
<td style="text-align: right">8,192</td>
<td>读取历史 KV 与 kernel 启动成本</td>
</tr>
<tr>
<td>批量 Decode</td>
<td style="text-align: right">1 × 多序列</td>
<td style="text-align: right">长短混合</td>
<td>调度、padding/分页与 KV 访问效率</td>
</tr>
</tbody>
</table>
<p>如果只用方形的 <code>Q=K=8192</code> 测出漂亮结果，再宣称聊天 Decode 也有相同倍数，比较对象就错了。</p>
<h2>7. 在线状态也可以按块合并</h2>
<p>前面的逐元素代码展示了状态更新。下面再把同一组数据切成不同块，验证块边界不会改变结果：</p>
<pre><code class="language-python">from math import exp

def summarize(scores, values):
    m = max(scores)
    weights = [exp(s - m) for s in scores]
    return m, sum(weights), sum(w * v for w, v in zip(weights, values))

def merge(a, b):
    ma, la, ua = a
    mb, lb, ub = b
    m = max(ma, mb)
    return (
        m,
        exp(ma - m) * la + exp(mb - m) * lb,
        exp(ma - m) * ua + exp(mb - m) * ub,
    )

scores = [1000.0, 999.0, 1002.0, 998.0]
values = [2.0, -1.0, 4.0, 3.0]

left = summarize(scores[:2], values[:2])
right = summarize(scores[2:], values[2:])
m, denominator, numerator = merge(left, right)
blocked = numerator / denominator

whole = summarize(scores, values)
direct = whole[2] / whole[1]

print(blocked, direct)
assert abs(blocked - direct) &lt; 1e-12</code></pre>
<p>真实 kernel 会对每个 query 行保存一组状态，并让 <code>u</code> 成为一个 value 向量。二维 tiling、shared memory 容量、寄存器压力和并行划分决定块应该多大；上面的 Python 没有模拟这些硬件细节，但验证了分块归一化的数学接口。</p>
<h2>8. 一套能解释结果的验证顺序</h2>
<p>启用某个 attention backend 后，可以按以下顺序排查：</p>
<ol>
<li><strong>确认语义相同</strong>：causal mask、padding mask、dropout、精度和 head dimension 一致；</li>
<li><strong>确认实际派发</strong>：用框架日志或 profiler 检查命中的 kernel，而不是只看配置名称；</li>
<li><strong>固定形状测算子</strong>：预热后分别测长 Prefill、单请求 Decode 和批量 Decode；</li>
<li><strong>记录峰值显存</strong>：区分权重、KV Cache、attention 工作区和分配器保留量；</li>
<li><strong>回到服务指标</strong>：在相同请求到达模式下测 TTFT、TPOT、goodput 和错误率；</li>
<li><strong>验证输出</strong>：使用允许的数值误差比较 logits 或输出，单独测试极长输入和特殊 mask。</li>
</ol>
<p>如果没有命中预期 kernel，常见原因包括当前 GPU、dtype、head dimension 或 mask 不受该实现支持；框架可能回退到另一条路径。支持矩阵会随版本变化，部署记录里应保存框架版本、attention backend、GPU 型号和实际派发证据。</p>
<p>最终的决策规则很简单：长 Prefill 的显存或 attention IO 是瓶颈时，FlashAttention 值得优先验证；Decode 受权重/KV 带宽、通信或调度支配时，先处理 profile 中占比最大的部分。算法名字本身不能替代瓶颈证据。</p>
<h2>继续阅读</h2>
<ul>
<li><a href="https://www.cztcode.com/2026/5366/">大模型推理加速（二）：KV Cache 到底占多少显存，PagedAttention 又解决了什么</a></li>
<li><a href="https://www.cztcode.com/2026/5360/">吞吐涨了，用户却觉得更卡：连续批处理的延迟账怎么算</a></li>
<li><a href="https://www.cztcode.com/2026/5359/">前缀缓存命中率很高，首字为什么还是慢？</a></li>
</ul>
<hr />
<p><a href="https://www.cztcode.com/ai-content-policy/">本站的内容生成、审核与纠错说明</a></p>
]]></content:encoded>
					
					<wfw:commentRss>https://www.cztcode.com/2026/5368/feed/</wfw:commentRss>
			<slash:comments>1</slash:comments>
		
		
		<post-id xmlns="com-wordpress:feed-additions:1">5368</post-id>	</item>
		<item>
		<title>大模型推理加速（二）：KV Cache 到底占多少显存，PagedAttention 又解决了什么</title>
		<link>https://www.cztcode.com/2026/5366/</link>
					<comments>https://www.cztcode.com/2026/5366/#comments</comments>
		
		<dc:creator><![CDATA[Jellow]]></dc:creator>
		<pubDate>Sun, 06 Sep 2026 01:00:04 +0000</pubDate>
				<category><![CDATA[模型推理加速]]></category>
		<category><![CDATA[GQA]]></category>
		<category><![CDATA[KV Cache]]></category>
		<category><![CDATA[PagedAttention]]></category>
		<category><![CDATA[显存优化]]></category>
		<guid isPermaLink="false">https://www.cztcode.com/2026/5366/</guid>

					<description><![CDATA[从张量形状推导 KV Cache 显存占用，解释 GQA、分页分配与前缀缓存的区别，并给出容量估算代码。]]></description>
										<content:encoded><![CDATA[<div id="bsf_rt_marker"></div><blockquote>
<p><strong>Jellow 编辑发布，AI 辅助生成并完成技术核对。</strong> 计算示例和实测结果会在文中分别说明。</p>
</blockquote>
<p>模型权重明明能放进显卡，为什么并发稍微提高，或者对话变长，显存就不够了？</p>
<p>原因之一是生成过程还需要保存 KV Cache。它随着活跃请求的上下文增长，规模并不由模型参数量单独决定。理解这部分显存，可以帮助我们判断该缩短上下文、减少并发，还是调整缓存管理方式。</p>
<p>本文讨论每层都有标准全注意力、各层 KV 头数和维度相同的模型。滑动窗口、混合注意力、MLA 等结构需要按各自的缓存布局另算。所有容量数字均为公式推导。</p>
<h2>1. 缓存的是历史 key 和 value</h2>
<p>在因果注意力中，历史位置的表示不会因为后面多了一个 token 就重新变化。因此，推理时可以保存每层已计算的 key 和 value，后续步骤追加新位置的缓存。</p>
<p>历史 query 通常不需要保存给后续步骤使用，因为当前输出依赖的是当前 query 与历史 K、V 的交互。每层保存自己的缓存，不能只计算一层的占用。<a href="https://huggingface.co/docs/transformers/v4.57.0/en/cache_explanation" target="_blank" rel="noopener">缓存机制说明</a></p>
<h2>2. 从张量形状推导显存公式</h2>
<p>定义几个量：</p>
<table>
<thead>
<tr>
<th>符号</th>
<th>含义</th>
</tr>
</thead>
<tbody>
<tr>
<td>L</td>
<td>需要缓存的注意力层数</td>
</tr>
<tr>
<td>Hkv</td>
<td>每层 key/value 头数</td>
</tr>
<tr>
<td>D</td>
<td>每个头的维度，假设 K 与 V 相同</td>
</tr>
<tr>
<td>S</td>
<td>所有活跃序列当前缓存的 token 数之和</td>
</tr>
<tr>
<td>b</td>
<td>每个缓存元素的字节数</td>
</tr>
</tbody>
</table>
<p>一个 token 在一层里，需要保存 Hkv × D 个 key 元素和同样多的 value 元素。乘上层数和 token 总数，就得到：</p>
<pre><code class="language-text">KV Cache 字节数 = 2 × L × Hkv × D × S × b</code></pre>
<p>前面的 2 表示 K 和 V。如果 batch 中 B 条序列长度都为 T，则 S = B × T；长度不同时，使用各序列实际长度求和。</p>
<p>这里计算的是逻辑缓存数据量，没有包含块表、对齐、预分配空间、量化参数、工作区以及其他运行时开销。</p>
<p>举一个假设模型：32 层、8 个 KV 头、头维度 128，缓存采用 BF16，每个元素 2 字节。</p>
<pre><code class="language-text">每 token = 2 × 32 × 8 × 128 × 2
          = 131,072 字节
          = 128 KiB

8,192 token 的单条序列 = 1 GiB
16 条这样的活跃序列 = 16 GiB</code></pre>
<p>这里的上下文长度包括已缓存的输入和生成内容。请求还在生成时，缓存也可能继续增加。GB 和 GiB 的换算不同，做容量规划时要统一单位。</p>
<p>下面的 Python 代码可以直接计算：</p>
<pre><code class="language-python">def kv_cache_gib(layers, kv_heads, head_dim, lengths,
                 bytes_per_element=2):
    total_tokens = sum(lengths)
    total_bytes = (
        2 * layers * kv_heads * head_dim
        * total_tokens * bytes_per_element
    )
    return total_bytes / 1024**3

print(kv_cache_gib(32, 8, 128, [8192]))       # 1.0
print(kv_cache_gib(32, 8, 128, [8192] * 16))  # 16.0</code></pre>
<h2>3. 为什么要看 KV 头数，而不是直接用 attention 头数</h2>
<p>MHA 中，每个 query 头通常有对应的 K、V 头。MQA 让多个 query 头共享一组 K、V；GQA 则让若干 query 头共享一组 K、V，处于二者之间。<a href="https://arxiv.org/abs/2305.13245" target="_blank" rel="noopener">GQA 论文</a></p>
<p>如果模型有 32 个 query 头、8 个 KV 头，容量公式里应该代入 8。如果误用 32，估算会大四倍。</p>
<p>同样，权重采用 4 bit 量化，并不意味着 KV Cache 也自动变成 4 bit。权重存储格式与缓存格式是两件事，需要分别检查。比较容量之前，应读取模型配置和实际推理设置。</p>
<h2>4. PagedAttention 处理的是分配问题</h2>
<p>假设为每个请求一次性预留最大上下文长度的连续缓存。短请求会浪费空间，长度变化还可能让分配与复用变得困难。</p>
<p>PagedAttention 把逻辑 KV 序列分成固定 token 数的块，通过映射关系访问物理缓存块。不同逻辑块可以放在不同物理位置，请求增长时再按块分配。它借用了操作系统分页的思路，但不等于每次访问都经过 CPU 虚拟内存换页。<a href="https://arxiv.org/abs/2309.06180" target="_blank" rel="noopener">PagedAttention 论文</a></p>
<p>设每块容纳 16 个 token，一条序列当前有 35 个 token，则需要 3 个块，总容量 48 个 token，最后一块剩余 13 个位置。</p>
<p>进一步假设三条序列分别有 35、17、48 个 token：</p>
<table>
<thead>
<tr>
<th>分配方式</th>
<th>为三条序列分配的 token 容量</th>
</tr>
</thead>
<tbody>
<tr>
<td>每条固定预留 64 个位置</td>
<td>192</td>
</tr>
<tr>
<td>按 16-token 块分配</td>
<td>48 + 32 + 48 = 128</td>
</tr>
<tr>
<td>实际使用</td>
<td>100</td>
</tr>
</tbody>
</table>
<p>这是为解释分配方式构造的例子，不能用来概括所有分配器的浪费比例。分页仍有尾块浪费和管理开销。</p>
<p>实际 attention kernel 需要通过块映射找到相应 K、V，不能把逻辑上不连续的缓存直接当成普通连续数组处理。<a href="https://docs.vllm.ai/en/stable/design/paged_attention/" target="_blank" rel="noopener">vLLM Paged Attention 实现说明</a></p>
<h2>5. 分页和前缀缓存不是同一个功能</h2>
<p>分页回答“缓存放在哪里”，前缀缓存回答“哪些计算已经做过”。</p>
<p>如果多次请求拥有完全相同、可复用的 token 前缀，系统可以复用这部分前缀的 KV Cache，减少重复 Prefill。语义相近不等于缓存相同，模型配置、token 前缀及其他影响状态的条件必须匹配。vLLM 的 APC 文档也强调，这项优化主要减少输入处理时间，不会直接消除后续生成 token 的计算。<a href="https://docs.vllm.ai/en/latest/features/automatic_prefix_caching/" target="_blank" rel="noopener">自动前缀缓存</a></p>
<p>比如同一份长文档被连续提问，文档部分适合放在稳定前缀中。把每次都变化的时间戳放在最前面，可能提前打断可复用前缀。是否值得调整 prompt 结构，应结合真实缓存命中情况判断。</p>
<h2>6. 如何判断这些优化对自己的服务有没有帮助</h2>
<p>可以先做三类对照：相同前缀反复提问、完全不同的输入、混合长短请求。分别记录 Prefill 耗时、输出速度、最大活跃序列数和显存占用。</p>
<p>如果相同前缀的 TTFT 明显改善，而 Decode 变化不大，这是符合预期的结果。如果分页后能容纳更多并发，但单请求生成速度几乎不变，也不能据此判断优化无效：容量利用率和单请求延迟是不同目标。</p>
<p>下一次看到显存不足，可以先算出每条序列的 KV 成本，再查看活跃 token 总量和缓存分配情况。进一步做容量规划时，还要把权重、工作区、安全余量和分页尾块放进同一个预算。</p>
<h2>7. 从显存预算反推服务容量</h2>
<p>只计算单条序列还不够。部署时更实用的问题是：扣除模型权重和运行时开销后，还能放多少 token？</p>
<p>可以先建立一个保守预算：</p>
<pre><code class="language-text">可用于 KV 的字节数
= 显存总量 - 权重 - 固定运行时开销 - 安全余量

逻辑 token 容量
= floor(可用于 KV 的字节数 / 每 token KV 字节数)</code></pre>
<p>假设显存总量为 80 GiB，权重占 14 GiB，算子工作区、CUDA context 等固定预留 6 GiB，再留出 4 GiB 安全余量。上一节的假设模型每 token 使用 128 KiB，那么：</p>
<pre><code class="language-text">可用于 KV = 80 - 14 - 6 - 4 = 56 GiB
token 容量 = 56 × 1024² KiB / 128 KiB
           = 458,752 token</code></pre>
<p>如果活跃请求的平均已缓存长度是 4,000 token，简单相除得到约 114 条序列。这个数只是逻辑上限，不能直接写进并发配置：长度分布有长尾，分页有尾块，调度器可能预留尚未生成的空间，运行时开销也会随 batch 改变。</p>
<p>更稳妥的做法是使用长度分布，而不是只用均值。例如分别计算 P50、P95 的上下文长度，再用请求回放观察容量耗尽时是哪些请求占用了缓存。均值相同的两个负载，若其中一个包含少量超长对话，拒绝率和尾延迟可能完全不同。</p>
<h2>8. 分页能把浪费压到什么范围</h2>
<p>设块大小为 B 个 token，当前有 A 条活跃序列。每条序列只有最后一块可能没有填满，因此仅由尾块造成的未使用位置满足：</p>
<pre><code class="language-text">尾块浪费 token &lt; A × B</code></pre>
<p>更严格地说，每条非空序列最多浪费 B-1 个位置，所以总量不超过 <code>A × (B-1)</code>。乘上每 token 的 KV 字节数，就能得到这部分显存的上界。</p>
<p>这不是整个分配器开销的上界。块表、元数据、预留策略和暂时无法复用的块还会增加消耗。块越小，尾部浪费通常越少，映射表和调度管理却会更细；块越大则相反。因此应该测真实长度分布下的已用块、空闲块和尾块，而不是只比较配置里的 block size。</p>
<p>分页也没有改变逻辑 KV 数据量。上面的 458,752 token 容量不会因为使用 PagedAttention 就凭空翻倍；它能做的是让物理容量更接近逻辑使用量，避免为每条请求按最大长度提前留出连续大块。PagedAttention 论文把这种分配与共享机制作为提升服务容量的核心。<a href="https://arxiv.org/abs/2309.06180" target="_blank" rel="noopener">论文中的内存管理设计</a></p>
<h2>9. 用症状决定下一步，而不是看到 OOM 就开分页</h2>
<table>
<thead>
<tr>
<th>观测到的现象</th>
<th>更可能的原因</th>
<th>优先检查</th>
</tr>
</thead>
<tbody>
<tr>
<td>服务启动就几乎占满显存</td>
<td>权重或固定工作区过大</td>
<td>权重精度、并行切分、加载时峰值</td>
</tr>
<tr>
<td>请求数不多，但长对话后 OOM</td>
<td>逻辑 KV 数据过大</td>
<td>每 token 成本、总活跃 token、上下文上限</td>
</tr>
<tr>
<td>平均长度很短，仍有大量保留空间</td>
<td>预留策略或碎片</td>
<td>物理块利用率、尾块和调度器配置</td>
</tr>
<tr>
<td>相同长前缀反复输入，TTFT 仍高</td>
<td>没有复用 Prefill</td>
<td>前缀缓存命中条件与 prompt 布局</td>
</tr>
<tr>
<td>降低 KV 精度后容量提高但质量异常</td>
<td>缓存量化误差</td>
<td>校准方法、敏感任务和长上下文回归</td>
</tr>
</tbody>
</table>
<p>容量方案至少要同时回答三个问题：逻辑上每 token 需要多少字节，物理分配额外浪费多少，以及业务长度分布会同时保留多少 token。把这三层分开，才能知道分页、缓存量化、缩短上下文或降低并发中的哪一项真正对症。</p>
<h2>继续阅读</h2>
<ul>
<li><a href="https://www.cztcode.com/2026/5360/">吞吐涨了，用户却觉得更卡：连续批处理的延迟账怎么算</a></li>
<li><a href="https://www.cztcode.com/2026/5359/">前缀缓存命中率很高，首字为什么还是慢？</a></li>
<li><a href="https://www.cztcode.com/2026/5358/">4bit 量化为什么不一定更快：权重变小之后，瓶颈去了哪里</a></li>
</ul>
<hr />
<p><a href="https://www.cztcode.com/ai-content-policy/">本站的内容生成、审核与纠错说明</a></p>
]]></content:encoded>
					
					<wfw:commentRss>https://www.cztcode.com/2026/5366/feed/</wfw:commentRss>
			<slash:comments>1</slash:comments>
		
		
		<post-id xmlns="com-wordpress:feed-additions:1">5366</post-id>	</item>
		<item>
		<title>吞吐涨了，用户却觉得更卡：连续批处理的延迟账怎么算</title>
		<link>https://www.cztcode.com/2026/5360/</link>
					<comments>https://www.cztcode.com/2026/5360/#comments</comments>
		
		<dc:creator><![CDATA[Jellow]]></dc:creator>
		<pubDate>Sat, 05 Sep 2026 02:29:55 +0000</pubDate>
				<category><![CDATA[模型推理加速]]></category>
		<category><![CDATA[Chunked Prefill]]></category>
		<category><![CDATA[vLLM]]></category>
		<category><![CDATA[推理加速]]></category>
		<category><![CDATA[连续批处理]]></category>
		<guid isPermaLink="false">https://www.cztcode.com/?p=5360</guid>

					<description><![CDATA[连续批处理提高了吞吐，为什么聊天输出反而更卡？用可运行的调度模型展示分块 Prefill 对首 token 和输出间隔的取舍，辨析闭环压测与尾延迟口径，按满足延迟要求的请求吞吐选择配置。]]></description>
										<content:encoded><![CDATA[<div id="bsf_rt_marker"></div><blockquote>
<p><strong>Jellow 编辑发布，AI 辅助生成并完成技术核对。</strong> 计算示例和实测结果会在文中分别说明。</p>
</blockquote>
<p>同一个模型服务，调大批处理预算后，监控上的 output tokens/s 更高了，聊天界面却开始一顿一顿地输出。两个现象可以同时成立：GPU 每秒产出了更多 token，但某个用户的相邻两次输出隔得更久。</p>
<p>要理解这个取舍，不能只看一场压测的总吞吐。需要把镜头拉近到每次调度：这一轮让谁运行、允许带进来多少新输入、已经开始输出的人要等多久。</p>
<p>本文用一个几十行的调度模型解释连续批处理和分块 Prefill，再讨论怎样把测试目标从“最大 tokens/s”改成“在延迟要求内完成多少请求”。所有耗时都是教学用假设，没有对应的 GPU 实测成绩。</p>
<h2>连续批处理解决的是批次更新问题</h2>
<p>传统的固定批次可以让一组请求一起进入，整组完成后再换下一组。长短输出混在一起时，短请求已经结束，空出来的位置却未必马上能接新请求。</p>
<p>连续批处理把决策点推进到生成迭代之间：一轮结束后，移除已完成的请求，有资源时加入其他请求，再运行下一轮。Orca 的 iteration-level scheduling 是理解这一思路的一手资料；它还提出了 selective batching，不能把整个系统简化成“多加一个循环”。<a href="https://www.usenix.org/conference/osdi22/presentation/yu" target="_blank" rel="noopener">Orca 论文与会议页面</a>说明了两者的分工。</p>
<p>但能在轮次之间换人，不代表一个很长的 Prefill 不会阻塞正在 Decode 的请求。调度器可以在下一轮重新选择，却通常不能任意切开一个已经提交执行的大计算片段。</p>
<p>因此这里有两个不同的问题：</p>
<table>
<thead>
<tr>
<th>机制</th>
<th>主要改变的事情</th>
</tr>
</thead>
<tbody>
<tr>
<td>连续批处理</td>
<td>一轮与下一轮之间，哪些请求留在批次里</td>
</tr>
<tr>
<td>分块 Prefill</td>
<td>长输入在某轮最多推进多少 token</td>
</tr>
</tbody>
</table>
<p>两者可以配合，但开启前者不会自动证明后者的预算适合当前负载。</p>
<h2>把一次长 Prefill 放进时间轴</h2>
<p>设想已有两条请求持续 Decode，观察期间它们都不会结束。现在来了一条含 4096 个输入 token 的新请求。</p>
<p>为了只看调度取舍，作如下假设：现有两条请求每轮的 Decode 总成本固定为 4 ms；新请求完整 Prefill 的成本为 64 ms，暂按输入 token 线性分摊；每轮另有 0.5 ms 固定开销。各项顺序执行、不重叠，已有请求的下一次输出统一在轮末交付。</p>
<p>如果一轮允许处理 c 个 Prefill token，则：</p>
<pre><code class="language-text">单轮耗时 = 4 + 64 × c / 4096 + 0.5   ms</code></pre>
<p>最后一轮按实际剩余 token 数计算。在此模型里，已有请求的输出间隔就是这一轮耗时，新请求的 Prefill 完成时间则是这些轮次耗时之和。</p>
<table>
<thead>
<tr>
<th>每轮 Prefill token 上限</th>
<th style="text-align: right">需要轮数</th>
<th style="text-align: right">已有请求最大输出间隔</th>
<th style="text-align: right">新请求 Prefill 完成时间</th>
</tr>
</thead>
<tbody>
<tr>
<td>4096</td>
<td style="text-align: right">1</td>
<td style="text-align: right">68.5 ms</td>
<td style="text-align: right">68.5 ms</td>
</tr>
<tr>
<td>1024</td>
<td style="text-align: right">4</td>
<td style="text-align: right">20.5 ms</td>
<td style="text-align: right">82.0 ms</td>
</tr>
<tr>
<td>256</td>
<td style="text-align: right">16</td>
<td style="text-align: right">8.5 ms</td>
<td style="text-align: right">136.0 ms</td>
</tr>
</tbody>
</table>
<p>把一整块拆成四块后，已有用户等待下一次输出的最长间隔大幅缩短；新请求的 Prefill 却从 68.5 ms 后完成变成 82 ms 后完成。拆到十六块，已有请求更平滑，新请求还要等更久。</p>
<p>差出来的时间并不全是浪费。现有两条请求在更多轮次里继续生成了 token，只是这些工作让新请求更晚完成 Prefill。固定的每轮开销也付了更多次。</p>
<p>这里特意写“Prefill 完成时间”，没有把它直接叫 TTFT。真实首 token 延迟还包含排队、输出头、采样与回传等阶段；这个模型也没有模拟混合 batch 的矩阵乘效率、KV 随生成增长的开销或 CPU/GPU 流水重叠。</p>
<h2>运行一下，再改变假设</h2>
<p>下面代码只用 Python 标准库。把 <code>chunk_tokens</code> 改成不能整除 4096 的数，也可以检查最后一轮是否按剩余输入计时。</p>
<pre><code class="language-python">from math import ceil

def simulate(chunk_tokens, prompt_tokens=4096, decode_ms=4.0,
             full_prefill_ms=64.0, overhead_ms=0.5):
    if chunk_tokens &lt;= 0 or prompt_tokens &lt;= 0:
        raise ValueError(&quot;token counts must be positive&quot;)
    remaining = prompt_tokens
    now = 0.0
    gaps = []
    while remaining:
        take = min(chunk_tokens, remaining)
        prefill_ms = full_prefill_ms * take / prompt_tokens
        step_ms = decode_ms + prefill_ms + overhead_ms
        now += step_ms
        gaps.append(step_ms)  # 已有 Decode 请求每轮末收到一个 token
        remaining -= take
    return len(gaps), max(gaps), now

print(&quot;chunk rounds max_gap_ms prefill_done_ms&quot;)
for chunk in (4096, 1024, 256):
    rounds, gap, done = simulate(chunk)
    print(f&quot;{chunk:5d} {rounds:6d} {gap:10.1f} {done:15.1f}&quot;)

rounds, gap, done = simulate(1000)
assert rounds == ceil(4096 / 1000)
assert done == 64 + rounds * (4 + 0.5)
print(&quot;chunk_1000:&quot;, rounds, gap, done)</code></pre>
<p>最后一行得到 <code>5、20.125、86.5</code>。五轮总 Prefill 工作仍为 64 ms，外加五次 Decode 和固定开销。读者可以把 <code>decode_ms</code> 或 <code>overhead_ms</code> 调高，观察继续缩小块长为什么会越来越昂贵。</p>
<p>真实服务里，Prefill 时间并非严格随 chunk token 数线性增长。后面的 chunk 要关注更长的历史，不同形状也会触发不同执行效率。所以这段代码适合解释因果关系，不适合据此宣布某个具体 token 预算最优。</p>
<h2>在 vLLM 中，token 预算不是请求数</h2>
<p>vLLM 的调优文档描述了分块 Prefill 下优先安排 Decode，再用剩余 token 预算安排 Prefill 的策略；放不下的长输入可以拆分处理。文档也指出，较小预算通常更有利于输出间隔，较大预算有利于推进 Prefill。<a href="https://docs.vllm.ai/en/stable/configuration/optimization/#chunked-prefill" target="_blank" rel="noopener">vLLM 调优说明</a>可以用来核对所用版本的实际行为。</p>
<p>这给出了调参方向，但没有给所有模型一个通用答案。尤其要区分 <code>max_num_batched_tokens</code> 和 <code>max_num_seqs</code>：前者约束一轮可调度的 token 数，后者限制一轮可处理的序列数，具体定义见<a href="https://docs.vllm.ai/en/v0.25.1/configuration/engine_args/" target="_blank" rel="noopener">引擎参数文档</a>。一个长 Prefill 请求就可能消耗很多 token 预算；普通自回归 Decode 则通常每条序列每轮推进一个 token。</p>
<p>因此，本文模拟里的“每轮 Prefill 上限 1024”，不等于把实际引擎的总 token 预算直接设成 1024。后者还要容纳 Decode token，并受序列数、KV 空间和调度细节共同约束；推测解码、多模态输入等也会改变预算的解释。</p>
<p>比较时应记录实际引擎版本和完整启动参数。先固定序列数上限，只改变 token 预算，观察 Prefill/Decode 混合负载；不要同时改量化、并行度和请求长度，否则很难知道收益来自哪里。</p>
<h2>两种压测，回答的是不同问题</h2>
<p>固定并发压测常见的做法是：始终维持 N 个在途请求，完成一个再补一个。忽略思考时间，处于稳定状态时，到达率近似满足：</p>
<pre><code class="language-text">到达率 ≈ N / 平均完整请求耗时</code></pre>
<p>假设 N 为 32，平均请求耗时是 2 秒，对应约 16 请求/秒。若服务变慢到 4 秒，压测工具实际补充请求的速率也降到约 8 请求/秒。这个闭环模型适合测固定在途负载，但客户端会随着服务变慢自动减速，不能单凭它证明服务能承受固定的外部到达率。</p>
<p>如果用户请求本来按独立节奏到来，服务变慢时，新请求不会自动减半。此时应补一场固定到达负载的测试，保留到达时刻、实际发出时刻和完成时刻，确认压测机或客户端并发上限没有悄悄改变计划。</p>
<p>两场测试都要保持输入与输出长度分布、采样参数和缓存冷热条件可比。某组输出更短，可能看起来请求完成更快；某组长请求超时后被丢出统计，尾延迟也可能虚假地变好。错误和超时必须单独记录，并保留在请求总数的分母里。</p>
<h2>平均输出间隔，会藏起哪种停顿</h2>
<p>假设一条回答有 100 个相邻 token 间隔，其中 99 个间隔是 10 ms，另一个是 500 ms。平均值为 14.9 ms，看上去并不惊人，但用户会清楚感受到中间停了半秒。</p>
<p>若采用最近秩定义，这 100 个间隔的 p99 还是 10 ms：排序后的第 99 个值仍在那 99 个短间隔里。采用插值的分位数实现可能得到别的数，因此连“p99”也必须带上统计口径。</p>
<p>更需要注意的是采样单位。一条请求的平均每 token 时间、所有流式响应间隔的分布、每条请求最大停顿的分布，是三组不同的数据。很多协议一次会回传多个 token，网络缓冲又会改变客户端观察到的间隔，不能把流式 chunk 不加区分地当成一个 token。</p>
<p>例如 GenAI-Perf 的指标定义会把相邻响应时间除以后一个响应中的生成 token 数，用于计算 ITL；它并不等于逐个 token 在 GPU 上完成的精确时间。<a href="https://github.com/triton-inference-server/perf_analyzer/blob/main/genai-perf/README.md#metrics" target="_blank" rel="noopener">其官方指标表</a>可用来理解口径。此处引用定义，不要求读者选用某个压测工具。</p>
<p>对交互服务，除了 TTFT 和常规 ITL，可以额外记录每条请求最大的客户端停顿、超过某个停顿阈值的请求比例，并把网络传输层和引擎内部耗时放在一起排查。</p>
<h2>用达标请求数决定配置</h2>
<p>先把服务要求写清楚。比如人为设定：首 token 在 1 秒内到达、整条回答里最长输出停顿不超过 100 ms，且请求成功完成。阈值只是例子，应由实际产品体验决定。</p>
<p>将一个完整测试批次从开始发送到全部结束或超时的时间定义为 T，则这场测试的达标请求吞吐可以定义为：</p>
<pre><code class="language-text">goodput = 同时满足全部条件的请求数 / T</code></pre>
<p>比较不同配置时，用同一套计时边界、超时规则和请求序列；如果采用稳态窗口，还要说明跨窗口请求如何归属。不要把达标比例、请求吞吐和输出 token 吞吐混成一个数。</p>
<p>例如同样 100 秒的两个假设测试，A 完成 1000 个请求，只有 600 个达标；B 完成 900 个请求，有 810 个达标。A 的完成吞吐更高，goodput 却是 6 请求/秒，低于 B 的 8.1 请求/秒。此时继续追求 A 的最大吞吐，很可能背离用户需要。</p>
<p>实际调参可以从一组保守配置开始，在相同到达负载下逐档增加 token 预算。每档同时记录 TTFT、停顿分布、goodput、失败率和 KV 压力。已有用户停顿超标时，检查是否被长 Prefill 拉长了轮次；首 token 太慢而 Decode 很平稳时，再看是否预算过小或等待过久。</p>
<p>如果两类延迟都随负载恶化，缩放 chunk 未必能解决总容量不足。它决定有限资源如何分配，不能凭空增加资源。此时需要判断是否该降低接入负载、增加副本，或重新安排不同服务等级的请求。</p>
<p>一个值得保留的配置，应当能解释它让谁更快、让谁多等，以及在什么负载下开始越过延迟要求。这样得到的吞吐数字，才与聊天窗口里的体验连得上。</p>
<hr />
<p>技术资料核对日期：2026-09-05。示例为 CPU 上运行的调度模型与手算数据，不是 vLLM 内部调度器的复刻，也不是 GPU 基准测试。</p>
<p><a href="https://www.cztcode.com/llm-inference-acceleration/">模型推理加速专题</a> · <a href="https://www.cztcode.com/2026/5348/">先分清 Prefill、Decode 和指标</a> · <a href="https://www.cztcode.com/ai-content-policy/">内容生成、审核与纠错说明</a></p>
]]></content:encoded>
					
					<wfw:commentRss>https://www.cztcode.com/2026/5360/feed/</wfw:commentRss>
			<slash:comments>1</slash:comments>
		
		
		<post-id xmlns="com-wordpress:feed-additions:1">5360</post-id>	</item>
		<item>
		<title>前缀缓存命中率很高，首字为什么还是慢？</title>
		<link>https://www.cztcode.com/2026/5359/</link>
					<comments>https://www.cztcode.com/2026/5359/#respond</comments>
		
		<dc:creator><![CDATA[Jellow]]></dc:creator>
		<pubDate>Sat, 05 Sep 2026 02:29:54 +0000</pubDate>
				<category><![CDATA[模型推理加速]]></category>
		<category><![CDATA[vLLM]]></category>
		<category><![CDATA[前缀缓存]]></category>
		<category><![CDATA[性能分析]]></category>
		<category><![CDATA[推理加速]]></category>
		<guid isPermaLink="false">https://www.cztcode.com/?p=5359</guid>

					<description><![CDATA[前缀缓存命中率达到 90%，首 token 仍然慢，问题可能出在统计口径和排队。用一组可复算请求区分命中次数、token 复用率与计算节省，再推导缓存后剩余注意力的成本，给出真实流量下的对照方法。]]></description>
										<content:encoded><![CDATA[<div id="bsf_rt_marker"></div><blockquote>
<p><strong>Jellow 编辑发布，AI 辅助生成并完成技术核对。</strong> 计算示例和实测结果会在文中分别说明。</p>
</blockquote>
<p>假设一项问答服务打开了前缀缓存，监控上显示 90% 的请求命中，用户却仍要等很久才看到回答。缓存是不是没起作用？</p>
<p>还不能这么判断。“有多少请求碰到了缓存”没有回答两个更关键的问题：省掉了多少工作，以及这份工作原本占等待时间的多少。</p>
<p>本文沿着这两个问题展开。讨论范围是完整因果注意力模型、可复用的前缀 KV，以及普通自回归生成。滑动窗口、混合注意力和跨节点缓存传输都有额外条件，不能直接照搬这里的块数和成本估计。</p>
<h2>命中的是 token 前缀，不是相似的问题</h2>
<p>前缀缓存保存已经计算过的 KV。新请求如果使用相同前缀，可以跳过相应的 Prefill。它不会因为两句话意思接近，就把一句话的 KV 交给另一句话使用。<a href="https://docs.vllm.ai/en/stable/features/automatic_prefix_caching/" target="_blank" rel="noopener">vLLM 的 APC 功能说明</a>明确把收益定位在共享前缀的计算复用，而非后续生成过程的直接提速。</p>
<p>判断是否相同，应看 chat template、tokenizer 等处理之后真正送进模型的输入。肉眼看起来一样的正文，如果模板、分隔符或前面的内容不同，最终前缀可能不同。</p>
<p>在 vLLM 的哈希块设计中，一个块的身份还依赖前面的块，并包含必要的额外标识，例如 LoRA、多模态输入或缓存隔离信息；该设计只缓存完整块。<a href="https://docs.vllm.ai/en/stable/design/prefix_caching/" target="_blank" rel="noopener">官方设计文档</a>给出了这些条件。</p>
<p>由此可以推导一个很实用的排查顺序：先确认输入序列和运行上下文真的可复用，再讨论缓存是否足够大。如果一个会变化的时间戳被拼在提示词最前面，后面几千 token 的稳定说明也不能作为相同前缀直接命中。</p>
<p>对于适合调整的业务模板，可以把稳定说明、固定文档放在前面，把本次问题放在后面。但不要只为命中率重排会改变含义的指令，也不要把不同权限或不同租户的数据混在一个共享范围里。模板改动之后仍要检查回答质量。</p>
<p>完整块还有一个取整问题。假设块长为 16 token，两次请求共享的前缀长 1000 token，那么完整共享块覆盖的是：</p>
<pre><code class="language-text">floor(1000 / 16) × 16 = 992 token</code></pre>
<p>这里的 16 是示例参数，不是对所有后端默认值的声明。实际可复用量还受块是否仍驻留、运行配置是否兼容等条件影响。另外，<a href="https://github.com/vllm-project/vllm/blob/main/vllm/v1/core/kv_cache_manager.py" target="_blank" rel="noopener">vLLM 的 KV 管理代码</a>说明，为获得最后位置的 logits，完整命中时仍需重算；块对齐还可能扩大重算范围。因此不能据此断言 Prefill 耗时必为零。</p>
<h2>90% 命中，可能只省了 1.54% 的输入 token</h2>
<p>为避免与某个框架的监控命名混淆，先自行定义两个统计口径。第 i 个请求的输入长度为 nᵢ，实际复用长度为 hᵢ：</p>
<pre><code class="language-text">请求命中率 = hᵢ &gt; 0 的请求数 / 总请求数
token 复用率 = Σhᵢ / Σnᵢ</code></pre>
<p>有些监控指标按块查询次数计数，既不是第一个，也不是第二个。看到 <code>hit_rate</code> 时，先查分子、分母和统计窗口。</p>
<p>构造 100 个请求：</p>
<table>
<thead>
<tr>
<th>请求组</th>
<th style="text-align: right">数量</th>
<th style="text-align: right">每次输入长度</th>
<th style="text-align: right">每次实际复用长度</th>
</tr>
</thead>
<tbody>
<tr>
<td>短问答</td>
<td style="text-align: right">90</td>
<td style="text-align: right">128 token</td>
<td style="text-align: right">16 token</td>
</tr>
<tr>
<td>长文档</td>
<td style="text-align: right">10</td>
<td style="text-align: right">8192 token</td>
<td style="text-align: right">0 token</td>
</tr>
</tbody>
</table>
<p>请求命中率的确为 90%。但总输入有 93,440 token，实际只复用了 1,440 token，token 复用率约为 1.54%。</p>
<p>这并不矛盾。大量短请求各碰到一个小前缀，就足以让“命中的请求数”很好看；真正占输入大头的长请求可能一次都没复用。</p>
<p>如果那 10% 未命中的长请求又恰好最慢，整体 p95 仍可能落在它们当中。缓存帮助了多数请求，也不保证最慢一段用户的体验同步改善。这里应按长度和是否命中分组看延迟分布，不能对几组 p95 做加权平均来拼出整体 p95。</p>
<h2>token 复用率也不等于计算节省比例</h2>
<p>即使知道复用了多少 token，仍然少了一步：原来每个 token 要做的工作并不完全相同。</p>
<p>对长度为 n 的完整因果注意力，注意力中有效的 query-key 位置对数量是 <code>n(n+1)/2</code>。为了区分随 token 数增长的工作和随位置对增长的工作，构造一个成本模型：</p>
<pre><code class="language-text">C(n) = a × n + b × n(n+1)/2</code></pre>
<p>a 汇总模型各层中按 token 线性增长的工作，b 表示每个有效注意力位置对的相对成本。它们是简化系数，不是通用硬件常数；矩阵形状、算子分块和 GPU 利用率都会让真实时间偏离这条曲线。</p>
<p>若前 h 个 token 已经缓存，后面还剩 m 个，其中 <code>m=n-h</code>。新 token 仍然需要关注前面的 h 个位置，所以剩余成本应为：</p>
<pre><code class="language-text">Cremaining = a × m + b × [m × h + m(m+1)/2]
           = C(n) - C(h)</code></pre>
<p>不能写成 <code>C(n-h)</code>。那会漏掉后缀对已有前缀的注意力。缓存省掉的是前缀自身的计算，不是从注意力视野中删除这段上下文。</p>
<p>因此，假如缓存了 8192 token 输入的一半，在这个模型里线性部分省掉一半，而注意力位置对只省掉约四分之一。最终时间能省多少，还取决于两部分的比例和执行效率。这也解释了为什么“命中了 50% token”不应直接写成“Prefill 快了一倍”。</p>
<p>下面把前面的 100 请求例子完整算一遍。</p>
<pre><code class="language-python">requests = [(128, 16)] * 90 + [(8192, 0)] * 10

def cost(n, a=1.0, b=1.0 / 1024):
    # 人为选择的成本单位，不是微秒或 GPU FLOPs。
    return a * n + b * n * (n + 1) / 2

total_tokens = sum(n for n, h in requests)
reused_tokens = sum(h for n, h in requests)
request_hits = sum(h &gt; 0 for n, h in requests)
uncached_work = sum(cost(n) for n, h in requests)
saved_work = sum(cost(h) for n, h in requests)

print(f"request_hit_pct={100 * request_hits / len(requests):.2f}")
print(f"token_reuse_pct={100 * reused_tokens / total_tokens:.2f}")
print(f"model_work_saved_pct={100 * saved_work / uncached_work:.3f}")

n, h = 8192, 4096
m = n - h
remaining = m + (m * h + m * (m + 1) / 2) / 1024
print("remaining_work:", remaining)
print("incorrect_suffix_only:", cost(m))
assert remaining == cost(n) - cost(h)
assert remaining &gt; cost(m)</code></pre>
<p>运行后，三个比例依次为 <code>90.00%</code>、<code>1.54%</code>、<code>0.344%</code>；后两行工作量为 <code>28674.0</code> 和 <code>12290.0</code>。示例中长输入占据大量注意力工作，却完全未命中，按这个成本模型算出的收益便比 token 复用率还小。</p>
<p>这些数字只验证推导。改变 a、b 或请求长度分布，结论的具体幅度会变；是否值得部署，仍要测真实 Prefill 时间。</p>
<h2>首 token 的账里，还有排队</h2>
<p>用户感知的 TTFT，是发出请求到收到第一个有效输出之间的时间。为了诊断，可以把关键路径概念性地拆成：</p>
<pre><code class="language-text">TTFT ≈ 输入处理与网络 + 排队 + 缓存查找/必要传输
       + 剩余 Prefill + 输出头与采样 + 首次回传</code></pre>
<p>真实系统中一些阶段可以重叠，trace 里的字段也可能包含彼此；相加之前要先对齐口径。对 Prefill、Decode 与客户端指标不熟悉，可以结合<a href="https://www.cztcode.com/2026/5348/">第一篇的指标说明</a>阅读。</p>
<p>假设一次请求排队 800 ms，Prefill 为 200 ms，其他开销暂记为零。缓存把 Prefill 降到 20 ms，而排队没有变化，那么 TTFT 从 1000 ms 变成 820 ms，只提升约 1.22 倍。</p>
<p>这不意味着缓存最多只有这点作用。如果缓存减少了全站计算压力，排队也可能随之下降。上面的例子特意固定排队，只是为了证明：<strong>单请求省下的计算时间，与整个服务最终少等的时间，需要分别测量。</strong></p>
<p>多副本部署还会带来另一个取舍。假设副本 A 有缓存，但预计要排队 600 ms、剩余 Prefill 40 ms；副本 B 没有缓存，只排队 20 ms、完整 Prefill 200 ms。忽略其他开销，A 是 640 ms，B 是 220 ms。此时只按“去有缓存的副本”路由，会选错。</p>
<p>这也是一个假设案例，不是在推荐具体路由算法。它给出的决策条件是：缓存省掉的计算与传输，是否大于额外排队和路由成本。热点前缀的局部命中率再漂亮，也需要放回这个比较中。</p>
<h2>怎样做一次不会高估收益的实验</h2>
<p>先准备一份固定请求序列，保留真实的输入长度、共享前缀长度和到达顺序。只反复发送同一个长提示词，测到的是近乎理想的复用条件；它可以验证功能，却不能代表生产收益。</p>
<p>用同样的到达负载做三个对照：缓存关闭、缓存开启但从空状态开始、缓存开启且经过符合真实流量的预热。记录预热请求，但与正式窗口分开计时。不要用提前知道未来全部请求的方式构造“完美缓存”，再把结果当成正常服务表现。</p>
<p>每次请求至少关联这些数据：</p>
<table>
<thead>
<tr>
<th>数据</th>
<th>要回答的问题</th>
</tr>
</thead>
<tbody>
<tr>
<td>输入 token 数、实际复用 token 数</td>
<td>命中的是短前缀，还是主要计算部分</td>
</tr>
<tr>
<td>副本、模型与模板版本、前缀分组标识</td>
<td>是否因为输入或路由变化失去复用</td>
</tr>
<tr>
<td>排队时间、Prefill 时间、客户端 TTFT</td>
<td>收益出现在哪一段，是否被其他等待抵消</td>
</tr>
<tr>
<td>缓存容量压力、淘汰或重算情况</td>
<td>缓存是否在真实混合流量下仍能留住热点</td>
</tr>
<tr>
<td>错误、超时和请求到达率</td>
<td>是否通过少接请求或剔除失败美化了结果</td>
</tr>
</tbody>
</table>
<p>缓存分组可用内部标识关联，通常不必把完整用户提示词写进性能日志。</p>
<p>当结果不理想时，顺序也很重要。复用 token 本来就少，先检查模板和流量结构；复用多但 Prefill 没降，检查实际是否重算以及成本模型是否适合该架构；Prefill 已明显下降而 TTFT 不动，再看队列、路由、传输和客户端缓冲。</p>
<p>缓存优化最终应该交付一件具体的东西：在相同请求到达条件下，哪些请求的等待下降了多少，哪些仍然没有受益。命中率是解释这个结果的线索，不是结果本身。</p>
<hr />
<p>技术资料核对日期：2026-09-05。代码在 CPU 上验证统计口径和成本恒等式；文中耗时均为假设示例，未进行 GPU 服务实测。</p>
<p><a href="https://www.cztcode.com/llm-inference-acceleration/">模型推理加速专题</a> · <a href="https://www.cztcode.com/ai-content-policy/">内容生成、审核与纠错说明</a></p>
]]></content:encoded>
					
					<wfw:commentRss>https://www.cztcode.com/2026/5359/feed/</wfw:commentRss>
			<slash:comments>0</slash:comments>
		
		
		<post-id xmlns="com-wordpress:feed-additions:1">5359</post-id>	</item>
		<item>
		<title>4bit 量化为什么不一定更快：权重变小之后，瓶颈去了哪里</title>
		<link>https://www.cztcode.com/2026/5358/</link>
					<comments>https://www.cztcode.com/2026/5358/#respond</comments>
		
		<dc:creator><![CDATA[Jellow]]></dc:creator>
		<pubDate>Sat, 05 Sep 2026 02:29:52 +0000</pubDate>
				<category><![CDATA[模型推理加速]]></category>
		<category><![CDATA[KV Cache]]></category>
		<category><![CDATA[性能分析]]></category>
		<category><![CDATA[推理加速]]></category>
		<category><![CDATA[模型量化]]></category>
		<guid isPermaLink="false">https://www.cztcode.com/?p=5358</guid>

					<description><![CDATA[4bit 权重量化省下了显存，为什么生成速度没有同比提升？从权重与 KV 读取量出发，推导不同批次下的收益边界，用可运行代码区分访存、计算和反量化开销，再设计固定负载与容量两组对照实验。]]></description>
										<content:encoded><![CDATA[<div id="bsf_rt_marker"></div><blockquote>
<p><strong>Jellow 编辑发布，AI 辅助生成并完成技术核对。</strong> 计算示例和实测结果会在文中分别说明。</p>
</blockquote>
<p>把 BF16 模型换成 4bit，显存占用明显降了，生成速度却只涨了一点。该怀疑量化方案，还是推理框架？</p>
<p>先别急着换框架。模型文件的大小、一次前向需要搬运的数据量、GPU 完成这些工作的时间，是三件不同的事。量化直接改变其中一部分，剩下的部分可能很快成为主要开销。</p>
<p>这篇文章只讨论稠密自回归模型的权重量化，重点是 W4A16 的 Decode 阶段。这里 W 表示权重精度，A 表示激活精度；不把原生低精度计算、KV Cache 量化、MoE 或跨卡通信混到同一个加速数字里。Prefill 与 Decode 的基本区别，可以先看<a href="https://www.cztcode.com/2026/5348/">系列第一篇</a>。</p>
<h2>4bit 描述的是哪一段数据</h2>
<p>W4A16 的含义不是“整个模型用 4bit 计算”。以 TensorRT-LLM 文档中的 weight-only 路径为例，权重以低位宽存储，线性层使用时进行反量化，激活仍为 FP16 或 BF16。<a href="https://nvidia.github.io/TensorRT-LLM/reference/precision.html" target="_blank" rel="noopener">官方精度说明</a>把 W4A16 与其他精度方案分开列出。</p>
<p>只算权重存储，也未必能从 16bit 精确降到四分之一。假设原权重占 W 字节，其中比例 f 被量化；每 G 个被量化的权重共用一个 16bit scale，暂不考虑 zero-point、对齐和其他元数据，那么：</p>
<pre><code class="language-text">量化后权重 / 原权重 ≈ (1 - f) + f / 4 + f / G</code></pre>
<p>最后一项来自 scale：原来每个权重是 2 字节，一组 G 个权重额外保存 2 字节，所以相对于原权重的比例是 <code>f / G</code>。</p>
<p>取 <code>f = 0.9，G = 128</code>，结果约为 0.332。这个假想格式的压缩比约 3.01 倍，尚未计入工作区、激活、KV Cache 和分配器预留。它说明为什么“4bit 模型”不足以推算进程总显存；实际数字要看格式和加载结果。</p>
<h2>Decode 每一步，还要读什么</h2>
<p>考虑一个简化场景：单卡、稠密模型、完整因果注意力，一个 Decode 批次包含 B 条序列，每条生成一个 token。忽略片上缓存复用差异，假设批内共享一次权重读取，历史 KV 各读一次。</p>
<p>这一步的主要读取量可以粗估为：</p>
<pre><code class="language-text">读取量 ≈ W + B × K</code></pre>
<p>W 是整次前向需要读取的权重字节数；K 是一条序列当前历史 KV 的总字节数。式子省略了激活读写、新 KV 写入、scale 读取以及其他算子流量，因此只是定位瓶颈的模型，不能直接当作 profiler 结果。</p>
<p>对使用常规 K、V 布局的完整注意力，单条序列每个历史 token 的 KV 大小为：</p>
<pre><code class="language-text">c = 2 × L × Hkv × D × bytes_per_element
K = S × c</code></pre>
<p>其中 2 对应 K 和 V，L 为层数，Hkv 为 KV 头数，D 为头维度，S 为上下文长度。假设 <code>L=32、Hkv=8、D=128</code>，KV 使用 2 字节精度，则 <code>c=128 KiB/token</code>。当 <code>S=8192</code> 时，每条序列的历史 KV 就有 1 GiB。</p>
<p>这一步没有改变 KV 精度。权重量化之后，这 1 GiB 仍然在。</p>
<p>下面另设一组便于心算的权重数字：BF16 路径每步读取 14 GiB，量化路径连同相关元数据读取 4 GiB。<strong>这两个数是独立的假设输入，不是上面 f、G 示例的测量结果。</strong> 假设两条路径的有效带宽均为 1 TiB/s，暂时忽略计算和额外开销：</p>
<table>
<thead>
<tr>
<th>Decode 批次 B</th>
<th style="text-align: right">BF16 读取时间</th>
<th style="text-align: right">量化读取时间</th>
<th style="text-align: right">仅按读取估计的加速比</th>
</tr>
</thead>
<tbody>
<tr>
<td>1</td>
<td style="text-align: right">14.65 ms</td>
<td style="text-align: right">4.88 ms</td>
<td style="text-align: right">3.00×</td>
</tr>
<tr>
<td>8</td>
<td style="text-align: right">21.48 ms</td>
<td style="text-align: right">11.72 ms</td>
<td style="text-align: right">1.83×</td>
</tr>
<tr>
<td>32</td>
<td style="text-align: right">44.92 ms</td>
<td style="text-align: right">35.16 ms</td>
<td style="text-align: right">1.28×</td>
</tr>
</tbody>
</table>
<p>所有数字都是计算示例。两条路径各有相同的 B，不是在拿单请求和高并发互相比。</p>
<p>加速比来自：</p>
<pre><code class="language-text">Sread = (W16 + B × K) / (W4 + B × K)</code></pre>
<p>当 <code>B × K</code> 越来越大，分子和分母里相同的部分占比升高，加速比便趋近 1。反过来，如果上下文很短、批次很小，权重流量占主导，权重量化就更值得优先验证。</p>
<p>还有一个容易误读的地方：B 增加时，表中的一步耗时也增加，但一步产出了 B 个 token。系统吞吐应按 <code>B / step_time</code> 算，不能只看一步越来越慢就认定总吞吐下降；同样，总吞吐增加也不意味着每个用户的输出间隔更短。</p>
<h2>把反量化和计算放回去</h2>
<p>TensorRT 的 weight-only 实现会读取 4bit 权重，再反量化并做高精度点积。低位宽存储因此不自动等于同倍数的算术加速。<a href="https://docs.nvidia.com/deeplearning/tensorrt/11.2.1/inference-library/quantized-types-explicit-quantization.html#weight-only-quantization" target="_blank" rel="noopener">TensorRT 11.2.1 的实现说明</a>直接描述了这条执行路径。</p>
<p>用一个粗略的 roofline 视角，可以把单步时间写成：</p>
<pre><code class="language-text">Tstep ≳ max(实际搬运字节 / 有效带宽, 实际运算量 / 有效算力)</code></pre>
<p>这不是把整个模型当成一个能完美重叠的大算子。真实前向由许多有依赖的算子组成，还要考虑调度、同步和启动开销。它的用处是提醒我们：降低字节数，只压低了其中一个约束。</p>
<p>反量化也不能机械地当成一段独立时间加上去。有的实现把它融合到矩阵乘里，与访存或计算部分重叠；有的形状却会因解包、寄存器压力或低效内核丢掉收益。应该看实际执行的 kernel 和时间分布。</p>
<p>为了估算余量，可以暂时再作一个更强的假设：两条路径都只受带宽限制，而且量化路径多出一段<strong>无法重叠的额外耗时 Δ</strong>。那么量化更快的条件是：</p>
<pre><code class="language-text">Δ &lt; (W16 - W4) / BW</code></pre>
<p>在上表的假设下，右边约为 9.77 ms。超过它，少搬权重省出的时间就被抵消。这个阈值与 B 无关，是因为模型假设两条路径的 KV 流量、带宽完全相同；实际内核一变，这个简化条件也要重算。</p>
<h2>一个可以自己改参数的计算器</h2>
<p>下面的 Python 只依赖标准库，保存后直接运行。它不调用 GPU，也不预测某张显卡的性能。<code>compute_floor_ms</code> 和 <code>extra_ms</code> 是留给读者探索边界的假设参数，不能不经测量就填成“真实开销”。</p>
<pre><code class="language-python">GiB = 2**30
TiB = 2**40

def step_ms(weight_gib, batch, kv_gib=1.0, bandwidth=TiB,
            compute_floor_ms=0.0, extra_ms=0.0):
    read_ms = (weight_gib + batch * kv_gib) * GiB / bandwidth * 1000
    return max(read_ms, compute_floor_ms) + extra_ms

print("B  bf16_ms  w4_ms  speedup")
for batch in (1, 8, 32):
    bf16 = step_ms(14, batch)
    w4 = step_ms(4, batch)
    print(f"{batch:2d} {bf16:8.2f} {w4:6.2f} {bf16 / w4:7.2f}")

# 人为加入 12 ms 无法重叠的额外开销，构造更慢的反例。
print("extra_12ms_speedup:", round(step_ms(14, 1) /
      step_ms(4, 1, extra_ms=12), 3))

# 同样的 50 ms 计算约束主导两条路径：搬运减少也不改变结果。
print("compute_bound_speedup:", step_ms(14, 1, compute_floor_ms=50) /
      step_ms(4, 1, compute_floor_ms=50))</code></pre>
<p>最后两行分别得到约 <code>0.868</code> 和 <code>1.0</code>。前者是额外开销吃掉收益的反例，后者是计算约束盖住访存收益的反例。这两种情况都不需要“量化失败”才能发生。</p>
<p>长输入 Prefill 应单独测量。一次处理更多 token 会改变矩阵形状和权重复用程度；Decode 上得到的比例，没有理由原样搬到 Prefill。</p>
<h2>实际选型时，把两场实验分开做</h2>
<p>第一场实验固定工作负载，回答“相同工作是不是更快”。使用同一模型来源、tokenizer、输入 token、输出长度设置和 KV 精度，在同一 GPU 上比较 BF16 与 W4A16。保持推理框架、注意力实现等尽可能一致，记录无法一致的部分；固定一个可容纳的批次后，再分别测短、长上下文。单独记录 Prefill、Decode、端到端延迟，以及真正执行的 kernel。</p>
<p>第二场实验允许利用量化省下的显存，回答“同一硬件能服务多少人”。逐步增加负载，比较满足首 token 延迟和输出间隔要求的吞吐。此时更高吞吐可能来自容纳了更多 KV，而不是每个请求本身更快。报告里应把这个原因写出来。</p>
<p>两场实验都需要先过质量门槛。位宽和误差之间没有一个适用于所有模型、所有任务的固定换算。<a href="https://arxiv.org/abs/2306.00978" target="_blank" rel="noopener">AWQ 论文</a>利用激活统计识别重要通道并调整缩放，说明权重如何量化同样关键；论文中的效果不能替代你自己的任务检查。</p>
<p>做代码生成，就检查可执行正确率；做结构化提取，就检查字段准确率和格式通过率。测试数据与量化校准数据分开，尤其保留真实业务中较长、较难的输入。若质量不达标，先比较更合适的校准方式或更高精度方案，再讨论吞吐。</p>
<p>当结果不符合预期，可以按观察到的现象缩小范围：</p>
<table>
<thead>
<tr>
<th>现象</th>
<th>下一步检查</th>
</tr>
</thead>
<tbody>
<tr>
<td>短上下文、小批次也更慢</td>
<td>是否使用预期的量化 kernel；是否存在解包、转换或回退路径</td>
</tr>
<tr>
<td>短上下文有效，长上下文收益缩小</td>
<td>实际 KV 流量和注意力耗时是否已占主要部分</td>
</tr>
<tr>
<td>显存降了，固定批次速度接近</td>
<td>原瓶颈是否在计算、启动或其他未被量化的操作</td>
</tr>
<tr>
<td>高并发吞吐更高，输出间隔变大</td>
<td>是否只是批次增大；延迟约束下的有效吞吐是否仍有收益</td>
</tr>
</tbody>
</table>
<p>量化值得做的理由可以是省显存、降低单请求延迟，也可以是在同样延迟要求下多接请求。先把希望得到的收益说清楚，后面的测试才不会被一个漂亮的“4bit”标签带偏。</p>
<hr />
<p>技术资料核对日期：2026-09-05。本文代码用于验证计算关系，未进行 GPU 性能实测；运行时请以所用版本的文档和 profiler 结果为准。</p>
<p><a href="https://www.cztcode.com/llm-inference-acceleration/">模型推理加速专题</a> · <a href="https://www.cztcode.com/ai-content-policy/">内容生成、审核与纠错说明</a></p>
]]></content:encoded>
					
					<wfw:commentRss>https://www.cztcode.com/2026/5358/feed/</wfw:commentRss>
			<slash:comments>0</slash:comments>
		
		
		<post-id xmlns="com-wordpress:feed-additions:1">5358</post-id>	</item>
		<item>
		<title>大模型推理加速（一）：先分清 Prefill、Decode 和你真正要优化的指标</title>
		<link>https://www.cztcode.com/2026/5348/</link>
					<comments>https://www.cztcode.com/2026/5348/#comments</comments>
		
		<dc:creator><![CDATA[Jellow]]></dc:creator>
		<pubDate>Fri, 04 Sep 2026 18:16:16 +0000</pubDate>
				<category><![CDATA[模型推理加速]]></category>
		<category><![CDATA[Decode]]></category>
		<category><![CDATA[LLM推理]]></category>
		<category><![CDATA[Prefill]]></category>
		<category><![CDATA[性能测试]]></category>
		<guid isPermaLink="false">https://www.cztcode.com/2026/5348/</guid>

					<description><![CDATA[从首 token 等待和逐 token 生成两种慢出发，区分 Prefill、Decode、TTFT、TPOT 与吞吐，并给出可比较的服务基准方法。]]></description>
										<content:encoded><![CDATA[<div id="bsf_rt_marker"></div><blockquote>
<p><strong>Jellow 编辑发布，AI 辅助生成并完成技术核对。</strong> 计算示例和实测结果会在文中分别说明。</p>
</blockquote>
<p>同一个模型，有时要等几秒才开始回答，有时第一句话出现得很快，后面却一个字一个字地往外蹦。这两种“慢”，往往需要不同的处理方法。</p>
<p>讨论推理加速之前，先把一次请求拆开：服务器什么时候收到请求，什么时候开始计算，什么时候产生第一个 token，又在什么时候完成回答。只有知道时间花在哪里，才能判断某个优化是否有效。</p>
<p>本文以使用 KV Cache 的自回归、decoder-only Transformer 为例。后面的数字是人为设置的计算示例，不是某张显卡的性能实测。</p>
<h2>1. 一次生成包含两个主要阶段</h2>
<p>假设输入经过分词后有 1,024 个 token，模型需要继续生成 128 个 token。</p>
<p>Prefill 阶段处理已经给定的输入，计算各层表示并建立 KV Cache。输入 token 已经全部已知，因此同一层中多个位置的计算可以组织成较大的矩阵运算。因果注意力仍然保留：后面的位置可以看前面，前面的位置不能偷看后面。</p>
<p>Prefill 的最后一个位置通常就能给出第一个输出 token 的概率分布。</p>
<p>Decode 阶段继续生成后面的 token。标准自回归生成需要先确定前一个 token，再计算下一个 token。即使显卡还有空闲算力，同一条序列也不能随意把这些依赖关系拆开。Prefill 和 Decode 的计算特点，以及由此带来的优化差异，可参考 <a href="https://developer.nvidia.com/blog/mastering-llm-techniques-inference-optimization/" target="_blank" rel="noopener">NVIDIA 的推理优化说明</a>。</p>
<p>这里容易产生一个误解：使用 KV Cache 后，模型是不是只看最后一个 token？实际情况是，新的 query 仍然需要访问历史 key 和 value；缓存节省的是历史状态的重复计算。<a href="https://huggingface.co/docs/transformers/v4.57.0/en/cache_explanation" target="_blank" rel="noopener">Hugging Face 的缓存说明</a>给出了这一过程。</p>
<h2>2. “吞吐更高”不一定代表“聊天更快”</h2>
<p>先明确几个指标：</p>
<table>
<thead>
<tr>
<th>指标</th>
<th>测量内容</th>
<th>更接近哪种体验</th>
</tr>
</thead>
<tbody>
<tr>
<td>TTFT</td>
<td>从发出请求到收到第一个输出的时间</td>
<td>要等多久才开始回答</td>
</tr>
<tr>
<td>TPOT</td>
<td>除第一个输出 token 外，平均每个输出 token 花费的时间</td>
<td>开始回答后生成得快不快</td>
</tr>
<tr>
<td>E2E latency</td>
<td>一次请求从开始到完成的总时间</td>
<td>多久能拿到完整答案</td>
</tr>
<tr>
<td>Output throughput</td>
<td>测量窗口内成功产生的输出 token 总数除以窗口时间</td>
<td>服务整体能处理多少输出</td>
</tr>
</tbody>
</table>
<p>测量位置必须写清楚。客户端 TTFT 会包含网络、排队和服务端处理，不能直接等同于 GPU 的 Prefill 时间。输入和输出 token 混合统计得到的 total throughput，也不能直接和 output throughput 比较。</p>
<p>对产生至少两个输出 token 的请求，一种常见的计算口径是：</p>
<pre><code class="language-text">TPOT = (E2E latency - TTFT) / (输出 token 数 - 1)</code></pre>
<p>vLLM 的基准工具采用上述 TPOT 定义。它还区分 TPOT 和流式输出之间的间隔 ITL：一次流式输出可能包含多个 token，尤其是在推测解码中，二者并不总相等。<a href="https://docs.vllm.ai/en/latest/benchmarking/cli/" target="_blank" rel="noopener">指标定义</a></p>
<p>下面是假设数据：</p>
<table>
<thead>
<tr>
<th>配置</th>
<th>单请求输出长度</th>
<th>TTFT</th>
<th>TPOT</th>
<th>推导出的 E2E</th>
</tr>
</thead>
<tbody>
<tr>
<td>A</td>
<td>101 token</td>
<td>0.30 秒</td>
<td>0.020 秒</td>
<td>2.30 秒</td>
</tr>
<tr>
<td>B</td>
<td>101 token</td>
<td>0.80 秒</td>
<td>0.015 秒</td>
<td>2.30 秒</td>
</tr>
</tbody>
</table>
<p>两种配置的完整回答耗时相同，但 A 开始得早，B 后半段更流畅。如果只比较总耗时，就会丢掉这部分体验差异。</p>
<h2>3. 为什么单请求 Decode 容易受显存带宽限制</h2>
<p>把一个稠密线性层简化成矩阵乘法。处理很多 token 时，一次读入的权重可以参与更多运算；只处理一个 token 时，权重读取成本更难被摊薄。</p>
<p>可以用一个简化的下界模型建立直觉：</p>
<pre><code class="language-text">单步耗时 &gt;= max(运算量 / 可达到的计算速率,
               数据搬运量 / 可达到的内存带宽)</code></pre>
<p>这个模型假设计算和搬运能较好重叠，没有计入所有调度、同步和通信开销。</p>
<p>再假设一个 70 亿参数的稠密模型，权重全部使用 2 字节存储，每步都需要从显存读取约 14 GB 权重。如果持续可达到的带宽是 1 TB/s，仅权重搬运就需要约 14 毫秒。这是人为设定条件下的估算，不是对具体模型输出速度的承诺。</p>
<p>长上下文还会带来 KV Cache 访问，大 batch 会提高权重复用，多卡部署会引入通信。这些因素都可能改变瓶颈。因此，“Decode 经常受带宽限制”是一种工作负载特征，不是所有模型、所有 batch 下都成立的定律。</p>
<h2>4. 批处理解决的是复用与空转问题</h2>
<p>多个请求合成一个 batch，有机会共同摊薄权重读取和 kernel 启动成本。但请求的输出长度不同，固定 batch 如果一直等最长的请求结束，短请求留下的位置就会空着。</p>
<p>Continuous batching 可以在迭代边界移出已完成请求，再加入新请求。它改变的是调度单位。Orca 的 iteration-level scheduling 是理解这一方向的重要工作。<a href="https://www.usenix.org/conference/osdi22/presentation/yu" target="_blank" rel="noopener">Orca 论文</a></p>
<p>另一个问题是长输入对正在 Decode 的请求造成干扰。Chunked prefill 把较长的输入处理拆成若干块，允许调度器更细地安排输入处理与输出生成。块大小会影响首 token 等待时间、输出间隔和吞吐，需要结合负载调节。<a href="https://docs.vllm.ai/en/stable/configuration/optimization/" target="_blank" rel="noopener">vLLM 调优文档</a></p>
<p>这也解释了为什么把 batch 调大后，吞吐可能提升，P95 延迟却恶化：服务更忙，并不等于每个用户等待更短。</p>
<h2>5. 做一次有解释力的对照实验</h2>
<p>建议先准备三个固定的负载组，再逐项调整配置：</p>
<table>
<thead>
<tr>
<th>负载组</th>
<th>输入长度</th>
<th>目标输出长度</th>
<th>想观察的问题</th>
</tr>
</thead>
<tbody>
<tr>
<td>短问短答</td>
<td>256 token</td>
<td>128 token</td>
<td>基础服务开销与输出延迟</td>
</tr>
<tr>
<td>长文理解</td>
<td>8,192 token</td>
<td>128 token</td>
<td>输入处理与长上下文影响</td>
</tr>
<tr>
<td>长答案生成</td>
<td>1,024 token</td>
<td>1,024 token</td>
<td>持续 Decode 表现</td>
</tr>
</tbody>
</table>
<p>这些长度是实验设计示例，可以按真实业务替换。每组从低并发开始，逐步提高到出现明显排队的位置，同时记录 TTFT、TPOT、吞吐、失败率和显存占用。</p>
<p>比较结果时，固定模型与 tokenizer 版本、精度、采样参数、硬件和软件版本；明确是固定并发还是固定到达率。预热也要一致。重复使用相同输入可能命中前缀缓存，热缓存结果应与冷缓存结果分开记录。<a href="https://docs.vllm.ai/en/latest/benchmarking/cli/" target="_blank" rel="noopener">vLLM 基准注意事项</a></p>
<p>测试最终要回答一个具体问题，例如“在 P95 TTFT 不超过 1 秒的条件下，最大可接受请求率提高了多少”。要得到这个结论，还需要避免负载模型把排队问题藏起来。</p>
<h2>6. 闭环压测可能把过载藏起来</h2>
<p>负载生成方式也会改变结论。</p>
<p>闭环压测通常维持固定并发：一个请求完成后才发下一个。当服务变慢时，客户端也会跟着降低发送速度。它适合模拟“固定数量的用户各自等待回答”，却可能把系统的过载点藏起来。</p>
<p>开放环压测按照预定到达率发请求，不等待前一个请求完成，更接近有独立用户持续到达的场景。vLLM 的在线基准区分请求率、并发上限和不同的到达模式，也支持按照延迟 SLO 统计 goodput。<a href="https://docs.vllm.ai/en/latest/benchmarking/cli/" target="_blank" rel="noopener">vLLM Benchmark CLI</a></p>
<p>假设服务在某个输入输出分布下，稳定处理能力约为每秒 5 个请求：</p>
<ul>
<li>闭环客户端看到延迟增加后自然降到每秒 4.5 个请求，队列看起来一直不长；</li>
<li>开放环仍以每秒 6 个请求到达，未完成请求会持续积累，P95 和 P99 很快恶化。</li>
</ul>
<p>这里的 5、4.5、6 都是假设数字。关键判断是：当长期到达率高于完成率时，不存在稳定的排队状态。短时间压测可能在队列填满前结束，于是给出过于乐观的结果。</p>
<p>在稳定区间内，还可以用 Little 定律做一次一致性检查：</p>
<pre><code class="language-text">平均在途请求数 L = 到达率 λ × 平均系统时间 W</code></pre>
<p>例如平均每秒完成 5 个请求，平均端到端时间 2 秒，则系统内平均约有 10 个请求。这个数量包括排队和执行中的请求，不等于 GPU 上某一时刻的 batch size。如果监控值相差很大，先检查统计窗口、重试、流式连接关闭和请求是否丢失。</p>
<h2>7. 用 SLO goodput 取代孤立的峰值吞吐</h2>
<p>吞吐只计算“完成了多少”，没有说明这些请求是否来得及。可以先给业务定义一组服务目标，例如：</p>
<pre><code class="language-text">TTFT P95 &lt;= 1.0 s
TPOT P95 &lt;= 40 ms
请求失败率 &lt;= 0.1%</code></pre>
<p>然后把 goodput 定义为单位时间内同时满足这些条件的请求数。某个配置可能把输出吞吐从每秒 1,000 token 提高到 1,200 token，但大量短请求的 TTFT 超标；它的峰值吞吐更高，goodput 反而可能更低。</p>
<p>SLO 必须按请求类型制定。交互式聊天通常更在意 TTFT 和输出节奏，离线摘要更关心完成时间与总成本。把两类流量混在一个 P95 中，会让指标既不能代表聊天体验，也不能代表批任务效率。</p>
<p>一次更完整的容量实验可以这样进行：</p>
<ol>
<li>从真实流量中只保留 token 长度、到达间隔和采样参数等非敏感统计，构造可重复数据集；</li>
<li>选择多个到达率，每档持续到队列、吞吐和延迟进入稳定区间；</li>
<li>每档同时报告 TTFT、TPOT、E2E 的 P50/P95/P99、完成率、goodput 和显存峰值；</li>
<li>画出“到达率—goodput”和“到达率—P95 延迟”曲线，找到延迟开始陡增的位置；</li>
<li>只在同一 SLO 和同一输入输出分布下比较优化前后容量。</li>
</ol>
<p>这样测试得到的不是“这台机器最快能跑多少 token”，而是“在用户还能接受的延迟下，它能稳定接住多少请求”。后一个问题才更接近部署决策。</p>
<blockquote>
<p><strong>更新说明（2026-09-05）：</strong> 补充了推导、失效条件和可执行的部署判断方法。</p>
</blockquote>
<hr />
<p><a href="https://www.cztcode.com/ai-content-policy/">本站的内容生成、审核与纠错说明</a></p>
]]></content:encoded>
					
					<wfw:commentRss>https://www.cztcode.com/2026/5348/feed/</wfw:commentRss>
			<slash:comments>1</slash:comments>
		
		
		<post-id xmlns="com-wordpress:feed-additions:1">5348</post-id>	</item>
	</channel>
</rss>
