Merge simple non-abstract methods into abstract methods

This introduces a new optimization that attempts to merge non-abstract virtual methods into the abstract methods they override, to reduce the overall method count.

Example:

abstract class A {
  abstract boolean m();
}
class B extends A {
  @Override
  boolean m() {
    return true;
  }
}

Gets optimized into:

abstract class A {
  boolean m() {
    return true;
  }
}
class B extends A {}

Bug: b/241190995
Change-Id: I4925bb25f69d58962199a4f82212139eaa47f3ec
diff --git a/src/main/java/com/android/tools/r8/R8.java b/src/main/java/com/android/tools/r8/R8.java
index 376be97..3aaecc5 100644
--- a/src/main/java/com/android/tools/r8/R8.java
+++ b/src/main/java/com/android/tools/r8/R8.java
@@ -80,6 +80,7 @@
 import com.android.tools.r8.optimize.redundantbridgeremoval.RedundantBridgeRemover;
 import com.android.tools.r8.optimize.singlecaller.SingleCallerInliner;
 import com.android.tools.r8.optimize.smallmethodinliner.SmallMethodInliner;
+import com.android.tools.r8.optimize.virtualmethodhoisting.VirtualMethodHoister;
 import com.android.tools.r8.origin.CommandLineOrigin;
 import com.android.tools.r8.profile.art.ArtProfileCompletenessChecker;
 import com.android.tools.r8.profile.rewriting.ProfileCollectionAdditions;
@@ -834,6 +835,7 @@
                 .runIfNecessary(executorService, timing);
             assert appView.getTypeElementFactory().verifyNoCachedTypeElements();
 
+            new VirtualMethodHoister(appView.withLiveness()).run(executorService, timing);
             new SingleCallerInliner(appViewWithLiveness).runIfNecessary(executorService, timing);
             new ProtoNormalizer(appViewWithLiveness).run(executorService, timing);
           }
diff --git a/src/main/java/com/android/tools/r8/graph/AppView.java b/src/main/java/com/android/tools/r8/graph/AppView.java
index 4fa7270..5277d5d 100644
--- a/src/main/java/com/android/tools/r8/graph/AppView.java
+++ b/src/main/java/com/android/tools/r8/graph/AppView.java
@@ -9,7 +9,6 @@
 import com.android.tools.r8.FeatureSplit;
 import com.android.tools.r8.androidapi.AndroidApiLevelCompute;
 import com.android.tools.r8.androidapi.ComputedApiLevel;
-import com.android.tools.r8.classmerging.ClassMergerMode;
 import com.android.tools.r8.contexts.CompilationContext;
 import com.android.tools.r8.contexts.CompilationContext.ProcessorContext;
 import com.android.tools.r8.errors.dontwarn.DontWarnConfiguration;
@@ -75,6 +74,7 @@
 import com.android.tools.r8.utils.threads.ThreadTask;
 import com.android.tools.r8.utils.threads.ThreadTaskUtils;
 import com.android.tools.r8.utils.timing.Timing;
+import com.android.tools.r8.verticalclassmerging.ClassMergerMode;
 import com.android.tools.r8.verticalclassmerging.VerticallyMergedClasses;
 import com.google.common.base.Predicates;
 import com.google.common.collect.ImmutableSet;
