AI AI infra · 推理侧
第 1 章
AI infra · 推理侧

第 2 章 先备知识

第 1 章用一条时间式子把"慢"拆成搬运、计算和固定开销三部分,也说明了预填充卡在算力、逐字生成卡在带宽。但要真正把这条式子用起来,还缺四个基础概念:文本如何变成 token,一次前向到底在算什么,精度如何决定字节数,以及算力和带宽凭什么可以放在一起比较。这一章补这四样。补完之后,你应当能读懂一个模型的 config.json,也能看懂推理日志里那几行数字在说什么。

这些内容在任何一本机器学习教材里都有,但推理工程师需要的只是其中一部分:不需要会推导反向传播,只需要知道哪些参数决定了显存占用、哪些操作决定了时间花在哪里。

文本先变成 token

模型不处理文字。文本进入模型之前,先被一个分词器(tokenizer)映射成一串整数。这个过程不涉及神经网络,只是一张字符串到编号的表,加上一套切分规则。

现代模型用的是子词切分(subword tokenization):一个 token 可能是一个完整的词,也可能是词的一部分,还可能是标点或空格。这样做的原因是词表不能无限大——把常见词整块收进去,把生僻词拆成几段,可以在词表规模和表达能力之间取平衡。词表大小通常在几万到十几万之间,它决定了模型输出层的宽度:每生成一个 token,模型都要为词表里的每一项算一个分数,这个分数向量叫 logits,再经过归一化和采样,才变成最终输出的那个 token。

token 的数量决定后面所有的账。预填充要处理的 token 越多,计算量越大;缓存要保存的 token 越多,显存占用越大;按 token 计费的服务,账单也按这个数走。同一段中文,在不同分词器下可能切出相差两三成的 token 数,这个差异会原样传递到延迟和成本上。所以看到一个"输入长度 8K"的说法,第一件该问的事是哪个分词器数的。

词表、采样与输出长度

分词器的另一头连着输出。模型每生成一个位置,都会为词表里的每一项算一个分数,得到一个长度等于词表大小的向量,叫 logits。这个向量经过归一化变成概率分布,再按采样策略选出一个 token。

采样策略会实实在在影响成本。温度调低,输出更确定、更短,重复和啰嗦更少;温度调高,输出更发散,长度也更难预测。top-k 和 top-p 限制候选集合,作用类似。这些参数通常被当成"效果调优"的旋钮,但它们同时是成本旋钮:输出长度每增加一倍,逐字生成的时间就增加一倍,缓存的最终占用也增加一倍。

还有一个容易被忽略的细节:模型不会自己停下来。生成到结束符(EOS)才会终止,而结束符是否按时出现,取决于模型和提示词。没有长度上限的接口,等于把成本交给模型的随机性。生产环境里通常同时设最大输出长度和超时,这两道闸门不是可选项。

一次前向在算什么

神经网络的基本单位是节点:输入乘上权重、加上偏置、输出结果。一组节点构成一层,几十到上百层串起来,就得到一个模型。层与层之间的中间结果叫隐藏状态,每一层的输出是下一层的输入。

层里最主要的运算是矩阵乘法。一个线性层做的事情可以写成"输出等于权重矩阵乘以输入向量,再加偏置"。矩阵乘之所以是主角,是因为它同时决定了计算量和权重体积:权重矩阵的元素个数就是这一层的参数量,而乘加次数与参数量成正比。

只有线性层是不够的。连续两个线性层可以合并成一个,层数再多也会塌缩成一层,模型就失去了表达能力。激活函数的作用是在两层之间插入非线性,把这种塌缩打断。常见的 ReLU、SiLU、SwiGLU 计算都很便宜,它们不是瓶颈,但缺了它们,深层网络没有意义。

一个大语言模型的主体是几十到上百个 transformer 块,每个块里有注意力子层、前馈网络子层和归一化。前馈网络承载了大部分可训练参数,注意力子层是第二大的部分。这个比例很重要:它意味着模型的显存占用主要被前馈网络占据,而推理时每一步的时间却常常被注意力之外的东西拖住。

最后是输出层。它把最后一层的隐藏状态变成词表长度的 logits 向量,采样之后得到下一个 token。整个流程每生成一个 token 就要重复一次,这就是自回归。

注意力:为什么它既贵又能被缓存

