Refactor Timing

Create an abstract Timing superclass with subclasses not sharing state.

Bug: b/399835968
Change-Id: I01ef679ab4e4b1bf0b8e58cc6c838a929ebb4ca9
diff --git a/src/main/java/com/android/tools/r8/utils/Timing.java b/src/main/java/com/android/tools/r8/utils/Timing.java
index 9cbc7e9..d06c926 100644
--- a/src/main/java/com/android/tools/r8/utils/Timing.java
+++ b/src/main/java/com/android/tools/r8/utils/Timing.java
@@ -3,133 +3,16 @@
 // BSD-style license that can be found in the LICENSE file.
 package com.android.tools.r8.utils;
 
-// Helper for collecting timing information during execution.
-// Timing t = new Timing("R8");
-// A timing tree is collected by calling the following pair (nesting will create the tree):
-//     t.begin("My task);
-//     try { ... } finally { t.end(); }
-// or alternatively:
-//     t.scope("My task", () -> { ... });
-// Finally a report is printed by:
-//     t.report();
-
-import java.util.ArrayDeque;
+import com.android.tools.r8.utils.timing.TimingEmpty;
+import com.android.tools.r8.utils.timing.TimingImpl;
+import com.android.tools.r8.utils.timing.TimingWithCancellation;
 import java.util.Collection;
-import java.util.Deque;
-import java.util.LinkedHashMap;
-import java.util.Map;
-import java.util.Map.Entry;
 import java.util.concurrent.ExecutorService;
 
