diff --git a/check_api/src/main/java/com/google/errorprone/CheckTiming.java b/check_api/src/main/java/com/google/errorprone/CheckTiming.java new file mode 100644 index 00000000000..bfb90082998 --- /dev/null +++ b/check_api/src/main/java/com/google/errorprone/CheckTiming.java @@ -0,0 +1,172 @@ +/* + * Copyright 2026 The Error Prone Authors. + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + +package com.google.errorprone; + +import java.io.Serializable; +import java.time.Duration; + +/** + * How long one check ran during one compilation, and how many times. + * + *
A {@link com.google.errorprone.bugpatterns.BugChecker} owns its instance; every other {@link + * com.google.errorprone.matchers.Suppressible} shares one per canonical name with {@link + * ErrorProneTimings}. Either way {@link ErrorProneTimings} claims the instance for the compilation + * that is running. + * + *
The count is exact. The elapsed time is an estimate: a check whose invocations are shorter than + * a clock read is timed on a sample of them, and each sample counts for the invocations it stands in + * for. + * + *
An instance is confined to one thread, and {@link #begin} rejects a span opened inside another + * span on the same instance. javac is single-threaded and a check never runs inside itself, so + * nothing here is synchronized. + */ +public final class CheckTiming implements AutoCloseable, Serializable { + + /** + * Invocation count below which every invocation is timed. + * + *
Above it, one invocation in {@code count / SAMPLES_PER_STRIDE} is timed, so a check that runs + * a million times contributes a few thousand samples rather than a million clock reads. + */ + private static final long SAMPLES_PER_STRIDE = 256; + + /** + * Mean cost, in nanoseconds, above which a check is timed on every invocation. + * + *
A pair of {@link System#nanoTime} calls costs a few tens of nanoseconds, so timing an + * invocation that lasts a microsecond perturbs it by a few percent. Raising this bound would sample + * the checks a report is read for: a check that visits a hundred thousand nodes and takes a second + * averages about ten microseconds per invocation. + */ + private static final long TIME_EVERY_INVOCATION_ABOVE_NANOS = 1_000; + + private static final long serialVersionUID = 1L; + + /** + * The {@link ErrorProneTimings} this state belongs to. + * + *
A scanner can outlive the compilation that built it, so a check whose instance is reused + * starts from zero rather than accumulating across compilations. Two compilations that share one + * scanner and run at the same time would race here; nothing in Error Prone shares a scanner that + * way, and {@link com.google.errorprone.scanner.ScannerSupplier#fromScanner} is the one entry + * point that lets an embedder do it. + */ + private transient Object owner; + + private transient long count; + private transient long stride; + private transient long nextSample; + private transient long weightedNanos; + private transient long sampledNanos; + private transient long sampledCount; + private transient long maxNanos; + private transient long startNanos; + private transient boolean sampling; + private transient boolean open; + + boolean claim(Object newOwner) { + if (owner == newOwner) { + return false; + } + owner = newOwner; + count = 0; + stride = 1; + nextSample = 1; + weightedNanos = 0; + sampledNanos = 0; + sampledCount = 0; + maxNanos = 0; + sampling = false; + open = false; + return true; + } + + /** + * Records that an invocation has started, and reads the clock if this one is being timed. + * + * @throws IllegalStateException if a span on this check is already open + */ + void begin() { + if (open) { + throw new IllegalStateException("a timing span for this check is already open; close it before opening another"); + } + open = true; + long n = ++count; + if (n < nextSample) { + sampling = false; + return; + } + sampling = true; + startNanos = System.nanoTime(); + } + + /** Returns whether {@link #begin} chose to time the invocation that is open. */ + boolean sampled() { + return sampling; + } + + /** Records the end of the invocation {@link #begin} started, timing it if it was sampled. */ + @Override + public void close() { + // The clock is read only for an invocation begin() chose to time. + closeWith(sampling ? System.nanoTime() - startNanos : 0); + } + + /** + * Records the end of the open invocation, as {@link #close} does, with the elapsed time supplied + * rather than measured. + */ + void closeWith(long elapsedNanos) { + open = false; + if (!sampling) { + return; + } + // A span records once, so a second close takes the return above. + sampling = false; + weightedNanos += elapsedNanos * stride; + sampledNanos += elapsedNanos; + sampledCount++; + maxNanos = Math.max(maxNanos, elapsedNanos); + stride = + sampledNanos / sampledCount >= TIME_EVERY_INVOCATION_ABOVE_NANOS + ? 1 + : Math.max(1, count / SAMPLES_PER_STRIDE); + nextSample = count + stride; + } + + /** Returns how long this check ran, estimated from the invocations that were timed. */ + public Duration elapsed() { + return Duration.ofNanos(weightedNanos); + } + + /** Returns exactly how many times this check ran. */ + public long count() { + return count; + } + + /** + * Returns the longest timed invocation, in nanoseconds. + * + *
A check whose total is large while this value is small is expensive on every invocation. One
+ * whose total is close to this value paid a one-off cost, such as the first lookup of a type that
+ * the compilation classpath does not have, and is cheap the rest of the time.
+ */
+ public long maxNanos() {
+ return maxNanos;
+ }
+}
diff --git a/check_api/src/main/java/com/google/errorprone/ErrorProneAnalyzer.java b/check_api/src/main/java/com/google/errorprone/ErrorProneAnalyzer.java
index 45d073e88dc..909b32c1088 100644
--- a/check_api/src/main/java/com/google/errorprone/ErrorProneAnalyzer.java
+++ b/check_api/src/main/java/com/google/errorprone/ErrorProneAnalyzer.java
@@ -19,9 +19,11 @@
import static com.google.common.base.Preconditions.checkNotNull;
import static com.google.common.base.Throwables.getStackTraceAsString;
import static com.google.common.base.Verify.verify;
+import static java.util.Comparator.comparing;
import com.google.common.base.Supplier;
import com.google.common.base.Suppliers;
+import com.google.common.collect.ImmutableMap;
import com.google.common.collect.ImmutableSet;
import com.google.errorprone.BugPattern.SeverityLevel;
import com.google.errorprone.ErrorProneOptions.Severity;
@@ -45,7 +47,10 @@
import com.sun.tools.javac.util.Log.WriterKind;
import com.sun.tools.javac.util.PropagatedException;
import java.io.PrintWriter;
+import java.time.Duration;
import java.util.HashSet;
+import java.util.Locale;
+import java.util.Map;
import java.util.Set;
import javax.tools.JavaFileObject;
import org.safere.Pattern;
@@ -190,8 +195,43 @@ private ErrorProneAnalyzer(
private int errorProneErrors = 0;
+ /** Prints how long each check ran and how often, slowest first. */
+ private void printTimings() {
+ ErrorProneTimings timings = ErrorProneTimings.instance(context);
+ ImmutableMap A canonical name maps to every {@link CheckTiming} reported under it, so two checks sharing
+ * one name are reported as their sum rather than as whichever ran last.
+ */
+ private final Map Keyed by canonical name, so the map holds one entry per name a compilation reports and needs
+ * no equality contract from {@link Suppressible}, which declares none.
+ */
+ private final Map The returned value is state this collection keeps rather than a fresh object, so the caller
+ * must close it before another invocation reporting the same canonical name begins, and must not
+ * retain it.
+ */
public AutoCloseable span(Suppressible suppressible) {
- String key = suppressible.canonicalName();
- Stopwatch sw = timers.computeIfAbsent(key, k -> Stopwatch.createUnstarted()).start();
- return () -> sw.stop();
+ CheckTiming timing =
+ suppressible instanceof BugChecker bugChecker
+ ? bugChecker.checkTiming()
+ : unownedTimings.computeIfAbsent(
+ suppressible.canonicalName(), unused -> new CheckTiming());
+ if (timing.claim(this)) {
+ timings.computeIfAbsent(suppressible.canonicalName(), unused -> new ArrayList<>()).add(timing);
+ }
+ timing.begin();
+ return timing;
}
/** Creates a timing span for initialization. */
@@ -60,10 +93,41 @@ public AutoCloseable initializationTimeSpan() {
return () -> initializationTime.stop();
}
- /** Returns the elapsed durations of each timer. */
+ /** Returns how long each check ran, estimated as {@link CheckTiming#elapsed} describes. */
public ImmutableMap {@link com.google.errorprone.ErrorProneTimings} reads this once per invocation, so the state
+ * is a field rather than a lookup by name.
+ */
+ public CheckTiming checkTiming() {
+ return checkTiming;
+ }
+
public BugChecker() {
info = BugCheckerInfo.create(getClass());
checkSuppression = suppressionPredicate(info.customSuppressionAnnotations());
diff --git a/check_api/src/test/java/com/google/errorprone/CheckTimingTest.java b/check_api/src/test/java/com/google/errorprone/CheckTimingTest.java
new file mode 100644
index 00000000000..79d0d948e19
--- /dev/null
+++ b/check_api/src/test/java/com/google/errorprone/CheckTimingTest.java
@@ -0,0 +1,186 @@
+/*
+ * Copyright 2026 The Error Prone Authors.
+ *
+ * Licensed under the Apache License, Version 2.0 (the "License");
+ * you may not use this file except in compliance with the License.
+ * You may obtain a copy of the License at
+ *
+ * http://www.apache.org/licenses/LICENSE-2.0
+ *
+ * Unless required by applicable law or agreed to in writing, software
+ * distributed under the License is distributed on an "AS IS" BASIS,
+ * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+ * See the License for the specific language governing permissions and
+ * limitations under the License.
+ */
+
+package com.google.errorprone;
+
+import static com.google.common.truth.Truth.assertThat;
+import static org.junit.Assert.assertThrows;
+
+import java.time.Duration;
+import org.junit.Test;
+import org.junit.runner.RunWith;
+import org.junit.runners.JUnit4;
+
+/**
+ * Fails when the sampled estimator stops producing the numbers a timing report prints.
+ *
+ * Every case supplies the elapsed time through {@link CheckTiming#closeWith} rather than letting
+ * the clock decide it, so the sampling schedule and the weighted total are determined. The expected
+ * values are worked out from the rule the class documents: invocations are timed one at a time until
+ * the count reaches 256, and each timed invocation afterwards counts for the {@code count / 256}
+ * invocations it stands in for.
+ */
+@RunWith(JUnit4.class)
+public final class CheckTimingTest {
+
+ /** The invocation count at which the stride first moves off one. */
+ private static final int FIRST_SKIP = 512;
+
+ private final CheckTiming timing = new CheckTiming();
+
+ @Test
+ public void aTimingThatNeverRanIsEmpty() {
+ assertThat(timing.count()).isEqualTo(0);
+ assertThat(timing.elapsed()).isEqualTo(Duration.ZERO);
+ assertThat(timing.maxNanos()).isEqualTo(0);
+ }
+
+ @Test
+ public void everyInvocationIsTimedBelowTheStrideThreshold() {
+ timing.claim(this);
+ for (int i = 0; i < FIRST_SKIP; i++) {
+ timing.begin();
+ assertThat(timing.sampled()).isTrue();
+ timing.closeWith(100);
+ }
+ assertThat(timing.count()).isEqualTo(FIRST_SKIP);
+ assertThat(timing.elapsed()).isEqualTo(Duration.ofNanos(FIRST_SKIP * 100L));
+ }
+
+ @Test
+ public void aSampleCountsForTheInvocationsItStandsIn() {
+ timing.claim(this);
+ for (int i = 0; i < FIRST_SKIP; i++) {
+ timing.begin();
+ timing.closeWith(100);
+ }
+ long timedOneAtATime = timing.elapsed().toNanos();
+
+ // The 512th invocation moved the stride to two, so the 513th is skipped.
+ timing.begin();
+ assertThat(timing.sampled()).isFalse();
+ timing.closeWith(999);
+ assertThat(timing.elapsed().toNanos()).isEqualTo(timedOneAtATime);
+
+ // The 514th is timed, and stands in for itself and the one that was skipped.
+ timing.begin();
+ assertThat(timing.sampled()).isTrue();
+ timing.closeWith(1000);
+ assertThat(timing.elapsed().toNanos()).isEqualTo(timedOneAtATime + 2 * 1000);
+ assertThat(timing.count()).isEqualTo(FIRST_SKIP + 2);
+ }
+
+ @Test
+ public void anExpensiveCheckIsTimedOnEveryInvocation() {
+ timing.claim(this);
+ int invocations = 600;
+ for (int i = 0; i < invocations; i++) {
+ timing.begin();
+ assertThat(timing.sampled()).isTrue();
+ timing.closeWith(2000);
+ }
+ assertThat(timing.count()).isEqualTo(invocations);
+ assertThat(timing.elapsed()).isEqualTo(Duration.ofNanos(invocations * 2000L));
+ }
+
+ @Test
+ public void spansKeepWorkingOnceSamplingStops() {
+ timing.claim(this);
+ int invocations = 600;
+ int skipped = 0;
+ for (int i = 0; i < invocations; i++) {
+ timing.begin();
+ if (!timing.sampled()) {
+ skipped++;
+ }
+ timing.closeWith(100);
+ }
+ assertThat(skipped).isGreaterThan(0);
+ assertThat(timing.count()).isEqualTo(invocations);
+ }
+
+ @Test
+ public void theLongestInvocationIsKept() {
+ timing.claim(this);
+ for (long elapsedNanos : new long[] {100, 5000, 200}) {
+ timing.begin();
+ timing.closeWith(elapsedNanos);
+ }
+ assertThat(timing.maxNanos()).isEqualTo(5000);
+ assertThat(timing.elapsed()).isEqualTo(Duration.ofNanos(5300));
+ }
+
+ @Test
+ public void aSecondOwnerStartsFromZero() {
+ timing.claim(this);
+ assertThat(timing.claim(this)).isFalse();
+ timing.begin();
+ timing.closeWith(4000);
+ assertThat(timing.count()).isEqualTo(1);
+
+ assertThat(timing.claim(new Object())).isTrue();
+ assertThat(timing.count()).isEqualTo(0);
+ assertThat(timing.elapsed()).isEqualTo(Duration.ZERO);
+ assertThat(timing.maxNanos()).isEqualTo(0);
+ }
+
+ @Test
+ public void aSecondCloseRecordsNothing() {
+ timing.claim(this);
+ timing.begin();
+ timing.closeWith(4000);
+ long recorded = timing.elapsed().toNanos();
+
+ timing.closeWith(9_000_000);
+
+ assertThat(timing.elapsed().toNanos()).isEqualTo(recorded);
+ assertThat(timing.maxNanos()).isEqualTo(4000);
+ }
+
+ @Test
+ public void aNestedSpanIsRejected() {
+ timing.claim(this);
+ timing.begin();
+ assertThat(timing.sampled()).isTrue();
+
+ assertThrows(IllegalStateException.class, timing::begin);
+ }
+
+ @Test
+ public void aNestedSpanIsRejectedOnceSamplingHasStopped() {
+ timing.claim(this);
+ for (int i = 0; i < FIRST_SKIP; i++) {
+ timing.begin();
+ timing.closeWith(100);
+ }
+ timing.begin();
+ assertThat(timing.sampled()).isFalse();
+
+ assertThrows(IllegalStateException.class, timing::begin);
+ }
+
+ @Test
+ public void aNewOwnerReleasesAnOpenSpan() {
+ timing.claim(this);
+ timing.begin();
+
+ assertThat(timing.claim(new Object())).isTrue();
+
+ timing.begin();
+ timing.closeWith(100);
+ assertThat(timing.count()).isEqualTo(1);
+ }
+}
diff --git a/check_api/src/test/java/com/google/errorprone/ErrorProneOptionsTest.java b/check_api/src/test/java/com/google/errorprone/ErrorProneOptionsTest.java
index bc0e917b2b6..2b562b640de 100644
--- a/check_api/src/test/java/com/google/errorprone/ErrorProneOptionsTest.java
+++ b/check_api/src/test/java/com/google/errorprone/ErrorProneOptionsTest.java
@@ -170,6 +170,18 @@ public void recognizesDisableAllChecks() {
assertThat(options.isDisableAllChecks()).isTrue();
}
+ @Test
+ public void recognizesPrintTimings() {
+ ErrorProneOptions options = ErrorProneOptions.processArgs(new String[] {"-XepPrintTimings"});
+ assertThat(options.printTimings()).isTrue();
+ assertThat(ErrorProneOptions.isSupportedOption("-XepPrintTimings")).isEqualTo(0);
+ }
+
+ @Test
+ public void printTimingsIsOffByDefault() {
+ assertThat(ErrorProneOptions.processArgs(new String[] {}).printTimings()).isFalse();
+ }
+
@Test
public void recognizesCompilingTestOnlyCode() {
ErrorProneOptions options =
diff --git a/check_api/src/test/java/com/google/errorprone/ErrorProneTimingsTest.java b/check_api/src/test/java/com/google/errorprone/ErrorProneTimingsTest.java
new file mode 100644
index 00000000000..ecd41d6030c
--- /dev/null
+++ b/check_api/src/test/java/com/google/errorprone/ErrorProneTimingsTest.java
@@ -0,0 +1,161 @@
+/*
+ * Copyright 2026 The Error Prone Authors.
+ *
+ * Licensed under the Apache License, Version 2.0 (the "License");
+ * you may not use this file except in compliance with the License.
+ * You may obtain a copy of the License at
+ *
+ * http://www.apache.org/licenses/LICENSE-2.0
+ *
+ * Unless required by applicable law or agreed to in writing, software
+ * distributed under the License is distributed on an "AS IS" BASIS,
+ * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+ * See the License for the specific language governing permissions and
+ * limitations under the License.
+ */
+
+package com.google.errorprone;
+
+import static com.google.common.truth.Truth.assertThat;
+import static com.google.errorprone.BugPattern.SeverityLevel.WARNING;
+
+import com.google.errorprone.bugpatterns.BugChecker;
+import com.google.errorprone.matchers.Suppressible;
+import com.sun.tools.javac.util.Context;
+import com.sun.tools.javac.util.Name;
+import java.lang.annotation.Annotation;
+import java.time.Duration;
+import java.util.Set;
+import org.junit.Test;
+import org.junit.runner.RunWith;
+import org.junit.runners.JUnit4;
+
+/**
+ * Fails when a check's run time stops reaching the report it belongs in.
+ *
+ * {@link ErrorProneTimings#span} reaches a check's state one way for a {@link BugChecker}, which
+ * keeps the state in a field, and another for a {@link Suppressible} that does not. {@link
+ * TimedCheck} covers the first and {@link NamedCheck} the second.
+ */
+@RunWith(JUnit4.class)
+public final class ErrorProneTimingsTest {
+
+ /** A {@link Suppressible} that answers only what the timings read. */
+ private static final class NamedCheck implements Suppressible {
+
+ private final String canonicalName;
+
+ NamedCheck(String canonicalName) {
+ this.canonicalName = canonicalName;
+ }
+
+ @Override
+ public String canonicalName() {
+ return canonicalName;
+ }
+
+ @Override
+ public Set