Prhub

#55301 [Transformers] Generalize merged-column linear fusion

原始 PR 作者 BANANASJIM 合并时间 2026-09-09 07:49 文件变更 8 提交数 22 评论 17 代码增减 +425 / -126

执行摘要

新增通用 MergedColumnParallelFuser 并重构 GLU/QKV/MLA fuser 以复用共享逻辑,提升代码可维护性。

为解决现有 fuser(GLU、QKV)仅能处理特定模式、无法覆盖通用列并行投影融合的问题,以及 fuser 之间存在大量近似重复代码、维护成本高的问题。PR body 中明确指出,新 fuser 作为 GLUFuser/QKVFuser 的结构化回退,处理不符合其特定模式的同源列并行投影。同时,通过重构将共享的合并-拆分 AST 重写逻辑提取到基类中,消除了三份近似相同的实现。

该 PR 值得精读,特别是对于负责 Transformers 后端、模型优化或 fuser 系统的工程师。核心设计决策包括:通用 fuser 的匹配逻辑与动态命名策略、GLU/QKV/MLA 的重构路径选择、以及多候选 fuser 的实例级选择机制。这些决策体现了良好的代码设计模式和对边缘情况的深思熟虑。测试覆盖全面,是高质量重构的典范。

讨论亮点
  1. 重命名以预留未来空间:Reviewer hmellor 建议将 merged_cls 重命名为 merged_cls_name(所有地方),以便将来可以将 merged_cls 用于实际的类引用。此建议被采纳并实施。
  2. GLUFuser 的重构路径:最初讨论了让 GLUFuser.match() 调用 super().match() 的方案,但发现这会继承整个组的限制(要求组内仅有 gate/up),导致在存在路由器等额外投影时无法融合。作者 BANANASJIM 解释了提取 sibling_groups() 的尝试,但 hmellor 最终决定回退此部分,因为 mul 节点匹配本身已经足够精确地识别 GLU 模式。
  3. 关键正确性修复:在讨论中发现 GLUFuser.validate 原本假设 _get_glu_nodes 的共享输入检查已隐含了形状验证,但 fx 的叶模块追踪从不验证形状,因此需要显式检查 gate 和 up 的 out_features 是否相等,因为 AndMul 内核要求等分。此修复被合并到最终版本中。
  4. 多候选 fuser 选择:讨论了如何让 get_fusers 缓存多个匹配的 fuser,并在 Fusers.__getitem__ 中按实例进行选择,以支持同一 Python 类但形状不同的层(如 PlainQKV 的异构实例)使用不同的 fuser。

实现拆解

  1. 新增通用 fuser:在 vllm/model_executor/models/transformers/fusers/merged_column.py 中新增 MergedColumnParallelFuser 类。它通过分析模块的 FX 图,识别所有共享同一 placeholder 输入的同源线性层,验证其为单一可融合组后,生成一个动态计算的唯一 merged_name(基于线性层名称的长度前缀哈希),并在 update_forward 中通过 AST 重写将 l1(x), l2(x), ... 替换为 merged(x).split(sizes, -1)。
  2. 重构现有 fuser:修改 vllm/model_executor/models/transformers/fusers/glu.py 中的 GLUFuser,使其继承自 MergedColumnParallelFuser 而非 StackedFuser。移除了 gate_name/up_name 作为独立字段,改为通过 linear_names 属性访问。validate 方法增加了对 super().validate() 的调用(继承了输入特征维度、偏置一致性、TP 整除性检查),并新增了 gate/up 输出尺寸必须相等的检查。
  3. 提取共享逻辑:在 vllm/model_executor/models/transformers/fusers/base.py 的 StackedFuser 中新增 _splice_merged_split 静态方法,封装了合并调用-拆分结果的 AST 重写逻辑。MLAFuser 和 QKVFuser 的 update_forward 方法被简化为调用此共享方法。
  4. 调整 fuser 调度与选择:在 vllm/model_executor/models/transformers/fuser.py 中,将 MergedColumnParallelFuser 添加到 FUSERS 元组中,并调整了 get_fusers 的逻辑,使其缓存所有结构匹配的重写型 fuser(而非第一个)。Fusers.__getitem__ 现在按优先级顺序验证并选择第一个通过验证的重写型 fuser,支持同一类的异构实例回退。
  5. 完善测试:在 tests/models/transformers/fusers/test_linear.py 中新增了大量测试用例,覆盖任意数量线性层的融合、输入变异拒绝、BroadcastGLU/ExtraProjGLU 等边界情况、merged_name 唯一性,以及异构实例的 fuser 回退逻辑。