diff --git a/src/main/java/com/android/tools/r8/optimize/virtualmethodhoisting/VirtualMethodHoister.java b/src/main/java/com/android/tools/r8/optimize/virtualmethodhoisting/VirtualMethodHoister.java
new file mode 100644
index 0000000..aa8a5a5
--- /dev/null
+++ b/src/main/java/com/android/tools/r8/optimize/virtualmethodhoisting/VirtualMethodHoister.java
@@ -0,0 +1,326 @@
+// Copyright (c) 2026, the R8 project authors. Please see the AUTHORS file
+// for details. All rights reserved. Use of this source code is governed by a
+// BSD-style license that can be found in the LICENSE file.
+package com.android.tools.r8.optimize.virtualmethodhoisting;
+
+import static com.android.tools.r8.graph.DexProgramClass.asProgramClassOrNull;
+import static com.android.tools.r8.ir.code.Opcodes.ARGUMENT;
+import static com.android.tools.r8.ir.code.Opcodes.ASSUME;
+import static com.android.tools.r8.ir.code.Opcodes.CONST_NUMBER;
+import static com.android.tools.r8.ir.code.Opcodes.CONST_STRING;
+import static com.android.tools.r8.ir.code.Opcodes.RETURN;
+import static com.android.tools.r8.utils.internal.MapUtils.ignoreKey;
+
+import com.android.tools.r8.graph.AccessFlags;
+import com.android.tools.r8.graph.AppView;
+import com.android.tools.r8.graph.DexClass;
+import com.android.tools.r8.graph.DexEncodedMethod;
+import com.android.tools.r8.graph.DexItemFactory;
+import com.android.tools.r8.graph.DexMethod;
+import com.android.tools.r8.graph.DexProgramClass;
+import com.android.tools.r8.graph.DexType;
+import com.android.tools.r8.graph.ImmediateProgramSubtypingInfo;
+import com.android.tools.r8.graph.MethodAccessFlags;
+import com.android.tools.r8.graph.ProgramMethod;
+import com.android.tools.r8.graph.PrunedItems;
+import com.android.tools.r8.ir.code.IRCode;
+import com.android.tools.r8.ir.code.Instruction;
+import com.android.tools.r8.ir.code.Return;
+import com.android.tools.r8.ir.code.Value;
+import com.android.tools.r8.ir.conversion.LirConverter;
+import com.android.tools.r8.optimize.argumentpropagation.utils.DepthFirstTopDownClassHierarchyTraversal;
+import com.android.tools.r8.optimize.argumentpropagation.utils.ProgramClassesBidirectedGraph;
+import com.android.tools.r8.shaking.AppInfoWithLiveness;
+import com.android.tools.r8.utils.ThreadUtils;
+import com.android.tools.r8.utils.collections.DexMethodSignatureMap;
+import com.android.tools.r8.utils.internal.ListUtils;
+import com.android.tools.r8.utils.internal.ThrowingAction;
+import com.android.tools.r8.utils.timing.Timing;
+import com.google.common.collect.Sets;
+import java.util.ArrayList;
+import java.util.Collections;
+import java.util.Comparator;
+import java.util.IdentityHashMap;
+import java.util.List;
+import java.util.Map;
+import java.util.Set;
+import java.util.concurrent.ConcurrentHashMap;
+import java.util.concurrent.ExecutionException;
+import java.util.concurrent.ExecutorService;
+
+/**
+ * Searches for non-abstract methods that override an abstract method, where the body of the
+ * non-abstract method can safely be hoisted up to the declaration of the abstract method. This
+ * helps reduce the method count.
+ */
+public class VirtualMethodHoister {
+
+  private final AppView<AppInfoWithLiveness> appView;
+  private final DexItemFactory factory;
+
+  private final VirtualMethodHoisterLens.Builder lensBuilder =
+      new VirtualMethodHoisterLens.Builder();
+
+  public VirtualMethodHoister(AppView<AppInfoWithLiveness> appView) {
+    this.appView = appView;
+    this.factory = appView.dexItemFactory();
+  }
+
+  public void run(ExecutorService executorService, Timing timing) throws ExecutionException {
+    if (shouldRun()) {
+      try (Timing t0 = timing.begin("VirtualMethodHoister")) {
+        internalRun(executorService, timing);
+        appView.getTypeElementFactory().clearTypeElementsCache();
+        appView.notifyOptimizationFinished();
+      }
+    }
+  }
+
+  private boolean shouldRun() {
+    return appView.options().isOptimizing() && appView.options().isShrinking();
+  }
+
+  private void internalRun(ExecutorService executorService, Timing timing)
+      throws ExecutionException {
+    Set<DexMethod> hoistedMethods = hoistMethodsConcurrently(executorService);
+    if (hoistedMethods.isEmpty()) {
+      return;
+    }
+
+    // Prune the methods that have been removed due to hoisting.
+    pruneApp(hoistedMethods, executorService, timing);
+
+    // Rewrite AppView and LirCode.
+    VirtualMethodHoisterLens lens = lensBuilder.build(appView);
+    appView.rewriteWithLens(lens, executorService, timing);
+    LirConverter.rewriteLirWithLens(appView, timing, executorService, ThrowingAction.empty());
+    appView.clearCodeRewritings(executorService, timing);
+  }
+
+  private Set<DexMethod> hoistMethodsConcurrently(ExecutorService executorService)
+      throws ExecutionException {
+    // Process the strongly connected program components concurrently.
+    ImmediateProgramSubtypingInfo immediateSubtypingInfo =
+        ImmediateProgramSubtypingInfo.createWithDeterministicOrder(appView);
+    Set<DexMethod> hoistedMethods = ConcurrentHashMap.newKeySet();
+    List<Set<DexProgramClass>> stronglyConnectedComponents =
+        new ProgramClassesBidirectedGraph(appView, immediateSubtypingInfo)
+            .computeStronglyConnectedComponents();
+    ThreadUtils.processItems(
+        stronglyConnectedComponents,
+        stronglyConnectedComponent -> {
+          VirtualMethodHoisterTraversal traversal =
+              new VirtualMethodHoisterTraversal(appView, immediateSubtypingInfo);
+          traversal.run(
+              ListUtils.sort(stronglyConnectedComponent, Comparator.comparing(DexClass::getType)));
+          hoistedMethods.addAll(traversal.removeAndGetHoistedMethods());
+        },
+        appView.options().getThreadingModule(),
+        executorService);
+    return hoistedMethods;
+  }
+
+  private void pruneApp(
+      Set<DexMethod> hoistedMethods, ExecutorService executorService, Timing timing)
+      throws ExecutionException {
+    PrunedItems prunedItems =
+        PrunedItems.builder().setPrunedApp(appView.app()).addRemovedMethods(hoistedMethods).build();
+    appView.pruneItems(prunedItems, executorService, timing);
+  }
+
+  private class VirtualMethodHoisterTraversal extends DepthFirstTopDownClassHierarchyTraversal {
+
+    private final Map<DexProgramClass, Set<DexEncodedMethod>> hoistedMethods =
+        new IdentityHashMap<>();
+
+    private final Map<DexProgramClass, BottomUpTraversalState> states = new IdentityHashMap<>();
+
+    VirtualMethodHoisterTraversal(
+        AppView<AppInfoWithLiveness> appView,
+        ImmediateProgramSubtypingInfo immediateSubtypingInfo) {
+      super(appView, immediateSubtypingInfo);
+    }
+
+    private BottomUpTraversalState getOrCreateState(DexProgramClass clazz) {
+      return states.computeIfAbsent(clazz, ignoreKey(BottomUpTraversalState::new));
+    }
+
+    public Set<DexMethod> removeAndGetHoistedMethods() {
+      Set<DexMethod> hoistedMethodReferences = Sets.newIdentityHashSet();
+      hoistedMethods.forEach(
+          (sourceClass, sourceMethods) -> {
+            sourceClass.getMethodCollection().removeMethods(sourceMethods);
+            for (DexEncodedMethod sourceMethod : sourceMethods) {
+              hoistedMethodReferences.add(sourceMethod.getReference());
+            }
+          });
+      return hoistedMethodReferences;
+    }
+
+    @Override
+    public void visit(DexProgramClass clazz) {
+      // Top-down pass, not used.
+      // TODO(b/241190995): Accumulate abstract methods in the top-down traversal so that we only
+      //  carry up virtual methods that override abstract methods in the bottom-up traversal.
+    }
+
+    @Override
+    public void prune(DexProgramClass clazz) {
+      if (clazz.isInterface()) {
+        assert !states.containsKey(clazz);
+        return;
+      }
+
+      BottomUpTraversalState state = getOrCreateState(clazz);
+      clazz.forEachProgramVirtualMethod(
+          method -> {
+            ProgramMethod sourceMethod = findHoistCandidate(method, state);
+
+            // Shadowing: Clear subclass candidates for this signature.
+            state.clearCandidates(method);
+
+            if (sourceMethod != null) {
+              // Replace the abstract target method by the source method. In principle the
+              // replacement may be subject to further hoisting if we add it to the state, but
+              // we don't expect abstract overrides of abstract methods.
+              applyHoist(sourceMethod, method);
+            } else if (!method.getAccessFlags().isAbstract()) {
+              // Add method as a candidate for hoisting.
+              state.addCandidate(method);
+            }
+          });
+
+      // Propagate state to superclasses (only superclass, not interfaces).
+      if (clazz.hasSuperType() && !state.isEmpty()) {
+        DexProgramClass superClass =
+            asProgramClassOrNull(appView.definitionFor(clazz.getSuperType()));
+        if (superClass != null) {
+          getOrCreateState(superClass).addAll(state);
+        }
+      }
+
+      states.remove(clazz);
+    }
+
+    private ProgramMethod findHoistCandidate(
+        ProgramMethod targetMethod, BottomUpTraversalState state) {
+      if (targetMethod.getAccessFlags().isAbstract()
+          && !appView.getKeepInfo(targetMethod).isPinned(appView.options())) {
+        for (ProgramMethod candidate : state.getCandidates(targetMethod)) {
+          if (isSafeToHoist(candidate, targetMethod)) {
+            return candidate;
+          }
+        }
+      }
+      return null;
+    }
+
+    private boolean isSafeToHoist(ProgramMethod candidate, ProgramMethod targetMethod) {
+      if (appView.getKeepInfo(candidate).isPinned(appView.options())) {
+        return false;
+      }
+      IRCode code = candidate.buildIR(appView);
+      for (Instruction instruction : code.instructions()) {
+        int opcode = instruction.opcode();
+        if (opcode == ARGUMENT
+            || opcode == ASSUME
+            || opcode == CONST_NUMBER
+            || opcode == CONST_STRING) {
+          // Safe
+        } else if (opcode == RETURN) {
+          Return returnInstruction = instruction.asReturn();
+          if (!returnInstruction.isReturnVoid()) {
+            Value returnValue = returnInstruction.returnValue();
+            if (returnValue.getAliasedValue().isThis()) {
+              DexType returnType = targetMethod.getReturnType();
+              DexClass targetClass = targetMethod.getHolder();
+              if (!appView.appInfo().isSubtype(targetClass.getType(), returnType)) {
+                return false;
+              }
+            }
+          }
+        } else {
+          return false;
+        }
+      }
+      return true;
+    }
+
+    private void applyHoist(ProgramMethod sourceMethod, ProgramMethod targetMethod) {
+      // Replace the target method with the source method.
+      DexProgramClass targetClass = targetMethod.getHolder();
+      targetClass
+          .getMethodCollection()
+          .replaceVirtualMethod(
+              targetMethod.getReference(),
+              t ->
+                  sourceMethod
+                      .getDefinition()
+                      .toTypeSubstitutedMethodAsInlining(
+                          t.getReference(),
+                          factory,
+                          builder -> builder.modifyAccessFlags(AccessFlags::demoteFromFinal)));
+
+      // Record for lens and pruning.
+      if (canRetargetInvokesToTargetMethod(sourceMethod, targetMethod)) {
+        lensBuilder.map(sourceMethod, targetMethod);
+      }
+
+      hoistedMethods
+          .computeIfAbsent(sourceMethod.getHolder(), ignoreKey(Sets::newIdentityHashSet))
+          .add(sourceMethod.getDefinition());
+    }
+
+    private boolean canRetargetInvokesToTargetMethod(
+        ProgramMethod sourceMethod, ProgramMethod targetMethod) {
+      DexProgramClass sourceHolder = sourceMethod.getHolder();
+      DexClass targetHolder = targetMethod.getHolder();
+      if (!targetHolder.getAccessFlags().isPublic()) {
+        if (sourceHolder.getAccessFlags().isPublic() || !sourceMethod.isSamePackage(targetMethod)) {
+          return false;
+        }
+      }
+      if (targetMethod.getAccessFlags().isPublic()) {
+        return true;
+      }
+      MethodAccessFlags sourceAccessFlags = sourceMethod.getAccessFlags();
+      MethodAccessFlags targetAccessFlags = targetMethod.getAccessFlags();
+      if (sourceAccessFlags.isPackagePrivate()
+          && !targetAccessFlags.isPrivate()
+          && sourceMethod.isSamePackage(targetMethod)) {
+        return true;
+      }
+      return sourceAccessFlags.isProtected()
+          && targetAccessFlags.isProtected()
+          && sourceMethod.isSamePackage(targetMethod);
+    }
+  }
+
+  private static class BottomUpTraversalState {
+
+    private final DexMethodSignatureMap<List<ProgramMethod>> candidates =
+        DexMethodSignatureMap.create();
+
+    void addCandidate(ProgramMethod method) {
+      candidates.computeIfAbsent(method, ignoreKey(ArrayList::new)).add(method);
+    }
+
+    void addAll(BottomUpTraversalState other) {
+      other.candidates.forEach(
+          (signature, methods) ->
+              candidates.computeIfAbsent(signature, ignoreKey(ArrayList::new)).addAll(methods));
+    }
+
+    List<ProgramMethod> getCandidates(ProgramMethod method) {
+      return candidates.getOrDefault(method, Collections.emptyList());
+    }
+
+    void clearCandidates(ProgramMethod method) {
+      candidates.remove(method);
+    }
+
+    boolean isEmpty() {
+      return candidates.isEmpty();
+    }
+  }
+}
diff --git a/src/main/java/com/android/tools/r8/optimize/virtualmethodhoisting/VirtualMethodHoisterLens.java b/src/main/java/com/android/tools/r8/optimize/virtualmethodhoisting/VirtualMethodHoisterLens.java
new file mode 100644
index 0000000..47af9d1
--- /dev/null
+++ b/src/main/java/com/android/tools/r8/optimize/virtualmethodhoisting/VirtualMethodHoisterLens.java
@@ -0,0 +1,64 @@
+// Copyright (c) 2026, the R8 project authors. Please see the AUTHORS file
+// for details. All rights reserved. Use of this source code is governed by a
+// BSD-style license that can be found in the LICENSE file.
+
+package com.android.tools.r8.optimize.virtualmethodhoisting;
+
+import com.android.tools.r8.graph.AppView;
+import com.android.tools.r8.graph.DexMethod;
+import com.android.tools.r8.graph.ProgramMethod;
+import com.android.tools.r8.graph.lens.DefaultNonIdentityGraphLens;
+import com.android.tools.r8.graph.lens.GraphLens;
+import com.android.tools.r8.graph.lens.MethodLookupResult;
+import java.util.IdentityHashMap;
+import java.util.Map;
+import java.util.concurrent.ConcurrentHashMap;
+
+public class VirtualMethodHoisterLens extends DefaultNonIdentityGraphLens {
+
+  private final Map<DexMethod, DexMethod> methodMap;
+
+  public VirtualMethodHoisterLens(AppView<?> appView, Map<DexMethod, DexMethod> methodMap) {
+    super(appView);
+    this.methodMap = methodMap;
+  }
+
+  // Methods.
+
+  @Override
+  public DexMethod getNextMethodSignature(DexMethod method) {
+    return methodMap.getOrDefault(method, method);
+  }
+
+  @Override
+  protected MethodLookupResult internalDescribeLookupMethod(
+      MethodLookupResult previous, DexMethod context, GraphLens codeLens) {
+    DexMethod newReference = methodMap.get(previous.getReference());
+    if (newReference != null) {
+      return MethodLookupResult.builder(this, codeLens)
+          .setReboundReference(newReference)
+          .setReference(newReference)
+          .setPrototypeChanges(previous.getPrototypeChanges())
+          .setType(previous.getType())
+          .build();
+    }
+    return previous.verify(this, codeLens);
+  }
+
+  public static class Builder {
+
+    private final Map<DexMethod, DexMethod> methodMap = new ConcurrentHashMap<>();
+
+    public void map(ProgramMethod from, ProgramMethod to) {
+      methodMap.put(from.getReference(), to.getReference());
+    }
+
+    public boolean isEmpty() {
+      return methodMap.isEmpty();
+    }
+
+    public VirtualMethodHoisterLens build(AppView<?> appView) {
+      return new VirtualMethodHoisterLens(appView, new IdentityHashMap<>(methodMap));
+    }
+  }
+}
diff --git a/src/main/java/com/android/tools/r8/utils/collections/DexMethodSignatureMap.java b/src/main/java/com/android/tools/r8/utils/collections/DexMethodSignatureMap.java
index ee067de..6ec6d19 100644
--- a/src/main/java/com/android/tools/r8/utils/collections/DexMethodSignatureMap.java
+++ b/src/main/java/com/android/tools/r8/utils/collections/DexMethodSignatureMap.java
@@ -94,6 +94,10 @@
     return getOrDefault(method.getSignature(), defaultValue);
   }
 