-public class Timing implements AutoCloseable {
-
-  private static final int MINIMUM_REPORT_MS =
-      SystemPropertyUtils.parseSystemPropertyOrDefault(
-          "com.android.tools.r8.printtimes.minvalue_ms", 10);
-  private static final int MINIMUM_REPORT_PERCENTAGE =
-      SystemPropertyUtils.parseSystemPropertyOrDefault(
-          "com.android.tools.r8.printtimes.minvalue", 0);
-
-  private static final Timing EMPTY =
-      new Timing("<empty>", false) {
-        @Override
-        public TimingMerger beginMerger(String title, int numberOfThreads) {
-          return new TimingMerger(null, -1, this) {
-            @Override
-            public void add(Collection<Timing> timings) {
-              // Ignore.
-            }
-
-            @Override
-            public void end() {
-              // Ignore.
-            }
-
-            @Override
-            public boolean isEmpty() {
-              return true;
-            }
-          };
-        }
-
-        @Override
-        public Timing begin(String title) {
-          // Ignore.
-          return this;
-        }
-
-        @Override
-        public Timing end() {
-          // Ignore.
-          return this;
-        }
-
-        @Override
-        public void report() {
-          // Ignore.
-        }
-      };
+public abstract class Timing implements AutoCloseable {
 
   public static Timing empty() {
-    return Timing.EMPTY;
-  }
-
-  private abstract static class TimingDelegateBase extends Timing {
-    private final Timing timing;
-
-    public TimingDelegateBase(String title, Timing timing) {
-      super(title);
-      this.timing = timing;
-    }
-
-    @Override
-    public TimingMerger beginMerger(String title, int numberOfThreads) {
-      return timing.beginMerger(title, numberOfThreads);
-    }
-
-    @Override
-    public Timing begin(String title) {
-      timing.begin(title);
-      return this;
-    }
-
-    @Override
-    public <E extends Exception> void time(String title, ThrowingAction<E> action) throws E {
-      timing.time(title, action);
-    }
-
-    @Override
-    public <T, E extends Exception> T time(String title, ThrowingSupplier<T, E> supplier) throws E {
-      return timing.time(title, supplier);
-    }
-
-    @Override
-    public Timing end() {
-      timing.end();
-      return this;
-    }
-
-    @Override
-    public void report() {
-      timing.report();
-    }
-  }
-
-  private static class TimingWithCancellation extends TimingDelegateBase {
-    private final InternalOptions options;
-
-    TimingWithCancellation(InternalOptions options, Timing timing) {
-      super("<cancel>", timing);
-      this.options = options;
-    }
-
-    @Override
-    public Timing begin(String title) {
-      if (options.checkIfCancelled()) {
-        throw new CancelCompilationException();
-      }
-      return super.begin(title);
-    }
+    return TimingEmpty.getEmpty();
   }
 
   public static Timing createRoot(String title, InternalOptions options) {
@@ -143,7 +26,7 @@
     // We also create a timer when running assertions to validate wellformedness of the node stack.
     Timing timing =
         options.printTimes || InternalOptions.assertionsEnabled()
-            ? new Timing(title, options.printMemory)
+            ? new TimingImpl(title, options)
             : Timing.empty();
     if (options.cancelCompilationChecker != null) {
       return new TimingWithCancellation(options, timing);
@@ -151,387 +34,34 @@
     return timing;
   }
 
-  public static Timing create(String title, boolean printMemory) {
-    return new Timing(title, printMemory);
-  }
+  public abstract Timing begin(String title);
 
-  private final Node top;
-  private final Deque<Node> stack;
-  private final boolean trackMemory;
+  public abstract Timing end();
 
-  @Deprecated
-  private Timing(String title) {
-    this(title, false);
-  }
-
-  private Timing(String title, boolean trackMemory) {
-    this.trackMemory = trackMemory;
-    stack = new ArrayDeque<>();
-    top = new Node(title, trackMemory);
-    stack.push(top);
-  }
-
-  private static class MemInfo {
-    final long used;
-
-    MemInfo(long used) {
-      this.used = used;
-    }
-
-    public static MemInfo fromTotalAndFree(long total, long free) {
-      return new MemInfo(total - free);
-    }
-
-    long usedDelta(MemInfo previous) {
-      return used - previous.used;
-    }
-  }
-
-  static class Node {
-    final String title;
-    final boolean trackMemory;
-
-    final Map<String, Node> children = new LinkedHashMap<>();
-    long duration = 0;
-    long start_time;
-    Map<String, MemInfo> startMemory;
-    Map<String, MemInfo> endMemory;
-
-    Node(String title, boolean trackMemory) {
-      this.title = title;
-      this.trackMemory = trackMemory;
-      if (trackMemory) {
-        startMemory = computeMemoryInformation();
-      }
-      this.start_time = System.nanoTime();
-    }
-
-    void restart() {
-      assert start_time == -1;
-      if (trackMemory) {
-        startMemory = computeMemoryInformation();
-      }
-      start_time = System.nanoTime();
-    }
-
-    void end() {
-      duration += System.nanoTime() - start_time;
-      start_time = -1;
-      assert duration() >= 0;
-      if (trackMemory) {
-        endMemory = computeMemoryInformation();
-      }
-    }
-
-    long duration() {
-      return duration;
-    }
-
-    @Override
-    public String toString() {
-      return title + ": " + prettyTime(duration());
-    }
-
-    public String toString(Node top) {
-      if (this == top) return toString();
-      return "(" + prettyPercentage(duration(), top.duration()) + ") " + toString();
-    }
-
-    public void report(int depth, Node top) {
-      assert duration() >= 0;
-      if (durationInMs(duration()) < MINIMUM_REPORT_MS) {
-        return;
-      }
-      if (percentage(duration(), top.duration()) < MINIMUM_REPORT_PERCENTAGE) {
-        return;
-      }
-      printPrefix(depth);
-      System.out.println(toString(top));
-      if (trackMemory) {
-        printMemory(depth);
-      }
-      if (children.isEmpty()) {
-        return;
-      }
-      Collection<Node> childNodes = children.values();
-      long childTime = 0;
-      for (Node childNode : childNodes) {
-        childTime += childNode.duration();
-      }
-      if (childTime < duration()) {
-        long unaccounted = duration() - childTime;
-        if (durationInMs(unaccounted) >= MINIMUM_REPORT_MS
-            && percentage(unaccounted, top.duration()) >= MINIMUM_REPORT_PERCENTAGE) {
-          printPrefix(depth + 1);
-          System.out.println(
-              "("
-                  + prettyPercentage(unaccounted, top.duration())
-                  + ") Unaccounted: "
-                  + prettyTime(unaccounted));
-        }
-      }
-      childNodes.forEach(p -> p.report(depth + 1, top));
-
-    }
-
-    void printPrefix(int depth) {
-      if (depth > 0) {
-        System.out.print("  ".repeat(depth));
-        System.out.print("- ");
-      }
-    }
-
-    void printMemory(int depth) {
-      for (Entry<String, MemInfo> start : startMemory.entrySet()) {
-        if (start.getKey().equals("Memory")) {
-          for (int i = 0; i <= depth; i++) {
-            System.out.print("  ");
-          }
-          MemInfo endValue = endMemory.get(start.getKey());
-          MemInfo startValue = start.getValue();
-          System.out.println(
-              start.getKey()
-                  + " start: "
-                  + prettySize(startValue.used)
-                  + ", end: "
-                  + prettySize(endValue.used)
-                  + ", delta: "
-                  + prettySize(endValue.usedDelta(startValue)));
-        }
-      }
-    }
-  }
-
-  public static class TimingMerger {
-    final Node parent;
-    final Node merged;
-
-    private int taskCount = 0;
-    private Node slowest = new Node("<zero>", false);
-
-    private TimingMerger(String title, int numberOfThreads, Timing timing) {
-      Deque<Timing.Node> stack =
-          timing instanceof TimingDelegateBase
-              ? ((TimingDelegateBase) timing).timing.stack
-              : timing.stack;
-      parent = stack.peek();
-      merged =
-          new Node(title, timing.trackMemory) {
-            @Override
-            public void report(int depth, Node top) {
-              assert duration() >= 0;
-              printPrefix(depth);
-              System.out.print(toString());
-              if (numberOfThreads <= 0) {
-                System.out.println(" (unknown thread count)");
-              } else {
-                long walltime = parent.duration();
-                long perThreadTime = duration() / numberOfThreads;
-                System.out.println(
-                    ", tasks: "
-                        + taskCount
-                        + ", threads: "
-                        + numberOfThreads
-                        + ", utilization: "
-                        + prettyPercentage(perThreadTime, walltime));
-              }
-              if (trackMemory) {
-                printMemory(depth);
-              }
-              // Report children with this merge node as "top" so times are relative to the total
-              // merge.
-              children.forEach((title, node) -> node.report(depth + 1, this));
-              // Print the slowest entry if one was found.
-              if (slowest != null && slowest.duration > 0) {
-                printPrefix(depth);
-                System.out.println("SLOWEST " + slowest.toString(this));
-                slowest.children.forEach((title, node) -> node.report(depth + 1, this));
-              }
-            }
-
-            @Override
-            public String toString() {
-              return "MERGE " + super.toString();
-            }
-          };
-    }
-
-    public TimingMerger disableSlowestReporting() {
-      slowest = null;
-      return this;
-    }
-
-    public boolean isEmpty() {
-      return false;
-    }
-
-    private static class Item {
-      final Node mergeTarget;
-      final Node mergeSource;
-
-      public Item(Node mergeTarget, Node mergeSource) {
-        this.mergeTarget = mergeTarget;
-        this.mergeSource = mergeSource;
-      }
-    }
-
-    public void add(Collection<Timing> timings) {
-      final boolean trackMemory = merged.trackMemory;
-      Deque<Item> worklist = new ArrayDeque<>();
-      for (Timing timing : timings) {
-        if (timing == empty()) {
-          continue;
-        }
-        Deque<Timing.Node> stack =
-            timing instanceof TimingDelegateBase
-                ? ((TimingDelegateBase) timing).timing.stack
-                : timing.stack;
-        assert stack.isEmpty() : "Expected sub-timing to have completed prior to merge";
-        ++taskCount;
-        merged.duration += timing.top.duration;
-        if (slowest != null && timing.top.duration > slowest.duration) {
-          slowest = timing.top;
-        }
-        worklist.addLast(new Item(merged, timing.top));
-      }
-      while (!worklist.isEmpty()) {
-        Item item = worklist.pollFirst();
-        item.mergeSource.children.forEach(
-            (title, child) -> {
-              Node mergeTarget =
-                  item.mergeTarget.children.computeIfAbsent(title, t -> new Node(t, trackMemory));
-              mergeTarget.duration += child.duration;
-              mergeTarget.endMemory = child.endMemory;
-              if (!child.children.isEmpty()) {
-                worklist.addLast(new Item(mergeTarget, child));
-              }
-            });
-      }
-    }
-
-    public void end() {
-      assert verifyUnambiguous(parent, merged.title);
-      merged.end();
-      parent.children.put(merged.title, merged);
-    }
-  }
-
-  private static boolean verifyUnambiguous(Node node, String title) {
-    // If this assertion fails, then the merger is reusing the same name as a previous point.
-    // The point should likely be disambiguated by adding a timing.begin/end in the call chain.
-    assert !node.children.containsKey(title) : "Ambiguous timing chain. Insert a begin/end to fix";
-    return true;
-  }
+  public abstract TimingMerger beginMerger(String title, int numberOfThreads);
 
   public final TimingMerger beginMerger(String title, ExecutorService executorService) {
     return beginMerger(title, ThreadUtils.getNumberOfThreads(executorService));
   }
 
-  public TimingMerger beginMerger(String title, int numberOfThreads) {
-    assert !stack.isEmpty();
-    assert verifyUnambiguous(stack.peekFirst(), title);
-    return new TimingMerger(title, numberOfThreads, this);
-  }
+  public abstract <E extends Exception> void time(String title, ThrowingAction<E> action) throws E;
 
-  private static long durationInMs(long value) {
-    return value / 1000000;
-  }
+  public abstract <T, E extends Exception> T time(String title, ThrowingSupplier<T, E> supplier)
+      throws E;
 
-  private static long percentage(long part, long total) {
-    return part * 100 / total;
-  }
+  public abstract void report();
 
-  private static String prettyPercentage(long part, long total) {
-    return percentage(part, total) + "%";
-  }
-
-  private static String prettyTime(long value) {
-    return durationInMs(value) + "ms";
-  }
-
-  private static String prettySize(long value) {
-    return prettyNumber(value / 1024) + "k";
-  }
-
-  private static String prettyNumber(long value) {
-    String printed = "" + Math.abs(value);
-    if (printed.length() < 4) {
-      return "" + value;
-    }
-    StringBuilder builder = new StringBuilder();
-    if (value < 0) {
-      builder.append('-');
-    }
-    int prefix = printed.length() % 3;
-    builder.append(printed, 0, prefix);
-    for (int i = prefix; i < printed.length(); i += 3) {
-      if (i > 0) {
-        builder.append('.');
-      }
-      builder.append(printed, i, i + 3);
-    }
-    return builder.toString();
-  }
-
-  public Timing begin(String title) {
-    Node parent = stack.peek();
-    Node child;
-    if (parent.children.containsKey(title)) {
-      child = parent.children.get(title);
-      child.restart();
-    } else {
-      child = new Node(title, trackMemory);
-      parent.children.put(title, child);
-    }
-    stack.push(child);
-    return this;
-  }
-
-  public <E extends Exception> void time(String title, ThrowingAction<E> action) throws E {
-    begin(title);
-    try {
-      action.execute();
-    } finally {
-      end();
-    }
-  }
-
-  public <T, E extends Exception> T time(String title, ThrowingSupplier<T, E> supplier) throws E {
-    begin(title);
-    try {
-      return supplier.get();
-    } finally {
-      end();
-    }
-  }
-
+  // Remove throws from close() in AutoClosable to allow try with resources without explicit catch.
   @Override
-  public final void close() {
-    end();
-  }
+  public abstract void close();
 
-  public Timing end() {
-    stack.peek().end();  // record time.
-    stack.pop();
-    return this;
-  }
+  public interface TimingMerger {
+    void add(Collection<Timing> timings);
 
-  public void report() {
-    assert stack.size() == 1 : "Unexpected non-singleton stack: " + stack;
-    Node top = stack.peek();
-    assert top == this.top;
-    top.end();
-    System.out.println("Recorded timings:");
-    top.report(0, top);
-  }
+    void end();
 
-  private static Map<String, MemInfo> computeMemoryInformation() {
-    System.gc();
-    Map<String, MemInfo> info = new LinkedHashMap<>();
-    info.put(
-        "Memory",
-        MemInfo.fromTotalAndFree(
-            Runtime.getRuntime().totalMemory(), Runtime.getRuntime().freeMemory()));
-    return info;
+    boolean isEmpty();
+
+    TimingMerger disableSlowestReporting();
   }
 }
diff --git a/src/main/java/com/android/tools/r8/utils/timing/TimingDelegate.java b/src/main/java/com/android/tools/r8/utils/timing/TimingDelegate.java
new file mode 100644
index 0000000..afb9e04
--- /dev/null
+++ b/src/main/java/com/android/tools/r8/utils/timing/TimingDelegate.java
@@ -0,0 +1,54 @@
+// Copyright (c) 2025, 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.utils.timing;
+
+import com.android.tools.r8.utils.ThrowingAction;
+import com.android.tools.r8.utils.ThrowingSupplier;
+import com.android.tools.r8.utils.Timing;
+
+class TimingDelegate extends Timing {
+
+  private final Timing delegate;
+
+  public TimingDelegate(Timing timing) {
+    this.delegate = timing;
+  }
+
+  @Override
+  public TimingMerger beginMerger(String title, int numberOfThreads) {
+    return delegate.beginMerger(title, numberOfThreads);
+  }
+
+  @Override
+  public Timing begin(String title) {
+    delegate.begin(title);
+    return this;
+  }
+
+  @Override
+  public <E extends Exception> void time(String title, ThrowingAction<E> action) throws E {
+    delegate.time(title, action);
+  }
+
+  @Override
+  public <T, E extends Exception> T time(String title, ThrowingSupplier<T, E> supplier) throws E {
+    return delegate.time(title, supplier);
+  }
+
+  @Override
+  public Timing end() {
+    delegate.end();
+    return this;
+  }
+
+  @Override
+  public void report() {
+    delegate.report();
+  }
+
+  @Override
+  public void close() {
+    delegate.close();
+  }
+}
diff --git a/src/main/java/com/android/tools/r8/utils/timing/TimingEmpty.java b/src/main/java/com/android/tools/r8/utils/timing/TimingEmpty.java
new file mode 100644
index 0000000..9a10999
--- /dev/null
+++ b/src/main/java/com/android/tools/r8/utils/timing/TimingEmpty.java
@@ -0,0 +1,66 @@
+// Copyright (c) 2025, 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.utils.timing;
+
+import com.android.tools.r8.utils.ThrowingAction;
+import com.android.tools.r8.utils.ThrowingSupplier;
+import com.android.tools.r8.utils.Timing;
+import java.util.Collection;
+
+public class TimingEmpty extends Timing {
+  private static TimingEmpty INSTANCE = new TimingEmpty();
+
+  public static Timing getEmpty() {
+    return INSTANCE;
+  }
+
+  private TimingEmpty() {}
+
+  @Override
+  public TimingMerger beginMerger(String title, int numberOfThreads) {
+    return new TimingMerger() {
+      @Override
+      public void add(Collection<Timing> timings) {}
+
+      @Override
+      public void end() {}
+
+      @Override
+      public boolean isEmpty() {
+        return true;
+      }
+
+      @Override
+      public TimingMerger disableSlowestReporting() {
+        return this;
+      }
+    };
+  }
+
+  @Override
+  public Timing begin(String title) {
+    return this;
+  }
+
+  @Override
+  public Timing end() {
+    return this;
+  }
+
+  @Override
+  public <E extends Exception> void time(String title, ThrowingAction<E> action) throws E {
+    action.execute();
+  }
+
+  @Override
+  public <T, E extends Exception> T time(String title, ThrowingSupplier<T, E> supplier) throws E {
+    return supplier.get();
+  }
+
+  @Override
+  public void report() {}
+
+  @Override
+  public void close() {}
+}
diff --git a/src/main/java/com/android/tools/r8/utils/timing/TimingImpl.java b/src/main/java/com/android/tools/r8/utils/timing/TimingImpl.java
new file mode 100644
index 0000000..a796233
--- /dev/null
+++ b/src/main/java/com/android/tools/r8/utils/timing/TimingImpl.java
@@ -0,0 +1,404 @@
+// Copyright (c) 2025, 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.utils.timing;
+
+import com.android.tools.r8.utils.InternalOptions;
+import com.android.tools.r8.utils.SystemPropertyUtils;
+import com.android.tools.r8.utils.ThrowingAction;
+import com.android.tools.r8.utils.ThrowingSupplier;
+import com.android.tools.r8.utils.Timing;
+import java.util.ArrayDeque;
+import java.util.Collection;
+import java.util.Deque;
+import java.util.LinkedHashMap;
+import java.util.Map;
+import java.util.Map.Entry;
+
+public class TimingImpl extends Timing {
+
+  private static final int MINIMUM_REPORT_MS =
+      SystemPropertyUtils.parseSystemPropertyOrDefault(
+          "com.android.tools.r8.printtimes.minvalue_ms", 10);
+  private static final int MINIMUM_REPORT_PERCENTAGE =
+      SystemPropertyUtils.parseSystemPropertyOrDefault(
+          "com.android.tools.r8.printtimes.minvalue", 0);
+
+  private final Node top;
+  private final Deque<Node> stack;
+  private final boolean trackMemory;
+
+  public TimingImpl(String title, InternalOptions options) {
+    this.trackMemory = options.printMemory;
+    stack = new ArrayDeque<>();
+    top = new Node(title, trackMemory);
+    stack.push(top);
+  }
+
+  private static class MemInfo {
+    final long used;
+
+    MemInfo(long used) {
+      this.used = used;
+    }
+
+    public static MemInfo fromTotalAndFree(long total, long free) {
+      return new MemInfo(total - free);
+    }
+
+    long usedDelta(MemInfo previous) {
+      return used - previous.used;
+    }
+  }
+
+  static class Node {
+    final String title;
+    final boolean trackMemory;
+
+    final Map<String, Node> children = new LinkedHashMap<>();
+    long duration = 0;
+    long start_time;
+    Map<String, MemInfo> startMemory;
+    Map<String, MemInfo> endMemory;
+
+    Node(String title, boolean trackMemory) {
+      this.title = title;
+      this.trackMemory = trackMemory;
+      if (trackMemory) {
+        startMemory = computeMemoryInformation();
+      }
+      this.start_time = System.nanoTime();
+    }
+
+    void restart() {
+      assert start_time == -1;
+      if (trackMemory) {
+        startMemory = computeMemoryInformation();
+      }
+      start_time = System.nanoTime();
+    }
+
+    void end() {
+      duration += System.nanoTime() - start_time;
+      start_time = -1;
+      assert duration() >= 0;
+      if (trackMemory) {
+        endMemory = computeMemoryInformation();
+      }
+    }
+
+    long duration() {
+      return duration;
+    }
+
+    @Override
+    public String toString() {
+      return title + ": " + prettyTime(duration());
+    }
+
+    public String toString(Node top) {
+      if (this == top) return toString();
+      return "(" + prettyPercentage(duration(), top.duration()) + ") " + toString();
+    }
+
+    public void report(int depth, Node top) {
+      assert duration() >= 0;
+      if (durationInMs(duration()) < MINIMUM_REPORT_MS) {
+        return;
+      }
+      if (percentage(duration(), top.duration()) < MINIMUM_REPORT_PERCENTAGE) {
+        return;
+      }
+      printPrefix(depth);
+      System.out.println(toString(top));
+      if (trackMemory) {
+        printMemory(depth);
+      }
+      if (children.isEmpty()) {
+        return;
+      }
+      Collection<Node> childNodes = children.values();
+      long childTime = 0;
+      for (Node childNode : childNodes) {
+        childTime += childNode.duration();
+      }
+      if (childTime < duration()) {
+        long unaccounted = duration() - childTime;
+        if (durationInMs(unaccounted) >= MINIMUM_REPORT_MS
+            && percentage(unaccounted, top.duration()) >= MINIMUM_REPORT_PERCENTAGE) {
+          printPrefix(depth + 1);
+          System.out.println(
+              "("
+                  + prettyPercentage(unaccounted, top.duration())
+                  + ") Unaccounted: "
+                  + prettyTime(unaccounted));
+        }
+      }
+      childNodes.forEach(p -> p.report(depth + 1, top));
+    }
+
+    void printPrefix(int depth) {
+      if (depth > 0) {
+        System.out.print("  ".repeat(depth));
+        System.out.print("- ");
+      }
+    }
+
+    void printMemory(int depth) {
+      for (Entry<String, MemInfo> start : startMemory.entrySet()) {
+        if (start.getKey().equals("Memory")) {
+          for (int i = 0; i <= depth; i++) {
+            System.out.print("  ");
+          }
+          MemInfo endValue = endMemory.get(start.getKey());
+          MemInfo startValue = start.getValue();
+          System.out.println(
+              start.getKey()
+                  + " start: "
+                  + prettySize(startValue.used)
+                  + ", end: "
+                  + prettySize(endValue.used)
+                  + ", delta: "
+                  + prettySize(endValue.usedDelta(startValue)));
+        }
+      }
+    }
+  }
+
+  private static class TimingMergerImpl implements TimingMerger {
+
+    final Node parent;
+    final Node merged;
+
+    private int taskCount = 0;
+    private Node slowest = new Node("<zero>", false);
+
+    TimingMergerImpl(String title, int numberOfThreads, TimingImpl timing) {
+      Deque<Node> stack = timing.stack;
+      parent = stack.peek();
+      merged =
+          new Node(title, timing.trackMemory) {
+            @Override
+            public void report(int depth, Node top) {
+              assert duration() >= 0;
+              printPrefix(depth);
+              System.out.print(toString());
+              if (numberOfThreads <= 0) {
+                System.out.println(" (unknown thread count)");
+              } else {
+                long walltime = parent.duration();
+                long perThreadTime = duration() / numberOfThreads;
+                System.out.println(
+                    ", tasks: "
+                        + taskCount
+                        + ", threads: "
+                        + numberOfThreads
+                        + ", utilization: "
+                        + TimingImpl.prettyPercentage(perThreadTime, walltime));
+              }
+              if (trackMemory) {
+                printMemory(depth);
+              }
+              // Report children with this merge node as "top" so times are relative to the total
+              // merge.
+              children.forEach((title, node) -> node.report(depth + 1, this));
+              // Print the slowest entry if one was found.
+              if (slowest != null && slowest.duration > 0) {
+                printPrefix(depth);
+                System.out.println("SLOWEST " + slowest.toString(this));
+                slowest.children.forEach((title, node) -> node.report(depth + 1, this));
+              }
+            }
+
+            @Override
+            public String toString() {
+              return "MERGE " + super.toString();
+            }
+          };
+    }
+
+    @Override
+    public TimingMerger disableSlowestReporting() {
+      slowest = null;
+      return this;
+    }
+
+    @Override
+    public boolean isEmpty() {
+      return false;
+    }
+
+    private static class Item {
+
+      final Node mergeTarget;
+      final Node mergeSource;
+
+      public Item(Node mergeTarget, Node mergeSource) {
+        this.mergeTarget = mergeTarget;
+        this.mergeSource = mergeSource;
+      }
+    }
+
+    @Override
+    public void add(Collection<Timing> timings) {
+      final boolean trackMemory = merged.trackMemory;
+      Deque<Item> worklist = new ArrayDeque<>();
+      for (Timing timing : timings) {
+        if (timing == Timing.empty()) {
+          continue;
+        }
+        assert timing instanceof TimingImpl;
+        TimingImpl timingImpl = (TimingImpl) timing;
+        Deque<Node> stack = timingImpl.stack;
+        assert stack.isEmpty() : "Expected sub-timing to have completed prior to merge";
+        ++taskCount;
+        merged.duration += timingImpl.top.duration;
+        if (slowest != null && timingImpl.top.duration > slowest.duration) {
+          slowest = timingImpl.top;
+        }
+        worklist.addLast(new Item(merged, timingImpl.top));
+      }
+      while (!worklist.isEmpty()) {
+        Item item = worklist.pollFirst();
+        item.mergeSource.children.forEach(
+            (title, child) -> {
+              Node mergeTarget =
+                  item.mergeTarget.children.computeIfAbsent(title, t -> new Node(t, trackMemory));
+              mergeTarget.duration += child.duration;
+              mergeTarget.endMemory = child.endMemory;
+              if (!child.children.isEmpty()) {
+                worklist.addLast(new Item(mergeTarget, child));
+              }
+            });
+      }
+    }
+
+    @Override
+    public void end() {
+      assert TimingImpl.verifyUnambiguous(parent, merged.title);
+      merged.end();
+      parent.children.put(merged.title, merged);
+    }
+  }
+
+  private static boolean verifyUnambiguous(Node node, String title) {
+    // If this assertion fails, then the merger is reusing the same name as a previous point.
+    // The point should likely be disambiguated by adding a timing.begin/end in the call chain.
+    assert !node.children.containsKey(title) : "Ambiguous timing chain. Insert a begin/end to fix";
+    return true;
+  }
+
+  @Override
+  public TimingMerger beginMerger(String title, int numberOfThreads) {
+    assert !stack.isEmpty();
+    assert verifyUnambiguous(stack.peekFirst(), title);
+    return new TimingMergerImpl(title, numberOfThreads, this);
+  }
+
+  private static long durationInMs(long value) {
+    return value / 1000000;
+  }
+
+  private static long percentage(long part, long total) {
+    return part * 100 / total;
+  }
+
+  private static String prettyPercentage(long part, long total) {
+    return percentage(part, total) + "%";
+  }
+
+  private static String prettyTime(long value) {
+    return durationInMs(value) + "ms";
+  }
+
+  private static String prettySize(long value) {
+    return prettyNumber(value / 1024) + "k";
+  }
+
+  private static String prettyNumber(long value) {
+    String printed = "" + Math.abs(value);
+    if (printed.length() < 4) {
+      return "" + value;
+    }
+    StringBuilder builder = new StringBuilder();
+    if (value < 0) {
+      builder.append('-');
+    }
+    int prefix = printed.length() % 3;
+    builder.append(printed, 0, prefix);
+    for (int i = prefix; i < printed.length(); i += 3) {
+      if (i > 0) {
+        builder.append('.');
+      }
+      builder.append(printed, i, i + 3);
+    }
+    return builder.toString();
+  }
+
+  @Override
+  public Timing begin(String title) {
+    Node parent = stack.peek();
+    Node child;
+    if (parent.children.containsKey(title)) {
+      child = parent.children.get(title);
+      child.restart();
+    } else {
+      child = new Node(title, trackMemory);
+      parent.children.put(title, child);
+    }
+    stack.push(child);
+    return this;
+  }
+
+  @Override
+  public <E extends Exception> void time(String title, ThrowingAction<E> action) throws E {
+    begin(title);
+    try {
+      action.execute();
+    } finally {
+      end();
+    }
+  }
+
+  @Override
+  public <T, E extends Exception> T time(String title, ThrowingSupplier<T, E> supplier) throws E {
+    begin(title);
+    try {
+      return supplier.get();
+    } finally {
+      end();
+    }
+  }
+
+  @Override
+  public final void close() {
+    end();
+  }
+
+  @Override
+  public Timing end() {
+    stack.peek().end(); // record time.
+    stack.pop();
+    return this;
+  }
+
+  @Override
+  public void report() {
+    assert stack.size() == 1 : "Unexpected non-singleton stack: " + stack;
+    Node top = stack.peek();
+    assert top == this.top;
+    top.end();
+    System.out.println("Recorded timings:");
+    top.report(0, top);
+  }
+
+  private static Map<String, MemInfo> computeMemoryInformation() {
+    System.gc();
+    Map<String, MemInfo> info = new LinkedHashMap<>();
+    info.put(
+        "Memory",
+        MemInfo.fromTotalAndFree(
+            Runtime.getRuntime().totalMemory(), Runtime.getRuntime().freeMemory()));
+    return info;
+  }
+}
diff --git a/src/main/java/com/android/tools/r8/utils/timing/TimingWithCancellation.java b/src/main/java/com/android/tools/r8/utils/timing/TimingWithCancellation.java
new file mode 100644
index 0000000..7c3591b
--- /dev/null
+++ b/src/main/java/com/android/tools/r8/utils/timing/TimingWithCancellation.java
@@ -0,0 +1,26 @@
+// Copyright (c) 2025, 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.utils.timing;
+
+import com.android.tools.r8.utils.CancelCompilationException;
+import com.android.tools.r8.utils.InternalOptions;
+import com.android.tools.r8.utils.Timing;
+
+public class TimingWithCancellation extends TimingDelegate {
+
+  private final InternalOptions options;
+
+  public TimingWithCancellation(InternalOptions options, Timing timing) {
+    super(timing);
+    this.options = options;
+  }
+
+  @Override
+  public Timing begin(String title) {
+    if (options.checkIfCancelled()) {
+      throw new CancelCompilationException();
+    }
+    return super.begin(title);
+  }
+}