注意力解决的问题是:当前这个 token 应该参考前面哪些 token。标准形式叫缩放点积注意力,有三个输入。Q 是当前 token 的表示,K 是此前所有 token 的表示,V 是此前所有 token 的内容。用 Q 去和每个 K 做点积,得到一组相关性分数,归一化之后作为权重,再对 V 加权求和。

注意力是多头的。每个头独立做一次上述运算,不同的头可以学到不同种类的关系,有的关注主谓一致,有的关注指代。头数是一个结构参数,后面会看到它如何影响缓存大小。

点积之后除以头维度的平方根,是为了把分数的量级压回合理范围,否则维度一大,softmax 就会退化成几乎只看一个位置。语言模型还要加一个因果掩码,让当前位置只能看到之前的 token,不能看到之后的——这正是"自回归"在结构上的实现。掩码的存在也解释了为什么生成阶段必须一步一步来:第 N 个位置的计算依赖前面所有位置的结果。

按 Q 和 K 的来源,注意力分两种。自注意力里 Q、K、V 都来自同一段序列,语言模型用的就是这种;交叉注意力里 Q 来自一段序列,K 和 V 来自另一段,图像生成和多模态模型常用。

注意力贵,是因为当前 token 要和此前每一个 token 各算一次相关性,计算量随序列长度平方增长。这也是为什么长上下文首先撞的是注意力:长度翻四倍,注意力的比较次数翻十六倍。

注意力又可以被缓存,是因为 K 和 V 一旦算出来就不再变化。既然每个 token 的 K、V 是固定的,就没有必要在生成下一个 token 时重新计算它们,只需要存下来、下次直接读。这一步把逐字生成从"每步重算全部历史"变成"每步读一份历史",注意力在生成阶段从平方变成了线性。要注意缓存的是 K 和 V,不包含 Q——Q 只属于当前这一步,用一次就丢。

这个缓存就是后面反复出现的 KV cache。它省下的是重复计算,付出的是显存,而且这笔显存随上下文长度线性增长。

精度:每个参数占几个字节

权重和缓存都要占显存,占多少取决于每个数用几个字节表示。

单精度浮点 FP32 用四个字节,半精度 FP16 和 BF16 用两个字节,FP8 用一个字节,INT8 也是一个字节,INT4 是半个字节。推理里常见的组合是权重用 FP16 或 BF16、缓存用 FP16,需要省显存时再降到 FP8 或 INT8。

FP16 和 BF16 都是两字节,区别在位数怎么分配。FP16 给尾数更多位,精度更高,但能表示的数值范围较窄;BF16 保留了 FP32 的指数位,范围大、精度低。这个差别在训练里很关键,因为梯度容易溢出;在推理里通常不构成问题,两种格式都能用。

低精度之所以可行,是因为推理只做前向,不需要保存梯度和优化器状态,数值动态范围的要求比训练低得多。但低精度不是免费的:权重存成 INT4 之后,计算前要先还原成高精度,这一步有开销;缓存压到 FP8 之后,数值误差会累积到注意力分数上,长上下文时更明显。

字节数决定两件事。第一是权重有多大:参数量乘以每参数字节,就是权重体积。第二是每一步要搬多少字节:逐字生成每步都要把权重完整读一遍,字节数减半,搬运时间也近似减半。量化之所以在推理侧流行,根本原因就在这两条上。

还有一个工程细节值得知道:低精度的乘加通常在更高精度的累加器里完成。也就是说,输入是 FP8,累加仍然是 FP32,否则几百次相加之后误差会把结果吃掉。这意味着低精度节省的是存储和搬运,不是计算单元里的全部工作;反量化、类型转换这些步骤都要花时间,这也是为什么量化到 4 比特之后,速度往往只提升两三倍而不是四倍。

算力与带宽:两个上限

一台加速器有两个关键指标:每秒能做多少次浮点运算,以及每秒能从显存搬多少字节。前者决定计算上限,后者决定搬运上限。

把这两个数相除,得到一个比值,含义是"每从显存读一个字节,机器能做多少次运算"。Inference Engineering 给的例子是 H100:FP16 算力约 989 TFLOPS,显存带宽约 3.35 TB/s,比值约为 295。A100 的数字小一些,312 TFLOPS 除以 2039 GB/s,约为 153。这个比值是硬件的属性,换卡就换一个数。