+  public T getOrDefault(DexClassAndMethod method, T defaultValue) {
+    return getOrDefault(method.getMethodSignature(), defaultValue);
+  }
+
   @Override
   public void forEach(BiConsumer<? super DexMethodSignature, ? super T> action) {
     backing.forEach(action);
@@ -263,6 +267,10 @@
     return remove(method.getSignature());
   }
 
+  public T remove(DexClassAndMethod method) {
+    return remove(method.getMethodSignature());
+  }
+
   public boolean removeIf(BiPredicate<DexMethodSignature, T> predicate) {
     return backing.entrySet().removeIf(entry -> predicate.test(entry.getKey(), entry.getValue()));
   }
diff --git a/src/main/java/com/android/tools/r8/classmerging/ClassMergerMode.java b/src/main/java/com/android/tools/r8/verticalclassmerging/ClassMergerMode.java
similarity index 76%
rename from src/main/java/com/android/tools/r8/classmerging/ClassMergerMode.java
rename to src/main/java/com/android/tools/r8/verticalclassmerging/ClassMergerMode.java
index bb9a37b..bae4e34 100644
--- a/src/main/java/com/android/tools/r8/classmerging/ClassMergerMode.java
+++ b/src/main/java/com/android/tools/r8/verticalclassmerging/ClassMergerMode.java
@@ -1,17 +1,14 @@
 // Copyright (c) 2024, the R8 project authors. Please see the AUTHORS file
 // for details. All rights reserved. Use of this source code is governed by a
 // BSD-style license that can be found in the LICENSE file.
