[Transformers backend] Enable QKV-Fuser for Gemma4 (#55690)

Signed-off-by: Thomas Ortner <boh@zurich.ibm.com> Signed-off-by: Thomas Ortner <bohnstinglthomas@gmail.com> Signed-off-by: Harry Mellor <19981378+hmellor@users.noreply.github.com> Co-authored-by: Harry Mellor <19981378+hmellor@users.noreply.github.com>

Thomas Ortner committed Sep 9, 2026 at 15:46 UTC e509d32b5a2f3d81dc949ff89f85c4c8f860ae29
8 files changed +1027 -82
tests/models/transformers/fusers/test_linear.py
+578 -15
@@ -2,6 +2,7 @@
2 # SPDX-FileCopyrightText: Copyright contributors to the vLLM project
3 """Unit tests for the Transformers modeling backend's linear fusers."""
4
5 +import ast
6 import inspect
7 from types import MethodType, SimpleNamespace
8
@@ -19,7 +20,10 @@ from vllm.model_executor.models.transformers.fusers import (
20 packed_qkv,
21 qkv,
22 )
22 -from vllm.model_executor.models.transformers.fx_utils import trace
23 +from vllm.model_executor.models.transformers.fx_utils import (
24 + bypass_existence_guard,
25 + trace,
26 +)
27
28
29 class SiluAndMulStub(nn.Module):
@@ -202,6 +206,14 @@ class FourParallelLinears(nn.Module):
206 return self.proj_a(x), self.proj_b(x), self.proj_c(x), self.proj_d(x)
207
208
209 +class GuardedParallelLinears(FourParallelLinears):
210 + """A projection referenced by an existence guard as well as its call."""
211 +
212 + def forward(self, x):
213 + d = self.proj_d(x) if self.proj_d is not None else None
214 + return self.proj_a(x), self.proj_b(x), self.proj_c(x), d
215 +
216 +
217 class MutatingParallelLinears(FourParallelLinears):
218 def forward(self, x):
219 a = self.proj_a(x)
@@ -209,6 +221,34 @@ class MutatingParallelLinears(FourParallelLinears):
221 return a, self.proj_b(x), self.proj_c(x), self.proj_d(x)
222
223
224 +class AliasMutatingParallelLinears(FourParallelLinears):
225 + """A view of the input is mutated between the projections."""
226 +
227 + def forward(self, x):
228 + a = self.proj_a(x)
229 + alias = x.view(-1)
230 + alias.mul_(2)
231 + return a, self.proj_b(x), self.proj_c(x), self.proj_d(x)
232 +
233 +
234 +class FunctionalInplaceParallelLinears(FourParallelLinears):
235 + """The input is mutated by a functional in-place call between projections."""
236 +
237 + def forward(self, x):
238 + a = self.proj_a(x)
239 + F.relu(x, inplace=True)
240 + return a, self.proj_b(x), self.proj_c(x), self.proj_d(x)
241 +
242 +
243 +class UnderscoreInplaceParallelLinears(FourParallelLinears):
244 + """The input is mutated by a free in-place function between projections."""
245 +
246 + def forward(self, x):
247 + a = self.proj_a(x)
248 + torch.relu_(x)
249 + return a, self.proj_b(x), self.proj_c(x), self.proj_d(x)
250 +
251 +
252 class PlainQKV(nn.Module):
253 """q/k/v returned as a bare tuple: no reshape stands between the calls and
254 the return, so both QKVFuser and MergedColumnParallelFuser can rewrite it."""
@@ -294,6 +334,306 @@ class ResidDropoutAttention(FakeAttention):
334 return self.resid_dropout(self.o_proj(attn_output)), None
335
336
337 +class GuardedVAttention(FakeAttention):
338 + """Gemma 4-style: `v_proj` is called once but also read in a `None` guard.
339 +
340 + The guard is a fusion invariant (the fuser only binds instances where the
341 + projection exists), so it folds to `True` and the call still fuses."""
342 +
343 + def forward(
344 + self, hidden_states, attention_mask=None, past_key_values=None, **kwargs
345 + ):
346 + from transformers.modeling_utils import ALL_ATTENTION_FUNCTIONS
347 +
348 + input_shape = hidden_states.shape[:-1]
349 + hidden_shape = (*input_shape, -1, self.head_dim)
350 + q = self.q_proj(hidden_states).view(hidden_shape).transpose(1, 2)
351 + k = self.k_proj(hidden_states).view(hidden_shape).transpose(1, 2)
352 + v = (
353 + self.v_proj(hidden_states).view(hidden_shape).transpose(1, 2)
354 + if self.v_proj is not None
355 + else k
356 + )
357 + attention_interface = ALL_ATTENTION_FUNCTIONS.get_interface(
358 + self.config._attn_implementation, None
359 + )
360 + attn_output, _ = attention_interface(
361 + self, q, k, v, attention_mask, scaling=self.scaling, **kwargs
362 + )
363 + return self.o_proj(attn_output.reshape(*input_shape, -1).contiguous()), None
364 +
365 +
366 +class TruthyGuardAttention(FakeAttention):
367 + """`v_proj` guarded by a bare truthiness test instead of `is not None`.
368 +
369 + An `nn.Linear` is always truthy, so the guard folds to `True` and the call
370 + still fuses (the projection is a proven invariant, exactly as for a `None`
371 + guard)."""
372 +
373 + def forward(
374 + self, hidden_states, attention_mask=None, past_key_values=None, **kwargs
375 + ):
376 + from transformers.modeling_utils import ALL_ATTENTION_FUNCTIONS
377 +
378 + input_shape = hidden_states.shape[:-1]
379 + hidden_shape = (*input_shape, -1, self.head_dim)
380 + q = self.q_proj(hidden_states).view(hidden_shape).transpose(1, 2)
381 + k = self.k_proj(hidden_states).view(hidden_shape).transpose(1, 2)
382 + v = (
383 + self.v_proj(hidden_states).view(hidden_shape).transpose(1, 2)
384 + if self.v_proj
385 + else k
386 + )
387 + attention_interface = ALL_ATTENTION_FUNCTIONS.get_interface(
388 + self.config._attn_implementation, None
389 + )
390 + attn_output, _ = attention_interface(
391 + self, q, k, v, attention_mask, scaling=self.scaling, **kwargs
392 + )
393 + return self.o_proj(attn_output.reshape(*input_shape, -1).contiguous()), None
394 +
395 +
396 +class StatementGuardVAttention(FakeAttention):
397 + """`v_proj` guarded by a statement-form `if self.v_proj:` block.
398 +
399 + The `if`'s test is not an `ast.Compare`, so the identity-check scan skips it;
400 + `_in_boolean_context` folds the test to `True` (an `nn.Linear` is always
401 + truthy) and the call inside the now-`if True:` body still fuses, with the
402 + live `else` surviving the rewrite."""
403 +
404 + def forward(
405 + self, hidden_states, attention_mask=None, past_key_values=None, **kwargs
406 + ):
407 + from transformers.modeling_utils import ALL_ATTENTION_FUNCTIONS
408 +
409 + input_shape = hidden_states.shape[:-1]
410 + hidden_shape = (*input_shape, -1, self.head_dim)
411 + q = self.q_proj(hidden_states).view(hidden_shape).transpose(1, 2)
412 + k = self.k_proj(hidden_states).view(hidden_shape).transpose(1, 2)
413 + if self.v_proj:
414 + v = self.v_proj(hidden_states).view(hidden_shape).transpose(1, 2)
415 + else:
416 + v = k
417 + attention_interface = ALL_ATTENTION_FUNCTIONS.get_interface(
418 + self.config._attn_implementation, None
419 + )
420 + attn_output, _ = attention_interface(
421 + self, q, k, v, attention_mask, scaling=self.scaling, **kwargs
422 + )
423 + return self.o_proj(attn_output.reshape(*input_shape, -1).contiguous()), None
424 +
425 +
426 +class BranchedKVAttention(FakeAttention):
427 + """Gemma 4-style: q at body level, k/v inside an untaken `else` branch.
428 +
429 + The three calls do not share a block, so the fused GEMM must be hoisted to
430 + the innermost block that dominates all of them (the function body). The
431 + live `if`/`else` must survive the rewrite."""
432 +
433 + is_kv_shared_layer = False
434 +
435 + def forward(
436 + self, hidden_states, attention_mask=None, past_key_values=None, **kwargs
437 + ):
438 + from transformers.modeling_utils import ALL_ATTENTION_FUNCTIONS
439 +
440 + input_shape = hidden_states.shape[:-1]
441 + hidden_shape = (*input_shape, -1, self.head_dim)
442 + q = self.q_proj(hidden_states).view(hidden_shape).transpose(1, 2)
443 + if self.is_kv_shared_layer:
444 + k = v = None
445 + else:
446 + k = self.k_proj(hidden_states).view(hidden_shape).transpose(1, 2)
447 + v = self.v_proj(hidden_states).view(hidden_shape).transpose(1, 2)
448 + attention_interface = ALL_ATTENTION_FUNCTIONS.get_interface(
449 + self.config._attn_implementation, None
450 + )
451 + attn_output, _ = attention_interface(
452 + self, q, k, v, attention_mask, scaling=self.scaling, **kwargs
453 + )
454 + return self.o_proj(attn_output.reshape(*input_shape, -1).contiguous()), None
455 +
456 +
457 +class RebindArgAttention(FakeAttention):
458 + """The projection input is rebound (in an untaken branch) before k/v.
459 +
460 + The branch is dead at trace time so the match succeeds, but the source
461 + rewrite must refuse: hoisting the GEMM above the rebind would feed k/v a
462 + different input. Result: no fusion."""
463 +
464 + recompute = False
465 +
466 + def forward(
467 + self, hidden_states, attention_mask=None, past_key_values=None, **kwargs
468 + ):
469 + from transformers.modeling_utils import ALL_ATTENTION_FUNCTIONS
470 +
471 + input_shape = hidden_states.shape[:-1]
472 + hidden_shape = (*input_shape, -1, self.head_dim)
473 + q = self.q_proj(hidden_states).view(hidden_shape).transpose(1, 2)
474 + if self.recompute:
475 + hidden_states = hidden_states * 2
476 + k = self.k_proj(hidden_states).view(hidden_shape).transpose(1, 2)
477 + v = self.v_proj(hidden_states).view(hidden_shape).transpose(1, 2)
478 + attention_interface = ALL_ATTENTION_FUNCTIONS.get_interface(
479 + self.config._attn_implementation, None
480 + )
481 + attn_output, _ = attention_interface(
482 + self, q, k, v, attention_mask, scaling=self.scaling, **kwargs
483 + )
484 + return self.o_proj(attn_output.reshape(*input_shape, -1)), None
485 +
486 +
487 +class InPlaceMutatedArgAttention(FakeAttention):
488 + """The shared input is mutated in place (in an untaken branch) before k/v.
489 +
490 + Like `RebindArgAttention`, but the mutation keeps the name in a `Load`
491 + context (`hidden_states.mul_(2)`), so a bare `Name`-store check would miss it.
492 + The rewrite must still refuse: hoisting the GEMM above the mutation changes
493 + the value k/v see. Result: no fusion."""
494 +
495 + recompute = False
496 +
497 + def forward(
498 + self, hidden_states, attention_mask=None, past_key_values=None, **kwargs
499 + ):
500 + from transformers.modeling_utils import ALL_ATTENTION_FUNCTIONS
501 +
502 + input_shape = hidden_states.shape[:-1]
503 + hidden_shape = (*input_shape, -1, self.head_dim)
504 + q = self.q_proj(hidden_states).view(hidden_shape).transpose(1, 2)
505 + if self.recompute:
506 + hidden_states.mul_(2)
507 + k = self.k_proj(hidden_states).view(hidden_shape).transpose(1, 2)
508 + v = self.v_proj(hidden_states).view(hidden_shape).transpose(1, 2)
509 + attention_interface = ALL_ATTENTION_FUNCTIONS.get_interface(
510 + self.config._attn_implementation, None
511 + )
512 + attn_output, _ = attention_interface(
513 + self, q, k, v, attention_mask, scaling=self.scaling, **kwargs
514 + )
515 + return self.o_proj(attn_output.reshape(*input_shape, -1)), None
516 +
517 +
518 +class AliasMutatedArgAttention(FakeAttention):
519 + """A view of the shared input is mutated before k/v.
520 +
521 + `hidden_states.view(-1)` shares storage with its base, so `alias.mul_(2)`
522 + changes what k/v read without ever naming `hidden_states` as a target. Only
523 + tracking names that may alias the input catches it. Result: no fusion."""
524 +
525 + recompute = False
526 +
527 + def forward(
528 + self, hidden_states, attention_mask=None, past_key_values=None, **kwargs
529 + ):
530 + from transformers.modeling_utils import ALL_ATTENTION_FUNCTIONS
531 +
532 + input_shape = hidden_states.shape[:-1]
533 + hidden_shape = (*input_shape, -1, self.head_dim)
534 + q = self.q_proj(hidden_states).view(hidden_shape).transpose(1, 2)
535 + if self.recompute:
536 + alias = hidden_states.view(-1)
537 + alias.mul_(2)
538 + k = self.k_proj(hidden_states).view(hidden_shape).transpose(1, 2)
539 + v = self.v_proj(hidden_states).view(hidden_shape).transpose(1, 2)
540 + attention_interface = ALL_ATTENTION_FUNCTIONS.get_interface(
541 + self.config._attn_implementation, None
542 + )
543 + attn_output, _ = attention_interface(
544 + self, q, k, v, attention_mask, scaling=self.scaling, **kwargs
545 + )
546 + return self.o_proj(attn_output.reshape(*input_shape, -1)), None
547 +
548 +
549 +class FunctionalInplaceArgAttention(FakeAttention):
550 + """The shared input is mutated by a functional in-place call before k/v.
551 +
552 + `F.relu(hidden_states, inplace=True)` writes through an argument, so the
553 + name appears only in a `Load` context inside a call that is not a method on
554 + it -- invisible to a trailing-underscore method check. Result: no fusion."""
555 +
556 + recompute = False
557 +
558 + def forward(
559 + self, hidden_states, attention_mask=None, past_key_values=None, **kwargs
560 + ):
561 + from transformers.modeling_utils import ALL_ATTENTION_FUNCTIONS
562 +
563 + input_shape = hidden_states.shape[:-1]
564 + hidden_shape = (*input_shape, -1, self.head_dim)
565 + q = self.q_proj(hidden_states).view(hidden_shape).transpose(1, 2)
566 + if self.recompute:
567 + F.relu(hidden_states, inplace=True)
568 + k = self.k_proj(hidden_states).view(hidden_shape).transpose(1, 2)
569 + v = self.v_proj(hidden_states).view(hidden_shape).transpose(1, 2)
570 + attention_interface = ALL_ATTENTION_FUNCTIONS.get_interface(
571 + self.config._attn_implementation, None
572 + )
573 + attn_output, _ = attention_interface(
574 + self, q, k, v, attention_mask, scaling=self.scaling, **kwargs
575 + )
576 + return self.o_proj(attn_output.reshape(*input_shape, -1)), None
577 +
578 +
579 +class SubscriptMutatedArgAttention(FakeAttention):
580 + """The shared input is written through a subscript (in an untaken branch).
581 +
582 + `hidden_states[..., 0] = 0` leaves the name in a `Load` context on the
583 + subscript's value, so it too evades a bare `Name`-store check. The rewrite
584 + must refuse for the same reason. Result: no fusion."""
585 +
586 + recompute = False
587 +
588 + def forward(
589 + self, hidden_states, attention_mask=None, past_key_values=None, **kwargs
590 + ):
591 + from transformers.modeling_utils import ALL_ATTENTION_FUNCTIONS
592 +
593 + input_shape = hidden_states.shape[:-1]
594 + hidden_shape = (*input_shape, -1, self.head_dim)
595 + q = self.q_proj(hidden_states).view(hidden_shape).transpose(1, 2)
596 + if self.recompute:
597 + hidden_states[..., 0] = 0
598 + k = self.k_proj(hidden_states).view(hidden_shape).transpose(1, 2)
599 + v = self.v_proj(hidden_states).view(hidden_shape).transpose(1, 2)
600 + attention_interface = ALL_ATTENTION_FUNCTIONS.get_interface(
601 + self.config._attn_implementation, None
602 + )
603 + attn_output, _ = attention_interface(
604 + self, q, k, v, attention_mask, scaling=self.scaling, **kwargs
605 + )
606 + return self.o_proj(attn_output.reshape(*input_shape, -1)), None
607 +
608 +
609 +class NonFoldableRefAttention(FakeAttention):
610 + """A surviving `v_proj` reference that is not an existence guard -> no fusion.
611 +
612 + Deleting `v_proj` would break `isinstance(self.v_proj, nn.Linear)`: it reads
613 + the projection's type, not just whether it exists, so the fuser cannot fold
614 + it and must refuse rather than rewrite it."""
615 +
616 + def forward(
617 + self, hidden_states, attention_mask=None, past_key_values=None, **kwargs
618 + ):
619 + from transformers.modeling_utils import ALL_ATTENTION_FUNCTIONS
620 +
621 + input_shape = hidden_states.shape[:-1]
622 + hidden_shape = (*input_shape, -1, self.head_dim)
623 + q = self.q_proj(hidden_states).view(hidden_shape).transpose(1, 2)
624 + k = self.k_proj(hidden_states).view(hidden_shape).transpose(1, 2)
625 + v = self.v_proj(hidden_states).view(hidden_shape).transpose(1, 2)
626 + if isinstance(self.v_proj, nn.Linear):
627 + v = v.contiguous()
628 + attention_interface = ALL_ATTENTION_FUNCTIONS.get_interface(
629 + self.config._attn_implementation, None
630 + )
631 + attn_output, _ = attention_interface(
632 + self, q, k, v, attention_mask, scaling=self.scaling, **kwargs
633 + )
634 + return self.o_proj(attn_output.reshape(*input_shape, -1)), None
635 +
636 +
637 class PackedQKVAttention(nn.Module):
638 """GPTBigCode-style: one packed projection split into q/k/v in the forward."""
639
@@ -561,18 +901,246 @@ def test_qkv_identifies_output_projection():
901 assert get_fuser(ResidDropoutAttention(), QKVFuser).o_name == "o_proj"
902
903
564 -def test_merged_column_fuser_rejects_input_mutation():
565 - """The later projections must not be moved ahead of an input mutation."""
904 +@pytest.mark.parametrize(
905 + "attn_cls",
906 + [
907 + GuardedVAttention,
908 + TruthyGuardAttention,
909 + StatementGuardVAttention,
910 + BranchedKVAttention,
911 + ],
912 +)
913 +def test_fuses_gemma4_qkv_obstacles(attn_cls):
914 + """A guarded `v_proj` (obstacle 1) or branch-split k/v (obstacle 2) fuses.
915 +
916 + Both patterns leave `v_proj` referenced beyond its single call, or split the
917 + calls across blocks; the rewrite folds the guard / hoists the GEMM and the
918 + numerics still match. The guard may be a `None` comparison or a bare
919 + truthiness test (ternary or statement form); all are proven invariants, so
920 + folding them is sound. The naive "relax the reference count" fix fuses the
921 + guarded cases but silently drops the guard's semantics.
922 + """
923 + with torch.device("meta"):
924 + meta = attn_cls()
925 + fuser = get_fuser(meta, QKVFuser)
926 + assert isinstance(fuser, QKVFuser)
927 + assert (fuser.q_name, fuser.o_name) == ("q_proj", "o_proj")
928 +
929 + names = set(fuser.fused_forward.__code__.co_names)
930 + assert "qkv_proj" in names
931 + assert not {"q_proj", "k_proj", "v_proj"} & names
932 + if attn_cls is BranchedKVAttention:
933 + assert "is_kv_shared_layer" in names # the live branch survives
934 +
935 + real = attn_cls(kv_heads=4, layer_idx=3)
936 + for p in real.parameters():
937 + nn.init.normal_(p, std=0.05)
938 + x = torch.randn(1, 5, 32)
939 + real.attn = FakeSelfAttn()
940 + expected, _ = real(x)
941 + fused = _apply_qkv_fuser_with_stubs(real, fuser)
942 + out, _ = fused(x)
943 + torch.testing.assert_close(out, expected, atol=1e-5, rtol=1e-5)
944 +
945 +
946 +@pytest.mark.parametrize(
947 + "attn_cls",
948 + [
949 + RebindArgAttention,
950 + InPlaceMutatedArgAttention,
951 + SubscriptMutatedArgAttention,
952 + AliasMutatedArgAttention,
953 + FunctionalInplaceArgAttention,
954 + NonFoldableRefAttention,
955 + ],
956 +)
957 +def test_qkv_refuses_unsound_rewrites(attn_cls):
958 + """The match succeeds but the source rewrite must refuse, leaving no fuser.
959 +
960 + `RebindArgAttention` rebinds the shared input before k/v; the `*Mutated*` and
961 + `FunctionalInplace*` cases mutate it in place, directly (`x.mul_(...)`,
962 + `x[...] = 0`), through a view that shares its storage, or through a call that
963 + writes an argument (`F.relu(x, inplace=True)`) -- each would change what k/v
964 + see if the GEMM were hoisted above them.
965 + `NonFoldableRefAttention` reads `v_proj` outside an existence guard (the fuser
966 + cannot fold it away). All fail closed in `update_forward`, so `get_fuser`
967 + returns `None`.
968 + """
969 with torch.device("meta"):
567 - module = MutatingParallelLinears()
970 + meta = attn_cls()
971 + assert get_fuser(meta, QKVFuser) is None
972 +
973 +
974 +def _guard_funcdef(expr: str) -> tuple[ast.FunctionDef, ast.Attribute]:
975 + """A `def f(self, a, b, T): return <expr>` and its `self.v_proj` reference."""
976 + funcdef = ast.parse(f"def f(self, a, b, T):\n return {expr}").body[0]
977 + assert isinstance(funcdef, ast.FunctionDef)
978 + ref = next(
979 + node
980 + for node in ast.walk(funcdef)
981 + if isinstance(node, ast.Attribute) and node.attr == "v_proj"
982 + )
983 + return funcdef, ref
984 +
985 +
986 +@pytest.mark.parametrize(
987 + "expr, expected",
988 + [
989 + ("a if self.v_proj is not None else b", True),
990 + ("a if self.v_proj is None else b", False),
991 + ("a if None is not self.v_proj else b", True), # None on the left
992 + ],
993 +)
994 +def test_bypass_existence_guard_folds_identity_none_check(expr, expected):
995 + """`is`/`is not None`, either operand order, fold to a bool.
996 +
997 + The projection is a proven invariant (it exists as an `nn.Linear`), so an
998 + `is (not) None` guard has a constant truth value; folding it lets the call be
999 + rewritten away without changing the guard's outcome. `==`/`!=` are excluded
1000 + (see the refusal test) because a subclass may override `__eq__`/`__ne__`."""
1001 + funcdef, ref = _guard_funcdef(expr)
1002 + bypass_existence_guard(funcdef, ref, "v_proj")
1003 + test = next(node for node in ast.walk(funcdef) if isinstance(node, ast.IfExp)).test
1004 + assert isinstance(test, ast.Constant) and test.value is expected
1005 +
1006 +
1007 +@pytest.mark.parametrize(
1008 + "expr",
1009 + [
1010 + "a if self.v_proj else b", # a ternary test
1011 + "not self.v_proj", # a `not` operand
1012 + ],
1013 +)
1014 +def test_bypass_existence_guard_folds_bare_truthiness(expr):
1015 + """A bare truthiness test folds the reference itself to `True`.
1016 +
1017 + An `nn.Linear` has no `__bool__`/`__len__`, so it is always truthy; in a pure
1018 + boolean position (a test, or a `not` operand) the reference can be replaced by
1019 + `True`, letting the projection be deleted."""
1020 + funcdef, ref = _guard_funcdef(expr)
1021 + bypass_existence_guard(funcdef, ref, "v_proj")
1022 + # No `self.v_proj` reference survives; a literal `True` took its place.
1023 + attrs = [n for n in ast.walk(funcdef) if isinstance(n, ast.Attribute)]
1024 + assert not any(n.attr == "v_proj" for n in attrs)
1025 + constants = [n for n in ast.walk(funcdef) if isinstance(n, ast.Constant)]
1026 + assert any(n.value is True for n in constants)
1027 +
1028 +
1029 +def test_bypass_existence_guard_folds_bare_if_statement():
1030 + """A statement-form `if self.<name>:` folds its test to `True`.
1031 +
1032 + The `len(node.ops) == 1` `Compare` scan skips it (it is not a comparison at
1033 + all); `_in_boolean_context` then matches the `if`'s test and folds it, so the
1034 + bare-truthiness statement form is handled, not silently dropped."""
1035 + funcdef = ast.parse(
1036 + "def f(self, a, b):\n if self.v_proj:\n return a\n return b"
1037 + ).body[0]
1038 + assert isinstance(funcdef, ast.FunctionDef)
1039 + ref = next(
1040 + node
1041 + for node in ast.walk(funcdef)
1042 + if isinstance(node, ast.Attribute) and node.attr == "v_proj"
1043 + )
1044 + bypass_existence_guard(funcdef, ref, "v_proj")
1045 + test = next(node for node in ast.walk(funcdef) if isinstance(node, ast.If)).test
1046 + assert isinstance(test, ast.Constant) and test.value is True
1047 +
1048 +
1049 +@pytest.mark.parametrize(
1050 + "expr",
1051 + [
1052 + "a if isinstance(self.v_proj, T) else b", # a different predicate
1053 + "self.v_proj.weight", # an attribute read, not a guard
1054 + "a and self.v_proj", # `and` yields the operand, which may escape
1055 + "self.v_proj or a", # `or` yields the module itself when truthy
1056 + "a if self.v_proj == None else b", # `__eq__` may not track identity
1057 + "a if self.v_proj != None else b", # `__ne__` may not track identity
1058 + ],
1059 +)
1060 +def test_bypass_existence_guard_refuses_non_guard_reference(expr):
1061 + """A surviving reference that is not an identity guard cannot be folded away.
1062 +
1063 + Rewriting it is unsafe (its value depends on more than the projection's
1064 + existence), so the fuser refuses rather than change semantics. `==`/`!=` are
1065 + refused too: `is_linear` accepts `nn.Linear` subclasses that could override
1066 + `__eq__`/`__ne__`, so equality need not agree with `is (not) None`."""
1067 + funcdef, ref = _guard_funcdef(expr)
1068 + with pytest.raises(ValueError, match="outside an existence guard"):
1069 + bypass_existence_guard(funcdef, ref, "v_proj")
1070 +
1071 +
1072 +@pytest.mark.parametrize("layer_idx", [0, 1])
1073 +def test_fuses_real_gemma4_attention(layer_idx):
1074 + """The real Gemma 4 attention (both obstacles at once) fuses end to end."""
1075 + pytest.importorskip("transformers.models.gemma4")
1076 + from transformers.models.gemma4.configuration_gemma4 import Gemma4TextConfig
1077 + from transformers.models.gemma4.modeling_gemma4 import Gemma4TextAttention
1078 +
1079 + config = Gemma4TextConfig(
1080 + hidden_size=64,
1081 + num_attention_heads=4,
1082 + num_key_value_heads=2,
1083 + num_hidden_layers=2,
1084 + head_dim=16,
1085 + intermediate_size=128,
1086 + )
1087 + with torch.device("meta"):
1088 + meta = Gemma4TextAttention(config, layer_idx=layer_idx)
1089 + fuser = get_fuser(meta, QKVFuser)
1090 + assert isinstance(fuser, QKVFuser)
1091 + assert (fuser.q_name, fuser.k_name, fuser.v_name) == (
1092 + "q_proj",
1093 + "k_proj",
1094 + "v_proj",
1095 + )
1096 + assert fuser.o_name == "o_proj"
1097 + names = set(fuser.fused_forward.__code__.co_names)
1098 + assert not {"q_proj", "k_proj", "v_proj"} & names
1099 +
1100 +
1101 +def test_merged_column_fuser_folds_existence_guard():
1102 + """A guarded projection fuses: the merged linear replaces the deleted names.
1103 +
1104 + Guard folding lives on `StackedFuser`, not just `QKVFuser`, because every
1105 + stacked fuser deletes the projections it merges -- a surviving reference to
1106 + one would fail at runtime."""
1107 + with torch.device("meta"):
1108 + module = GuardedParallelLinears()
1109 + fuser = MergedColumnParallelFuser.match(trace(module), module)
1110 + assert fuser is not None
1111 + fuser.update_forward(module)
1112 + names = set(fuser.fused_forward.__code__.co_names)
1113 + assert fuser.merged_name in names
1114 + assert not {"proj_a", "proj_b", "proj_c", "proj_d"} & names
1115 +
1116 +
1117 +@pytest.mark.parametrize(
1118 + "cls",
1119 + [
1120 + MutatingParallelLinears,
1121 + AliasMutatingParallelLinears,
1122 + FunctionalInplaceParallelLinears,
1123 + UnderscoreInplaceParallelLinears,
1124 + ],
1125 +)
1126 +def test_merged_column_fuser_rejects_input_mutation(cls):
1127 + """The later projections must not be moved ahead of an input mutation.
1128 +
1129 + The mutation may be direct (`x.add_(1)`), through a view that shares the
1130 + input's storage, or through a call that writes an argument in place
1131 + (`F.relu(x, inplace=True)`, `torch.relu_(x)`). Each one silently changes the
1132 + fused result, so the rewrite must refuse rather than fuse."""
1133 + with torch.device("meta"):
1134 + module = cls()
1135 fuser = MergedColumnParallelFuser.match(trace(module), module)
1136 assert fuser is not None
570 - with pytest.raises(ValueError, match="cross other operations"):
1137 + with pytest.raises(ValueError, match="rebound or mutated"):
1138 fuser.update_forward(module)
1139
1140
1141 +@pytest.mark.parametrize("cls", [FourParallelLinears, GuardedParallelLinears])
1142 def test_merged_column_fuser_supports_any_number_of_linears(
575 - default_vllm_config, monkeypatch
1143 + cls, default_vllm_config, monkeypatch
1144 ):
1145 from vllm.model_executor import parameter
1146 from vllm.model_executor.layers import linear
@@ -585,19 +1153,14 @@ def test_merged_column_fuser_supports_any_number_of_linears(
1153 monkeypatch.setattr(parameter, "get_tensor_model_parallel_rank", lambda: 0)
1154 monkeypatch.setattr(parameter, "get_tensor_model_parallel_world_size", lambda: 1)
1155 with torch.device("meta"):
588 - meta = FourParallelLinears()
1156 + meta = cls()
1157 fuser = MergedColumnParallelFuser.match(trace(meta), meta)
1158 assert fuser is not None
591 - assert fuser.linear_names == ("proj_a", "proj_b", "proj_c", "proj_d")
592 - assert fuser.shards == [
593 - ("proj_a", 0),
594 - ("proj_b", 1),
595 - ("proj_c", 2),
596 - ("proj_d", 3),
597 - ]
1159 + assert set(fuser.linear_names) == {"proj_a", "proj_b", "proj_c", "proj_d"}
1160 + assert fuser.shards == [(name, i) for i, name in enumerate(fuser.linear_names)]
1161 fuser.update_forward(meta)
1162
600 - real = FourParallelLinears()
1163 + real = cls()
1164 x = torch.randn(2, 8)
1165 expected = real(x)
1166 weights = [
tests/models/transformers/fusers/test_mla.py
+26 -4
@@ -19,9 +19,8 @@ from vllm.model_executor.models.transformers.fx_utils import trace
19 _FUSED_QKV_A_PROJ = MLAFuser.merged_name
20
21
22 -def _match(q_lora_rank: int | None) -> MLAFuser | None:
23 - """Match a meta `DeepseekV2Attention` directly (bypassing the per-class
24 - `get_fuser` cache, so both q_lora variants of the same class are seen)."""
22 +def _attention(q_lora_rank: int | None):
23 + """A meta `DeepseekV2Attention` in the requested q_lora configuration."""
24 pytest.importorskip("transformers.models.deepseek_v2.modeling_deepseek_v2")
25 from transformers.models.deepseek_v2.configuration_deepseek_v2 import (
26 DeepseekV2Config,
@@ -41,7 +40,13 @@ def _match(q_lora_rank: int | None) -> MLAFuser | None:
40 num_hidden_layers=1,
41 )
42 with torch.device("meta"):
44 - attn = DeepseekV2Attention(cfg, layer_idx=0)
43 + return DeepseekV2Attention(cfg, layer_idx=0)
44 +
45 +
46 +def _match(q_lora_rank: int | None) -> MLAFuser | None:
47 + """Match a meta `DeepseekV2Attention` directly (bypassing the per-class
48 + `get_fuser` cache, so both q_lora variants of the same class are seen)."""
49 + attn = _attention(q_lora_rank)
50 return MLAFuser.match(trace(attn), attn)
51
52
@@ -90,6 +95,23 @@ def test_q_lora_stacks_qkv_a_proj():
95 }
96
97
98 +@pytest.mark.parametrize("q_lora_rank", [64, None])
99 +def test_update_forward_rewrites_real_attention(q_lora_rank):
100 + """The source rewrite must survive the input-stability check.
101 +
102 + The q-LoRA path hoists the merged down-projection to a top-level statement
103 + while its calls sit inside the `q_lora_rank` branches, so the region it
104 + spans is wide; a check that over-approximates aliasing would silently refuse
105 + here and cost the fusion rather than fail loudly."""
106 + fuser = _match(q_lora_rank)
107 + assert isinstance(fuser, MLAFuser)
108 + fuser.update_forward(_attention(q_lora_rank))
109 + names = set(fuser.fused_forward.__code__.co_names)
110 + if fuser.has_q_lora:
111 + assert _FUSED_QKV_A_PROJ in names
112 + assert not {"q_a_proj", "kv_a_proj_with_mqa"} & names
113 +
114 +
115 class _Norm(nn.Module):
116 """A real RMSNorm computation: `match` verifies chain norms via `RMSNormFuser`."""
117
vllm/model_executor/models/transformers/fusers/base.py
+55 -2
@@ -5,13 +5,19 @@
5 import ast
6 import types
7 from abc import ABC, abstractmethod
8 -from collections.abc import Callable
8 +from collections.abc import Callable, Iterable
9 from dataclasses import dataclass, field
10 from typing import TYPE_CHECKING, ClassVar
11
12 from torch import fx, nn
13
14 -from vllm.model_executor.models.transformers.fx_utils import replace_expr
14 +from vllm.model_executor.models.transformers.fx_utils import (
15 + aliasing_names,
16 + bypass_existence_guard,
17 + replace_expr,
18 + self_call_and_refs,
19 + written_names,
20 +)
21 from vllm.model_executor.models.utils import ShardId, maybe_prefix
22
23 if TYPE_CHECKING:
@@ -160,6 +166,53 @@ class StackedFuser(RewriteFuser):
166 fused layer into its per-shard configs."""
167 return {self.merged_name: [name for name, _ in self.shards]}
168
169 + def _unguarded_calls(
170 + self, funcdef: ast.FunctionDef, names: Iterable[str]
171 + ) -> list[ast.Call]:
172 + """One `self.<name>(arg)` call per projection, existence guards folded.
173 +
174 + `update_attrs` deletes the projections it merges, so any reference to one beyond
175 + its call site must be a guard on its existence; folding those to their constant
176 + value keeps the rewritten forward off a name that no longer exists. A reference
177 + that is not such a guard raises, so fusion is skipped."""
178 + calls = []
179 + for name in names:
180 + call, refs = self_call_and_refs(funcdef, name)
181 + for ref in refs:
182 + bypass_existence_guard(funcdef, ref, name)
183 + calls.append(call)
184 + return calls
185 +
186 + def _check_input_stable(
187 + self,
188 + funcdef: ast.FunctionDef,
189 + module: nn.Module,
190 + calls: list[ast.Call],
191 + block: list[ast.stmt],
192 + indices: list[int],
193 + ) -> None:
194 + """Raise unless hoisting the merged GEMM preserves what it reads.
195 +
196 + Fusing moves every projection to one call at `min(indices)`, so the
197 + merged GEMM reads the input once, up front, where the last of `calls`
198 + would have read it later. That holds only if nothing in between changes
199 + the input, and a change need not name it: writing any view that shares
200 + its storage changes it too. Both halves of the check over-approximate,
201 + since a false hit costs a fusion while a miss returns wrong numbers.
202 + """
203 + arg_names = {
204 + node.id
205 + for node in ast.walk(calls[0].args[0])
206 + if isinstance(node, ast.Name) and isinstance(node.ctx, ast.Load)
207 + }
208 + # Protect the names the input reads and anything that may alias them.
209 + tracked = aliasing_names(funcdef, arg_names, module)
210 + # Any of them rebound, written through, or passed to a call that writes
211 + # its argument would leave the hoisted GEMM reading a different value.
212 + region = block[min(indices) : max(indices) + 1]
213 + if tracked & written_names(region):
214 + raise ValueError("projection input is rebound or mutated before all calls")
215 +
216 def _splice_merged_split(
217 self,
218 funcdef: ast.FunctionDef,
vllm/model_executor/models/transformers/fusers/glu.py
+2 -2
@@ -137,8 +137,8 @@ class GLUFuser(MergedColumnParallelFuser):
137 """Replace `act(gate(x)) * up(x)` with `act(gate_up(x))` in source."""
138 funcdef, fn = recover_forward(type(module))
139 act_call = single_self_call(funcdef, self.act_name)
140 - gate_call = single_self_call(funcdef, self.gate_name)
141 - up_call = single_self_call(funcdef, self.up_name)
140 + calls = self._unguarded_calls(funcdef, (self.gate_name, self.up_name))
141 + gate_call, up_call = calls
142 if act_call.args[0] is not gate_call:
143 raise ValueError("activation does not directly wrap the gate")
144 if ast.dump(gate_call.args[0]) != ast.dump(up_call.args[0]):
vllm/model_executor/models/transformers/fusers/merged_column.py
+8 -19
@@ -12,11 +12,10 @@ from vllm.logger import init_logger
12 from vllm.model_executor.layers.linear import MergedColumnParallelLinear
13 from vllm.model_executor.models.transformers.fusers.base import StackedFuser
14 from vllm.model_executor.models.transformers.fx_utils import (
15 + block_chain,
16 compile_forward,
16 - innermost_block,
17 is_linear,
18 recover_forward,
19 - single_self_call,
19 )
20 from vllm.model_executor.models.utils import ShardId, maybe_prefix
21
@@ -92,30 +91,20 @@ class MergedColumnParallelFuser(StackedFuser):
91 def update_forward(self, module: nn.Module) -> None:
92 """Replace the parallel calls with one merged call and split."""
93 funcdef, fn = recover_forward(type(module))
95 - calls = [single_self_call(funcdef, name) for name in self.linear_names]
94 + calls = self._unguarded_calls(funcdef, self.linear_names)
95 if len(set(ast.dump(call.args[0]) for call in calls)) != 1:
96 raise ValueError("parallel linears read different inputs")
98 - blocks = [innermost_block(funcdef.body, call) for call in calls]
99 - if any(found is None for found in blocks):
97 + chains = [block_chain(funcdef.body, call) for call in calls]
98 + if any(not chain for chain in chains):
99 raise ValueError("parallel linear calls not found in the function body")
100 + blocks = [chain[-1] for chain in chains]
101 if len(set(id(block) for block, _ in blocks)) != 1:
102 raise ValueError("parallel linear calls are in different blocks")
103
104 block = blocks[0][0]
105 - index = min(index for _, index in blocks)
106 - calls_have_name_arg = all(isinstance(call.args[0], ast.Name) for call in calls)
107 - # Moving projections must not cross operations that can change their input.
108 - for statement in block[index : max(index for _, index in blocks) + 1]:
109 - if not isinstance(statement, (ast.Assign, ast.Return)):
110 - raise ValueError("parallel projections cross other operations")
111 - if isinstance(statement, ast.Assign) and any(
112 - not isinstance(target, ast.Name) for target in statement.targets
113 - ):
114 - raise ValueError("parallel projection assignment has side effects")
115 - value = statement.value
116 - values = value.elts if isinstance(value, ast.Tuple) else [value]
117 - if any(value not in calls for value in values) or not calls_have_name_arg:
118 - raise ValueError("parallel projections cross other operations")
105 + indices = [index for _, index in blocks]
106 + index = min(indices)
107 + self._check_input_stable(funcdef, module, calls, block, indices)
108
109 # l1(x), l2(x), ... -> merged(x).split(merged.output_sizes / merged.tp_size, -1)
110 self._splice_merged_split(funcdef, calls, block, index)
vllm/model_executor/models/transformers/fusers/mla.py
+4 -4
@@ -21,7 +21,6 @@ from vllm.model_executor.models.transformers.fx_utils import (
21 recover_forward,
22 replace_expr,
23 returned_linear,
24 - single_self_call,
24 upstream_linear,
25 )
26 from vllm.model_executor.models.transformers.utils import (
@@ -239,11 +238,12 @@ class MLAFuser(StackedFuser):
238 # q_a_proj is usually inside the `else` of `if self.q_lora_rank is None`.
239 # The fused call is inserted at the top-level statement preceding both.
240 names = [self.q_a_proj_name, self.kv_a_proj_name]
242 - calls = [single_self_call(funcdef, name) for name in names]
241 + calls = self._unguarded_calls(funcdef, names)
242 if ast.dump(calls[0].args[0]) != ast.dump(calls[1].args[0]):
243 raise ValueError("down-projections read different inputs")
245 - index = min(_top_level_index(funcdef, call) for call in calls)
246 - self._splice_merged_split(funcdef, calls, funcdef.body, index)
244 + indices = [_top_level_index(funcdef, call) for call in calls]
245 + self._check_input_stable(funcdef, module, calls, funcdef.body, indices)
246 + self._splice_merged_split(funcdef, calls, funcdef.body, min(indices))
247
248 # Transformers expands the latent into full key/value in a dedicated method.
249 # `MLAAttention` consumes the latent directly (absorbing `kv_b_proj`),
vllm/model_executor/models/transformers/fusers/qkv.py
+28 -15
@@ -15,12 +15,11 @@ from vllm.model_executor.models.transformers.fusers.base import (
15 fused_head_size,
16 )
17 from vllm.model_executor.models.transformers.fx_utils import (
18 + block_chain,
19 compile_forward,
19 - innermost_block,
20 is_linear,
21 recover_forward,
22 returned_linear,
23 - single_self_call,
23 )
24 from vllm.model_executor.models.transformers.utils import (
25 log_replacement,
@@ -99,12 +98,18 @@ class QKVFuser(StackedFuser):
98 return cls(source_cls=type(module).__name__, **names)
99
100 def update_forward(self, module: nn.Module) -> None:
102 - """Replace `q(x), k(x), v(x)` with `qkv(x).split(sizes, -1)` in source."""
101 + """Replace `q(x), k(x), v(x)` with `qkv(x).split(sizes, -1)` in source.
102 +
103 + A projection may be guarded by an existence check -- a
104 + `self.<proj> is (not) None` comparison (e.g. Gemma 4's `v_proj`) or a bare
105 + truthiness test -- which is folded to its constant value (see
106 + `bypass_existence_guard`). The calls may sit in different branches, so the
107 + fused GEMM is inserted before the earliest of them, in the innermost block
108 + that dominates all three.
109 + """
110 funcdef, fn = recover_forward(type(module))
104 - calls = [
105 - single_self_call(funcdef, name)
106 - for name in (self.q_name, self.k_name, self.v_name)
107 - ]
111 + proj_names = (self.q_name, self.k_name, self.v_name)
112 + calls = self._unguarded_calls(funcdef, proj_names)
113 arg_dumps = {ast.dump(call.args[0]) for call in calls}
114 if len(arg_dumps) != 1:
115 raise ValueError("projection inputs are written differently")
@@ -115,7 +120,7 @@ class QKVFuser(StackedFuser):
120 name
121 for name, child in module.named_children()
122 if isinstance(child, nn.Linear)
118 - } - {self.q_name, self.k_name, self.v_name}
123 + } - set(proj_names)
124 for node in ast.walk(funcdef):
125 if (
126 isinstance(node, ast.Call)
@@ -124,16 +129,24 @@ class QKVFuser(StackedFuser):
129 and any(ast.dump(arg) in arg_dumps for arg in node.args)
130 ):
131 raise ValueError("another linear consumes the same input")
127 - blocks = [innermost_block(funcdef.body, call) for call in calls]
128 - if any(found is None for found in blocks):
132 +
133 + # Insert the fused GEMM before the earliest call, in the innermost block
134 + # common to all three (the calls may be split across branches).
135 + chains = [block_chain(funcdef.body, call) for call in calls]
136 + if any(not chain for chain in chains):
137 raise ValueError("projection calls not found in the function body")
130 - if len({id(block) for block, _ in blocks}) != 1:
131 - raise ValueError("projection calls are in different blocks")
138 + depth = 0
139 + for level in zip(*chains):
140 + if len({id(block) for block, _ in level}) != 1:
141 + break
142 + depth += 1
143 + block = chains[0][depth - 1][0]
144 + indices = [chain[depth - 1][1] for chain in chains]
145 + insert_index = min(indices)
146 + self._check_input_stable(funcdef, module, calls, block, indices)
147
148 # q(x), k(x), v(x) -> q, k, v = qkv(x).split(qkv.output_sizes / qkv.tp_size, -1)
134 - block = blocks[0][0]
135 - index = min(index for _, index in blocks)
136 - self._splice_merged_split(funcdef, calls, block, index)
149 + self._splice_merged_split(funcdef, calls, block, insert_index)
150 self.fused_forward = compile_forward(funcdef, fn)
151
152 def validate(self, module: nn.Module, vllm_config: "VllmConfig") -> bool:
vllm/model_executor/models/transformers/fx_utils.py
+326 -21
@@ -70,6 +70,13 @@ class _MetaProxy(fx.Proxy):
70 def __getattr__(self, k: str) -> "_MetaAttribute":
71 return _MetaAttribute(self, k)
72
73 + def __setitem__(self, key: object, value: object) -> None:
74 + # Record `obj[key] = value` so tracing continues past in-place writes
75 + # into containers (e.g. Gemma 4's `shared_kv_states[...] = ...`).
76 + self.tracer.create_proxy(
77 + "call_function", operator.setitem, (self, key, value), {}
78 + )
79 +
80
81 class _MetaAttribute(_MetaProxy, fx.proxy.Attribute):
82 """Attribute proxy (e.g. `x.shape`) carrying its meta value.
@@ -312,41 +319,125 @@ def compile_forward(funcdef: ast.FunctionDef, fn: Callable) -> Callable:
319 return namespace[funcdef.name]
320
321
315 -def single_self_call(funcdef: ast.FunctionDef, name: str) -> ast.Call:
316 - """The unique `self.<name>(arg)` call in `funcdef`.
322 +def self_call_and_refs(
323 + funcdef: ast.FunctionDef, name: str
324 +) -> tuple[ast.Call, list[ast.Attribute]]:
325 + """The unique `self.<name>(arg)` call, plus every other `self.<name>` reference.
326
318 - Raises unless `name` appears exactly once, as such a call, so the source
319 - rewrite agrees with the fx match.
327 + Raises unless exactly one single-argument `self.<name>(arg)` call exists, so
328 + one fx `call_module` node maps to one syntactic call site. The remaining
329 + references (e.g. `is not None` guards) are returned for the caller to resolve.
330 """
331 uses = [
332 node
333 for node in ast.walk(funcdef)
324 - if isinstance(node, ast.Attribute) and node.attr == name
334 + if isinstance(node, ast.Attribute)
335 + and node.attr == name
336 + and isinstance(node.value, ast.Name)
337 + and node.value.id == "self"
338 ]
326 - if len(uses) != 1:
327 - raise ValueError(f"{name} is referenced {len(uses)} times")
339 calls = [
340 node
341 for node in ast.walk(funcdef)
342 if isinstance(node, ast.Call)
332 - and node.func is uses[0]
343 + and any(node.func is use for use in uses)
344 and len(node.args) == 1
345 and not isinstance(node.args[0], ast.Starred)
346 and not node.keywords
347 ]
337 - if (
338 - len(calls) != 1
339 - or not isinstance(uses[0].value, ast.Name)
340 - or uses[0].value.id != "self"
341 - ):
342 - raise ValueError(f"{name} is not a single-argument call on self")
343 - return calls[0]
348 + if len(calls) != 1:
349 + raise ValueError(f"{name} is not called exactly once as self.{name}(arg)")
350 + call = calls[0]
351 + other_refs = [use for use in uses if use is not call.func]
352 + return call, other_refs
353 +
354 +
355 +def single_self_call(funcdef: ast.FunctionDef, name: str) -> ast.Call:
356 + """The unique `self.<name>(arg)` call in `funcdef`.
357 +
358 + Raises unless `name` appears exactly once, as such a call, so the source
359 + rewrite agrees with the fx match.
360 + """
361 + call, other_refs = self_call_and_refs(funcdef, name)
362 + if other_refs:
363 + raise ValueError(f"{name} is referenced {len(other_refs) + 1} times")
364 + return call
365 +
366 +
367 +def _is_none(node: ast.expr) -> bool:
368 + return isinstance(node, ast.Constant) and node.value is None
369 +
370 +
371 +def _in_boolean_context(funcdef: ast.FunctionDef, ref: ast.expr) -> bool:
372 + """Is `ref` used only for its truth value (a test, or a `not` operand)?
373
374 + In these positions the object's identity never escapes, so a reference that
375 + is always truthy can be replaced by `True`. `and`/`or` are excluded: they
376 + yield an operand, so the module could escape (`x and self.<name>`)."""
377 + for node in ast.walk(funcdef):
378 + if (
379 + isinstance(node, (ast.If, ast.IfExp, ast.While, ast.Assert))
380 + and node.test is ref
381 + ):
382 + return True
383 + if (
384 + isinstance(node, ast.UnaryOp)
385 + and isinstance(node.op, ast.Not)
386 + and node.operand is ref
387 + ):
388 + return True
389 + return False
390 +
391 +
392 +def bypass_existence_guard(
393 + funcdef: ast.FunctionDef, ref: ast.Attribute, name: str
394 +) -> None:
395 + """Fold a guard on `self.<name>`'s existence to its constant value.
396
346 -def innermost_block(
397 + The fuser deletes `self.<name>` and binds only instances where it exists as a
398 + truthy `nn.Linear` (guaranteed by the match, the cache key, and `validate`),
399 + so a guard testing its presence is a fusion invariant. Two forms are folded:
400 +
401 + - an identity `None` check `self.<name> is None` -> `False`,
402 + `self.<name> is not None` -> `True` (either operand order). `==`/`!=` are
403 + *not* folded: `is_linear` accepts `nn.Linear` subclasses, which may override
404 + `__eq__`/`__ne__`, so equality is not guaranteed to track identity.
405 + - a bare truthiness test (`if self.<name>:`, `... if self.<name> else ...`,
406 + `not self.<name>`): the reference itself to `True`.
407 +
408 + Any other surviving reference escapes the projection's value, which no longer
409 + exists after fusion, so refuse rather than change semantics."""
410 + for node in ast.walk(funcdef):
411 + # `self.<name> is (not) None`, either operand order.
412 + if not (isinstance(node, ast.Compare) and len(node.ops) == 1):
413 + continue
414 + (op,), (right,) = node.ops, node.comparators
415 + if not isinstance(op, (ast.Is, ast.IsNot)):
416 + continue
417 + if (ref is node.left and _is_none(right)) or (
418 + ref is right and _is_none(node.left)
419 + ):
420 + value = isinstance(op, ast.IsNot)
421 + replace_expr(funcdef, node, ast.copy_location(ast.Constant(value), node))
422 + return
423 + # A bare truthiness test (statement `if`, ternary, or `not`); an `nn.Linear`
424 + # is always truthy, so the reference folds to `True`.
425 + if _in_boolean_context(funcdef, ref):
426 + replace_expr(funcdef, ref, ast.copy_location(ast.Constant(True), ref))
427 + return
428 + raise ValueError(f"{name} is referenced outside an existence guard")
429 +
430 +
431 +def block_chain(
432 block: list[ast.stmt], node: ast.AST
348 -) -> tuple[list[ast.stmt], int] | None:
349 - """The innermost statement list containing `node`, and the index within."""
433 +) -> list[tuple[list[ast.stmt], int]]:
434 + """Path of (statement list, index) pairs from `block` down to `node`.
435 +
436 + Each pair names a nested block and the index in it of the statement
437 + containing `node`; the last pair is `node`'s innermost block. Empty if
438 + `node` is not in `block`. The longest common prefix of several nodes' chains
439 + is the innermost block that dominates them all.
440 + """
441 for index, stmt in enumerate(block):
442 if not any(child is node for child in ast.walk(stmt)):
443 continue
@@ -359,13 +450,227 @@ def innermost_block(
450 if (
451 isinstance(child_block, list)
452 and child_block
362 - and (found := innermost_block(child_block, node)) is not None
453 + and (tail := block_chain(child_block, node))
454 ):
364 - return found
365 - return block, index
455 + return [(block, index), *tail]
456 + return [(block, index)]
457 + return []
458 +
459 +
460 +def _base_name(node: ast.expr) -> str | None:
461 + """The root `Name` of an attribute/subscript chain (`a.b[c]` -> `a`)."""
462 + while isinstance(node, (ast.Attribute, ast.Subscript)):
463 + node = node.value
464 + return node.id if isinstance(node, ast.Name) else None
465 +
466 +
467 +def _rebound_names(region: list[ast.stmt]) -> set[str]:
468 + """Names rebound outright within `region` (`x = ...`, `del x`).
469 +
470 + These are `Name` nodes in a `Store`/`Del` context."""
471 + return {
472 + node.id
473 + for stmt in region
474 + for node in ast.walk(stmt)
475 + if isinstance(node, ast.Name) and isinstance(node.ctx, (ast.Store, ast.Del))
476 + }
477 +
478 +
479 +def _inplace_target(node: ast.AST) -> str | None:
480 + """Base name `node` mutates in place, if any.
481 +
482 + A write through the name (`x[i] = ...`, `x.attr = ...`) or an in-place method
483 + call (`x.mul_(...)`) leaves the base name in a `Load` context, so it is not a
484 + plain `Name` store (see `_rebound_names`)."""
485 + if isinstance(node, (ast.Attribute, ast.Subscript)) and isinstance(
486 + node.ctx, (ast.Store, ast.Del)
487 + ):
488 + return _base_name(node)
489 + if (
490 + isinstance(node, ast.Call)
491 + and isinstance(node.func, ast.Attribute)
492 + and node.func.attr.endswith("_")
493 + and not node.func.attr.endswith("__")
494 + ):
495 + return _base_name(node.func.value)
496 return None
497
498
499 +def _mutated_names(region: list[ast.stmt]) -> set[str]:
500 + """Names mutated in place within `region` (see `_inplace_target`)."""
501 + return {
502 + base
503 + for stmt in region
504 + for node in ast.walk(stmt)
505 + if (base := _inplace_target(node)) is not None
506 + }
507 +
508 +
509 +def _call_name(call: ast.Call) -> str:
510 + """The called function's own name, without its qualifier."""
511 + if isinstance(call.func, ast.Attribute):
512 + return call.func.attr
513 + return call.func.id if isinstance(call.func, ast.Name) else ""
514 +
515 +
516 +def _arg_names(call: ast.Call) -> set[str]:
517 + """Every name `call` reads through its arguments."""
518 + reads = [*call.args, *(kw.value for kw in call.keywords)]
519 + return {
520 + node.id
521 + for arg in reads
522 + for node in ast.walk(arg)
523 + if isinstance(node, ast.Name) and isinstance(node.ctx, ast.Load)
524 + }
525 +
526 +
527 +def _writes_through_args(call: ast.Call) -> bool:
528 + """Does `call` write through an argument rather than return a new value?
529 +
530 + Torch spells this three ways: an `out=` destination, `inplace=True`, and the
531 + trailing-underscore convention (`torch.relu_(x)`). The underscore is checked
532 + on the function's own name, so it catches the free-function form that
533 + `_inplace_target` cannot (there the base name is the module, `torch`).
534 + """
535 + for keyword in call.keywords:
536 + if keyword.arg == "out":
537 + return True
538 + if keyword.arg == "inplace" and not (
539 + isinstance(keyword.value, ast.Constant) and keyword.value.value is False
540 + ):
541 + return True
542 + name = _call_name(call)
543 + return name.endswith("_") and not name.endswith("__")
544 +
545 +
546 +def written_names(region: list[ast.stmt]) -> set[str]:
547 + """Names `region` may rebind or write through.
548 +
549 + Beyond plain rebinding and writes through the name itself (`_rebound_names`,
550 + `_mutated_names`), a call can write through an argument without naming it as
551 + a target: `F.relu(x, inplace=True)`, `torch.add(x, y, out=x)`,
552 + `torch.relu_(x)`. A statement-level call whose result is discarded is treated
553 + the same way, since it can only be there for a side effect.
554 + """
555 + written = _rebound_names(region) | _mutated_names(region)
556 + for stmt in region:
557 + if isinstance(stmt, ast.Expr) and isinstance(stmt.value, ast.Call):
558 + written |= _arg_names(stmt.value)
559 + for node in ast.walk(stmt):
560 + if isinstance(node, ast.Call) and _writes_through_args(node):
561 + written |= _arg_names(node)
562 + return written
563 +
564 +
565 +_METADATA_ATTRS = frozenset(
566 + {"shape", "dtype", "device", "ndim", "size", "dim", "numel", "item"}
567 +)
568 +
569 +
570 +def _aliasing_reads(node: ast.expr) -> set[str]:
571 + """Names `node` reads in a way that could yield a view of them.
572 +
573 + Metadata (`x.shape`, `x.size(0)`, `x.dtype`) is ints and tuples sharing no
574 + storage with `x`, so reaching a name only through metadata cannot alias it.
575 + Shape arithmetic is pervasive between projections, so propagating through it
576 + would taint nearly every later name.
577 + """
578 + names: set[str] = set()
579 + stack = [node]
580 + while stack:
581 + current = stack.pop()
582 + if isinstance(current, ast.Attribute) and current.attr in _METADATA_ATTRS:
583 + continue
584 + if (
585 + isinstance(current, ast.Call)
586 + and isinstance(current.func, ast.Attribute)
587 + and current.func.attr in _METADATA_ATTRS
588 + ):
589 + stack.extend(current.args)
590 + stack.extend(keyword.value for keyword in current.keywords)
591 + continue
592 + if isinstance(current, ast.Name) and isinstance(current.ctx, ast.Load):
593 + names.add(current.id)
594 + stack.extend(ast.iter_child_nodes(current))
595 + return names
596 +
597 +
598 +def _returns_fresh(node: ast.expr, module: nn.Module) -> bool:
599 + """Does evaluating `node` allocate, rather than return a view of its input?
600 +
601 + A `nn.Linear` writes its output into new storage, so its result aliases
602 + nothing that went in, and a chain of view ops on top of it aliases only that
603 + fresh tensor. Walking the receiver chain down to a `self.<linear>(...)` call
604 + therefore proves the value cannot alias the projection input. A literal
605 + aliases nothing either, and a branch or sequence is fresh when every part is
606 + -- which is how a guarded projection (`p(x) if p is not None else None`)
607 + stays untracked. A call on an attribute that is missing or `None` cannot run
608 + at all, so a branch selecting a different configuration is fresh too.
609 + """
610 + if isinstance(node, ast.Constant):
611 + return True
612 + if isinstance(node, ast.IfExp):
613 + if isinstance(node.test, ast.Constant):
614 + # A folded existence guard leaves the untaken branch as dead code.
615 + taken = node.body if node.test.value else node.orelse
616 + return _returns_fresh(taken, module)
617 + return _returns_fresh(node.body, module) and _returns_fresh(node.orelse, module)
618 + if isinstance(node, (ast.Tuple, ast.List)):
619 + return all(_returns_fresh(elt, module) for elt in node.elts)
620 + while isinstance(node, ast.Call) and isinstance(node.func, ast.Attribute):
621 + func = node.func
622 + if isinstance(func.value, ast.Name) and func.value.id == "self":
623 + attribute = getattr(module, func.attr, None)
624 + if attribute is None or isinstance(attribute, nn.Linear):
625 + # A missing or `None` attribute cannot be called, so the branch
626 + # selecting it never runs (`q_proj` on a q-LoRA layer).
627 + return True
628 + # Anything else can only return storage it was handed, so it is
629 + # fresh exactly when its inputs are (`q_norm(q_proj(x))`).
630 + return bool(node.args) and all(
631 + _returns_fresh(arg, module) for arg in node.args
632 + )
633 + node = func.value
634 + return False
635 +
636 +
637 +def aliasing_names(
638 + funcdef: ast.FunctionDef, seed: set[str], module: nn.Module
639 +) -> set[str]:
640 + """`seed` plus every name that may alias one of them.
641 +
642 + A view shares storage with its base (`h = x.view(-1)`), so mutating `h`
643 + mutates `x`; tracking only `seed` would miss that. Any assignment reading a
644 + tracked name therefore taints its targets, transitively, unless its value is
645 + freshly allocated (`_returns_fresh`).
646 + """
647 + names = set(seed)
648 + assigns = [
649 + node
650 + for node in ast.walk(funcdef)
651 + if isinstance(node, (ast.Assign, ast.AnnAssign))
652 + and node.value is not None
653 + and not _returns_fresh(node.value, module)
654 + ]
655 + changed = True
656 + while changed:
657 + changed = False
658 + for node in assigns:
659 + if not _aliasing_reads(node.value) & names:
660 + continue
661 + targets = getattr(node, "targets", None) or [node.target]
662 + stores = {
663 + child.id
664 + for target in targets
665 + for child in ast.walk(target)
666 + if isinstance(child, ast.Name) and isinstance(child.ctx, ast.Store)
667 + }
668 + if stores - names:
669 + names |= stores
670 + changed = True
671 + return names
672 +
673 +
674 def replace_expr(module: ast.AST, old: ast.expr, new: ast.expr) -> None:
675 """Replace the expression `old` (by identity) with `new` within `module`."""
676