同一张表,我先读出「不换」,换一格读出「快 2.58 倍」。
两个结论来自同一次扫描、同一台机器、同一天,一个数字都没改。我按第二个结论把配置换了,然后打开自己天天用的聊天客户端问了几个问题,界面上显示 38 tok/s。直连同一个服务,同样的问题是 74.1。
客户端吃掉了将近一半,而我那 2.58 倍在散文问题上本来就只剩一成。用起来没区别,一点都不奇怪。
想直接拿式子的看第 3 章;想知道怎么拆一张模型卡上的加速数字,看第 4 章。
1. 一张表,两个判决
机器是一张挖矿版的数据中心卡,软件解锁到 64GB 显存,上面常驻一个 27B 的稠密模型。我想比两套配置:
- 旧的:FP8 权重 + 模型自带的 MTP 头,一次猜 5 个 token
- 新的:INT8 权重 + 一个单独训练的 DFlash2 草稿模型,一次猜 7 个
投机解码就是让一个便宜的小东西先猜后面几个词,大模型一次性验,猜对白赚,猜错就退回去重来。所以它赚不赚,取决于猜得准不准、验一次贵不贵,还有一件经常被忘的事:卡上同时有几条请求在跑。
我用同一个流式探针,同一天,两套配置轮流上,扫了 1、8、32、64 四档并发。只看聚合吞吐(所有请求的输出 token 加起来除以墙钟):
| 并发 | 代码题 旧 | 代码题 新 | 倍数 | 散文题 旧 | 散文题 新 | 倍数 |
|---|---|---|---|---|---|---|
| 1 | 122.6 | 111.2 | 0.91× | 62.2 | 69.0 | 1.11× |
| 8 | 274.7 | 708.4 | 2.58× | 289.0 | 302.0 | 1.04× |
| 32 | 554.3 | 714.5 | 1.29× | 340.5 | 308.5 | 0.91× |
| 64 | 663.7 | 711.2 | 1.07× | 359.8 | 306.9 | 0.85× |
第一次读这张表,我直接去看了最后一行。理由听着挺专业:服务启动参数里写着 --max-num-seqs 64,那就按 64 并发判。代码题只快 7%,散文题还亏 15%,首字延迟 p95 也从 12.0 秒变成 13.5 秒。判决:不换。
过了一阵我才问自己一个早该先问的问题:这台服务平时到底几条请求在跑?
答案很尴尬。就我一个人用,一个聊天客户端,加上偶尔几个后台 agent,最多 4 条。64 是当初配的时候怕不够写的上限,从来没真跑到过。
换成第二行读,代码题聚合吞吐 2.58 倍,首字延迟 p95 从 2472 毫秒降到 333 毫秒,散文也从亏 15% 变成赚 4%。判决翻过来:该换。
--max-num-seqs 这类参数是配置上限,跟工作点没关系。 它只告诉你写配置的人担心过什么,不告诉你实际有几个人在用。而并发恰好是投机解码最敏感的那一轴:并发低的时候卡在等内存,猜 7 个白赚;并发一高,卡已经被正经请求喂饱了,再塞一堆猜测进去就是在抢算力。
定工作点,我现在按这个顺序来:先问用的人(或者问自己);问不到就去服务的 /metrics 里采一段时间的 num_requests_running,看分布,别看峰值;再不行看调用方,单人用的聊天客户端和 agent 基本在 1 到 4,进了负载均衡池给一群服务调用的才可能是几十。
2. 低并发那格,别读聚合吞吐
表里还有一格我差点又读错:并发 1,代码题,新配置 0.91 倍,看着是输的。
可同一格换个口径,每条请求自己算速度(这条请求的输出 token 除以它自己的耗时,取中位数),旧的 137.4,新的 194.5,快 1.42 倍。
两个口径差这么多,原因很朴素:
聚合吞吐 = 所有请求的输出 token 总和 ÷ 整段墙钟
单路速度 = 一条请求的输出 token ÷ 这条请求自己的耗时
并发 1 的时候墙钟里有一段预热和请求之间的空档,这段时间两边差不多长。新配置每条请求结束得更快,空档占的比例就更大,聚合吞吐被拖下来了。一个人用的时候你感受到的是单路速度,聚合那一列在这一格基本是噪声。
所以「看哪一格」其实是两个问题:哪个并发档,和那一档该读哪一列。并发高读聚合,因为你关心卡一共能吐多少;并发低读单路,因为你关心自己那条请求等多久。
3. 换上之后,客户端里只有 38
判决翻转之后我就换了。验收跑了三发裸请求,工具调用正常,并发探针在 4 并发那格是聚合 490.2、单路 147.5、首字 p95 229 毫秒。全绿。
然后我在自己的聊天客户端里问了一个散文类的问题,界面显示 38 tok/s,首字等了 6.1 秒。
我第一反应是去解释「散文本来就赚得少」。这个解释没错,可它没回答「为什么只有 38」。我还犯了个更蠢的错:客户端装在另一台机器上,我先跑去那台机器上把本地的推理服务测了一圈,全是不相干的东西。后来老老实实读客户端的配置,顺着它的地址一路查到后端,才确认它打的就是这张卡。
最后的办法最笨也最准:打开浏览器,在客户端里真点。同一个模型,同样的内容,同一天:
| 测法 | 散文题 | 代码题 |
|---|---|---|
| 直连推理服务 | 74.1 tok/s | 208.8 tok/s |
| 经过负载均衡网关 | 75.1 tok/s | — |
| 经过聊天客户端 | 38 tok/s,首字 6.1 秒 | 185 tok/s,首字 3.3 秒 |
| 客户端 ÷ 直连 | 0.51× | 0.89× |
网关零开销。客户端在散文上吃掉一半,在代码上几乎不吃。
原因在客户端自己记的统计里。每一轮对话,它先往前面塞三块东西:系统提示 1509 个 token,工具和技能的定义 6603 个,我真正问的话加历史 1347 个。我打一句话,它发过去 8112 个 token 起步。
这 8k 的前缀带来两层代价。首字等待变长,因为模型得先把这 8k 全读一遍(这一步叫 prefill,投机解码对它完全没用:两套配置在 6.3k 和 25k 的 prefill 上都是 0.99 倍)。解码也变慢,因为后面每生成一个词都要回头扫一遍更长的上下文。散文题答得长,每一步都扛着这个包袱;代码题答一百多个 token 就停了,影响小得多。
这个客户端的速度数字是这么算的:解码 token 数 ÷ 解码耗时。我那一轮是 377 个 token、10.003 秒,37.7,界面四舍五入成 38。整轮耗时 16.087 秒,其中首字等待 6.084 秒。
有了这几个数,就能算出用户这一侧真正能感觉到的加速:
整轮加速 = (首字等待 + 解码耗时 × s) ÷ (首字等待 + 解码耗时)
s 是引擎侧的解码加速比,首字等待和解码耗时都用换之后测到的值。前提是首字等待不变,这里成立,因为 prefill 两边一样。
代进去。散文单路在探针上是 1.11 倍:
(6.084 + 10.003 × 1.11) ÷ (6.084 + 10.003)
= 17.19 ÷ 16.09
≈ 1.07
整轮只快 7%。 一个人凭体感分辨 7%,基本不可能。就算我把代码题的 1.42 倍硬套到这一轮上,也只有 1.26 倍;把那个最漂亮的 2.58 倍套上去,才勉强到 1.98 倍。首字那 6 秒是固定成本,引擎侧再怎么快,它都原样摆在那儿。
这个式子你拿自己的数也能算:从客户端里找出首字等待和解码耗时,乘上你在探针上测到的加速比。首字等待占整轮的比例越大,引擎侧的加速被稀释得越狠。
回头看,我报那个 2.58 倍的时候,三个轴全取在了最有利的那一侧:
| 轴 | 我报数字用的 | 我自己实际用的 |
|---|---|---|
| 内容 | 代码题 | 散文问答为主 |
| 提示长度 | 33 个 token 的短提示 | 每轮先注入 8112 个 |
| 并发 | 8 | 1 |
三个都在收益最小的一侧,乘起来就是「没区别」。代码题那边换配置是真兑现了:客户端里 185,换之前直连都只有 140 出头。我只是平时不怎么在客户端里问代码。
顺带看出来的下一个优化点也在客户端那一侧:那 6603 个 token 的工具定义跟引擎完全无关,砍掉它对首字等待的影响,比我再调一周引擎都大。
4. 拆一个模型卡上的数字
新配置里那个草稿模型有两个版本,一个原精度,一个 4bit 权重的 W4A16。模型卡上说 4bit 版每一步少读 2.7GB,接受率不变。少读就是快,我当时就是这么理解的。
我当时顺手算了一笔,觉得很严谨:
省下的时间上限 = 少读的字节 ÷ 显存带宽
2.7 GB ÷ 1133 GB/s ≈ 2.4 毫秒/步
当时实测每步 17.6 毫秒,扣掉 2.4,我预测能到 214 tok/s。
实测:原精度草稿 185.5,4bit 草稿 193.3,快 4.2%,每步只省了大约 0.6 毫秒,离预测差了 4 倍。
错在两处,都值得记。
第一处是那个除法只管「读权重」这一个环节。草稿模型往前算一步,除了读权重,还有自己的注意力计算、kernel 发射、计算图回放、从候选里挑前几名。这些都不会因为权重变小而变快。拿一个环节的理论节省去除整步的耗时,就默认了这个环节占满整步,这只在它真的占满的时候才成立。
第二处更要紧:模型卡的数字是在一张 24GB 的消费卡上测的。27B 的目标模型加上原精度的草稿模型,在 24GB 上放不下,原精度版得来回换进换出,4bit 版省下的主要是这部分折腾。省的是容量。我这张卡 64GB,两个都放得下,没有换进换出可省,剩下的只有那一点读带宽。
接受长度几乎没动(平均每轮接受 3.273 个对 3.296 个),这点跟模型卡一致。模型卡没说谎,只是它的收益机制在我的卡上不存在。
所以看到别人报「换个量化格式快了 X%」,我先问一句:它省的是带宽还是容量? 省容量的收益,在不缺容量的机器上直接归零。省带宽的收益,再用上面那个除法算个上限,然后打个折,看那个环节占整步多大比例。
5. 这些数哪里不能信
带宽常数我用错过。 第 4 章那个 1133 GB/s 是我当时在限功耗、没预热的条件下单次测的,后来老实重测是 1674。换成 1674,少读 2.7GB 的理论节省是 1.6 毫秒,实测 0.6,结论方向不变,但我当时那个 214 的预测本身就建在一个错的分母上。
整轮 1.07 倍是估算。 1.11 倍是探针上散文单路的聚合吞吐比,我没有在客户端里对旧配置做配对测量(换的时候没想到要测客户端)。把探针的比值套到客户端那一轮上,默认了客户端里的解码加速比跟探针一样,这一步没验证过。
散文的单路数字我没有。 第 2 章 1.42 倍的单路速度只测了代码题。散文并发 1 那格我只有聚合口径的 1.11 倍,真实单路可能高一点。
历史数据对不上。 旧配置的启动脚本里记着三天前的一条并发阶梯:1/4/8/16/32 并发分别是 94、248、398、468、478。我这次同一个服务测出 1/8/32 是 122.6、274.7、554.3,两头更高、中间更低,方向都不一致。最可能是提示组成和输出长度不同。我没去追,只在判决里用同一天同一探针的配对数据,那条旧阶梯标成不可比。
我还差点用过一个更烂的比值。 早一版对比里,我把一个 65k 上下文、单并发、非流式计时的新配置数字(172.3)和一个 64 并发上限、流式计时的旧配置数字(140.9)一除,得了 1.22 倍。四个变量同时不同,这个数没法用。上面的表把这四个都固定住了。
质量这边是打平。 169 道题,同底座,温度 0,总分 8.74 对 8.79,配对 t 值 0.21,95% 置信区间正负 0.449,噪声以内。两边答案逐字节相同的 40 道题被我拿来标定评委的噪声,分差均值是 -0.05(理论上应该是 0)。能说的只有「没看出变差」。
6. 下次读一个加速比之前
- 先问它是哪一格:什么并发、什么内容、多长的提示。三个都不说的加速比,默认是挑最好看的那格报的。
- 工作点别从服务配置里读。
--max-num-seqs是上限,去问用的人,或者采num_requests_running的分布。 - 并发低读单路速度,并发高读聚合吞吐。两列都打出来,别只留一列。
- 验收走到你实际用的那个客户端为止,从客户端自己的统计里拿首字等待和解码耗时,先搞清它的速度是怎么算的。
- 用
(首字等待 + 解码耗时 × s) ÷ (首字等待 + 解码耗时)算一遍整轮加速,看引擎侧的提升被稀释成了多少。 - 客户端和直连差一倍以上,先查客户端每轮往前面塞了多少 token,再回头怀疑引擎。
- 看到模型卡或别人的「省 X GB 所以快 Y%」,先问省的是带宽还是容量,再用「少读的字节 ÷ 带宽」算个上限,乘上那个环节在整步里的占比。
我调出来的 2.58 倍,在自己的客户端里输给了一份 6603 个 token 的工具清单。