文件 模块 状态 重要度
vllm/model_executor/models/transformers/fusers/merged_column.py 投影融合 added 9.32
vllm/model_executor/models/transformers/fusers/glu.py 投影融合 modified 8.67
tests/models/transformers/fusers/test_linear.py 单元测试 modified 7.88
vllm/model_executor/models/transformers/fusers/base.py 投影融合 modified 7.25
vllm/model_executor/models/transformers/fuser.py 投影融合 modified 6.9

关键符号

MergedColumnParallelFuser.match MergedColumnParallelFuser.update_forward MergedColumnParallelFuser.validate MergedColumnParallelFuser.update_attrs GLUFuser.validate GLUFuser.update_attrs StackedFuser._splice_merged_split get_fusers Fusers.__getitem__

关键源码片段

vllm/model_executor/models/transformers/fusers/merged_column.py core-logic

核心新增文件,实现了通用的列并行投影融合逻辑,是整个 PR 的核心。

# MergedColumnParallelFuser 的 match 方法,展示如何从 FX 图中识别可融合的同源线性组
@classmethod
def match(
    cls, graph: fx.Graph, module: nn.Module
) -> "MergedColumnParallelFuser | None":
    """Fuse the module's sibling linears when there is only one such group."""
    # 按输入节点分组线性层
    by_input: dict[fx.Node, list[fx.Node]] = {}
    for node in graph.nodes:
        if (
            is_linear(node, module)
            and len(node.args) == 1 # 仅接受单个位置参数
            and not node.kwargs # 不接受关键字参数
            and isinstance(node.args[0], fx.Node)
            # 必须是直接消费 placeholder 输入的线性层,
            # 以避免吸收 QKV 等更具体 fuser 已拒绝的模式(如 norm 后的模式)
            and node.args[0].op == "placeholder"
        ):
            by_input.setdefault(node.args[0], []).append(node)
    # 筛选出包含 ≥2 个不同成员且名称唯一(不含 '.')的组
    groups = [
        (group, names)
        for group in by_input.values()
        if len(group) >= 2 and (names := cls._names(group)) is not None
    ]
    # 如果存在多个可融合组,则存在歧义,跳过融合
    if len(groups) > 1:
        logger.debug(
            "%s has %d fusable sibling-linear groups; skipping fusion "
            "since which one to merge is ambiguous",
            type(module).__name__,
            len(groups),
        )
    if len(groups) != 1:
        return None
    _, names = groups[0]
    fuser = cls(source_cls=type(module).__name__, linear_names=names)
    # 防止对已融合的模块重复融合
    return None if hasattr(module, fuser.merged_name) else fuser
vllm/model_executor/models/transformers/fusers/glu.py refactoring

关键重构文件,展示了如何通过继承通用 fuser 来简化现有 GLU 融合逻辑,并修复了重要的正确性问题。