-package com.android.tools.r8.classmerging;
+package com.android.tools.r8.verticalclassmerging;
 
 public enum ClassMergerMode {
   INITIAL,
+  INTERMEDIATE,
   FINAL;
 
   public boolean isInitial() {
     return this == INITIAL;
   }
-
-  public boolean isFinal() {
-    return this == FINAL;
-  }
 }
diff --git a/src/main/java/com/android/tools/r8/verticalclassmerging/IncompleteVerticalClassMergerBridgeCode.java b/src/main/java/com/android/tools/r8/verticalclassmerging/IncompleteVerticalClassMergerBridgeCode.java
index b64c347..e129b0c 100644
--- a/src/main/java/com/android/tools/r8/verticalclassmerging/IncompleteVerticalClassMergerBridgeCode.java
+++ b/src/main/java/com/android/tools/r8/verticalclassmerging/IncompleteVerticalClassMergerBridgeCode.java
@@ -6,7 +6,6 @@
 import static com.android.tools.r8.ir.analysis.type.Nullability.definitelyNotNull;
 import static com.android.tools.r8.ir.analysis.type.Nullability.maybeNull;
 
-import com.android.tools.r8.classmerging.ClassMergerMode;
 import com.android.tools.r8.graph.AppView;
 import com.android.tools.r8.graph.ClasspathMethod;
 import com.android.tools.r8.graph.Code;