一个计算任务的算术强度,是它的运算量除以读取字节数。把任务的算术强度和硬件的比值放在一起,就能判断它受哪个上限约束:任务比值高于硬件比值,算力有余、搬运跟不上,属于带宽受限;任务比值低于硬件比值,搬运不是问题、算力不够用,属于算力受限。这条判断线就是 roofline 模型的核心。

回到语言模型。逐字生成一步的运算量约为两倍参数量,读取量约为全部权重,两者相除得到的算术强度接近一,远低于任何现代加速器的几百。所以逐字生成必然带宽受限。预填充一次处理整段输入,运算量随输入长度增长,读取量不变,算术强度可以到几千,必然算力受限。

这就是第 1 章那条结论的来源。它不是经验之谈,是两个除法的结果。

ISL 与 OSL:工作负载的两个坐标

算完硬件,还要给负载定位。Inference Engineering 用两个参数描述一个负载:输入序列长度(ISL)和输出序列长度(OSL)。

ISL 是提示词的 token 数,它决定预填充的成本:输入越长,要过的矩阵越大,首字延迟越高。OSL 是生成出来的 token 数,它决定逐字生成的成本:输出越长,要走过的步数越多,端到端时间越长。

两者的比值刻画了负载的形状。摘要类任务输入长、输出短,属于预填充密集,卡在算力上;闲聊和 Agent 类任务输入短、输出长,属于逐字生成密集,卡在带宽上。同一条结论——"该买什么卡"——在两种负载下答案相反:预填充密集需要高算力,逐字生成密集需要高带宽。

这解释了一个常见的困惑:两个团队用同一个模型,一个说 H100 提升明显,另一个说没什么区别。差别往往不在模型,而在 ISL 与 OSL 的分布。所以压测必须用真实分布,用统一长度的合成负载测出来的数字,只能说明那一种形状下的表现。

一个请求的完整账

把前面几节串起来,算一次完整请求。条件沿用第 1 章:7B 模型,FP16,输入 8K token,输出 512 token,A100。

预填充的运算量是两倍参数量乘以输入长度,约 114 TFLOP。按 312 TFLOPS 的理想算力,0.37 秒。这是首字延迟的理论下限,实际还要加上排队和 kernel 启动。

逐字生成要重复 512 步。每一步读取约 14 GB 权重,按 2039 GB/s 算是 6.9 毫秒,512 步约 3.5 秒。加上前面的 0.37 秒,整条请求约 3.9 秒。

这两个数字的对比值得记住:预填充花了 0.37 秒处理 8192 个 token,逐字生成花了 3.5 秒处理 512 个 token。按单位 token 算,生成一个 token 的时间是预填充的四十多倍。这就是为什么输出长度对成本的影响远大于输入长度——用户多要五百字,代价是好几秒。

三个常见误解

第一个误解是 token 等于词。中文里一个 token 可能是一个字、半个词,也可能是一个标点;英文里常见词通常一个 token,生僻词会被拆成几段。按词估算 token 数,误差可以到两三成。

第二个误解是注意力是参数的大头。前面算过,前馈占了接近三分之二。注意力重要,是因为它在计算和缓存上的占比高,不是因为它的权重多。

第三个误解是精度越高越好。FP16 和 BF16 都是两字节,前者尾数长、后者范围大;推理里两者差别通常可以忽略,真正影响质量的是量化到 8 比特以下时的取舍。选精度要看任务,不是看位数。

这四个概念合起来能做什么

把这四样放在一起,可以做三件事。

第一,读一个模型的结构。拿到 config.json,看层数、隐藏维度、注意力头数、KV 头数、词表大小,就能知道参数量大概花在哪里,也能算出权重的字节数。

第二,算一次请求的占用。token 数决定输入长度,输入长度和 KV 头数决定缓存大小,缓存加上权重决定并发上限。

第三,判断一次性能问题。看日志里首字延迟和出字速度的比例:首字慢而出字快,多半在预填充;出字慢而首字正常,多半在带宽;两个都慢,先看队列。

下一章就用这四样去拆模型结构。同样是 7B,为什么有的能在一张 24GB 卡上并六条,有的只能并一条;为什么 MoE 的参数量很大、单条请求却很便宜,到了批处理又变贵;为什么上下文从 8K 到 32K,问题会同时出现在算力和显存两边。