# GLUFuser 重构后的类定义和关键方法
class GLUFuser(MergedColumnParallelFuser):
    """Fuser for the GLU pattern `act(gate(x)) * up(x)`."""
    act_name: str
    down_name: str | None
    merged_name: ClassVar[str] = "gate_up_proj"
    # gate_name 和 up_name 现在通过 linear_names 属性访问,而非独立字段
    @property
    def gate_name(self) -> str:
        return self.linear_names[0]
    @property
    def up_name(self) -> str:
        return self.linear_names[1]
    def validate(self, module: nn.Module, vllm_config: "VllmConfig") -> bool:
        # 首先调用父类的 validate,检查输入维度、偏置一致性和 TP 整除性
        if not super().validate(module, vllm_config):
            return False
        # 新增关键检查:AndMul 内核要求 gate 和 up 输出尺寸相等(等分输入)
        gate = module.get_submodule(self.gate_name)
        up = module.get_submodule(self.up_name)
        if gate.out_features != up.out_features:
            logger.debug("%s and %s differ in size; skipping fusion", gate, up)
            return False
        # 检查激活函数是否有 AndMul 等效实现
        act = module.get_submodule(self.act_name)
        if self._get_act_and_mul_name(act) is None:
            logger.debug("%s has no AndMul equivalent; skipping fusion", act)
            return False
        return True
    def update_attrs(
        self, module: nn.Module, prefix: str, vllm_config: "VllmConfig"
    ) -> None:
        # 调用父类的 update_attrs,处理合并投影的创建和属性替换
        super().update_attrs(module, prefix, vllm_config)
        # 替换激活函数为 AndMul 等效实现
        act_fn = self._get_act_and_mul(module.get_submodule(self.act_name))
        setattr(module, self.act_name, act_fn)
        # 处理下行投影(如果有),强制为 RowParallelLinear
        if self.down_name is not None:
            down_prefix = maybe_prefix(prefix, self.down_name)
            down = module.get_submodule(self.down_name)
            new_down = replace_linear_class(
                down, "rowwise", vllm_config.quant_config, prefix=down_prefix
            )
            setattr(module, self.down_name, new_down)
            log_replacement(down_prefix, down, new_down)
vllm/model_executor/models/transformers/fusers/base.py data-contract

基础类文件,新增了 `_splice_merged_split` 共享方法,是消除代码重复的关键。

# StackedFuser 中新增的共享方法,用于合并调用并拆分结果
def _splice_merged_split(
    self,
    funcdef: ast.FunctionDef,
    calls: list[ast.Call],
    block: list[ast.stmt],
    index: int,
) -> None:
    """Insert `temps = self.<merged_name>(arg).split(sizes, -1)` at
    `block[index]` and replace each of `calls` with its temp name.
​
    `calls` must share one input argument; `block[index]` must be where
    they are (or would be) evaluated. Raises if a generated temporary
    would shadow an existing name in `funcdef`.
    """
    # 为每个调用生成一个临时变量名
    temps = [f"_vllm_merged_{i}" for i in range(len(calls))]
    # 检查临时变量名是否与函数中已有的名称冲突
    names = {node.id for node in ast.walk(funcdef) if isinstance(node, ast.Name)}
    if names & set(temps):
        raise ValueError("fused temporaries would shadow existing names")
    targets = ", ".join(temps)
    # 构建拆分大小的表达式(使用合并模块的 output_sizes)
    sections = local_output_sizes(self.merged_name)
    source = f"{targets} = self.{self.merged_name}(__arg__).split({sections}, -1)"
    assign = ast.parse(source).body[0]
    # 将占位符参数替换为实际的输入节点
    arg = next(
        node
        for node in ast.walk(assign)
        if isinstance(node, ast.Name) and node.id == "__arg__"
    )
    replace_expr(assign, arg, calls[0].args[0])
    ast.copy_location(assign, block[index])
    block.insert(index, assign)
    # 将原始的线性层调用替换为对应的临时变量
    for call, temp in zip(calls, temps):
        replace_expr(funcdef, call, ast.Name(id=temp, ctx=ast.Load()))

评论区精华

重命名 merged_cls 以预留类引用空间 style