diff --git a/src/main/java/com/android/tools/r8/verticalclassmerging/VerticalClassMerger.java b/src/main/java/com/android/tools/r8/verticalclassmerging/VerticalClassMerger.java
index 5af1803..a36a91c 100644
--- a/src/main/java/com/android/tools/r8/verticalclassmerging/VerticalClassMerger.java
+++ b/src/main/java/com/android/tools/r8/verticalclassmerging/VerticalClassMerger.java
@@ -5,7 +5,6 @@
 
 import static com.android.tools.r8.graph.DexClassAndMethod.asProgramMethodOrNull;
 
-import com.android.tools.r8.classmerging.ClassMergerMode;
 import com.android.tools.r8.classmerging.ClassMergerSharedData;
 import com.android.tools.r8.classmerging.Policy;
 import com.android.tools.r8.graph.AppView;
@@ -72,7 +71,7 @@
   public static VerticalClassMerger createForIntermediateClassMerging(
       AppView<AppInfoWithLiveness> appView, Timing timing) {
     timing.begin("VerticalClassMerger (2/3)");
-    return new VerticalClassMerger(appView, ClassMergerMode.FINAL);
+    return new VerticalClassMerger(appView, ClassMergerMode.INTERMEDIATE);
   }
 
   public static VerticalClassMerger createForFinalClassMerging(
diff --git a/src/main/java/com/android/tools/r8/verticalclassmerging/VerticalClassMergerOptions.java b/src/main/java/com/android/tools/r8/verticalclassmerging/VerticalClassMergerOptions.java
index aaeae0e..32ce821 100644
--- a/src/main/java/com/android/tools/r8/verticalclassmerging/VerticalClassMergerOptions.java
+++ b/src/main/java/com/android/tools/r8/verticalclassmerging/VerticalClassMergerOptions.java
@@ -3,17 +3,18 @@
 // BSD-style license that can be found in the LICENSE file.
 package com.android.tools.r8.verticalclassmerging;
 
-import com.android.tools.r8.classmerging.ClassMergerMode;
 import com.android.tools.r8.utils.InternalOptions;
+import java.util.function.Predicate;
 
 public class VerticalClassMergerOptions {
 
   private final InternalOptions options;
 
   private boolean enabled = true;
-  private boolean enableInitial = true;
   private boolean enableBridgeAnalysis = true;
 
+  private Predicate<ClassMergerMode> modePredicate = null;
+
   public VerticalClassMergerOptions(InternalOptions options) {
     this.options = options;
   }
@@ -23,7 +24,11 @@
   }
 
   public void disableInitial() {
-    enableInitial = false;
+    if (modePredicate == null) {
+      modePredicate = mode -> mode != ClassMergerMode.INITIAL;
+    } else {
+      modePredicate = modePredicate.and(mode -> mode != ClassMergerMode.INITIAL);
+    }
   }
 
   public boolean isEnabled(ClassMergerMode mode) {
@@ -34,7 +39,7 @@
         || !options.isShrinking()) {
       return false;
     }
-    if (mode.isInitial() && !enableInitial) {
+    if (modePredicate != null && !modePredicate.test(mode)) {
       return false;
     }
     return true;
@@ -51,4 +56,8 @@
   public void setEnableBridgeAnalysis(boolean enableBridgeAnalysis) {
     this.enableBridgeAnalysis = enableBridgeAnalysis;
   }
+
+  public void setModePredicate(Predicate<ClassMergerMode> modePredicate) {
+    this.modePredicate = modePredicate;
+  }
 }
diff --git a/src/test/java/com/android/tools/r8/optimize/virtualmethodhoisting/VirtualMethodHoistingTest.java b/src/test/java/com/android/tools/r8/optimize/virtualmethodhoisting/VirtualMethodHoistingTest.java
new file mode 100644
index 0000000..2cd16d0
--- /dev/null
+++ b/src/test/java/com/android/tools/r8/optimize/virtualmethodhoisting/VirtualMethodHoistingTest.java
@@ -0,0 +1,102 @@
+// Copyright (c) 2026, the R8 project authors. Please see the AUTHORS file
+// for details. All rights reserved. Use of this source code is governed by a
+// BSD-style license that can be found in the LICENSE file.
+package com.android.tools.r8.optimize.virtualmethodhoisting;
+
+import static com.android.tools.r8.utils.codeinspector.Matchers.isAbsent;
+import static com.android.tools.r8.utils.codeinspector.Matchers.isAbstract;
+import static com.android.tools.r8.utils.codeinspector.Matchers.isPresent;
+import static org.hamcrest.CoreMatchers.not;
+import static org.hamcrest.MatcherAssert.assertThat;
+import static org.junit.Assert.assertTrue;
+
+import com.android.tools.r8.NoHorizontalClassMerging;
+import com.android.tools.r8.TestBase;
+import com.android.tools.r8.TestParameters;
+import com.android.tools.r8.TestParametersCollection;
+import com.android.tools.r8.utils.codeinspector.ClassSubject;
+import com.android.tools.r8.utils.codeinspector.CodeInspector;
+import com.android.tools.r8.utils.codeinspector.MethodSubject;
+import org.junit.Test;
+import org.junit.runner.RunWith;
+import org.junit.runners.Parameterized;
+import org.junit.runners.Parameterized.Parameter;
+import org.junit.runners.Parameterized.Parameters;
+
+@RunWith(Parameterized.class)
+public class VirtualMethodHoistingTest extends TestBase {
+
+  @Parameter(0)
+  public TestParameters parameters;
+
+  @Parameters(name = "{0}")
+  public static TestParametersCollection data() {
+    return getTestParameters().withAllRuntimesAndApiLevels().build();
+  }
+
+  @Test
+  public void test() throws Exception {
+    testForR8(parameters)
+        .addInnerClasses(getClass())
+        .addKeepMainRule(Main.class)
+        .enableNoHorizontalClassMergingAnnotations()
+        .compile()
+        .inspect(this::inspect)
+        .run(parameters.getRuntime(), Main.class)
+        .assertSuccessWithOutputLines("1", "2");
+  }
+
+  private void inspect(CodeInspector inspector) {
+    ClassSubject baseClass = inspector.clazz(Base.class);
+    assertThat(baseClass, isPresent());
+
+    // Base#method should no longer be abstract
+    MethodSubject baseMethod = baseClass.uniqueMethodWithOriginalName("method");
+    assertThat(baseMethod, isPresent());
+    assertThat(baseMethod, not(isAbstract()));
+    assertTrue(
+        baseMethod.streamInstructions().anyMatch(instruction -> instruction.isConstNumber(1)));
+
+    // A#method should be hoisted to Base, so A should no longer have it.
+    ClassSubject aClass = inspector.clazz(A.class);
+    assertThat(aClass, isPresent());
+    assertThat(aClass.uniqueMethodWithOriginalName("method"), isAbsent());
+
+    // B#method should still be present.
+    ClassSubject bClass = inspector.clazz(B.class);
+    assertThat(bClass, isPresent());
+    assertThat(bClass.uniqueMethodWithOriginalName("method"), isPresent());
+  }
+
+  static class Main {
+
+    public static void main(String[] args) {
+      Base a = System.currentTimeMillis() > 0 ? new A() : new B();
+      Base b = System.currentTimeMillis() > 0 ? new B() : new A();
+      System.out.println(a.method());
+      System.out.println(b.method());
+    }
+  }
+
+  public abstract static class Base {
+    public abstract int method();
+  }
+
+  @NoHorizontalClassMerging
+  public static class A extends Base {
+
+    @Override
+    public int method() {
+      return 1;
+    }
+  }
+
+  @NoHorizontalClassMerging
+  public static class B extends Base {
+
+    @Override
+    public int method() {
+      return 2;
+    }
+  }
+}
diff --git a/src/test/java/com/android/tools/r8/optimize/virtualmethodhoisting/VirtualMethodHoistingTwoLevelsTest.java b/src/test/java/com/android/tools/r8/optimize/virtualmethodhoisting/VirtualMethodHoistingTwoLevelsTest.java
new file mode 100644
index 0000000..0fae771
--- /dev/null
+++ b/src/test/java/com/android/tools/r8/optimize/virtualmethodhoisting/VirtualMethodHoistingTwoLevelsTest.java
@@ -0,0 +1,115 @@
+// Copyright (c) 2026, the R8 project authors. Please see the AUTHORS file
+// for details. All rights reserved. Use of this source code is governed by a
+// BSD-style license that can be found in the LICENSE file.
+package com.android.tools.r8.optimize.virtualmethodhoisting;
+
+import static com.android.tools.r8.utils.codeinspector.Matchers.isAbsent;
+import static com.android.tools.r8.utils.codeinspector.Matchers.isAbstract;
+import static com.android.tools.r8.utils.codeinspector.Matchers.isPresent;
+import static org.hamcrest.CoreMatchers.not;
+import static org.hamcrest.MatcherAssert.assertThat;
+import static org.junit.Assert.assertTrue;
+
+import com.android.tools.r8.NoHorizontalClassMerging;
+import com.android.tools.r8.NoVerticalClassMerging;
+import com.android.tools.r8.TestBase;
+import com.android.tools.r8.TestParameters;
+import com.android.tools.r8.TestParametersCollection;
+import com.android.tools.r8.utils.codeinspector.ClassSubject;
+import com.android.tools.r8.utils.codeinspector.CodeInspector;
+import com.android.tools.r8.utils.codeinspector.MethodSubject;
+import org.junit.Test;
+import org.junit.runner.RunWith;
+import org.junit.runners.Parameterized;
+import org.junit.runners.Parameterized.Parameter;
+import org.junit.runners.Parameterized.Parameters;
+
+@RunWith(Parameterized.class)
+public class VirtualMethodHoistingTwoLevelsTest extends TestBase {
+
+  @Parameter(0)
+  public TestParameters parameters;
+
+  @Parameters(name = "{0}")
+  public static TestParametersCollection data() {
+    return getTestParameters().withAllRuntimesAndApiLevels().build();
+  }
+
+  @Test
+  public void test() throws Exception {
+    testForR8(parameters)
+        .addInnerClasses(getClass())
+        .addKeepMainRule(Main.class)
+        .enableNoHorizontalClassMergingAnnotations()
+        .enableNoVerticalClassMergingAnnotations()
+        .compile()
+        .inspect(this::inspect)
+        .run(parameters.getRuntime(), Main.class)
+        .assertSuccessWithOutputLines("1", "2");
+  }
+
+  private void inspect(CodeInspector inspector) {
+    ClassSubject baseClass = inspector.clazz(Base.class);
+    assertThat(baseClass, isPresent());
+
+    // Base#method should no longer be abstract (it should have hoisted A#method)
+    MethodSubject baseMethod = baseClass.uniqueMethodWithOriginalName("method");
+    assertThat(baseMethod, isPresent());
+    assertThat(baseMethod, not(isAbstract()));
+    assertTrue(
+        baseMethod.streamInstructions().anyMatch(instruction -> instruction.isConstNumber(1)));
+
+    // Sub should not have the method (it inherits from Base)
+    ClassSubject subClass = inspector.clazz(Sub.class);
+    assertThat(subClass, isPresent());
+    assertThat(subClass.uniqueMethodWithOriginalName("method"), isAbsent());
+
+    // A#method should be hoisted to Base, so A should no longer have it.
+    ClassSubject aClass = inspector.clazz(A.class);
+    assertThat(aClass, isPresent());
+    assertThat(aClass.uniqueMethodWithOriginalName("method"), isAbsent());
+
+    // Z#method should still be present.
+    ClassSubject zClass = inspector.clazz(Z.class);
+    assertThat(zClass, isPresent());
+    assertThat(zClass.uniqueMethodWithOriginalName("method"), isPresent());
+  }
+
+  static class Main {
+
+    public static void main(String[] args) {
+      Base a = System.currentTimeMillis() > 0 ? new A() : new Z();
+      Base b = System.currentTimeMillis() > 0 ? new Z() : new A();
+      System.out.println(a.method());
+      System.out.println(b.method());
+    }
+  }
+
+  public abstract static class Base {
+    public abstract int method();
+  }
+
+  @NoVerticalClassMerging
+  @NoHorizontalClassMerging
+  public abstract static class Sub extends Base {}
+
+  @NoVerticalClassMerging
+  @NoHorizontalClassMerging
+  public static class A extends Sub {
+
+    @Override
+    public int method() {
+      return 1;
+    }
+  }
+
+  @NoVerticalClassMerging
+  @NoHorizontalClassMerging
+  public static class Z extends Base {
+
+    @Override
+    public int method() {
+      return 2;
+    }
+  }
+}
diff --git a/src/test/java/com/android/tools/r8/optimize/virtualmethodhoisting/VirtualMethodHoistingWithNonReboundReferenceTest.java b/src/test/java/com/android/tools/r8/optimize/virtualmethodhoisting/VirtualMethodHoistingWithNonReboundReferenceTest.java
new file mode 100644
index 0000000..e93f769
--- /dev/null
+++ b/src/test/java/com/android/tools/r8/optimize/virtualmethodhoisting/VirtualMethodHoistingWithNonReboundReferenceTest.java
@@ -0,0 +1,197 @@
+// Copyright (c) 2026, the R8 project authors. Please see the AUTHORS file
+// for details. All rights reserved. Use of this source code is governed by a
+// BSD-style license that can be found in the LICENSE file.
+package com.android.tools.r8.optimize.virtualmethodhoisting;
+
+import static com.android.tools.r8.utils.codeinspector.CodeMatchers.invokesMethod;
+import static com.android.tools.r8.utils.codeinspector.Matchers.isAbsent;
+import static com.android.tools.r8.utils.codeinspector.Matchers.isAbstract;
+import static com.android.tools.r8.utils.codeinspector.Matchers.isPresent;
+import static org.hamcrest.CoreMatchers.not;
+import static org.hamcrest.MatcherAssert.assertThat;
+import static org.junit.Assert.assertTrue;
+
+import com.android.tools.r8.KeepUnusedArguments;
+import com.android.tools.r8.NeverClassInline;
+import com.android.tools.r8.NeverInline;
+import com.android.tools.r8.NeverPropagateValue;
+import com.android.tools.r8.NoAccessModification;
+import com.android.tools.r8.NoHorizontalClassMerging;
+import com.android.tools.r8.NoParameterTypeStrengthening;
+import com.android.tools.r8.NoVerticalClassMerging;
+import com.android.tools.r8.TestBase;
+import com.android.tools.r8.TestParameters;
+import com.android.tools.r8.TestParametersCollection;
+import com.android.tools.r8.utils.codeinspector.ClassSubject;
+import com.android.tools.r8.utils.codeinspector.CodeInspector;
+import com.android.tools.r8.utils.codeinspector.MethodSubject;
+import com.android.tools.r8.verticalclassmerging.ClassMergerMode;
+import com.google.common.collect.ImmutableList;
+import java.util.List;
+import org.junit.Test;
+import org.junit.runner.RunWith;
+import org.junit.runners.Parameterized;
+import org.junit.runners.Parameterized.Parameter;
+import org.junit.runners.Parameterized.Parameters;
+
+/**
+ * Tests that VirtualMethodHoister does not introduce IllegalAccessErrors from rewriting invokes to
+ * methods higher up in the class hierarchy that are inaccessible to callers. Instead, the
+ * VirtualMethodHoister should leave such invokes unchanged, meaning the invokes no longer point
+ * directly to a method definition.
+ */
+@RunWith(Parameterized.class)
+public class VirtualMethodHoistingWithNonReboundReferenceTest extends TestBase {
+
+  @Parameter(0)
+  public TestParameters parameters;
+
+  @Parameters(name = "{0}")
+  public static TestParametersCollection data() {
+    return getTestParameters().withAllRuntimesAndApiLevels().build();
+  }
+
+  @Test
+  public void testJvm() throws Exception {
+    parameters.assumeJvmTestParameters();
+    testForJvm(parameters)
+        .addProgramClasses(Base.class, ASuper.class, B.class, S.class, T.class)
+        .addProgramClassFileData(getProgramClassFileData())
+        .run(parameters.getRuntime(), Main.class)
+        .assertSuccessWithOutputLines("1", "2", "3");
+  }
+
+  @Test
+  public void test() throws Exception {
+    testForR8(parameters)
+        .addProgramClasses(Base.class, ASuper.class, B.class, S.class, T.class)
+        .addProgramClassFileData(getProgramClassFileData())
+        .addKeepMainRule(Main.class)
+        .addOptionsModification(
+            o ->
+                o.getVerticalClassMergerOptions()
+                    .setModePredicate(mode -> mode == ClassMergerMode.FINAL))
+        .enableInliningAnnotations()
+        .enableNeverClassInliningAnnotations()
+        .enableMemberValuePropagationAnnotations()
+        .enableNoAccessModificationAnnotationsForClasses()
+        .enableNoHorizontalClassMergingAnnotations()
+        .enableNoParameterTypeStrengtheningAnnotations()
+        .enableNoVerticalClassMergingAnnotations()
+        .enableUnusedArgumentAnnotations()
+        .compile()
+        .inspect(this::inspect)
+        .run(parameters.getRuntime(), Main.class)
+        .assertSuccessWithOutputLines("1", "2", "3");
+  }
+
+  private List<byte[]> getProgramClassFileData() {
+    return ImmutableList.of(
+        transformer(Main.class)
+            .replaceClassDescriptorInMethodInstructions(descriptor(A.class), "Lpkg/A;")
+            .transform(),
+        transformer(A.class).setClassDescriptor("Lpkg/A;").transform());
+  }
+
+  private void inspect(CodeInspector inspector) {
+    ClassSubject baseClass = inspector.clazz(Base.class);
+    assertThat(baseClass, isPresent());
+
+    // S should be merged into T.
+    ClassSubject sClass = inspector.clazz(S.class);
+    assertThat(sClass, isAbsent());
+
+    ClassSubject tClass = inspector.clazz(T.class);
+    assertThat(tClass, isPresent());
+
+    // Base#method should no longer be abstract
+    MethodSubject baseMethod = baseClass.uniqueMethodWithOriginalName("method");
+    assertThat(baseMethod, isPresent());
+    assertThat(baseMethod, not(isAbstract()));
+    assertTrue(
+        baseMethod.streamInstructions().anyMatch(instruction -> instruction.isConstNumber(1)));
+
+    // A#method should be hoisted to Base, so A should no longer have it.
+    ClassSubject aClass = inspector.clazz("pkg.A");
+    assertThat(aClass, isPresent());
+    assertThat(aClass.uniqueMethodWithOriginalName("method"), isAbsent());
+
+    // A#callMethod should NOT be updated to call Base#method, since it doesn't have access to Base.
+    // Moreover, the non-rebound method reference that A#method(S) should be correctly rewritten
+    // to A#method(T) when the final round of the vertical class merger merges S into T.
+    MethodSubject callMethodSubject = aClass.uniqueMethodWithOriginalName("callMethod");
+    assertThat(callMethodSubject, isPresent());
+    assertThat(
+        callMethodSubject,
+        invokesMethod(
+            null,
+            aClass.getFinalName(),
+            baseMethod.getFinalName(),
+            ImmutableList.of(tClass.getFinalName())));
+
+    // B#method should still be present.
+    ClassSubject bClass = inspector.clazz(B.class);
+    assertThat(bClass, isPresent());
+    assertThat(bClass.uniqueMethodWithOriginalName("method"), isPresent());
+  }
+
+  public static class Main {
+
+    public static void main(String[] args) {
+      Base a = System.currentTimeMillis() > 0 ? new A() : new B();
+      Base b = System.currentTimeMillis() > 0 ? new B() : new A();
+      System.out.println(a.method(new T()));
+      System.out.println(b.method(new T()));
+      System.out.println(new A().callMethod(new T()));
+    }
+  }
+
+  @NoAccessModification
+  @NoVerticalClassMerging
+  abstract static class Base {
+
+    @NoParameterTypeStrengthening
+    public abstract int method(S s);
+  }
+
+  @NoVerticalClassMerging
+  public abstract static class ASuper extends Base {}
+
+  @NeverClassInline
+  @NoHorizontalClassMerging
+  public static class /*pkg.*/ A extends ASuper {
+
+    @KeepUnusedArguments
+    @NeverInline
+    @NeverPropagateValue
+    @NoParameterTypeStrengthening
+    @Override
+    public int method(S s) {
+      return 1;
+    }
+
+    @KeepUnusedArguments
+    @NeverInline
+    @NeverPropagateValue
+    @NoParameterTypeStrengthening
+    public int callMethod(S s) {
+      return method(s) + 2;
+    }
+  }
+
+  @NoHorizontalClassMerging
+  public static class B extends Base {
+
+    @KeepUnusedArguments
+    @NoParameterTypeStrengthening
+    @Override
+    public int method(S s) {
+      return 2;
+    }
+  }
+
+  public static class S {}
+
+  @NeverClassInline
+  public static class T extends S {}
+}