Skip to content

Commit c49b77c

Browse files
runningcodeclaude
andcommitted
perf(core): Remove executor prewarm
SentryExecutorService is single-threaded, and prewarm() is submitted ahead of loadLazyFields() during init, so its 40-task schedule/cancel/purge loop cannot reduce first-task latency — it can only delay it. The thread creation and executor class loading it aimed to warm are paid identically by the first real task (loadLazyFields), which is submitted unconditionally right after, so prewarm warms nothing that would not already be warmed. On-device A/B benchmarks on a Galaxy A55 (Android 16) show no measurable first-useful-task speedup from prewarm and ~20us of extra background-thread work. Remove prewarm() from ISentryExecutorService and its implementations and from both init call sites, and add PrewarmBenchmarkTest documenting the measurement. Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
1 parent 7d8a394 commit c49b77c

13 files changed

Lines changed: 196 additions & 80 deletions

File tree

CHANGELOG.md

Lines changed: 7 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1,5 +1,12 @@
11
# Changelog
22

3+
## Unreleased
4+
5+
### Performance
6+
7+
- Remove executor prewarm during SDK init ([#5681](https://github.com/getsentry/sentry-java/pull/5681))
8+
- The single-threaded `SentryExecutorService` queued the prewarm work ahead of the first useful task, so it could only delay init work, never speed it up; the thread and class loading it warmed are paid identically by the first real task submitted right after.
9+
310
## 8.47.0
411

512
### Behavioral Changes

sentry-android-core/src/test/java/io/sentry/android/core/AndroidProfilerTest.kt

Lines changed: 0 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -81,8 +81,6 @@ class AndroidProfilerTest {
8181
override fun close(timeoutMillis: Long) {}
8282

8383
override fun isClosed() = false
84-
85-
override fun prewarm() = Unit
8684
}
8785

8886
val options =

sentry-android-core/src/test/java/io/sentry/android/core/AndroidTransactionProfilerTest.kt

Lines changed: 0 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -89,8 +89,6 @@ class AndroidTransactionProfilerTest {
8989
override fun close(timeoutMillis: Long) {}
9090

9191
override fun isClosed() = false
92-
93-
override fun prewarm() = Unit
9492
}
9593

9694
val options =
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,189 @@
1+
package io.sentry.uitest.android.benchmark
2+
3+
import android.util.Log
4+
import androidx.test.ext.junit.runners.AndroidJUnit4
5+
import java.util.concurrent.CountDownLatch
6+
import java.util.concurrent.ScheduledThreadPoolExecutor
7+
import java.util.concurrent.TimeUnit
8+
import kotlin.math.sqrt
9+
import kotlin.test.Test
10+
import org.junit.runner.RunWith
11+
12+
/**
13+
* On-device A/B benchmark for the executor prewarm removed in this change. Answers two questions:
14+
* 1. Current prewarm (schedule+cancel 40 dummy tasks, then purge) vs a single `submit(noop)`.
15+
* 2. Whether warming the executor thread / class loading during init helps the first useful task at
16+
* all — given the executor is single-threaded and prewarm was queued ahead of `loadLazyFields`.
17+
*
18+
* `SentryExecutorService` wraps a single-thread [ScheduledThreadPoolExecutor], and its removed
19+
* `prewarm()` submitted the 40-task loop onto that executor. This benchmark reproduces that on a
20+
* raw [ScheduledThreadPoolExecutor] so it stays valid after the API is removed (the wrapper only
21+
* adds a queue-size check around `submit`, identical across variants).
22+
*
23+
* Uses `System.nanoTime` with interleaved rounds (same methodology as [InitReflectionBenchmarkTest]
24+
* and [FindTargetBenchmarkTest]). Each variant is sampled hundreds of times and reported as a
25+
* distribution (median + quartiles + p10/p90 + min/max + mean + stddev) rather than a single mean,
26+
* so a sub-30µs prewarm effect can be told apart from thread-creation and scheduling jitter. Not
27+
* run in CI. Each test method runs in a fresh process under the orchestrator, which lets
28+
* [firstTaskClassLoadingCost] observe process-once class loading.
29+
*/
30+
@RunWith(AndroidJUnit4::class)
31+
class PrewarmBenchmarkTest {
32+
33+
private val tag = "PrewarmBench"
34+
35+
// Summarize a sample of nanosecond durations as a distribution, printed in microseconds. Median
36+
// and quartiles are robust to the occasional GC/scheduling outlier that a mean would hide.
37+
private fun stats(label: String, samplesNs: LongArray): String {
38+
val s = samplesNs.clone()
39+
s.sort()
40+
val n = s.size
41+
fun pct(p: Double): Long = s[minOf(n - 1, (p * n).toInt())]
42+
val mean = s.sum().toDouble() / n
43+
val sd = sqrt(s.fold(0.0) { acc, v -> acc + (v - mean) * (v - mean) } / n)
44+
return "$label n=$n min=${s[0] / 1000} p10=${pct(0.10) / 1000} p25=${pct(0.25) / 1000} " +
45+
"median=${pct(0.50) / 1000} p75=${pct(0.75) / 1000} p90=${pct(0.90) / 1000} " +
46+
"max=${s[n - 1] / 1000} mean=${(mean / 1000).toInt()} sd=${(sd / 1000).toInt()} (us)"
47+
}
48+
49+
// The prewarm work, reproduced from the removed SentryExecutorService.prewarm(): schedule n dummy
50+
// runnables far in the future, cancel each (they stay queued until purge, forcing the
51+
// DelayedWorkQueue backing array to grow), then purge.
52+
private fun prewarmBody(executor: ScheduledThreadPoolExecutor, n: Int) {
53+
val dummy = Runnable {}
54+
for (i in 0 until n) {
55+
val f = executor.schedule(dummy, 365L, TimeUnit.DAYS)
56+
f.cancel(true)
57+
}
58+
executor.purge()
59+
}
60+
61+
/**
62+
* M1 — raw cost of the prewarm work on the executor thread: the current 40-task loop vs a single
63+
* dummy schedule. Run on a warm, dedicated executor so thread-creation noise is excluded and only
64+
* the queue churn is measured, sampled per call so the per-call spread is visible.
65+
*/
66+
@Test
67+
fun prewarmBodyCost() {
68+
val executor = ScheduledThreadPoolExecutor(1)
69+
prewarmBody(executor, 40) // warm the thread + class loading before measuring
70+
71+
fun timeOne(n: Int): Long {
72+
val start = System.nanoTime()
73+
prewarmBody(executor, n)
74+
return System.nanoTime() - start
75+
}
76+
77+
repeat(50) {
78+
timeOne(40)
79+
timeOne(1)
80+
}
81+
val rounds = 500
82+
val loop40 = LongArray(rounds)
83+
val single = LongArray(rounds)
84+
for (round in 0 until rounds) {
85+
if (round % 2 == 0) {
86+
loop40[round] = timeOne(40)
87+
single[round] = timeOne(1)
88+
} else {
89+
single[round] = timeOne(1)
90+
loop40[round] = timeOne(40)
91+
}
92+
}
93+
Log.i(tag, stats("M1 loop40 ", loop40))
94+
Log.i(tag, stats("M1 single ", single))
95+
executor.shutdownNow()
96+
}
97+
98+
/**
99+
* M2 — the decision metric. From a fresh single-thread executor (as init creates), measure the
100+
* time until the first useful task (a stand-in for `loadLazyFields`) actually finishes running,
101+
* for three init strategies:
102+
* - A: prewarm (submit the 40-task loop) then submit the task
103+
* - B: `submit(noop)` then submit the task
104+
* - C: no warming, just submit the task
105+
*
106+
* On a single-threaded executor all warming is queued ahead of the task, so this shows whether
107+
* prewarming brings the first useful task sooner (helps) or later (pure overhead). Hundreds of
108+
* fresh executors are sampled, interleaving A/B/C every round so thermal drift and scheduling
109+
* jitter hit all three equally, and the distributions are compared.
110+
*/
111+
@Test
112+
fun firstUsefulTaskDelay() {
113+
fun measure(strategy: Int): Long {
114+
val executor = ScheduledThreadPoolExecutor(1)
115+
val done = CountDownLatch(1)
116+
val start = System.nanoTime()
117+
when (strategy) {
118+
0 -> executor.submit { prewarmBody(executor, 40) } // reproduces prewarm()
119+
1 -> executor.submit {}
120+
2 -> {}
121+
}
122+
executor.submit { done.countDown() }
123+
done.await(5, TimeUnit.SECONDS)
124+
val elapsed = System.nanoTime() - start
125+
executor.shutdownNow()
126+
return elapsed
127+
}
128+
129+
// discard warmup (the first executor in the process also pays class loading)
130+
repeat(30) {
131+
measure(0)
132+
measure(1)
133+
measure(2)
134+
}
135+
val rounds = 400
136+
val a = LongArray(rounds)
137+
val b = LongArray(rounds)
138+
val c = LongArray(rounds)
139+
for (round in 0 until rounds) {
140+
// rotate order each round so ordering/thermal bias does not favour any variant
141+
when (round % 3) {
142+
0 -> {
143+
a[round] = measure(0)
144+
b[round] = measure(1)
145+
c[round] = measure(2)
146+
}
147+
1 -> {
148+
b[round] = measure(1)
149+
c[round] = measure(2)
150+
a[round] = measure(0)
151+
}
152+
else -> {
153+
c[round] = measure(2)
154+
a[round] = measure(0)
155+
b[round] = measure(1)
156+
}
157+
}
158+
}
159+
Log.i(tag, stats("M2 A(prewarm) ", a))
160+
Log.i(tag, stats("M2 B(single-sub) ", b))
161+
Log.i(tag, stats("M2 C(none) ", c))
162+
}
163+
164+
/**
165+
* M3 — upper bound on what "warming class loading" could ever save: the extra latency the very
166+
* first [ScheduledThreadPoolExecutor] first-task pays (class loading + JIT of the executor
167+
* internals + first thread creation) versus subsequent, warm ones in the same process. In a real
168+
* app this cost is only paid by Sentry if the app has not already used an STPE.
169+
*/
170+
@Test
171+
fun firstTaskClassLoadingCost() {
172+
fun firstTaskLatency(): Long {
173+
val executor = ScheduledThreadPoolExecutor(1)
174+
val done = CountDownLatch(1)
175+
val start = System.nanoTime()
176+
executor.submit { done.countDown() }
177+
done.await(5, TimeUnit.SECONDS)
178+
val elapsed = System.nanoTime() - start
179+
executor.shutdownNow()
180+
return elapsed
181+
}
182+
183+
val cold = firstTaskLatency() // pays process-once class loading + JIT
184+
val warmRounds = 200
185+
val warm = LongArray(warmRounds) { firstTaskLatency() }
186+
Log.i(tag, "M3 cold(first-in-process)=${cold / 1000}us")
187+
Log.i(tag, stats("M3 warm ", warm))
188+
}
189+
}

sentry-test-support/src/main/kotlin/io/sentry/test/Mocks.kt

Lines changed: 0 additions & 6 deletions
Original file line numberDiff line numberDiff line change
@@ -35,8 +35,6 @@ class ImmediateExecutorService : ISentryExecutorService {
3535
override fun close(timeoutMillis: Long) {}
3636

3737
override fun isClosed(): Boolean = false
38-
39-
override fun prewarm() = Unit
4038
}
4139

4240
class DeferredExecutorService : ISentryExecutorService {
@@ -74,8 +72,6 @@ class DeferredExecutorService : ISentryExecutorService {
7472

7573
override fun isClosed(): Boolean = false
7674

77-
override fun prewarm() = Unit
78-
7975
fun hasScheduledRunnables(): Boolean = scheduledRunnables.isNotEmpty()
8076
}
8177

@@ -90,8 +86,6 @@ class NonOverridableNoOpSentryExecutorService : ISentryExecutorService {
9086
override fun close(timeoutMillis: Long) {}
9187

9288
override fun isClosed(): Boolean = false
93-
94-
override fun prewarm() = Unit
9589
}
9690

9791
fun createSentryClientMock(enabled: Boolean = true) =

sentry/api/sentry.api

Lines changed: 0 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -1133,7 +1133,6 @@ public abstract interface class io/sentry/ISentryClient {
11331133
public abstract interface class io/sentry/ISentryExecutorService {
11341134
public abstract fun close (J)V
11351135
public abstract fun isClosed ()Z
1136-
public abstract fun prewarm ()V
11371136
public abstract fun schedule (Ljava/lang/Runnable;J)Ljava/util/concurrent/Future;
11381137
public abstract fun submit (Ljava/lang/Runnable;)Ljava/util/concurrent/Future;
11391138
public abstract fun submit (Ljava/util/concurrent/Callable;)Ljava/util/concurrent/Future;
@@ -1879,7 +1878,6 @@ public final class io/sentry/NoOpSentryExecutorService : io/sentry/ISentryExecut
18791878
public fun close (J)V
18801879
public static fun getInstance ()Lio/sentry/ISentryExecutorService;
18811880
public fun isClosed ()Z
1882-
public fun prewarm ()V
18831881
public fun schedule (Ljava/lang/Runnable;J)Ljava/util/concurrent/Future;
18841882
public fun submit (Ljava/lang/Runnable;)Ljava/util/concurrent/Future;
18851883
public fun submit (Ljava/util/concurrent/Callable;)Ljava/util/concurrent/Future;
@@ -3181,7 +3179,6 @@ public final class io/sentry/SentryExecutorService : io/sentry/ISentryExecutorSe
31813179
public fun <init> (Lio/sentry/SentryOptions;)V
31823180
public fun close (J)V
31833181
public fun isClosed ()Z
3184-
public fun prewarm ()V
31853182
public fun schedule (Ljava/lang/Runnable;J)Ljava/util/concurrent/Future;
31863183
public fun submit (Ljava/lang/Runnable;)Ljava/util/concurrent/Future;
31873184
public fun submit (Ljava/util/concurrent/Callable;)Ljava/util/concurrent/Future;

sentry/src/main/java/io/sentry/ISentryExecutorService.java

Lines changed: 0 additions & 6 deletions
Original file line numberDiff line numberDiff line change
@@ -45,10 +45,4 @@ Future<?> schedule(final @NotNull Runnable runnable, final long delayMillis)
4545
* @return If the executorService was previously closed
4646
*/
4747
boolean isClosed();
48-
49-
/**
50-
* Pre-warms the executor service by increasing the initial queue capacity. SHOULD be called
51-
* directly after instantiating this executor service.
52-
*/
53-
void prewarm();
5448
}

sentry/src/main/java/io/sentry/NoOpSentryExecutorService.java

Lines changed: 0 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -38,7 +38,4 @@ public void close(long timeoutMillis) {}
3838
public boolean isClosed() {
3939
return false;
4040
}
41-
42-
@Override
43-
public void prewarm() {}
4441
}

sentry/src/main/java/io/sentry/Sentry.java

Lines changed: 0 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -351,7 +351,6 @@ private static void init(final @NotNull SentryOptions options, final boolean glo
351351
// to set a new one
352352
if (options.getExecutorService().isClosed()) {
353353
options.setExecutorService(new SentryExecutorService(options));
354-
options.getExecutorService().prewarm();
355354
}
356355

357356
// load lazy fields of the options in a separate thread

sentry/src/main/java/io/sentry/SentryExecutorService.java

Lines changed: 0 additions & 39 deletions
Original file line numberDiff line numberDiff line change
@@ -16,13 +16,6 @@
1616
@ApiStatus.Internal
1717
public final class SentryExecutorService implements ISentryExecutorService {
1818

19-
/**
20-
* ScheduledThreadPoolExecutor grows work queue by 50% each time. With the initial capacity of 16
21-
* it will have to resize 4 times to reach 40, which is a decent middle-ground for prewarming.
22-
* This will prevent from growing in unexpected areas of the SDK.
23-
*/
24-
private static final int INITIAL_QUEUE_SIZE = 40;
25-
2619
/**
2720
* By default, the work queue is unbounded so it can grow as much as the memory allows. We want to
2821
* limit it by 271 which would be x8 times growth from the default initial capacity.
@@ -32,9 +25,6 @@ public final class SentryExecutorService implements ISentryExecutorService {
3225
private final @NotNull ScheduledThreadPoolExecutor executorService;
3326
private final @NotNull AutoClosableReentrantLock lock = new AutoClosableReentrantLock();
3427

35-
@SuppressWarnings("UnnecessaryLambda")
36-
private final @NotNull Runnable dummyRunnable = () -> {};
37-
3828
private final @Nullable SentryOptions options;
3929

4030
@TestOnly
@@ -120,35 +110,6 @@ public boolean isClosed() {
120110
}
121111
}
122112

123-
@SuppressWarnings({"FutureReturnValueIgnored"})
124-
@Override
125-
public void prewarm() {
126-
try {
127-
executorService.submit(
128-
() -> {
129-
try {
130-
// schedule a bunch of dummy runnables in the future that will never execute to
131-
// trigger
132-
// queue growth and then purge the queue
133-
for (int i = 0; i < INITIAL_QUEUE_SIZE; i++) {
134-
final Future<?> future =
135-
executorService.schedule(dummyRunnable, 365L, TimeUnit.DAYS);
136-
future.cancel(true);
137-
}
138-
executorService.purge();
139-
} catch (RejectedExecutionException ignored) {
140-
// ignore
141-
}
142-
});
143-
} catch (RejectedExecutionException e) {
144-
if (options != null) {
145-
options
146-
.getLogger()
147-
.log(SentryLevel.WARNING, "Prewarm task rejected from " + executorService, e);
148-
}
149-
}
150-
}
151-
152113
private static final class SentryExecutorServiceThreadFactory implements ThreadFactory {
153114
private int cnt;
154115

0 commit comments

Comments
 (0)