<?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>推理加速 &#8211; Blog of Code</title>
	<atom:link href="https://www.cztcode.com/tag/%E6%8E%A8%E7%90%86%E5%8A%A0%E9%80%9F/feed/" rel="self" type="application/rss+xml" />
	<link>https://www.cztcode.com</link>
	<description>模型推理加速与编程实践</description>
	<lastBuildDate>Sat, 05 Sep 2026 02:29:55 +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>推理加速 &#8211; 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>吞吐涨了，用户却觉得更卡：连续批处理的延迟账怎么算</title>
		<link>https://www.cztcode.com/2026/5360/</link>
					<comments>https://www.cztcode.com/2026/5360/#respond</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>0</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>
	</channel>
</rss>