Reviewer hmellor 建议将 `merged_cls` 重命名为 `merged_cls_name`,以便将来可以将 `merged_cls` 用于实际的类引用(如 `MergedColumnParallelLinear`)。

结论:采纳建议,将 `merged_cls` 统一重命名为 `merged_cls_name`。 · 已解决

GLUFuser 的重构路径选择 设计

讨论了 `GLUFuser.match()` 是否应该调用 `super().match()`。发现这会继承整个组的限制,导致存在路由器等额外投影时无法融合。最终决定保持 GLU 独立的匹配逻辑。

结论:回退了调用 `super().match()` 的尝试,保持 GLU 独立的 `match` 实现。 · 已解决

GLUFuser.validate 中缺失 gate/up 输出尺寸检查 正确性

在讨论中发现 fx 的叶模块追踪不验证形状,因此需要显式检查 gate 和 up 的 `out_features` 是否相等,因为 AndMul 内核要求等分输入。

结论:在 `GLUFuser.validate` 中新增了 `gate.out_features != up.out_features` 的检查。 · 已解决

多候选 fuser 的选择机制 设计

讨论了如何让 `get_fusers` 缓存多个匹配的 fuser,并在 `Fusers.__getitem__` 中按实例进行选择,以支持异构实例。

结论:修改了 `get_fusers` 缓存所有匹配的重写型 fuser,并在 `Fusers.__getitem__` 中按优先级和验证结果选择第一个通过验证的。 · 已解决

BroadcastGLU 测试的隔离性 测试

Reviewer 指出 `BroadcastGLU` 测试中的 `gate_proj` 使用了默认 `bias=True`,而 `up_proj` 是 `bias=False`,导致测试首先因偏置不一致而被拒绝,未能有效测试宽度不匹配的情况。

结论:在 `BroadcastGLU` 的 `gate_proj` 中显式设置 `bias=False`,以隔离宽度不匹配的测试场景。 · 已解决

风险与影响

  1. 回归风险(中):重构了 GLUFuser、QKVFuser、MLAFuser 的继承链和 update_forward 实现,虽然测试覆盖充分(72 passed, 1 skipped),但修改了核心 fuser 逻辑,可能影响现有模型的融合行为。特别是 GLUFuser.validate 新增了 gate/up 输出尺寸检查,可能使之前意外通过的模型现在被拒绝融合。
  2. 性能风险(低):变更主要是代码重构和逻辑调整,不涉及计算核心路径。MergedColumnParallelFuser 的 match 方法增加了对输入变异的检查,可能略微增加匹配时间,但对整体性能影响可忽略。
  3. 兼容性风险(中):merged_name 的生成逻辑从固定名称改为动态计算(基于线性层名称),虽然做了冲突防护(长度前缀),但可能影响依赖旧名称的外部代码或自定义模型。
  4. 测试覆盖(高):新增了全面的测试用例,覆盖了多种边界情况和正确性验证,包括数值精度测试(torch.testing.assert_close),降低了回归风险。
  1. 对用户的影响(低):本次变更是内部重构,不改变公开 API 或用户可观察的行为。融合结果在数值上应保持一致,但某些边缘情况(如 gate/up 尺寸不匹配的 GLU)可能从“意外融合”变为“正确拒绝”。
  2. 对系统的影响(中):增强了 fuser 系统的可扩展性和可维护性。新的通用 fuser 为未来支持更多列并行投影融合提供了基础。多候选选择机制提升了异构模型的处理能力。
  3. 对团队的影响(中):消除了大量重复代码(特别是 _splice_merged_split 的提取),降低了维护成本。清晰的继承结构(StackedFuser -> MergedColumnParallelFuser -> GLUFuser)使代码更易于理解和扩展。
核心路径重构 继承链变更 验证逻辑新增 动态命名可能影响外部依赖

关联 Issue

未识别关联 Issue

当前没有检测到明确关联的 Issue 链接,后续同步到相关引用后会出现在这里。

完整报告

参与讨论