[{"content":" 核心直觉： OBS 不是把某个权重置零后就结束，而是在必须删除该权重的约束下，让其他权重一起调整，用最小的二阶损失增量完成剪枝。\nOptimal Brain Surgeon（OBS）要解决的问题可以拆成两句话：\n如果必须剪掉一个权重，应该剪掉哪一个？ 剪掉以后，其他权重应该分别补偿多少，才能让损失增加最少？ 这正是 OBS 和 Optimal Brain Damage（OBD）的核心差别。OBD 使用 Hessian 的对角近似，为每个权重独立打分；OBS 则保留完整 Hessian，显式利用权重之间的耦合关系。\n从一个线性例子理解补偿 先看一个最简单的线性模型：\n$$ y=w_1x_1+w_2x_2. $$假设当前权重为：\n$$ w_1=2,\\qquad w_2=3, $$并且某次输入满足：\n$$ x_1=1,\\qquad x_2=1. $$原始输出为：\n$$ y=2\\times 1+3\\times 1=5. $$现在想剪掉 $w_1$，也就是强制：\n$$ w_1:2\\longrightarrow 0. $$如果其他权重完全不动，新的输出变为：\n$$ y'=0\\times 1+3\\times 1=3. $$输出从 $5$ 变成 $3$，模型行为显然发生了变化。\nOBS 会继续问一个问题：既然 $w_1$ 必须删除，能不能让 $w_2$ 帮忙补回来？例如把 $w_2$ 从 $3$ 调整到 $5$：\n$$ y'=0\\times 1+5\\times 1=5. $$这样即使 $w_1$ 被删除，输出仍然保持不变。这个例子虽然极简，但它揭示了 OBS 的核心：删除一个权重以后，允许其他权重做最优补偿。\n为什么其他权重可以补偿 神经网络中的特征通常不是完全独立的。若两个输入特征高度相关，例如：\n$$ x_1\\approx x_2, $$那么：\n$$ w_1x_1+w_2x_2 $$本身就存在一定冗余。极端情况下，如果：\n$$ x_1=x_2=x, $$则：\n$$ 2x_1+3x_2=5x. $$这时把表达式改成：\n$$ 0x_1+5x_2=5x $$并不会改变输出。\n这就是权重之间的可补偿关系。OBD 的对角 Hessian 近似基本假设每个权重独立变化，而 OBS 则认为权重之间的耦合关系很重要，应该在剪枝时被保留下来。\nOBD 忽略了什么 假设损失函数关于参数的 Hessian 为：\n$$ H= \\begin{bmatrix} 2 \u0026 0.8 \u0026 0.2\\\\ 0.8 \u0026 3 \u0026 0.5\\\\ 0.2 \u0026 0.5 \u0026 4 \\end{bmatrix}. $$OBD 只保留对角元素：\n$$ H_{\\mathrm{OBD}}\\approx \\begin{bmatrix} 2 \u0026 0 \u0026 0\\\\ 0 \u0026 3 \u0026 0\\\\ 0 \u0026 0 \u0026 4 \\end{bmatrix}. $$于是它只关心每个权重自身方向上的曲率：\n$$ H_{11},\\quad H_{22},\\quad H_{33}. $$但是完整 Hessian 中的非对角元素：\n$$ H_{12},\\quad H_{13},\\quad H_{23} $$描述的是不同权重之间的耦合关系。它们决定了当一个权重被删除时，其他权重能否通过联动调整来降低损失增量。\nOBS 的出发点就是：如果这些非对角项并不小，就不应该把它们扔掉。\nOBS 的目标函数 设模型参数为：\n$$ \\mathbf{w}=[w_1,w_2,\\ldots,w_n]^T. $$如果要删除第 $q$ 个权重，就需要强制：\n$$ w_q+\\delta w_q=0. $$也就是：\n$$ \\delta w_q=-w_q. $$用单位向量 $\\mathbf{e}_q$ 表示第 $q$ 个坐标方向，上面的删除约束可以写成：\n$$ \\mathbf{e}_q^T\\delta\\mathbf{w}+w_q=0. $$在模型已经训练到局部最优点附近时，一阶梯度近似为零，损失变化可以用二阶项近似：\n$$ \\Delta L \\approx \\frac{1}{2}\\delta\\mathbf{w}^TH\\delta\\mathbf{w}. $$因此 OBS 的优化问题是：\n$$ \\min_{\\delta\\mathbf{w}} \\frac{1}{2}\\delta\\mathbf{w}^TH\\delta\\mathbf{w}, \\qquad \\mathbf{e}_q^T\\delta\\mathbf{w}+w_q=0. $$这个式子非常关键：删除 $w_q$ 是硬约束，但其他参数的变化 $\\delta w_i$ 可以自由选择。OBS 要找的是满足删除约束时，让二阶损失增量最小的整体参数扰动。\n二维例子：怎么算出最优补偿 考虑两个权重：\n$$ \\mathbf{w}= \\begin{bmatrix} w_1\\\\ w_2 \\end{bmatrix}. $$假设要删除 $w_1$，且：\n$$ w_1=1. $$那么必须有：\n$$ \\Delta w_1=-1. $$再假设 Hessian 为：\n$$ H= \\begin{bmatrix} 2 \u0026 1\\\\ 1 \u0026 2 \\end{bmatrix}. $$二阶损失增量为：\n$$ \\Delta L = \\frac{1}{2} \\begin{bmatrix} \\Delta w_1 \u0026 \\Delta w_2 \\end{bmatrix} \\begin{bmatrix} 2 \u0026 1\\\\ 1 \u0026 2 \\end{bmatrix} \\begin{bmatrix} \\Delta w_1\\\\ \\Delta w_2 \\end{bmatrix}. $$展开得到：\n$$ \\Delta L = \\Delta w_1^2+\\Delta w_1\\Delta w_2+\\Delta w_2^2. $$把删除约束 $\\Delta w_1=-1$ 代入：\n$$ \\Delta L = 1-\\Delta w_2+\\Delta w_2^2. $$现在问题变成了一个一元二次函数最小化：\n$$ \\min_{\\Delta w_2} \\left(1-\\Delta w_2+\\Delta w_2^2\\right). $$对 $\\Delta w_2$ 求导：\n$$ \\frac{\\partial \\Delta L}{\\partial \\Delta w_2} = -1+2\\Delta w_2. $$令导数为零：\n$$ -1+2\\Delta w_2=0. $$得到：\n$$ \\Delta w_2=0.5. $$所以 OBS 会选择：\n$$ \\Delta w_1=-1,\\qquad \\Delta w_2=0.5. $$也就是：\n$$ \\begin{bmatrix} w_1\\\\ w_2 \\end{bmatrix} \\longrightarrow \\begin{bmatrix} 0\\\\ w_2+0.5 \\end{bmatrix}. $$这就是“删除 $w_1$，同时让 $w_2$ 进行最优补偿”。\n为什么这个补偿是最优的 继续使用上面的损失函数：\n$$ \\Delta L=1-\\Delta w_2+\\Delta w_2^2. $$不同补偿量对应的损失增量如下：\n$\\Delta w_2$ $\\Delta L$ $0$ $1.00$ $0.2$ $0.84$ $0.5$ $0.75$ $0.8$ $0.84$ $1.0$ $1.00$ 完全不补偿时，损失增量为 $1$；补偿过头时，损失增量也会变大。最低点正好出现在：\n$$ \\Delta w_2=0.5. $$所以 OBS 里的 Optimal 不是经验上的“稍微调一下”，而是在 Hessian 给出的二阶近似下直接求解：\n$$ \\arg\\min_{\\delta\\mathbf{w}}\\Delta L. $$OBS 的闭式解 对约束优化问题：\n$$ \\min_{\\delta\\mathbf{w}} \\frac{1}{2}\\delta\\mathbf{w}^TH\\delta\\mathbf{w}, \\qquad \\mathbf{e}_q^T\\delta\\mathbf{w}+w_q=0, $$使用拉格朗日乘子可以得到最优参数扰动：\n$$ \\boxed{ \\delta\\mathbf{w}^* = -\\frac{w_q}{[H^{-1}]_{qq}}H^{-1}\\mathbf{e}_q }. $$对应的最小损失增量为：\n$$ \\boxed{ \\Delta L_q^{\\mathrm{OBS}} = \\frac{w_q^2}{2[H^{-1}]_{qq}} }. $$这两个式子分别回答了 OBS 的两个问题：\n$\\delta\\mathbf{w}^*$ 告诉我们删除 $w_q$ 后，所有其他权重应该怎么补偿； $\\Delta L_q^{\\mathrm{OBS}}$ 告诉我们删除 $w_q$ 并完成最优补偿后，预计最少会增加多少损失。 因此，OBS 的剪枝流程可以概括为：\n对每个候选权重 $w_q$，计算 $\\Delta L_q^{\\mathrm{OBS}}$； 选择损失增量最小的权重： $$ q^*=\\arg\\min_q\\Delta L_q^{\\mathrm{OBS}}; $$ 使用 $\\delta\\mathbf{w}^*$ 更新所有权重； 被选中的权重变为零，其余权重完成补偿。 与 OBD 的直接对比 OBD 的权重显著性为：\n$$ S_q^{\\mathrm{OBD}} = \\frac{1}{2}H_{qq}w_q^2. $$它只使用 Hessian 对角元素 $H_{qq}$。这意味着 OBD 只估计“单独把 $w_q$ 置零”造成的损失增量，不显式考虑其他权重的补偿。\nOBS 的显著性为：\n$$ S_q^{\\mathrm{OBS}} = \\frac{w_q^2}{2[H^{-1}]_{qq}}. $$它使用的是 Hessian 逆矩阵的对角元素 $[H^{-1}]_{qq}$。因为 $H^{-1}$ 已经包含了完整 Hessian 中的参数耦合信息，所以 OBS 的评分天然考虑了“其他参数可以怎样一起调整”。\n二者的区别可以总结为：\n方法 使用的二阶信息 是否考虑补偿 剪枝评分 OBD Hessian 对角元素 $H_{qq}$ 否 $\\frac{1}{2}H_{qq}w_q^2$ OBS Hessian 逆的对角元素 $[H^{-1}]_{qq}$ 是 $\\frac{w_q^2}{2[H^{-1}]_{qq}}$ 如果 Hessian 近似是对角矩阵，OBS 会退化到接近 OBD 的形式；但当非对角元素较大时，OBS 能利用权重之间的相关性，得到更合理的删除顺序和补偿方向。\n计算代价与边界 OBS 的代价也比 OBD 高得多。对于 $N$ 个参数，完整 Hessian 的形状是：\n$$ N\\times N. $$直接存储和求逆的代价分别非常高：\n$$ O(N^2)\\quad \\text{存储}, \\qquad O(N^3)\\quad \\text{直接求逆}. $$这也是 OBS 在现代大模型上很难直接原样应用的原因。实际系统通常需要使用块级近似、低秩近似、分层处理、迭代更新或只在局部模块内构造二阶信息。\nOBS 的推导还依赖几个前提：\n模型已经训练到局部最优点附近，一阶梯度可以近似忽略； 当前参数附近的损失曲面可以由二阶泰勒展开较好近似； Hessian 或其逆矩阵的近似足够稳定； 剪枝扰动不能过大，否则局部二阶近似会失效。 因此，OBS 更适合作为理解二阶剪枝和权重补偿的理论基准，而不是可以直接照搬到超大模型上的完整工程方案。\n总结 OBS 的核心不是“更复杂地计算权重大小”，而是把剪枝看成一个带约束的二阶优化问题：\n必须删除某个权重 -\u0026gt; 其他权重允许共同调整 -\u0026gt; 用完整 Hessian 描述权重之间的耦合 -\u0026gt; 求解最小二阶损失增量 -\u0026gt; 同时得到删除对象和补偿方向 OBD 的简化让每个权重可以独立打分，计算更便宜，但会丢掉参数之间的耦合关系。OBS 则更像“外科手术”：切掉一个连接以后，还会调整周围连接，使网络功能尽量保持不变。\n最终可以记住这两个公式：\n$$ S_q^{\\mathrm{OBD}} = \\frac{1}{2}H_{qq}w_q^2, \\qquad S_q^{\\mathrm{OBS}} = \\frac{w_q^2}{2[H^{-1}]_{qq}}. $$前者对应“独立删除”，后者对应“删除并最优补偿”。这就是 OBS 相比 OBD 多出来的本质能力。\n参考资料 Optimal Brain Damage（NeurIPS Proceedings） Second Order Derivatives for Network Pruning: Optimal Brain Surgeon（NeurIPS Proceedings） ","permalink":"https://zhangge.dev/model-inference/optimal-brain-surgeon/","summary":"OBS 相比 OBD 的关键区别在于补偿：删除某个权重以后，它允许其他权重沿着 Hessian 给出的二阶曲率方向共同调整，从而尽量降低剪枝带来的损失增量。","title":"Optimal Brain Surgeon（OBS）：带补偿的二阶剪枝方法"},{"content":"题目：1. 两数之和 - 力扣（LeetCode）\n题目描述 给定一个整数数组 nums 和一个整数目标值 target，需要在数组中找出两个数，使它们的和等于 target。\n返回这两个数在数组中的下标。题目保证每组输入只会对应一个答案，并且同一个元素不能使用两次。\n核心思路 这道题可以用哈希表一次遍历解决。\n假设当前遍历到的数字是 value，它的下标是 key。如果存在另一个数字 b，使得：\nvalue + b = target 那么：\nb = target - value 所以在遍历 nums 时，可以用一个哈希表 record_map 记录已经遍历过的数字和它们的下标。\n每次遇到当前数字 value，先检查 target - value 是否已经在哈希表中：\n如果存在，说明已经找到了答案； 如果不存在，就把当前数字和下标记录到哈希表中，继续向后遍历。 这样可以避免两层循环，把查找另一个数的过程从 O(n) 降到平均 O(1)。\nPython 实现 class Solution: def twoSum(self, nums: List[int], target: int) -\u0026gt; List[int]: record_map = {} # a + b = c # c - a = b for key, value in enumerate(nums): if target - value in record_map: return [key, record_map[target - value]] record_map[value] = key return [-1, -1] 这里的 record_map 存储的是：\n数字 -\u0026gt; 下标 例如遍历到 value = 7、target = 9 时，只需要检查 2 是否已经出现在 record_map 中。如果出现过，就可以直接返回当前下标和 2 对应的下标。\n为什么要先查再存 循环中要先判断 target - value 是否存在，再把当前 value 放进哈希表。\n这样可以避免同一个元素被使用两次。\n例如 nums = [3]、target = 6 时，如果先把 3 存入哈希表，再检查 target - 3，就可能错误地把同一个 3 当作两个数使用。\n复杂度分析 时间复杂度：O(n)，只需要遍历数组一次； 空间复杂度：O(n)，最坏情况下哈希表会存储数组中的大部分元素。 容易出错的地方 哈希表中应该存下标，而不是只存数字； 不能用同一个元素两次，所以要先查找补数，再记录当前数字； 返回的是下标，不是两个数字本身； 有重复数字时，哈希表仍然可以正常处理，因为每次只查找已经遍历过的元素。 ","permalink":"https://zhangge.dev/leetcode/two-sum/","summary":"两数之和可以用哈希表在一次遍历中完成：遍历当前数字时，查找 target - 当前值是否已经出现，若存在就返回两个下标。","title":"1. 两数之和"},{"content":" 核心思想： OBD 不只看权重有多小，而是利用 Hessian 对角元素判断损失函数对该权重方向有多敏感，进而选择删除后损失增量最小的权重。\nOptimal Brain Damage（OBD）要回答的问题是：当必须删除一些权重时，怎样判断删除哪个权重对模型损失的影响最小？\nOBD 由 Yann LeCun、John S. Denker 和 Sara A. Solla 提出。论文收录于 Advances in Neural Information Processing Systems 2（NIPS 1989）；一些参考文献也会按照论文集出版年份将其标为 1990。\n理解 OBD 之前，需要先理解 Hessian 矩阵及其对角元素表示的含义。\n什么是 Hessian 矩阵 Hessian（海森）矩阵是一个由多元函数全部二阶偏导数组成的方阵。\n考虑二元函数：\n$$ f(x,y). $$它有两个输入变量，因此不仅需要考虑函数沿 $x$ 方向和 $y$ 方向的曲率，还需要考虑两个变量之间的相互影响。对应的二阶偏导数包括：\n$$ \\frac{\\partial^2 f}{\\partial x^2}, \\qquad \\frac{\\partial^2 f}{\\partial x\\partial y}, \\qquad \\frac{\\partial^2 f}{\\partial y\\partial x}, \\qquad \\frac{\\partial^2 f}{\\partial y^2}. $$将它们排列成矩阵：\n$$ H_f(x,y)= \\begin{bmatrix} \\dfrac{\\partial^2 f}{\\partial x^2} \u0026 \\dfrac{\\partial^2 f}{\\partial x\\partial y} \\\\[6pt] \\dfrac{\\partial^2 f}{\\partial y\\partial x} \u0026 \\dfrac{\\partial^2 f}{\\partial y^2} \\end{bmatrix}. $$这就是函数 $f$ 的 Hessian 矩阵。\n对角元素和非对角元素 假设：\n$$ H= \\begin{bmatrix} H_{11} \u0026 H_{12}\\\\ H_{21} \u0026 H_{22} \\end{bmatrix}. $$其中：\n$$ H_{11}=\\frac{\\partial^2 f}{\\partial x^2} $$表示函数沿 $x$ 方向的弯曲程度；\n$$ H_{22}=\\frac{\\partial^2 f}{\\partial y^2} $$表示函数沿 $y$ 方向的弯曲程度；而\n$$ H_{12}=\\frac{\\partial^2 f}{\\partial x\\partial y} $$表示变量之间的耦合关系：当 $y$ 发生变化时，函数关于 $x$ 的斜率会怎样变化。\n因此：\n对角元素 $H_{ii}$ 描述损失函数沿单个参数方向的曲率或敏感度； 非对角元素 $H_{ij}$ 描述参数 $w_i$ 与 $w_j$ 之间的耦合关系。 Hessian 的计算示例 考虑函数：\n$$ f(x,y)=x^2+xy+3y^2. $$先求一阶偏导：\n$$ \\frac{\\partial f}{\\partial x}=2x+y, \\qquad \\frac{\\partial f}{\\partial y}=x+6y. $$因此梯度为：\n$$ \\nabla f(x,y)= \\begin{bmatrix} 2x+y\\\\ x+6y \\end{bmatrix}. $$再对梯度求一次偏导。对于梯度的第一个分量 $2x+y$：\n$$ \\frac{\\partial(2x+y)}{\\partial x}=2, \\qquad \\frac{\\partial(2x+y)}{\\partial y}=1. $$对于梯度的第二个分量 $x+6y$：\n$$ \\frac{\\partial(x+6y)}{\\partial x}=1, \\qquad \\frac{\\partial(x+6y)}{\\partial y}=6. $$所以 Hessian 矩阵为：\n$$ H_f= \\begin{bmatrix} 2 \u0026 1\\\\ 1 \u0026 6 \\end{bmatrix}. $$这个例子中的 Hessian 不依赖 $x$、$y$，因为原函数是二次函数。\nOBD：Optimal Brain Damage 名字的由来 Optimal Brain Damage 这个名字本质上是一个带有幽默感的比喻，可以直译为：\n以最优方式“损伤”神经网络。\n其中：\nBrain：把神经网络比喻成“大脑”； Damage：主动删除一部分权重或神经连接，对网络进行有意的“损伤”； Optimal：不是随意删除，而是选择对模型损失影响最小的权重进行删除。 因此，OBD 并不是要破坏模型功能，而是要在必须缩小网络时，以尽可能小的性能损失移除冗余连接。这里的“最优损伤”，对应的实际操作就是权重剪枝。\nOBD 说白了就是：\n利用损失函数的二阶泰勒展开，估计修改某个权重会对损失造成多大影响。\n但更准确地说，OBD 针对的是一种特定的参数修改：把某个权重从 $w_q$ 修改为：\n$$ w_q'=0. $$也就是进行权重剪枝。OBD 要估计的，正是这次置零操作会使损失增加多少。\nOBD 要解决什么问题 一个训练好的神经网络通常包含大量权重。为了减小模型规模和计算量，我们希望将一些不重要的权重设置为零，也就是进行权重剪枝。\n假设模型中有一个权重向量：\n$$ \\mathbf{w}=[w_1,w_2,\\ldots,w_q,\\ldots,w_n]. $$如果想要删除第 $q$ 个权重，可以把它设置为零：\n$$ w_q'=0. $$一个线性层原本可能计算：\n$$ y=w_1x_1+w_2x_2+\\cdots+w_qx_q+\\cdots+w_nx_n. $$将 $w_q$ 设置为零后：\n$$ y'=w_1x_1+w_2x_2+\\cdots+0\\cdot x_q+\\cdots+w_nx_n. $$于是 $w_qx_q$ 这条连接不再对计算结果产生影响，等价于将这条神经网络连接剪掉。\n参数变化定义为：\n$$ \\delta w_q=w_q'-w_q. $$删除权重意味着：\n$$ w_q'=0. $$所以：\n$$ \\delta w_q=0-w_q=-w_q. $$最直接的方法：幅值剪枝 最简单的剪枝策略是删除绝对值最小的权重。\n假设模型有三个权重：\n$$ \\mathbf{w}=[2.0,\\ 0.01,\\ -1.5]. $$如果必须删除一个权重，最直观的选择是：\n$$ w_2=0.01, $$因为它离零最近。删除不同权重时，参数变化量分别为：\n$$ \\begin{aligned} |\\delta w_1|\u0026=|0-2.0|=2.0,\\\\ |\\delta w_2|\u0026=|0-0.01|=0.01,\\\\ |\\delta w_3|\u0026=|0-(-1.5)|=1.5. \\end{aligned} $$删除 $w_2$ 对参数的改动最小。因此，幅值剪枝使用的基本直觉是：\n权重绝对值越小，将它设置为零造成的变化越小，对模型的影响也可能越小。\n幅值剪枝的问题 “权重小，所以不重要”只是一个非常粗糙的假设。一个权重对模型的影响，不仅取决于权重本身，还取决于：\n对应的输入激活有多大； 损失函数对该权重有多敏感； 其他权重能否补偿它； 该权重与其他参数之间是否存在关联。 例如有一个线性计算：\n$$ y=w_1x_1+w_2x_2. $$假设：\n$$ w_1=0.01,\\qquad x_1=1000, $$$$ w_2=1,\\qquad x_2=0.001. $$两个连接的实际贡献分别是：\n$$ w_1x_1=0.01\\times1000=10, $$$$ w_2x_2=1\\times0.001=0.001. $$虽然：\n$$ |w_1|\\lt|w_2|, $$但是 $w_1$ 对输出的影响远大于 $w_2$。如果只根据权重绝对值剪枝，就会删除 $w_1$，造成很大的输出变化。\n因此：\n权重小不等于权重不重要。\n从直接试删到二阶近似 假设要删除第 $q$ 个权重：\n$$ w_q\\longrightarrow 0, $$对应参数变化为：\n$$ \\delta w_q=-w_q. $$最简单的显著性指标是：\n$$ \\operatorname{importance}(w_q)=|w_q|. $$但权重小不代表不重要。某个权重虽然数值小，但损失函数可能在该方向非常陡峭；轻微改变它，就可能导致损失显著增加。\nOBD 因而同时考虑两个因素：\n权重有多大； 损失函数对这个权重有多敏感。 如果模型有 $n$ 个权重：\n$$ w_1,w_2,\\ldots,w_n, $$理论上可以依次尝试删除每一个权重。例如：\n删除 $w_1$，损失可能增加 $0.5$； 删除 $w_2$，损失可能增加 $0.001$； 删除 $w_3$，损失可能增加 $2.0$。 理想选择是：\n$$ q^* =\\arg\\min_q \\left[ L(\\mathbf{w}\\ \\text{删除}\\ w_q)-L(\\mathbf{w}) \\right], $$也就是选择删除后损失增加最少的权重。\n但是，如果真的对每个权重都执行一次“删除、重新运行整个验证集、计算模型损失、再恢复原权重”，计算成本会非常高。\nOBD 的目标就是：\n不实际逐个删除并运行模型，而是利用二阶泰勒展开和 Hessian，对删除每个权重造成的损失变化进行近似估计。\n为什么训练良好的模型可以使用二阶近似 假设模型当前参数为：\n$$ \\mathbf{w}, $$剪枝后参数变为：\n$$ \\mathbf{w}+\\delta\\mathbf{w}. $$我们想知道剪枝引起的参数变化 $\\delta\\mathbf{w}$ 会使损失增加多少，也就是计算：\n$$ \\Delta L=L(\\mathbf{w}+\\delta\\mathbf{w})-L(\\mathbf{w}). $$如果为每个候选权重都实际执行一次剪枝，再重新运行数据集计算 $L(\\mathbf{w}+\\delta\\mathbf{w})$，评估成本会非常高。因此，OBD 不直接逐个试删，而是在当前参数 $\\mathbf{w}$ 附近对剪枝后的损失 $L(\\mathbf{w}+\\delta\\mathbf{w})$ 进行泰勒展开：\n$$ L(\\mathbf{w}+\\delta\\mathbf{w}) \\approx L(\\mathbf{w}) +\\nabla L(\\mathbf{w})^T\\delta\\mathbf{w} +\\frac{1}{2}\\delta\\mathbf{w}^TH\\delta\\mathbf{w} +O(\\|\\delta\\mathbf{w}\\|^3). $$将等式两边同时减去剪枝前的损失 $L(\\mathbf{w})$，就得到损失增量 $\\Delta L$ 的近似：\n$$ \\Delta L \\approx \\nabla L(\\mathbf{w})^T\\delta\\mathbf{w} +\\frac{1}{2}\\delta\\mathbf{w}^TH\\delta\\mathbf{w} +O(\\|\\delta\\mathbf{w}\\|^3). $$其中：\n第一项是一阶项，描述当前位置的斜率； 第二项是二阶项，描述损失曲面的弯曲程度； 后面的三次及以上项描述更复杂的高阶形状。 当模型已经训练得较好，并位于局部最优点附近时：\n$$ \\nabla L(\\mathbf{w})\\approx 0. $$如果同时假设当前邻域内的损失函数可以用二次函数近似，高阶项可以忽略，于是：\n$$ \\Delta L \\approx \\frac{1}{2}\\delta\\mathbf{w}^TH\\delta\\mathbf{w}. $$泰勒展开和 Hessian 在 OBD 中的关系 在 OBD 中，泰勒展开和 Hessian 并不是两个彼此独立的工具：\n泰勒展开负责把“剪枝会使损失增加多少”转化为一个局部近似问题，Hessian 则提供这个近似中的二阶曲率信息。\n二者的关系可以概括为：\n剪枝 → 参数产生变化 δw → 用泰勒展开近似损失变化 → 用 Hessian 描述二阶损失变化 → 得到权重的剪枝显著性 Hessian 是损失函数对所有参数的二阶偏导矩阵：\n$$ H_{ij} =\\frac{\\partial^2L}{\\partial w_i\\partial w_j}. $$它出现在泰勒展开的二阶项中：\n$$ \\frac{1}{2}\\delta\\mathbf{w}^TH\\delta\\mathbf{w}. $$当模型已经训练到局部最优点附近时，一阶梯度近似为零，损失变化主要由这个二阶项决定。因此，Hessian 可以用来判断损失曲面沿不同权重方向弯曲得有多厉害：\n$H_{qq}$ 较大，表示损失函数在 $w_q$ 方向曲率较大，改变该权重可能明显增加损失； $H_{qq}$ 较小，表示损失函数在 $w_q$ 方向比较平坦，改变或删除该权重造成的损失可能较小。 删除第 $q$ 个权重时：\n$$ \\delta w_q=-w_q. $$如果进一步采用 OBD 的 Hessian 对角近似，只保留与 $w_q$ 对应的二阶项，就得到：\n$$ \\Delta L_q \\approx \\frac{1}{2}H_{qq}(\\delta w_q)^2 =\\frac{1}{2}H_{qq}w_q^2. $$因此，OBD 将权重显著性定义为：\n$$ S_q=\\frac{1}{2}H_{qq}w_q^2. $$也就是说，泰勒展开给出了从参数扰动到损失变化的推导框架，Hessian 提供损失对参数变化的二阶敏感度，而 OBD 利用 Hessian 对角元素将这种敏感度转化为每个权重可以独立比较的剪枝分数。\nHessian 的对角元素和非对角元素 假设只有两个参数：\n$$ \\mathbf{w}= \\begin{bmatrix} w_1\\\\ w_2 \\end{bmatrix}, \\qquad H= \\begin{bmatrix} H_{11} \u0026 H_{12}\\\\ H_{21} \u0026 H_{22} \\end{bmatrix}. $$二阶损失变化为：\n$$ \\Delta L \\approx \\frac{1}{2} \\begin{bmatrix} \\delta w_1 \u0026 \\delta w_2 \\end{bmatrix} \\begin{bmatrix} H_{11} \u0026 H_{12}\\\\ H_{21} \u0026 H_{22} \\end{bmatrix} \\begin{bmatrix} \\delta w_1\\\\ \\delta w_2 \\end{bmatrix}. $$在 Hessian 对称，即 $H_{12}=H_{21}$ 时，展开得到：\n$$ \\Delta L \\approx \\frac{1}{2} \\left( H_{11}\\delta w_1^2 +2H_{12}\\delta w_1\\delta w_2 +H_{22}\\delta w_2^2 \\right). $$其中：\n$H_{11}\\delta w_1^2$ 表示只改变 $w_1$ 带来的曲率影响； $H_{22}\\delta w_2^2$ 表示只改变 $w_2$ 带来的曲率影响； $2H_{12}\\delta w_1\\delta w_2$ 表示两个权重同时改变时产生的相互作用。 因此，对角元素 $H_{qq}$ 表示单个参数方向上的敏感度，非对角元素 $H_{ij}$ 则表示参数 $w_i$ 与 $w_j$ 之间的耦合关系。\nOBD 为什么只看 Hessian 对角元素 OBD 进一步只保留 Hessian 的对角元素。这不是说参数之间真的彼此独立，而是一种让二阶剪枝能够实际计算的近似。\n原因一：完整 Hessian 太大 假设模型有 $N$ 个参数，Hessian 的形状是：\n$$ N\\times N, $$需要存储：\n$$ N^2 $$个元素。\n例如，一个只有一亿参数的模型：\n$$ N=10^8. $$Hessian 的元素数量为：\n$$ 10^{16}. $$即使使用 FP32，每个元素占 4 字节，也需要：\n$$ 4\\times10^{16}\\ \\text{bytes}, $$约为 $40\\ \\text{PB}$，显然无法直接存储。\n而 Hessian 对角线只有 $N$ 个元素，存储成本从：\n$$ O(N^2) $$降低为：\n$$ O(N). $$OBD 出现在计算条件有限的时代，完整 Hessian 在当时以及现代大模型上都很难直接使用。\n原因二：需要给每个权重一个独立评分 OBD 希望为每个权重计算一个显著性：\n$$ S_q=\\frac{1}{2}H_{qq}w_q^2, $$然后对所有权重排序：\n$$ S_{q_1}\\lt S_{q_2}\\lt\\cdots. $$优先删除显著性最小的权重。\n如果保留 Hessian 的非对角元素，损失变化会包含：\n$$ H_{ij}\\delta w_i\\delta w_j. $$这意味着一个权重是否重要，不再只取决于它自己，还取决于其他哪些权重也被删除。例如，单独删除 $w_1$ 可能安全，单独删除 $w_2$ 也可能安全，但同时删除它们却可能造成很大影响。这时很难为每个权重分配一个完全独立的分数。\n使用对角近似后：\n$$ \\Delta L \\approx \\frac{1}{2}\\sum_i H_{ii}\\delta w_i^2. $$每个权重的贡献可以独立计算：\n$$ S_i=\\frac{1}{2}H_{ii}w_i^2. $$这使剪枝排序变得非常简单。\n原因三：假设权重之间的相互作用较弱 OBD 做了近似：\n$$ H_{ij}\\approx 0, \\qquad i\\ne j. $$也就是假设不同权重方向之间的耦合较弱。于是 Hessian 近似为：\n$$ H\\approx \\begin{bmatrix} H_{11} \u0026 0 \u0026 \\cdots\\\\ 0 \u0026 H_{22} \u0026 \\cdots\\\\ \\vdots \u0026 \\vdots \u0026 \\ddots \\end{bmatrix}. $$但这只是为了简化计算，并不意味着神经网络中的参数真的彼此独立。实际上，神经网络权重之间往往存在较强相关性，这也是后续二阶剪枝方法继续改进 OBD 的重要原因。\nOBD 的显著性公式 删除第 $q$ 个权重时：\n$$ \\delta w_q=-w_q. $$在模型已经训练得较好的情况下，使用二阶近似：\n$$ \\Delta L \\approx \\frac{1}{2}\\delta\\mathbf{w}^TH\\delta\\mathbf{w}. $$OBD 进一步忽略 Hessian 的非对角元素，只保留第 $q$ 个权重对应的对角项：\n$$ \\Delta L_q \\approx \\frac{1}{2}H_{qq}(\\delta w_q)^2. $$因为：\n$$ (\\delta w_q)^2=(-w_q)^2=w_q^2, $$所以：\n$$ \\boxed{ \\Delta L_q \\approx \\frac{1}{2}H_{qq}w_q^2 }. $$OBD 将：\n$$ \\boxed{ S_q=\\frac{1}{2}H_{qq}w_q^2 } $$定义为权重 $w_q$ 的显著性。\n这个公式不只看权重幅值 $w_q^2$，还看 Hessian 对角元素 $H_{qq}$。其中，$H_{qq}$ 表示损失函数在 $w_q$ 这个参数方向上的弯曲程度，也就是损失对该权重变化的敏感程度。\n具体例子 假设有两个权重：\n$$ w_1=0.1, \\qquad w_2=1.0. $$只看绝对值时：\n$$ |w_1|\\lt|w_2|, $$因此幅值剪枝会删除 $w_1$。\n但是，假设 Hessian 的对角元素为：\n$$ H_{11}=1000, \\qquad H_{22}=0.01. $$OBD 显著性分别为：\n$$ S_1 =\\frac{1}{2}\\times1000\\times0.1^2 =5, $$$$ S_2 =\\frac{1}{2}\\times0.01\\times1^2 =0.005. $$虽然 $w_2$ 比 $w_1$ 大很多，但删除 $w_2$ 对损失的近似影响反而更小：\n$$ S_2\\lt S_1. $$所以 OBD 会删除：\n$$ w_2, $$而不是幅值更小的 $w_1$。这说明 OBD 真正解决的是：\n不要根据权重大小剪枝，而要根据预计的损失增量剪枝。\nOBD 的假设与边界 OBD 的推导依赖几个关键近似：\n极值点近似：模型已经训练到局部最优点附近，因此一阶梯度近似为零； 二次近似：损失函数在当前参数附近可以由二阶泰勒展开近似，高阶项可以忽略； 对角近似：忽略不同权重之间的 Hessian 非对角项，将各权重的损失贡献视为可以独立相加。 这些近似让 OBD 的计算从理论上不可承受的完整 Hessian，简化为每个权重一个独立分数。但它也带来局限：当模型尚未收敛、剪枝扰动很大，或参数之间存在明显耦合时，$\\frac{1}{2}H_{qq}w_q^2$ 可能无法准确预测真实损失变化。\nOBD 的主要问题：忽略 Hessian 非对角元素 OBD 最关键的局限来自 Hessian 对角近似。完整 Hessian 的非对角元素为：\n$$ H_{ij} =\\frac{\\partial^2L}{\\partial w_i\\partial w_j}, \\qquad i\\ne j. $$它描述参数 $w_i$ 与 $w_j$ 在损失曲率上的耦合关系。直观地说，非对角元素反映的是：\n改变或删除 $w_i$ 后，能否通过调整 $w_j$ 以及其他权重来补偿这次扰动。\n完整的二阶损失变化包含交叉项：\n$$ \\Delta L \\approx \\frac{1}{2} \\sum_i H_{ii}\\delta w_i^2 +\\frac{1}{2} \\sum_{i\\ne j}H_{ij}\\delta w_i\\delta w_j. $$OBD 忽略第二部分，只用：\n$$ \\Delta L_{\\mathrm{OBD}} \\approx \\frac{1}{2} \\sum_iH_{ii}\\delta w_i^2. $$这相当于把每个权重看成可以独立评分的参数。删除 $w_q$ 时，OBD 只考虑 $\\delta w_q=-w_q$，没有利用其他参数的联合调整来寻找损失更小的补偿方向。\n现实中的神经网络参数通常高度相关，Hessian 一般不是对角矩阵。当非对角项不可忽略时，两个问题会随之出现：\n损失增量估计不准：$\\frac{1}{2}H_{qq}w_q^2$ 可能高估或低估删除 $w_q$ 后的真实损失变化； 权重排序可能错误：某个按 OBD 分数看似不重要的权重，可能通过非对角耦合影响许多其他参数，最终导致 OBD 删除错误的权重。 Optimal Brain Surgeon（OBS）正是针对这一问题提出的。它不要求 Hessian 是对角矩阵，而是在删除 $w_q$ 的约束下，允许其他权重共同调整：\n$$ \\min_{\\delta\\mathbf{w}} \\frac{1}{2}\\delta\\mathbf{w}^TH\\delta\\mathbf{w}, \\qquad \\mathbf{e}_q^T\\delta\\mathbf{w}+w_q=0. $$其中 $\\mathbf{e}_q$ 是第 $q$ 个坐标方向的单位向量。这个约束保证更新后 $w_q$ 变为零，而优化过程可以利用其余参数对剪枝扰动进行补偿。对应的最优参数变化和损失增量为：\n$$ \\delta\\mathbf{w}^* =-\\frac{w_q}{[H^{-1}]_{qq}}H^{-1}\\mathbf{e}_q, $$$$ \\Delta L_q^{\\mathrm{OBS}} =\\frac{w_q^2}{2[H^{-1}]_{qq}}. $$OBS 原始论文指出，其考察问题中的 Hessian 呈明显的非对角结构，许多非对角项与对角项处于可比量级；论文将这种结构视为 OBD 经常选择错误剪枝权重的重要原因。换句话说，OBD 的问题并非使用二阶信息这个方向不对，而是为了计算简单而丢弃了二阶信息中的参数耦合部分。\n总结 OBD 的推理链条可以概括为：\n删除权重 → 参数扰动 δw → 二阶泰勒展开估计损失变化 → 忽略一阶项、高阶项和 Hessian 非对角项 → 得到每个权重的独立显著性分数 最终评分公式是：\n$$ S_q=\\frac{1}{2}H_{qq}w_q^2. $$权重越大，或损失函数沿该权重方向的曲率越大，删除它造成的预计损失增量就越大。相比只看 $|w_q|$ 的幅值剪枝，OBD 将模型对参数变化的敏感度纳入了剪枝依据。\n参考资料 Optimal Brain Damage（NeurIPS Proceedings） Second Order Derivatives for Network Pruning: Optimal Brain Surgeon（NeurIPS Proceedings） ","permalink":"https://zhangge.dev/model-inference/optimal-brain-damage/","summary":"权重绝对值小不等于权重不重要。OBD 使用 Hessian 对角元素衡量损失对各权重方向的曲率，以二阶显著性分数选择删除后损失增加最小的权重。","title":"Optimal Brain Damage（OBD）：用 Hessian 判断权重重要性"},{"content":" 核心问题： 量化时，究竟应该让整个权重矩阵共享一套 scale 和 zero point，还是为不同通道乃至不同权重组分别计算量化参数？\n假设有一个全连接层：\n$$ Y=XW^{T}, $$权重矩阵 $W$ 的形状为：\n$$ W\\in\\mathbb{R}^{4\\times 8}. $$也就是说，这一层有 4 个输出通道，每个输出通道有 8 个权重，总共有 32 个权重。例如：\n$$ W= \\begin{bmatrix} 0.1 \u0026 0.2 \u0026 0.3 \u0026 0.4 \u0026 0.2 \u0026 0.1 \u0026 0.3 \u0026 0.2\\\\ -1.0 \u0026 1.5 \u0026 -2.0 \u0026 2.2 \u0026 -1.8 \u0026 1.2 \u0026 -2.1 \u0026 2.0\\\\ 10 \u0026 12 \u0026 9 \u0026 11 \u0026 13 \u0026 8 \u0026 10 \u0026 12\\\\ -0.01 \u0026 0.02 \u0026 -0.03 \u0026 0.01 \u0026 0.02 \u0026 -0.01 \u0026 0.03 \u0026 -0.02 \\end{bmatrix}. $$不同行的数值范围差别很大：\n输出通道 大致范围 第 0 行 $[-0.4,0.4]$ 附近 第 1 行 $[-2.2,2.2]$ 第 2 行 $[-13,13]$ 第 3 行 $[-0.03,0.03]$ 问题在于：应该让这 32 个权重使用同一个 scale，还是分别使用不同的 scale？这就产生了逐层、逐通道和逐组量化。\n逐层量化 逐层量化也叫：\nPer-layer quantization； 在很多语境中也叫 Per-tensor quantization。 对于整个权重矩阵 $W$，只计算一套量化参数：\n$$ s_W,\\qquad z_W. $$量化时，所有元素都使用相同的 scale 和 zero point：\n$$ W_{ij}\\longrightarrow Q_{ij}, $$$$ Q_{ij} =\\operatorname{round}\\left(\\frac{W_{ij}}{s_W}\\right)+z_W. $$对于前面的 $4\\times 8$ 矩阵，整个 $W$ 只共用一个 scale：\nW[0, :] ┐ W[1, :] │ W[2, :] ├── scale_0 W[3, :] ┘ 因此，scale 集合只有一个元素：\n$$ S=[s_0]. $$示例 假设采用 INT8 对称量化，整数范围通常取：\n$$ [-127,127]. $$整个矩阵中最大的绝对值为：\n$$ \\max(|W|)=13. $$因此：\n$$ s=\\frac{13}{127}\\approx 0.1024. $$这意味着相邻两个可表示浮点数之间的间隔约为 $0.1024$。对于第三行中的 $10$、$12$、$13$ 来说，这个精度还可以；但是第四行的数值范围是：\n$$ [-0.03,0.03]. $$其中所有数值都小于半个量化步长：\n$$ \\frac{s}{2}\\approx 0.0512. $$因此，第四行中的权重可能全部被量化为：\n$$ q=0. $$反量化后也全部变成：\n$$ \\widehat{W}=0. $$也就是说，小数值通道中的信息可能完全消失。\n优缺点 优点：\n只需要保存一套 scale 和 zero point； 实现简单； 量化元数据很少； 计算内核容易优化； 通常速度较高。 缺点：\n容易受到异常值和大数值通道影响； 不同通道数值分布差异较大时，量化误差明显； 低比特量化，尤其是 INT4 时，精度通常较差。 适用情况 逐层量化适合：\n数值分布比较均匀的张量； 对精度要求不高的场景； INT8 激活量化； 硬件只支持较粗量化粒度的场景。 逐通道量化 逐通道量化也叫 Per-channel quantization。对权重而言，通常是每个输出通道使用一套量化参数。\n对于线性层权重：\n$$ W\\in\\mathbb{R}^{C_{\\mathrm{out}}\\times C_{\\mathrm{in}}}, $$通常每一行对应一个输出通道，因此每行分别计算 scale：\n$$ s_0,s_1,\\ldots,s_{C_{\\mathrm{out}}-1}. $$量化公式变为：\n$$ Q_{ij} =\\operatorname{round}\\left(\\frac{W_{ij}}{s_i}\\right)+z_i. $$注意，scale 由行号 $i$ 决定，而不是整个矩阵共用一个。\n前面的矩阵有 4 行，因此有 4 套参数：\nW[0, :] ─── scale_0 W[1, :] ─── scale_1 W[2, :] ─── scale_2 W[3, :] ─── scale_3 scale 张量的形状是：\n$$ S\\in\\mathbb{R}^{4}, $$为了广播，也可以写成：\n$$ S\\in\\mathbb{R}^{4\\times 1}. $$这里所谓输出通道，就是按矩阵的行进行量化。因为在 $Y=XW^T$ 的约定下，$W$ 的列数对应输入维度，行数对应输出维度。\n各通道分别计算 scale 仍然采用 INT8 对称量化。\n第一行最大绝对值约为 $0.4$：\n$$ s_0=\\frac{0.4}{127}\\approx 0.00315. $$第二行最大绝对值为 $2.2$：\n$$ s_1=\\frac{2.2}{127}\\approx 0.01732. $$第三行最大绝对值为 $13$：\n$$ s_2=\\frac{13}{127}\\approx 0.10236. $$第四行最大绝对值为 $0.03$：\n$$ s_3=\\frac{0.03}{127}\\approx 0.000236. $$因此：\n$$ S= \\begin{bmatrix} 0.00315\\\\ 0.01732\\\\ 0.10236\\\\ 0.000236 \\end{bmatrix}. $$现在第四行不再使用由第三行大数值所决定的 $0.1024$ 步长，而是使用 $0.000236$。原来的 $0.01$ 可以量化为：\n$$ q=\\operatorname{round}\\left(\\frac{0.01}{0.000236}\\right)\\approx 42. $$反量化得到：\n$$ \\widehat{x}=42\\times 0.000236\\approx 0.00991. $$这个结果与原始的 $0.01$ 很接近。\n为什么通常按输出通道量化 考虑线性层：\n$$ y_i=\\sum_j x_jW_{ij}. $$第 $i$ 行权重负责计算第 $i$ 个输出通道：\n$$ W_{i,:}\\longrightarrow y_i. $$如果这一行共享一个 scale：\n$$ W_{ij}\\approx s_iQ_{ij}, $$那么：\n$$ y_i =\\sum_j x_jW_{ij} \\approx s_i\\sum_j x_jQ_{ij}. $$这样可以先执行整数或低比特矩阵乘法，再对第 $i$ 个输出通道统一乘一次 $s_i$。这种方式既符合权重分布，又比较方便计算内核实现。\n优缺点 优点：\n每个通道可以适应自己的数值范围； 不容易被其他通道的异常值影响； 通常比逐层量化精度高很多； scale 数量仍然不算太多； 在 INT8 权重量化中非常常见。 缺点：\nscale 数量增加； 反量化和矩阵乘法实现更复杂； 如果单个通道内部差异仍然很大，误差依然明显； 对 INT4 大模型权重而言，有时粒度仍然过粗。 逐组量化 逐组量化也叫：\nPer-group quantization； Group-wise quantization； Block-wise quantization。 它是介于逐层量化和逐元素量化之间的一种粒度。\n对于线性层权重：\n$$ W\\in\\mathbb{R}^{C_{\\mathrm{out}}\\times C_{\\mathrm{in}}}, $$通常的做法是：\n每个输出通道单独处理； 再把一行中的权重沿输入维度切成若干组； 每组使用一套量化参数。 假设：\n$$ W\\in\\mathbb{R}^{4\\times 8}, $$group size 为 4，则每一行被分成两组：\nW[0, 0:4] ─── scale_00 W[0, 4:8] ─── scale_01 W[1, 0:4] ─── scale_10 W[1, 4:8] ─── scale_11 W[2, 0:4] ─── scale_20 W[2, 4:8] ─── scale_21 W[3, 0:4] ─── scale_30 W[3, 4:8] ─── scale_31 总共有：\n$$ 4\\times 2=8 $$个 scale。\n数学表示 假设 group size 是 $G$，元素 $W_{ij}$ 所在的组为：\n$$ g=\\left\\lfloor\\frac{j}{G}\\right\\rfloor. $$量化公式是：\n$$ Q_{ij} =\\operatorname{round}\\left(\\frac{W_{ij}}{s_{i,g}}\\right)+z_{i,g}, \\qquad g=\\left\\lfloor\\frac{j}{G}\\right\\rfloor. $$此时 scale 不再只依赖输出通道 $i$，还依赖当前元素位于第几个组 $g$。\n一个更有代表性的例子 假设一行权重为：\n$$ W_{0,:}=[0.1,0.2,0.3,0.4,10,12,9,11]. $$如果采用逐通道量化，这一行的最大绝对值是 $12$。使用 INT8 对称量化时：\n$$ s=\\frac{12}{127}\\approx 0.0945. $$前半部分的 $0.1$ 只能量化为：\n$$ q=\\operatorname{round}\\left(\\frac{0.1}{0.0945}\\right)=1. $$反量化结果为：\n$$ \\widehat{x}=1\\times 0.0945=0.0945. $$对于 $0.2$：\n$$ q=2,\\qquad \\widehat{x}=0.189. $$虽然在 INT8 下还可以接受，但如果换成 INT4，问题会严重得多。INT4 对称量化通常使用：\n$$ [-7,7]. $$整行共用的 scale 为：\n$$ s=\\frac{12}{7}\\approx 1.714. $$这样，$0.1$、$0.2$、$0.3$、$0.4$ 可能全部被量化为 0。\n如果每 4 个元素一组，第一组为：\n$$ [0.1,0.2,0.3,0.4], $$使用：\n$$ s_0=\\frac{0.4}{7}\\approx 0.0571. $$第二组为：\n$$ [10,12,9,11], $$使用：\n$$ s_1=\\frac{12}{7}\\approx 1.714. $$这样，小数值部分不会再被大数值部分压制。\ngroup size 的含义 常见的 group size 包括：\n$$ 32,\\quad 64,\\quad 128,\\quad 256. $$例如某个权重矩阵为：\n$$ W\\in\\mathbb{R}^{4096\\times 4096}. $$使用 group size = 128 时，每个输出通道包含：\n$$ \\frac{4096}{128}=32 $$个组。总 scale 数量为：\n$$ 4096\\times 32=131072. $$相比之下，逐通道量化只有：\n$$ 4096 $$个 scale。因此，逐组量化的精度更高，但需要保存更多量化参数。\n三种量化方法的直接对比 仍然假设权重矩阵为：\n$$ W\\in\\mathbb{R}^{4096\\times 4096}, $$并且逐组量化的 group size 为 128：\n量化粒度 一套参数覆盖范围 scale 数量 逐层 整个 $4096\\times4096$ 矩阵 $1$ 逐通道 每个输出通道的一整行 $4096$ 逐组 每行每 128 个权重一组 $4096\\times32=131072$ 从粗到细排列：\n逐层 → 逐通道 → 逐组 → 逐元素 粒度越细：\nscale 越多； 对局部数值分布的拟合越准确； 量化误差越小； 元数据和计算复杂度越高。 总结 量化粒度本质上决定了多少个权重共享一套量化参数：\n逐层量化开销最小、实现最简单，但最容易被跨通道的数值范围差异影响； 逐通道量化为每个输出通道保留独立参数，在精度和元数据之间取得了较好的平衡； 逐组量化继续沿输入维度拆分权重，尤其适合 INT4 等低比特大模型权重量化，但会引入更多 scale、元数据访问和反量化计算。 实际选择不能只看精度，还要同时考虑目标位宽、权重分布、硬件支持以及计算内核能否高效消费相应粒度的量化参数。\n","permalink":"https://zhangge.dev/model-inference/quantization-granularity/","summary":"量化粒度决定多少个权重共享一套量化参数。本文通过一个 4×8 权重矩阵，逐步推导逐层、逐通道与逐组量化的公式、误差、优缺点和适用场景。","title":"大模型量化粒度详解：逐层、逐通道与逐组量化"},{"content":" 核心直觉： 模型量化是在更少的离散数值中近似表示权重和激活，用可控的数值误差换取更低的存储、显存、带宽和计算成本。\n什么是模型量化 神经网络训练完成后，权重通常使用 FP32、FP16 或 BF16 保存。模型量化（Model Quantization）则用更低位宽的数据类型近似表示权重、激活或中间计算结果。\n仅考虑权重时，不同数据类型的理论存储成本如下：\n数据类型 每个参数的位数 70 亿参数的理论权重大小 FP32 32 bit 约 28 GB FP16 / BF16 16 bit 约 14 GB INT8 8 bit 约 7 GB INT4 4 bit 约 3.5 GB 因此，将 FP16 权重转成 INT4 后，理论权重大小会缩小到四分之一。实际模型还需要保存 scale、zero point、分组索引等元数据，所以压缩比通常略低于理论值。\n量化也不只是“把小数改成整数”。它需要回答三个问题：\n如何建立浮点数与低精度编码之间的映射； 如何控制舍入、截断和异常值造成的误差； 如何让部署硬件和推理框架真正利用低精度数据。 数据类型，数值范围和表示精度 浮点数如何组成 对于规格化浮点数，数值可以抽象为：\n$$ x=(-1)^s\\times 2^E\\times m, $$其中符号位 $s$ 决定正负，实际指数 $E$ 决定整体数量级，尾数 $m$ 决定这个数量级上的具体比例。实际编码中保存的是带偏置的指数域，解码后才得到这里的 $E$。\n关键在于：$m$ 不是任意实数，而是由有限个尾数位拼成的离散值。正因为 $m$ 只能取有限个值，同一个指数区间内才会存在固定的相邻浮点数间隔。\n尾数是如何组成的 以具有 23 位尾数域的规格化 FP32 为例，其有效尾数可写成：\n$$ m=(1.f)_2 =1+b_1 2^{-1}+b_2 2^{-2}+\\cdots+b_{23}2^{-23}, \\qquad b_i\\in\\{0,1\\}. $$前面的 1 称为隐含位或隐藏位。规格化数的最高有效位一定是 1，因此编码时不必保存它；FP32 虽然只存储 23 个尾数位，却具有 24 位二进制有效精度。FP16 同理：存储 10 个尾数位，加上隐含的最高位 1，共有 11 位二进制有效精度。\n下标 2 表示这个数采用二进制表示，并不是“乘以 2”。例如：\n$$ \\begin{aligned} (1.101)_2 \u0026=1\\times2^0+1\\times2^{-1}+0\\times2^{-2}+1\\times2^{-3}\\\\ \u0026=1+\\frac{1}{2}+\\frac{1}{8}=1.625. \\end{aligned} $$因此，尾数的最后一位决定了同一指数区间内能够调整的最小步长。对具有 $p$ 个显式尾数位的规格化浮点格式，这个最小尾数增量是 $2^{-p}$。\n类型 符号位 指数位 尾数位 FP32 1 8 23 FP16 1 5 10 BF16 1 8 7 为什么 FP16 在 1 附近的间隔是 $2^{-10}$ FP16 和 BF16 都占 16 bit，却有不同取舍：FP16 把更多位留给尾数，BF16 把更多位留给指数。\n在 $[1,2)$ 区间内，实际指数 $E=0$。FP16 的规格化数可以写成：\n$$ x=2^0\\times \\left( 1+b_1 2^{-1}+b_2 2^{-2}+\\cdots+b_{10}2^{-10} \\right). $$尾数域有 10 位，最后一位的权重是 $2^{-10}$。所以 1 后面的下一个 FP16 数为：\n$$ 1+2^{-10}=1.0009765625, $$相邻间隔就是：\n$$ \\Delta_{\\mathrm{FP16}} =2^{-10} =0.0009765625. $$这里使用 $-10$ 是因为正在计算尾数最低位的权重，不是因为指数域有多少位。FP16 的 5 位指数域决定可以编码哪些数量级，并不直接决定同一数量级内的相邻间隔。若误用 $2^{-5}=0.03125$，相当于把指数位数误当成了尾数精度。\n同理，BF16 有 7 个尾数位，因此在 1 附近：\n$$ \\Delta_{\\mathrm{BF16}}=2^{-7}=0.0078125. $$浮点数的间隔也不是处处相同。对于具有 $p$ 个显式尾数位的规格化数，在 $[2^E,2^{E+1})$ 内：\n$$ \\operatorname{ULP}=2^{E-p}. $$所以 FP16 在 $[2,4)$ 内的间隔是 $2^{1-10}=2^{-9}$，在 $[1024,2048)$ 内的间隔则是 $2^{10-10}=1$。数值越大，相邻可表示数通常也离得越远。这个公式只描述规格化数；靠近 0 的非规格化数还需要单独讨论。\n数值范围对比 数据类型 最大有限正数 最小正规正数 最小非正规正数 FP32 约 $3.40\\times10^{38}$ 约 $1.18\\times10^{-38}$ 约 $1.40\\times10^{-45}$ FP16 $65504$ 约 $6.10\\times10^{-5}$ 约 $5.96\\times10^{-8}$ BF16 约 $3.39\\times10^{38}$ 约 $1.18\\times10^{-38}$ 约 $9.18\\times10^{-41}$ INT8 $[-128,127]$ — — INT4 $[-8,7]$ — — FP32 FP32 有：\n1 位符号； 8 位指数； 23 位尾数。 它大约能保留 7 位十进制有效数字。例如 1.234567 可以较准确地表示，而 1.23456789 通常会发生舍入。\n在 1 附近，FP32 相邻两个数之间的间隔大约是：\n$$ 2^{-23}\\approx1.19\\times10^{-7}. $$例如，1.0000000 和 1.000000119... 大致是相邻的两个 FP32 数。\nFP32 的特点是：\n范围大； 精度高； 存储和计算开销大； 常用于训练中的高精度累加、归一化和优化器状态。 FP16 FP16 有：\n1 位符号； 5 位指数； 10 位尾数。 它大约能保留 3～4 位十进制有效数字。在 1 附近，相邻数的间隔为：\n$$ 2^{-10}=0.0009765625. $$也就是说，FP16 在 1 附近大致能够区分：\n1.0000 1.0009765625 1.001953125 但不能准确表示它们之间的所有数。\nFP16 最大有限值只有：\n$$ 65504. $$因此 FP16 的主要问题是动态范围比较小，训练时比较容易：\n上溢出为 infinity； 下溢出为 0； 丢失小梯度。 BF16 BF16 是 Brain Floating Point 16。它有：\n1 位符号； 8 位指数； 7 位尾数。 BF16 的指数位和 FP32 一样，所以它的数值范围与 FP32 基本相同，但精度低得多。在 1 附近，BF16 的间隔为：\n$$ 2^{-7}=0.0078125. $$也就是说，相邻值大致是：\n1.0000000 1.0078125 1.0156250 BF16 大约只能保留 2～3 位十进制有效数字。\nFP16 和 BF16 的核心区别是：\n对比项 FP16 BF16 尾数精度 较高 较低 数值范围 较小 很大，接近 FP32 训练稳定性 较容易溢出 通常更稳定 1 附近的间隔 约 $0.00098$ 约 $0.00781$ 可以理解为：FP16 用更多位表示“精细程度”，BF16 用更多位表示“数值大小范围”。\nINT8 有符号 INT8 的原生范围是：\n$$ [-128,127]. $$共有：\n$$ 2^8=256 $$个整数编码。INT8 对整数是精确表示的，相邻整数之间固定相差 1。\n但模型量化中并不是说原始权重只能位于 $[-128,127]$，而是通过 scale 把一个浮点范围映射到这 256 个整数上。例如：\n$$ s=0.01. $$那么：\nq = 1 → 0.01 q = 2 → 0.02 q = 100 → 1.00 此时量化后的浮点表示范围大约是：\n$$ [-1.28,1.27], $$相邻浮点量化值之间的间隔是 0.01。\n工程中的对称 INT8 量化经常只使用：\n$$ [-127,127], $$而不使用 $-128$，这样正负两侧可以保持完全对称。\nINT4 有符号 INT4 的原生范围是：\n$$ [-8,7]. $$共有：\n$$ 2^4=16 $$个整数编码。对称量化中也经常使用 $[-7,7]$，即 15 个对称取值。\n假设 scale 是：\n$$ s=0.1, $$那么：\nq = -7 → -0.7 q = -1 → -0.1 q = 0 → 0 q = 1 → 0.1 q = 7 → 0.7 INT4 只有 16 个编码，因此在相同浮点范围下，它的量化步长远大于 INT8，精度也更低。\n量化的基本数学原理 浮点数到整数的映射 假设原始浮点数为 $x$，量化后的整数为 $q$。最常见的线性量化公式是：\n$$ q=\\operatorname{clamp}\\left( \\operatorname{round}\\left(\\frac{x}{s}\\right)+z,\\, q_{\\min},q_{\\max} \\right). $$其中：\n$s$：scale，缩放因子； $z$：zero point，零点； $q_{\\min}$、$q_{\\max}$：整数类型能够表示的最小值和最大值； $\\operatorname{round}$：把结果舍入到整数； $\\operatorname{clamp}$：把结果限制在整数可表示范围内。 反量化公式是：\n$$ \\hat{x}=s(q-z). $$其中 $\\hat{x}$ 是从量化整数恢复出的近似浮点数。需要注意：\n$$ \\hat{x}\\ne x. $$两者之间的差值就是量化误差：\n$$ e=x-\\hat{x}. $$为什么要有反量化 量化后的 $q$ 不是原始物理数值，而更像是一个离散编号。例如：\n$$ q=5. $$单独看这个 5，并不知道它代表 $5.0$、$0.5$、$0.05$ 还是 $50$。只有结合 scale 和 zero point，才能知道它代表的浮点值。反量化公式仍然是：\n$$ \\hat{x}=s(q-z). $$例如：\n$$ q=5,\\qquad s=0.1,\\qquad z=0, $$则：\n$$ \\hat{x}=0.1\\times(5-0)=0.5. $$因此，这里的整数编码 $q=5$，实际代表的近似浮点值是 $0.5$。\n为什么计算中需要反量化 假设权重被保存成 INT4，但激活仍然是 FP16，原始矩阵乘法为：\n$$ Y=XW, $$其中：\n$X$：FP16 激活； $W$：INT4 量化权重。 INT4 权重实际表示的是：\n$$ W\\approx s_wQ_w. $$所以计算变成：\n$$ Y\\approx X(s_wQ_w). $$硬件需要处理 scale，才能把整数权重解释为正确的数值。实际的高性能 kernel 通常不会先在显存中生成一份完整的 FP16 权重，而是执行：\n读取打包的 INT4 权重 ↓ 解包 INT4 ↓ 读取对应的 scale ↓ 在寄存器或共享内存中恢复近似值 ↓ 执行矩阵乘法 这也属于广义的反量化过程。\n对于 W8A8 整数矩阵乘法，可以先执行：\n$$ Q_xQ_w, $$得到 INT32 累加结果，然后在输出阶段乘以：\n$$ s_xs_w, $$恢复到实际数值尺度。\nscale 和 zero point 分别是什么 Scale：决定整数每一格代表多大的浮点范围。\n假设需要用 INT8 表示浮点范围：\n$$ [-1.27,1.27]. $$INT8 的有符号整数范围是：\n$$ [-128,127]. $$可以设置：\n$$ s=0.01. $$这意味着整数每增加 1，对应浮点数增加 0.01：\n整数 $q$ 反量化结果 $-100$ $-1.00$ $-50$ $-0.50$ $0$ $0.00$ $50$ $0.50$ $100$ $1.00$ 例如原始数据为：\n$$ x=0.537. $$量化为：\n$$ q=\\operatorname{round}\\left(\\frac{0.537}{0.01}\\right)=54. $$反量化结果为：\n$$ \\hat{x}=54\\times0.01=0.54. $$量化误差为：\n$$ e=0.537-0.54=-0.003. $$因此，scale 决定了量化的分辨率。scale 越小，整数的一格越细，精度越高；但同时可覆盖的浮点范围会越小，更容易发生溢出或截断。\nZero point：指定浮点数 0 对应哪个整数。\n在一些量化方式中，浮点数 0 不一定映射到整数 0，因此需要 zero point。例如使用无符号 INT8：\n$$ q\\in[0,255], $$而浮点数范围为：\n$$ x\\in[-1,3]. $$因为整数只能表示非负数，所以需要把整个区间平移。缩放因子为：\n$$ s=\\frac{x_{\\max}-x_{\\min}}{q_{\\max}-q_{\\min}} =\\frac{3-(-1)}{255} =\\frac{4}{255}. $$zero point 可以近似计算为：\n$$ z=q_{\\min}-\\frac{x_{\\min}}{s} =0-\\frac{-1}{4/255} =63.75 \\approx64. $$此时：\n浮点数 整数编码 $-1$ $0$ $0$ $64$ $3$ $255$ 因此，zero point 的作用是平移量化区间，使浮点数 0 能够被准确表示。\n量化步长 量化步长通常就是 scale：\n$$ \\Delta=s. $$它表示相邻两个反量化值之间的距离。假设：\n$$ s=0.1, $$那么可表示的值是：\n... -0.3、-0.2、-0.1、0、0.1、0.2、0.3 ... 相邻值相差 0.1，所以量化步长是 0.1。\n对称量化和非对称量化 根据 zero point 是否为 0，线性量化通常分为两类。\n对称量化 对称量化将浮点数范围设置成关于 0 对称的区间：\n$$ [-\\alpha,\\alpha]. $$通常令：\n$$ z=0. $$量化公式简化为：\n$$ q=\\operatorname{clamp}\\left( \\operatorname{round}\\left(\\frac{x}{s}\\right),q_{\\min},q_{\\max} \\right). $$例如，对称 INT8 量化常使用：\n$$ q\\in[-127,127]. $$scale 为：\n$$ s=\\frac{\\max(|x_{\\min}|,|x_{\\max}|)}{127}. $$假设一组权重范围是：\n$$ [-1.0,0.6]. $$为了保证所有数据都能表示，需要将范围扩展成：\n$$ [-1.0,1.0]. $$此时正数部分的 $0.6\\sim1.0$ 区间实际上没有被利用。\n对称量化的优势是：\n计算简单； zero point 为 0； 更适合矩阵乘法； 硬件实现效率通常更高； 常用于权重量化。 缺点是：当数据分布明显不对称时，可能浪费整数表示范围。\n非对称量化 非对称量化直接覆盖实际范围：\n$$ [x_{\\min},x_{\\max}]. $$scale 为：\n$$ s=\\frac{x_{\\max}-x_{\\min}}{q_{\\max}-q_{\\min}}. $$zero point 通常不为 0。例如数据范围是：\n$$ [-1.0,0.6]. $$非对称量化会完整利用整数空间来表示这个范围，而不必扩展到 $[-1,1]$。\n优势是：\n对偏斜的数据分布利用率更高； 通常可以获得更小的量化误差； 常用于激活值量化。 缺点是：\n计算中需要处理 zero point； 矩阵乘法实现更复杂； 某些硬件上的执行效率不如对称量化。 “权重用对称量化、激活用非对称量化”是常见经验，不是普遍定律。实际选择还取决于数据分布、算子实现和硬件后端。\n量化如何用于矩阵乘法 神经网络的大部分计算可以归结为：\n$$ Y=XW. $$其中 $X$ 是输入或激活值，$W$ 是模型权重，$Y$ 是输出。\n假设原始激活矩阵是：\n$$ X= \\begin{bmatrix} 0.1\u00260.3\\\\ -0.5\u00260.8 \\end{bmatrix}. $$对它执行对称 INT8 量化。如果选择：\n$$ s_x=0.01, $$那么：\n$$ Q_x=\\operatorname{round}\\left(\\frac{X}{s_x}\\right)= \\begin{bmatrix} 10\u002630\\\\ -50\u002680 \\end{bmatrix}. $$于是：\n$$ X\\approx s_xQ_x \\approx0.01 \\begin{bmatrix} 10\u002630\\\\ -50\u002680 \\end{bmatrix}. $$对称量化后，激活和权重可近似写成：\n$$ X\\approx s_xQ_x, \\qquad W\\approx s_wQ_w. $$代入矩阵乘法：\n$$ Y\\approx(s_xQ_x)(s_wQ_w) =s_xs_w(Q_xQ_w). $$$Q_xQ_w$ 可以由低精度整数矩阵乘法完成。INT8 乘法通常使用 INT32 累加，因为单个最大正数乘积已经达到：\n$$ 127\\times127=16129, $$矩阵乘法还要累加大量乘积，INT8 无法容纳结果。典型 W8A8 数据流可以概括为：\nFP16 / BF16 激活 ↓ 量化 INT8 激活 × INT8 权重 ↓ INT32 累加 ↓ 乘输出 scale FP16 / BF16 或量化输出 权重 INT4、激活 FP16 的 weight-only 方案则通常在高性能 kernel 内解包权重、读取分组 scale，并在寄存器或共享内存中完成反量化与乘法融合，而不是先在显存中生成完整的 FP16 权重副本。\n量化本质 量化本质上是一个离散化过程。\n从几何角度理解，量化相当于在数轴上设置一系列固定的离散点。例如 scale 为 0.1 时，可表示的值包括：\n... -0.3、-0.2、-0.1、0、0.1、0.2、0.3 ... 原始浮点数会被映射到距离最近的离散点：\n0.14 → 0.1 0.17 → 0.2 0.26 → 0.3 这相当于把连续空间切分为多个区间：\n[0.05, 0.15) → 0.1 [0.15, 0.25) → 0.2 [0.25, 0.35) → 0.3 每个整数值代表一个量化桶，也可以称为 quantization bin。位宽越低，可使用的桶越少：\nINT8：约 256 个取值； INT4：16 个取值； INT2：4 个取值。 因此，位宽越低，量化越激进，同一个桶需要覆盖更大的浮点范围，量化误差通常也越大。\n一个完整的 INT8 量化示例 假设权重为：\n$$ W=[-1.0,-0.5,0,0.3,0.8]. $$最大绝对值 $\\alpha=1.0$，因此：\n确定量化范围 最大绝对值为：\n$$ \\alpha=\\max(|W|)=1.0. $$INT8 使用：\n$$ [-127,127]. $$因此：\n$$ s=\\frac{1.0}{127}\\approx0.007874. $$执行量化 使用：\n$$ q=\\operatorname{round}\\left(\\frac{x}{s}\\right). $$量化和反量化结果为：\n原始值 $x$ 量化值 $q$ 反量化值 $\\hat{x}$ $x-\\hat{x}$ -1.0 -127 -1.0000 0.0000 -0.5 -64 -0.5039 0.0039 0 0 0 0 0.3 38 0.2992 0.0008 0.8 102 0.8031 -0.0031 量化后的整数权重为：\n$$ Q_W=[-127,-64,0,38,102]. $$反量化 使用：\n$$ \\hat{x}=sq, $$得到：\n$$ \\hat{W}\\approx[-1.0000,-0.5039,0,0.2992,0.8031]. $$下面的 Python 代码复现了这个过程：\nfrom __future__ import annotations import numpy as np def symmetric_int8_quantize( values: np.ndarray, ) -\u0026gt; tuple[np.ndarray, float]: values = values.astype(np.float32) max_abs = float(np.max(np.abs(values))) if max_abs == 0.0: return np.zeros_like(values, dtype=np.int8), 1.0 scale = max_abs / 127.0 quantized = np.clip( np.rint(values / scale), -127, 127, ).astype(np.int8) return quantized, scale weights = np.array([-1.0, -0.5, 0.0, 0.3, 0.8]) quantized, scale = symmetric_int8_quantize(weights) restored = quantized.astype(np.float32) * scale print(f\u0026#34;scale: {scale:.8f}\u0026#34;) print(\u0026#34;quantized:\u0026#34;, quantized.tolist()) print(\u0026#34;restored:\u0026#34;, np.round(restored, 4).tolist()) print(\u0026#34;error:\u0026#34;, np.round(weights - restored, 4).tolist()) 这里使用 np.rint 表示舍入到最近整数。具体后端可能采用 ties-to-even 等明确的舍入规则，比较不同框架时需要确认这一细节。\n量化误差从何而来 舍入误差 原始值不能恰好落在量化网格上。例如可表示值为：\n0.0、0.1、0.2、0.3 而原始值是 0.17，只能近似映射为 0.2。误差为：\n$$ 0.17-0.2=-0.03. $$截断误差 如果原始值超出了量化范围，会被 clamp 到边界。例如量化范围是 $[-1,1]$，但原始值为 1.5，则只能量化为最大值对应的 1.0：\n$$ 1.5\\longrightarrow1.0. $$误差达到 0.5。这种情况也被称为 clipping 或 saturation。\n异常值导致量化范围过大 假设大多数激活值位于 $[-1,1]$，但存在少数异常值 $10$ 和 $-12$。如果按照绝对最大值确定量化范围，就需要覆盖：\n$$ [-12,12]. $$对于 INT8，量化步长约为：\n$$ s=\\frac{12}{127}\\approx0.0945. $$这意味着大量位于 $[-1,1]$ 的普通数值只能使用很少的整数档位表示，精度明显下降。\n如果忽略异常值，将量化范围限制在 $[-1,1]$，普通数值的精度会提高，但异常值会被截断。\n因此，量化范围选择本质上是在平衡两种误差：\n范围过大：舍入误差增大； 范围过小：截断误差增大。 误差在网络层间传播 单个权重的量化误差可能很小，但神经网络包含很多层。例如：\n$$ \\begin{aligned} h_1\u0026=f(W_1x),\\\\ h_2\u0026=f(W_2h_1),\\\\ h_3\u0026=f(W_3h_2), \\end{aligned} $$当 $W_1$ 和 $h_1$ 发生量化误差后，误差会进入下一层，并可能逐步传播或放大。\n因此，评估量化不能只比较单个权重的误差，还要评估：\n单层输出误差； 隐藏状态差异； logits 差异； 最终任务精度； 生成文本质量。 低位宽量化通常还需要更细的粒度，例如 per-channel、per-group 或 per-block scale，避免一个异常值控制过大的张量范围。\n为什么量化能加速推理 降低显存占用 以 FP16 转 INT4 为例：\n$$ 16\\ \\mathrm{bit}\\longrightarrow4\\ \\mathrm{bit}. $$理论权重大小缩小到四分之一。这意味着：\n更大的模型可以放入单卡； 可以增加并发数； 可以增加 KV Cache 容量； 可以减少多卡切分。 降低显存带宽压力 大模型推理，尤其是 decode 阶段，经常是 memory-bound，而不是 compute-bound。每生成一个 token，都需要从显存读取大量模型权重。\n一个 FP16 权重需要读取 2 字节，而 INT4 权重只需要读取 0.5 字节。理论上，权重读取量下降到四分之一。因此，即使需要额外进行反量化，减少的显存访问成本仍可能带来明显加速。\n使用低精度硬件计算单元 现代 GPU、NPU 和 CPU 通常具有针对低精度数据的专用计算单元，例如：\nINT8 Tensor Core； INT4 Tensor Core； FP8 Tensor Core； CPU VNNI、AMX； NPU 低精度矩阵乘单元。 相同面积和功耗下，低位宽计算通常可以实现更高吞吐。\n但要注意：模型变小，不代表推理一定会按位宽比例加速。例如 FP16 转 INT4 后，模型大小理论上缩小 4 倍，但实际性能不一定提升 4 倍，因为还存在：\n反量化开销； scale 读取开销； 数据解包开销； 算子启动开销； 非矩阵乘法算子； KV Cache 和激活开销； 硬件是否原生支持 INT4； batch size 和序列长度的影响。 静态量化和动态量化 静态量化会提前确定量化参数，例如 scale 和 zero point。通常需要准备一批校准数据，运行模型并统计激活范围：\n校准数据 ↓ 收集各层激活分布 ↓ 确定 scale / zero point ↓ 保存量化模型 推理时直接使用预先计算好的量化参数。\n优势：\n推理额外开销较小； 更容易使用高性能量化算子； 量化参数固定。 缺点：\n强依赖校准数据； 校准数据和线上数据分布不一致时，精度可能下降； 难以处理输入相关的动态异常值。 为什么需要校准数据 模型权重是固定的，因此可以直接观察权重范围。但激活值依赖输入。例如同一层，在不同输入下可能出现：\n输入 A：激活范围 [-1, 1] 输入 B：激活范围 [-4, 5] 输入 C：激活范围 [-20, 18] 在实际推理前，并不知道典型激活范围是多少。静态量化又要求推理前提前确定 scale，因此需要用一批代表性输入运行模型，观察激活分布。这批输入就是校准数据。\n校准过程 基本过程是：\n准备代表性数据 ↓ 用浮点模型执行前向传播 ↓ 记录每一层激活 ↓ 统计 min、max、直方图、百分位数 ↓ 选择 clipping 范围 ↓ 计算 scale 和 zero point 例如某一层采集到的激活范围是：\n$$ [-8.2,7.5]. $$最简单的方法是使用：\n$$ a_{\\max}=8.2. $$那么对称 INT8 scale 为：\n$$ s=\\frac{8.2}{127}\\approx0.0646. $$推理时，这一层就固定使用这个 scale。\n动态量化 动态量化在推理过程中，根据当前输入实时计算 scale。例如，对每个 token 的激活向量动态计算：\n$$ s=\\frac{\\max(|x|)}{127}. $$优势：\n能适应不同输入； 对激活分布变化更稳健； 通常不需要复杂校准。 缺点：\n需要实时计算最大值； 需要执行归约操作； 引入额外 kernel 和同步开销； 小 batch 场景下开销可能更明显。 权重量化通常是静态的，因为模型权重固定；激活量化则可以是静态或动态的。\n如何衡量量化效果的好坏 量化评估不能只看一个指标，至少应该分为五个层次：\n层次 关注内容 常见指标 张量数值 量化前后的近似程度 MSE、MAE、Relative L2、SQNR 层输出 误差在哪些层被放大 Output MSE、Cosine Similarity 输出分布 token 决策是否改变 Logits MSE、KL 散度、Top-k 重合率 模型能力 最终任务是否退化 PPL、Accuracy、F1、pass@1 系统收益 部署是否真正获益 显存、TTFT、TPOT、吞吐、功耗 第一层：张量数值误差 均方误差 MSE 为：\n$$ \\operatorname{MSE} =\\frac{1}{N}\\sum_{i=1}^{N}(x_i-\\hat{x}_i)^2, $$MSE 越小，说明恢复值与原值越接近，但它对大数值误差比较敏感。\n平均绝对误差 MAE 为：\n$$ \\operatorname{MAE} =\\frac{1}{N}\\sum_{i=1}^{N}|x_i-\\hat{x}_i|. $$MAE 表示平均绝对误差，相比 MSE 不会过度放大少量大误差。\n相对 L2 误差为：\n$$ \\operatorname{RelativeL2} =\\frac{\\|X-\\hat{X}\\|_2}{\\|X\\|_2}. $$相比直接看 MSE，它考虑了张量自身的数值尺度。\n余弦相似度为：\n$$ \\cos(X,\\hat{X}) =\\frac{X\\cdot\\hat{X}}{\\|X\\|_2\\|\\hat{X}\\|_2}. $$它主要衡量方向是否一致。对于隐藏状态，余弦相似度通常比绝对值误差更直观。\n信号量化噪声比 SQNR 为：\n$$ \\operatorname{SQNR} =10\\log_{10}\\left( \\frac{\\sum_i x_i^2} {\\sum_i(x_i-\\hat{x}_i)^2} \\right). $$单位是 dB。SQNR 越高，说明量化噪声相对于原始信号越小。\n第二层：层输出误差 仅比较权重通常不够，还应比较每一层输出。原始层输出与量化层输出分别为：\n$$ Y=XW, \\qquad \\hat{Y}=X\\hat{W}. $$可以测量：\nLayer output MSE； Relative L2； Cosine similarity； 每层最大误差； 不同 token 的误差分布。 这样可以定位量化敏感层。例如：\n第 1～10 层 cosine \u0026gt; 0.999 第 11 层 cosine = 0.993 第 12 层 cosine = 0.94 第 12 层很可能需要：\n更高位宽； 更小 group size； 更好的 clipping； 保留 FP16。 第三层：logits 和概率分布误差 对于语言模型，可以比较 FP16 模型和量化模型输出的 logits。\nLogits MSE：\n$$ \\operatorname{MSE}(L,\\hat{L}). $$KL 散度： 假设原模型概率分布为 $P$，量化模型概率分布为 $Q$：\n$$ D_{\\mathrm{KL}}(P\\|Q) =\\sum_i P_i\\log\\frac{P_i}{Q_i}. $$KL 越小，表示两个 token 概率分布越接近。\nTop-1 一致率： 统计原模型和量化模型 argmax token 相同的比例。例如：\nFP16 top-1 token：北京 INT4 top-1 token：上海 即使两个 logits 差值不大，也可能改变贪心解码结果。还可以观察：\nTop-5 overlap； token rank correlation； greedy token agreement。 第四层：模型任务效果 这是最重要的一层。\n语言模型常使用困惑度 PPL：\n$$ \\operatorname{PPL} =\\exp\\left( -\\frac{1}{N}\\sum_{i=1}^{N}\\log p(x_i\\mid x_{\\lt i}) \\right). $$一般来说，PPL 越低越好。例如：\n模型 PPL FP16 6.20 INT8 6.23 INT4 6.58 不要只看绝对值，也要看相对变化：\n$$ \\Delta\\operatorname{PPL} =\\operatorname{PPL}_{\\mathrm{quant}} -\\operatorname{PPL}_{\\mathrm{fp16}}. $$下游任务 根据模型用途评估：\n分类：Accuracy、F1； 阅读理解：EM、F1； 翻译：BLEU、COMET； 摘要：ROUGE； 代码：pass@1； 数学：最终答案准确率； 大模型综合能力：MMLU、CMMLU、C-Eval、GSM8K 等。 量化模型应与原始模型使用相同的：\nprompt； tokenizer； generation 参数； 数据集； 评测脚本。 生成质量评估 开放式生成还需要评估：\n指令遵循； 事实性； 连贯性； 重复； 格式遵循； 长文本稳定性； 代码可执行性； 数学推理正确性。 可以采用：\n人工 pairwise 比较； 规则评测； LLM-as-a-judge； 原模型与量化模型输出一致率。 不过 LLM-as-a-judge 本身存在偏差，不能单独使用。\n第五层：系统性能 量化不是只为了精度，还要真正获得系统收益。\n模型体积 需要比较：\nFP16 权重大小 INT8 权重大小 INT4 权重大小 注意 scale、zero point、索引等元数据也占空间，因此实际压缩比小于理论值。\n显存占用 需要分别测量：\n模型权重显存； KV Cache； 临时 workspace； 峰值显存； 不同 batch 下的显存。 延迟 大模型推理一般关注：\nTTFT：首 token 延迟； TPOT：每个输出 token 的时间； ITL：token 间延迟； 端到端 latency。 吞吐 常见指标包括：\noutput tokens/s； total tokens/s； requests/s； 不同并发下的吞吐。 性能收益是否真实 一个 INT4 模型如果显存下降 70%，但吞吐只提升 3%，可能说明：\nkernel 没有优化； 反量化开销过高； 当前场景不是 memory-bound； 非量化算子占比高； batch 或矩阵形状不适合 kernel。 PTQ 和 QAT PTQ：训练后量化 PTQ 即 Post-Training Quantization。流程是：\n训练好的浮点模型 ↓ 收集校准数据 ↓ 分析权重和激活分布 ↓ 计算量化参数 ↓ 生成量化模型 不需要重新进行完整训练。\n优势：\n成本低； 使用方便； 适合已有大模型； 是当前大模型量化的主要路线。 缺点：\n低位宽下精度损失可能较明显； 对异常值和敏感层处理要求较高。 常见的大模型 PTQ 方法包括权重重构、激活平滑、异常值保护和误差补偿等。\nQAT：量化感知训练 QAT 即 Quantization-Aware Training。训练过程中模拟量化和反量化：\n$$ x\\longrightarrow Q(x)\\longrightarrow D(Q(x))\\longrightarrow\\hat{x}. $$前向传播中，模型看到的是带有量化误差的数值；反向传播则更新浮点权重，使模型逐渐适应量化误差。\n常使用伪量化：\nFP32 权重 ↓ 模拟量化 INT8 数值 ↓ 立即反量化 FP32 近似值 ↓ 继续训练 这里通常并不会真的使用整数执行训练，而是在浮点计算中模拟量化行为。\n优势：\n模型能够主动适应量化误差； 低位宽下通常比 PTQ 精度更好； 更适合 INT4、INT2 等激进量化。 缺点：\n需要训练数据； 训练成本较高； 大模型训练资源要求高； 训练流程和分布式系统更复杂。 线性量化并不是唯一方法 前文属于均匀线性量化。它要求相邻整数之间对应固定的浮点间隔：\n$$ \\hat{x}_{q+1}-\\hat{x}_q=s. $$除此之外，还有非均匀量化。例如构造一个码本：\nCode 0 → -1.0 Code 1 → -0.4 Code 2 → -0.1 Code 3 → 0.0 Code 4 → 0.1 Code 5 → 0.4 Code 6 → 1.0 数值映射到距离最近的码本元素。\n这种方法适合数据分布不均匀的情况。例如权重大量集中在 0 附近，就可以在 0 附近设置更密集的离散值。常见思想包括：\ncodebook quantization； k-means quantization； NormalFloat； logarithmic quantization； vector quantization。 但其硬件实现通常比线性整数矩阵乘法更复杂。\n常见误区 INT4 模型一定比 FP16 快 4 倍。 位宽只决定理论数据量，真实速度还受硬件、kernel、解包和工作负载限制。 反量化会还原原始值。 反量化只能恢复量化网格上的近似值，舍入和截断造成的信息已经丢失。 量化只影响权重。 激活、KV Cache 和中间累加也可以采用不同精度；W4A16、W8A8 等命名正是在区分权重与激活位宽。 量化误差小，模型质量就一定不变。 小的局部误差可能跨层放大，也可能改变 logits 排名和最终生成结果。 低精度数据都属于整数线性量化。 FP8、NormalFloat、码本与向量量化有不同的表示和计算机制，不能套用同一组公式。 总结 模型量化的本质是离散化：用有限编码近似连续浮点值。scale 决定网格间距，zero point 决定网格原点；对称量化实现简单，非对称量化更充分地利用偏斜范围。进入矩阵乘法后，低精度整数乘法通常配合更高精度累加，并在输出阶段恢复数值尺度。\n量化是否成功，最终取决于两个条件：模型质量损失是否可接受，以及目标硬件上的显存、延迟和吞吐是否真的改善。只有把数值误差、模型能力和系统性能放在一起评估，才有意义。\n参考 NVIDIA TensorRT：Working with Quantized Types PyTorch：Static Quantization Jacob et al.：Quantization and Training of Neural Networks for Efficient Integer-Arithmetic-Only Inference Xiao et al.：SmoothQuant: Accurate and Efficient Post-Training Quantization for Large Language Models ","permalink":"https://zhangge.dev/model-inference/model-quantization-fundamentals/","summary":"量化不是简单地把浮点数改成整数，而是在有限离散值中近似权重和激活。本文系统梳理 scale、zero point、对称量化、INT8 矩阵乘法与量化评估。","title":"量化基本概念"},{"content":"拿到一个大语言模型后，最快的认识方式往往不是立刻阅读全部源码，而是先打开它的 config.json。这个文件可以看作模型的结构说明书：它描述模型有多少层、每层多宽、注意力头如何划分、词表多大，以及使用什么归一化和位置编码。\n本文以 ModelScope 上的 Llama 2 7B 配置 为例，说明如何从配置还原网络结构，并进一步判断推理阶段的计算量、KV Cache 和显存需求。\nconfig.json 不包含模型权重 先区分两个容易混淆的概念：\nconfig.json → 按配置创建模型骨架 model.safetensors → 把训练好的参数填入模型骨架 权重也可能被拆成多个分片，例如：\nmodel-00001-of-00002.safetensors model-00002-of-00002.safetensors 因此，只有 config.json 不能运行模型，但它足以回答很多结构问题。本文使用的关键字段如下：\n{ \u0026#34;architectures\u0026#34;: [\u0026#34;LlamaForCausalLM\u0026#34;], \u0026#34;model_type\u0026#34;: \u0026#34;llama\u0026#34;, \u0026#34;hidden_size\u0026#34;: 4096, \u0026#34;intermediate_size\u0026#34;: 11008, \u0026#34;num_hidden_layers\u0026#34;: 32, \u0026#34;num_attention_heads\u0026#34;: 32, \u0026#34;num_key_value_heads\u0026#34;: 32, \u0026#34;max_position_embeddings\u0026#34;: 4096, \u0026#34;hidden_act\u0026#34;: \u0026#34;silu\u0026#34;, \u0026#34;attention_bias\u0026#34;: false, \u0026#34;attention_dropout\u0026#34;: 0.0, \u0026#34;mlp_bias\u0026#34;: false, \u0026#34;rms_norm_eps\u0026#34;: 1e-5, \u0026#34;rope_theta\u0026#34;: 10000.0, \u0026#34;rope_scaling\u0026#34;: null, \u0026#34;vocab_size\u0026#34;: 32000, \u0026#34;tie_word_embeddings\u0026#34;: false, \u0026#34;torch_dtype\u0026#34;: \u0026#34;bfloat16\u0026#34;, \u0026#34;use_cache\u0026#34;: true } 一句话概括，这是一套由 32 层 Decoder Block 组成、隐藏维度为 4096、使用标准多头注意力、RoPE、RMSNorm 和 SwiGLU 风格 MLP 的 Decoder-only 因果语言模型。\n从输入到输出的完整数据流 Llama 2 7B 的整体数据流可以写成：\n输入文本 ↓ Tokenizer ↓ token IDs：[B, T] ↓ Token Embedding ↓ 隐藏状态：[B, T, 4096] ↓ Llama Decoder Layer × 32 ↓ Final RMSNorm ↓ LM Head：4096 → 32000 ↓ 每个位置对下一个 token 的 logits 其中：\n$B$ 是 batch size； $T$ 是序列长度； $4096$ 是每个 token 的隐藏向量维度。 例如，[2, 100, 4096] 表示一个 batch 中有 2 条序列，每条序列包含 100 个 token，每个 token 用 4096 个数表示。这里的 4096 是隐藏维度，不是 token 数量。\n每个 Decoder Block 的结构为：\n输入 x ├─ RMSNorm → Causal Self-Attention → Residual Add └─ RMSNorm → SwiGLU MLP → Residual Add 这是一种 Pre-Norm 结构：先做归一化，再进入注意力或 MLP，最后与残差相加。\n用 architectures 判断模型用途 配置中的\n\u0026#34;architectures\u0026#34;: [\u0026#34;LlamaForCausalLM\u0026#34;] 表示 Hugging Face Transformers 加载模型时应创建 LlamaForCausalLM。这个名称可以拆成两部分：\nLlama：使用 Llama 系列的网络结构； ForCausalLM：增加因果语言建模所需的 LM Head。 代码关系可以粗略理解为：\nclass LlamaForCausalLM: def __init__(self, config): self.model = LlamaModel(config) self.lm_head = Linear( config.hidden_size, config.vocab_size, bias=False, ) LlamaForCausalLM 只是代码模板，具体实例有多大、使用多少个注意力头，仍然由配置决定。同一个类可以根据不同配置创建 Llama 2 7B、Llama 2 70B 或其他 Llama 系列模型。\n\u0026quot;model_type\u0026quot;: \u0026quot;llama\u0026quot; 则用于让 Transformers 识别配置所属的模型家族。\n层数、隐藏维度与 MLP 宽度 num_hidden_layers \u0026#34;num_hidden_layers\u0026#34;: 32 表示模型包含 32 个 Transformer Decoder Block。通常所说的“32 层”不包括：\n输入 Embedding； 最后的 RMSNorm； LM Head。 hidden_size \u0026#34;hidden_size\u0026#34;: 4096 hidden_size 也常写作 $d_{\\text{model}}$。每个 token 在模型内部都由一个 4096 维向量表示：\n$$ X\\in\\mathbb{R}^{B\\times T\\times 4096}. $$这个维度同时决定注意力投影、残差流、归一化参数和 LM Head 输入的宽度，是模型规模的核心参数之一。\nintermediate_size \u0026#34;intermediate_size\u0026#34;: 11008 它表示 MLP 的中间维度。隐藏状态会先从 4096 维扩展到 11008 维，再投影回 4096 维。\nLlama 使用的不是简单的 Linear → ReLU → Linear，而是带门控的 SwiGLU 风格结构，包含三组矩阵：\ngate_proj：4096 → 11008 up_proj： 4096 → 11008 down_proj：11008 → 4096 其计算可以写成：\n$$ \\operatorname{MLP}(x) =W_{\\text{down}} \\left[ \\operatorname{SiLU}(W_{\\text{gate}}x) \\odot (W_{\\text{up}}x) \\right]. $$\u0026quot;hidden_act\u0026quot;: \u0026quot;silu\u0026quot; 指定门控分支使用 SiLU，\u0026quot;mlp_bias\u0026quot;: false 表示三个线性层均不使用偏置。\n从 Q 头和 KV 头判断注意力类型 配置中最值得关注的一组字段是：\n\u0026#34;num_attention_heads\u0026#34;: 32, \u0026#34;num_key_value_heads\u0026#34;: 32 前者表示 Query 有 32 个头，后者表示 Key、Value 各有 32 个头。单个头的维度为：\n$$ d_{\\text{head}} =\\frac{d_{\\text{model}}}{n_{\\text{heads}}} =\\frac{4096}{32} =128. $$输入隐藏状态经过投影后，张量形状从\n$$ [B,T,4096] $$变为\n$$ Q,K,V\\in\\mathbb{R}^{B\\times 32\\times T\\times 128}. $$每个头独立计算：\n$$ \\operatorname{Attention}(Q,K,V) =\\operatorname{softmax} \\left( \\frac{QK^\\top}{\\sqrt{128}} \\right)V. $$32 个头的输出拼接后重新回到 4096 维。\nMHA、GQA 与 MQA 可以通过 Query 头数与 KV 头数的关系判断注意力类型：\n类型 头数关系 含义 MHA $n_q=n_{kv}$ 每个 Query 头有独立的 KV 头 GQA $1\\lt n_{kv}\\lt n_q$ 多个 Query 头共享一组 KV 头 MQA $n_{kv}=1$ 所有 Query 头共享一组 KV 头 这份 Llama 2 7B 配置中：\n$$ n_q=n_{kv}=32, $$因此使用的是标准 Multi-Head Attention，而不是 GQA。不能因为 Llama 2 70B 使用 GQA，就推断所有 Llama 2 规格都使用 GQA，最终仍应检查 num_key_value_heads。\n对于推理而言，KV 头数量直接影响 KV Cache 大小。其他条件相同时，KV 头从 32 减少到 8，缓存理论大小也会降到原来的四分之一。\nAttention 中的 bias 与 dropout \u0026#34;attention_bias\u0026#34;: false, \u0026#34;attention_dropout\u0026#34;: 0.0 attention_bias: false 表示 $Q$、$K$、$V$ 和输出投影的线性层不使用 bias，例如：\n$$ Q=XW_Q,\\qquad K=XW_K,\\qquad V=XW_V. $$注意力权重可以记为：\n$$ A=\\operatorname{softmax} \\left( \\frac{QK^\\top}{\\sqrt{d_{\\text{head}}}} \\right). $$训练时，一些模型还会计算\n$$ A'=\\operatorname{Dropout}(A). $$这里的 Dropout 概率为 0，因此训练时也不会随机丢弃注意力权重。推理模式本来就会关闭 Dropout，所以这一字段主要影响训练行为。\nRoPE 与上下文长度 \u0026#34;max_position_embeddings\u0026#34;: 4096, \u0026#34;rope_theta\u0026#34;: 10000.0, \u0026#34;rope_scaling\u0026#34;: null 这三个字段应放在一起理解：\nmax_position_embeddings: 4096：配置对应的原生最大位置范围为 4096； rope_theta: 10000：控制 RoPE 旋转频率的分布； rope_scaling: null：没有启用额外的 RoPE 缩放方案。 推理时，输入 prompt 与已经生成的 token 会共同占用上下文：\n$$ T_{\\text{context}} =T_{\\text{prompt}}+T_{\\text{generated}}. $$如果配置启用了上下文扩展，可能会出现类似字段：\n\u0026#34;rope_scaling\u0026#34;: { \u0026#34;type\u0026#34;: \u0026#34;linear\u0026#34;, \u0026#34;factor\u0026#34;: 2.0 } 但扩展位置范围并不自动保证长上下文质量不下降，还需要与模型训练方式和推理实现共同评估。\nRMSNorm \u0026#34;rms_norm_eps\u0026#34;: 1e-5 Llama 使用 RMSNorm，而不是原始 Transformer 中的 LayerNorm。对于 $d$ 维向量 $x$，先计算：\n$$ \\operatorname{RMS}(x) =\\sqrt{ \\frac{1}{d}\\sum_{i=1}^{d}x_i^2+\\epsilon }. $$再进行缩放：\n$$ \\operatorname{RMSNorm}(x) =\\frac{x}{\\operatorname{RMS}(x)}\\odot\\gamma. $$在这份配置中：\n$$ d=4096,\\qquad \\epsilon=10^{-5}, $$$\\gamma$ 是可训练的缩放参数。$\\epsilon$ 用于避免分母为零或过于接近零，从而提高数值稳定性。\n词表、特殊 token 与输出层 \u0026#34;vocab_size\u0026#34;: 32000, \u0026#34;bos_token_id\u0026#34;: 1, \u0026#34;eos_token_id\u0026#34;: 2, \u0026#34;pad_token_id\u0026#34;: 0 词表包含 32000 个 token。输入 Embedding 的权重形状为：\n$$ W_{\\text{embed}}\\in\\mathbb{R}^{32000\\times4096}, $$LM Head 把隐藏向量转换为词表 logits：\n$$ W_{\\text{lm}}\\in\\mathbb{R}^{32000\\times4096}. $$特殊 token 的作用分别是：\nBOS：序列开始标记，ID 为 1； EOS：序列结束标记，ID 为 2； PAD：批处理补齐标记，ID 为 0。 例如两条不同长度的序列可以补齐为：\nA tokens：[11, 25, 37, 48, 52] B tokens：[19, 21, 33, 0, 0] A mask： [ 1, 1, 1, 1, 1] B mask： [ 1, 1, 1, 0, 0] Attention Mask 会让模型忽略补齐位置。\n需要注意，这份配置的 _name_or_path 指向 unsloth/llama-2-7b，说明它经过 Unsloth 打包或处理。pad_token_id 等 tokenizer 适配字段未必与最初发布版本完全一致，部署时应以当前仓库配套的 tokenizer 文件为准。\nEmbedding 与 LM Head 是否共享 \u0026#34;tie_word_embeddings\u0026#34;: false 这表示输入 Embedding 与输出 LM Head 不共享权重。两者各自包含：\n$$ 32000\\times4096 =131{,}072{,}000 $$个参数，合计约 2.62 亿。如果启用 Weight Tying，则可以省去其中一份独立参数。\nuse_cache 与 KV Cache \u0026#34;use_cache\u0026#34;: true 表示生成时默认返回并复用历史 Key、Value。若不使用 KV Cache，每生成一个新 token 都要重新计算完整前缀；启用缓存后，只需计算新 token 对应的 Query、Key、Value，并读取历史缓存。\n对于普通 MHA，每层 KV Cache 的元素数量近似为：\n$$ N_{\\text{KV, layer}} =2BTn_{kv}d_{\\text{head}}, $$其中系数 2 分别对应 Key 和 Value。全模型缓存字节数近似为：\n$$ M_{\\text{KV}} =2BTLn_{kv}d_{\\text{head}}s, $$其中 $L$ 是层数，$s$ 是每个元素的字节数。\n以 batch size 为 1、上下文长度为 4096、BF16 为例：\n$$ M_{\\text{KV}} =2\\times1\\times4096\\times32\\times32\\times128\\times2 =2{,}147{,}483{,}648\\ \\text{bytes}, $$约为 2 GiB。这还没有包含权重、临时激活、CUDA Kernel 工作空间和框架管理开销。\ntorch_dtype 与权重显存 \u0026#34;torch_dtype\u0026#34;: \u0026#34;bfloat16\u0026#34; BF16 每个参数占 2 字节。对于约 67.4 亿参数：\n$$ 6.74\\times10^9\\times2 \\approx13.48\\times10^9\\ \\text{bytes}. $$也就是说，仅模型权重就约占 13.48 GB，折合约 12.55 GiB。实际推理显存还需要容纳：\nKV Cache； 临时激活； CUDA Kernel 工作空间； 推理框架的管理开销。 因此，“权重小于显存容量”不等于模型一定能够高效运行。torch_dtype 也只是默认加载提示，调用 from_pretrained 时仍可显式覆盖，或者加载量化权重。\n从配置还原一层 Llama Decoder 把关键字段组合起来，一层 Decoder 可以近似写成：\nclass LlamaDecoderLayer: def __init__(self): self.input_layernorm = RMSNorm( hidden_size=4096, eps=1e-5, ) self.self_attn = { \u0026#34;q_proj\u0026#34;: Linear(4096, 4096, bias=False), \u0026#34;k_proj\u0026#34;: Linear(4096, 4096, bias=False), \u0026#34;v_proj\u0026#34;: Linear(4096, 4096, bias=False), \u0026#34;o_proj\u0026#34;: Linear(4096, 4096, bias=False), \u0026#34;num_heads\u0026#34;: 32, \u0026#34;num_kv_heads\u0026#34;: 32, \u0026#34;head_dim\u0026#34;: 128, \u0026#34;rope_theta\u0026#34;: 10000, } self.post_attention_layernorm = RMSNorm( hidden_size=4096, eps=1e-5, ) self.mlp = { \u0026#34;gate_proj\u0026#34;: Linear(4096, 11008, bias=False), \u0026#34;up_proj\u0026#34;: Linear(4096, 11008, bias=False), \u0026#34;down_proj\u0026#34;: Linear(11008, 4096, bias=False), \u0026#34;activation\u0026#34;: SiLU(), } 前向计算可以概括为：\n# Attention residual = x x = rms_norm_1(x) x = self_attention(x) x = residual + x # MLP residual = x x = rms_norm_2(x) x = down_proj(silu(gate_proj(x)) * up_proj(x)) x = residual + x 这一层重复 32 次后，再经过 Final RMSNorm 和 LM Head。\n从配置估算参数量 单层 Attention $Q$、$K$、$V$、$O$ 四个投影矩阵共包含：\n$$ 4\\times4096\\times4096 =67{,}108{,}864 $$个参数，约 6711 万。\n单层 MLP gate_proj、up_proj、down_proj 三个矩阵共包含：\n$$ 3\\times4096\\times11008 =135{,}266{,}304 $$个参数，约 1.353 亿。\n忽略少量 RMSNorm 参数，单个 Decoder Block 约有：\n$$ 67.11\\ \\text{M}+135.27\\ \\text{M} \\approx202.38\\ \\text{M} $$个参数。32 层约为：\n$$ 202.38\\ \\text{M}\\times32 \\approx6.476\\ \\text{B}. $$再加上不共享的 Embedding 与 LM Head：\n$$ 2\\times32000\\times4096 =0.262144\\ \\text{B}. $$计入 RMSNorm 后，总参数量约为：\n$$ 6.476\\ \\text{B}+0.262\\ \\text{B} \\approx6.74\\ \\text{B}. $$这也解释了为什么型号写作“7B”，而更精确的参数规模约为 6.74B。\n哪些字段不直接决定网络结构 配置中还有一些运行信息或元数据：\ntransformers_version：保存配置时使用或兼容的 Transformers 版本； unsloth_version：模型经过哪个版本的 Unsloth 工具处理； _name_or_path：模型保存前的名称或来源路径； initializer_range：随机初始化新参数时使用的分布范围； pretraining_tp：兼容预训练阶段张量并行切分的字段。 尤其要注意：\n\u0026#34;pretraining_tp\u0026#34;: 1 不表示模型只能单卡运行，也不表示推理时 tensor parallel size 必须为 1。vLLM 等推理框架中的张量并行配置属于另一个层面。\n阅读模型配置的推荐顺序 以后拿到一个陌生模型，可以按照下面的顺序阅读：\n判断模型用途：查看 architectures 和 model_type。 判断基础规模：查看 num_hidden_layers、hidden_size 和 intermediate_size。 判断注意力类型：比较 num_attention_heads 与 num_key_value_heads。 判断上下文能力：查看 max_position_embeddings、rope_theta 和 rope_scaling。 判断 FFN 与归一化：查看 hidden_act、intermediate_size 和 rms_norm_eps，并结合代码确认具体实现。 判断词表与输出层：查看 vocab_size、特殊 token ID 和 tie_word_embeddings。 估算推理资源：结合 torch_dtype、use_cache、KV 头数、层数和上下文长度计算权重与 KV Cache。 区分结构字段和元数据：不要把 transformers_version、_name_or_path 或 pretraining_tp 误认为网络层配置。 总结 config.json 的价值不只是让框架能够加载模型。正确阅读这些字段后，可以从一个很小的配置文件还原出：\n模型是 Decoder-only 因果语言模型； 主干包含 32 个 Decoder Block； 隐藏维度为 4096，MLP 中间维度为 11008； 32 个 Query 头和 32 个 KV 头构成标准 MHA； 单头维度为 128； 使用 RoPE、RMSNorm 和 SwiGLU 风格 MLP； 原生上下文长度为 4096； Embedding 与 LM Head 不共享； BF16 权重约为 13.48 GB； 4096 token、batch size 为 1 时，KV Cache 约为 2 GiB； 总参数量约为 6.74B。 从配置阅读模型，本质上是在建立一条完整链路：\n字段 → 张量形状 → 网络结构 → 参数量 → 推理显存与性能 掌握这条链路后，面对其他 Llama、Qwen 或 Hugging Face 模型，也可以用同样的方法快速定位其结构与推理特征。\n","permalink":"https://zhangge.dev/model-inference/llama2-config/","summary":"config.json 不只是模型加载配置：从层数、隐藏维度、注意力头、上下文长度和精度字段，可以还原 Llama 2 7B 的网络结构并估算推理资源。","title":"从 config.json 读懂 Llama 2 7B"},{"content":"Self-Attention 要解决的核心问题是：让序列中的每个 token 根据当前上下文，动态地从其他 token 中提取自己需要的信息。\n例如句子：\n小明把书放在桌子上，因为它很重。\n模型处理“它”时，需要判断“它”更可能指“书”，而不是“小明”或“桌子”。Self-Attention 会让“它”与序列中的其他 token 计算相关性，再根据相关性对这些 token 携带的信息加权汇总。\n之所以叫 Self-Attention，是因为生成 Query、Key、Value 的输入都来自同一个序列。Cross-Attention 则不同：Query 与 Key、Value 来自不同序列。\nSelf-Attention 的输入 假设输入序列有 $n$ 个 token，每个 token 的隐藏向量维度为 $d_{\\text{model}}$。将这些向量按行排列，得到输入矩阵：\n$$ X\\in\\mathbb{R}^{n\\times d_{\\text{model}}}. $$例如有 4 个 token，每个 token 是一个 8 维向量，那么：\n$$ X\\in\\mathbb{R}^{4\\times 8}. $$这里的 $X$ 不一定只是最初的 Token Embedding：\n第一层 Transformer 的输入通常是 Token Embedding 加位置信息； 后续层的输入是上一层输出的隐藏状态。 因此，每一层 Self-Attention 都会重新理解当前序列表示。\nQ、K、V 是怎么得到的 输入矩阵 $X$ 分别乘以三个可学习的权重矩阵：\n$$ \\begin{aligned} Q\u0026=XW_Q,\\\\ K\u0026=XW_K,\\\\ V\u0026=XW_V. \\end{aligned} $$其中：\n$$ W_Q\\in\\mathbb{R}^{d_{\\text{model}}\\times d_k}, \\qquad W_K\\in\\mathbb{R}^{d_{\\text{model}}\\times d_k}, \\qquad W_V\\in\\mathbb{R}^{d_{\\text{model}}\\times d_v}. $$因此：\n$$ Q\\in\\mathbb{R}^{n\\times d_k}, \\qquad K\\in\\mathbb{R}^{n\\times d_k}, \\qquad V\\in\\mathbb{R}^{n\\times d_v}. $$每个 token 的输入向量 $x_i$ 会被投影成三种不同的表示：\n$$ x_i\\longrightarrow q_i,\\ k_i,\\ v_i. $$这三种向量不是同一份信息的简单复制，而是模型通过训练学习出的三种不同表示。\nQ、K、V 分别有什么作用 可以把 Self-Attention 类比成一个检索系统。\nQuery：当前 token 想找什么 Query 表示当前 token 想从上下文中寻找什么信息。例如当前 token 是“它”，它的 Query 可能倾向于寻找：\n前面出现过的名词； 与“重”这个属性匹配的对象； 语法上可能作为指代对象的词。 这只是功能上的直觉。模型并没有显式规定某个维度必须表示“名词”或“重量”，这些模式都是从训练数据中学习得到的。\nKey：当前 token 可以如何被匹配 Key 表示当前 token 可以通过哪些特征被其他 token 匹配到。例如“书”的 Key 可能编码：\n它是名词； 它可以被代词指代； 它可能具有重量； 它在当前句子中的语法角色。 Query 和 Key 的匹配程度决定 Attention 分数。\nValue：当前 token 实际提供什么 Value 表示：如果其他 token 关注当前 token，它应该向对方传递什么内容。\n可以把三者简化为：\n向量 作用 Query 当前 token 要找什么 Key 当前 token 可以如何被匹配 Value 当前 token 实际提供什么内容 Key 决定“是否应该找到我”，Value 决定“找到我之后拿走什么信息”。同样，这些只是功能解释，并不表示模型内部存在人工定义的查询字段或内容字段。\n单个 token 的 Attention 计算 假设正在计算第 $i$ 个 token 的输出，它的 Query 为：\n$$ q_i\\in\\mathbb{R}^{d_k}. $$序列中第 $j$ 个 token 的 Key 为：\n$$ k_j\\in\\mathbb{R}^{d_k}. $$先计算二者的点积：\n$$ s_{ij}=q_i^{\\mathsf T}k_j. $$$s_{ij}$ 表示第 $i$ 个 token 对第 $j$ 个 token 的关注程度。点积越大，通常说明 $q_i$ 和 $k_j$ 越匹配。\n对第 $i$ 个 token，需要分别与序列中的所有 Key 计算：\n$$ s_{i1},s_{i2},\\ldots,s_{in}. $$经过 Softmax 后得到 Attention 权重：\n$$ a_{ij} =\\frac{\\exp(s_{ij})} {\\sum_{m=1}^{n}\\exp(s_{im})}. $$这些权重满足：\n$$ \\sum_{j=1}^{n}a_{ij}=1. $$最后使用这些权重对 Value 加权求和：\n$$ o_i=\\sum_{j=1}^{n}a_{ij}v_j. $$因此，Attention 的核心过程可以概括为：\n用 Query 和所有 Key 计算相关性； 用 Softmax 把相关性转换为权重； 用这些权重加权汇总 Value。 为什么要除以 $\\sqrt{d_k}$ 实际使用的 Attention 分数不是简单的 $q_i^{\\mathsf T}k_j$，而是：\n$$ s_{ij}=\\frac{q_i^{\\mathsf T}k_j}{\\sqrt{d_k}}. $$点积可以展开为：\n$$ q_i^{\\mathsf T}k_j =\\sum_{r=1}^{d_k}q_{ir}k_{jr}. $$如果 Query 和 Key 的各个分量相互独立、均值接近 0、方差接近 1，那么点积的方差大约会随 $d_k$ 线性增长。$d_k$ 越大，分数的绝对值通常越大。\n如果输入 Softmax 的分数差异过大，例如：\n$$ [1,2,20], $$Softmax 输出会非常接近：\n$$ [0,0,1]. $$这会使 Softmax 进入饱和区域，梯度变小，不利于稳定训练。除以 $\\sqrt{d_k}$ 后，点积分数的方差被拉回相对稳定的尺度，从而减轻 Softmax 过度饱和的问题。\nSelf-Attention 的矩阵形式 把所有 token 的计算合并起来，缩放点积分数矩阵为：\n$$ S=\\frac{QK^{\\mathsf T}}{\\sqrt{d_k}}, \\qquad S\\in\\mathbb{R}^{n\\times n}. $$第 $i$ 行记录第 $i$ 个 token 对所有 token 的注意力分数。对每一行执行 Softmax：\n$$ A=\\operatorname{Softmax}(S), \\qquad A\\in\\mathbb{R}^{n\\times n}. $$再与 Value 矩阵相乘：\n$$ O=AV, \\qquad O\\in\\mathbb{R}^{n\\times d_v}. $$合起来就是经典的缩放点积注意力：\n$$ \\operatorname{Attention}(Q,K,V) =\\operatorname{Softmax}\\!\\left( \\frac{QK^{\\mathsf T}}{\\sqrt{d_k}} \\right)V. $$在多头注意力中，$Q$、$K$、$V$ 会被拆分到多个头中。每个头独立执行缩放点积注意力，各头输出拼接后再经过一次线性投影。不同注意力头可以学习不同类型的匹配关系。\n掩码矩阵是什么 Decoder-only 大语言模型采用自回归生成：\n$$ x_1\\longrightarrow x_2\\longrightarrow x_3\\longrightarrow\\cdots. $$预测第 $i$ 个位置时，模型不能看到未来 token。否则训练时模型在预测目标时可以直接读取后面的答案，造成信息泄漏。\n因此需要使用 Causal Mask，也叫因果掩码。对于长度为 $n$ 的序列，掩码矩阵定义为：\n$$ M_{ij} =\\begin{cases} 0,\u0026j\\le i,\\\\ -\\infty,\u0026j\u003ei. \\end{cases} $$以长度 4 为例：\n$$ M= \\begin{bmatrix} 0\u0026-\\infty\u0026-\\infty\u0026-\\infty\\\\ 0\u00260\u0026-\\infty\u0026-\\infty\\\\ 0\u00260\u00260\u0026-\\infty\\\\ 0\u00260\u00260\u00260 \\end{bmatrix}. $$在 Softmax 之前把掩码加到分数矩阵：\n$$ A =\\operatorname{Softmax}\\!\\left( \\frac{QK^{\\mathsf T}}{\\sqrt{d_k}}+M \\right). $$因为 $\\exp(-\\infty)=0$，被屏蔽的位置经过 Softmax 后权重为 0。最终的 Attention 权重矩阵呈下三角形式：\n$$ A= \\begin{bmatrix} 1\u00260\u00260\u00260\\\\ a_{21}\u0026a_{22}\u00260\u00260\\\\ a_{31}\u0026a_{32}\u0026a_{33}\u00260\\\\ a_{41}\u0026a_{42}\u0026a_{43}\u0026a_{44} \\end{bmatrix}. $$它表示：\ntoken 1 只能看到 token 1； token 2 可以看到 token 1、2； token 3 可以看到 token 1、2、3； token 4 可以看到 token 1、2、3、4。 在语言模型训练中，位置 $i$ 的隐藏状态通常用来预测位置 $i+1$ 的 token。例如输入“我 喜欢 吃”，模型在“吃”这个位置得到的隐藏状态可以用来预测下一个 token“苹果”。\n完整的 QKV 计算过程 假设某层 Transformer 当前有 $n$ 个 token，其输入隐藏状态为：\n$$ X_n\\in\\mathbb{R}^{n\\times d_{\\text{model}}}. $$经过线性投影：\n$$ \\begin{aligned} Q_n\u0026=X_nW_Q,\\\\ K_n\u0026=X_nW_K,\\\\ V_n\u0026=X_nW_V. \\end{aligned} $$然后计算缩放点积分数：\n$$ S_n=\\frac{Q_nK_n^{\\mathsf T}}{\\sqrt{d_k}}. $$加入因果掩码并执行 Softmax：\n$$ A_n=\\operatorname{Softmax}(S_n+M_n). $$最后得到：\n$$ O_n=A_nV_n. $$$A_n\\in\\mathbb{R}^{n\\times n}$，其中第 $i$ 行表示第 $i$ 个 token 对它可以看到的各个 token 分配了多少注意力。\n生成一个新 token 后，矩阵发生什么变化 假设当前序列为：\n$$ x_1,x_2,\\ldots,x_n, $$模型又生成了一个 token $x_{n+1}$。从完整矩阵的概念上看，下一轮输入变成：\n$$ X_{n+1}= \\begin{bmatrix} X_n\\\\ x_{n+1} \\end{bmatrix}. $$模型参数 $W_Q$、$W_K$、$W_V$ 在推理期间不会发生变化，于是：\n$$ Q_{n+1} =X_{n+1}W_Q =\\begin{bmatrix} Q_n\\\\ q_{n+1} \\end{bmatrix}, $$$$ K_{n+1}= \\begin{bmatrix} K_n\\\\ k_{n+1} \\end{bmatrix}, \\qquad V_{n+1}= \\begin{bmatrix} V_n\\\\ v_{n+1} \\end{bmatrix}. $$新的 Q、K、V 相比旧矩阵都只多出最后一行。但这不意味着三者都值得缓存。\n从完整 Attention 矩阵观察新增行和新增列 如果概念上展开新的未缩放分数矩阵：\n$$ Q_{n+1}K_{n+1}^{\\mathsf T} =\\begin{bmatrix} Q_nK_n^{\\mathsf T} \u0026 Q_nk_{n+1}^{\\mathsf T}\\\\ q_{n+1}K_n^{\\mathsf T} \u0026 q_{n+1}k_{n+1}^{\\mathsf T} \\end{bmatrix}. $$这个矩阵可以分成四块。\n左上角：历史 Attention 分数 $$ Q_nK_n^{\\mathsf T}\\in\\mathbb{R}^{n\\times n}. $$这是上一轮已经计算过的历史 token 之间的分数。历史位置的最终隐藏状态已经产生，不需要重新生成，因此无需再计算这一整块。\n右上角：历史 Query 与新增 Key $$ Q_nk_{n+1}^{\\mathsf T}\\in\\mathbb{R}^{n\\times 1}. $$这一列表示历史位置是否关注新 token。但对每个历史位置来说，第 $n+1$ 个 token 都属于未来位置，会被因果掩码屏蔽：\n$$ Q_nk_{n+1}^{\\mathsf T} \\longrightarrow \\begin{bmatrix} -\\infty\\\\ \\vdots\\\\ -\\infty \\end{bmatrix}. $$Softmax 后这一列全部变成 0，因此没有必要使用历史 Query 计算这列。\n左下角：新增 Query 关注历史 Key $$ q_{n+1}K_n^{\\mathsf T}\\in\\mathbb{R}^{1\\times n}. $$它表示新 token 对所有历史 token 的注意力分数。这一部分必须计算，因为新 token 需要读取整个历史上下文。计算它需要当前新增的 Query $q_{n+1}$ 和所有历史 Key $K_n$，所以历史 Key 必须保存。\n右下角：新 token 关注自己 $$ q_{n+1}k_{n+1}^{\\mathsf T}. $$这是一个标量，表示新 token 对自身位置的注意力分数。当前 token 可以看到自己，因此这一项不会被因果掩码屏蔽。\n增量解码实际只需要计算最后一行 在真实的增量解码中，不会构造完整的 $(n+1)\\times(n+1)$ 注意力矩阵。只需要计算新 token 对历史和自身的最后一行：\n$$ s_{n+1} =\\frac{q_{n+1}}{\\sqrt{d_k}} \\begin{bmatrix} K_n\\\\ k_{n+1} \\end{bmatrix}^{\\mathsf T} \\in\\mathbb{R}^{1\\times(n+1)}. $$然后：\n$$ a_{n+1}=\\operatorname{Softmax}(s_{n+1}), $$$$ o_{n+1} =a_{n+1} \\begin{bmatrix} V_n\\\\ v_{n+1} \\end{bmatrix} \\in\\mathbb{R}^{1\\times d_v}. $$因此，解码阶段不是重新构造完整的注意力矩阵，而是直接计算尺寸为 $1\\times(n+1)$ 的最后一行。\n用 4 个 token 的例子理解：假设前三个 token 已经处理完毕，当前新增第 4 个 token。完整分数矩阵在因果掩码下概念上为：\n$$ \\begin{bmatrix} q_1^{\\mathsf T}k_1\u0026-\\infty\u0026-\\infty\u0026-\\infty\\\\ q_2^{\\mathsf T}k_1\u0026q_2^{\\mathsf T}k_2\u0026-\\infty\u0026-\\infty\\\\ q_3^{\\mathsf T}k_1\u0026q_3^{\\mathsf T}k_2\u0026q_3^{\\mathsf T}k_3\u0026-\\infty\\\\ q_4^{\\mathsf T}k_1\u0026q_4^{\\mathsf T}k_2\u0026q_4^{\\mathsf T}k_3\u0026q_4^{\\mathsf T}k_4 \\end{bmatrix}. $$前三行在之前已经计算完成，第四列的前三个元素又会被 Mask 屏蔽。所以这一轮真正需要计算的只有：\n$$ \\begin{bmatrix} q_4^{\\mathsf T}k_1\u0026 q_4^{\\mathsf T}k_2\u0026 q_4^{\\mathsf T}k_3\u0026 q_4^{\\mathsf T}k_4 \\end{bmatrix}. $$计算这一行需要新的 $q_4$、历史 $k_1,k_2,k_3$ 和新的 $k_4$，不需要历史 $q_1,q_2,q_3$。\n为什么缓存 K 和 V，而不缓存 Q 从功能角度看：\nQuery 是当前 token 主动提出的问题； Key 是历史 token 留给未来查询的索引； Value 是历史 token 留给未来读取的内容。 未来 token 到来时，会产生自己的 Query：\n$$ q_{\\text{future}}, $$然后用它查询历史 Key，并从历史 Value 中提取信息。过去的 Query 只服务于过去那个位置的 Attention 计算，计算完成后就没有复用价值。\n可以类比数据库：\nQuery：本次发出的查询语句； Key：数据库索引； Value：数据库记录。 执行下一次查询时，数据库索引和记录仍然有用，而上一次查询语句通常没有复用价值；下一次会发出新的查询语句。\n因此，增量解码缓存的是历史 $K$ 和 $V$，而不是 $Q$。这就是 KV Cache 的来源。\n总结 Self-Attention 可以概括为以下过程：\n输入隐藏状态分别投影为 Query、Key 和 Value； Query 与 Key 的缩放点积给出 token 之间的匹配分数； Softmax 将分数转换为权重，再对 Value 加权汇总； Decoder-only 模型通过因果掩码阻止当前位置读取未来 token； 增量解码只计算新 token 对历史和自身的最后一行注意力； 历史 Key 和 Value 会被未来 token 反复使用，而历史 Query 不会，因此推理时使用 KV Cache，而没有 Q Cache。 ","permalink":"https://zhangge.dev/ai-fundamentals/self-attention/","summary":"系统梳理 Self-Attention 的输入、QKV 投影、注意力权重与因果掩码，并从矩阵变化解释增量解码为何只计算最后一行、为何缓存 K 和 V。","title":"self-attention"},{"content":"理解 Transformer 时，一个常见困惑是：\n原始 Transformer 的 Decoder 中明明有两层多头注意力，为什么 GPT、Qwen 等模型被称为 decoder-only，它们的每个网络块里却通常只有一类注意力？\n问题的关键在于：原始 Transformer 的 Decoder 是为 Encoder–Decoder 任务设计的，而现代语言模型中的 decoder-only 是在它的基础上删去 Encoder 和交叉注意力后形成的架构类型。 两者有关联，但并不是完全相同的结构。\n先给出全文结论：\n原始 Transformer 是面向机器翻译设计的 Encoder–Decoder 架构。 原始 Decoder 包含因果自注意力和交叉注意力，两者负责不同的信息来源。 decoder-only 模型删去了文本 Encoder 与交叉注意力，只保留因果自注意力。 在 decoder-only 模型中，提示词和待生成内容被放进同一个 token 序列，因此模型仍然能够读取输入并继续生成。 原始 Transformer：一个完整的 Encoder–Decoder 以英文到中文的翻译为例：\n输入：I love cats 输出：我 喜欢 猫 原始 Transformer 将任务拆成两步：\nEncoder 读取完整的源语言序列，并把它编码为一组上下文表示。 Decoder 读取已经生成的目标语言 token，同时查询 Encoder 的输出，预测下一个 token。 下图是论文 Attention Is All You Need 中的完整结构。左侧是 Encoder，右侧是 Decoder。\n图源：Vaswani 等，Attention Is All You Need，Figure 1。\n设输入序列为\n$$ X=(x_1,x_2,\\ldots,x_n), $$Encoder 将其映射为\n$$ Z=\\operatorname{Encoder}(X),\\qquad Z\\in\\mathbb{R}^{n\\times d_{\\text{model}}}. $$这里的每一行都对应一个输入位置，但已经融合了整个输入序列的上下文。Decoder 则在已生成目标序列和 $Z$ 的共同条件下逐步生成输出。\n注意力到底在计算什么 缩放点积注意力的核心公式是\n$$ \\operatorname{Attention}(Q,K,V) =\\operatorname{softmax}\\left(\\frac{QK^\\top}{\\sqrt{d_k}}\\right)V. $$可以把它理解为三个步骤：\n用 $QK^\\top$ 计算每个 Query 与各个 Key 的相关性。 除以 $\\sqrt{d_k}$ 控制数值尺度，再用 Softmax 得到权重。 按权重对 Value 做加权求和，得到当前位置需要的信息。 图源：Vaswani 等，Attention Is All You Need，Figure 2。\n多头注意力会先使用不同的参数将输入投影到多个子空间：\n$$ \\operatorname{head}_i =\\operatorname{Attention}(QW_i^Q,KW_i^K,VW_i^V), $$然后拼接各个注意力头并做一次线性变换：\n$$ \\operatorname{MultiHead}(Q,K,V) =\\operatorname{Concat}(\\operatorname{head}_1,\\ldots,\\operatorname{head}_h)W^O. $$不同注意力层之间最重要的区别，不在于公式是否改变，而在于 $Q$、$K$、$V$ 分别来自哪里，以及是否施加因果掩码。\nEncoder：让每个输入位置看到完整上下文 原始 Transformer 的一个 Encoder Block 主要包含：\n多头自注意力； 前馈神经网络； 围绕两个子层的残差连接与 LayerNorm。 在 Encoder 的自注意力中，$Q$、$K$、$V$ 都由同一份输入表示 $X$ 投影得到：\n$$ Q=XW^Q,\\qquad K=XW^K,\\qquad V=XW^V. $$因此它叫作 Self-Attention。Encoder 通常采用双向可见性，一个位置既可以关注前面的 token，也可以关注后面的 token。这样得到的表示适合完整地理解输入，而不是按顺序生成输出。\n关于各个组成部分的细节，可以继续阅读 self-attention、FeedForward 和 Add\u0026amp;LayerNorm。\n原始 Decoder 为什么有两层注意力 原始 Decoder Block 中的两层注意力看起来相似，实际上承担着两个完全不同的任务。\n第一层：Masked Self-Attention 第一层注意力处理的是右移后的目标序列。训练时即使完整答案已经存在，位置 $t$ 也只能看到 $t$ 之前的 token，不能提前读取未来答案。\n设 Decoder 当前的隐藏表示为 $H$，则\n$$ Q=HW^Q,\\qquad K=HW^K,\\qquad V=HW^V. $$它仍然是自注意力，但会加入因果掩码 $M$：\n$$ \\operatorname{CausalAttention}(Q,K,V) =\\operatorname{softmax}\\left(\\frac{QK^\\top}{\\sqrt{d_k}}+M\\right)V, $$其中\n$$ M_{ij}= \\begin{cases} 0, \u0026 j\\le i,\\\\ -\\infty, \u0026 j\u003ei. \\end{cases} $$这样，第 $i$ 个目标位置只能利用自身及其之前的目标 token。它解决的问题是：\n根据已经生成的内容，当前应该怎样表示目标端上下文？\n第二层：Encoder–Decoder Attention 第二层也叫 Cross-Attention。此时三组向量不再来自同一个序列：\n$$ Q=H_{\\text{dec}}W^Q,\\qquad K=ZW^K,\\qquad V=ZW^V. $$Query 来自 Decoder，Key 和 Value 来自 Encoder 的输出 $Z$。因此，这层注意力表达的是：\nDecoder 当前正在生成的内容，应该从输入原文的哪些位置取回信息？\n两层注意力可以概括为：\n模块 Query 来源 Key 来源 Value 来源 作用 Decoder Self-Attention Decoder Decoder Decoder 建模已生成的目标内容 Cross-Attention Decoder Encoder Encoder 从源序列读取相关信息 所以，原始 Decoder 中并不是有两层功能重复的自注意力。第一层在目标序列内部建模，第二层连接目标序列和源序列。\n为什么 Encoder 输出连接到 Decoder 的中间 Encoder 输出并不是 Decoder 的普通输入 token。Decoder 的普通输入是右移后的目标序列，而 Encoder 输出更像一块外部记忆。\n信息流可以理解为：\nDecoder 先通过因果自注意力整理已经生成的目标上下文。 再用整理后的状态作为 Query，去 Encoder 的结果中检索相关信息。 检索结果经过前馈网络处理后，用于预测下一个 token。 这个区别从矩阵形状上会更加直观。设源序列长度为 $n$，目标序列长度为 $m$。\nDecoder 自注意力的分数矩阵为\n$$ Q_{\\text{dec}}K_{\\text{dec}}^\\top\\in\\mathbb{R}^{m\\times m}, $$它描述目标位置之间的关系，并使用下三角因果掩码。\n交叉注意力的分数矩阵为\n$$ Q_{\\text{dec}}K_{\\text{enc}}^\\top\\in\\mathbb{R}^{m\\times n}, $$每一行对应一个目标位置，每一列对应一个源位置。由于翻译开始前完整原文已经给出，交叉注意力不需要因果掩码；如果源序列包含补齐 token，则只需使用 Padding Mask。\n从原始 Transformer 到 decoder-only 原始 Transformer 的结构可以简化为：\n输入文本 → Encoder → 上下文表示 ↓ 目标前缀 → Decoder → 下一个 token 而 decoder-only 模型删除了 Encoder 和 Cross-Attention，把提示词与回答统一放进一个序列：\n[提示词 token][回答 token] 整个序列只经过重复堆叠的 decoder-only Block。每个 Block 通常包含：\n因果自注意力； 前馈网络； 归一化与残差连接。 此时，模型用统一的下一个 token 预测目标训练：\n$$ P(x_1,x_2,\\ldots,x_T) =\\prod_{t=1}^{T}P(x_t\\mid x_{1:t-1}). $$推理时，提示词位于生成内容之前。回答位置虽然不能看未来，却可以通过因果自注意力看到前面的全部提示词。因此，没有独立的 Encoder 并不等于模型看不到或无法理解输入。\n为什么 decoder-only 适合现代大语言模型 decoder-only 的流行并不意味着 Encoder–Decoder 没有价值，而是它特别适合通用的自回归语言建模。\n统一训练目标 无论是续写、问答、摘要还是代码生成，都可以转化为“根据前文预测下一个 token”。模型不必为不同任务更换基本训练目标。\n统一输入与输出 指令、上下文、示例和回答都能编码到同一个序列中。信息通过同一种因果自注意力机制传递，架构和数据组织都更加统一。\n与生成过程天然一致 训练目标和推理过程都是从左到右预测下一个 token，适合持续扩展上下文和模型规模。\n需要注意的是，decoder-only 描述的是一种核心文本架构。多模态系统还可能包含视觉编码器、音频编码器、投影层或其他模块，不能因为语言主干采用 decoder-only，就把整个系统简单等同于纯文本 Decoder。\n现代 Decoder Block 还改变了什么 现代大模型保留了“因果自注意力 + 前馈网络”这一核心，但实现细节通常已经不同于 2017 年的原始 Transformer，例如：\n使用 Pre-Norm，而不是原论文中的 Post-Norm； 使用 RMSNorm 替代 LayerNorm； 使用 RoPE 等相对位置信息方案； 使用 SwiGLU 等门控前馈网络； 使用 MQA 或 GQA 降低推理时的 KV Cache 开销； 使用 MoE、滑动窗口注意力或稀疏注意力扩展模型容量与上下文。 这些变化改进了训练稳定性、表达能力或推理效率，但没有改变 decoder-only 的基本逻辑：通过带因果约束的注意力，从已有 token 预测下一个 token。其中 RoPE 的原理可参考 旋转位置编码。\nDecoder 不等于 decode 这两个词很接近，却属于不同层面：\n术语 含义 Decoder 模型架构中的组成部分 decoder-only 只使用 Decoder 风格主干的架构类型 decode 自回归推理中逐 token 生成的运行阶段 KV Cache 在生成过程中复用历史 Key、Value 的缓存机制 decoder-only 模型的推理通常分为两个阶段：\nPrefill：并行处理完整提示词，并建立各层的 KV Cache。 Decode：一次生成一个新 token，并把新产生的 Key、Value 追加到缓存。 原始 Encoder–Decoder 模型也可以使用缓存：Decoder 自注意力的历史 Key、Value 可以复用；交叉注意力中由 Encoder 输出计算出的 Key、Value 在一次生成过程中保持不变，同样可以预先计算并重复使用。\n常见误区 误区一：没有 Encoder 就无法理解输入 decoder-only 模型把输入放在生成内容之前，后续位置可以通过因果自注意力读取全部输入。独立 Encoder 被删除了，输入信息本身并没有消失。\n误区二：现代 decoder-only 就是原始 Decoder 原封不动地保留下来 现代结构通常删除了 Cross-Attention，并对归一化、位置编码、前馈网络和注意力实现做了大量修改。\n误区三：Encoder 输出是 Decoder 的普通输入 Decoder 的普通输入是右移后的目标 token；Encoder 输出作为外部记忆，为 Cross-Attention 提供 Key 和 Value。\n误区四：原始 Decoder 的两层注意力都是 Self-Attention 只有第一层是目标序列内部的因果自注意力。第二层的 Query 来自 Decoder，而 Key、Value 来自 Encoder，因此是交叉注意力。\n误区五：Decoder 和 decode 是同一个概念 Decoder 是架构，decode 是推理阶段。一个描述模型“长什么样”，另一个描述模型“怎样运行”。\n总结 可以用三句话记住 Transformer 的结构演变：\n原始 Encoder：读取完整源序列，让每个输入位置融合上下文。 原始 Decoder：先建模目标历史，再通过 Cross-Attention 查询源序列，最后预测下一个 token。 现代 decoder-only：把提示词和回答放在同一序列中，只用因果自注意力同时完成上下文读取与生成。 原始结构的两种注意力也可以浓缩为：\n$$ \\boxed{\\text{Self-Attention：在同一序列内部建立联系}} $$$$ \\boxed{\\text{Cross-Attention：用一个序列查询另一个序列}} $$理解 $Q$、$K$、$V$ 的来源以及掩码的作用后，Encoder、原始 Decoder 和 decoder-only 之间的区别就会变得清晰。\n参考资料 Vaswani, A. et al. Attention Is All You Need, 2017. ","permalink":"https://zhangge.dev/ai-fundamentals/transformer-architecture/","summary":"为什么原始 Transformer 的 Decoder 有两层注意力，而 GPT、Qwen 等 decoder-only 模型通常只有一类注意力？本文从信息流和矩阵形状出发梳理 Transformer 结构。","title":"transformer结构"},{"content":"雅可比矩阵可以看作导数在高维空间中的推广。单变量函数只有一个输入方向和一个输出方向，因此一个导数就足以描述局部变化；当函数同时拥有多个输入和多个输出时，就需要用一个矩阵记录所有输出相对于所有输入的变化率。\n雅可比矩阵是什么 从单变量函数的导数说起 对于单变量函数\n$$ y=f(x), $$导数为\n$$ f'(x)=\\frac{\\mathrm{d}y}{\\mathrm{d}x}. $$它描述了输入 $x$ 发生微小变化时，输出 $y$ 会以多快的速度变化。例如\n$$ y=x^2,\\qquad \\frac{\\mathrm{d}y}{\\mathrm{d}x}=2x. $$当 $x=3$ 且 $\\Delta x=0.01$ 时，可以利用导数估计输出的变化：\n$$ \\Delta y\\approx 2x\\Delta x =6\\times 0.01 =0.06. $$这里的导数就是一维函数在当前点附近的“斜率”。\n多个输入、一个输出：梯度 如果函数有多个输入，但只有一个标量输出：\n$$ y=f(x_1,x_2,\\ldots,x_n), $$那么需要分别计算 $y$ 对每个输入的偏导数，并把它们组成梯度：\n$$ \\nabla_{\\boldsymbol{x}}y= \\begin{bmatrix} \\dfrac{\\partial y}{\\partial x_1}\\\\ \\dfrac{\\partial y}{\\partial x_2}\\\\ \\vdots\\\\ \\dfrac{\\partial y}{\\partial x_n} \\end{bmatrix}. $$例如\n$$ y=x_1^2+3x_2, $$其梯度为\n$$ \\nabla_{\\boldsymbol{x}}y= \\begin{bmatrix} 2x_1\\\\ 3 \\end{bmatrix}. $$梯度记录的是：一个标量输出对每个输入方向分别有多敏感。\n多个输入、多个输出：雅可比矩阵 进一步考虑多输入、多输出函数：\n$$ \\boldsymbol{x}= \\begin{bmatrix} x_1\\\\ \\vdots\\\\ x_n \\end{bmatrix}, \\qquad \\boldsymbol{y}=f(\\boldsymbol{x})= \\begin{bmatrix} y_1\\\\ \\vdots\\\\ y_m \\end{bmatrix}. $$每个输出 $y_i$ 都可能依赖每个输入 $x_j$。把全部一阶偏导数按照“输出为行、输入为列”排列，就得到雅可比矩阵：\n$$ J_f(\\boldsymbol{x}) =\\frac{\\partial\\boldsymbol{y}}{\\partial\\boldsymbol{x}} =\\begin{bmatrix} \\dfrac{\\partial y_1}{\\partial x_1} \u0026 \\dfrac{\\partial y_1}{\\partial x_2} \u0026 \\cdots \u0026 \\dfrac{\\partial y_1}{\\partial x_n}\\\\ \\dfrac{\\partial y_2}{\\partial x_1} \u0026 \\dfrac{\\partial y_2}{\\partial x_2} \u0026 \\cdots \u0026 \\dfrac{\\partial y_2}{\\partial x_n}\\\\ \\vdots \u0026 \\vdots \u0026 \\ddots \u0026 \\vdots\\\\ \\dfrac{\\partial y_m}{\\partial x_1} \u0026 \\dfrac{\\partial y_m}{\\partial x_2} \u0026 \\cdots \u0026 \\dfrac{\\partial y_m}{\\partial x_n} \\end{bmatrix} \\in\\mathbb{R}^{m\\times n}. $$在本文采用的约定中，\n$$ \\left[J_f(\\boldsymbol{x})\\right]_{ij} =\\frac{\\partial y_i}{\\partial x_j}. $$因此：\n第 $i$ 行是输出 $y_i$ 对全部输入的梯度； 第 $j$ 列表示输入 $x_j$ 对全部输出的影响； 行数等于输出维度，列数等于输入维度。 有些教材会采用转置后的排列方式，因此阅读公式时应先确认作者对雅可比矩阵形状的约定。\n具体计算一个雅可比矩阵 考虑函数\n$$ y_1=x_1^2+3x_2,\\qquad y_2=x_1x_2. $$写成向量形式：\n$$ f\\!\\left( \\begin{bmatrix} x_1\\\\ x_2 \\end{bmatrix} \\right) =\\begin{bmatrix} x_1^2+3x_2\\\\ x_1x_2 \\end{bmatrix}. $$分别计算每个输出对每个输入的偏导数：\n$$ \\frac{\\partial y_1}{\\partial x_1}=2x_1,\\qquad \\frac{\\partial y_1}{\\partial x_2}=3, $$$$ \\frac{\\partial y_2}{\\partial x_1}=x_2,\\qquad \\frac{\\partial y_2}{\\partial x_2}=x_1. $$所以\n$$ J_f(\\boldsymbol{x}) =\\begin{bmatrix} 2x_1 \u0026 3\\\\ x_2 \u0026 x_1 \\end{bmatrix}. $$在点\n$$ \\boldsymbol{x}= \\begin{bmatrix} 1\\\\ 2 \\end{bmatrix} $$处，雅可比矩阵为\n$$ J_f(1,2)= \\begin{bmatrix} 2 \u0026 3\\\\ 2 \u0026 1 \\end{bmatrix}. $$这个矩阵给出了函数在 $(1,2)$ 附近的局部变化关系：第一列表示 $x_1$ 的微小变化会分别以约 $2$ 倍的速度影响 $y_1$ 和 $y_2$；第二列表示 $x_2$ 的微小变化会分别以约 $3$ 倍和 $1$ 倍的速度影响两个输出。\n雅可比矩阵最核心的含义：局部线性化 只会计算偏导数，还没有真正理解雅可比矩阵。它最核心的作用，是把一个光滑的非线性函数在某一点附近近似成线性映射：\n$$ f(\\boldsymbol{x}+\\Delta\\boldsymbol{x}) \\approx f(\\boldsymbol{x})+J_f(\\boldsymbol{x})\\Delta\\boldsymbol{x}. $$也可以写成\n$$ \\Delta\\boldsymbol{y} \\approx J_f(\\boldsymbol{x})\\Delta\\boldsymbol{x}. $$继续使用上一节的例子。在 $(1,2)$ 附近，令\n$$ \\Delta\\boldsymbol{x}= \\begin{bmatrix} 0.01\\\\ -0.02 \\end{bmatrix}, $$则\n$$ \\Delta\\boldsymbol{y} \\approx \\begin{bmatrix} 2 \u0026 3\\\\ 2 \u0026 1 \\end{bmatrix} \\begin{bmatrix} 0.01\\\\ -0.02 \\end{bmatrix} =\\begin{bmatrix} -0.04\\\\ 0 \\end{bmatrix}. $$这意味着输入按上述方向发生微小变化时，$y_1$ 预计减少约 $0.04$，而 $y_2$ 在一阶近似下保持不变。\n单变量导数是一个数，因为输入和输出都只有一个方向；高维函数有许多输入方向和输出方向，因此它的“斜率”自然表现为一个矩阵。雅可比矩阵就是这个高维斜率。\n雅可比矩阵与神经网络 神经网络本质上是一个由参数控制的多输入、多输出函数：\n$$ \\boldsymbol{y}=f(\\boldsymbol{x};\\boldsymbol{\\theta}), $$其中 $\\boldsymbol{x}$ 是输入，$\\boldsymbol{y}$ 是模型输出，$\\boldsymbol{\\theta}$ 是模型参数。因此至少有两类重要的雅可比矩阵：\n$$ J_{\\boldsymbol{x}} =\\frac{\\partial\\boldsymbol{y}}{\\partial\\boldsymbol{x}}, \\qquad J_{\\boldsymbol{\\theta}} =\\frac{\\partial\\boldsymbol{y}}{\\partial\\boldsymbol{\\theta}}. $$前者描述输出对输入的敏感程度，后者描述输出对模型参数的敏感程度。\n线性层的雅可比矩阵 对于线性层\n$$ \\boldsymbol{y}=W\\boldsymbol{x}+\\boldsymbol{b}, \\qquad W\\in\\mathbb{R}^{m\\times n}, $$第 $i$ 个输出可以写成\n$$ y_i=\\sum_j W_{ij}x_j+b_i. $$于是\n$$ \\frac{\\partial y_i}{\\partial x_j}=W_{ij}, $$从而得到\n$$ \\frac{\\partial\\boldsymbol{y}}{\\partial\\boldsymbol{x}}=W. $$也就是说，线性层相对于输入的雅可比矩阵就是它的权重矩阵。\n激活函数的雅可比矩阵 假设激活函数逐元素作用：\n$$ \\boldsymbol{h}=\\sigma(\\boldsymbol{z}), \\qquad h_i=\\sigma(z_i). $$因为 $h_i$ 只依赖 $z_i$，所以\n$$ \\frac{\\partial h_i}{\\partial z_j} =\\begin{cases} \\sigma'(z_i), \u0026 i=j,\\\\ 0, \u0026 i\\ne j. \\end{cases} $$它的雅可比矩阵是对角矩阵：\n$$ J_\\sigma(\\boldsymbol{z}) =\\operatorname{diag}\\!\\left( \\sigma'(z_1),\\sigma'(z_2),\\ldots,\\sigma'(z_n) \\right). $$以 ReLU 为例：\n$$ \\operatorname{ReLU}(z)=\\max(0,z), $$在 $z\\ne 0$ 时，\n$$ \\operatorname{ReLU}'(z) =\\begin{cases} 1, \u0026 z\u003e0,\\\\ 0, \u0026 z\u003c0. \\end{cases} $$在 $z=0$ 处函数不可导，深度学习框架通常选取一个约定值。ReLU 的雅可比矩阵在对角线上主要由 $0$ 和 $1$ 组成：正值位置允许梯度通过，负值位置会把梯度截断。\n两层神经网络的雅可比矩阵 考虑一个两层网络：\n$$ \\boldsymbol{a}=W_1\\boldsymbol{x}+\\boldsymbol{b}_1, $$$$ \\boldsymbol{h}=\\sigma(\\boldsymbol{a}), $$$$ \\boldsymbol{y}=W_2\\boldsymbol{h}+\\boldsymbol{b}_2. $$根据链式法则，输出对输入的雅可比矩阵为\n$$ \\frac{\\partial\\boldsymbol{y}}{\\partial\\boldsymbol{x}} =W_2\\operatorname{diag}\\!\\left(\\sigma'(\\boldsymbol{a})\\right)W_1. $$这就是链式法则的矩阵形式。网络每经过一层，整体雅可比矩阵就要与该层的局部雅可比矩阵相乘。\n雅可比矩阵和反向传播 雅可比矩阵在 AI 中最核心的应用之一就是反向传播。设神经网络为\n$$ \\boldsymbol{y}=f(\\boldsymbol{x}), $$损失函数为\n$$ L=\\ell(\\boldsymbol{y}), $$其中 $\\boldsymbol{y}$ 是向量，$L$ 是标量。为了计算损失对输入的梯度，根据链式法则有\n$$ \\nabla_{\\boldsymbol{x}}L =J_f(\\boldsymbol{x})^{\\mathsf{T}}\\nabla_{\\boldsymbol{y}}L, $$其中\n$$ J_f(\\boldsymbol{x}) =\\frac{\\partial\\boldsymbol{y}}{\\partial\\boldsymbol{x}}. $$如果 $\\boldsymbol{x}\\in\\mathbb{R}^n$、$\\boldsymbol{y}\\in\\mathbb{R}^m$，那么\n$$ J_f\\in\\mathbb{R}^{m\\times n}, \\qquad \\nabla_{\\boldsymbol{y}}L\\in\\mathbb{R}^m, $$因此\n$$ J_f^{\\mathsf{T}}\\nabla_{\\boldsymbol{y}}L \\in\\mathbb{R}^n, $$恰好得到损失相对于输入的梯度。类似地，损失对模型参数的梯度为\n$$ \\nabla_{\\boldsymbol{\\theta}}L =J_{\\boldsymbol{\\theta}}^{\\mathsf{T}}\\nabla_{\\boldsymbol{y}}L, \\qquad J_{\\boldsymbol{\\theta}} =\\frac{\\partial\\boldsymbol{y}}{\\partial\\boldsymbol{\\theta}}. $$因此，反向传播的本质可以概括为：\n从输出端开始，把下游梯度依次乘以各层局部雅可比矩阵的转置，使梯度沿计算图反向传递。\n实际的自动微分系统通常不会显式构造完整雅可比矩阵，因为它可能非常庞大。反向传播直接计算“转置雅可比矩阵与向量的乘积”，也就是向量—雅可比积，从而在保留链式法则结果的同时显著节省内存和计算量。\n总结 雅可比矩阵把多输入、多输出函数的全部一阶偏导数组织为一个矩阵：\n$$ \\left[J_f(\\boldsymbol{x})\\right]_{ij} =\\frac{\\partial y_i}{\\partial x_j}. $$它有三个层层递进的理解角度：\n计算角度：每一行是一个输出对全部输入的梯度； 几何角度：它是非线性函数在某一点附近的局部线性映射； 神经网络角度：各层雅可比矩阵通过链式法则相乘，反向传播则不断计算转置雅可比矩阵与下游梯度的乘积。 从一维导数到梯度，再到雅可比矩阵，本质上都是在回答同一个问题：输入发生微小变化时，输出会怎样变化。\n","permalink":"https://zhangge.dev/math/jacobian-matrix/","summary":"雅可比矩阵把多输入、多输出函数的一阶偏导数组织成矩阵，是高维函数的局部线性映射，也是理解神经网络链式法则与反向传播的关键。","title":"雅可比矩阵"},{"content":"Feed Forward 直译为“前馈”，核心含义是信息按照固定方向从输入流向输出，不存在循环反馈。在 Transformer 中，Feed Forward Network（FFN）是每个 Block 内部的重要子模块：Attention 负责不同 token 之间的信息交互，FFN 则独立地对每个 token 的特征进行非线性加工。\n要理解 FFN，首先要从激活函数以及它所引入的非线性讲起。\n激活函数 激活函数是一类数学函数，用来给神经网络引入非线性。神经网络中的一个线性层通常写成：\n$$ y=Wx+b, $$其中 $x$ 是输入向量，$W$ 是权重矩阵，$b$ 是偏置，$y$ 是输出向量。\n如果连续堆叠两个线性层：\n$$ \\begin{aligned} h\u0026=W_1x+b_1,\\\\ y\u0026=W_2h+b_2, \\end{aligned} $$代入可得：\n$$ y=W_2(W_1x+b_1)+b_2 =(W_2W_1)x+(W_2b_1+b_2). $$令：\n$$ W'=W_2W_1,\\qquad b'=W_2b_1+b_2, $$那么：\n$$ y=W'x+b'. $$也就是说，两层线性层本质上仍然等价于一层线性层。即使堆叠几十层，只要中间没有非线性操作，整个网络最终仍然只是一个线性变换，表达能力非常有限。\n因此需要在两个线性层之间加入激活函数：\n$$ h=\\sigma(W_1x+b_1), $$这里的 $\\sigma$ 就是激活函数。因为激活函数通常是非线性的，所以整个计算不再能合并成一次简单的矩阵乘法。\n为什么需要非线性 假设输入只有一个数字 $x$，纯线性模型只能表示：\n$$ y=ax+b. $$它只能画出一条直线，但现实中的关系通常不是线性的。例如：\n图片中某些边缘组合起来才表示眼睛； 眼睛、鼻子和嘴巴组合起来才表示人脸； 某些 token 的组合才能表达否定、因果、讽刺等语义； 某个特征超过阈值后才应该激活。 激活函数使网络能够表示弯曲、分段、组合和条件性的复杂关系。可以把它理解为：线性层负责重新组合特征，激活函数负责决定哪些特征需要保留、增强或抑制。\nReLU ReLU 的全称是 Rectified Linear Unit，中文通常叫“修正线性单元”。其定义为：\n$$ \\operatorname{ReLU}(x)=\\max(0,x)=\\begin{cases} x,\u0026x\u003e0,\\\\ 0,\u0026x\\le 0. \\end{cases} $$因此 ReLU 所做的事情很简单：负数截断为 0，正数保持不变。\n输入 $x$ ReLU 输出 $-3$ $0$ $-0.5$ $0$ $0$ $0$ $2$ $2$ $5$ $5$ ReLU 为什么能引入非线性 虽然 ReLU 在正数区域和负数区域分别都是线性的，但它整体不是一个线性函数。线性函数需要满足：\n$$ f(x_1+x_2)=f(x_1)+f(x_2). $$取 $x_1=1,\\ x_2=-2$，那么：\n$$ \\operatorname{ReLU}(x_1+x_2) =\\operatorname{ReLU}(-1)=0, $$但：\n$$ \\operatorname{ReLU}(x_1)+\\operatorname{ReLU}(x_2) =1+0=1. $$两者不相等，所以 ReLU 不是线性函数。神经网络通过大量线性层和 ReLU 的组合，可以构造非常复杂的分段线性函数。\nReLU 的优点 计算简单。 ReLU 只需要计算 $\\max(0,x)$。相比 Sigmoid、Tanh 中的指数运算，计算成本较低。 正半轴梯度稳定。 当 $x\u0026gt;0$ 时： $$ \\operatorname{ReLU}'(x)=1. $$梯度不会因为激活函数本身不断缩小，这比 Sigmoid 在饱和区域中的梯度衰减问题要轻。\n激活具有稀疏性。 负数会全部变成 0： $$ [-2,3,-1,5]\\longrightarrow[0,3,0,5]. $$这种稀疏激活有时有利于特征选择和训练。\nReLU 的问题：死亡 ReLU 由于负数区域的导数为 0，如果一个神经元长期满足：\n$$ Wx+b\u003c0, $$那么它的输出一直是 0，梯度也一直是 0，权重可能再也无法更新回来。这种情况称为 Dying ReLU，即“死亡 ReLU”。\n为缓解这个问题，可以使用 Leaky ReLU：\n$$ \\operatorname{LeakyReLU}(x)=\\begin{cases} x,\u0026x\u003e0,\\\\ \\alpha x,\u0026x\\le 0, \\end{cases} $$其中 $\\alpha$ 是一个很小的正数，例如 $0.01$。这样负数区域仍然保留一个很小的梯度：\n$$ \\operatorname{LeakyReLU}'(x)=\\alpha,\\qquad x\u003c0. $$Feed Forward 的含义 “Feed Forward”直译是“前馈”。它表示信息按照固定方向从输入流向输出，不存在循环反馈。例如：\n$$ x\\longrightarrow\\operatorname{Linear} \\longrightarrow\\operatorname{Activation} \\longrightarrow\\operatorname{Linear} \\longrightarrow y. $$这是一个前馈计算过程。\n广义的前馈神经网络 最普通的多层感知机 MLP，就是一种前馈神经网络：\n$$ x\\rightarrow\\operatorname{Linear}_1 \\rightarrow\\operatorname{ReLU} \\rightarrow\\operatorname{Linear}_2 \\rightarrow\\operatorname{ReLU} \\rightarrow\\operatorname{Linear}_3 \\rightarrow y. $$它和循环神经网络 RNN 的区别在于：\n前馈网络中，信息只向前传播； RNN 中，前一时刻的隐藏状态会反馈给下一时刻。 不过，训练前馈网络时仍然会进行反向传播。“前馈”描述的是模型的计算图结构，并不表示训练时没有反向传播。\nTransformer 中的 Feed Forward Network 在 Transformer 中，FFN 通常是每个 Transformer Block 内部的一个子模块。经典形式为：\n$$ \\operatorname{FFN}(x) =W_2\\operatorname{ReLU}(W_1x+b_1)+b_2. $$也可以写成：\n$$ x\\xrightarrow{W_1}h \\xrightarrow{\\operatorname{ReLU}}a \\xrightarrow{W_2}y. $$其中通常先升维，再降维：\n$$ d_{\\text{model}}\\rightarrow d_{\\text{ff}}\\rightarrow d_{\\text{model}}. $$例如：\n$$ 4096\\rightarrow16384\\rightarrow4096. $$若输入 token 向量为 $x\\in\\mathbb{R}^{4096}$，经过第一个线性层：\n$$ h=W_1x+b_1,\\qquad h\\in\\mathbb{R}^{16384}, $$再经过激活函数：\n$$ a=\\operatorname{ReLU}(h), $$最后通过第二个线性层恢复原始隐藏维度：\n$$ y=W_2a+b_2,\\qquad y\\in\\mathbb{R}^{4096}. $$因此，FFN 不是一个激活函数，而是一个包含线性投影和激活函数的完整模块。\nFFN 为什么要先升维再降维 假设输入向量是 4 维：\n$$ x=[x_1,x_2,x_3,x_4]. $$第一个线性层把它投影到更大的特征空间：\n$$ 4\\rightarrow16. $$这相当于生成更多中间特征：\n$$ h=[h_1,h_2,\\ldots,h_{16}], $$其中每个 $h_i$ 都是输入各维度的不同组合。激活函数再对这些中间特征进行非线性筛选：\n$$ a_i=\\operatorname{ReLU}(h_i). $$第二个线性层把这些中间特征重新组合，并降回原始隐藏维度：\n$$ 16\\rightarrow4. $$可以粗略理解为：\n第一个线性层展开出大量候选特征； 激活函数筛选或门控这些特征； 第二个线性层把特征组合回原始隐藏维度。 升维提供了更大的中间表示空间，使 FFN 能学习更复杂的变换。\nAttention 和 FFN 的分工 Transformer Block 可以简化为：\n$$ x\\longrightarrow\\operatorname{Attention} \\longrightarrow\\operatorname{FFN}. $$二者作用不同：\nAttention 主要负责不同 token 之间的信息交互； FFN 主要负责对每个 token 自身的特征进行非线性加工。 同一个 FFN 会独立应用到每个 token：\n$$ y_i=\\operatorname{FFN}(x_i). $$不同 token 之间不会在 FFN 内直接交互。换句话说，Attention 在 token 维度上混合信息，而 FFN 在特征维度上变换信息。\n一个具体的 FFN 计算示例 假设输入和第一层权重为：\n$$ x= \\begin{bmatrix} 1\\\\ 2 \\end{bmatrix}, \\qquad W_1= \\begin{bmatrix} 1\u00261\\\\ -1\u00261\\\\ 2\u0026-1 \\end{bmatrix}. $$暂时忽略偏置，第一个线性层得到：\n$$ h=W_1x=\\begin{bmatrix} 1\\times1+1\\times2\\\\ -1\\times1+1\\times2\\\\ 2\\times1-1\\times2 \\end{bmatrix}=\\begin{bmatrix} 3\\\\ 1\\\\ 0 \\end{bmatrix}. $$经过 ReLU 后 $a=[3,1,0]^{\\mathsf T}$。假设第二个线性层为：\n$$ W_2= \\begin{bmatrix} 1\u00262\u00261\\\\ -1\u00261\u00262 \\end{bmatrix}, $$那么：\n$$ y=W_2a=\\begin{bmatrix} 1\\times3+2\\times1+1\\times0\\\\ -1\\times3+1\\times1+2\\times0 \\end{bmatrix}=\\begin{bmatrix} 5\\\\ -2 \\end{bmatrix}. $$整个过程完成了：\n$$ \\mathbb{R}^{2}\\rightarrow\\mathbb{R}^{3} \\xrightarrow{\\operatorname{ReLU}}\\mathbb{R}^{3} \\rightarrow\\mathbb{R}^{2}. $$现代大模型中的激活与门控 经典 Transformer 论文中的 FFN 使用 ReLU，但现代大语言模型更常使用 GELU、SiLU/Swish、GLU、SwiGLU 或 GeGLU。其中，SwiGLU 是现代 Transformer 中常见的一种门控前馈网络结构。\nSwiGLU 不是单纯的激活函数，而是：\nSwish/SiLU 激活函数 + GLU 门控机制。\n它通常用来替代经典的：\n$$ \\operatorname{Linear}\\rightarrow\\operatorname{ReLU}\\rightarrow\\operatorname{Linear}. $$SiLU SwiGLU 中的 “Swi” 来自 Swish。实践中通常使用的 Swish 形式就是 SiLU：\n$$ \\operatorname{SiLU}(x)=x\\cdot\\sigma(x), \\qquad \\sigma(x)=\\frac{1}{1+e^{-x}}. $$所以：\n$$ \\operatorname{SiLU}(x)=\\frac{x}{1+e^{-x}}. $$和 ReLU 相比，ReLU 会直接将负数截断为 0，而 SiLU 会保留一个较小的负值。因此：\nReLU 在 $x=0$ 处有折点； SiLU 处处平滑、可导； SiLU 的负半轴仍然可以传播梯度； SiLU 不容易出现严重的“死亡神经元”。 可以粗略理解为：\n$$ \\operatorname{SiLU}(x)=x\\times\\text{软门控系数}, $$因为 $\\sigma(x)\\in(0,1)$，Sigmoid 决定输入 $x$ 被保留多少。\nGLU GLU 的全称是 Gated Linear Unit，即门控线性单元。普通 FFN 只有一条升维分支：\n$$ h=xW_{\\text{up}}. $$GLU 会把输入投影成两条分支：\n$$ a=xW_{\\text{gate}},\\qquad b=xW_{\\text{up}}, $$然后一条分支充当“门”，另一条分支充当“内容”：\n$$ h=\\sigma(a)\\odot b, $$其中 $\\sigma(a)$ 决定内容分支的每个维度保留多少，$\\odot$ 表示对应元素相乘。\n例如：\n$$ \\sigma(a)=[0.1,0.9,0.3],\\qquad b=[10,5,-2], $$那么：\n$$ h=[0.1,0.9,0.3]\\odot[10,5,-2]=[1,4.5,-0.6]. $$第一个维度被大幅抑制，第二个维度基本被保留，第三个维度被部分保留。门控结构让网络不仅能决定“激活值是多少”，还可以根据输入动态决定“这条特征通道应该打开多少”。\nSwiGLU 的计算 SwiGLU 把 SiLU 激活与 GLU 门控结合起来。其核心计算为：\n$$ h=\\operatorname{SiLU}(xW_{\\text{gate}}) \\odot(xW_{\\text{up}}), $$然后通过降维矩阵：\n$$ y=hW_{\\text{down}}. $$合起来就是：\n$$ y= \\left[ \\operatorname{SiLU}(xW_{\\text{gate}}) \\odot(xW_{\\text{up}}) \\right]W_{\\text{down}}. $$其中 $W_{\\text{gate}}$ 生成门控分支，$W_{\\text{up}}$ 生成内容分支，$W_{\\text{down}}$ 将结果降回隐藏维度。虽然实现比 ReLU FFN 更复杂，但本质仍然是：线性投影 $\\rightarrow$ 非线性激活或门控 $\\rightarrow$ 线性投影。\nSwiGLU 的优势 更强的动态特征选择。 门控分支会根据当前输入动态调制内容分支，不同 token 会生成不同的门控值； 激活函数平滑。 SiLU 没有 ReLU 在 0 处的尖锐折点，梯度变化更加平滑； 负半轴保留梯度。 SiLU 在大部分负半轴仍有非零梯度，因此不容易完全失活； 引入乘法交互。 两条输入相关分支之间进行逐元素乘法： $$ f(x)\\odot g(x), $$这种结构比单分支激活拥有更丰富的函数表达能力。在参数量和计算量相近的条件下，门控 FFN 往往比经典 ReLU/GELU FFN 表现更好，因此现代大语言模型广泛采用类似结构。\nSwiGLU 的代价 普通 FFN 有两个矩阵：\n$$ W_1,\\quad W_2. $$SwiGLU 有三个矩阵：\n$$ W_{\\text{gate}},\\quad W_{\\text{up}},\\quad W_{\\text{down}}. $$因此如果中间维度不缩小，计算量和参数量会显著增加。SwiGLU 还需要同时产生：\n$$ G=XW_{\\text{gate}}, \\qquad U=XW_{\\text{up}}, $$这会增加中间激活显存和内存带宽压力。推理框架通常会尝试融合 gate projection、up projection、SiLU 和 elementwise multiply，否则可能产生更多 kernel launch 和中间张量读写。\n总结 Feed Forward 的核心不是简单地“向前计算”，而是通过线性投影和非线性激活，对特征进行逐 token 加工：\n没有激活函数时，多层线性层仍可合并为一层，表达能力有限； ReLU 计算简单、正半轴梯度稳定，但存在死亡 ReLU 问题； Transformer FFN 通常先升维、执行非线性变换，再降回隐藏维度； Attention 负责 token 间的信息混合，FFN 负责每个 token 内部的特征变换； SwiGLU 通过 SiLU 和门控乘法提供更强的动态特征选择，但会增加投影、显存和实现成本。 参考 神经网络中的激活函数与前馈网络 ","permalink":"https://zhangge.dev/ai-fundamentals/feedforward/","summary":"梳理 Feed Forward 的基本含义、Transformer 中 FFN 与 Attention 的分工，并从 ReLU、SiLU、GLU 逐步推导到 SwiGLU。","title":"FeedForward"},{"content":"Transformer 结构中的 Add \u0026amp; Norm 由 Add 和 Norm 两部分组成：Add 指残差连接，Norm 指层归一化。它们共同作用于模型的各个子层，以提高训练效率和稳定性。\n以原始 Transformer 的 Post-LN 结构为例，两个子层的计算分别是：\n$$ \\operatorname{LayerNorm}\\left(X+\\operatorname{MultiHeadAttention}(X)\\right), $$$$ \\operatorname{LayerNorm}\\left(X+\\operatorname{FeedForward}(X)\\right). $$其中，$X$ 表示 Multi-Head Attention 或 Feed Forward 的输入，两个子层的输出与 $X$ 维度相同，因此可以直接相加。\nAdd 对应 $X+\\operatorname{MultiHeadAttention}(X)$ 或 $X+\\operatorname{FeedForward}(X)$。它是一种残差连接，通常用于解决深层网络训练困难的问题。Norm 对应 Layer Normalization，它把每个 token 的特征调整到相对稳定的数值尺度，从而加快收敛并改善训练稳定性。\n残差连接（Residual Add） 残差连接主要解决深层神经网络越来越难训练的问题。它的典型形式是：\n$$ y=F(x)+x, $$其中：\n$x$：这一层的输入； $F(x)$：若干网络层对 $x$ 做出的变换； $y$：输出。 核心思想是：网络不必重新学习完整映射，只需要学习相对于输入的变化量。\n1. 解决深层网络的退化问题 理论上，网络层数增加，表达能力应该更强，至少不应该比浅层网络差。新增的层完全可以什么都不做，直接实现恒等映射：\n$$ H(x)=x. $$这样深层网络至少可以退化成浅层网络。但实际训练中，普通网络很难让新增的多层精确学习恒等映射，结果可能出现：\n训练误差反而升高； 测试误差也升高； 网络越深，效果越差。 这称为网络退化问题，它不是单纯的过拟合。有了残差连接：\n$$ H(x)=F(x)+x. $$要实现恒等映射，只需要令：\n$$ F(x)=0. $$相比让多层网络直接学习 $H(x)=x$，让残差分支学习接近 0 通常更容易。\n2. 缓解梯度消失，使深层网络更容易训练 普通网络中，假设多层变换为：\n$$ x_{l+1}=F_l(x_l). $$反向传播时：\n$$ \\frac{\\partial x_L}{\\partial x_l} =\\prod_{i=l}^{L-1}\\frac{\\partial F_i}{\\partial x_i}. $$梯度需要连续乘很多个雅可比矩阵。如果每一项的模长都小于 1，梯度会越来越小，出现梯度消失。\n加入残差后：\n$$ x_{l+1}=x_l+F_l(x_l). $$对应的雅可比矩阵为：\n$$ \\frac{\\partial x_{l+1}}{\\partial x_l} =I+\\frac{\\partial F_l}{\\partial x_l}. $$这里多出了恒等项 $I$。因此反向传播时，即使残差分支 $F$ 的梯度很小，梯度仍然可以沿恒等路径传播：\n$$ \\frac{\\partial x_{l+1}}{\\partial x_l}\\approx I. $$可以直观理解为：普通网络只有一条崎岖小路，残差连接额外修了一条直通道路。但残差连接只是缓解梯度消失和训练困难，不代表它能无条件彻底消除梯度消失或梯度爆炸。\n3. 保留原始信息，减少信息丢失 如果没有残差连接，每一层都要对上一层输出进行完整变换。经过很多层后，早期信息可能逐渐丢失。残差连接让输入直接加入输出，所以输出中至少保留了输入的一条直接通路。\n这对于 Transformer 尤其重要。例如：\n$$ x'=x+\\operatorname{Attention}(x). $$这表示 Attention 不需要完全替换原来的 token 表示，而是在原表示上补充上下文信息。后面的 FFN 也是：\n$$ y=x'+\\operatorname{FFN}(x'). $$FFN 只需要对表示进行增量调整。因此，Transformer 每一层更像是在保留原有语义的基础上，再逐步增加或修正一部分信息。\n4. 将“学习完整映射”变成“学习修正量” 很多深层网络中，每一层对表示的修改实际上都比较小。残差结构非常适合这种“逐层微调”的过程。例如大模型中的某一层可能只是：\n增强某些 token 之间的关联； 补充少量语义信息； 调整某些特征方向； 对已有表示做小幅修正。 因此，学习残差通常比学习完整表示更容易。\n残差连接主要解决三个问题：深层网络退化、梯度传播困难和信息容易丢失。它让网络加深时更容易保持原有能力，为梯度提供恒等直通路径，也让原始表示能够跨层保留。\nNorm（层归一化） 归一化是一种数据处理技术，旨在通过线性或非线性变换，将输入数据或神经网络层的输出映射到特定的数值范围或分布中。这一过程对于提升训练稳定性、加快收敛以及改善模型性能十分重要。\n神经网络中常见的归一化方法包括：\n批归一化（Batch Normalization）：在 batch 维度上统计均值和方差，能降低网络对初始化和学习率的敏感性，也有一定的正则化效果； 层归一化（Layer Normalization）：对单个样本自身的特征维度做归一化，不依赖同一批次中的其他样本； 组归一化（Group Normalization）：把特征划分为多个小组，再在各组内做归一化。 Transformer 使用的是层归一化。LayerNorm 主要做一件事：把一个 token 的各个特征维度调整到相对稳定的数值范围。它在 Transformer 中几乎是标准组件，主要作用是让网络训练更稳定、梯度传播更顺畅。\nLayerNorm 到底归一化什么 假设一个 token 的隐藏向量维度是 4：\n$$ x=[1,2,3,4]. $$LayerNorm 会在这 4 个特征维度上计算均值和方差。均值为：\n$$ \\mu=\\frac{1+2+3+4}{4}=2.5. $$方差为：\n$$ \\sigma^2 =\\frac{(1-2.5)^2+(2-2.5)^2+(3-2.5)^2+(4-2.5)^2}{4} =1.25. $$然后对每一个维度做归一化：\n$$ \\hat{x}_i=\\frac{x_i-\\mu}{\\sqrt{\\sigma^2+\\epsilon}}. $$忽略很小的 $\\epsilon$，得到大约：\n$$ \\hat{x}=[-1.34,-0.45,0.45,1.34]. $$归一化后，均值大约为 0，方差大约为 1。注意，它不是把所有值压到 $[0,1]$，而是把数据中心移动到 0 附近，同时调整尺度。\n为什么需要 LayerNorm 神经网络经过很多层变换后，隐藏状态的数值可能越来越不稳定。例如某一层输入是：\n$$ [0.2,-0.1,0.3], $$经过若干层以后可能变成：\n$$ [100,-50,80], $$或者：\n$$ [0.0001,-0.0002,0.0003]. $$数值过大可能带来梯度爆炸、激活值异常、Softmax 过度饱和和训练震荡；数值过小则可能带来梯度消失、参数更新缓慢和有效信息被淹没。LayerNorm 会把输入重新拉回一个较稳定的尺度。可以把它理解为：每经过一个模块，都重新校准一下当前 token 的特征分布。\nTransformer 中的输入形状 Transformer 的隐藏状态通常写作：\n$$ X\\in\\mathbb{R}^{B\\times T\\times D}, $$其中：\n$B$：batch size； $T$：序列长度； $D$：隐藏维度。 例如 $X.\\operatorname{shape}=[2,3,4]$，表示 2 个样本、每个样本 3 个 token、每个 token 是 4 维向量。LayerNorm 通常针对最后一个维度 $D$ 做归一化，也就是每个 token 独立计算：\n$$ \\mu_{b,t}=\\frac{1}{D}\\sum_{i=1}^{D}X_{b,t,i}, $$$$ \\sigma^2_{b,t}=\\frac{1}{D}\\sum_{i=1}^{D}\\left(X_{b,t,i}-\\mu_{b,t}\\right)^2. $$因此，$X_{b,t,:}$ 中的每一个 token 向量会单独归一化，不同 token 之间不会混在一起计算。\n为什么归一化后还需要 $\\gamma$ 和 $\\beta$ 完整的 LayerNorm 不只是标准化：\n$$ \\hat{x}_i=\\frac{x_i-\\mu}{\\sqrt{\\sigma^2+\\epsilon}}, $$还会进行一次可学习的缩放和平移：\n$$ y_i=\\gamma_i\\hat{x}_i+\\beta_i, $$其中，$\\gamma$ 是可学习的缩放参数，$\\beta$ 是可学习的偏移参数。若隐藏维度为 $D$，那么：\n$$ \\gamma,\\beta\\in\\mathbb{R}^{D}. $$强制所有数据始终保持均值 0、方差 1，可能会限制模型的表达能力。加入 $\\gamma$ 和 $\\beta$ 后，模型可以自己决定某个维度应该放大、缩小或偏移，也可以恢复到某种特定的数值分布。\n例如：\n$$ \\hat{x}=[-1,0,1],\\qquad \\gamma=[2,1,0.5],\\qquad \\beta=[1,0,-1], $$那么输出为：\n$$ y=\\gamma\\odot\\hat{x}+\\beta=[-1,0,-0.5]. $$因此，LayerNorm 不是单纯“抹平数据”，而是先把输入变得稳定，再让模型通过可学习参数重新调整。\nLayerNorm 和 BatchNorm 的区别 这是很容易混淆的地方。BatchNorm 通常对同一个特征维度在 batch 中进行统计。假设：\n$$ X\\in\\mathbb{R}^{B\\times D}, $$它对第 $i$ 个特征统计不同样本之间的均值和方差，也就是跨样本计算。LayerNorm 则对每个样本自身的特征做归一化；在 Transformer 中，就是每个 token 独立归一化其隐藏维度。\n为什么 Transformer 更适合 LayerNorm Transformer 常见输入具有以下特点：\n序列长度可能不一致； batch size 可能很小； 推理阶段经常一个 token 一个 token 生成； 不同 token 的分布可能差异很大。 BatchNorm 依赖 batch 统计量。如果 batch 很小，均值和方差就不稳定。尤其在大模型 decode 阶段，可能一次只有：\n$$ B=1,\\qquad T=1. $$这时 BatchNorm 很难获得可靠的跨样本统计信息。LayerNorm 不依赖其他样本，只使用当前 token 自己的隐藏维度，所以：\nbatch 大小不影响结果； 训练和推理逻辑一致； 适合变长序列； 适合自回归生成。 因此，Transformer 通常采用 LayerNorm，而不是 BatchNorm。\nLayerNorm 与残差连接的关系 Transformer 中，LayerNorm 经常和残差连接一起出现。常见形式包括 Pre-LN：\n$$ x+\\operatorname{Attention}(\\operatorname{LN}(x)), $$以及原始 Transformer 使用的 Post-LN：\n$$ \\operatorname{LN}\\left(x+\\operatorname{Attention}(x)\\right). $$残差连接负责提供信息和梯度的直通路径，LayerNorm 负责控制每层输入或输出的数值尺度。二者解决的问题不同，但会相互配合。\n可以把它们理解为：残差连接修建高速公路，让信息可以直接通过；LayerNorm 设置限速和车道规则，避免数值失控。\n总结 Add \u0026amp; Norm 是 Transformer 能够稳定堆叠很多层的重要基础：\nAdd 通过残差连接缓解网络退化和梯度传播困难，并保留原始信息； LayerNorm 对每个 token 的隐藏维度独立归一化，使隐藏状态保持稳定尺度； $\\gamma$ 和 $\\beta$ 在稳定数值的同时保留模型的表达能力； LayerNorm 不依赖 batch 统计量，因此比 BatchNorm 更适合变长序列和自回归生成； 残差连接提供直通路径，LayerNorm 控制数值尺度，二者共同保证 Transformer 的深层训练。 参考 详解残差网络 Transformer 中的 Add \u0026amp; Norm ","permalink":"https://zhangge.dev/ai-fundamentals/add-layernorm/","summary":"从残差连接、梯度传播、信息保留到 LayerNorm 的计算、可学习参数及其与 BatchNorm 的区别，系统理解 Transformer 中的 Add \u0026amp; Norm。","title":"Add\u0026LayerNorm"},{"content":"Sigmoid 函数 Sigmoid 函数是一种 logistic 函数，它将任意值转换到 $(0,1)$ 之间，函数表达式为：\n$$ \\sigma(x)=\\frac{1}{1+e^{-x}}. $$\nSigmoid 函数的优点包括：\n输出位于 $(0,1)$ 之间，输出范围有限，优化相对稳定，可以用作输出层； 它是连续函数，便于求导。 它的缺点也很明显：\n饱和性。 从曲线可以看出，两侧的导数逐渐趋近于 0，容易造成梯度消失； 激活函数的偏移现象。 Sigmoid 的输出值均大于 0，使得输出不是零均值。后一层神经元会接收到上一层非零均值的信号，这会对梯度产生影响； 计算复杂度较高。 Sigmoid 中包含指数运算。 Softmax 函数 Softmax 是二分类函数 Sigmoid 在多分类上的推广，目的是将多分类结果以概率的形式展现出来。它的数学公式为：\n$$ \\operatorname{softmax}(x_i) =\\frac{e^{x_i}}{\\sum_{j=1}^{N}e^{x_j}}. $$例如，原始输出为 $[3,1,-3]$，经过 Softmax 后会被映射为 $(0,1)$ 之间的值，并且所有值之和为 1：\n$$ \\begin{aligned} p_1\u0026=\\frac{e^3}{e^3+e^1+e^{-3}}\\approx 0.88,\\\\ p_2\u0026=\\frac{e^1}{e^3+e^1+e^{-3}}\\approx 0.12,\\\\ p_3\u0026=\\frac{e^{-3}}{e^3+e^1+e^{-3}}\\approx 0. \\end{aligned} $$因此，可以把这些输出理解为概率。在最后选择输出节点时，可以选取概率最大的节点，也就是原始值最大的节点，作为预测目标。\nSoftmax 先通过指数函数拉大输入向量中元素之间的差异，然后将结果归一化为概率分布。应用到分类问题时，各类别之间的概率差异会更加显著，最大值产生的概率更接近 1，输出分布的形式也更接近目标分布。\n如何理解 Softmax 可以从三个不同角度理解 Softmax。不同的视角能帮助我们更深入地理解它的应用场景。\n作为 argmax 的平滑近似 Softmax 可以看作 argmax 的一种平滑近似。argmax 会直接选出最大值，并产生 one-hot 形式的结果；Softmax 则对这种输出做了一定平滑，把 one-hot 输出中最大位置对应的 1，按照输入元素的大小分配到其他位置。\n所谓 argmax，就是直接返回输入参数中最大值的索引。例如：\n$$ x=[2,7,4],\\qquad \\max(x)=7,\\qquad \\operatorname{argmax}(x)=1. $$作为类别概率分布 Softmax 将输入向量归一化并映射为类别概率分布。这也是深度学习中经常把 Softmax 作为 MLP 最后一层，并配合交叉熵损失函数使用的原因。交叉熵可以衡量两个分布之间的差异。\n从概率图模型理解 从概率图模型的角度看，Softmax 的形式可以理解为概率无向图上的联合概率。条件最大熵模型与 Softmax 回归模型在形式上是一致的。概率图模型在很大程度上借用了一些热力学系统的理论，因此也可以从物理系统的角度理解 Softmax。\n在推理阶段，Softmax 的输出通常用于：\n采样： 根据概率分布随机采样下一个 token； 贪婪解码： 选择概率最高的 token，也就是执行 argmax。 如何优化 Softmax 1. 数值稳定性优化 Softmax 定义为：\n$$ \\operatorname{softmax}(x_i) =\\frac{e^{x_i}}{\\sum_{j=1}^{N}e^{x_j}}. $$直接计算可能发生溢出。例如在 FP16 中，较大的 $x_i$ 做指数运算很容易得到 inf。\n假设输入为：\n$$ x=[12,11,10]. $$数学上：\n$$ e^{12}\\approx 162754.79. $$但 FP16 能表示的最大有限值约为：\n$$ 65504. $$所以在 FP16 中，直接计算 $e^{12}$ 会溢出为 inf。后续可能出现 Inf/Inf，结果就是 NaN。\n减去最大值 Softmax 有一个重要性质：所有输入同时减去同一个数，结果不变。对于任意常数 $c$：\n$$ \\frac{e^{x_i-c}}{\\sum_j e^{x_j-c}} =\\frac{e^{x_i}/e^c}{\\sum_j e^{x_j}/e^c} =\\frac{e^{x_i}}{\\sum_j e^{x_j}}. $$因此，取：\n$$ m=\\max(x)=12. $$计算：\n$$ x-m=[0,-1,-2], $$于是：\n$$ e^{x-m}=[1,e^{-1},e^{-2}]. $$所有指数结果都小于等于 1，不再发生上溢。最终：\n$$ \\operatorname{softmax}(x) =\\frac{[1,e^{-1},e^{-2}]}{1+e^{-1}+e^{-2}} \\approx[0.6652,0.2447,0.0900]. $$这就是“减去最大值”的数值稳定 Softmax。最大的指数输入变成 0，所有指数结果都落在 $(0,1]$ 范围内。\n工程上一般还会：\n输入、输出使用 FP16/BF16； 最大值归约和求和使用 FP32； 计算交叉熵时使用 log_softmax，避免先计算 Softmax 再取对数； 对全 Mask 行进行特殊处理，防止出现 $-\\infty-(-\\infty)$ 导致 NaN。 工程计算中的数值类型优化 典型计算过程可以理解为：\n# logits 原本是 FP16/BF16 x_fp32 = logits.float() max_value = x_fp32.max(dim=-1, keepdim=True).values exp_value = torch.exp(x_fp32 - max_value) sum_value = exp_value.sum(dim=-1, keepdim=True) prob_fp32 = exp_value / sum_value # 最终按需要转回 FP16/BF16 prob = prob_fp32.to(logits.dtype) 也就是：\nFP16/BF16 输入 ↓ 转换成 FP32 ↓ 减最大值、exp、求和、除法 ↓ 得到 FP32 Softmax ↓ 根据需要转回 FP16/BF16 为什么不全程使用 FP32？因为 FP16/BF16：\n占用显存更少； 显存带宽压力更小； 矩阵乘法速度通常更高； 更适合 Tensor Core； 模型权重和激活通常本来就是低精度。 为什么中间过程又要使用 FP32？因为 FP32 的数值范围和有效精度更高，更适合指数和累加操作。\nlog_softmax Softmax 之后经常需要取对数，例如交叉熵：\n$$ L=-\\log p_y, $$其中 $y$ 是正确类别。最直接的写法是：\nprob = softmax(logits) log_prob = torch.log(prob) loss = -log_prob[target] 问题是，某个概率可能非常小，并在低精度下溢为 0。例如：\n$$ x=[0,-100]. $$理论上，第二个类别的概率约为：\n$$ e^{-100}\\approx 3.72\\times 10^{-44}. $$如果先计算 Softmax，这个概率很可能变成 0，然后：\n$$ \\log(0)=-\\infty, $$损失也会变成无穷大。但理论上的对数概率其实约为 $-100$，而不是负无穷。\nlog_softmax 直接计算对数概率。根据：\n$$ p_i=\\frac{e^{x_i}}{\\sum_j e^{x_j}}, $$两边取对数：\n$$ \\log p_i=x_i-\\log\\left(\\sum_j e^{x_j}\\right). $$进一步使用稳定的 logsumexp。令 $m=\\max_j x_j$，则：\n$$ \\log p_i =x_i-\\left[m+\\log\\left(\\sum_j e^{x_j-m}\\right)\\right]. $$这样不需要先得到一个极小的概率再取对数。对于 $x=[0,-100]$，log_softmax 可以直接得到近似结果：\n$$ [0,-100]. $$全 Mask 行处理 在 Attention 中，不允许关注的位置会被 Mask，常见做法是将对应分数设成 $-\\infty$。例如某一行：\n$$ [2.1,-\\infty,0.7,-\\infty]. $$Softmax 后，被 Mask 的位置概率为 0：\n$$ [p_1,0,p_3,0]. $$所谓“全 Mask 行”，是指这一整行没有任何有效位置：\n$$ [-\\infty,-\\infty,-\\infty,-\\infty]. $$为什么全 Mask 会产生 NaN？稳定 Softmax 的第一步是求最大值：\n$$ m=\\max(-\\infty,-\\infty,-\\infty)=-\\infty. $$接下来减去最大值：\n$$ x_i-m=-\\infty-(-\\infty). $$在浮点运算中：\n$$ -\\infty-(-\\infty)=\\mathrm{NaN}, $$于是 $e^{\\mathrm{NaN}}=\\mathrm{NaN}$，后面整行都会变成 NaN。一旦 Attention 中出现 NaN，后续矩阵乘法、残差连接和网络层都可能被污染。\n这主要是数值稳定性优化，不一定会直接提升性能。\n常见处理方式是先判断这一行是否至少有一个有效元素：\nvalid_row = mask.any(dim=-1, keepdim=True) masked_logits = torch.where( mask, logits.float(), float(\u0026#34;-inf\u0026#34;), ) # 对于全 Mask 行，暂时将最大值设为 0，防止 -inf - (-inf) row_max = masked_logits.max(dim=-1, keepdim=True).values row_max = torch.where(valid_row, row_max, 0.0) exp_value = torch.where( mask, torch.exp(masked_logits - row_max), 0.0, ) denominator = exp_value.sum(dim=-1, keepdim=True) safe_denominator = torch.where(valid_row, denominator, 1.0) prob = exp_value / safe_denominator prob = torch.where(valid_row, prob, 0.0) 对于全 Mask 行，最终将它定义为：\n$$ [0,0,0,0]. $$这样后续计算 $PV$ 时，得到的 Attention 输出也是零向量，不会产生 NaN。\n2. 融合算子，减少显存读写 朴素 Softmax 可能被拆成多个 Kernel：\n求每一行的最大值； 计算 $\\exp(x-\\max(x))$； 求和； 除以总和。 如果每一步都把中间结果写回显存，就会产生大量 HBM 读写和 Kernel Launch。更好的实现会把这些步骤融合成一个或少数几个 Kernel：\nload logits → reduce max → exp(logits - max) → reduce sum → normalize → store result Attention 中还可以进一步融合：\nscale → add mask → softmax → dropout 常见形式包括：\nscale + mask + softmax； masked_softmax； softmax + dropout； $QK^\\mathsf{T}+\\text{scale}+\\text{mask}+\\text{softmax}+PV$。 Softmax 的算术量其实不大，很多时候真正的瓶颈是显存带宽和 Kernel 启动开销，因此算子融合往往比减少几次浮点运算更重要。\n3. Online Softmax 标准稳定 Softmax 需要先求最大值，再求指数和，逻辑上至少需要两次扫描。Online Softmax 可以在一次流式扫描中同时更新最大值和归一化分母。\n假设已经处理了前 $j-1$ 个元素：\n$$ \\begin{aligned} m_{j-1}\u0026=\\max(x_1,\\ldots,x_{j-1}),\\\\ \\ell_{j-1}\u0026=\\sum_{i=1}^{j-1}e^{x_i-m_{j-1}}. \\end{aligned} $$读入新的 $x_j$ 后：\n$$ \\begin{aligned} m_j\u0026=\\max(m_{j-1},x_j),\\\\ \\ell_j\u0026=\\ell_{j-1}e^{m_{j-1}-m_j}+e^{x_j-m_j}. \\end{aligned} $$下面看整个公式是如何推导出来的。原始公式为：\n$$ \\ell_j=\\sum_{i=1}^{j}e^{x_i-m_j}. $$把新元素单独拿出来：\n$$ \\ell_j=\\sum_{i=1}^{j-1}e^{x_i-m_j}+e^{x_j-m_j}. $$对于前面的历史元素，可以写成：\n$$ x_i-m_j=(x_i-m_{j-1})+(m_{j-1}-m_j). $$所以：\n$$ e^{x_i-m_j} =e^{x_i-m_{j-1}}e^{m_{j-1}-m_j}. $$代入原式：\n$$ \\ell_j =\\sum_{i=1}^{j-1}e^{x_i-m_{j-1}}e^{m_{j-1}-m_j} +e^{x_j-m_j}. $$其中 $e^{m_{j-1}-m_j}$ 对所有历史元素都是相同的，可以提出来：\n$$ \\ell_j =e^{m_{j-1}-m_j}\\sum_{i=1}^{j-1}e^{x_i-m_{j-1}} +e^{x_j-m_j}. $$而：\n$$ \\sum_{i=1}^{j-1}e^{x_i-m_{j-1}}=\\ell_{j-1}. $$于是得到：\n$$ \\boxed{ \\ell_j=\\ell_{j-1}e^{m_{j-1}-m_j}+e^{x_j-m_j} }. $$这两个部分分别表示：\n$\\ell_{j-1}e^{m_{j-1}-m_j}$：把历史指数和换算到新的最大值基准； $e^{x_j-m_j}$：加入当前新元素。 Online Softmax 的价值主要在于：\n可以分块计算； 不需要一次保存完整的一行； 可以和后续矩阵乘法融合； 它是 FlashAttention 的关键基础之一。 需要注意：对于一个独立的 Softmax 算子，即使一次扫描得到了最大值和分母，输出每个 $p_i$ 时仍然需要访问原始元素，除非这些元素一直保存在寄存器或片上存储中。因此，它真正的优势通常体现在分块融合计算中。\n总结 Softmax 的优化可以分成三个层面：\n数值稳定性： 减去最大值、使用 FP32 完成归约和累加、直接计算 log_softmax，并正确处理全 Mask 行； 访存与调度： 通过算子融合减少 HBM 读写和 Kernel Launch； 流式与分块： 使用 Online Softmax 在线更新最大值和归一化分母，为后续融合计算以及 FlashAttention 提供基础。 参考 https://zhuanlan.zhihu.com/p/356976844 https://zhuanlan.zhihu.com/p/8450501217 ","permalink":"https://zhangge.dev/ai-fundamentals/softmax-optimization/","summary":"Softmax 的优化既要解决指数运算的数值稳定性，也要减少显存读写，并为 Attention 与 FlashAttention 的融合计算打下基础。","title":"softmax的优化"},{"content":"RoPE 的方法论文是 RoFormer: Enhanced Transformer with Rotary Position Embedding。旋转位置编码的作者之一苏剑林长期维护科学空间，其中有多篇位置编码与 RoPE 的推导文章，值得结合阅读。\n本文一方面整理苏剑林相关文章中的核心内容，另一方面补充自己的理解，并结合其他分析对 RoPE 做进一步梳理。\n背景 不同于 RNN、CNN 等模型，对于 Transformer 来说，位置编码必不可少。纯粹的 Attention 模块无法捕捉输入顺序，也就无法区分不同位置的 token。\n为此，大体有两个选择：\n想办法将位置信息融入输入，这构成了绝对位置编码的一般做法； 调整 Attention 结构，使它能够分辨不同位置的 token，这构成了相对位置编码的一般做法。 绝对位置编码 从形式上看，绝对位置编码是一种相对简单的方案。通常把位置编码直接加到输入中：在输入的第 $k$ 个向量 $x_k$ 中加入只依赖位置编号 $k$ 的位置向量 $p_k$：\n$$ x_k \\longrightarrow x_k+p_k. $$训练式 最朴素的绝对位置编码方案，是不额外设计位置函数，而是直接把位置编码作为可训练参数。\n例如，最大序列长度为 512，编码维度为 768，就初始化一个 $512\\times768$ 的矩阵作为位置向量，并让它随训练过程更新。\n这种训练式绝对位置编码的常见缺点是缺少外推性。如果预训练时的最大长度为 512，那么模型原本只学习了前 512 个位置的向量。超过 512 的位置没有训练好的编码；虽然可以随机初始化这些新位置并继续微调，但这不再是直接的长度外推。\n三角式 三角函数式位置编码通常也称为 Sinusoidal 位置编码，是论文《Attention Is All You Need》提出的显式方案：\n$$ \\begin{aligned} p_{k,2i} \u0026= \\sin\\left(\\frac{k}{10000^{2i/d}}\\right),\\\\ p_{k,2i+1} \u0026= \\cos\\left(\\frac{k}{10000^{2i/d}}\\right). \\end{aligned} $$其中，$p_{k,2i}$ 和 $p_{k,2i+1}$ 分别是位置 $k$ 的编码向量中第 $2i$、$2i+1$ 个分量，$d$ 是位置向量的维度。\n这里先看 $k$ 的范围。$k$ 表示序列中的位置编号。假设序列长度为 $L$，通常从 0 开始编号：\n$$ k=0,1,2,\\ldots,L-1. $$例如，输入序列有 5 个 token：\ntoken 位置 $k$ 我 0 喜 1 欢 2 学 3 习 4 再看 $i$ 的范围。$i$ 是正弦—余弦维度对的编号。假设位置编码维度为偶数 $d$，则：\n$$ i=0,1,2,\\ldots,\\frac{d}{2}-1. $$每一个 $i$ 对应位置向量中的两个维度：第 $2i$ 维使用 $\\sin$，第 $2i+1$ 维使用 $\\cos$。因此，$i$ 不是位置编码的直接维度编号，而是正弦—余弦维度对的编号。\n例如 $d=8$：\n$i$ 正弦维度 $2i$ 余弦维度 $2i+1$ 0 0 1 1 2 3 2 4 5 3 6 7 为了紧凑地写出完整位置编码，记：\n$$ s_i=\\sin\\left(\\frac{k}{10000^{2i/8}}\\right), \\qquad c_i=\\cos\\left(\\frac{k}{10000^{2i/8}}\\right). $$那么 $d=8$ 时的位置向量为：\n$$ p_k=(s_0,c_0,s_1,c_1,s_2,c_2,s_3,c_3)^\\top. $$此时四组频率依次是 $1$、$0.1$、$0.01$、$0.001$，也就是分别计算 $k$、$k/10$、$k/100$、$k/1000$ 的正弦和余弦。\n可以看到，不同的 $i$ 对应不同的变化频率：\n$i$ 较小时变化快，用于刻画较细粒度的位置差异； $i$ 较大时变化慢，用于刻画较大尺度的位置关系。 三角函数式位置编码具有显式的生成规律，因此可以期望它具备一定的外推性。使用它的另一个理由来自三角恒等式：\n$$ \\sin(\\alpha+\\beta)=\\sin\\alpha\\cos\\beta+\\cos\\alpha\\sin\\beta, $$$$ \\cos(\\alpha+\\beta)=\\cos\\alpha\\cos\\beta-\\sin\\alpha\\sin\\beta. $$这说明位置 $\\alpha+\\beta$ 的向量可以表示为位置 $\\alpha$ 与位移 $\\beta$ 对应分量的组合，从而提供了表达相对位置信息的可能性。\n递归式 原则上，RNN 不需要额外的位置编码，因为它的递归结构本身就具备学习位置信息的可能性。如果先在输入后接一层 RNN，再接 Transformer，理论上就不必另外加入位置编码。\n同理，也可以用递归模型学习一种绝对位置编码：从向量 $p_0$ 出发，通过递归关系\n$$ p_{k+1}=f(p_k) $$得到各个位置的编码向量。\nICML 2020 论文《Learning to Encode Position for Transformer with Continuous Dynamical Model》进一步使用微分方程建模位置编码，并把该方案称为 FLOATER。函数 $h(p(t),t)$ 可以由神经网络建模，因此这种微分方程也称为神经微分方程。\n理论上，基于递归模型的位置编码具有较好的外推性，也比固定三角函数具有更高的灵活性；三角函数式位置编码可以看作 FLOATER 的某个特解。但递归形式会牺牲一定并行性，可能带来速度瓶颈。\n相对位置编码 相对位置编码不必完整建模每个输入的绝对位置，而是在计算 Attention 时考虑当前位置与被关注位置之间的相对距离。自然语言通常更依赖相对位置关系，因此相对位置编码往往也有较好的表现。\nRoPE 出发点 RoPE 的出发点是：通过绝对位置编码的方式实现相对位置编码。\n具体来说，分别给 Query 和 Key 编入各自的绝对位置 $m$、$n$，但在计算两者内积时，让结果只依赖相对距离 $m-n$ 或 $n-m$。\n动机 为什么实现 RoPE 时，要给 Query 和 Key 分别编入绝对位置 $m$、$n$？\n因为在 Attention 中，Query 和 Key 通常来自不同位置。更准确地说，$q_m$ 表示“第 $m$ 个 token 生成的 Query”，$k_n$ 表示“第 $n$ 个 token 生成的 Key”。位置 $m$ 的 token 要判断位置 $n$ 的 token 对自己有多重要，因此需要计算：\n$$ q_m^\\top k_n. $$这里自然涉及两个位置：\n$m$：当前发起查询的 token 的位置； $n$：被查询、被匹配的 token 的位置。 普通线性层生成的 $q_m$、$k_n$ 主要包含 token 的语义信息，并不知道当前 token 位于第几个位置、两个 token 相距多远，也不知道一个 token 在另一个 token 的左边还是右边。\n例如，“猫追老鼠”和“老鼠追猫”包含相似的词，但位置关系完全不同。因此，需要把位置信息加入 Query 和 Key。\n设加入位置后的 Query 和 Key 为：\n$$ \\tilde{q}_m=f(q,m), \\qquad \\tilde{k}_n=f(k,n). $$原始向量 $q$、$k$ 负责承载语义信息，函数 $f$ 再分别将位置 $m$、$n$ 编入它们。此时，$\\tilde{q}_m$ 表示位于位置 $m$ 的 Query，$\\tilde{k}_n$ 表示位于位置 $n$ 的 Key。\nAttention 真正使用的是 Query 和 Key 的内积，因此希望结果满足：\n$$ \\left\\langle f(q,m),f(k,n)\\right\\rangle =g(q,k,m-n). $$右侧函数 $g$ 只接收原始语义向量 $q$、$k$ 和相对位置 $m-n$，不再分别依赖 $m$ 与 $n$。\n例如：\n$$ m=10,n=7,\\qquad m-n=3, $$$$ m=100,n=97,\\qquad m-n=3. $$如果满足上面的关系，这两组绝对位置就具有相同的相对位置结构。模型关心的是“Query 在 Key 后面 3 个位置”，而不是它们分别位于第 10、7 或第 100、97 个位置。\n“用绝对位置编码实现相对位置编码”可以概括为：先分别对 Query 和 Key 编入绝对位置，再利用二者的内积自然得到相对位置。\n可以用钟表指针来类比。假设 Query 根据位置 $m$ 旋转到角度 $m\\theta$，Key 根据位置 $n$ 旋转到角度 $n\\theta$。每根指针的方向分别包含自己的绝对角度，但两根指针之间的夹角只取决于：\n$$ m\\theta-n\\theta=(m-n)\\theta. $$所以，单独看每根指针时是绝对位置；比较两根指针时得到相对位置。RoPE 正是利用了这种旋转性质。\n具体实现 下面看 RoPE 如何构造出这个解。先只考虑二维向量，定义位置 $m$ 对应的旋转矩阵：\n$$ R_m= \\begin{bmatrix} \\cos(m\\theta) \u0026 -\\sin(m\\theta)\\\\ \\sin(m\\theta) \u0026 \\cos(m\\theta) \\end{bmatrix}. $$使用旋转矩阵给向量加入位置：\n$$ f(q,m)=R_mq, \\qquad f(k,n)=R_nk. $$这表示把 $q$ 旋转 $m\\theta$，把 $k$ 旋转 $n\\theta$。现在计算二者的内积：\n$$ (R_mq)^\\top(R_nk). $$根据转置规则展开：\n$$ (R_mq)^\\top(R_nk)=q^\\top R_m^\\top R_nk. $$旋转矩阵有一个重要性质：转置等于反向旋转。\n$$ R_m^\\top=R_{-m}. $$因此，两个旋转矩阵可以合并：\n$$ R_m^\\top R_n=R_{-m}R_n=R_{n-m}. $$于是得到：\n$$ \\left\\langle R_mq,R_nk\\right\\rangle =q^\\top R_{n-m}k. $$最终结果只依赖 $n-m$，不再分别依赖 $m$、$n$，正好满足最初要求：\n$$ \\left\\langle f(q,m),f(k,n)\\right\\rangle =g(q,k,n-m). $$这里写成 $m-n$ 还是 $n-m$ 取决于内积展开与符号约定；核心结论不变：结果只依赖两个位置之差。\n实际例子 二维向量也可以看成复数。设向量 $q$ 的模长为 $\\lVert q\\rVert$、初始辐角为 $\\Theta(q)$，位置 $m$ 让它额外旋转 $m\\theta$，那么：\n$$ \\begin{aligned} f(q,m) \u0026=R_f(q,m)e^{\\mathrm{i}\\Theta_f(q,m)}\\\\ \u0026=\\lVert q\\rVert e^{\\mathrm{i}(\\Theta(q)+m\\theta)}\\\\ \u0026=q\\,e^{\\mathrm{i}m\\theta}. \\end{aligned} $$复数乘以 $e^{\\mathrm{i}m\\theta}$ 的几何意义就是旋转 $m\\theta$。把它写回实数坐标，正好得到前面的二维旋转矩阵。\n实际 Transformer 的维度不是 2，而可能是 128、256 或 4096。RoPE 会把向量每两个维度分成一组：\n$$ (q_0,q_1),(q_2,q_3),(q_4,q_5),\\ldots $$每组二维分量使用不同的旋转频率：\n$$ \\theta_i=10000^{-2i/d}. $$第 $i$ 组在位置 $m$ 的旋转角度为 $m\\theta_i$，因此：\n$$ \\tilde{q}_{m,2i} =q_{2i}\\cos(m\\theta_i)-q_{2i+1}\\sin(m\\theta_i). $$$$ \\tilde{q}_{m,2i+1} =q_{2i}\\sin(m\\theta_i)+q_{2i+1}\\cos(m\\theta_i). $$不同维度对有不同频率：高频维度负责刻画较短距离，低频维度负责刻画较长距离。\n这一点与 Sinusoidal 位置编码使用不同频率的思想相似，但应用方式不同：\nSinusoidal：生成一个位置向量，然后加到 token 表示上； RoPE：用正弦、余弦直接旋转 Query 和 Key。 不同维度使用不同频率的含义 RoPE 使用二维旋转，而二维旋转一次只能作用在两个坐标上，因此需要把高维向量拆成多个二维平面。\n假设某个注意力头中的 Query 维度为 $d=8$：\n$$ q=[q_0,q_1,q_2,q_3,q_4,q_5,q_6,q_7]. $$它始终是一个长度为 8 的向量。RoPE 不会把它变成多个向量，也不会增加或减少维度，而是将 8 个分量两两分组：\n组编号 $i$ 该组包含的维度 0 第 0、1 维 1 第 2、3 维 2 第 4、5 维 3 第 6、7 维 所以一共有 4 组二维分量，第 $i$ 组就是 $(q_{2i},q_{2i+1})$。这里的“组”只是对向量分量的划分方式。\n为什么必须两个维度一组？因为平面旋转需要两个坐标。对于二维向量 $(x,y)$，旋转 $\\phi$ 后得到：\n$$ \\begin{aligned} x'\u0026=x\\cos\\phi-y\\sin\\phi,\\\\ y'\u0026=x\\sin\\phi+y\\cos\\phi. \\end{aligned} $$两个新坐标都同时依赖原来的 $x$、$y$，所以二维旋转不能只作用于单独一个分量。\n每一组使用不同的旋转速度。第 $i$ 组使用频率：\n$$ \\theta_i=10000^{-2i/d}. $$位置为 $m$ 时，该组的旋转角度为 $m\\theta_i$，变换结果仍是：\n$$ \\tilde{q}_{m,2i} =q_{2i}\\cos(m\\theta_i)-q_{2i+1}\\sin(m\\theta_i). $$$$ \\tilde{q}_{m,2i+1} =q_{2i}\\sin(m\\theta_i)+q_{2i+1}\\cos(m\\theta_i). $$每组包含两个维度，但两个维度共享同一个频率 $\\theta_i$。当 $d=8$ 时：\n$i$ 维度对 频率 0 $(q_0,q_1)$ $\\theta_0=10000^0=1$ 1 $(q_2,q_3)$ $\\theta_1=10000^{-2/8}=0.1$ 2 $(q_4,q_5)$ $\\theta_2=10000^{-4/8}=0.01$ 3 $(q_6,q_7)$ $\\theta_3=10000^{-6/8}=0.001$ 位置 $m$ 的整个向量经过变换后，可以把四组结果拼接起来：\n$$ \\tilde{q}_m =\\operatorname{concat}_{i=0}^{3} \\operatorname{Rotate}(q_{2i},q_{2i+1};m\\theta_i). $$其中 $\\operatorname{Rotate}(x,y;\\phi)$ 表示把二维分量 $(x,y)$ 旋转 $\\phi$ 后得到的两个新分量。\n结果依然只有 8 个分量，只是被分成 4 个二维平面分别旋转。\n如果所有维度都使用同一个频率，例如 $\\theta=1$，所有二维组就会以完全相同的速度旋转。虽然各组原始数值不同，但它们携带的位置变化模式是重复的；同时三角函数具有周期性，旋转一圈后某些位置模式会重复。\n因此，RoPE 为不同维度对设置不同频率。相同的 token 位置 $m$ 会被编码为多个不同速度的旋转状态，这些频率的组合能够提供更丰富的位置模式。\n仍以长度为 8 的向量为例，可以把它想象成四块表盘：\n表盘 维度对 每经过一个 token 的转角 表盘 0 $(q_0,q_1)$ $1$ 表盘 1 $(q_2,q_3)$ $0.1$ 表盘 2 $(q_4,q_5)$ $0.01$ 表盘 3 $(q_6,q_7)$ $0.001$ 当 token 位于 $m=3$ 时，各表盘分别旋转：\n$$ 3,\\quad 0.3,\\quad 0.03,\\quad 0.003. $$最终，位置 3 不是只由一个角度表示，而是由一组角度共同表示。每个角度只作用于对应的两个原有分量，并没有增加向量维度。\n旋转矩阵是如何定义和设计的 二维旋转矩阵定义为：\n$$ R_\\phi= \\begin{bmatrix} \\cos\\phi \u0026 -\\sin\\phi\\\\ \\sin\\phi \u0026 \\cos\\phi \\end{bmatrix}. $$这个矩阵会保持任意向量的长度不变，同时让向量方向增加角度 $\\phi$。\n先看 $x$ 轴单位向量 $e_x=(1,0)^\\top$。旋转矩阵把它旋转了 $\\phi$：\n$$ R_\\phi e_x=(\\cos\\phi,\\sin\\phi)^\\top. $$结果正是单位圆上角度为 $\\phi$ 的点。由于\n$$ \\cos^2\\phi+\\sin^2\\phi=1, $$旋转后的向量长度仍为 1。\n再看 $y$ 轴单位向量 $e_y=(0,1)^\\top$，它也会被旋转 $\\phi$：\n$$ R_\\phi e_y=(-\\sin\\phi,\\cos\\phi)^\\top. $$这说明旋转矩阵的两个列向量，正是原始坐标轴旋转后的结果。RoPE 将这一标准二维旋转按不同频率复制到高维向量的多个二维分量对中，从而完成位置编码。\n进一步的理论分析可以参考：\nTransformer升级之路：6、旋转位置编码的完备性分析 相对位置编码 Transformer 的一个理论缺陷与对策 Transformer升级之路：4、二维位置的旋转式位置编码 参考 Su et al.：RoFormer: Enhanced Transformer with Rotary Position Embedding 苏剑林：让研究人员绞尽脑汁的 Transformer 位置编码 苏剑林：Transformer升级之路——博采众长的旋转式位置编码 Vaswani et al.：Attention Is All You Need Liu et al.：Learning to Encode Position for Transformer with Continuous Dynamical Model ","permalink":"https://zhangge.dev/ai-fundamentals/rotary-position-embedding/","summary":"RoPE 分别为 Query 和 Key 编入绝对位置，并利用旋转矩阵的内积性质，让注意力分数自然只依赖相对位置。","title":"RoPE 旋转位置编码：从绝对位置到相对位置"},{"content":"这 6 项是当前预置任务。你也可以在页面底部选择分类并添加新的研究事项；自定义事项和勾选状态均保存在当前浏览器中。\n","permalink":"https://zhangge.dev/todo/","summary":"\u003cp\u003e这 6 项是当前预置任务。你也可以在页面底部选择分类并添加新的研究事项；自定义事项和勾选状态均保存在当前浏览器中。\u003c/p\u003e","title":"Research Todo"},{"content":"我是张革，目前从事大模型推理加速、AI Agent、程序分析与自动算子优化相关工作。近期重点关注 Qwen 系列模型的投机推理加速，以及基于 MCTS 的自动算子优化，覆盖算法设计、模型训练、推理框架适配、自动化评测和端到端性能优化。\n联系方式：\nEmail: 772422673@qq.com GitHub: Gwatermelon 代码仓：Gwatermelon/blog 工作经历 北京华为数字技术有限公司 2022 年 1 月至今\n高级工程师 / Committer，2026 年至今 工程师 / Committer，2024 年至 2026 年 工程师，2022 年至 2024 年 滴滴出行 2021 年 7 月至 2021 年 10 月，实习\n项目经历 大模型推理加速 2026 年至今，Team Leader\n负责 Qwen3 / Qwen3.5 系列大模型投机推理加速，基于 vLLM Speculators 完成 EAGLE-3 草稿模型训练、推理适配与端到端验证。 在中英混合数据集上提升草稿接受长度，并取得优于公开基线的效果。 基于 FrSpec 设计并实现 Qwen3.5 MTP 一键式推理优化流程，通过调整 vLLM 中草稿模型的词表范围降低草稿阶段推理耗时。 该流程在单请求和并发场景下均获得稳定的端到端性能收益。 面向 Qwen3.5 系列训练 DFlash 草稿模型，建立草稿质量、接受率、吞吐和延迟等评测流程。 算子优化与 Agent 开发 2025 年至 2026 年，Core Developer\n对标开源技术 EVO，设计并实现基于蒙特卡洛树搜索（MCTS）的 LLM 算子优化 Agent，将人工算子调优流程转化为多 Agent 自主搜索与迭代优化流程。 负责 MCTS 搜索策略、多 Agent 任务编排与调度逻辑开发，支持复杂算子优化任务的自动拆解、候选代码生成、性能评测与反馈迭代。 在长序列及复杂算子场景下提升候选生成质量与搜索效率，显著改善核心算子性能。 鸿蒙生态程序分析底座构建 2024 年至 2025 年，Team Leader\n基于鸿蒙生态程序分析底座方舟分析器 ArkAnalyzer 引入 C/C++ 语言解析能力，实现 TS 与 C/C++ 在 ArkAnalyzer 中的统一 IR 翻译与表达。 主导端到端架构设计、核心技术攻关与可行性验证，组织团队完成 C/C++ 核心语法及常用标准库的分析能力交付。 将 C++ 特性深度接入 ArkIR，实现万行级代码语义秒级解析。 基于该底座开发检查工具，用于发现高风险内存管理及性能规范问题。 项目地址：openharmony-sig/arkanalyzer 相似算子识别 2023 年至 2024 年，Core Developer\n面向 HPC 算子替换场景，设计自动化相似算子识别方案，为高性能算子替换提供候选代码块匹配能力。 基于 Joern 引入 Fortran 语言语义表达，构建面向 Fortran / C / C++ 算子的代码属性图表示。 基于代码属性图实现类 Bag of Paths 的路径匹配算法，在超大规模代码库中实现高准确率算子相似度匹配。 方案集成至核心产品线算子优化系统，提升算子替换工程的自动化率。 论文与专利 [TechReport 2024] Wenrui Zhang, Tiehang Fu, Ting Yuan, Ge Zhang, Dong Chen, and Jie Wang. \u0026ldquo;A Lightweight Framework for Adaptive Retrieval In Code Completion With Critique Model.\u0026rdquo; arXiv:2406.10263. [Patent 2026] 一种融合大模型生成与蒙特卡洛树搜索策略的算子性能迭代优化系统。 [Patent 2025] 一种领域特定语言语句生成方法、装置以及计算设备集群。 教育经历 爱丁堡大学，Master of Science in Computer Science，高性能计算与数据科学，2020 年 8 月至 2021 年 12 月。 中国计量大学，Bachelor of Science，信息与计算科学，2016 年 8 月至 2020 年 7 月。 技术栈 大模型推理加速：Speculative Decoding、MTP、PEARL、SSD、EAGLE-3、DFlash、vLLM、vLLM Speculators。 AI Agent 与算子优化：MCTS、多 Agent 编排、任务规划、候选代码生成、性能反馈迭代。 代码智能与程序分析：Clang AST、tree-sitter、Joern、代码属性图、ArkAnalyzer、ArkIR。 工程与语言：Python、Git、Docker、Linux。 ","permalink":"https://zhangge.dev/about/","summary":"高级工程师，主要从事大模型推理加速、AI Agent、程序分析和自动算子优化。","title":"关于我"},{"content":" 核心直觉： 泰勒公式用函数在展开点处的函数值、斜率、曲率以及更高阶变化，构造一个局部行为尽可能相同的多项式。\n复杂函数可能难以直接计算或分析，但多项式只包含加法、乘法和乘方，求值、求导、积分都很方便。泰勒公式因此建立了一座桥：把函数在某一点附近的问题，转化为多项式问题。\n从局部多项式逼近开始 设函数 $f(x)$ 在 $x=a$ 附近足够光滑。我们希望用一个 $n$ 次多项式\n$$ P_n(x)=c_0+c_1(x-a)+c_2(x-a)^2+\\cdots+c_n(x-a)^n $$近似 $f(x)$。\n为什么使用 $(x-a)$ 的幂，而不是直接使用 $x$ 的幂？因为我们关心的是函数在 $a$ 附近的局部行为。当 $x$ 接近 $a$ 时，$x-a$ 是一个小量；次数越高的项通常越小，也越适合描述更精细的修正。\n为了让 $P_n(x)$ 与 $f(x)$ 在 $a$ 附近尽可能相似，我们要求它们在展开点处的各阶导数一致：\n$$ P_n(a)=f(a),\\quad P_n'(a)=f'(a),\\quad \\ldots,\\quad P_n^{(n)}(a)=f^{(n)}(a). $$这些条件依次匹配了函数值、斜率、曲率以及更高阶的局部变化。\n为什么系数中会出现阶乘 泰勒公式中的 $n!$ 不是人为规定的，而是连续求导自然产生的。\n常数项 令 $x=a$，所有包含 $(x-a)$ 的项都变为 0：\n$$ P_n(a)=c_0. $$由 $P_n(a)=f(a)$ 得到：\n$$ c_0=f(a). $$一次项 对 $P_n(x)$ 求一次导数：\n$$ P_n'(x)=c_1+2c_2(x-a)+3c_3(x-a)^2+\\cdots. $$令 $x=a$，除 $c_1$ 外的项全部消失，因此：\n$$ c_1=P_n'(a)=f'(a). $$二次项 再求一次导数并令 $x=a$：\n$$ P_n''(a)=2c_2, $$所以：\n$$ c_2=\\frac{f''(a)}{2}=\\frac{f''(a)}{2!}. $$一般的 $n$ 次项 对 $c_n(x-a)^n$ 连续求 $n$ 次导数，指数会依次乘下来：\n$$ n(n-1)(n-2)\\cdots 2\\cdot 1=n!. $$而次数低于 $n$ 的项在求 $n$ 次导数后已经变为 0，次数高于 $n$ 的项在 $x=a$ 时仍含有 $(x-a)$。于是：\n$$ P_n^{(n)}(a)=n!c_n=f^{(n)}(a), $$最终得到：\n$$ c_n=\\frac{f^{(n)}(a)}{n!}. $$泰勒多项式、泰勒公式与泰勒级数 代入刚才得到的系数，$f(x)$ 的 $n$ 阶泰勒多项式为：\n$$ P_n(x)=\\sum_{k=0}^{n}\\frac{f^{(k)}(a)}{k!}(x-a)^k. $$只写多项式近似还不完整。泰勒公式应包含余项：\n$$ f(x)=\\sum_{k=0}^{n}\\frac{f^{(k)}(a)}{k!}(x-a)^k+R_n(x). $$$R_n(x)$ 表示截断到 $n$ 次后没有写出的误差。把次数无限增加，形式上得到泰勒级数：\n$$ \\sum_{k=0}^{\\infty}\\frac{f^{(k)}(a)}{k!}(x-a)^k. $$这里必须区分三个概念：\n泰勒多项式是有限项多项式 $P_n(x)$； 泰勒公式是 $f(x)=P_n(x)+R_n(x)$，明确保留误差； 泰勒级数是无限级数，只有在级数收敛且余项趋于 0 时才等于原函数。 当展开点是 $a=0$ 时，泰勒展开称为麦克劳林展开：\n$$ f(x)=f(0)+f'(0)x+\\frac{f''(0)}{2!}x^2+\\cdots. $$余项如何控制近似误差 常用的拉格朗日余项写作：\n$$ R_n(x)=\\frac{f^{(n+1)}(\\xi)}{(n+1)!}(x-a)^{n+1}, $$其中 $\\xi$ 位于 $a$ 与 $x$ 之间。\n如果在这段区间上满足\n$$ \\left|f^{(n+1)}(t)\\right|\\le M, $$那么可以得到误差上界：\n$$ |R_n(x)|\\le \\frac{M}{(n+1)!}|x-a|^{n+1}. $$这个式子给出两个直接结论：\n$x$ 越接近展开点 $a$，局部近似通常越准确； 在导数可控时，提高展开阶数通常会继续减小误差。 在只关心 $x\\to a$ 时的阶数关系时，也常使用佩亚诺余项：\n$$ f(x)=\\sum_{k=0}^{n}\\frac{f^{(k)}(a)}{k!}(x-a)^k+o\\!\\left((x-a)^n\\right). $$它适合处理极限和无穷小比较，但不像拉格朗日余项那样直接给出一个数值误差上界。\n常见的麦克劳林展开 下面几组展开式经常作为计算的基础模块。\n指数函数 $$ e^x=1+x+\\frac{x^2}{2!}+\\frac{x^3}{3!}+\\cdots, \\qquad x\\in\\mathbb{R}. $$正弦与余弦 $$ \\sin x=x-\\frac{x^3}{3!}+\\frac{x^5}{5!}-\\cdots, $$$$ \\cos x=1-\\frac{x^2}{2!}+\\frac{x^4}{4!}-\\cdots. $$这两个级数对所有实数 $x$ 都收敛到原函数。\n几何级数 $$ \\frac{1}{1-x}=1+x+x^2+x^3+\\cdots, \\qquad |x|\u003c1. $$对数函数 $$ \\ln(1+x)=x-\\frac{x^2}{2}+\\frac{x^3}{3}-\\frac{x^4}{4}+\\cdots, \\qquad |x|\u003c1. $$收敛区间是公式的一部分，不能只记右侧的多项式形式。\n应用一：近似计算 用 $e^x$ 在 $0$ 点的三阶泰勒多项式估算 $e^{0.1}$：\n$$ e^{0.1}\\approx 1+0.1+\\frac{0.1^2}{2}+\\frac{0.1^3}{6} =1.105166\\overline{6}. $$真实值约为 $1.105170918$。对三阶截断，拉格朗日余项满足：\n$$ |R_3(0.1)| \\le \\frac{e^{0.1}}{4!}(0.1)^4 \u003c4.61\\times 10^{-6}. $$这说明泰勒公式不仅给出近似值，还能说明近似究竟有多可靠。\n应用二：计算极限 考虑极限：\n$$ \\lim_{x\\to 0}\\frac{\\sin x-x}{x^3}. $$利用\n$$ \\sin x=x-\\frac{x^3}{6}+o(x^3), $$代入后得到：\n$$ \\frac{\\sin x-x}{x^3} =\\frac{-x^3/6+o(x^3)}{x^3} \\to -\\frac{1}{6}. $$关键是展开到分子中第一个不会相互抵消的非零项。展开阶数不足得不到结果，展开过多则增加无用计算。\n应用三：判断无穷小的阶 当 $x\\to 0$ 时：\n$$ e^x-1-x=\\frac{x^2}{2}+o(x^2). $$因此 $e^x-1-x$ 是二阶无穷小，并且：\n$$ e^x-1-x\\sim \\frac{x^2}{2}. $$泰勒展开把“谁趋近于 0 更快”转化成比较首个非零项的次数和系数。\n应用四：研究函数的局部性质 在 $x=a$ 附近，二阶近似为：\n$$ f(x)\\approx f(a)+f'(a)(x-a)+\\frac{f''(a)}{2}(x-a)^2. $$各项具有清晰的几何意义：\n$f(a)$ 决定展开点的高度； $f\u0026rsquo;(a)$ 决定切线斜率和一阶变化； $f\u0026rsquo;\u0026rsquo;(a)$ 决定局部弯曲方向和曲率趋势； 更高阶项补充更细致的局部变化。 如果 $f\u0026rsquo;(a)=0$ 且 $f\u0026rsquo;\u0026rsquo;(a)\u0026gt;0$，二次项在 $a$ 附近为正，通常说明 $a$ 是局部极小点；若 $f\u0026rsquo;\u0026rsquo;(a)\u0026lt;0$，则通常是局部极大点。遇到 $f\u0026rsquo;\u0026rsquo;(a)=0$ 时，需要继续检查更高阶的首个非零导数。\n如何选择展开点和阶数 实际使用泰勒公式时，可以按下面的顺序思考：\n确定目标位置：要近似哪个 $x$，或研究哪个极限； 选择展开点：选离目标位置近、函数值和各阶导数又容易计算的 $a$； 判断所需阶数：数值近似由误差要求决定，极限计算则展开到首个不抵消项； 写出余项或阶数符号：数值问题使用误差上界，极限问题可使用 $O$ 或 $o$ 记号； 检查适用条件：确认导数存在，并检查级数的收敛范围。 围绕目标点展开通常比固定在 0 点更高效。例如近似 $\\ln(1.02)$ 时在 0 点展开很自然；近似 $\\ln(10.02)$ 时，则可以先写成 $\\ln 10+\\ln(1.002)$，再对较小的增量展开。\nPython 验证近似与误差 下面用三阶泰勒多项式近似 $e^{0.1}$，并验证实际误差没有超过拉格朗日余项给出的上界。\nfrom math import exp, factorial def exp_taylor(x: float, degree: int) -\u0026gt; float: return sum(x**k / factorial(k) for k in range(degree + 1)) x = 0.1 degree = 3 approximation = exp_taylor(x, degree) actual = exp(x) actual_error = abs(actual - approximation) # 当 x \u0026gt; 0 时，区间 [0, x] 上 e^t 的最大值是 e^x。 remainder_bound = exp(x) * x ** (degree + 1) / factorial(degree + 1) print(f\u0026#34;approximation = {approximation:.10f}\u0026#34;) print(f\u0026#34;actual = {actual:.10f}\u0026#34;) print(f\u0026#34;actual error = {actual_error:.10e}\u0026#34;) print(f\u0026#34;error bound = {remainder_bound:.10e}\u0026#34;) assert actual_error \u0026lt;= remainder_bound 常见误区 泰勒展开是在一个点上近似 展开点决定系数，但近似描述的是该点附近的一段区域。离展开点越远，高阶项和余项越不能忽略。\n函数无限可导就一定等于泰勒级数 无限可导不等于解析。经典反例是：\n$$ f(x)= \\begin{cases} e^{-1/x^2}, \u0026 x\\ne 0,\\\\ 0, \u0026 x=0. \\end{cases} $$它在 $0$ 点的各阶导数都为 0，因此泰勒级数恒为 0；但当 $x\\ne 0$ 时，原函数为正。要让泰勒级数等于函数，还需要余项趋于 0。\n阶数越高就无条件越准确 在固定收敛区域内，提高阶数通常能改善近似，但这依赖函数、展开点和目标位置。离展开点过远或超出收敛区间时，增加项数未必有帮助。\n只写无限级数，不写收敛条件 几何级数和对数级数都有明确的收敛范围。忽略范围，会把局部等式错误地当成全局等式。\n总结 泰勒公式可以概括为：\n$$ \\text{函数的局部行为} \\approx \\text{函数值}+\\text{斜率}+\\text{曲率}+\\text{更高阶变化}. $$它的构造原则是让多项式与原函数在展开点处的函数值和各阶导数相同；系数中的 $n!$ 来自 $(x-a)^n$ 连续求导 $n$ 次。真正使用时，还要同时关注余项、展开点、截断阶数和收敛范围。\n因此，泰勒公式不只是一个需要背诵的展开式，而是一套把复杂函数局部转化为简单多项式，并用余项控制误差的方法。\n","permalink":"https://zhangge.dev/math/taylor-series/","summary":"泰勒公式把函数在某一点附近的局部行为编码进一个多项式。本文推导系数来源，解释余项与收敛条件，并整理常见展开式和应用方法。","title":"泰勒公式与泰勒展开：用多项式逼近函数"},{"content":" 核心直觉： Tokenizer 是文本与神经网络之间的编码层。它把字符串转换成有限词表中的整数 ID，同时尽量让词表不要太大、序列不要太长，并让任意输入都能被表示。\nTokenizer 到底做什么 语言模型不能直接计算字符串。模型接收的是整数 ID，再通过 embedding 表把每个 ID 映射成向量。以文本 playing football 为例，一种可能的结果是：\n原始文本：playing football Token： [\u0026#34;play\u0026#34;, \u0026#34;ing\u0026#34;, \u0026#34; football\u0026#34;] Token ID：[1234, 567, 8910] 这里的切分只用于说明流程；真实结果由具体模型的 tokenizer、词表和配置决定。同一段文本交给不同模型，token 数量和 ID 通常都不相同。\n一个完整的 tokenizer 通常包含以下阶段：\n归一化（Normalizer）：按配置处理 Unicode、大小写或重音符号。归一化不是越多越好；例如代码模型往往必须保留大小写和空白差异。 预切分（Pre-tokenizer）：按空格、标点或正则规则划分边界，并记录原文位置。SentencePiece 一类方案也可以直接从原始句子训练，不依赖语言专用的分词器。 子词模型（Model）：使用 BPE、WordPiece 或 Unigram 等算法把片段切成词表中的 token。 后处理（Post-processor）：按模型约定加入 [CLS]、[SEP]、BOS、EOS 等特殊 token。 ID 映射：根据固定词表把 token 转换成整数 ID。 编码只是半个过程。Tokenizer 还需要把模型生成的 ID 解码回文本，并尽可能正确地恢复空格、标点和字节序列。\n设计时的五个权衡 Tokenizer 的目标不是切出语言学上最正确的词，而是为模型构造合适的离散表示。常见权衡包括：\n词表大小：词表越大，输入 embedding 和输出投影层通常越大。 序列长度：切分越细，token 数越多，训练与推理成本越高，可容纳的有效文本也越少。 开放词汇：新词、人名、URL、代码、emoji 和拼写错误不应轻易退化成 [UNK]。 跨语言覆盖：词表容量要在不同语言、字符和领域之间合理分配。 可逆性与稳定性：解码应尽量还原原文；同一配置下的编码结果应可复现。 这些目标彼此冲突。词级切分序列短，却需要巨大词表；字符级词表小，却会显著拉长序列。子词方法位于两者之间，因此成为现代 Transformer 的主流选择。\n从词级到子词级 词级 Tokenizer 词级方法把完整单词作为 token：\nI love playing football → [\u0026#34;I\u0026#34;, \u0026#34;love\u0026#34;, \u0026#34;playing\u0026#34;, \u0026#34;football\u0026#34;] 它直观且序列较短，但自然语言的词形变化、复合词和专有名词几乎没有上限。只保留高频词会产生大量 [UNK]，全部保留又会让词表迅速膨胀。因此，纯词级 tokenizer 在现代大模型中已经少见。\n字符级 Tokenizer 字符级方法把每个 Unicode 字符作为 token：\nplaying → [\u0026#34;p\u0026#34;, \u0026#34;l\u0026#34;, \u0026#34;a\u0026#34;, \u0026#34;y\u0026#34;, \u0026#34;i\u0026#34;, \u0026#34;n\u0026#34;, \u0026#34;g\u0026#34;] 我喜欢你 → [\u0026#34;我\u0026#34;, \u0026#34;喜\u0026#34;, \u0026#34;欢\u0026#34;, \u0026#34;你\u0026#34;] 它能用较小的词表覆盖大量文本，但会生成很长的序列，而且 Unicode 字符集合本身仍然很大。纯字符建模可用于特定的 OCR、拼写纠错等任务，却不是当前通用大模型最常见的方案。\n子词 Tokenizer 子词方法保留高频片段，把低频词拆成更小单位：\nplaying → [\u0026#34;play\u0026#34;, \u0026#34;ing\u0026#34;] unbelievable → [\u0026#34;un\u0026#34;, \u0026#34;believ\u0026#34;, \u0026#34;able\u0026#34;] 这样既能复用词根、前缀和后缀，又能控制词表大小。需要注意的是，算法只根据训练目标和语料统计学习片段，并不保证 token 边界符合语言学词素。\nBPE：从小单位不断合并 BPE（Byte Pair Encoding）最初是一种数据压缩算法，后来被改造成子词学习方法。用于文本时，它通常从字符或字节等小单位开始，反复合并语料中最频繁的相邻 pair。\n假设词频为：\nlow × 5 lower × 2 lowest × 2 初始时，每个词被拆成字符并带上词尾标记：\nl o w \u0026lt;/w\u0026gt; l o w e r \u0026lt;/w\u0026gt; l o w e s t \u0026lt;/w\u0026gt; 训练循环如下：\n按词频加权，统计所有相邻 token pair； 选择出现次数最多的 pair； 把它合并为新 token，并记录合并规则； 更新语料的切分； 重复执行，直到达到词表大小或合并次数上限。 如果 (l, o) 最常见，就先得到 lo；后续可能继续把 (lo, w) 合并成 low。最终编码新文本时，按照训练得到的合并优先级应用规则。\n一份可运行的简化实现 下面的代码保留了 BPE 的核心机制：词频加权、pair 统计、确定性 tie-break、合并规则训练，以及用规则编码新词。为了突出算法，它没有实现 Unicode 归一化、特殊 token、字节回退或持久化格式，不能直接替代生产级 tokenizer。\nfrom __future__ import annotations from collections import Counter import re Word = tuple[str, ...] Pair = tuple[str, str] def pre_tokenize(text: str) -\u0026gt; list[str]: \u0026#34;\u0026#34;\u0026#34;保留单词与标点；这里只做教学用的简单预切分。\u0026#34;\u0026#34;\u0026#34; return re.findall(r\u0026#34;\\w+|[^\\w\\s]\u0026#34;, text.lower(), flags=re.UNICODE) def merge_pair(symbols: Word, pair: Pair) -\u0026gt; Word: \u0026#34;\u0026#34;\u0026#34;合并序列中所有不重叠的指定 pair。\u0026#34;\u0026#34;\u0026#34; merged: list[str] = [] i = 0 while i \u0026lt; len(symbols): if i + 1 \u0026lt; len(symbols) and symbols[i : i + 2] == pair: merged.append(pair[0] + pair[1]) i += 2 else: merged.append(symbols[i]) i += 1 return tuple(merged) def count_pairs(vocabulary: Counter[Word]) -\u0026gt; Counter[Pair]: counts: Counter[Pair] = Counter() for symbols, frequency in vocabulary.items(): for pair in zip(symbols, symbols[1:]): counts[pair] += frequency return counts def train_bpe(corpus: list[str], num_merges: int) -\u0026gt; list[Pair]: word_counts = Counter( token for text in corpus for token in pre_tokenize(text) ) vocabulary: Counter[Word] = Counter({ tuple(word) + (\u0026#34;\u0026lt;/w\u0026gt;\u0026#34;,): frequency for word, frequency in word_counts.items() }) merges: list[Pair] = [] for _ in range(num_merges): pair_counts = count_pairs(vocabulary) if not pair_counts: break # 频率相同时按字典序选择，保证示例结果可复现。 best_pair = min( pair_counts, key=lambda pair: (-pair_counts[pair], pair), ) merges.append(best_pair) updated: Counter[Word] = Counter() for symbols, frequency in vocabulary.items(): updated[merge_pair(symbols, best_pair)] += frequency vocabulary = updated return merges def encode_word(word: str, merges: list[Pair]) -\u0026gt; list[str]: symbols: Word = tuple(word.lower()) + (\u0026#34;\u0026lt;/w\u0026gt;\u0026#34;,) merge_rank = {pair: rank for rank, pair in enumerate(merges)} while len(symbols) \u0026gt; 1: candidates = { pair for pair in zip(symbols, symbols[1:]) if pair in merge_rank } if not candidates: break best_pair = min(candidates, key=merge_rank.__getitem__) symbols = merge_pair(symbols, best_pair) # 词尾标记只用于防止跨越词边界的合并，不作为最终文本输出。 return [ symbol.removesuffix(\u0026#34;\u0026lt;/w\u0026gt;\u0026#34;) for symbol in symbols if symbol != \u0026#34;\u0026lt;/w\u0026gt;\u0026#34; ] corpus = [ \u0026#34;low low low low low\u0026#34;, \u0026#34;lower lower\u0026#34;, \u0026#34;lowest lowest\u0026#34;, ] rules = train_bpe(corpus, num_merges=8) print(rules) print(encode_word(\u0026#34;lowest\u0026#34;, rules)) 真实实现还需要处理预切分边界、未知字符、词表 ID、特殊 token、归一化和高效数据结构。不同实现对词尾、空格和合并冲突的约定也可能不同。\nBPE 的优缺点 BPE 简单、训练与编码都较高效，而且合并规则固定后结果具有确定性。它的局限是训练目标主要来自相邻片段频率，并不直接优化语言学边界或下游模型损失；稀有字符仍可能占用词表或触发未知字符问题，具体取决于基础符号集合和回退策略。\nWordPiece：训练打分与最长匹配 WordPiece 与 BPE 都会学习子词词表，但不能简单理解为“BPE 换了一个 token 前缀”。常见 WordPiece 训练实现不会只选择频率最高的 pair，而会使用类似下面的分数，降低两个高频片段仅因各自常见而被合并的倾向：\nscore(a, b) = freq(a, b) / (freq(a) × freq(b)) 推理时，WordPiece 通常对每个预切分后的词执行最长匹配优先：先尝试词表中最长的前缀；匹配后，再对剩余部分重复。如果无法把整个词拆成已知片段，经典实现可能输出 [UNK]。\nplaying → [\u0026#34;play\u0026#34;, \u0026#34;##ing\u0026#34;] ## 是一种显示与解码约定，表示该 token 接在词内而不是从词首开始；它不是 WordPiece 的数学本质。\nUnigram：从大词表逐步剪枝 Unigram 与 BPE 的生长方向相反：它先构造一个较大的候选子词集合，再逐步删除对语料似然贡献较小的 token。\n在简化的 Unigram 模型中，一种切分 (x = (x_1, \\ldots, x_n)) 的概率为：\n[ P(x) = \\prod_{i=1}^{n} P(x_i) ]\n同一个字符串可能存在多条切分路径：\nunhappiness → [\u0026#34;un\u0026#34;, \u0026#34;happiness\u0026#34;] → [\u0026#34;un\u0026#34;, \u0026#34;happy\u0026#34;, \u0026#34;ness\u0026#34;] → [\u0026#34;u\u0026#34;, \u0026#34;n\u0026#34;, \u0026#34;h\u0026#34;, ...] 编码时可以用动态规划寻找概率最大的路径。训练时则反复估计 token 概率、评估删除候选造成的损失，再剪掉影响较小的候选，直到达到目标词表规模。\n因为模型保留了切分概率，训练阶段还可以从多种合理切分中采样，这就是 subword regularization 的基础。它能把分词歧义作为噪声注入训练，但实现和训练成本通常高于确定性的 BPE。\n字节级 BPE：用 256 个字节兜底 字符级 BPE 的基础词表取决于 Unicode 字符集合。面对中文、日文、emoji、代码和噪声文本，大量低频字符会占据词表空间；训练时未覆盖的字符还可能变成未知 token。\n字节级 BPE（Byte-level BPE / BBPE）先把 UTF-8 文本转换成字节序列，基础字节只有 256 种，再在字节或已合并的字节片段上执行 BPE。只要输入能编码为字节，就不必因为新字符而使用 [UNK]。\n以 ASCII 文本为例：\ncat → [99, 97, 116] car → [99, 97, 114] 如果 (99, 97) 最常见，可以先合并代表 ca 的片段，之后再继续合并 cat。对非 ASCII 字符，一个人类眼中的字符可能由多个 UTF-8 字节组成，因此中间 token 不一定对应可单独阅读的字符。\nGPT-2 风格实现会把 256 个字节可逆地映射到可显示的 Unicode 符号，再运行 BPE。调试输出中常见的 Ġ 通常用来表示带有前导空格的片段；它是字节到可见符号映射的一部分，不是原文真的包含了这个字母。\n字节级方法消除了字符层面的 OOV，并促进跨语言共享基础词表，但不保证所有语言的压缩效率相同。某些字符需要多个 UTF-8 字节，低资源语言或特殊领域仍可能被切得更碎。\nSentencePiece 不是第四种子词算法 SentencePiece 经常与 BPE、WordPiece、Unigram 并列，容易造成概念混淆。更准确地说，SentencePiece 是一个从原始句子训练、编码和解码子词模型的工具体系；它支持 BPE 和 Unigram 等模型，并用特殊符号显式表达空格，从而实现不依赖语言专用预分词器的处理流程。\n因此，“使用 SentencePiece”并不能单独说明底层采用了哪种子词算法，还需要查看模型类型和具体配置。\n主流方法对比 方法 起始单位 训练方向或核心规则 推理时切分 主要特点 词级 完整单词 统计并截断词表 查词表 序列短，但词表大、OOV 严重 字符级 Unicode 字符 通常无需合并 逐字符 词表较小，但序列长 BPE 字符或字节 反复合并高频相邻 pair 按合并优先级应用规则 简单、高效、确定性强 WordPiece 字符/子词 常用归一化 pair 分数扩展词表 最长匹配优先 BERT 系模型中的经典方案，可能产生 [UNK] Unigram 大量候选子词 概率估计并逐步剪枝 选择最高概率路径，也可采样 支持多种切分，训练更复杂 字节级 BPE 256 种字节 在字节片段上执行 BPE 按 merge rules 合并 字符层面无 OOV，适合多语言和噪声文本 Tokenizer 如何影响模型 Tokenizer 并不是无关紧要的预处理工具，它会改变模型看到的数据形态：\n上下文容量：上下文窗口按 token 计数；切分更碎时，同样长度的原文会更快占满窗口。 训练和推理成本：token 数量影响前向次数、KV Cache 占用和计费；词表大小又影响 embedding 与词表投影成本。 跨语言效率：不同语言的每 token 字符数可能差异明显，进而影响可用上下文和使用成本。 数字、代码与结构化文本能力：数字、缩进、运算符和标识符如何切分，会改变模型学习局部模式的难度。 模型兼容性：tokenizer 的词表、ID 排列、特殊 token 和归一化规则都属于模型参数契约，不能随意替换。 评估 tokenizer 时，不应只看词表大小。至少要在目标语种和领域上比较 token/字符比、字节/token 比、未知 token 率、解码可逆性，以及加入特殊 token 后的真实序列长度。\n常见误区 Token 就等于单词 Token 可能是完整单词、子词、标点、空白片段、单个字节或特殊控制符。界面里显示的“token 数”不能直接等同于词数。\n子词边界一定符合词根和词缀 BPE 和常见 WordPiece 训练主要依赖语料统计。play + ing 可能恰好符合词法直觉，但算法并不承诺每次都得到语义上最自然的边界。\nByte-level BPE 的 token 就是一个字节 它以字节为基础词表，但 BPE 会把多个相邻字节反复合并。最终 token 可以覆盖一个字节、一个字符、多个字符，甚至带前导空格的整段文本。\n词表越大越好 更大的词表可能缩短序列，却会增加参数和词表投影开销，还会让低频 token 得不到充分训练。最优规模取决于语料、模型大小、语言分布和部署目标。\n可以给训练好的模型随意更换 Tokenizer 模型 embedding 的第 i 行只对应训练时词表中的第 i 个 token。更换词表或 ID 顺序会让这些语义对应关系失效；即使词表大小相同，也不能直接替换。\n总结 Tokenizer 的本质是在词表容量与序列长度之间做压缩，并保证开放世界中的文本能够进入一个固定大小的模型接口。\n词级方法直观但无法经济地覆盖开放词汇； 字符级方法覆盖广，却会产生过长序列； BPE 通过高频 pair 合并构造确定性的子词词表； WordPiece 常用不同的训练打分，并在推理时执行最长匹配； Unigram 用概率模型比较多条切分路径，并从大候选集逐步剪枝； 字节级 BPE 用 256 个基础字节解决字符 OOV，再通过合并恢复压缩效率。 理解这些差异后，再看到模型的词表大小、上下文窗口或 token 计费时，就能把它们与底层文本表示联系起来，而不只是把 tokenizer 当成一个黑盒 encode() 函数。\n参考资料 Hugging Face：Tokenization algorithms Hugging Face Tokenizers：Components Sennrich et al.：Neural Machine Translation of Rare Words with Subword Units Kudo：Subword Regularization Kudo \u0026amp; Richardson：SentencePiece Wang et al.：Neural Machine Translation with Byte-Level Subwords ","permalink":"https://zhangge.dev/ai-fundamentals/tokenizer-algorithms/","summary":"Tokenizer 不只是切词：它要在词表大小、序列长度、开放词汇与多语言覆盖之间权衡。本文用例子和一份可运行的 BPE 实现讲清主流算法。","title":"Tokenizer 基础：从文本到 Token ID"},{"content":" DFlash 比 EAGLE-3 更快的核心原因，不是“模型更小”，而是它把草稿生成从跨 token 的自回归串行过程，改造成单次前向中的块级并行预测。\n投机解码（Speculative Decoding）把一次生成拆成两个阶段：先由轻量草稿模型提出多个候选 token，再由目标大模型并行验证。只要采用严格的接受规则，最终输出分布就可以与目标模型直接自回归解码保持一致。\n这套方法的上限不仅取决于目标模型一次能验证多少 token，也取决于草稿本身生成得有多快。EAGLE 系列持续提高草稿质量和接受长度，但直到 EAGLE-3，其草稿阶段仍然保留明显的自回归依赖。DFlash 的变化更根本：它用轻量级块扩散模型一次并行预测整个草稿块。\n从 EAGLE 到 EAGLE-3 理解 DFlash 的优势，首先需要看清 EAGLE 系列逐步解决了什么问题。\nEAGLE：在特征空间自回归 原始 EAGLE 不让草稿模型仅根据 token 做预测，而是复用目标模型靠近输出端的隐藏特征，并将其与错开一个时间步的 token embedding 一起输入草稿模型。草稿模型在特征空间中自回归地预测下一步特征，再通过目标模型的 LM Head 得到 token 分布。\n图 1：EAGLE 的目标模型与草稿模型数据流。图源：EAGLE 论文。\n这种设计利用了目标模型已经计算出的语义信息，通常比独立的小语言模型更容易生成高质量草稿。不过，草稿 token 仍然需要一步接一步地产生，并按预设的静态草稿树扩展候选。\nEAGLE-2：从静态树变成动态树 EAGLE-2 沿用 EAGLE 的草稿模型，重点改造草稿树。静态树对所有上下文使用同一种宽度和深度，但不同上下文的预测难度并不相同：有些位置几乎只有一个高概率答案，有些位置则需要保留更多分支。\n图 2：EAGLE 使用固定草稿树，EAGLE-2 根据草稿模型置信度动态分配候选。图源：EAGLE-2 论文。\n因此，EAGLE-2 使用草稿模型的置信度近似 token 接受率，把计算预算动态分配给更有希望的分支。这减少了无效候选，但没有改变草稿 token 之间的自回归依赖。\nEAGLE-3：多层特征融合与直接 token 预测 EAGLE-3 对模型结构做了两项重要调整：\n不再要求草稿模型拟合目标模型的下一步隐藏特征，而是直接优化 token 预测。 不再只依赖靠近输出端的一层特征，而是融合目标模型的低层、中层和高层特征。 图 3：EAGLE-3 融合目标模型不同层次的隐藏特征，并在草稿模型中逐步生成候选。图源：EAGLE-3 论文。\n这些改动提升了草稿质量，也让草稿模型能够从更多训练数据中获益。但在推理阶段，EAGLE-3 仍然需要把上一步草稿结果反馈给下一步：要产生长度为 (K) 的草稿路径，关键路径上仍存在约 (K) 次顺序相关的草稿计算。\nDFlash 做了什么改变 DFlash 使用轻量级 block diffusion 模型生成草稿。它不再沿着一条路径从左到右逐 token 解码，而是先为一个固定长度的草稿块放置 mask token，再在双向注意力中并行恢复整个块。\n图 4：DFlash 将目标模型上下文特征注入各个草稿层，并对一个 token 块进行并行预测。图源：DFlash 论文。\n这个结构有三个关键点：\n块级并行预测：同一草稿块中的多个位置在一次前向计算中同时产生，不再形成跨 token 的串行链。 双向注意力：草稿块内部的 mask 位置可以相互建模，适合并行恢复多个 token，而不是使用标准因果注意力逐个向右生成。 KV injection：目标模型提取出的上下文隐藏特征被注入每一层草稿网络，并预先形成可复用的 Key/Value，使草稿模型获得目标模型的上下文知识。 DFlash 可以使用多层 Transformer 来增强草稿能力。层数增加会增加单次前向的计算量，但这些层处理的是整块 token；它增加的是网络深度，而不是 token 之间必须依次等待的串行步数。\n为什么更深的 DFlash 仍然更快 乍看之下，五层 DFlash 应该比单层 EAGLE-3 更慢。这个直觉忽略了两类不同的“深度”：\nEAGLE-3 的主要延迟来自草稿步深度：下一个 token 依赖上一个 token 的结果。 DFlash 的主要延迟来自网络层深度：每层都在 GPU 上并行处理整个 token 块。 二者对硬件的影响不同。\n1. 串行草稿步变成一次块级前向 EAGLE-3 每增加一个草稿 token，都需要完成一次依赖前一步结果的草稿计算。即使单步模型只有一层，这些步骤也无法沿时间维度完全并行。\nDFlash 则把 (K) 个草稿位置组织成一个张量，在一次模型调用中并行处理。草稿长度从 4 增加到 16 时，矩阵形状会变大，但关键路径没有增加 12 次自回归迭代。\n2. 减少重复调度和同步 自回归草稿的每一步通常都伴随新的算子调度、KV Cache 更新以及下一步开始前的数据依赖同步。具体实现未必需要把数据搬回 CPU，但 GPU 仍要等待上一步结果才能继续。\nDFlash 把更多工作合并到一次连续的草稿前向过程中，减少了跨草稿步的调度与同步开销，也更容易形成尺寸更大、利用率更高的矩阵计算。\n3. LM Head 从逐步调用变成块级调用 EAGLE-3 在每个自回归草稿步都需要通过 LM Head 得到词表 logits，才能选出 token 并进入下一步。词表投影涉及隐藏状态与大词表矩阵的乘法，重复执行会形成不可忽略的成本。\nDFlash 在一次块级前向结束时，对所有草稿位置批量产生 logits。它并不是只计算一个位置的词表，而是把多个位置的词表投影组织成一次批量计算，避免逐 token 重复启动 LM Head。\n4. 更强的草稿模型还能提高接受率 更深的 DFlash 具有更强的拟合能力，但“层数更多”并不自动等于“预测一定更准”。真正需要衡量的是：增加的草稿成本，能否换来更高的接受率和更长的平均接受长度。\nDFlash 的多层结构、双向块建模和目标特征注入共同提高了草稿质量。草稿被接受得越多，目标模型每次验证所摊销的生成成本就越低。\n论文中的草稿延迟对比 图 5：生成 4、8、16 个草稿 token 时，EAGLE-3 与 1/3/5 层 DFlash 的草稿延迟。图源：DFlash 论文。\n图中的趋势比绝对数值更重要：\nEAGLE-3 的草稿延迟随 token 数量明显上升，从约 6 ms 增长到 20 ms 以上。 DFlash 的延迟随草稿长度变化很小，因为多个位置在同一次前向中并行计算。 即使是五层 DFlash，在图示设置下生成 16 个草稿 token 的成本仍显著低于单层 EAGLE-3。 这是一个草稿阶段微基准，不能直接等同于端到端生成速度。实际收益还会受到目标模型大小、验证成本、接受率、batch size、硬件和推理框架实现的影响。不过，它直接说明了 DFlash 的核心优势：草稿长度增加时，延迟不再近似线性增长。\n一张表总结差异 维度 EAGLE-3 DFlash 草稿生成方式 自回归、逐步反馈 块扩散、并行预测 草稿网络 单层 Transformer Decoder 可配置多层 Transformer 注意力形式 因果/树形依赖 草稿块内双向注意力 目标模型信息 低、中、高层特征融合 上下文特征逐层 KV injection 生成 (K) 个草稿 token 关键路径包含多次顺序草稿步 单次块级草稿前向 LM Head 随自回归步骤重复调用 对整个草稿块批量调用 草稿长度增大时的延迟 通常明显增长 在硬件容量范围内增长较缓 结论 DFlash 比 EAGLE-3 更快，根本原因可以概括为一句话：\nEAGLE-3 优化了“每一步怎样猜得更准”，DFlash 则进一步改变了“这些步骤是否必须依次执行”。\nEAGLE-3 通过直接 token 预测和多层特征融合提高了草稿质量，但其推理关键路径仍然是自回归的。DFlash 用块扩散把多个草稿 token 放进同一次前向中并行生成，以更深但更适合 GPU 的计算，替代跨 token 的串行等待。\n因此，DFlash 的五层网络并不必然比 EAGLE-3 的单层网络慢。只要并行计算增加的成本小于被消除的自回归迭代、重复调度和逐步词表投影成本，它就能同时获得更强的草稿能力和更低的草稿延迟。\n参考资料 EAGLE: Speculative Sampling Requires Rethinking Feature Uncertainty EAGLE-2: Faster Inference of Language Models with Dynamic Draft Trees EAGLE-3: Scaling up Inference Acceleration of Large Language Models via Training-Time Test DFlash: Block Diffusion for Flash Speculative Decoding DFlash 官方实现 ","permalink":"https://zhangge.dev/model-inference/dflash-vs-eagle3/","summary":"DFlash 的关键并不只是增加模型层数，而是把跨 token 的串行草稿改造成一次前向中的块级并行预测。","title":"为什么 DFlash 比 EAGLE-3 推理更快？"},{"content":"题目：146. LRU 缓存 - 力扣（LeetCode）\nLRU 是很经典也很重要的面试题，一定要熟练掌握。\n题目描述 请你设计并实现一个满足 LRU（最近最少使用）缓存约束的数据结构。\n实现 LRUCache 类：\nLRUCache(int capacity)：以正整数 capacity 初始化 LRU 缓存； int get(int key)：如果关键字 key 存在于缓存中，则返回关键字的值，否则返回 -1； void put(int key, int value)：如果关键字 key 已经存在，则变更其数据值 value；如果不存在，则插入该组 key-value； 如果插入操作导致关键字数量超过 capacity，则应该逐出最久未使用的关键字。 题目要求 get 和 put 都必须以 O(1) 的平均时间复杂度运行。\n核心思路 这道题主要考察两个结构的配合：\n哈希表：通过 key 在 O(1) 时间内找到节点； 双向链表：维护节点的新旧顺序，并支持在 O(1) 时间内删除和插入节点。 我们可以设计一个 dummy 哨兵节点，让它同时作为链表的虚拟头尾：\ndummy.next 指向最新使用的节点； dummy.prv 指向最久未使用的节点； 初始时，dummy.next = dummy，dummy.prv = dummy。 每次访问或更新一个节点时，都把它移动到链表头部。这样链表尾部的节点就是最久未使用的节点，容量超出时直接删除 dummy.prv 即可。\n两个核心操作 为了维护双向链表，核心是写好两个函数：\nremove(node)：从链表中移除当前节点； move_to_head(node)：把节点加入链表头部，也就是放到 dummy 后面。 remove 的关键是跳过当前节点：\ndef remove(self, node): node.prv.next = node.next node.next.prv = node.prv move_to_head 的作用是把节点插入 dummy 和原头节点之间：\ndummy \u0026lt;-\u0026gt; original_head 插入 new_head 后： dummy \u0026lt;-\u0026gt; new_head \u0026lt;-\u0026gt; original_head 对应四步操作：\n让 node.next 指向原头节点； 让 node.prv 指向 dummy； 让原头节点的 prv 指向 node； 让 dummy.next 指向 node。 这里要注意第 3 步要在第 4 步之前执行。否则如果先改了 dummy.next，就拿不到原来的头节点了。\nPython 实现 class Node: __slots__ = (\u0026#39;prv\u0026#39;, \u0026#39;next\u0026#39;, \u0026#39;key\u0026#39;, \u0026#39;value\u0026#39;) def __init__(self, key, value): self.key = key self.value = value self.prv = None self.next = None class LRUCache: def __init__(self, capacity: int): self.dummy = Node(0, 0) self.dummy.prv = self.dummy self.dummy.next = self.dummy self.key_to_node = {} self.capacity = capacity def get(self, key: int) -\u0026gt; int: if key in self.key_to_node: find_node = self.key_to_node[key] self.remove(find_node) self.move_to_head(find_node) return find_node.value return -1 def put(self, key: int, value: int) -\u0026gt; None: if key in self.key_to_node: find_node = self.key_to_node[key] find_node.value = value self.remove(find_node) self.move_to_head(find_node) return new_node = Node(key, value) self.key_to_node[key] = new_node self.move_to_head(new_node) if len(self.key_to_node) \u0026gt; self.capacity: old_node = self.dummy.prv del self.key_to_node[old_node.key] self.remove(old_node) def remove(self, node): node.prv.next = node.next node.next.prv = node.prv def move_to_head(self, node): node.next = self.dummy.next node.prv = self.dummy self.dummy.next.prv = node self.dummy.next = node 为什么是 O(1) get 操作中：\n用哈希表根据 key 找节点是 O(1)； 从双向链表中删除节点是 O(1)； 把节点移动到头部是 O(1)。 put 操作中：\n如果 key 已存在，更新值并移动到头部，整体是 O(1)； 如果 key 不存在，新建节点并插入头部，整体是 O(1)； 如果容量超出，删除尾部节点也是 O(1)。 因此两个接口都满足题目要求。\n容易出错的地方 get 命中后也要把节点移动到头部，因为它刚刚被使用过； put 更新已有 key 时，也要把节点移动到头部； 超出容量时，要先拿到 dummy.prv，再从哈希表和链表中删除它； 插入头部时，更新指针的顺序不能写错； 哈希表中存的是 key -\u0026gt; node，不是 key -\u0026gt; value，否则无法在 O(1) 时间内移动链表节点。 ","permalink":"https://zhangge.dev/leetcode/lru-cache/","summary":"用哈希表和双向链表实现 O(1) 的查询、更新与淘汰，并梳理哨兵节点和边界处理。","title":"146. LRU 缓存"},{"content":"题目：51. N 皇后 - 力扣（LeetCode）\n题目描述 按照国际象棋的规则，皇后可以攻击与之处在同一行、同一列或同一斜线上的棋子。\nn 皇后问题研究的是：如何将 n 个皇后放置在 n x n 的棋盘上，并且使皇后彼此之间不能相互攻击。\n给你一个整数 n，返回所有不同的 n 皇后问题的解决方案。\n每一种解法包含一个不同的棋子放置方案，其中 'Q' 和 '.' 分别代表皇后和空位。\n回溯思路 N 皇后是一道经典回溯题。核心思路是：按照行来放棋子。\n也就是说，递归函数 dfs(row) 表示当前准备处理第 row 行。因为每一行只能放一个皇后，所以在这一行中枚举所有列，判断当前位置能不能放皇后：\n如果当前位置合法，就放置皇后； 递归处理下一行； 递归结束后，把当前位置恢复成空位。 递归终止条件是 row == n。\n以 n = 4 为例，当 row == 4 时，说明第 0 到第 3 行都已经处理完了，此时棋盘就是一个完整解法，可以加入答案。\n如何判断当前位置是否合法 假设当前准备在 (row, col) 放皇后。因为我们是从上到下逐行放置，所以只需要检查已经放过皇后的区域：\n同一列的上方是否有皇后； 左上对角线是否有皇后； 右上对角线是否有皇后。 文档里的实现还检查了当前行左侧。由于每一行在进入下一行前都会回溯恢复，当前行通常不会残留皇后，这个检查不是必须的，但保留它也不影响正确性。\nPython 实现 class Solution: def solveNQueens(self, n: int) -\u0026gt; List[List[str]]: chess = [[\u0026#39;.\u0026#39; for _ in range(n)] for _ in range(n)] ans = [] def dfs(row, chess): if row == n: ans.append([\u0026#39;\u0026#39;.join(r) for r in chess]) return for i in range(n): if is_valid(row, i, chess): chess[row][i] = \u0026#39;Q\u0026#39; dfs(row + 1, chess) chess[row][i] = \u0026#39;.\u0026#39; def is_valid(row, col, chess): if 0 \u0026lt;= row \u0026lt;= n - 1 and 0 \u0026lt;= col \u0026lt;= n - 1: x = row - 1 while x \u0026gt;= 0: if chess[x][col] == \u0026#39;Q\u0026#39;: return False x -= 1 y = col - 1 while y \u0026gt;= 0: if chess[row][y] == \u0026#39;Q\u0026#39;: return False y -= 1 x, y = row - 1, col - 1 while x \u0026gt;= 0 and y \u0026gt;= 0: if chess[x][y] == \u0026#39;Q\u0026#39;: return False x -= 1 y -= 1 x, y = row - 1, col + 1 while x \u0026gt;= 0 and y \u0026lt;= n - 1: if chess[x][y] == \u0026#39;Q\u0026#39;: return False x -= 1 y += 1 return True return False dfs(0, chess) return ans 关键点 这道题的关键不是一次性把所有皇后放好，而是把问题拆成一行一行处理。\n递归过程中的状态是当前棋盘 chess 和正在处理的行号 row。每次选择一个合法列放置皇后，然后进入下一层递归。如果后续走不通，就撤销当前选择，继续尝试这一行的下一个列。\n这就是典型的回溯结构：\n做选择 递归 撤销选择 复杂度分析 时间复杂度：近似 O(n!)。每一行都要选择一个列，并且后续可选位置会逐渐减少。 空间复杂度：O(n^2)，主要来自棋盘存储；递归栈深度为 O(n)。 容易出错的地方 row == n 时要立刻收集答案并返回； 加入答案时要把每一行转换成字符串，不能直接把二维列表引用放进答案； 回溯后必须把 chess[row][i] 恢复成 '.'； 对角线检查时，左上和右上的坐标更新方向不要写反。 ","permalink":"https://zhangge.dev/leetcode/n-queens/","summary":"按行回溯放置皇后，用列和两条对角线集合完成常数时间冲突检测。","title":"51. N 皇后"},{"content":"题目：LCR 053. 二叉搜索树中的中序后继 - 力扣（LeetCode）\n这道题和中序遍历的关系 在二叉搜索树中，节点 p 的中序后继等价于：\n所有值大于 p.val 的节点中，值最小的那个节点。\n例如下面这棵二叉搜索树：\n5 / \\ 3 8 / \\ / \\ 2 4 6 9 它的中序遍历顺序是：\n2, 3, 4, 5, 6, 8, 9 如果 p = 5：\n中序遍历中，5 的下一个节点是 6； 所有比 5 大的节点是 6、8、9； 其中最小的是 6。 根据这个逻辑，可以先使用中序遍历记录节点顺序，然后返回 p 所在位置的下一个节点。\n方法一：中序遍历记录节点列表 # Definition for a binary tree node. # class TreeNode: # def __init__(self, x): # self.val = x # self.left = None # self.right = None class Solution: def inorderSuccessor(self, root: \u0026#39;TreeNode\u0026#39;, p: \u0026#39;TreeNode\u0026#39;) -\u0026gt; \u0026#39;TreeNode\u0026#39;: find_list = [] def dfs(node): if not node: return dfs(node.left) find_list.append(node) dfs(node.right) dfs(root) if p in find_list: index = find_list.index(p) if index != len(find_list) - 1: return find_list[index + 1] return None 这个方法直接利用了二叉搜索树的中序遍历结果有序这一点。缺点是需要额外的列表维护遍历顺序。\n方法二：遍历整棵树维护候选答案 也可以遍历整棵树，并在遍历过程中维护一个变量 ans，用于记录当前已经找到的、大于 p.val 的最小节点。\n这种写法不需要用列表维护顺序。因为这里遍历的是整棵树，所以前序、中序、后序都可以。\n# Definition for a binary tree node. # class TreeNode: # def __init__(self, x): # self.val = x # self.left = None # self.right = None class Solution: def inorderSuccessor(self, root: \u0026#39;TreeNode\u0026#39;, p: \u0026#39;TreeNode\u0026#39;) -\u0026gt; \u0026#39;TreeNode\u0026#39;: ans = None def dfs(node): if not node: return nonlocal ans if node.val \u0026gt; p.val: if not ans or ans.val \u0026gt; node.val: ans = node dfs(node.left) dfs(node.right) dfs(root) return ans 方法三：利用二叉搜索树性质 上面的做法都没有充分利用二叉搜索树左小右大的性质。\n可以通过比较当前节点和目标节点 p 的大小来缩小搜索范围：\n如果 root.val \u0026gt; p.val，说明当前节点是一个可能的后继节点。先记录它，然后继续去左子树找更小但仍然大于 p.val 的节点。 如果 root.val \u0026lt;= p.val，说明当前节点以及它的左子树都不可能是答案，应该去右子树查找。 如果没有找到符合要求的节点，ans 会保持为 None。 # Definition for a binary tree node. # class TreeNode: # def __init__(self, x): # self.val = x # self.left = None # self.right = None class Solution: def inorderSuccessor(self, root: \u0026#39;TreeNode\u0026#39;, p: \u0026#39;TreeNode\u0026#39;) -\u0026gt; \u0026#39;TreeNode\u0026#39;: ans = None if not root: return None while root is not None: if root.val \u0026gt; p.val: ans = root root = root.left else: root = root.right return ans 这个方法利用了二叉搜索树的有序性，不需要完整遍历所有节点，也不需要额外列表。\n","permalink":"https://zhangge.dev/leetcode/inorder-successor-in-bst/","summary":"从完整中序遍历到利用 BST 有序性质的迭代查找，比较三种中序后继解法及其复杂度。","title":"LCR 053. 二叉搜索树中的中序后继"},{"content":" 一句话概括： Token Recycling（TR）把目标模型在验证过程中已经计算、却通常会被丢弃的 top-k 候选 token 保存下来，在后续解码中将它们重新组织成草稿树。它不需要训练 draft model，也不需要增加 MTP 或 Medusa Head，而是让目标模型“回收”自己过去算过的候选答案。\n论文要解决什么问题 普通自回归解码每次只提交一个 token。以 greedy decoding 为例，目标模型虽然会计算整个词表上的概率分布，但最终只保留 top-1：\n当前上下文：... volunteering as a guest top-1 speaker ← 被选中 top-2 speak top-3 event top-4 speaking speak、event 和 speaking 在这一轮没有胜出，通常会被直接丢弃。然而这些候选并不一定是无用的：\n当前没有被选中的 token，未来可能在相似上下文中成为正确答案； 被拒绝分支上的模型输出，仍然包含有价值的局部转移关系； 这些概率分布本来就是目标模型前向计算的结果，保存其 top-k 不需要再调用一个生成模型。 TR 的思路因此非常直接：记录“每个 token 后面最可能出现哪些 token”，下次再看到它时，从这些关系中构造候选路径，交给目标模型并行验证。\n传统投机解码会丢弃未被接受的候选；TR 将候选写入邻接矩阵，供后续生成检索。\n核心数据结构：邻接矩阵 TR 维护一个形状为 |V| × K 的邻接矩阵 M：\nV 是词表，|V| 是词表大小； K 是每个 token 保存的候选后继数量； M[i] 保存 token i 后面概率最高的 K 个 token ID。 假设 K = 3，矩阵中的一部分可能是：\n当前 token 候选后继 token guest speaker, speak, event speaker at, for, is speak ing, in, ers at a, the, an 它本质上是一张一阶 token 转移图。更新时，目标模型已经给出了完整上下文下的条件分布：\nM[xᵢ] = top-k P(next token | x₀, ..., xᵢ) 但后续查询只使用当前 token xᵢ 作为索引，相当于把完整上下文下的转移关系近似压缩为：\nP(xᵢ₊₁ | x₀, ..., xᵢ) → P(xᵢ₊₁ | xᵢ) 这是 TR 极其轻量的原因，也是它最重要的近似与局限：相同 token 在不同语境中的候选会写入同一行，完整上下文信息不会被保留。\n完整工作流程 一次 TR 解码可以拆成四步。\n1. 根据当前 token 查询候选 假设当前序列结尾是：\n... volunteering as a guest 以最后一个 token guest 查询邻接矩阵：\nM[guest] = [speaker, speak, event] 这三个 token 就是草稿树的第一层候选。\n2. 递归查询并构造草稿树 TR 不只查询一层，还会继续沿候选关系展开：\nM[speaker] = [at, for, is] M[speak] = [ing, in, ers] 由此可得到多条候选路径：\nguest → speaker → at guest → speaker → for guest → speak → ing 这个过程类似 BFS，但并非完整、均匀地展开。论文使用预定义的静态非平衡树：排名靠前的候选可以获得更多子节点并延伸得更深，低概率候选只分配少量节点；总节点数保持固定，以便预先准备 attention mask 和相关 CUDA 数据结构。\n一个容易混淆的细节是更新时机。TR 会先使用旧矩阵构造完整草稿树，再由目标模型一次前向验证整棵树；验证结束后，才用各节点 logits 的 top-k 批量更新矩阵，供下一轮解码使用。\n3. 使用 Tree Attention 并行验证 如果分别验证每条路径，目标模型仍然需要执行多次前向，投机解码就失去了意义。Tree Attention 会把整棵树压平成一个合并序列：\n[guest, speaker, speak, event, at, for, ing] 然后通过 attention mask 限制每个节点的可见范围：\nat 只能看到 guest → speaker → at； for 只能看到 guest → speaker → for； ing 只能看到 guest → speak → ing； 兄弟分支之间互不可见。 这样，一次目标模型前向就能验证多条候选路径，同时保持每条路径的因果依赖正确。\n4. 接受最长正确路径 假设目标模型真实的 greedy 输出是：\nguest → speaker → at → a 那么草稿路径 guest → speaker → at 连续命中，可以一次确认多个 token。若另一条路径是 guest → speaker → for，而目标模型在 speaker 后选择了 at，它就会在 for 处停止匹配。\n最终，TR 选择连续匹配最长的路径，并补上目标模型在首个失败位置给出的正确 token，保证结果与目标模型原始解码一致。\nTR 先从邻接矩阵检索草稿树，再用 Tree Attention 验证，最后接受最长正确路径并更新矩阵。\n为什么叫 Token Recycling 验证整棵树时，目标模型会为树中每个节点产生一个完整词表分布。例如：\nP(next | ... guest) → [speaker, event, speaking, ...] P(next | ... guest speaker) → [at, is, event, ...] P(next | ... guest speak) → [ers, at, ER, ...] P(next | ... speaker at) → [a, an, the, ...] TR 保留每个分布中概率最高的 K 个 token：\nM[guest] = [speaker, event, speaking] M[speaker] = [at, is, event] M[speak] = [ers, at, ER] M[at] = [a, an, the] 即使 speak 所在的路径最终没有被接受，它后面的 top-k 结果仍会进入矩阵。整个过程由此形成闭环：\n查询邻接矩阵； 构造草稿树； 由目标模型并行验证； 接受最长正确路径； 回收所有已验证节点的 top-k，更新邻接矩阵。 所谓“把 trash tokens 变成 treasure tokens”，指的正是对这些已计算但未被采用的候选进行再利用。\n热启动：跨请求复用矩阵 如果邻接矩阵初始全为零，新请求开始时就没有可用的候选关系，必须经过若干轮解码才能逐步积累。论文使用 Hot Start 缓解冷启动：新请求继承先前请求运行后得到的邻接矩阵，而不是每次重新清空。\n这种复用让系统能更早构造有效草稿树。不过从方法机制上看，它也意味着矩阵中的局部转移统计来自之前处理过的上下文；在输入分布变化明显时，候选的命中率可能随之变化。\n为什么一个很小的矩阵就够用 以词表大小 32,000、K = 8、每个候选 ID 使用 64 位整数为例：\n32,000 × 8 × 8 bytes = 2,048,000 bytes ≈ 1.95 MiB 矩阵不保存完整概率分布、hidden state、KV Cache、完整上下文、n-gram 字符串或外部语料索引，只保存：\ntoken_id → 8 个 candidate_token_id 因此候选检索可以直接通过 GPU tensor indexing 完成，不需要字符串匹配或数据库查询。TR 用上下文精度换取了非常低的存储和检索开销。\n我的理解：优势与边界 TR 最有价值的地方，不是发明了另一个“猜 token”的模型，而是重新审视目标模型前向计算中的浪费：既然验证阶段已经得到大量候选分布，就应尽可能把它们变成后续解码的草稿来源。\n它的工程优势很明确：\n无需训练：不引入单独的 draft model、MTP Head 或额外训练流程； 存储很小：仅保存 token ID 级别的邻接关系； 检索简单：矩阵可常驻 GPU，通过索引直接读取； 结果无损：候选始终由目标模型验证，接受规则不改变目标模型原本的输出。 边界也同样清楚：\n上下文被压缩：同一个 token 在不同语境下共享一组候选，一阶关系可能产生歧义； 依赖候选复现：如果后续生成与历史局部转移差异很大，回收候选的命中率会下降； 树结构需要取舍：固定节点预算有利于内核与 mask 预分配，但无法为每个上下文动态找到最优展开方式； 冷启动需要积累：Hot Start 可以缓解，但不能消除数据分布变化带来的影响。 总体来看，Token Recycling 是一种典型的系统型优化：它接受一个粗粒度的一阶近似，换来训练成本、显存开销和检索复杂度都很低的草稿生成机制。真正的关键不在于“预测得多聪明”，而在于把目标模型已经付过计算成本的信息再利用一次。\n论文信息 论文：Turning Trash into Treasure: Accelerating Inference of Large Language Models with Token Recycling 会议：ACL 2025 关键词：speculative decoding、training-free、draft tree、tree attention、adjacency matrix ","permalink":"https://zhangge.dev/papers/token-recycling/","summary":"Token Recycling 不额外训练 draft model，而是保存目标模型验证时产生的 top-k 候选，用轻量邻接矩阵构造草稿树，再通过 Tree Attention 一次验证多条路径。","title":"Token Recycling：把被丢弃的候选 Token 变成下一轮草稿"}]