Improve Transformers modelling backend `fx` tracer (#49957)
Signed-off-by: Harry Mellor <19981378+hmellor@users.noreply.github.com>
Harry Mellor committed
Jul 27, 2026 at 12:51 UTC
92e8518d376c3c9a6979da4d56ee6263ef6af630
9 files changed
+307
-128
tests/models/transformers/fusers/test_linear.py
+3
-4
@@ -368,12 +368,12 @@ def test_qkv_identifies_output_projection():
368
assert get_fuser(PerHeadQKNormAttention()).o_name == "o_proj"
369
370
371
-def test_fuser_is_cached_per_class():
371
+def test_fuser_is_cached_per_class_and_structure():
372
with torch.device("meta"):
373
fuser_a = get_fuser(GLUMLP())
374
fuser_b = get_fuser(GLUMLP())
375
assert fuser_a is fuser_b
376
- assert GLUMLP in get_fuser.cache
376
+ assert any(key[0] is GLUMLP for key in get_fuser.cache)
377
378
379
@pytest.mark.parametrize("cls", [NotAnMLP, UntraceableMLP])
@@ -460,8 +460,7 @@ def test_unfusable_modules_are_not_fused(cls, default_vllm_config):
460
fuser = get_fuser(module)
461
# Either no pattern matches the class, or this instance fails validation
462
# (`recursive_replace` gates fusion and its weight mappings on `validate`)
463
- model_config = default_vllm_config.model_config
464
- assert fuser is None or not fuser.validate(module, model_config)
463
+ assert fuser is None or not fuser.validate(module, default_vllm_config)
464
465
466
def test_act_and_mul_derived_from_module(default_vllm_config):
tests/models/transformers/fusers/test_rms_norm.py
+11
-6
@@ -148,11 +148,13 @@ def test_rms_norm_builds_vllm_class(cls, expected, zero_centered, default_vllm_c
148
149
# `default_vllm_config` supplies the config context the CustomOp needs; the
150
# weightless path reads hidden size from the model config, so stub it.
151
- model_config = SimpleNamespace(get_hidden_size=lambda: 16)
151
+ vllm_config = SimpleNamespace(
152
+ model_config=SimpleNamespace(get_hidden_size=lambda: 16)
153
+ )
154
with torch.device("meta"):
155
module = cls()
156
fuser = get_fuser(module)
155
- built = fuser.fuse(module, "norm", model_config, None)
157
+ built = fuser.fuse(module, "norm", vllm_config)
158
from vllm.model_executor.models.transformers.fusers.rms_norm import (
159
TPAwareNormMixin,
160
)
@@ -176,8 +178,9 @@ def test_fused_rms_norm_op_default_eps(default_vllm_config):
178
fuser = get_fuser(module)
179
assert isinstance(fuser, RMSNormFuser)
180
assert not fuser.zero_centered
179
- model_config = SimpleNamespace(get_hidden_size=lambda: 16, dtype=torch.float32)
180
- built = fuser.fuse(module, "norm", model_config, None)
181
+ mc = SimpleNamespace(get_hidden_size=lambda: 16, dtype=torch.float32)
182
+ vllm_config = SimpleNamespace(model_config=mc)
183
+ built = fuser.fuse(module, "norm", vllm_config)
184
assert isinstance(built, VLLMRMSNorm)
185
assert built.variance_epsilon == torch.finfo(torch.float32).eps
186
@@ -185,11 +188,13 @@ def test_fused_rms_norm_op_default_eps(default_vllm_config):
188
def test_eps_is_derived_per_instance(default_vllm_config):
189
"""Two instances of the same norm class with different eps must fuse to their
190
own eps: the type-cached fuser holds only structure, not this value."""
188
- model_config = SimpleNamespace(get_hidden_size=lambda: 16)
191
+ vllm_config = SimpleNamespace(
192
+ model_config=SimpleNamespace(get_hidden_size=lambda: 16)
193
+ )
194
with torch.device("meta"):
195
for eps in (1e-5, 1e-6):
196
module = RMSNorm(16, eps=eps)
192
- built = get_fuser(module).fuse(module, "norm", model_config, None)
197
+ built = get_fuser(module).fuse(module, "norm", vllm_config)
198
assert built.variance_epsilon == eps
199
200
vllm/model_executor/models/transformers/base.py
+4
-5
@@ -104,6 +104,7 @@ class Base(
104
super().__init__()
105
logger.info("Using Transformers modeling backend.")
106
107
+ self.vllm_config = vllm_config
108
self.config = vllm_config.model_config.hf_config
109
self.text_config = self.config.get_text_config()
110
self.cache_config = vllm_config.cache_config
@@ -357,7 +358,7 @@ class Base(
358
tip = get_feature_request_tip(
359
self.model_config.model, self.model_config.trust_remote_code
360
)
360
- logger.warning(
361
+ logger.warning_once(
362
"%s does not define a pipeline parallel plan. The Transformers "
363
"modeling backend will infer the split from the layers of %s in order "
364
"of declaration and keep parameter-free modules on every rank. This "
@@ -452,7 +453,7 @@ class Base(
453
# Prefix the patterns because we always start from `self.model`
454
tp_plan = {maybe_prefix("model", k): v for k, v in tp_plan.items()}
455
# Detect fusable patterns once per module class (cached, so this is cheap)
455
- fusers = Fusers(self.model, self.model_config)
456
+ fusers = Fusers(self.model, self.vllm_config)
457
458
def register_fusion(fuser: BaseFuser, prefix: str):
459
"""Register a fused layer's mappings just before it is built."""
@@ -503,9 +504,7 @@ class Base(
504
new_module = replace_conv_class(child_module)
505
elif (fuser := fusers[child_module]) is not None:
506
register_fusion(fuser, qual_name)
506
- new_module = fuser.fuse(
507
- child_module, qual_name, self.model_config, self.quant_config
508
- )
507
+ new_module = fuser.fuse(child_module, qual_name, self.vllm_config)
508
logger.info_once(fuser.info(child_name))
509
_recursive_replace(new_module, prefix=qual_name)
510
elif not isinstance(child_module, MoERunner):
vllm/model_executor/models/transformers/fuser.py
+17
-9
@@ -25,14 +25,19 @@ from vllm.model_executor.models.transformers.fusers import (
25
from vllm.model_executor.models.transformers.fx_utils import trace
26
27
if TYPE_CHECKING:
28
- from vllm.config.model import ModelConfig
28
+ from vllm.config import VllmConfig
29
30
logger = init_logger(__name__)
31
32
33
-@cached(cache={}, key=type)
33
+def key(module: nn.Module) -> tuple:
34
+ """Cache key for `get_fuser`. Considers module type and its immediate children."""
35
+ return (type(module), tuple(name for name, _ in module.named_children()))
36
+
37
+
38
+@cached(cache={}, key=key)
39
def get_fuser(module: nn.Module) -> BaseFuser | None:
35
- """The fuser for `type(module)` (cached per class), or `None` if no match."""
40
+ """The fuser for `module`'s class and shape (cached), or `None` if no match."""
41
# Projection fusions need >=2 sibling linears; the RMSNorm fusion needs a
42
# leaf module (raw tensor math, no submodules). Nothing else can match, and
43
# tracing is skipped for it.
@@ -48,11 +53,14 @@ def get_fuser(module: nn.Module) -> BaseFuser | None:
53
try:
54
fuser.update_forward(module)
55
except Exception as exc:
51
- # An unrecognised source just means we cannot fuse here.
56
logger.debug(
53
- "Could not rewrite %s for fusion: %s", type(module), exc
57
+ "Attempted to fuse %s using %s but failed "
58
+ "to update its forward method: %s",
59
+ type(module),
60
+ fuser_cls.__name__,
61
+ exc,
62
)
55
- return None
63
+ continue
64
return fuser
65
# A norm we could not match structurally is left unfused; flag likely misses.
66
if module.__class__.__name__.endswith("RMSNorm"):
@@ -67,12 +75,12 @@ def get_fuser(module: nn.Module) -> BaseFuser | None:
75
class Fusers(UserDict):
76
"""Mapping from module class to fuser, for all fusable classes in a model."""
77
70
- def __init__(self, model: nn.Module, model_config: "ModelConfig"):
71
- self.model_config = model_config
78
+ def __init__(self, model: nn.Module, vllm_config: "VllmConfig"):
79
+ self.vllm_config = vllm_config
80
super().__init__({type(m): get_fuser(m) for m in model.modules()})
81
82
def __getitem__(self, m: nn.Module) -> BaseFuser | None:
83
fuser = self.data.get(type(m))
76
- if fuser is not None and fuser.validate(m, self.model_config):
84
+ if fuser is not None and fuser.validate(m, self.vllm_config):
85
return fuser
86
return None
vllm/model_executor/models/transformers/fusers/base.py
+6
-19
@@ -13,8 +13,7 @@ from torch import fx, nn
13
from vllm.model_executor.models.utils import ShardId, maybe_prefix
14
15
if TYPE_CHECKING:
16
- from vllm.config.model import ModelConfig
17
- from vllm.model_executor.layers.quantization import QuantizationConfig
16
+ from vllm.config import VllmConfig
17
18
19
@dataclass
@@ -36,16 +35,12 @@ class BaseFuser(ABC):
35
"""Match the pattern in `graph`, returning a fuser if found."""
36
37
@abstractmethod
39
- def validate(self, module: nn.Module, model_config: "ModelConfig") -> bool:
38
+ def validate(self, module: nn.Module, vllm_config: "VllmConfig") -> bool:
39
"""Whether this fuser can be applied to this `module` instance."""
40
41
@abstractmethod
42
def fuse(
44
- self,
45
- module: nn.Module,
46
- prefix: str,
47
- model_config: "ModelConfig",
48
- quant_config: "QuantizationConfig",
43
+ self, module: nn.Module, prefix: str, vllm_config: "VllmConfig"
44
) -> nn.Module:
45
"""Apply the fusion to an already-validated `module`, returning the
46
module to install in its place (mutated in place, or freshly built)."""
@@ -123,24 +118,16 @@ class StackedFuser(BaseFuser):
118
119
@abstractmethod
120
def update_attrs(
126
- self,
127
- module: nn.Module,
128
- prefix: str,
129
- model_config: "ModelConfig",
130
- quant_config: "QuantizationConfig",
121
+ self, module: nn.Module, prefix: str, vllm_config: "VllmConfig"
122
) -> None:
123
"""Replace `module`'s submodules with the merged module."""
124
125
def fuse(
135
- self,
136
- module: nn.Module,
137
- prefix: str,
138
- model_config: "ModelConfig",
139
- quant_config: "QuantizationConfig",
126
+ self, module: nn.Module, prefix: str, vllm_config: "VllmConfig"
127
) -> nn.Module:
128
"""Fuse an already-validated `module` in place (see `Fusers.__getitem__`).
129
130
Builds the merged submodule and binds the compiled forward."""
144
- self.update_attrs(module, prefix, model_config, quant_config)
131
+ self.update_attrs(module, prefix, vllm_config)
132
module.forward = types.MethodType(self.fused_forward, module)
133
return module
vllm/model_executor/models/transformers/fusers/glu.py
+4
-8
@@ -33,8 +33,7 @@ from vllm.model_executor.models.transformers.utils import (
33
from vllm.model_executor.models.utils import ShardId, maybe_prefix
34
35
if TYPE_CHECKING:
36
- from vllm.config.model import ModelConfig
37
- from vllm.model_executor.layers.quantization import QuantizationConfig
36
+ from vllm.config import VllmConfig
37
38
logger = init_logger(__name__)
39
@@ -168,7 +167,7 @@ class GLUFuser(StackedFuser):
167
replace_expr(funcdef, muls[0], act_call)
168
self.fused_forward = compile_forward(funcdef, fn)
169
171
- def validate(self, module: nn.Module, model_config: "ModelConfig") -> bool:
170
+ def validate(self, module: nn.Module, vllm_config: "VllmConfig") -> bool:
171
act = module.get_submodule(self.act_name)
172
if self._get_act_and_mul_name(act) is None:
173
logger.debug("No AndMul equivalent for %s; skipping fusion", type(act))
@@ -176,12 +175,9 @@ class GLUFuser(StackedFuser):
175
return True
176
177
def update_attrs(
179
- self,
180
- module: nn.Module,
181
- prefix: str,
182
- model_config: "ModelConfig",
183
- quant_config: "QuantizationConfig",
178
+ self, module: nn.Module, prefix: str, vllm_config: "VllmConfig"
179
) -> None:
180
+ quant_config = vllm_config.quant_config
181
act_fn = self._get_act_and_mul(module.get_submodule(self.act_name))
182
gate = module.get_submodule(self.gate_name)
183
up = module.get_submodule(self.up_name)
vllm/model_executor/models/transformers/fusers/qkv.py
+17
-19
@@ -17,6 +17,7 @@ from vllm.model_executor.models.transformers.fx_utils import (
17
is_linear,
18
recover_forward,
19
replace_expr,
20
+ returned_linear,
21
single_self_call,
22
)
23
from vllm.model_executor.models.transformers.utils import (
@@ -26,8 +27,7 @@ from vllm.model_executor.models.transformers.utils import (
27
from vllm.model_executor.models.utils import ShardId, maybe_prefix
28
29
if TYPE_CHECKING:
29
- from vllm.config.model import ModelConfig
30
- from vllm.model_executor.layers.quantization import QuantizationConfig
30
+ from vllm.config import VllmConfig
31
32
logger = init_logger(__name__)
33
@@ -84,15 +84,16 @@ class QKVFuser(StackedFuser):
84
return None
85
q, k, v = qkv_nodes
86
names = dict(q_name=q.target, k_name=k.target, v_name=v.target)
87
- attn_width = module.get_submodule(q.target).out_features
88
- candidates = [
89
- name
90
- for name, child in module.named_children()
91
- if isinstance(child, nn.Linear)
92
- and name not in names.values()
93
- and child.in_features == attn_width
94
- ]
95
- names["o_name"] = candidates[0] if len(candidates) == 1 else None
87
+ # o_proj produces the module's output.
88
+ o_name = returned_linear(graph, module)
89
+ # o_proj must be compatible with the q/k/v projections.
90
+ if o_name in names.values() or (
91
+ o_name is not None
92
+ and module.get_submodule(o_name).in_features
93
+ != module.get_submodule(q.target).out_features
94
+ ):
95
+ o_name = None
96
+ names["o_name"] = o_name
97
return cls(source_cls=type(module).__name__, **names)
98
99
def update_forward(self, module: nn.Module) -> None:
@@ -149,12 +150,12 @@ class QKVFuser(StackedFuser):
150
replace_expr(funcdef, call, ast.Name(id=temp, ctx=ast.Load()))
151
self.fused_forward = compile_forward(funcdef, fn)
152
152
- def validate(self, module: nn.Module, model_config: "ModelConfig") -> bool:
153
+ def validate(self, module: nn.Module, vllm_config: "VllmConfig") -> bool:
154
"""Shapes must be compatible for a single merged, head-sharded GEMM."""
155
q = module.get_submodule(self.q_name)
156
k = module.get_submodule(self.k_name)
157
v = module.get_submodule(self.v_name)
157
- head_size = model_config.get_head_size()
158
+ head_size = vllm_config.model_config.get_head_size()
159
compatible = (
160
q.in_features == k.in_features == v.in_features
161
and len({proj.bias is None for proj in (q, k, v)}) == 1
@@ -167,13 +168,10 @@ class QKVFuser(StackedFuser):
168
return compatible
169
170
def update_attrs(
170
- self,
171
- module: nn.Module,
172
- prefix: str,
173
- model_config: "ModelConfig",
174
- quant_config: "QuantizationConfig",
171
+ self, module: nn.Module, prefix: str, vllm_config: "VllmConfig"
172
) -> None:
176
- head_size = model_config.get_head_size()
173
+ quant_config = vllm_config.quant_config
174
+ head_size = vllm_config.model_config.get_head_size()
175
q = module.get_submodule(self.q_name)
176
k = module.get_submodule(self.k_name)
177
merged = QKVParallelLinear(
vllm/model_executor/models/transformers/fusers/rms_norm.py
+7
-12
@@ -21,13 +21,13 @@ from vllm.model_executor.models.transformers.fx_utils import (
21
find_node,
22
forward_input_count,
23
is_op,
24
+ output_value,
25
peel,
26
trace,
27
)
28
29
if TYPE_CHECKING:
29
- from vllm.config.model import ModelConfig
30
- from vllm.model_executor.layers.quantization import QuantizationConfig
30
+ from vllm.config import VllmConfig
31
32
33
def _is_squared(node: object, x: fx.Node) -> bool:
@@ -71,10 +71,8 @@ def _is_one_plus(node: object) -> bool:
71
72
def _has_trailing_compute(graph: fx.Graph, node: fx.Node) -> bool:
73
"""Does the forward compute anything after `node` before returning?"""
74
- output = find_node(graph, lambda n: n.op == "output")
75
- if output is None or not output.args:
76
- return False
77
- return peel(output.args[0]) is not node
74
+ value = output_value(graph)
75
+ return value is not None and peel(value) is not node
76
77
78
class TPAwareNormMixin(nn.Module):
@@ -185,17 +183,14 @@ class RMSNormFuser(BaseFuser):
183
return eps
184
return None
185
188
- def validate(self, module: nn.Module, model_config: "ModelConfig") -> bool:
186
+ def validate(self, module: nn.Module, vllm_config: "VllmConfig") -> bool:
187
return True
188
189
def fuse(
192
- self,
193
- module: nn.Module,
194
- prefix: str,
195
- model_config: "ModelConfig",
196
- quant_config: "QuantizationConfig",
190
+ self, module: nn.Module, prefix: str, vllm_config: "VllmConfig"
191
) -> nn.Module:
192
"""Fuse the matched RMSNorm pattern into a vLLM fused RMSNorm CustomOp."""
193
+ model_config = vllm_config.model_config
194
weight = getattr(module, "weight", None)
195
hidden_size = (
196
weight.size(0) if weight is not None else model_config.get_hidden_size()
vllm/model_executor/models/transformers/fx_utils.py
+238
-46
@@ -9,10 +9,13 @@ stays live Python. `fusion.py` builds the concrete fusion patterns on top.
9
"""
10
11
import ast
12
+import contextlib
13
import inspect
14
import operator
15
import textwrap
16
from collections.abc import Callable
17
+from itertools import chain
18
+from unittest import mock
19
20
import torch
21
from torch import fx, nn
@@ -22,46 +25,64 @@ from vllm.logger import init_logger
25
26
logger = init_logger(__name__)
27
28
+_UNKNOWN = object()
29
+"""Sentinel meta value for proxies whose concrete value could not be inferred.
30
+Distinct from `None`, which is a valid concrete value (e.g. `attn_weights`)."""
31
26
-def _infer_len(node: fx.Node) -> int | None:
27
- """Concrete length of a proxy's value, inferred from its node chain.
32
+_MODULE_CALL = nn.Module.__call__
33
+"""The unpatched `nn.Module.__call__`. During tracing fx patches it to record
34
+`call_module` nodes; meta execution must call modules for real."""
35
+
36
+
37
+def is_leaf_call(node: object) -> bool:
38
+ """Is node a call recorded by `_as_leaf_call` (e.g. an attention interface)."""
39
+ return isinstance(node, fx.Node) and node.meta.get("leaf_call", False)
40
29
- Lets tracing pass through the shape unpacks and `*`-splats (e.g.
30
- `(*input_shape, -1, head_dim)`) that precede the patterns in HF attention.
31
- """
32
- # `x.shape` has the rank of `x`, when known
33
- if (
34
- node.op == "call_function"
35
- and node.target is getattr
36
- and node.args[1] == "shape"
37
- and (rank := _rank(node.args[0])) is not None
38
- ):
39
- return rank
40
- # Slices of known-length values
41
- if node.op == "call_function" and node.target is operator.getitem:
42
- src_len = _infer_len(node.args[0])
43
- index = node.args[1]
44
- if src_len is not None and isinstance(index, slice):
45
- return len(range(*index.indices(src_len)))
46
- return None
41
42
+def _reference_weight(module: nn.Module) -> torch.Tensor | None:
43
+ """A weight whose trailing dim is the module's hidden size.
44
49
-def _rank(node: fx.Node) -> int | None:
50
- """The tensor rank of `node`'s value, if known."""
51
- # vLLM always feeds the model [1, seq_len, hidden_size] hidden states
52
- if node.op == "placeholder" and node.target == "hidden_states":
53
- return 3
45
+ Linears and 2-D gate weights are `[out, hidden]`; norm weights are
46
+ `[hidden]`. Used to fabricate a placeholder input of matching size/dtype."""
47
+ for child in module.modules():
48
+ if isinstance(child, nn.Linear):
49
+ return child.weight
50
+ for param in module.parameters():
51
+ if param.ndim in (1, 2):
52
+ return param
53
return None
54
55
57
-class _SizedProxy(fx.Proxy):
58
- """Proxy whose `len` is inferred from the graph (see `_infer_len`)."""
56
+class _MetaProxy(fx.Proxy):
57
+ """Proxy carrying the meta-tensor value of the traced expression.
58
+
59
+ Shape questions (`len`, iteration, `.shape` unpacks) are answered by
60
+ executing each op on the meta values, so PyTorch's meta kernels are the
61
+ single source of shape inference — no per-op rules."""
62
+
63
+ meta: object = _UNKNOWN
64
65
def __len__(self) -> int:
61
- length = _infer_len(self.node)
62
- if length is None:
63
- return super().__len__()
64
- return length
66
+ if self.meta is not _UNKNOWN:
67
+ return len(self.meta)
68
+ return super().__len__() # type: ignore[misc]
69
+
70
+ def __getattr__(self, k: str) -> "_MetaAttribute":
71
+ return _MetaAttribute(self, k)
72
+
73
+
74
+class _MetaAttribute(_MetaProxy, fx.proxy.Attribute):
75
+ """Attribute proxy (e.g. `x.shape`) carrying its meta value.
76
+
77
+ `Proxy.__getattr__` constructs `Attribute` directly, bypassing
78
+ `Tracer.proxy`, so the meta value must be grafted on here too."""
79
+
80
+ def __init__(self, root: fx.Proxy, attr: str):
81
+ super().__init__(root, attr)
82
+ root_meta = getattr(root, "meta", _UNKNOWN)
83
+ if root_meta is not _UNKNOWN:
84
+ with contextlib.suppress(Exception):
85
+ self.meta = getattr(root_meta, attr)
86
87
88
class _AllLeafTracer(fx.Tracer):
@@ -69,31 +90,159 @@ class _AllLeafTracer(fx.Tracer):
90
91
Each child stays one `call_module` node, so matching sees the module's own
92
forward structure (activations aren't decomposed into e.g. `sigmoid * x`).
72
- `iter` traces through the leading shape unpacks (see `_infer_len`); anything
73
- else untraceable ends the trace early and the partial graph is matched.
93
+ Every traced op is also executed on meta tensors (see `_MetaProxy`) so
94
+ shape unpacks and `*`-splats trace through; anything else untraceable ends
95
+ the trace early and the partial graph is matched.
96
"""
97
98
+ varkw: str | None = None
99
+ """Name of the traced forward's `**kwargs` parameter, if any."""
100
+
101
def is_leaf_module(self, m: nn.Module, module_qualified_name: str) -> bool:
102
return True
103
104
def proxy(self, node: fx.Node) -> fx.Proxy:
80
- return _SizedProxy(node, self)
105
+ return _MetaProxy(node, self)
106
+
107
+ def create_proxy(self, kind, target, args, kwargs, *extra, **extra_kwargs):
108
+ proxy = super().create_proxy(kind, target, args, kwargs, *extra, **extra_kwargs)
109
+ if isinstance(proxy, _MetaProxy) and proxy.meta is _UNKNOWN:
110
+ # A failure stays _UNKNOWN; only fatal if a shape question is asked.
111
+ with contextlib.suppress(Exception):
112
+ proxy.meta = self._infer_meta(kind, target, args, kwargs)
113
+ return proxy
114
+
115
+ def _infer_meta(self, kind: str, target: object, args: tuple, kwargs: dict):
116
+ """Execute the op on meta tensors; PyTorch infers the output value."""
117
+ if kind == "placeholder":
118
+ # vLLM always feeds the model [1, seq_len, hidden_size] hidden states.
119
+ weight = _reference_weight(self.root)
120
+ if str(target) == "hidden_states" and weight is not None:
121
+ return torch.empty(
122
+ 1, 8, weight.shape[-1], dtype=weight.dtype, device="meta"
123
+ )
124
+ return _UNKNOWN
125
+ if kind == "get_attr":
126
+ value = operator.attrgetter(str(target))(self.root)
127
+ if isinstance(value, torch.Tensor):
128
+ value = torch.empty_like(value, device="meta")
129
+ return value
130
+ unknown = False
131
+
132
+ def meta_of(arg: object) -> object:
133
+ nonlocal unknown
134
+ if isinstance(arg, fx.Proxy):
135
+ meta = getattr(arg, "meta", _UNKNOWN)
136
+ unknown = unknown or meta is _UNKNOWN
137
+ return meta
138
+ return arg
139
+
140
+ meta_args = fx.node.map_aggregate(args, meta_of)
141
+ meta_kwargs = fx.node.map_aggregate(kwargs, meta_of)
142
+ if unknown:
143
+ return _UNKNOWN
144
+ if kind == "call_function":
145
+ return target(*meta_args, **meta_kwargs)
146
+ if kind == "call_method":
147
+ receiver, *rest = meta_args
148
+ return getattr(receiver, str(target))(*rest, **meta_kwargs)
149
+ if kind == "call_module":
150
+ # Run the child's forward with all its state on "meta", without
151
+ # mutating it (at match time params may be meta but buffers real).
152
+ # fx patches `nn.Module.__call__` while tracing; restore the real
153
+ # one so this execution is not itself recorded.
154
+ child = self.root.get_submodule(str(target))
155
+ state = {
156
+ name: torch.empty_like(tensor, device="meta")
157
+ for name, tensor in chain(
158
+ child.named_parameters(), child.named_buffers()
159
+ )
160
+ }
161
+ with mock.patch.object(nn.Module, "__call__", _MODULE_CALL):
162
+ return torch.func.functional_call(child, state, meta_args, meta_kwargs)
163
+ return _UNKNOWN
164
+
165
+ def _is_varkw(self, node: object) -> bool:
166
+ return (
167
+ isinstance(node, fx.Node)
168
+ and node.op == "placeholder"
169
+ and str(node.target).lstrip("*") == self.varkw
170
+ )
171
172
def iter(self, obj: fx.Proxy):
83
- length = _infer_len(obj.node)
84
- if length is None:
173
+ # Assume kwargs is always empty to simplify tracing.
174
+ node = obj.node
175
+ if self._is_varkw(node) or (
176
+ node.op == "call_method"
177
+ and node.target == "keys"
178
+ and self._is_varkw(node.args[0])
179
+ ):
180
+ return iter(())
181
+ meta = getattr(obj, "meta", _UNKNOWN)
182
+ if meta is _UNKNOWN:
183
return super().iter(obj)
86
- return iter([obj[i] for i in range(length)])
184
+ return iter([obj[i] for i in range(len(meta))])
185
186
89
-def trace(module: nn.Module) -> fx.Graph | None:
90
- """Trace `module.forward`, returning the partial graph on failure.
187
+def _as_leaf_call(fn: Callable, length: int | None = None) -> Callable:
188
+ """Wrap any callable so tracing records it as one opaque `call_function` node.
189
+
190
+ Lets the trace continue past untraceable bodies. Only the proxy arguments carry into
191
+ the node's dataflow; the rest are dropped rather than lifted into the graph.
192
+ `length` declares how many values the callable returns, so unpacking its result also
193
+ traces. Called without proxies (i.e. outside tracing), the wrapper is a passthrough.
194
+ """
195
+
196
+ def leaf(*args, **kwargs):
197
+ proxies = tuple(arg for arg in args if isinstance(arg, fx.Proxy))
198
+ if not proxies:
199
+ return fn(*args, **kwargs)
200
+ proxy = proxies[0].tracer.create_proxy("call_function", fn, proxies, {})
201
+ proxy.node.meta["leaf_call"] = True
202
+ if length is not None:
203
+ # The body never executes, so fabricate a value of the declared length.
204
+ proxy.meta = (_UNKNOWN,) * length
205
+ return proxy
206
+
207
+ return leaf
208
+
209
+
210
+def _leaf_attention_interfaces():
211
+ """Patch `AttentionInterface.get_interface` so traced forwards see a leaf node.
212
92
- The graph is only evidence for matching, and the patterns sit at the top of
93
- their forwards, so a trace that fails partway can still be matched."""
213
+ `vllm_attention_function` needs runtime context so it is untraceable.
214
+ Every interface returns `(attn_output, attn_weights)`."""
215
+ from transformers.modeling_utils import AttentionInterface
216
+
217
+ original = AttentionInterface.get_interface
218
+
219
+ def get_interface(self, *args, **kwargs):
220
+ return _as_leaf_call(original(self, *args, **kwargs), length=2)
221
+
222
+ return mock.patch.object(AttentionInterface, "get_interface", get_interface)
223
+
224
+
225
+def trace(module: nn.Module) -> fx.Graph | None:
226
+ """Trace `module.forward`, returning the partial graph on failure."""
227
+ parameters = forward_parameters(type(module))
228
+ # vLLM never passes `past_key_values` so it is always the default value of `None`.
229
+ # Make this concrete to simplify tracing.
230
+ concrete_args = None
231
+ if "past_key_values" in parameters:
232
+ concrete_args = {"past_key_values": None}
233
+ # Get the name of the kwargs parameter passed to module.forward (usually "kwargs")
234
tracer = _AllLeafTracer()
235
+ tracer.varkw = next(
236
+ (
237
+ p.name
238
+ for p in parameters.values()
239
+ if p.kind is inspect.Parameter.VAR_KEYWORD
240
+ ),
241
+ None,
242
+ )
243
try:
96
- return tracer.trace(module)
244
+ with _leaf_attention_interfaces():
245
+ return tracer.trace(module, concrete_args=concrete_args)
246
except Exception as exc:
247
logger.debug("Could not fully trace %s: %s", type(module), exc)
248
return getattr(tracer, "graph", None)
@@ -129,20 +278,27 @@ def recover_forward(cls: type[nn.Module]) -> tuple[ast.FunctionDef, Callable]:
278
return funcdef, fn
279
280
281
+def forward_parameters(cls: type[nn.Module]) -> dict[str, inspect.Parameter]:
282
+ """`cls.forward`'s signature parameters, or empty if uninspectable."""
283
+ try:
284
+ return dict(inspect.signature(cls.forward).parameters)
285
+ except (TypeError, ValueError):
286
+ return {}
287
+
288
+
289
def forward_input_count(cls: type[nn.Module]) -> int:
290
"""The number of tensor inputs `cls.forward` declares, excluding `self` and
291
any `*args`/`**kwargs`. Read from the signature, so it is independent of
292
whether the trace completes (unlike counting placeholders)."""
136
- try:
137
- params = list(inspect.signature(cls.forward).parameters.values())[1:]
138
- except (ValueError, TypeError):
293
+ params = list(forward_parameters(cls).values())
294
+ if not params:
295
return 1 # uninspectable: assume a single input and let matching decide
296
fixed = (
297
inspect.Parameter.POSITIONAL_ONLY,
298
inspect.Parameter.POSITIONAL_OR_KEYWORD,
299
inspect.Parameter.KEYWORD_ONLY,
300
)
145
- return sum(1 for p in params if p.kind in fixed)
301
+ return sum(1 for p in params[1:] if p.kind in fixed)
302
303
304
def compile_forward(funcdef: ast.FunctionDef, fn: Callable) -> Callable:
@@ -227,6 +383,42 @@ def find_node(graph: fx.Graph, predicate: Callable[[fx.Node], bool]) -> fx.Node
383
return next((n for n in graph.nodes if predicate(n)), None)
384
385
386
+def output_value(graph: fx.Graph) -> object | None:
387
+ """The value the graph's `output` node returns, if the trace reached one."""
388
+ output = find_node(graph, lambda n: n.op == "output")
389
+ if output is None or not output.args:
390
+ return None
391
+ return output.args[0]
392
+
393
+
394
+def upstream_linear(node: object, module: nn.Module) -> fx.Node | None:
395
+ """Nearest linear producing `node`, walking back through splits/reshapes.
396
+
397
+ Never walks through a leaf call (e.g. an attention interface): its inputs
398
+ are what attention consumes, not what produced the value."""
399
+ stack = [node]
400
+ seen: set[fx.Node] = set()
401
+ while stack:
402
+ current = stack.pop()
403
+ if not isinstance(current, fx.Node) or current in seen:
404
+ continue
405
+ seen.add(current)
406
+ if is_linear(current, module):
407
+ return current
408
+ if current.op in ("call_function", "call_method") and not is_leaf_call(current):
409
+ stack.extend(current.args)
410
+ return None
411
+
412
+
413
+def returned_linear(graph: fx.Graph, module: nn.Module) -> str | None:
414
+ """Name of the Linear producing the graph's (first) output value."""
415
+ value = output_value(graph)
416
+ if isinstance(value, (tuple, list)) and value:
417
+ value = value[0]
418
+ linear = upstream_linear(value, module)
419
+ return None if linear is None else str(linear.target)
420
+
421
+
422
def is_linear(node: fx.Node, module: nn.Module) -> bool:
423
"""Is node `nn.Linear.__call__()`."""
424
return node.op == "call_module" and isinstance(